1
0
Fork 0
mirror of https://github.com/Eggbertx/gochan.git synced 2025-08-17 10:56:24 -07:00

refactor/reorganize gochan's source code into subpackages

Also use Go version 1.11 in vagrant for module support
This commit is contained in:
Eggbertx 2020-04-29 17:44:29 -07:00
parent 76f2934f14
commit d1292bd9fe
51 changed files with 4412 additions and 4085 deletions

22
.gitignore vendored
View file

@ -1,14 +1,14 @@
gochan*
lib/
log/
releases/
vagrant/.vagrant/
vagrant/*.log
html/boards.json
html/index.html
html/test*
html/javascript/consts.js
templates/override
/gochan*
/lib/
/log/
/releases/
/vagrant/.vagrant/
/vagrant/*.log
/html/boards.json
/html/index.html
/html/test*
/html/javascript/consts.js
/templates/override
**/*.bak
*.log
__debug_bin

View file

@ -6,6 +6,7 @@ ifeq (${GCOS_NAME},darwin)
GCOS_NAME=macos
endif
GOCHAN_PKG=github.com/gochan-org/gochan
DOCUMENT_ROOT=/srv/gochan
RELEASE_NAME=${BIN}-v${VERSION}_${GCOS_NAME}
RELEASE_DIR=releases/${RELEASE_NAME}
@ -30,15 +31,16 @@ DOCUMENT_ROOT_FILES= \
hittheroad*
build:
GOOS=${GCOS} ${GO_CMD} -gcflags=${GCFLAGS} -asmflags=${ASMFLAGS} -ldflags="${LDFLAGS} -w -s" ./src
GOOS=${GCOS} ${GO_CMD} -gcflags=${GCFLAGS} -asmflags=${ASMFLAGS} -ldflags="${LDFLAGS} -w -s" ./cmd/gochan
build-debug:
GOOS=${GCOS} ${GO_CMD} -gcflags="${GCFLAGS} -l -N" -asmflags=${ASMFLAGS} -ldflags="${LDFLAGS}" ./src
GOOS=${GCOS} ${GO_CMD} -gcflags="${GCFLAGS} -l -N" -asmflags=${ASMFLAGS} -ldflags="${LDFLAGS}" ./cmd/gochan
clean:
rm -f ${BIN}
rm -f ${BIN}.exe
rm -rf releases/
rm -rf ~/go/src/${GOCHAN_PKG}
dependencies:
go get -v \
@ -52,7 +54,7 @@ dependencies:
github.com/frustra/bbcode \
github.com/mattn/go-sqlite3 \
github.com/tdewolff/minify \
gopkg.in/mojocn/base64Captcha.v1
github.com/mojocn/base64Captcha
install:
mkdir -p \
@ -124,7 +126,6 @@ else
tar -C releases -zcvf ${RELEASE_DIR}.tar.gz ${RELEASE_NAME}
endif
.PHONY: sass
sass:
sass --no-source-map sass:html/css
@ -132,4 +133,6 @@ sass-minified:
sass --style compressed --no-source-map sass:html/css
test:
go test -v ./src
go test -v ./src
.PHONY: subpackages ${INTERNALS} sass

View file

@ -46,7 +46,7 @@ html/javascript
"@
function build {
$cmd = "& go build -v -gcflags=-trimpath=$PWD -asmflags=-trimpath=$PWD -ldflags=`"$LDFLAGS`" -o $BINEXE ./src "
$cmd = "& go build -v -gcflags=-trimpath=$PWD -asmflags=-trimpath=$PWD -ldflags=`"$LDFLAGS`" -o $BINEXE ./cmd/gochan "
$env:GOOS=$platform; Invoke-Expression $cmd
}
@ -67,7 +67,7 @@ function dependencies {
github.com/frustra/bbcode `
github.com/mattn/go-sqlite3 `
github.com/tdewolff/minify `
gopkg.in/mojocn/base64Captcha.v1
github.com/mojocn/base64Captcha
}
function dockerImage {

91
cmd/gochan/main.go Normal file
View file

@ -0,0 +1,91 @@
package main
import (
"flag"
"fmt"
"os"
"os/signal"
"strings"
"syscall"
"github.com/gochan-org/gochan/pkg/config"
"github.com/gochan-org/gochan/pkg/gclog"
"github.com/gochan-org/gochan/pkg/gcsql"
"github.com/gochan-org/gochan/pkg/gcutil"
"github.com/gochan-org/gochan/pkg/posting"
_ "github.com/go-sql-driver/mysql"
_ "github.com/lib/pq"
_ "github.com/mattn/go-sqlite3"
)
var (
versionStr string
stdFatal = gclog.LStdLog | gclog.LFatal
)
func main() {
defer func() {
gclog.Print(gclog.LStdLog, "Cleaning up")
gcsql.ExecSQL("DROP TABLE DBPREFIXsessions")
gcsql.Close()
}()
gclog.Printf(gclog.LStdLog, "Starting gochan v%s", versionStr)
config.InitConfig(versionStr)
gcsql.ConnectToDB(
config.Config.DBhost, config.Config.DBtype, config.Config.DBname,
config.Config.DBusername, config.Config.DBpassword, config.Config.DBprefix)
parseCommandLine()
gcutil.InitMinifier()
posting.InitCaptcha()
sc := make(chan os.Signal, 1)
signal.Notify(sc, syscall.SIGINT, syscall.SIGTERM, os.Interrupt)
posting.InitPosting()
go initServer()
<-sc
}
func parseCommandLine() {
var newstaff string
var delstaff string
var rank int
var err error
flag.StringVar(&newstaff, "newstaff", "", "<newusername>:<newpassword>")
flag.StringVar(&delstaff, "delstaff", "", "<username>")
flag.IntVar(&rank, "rank", 0, "New staff member rank, to be used with -newstaff or -delstaff")
flag.Parse()
if newstaff != "" {
arr := strings.Split(newstaff, ":")
if len(arr) < 2 || delstaff != "" {
flag.Usage()
os.Exit(1)
}
gclog.Printf(gclog.LStdLog|gclog.LStaffLog, "Creating new staff: %q, with password: %q and rank: %d from command line", arr[0], arr[1], rank)
if err = gcsql.NewStaff(arr[0], arr[1], rank); err != nil {
gclog.Print(stdFatal, err.Error())
}
os.Exit(0)
}
if delstaff != "" {
if newstaff != "" {
flag.Usage()
os.Exit(1)
}
gclog.Printf(gclog.LStdLog, "Are you sure you want to delete the staff account %q? [y/N]: ", delstaff)
var answer string
fmt.Scanln(&answer)
answer = strings.ToLower(answer)
if answer == "y" || answer == "yes" {
if err = gcsql.DeleteStaff(delstaff); err != nil {
gclog.Printf(stdFatal, "Error deleting %q: %s", delstaff, err.Error())
}
} else {
gclog.Print(stdFatal, "Not deleting.")
}
}
}

View file

@ -9,31 +9,35 @@ import (
"net/http/fcgi"
"os"
"path"
"regexp"
"strconv"
"strings"
"github.com/gochan-org/gochan/pkg/building"
"github.com/gochan-org/gochan/pkg/config"
"github.com/gochan-org/gochan/pkg/gclog"
"github.com/gochan-org/gochan/pkg/gcsql"
"github.com/gochan-org/gochan/pkg/gctemplates"
"github.com/gochan-org/gochan/pkg/gcutil"
"github.com/gochan-org/gochan/pkg/manage"
"github.com/gochan-org/gochan/pkg/posting"
"github.com/gochan-org/gochan/pkg/serverutil"
)
var (
server *GochanServer
referrerRegex *regexp.Regexp
server *gochanServer
)
type GochanServer struct {
type gochanServer struct {
namespaces map[string]func(http.ResponseWriter, *http.Request)
}
func (s GochanServer) AddNamespace(basePath string, namespaceFunction func(http.ResponseWriter, *http.Request)) {
s.namespaces[basePath] = namespaceFunction
}
func (s GochanServer) serveFile(writer http.ResponseWriter, request *http.Request) {
filePath := path.Join(config.DocumentRoot, request.URL.Path)
func (s gochanServer) serveFile(writer http.ResponseWriter, request *http.Request) {
filePath := path.Join(config.Config.DocumentRoot, request.URL.Path)
var fileBytes []byte
results, err := os.Stat(filePath)
if err != nil {
// the requested path isn't a file or directory, 404
serveNotFound(writer, request)
serverutil.ServeNotFound(writer, request)
return
}
@ -42,7 +46,7 @@ func (s GochanServer) serveFile(writer http.ResponseWriter, request *http.Reques
if results.IsDir() {
//check to see if one of the specified index pages exists
var found bool
for _, value := range config.FirstPage {
for _, value := range config.Config.FirstPage {
newPath := path.Join(filePath, value)
_, err := os.Stat(newPath)
if err == nil {
@ -52,12 +56,12 @@ func (s GochanServer) serveFile(writer http.ResponseWriter, request *http.Reques
}
}
if !found {
serveNotFound(writer, request)
serverutil.ServeNotFound(writer, request)
return
}
} else {
//the file exists, and is not a folder
extension = strings.ToLower(getFileExtension(request.URL.Path))
extension = strings.ToLower(gcutil.GetFileExtension(request.URL.Path))
switch extension {
case "png":
writer.Header().Add("Content-Type", "image/png")
@ -88,7 +92,7 @@ func (s GochanServer) serveFile(writer http.ResponseWriter, request *http.Reques
writer.Header().Add("Content-Type", "text/html")
writer.Header().Add("Cache-Control", "max-age=5, must-revalidate")
}
gclog.Printf(lAccessLog, "Success: 200 from %s @ %s", getRealIP(request), request.URL.Path)
gclog.Printf(gclog.LAccessLog, "Success: 200 from %s @ %s", gcutil.GetRealIP(request), request.URL.Path)
}
// serve the index page
@ -98,31 +102,9 @@ func (s GochanServer) serveFile(writer http.ResponseWriter, request *http.Reques
writer.Write(fileBytes)
}
func serveNotFound(writer http.ResponseWriter, request *http.Request) {
writer.Header().Add("Content-Type", "text/html; charset=utf-8")
writer.WriteHeader(404)
errorPage, err := ioutil.ReadFile(config.DocumentRoot + "/error/404.html")
if err != nil {
writer.Write([]byte("Requested page not found, and /error/404.html not found"))
} else {
minifyWriter(writer, errorPage, "text/html")
}
gclog.Printf(lAccessLog, "Error: 404 Not Found from %s @ %s", getRealIP(request), request.URL.Path)
}
func serveErrorPage(writer http.ResponseWriter, err string) {
minifyTemplate(errorpageTmpl, map[string]interface{}{
"config": config,
"ErrorTitle": "Error :c",
// "ErrorImage": "/error/lol 404.gif",
"ErrorHeader": "Error",
"ErrorText": err,
}, writer, "text/html")
}
func (s GochanServer) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
func (s gochanServer) ServeHTTP(writer http.ResponseWriter, request *http.Request) {
for name, namespaceFunction := range s.namespaces {
if request.URL.Path == config.SiteWebfolder+name {
if request.URL.Path == config.Config.SiteWebfolder+name {
// writer.WriteHeader(200)
namespaceFunction(writer, request)
return
@ -132,69 +114,45 @@ func (s GochanServer) ServeHTTP(writer http.ResponseWriter, request *http.Reques
}
func initServer() {
listener, err := net.Listen("tcp", config.ListenIP+":"+strconv.Itoa(config.Port))
listener, err := net.Listen("tcp", config.Config.ListenIP+":"+strconv.Itoa(config.Config.Port))
if err != nil {
gclog.Printf(lErrorLog|lStdLog|lFatal,
"Failed listening on %s:%d: %s", config.ListenIP, config.Port, err.Error())
gclog.Printf(gclog.LErrorLog|gclog.LStdLog|gclog.LFatal,
"Failed listening on %s:%d: %s", config.Config.ListenIP, config.Config.Port, err.Error())
}
server = new(GochanServer)
server = new(gochanServer)
server.namespaces = make(map[string]func(http.ResponseWriter, *http.Request))
// Check if Akismet API key is usable at startup.
if err = checkAkismetAPIKey(config.AkismetAPIKey); err != nil {
config.AkismetAPIKey = ""
if err = serverutil.CheckAkismetAPIKey(config.Config.AkismetAPIKey); err != nil {
config.Config.AkismetAPIKey = ""
}
// Compile regex for checking referrers.
referrerRegex = regexp.MustCompile(config.DomainRegex)
server.AddNamespace("banned", banHandler)
server.AddNamespace("captcha", serveCaptcha)
server.AddNamespace("manage", callManageFunction)
server.AddNamespace("post", makePost)
server.AddNamespace("util", utilHandler)
server.AddNamespace("example", func(writer http.ResponseWriter, request *http.Request) {
server.namespaces["banned"] = posting.BanHandler
server.namespaces["captcha"] = posting.ServeCaptcha
server.namespaces["manage"] = manage.CallManageFunction
server.namespaces["post"] = posting.MakePost
server.namespaces["util"] = utilHandler
server.namespaces["example"] = func(writer http.ResponseWriter, request *http.Request) {
if writer != nil {
http.Redirect(writer, request, "https://www.youtube.com/watch?v=dQw4w9WgXcQ", http.StatusFound)
}
})
// eventually plugins will be able to register new namespaces. Or they will be restricted to something like /plugin
}
// Eventually plugins will be able to register new namespaces (assuming they ever get it working on Windows or macOS)
// or they will be restricted to something like /plugin
if config.UseFastCGI {
if config.Config.UseFastCGI {
err = fcgi.Serve(listener, server)
} else {
err = http.Serve(listener, server)
}
if err != nil {
gclog.Print(lErrorLog|lStdLog|lFatal,
gclog.Print(gclog.LErrorLog|gclog.LStdLog|gclog.LFatal,
"Error initializing server: ", err.Error())
}
}
func getRealIP(request *http.Request) string {
// HTTP_CF_CONNECTING_IP > X-Forwarded-For > RemoteAddr
if request.Header.Get("HTTP_CF_CONNECTING_IP") != "" {
return request.Header.Get("HTTP_CF_CONNECTING_IP")
}
if request.Header.Get("X-Forwarded-For") != "" {
return request.Header.Get("X-Forwarded-For")
}
remoteHost, _, err := net.SplitHostPort(request.RemoteAddr)
if err != nil {
return request.RemoteAddr
}
return remoteHost
}
func validReferrer(request *http.Request) bool {
if config.DebugMode {
return true
}
return referrerRegex.MatchString(request.Referer())
}
// register /util handler
// handles requests to /util
func utilHandler(writer http.ResponseWriter, request *http.Request) {
action := request.FormValue("action")
password := request.FormValue("password")
@ -207,7 +165,8 @@ func utilHandler(writer http.ResponseWriter, request *http.Request) {
doEdit := request.PostFormValue("doedit")
if action == "" && deleteBtn != "Delete" && reportBtn != "Report" && editBtn != "Edit" && doEdit != "1" {
http.Redirect(writer, request, path.Join(config.SiteWebfolder, "/"), http.StatusFound)
gclog.Printf(gclog.LAccessLog, "Received invalid /util request from %q", request.Host)
http.Redirect(writer, request, path.Join(config.Config.SiteWebfolder, "/"), http.StatusFound)
return
}
var postsArr []string
@ -220,39 +179,39 @@ func utilHandler(writer http.ResponseWriter, request *http.Request) {
if editBtn == "Edit" {
var err error
if len(postsArr) == 0 {
serveErrorPage(writer, "You need to select one post to edit.")
serverutil.ServeErrorPage(writer, "You need to select one post to edit.")
return
} else if len(postsArr) > 1 {
serveErrorPage(writer, "You can only edit one post at a time.")
serverutil.ServeErrorPage(writer, "You can only edit one post at a time.")
return
} else {
rank := getStaffRank(request)
rank := manage.GetStaffRank(request)
if password == "" && rank == 0 {
serveErrorPage(writer, "Password required for post editing")
serverutil.ServeErrorPage(writer, "Password required for post editing")
return
}
passwordMD5 := md5Sum(password)
passwordMD5 := gcutil.Md5Sum(password)
var post Post
var post gcsql.Post
postid, _ := strconv.Atoi(postsArr[0])
post, err = GetSpecificPost(postid, true)
post, err = gcsql.GetSpecificPost(postid, true)
if err != nil {
serveErrorPage(writer, gclog.Print(lErrorLog,
serverutil.ServeErrorPage(writer, gclog.Print(gclog.LErrorLog,
"Error getting post information: ", err.Error()))
return
}
if post.Password != passwordMD5 && rank == 0 {
serveErrorPage(writer, "Wrong password")
serverutil.ServeErrorPage(writer, "Wrong password")
return
}
if err = postEditTmpl.Execute(writer, map[string]interface{}{
"config": config,
if err = gctemplates.PostEdit.Execute(writer, map[string]interface{}{
"config": config.Config,
"post": post,
"referrer": request.Referer(),
}); err != nil {
serveErrorPage(writer, gclog.Print(lErrorLog,
serverutil.ServeErrorPage(writer, gclog.Print(gclog.LErrorLog,
"Error executing edit post template: ", err.Error()))
return
}
@ -262,43 +221,44 @@ func utilHandler(writer http.ResponseWriter, request *http.Request) {
var password string
postid, err := strconv.Atoi(request.FormValue("postid"))
if err != nil {
serveErrorPage(writer, gclog.Print(lErrorLog,
serverutil.ServeErrorPage(writer, gclog.Print(gclog.LErrorLog,
"Invalid form data: ", err.Error()))
return
}
boardid, err := strconv.Atoi(request.FormValue("boardid"))
if err != nil {
serveErrorPage(writer, gclog.Print(lErrorLog,
serverutil.ServeErrorPage(writer, gclog.Print(gclog.LErrorLog,
"Invalid form data: ", err.Error()))
return
}
password, err = GetPostPassword(postid)
password, err = gcsql.GetPostPassword(postid)
if err != nil {
serveErrorPage(writer, gclog.Print(lErrorLog,
serverutil.ServeErrorPage(writer, gclog.Print(gclog.LErrorLog,
"Invalid form data: ", err.Error()))
return
}
rank := getStaffRank(request)
rank := manage.GetStaffRank(request)
if request.FormValue("password") != password && rank == 0 {
serveErrorPage(writer, "Wrong password")
serverutil.ServeErrorPage(writer, "Wrong password")
return
}
var board Board
var board gcsql.Board
if err = board.PopulateData(boardid); err != nil {
serveErrorPage(writer, gclog.Print(lErrorLog,
serverutil.ServeErrorPage(writer, gclog.Print(gclog.LErrorLog,
"Invalid form data: ", err.Error()))
return
}
if err = UpdatePost(postid, request.FormValue("editemail"), request.FormValue("editsubject"),
formatMessage(request.FormValue("editmsg")), request.FormValue("editmsg")); err != nil {
serveErrorPage(writer, gclog.Print(lErrorLog, "Unable to edit post: ", err.Error()))
if err = gcsql.UpdatePost(postid, request.FormValue("editemail"), request.FormValue("editsubject"),
posting.FormatMessage(request.FormValue("editmsg")), request.FormValue("editmsg")); err != nil {
serverutil.ServeErrorPage(writer, gclog.Print(gclog.LErrorLog,
"Unable to edit post: ", err.Error()))
return
}
buildBoards(boardid)
building.BuildBoards(boardid)
if request.FormValue("parentid") == "0" {
http.Redirect(writer, request, "/"+board.Dir+"/res/"+strconv.Itoa(postid)+".html", http.StatusFound)
} else {
@ -311,27 +271,28 @@ func utilHandler(writer http.ResponseWriter, request *http.Request) {
if deleteBtn == "Delete" {
// Delete a post or thread
writer.Header().Add("Content-Type", "text/plain")
passwordMD5 := md5Sum(password)
rank := getStaffRank(request)
passwordMD5 := gcutil.Md5Sum(password)
rank := manage.GetStaffRank(request)
if passwordMD5 == "" && rank == 0 {
serveErrorPage(writer, "Password required for post deletion")
serverutil.ServeErrorPage(writer, "Password required for post deletion")
return
}
for _, checkedPostID := range postsArr {
var post Post
var post gcsql.Post
var err error
post.ID, _ = strconv.Atoi(checkedPostID)
post.BoardID, _ = strconv.Atoi(boardid)
if post, err = GetSpecificPost(post.ID, true); err == sql.ErrNoRows {
if post, err = gcsql.GetSpecificPost(post.ID, true); err == sql.ErrNoRows {
//the post has already been deleted
writer.Header().Add("refresh", "4;url="+request.Referer())
fmt.Fprintf(writer, "%d has already been deleted or is a post in a deleted thread.\n", post.ID)
continue
} else if err != nil {
serveErrorPage(writer, gclog.Print(lErrorLog, "Error deleting post: ", err.Error()))
serverutil.ServeErrorPage(writer, gclog.Print(gclog.LErrorLog,
"Error deleting post: ", err.Error()))
return
}
@ -343,30 +304,33 @@ func utilHandler(writer http.ResponseWriter, request *http.Request) {
if fileOnly {
fileName := post.Filename
if fileName != "" && fileName != "deleted" {
if err = DeleteFilesFromPost(post.ID); err != nil {
serveErrorPage(writer, err.Error())
if err = gcsql.DeleteFilesFromPost(post.ID); err != nil {
serverutil.ServeErrorPage(writer, gclog.Print(gclog.LErrorLog,
"Error deleting files from post: ", err.Error()))
return
}
}
_board, _ := GetBoardFromID(post.BoardID)
buildBoardPages(&_board)
postBoard, _ := GetSpecificPost(post.ID, true)
buildThreadPages(&postBoard)
_board, _ := gcsql.GetBoardFromID(post.BoardID)
building.BuildBoardPages(&_board)
postBoard, _ := gcsql.GetSpecificPost(post.ID, true)
building.BuildThreadPages(&postBoard)
writer.Header().Add("refresh", "4;url="+request.Referer())
fmt.Fprintf(writer, "Attached image from %d deleted successfully\n", post.ID)
} else {
// delete the post
if err = DeletePost(post.ID); err != nil {
serveErrorPage(writer, err.Error())
if err = gcsql.DeletePost(post.ID); err != nil {
serverutil.ServeErrorPage(writer, gclog.Print(gclog.LErrorLog,
"Error deleting post: ", err.Error()))
}
if post.ParentID == 0 {
os.Remove(path.Join(config.DocumentRoot, board, "/res/"+strconv.Itoa(post.ID)+".html"))
os.Remove(path.Join(
config.Config.DocumentRoot, board, "/res/"+strconv.Itoa(post.ID)+".html"))
} else {
_board, _ := GetBoardFromID(post.BoardID)
buildBoardPages(&_board)
_board, _ := gcsql.GetBoardFromID(post.BoardID)
building.BuildBoardPages(&_board)
}
buildBoards(post.BoardID)
building.BuildBoards(post.BoardID)
writer.Header().Add("refresh", "4;url="+request.Referer())
fmt.Fprintf(writer, "%d deleted successfully\n", post.ID)

18
go.mod Normal file
View file

@ -0,0 +1,18 @@
module github.com/gochan-org/gochan
go 1.11
require (
github.com/aquilax/tripcode v1.0.0
github.com/disintegration/imaging v1.6.2
github.com/frustra/bbcode v0.0.0-20180807171629-48be21ce690c
github.com/go-sql-driver/mysql v1.5.0
github.com/lib/pq v1.4.0
github.com/mattn/go-sqlite3 v2.0.3+incompatible
github.com/mojocn/base64Captcha v1.3.1
github.com/nranchev/go-libGeoIP v0.0.0-20170629073846-d6d4a9a4c7e8 // indirect
github.com/tdewolff/minify v2.3.6+incompatible
github.com/tdewolff/parse v2.3.4+incompatible // indirect
golang.org/x/crypto v0.0.0-20200429183012-4b2356b1ed79
golang.org/x/net v0.0.0-20200425230154-ff2c4b7c35a0
)

42
go.sum Normal file
View file

@ -0,0 +1,42 @@
github.com/aquilax/tripcode v1.0.0 h1:uPW1T2brVth0t6YiDPlouncHXFGneflsAvkh4zEBN58=
github.com/aquilax/tripcode v1.0.0/go.mod h1:Tucn/H6BM/DEmxzj/tnmR7Vs/NV/bgCKo8Wi0yXrtzQ=
github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c=
github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4=
github.com/frustra/bbcode v0.0.0-20180807171629-48be21ce690c h1:qo8GNweP9HFxZJ5S7GSsiqut9k4H0MM29Ne/Mnp2BHc=
github.com/frustra/bbcode v0.0.0-20180807171629-48be21ce690c/go.mod h1:0QBxkXxN+o4FyZgLI9FHY/oUizheze3+bNY/kgCKL+4=
github.com/go-sql-driver/mysql v1.5.0 h1:ozyZYNQW3x3HtqT1jira07DN2PArx2v7/mN66gGcHOs=
github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
github.com/lib/pq v1.4.0 h1:TmtCFbH+Aw0AixwyttznSMQDgbR5Yed/Gg6S8Funrhc=
github.com/lib/pq v1.4.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo=
github.com/mattn/go-sqlite3 v2.0.3+incompatible h1:gXHsfypPkaMZrKbD5209QV9jbUTJKjyR5WD3HYQSd+U=
github.com/mattn/go-sqlite3 v2.0.3+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc=
github.com/mojocn/base64Captcha v1.2.2 h1:NTFnThPVrb3tR66JO/N8/ZHsyFrNc7ho+xRpxBUEIlo=
github.com/mojocn/base64Captcha v1.2.2/go.mod h1:wAQCKEc5bDujxKRmbT6/vTnTt5CjStQ8bRfPWUuz/iY=
github.com/mojocn/base64Captcha v1.3.1 h1:2Wbkt8Oc8qjmNJ5GyOfSo4tgVQPsbKMftqASnq8GlT0=
github.com/mojocn/base64Captcha v1.3.1/go.mod h1:wAQCKEc5bDujxKRmbT6/vTnTt5CjStQ8bRfPWUuz/iY=
github.com/nranchev/go-libGeoIP v0.0.0-20170629073846-d6d4a9a4c7e8 h1:IeI4GVfCGrGx4tZROZ/ju+nO9rKpgKJ7o4XmQgAM/2g=
github.com/nranchev/go-libGeoIP v0.0.0-20170629073846-d6d4a9a4c7e8/go.mod h1:CSS25pAr1pT+qxFdpFZIJFHraF4zZfZYeFirlVvLXb4=
github.com/tdewolff/minify v2.3.6+incompatible h1:2hw5/9ZvxhWLvBUnHE06gElGYz+Jv9R4Eys0XUzItYo=
github.com/tdewolff/minify v2.3.6+incompatible/go.mod h1:9Ov578KJUmAWpS6NeZwRZyT56Uf6o3Mcz9CEsg8USYs=
github.com/tdewolff/parse v2.3.4+incompatible h1:x05/cnGwIMf4ceLuDMBOdQ1qGniMoxpP46ghf0Qzh38=
github.com/tdewolff/parse v2.3.4+incompatible/go.mod h1:8oBwCsVmUkgHO8M5iCzSIDtpzXOT0WXX9cWhz+bIzJQ=
gitlab.com/nyarla/go-crypt v0.0.0-20160106005555-d9a5dc2b789b h1:7gd+rd8P3bqcn/96gOZa3F5dpJr/vEiDQYlNb/y2uNs=
gitlab.com/nyarla/go-crypt v0.0.0-20160106005555-d9a5dc2b789b/go.mod h1:T3BPAOm2cqquPa0MKWeNkmOM5RQsRhkrwMWonFMN7fE=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200427165652-729f1e841bcc h1:ZGI/fILM2+ueot/UixBSoj9188jCAxVHEZEGhqq67I4=
golang.org/x/crypto v0.0.0-20200427165652-729f1e841bcc/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200429183012-4b2356b1ed79 h1:IaQbIIB2X/Mp/DKctl6ROxz1KyMlKp4uyvL6+kQ7C88=
golang.org/x/crypto v0.0.0-20200429183012-4b2356b1ed79/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/image v0.0.0-20190501045829-6d32002ffd75/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U=
golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20200425230154-ff2c4b7c35a0 h1:Jcxah/M+oLZ/R4/z5RzfPzGbPXnVDPkEDtf2JnuxN+U=
golang.org/x/net v0.0.0-20200425230154-ff2c4b7c35a0/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=

239
pkg/building/boards.go Normal file
View file

@ -0,0 +1,239 @@
package building
import (
"encoding/json"
"os"
"path"
"strconv"
"syscall"
"github.com/gochan-org/gochan/pkg/config"
"github.com/gochan-org/gochan/pkg/gclog"
"github.com/gochan-org/gochan/pkg/gcsql"
"github.com/gochan-org/gochan/pkg/gctemplates"
"github.com/gochan-org/gochan/pkg/gcutil"
)
// BuildBoardPages builds the pages for the board archive.
// `board` is a Board object representing the board to build archive pages for.
// The return value is a string of HTML with debug information from the build process.
func BuildBoardPages(board *gcsql.Board) (html string) {
err := gctemplates.InitTemplates("boardpage")
if err != nil {
return err.Error()
}
var currentPageFile *os.File
var threads []interface{}
var threadPages [][]interface{}
var stickiedThreads []interface{}
var nonStickiedThreads []interface{}
var opPosts []gcsql.Post
// Get all top level posts for the board.
if opPosts, err = gcsql.GetTopPosts(board.ID, true); err != nil {
return html + gclog.Printf(gclog.LErrorLog,
"Error getting OP posts for /%s/: %s", board.Dir, err.Error()) + "<br />"
}
// For each top level post, start building a Thread struct
for _, op := range opPosts {
var thread gcsql.Thread
var postsInThread []gcsql.Post
var replyCount, err = gcsql.GetReplyCount(op.ID)
if err == nil {
return html + gclog.Printf(gclog.LErrorLog,
"Error getting replies to /%s/%d: %s",
board.Dir, op.ID, err.Error()) + "<br />"
}
thread.NumReplies = replyCount
fileCount, err := gcsql.GetReplyFileCount(op.ID)
if err == nil {
return html + gclog.Printf(gclog.LErrorLog,
"Error getting file count to /%s/%d: %s",
board.Dir, op.ID, err.Error()) + "<br />"
}
thread.NumImages = fileCount
thread.OP = op
var numRepliesOnBoardPage int
if op.Stickied {
// If the thread is stickied, limit replies on the archive page to the
// configured value for stickied threads.
numRepliesOnBoardPage = config.Config.StickyRepliesOnBoardPage
} else {
// Otherwise, limit the replies to the configured value for normal threads.
numRepliesOnBoardPage = config.Config.RepliesOnBoardPage
}
postsInThread, err = gcsql.GetExistingRepliesLimitedRev(op.ID, numRepliesOnBoardPage)
if err != nil {
return html + gclog.Printf(gclog.LErrorLog,
"Error getting posts in /%s/%d: %s",
board.Dir, op.ID, err.Error()) + "<br />"
}
var reversedPosts []gcsql.Post
for i := len(postsInThread); i > 0; i-- {
reversedPosts = append(reversedPosts, postsInThread[i-1])
}
if len(postsInThread) > 0 {
// Store the posts to show on board page
//thread.BoardReplies = postsInThread
thread.BoardReplies = reversedPosts
// Count number of images on board page
imageCount := 0
for _, reply := range postsInThread {
if reply.Filesize != 0 {
imageCount++
}
}
// Then calculate number of omitted images.
thread.OmittedImages = thread.NumImages - imageCount
}
// Add thread struct to appropriate list
if op.Stickied {
stickiedThreads = append(stickiedThreads, thread)
} else {
nonStickiedThreads = append(nonStickiedThreads, thread)
}
}
gcutil.DeleteMatchingFiles(path.Join(config.Config.DocumentRoot, board.Dir), "\\d.html$")
// Order the threads, stickied threads first, then nonstickied threads.
threads = append(stickiedThreads, nonStickiedThreads...)
// If there are no posts on the board
if len(threads) == 0 {
board.CurrentPage = 1
// Open board.html for writing to the first page.
boardPageFile, err := os.OpenFile(path.Join(config.Config.DocumentRoot, board.Dir, "board.html"), os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0777)
if err != nil {
return html + gclog.Printf(gclog.LErrorLog,
"Failed opening /%s/board.html: %s",
board.Dir, err.Error()) + "<br />"
}
// Render board page template to the file,
// packaging the board/section list, threads, and board info
if err = gcutil.MinifyTemplate(gctemplates.BoardPage, map[string]interface{}{
"config": config.Config,
"boards": gcsql.AllBoards,
"sections": gcsql.AllSections,
"threads": threads,
"board": board,
}, boardPageFile, "text/html"); err != nil {
return html + gclog.Printf(gclog.LErrorLog,
"Failed building /%s/: %s",
board.Dir, err.Error()) + "<br />"
}
html += "/" + board.Dir + "/ built successfully.\n"
return
}
// Create the archive pages.
threadPages = paginate(config.Config.ThreadsPerPage, threads)
board.NumPages = len(threadPages)
// Create array of page wrapper objects, and open the file.
pagesArr := make([]map[string]interface{}, board.NumPages)
catalogJSONFile, err := os.OpenFile(path.Join(config.Config.DocumentRoot, board.Dir, "catalog.json"), os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0777)
if err != nil {
return gclog.Printf(gclog.LErrorLog,
"Failed opening /%s/catalog.json: %s", board.Dir, err.Error()) + "<br />"
}
defer catalogJSONFile.Close()
currentBoardPage := board.CurrentPage
for _, pageThreads := range threadPages {
board.CurrentPage++
var currentPageFilepath string
pageFilename := strconv.Itoa(board.CurrentPage) + ".html"
currentPageFilepath = path.Join(config.Config.DocumentRoot, board.Dir, pageFilename)
currentPageFile, err = os.OpenFile(currentPageFilepath, os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0777)
if err != nil {
html += gclog.Printf(gclog.LErrorLog, "Failed opening /%s/%s: %s",
board.Dir, pageFilename, err.Error()) + "<br />"
continue
}
defer currentPageFile.Close()
// Render the boardpage template
if err = gcutil.MinifyTemplate(gctemplates.BoardPage, map[string]interface{}{
"config": config.Config,
"boards": gcsql.AllBoards,
"sections": gcsql.AllSections,
"threads": pageThreads,
"board": board,
"posts": []interface{}{
gcsql.Post{BoardID: board.ID},
},
}, currentPageFile, "text/html"); err != nil {
return html + gclog.Printf(gclog.LErrorLog,
"Failed building /%s/ boardpage: %s", board.Dir, err.Error()) + "<br />"
}
if board.CurrentPage == 1 {
boardPage := path.Join(config.Config.DocumentRoot, board.Dir, "board.html")
os.Remove(boardPage)
if err = syscall.Symlink(currentPageFilepath, boardPage); !os.IsExist(err) && err != nil {
html += gclog.Printf(gclog.LErrorLog, "Failed building /%s/: %s",
board.Dir, err.Error())
}
}
// Collect up threads for this page.
pageMap := make(map[string]interface{})
pageMap["page"] = board.CurrentPage
pageMap["threads"] = pageThreads
pagesArr = append(pagesArr, pageMap)
}
board.CurrentPage = currentBoardPage
catalogJSON, err := json.Marshal(pagesArr)
if err != nil {
return html + gclog.Print(gclog.LErrorLog, "Failed to marshal to JSON: ", err.Error()) + "<br />"
}
if _, err = catalogJSONFile.Write(catalogJSON); err != nil {
return html + gclog.Printf(gclog.LErrorLog,
"Failed writing /%s/catalog.json: %s", board.Dir, err.Error()) + "<br />"
}
html += "/" + board.Dir + "/ built successfully."
return
}
// BuildBoards builds the specified board IDs, or all boards if no arguments are passed
// The return value is a string of HTML with debug information produced by the build process.
func BuildBoards(which ...int) (html string) {
var boards []gcsql.Board
var err error
if which == nil {
boards = gcsql.AllBoards
} else {
for b, id := range which {
boards = append(boards, gcsql.Board{})
if err = boards[b].PopulateData(id); err != nil {
return gclog.Printf(gclog.LErrorLog, "Error getting board information (ID: %d)", id)
}
}
}
if len(boards) == 0 {
return "No boards to build."
}
for _, board := range boards {
if err = board.Build(false, true); err != nil {
return gclog.Printf(gclog.LErrorLog,
"Error building /%s/: %s", board.Dir, err.Error()) + "<br />"
}
html += "Built /" + board.Dir + "/ successfully."
}
return
}

152
pkg/building/building.go Normal file
View file

@ -0,0 +1,152 @@
package building
import (
"encoding/json"
"io/ioutil"
"os"
"path"
"github.com/gochan-org/gochan/pkg/config"
"github.com/gochan-org/gochan/pkg/gclog"
"github.com/gochan-org/gochan/pkg/gcsql"
"github.com/gochan-org/gochan/pkg/gctemplates"
"github.com/gochan-org/gochan/pkg/gcutil"
)
// BuildFrontPage builds the front page using templates/front.html
func BuildFrontPage() string {
err := gctemplates.InitTemplates("front")
if err != nil {
return gclog.Print(gclog.LErrorLog, "Error loading front page template: ", err.Error())
}
os.Remove(path.Join(config.Config.DocumentRoot, "index.html"))
frontFile, err := os.OpenFile(path.Join(config.Config.DocumentRoot, "index.html"), os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0777)
if err != nil {
return gclog.Print(gclog.LErrorLog, "Failed opening front page for writing: ", err.Error()) + "<br />"
}
defer frontFile.Close()
var recentPostsArr []gcsql.RecentPost
recentPostsArr, err = gcsql.GetRecentPostsGlobal(config.Config.MaxRecentPosts, !config.Config.RecentPostsWithNoFile)
if err == nil {
return gclog.Print(gclog.LErrorLog, "Failed loading recent posts: "+err.Error()) + "<br />"
}
for b := range gcsql.AllBoards {
if gcsql.AllBoards[b].Section == 0 {
gcsql.AllBoards[b].Section = 1
}
}
if err = gcutil.MinifyTemplate(gctemplates.FrontPage, map[string]interface{}{
"config": config.Config,
"sections": gcsql.AllSections,
"boards": gcsql.AllBoards,
"recent_posts": recentPostsArr,
}, frontFile, "text/html"); err != nil {
return gclog.Print(gclog.LErrorLog, "Failed executing front page template: "+err.Error()) + "<br />"
}
return "Front page rebuilt successfully."
}
// BuildBoardListJSON generates a JSON file with info about the boards
func BuildBoardListJSON() (html string) {
boardListFile, err := os.OpenFile(path.Join(config.Config.DocumentRoot, "boards.json"), os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0777)
if err != nil {
return gclog.Print(gclog.LErrorLog, "Failed opening boards.json for writing: ", err.Error()) + "<br />"
}
defer boardListFile.Close()
boardsMap := map[string][]gcsql.Board{
"boards": []gcsql.Board{},
}
// Our cooldowns are site-wide currently.
cooldowns := gcsql.BoardCooldowns{
NewThread: config.Config.NewThreadDelay,
Reply: config.Config.ReplyDelay,
ImageReply: config.Config.ReplyDelay}
for b := range gcsql.AllBoards {
gcsql.AllBoards[b].Cooldowns = cooldowns
boardsMap["boards"] = append(boardsMap["boards"], gcsql.AllBoards[b])
}
boardJSON, err := json.Marshal(boardsMap)
if err != nil {
return gclog.Print(gclog.LErrorLog, "Failed to create boards.json: ", err.Error()) + "<br />"
}
if _, err = gcutil.MinifyWriter(boardListFile, boardJSON, "application/json"); err != nil {
return gclog.Print(gclog.LErrorLog, "Failed writing boards.json file: ", err.Error()) + "<br />"
}
return "Board list JSON rebuilt successfully.<br />"
}
// BuildJS minifies (if enabled) gochan.js and consts.js (the latter is built from a template)
func BuildJS() string {
// minify gochan.js (if enabled)
gochanMinJSPath := path.Join(config.Config.DocumentRoot, "javascript", "gochan.min.js")
gochanMinJSFile, err := os.OpenFile(gochanMinJSPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
if err != nil {
return gclog.Printf(gclog.LErrorLog, "Error opening %q for writing: %s",
gochanMinJSPath, err.Error()) + "<br />"
}
defer gochanMinJSFile.Close()
gochanJSPath := path.Join(config.Config.DocumentRoot, "javascript", "gochan.js")
gochanJSBytes, err := ioutil.ReadFile(gochanJSPath)
if err != nil {
return gclog.Printf(gclog.LErrorLog, "Error opening %q for writing: %s",
gochanJSPath, err.Error()) + "<br />"
}
if _, err := gcutil.MinifyWriter(gochanMinJSFile, gochanJSBytes, "text/javascript"); err != nil {
config.Config.UseMinifiedGochanJS = false
return gclog.Printf(gclog.LErrorLog, "Error minifying %q: %s:",
gochanMinJSPath, err.Error()) + "<br />"
}
config.Config.UseMinifiedGochanJS = true
// build consts.js from template
if err = gctemplates.InitTemplates("js"); err != nil {
return gclog.Print(gclog.LErrorLog, "Error loading consts.js template: ", err.Error())
}
constsJSPath := path.Join(config.Config.DocumentRoot, "javascript", "consts.js")
constsJSFile, err := os.OpenFile(constsJSPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
if err != nil {
return gclog.Printf(gclog.LErrorLog, "Error opening %q for writing: %s",
constsJSPath, err.Error()) + "<br />"
}
defer constsJSFile.Close()
if err = gcutil.MinifyTemplate(gctemplates.JsConsts, config.Config, constsJSFile, "text/javascript"); err != nil {
return gclog.Printf(gclog.LErrorLog, "Error building %q: %s",
constsJSPath, err.Error()) + "<br />"
}
return "Built gochan.min.js and consts.js successfully.<br />"
}
// paginate returns a 2d array of a specified interface from a 1d array passed in,
// with a specified number of values per array in the 2d array.
// interfaceLength is the number of interfaces per array in the 2d array (e.g, threads per page)
// interf is the array of interfaces to be split up.
func paginate(interfaceLength int, interf []interface{}) [][]interface{} {
// paginatedInterfaces = the finished interface array
// numArrays = the current number of arrays (before remainder overflow)
// interfacesRemaining = if greater than 0, these are the remaining interfaces
// that will be added to the super-interface
var paginatedInterfaces [][]interface{}
numArrays := len(interf) / interfaceLength
interfacesRemaining := len(interf) % interfaceLength
currentInterface := 0
for l := 0; l < numArrays; l++ {
paginatedInterfaces = append(paginatedInterfaces,
interf[currentInterface:currentInterface+interfaceLength])
currentInterface += interfaceLength
}
if interfacesRemaining > 0 {
paginatedInterfaces = append(paginatedInterfaces, interf[len(interf)-interfacesRemaining:])
}
return paginatedInterfaces
}

108
pkg/building/threads.go Normal file
View file

@ -0,0 +1,108 @@
package building
import (
"encoding/json"
"fmt"
"os"
"path"
"strconv"
"github.com/gochan-org/gochan/pkg/config"
"github.com/gochan-org/gochan/pkg/gcsql"
"github.com/gochan-org/gochan/pkg/gctemplates"
"github.com/gochan-org/gochan/pkg/gcutil"
)
// BuildThreads builds thread(s) given a boardid, or if all = false, also given a threadid.
// if all is set to true, ignore which, otherwise, which = build only specified boardid
// TODO: make it variadic
func BuildThreads(all bool, boardid, threadid int) error {
var threads []gcsql.Post
var err error
if all {
threads, err = gcsql.GetTopPostsNoSort(boardid)
} else {
var post gcsql.Post
post, err = gcsql.GetSpecificTopPost(threadid)
threads = []gcsql.Post{post}
}
if err != nil {
return err
}
for _, op := range threads {
if err = BuildThreadPages(&op); err != nil {
return err
}
}
return nil
}
// BuildThreadPages builds the pages for a thread given by a Post object.
func BuildThreadPages(op *gcsql.Post) error {
err := gctemplates.InitTemplates("threadpage")
if err != nil {
return err
}
var replies []gcsql.Post
var threadPageFile *os.File
var board gcsql.Board
if err = board.PopulateData(op.BoardID); err != nil {
return err
}
replies, err = gcsql.GetExistingReplies(op.ID)
if err != nil {
return fmt.Errorf("Error building thread %d: %s", op.ID, err.Error())
}
os.Remove(path.Join(config.Config.DocumentRoot, board.Dir, "res", strconv.Itoa(op.ID)+".html"))
var repliesInterface []interface{}
for _, reply := range replies {
repliesInterface = append(repliesInterface, reply)
}
threadPageFilepath := path.Join(config.Config.DocumentRoot, board.Dir, "res", strconv.Itoa(op.ID)+".html")
threadPageFile, err = os.OpenFile(threadPageFilepath, os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0777)
if err != nil {
return fmt.Errorf("Failed opening /%s/res/%d.html: %s", board.Dir, op.ID, err.Error())
}
// render thread page
if err = gcutil.MinifyTemplate(gctemplates.ThreadPage, map[string]interface{}{
"config": config.Config,
"boards": gcsql.AllBoards,
"board": board,
"sections": gcsql.AllSections,
"posts": replies,
"op": op,
}, threadPageFile, "text/html"); err != nil {
return fmt.Errorf("Failed building /%s/res/%d threadpage: %s", board.Dir, op.ID, err.Error())
}
// Put together the thread JSON
threadJSONFile, err := os.OpenFile(path.Join(config.Config.DocumentRoot, board.Dir, "res", strconv.Itoa(op.ID)+".json"), os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0777)
if err != nil {
return fmt.Errorf("Failed opening /%s/res/%d.json: %s", board.Dir, op.ID, err.Error())
}
defer threadJSONFile.Close()
threadMap := make(map[string][]gcsql.Post)
// Handle the OP, of type *Post
threadMap["posts"] = []gcsql.Post{*op}
// Iterate through each reply, which are of type Post
threadMap["posts"] = append(threadMap["posts"], replies...)
threadJSON, err := json.Marshal(threadMap)
if err != nil {
return fmt.Errorf("Failed to marshal to JSON: %s", err.Error())
}
if _, err = threadJSONFile.Write(threadJSON); err != nil {
return fmt.Errorf("Failed writing /%s/res/%d.json: %s", board.Dir, op.ID, err.Error())
}
return nil
}

267
pkg/config/config.go Normal file
View file

@ -0,0 +1,267 @@
package config
import (
"encoding/json"
"fmt"
"io/ioutil"
"math/rand"
"os"
"path"
"time"
"github.com/gochan-org/gochan/pkg/gclog"
)
var Config GochanConfig
// Style represents a theme (Pipes, Dark, etc)
type Style struct {
Name string
Filename string
}
// GochanConfig stores crucial info and is read from/written to gochan.json
type GochanConfig struct {
ListenIP string
Port int
FirstPage []string
Username string
UseFastCGI bool
DebugMode bool `description:"Disables several spam/browser checks that can cause problems when hosting an instance locally."`
DocumentRoot string
TemplateDir string
LogDir string
DBtype string
DBhost string
DBname string
DBusername string
DBpassword string
DBprefix string
Lockdown bool `description:"Disables posting." default:"unchecked"`
LockdownMessage string `description:"Message displayed when someone tries to post while the site is on lockdown."`
Sillytags []string `description:"List of randomly selected staff tags separated by line, e.g. <span style=\"color: red;\">## Mod</span>, to be randomly assigned to posts if UseSillytags is checked. Don't include the \"## \""`
UseSillytags bool `description:"Use Sillytags" default:"unchecked"`
Modboard string `description:"A super secret clubhouse board that only staff can view/post to." default:"staff"`
SiteName string `description:"The name of the site that appears in the header of the front page." default:"Gochan"`
SiteSlogan string `description:"The text that appears below SiteName on the home page"`
SiteHeaderURL string `description:"To be honest, I'm not even sure what this does. It'll probably be removed later."`
SiteWebfolder string `description:"The HTTP root appearing in the browser (e.g. https://gochan.org/&lt;SiteWebFolder&gt;" default:"/"`
SiteDomain string `description:"The server's domain (duh). Do not edit this unless you know what you are doing or BAD THINGS WILL HAPPEN!" default:"127.0.0.1" critical:"true"`
Styles []Style `description:"List of styles (one per line) that should be accessed online at &lt;SiteWebFolder&gt;/css/&lt;Style&gt;/"`
DefaultStyle string `description:"Filename of the default Style. This should appear in the list above or bad things might happen."`
AllowDuplicateImages bool `description:"Disabling this will cause gochan to reject a post if the image has already been uploaded for another post.<br />This may end up being removed or being made board-specific in the future." default:"checked"`
AllowVideoUploads bool `description:"Allows users to upload .webm videos. <br />This may end up being removed or being made board-specific in the future."`
NewThreadDelay int `description:"The amount of time in seconds that is required before an IP can make a new thread.<br />This may end up being removed or being made board-specific in the future." default:"30"`
ReplyDelay int `description:"Same as the above, but for replies." default:"7"`
MaxLineLength int `description:"Any line in a post that exceeds this will be split into two (or more) lines.<br />I'm not really sure why this is here, so it may end up being removed." default:"150"`
ReservedTrips []string `description:"Secure tripcodes (!!Something) can be reserved here.<br />Each reservation should go on its own line and should look like this:<br />TripPassword1##Tripcode1<br />TripPassword2##Tripcode2"`
ThumbWidth int `description:"OP thumbnails use this as their max width.<br />To keep the aspect ratio, the image will be scaled down to the ThumbWidth or ThumbHeight, whichever is larger." default:"200"`
ThumbHeight int `description:"OP thumbnails use this as their max height.<br />To keep the aspect ratio, the image will be scaled down to the ThumbWidth or ThumbHeight, whichever is larger." default:"200"`
ThumbWidthReply int `description:"Same as ThumbWidth and ThumbHeight but for reply images." default:"125"`
ThumbHeightReply int `description:"Same as ThumbWidth and ThumbHeight but for reply images." default:"125"`
ThumbWidthCatalog int `description:"Same as ThumbWidth and ThumbHeight but for catalog images." default:"50"`
ThumbHeightCatalog int `description:"Same as ThumbWidth and ThumbHeight but for catalog images." default:"50"`
ThreadsPerPage int `default:"15"`
RepliesOnBoardPage int `description:"Number of replies to a thread to show on the board page." default:"3"`
StickyRepliesOnBoardPage int `description:"Same as above for stickied threads." default:"1"`
BanColors []string `description:"Colors to be used for public ban messages (e.g. USER WAS BANNED FOR THIS POST).<br />Each entry should be on its own line, and should look something like this:<br />username1:#FF0000<br />username2:#FAF00F<br />username3:blue<br />Invalid entries/nonexistent usernames will show a warning and use the default red."`
BanMsg string `description:"The default public ban message." default:"USER WAS BANNED FOR THIS POST"`
EmbedWidth int `description:"The width for inline/expanded webm videos." default:"200"`
EmbedHeight int `description:"The height for inline/expanded webm videos." default:"164"`
ExpandButton bool `description:"If checked, adds [Embed] after a Youtube, Vimeo, etc link to toggle an inline video frame." default:"checked"`
ImagesOpenNewTab bool `description:"If checked, thumbnails will open the respective image/video in a new tab instead of expanding them." default:"unchecked"`
MakeURLsHyperlinked bool `description:"If checked, URLs in posts will be turned into a hyperlink. If unchecked, ExpandButton and NewTabOnOutlinks are ignored." default:"checked"`
NewTabOnOutlinks bool `description:"If checked, links to external sites will open in a new tab." default:"checked"`
DisableBBcode bool `description:"If checked, gochan will not compile bbcode into HTML" default:"unchecked"`
MinifyHTML bool `description:"If checked, gochan will minify html files when building" default:"checked"`
MinifyJS bool `description:"If checked, gochan will minify js and json files when building" default:"checked"`
UseMinifiedGochanJS bool `json:"-"`
DateTimeFormat string `description:"The format used for dates. See <a href=\"https://golang.org/pkg/time/#Time.Format\">here</a> for more info." default:"Mon, January 02, 2006 15:04 PM"`
AkismetAPIKey string `description:"The API key to be sent to Akismet for post spam checking. If the key is invalid, Akismet won't be used."`
UseCaptcha bool `description:"If checked, a captcha will be generated"`
CaptchaWidth int `description:"Width of the generated captcha image" default:"240"`
CaptchaHeight int `description:"Height of the generated captcha image" default:"80"`
CaptchaMinutesExpire int `description:"Number of minutes before a user has to enter a new CAPTCHA before posting. If <1 they have to submit one for every post." default:"15"`
EnableGeoIP bool `description:"If checked, this enables the usage of GeoIP for posts." default:"checked"`
GeoIPDBlocation string `description:"Specifies the location of the GeoIP database file. If you're using CloudFlare, you can set it to cf to rely on CloudFlare for GeoIP information." default:"/usr/share/GeoIP/GeoIP.dat"`
MaxRecentPosts int `description:"The maximum number of posts to show on the Recent Posts list on the front page." default:"3"`
RecentPostsWithNoFile bool `description:"If checked, recent posts with no image/upload are shown on the front page (as well as those with images" default:"unchecked"`
EnableAppeals bool `description:"If checked, allow banned users to appeal their bans.<br />This will likely be removed (permanently allowing appeals) or made board-specific in the future." default:"checked"`
MaxLogDays int `description:"The maximum number of days to keep messages in the moderation/staff log file."`
RandomSeed string `critical:"true"`
TimeZone int `json:"-"`
Version *GochanVersion `json:"-"`
}
func (cfg *GochanConfig) checkString(val, defaultVal string, critical bool, msg string) string {
if val == "" {
val = defaultVal
flags := gclog.LStdLog | gclog.LErrorLog
if critical {
flags |= gclog.LFatal
}
}
return val
}
func (cfg *GochanConfig) checkInt(val, defaultVal int, critical bool, msg string) int {
if val == 0 {
val = defaultVal
flags := gclog.LStdLog | gclog.LErrorLog
if critical {
flags |= gclog.LFatal
}
}
return val
}
func InitConfig(versionStr string) {
cfgPath := findResource("gochan.json", "/etc/gochan/gochan.json")
if cfgPath == "" {
fmt.Println("gochan.json not found")
os.Exit(1)
}
jfile, err := ioutil.ReadFile(cfgPath)
if err != nil {
fmt.Printf("Error reading %s: %s\n", cfgPath, err.Error())
os.Exit(1)
}
if err = json.Unmarshal(jfile, &Config); err != nil {
fmt.Printf("Error parsing %s: %s\n", cfgPath, err.Error())
os.Exit(1)
}
Config.LogDir = findResource(Config.LogDir, "log", "/var/log/gochan/")
if err = gclog.InitLogs(
path.Join(Config.LogDir, "access.log"),
path.Join(Config.LogDir, "error.log"),
path.Join(Config.LogDir, "staff.log"),
Config.DebugMode); err != nil {
fmt.Println(err.Error())
os.Exit(1)
}
Config.checkString(Config.ListenIP, "", true, "ListenIP not set in gochan.json, halting.")
if Config.Port == 0 {
Config.Port = 80
}
if len(Config.FirstPage) == 0 {
Config.FirstPage = []string{"index.html", "board.html", "firstrun.html"}
}
Config.Username = Config.checkString(Config.Username, "gochan", false, "Username not set in gochan.json, using 'gochan' as default")
Config.DocumentRoot = Config.checkString(Config.DocumentRoot, "gochan", true, "DocumentRoot not set in gochan.json, halting.")
wd, wderr := os.Getwd()
if wderr == nil {
_, staterr := os.Stat(path.Join(wd, Config.DocumentRoot, "css"))
if staterr == nil {
Config.DocumentRoot = path.Join(wd, Config.DocumentRoot)
}
}
Config.TemplateDir = Config.checkString(
findResource(Config.TemplateDir, "templates", "/usr/local/share/gochan/templates/", "/usr/share/gochan/templates/"),
"", true, "Unable to locate template directory, halting.")
Config.checkString(Config.DBtype, "", true, "DBtype not set in gochan.json, halting (currently supported values: mysql,postgresql,sqlite3)")
Config.checkString(Config.DBhost, "", true, "DBhost not set in gochan.json, halting.")
Config.DBname = Config.checkString(Config.DBname, "gochan", false,
"DBname not set in gochan.json, setting to 'gochan'")
Config.checkString(Config.DBusername, "", true, "DBusername not set in gochan, halting.")
Config.checkString(Config.DBpassword, "", true, "DBpassword not set in gochan, halting.")
Config.LockdownMessage = Config.checkString(Config.LockdownMessage,
"The administrator has temporarily disabled posting. We apologize for the inconvenience", false, "")
Config.checkString(Config.SiteName, "", true, "SiteName not set in gochan.json, halting.")
Config.checkString(Config.SiteDomain, "", true, "SiteName not set in gochan.json, halting.")
if Config.SiteWebfolder == "" {
gclog.Print(gclog.LErrorLog|gclog.LStdLog, "SiteWebFolder not set in gochan.json, using / as default.")
} else if string(Config.SiteWebfolder[0]) != "/" {
Config.SiteWebfolder = "/" + Config.SiteWebfolder
}
if Config.SiteWebfolder[len(Config.SiteWebfolder)-1:] != "/" {
Config.SiteWebfolder += "/"
}
if Config.Styles == nil {
gclog.Print(gclog.LErrorLog|gclog.LStdLog|gclog.LFatal, "Styles not set in gochan.json, halting.")
}
Config.DefaultStyle = Config.checkString(Config.DefaultStyle, Config.Styles[0].Filename, false, "")
Config.NewThreadDelay = Config.checkInt(Config.NewThreadDelay, 30, false, "")
Config.ReplyDelay = Config.checkInt(Config.ReplyDelay, 7, false, "")
Config.MaxLineLength = Config.checkInt(Config.MaxLineLength, 150, false, "")
//ReservedTrips string //eventually this will be map[string]string
Config.ThumbWidth = Config.checkInt(Config.ThumbWidth, 200, false, "")
Config.ThumbHeight = Config.checkInt(Config.ThumbHeight, 200, false, "")
Config.ThumbWidthReply = Config.checkInt(Config.ThumbWidthReply, 125, false, "")
Config.ThumbHeightReply = Config.checkInt(Config.ThumbHeightReply, 125, false, "")
Config.ThumbWidthCatalog = Config.checkInt(Config.ThumbWidthCatalog, 50, false, "")
Config.ThumbHeightCatalog = Config.checkInt(Config.ThumbHeightCatalog, 50, false, "")
Config.ThreadsPerPage = Config.checkInt(Config.ThreadsPerPage, 10, false, "")
Config.RepliesOnBoardPage = Config.checkInt(Config.RepliesOnBoardPage, 3, false, "")
Config.StickyRepliesOnBoardPage = Config.checkInt(Config.StickyRepliesOnBoardPage, 1, false, "")
/*config.BanColors, err = c.GetString("threads", "ban_colors") //eventually this will be map[string] string
if err != nil {
config.BanColors = "admin:#CC0000"
}*/
Config.BanMsg = Config.checkString(Config.BanMsg, "(USER WAS BANNED FOR THIS POST)", false, "")
Config.DateTimeFormat = Config.checkString(Config.DateTimeFormat, "Mon, January 02, 2006 15:04 PM", false, "")
Config.CaptchaWidth = Config.checkInt(Config.CaptchaWidth, 240, false, "")
Config.CaptchaHeight = Config.checkInt(Config.CaptchaHeight, 80, false, "")
if Config.EnableGeoIP {
if Config.GeoIPDBlocation == "" {
gclog.Print(gclog.LErrorLog|gclog.LStdLog, "GeoIPDBlocation not set in gochan.json, disabling EnableGeoIP")
Config.EnableGeoIP = false
}
}
if Config.MaxLogDays == 0 {
Config.MaxLogDays = 15
}
if Config.RandomSeed == "" {
gclog.Print(gclog.LErrorLog|gclog.LStdLog, "RandomSeed not set in gochan.json, Generating a random one.")
for i := 0; i < 8; i++ {
num := rand.Intn(127-32) + 32
Config.RandomSeed += fmt.Sprintf("%c", num)
}
configJSON, _ := json.MarshalIndent(Config, "", "\t")
if err = ioutil.WriteFile(cfgPath, configJSON, 0777); err != nil {
gclog.Printf(gclog.LErrorLog|gclog.LStdLog|gclog.LFatal, "Unable to write %s with randomly generated seed: %s", cfgPath, err.Error())
}
}
_, zoneOffset := time.Now().Zone()
Config.TimeZone = zoneOffset / 60 / 60
// msgfmtr.InitBBcode()
Config.Version = ParseVersion(versionStr)
Config.Version.Normalize()
}

14
pkg/config/util.go Normal file
View file

@ -0,0 +1,14 @@
package config
import "os"
// copied from gcutil to avoid import loop
func findResource(paths ...string) string {
var err error
for _, filepath := range paths {
if _, err = os.Stat(filepath); err == nil {
return filepath
}
}
return ""
}

View file

@ -1,6 +1,6 @@
// used for version parsing, printing, and comparison
package main
package config
import (
"fmt"

159
pkg/gclog/logging.go Normal file
View file

@ -0,0 +1,159 @@
package gclog
import (
"errors"
"fmt"
"io"
"os"
"runtime"
"time"
)
var gclog *GcLogger
const (
logTimeFmt = "2006/01/02 15:04:05 "
logFileFlags = os.O_APPEND | os.O_CREATE | os.O_RDWR
// LAccessLog should be used for incoming requests
LAccessLog = 1 << iota
// LErrorLog should be used for internal errors, not HTTP errors like 4xx
LErrorLog
// LStaffLog is comparable to LAccessLog for staff actions
LStaffLog
// LStdLog prints directly to standard output
LStdLog
// LFatal exits gochan after printing (used for fatal initialization errors)
LFatal
)
// GcLogger is used for printing to access, error, and staff logs
type GcLogger struct {
accessFile *os.File
errorFile *os.File
staffFile *os.File
debug bool
}
func (gcl *GcLogger) selectLogs(flags int) []*os.File {
var logs []*os.File
if flags&LAccessLog > 0 {
logs = append(logs, gcl.accessFile)
}
if flags&LErrorLog > 0 {
logs = append(logs, gcl.errorFile)
}
if flags&LStaffLog > 0 {
logs = append(logs, gcl.staffFile)
}
if (flags&LStdLog > 0) || gcl.debug {
logs = append(logs, os.Stdout)
}
return logs
}
func (gcl *GcLogger) getPrefix() string {
prefix := time.Now().Format(logTimeFmt)
_, file, line, _ := runtime.Caller(2)
prefix += fmt.Sprint(file, ":", line, ": ")
return prefix
}
// Print is comparable to log.Print but takes binary flags
func (gcl *GcLogger) Print(flags int, v ...interface{}) string {
str := fmt.Sprint(v...)
logs := gcl.selectLogs(flags)
for _, l := range logs {
if l == os.Stdout {
io.WriteString(l, str+"\n")
} else {
io.WriteString(l, gcl.getPrefix()+str+"\n")
}
}
if flags&LFatal > 0 {
os.Exit(1)
}
return str
}
// Printf is comparable to log.Logger.Printf but takes binary OR'd flags
func (gcl *GcLogger) Printf(flags int, format string, v ...interface{}) string {
str := fmt.Sprintf(format, v...)
logs := gcl.selectLogs(flags)
for _, l := range logs {
if l == os.Stdout {
io.WriteString(l, str+"\n")
} else {
io.WriteString(l, gcl.getPrefix()+str+"\n")
}
}
if flags&LFatal > 0 {
os.Exit(1)
}
return str
}
// Println is comparable to log.Logger.Println but takes binary OR'd flags
func (gcl *GcLogger) Println(flags int, v ...interface{}) string {
str := fmt.Sprintln(v...)
logs := gcl.selectLogs(flags)
for _, l := range logs {
if l == os.Stdout {
io.WriteString(l, str+"\n")
} else {
io.WriteString(l, gcl.getPrefix()+str+"\n")
}
}
if flags&LFatal > 0 {
os.Exit(1)
}
return str
}
// Close closes the log file handles
func (gcl *GcLogger) Close() {
gcl.accessFile.Close()
gcl.errorFile.Close()
gcl.staffFile.Close()
}
// Print is comparable to log.Print but takes binary OR'd flags
func Print(flags int, v ...interface{}) string {
if gclog == nil {
return ""
}
return gclog.Print(flags, v...)
}
// Printf is comparable to log.Printf but takes binary OR'd flags
func Printf(flags int, format string, v ...interface{}) string {
if gclog == nil {
return ""
}
return gclog.Printf(flags, format, v...)
}
// Println is comparable to log.Println but takes binary OR'd flags
func Println(flags int, v ...interface{}) string {
if gclog == nil {
return ""
}
return gclog.Println(flags, v...)
}
// InitLogs initializes the log files to be used by gochan
func InitLogs(accessLogPath, errorLogPath, staffLogPath string, debugMode bool) error {
gclog = &GcLogger{debug: debugMode}
var err error
if gclog.accessFile, err = os.OpenFile(accessLogPath, logFileFlags, 0777); err != nil {
return errors.New("Error loading " + accessLogPath + ": " + err.Error())
}
if gclog.errorFile, err = os.OpenFile(errorLogPath, logFileFlags, 0777); err != nil {
return errors.New("Error loading " + errorLogPath + ": " + err.Error())
}
if gclog.staffFile, err = os.OpenFile(staffLogPath, logFileFlags, 0777); err != nil {
return errors.New("Error loading " + staffLogPath + ": " + err.Error())
}
return nil
}

18
pkg/gclog/logging_test.go Normal file
View file

@ -0,0 +1,18 @@
package gclog
import "testing"
func TestGochanLog(t *testing.T) {
err := InitLogs("../access.log", "../error.log", "../staff.log", true)
if err != nil {
t.Fatal(err.Error())
}
gclog.Print(LStdLog, "os.Stdout log")
gclog.Print(LStdLog|LAccessLog|LErrorLog|LStaffLog, "all logs")
gclog.Print(LAccessLog, "Access log")
gclog.Print(LErrorLog, "Error log")
gclog.Print(LStaffLog, "Staff log")
gclog.Print(LAccessLog|LErrorLog, "Access and error log")
gclog.Print(LAccessLog|LStaffLog|LFatal, "Fatal access and staff log")
gclog.Print(LAccessLog, "This shouldn't be here")
}

110
pkg/gcsql/connect.go Normal file
View file

@ -0,0 +1,110 @@
package gcsql
import (
"database/sql"
"fmt"
"io/ioutil"
"regexp"
"strings"
"github.com/gochan-org/gochan/pkg/gclog"
"github.com/gochan-org/gochan/pkg/gcutil"
)
var (
db *sql.DB
dbDriver string
fatalSQLFlags = gclog.LErrorLog | gclog.LStdLog | gclog.LFatal
nilTimestamp string
sqlReplacer *strings.Replacer // used during SQL string preparation
)
// ConnectToDB initializes the database connection and exits if there are any errors
func ConnectToDB(host string, dbType string, dbName string, username string, password string, prefix string) {
var err error
var connStr string
sqlReplacer = strings.NewReplacer(
"DBNAME", dbName,
"DBPREFIX", prefix,
"\n", " ")
gclog.Print(gclog.LStdLog|gclog.LErrorLog, "Initializing server...")
switch dbType {
case "mysql":
connStr = fmt.Sprintf("%s:%s@%s/%s?parseTime=true&collation=utf8mb4_unicode_ci",
username, password, host, dbName)
nilTimestamp = "0000-00-00 00:00:00"
case "postgres":
connStr = fmt.Sprintf("postgres://%s:%s@%s/%s?sslmode=disable",
username, password, host, dbName)
nilTimestamp = "0001-01-01 00:00:00"
case "sqlite3":
gclog.Print(gclog.LStdLog, "sqlite3 support is still flaky, consider using mysql or postgres")
connStr = fmt.Sprintf("file:%s?mode=rwc&_auth&_auth_user=%s&_auth_pass=%s&cache=shared",
host, username, password)
nilTimestamp = "0001-01-01 00:00:00+00:00"
default:
gclog.Printf(fatalSQLFlags,
`Invalid DBtype %q in gochan.json, valid values are "mysql", "postgres", and "sqlite3"`, dbType)
}
dbDriver = dbType
if db, err = sql.Open(dbType, connStr); err != nil {
gclog.Print(fatalSQLFlags, "Failed to connect to the database: ", err.Error())
}
if err = initDB("initdb_" + dbType + ".sql"); err != nil {
gclog.Print(fatalSQLFlags, "Failed initializing DB: ", err.Error())
}
// Create generic "Main" section if one doesn't already exist
if err = CreateDefaultSectionIfNotExist(); err != nil {
gclog.Print(fatalSQLFlags, "Failed initializing DB: ", err.Error())
}
//TODO fix new install thing once it works with existing database
// var sqlVersionStr string
// isNewInstall := false
// if err = queryRowSQL("SELECT value FROM DBPREFIXinfo WHERE name = 'version'",
// []interface{}{}, []interface{}{&sqlVersionStr},
// ); err == sql.ErrNoRows {
// isNewInstall = true
// } else if err != nil {
// gclog.Print(lErrorLog|lStdLog|lFatal, "Failed initializing DB: ", err.Error())
// }
err = CreateDefaultBoardIfNoneExist()
if err != nil {
gclog.Print(fatalSQLFlags, "Failed creating default board: ", err.Error())
}
err = CreateDefaultAdminIfNoStaff()
if err != nil {
gclog.Print(fatalSQLFlags, "Failed creating default admin account: ", err.Error())
}
//fix versioning thing
}
func initDB(initFile string) error {
var err error
filePath := gcutil.FindResource(initFile,
"/usr/local/share/gochan/"+initFile,
"/usr/share/gochan/"+initFile)
if filePath == "" {
return fmt.Errorf("SQL database initialization file (%s) missing. Please reinstall gochan", initFile)
}
sqlBytes, err := ioutil.ReadFile(filePath)
if err != nil {
return err
}
sqlStr := regexp.MustCompile("--.*\n?").ReplaceAllString(string(sqlBytes), " ")
sqlArr := strings.Split(sqlReplacer.Replace(sqlStr), ";")
for _, statement := range sqlArr {
if statement != "" && statement != " " {
if _, err = db.Exec(statement); err != nil {
return err
}
}
}
return nil
}

View file

@ -1,11 +1,17 @@
package main
package gcsql
import (
"crypto/md5"
"database/sql"
"errors"
"fmt"
"html"
"io/ioutil"
"net"
"strconv"
"net/http"
"time"
"github.com/gochan-org/gochan/pkg/gcutil"
)
//ErrNotImplemented is a not implemented exception
@ -139,28 +145,27 @@ func getRecentPostsInternal(amount int, onlyWithFile bool, boardID int, onSpecif
if onlyWithFile && onSpecificBoard {
recentQueryStr += `\nWHERE singlefiles.filename IS NOT NULL AND recentposts.boardid = ?
ORDER BY recentposts.created_on DESC LIMIT ?`
rows, err = querySQL(recentQueryStr, boardID, amount)
rows, err = QuerySQL(recentQueryStr, boardID, amount)
}
if onlyWithFile && !onSpecificBoard {
recentQueryStr += `\nWHERE singlefiles.filename IS NOT NULL
ORDER BY recentposts.created_on DESC LIMIT ?`
rows, err = querySQL(recentQueryStr, amount)
rows, err = QuerySQL(recentQueryStr, amount)
}
if !onlyWithFile && onSpecificBoard {
recentQueryStr += `\nWHERE recentposts.boardid = ?
ORDER BY recentposts.created_on DESC LIMIT ?`
rows, err = querySQL(recentQueryStr, boardID, amount)
rows, err = QuerySQL(recentQueryStr, boardID, amount)
}
if !onlyWithFile && !onSpecificBoard {
recentQueryStr += `\nORDER BY recentposts.created_on DESC LIMIT ?`
rows, err = querySQL(recentQueryStr, amount)
rows, err = QuerySQL(recentQueryStr, amount)
}
defer closeHandle(rows)
if err != nil {
return nil, err
}
defer rows.Close()
var recentPostsArr []RecentPost
for rows.Next() {
@ -226,14 +231,14 @@ func GetStaffByName(name string) (*Staff, error) { //TODO not upt to date with o
return nil, ErrNotImplemented
}
func newStaff(username string, password string, rank int) error { //TODO not up to date with old db yet
func NewStaff(username string, password string, rank int) error { //TODO not up to date with old db yet
// _, err := execSQL("INSERT INTO DBPREFIXstaff (username, password_checksum, rank) VALUES(?,?,?)",
// &username, bcryptSum(password), &rank)
// return err
return ErrNotImplemented
}
func deleteStaff(username string) error { //TODO not up to date with old db yet
func DeleteStaff(username string) error { //TODO not up to date with old db yet
// _, err := execSQL("DELETE FROM DBPREFIXstaff WHERE username = ?", username)
// return err
return ErrNotImplemented
@ -299,6 +304,33 @@ func UserBan(IP net.IP, threadBan bool, staffName string, boardURI string, expir
return ErrNotImplemented
}
// GetBannedStatus checks check poster's name/tripcode/file checksum (from Post post) for banned status
// returns ban table if the user is banned or sql.ErrNoRows if they aren't
func GetBannedStatus(request *http.Request) (*BanInfo, error) {
formName := request.FormValue("postname")
var tripcode string
if formName != "" {
parsedName := gcutil.ParseName(formName)
tripcode += parsedName["name"]
if tc, ok := parsedName["tripcode"]; ok {
tripcode += "!" + tc
}
}
ip := gcutil.GetRealIP(request)
var filename string
var checksum string
file, fileHandler, err := request.FormFile("imagefile")
if err == nil {
html.EscapeString(fileHandler.Filename)
if data, err2 := ioutil.ReadAll(file); err2 == nil {
checksum = fmt.Sprintf("%x", md5.Sum(data))
}
}
defer file.Close()
return CheckBan(ip, tripcode, filename, checksum)
}
func GetStaffRankAndBoards(username string) (rank int, boardUris []string, err error) {
return 420, nil, ErrNotImplemented
@ -393,12 +425,6 @@ func GetAllBans() ([]BanInfo, error) {
return nil, ErrNotImplemented
}
//HackyStringToInt parses a string to an int, or 0 if error
func HackyStringToInt(text string) int {
value, _ := strconv.Atoi(text)
return value
}
// BumpThread the given thread on the given board and returns true if there were no errors
func BumpThread(postID, boardID int) error { //NOT UP TO DATE
// _, err := execSQL("UPDATE DBPREFIXposts SET bumped = ? WHERE id = ? AND boardid = ?",

471
pkg/gcsql/tables.go Normal file
View file

@ -0,0 +1,471 @@
package gcsql
import (
"errors"
"fmt"
"html"
"os"
"path"
"strconv"
"strings"
"time"
"github.com/gochan-org/gochan/pkg/config"
)
const (
dirIsAFileStr = `unable to create "%s", path exists and is a file`
genericErrStr = `unable to create "%s": %s`
pathExistsStr = `unable to create "%s", path already exists`
_ = iota
threadBan
imageBan
fullBan
)
var (
// AllSections is a cached list of all of the board sections
AllSections []BoardSection
// AllBoards is a cached list of all of the boards
AllBoards []Board
// TempPosts is a cached list of all of the posts in the temporary posts table
TempPosts []Post
)
type Announcement struct {
ID uint `json:"no"`
Subject string `json:"sub"`
Message string `json:"com"`
Poster string `json:"name"`
Timestamp time.Time
}
type BanAppeal struct {
ID int
Ban int
Message string
Denied bool
StaffResponse string
}
type BanInfo struct {
ID uint
AllowRead bool
IP string
Name string
NameIsRegex bool
SilentBan uint8
Boards string
Staff string
Timestamp time.Time
Expires time.Time
Permaban bool
Reason string
Type int
StaffNote string
AppealAt time.Time
CanAppeal bool
}
// BannedForever returns true if the ban is an unappealable permaban
func (ban *BanInfo) BannedForever() bool {
return ban.Permaban && !ban.CanAppeal && ban.Type == fullBan && ban.Boards == ""
}
// IsActive returns true if the ban is still active (unexpired or a permaban)
func (ban *BanInfo) IsActive(board string) bool {
if ban.Boards == "" && (ban.Expires.After(time.Now()) || ban.Permaban) {
return true
}
boardsArr := strings.Split(ban.Boards, ",")
for _, b := range boardsArr {
if b == board && (ban.Expires.After(time.Now()) || ban.Permaban) {
return true
}
}
return false
}
// IsBanned checks to see if the ban applies to the given board
func (ban *BanInfo) IsBanned(board string) bool {
if ban.Boards == "" && (ban.Expires.After(time.Now()) || ban.Permaban) {
return true
}
boardsArr := strings.Split(ban.Boards, ",")
for _, b := range boardsArr {
if b == board && (ban.Expires.After(time.Now()) || ban.Permaban) {
return true
}
}
return false
}
type BannedHash struct {
ID uint
Checksum string
Description string
}
type Board struct {
ID int `json:"-"`
CurrentPage int `json:"-"`
NumPages int `json:"pages"`
ListOrder int `json:"-"`
Dir string `json:"board"`
Type int `json:"-"`
UploadType int `json:"-"`
Title string `json:"title"`
Subtitle string `json:"meta_description"`
Description string `json:"-"`
Section int `json:"-"`
MaxFilesize int `json:"max_filesize"`
MaxPages int `json:"max_pages"`
DefaultStyle string `json:"-"`
Locked bool `json:"is_archived"`
CreatedOn time.Time `json:"-"`
Anonymous string `json:"-"`
ForcedAnon bool `json:"-"`
MaxAge int `json:"-"`
AutosageAfter int `json:"bump_limit"`
NoImagesAfter int `json:"image_limit"`
MaxMessageLength int `json:"max_comment_chars"`
EmbedsAllowed bool `json:"-"`
RedirectToThread bool `json:"-"`
ShowID bool `json:"-"`
RequireFile bool `json:"-"`
EnableCatalog bool `json:"-"`
EnableSpoileredImages bool `json:"-"`
EnableSpoileredThreads bool `json:"-"`
Worksafe bool `json:"ws_board"`
ThreadPage int `json:"-"`
Cooldowns BoardCooldowns `json:"cooldowns"`
ThreadsPerPage int `json:"per_page"`
}
// AbsolutePath returns the full filepath of the board directory
func (board *Board) AbsolutePath(subpath ...string) string {
return path.Join(config.Config.DocumentRoot, board.Dir, path.Join(subpath...))
}
// WebPath returns a string that represents the file's path as accessible by a browser
// fileType should be "boardPage", "threadPage", "upload", or "thumb"
func (board *Board) WebPath(fileName string, fileType string) string {
var filePath string
switch fileType {
case "":
fallthrough
case "boardPage":
filePath = path.Join(config.Config.SiteWebfolder, board.Dir, fileName)
case "threadPage":
filePath = path.Join(config.Config.SiteWebfolder, board.Dir, "res", fileName)
case "upload":
filePath = path.Join(config.Config.SiteWebfolder, board.Dir, "src", fileName)
case "thumb":
filePath = path.Join(config.Config.SiteWebfolder, board.Dir, "thumb", fileName)
}
return filePath
}
func (board *Board) PagePath(pageNum interface{}) string {
var page string
pageNumStr := fmt.Sprintf("%v", pageNum)
if pageNumStr == "prev" {
if board.CurrentPage < 2 {
page = "1"
} else {
page = strconv.Itoa(board.CurrentPage - 1)
}
} else if pageNumStr == "next" {
if board.CurrentPage >= board.NumPages {
page = strconv.Itoa(board.NumPages)
} else {
page = strconv.Itoa(board.CurrentPage + 1)
}
} else {
page = pageNumStr
}
return board.WebPath(page+".html", "boardPage")
}
// Build builds the board and its thread files
// if newBoard is true, it adds a row to DBPREFIXboards and fails if it exists
// if force is true, it doesn't fail if the directories exist but does fail if it is a file
func (board *Board) Build(newBoard bool, force bool) error {
var err error
if board.Dir == "" {
return errors.New("board must have a directory before it is built")
}
if board.Title == "" {
return errors.New("board must have a title before it is built")
}
dirPath := board.AbsolutePath()
resPath := board.AbsolutePath("res")
srcPath := board.AbsolutePath("src")
thumbPath := board.AbsolutePath("thumb")
dirInfo, _ := os.Stat(dirPath)
resInfo, _ := os.Stat(resPath)
srcInfo, _ := os.Stat(srcPath)
thumbInfo, _ := os.Stat(thumbPath)
if dirInfo != nil {
if !force {
return fmt.Errorf(pathExistsStr, dirPath)
}
if !dirInfo.IsDir() {
return fmt.Errorf(dirIsAFileStr, dirPath)
}
} else {
if err = os.Mkdir(dirPath, 0666); err != nil {
return fmt.Errorf(genericErrStr, dirPath, err.Error())
}
}
if resInfo != nil {
if !force {
return fmt.Errorf(pathExistsStr, resPath)
}
if !resInfo.IsDir() {
return fmt.Errorf(dirIsAFileStr, resPath)
}
} else {
if err = os.Mkdir(resPath, 0666); err != nil {
return fmt.Errorf(genericErrStr, resPath, err.Error())
}
}
if srcInfo != nil {
if !force {
return fmt.Errorf(pathExistsStr, srcPath)
}
if !srcInfo.IsDir() {
return fmt.Errorf(dirIsAFileStr, srcPath)
}
} else {
if err = os.Mkdir(srcPath, 0666); err != nil {
return fmt.Errorf(genericErrStr, srcPath, err.Error())
}
}
if thumbInfo != nil {
if !force {
return fmt.Errorf(pathExistsStr, thumbPath)
}
if !thumbInfo.IsDir() {
return fmt.Errorf(dirIsAFileStr, thumbPath)
}
} else {
if err = os.Mkdir(thumbPath, 0666); err != nil {
return fmt.Errorf(genericErrStr, thumbPath, err.Error())
}
}
if newBoard {
board.CreatedOn = time.Now()
err := CreateBoard(board)
if err != nil {
return err
}
} else {
if err = board.UpdateID(); err != nil {
return err
}
}
/* buildBoardPages(board)
buildThreads(true, board.ID, 0)
resetBoardSectionArrays()
buildFrontPage()
if board.EnableCatalog {
buildCatalog(board.ID)
}
buildBoardListJSON() */
return nil
}
func (board *Board) SetDefaults() {
board.ListOrder = 0
board.Section = 1
board.MaxFilesize = 4096
board.MaxPages = 11
board.DefaultStyle = config.Config.DefaultStyle
board.Locked = false
board.Anonymous = "Anonymous"
board.ForcedAnon = false
board.MaxAge = 0
board.AutosageAfter = 200
board.NoImagesAfter = 0
board.MaxMessageLength = 8192
board.EmbedsAllowed = true
board.RedirectToThread = false
board.ShowID = false
board.RequireFile = false
board.EnableCatalog = true
board.EnableSpoileredImages = true
board.EnableSpoileredThreads = true
board.Worksafe = true
board.ThreadsPerPage = 10
}
type BoardSection struct {
ID int
ListOrder int
Hidden bool
Name string
Abbreviation string
}
// Post represents each post in the database
type Post struct {
ID int `json:"no"`
ParentID int `json:"resto"`
CurrentPage int `json:"-"`
BoardID int `json:"-"`
Name string `json:"name"`
Tripcode string `json:"trip"`
Email string `json:"email"`
Subject string `json:"sub"`
MessageHTML string `json:"com"`
MessageText string `json:"-"`
Password string `json:"-"`
Filename string `json:"tim"`
FilenameOriginal string `json:"filename"`
FileChecksum string `json:"md5"`
FileExt string `json:"extension"`
Filesize int `json:"fsize"`
ImageW int `json:"w"`
ImageH int `json:"h"`
ThumbW int `json:"tn_w"`
ThumbH int `json:"tn_h"`
IP string `json:"-"`
Capcode string `json:"capcode"`
Timestamp time.Time `json:"time"`
Autosage bool `json:"-"`
DeletedTimestamp time.Time `json:"-"`
Bumped time.Time `json:"last_modified"`
Stickied bool `json:"-"`
Locked bool `json:"-"`
Reviewed bool `json:"-"`
}
func (p *Post) GetURL(includeDomain bool) string {
postURL := ""
if includeDomain {
postURL += config.Config.SiteDomain
}
var board Board
if err := board.PopulateData(p.BoardID); err != nil {
return postURL
}
idStr := strconv.Itoa(p.ID)
postURL += config.Config.SiteWebfolder + board.Dir + "/res/"
if p.ParentID == 0 {
postURL += idStr + ".html#" + idStr
} else {
postURL += strconv.Itoa(p.ParentID) + ".html#" + idStr
}
return postURL
}
// Sanitize escapes HTML strings in a post. This should be run immediately before
// the post is inserted into the database
func (p *Post) Sanitize() {
p.Name = html.EscapeString(p.Name)
p.Email = html.EscapeString(p.Email)
p.Subject = html.EscapeString(p.Subject)
p.Password = html.EscapeString(p.Password)
}
type Report struct {
ID uint
Board string
PostID uint
Timestamp time.Time
IP string
Reason string
Cleared bool
IsTemp bool
}
type LoginSession struct {
ID uint
Name string
Data string
Expires string
}
// Staff represents a single staff member's info stored in the database
type Staff struct {
ID int
Username string
PasswordChecksum string
Rank int
Boards string
AddedOn time.Time
LastActive time.Time
}
type WordFilter struct {
ID int
From string
To string
Boards string
RegEx bool
}
type BoardCooldowns struct {
NewThread int `json:"threads"`
Reply int `json:"replies"`
ImageReply int `json:"images"`
}
type MessagePostContainer struct {
ID int
MessageRaw string
Message string
}
type RecentPost struct {
BoardName string
BoardID int
PostID int
ParentID int
Name string
Tripcode string
Message string
Filename string
ThumbW int
ThumbH int
IP string
Timestamp time.Time
}
// GetURL returns the full URL of the recent post, or the full path if includeDomain is false
func (p *RecentPost) GetURL(includeDomain bool) string {
postURL := ""
if includeDomain {
postURL += config.Config.SiteDomain
}
idStr := strconv.Itoa(p.PostID)
postURL += config.Config.SiteWebfolder + p.BoardName + "/res/"
if p.ParentID == 0 {
postURL += idStr + ".html#" + idStr
} else {
postURL += strconv.Itoa(p.ParentID) + ".html#" + idStr
}
return postURL
}
type Thread struct {
OP Post `json:"-"`
NumReplies int `json:"replies"`
NumImages int `json:"images"`
OmittedPosts int `json:"omitted_posts"`
OmittedImages int `json:"omitted_images"`
BoardReplies []Post `json:"-"`
Sticky int `json:"sticky"`
Locked int `json:"locked"`
ThreadPage int `json:"-"`
}

139
pkg/gcsql/util.go Normal file
View file

@ -0,0 +1,139 @@
package gcsql
import (
"database/sql"
"fmt"
"strings"
)
const (
MySQLDatetimeFormat = "2006-01-02 15:04:05"
unsupportedSQLVersionMsg = `Received syntax error while preparing a SQL string.
This means that either there is a bug in gochan's code (hopefully not) or that you are using an unsupported My/Postgre/SQLite version.
Before reporting an error, make sure that you are using the up to date version of your selected SQL server.
Error text: %s`
)
func sqlVersionErr(err error) error {
if err == nil {
return nil
}
errText := err.Error()
switch dbDriver {
case "mysql":
if !strings.Contains(errText, "You have an error in your SQL syntax") {
return err
}
case "postgres":
if !strings.Contains(errText, "syntax error at or near") {
return err
}
case "sqlite3":
if !strings.Contains(errText, "Error: near ") {
return err
}
}
return fmt.Errorf(unsupportedSQLVersionMsg, errText)
}
// used for generating a prepared SQL statement formatted according to config.DBtype
func prepareSQL(query string) (*sql.Stmt, error) {
var preparedStr string
switch dbDriver {
case "mysql":
fallthrough
case "sqlite3":
preparedStr = query
case "postgres":
arr := strings.Split(query, "?")
for i := range arr {
if i == len(arr)-1 {
break
}
arr[i] += fmt.Sprintf("$%d", i+1)
}
preparedStr = strings.Join(arr, "")
}
stmt, err := db.Prepare(sqlReplacer.Replace(preparedStr))
return stmt, sqlVersionErr(err)
}
// Close closes the connection to the SQL database
func Close() {
if db != nil {
db.Close()
}
}
/*
ExecSQL automatically escapes the given values and caches the statement
Example:
var intVal int
var stringVal string
result, err := gcsql.ExecSQL(
"INSERT INTO tablename (intval,stringval) VALUES(?,?)", intVal, stringVal)
*/
func ExecSQL(query string, values ...interface{}) (sql.Result, error) {
stmt, err := prepareSQL(query)
if err != nil {
return nil, err
}
defer stmt.Close()
return stmt.Exec(values...)
}
/*
QueryRowSQL gets a row from the db with the values in values[] and fills the respective pointers in out[]
Automatically escapes the given values and caches the query
Example:
id := 32
var intVal int
var stringVal string
err := queryRowSQL("SELECT intval,stringval FROM table WHERE id = ?",
[]interface{}{&id},
[]interface{}{&intVal, &stringVal})
*/
func QueryRowSQL(query string, values []interface{}, out []interface{}) error {
stmt, err := prepareSQL(query)
if err != nil {
return err
}
defer stmt.Close()
return stmt.QueryRow(values...).Scan(out...)
}
/*
QuerySQL gets all rows from the db with the values in values[] and fills the respective pointers in out[]
Automatically escapes the given values and caches the query
Example:
rows, err := gcsql.QuerySQL("SELECT * FROM table")
if err == nil {
for rows.Next() {
var intVal int
var stringVal string
rows.Scan(&intVal, &stringVal)
// do something with intVal and stringVal
}
}
*/
func QuerySQL(query string, a ...interface{}) (*sql.Rows, error) {
stmt, err := prepareSQL(query)
if err != nil {
return nil, err
}
defer stmt.Close()
return stmt.Query(a...)
}
// ResetBoardSectionArrays is run when the board list needs to be changed
// (board/section is added, deleted, etc)
func ResetBoardSectionArrays() {
AllBoards = nil
AllSections = nil
allBoardsArr, _ := GetAllBoards()
AllBoards = append(AllBoards, allBoardsArr...)
allSectionsArr, _ := GetAllSections()
AllSections = append(AllSections, allSectionsArr...)
}

199
src/template.go → pkg/gctemplates/funcs.go Executable file → Normal file
View file

@ -1,15 +1,17 @@
package main
package gctemplates
import (
"fmt"
"html"
"os"
"path"
"html/template"
"reflect"
"strconv"
"strings"
"text/template"
"time"
"github.com/gochan-org/gochan/pkg/config"
"github.com/gochan-org/gochan/pkg/gcsql"
"github.com/gochan-org/gochan/pkg/gcutil"
x_html "golang.org/x/net/html"
)
@ -57,7 +59,7 @@ var funcMap = template.FuncMap{
},
// String functions
"arrToString": arrToString,
// "arrToString": arrToString,
"intToString": strconv.Itoa,
"escapeString": func(a string) string {
return html.EscapeString(a)
@ -73,7 +75,9 @@ var funcMap = template.FuncMap{
}
return fmt.Sprintf("%0.2f GB", size/1024/1024/1024)
},
"formatTimestamp": humanReadableTime,
"formatTimestamp": func(t time.Time) string {
return t.Format(config.Config.DateTimeFormat)
},
"stringAppend": func(strings ...string) string {
var appended string
for _, str := range strings {
@ -96,10 +100,9 @@ var funcMap = template.FuncMap{
msg = msg + "..."
}
return msg
} else {
msg = msg[:limit]
truncated = true
}
msg = msg[:limit]
truncated = true
if truncated {
msg = msg + "..."
@ -132,12 +135,11 @@ var funcMap = template.FuncMap{
},
// Imageboard functions
"bannedForever": bannedForever,
"getCatalogThumbnail": func(img string) string {
return getThumbnailPath("catalog", img)
return gcutil.GetThumbnailPath("catalog", img)
},
"getThreadID": func(postInterface interface{}) (thread int) {
post, ok := postInterface.(Post)
post, ok := postInterface.(gcsql.Post)
if !ok {
thread = 0
} else if post.ParentID == 0 {
@ -149,18 +151,18 @@ var funcMap = template.FuncMap{
},
"getPostURL": func(postInterface interface{}, typeOf string, withDomain bool) (postURL string) {
if withDomain {
postURL = config.SiteDomain
postURL = config.Config.SiteDomain
}
postURL += config.SiteWebfolder
postURL += config.Config.SiteWebfolder
if typeOf == "recent" {
post, ok := postInterface.(*RecentPost)
post, ok := postInterface.(*gcsql.RecentPost)
if !ok {
return
}
postURL = post.GetURL(withDomain)
} else {
post, ok := postInterface.(*Post)
post, ok := postInterface.(*gcsql.Post)
if !ok {
return
}
@ -169,10 +171,10 @@ var funcMap = template.FuncMap{
return
},
"getThreadThumbnail": func(img string) string {
return getThumbnailPath("thread", img)
return gcutil.GetThumbnailPath("thread", img)
},
"getUploadType": func(name string) string {
extension := getFileExtension(name)
extension := gcutil.GetFileExtension(name)
var uploadType string
switch extension {
case "":
@ -207,10 +209,15 @@ var funcMap = template.FuncMap{
}
return img[0:index] + thumbSuffix
},
"isBanned": isBanned,
"numReplies": numReplies,
"numReplies": func(boardid, threadid int) int {
num, err := gcsql.GetReplyCount(threadid)
if err != nil {
return 0
}
return num
},
"getBoardDir": func(id int) string {
var board Board
var board gcsql.Board
if err := board.PopulateData(id); err != nil {
return ""
}
@ -226,8 +233,8 @@ var funcMap = template.FuncMap{
return loopArr
},
"generateConfigTable": func() string {
configType := reflect.TypeOf(config)
tableOut := "<table style=\"border-collapse: collapse;\" id=\"config\"><tr><th>Field name</th><th>Value</th><th>Type</th><th>Description</th></tr>\n"
configType := reflect.TypeOf(config.Config)
tableOut := `<table style="border-collapse: collapse;" id="config"><tr><th>Field name</th><th>Value</th><th>Type</th><th>Description</th></tr>`
numFields := configType.NumField()
for f := 17; f < numFields-2; f++ {
// starting at Lockdown because the earlier fields can't be safely edited from a web interface
@ -237,22 +244,22 @@ var funcMap = template.FuncMap{
}
name := field.Name
tableOut += "<tr><th>" + name + "</th><td>"
f := reflect.Indirect(reflect.ValueOf(config)).FieldByName(name)
f := reflect.Indirect(reflect.ValueOf(config.Config)).FieldByName(name)
kind := f.Kind()
switch kind {
case reflect.Int:
tableOut += "<input name=\"" + name + "\" type=\"number\" value=\"" + html.EscapeString(fmt.Sprintf("%v", f)) + "\" class=\"config-text\"/>"
tableOut += `<input name="` + name + `" type="number" value="` + html.EscapeString(fmt.Sprintf("%v", f)) + `" class="config-text"/>`
case reflect.String:
tableOut += "<input name=\"" + name + "\" type=\"text\" value=\"" + html.EscapeString(fmt.Sprintf("%v", f)) + "\" class=\"config-text\"/>"
tableOut += `<input name="` + name + `" type="text" value="` + html.EscapeString(fmt.Sprintf("%v", f)) + `" class="config-text"/>`
case reflect.Bool:
checked := ""
if f.Bool() {
checked = "checked"
}
tableOut += "<input name=\"" + name + "\" type=\"checkbox\" " + checked + " />"
tableOut += `<input name="` + name + `" type="checkbox" ` + checked + " />"
case reflect.Slice:
tableOut += "<textarea name=\"" + name + "\" rows=\"4\" cols=\"28\">"
tableOut += `<textarea name="` + name + `" rows="4" cols="28">`
arrLength := f.Len()
for s := 0; s < arrLength; s++ {
newLine := "\n"
@ -272,143 +279,15 @@ var funcMap = template.FuncMap{
defaultTagHTML = " <b>Default: " + defaultTag + "</b>"
}
tableOut += field.Tag.Get("description") + defaultTagHTML + "</td>"
tableOut += "</tr>\n"
tableOut += "</tr>"
}
tableOut += "</table>\n"
tableOut += "</table>"
return tableOut
},
"isStyleDefault": func(style string) bool {
return style == config.DefaultStyle
return style == config.Config.DefaultStyle
},
// Version functions
"version": func() string {
return version.String()
return config.Config.Version.String()
},
}
var (
banpageTmpl *template.Template
captchaTmpl *template.Template
catalogTmpl *template.Template
errorpageTmpl *template.Template
frontPageTmpl *template.Template
boardpageTmpl *template.Template
threadpageTmpl *template.Template
postEditTmpl *template.Template
manageBansTmpl *template.Template
manageBoardsTmpl *template.Template
manageConfigTmpl *template.Template
manageHeaderTmpl *template.Template
jsTmpl *template.Template
)
func loadTemplate(files ...string) (*template.Template, error) {
var templates []string
for i, file := range files {
templates = append(templates, file)
tmplPath := path.Join(config.TemplateDir, "override", file)
if _, err := os.Stat(tmplPath); !os.IsNotExist(err) {
files[i] = tmplPath
} else {
files[i] = path.Join(config.TemplateDir, file)
}
}
return template.New(templates[0]).Funcs(funcMap).ParseFiles(files...)
}
func templateError(name string, err error) error {
if err == nil {
return nil
}
return fmt.Errorf("Failed loading template '%s/%s': %s", config.TemplateDir, name, err.Error())
}
func initTemplates(which ...string) error {
var err error
buildAll := len(which) == 0 || which[0] == "all"
resetBoardSectionArrays()
for _, t := range which {
if buildAll || t == "banpage" {
banpageTmpl, err = loadTemplate("banpage.html", "page_footer.html")
if err != nil {
return templateError("banpage.html", err)
}
}
if buildAll || t == "captcha" {
captchaTmpl, err = loadTemplate("captcha.html")
if err != nil {
return templateError("captcha.html", err)
}
}
if buildAll || t == "catalog" {
catalogTmpl, err = loadTemplate("catalog.html", "page_header.html", "page_footer.html")
if err != nil {
return templateError("catalog.html", err)
}
}
if buildAll || t == "error" {
errorpageTmpl, err = loadTemplate("error.html")
if err != nil {
return templateError("error.html", err)
}
}
if buildAll || t == "front" {
frontPageTmpl, err = loadTemplate("front.html", "front_intro.html", "page_header.html", "page_footer.html")
if err != nil {
return templateError("front.html", err)
}
}
if buildAll || t == "boardpage" {
boardpageTmpl, err = loadTemplate("boardpage.html", "page_header.html", "postbox.html", "page_footer.html")
if err != nil {
return templateError("boardpage.html", err)
}
}
if buildAll || t == "threadpage" {
threadpageTmpl, err = loadTemplate("threadpage.html", "page_header.html", "postbox.html", "page_footer.html")
if err != nil {
return templateError("threadpage.html", err)
}
}
if buildAll || t == "postedit" {
postEditTmpl, err = loadTemplate("post_edit.html", "page_header.html", "page_footer.html")
if err != nil {
return templateError("threadpage.html", err)
}
}
if buildAll || t == "managebans" {
manageBansTmpl, err = loadTemplate("manage_bans.html")
if err != nil {
return templateError("manage_bans.html", err)
}
}
if buildAll || t == "manageboards" {
manageBoardsTmpl, err = loadTemplate("manage_boards.html")
if err != nil {
return templateError("manage_boards.html", err)
}
}
if buildAll || t == "manageconfig" {
manageConfigTmpl, err = loadTemplate("manage_config.html")
if err != nil {
return templateError("manage_config.html", err)
}
}
if buildAll || t == "manageheader" {
manageHeaderTmpl, err = loadTemplate("manage_header.html")
if err != nil {
return templateError("manage_header.html", err)
}
}
if buildAll || t == "js" {
jsTmpl, err = loadTemplate("consts.js")
if err != nil {
return templateError("consts.js", err)
}
}
}
return nil
}

View file

@ -0,0 +1,140 @@
package gctemplates
import (
"fmt"
"html/template"
"os"
"path"
"github.com/gochan-org/gochan/pkg/config"
"github.com/gochan-org/gochan/pkg/gcsql"
)
var (
Banpage *template.Template
Captcha *template.Template
Catalog *template.Template
ErrorPage *template.Template
FrontPage *template.Template
BoardPage *template.Template
JsConsts *template.Template
ManageBans *template.Template
ManageBoards *template.Template
ManageConfig *template.Template
ManageHeader *template.Template
PostEdit *template.Template
ThreadPage *template.Template
)
func loadTemplate(files ...string) (*template.Template, error) {
var templates []string
for i, file := range files {
templates = append(templates, file)
tmplPath := path.Join(config.Config.TemplateDir, "override", file)
if _, err := os.Stat(tmplPath); !os.IsNotExist(err) {
files[i] = tmplPath
} else {
files[i] = path.Join(config.Config.TemplateDir, file)
}
}
return template.New(templates[0]).Funcs(funcMap).ParseFiles(files...)
}
func templateError(name string, err error) error {
if err == nil {
return nil
}
return fmt.Errorf("Failed loading template '%s/%s': %s",
config.Config.TemplateDir, name, err.Error())
}
// InitTemplates loads the given templates by name. If no parameters are given,
// or the first one is "all", all templates are (re)loaded
func InitTemplates(which ...string) error {
var err error
buildAll := len(which) == 0 || which[0] == "all"
gcsql.ResetBoardSectionArrays()
for _, t := range which {
if buildAll || t == "banpage" {
Banpage, err = loadTemplate("banpage.html", "page_footer.html")
if err != nil {
return templateError("banpage.html", err)
}
}
if buildAll || t == "captcha" {
Captcha, err = loadTemplate("captcha.html")
if err != nil {
return templateError("captcha.html", err)
}
}
if buildAll || t == "catalog" {
Catalog, err = loadTemplate("catalog.html", "page_header.html", "page_footer.html")
if err != nil {
return templateError("catalog.html", err)
}
}
if buildAll || t == "error" {
ErrorPage, err = loadTemplate("error.html")
if err != nil {
return templateError("error.html", err)
}
}
if buildAll || t == "front" {
FrontPage, err = loadTemplate("front.html", "front_intro.html", "page_header.html", "page_footer.html")
if err != nil {
return templateError("front.html", err)
}
}
if buildAll || t == "boardpage" {
BoardPage, err = loadTemplate("boardpage.html", "page_header.html", "postbox.html", "page_footer.html")
if err != nil {
return templateError("boardpage.html", err)
}
}
if buildAll || t == "threadpage" {
ThreadPage, err = loadTemplate("threadpage.html", "page_header.html", "postbox.html", "page_footer.html")
if err != nil {
return templateError("threadpage.html", err)
}
}
if buildAll || t == "postedit" {
PostEdit, err = loadTemplate("post_edit.html", "page_header.html", "page_footer.html")
if err != nil {
return templateError("threadpage.html", err)
}
}
if buildAll || t == "managebans" {
ManageBans, err = loadTemplate("manage_bans.html")
if err != nil {
return templateError("manage_bans.html", err)
}
}
if buildAll || t == "manageboards" {
ManageBoards, err = loadTemplate("manage_boards.html")
if err != nil {
return templateError("manage_boards.html", err)
}
}
if buildAll || t == "manageconfig" {
ManageConfig, err = loadTemplate("manage_config.html")
if err != nil {
return templateError("manage_config.html", err)
}
}
if buildAll || t == "manageheader" {
ManageHeader, err = loadTemplate("manage_header.html")
if err != nil {
return templateError("manage_header.html", err)
}
}
if buildAll || t == "js" {
JsConsts, err = loadTemplate("consts.js")
if err != nil {
return templateError("consts.js", err)
}
}
}
return nil
}

View file

@ -1,4 +1,4 @@
package main
package gcutil
import (
"errors"
@ -6,13 +6,13 @@ import (
)
func TestAPI(t *testing.T) {
failedPost, _ := marshalJSON(map[string]interface{}{
failedPost, _ := MarshalJSON(map[string]interface{}{
"action": "post",
"success": false,
"message": errors.New("Post failed").Error(),
}, true)
madePost, _ := marshalJSON(map[string]interface{}{
madePost, _ := MarshalJSON(map[string]interface{}{
"action": "post",
"success": true,
"board": "test",
@ -21,6 +21,5 @@ func TestAPI(t *testing.T) {
t.Log(
"failedPost:", failedPost,
"\nmadePost:", madePost,
)
"\nmadePost:", madePost)
}

View file

@ -1,4 +1,4 @@
package main
package gcutil
import (
"fmt"
@ -6,19 +6,19 @@ import (
)
func TestDurationParse(t *testing.T) {
duration, err := parseDurationString("7y6mo5w4d3h2m1s")
duration, err := ParseDurationString("7y6mo5w4d3h2m1s")
if err != nil {
t.Fatal(err.Error())
}
fmt.Println(duration)
duration, err = parseDurationString("7year6month5weeks4days3hours2minutes1second")
duration, err = ParseDurationString("7year6month5weeks4days3hours2minutes1second")
if err != nil {
t.Fatal(err.Error())
}
fmt.Println(duration)
duration, err = parseDurationString("7 years 6 months 5 weeks 4 days 3 hours 2 minutes 1 seconds")
duration, err = ParseDurationString("7 years 6 months 5 weeks 4 days 3 hours 2 minutes 1 seconds")
if err != nil {
t.Fatal(err.Error())
}

61
pkg/gcutil/minifier.go Normal file
View file

@ -0,0 +1,61 @@
package gcutil
import (
"html/template"
"io"
"github.com/gochan-org/gochan/pkg/config"
"github.com/tdewolff/minify"
minifyHTML "github.com/tdewolff/minify/html"
minifyJS "github.com/tdewolff/minify/js"
minifyJSON "github.com/tdewolff/minify/json"
)
var minifier *minify.M
// InitMinifier sets up the HTML/JS/JSON minifier if enabled in gochan.json
func InitMinifier() {
if !config.Config.MinifyHTML && !config.Config.MinifyJS {
return
}
minifier = minify.New()
if config.Config.MinifyHTML {
minifier.AddFunc("text/html", minifyHTML.Minify)
}
if config.Config.MinifyJS {
minifier.AddFunc("text/javascript", minifyJS.Minify)
minifier.AddFunc("application/json", minifyJSON.Minify)
}
}
func canMinify(mediaType string) bool {
if mediaType == "text/html" && config.Config.MinifyHTML {
return true
}
if (mediaType == "application/json" || mediaType == "text/javascript") && config.Config.MinifyJS {
return true
}
return false
}
// MinifyTemplate minifies the given template/data (if enabled) and returns any errors
func MinifyTemplate(tmpl *template.Template, data interface{}, writer io.Writer, mediaType string) error {
if !canMinify(mediaType) {
return tmpl.Execute(writer, data)
}
minWriter := minifier.Writer(mediaType, writer)
defer minWriter.Close()
return tmpl.Execute(minWriter, data)
}
// MinifyWriter minifies the given writer/data (if enabled) and returns the number of bytes written and any errors
func MinifyWriter(writer io.Writer, data []byte, mediaType string) (int, error) {
if !canMinify(mediaType) {
return writer.Write(data)
}
minWriter := minifier.Writer(mediaType, writer)
defer minWriter.Close()
return minWriter.Write(data)
}

249
pkg/gcutil/util.go Normal file
View file

@ -0,0 +1,249 @@
package gcutil
import (
"crypto/md5"
"crypto/sha1"
"encoding/json"
"errors"
"fmt"
"io"
"io/ioutil"
"math/rand"
"net"
"net/http"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
"github.com/aquilax/tripcode"
"golang.org/x/crypto/bcrypt"
)
var (
ErrEmptyDurationString = errors.New("Empty Duration string")
ErrInvalidDurationString = errors.New("Invalid Duration string")
durationRegexp = regexp.MustCompile(`^((\d+)\s?ye?a?r?s?)?\s?((\d+)\s?mon?t?h?s?)?\s?((\d+)\s?we?e?k?s?)?\s?((\d+)\s?da?y?s?)?\s?((\d+)\s?ho?u?r?s?)?\s?((\d+)\s?mi?n?u?t?e?s?)?\s?((\d+)\s?s?e?c?o?n?d?s?)?$`)
)
// BcryptSum generates and returns a checksum using the bcrypt hashing function
func BcryptSum(str string) string {
digest, err := bcrypt.GenerateFromPassword([]byte(str), 4)
if err == nil {
return string(digest)
}
return ""
}
// Md5Sum generates and returns a checksum using the MD5 hashing function
func Md5Sum(str string) string {
hash := md5.New()
io.WriteString(hash, str)
return fmt.Sprintf("%x", hash.Sum(nil))
}
// Sha1Sum generates and returns a checksum using the SHA-1 hashing function
func Sha1Sum(str string) string {
hash := sha1.New()
io.WriteString(hash, str)
return fmt.Sprintf("%x", hash.Sum(nil))
}
/* func byteByByteReplace(input, from, to string) string {
if len(from) != len(to) {
return ""
}
for i := 0; i < len(from); i++ {
input = strings.Replace(input, from[i:i+1], to[i:i+1], -1)
}
return input
} */
// CloseHandle closes the given closer object only if it is non-nil
func CloseHandle(handle io.Closer) {
if handle != nil {
handle.Close()
}
}
// DeleteMatchingFiles deletes files in a folder (root) that match a given regular expression.
// Returns the number of files that were deleted, and any error encountered.
func DeleteMatchingFiles(root, match string) (filesDeleted int, err error) {
files, err := ioutil.ReadDir(root)
if err != nil {
return 0, err
}
for _, f := range files {
match, _ := regexp.MatchString(match, f.Name())
if match {
os.Remove(filepath.Join(root, f.Name()))
filesDeleted++
}
}
return filesDeleted, err
}
// FindResource searches for a file in the given paths and returns the first one it finds
// or a blank string if none of the paths exist
func FindResource(paths ...string) string {
var err error
for _, filepath := range paths {
if _, err = os.Stat(filepath); err == nil {
return filepath
}
}
return ""
}
// GetFileExtension returns the given file's extension, or a blank string if it has none
func GetFileExtension(filename string) string {
if !strings.Contains(filename, ".") {
return ""
}
return filename[strings.LastIndex(filename, ".")+1:]
}
// GetFormattedFilesize returns a human readable filesize
func GetFormattedFilesize(size float64) string {
if size < 1000 {
return fmt.Sprintf("%dB", int(size))
} else if size <= 100000 {
return fmt.Sprintf("%fKB", size/1024)
} else if size <= 100000000 {
return fmt.Sprintf("%fMB", size/1024.0/1024.0)
}
return fmt.Sprintf("%0.2fGB", size/1024.0/1024.0/1024.0)
}
// GetRealIP checks the HTTP_CF_CONNCTING_IP and X-Forwarded-For HTTP headers to get a
// potentially obfuscated IP address, before getting the requests reported remote address
// if neither header is set
func GetRealIP(request *http.Request) string {
if request.Header.Get("HTTP_CF_CONNECTING_IP") != "" {
return request.Header.Get("HTTP_CF_CONNECTING_IP")
}
if request.Header.Get("X-Forwarded-For") != "" {
return request.Header.Get("X-Forwarded-For")
}
remoteHost, _, err := net.SplitHostPort(request.RemoteAddr)
if err != nil {
return request.RemoteAddr
}
return remoteHost
}
// GetThumbnailPath returns the thumbnail path of the given filename
func GetThumbnailPath(thumbType string, img string) string {
filetype := strings.ToLower(img[strings.LastIndex(img, ".")+1:])
if filetype == "gif" || filetype == "webm" {
filetype = "jpg"
}
index := strings.LastIndex(img, ".")
if index < 0 || index > len(img) {
return ""
}
thumbSuffix := "t." + filetype
if thumbType == "catalog" {
thumbSuffix = "c." + filetype
}
return img[0:index] + thumbSuffix
}
// HackyStringToInt parses a string to an int, or 0 if error
func HackyStringToInt(text string) int {
value, _ := strconv.Atoi(text)
return value
}
// MarshalJSON creates a JSON string with the given data and returns the string and any errors
func MarshalJSON(data interface{}, indent bool) (string, error) {
var jsonBytes []byte
var err error
if indent {
jsonBytes, err = json.MarshalIndent(data, "", " ")
} else {
jsonBytes, err = json.Marshal(data)
}
if err != nil {
jsonBytes, _ = json.Marshal(map[string]string{"error": err.Error()})
}
return string(jsonBytes), err
}
// ParseDurationString parses the given string into a duration and returns any errors
// based on TinyBoard's parse_time function
func ParseDurationString(str string) (time.Duration, error) {
if str == "" {
return 0, ErrEmptyDurationString
}
matches := durationRegexp.FindAllStringSubmatch(str, -1)
if len(matches) == 0 {
return 0, ErrInvalidDurationString
}
var expire int
if matches[0][2] != "" {
years, _ := strconv.Atoi(matches[0][2])
expire += years * 60 * 60 * 24 * 365
}
if matches[0][4] != "" {
months, _ := strconv.Atoi(matches[0][4])
expire += months * 60 * 60 * 24 * 30
}
if matches[0][6] != "" {
weeks, _ := strconv.Atoi(matches[0][6])
expire += weeks * 60 * 60 * 24 * 7
}
if matches[0][8] != "" {
days, _ := strconv.Atoi(matches[0][8])
expire += days * 60 * 60 * 24
}
if matches[0][10] != "" {
hours, _ := strconv.Atoi(matches[0][10])
expire += hours * 60 * 60
}
if matches[0][12] != "" {
minutes, _ := strconv.Atoi(matches[0][12])
expire += minutes * 60
}
if matches[0][14] != "" {
seconds, _ := strconv.Atoi(matches[0][14])
expire += seconds
}
return time.ParseDuration(strconv.Itoa(expire) + "s")
}
// ParseName takes a name string from a request object and returns the name and tripcode parts
func ParseName(name string) map[string]string {
parsed := make(map[string]string)
if !strings.Contains(name, "#") {
parsed["name"] = name
parsed["tripcode"] = ""
} else if strings.Index(name, "#") == 0 {
parsed["tripcode"] = tripcode.Tripcode(name[1:])
} else if strings.Index(name, "#") > 0 {
postNameArr := strings.SplitN(name, "#", 2)
parsed["name"] = postNameArr[0]
parsed["tripcode"] = tripcode.Tripcode(postNameArr[1])
}
return parsed
}
// RandomString returns a randomly generated string of the given length
func RandomString(length int) string {
var str string
for i := 0; i < length; i++ {
num := rand.Intn(127)
if num < 32 {
num += 32
}
str += string(num)
}
return str
}

818
pkg/manage/funcs.go Normal file
View file

@ -0,0 +1,818 @@
package manage
import (
"bytes"
"encoding/json"
"fmt"
"html"
"io/ioutil"
"net"
"net/http"
"os"
"path"
"regexp"
"strconv"
"strings"
"time"
"github.com/gochan-org/gochan/pkg/building"
"github.com/gochan-org/gochan/pkg/config"
"github.com/gochan-org/gochan/pkg/gclog"
"github.com/gochan-org/gochan/pkg/gcsql"
"github.com/gochan-org/gochan/pkg/gctemplates"
"github.com/gochan-org/gochan/pkg/gcutil"
"github.com/gochan-org/gochan/pkg/posting"
"github.com/gochan-org/gochan/pkg/serverutil"
)
var (
chopPortNumRegex = regexp.MustCompile(`(.+|\w+):(\d+)$`)
)
// ManageFunction represents the functions accessed by staff members at /manage?action=<functionname>.
type ManageFunction struct {
Title string
Permissions int // 0 -> non-staff, 1 => janitor, 2 => moderator, 3 => administrator
Callback func(writer http.ResponseWriter, request *http.Request) string `json:"-"` //return string of html output
}
var manageFunctions = map[string]ManageFunction{
"cleanup": {
Title: "Cleanup",
Permissions: 3,
Callback: func(writer http.ResponseWriter, request *http.Request) (html string) {
html = `<h2 class="manage-header">Cleanup</h2><br />`
var err error
if request.FormValue("run") == "Run Cleanup" {
html += "Removing deleted posts from the database.<hr />"
if err = gcsql.PermanentlyRemoveDeletedPosts(); err != nil {
return html + "<tr><td>" +
gclog.Print(gclog.LErrorLog, "Error removing deleted posts from database: ", err.Error()) +
"</td></tr></table>"
}
// TODO: remove orphaned replies and uploads
html += "Optimizing all tables in database.<hr />"
err = gcsql.OptimizeDatabase()
if err != nil {
return html + "<tr><td>" +
gclog.Print(gclog.LErrorLog, "Error optimizing SQL tables: ", err.Error()) +
"</td></tr></table>"
}
html += "Cleanup finished"
} else {
html += `<form action="/manage?action=cleanup" method="post">` +
`<input name="run" id="run" type="submit" value="Run Cleanup" />` +
`</form>`
}
return
}},
"config": {
Title: "Configuration",
Permissions: 3,
Callback: func(writer http.ResponseWriter, request *http.Request) (html string) {
do := request.FormValue("do")
var status string
if do == "save" {
configJSON, err := json.MarshalIndent(config.Config, "", "\t")
if err != nil {
status += gclog.Println(gclog.LErrorLog, err.Error()) + "<br />"
} else if err = ioutil.WriteFile("gochan.json", configJSON, 0777); err != nil {
status += gclog.Println(gclog.LErrorLog,
"Error backing up old gochan.json, cancelling save:", err.Error())
} else {
config.Config.Lockdown = (request.PostFormValue("Lockdown") == "on")
config.Config.LockdownMessage = request.PostFormValue("LockdownMessage")
SillytagsArr := strings.Split(request.PostFormValue("Sillytags"), "\n")
var Sillytags []string
for _, tag := range SillytagsArr {
Sillytags = append(Sillytags, strings.Trim(tag, " \n\r"))
}
config.Config.Sillytags = Sillytags
config.Config.UseSillytags = (request.PostFormValue("UseSillytags") == "on")
config.Config.Modboard = request.PostFormValue("Modboard")
config.Config.SiteName = request.PostFormValue("SiteName")
config.Config.SiteSlogan = request.PostFormValue("SiteSlogan")
config.Config.SiteHeaderURL = request.PostFormValue("SiteHeaderURL")
config.Config.SiteWebfolder = request.PostFormValue("SiteWebfolder")
// TODO: Change this to match the new Style type in gochan.json
/* Styles_arr := strings.Split(request.PostFormValue("Styles"), "\n")
var Styles []string
for _, style := range Styles_arr {
Styles = append(Styles, strings.Trim(style, " \n\r"))
}
config.Styles = Styles */
config.Config.DefaultStyle = request.PostFormValue("DefaultStyle")
config.Config.AllowDuplicateImages = (request.PostFormValue("AllowDuplicateImages") == "on")
config.Config.AllowVideoUploads = (request.PostFormValue("AllowVideoUploads") == "on")
NewThreadDelay, err := strconv.Atoi(request.PostFormValue("NewThreadDelay"))
if err != nil {
status += err.Error() + "<br />"
} else {
config.Config.NewThreadDelay = NewThreadDelay
}
ReplyDelay, err := strconv.Atoi(request.PostFormValue("ReplyDelay"))
if err != nil {
status += err.Error() + "<br />"
} else {
config.Config.ReplyDelay = ReplyDelay
}
MaxLineLength, err := strconv.Atoi(request.PostFormValue("MaxLineLength"))
if err != nil {
status += err.Error() + "<br />"
} else {
config.Config.MaxLineLength = MaxLineLength
}
ReservedTripsArr := strings.Split(request.PostFormValue("ReservedTrips"), "\n")
var ReservedTrips []string
for _, trip := range ReservedTripsArr {
ReservedTrips = append(ReservedTrips, strings.Trim(trip, " \n\r"))
}
config.Config.ReservedTrips = ReservedTrips
ThumbWidth, err := strconv.Atoi(request.PostFormValue("ThumbWidth"))
if err != nil {
status += err.Error() + "<br />"
} else {
config.Config.ThumbWidth = ThumbWidth
}
ThumbHeight, err := strconv.Atoi(request.PostFormValue("ThumbHeight"))
if err != nil {
status += err.Error() + "<br />"
} else {
config.Config.ThumbHeight = ThumbHeight
}
ThumbWidthReply, err := strconv.Atoi(request.PostFormValue("ThumbWidthReply"))
if err != nil {
status += err.Error() + "<br />"
} else {
config.Config.ThumbWidthReply = ThumbWidthReply
}
ThumbHeightReply, err := strconv.Atoi(request.PostFormValue("ThumbHeightReply"))
if err != nil {
status += err.Error() + "<br />"
} else {
config.Config.ThumbHeightReply = ThumbHeightReply
}
ThumbWidthCatalog, err := strconv.Atoi(request.PostFormValue("ThumbWidthCatalog"))
if err != nil {
status += err.Error() + "<br />"
} else {
config.Config.ThumbWidthCatalog = ThumbWidthCatalog
}
ThumbHeightCatalog, err := strconv.Atoi(request.PostFormValue("ThumbHeightCatalog"))
if err != nil {
status += err.Error() + "<br />"
} else {
config.Config.ThumbHeightCatalog = ThumbHeightCatalog
}
RepliesOnBoardPage, err := strconv.Atoi(request.PostFormValue("RepliesOnBoardPage"))
if err != nil {
status += err.Error() + "<br />"
} else {
config.Config.RepliesOnBoardPage = RepliesOnBoardPage
}
StickyRepliesOnBoardPage, err := strconv.Atoi(request.PostFormValue("StickyRepliesOnBoardPage"))
if err != nil {
status += err.Error() + "<br />"
} else {
config.Config.StickyRepliesOnBoardPage = StickyRepliesOnBoardPage
}
BanColorsArr := strings.Split(request.PostFormValue("BanColors"), "\n")
var BanColors []string
for _, color := range BanColorsArr {
BanColors = append(BanColors, strings.Trim(color, " \n\r"))
}
config.Config.BanColors = BanColors
config.Config.BanMsg = request.PostFormValue("BanMsg")
EmbedWidth, err := strconv.Atoi(request.PostFormValue("EmbedWidth"))
if err != nil {
status += err.Error() + "<br />"
} else {
config.Config.EmbedWidth = EmbedWidth
}
EmbedHeight, err := strconv.Atoi(request.PostFormValue("EmbedHeight"))
if err != nil {
status += err.Error() + "<br />"
} else {
config.Config.EmbedHeight = EmbedHeight
}
config.Config.ExpandButton = (request.PostFormValue("ExpandButton") == "on")
config.Config.ImagesOpenNewTab = (request.PostFormValue("ImagesOpenNewTab") == "on")
config.Config.MakeURLsHyperlinked = (request.PostFormValue("MakeURLsHyperlinked") == "on")
config.Config.NewTabOnOutlinks = (request.PostFormValue("NewTabOnOutlinks") == "on")
config.Config.MinifyHTML = (request.PostFormValue("MinifyHTML") == "on")
config.Config.MinifyJS = (request.PostFormValue("MinifyJS") == "on")
config.Config.DateTimeFormat = request.PostFormValue("DateTimeFormat")
AkismetAPIKey := request.PostFormValue("AkismetAPIKey")
if err = serverutil.CheckAkismetAPIKey(AkismetAPIKey); err != nil {
status += err.Error() + "<br />"
} else {
config.Config.AkismetAPIKey = AkismetAPIKey
}
config.Config.UseCaptcha = (request.PostFormValue("UseCaptcha") == "on")
CaptchaWidth, err := strconv.Atoi(request.PostFormValue("CaptchaWidth"))
if err != nil {
status += err.Error() + "<br />"
} else {
config.Config.CaptchaWidth = CaptchaWidth
}
CaptchaHeight, err := strconv.Atoi(request.PostFormValue("CaptchaHeight"))
if err != nil {
status += err.Error() + "<br />"
} else {
config.Config.CaptchaHeight = CaptchaHeight
}
config.Config.EnableGeoIP = (request.PostFormValue("EnableGeoIP") == "on")
config.Config.GeoIPDBlocation = request.PostFormValue("GeoIPDBlocation")
MaxRecentPosts, err := strconv.Atoi(request.PostFormValue("MaxRecentPosts"))
if err != nil {
status += err.Error() + "<br />"
} else {
config.Config.MaxRecentPosts = MaxRecentPosts
}
config.Config.EnableAppeals = (request.PostFormValue("EnableAppeals") == "on")
MaxLogDays, err := strconv.Atoi(request.PostFormValue("MaxLogDays"))
if err != nil {
status += err.Error() + "<br />"
} else {
config.Config.MaxLogDays = MaxLogDays
}
configJSON, err = json.MarshalIndent(config.Config, "", "\t")
if err != nil {
status += err.Error() + "<br />"
} else if err = ioutil.WriteFile("gochan.json", configJSON, 0777); err != nil {
status = gclog.Print(gclog.LErrorLog, "Error writing gochan.json: ", err.Error())
} else {
status = "Wrote gochan.json successfully<br />"
building.BuildJS()
}
}
}
manageConfigBuffer := bytes.NewBufferString("")
if err := gctemplates.ManageConfig.Execute(manageConfigBuffer,
map[string]interface{}{"config": config.Config, "status": status},
); err != nil {
return html + gclog.Print(gclog.LErrorLog, "Error executing config management page: ", err.Error())
}
html += manageConfigBuffer.String()
return
}},
"login": {
Title: "Login",
Permissions: 0,
Callback: func(writer http.ResponseWriter, request *http.Request) (html string) {
if GetStaffRank(request) > 0 {
http.Redirect(writer, request, path.Join(config.Config.SiteWebfolder, "manage"), http.StatusFound)
}
username := request.FormValue("username")
password := request.FormValue("password")
redirectAction := request.FormValue("action")
if redirectAction == "" {
redirectAction = "announcements"
}
if username == "" || password == "" {
//assume that they haven't logged in
html = `<form method="POST" action="` + config.Config.SiteWebfolder + `manage?action=login" id="login-box" class="staff-form">` +
`<input type="hidden" name="redirect" value="` + redirectAction + `" />` +
`<input type="text" name="username" class="logindata" /><br />` +
`<input type="password" name="password" class="logindata" /><br />` +
`<input type="submit" value="Login" />` +
`</form>`
} else {
key := gcutil.Md5Sum(request.RemoteAddr + username + password + config.Config.RandomSeed + gcutil.RandomString(3))[0:10]
createSession(key, username, password, request, writer)
http.Redirect(writer, request, path.Join(config.Config.SiteWebfolder, "manage?action="+request.FormValue("redirect")), http.StatusFound)
}
return
}},
"logout": {
Title: "Logout",
Permissions: 1,
Callback: func(writer http.ResponseWriter, request *http.Request) (html string) {
cookie, _ := request.Cookie("sessiondata")
cookie.MaxAge = 0
cookie.Expires = time.Now().Add(-7 * 24 * time.Hour)
http.SetCookie(writer, cookie)
return "Logged out successfully"
}},
"announcements": {
Title: "Announcements",
Permissions: 1,
Callback: func(writer http.ResponseWriter, request *http.Request) (html string) {
html = `<h1 class="manage-header">Announcements</h1><br />`
//get all announcements to announcement list
//loop to html if exist, no announcement if empty
announcements, err := gcsql.GetAllAccouncements()
if err != nil {
return html + gclog.Print(gclog.LErrorLog, "Error getting announcements: ", err.Error())
}
if len(announcements) == 0 {
html += "No announcements"
} else {
for _, announcement := range announcements {
html += `<div class="section-block">` +
`<div class="section-title-block"><b>` + announcement.Subject + `</b> by ` + announcement.Poster + ` at ` + announcement.Timestamp.Format(config.Config.DateTimeFormat) + `</div>` +
`<div class="section-body">` + announcement.Message + `</div></div>`
}
}
return html
}},
"bans": {
Title: "Bans",
Permissions: 1,
Callback: func(writer http.ResponseWriter, request *http.Request) (pageHTML string) { //TODO whatever this does idk man
var post gcsql.Post
if request.FormValue("do") == "add" {
ip := net.ParseIP(request.FormValue("ip"))
name := request.FormValue("name")
nameIsRegex := (request.FormValue("nameregex") == "on")
checksum := request.FormValue("checksum")
filename := request.FormValue("filename")
durationForm := request.FormValue("duration")
permaban := (durationForm == "" || durationForm == "0" || durationForm == "forever")
duration, err := gcutil.ParseDurationString(durationForm)
if err != nil {
serverutil.ServeErrorPage(writer, err.Error())
}
expires := time.Now().Add(duration)
boards := request.FormValue("boards")
reason := html.EscapeString(request.FormValue("reason"))
staffNote := html.EscapeString(request.FormValue("staffnote"))
currentStaff, _ := getCurrentStaff(request)
err = nil
if filename != "" {
err = gcsql.FileNameBan(filename, nameIsRegex, currentStaff, expires, permaban, staffNote, boards)
}
if err != nil {
pageHTML += err.Error()
err = nil
}
if name != "" {
err = gcsql.UserNameBan(name, nameIsRegex, currentStaff, expires, permaban, staffNote, boards)
}
if err != nil {
pageHTML += err.Error()
err = nil
}
if request.FormValue("fullban") == "on" {
err = gcsql.UserBan(ip, false, currentStaff, boards, expires, permaban, staffNote, reason, true, time.Now())
if err != nil {
pageHTML += err.Error()
err = nil
}
} else {
if request.FormValue("threadban") == "on" {
err = gcsql.UserBan(ip, true, currentStaff, boards, expires, permaban, staffNote, reason, true, time.Now())
if err != nil {
pageHTML += err.Error()
err = nil
}
}
if request.FormValue("imageban") == "on" {
err = gcsql.FileBan(checksum, currentStaff, expires, permaban, staffNote, boards)
if err != nil {
pageHTML += err.Error()
err = nil
}
}
}
}
if request.FormValue("postid") != "" {
var err error
post, err = gcsql.GetSpecificPostByString(request.FormValue("postid"))
if err != nil {
return pageHTML + gclog.Print(gclog.LErrorLog, "Error getting post: ", err.Error())
}
}
banlist, err := gcsql.GetAllBans()
if err != nil {
return pageHTML + gclog.Print(gclog.LErrorLog, "Error getting ban list: ", err.Error())
}
manageBansBuffer := bytes.NewBufferString("")
if err := gctemplates.ManageBans.Execute(manageBansBuffer,
map[string]interface{}{"config": config.Config, "banlist": banlist, "post": post},
); err != nil {
return pageHTML + gclog.Print(gclog.LErrorLog, "Error executing ban management page template: ", err.Error())
}
pageHTML += manageBansBuffer.String()
return
}},
"getstaffjquery": {
Permissions: 0,
Callback: func(writer http.ResponseWriter, request *http.Request) (html string) {
staff, err := getCurrentFullStaff(request)
if err != nil {
html = "nobody;0;"
return
}
html = staff.Username + ";" + strconv.Itoa(staff.Rank) + ";" + staff.Boards
return
}},
"boards": {
Title: "Boards",
Permissions: 3,
Callback: func(writer http.ResponseWriter, request *http.Request) (html string) {
do := request.FormValue("do")
var done bool
board := new(gcsql.Board)
var boardCreationStatus string
var err error
for !done {
switch {
case do == "add":
board.Dir = request.FormValue("dir")
if board.Dir == "" {
boardCreationStatus = `Error: "Directory" cannot be blank`
do = ""
continue
}
orderStr := request.FormValue("order")
board.ListOrder, err = strconv.Atoi(orderStr)
if err != nil {
board.ListOrder = 0
}
board.Title = request.FormValue("title")
if board.Title == "" {
boardCreationStatus = `Error: "Title" cannot be blank`
do = ""
continue
}
board.Subtitle = request.FormValue("subtitle")
board.Description = request.FormValue("description")
sectionStr := request.FormValue("section")
if sectionStr == "none" {
sectionStr = "0"
}
board.CreatedOn = time.Now()
board.Section, err = strconv.Atoi(sectionStr)
if err != nil {
board.Section = 0
}
board.MaxFilesize, err = strconv.Atoi(request.FormValue("maximagesize"))
if err != nil {
board.MaxFilesize = 1024 * 4
}
board.MaxPages, err = strconv.Atoi(request.FormValue("maxpages"))
if err != nil {
board.MaxPages = 11
}
board.DefaultStyle = strings.Trim(request.FormValue("defaultstyle"), "\n")
board.Locked = (request.FormValue("locked") == "on")
board.ForcedAnon = (request.FormValue("forcedanon") == "on")
board.Anonymous = request.FormValue("anonymous")
if board.Anonymous == "" {
board.Anonymous = "Anonymous"
}
board.MaxAge, err = strconv.Atoi(request.FormValue("maxage"))
if err != nil {
board.MaxAge = 0
}
board.AutosageAfter, err = strconv.Atoi(request.FormValue("autosageafter"))
if err != nil {
board.AutosageAfter = 200
}
board.NoImagesAfter, err = strconv.Atoi(request.FormValue("noimagesafter"))
if err != nil {
board.NoImagesAfter = 0
}
board.MaxMessageLength, err = strconv.Atoi(request.FormValue("maxmessagelength"))
if err != nil {
board.MaxMessageLength = 1024 * 8
}
board.EmbedsAllowed = (request.FormValue("embedsallowed") == "on")
board.RedirectToThread = (request.FormValue("redirecttothread") == "on")
board.RequireFile = (request.FormValue("require_file") == "on")
board.EnableCatalog = (request.FormValue("enablecatalog") == "on")
//actually start generating stuff
if err = os.Mkdir(path.Join(config.Config.DocumentRoot, board.Dir), 0666); err != nil {
do = ""
boardCreationStatus = gclog.Printf(gclog.LStaffLog|gclog.LErrorLog, "Directory %s/%s/ already exists.",
config.Config.DocumentRoot, board.Dir)
break
}
if err = os.Mkdir(path.Join(config.Config.DocumentRoot, board.Dir, "res"), 0666); err != nil {
do = ""
boardCreationStatus = gclog.Printf(gclog.LStaffLog|gclog.LErrorLog, "Directory %s/%s/res/ already exists.",
config.Config.DocumentRoot, board.Dir)
break
}
if err = os.Mkdir(path.Join(config.Config.DocumentRoot, board.Dir, "src"), 0666); err != nil {
do = ""
boardCreationStatus = gclog.Printf(gclog.LStaffLog|gclog.LErrorLog, "Directory %s/%s/src/ already exists.",
config.Config.DocumentRoot, board.Dir)
break
}
if err = os.Mkdir(path.Join(config.Config.DocumentRoot, board.Dir, "thumb"), 0666); err != nil {
do = ""
boardCreationStatus = gclog.Printf(gclog.LStaffLog|gclog.LErrorLog, "Directory %s/%s/thumb/ already exists.",
config.Config.DocumentRoot, board.Dir)
break
}
if err := gcsql.CreateBoard(board); err != nil {
do = ""
boardCreationStatus = gclog.Print(gclog.LErrorLog, "Error creating board: ", err.Error())
break
} else {
boardCreationStatus = "Board created successfully"
building.BuildBoards()
gcsql.ResetBoardSectionArrays()
gclog.Print(gclog.LStaffLog, "Boards rebuilt successfully")
done = true
}
case do == "del":
// resetBoardSectionArrays()
case do == "edit":
// resetBoardSectionArrays()
default:
// put the default column values in the text boxes
board.Section = 1
board.MaxFilesize = 4718592
board.MaxPages = 11
board.DefaultStyle = "pipes.css"
board.Anonymous = "Anonymous"
board.AutosageAfter = 200
board.MaxMessageLength = 8192
board.EmbedsAllowed = true
board.EnableCatalog = true
board.Worksafe = true
board.ThreadsPerPage = config.Config.ThreadsPerPage
}
html = `<h1 class="manage-header">Manage boards</h1><form action="/manage?action=boards" method="POST"><input type="hidden" name="do" value="existing" /><select name="boardselect"><option>Select board...</option>`
boards, err := gcsql.GetBoardUris()
if err != nil {
return html + gclog.Print(gclog.LErrorLog, "Error getting board list: ", err.Error())
}
for _, boardDir := range boards {
html += "<option>" + boardDir + "</option>"
}
html += `</select><input type="submit" value="Edit" /><input type="submit" value="Delete" /></form><hr />` +
`<h2 class="manage-header">Create new board</h2><span id="board-creation-message">` + boardCreationStatus + `</span><br />`
manageBoardsBuffer := bytes.NewBufferString("")
gcsql.AllSections, _ = gcsql.GetAllSectionsOrCreateDefault()
if err := gctemplates.ManageBoards.Execute(manageBoardsBuffer, map[string]interface{}{
"config": config.Config,
"board": board,
"section_arr": gcsql.AllSections,
}); err != nil {
return html + gclog.Print(gclog.LErrorLog,
"Error executing board management page template: ", err.Error())
}
html += manageBoardsBuffer.String()
return
}
gcsql.ResetBoardSectionArrays()
return
}},
"staffmenu": {
Title: "Staff menu",
Permissions: 1,
Callback: func(writer http.ResponseWriter, request *http.Request) (html string) {
rank := GetStaffRank(request)
html = `<a href="javascript:void(0)" id="logout" class="staffmenu-item">Log out</a><br />` +
`<a href="javascript:void(0)" id="announcements" class="staffmenu-item">Announcements</a><br />`
if rank == 3 {
html += `<b>Admin stuff</b><br /><a href="javascript:void(0)" id="staff" class="staffmenu-item">Manage staff</a><br />` +
//`<a href="javascript:void(0)" id="purgeeverything" class="staffmenu-item">Purge everything!</a><br />` +
`<a href="javascript:void(0)" id="executesql" class="staffmenu-item">Execute SQL statement(s)</a><br />` +
`<a href="javascript:void(0)" id="cleanup" class="staffmenu-item">Run cleanup</a><br />` +
`<a href="javascript:void(0)" id="rebuildall" class="staffmenu-item">Rebuild all</a><br />` +
`<a href="javascript:void(0)" id="rebuildfront" class="staffmenu-item">Rebuild front page</a><br />` +
`<a href="javascript:void(0)" id="rebuildboards" class="staffmenu-item">Rebuild board pages</a><br />` +
`<a href="javascript:void(0)" id="reparsehtml" class="staffmenu-item">Reparse all posts</a><br />` +
`<a href="javascript:void(0)" id="boards" class="staffmenu-item">Add/edit/delete boards</a><br />`
}
if rank >= 2 {
html += `<b>Mod stuff</b><br />` +
`<a href="javascript:void(0)" id="bans" class="staffmenu-item">Ban User(s)</a><br />`
}
if rank >= 1 {
html += `<a href="javascript:void(0)" id="recentimages" class="staffmenu-item">Recently uploaded images</a><br />` +
`<a href="javascript:void(0)" id="recentposts" class="staffmenu-item">Recent posts</a><br />` +
`<a href="javascript:void(0)" id="searchip" class="staffmenu-item">Search posts by IP</a><br />`
}
return
}},
"rebuildfront": {
Title: "Rebuild front page",
Permissions: 3,
Callback: func(writer http.ResponseWriter, request *http.Request) (html string) {
gctemplates.InitTemplates()
return building.BuildFrontPage()
}},
"rebuildall": {
Title: "Rebuild everything",
Permissions: 3,
Callback: func(writer http.ResponseWriter, request *http.Request) (html string) {
gctemplates.InitTemplates()
gcsql.ResetBoardSectionArrays()
return building.BuildFrontPage() + "<hr />" +
building.BuildBoardListJSON() + "<hr />" +
building.BuildBoards() + "<hr />" +
building.BuildJS() + "<hr />"
}},
"rebuildboards": {
Title: "Rebuild boards",
Permissions: 3,
Callback: func(writer http.ResponseWriter, request *http.Request) (html string) {
gctemplates.InitTemplates()
return building.BuildBoards()
}},
"reparsehtml": {
Title: "Reparse HTML",
Permissions: 3,
Callback: func(writer http.ResponseWriter, request *http.Request) (html string) {
messages, err := gcsql.GetAllNondeletedMessageRaw()
if err != nil {
html += err.Error() + "<br />"
return
}
for _, message := range messages {
message.Message = posting.FormatMessage(message.MessageRaw)
}
err = gcsql.SetMessages(messages)
if err != nil {
return html + gclog.Printf(gclog.LErrorLog, err.Error())
}
html += "Done reparsing HTML<hr />" +
building.BuildFrontPage() + "<hr />" +
building.BuildBoardListJSON() + "<hr />" +
building.BuildBoards() + "<hr />"
return
}},
"recentposts": {
Title: "Recent posts",
Permissions: 1,
Callback: func(writer http.ResponseWriter, request *http.Request) (html string) {
limit := request.FormValue("limit")
if limit == "" {
limit = "50"
}
html = `<h1 class="manage-header">Recent posts</h1>` +
`Limit by: <select id="limit">` +
`<option>25</option><option>50</option><option>100</option><option>200</option>` +
`</select><br /><table width="100%%d" border="1">` +
`<colgroup><col width="25%%" /><col width="50%%" /><col width="17%%" /></colgroup>` +
`<tr><th></th><th>Message</th><th>Time</th></tr>`
recentposts, err := gcsql.GetRecentPostsGlobal(gcutil.HackyStringToInt(limit), false) //only uses boardname, boardid, postid, parentid, message, ip and timestamp
if err != nil {
return html + "<tr><td>" + gclog.Print(gclog.LErrorLog, "Error getting recent posts: ",
err.Error()) + "</td></tr></table>"
}
for _, recentpost := range recentposts {
html += fmt.Sprintf(
`<tr><td><b>Post:</b> <a href="%s">%s/%d</a><br /><b>IP:</b> %s</td><td>%s</td><td>%s</td></tr>`,
path.Join(config.Config.SiteWebfolder, recentpost.BoardName, "/res/", strconv.Itoa(recentpost.ParentID)+".html#"+strconv.Itoa(recentpost.PostID)),
recentpost.BoardName, recentpost.PostID, recentpost.IP, recentpost.Message,
recentpost.Timestamp.Format("01/02/06, 15:04"),
)
}
html += "</table>"
return
}},
"postinfo": {
Title: "Post info",
Permissions: 2,
Callback: func(writer http.ResponseWriter, request *http.Request) (html string) {
errMap := map[string]interface{}{
"action": "postInfo",
"success": false,
}
post, err := gcsql.GetSpecificPost(gcutil.HackyStringToInt(request.FormValue("postid")), false)
if err != nil {
errMap["message"] = err.Error()
jsonErr, _ := gcutil.MarshalJSON(errMap, false)
return jsonErr
}
jsonStr, _ := gcutil.MarshalJSON(post, false)
return jsonStr
}},
"staff": {
Title: "Staff",
Permissions: 3,
Callback: func(writer http.ResponseWriter, request *http.Request) (html string) {
do := request.FormValue("do")
html = `<h1 class="manage-header">Staff</h1><br />` +
`<table id="stafftable" border="1">` +
"<tr><td><b>Username</b></td><td><b>Rank</b></td><td><b>Boards</b></td><td><b>Added on</b></td><td><b>Action</b></td></tr>"
allStaff, err := gcsql.GetAllStaffNopass()
if err != nil {
return html + gclog.Print(gclog.LErrorLog, "Error getting staff list: ", err.Error())
}
for _, staff := range allStaff {
username := request.FormValue("username")
password := request.FormValue("password")
rank := request.FormValue("rank")
rankI, _ := strconv.Atoi(rank)
if do == "add" {
if err := gcsql.NewStaff(username, password, rankI); err != nil {
serverutil.ServeErrorPage(writer, gclog.Printf(gclog.LErrorLog,
"Error creating new staff account %q: %s", username, err.Error()))
return
}
} else if do == "del" && username != "" {
if err = gcsql.DeleteStaff(request.FormValue("username")); err != nil {
serverutil.ServeErrorPage(writer, gclog.Printf(gclog.LErrorLog,
"Error deleting staff account %q : %s", username, err.Error()))
return
}
}
switch {
case staff.Rank == 3:
rank = "admin"
case staff.Rank == 2:
rank = "mod"
case staff.Rank == 1:
rank = "janitor"
}
html += fmt.Sprintf(
`<tr><td>%s</td><td>%s</td><td>%s</td><td>%s</td><td><a href="/manage?action=staff&amp;do=del&amp;username=%s" style="float:right;color:red;">X</a></td></tr>`,
staff.Username, rank, staff.Boards, staff.AddedOn.Format(config.Config.DateTimeFormat), staff.Username)
}
html += `</table><hr /><h2 class="manage-header">Add new staff</h2>` +
`<form action="/manage?action=staff" onsubmit="return makeNewStaff();" method="POST">` +
`<input type="hidden" name="do" value="add" />` +
`Username: <input id="username" name="username" type="text" /><br />` +
`Password: <input id="password" name="password" type="password" /><br />` +
`Rank: <select id="rank" name="rank">` +
`<option value="3">Admin</option>` +
`<option value="2">Moderator</option>` +
`<option value="1">Janitor</option>` +
`</select><br />` +
`<input id="submitnewstaff" type="submit" value="Add" />` +
`</form>`
return
}},
"tempposts": {
Title: "Temporary posts lists",
Permissions: 3,
Callback: func(writer http.ResponseWriter, request *http.Request) (html string) {
html += `<h1 class="manage-header">Temporary posts</h1>`
if len(gcsql.TempPosts) == 0 {
html += "No temporary posts<br />"
return
}
for p, post := range gcsql.TempPosts {
html += fmt.Sprintf("Post[%d]: %#v<br />", p, post)
}
return
}},
}

59
pkg/manage/handler.go Normal file
View file

@ -0,0 +1,59 @@
package manage
import (
"bytes"
"net/http"
"github.com/gochan-org/gochan/pkg/config"
"github.com/gochan-org/gochan/pkg/gclog"
"github.com/gochan-org/gochan/pkg/gctemplates"
"github.com/gochan-org/gochan/pkg/serverutil"
)
// CallManageFunction is called when a user accesses /manage to use manage tools
// or log in to a staff account
func CallManageFunction(writer http.ResponseWriter, request *http.Request) {
var err error
if err = request.ParseForm(); err != nil {
serverutil.ServeErrorPage(writer, gclog.Print(gclog.LErrorLog,
"Error parsing form data: ", err.Error()))
}
action := request.FormValue("action")
staffRank := GetStaffRank(request)
var managePageBuffer bytes.Buffer
if action == "" {
action = "announcements"
} else if action == "postinfo" {
writer.Header().Add("Content-Type", "application/json")
writer.Header().Add("Cache-Control", "max-age=5, must-revalidate")
}
if action != "getstaffjquery" && action != "postinfo" {
managePageBuffer.WriteString("<!DOCTYPE html><html><head>")
if err = gctemplates.ManageHeader.Execute(&managePageBuffer, config.Config); err != nil {
serverutil.ServeErrorPage(writer, gclog.Print(gclog.LErrorLog|gclog.LStaffLog,
"Error executing manage page header template: ", err.Error()))
return
}
}
if _, ok := manageFunctions[action]; ok {
if staffRank >= manageFunctions[action].Permissions {
managePageBuffer.Write([]byte(manageFunctions[action].Callback(writer, request)))
} else if staffRank == 0 && manageFunctions[action].Permissions == 0 {
managePageBuffer.Write([]byte(manageFunctions[action].Callback(writer, request)))
} else if staffRank == 0 {
managePageBuffer.Write([]byte(manageFunctions["login"].Callback(writer, request)))
} else {
managePageBuffer.Write([]byte(action + " is undefined."))
}
} else {
managePageBuffer.Write([]byte(action + " is undefined."))
}
if action != "getstaffjquery" && action != "postinfo" {
managePageBuffer.Write([]byte("</body></html>"))
}
writer.Write(managePageBuffer.Bytes())
}

88
pkg/manage/util.go Normal file
View file

@ -0,0 +1,88 @@
package manage
import (
"net/http"
"time"
"github.com/gochan-org/gochan/pkg/gclog"
"github.com/gochan-org/gochan/pkg/gcsql"
"github.com/gochan-org/gochan/pkg/serverutil"
"golang.org/x/crypto/bcrypt"
)
const (
_ = iota
sSuccess
sInvalidPassword
sOtherError
)
func createSession(key string, username string, password string, request *http.Request, writer http.ResponseWriter) int {
//returns 0 for successful, 1 for password mismatch, and 2 for other
domain := request.Host
var err error
domain = chopPortNumRegex.Split(domain, -1)[0]
if !serverutil.ValidReferer(request) {
gclog.Print(gclog.LStaffLog, "Rejected login from possible spambot @ "+request.RemoteAddr)
return 2
}
staff, err := gcsql.GetStaffByName(username)
if err != nil {
gclog.Print(gclog.LErrorLog, err.Error())
return 1
}
success := bcrypt.CompareHashAndPassword([]byte(staff.PasswordChecksum), []byte(password))
if success == bcrypt.ErrMismatchedHashAndPassword {
// password mismatch
gclog.Print(gclog.LStaffLog, "Failed login (password mismatch) from "+request.RemoteAddr+" at "+time.Now().Format(gcsql.MySQLDatetimeFormat))
return 1
}
// successful login, add cookie that expires in one month
http.SetCookie(writer, &http.Cookie{
Name: "sessiondata",
Value: key,
Path: "/",
Domain: domain,
MaxAge: 60 * 60 * 24 * 7,
})
if err = gcsql.CreateSession(key, username); err != nil {
gclog.Print(gclog.LErrorLog, "Error creating new staff session: ", err.Error())
return 2
}
return 0
}
func getCurrentStaff(request *http.Request) (string, error) { //TODO after refactor, check if still used
sessionCookie, err := request.Cookie("sessiondata")
if err != nil {
return "", err
}
name, err := gcsql.GetStaffName(sessionCookie.Value)
if err == nil {
return "", err
}
return name, nil
}
func getCurrentFullStaff(request *http.Request) (*gcsql.Staff, error) {
sessionCookie, err := request.Cookie("sessiondata")
if err != nil {
return nil, err
}
return gcsql.GetStaffBySession(sessionCookie.Value)
}
// GetStaffRank returns the rank number of the staff referenced in the request
func GetStaffRank(request *http.Request) int {
staff, err := getCurrentFullStaff(request)
if err != nil {
gclog.Print(gclog.LErrorLog, "Error getting current staff: ", err.Error())
return 0
}
return staff.Rank
}

88
pkg/posting/bans.go Normal file
View file

@ -0,0 +1,88 @@
package posting
import (
"crypto/md5"
"database/sql"
"fmt"
"html"
"io/ioutil"
"net/http"
"github.com/gochan-org/gochan/pkg/config"
"github.com/gochan-org/gochan/pkg/gclog"
"github.com/gochan-org/gochan/pkg/gcsql"
"github.com/gochan-org/gochan/pkg/gctemplates"
"github.com/gochan-org/gochan/pkg/gcutil"
"github.com/gochan-org/gochan/pkg/serverutil"
)
const (
_ = iota
ThreadBan
ImageBan
FullBan
)
// BanHandler is used for serving ban pages
func BanHandler(writer http.ResponseWriter, request *http.Request) {
appealMsg := request.FormValue("appealmsg")
// banStatus, err := getBannedStatus(request)
var banStatus gcsql.BanInfo
var err error
if appealMsg != "" {
if banStatus.BannedForever() {
fmt.Fprint(writer, "No.")
return
}
escapedMsg := html.EscapeString(appealMsg)
if err = gcsql.AddBanAppeal(banStatus.ID, escapedMsg); err != nil {
serverutil.ServeErrorPage(writer, err.Error())
}
fmt.Fprint(writer,
"Appeal sent. It will (hopefully) be read by a staff member. check "+config.Config.SiteWebfolder+"banned occasionally for a response",
)
return
}
if err != nil && err != sql.ErrNoRows {
serverutil.ServeErrorPage(writer, gclog.Print(gclog.LErrorLog,
"Error getting banned status:", err.Error()))
return
}
if err = gcutil.MinifyTemplate(gctemplates.Banpage, map[string]interface{}{
"config": config.Config, "ban": banStatus, "banBoards": banStatus.Boards, "post": gcsql.Post{},
}, writer, "text/html"); err != nil {
serverutil.ServeErrorPage(writer, gclog.Print(gclog.LErrorLog,
"Error minifying page template: ", err.Error()))
return
}
}
// Checks check poster's name/tripcode/file checksum (from Post post) for banned status
// returns ban table if the user is banned or sql.ErrNoRows if they aren't
func getBannedStatus(request *http.Request) (*gcsql.BanInfo, error) {
formName := request.FormValue("postname")
var tripcode string
if formName != "" {
parsedName := gcutil.ParseName(formName)
tripcode += parsedName["name"]
if tc, ok := parsedName["tripcode"]; ok {
tripcode += "!" + tc
}
}
ip := gcutil.GetRealIP(request)
var filename string
var checksum string
file, fileHandler, err := request.FormFile("imagefile")
if err == nil {
html.EscapeString(fileHandler.Filename)
if data, err2 := ioutil.ReadAll(file); err2 == nil {
checksum = fmt.Sprintf("%x", md5.Sum(data))
}
}
file.Close()
return gcsql.CheckBan(ip, tripcode, filename, checksum)
}

View file

@ -1,4 +1,4 @@
package main
package posting
import (
"fmt"
@ -6,8 +6,14 @@ import (
"net/http"
"strconv"
//"github.com/mojocn/base64Captcha"
"gopkg.in/mojocn/base64Captcha.v1"
"github.com/gochan-org/gochan/pkg/building"
"github.com/gochan-org/gochan/pkg/config"
"github.com/gochan-org/gochan/pkg/gclog"
"github.com/gochan-org/gochan/pkg/gcsql"
"github.com/gochan-org/gochan/pkg/gctemplates"
"github.com/gochan-org/gochan/pkg/gcutil"
"github.com/gochan-org/gochan/pkg/serverutil"
"github.com/mojocn/base64Captcha"
)
var (
@ -23,23 +29,25 @@ type captchaJSON struct {
EmailCmd string `json:"-"`
}
func initCaptcha() {
if !config.UseCaptcha {
// InitCaptcha prepares the captcha driver for use
func InitCaptcha() {
if !config.Config.UseCaptcha {
return
}
driver = base64Captcha.NewDriverString(
config.CaptchaHeight, config.CaptchaWidth, 0, 0, 6,
config.Config.CaptchaHeight, config.Config.CaptchaWidth, 0, 0, 6,
"0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ",
&color.RGBA{0, 0, 0, 0}, nil).ConvertFonts()
}
func serveCaptcha(writer http.ResponseWriter, request *http.Request) {
if !config.UseCaptcha {
// ServeCaptcha handles requests to /captcha if UseCaptcha is enabled in gochan.json
func ServeCaptcha(writer http.ResponseWriter, request *http.Request) {
if !config.Config.UseCaptcha {
return
}
var err error
if err = request.ParseForm(); err != nil {
serveErrorPage(writer, gclog.Print(lErrorLog, "Error parsing request form: ", err.Error()))
serverutil.ServeErrorPage(writer, gclog.Print(gclog.LErrorLog, "Error parsing request form: ", err.Error()))
return
}
@ -56,14 +64,15 @@ func serveCaptcha(writer http.ResponseWriter, request *http.Request) {
useJSON := request.FormValue("json") == "1"
if useJSON {
writer.Header().Add("Content-Type", "application/json")
str, _ := marshalJSON(captchaStruct, false)
minifyWriter(writer, []byte(str), "application/json")
str, _ := gcutil.MarshalJSON(captchaStruct, false)
gcutil.MinifyWriter(writer, []byte(str), "application/json")
return
}
if request.FormValue("reload") == "Reload" {
request.Form.Del("reload")
request.Form.Add("didreload", "1")
serveCaptcha(writer, request)
ServeCaptcha(writer, request)
return
}
writer.Header().Add("Content-Type", "text/html")
@ -72,18 +81,20 @@ func serveCaptcha(writer http.ResponseWriter, request *http.Request) {
if captchaID != "" && request.FormValue("didreload") != "1" {
goodAnswer := base64Captcha.DefaultMemStore.Verify(captchaID, captchaAnswer, true)
if goodAnswer {
if tempPostIndex > -1 && tempPostIndex < len(tempPosts) {
if tempPostIndex > -1 && tempPostIndex < len(gcsql.TempPosts) {
// came from a /post redirect, insert the specified temporary post
// and redirect to the thread
InsertPost(&tempPosts[tempPostIndex], emailCommand == "noko")
buildBoards(tempPosts[tempPostIndex].BoardID)
buildFrontPage()
url := tempPosts[tempPostIndex].GetURL(false)
gcsql.InsertPost(&gcsql.TempPosts[tempPostIndex], emailCommand == "noko")
building.BuildBoards(gcsql.TempPosts[tempPostIndex].BoardID)
building.BuildFrontPage()
url := gcsql.TempPosts[tempPostIndex].GetURL(false)
// move the end Post to the current index and remove the old end Post. We don't
// really care about order as long as tempPost validation doesn't get jumbled up
tempPosts[tempPostIndex] = tempPosts[len(tempPosts)-1]
tempPosts = tempPosts[:len(tempPosts)-1]
gcsql.TempPosts[tempPostIndex] = gcsql.TempPosts[len(gcsql.TempPosts)-1]
gcsql.TempPosts = gcsql.TempPosts[:len(gcsql.TempPosts)-1]
http.Redirect(writer, request, url, http.StatusFound)
return
}
@ -91,15 +102,14 @@ func serveCaptcha(writer http.ResponseWriter, request *http.Request) {
captchaStruct.Result = "Incorrect CAPTCHA"
}
}
if err = minifyTemplate(captchaTmpl, captchaStruct, writer, "text/html"); err != nil {
if err = gcutil.MinifyTemplate(gctemplates.Captcha, captchaStruct, writer, "text/html"); err != nil {
fmt.Fprintf(writer,
gclog.Print(lErrorLog, "Error executing captcha template: ", err.Error()),
)
gclog.Print(gclog.LErrorLog, "Error executing captcha template: ", err.Error()))
}
}
func getCaptchaImage() (captchaID string, chaptchaB64 string) {
if !config.UseCaptcha {
if !config.Config.UseCaptcha {
return
}
captcha := base64Captcha.NewCaptcha(driver, base64Captcha.DefaultMemStore)

98
pkg/posting/formatting.go Normal file
View file

@ -0,0 +1,98 @@
package posting
import (
"strconv"
"strings"
"time"
"github.com/frustra/bbcode"
"github.com/gochan-org/gochan/pkg/config"
"github.com/gochan-org/gochan/pkg/gclog"
"github.com/gochan-org/gochan/pkg/gcsql"
)
var (
msgfmtr *MessageFormatter
)
// InitPosting prepares the formatter and the temp post pruner
func InitPosting() {
msgfmtr = new(MessageFormatter)
msgfmtr.InitBBcode()
tempCleanerTicker = time.NewTicker(time.Minute * 5)
go tempCleaner()
}
type MessageFormatter struct {
// Go's garbage collection does weird things with bbcode's internal tag map.
// Moving the bbcode compiler isntance (and eventually a Markdown compiler) to a struct
// appears to fix this
bbCompiler bbcode.Compiler
}
func (mf *MessageFormatter) InitBBcode() {
if config.Config.DisableBBcode {
return
}
mf.bbCompiler = bbcode.NewCompiler(true, true)
mf.bbCompiler.SetTag("center", nil)
mf.bbCompiler.SetTag("code", nil)
mf.bbCompiler.SetTag("color", nil)
mf.bbCompiler.SetTag("img", nil)
mf.bbCompiler.SetTag("quote", nil)
mf.bbCompiler.SetTag("size", nil)
}
func (mf *MessageFormatter) Compile(msg string) string {
if config.Config.DisableBBcode {
return msg
}
return mf.bbCompiler.Compile(msg)
}
func FormatMessage(message string) string {
message = msgfmtr.Compile(message)
// prepare each line to be formatted
postLines := strings.Split(message, "<br>")
for i, line := range postLines {
trimmedLine := strings.TrimSpace(line)
lineWords := strings.Split(trimmedLine, " ")
isGreentext := false // if true, append </span> to end of line
for w, word := range lineWords {
if strings.LastIndex(word, "&gt;&gt;") == 0 {
//word is a backlink
if postID, err := strconv.Atoi(word[8:]); err == nil {
// the link is in fact, a valid int
var boardDir string
var linkParent int
if boardDir, err = gcsql.GetBoardFromPostID(postID); err != nil {
gclog.Print(gclog.LErrorLog, "Error getting board dir for backlink: ", err.Error())
}
if linkParent, err = gcsql.GetThreadIDZeroIfTopPost(postID); err != nil {
gclog.Print(gclog.LErrorLog, "Error getting post parent for backlink: ", err.Error())
}
// get post board dir
if boardDir == "" {
lineWords[w] = `<a href="javascript:;"><strike>` + word + `</strike></a>`
} else if linkParent == 0 {
lineWords[w] = `<a href="` + config.Config.SiteWebfolder + boardDir + `/res/` + word[8:] + `.html" class="postref">` + word + `</a>`
} else {
lineWords[w] = `<a href="` + config.Config.SiteWebfolder + boardDir + `/res/` + strconv.Itoa(linkParent) + `.html#` + word[8:] + `" class="postref">` + word + `</a>`
}
}
} else if strings.Index(word, "&gt;") == 0 && w == 0 {
// word is at the beginning of a line, and is greentext
isGreentext = true
lineWords[w] = "<span class=\"greentext\">" + word
}
}
line = strings.Join(lineWords, " ")
if isGreentext {
line += "</span>"
}
postLines[i] = line
}
return strings.Join(postLines, "<br />")
}

396
pkg/posting/post.go Normal file
View file

@ -0,0 +1,396 @@
package posting
import (
"bytes"
"crypto/md5"
"database/sql"
"fmt"
"html"
"image"
"io/ioutil"
"net/http"
"net/url"
"os"
"os/exec"
"path"
"strconv"
"strings"
"syscall"
"time"
"github.com/disintegration/imaging"
"github.com/gochan-org/gochan/pkg/building"
"github.com/gochan-org/gochan/pkg/config"
"github.com/gochan-org/gochan/pkg/gclog"
"github.com/gochan-org/gochan/pkg/gcsql"
"github.com/gochan-org/gochan/pkg/gctemplates"
"github.com/gochan-org/gochan/pkg/gcutil"
"github.com/gochan-org/gochan/pkg/serverutil"
)
const (
yearInSeconds = 31536000
errStdLogs = gclog.LErrorLog | gclog.LStdLog
)
// MakePost is called when a user accesses /post. Parse form data, then insert and build
func MakePost(writer http.ResponseWriter, request *http.Request) {
var maxMessageLength int
var post gcsql.Post
// domain := request.Host
var formName string
var nameCookie string
var formEmail string
if request.Method == "GET" {
http.Redirect(writer, request, config.Config.SiteWebfolder, http.StatusFound)
return
}
// fix new cookie domain for when you use a port number
// domain = chopPortNumRegex.Split(domain, -1)[0]
post.ParentID, _ = strconv.Atoi(request.FormValue("threadid"))
post.BoardID, _ = strconv.Atoi(request.FormValue("boardid"))
var emailCommand string
formName = request.FormValue("postname")
parsedName := gcutil.ParseName(formName)
post.Name = parsedName["name"]
post.Tripcode = parsedName["tripcode"]
formEmail = request.FormValue("postemail")
http.SetCookie(writer, &http.Cookie{Name: "email", Value: formEmail, MaxAge: yearInSeconds})
if !strings.Contains(formEmail, "noko") && !strings.Contains(formEmail, "sage") {
post.Email = formEmail
} else if strings.Index(formEmail, "#") > 1 {
formEmailArr := strings.SplitN(formEmail, "#", 2)
post.Email = formEmailArr[0]
emailCommand = formEmailArr[1]
} else if formEmail == "noko" || formEmail == "sage" {
emailCommand = formEmail
post.Email = ""
}
post.Subject = request.FormValue("postsubject")
post.MessageText = strings.Trim(request.FormValue("postmsg"), "\r\n")
var err error
if maxMessageLength, err = gcsql.GetMaxMessageLength(post.BoardID); err != nil {
serverutil.ServeErrorPage(writer, gclog.Print(gclog.LErrorLog,
"Error getting board info: ", err.Error()))
}
if len(post.MessageText) > maxMessageLength {
serverutil.ServeErrorPage(writer, "Post body is too long")
return
}
post.MessageHTML = FormatMessage(post.MessageText)
password := request.FormValue("postpassword")
if password == "" {
password = gcutil.RandomString(8)
}
post.Password = gcutil.Md5Sum(password)
// Reverse escapes
nameCookie = strings.Replace(formName, "&amp;", "&", -1)
nameCookie = strings.Replace(nameCookie, "\\&#39;", "'", -1)
nameCookie = strings.Replace(url.QueryEscape(nameCookie), "+", "%20", -1)
// add name and email cookies that will expire in a year (31536000 seconds)
http.SetCookie(writer, &http.Cookie{Name: "name", Value: nameCookie, MaxAge: yearInSeconds})
http.SetCookie(writer, &http.Cookie{Name: "password", Value: password, MaxAge: yearInSeconds})
post.IP = gcutil.GetRealIP(request)
post.Timestamp = time.Now()
// post.PosterAuthority = getStaffRank(request)
post.Bumped = time.Now()
post.Stickied = request.FormValue("modstickied") == "on"
post.Locked = request.FormValue("modlocked") == "on"
//post has no referrer, or has a referrer from a different domain, probably a spambot
if !serverutil.ValidReferer(request) {
gclog.Print(gclog.LAccessLog, "Rejected post from possible spambot @ "+post.IP)
return
}
switch serverutil.CheckPostForSpam(post.IP, request.Header["User-Agent"][0], request.Referer(),
post.Name, post.Email, post.MessageText) {
case "discard":
serverutil.ServeErrorPage(writer, "Your post looks like spam.")
gclog.Print(gclog.LAccessLog, "Akismet recommended discarding post from: "+post.IP)
return
case "spam":
serverutil.ServeErrorPage(writer, "Your post looks like spam.")
gclog.Print(gclog.LAccessLog, "Akismet suggested post is spam from "+post.IP)
return
default:
}
file, handler, err := request.FormFile("imagefile")
if err != nil || handler.Size == 0 {
// no file was uploaded
post.Filename = ""
gclog.Printf(gclog.LAccessLog,
"Receiving post from %s, referred from: %s", post.IP, request.Referer())
} else {
data, err := ioutil.ReadAll(file)
if err != nil {
serverutil.ServeErrorPage(writer,
gclog.Print(gclog.LErrorLog, "Error while trying to read file: ", err.Error()))
return
}
defer file.Close()
post.FilenameOriginal = html.EscapeString(handler.Filename)
filetype := gcutil.GetFileExtension(post.FilenameOriginal)
thumbFiletype := strings.ToLower(filetype)
if thumbFiletype == "gif" || thumbFiletype == "webm" {
thumbFiletype = "jpg"
}
post.Filename = getNewFilename() + "." + gcutil.GetFileExtension(post.FilenameOriginal)
boardExists, err := gcsql.DoesBoardExistByID(
gcutil.HackyStringToInt(request.FormValue("boardid")))
if err != nil {
serverutil.ServeErrorPage(writer, "Server error: "+err.Error())
return
}
if !boardExists {
serverutil.ServeErrorPage(writer, "No boards have been created yet")
return
}
var _board = gcsql.Board{}
err = _board.PopulateData(gcutil.HackyStringToInt(request.FormValue("boardid")))
if err != nil {
serverutil.ServeErrorPage(writer, "Server error: "+err.Error())
return
}
boardDir := _board.Dir
filePath := path.Join(config.Config.DocumentRoot, "/"+boardDir+"/src/", post.Filename)
thumbPath := path.Join(config.Config.DocumentRoot, "/"+boardDir+"/thumb/", strings.Replace(post.Filename, "."+filetype, "t."+thumbFiletype, -1))
catalogThumbPath := path.Join(config.Config.DocumentRoot, "/"+boardDir+"/thumb/", strings.Replace(post.Filename, "."+filetype, "c."+thumbFiletype, -1))
if err = ioutil.WriteFile(filePath, data, 0777); err != nil {
gclog.Printf(gclog.LErrorLog, "Couldn't write file %q: %s", post.Filename, err.Error())
serverutil.ServeErrorPage(writer, `Couldn't write file "`+post.FilenameOriginal+`"`)
return
}
// Calculate image checksum
post.FileChecksum = fmt.Sprintf("%x", md5.Sum(data))
var allowsVids bool
if allowsVids, err = gcsql.GetEmbedsAllowed(post.BoardID); err != nil {
serverutil.ServeErrorPage(writer, gclog.Print(gclog.LErrorLog,
"Couldn't get board info: ", err.Error()))
return
}
if filetype == "webm" {
if !allowsVids || !config.Config.AllowVideoUploads {
serverutil.ServeErrorPage(writer, gclog.Print(gclog.LAccessLog,
"Video uploading is not currently enabled for this board."))
os.Remove(filePath)
return
}
gclog.Printf(gclog.LAccessLog, "Receiving post with video: %s from %s, referrer: %s",
handler.Filename, post.IP, request.Referer())
if post.ParentID == 0 {
if err := createVideoThumbnail(filePath, thumbPath, config.Config.ThumbWidth); err != nil {
serverutil.ServeErrorPage(writer, gclog.Print(gclog.LErrorLog,
"Error creating video thumbnail: ", err.Error()))
return
}
} else {
if err := createVideoThumbnail(filePath, thumbPath, config.Config.ThumbWidthReply); err != nil {
serverutil.ServeErrorPage(writer, gclog.Print(gclog.LErrorLog,
"Error creating video thumbnail: ", err.Error()))
return
}
}
if err := createVideoThumbnail(filePath, catalogThumbPath, config.Config.ThumbWidthCatalog); err != nil {
serverutil.ServeErrorPage(writer, gclog.Print(gclog.LErrorLog,
"Error creating video thumbnail: ", err.Error()))
return
}
outputBytes, err := exec.Command("ffprobe", "-v", "quiet", "-show_format", "-show_streams", filePath).CombinedOutput()
if err != nil {
serverutil.ServeErrorPage(writer, gclog.Print(gclog.LErrorLog,
"Error getting video info: ", err.Error()))
return
}
if outputBytes != nil {
outputStringArr := strings.Split(string(outputBytes), "\n")
for _, line := range outputStringArr {
lineArr := strings.Split(line, "=")
if len(lineArr) < 2 {
continue
}
value, _ := strconv.Atoi(lineArr[1])
switch lineArr[0] {
case "width":
post.ImageW = value
case "height":
post.ImageH = value
case "size":
post.Filesize = value
}
}
if post.ParentID == 0 {
post.ThumbW, post.ThumbH = getThumbnailSize(post.ImageW, post.ImageH, "op")
} else {
post.ThumbW, post.ThumbH = getThumbnailSize(post.ImageW, post.ImageH, "reply")
}
}
} else {
// Attempt to load uploaded file with imaging library
img, err := imaging.Open(filePath)
if err != nil {
os.Remove(filePath)
gclog.Printf(gclog.LErrorLog, "Couldn't open uploaded file %q: %s", post.Filename, err.Error())
serverutil.ServeErrorPage(writer, "Upload filetype not supported")
return
}
// Get image filesize
stat, err := os.Stat(filePath)
if err != nil {
serverutil.ServeErrorPage(writer, gclog.Print(gclog.LErrorLog,
"Couldn't get image filesize: "+err.Error()))
return
}
post.Filesize = int(stat.Size())
// Get image width and height, as well as thumbnail width and height
post.ImageW = img.Bounds().Max.X
post.ImageH = img.Bounds().Max.Y
if post.ParentID == 0 {
post.ThumbW, post.ThumbH = getThumbnailSize(post.ImageW, post.ImageH, "op")
} else {
post.ThumbW, post.ThumbH = getThumbnailSize(post.ImageW, post.ImageH, "reply")
}
gclog.Printf(gclog.LAccessLog, "Receiving post with image: %q from %s, referrer: %s",
handler.Filename, post.IP, request.Referer())
if request.FormValue("spoiler") == "on" {
// If spoiler is enabled, symlink thumbnail to spoiler image
if _, err := os.Stat(path.Join(config.Config.DocumentRoot, "spoiler.png")); err != nil {
serverutil.ServeErrorPage(writer, "missing /spoiler.png")
return
}
if err = syscall.Symlink(path.Join(config.Config.DocumentRoot, "spoiler.png"), thumbPath); err != nil {
serverutil.ServeErrorPage(writer, err.Error())
return
}
} else if config.Config.ThumbWidth >= post.ImageW && config.Config.ThumbHeight >= post.ImageH {
// If image fits in thumbnail size, symlink thumbnail to original
post.ThumbW = img.Bounds().Max.X
post.ThumbH = img.Bounds().Max.Y
if err := syscall.Symlink(filePath, thumbPath); err != nil {
serverutil.ServeErrorPage(writer, err.Error())
return
}
} else {
var thumbnail image.Image
var catalogThumbnail image.Image
if post.ParentID == 0 {
// If this is a new thread, generate thumbnail and catalog thumbnail
thumbnail = createImageThumbnail(img, "op")
catalogThumbnail = createImageThumbnail(img, "catalog")
if err = imaging.Save(catalogThumbnail, catalogThumbPath); err != nil {
serverutil.ServeErrorPage(writer, gclog.Print(gclog.LErrorLog,
"Couldn't generate catalog thumbnail: ", err.Error()))
return
}
} else {
thumbnail = createImageThumbnail(img, "reply")
}
if err = imaging.Save(thumbnail, thumbPath); err != nil {
serverutil.ServeErrorPage(writer, gclog.Print(gclog.LErrorLog,
"Couldn't save thumbnail: ", err.Error()))
return
}
}
}
}
if strings.TrimSpace(post.MessageText) == "" && post.Filename == "" {
serverutil.ServeErrorPage(writer, "Post must contain a message if no image is uploaded.")
return
}
postDelay := gcsql.SinceLastPost(&post)
if postDelay > -1 {
if post.ParentID == 0 && postDelay < config.Config.NewThreadDelay {
serverutil.ServeErrorPage(writer, "Please wait before making a new thread.")
return
} else if post.ParentID > 0 && postDelay < config.Config.ReplyDelay {
serverutil.ServeErrorPage(writer, "Please wait before making a reply.")
return
}
}
banStatus, err := getBannedStatus(request)
if err != nil && err != sql.ErrNoRows {
serverutil.ServeErrorPage(writer, gclog.Print(gclog.LErrorLog,
"Error getting banned status: ", err.Error()))
return
}
boards, _ := gcsql.GetAllBoards()
postBoard, _ := gcsql.GetBoardFromID(post.BoardID)
if banStatus.IsBanned(postBoard.Dir) {
var banpageBuffer bytes.Buffer
if err = gcutil.MinifyTemplate(gctemplates.Banpage, map[string]interface{}{
"config": config.Config, "ban": banStatus, "banBoards": boards[post.BoardID-1].Dir,
}, writer, "text/html"); err != nil {
serverutil.ServeErrorPage(writer,
gclog.Print(gclog.LErrorLog, "Error minifying page: ", err.Error()))
return
}
writer.Write(banpageBuffer.Bytes())
return
}
post.Sanitize()
if config.Config.UseCaptcha {
captchaID := request.FormValue("captchaid")
captchaAnswer := request.FormValue("captchaanswer")
if captchaID == "" && captchaAnswer == "" {
// browser isn't using JS, save post data to tempPosts and show captcha
request.Form.Add("temppostindex", strconv.Itoa(len(gcsql.TempPosts)))
request.Form.Add("emailcmd", emailCommand)
gcsql.TempPosts = append(gcsql.TempPosts, post)
ServeCaptcha(writer, request)
return
}
}
if err = gcsql.InsertPost(&post, emailCommand != "sage"); err != nil {
serverutil.ServeErrorPage(writer,
gclog.Print(gclog.LErrorLog, "Error inserting post: ", err.Error()))
return
}
// rebuild the board page
building.BuildBoards(post.BoardID)
building.BuildFrontPage()
if emailCommand == "noko" {
if post.ParentID < 1 {
http.Redirect(writer, request, config.Config.SiteWebfolder+postBoard.Dir+"/res/"+strconv.Itoa(post.ID)+".html", http.StatusFound)
} else {
http.Redirect(writer, request, config.Config.SiteWebfolder+postBoard.Dir+"/res/"+strconv.Itoa(post.ParentID)+".html#"+strconv.Itoa(post.ID), http.StatusFound)
}
} else {
http.Redirect(writer, request, config.Config.SiteWebfolder+postBoard.Dir+"/", http.StatusFound)
}
}

58
pkg/posting/tmpcleaner.go Normal file
View file

@ -0,0 +1,58 @@
package posting
import (
"os"
"path"
"time"
"github.com/gochan-org/gochan/pkg/config"
"github.com/gochan-org/gochan/pkg/gclog"
"github.com/gochan-org/gochan/pkg/gcsql"
"github.com/gochan-org/gochan/pkg/gcutil"
)
var tempCleanerTicker *time.Ticker
func tempCleaner() {
for {
select {
case <-tempCleanerTicker.C:
for p, post := range gcsql.TempPosts {
if !time.Now().After(post.Timestamp.Add(time.Minute * 5)) {
continue
}
// temporary post is >= 5 minutes, time to prune it
gcsql.TempPosts[p] = gcsql.TempPosts[len(gcsql.TempPosts)-1]
gcsql.TempPosts = gcsql.TempPosts[:len(gcsql.TempPosts)-1]
if post.FilenameOriginal == "" {
continue
}
var board gcsql.Board
err := board.PopulateData(post.BoardID)
if err != nil {
continue
}
fileSrc := path.Join(config.Config.DocumentRoot, board.Dir, "src", post.FilenameOriginal)
if err = os.Remove(fileSrc); err != nil {
gclog.Printf(errStdLogs,
"Error pruning temporary upload for %q: %s", fileSrc, err.Error())
}
thumbSrc := gcutil.GetThumbnailPath("thread", fileSrc)
if err = os.Remove(thumbSrc); err != nil {
gclog.Printf(errStdLogs,
"Error pruning temporary upload for %q: %s", thumbSrc, err.Error())
}
if post.ParentID == 0 {
catalogSrc := gcutil.GetThumbnailPath("catalog", fileSrc)
if err = os.Remove(catalogSrc); err != nil {
gclog.Printf(errStdLogs,
"Error pruning temporary upload for %s: %s", catalogSrc, err.Error())
}
}
}
}
}
}

111
pkg/posting/uploads.go Normal file
View file

@ -0,0 +1,111 @@
package posting
import (
"errors"
"image"
"math/rand"
"os/exec"
"strconv"
"strings"
"time"
"github.com/disintegration/imaging"
"github.com/gochan-org/gochan/pkg/config"
)
func createImageThumbnail(imageObj image.Image, size string) image.Image {
var thumbWidth int
var thumbHeight int
switch size {
case "op":
thumbWidth = config.Config.ThumbWidth
thumbHeight = config.Config.ThumbHeight
case "reply":
thumbWidth = config.Config.ThumbWidthReply
thumbHeight = config.Config.ThumbHeightReply
case "catalog":
thumbWidth = config.Config.ThumbWidthCatalog
thumbHeight = config.Config.ThumbHeightCatalog
}
oldRect := imageObj.Bounds()
if thumbWidth >= oldRect.Max.X && thumbHeight >= oldRect.Max.Y {
return imageObj
}
thumbW, thumbH := getThumbnailSize(oldRect.Max.X, oldRect.Max.Y, size)
imageObj = imaging.Resize(imageObj, thumbW, thumbH, imaging.CatmullRom) // resize to 600x400 px using CatmullRom cubic filter
return imageObj
}
func createVideoThumbnail(video, thumb string, size int) error {
sizeStr := strconv.Itoa(size)
outputBytes, err := exec.Command("ffmpeg", "-y", "-itsoffset", "-1", "-i", video, "-vframes", "1", "-filter:v", "scale='min("+sizeStr+"\\, "+sizeStr+"):-1'", thumb).CombinedOutput()
if err != nil {
outputStringArr := strings.Split(string(outputBytes), "\n")
if len(outputStringArr) > 1 {
outputString := outputStringArr[len(outputStringArr)-2]
err = errors.New(outputString)
}
}
return err
}
func getVideoInfo(path string) (map[string]int, error) {
vidInfo := make(map[string]int)
outputBytes, err := exec.Command("ffprobe", "-v quiet", "-show_format", "-show_streams", path).CombinedOutput()
if err == nil && outputBytes != nil {
outputStringArr := strings.Split(string(outputBytes), "\n")
for _, line := range outputStringArr {
lineArr := strings.Split(line, "=")
if len(lineArr) < 2 {
continue
}
if lineArr[0] == "width" || lineArr[0] == "height" || lineArr[0] == "size" {
value, _ := strconv.Atoi(lineArr[1])
vidInfo[lineArr[0]] = value
}
}
}
return vidInfo, err
}
func getNewFilename() string {
now := time.Now().Unix()
rand.Seed(now)
return strconv.Itoa(int(now)) + strconv.Itoa(rand.Intn(98)+1)
}
// find out what out thumbnail's width and height should be, partially ripped from Kusaba X
func getThumbnailSize(w int, h int, size string) (newWidth int, newHeight int) {
var thumbWidth int
var thumbHeight int
switch {
case size == "op":
thumbWidth = config.Config.ThumbWidth
thumbHeight = config.Config.ThumbHeight
case size == "reply":
thumbWidth = config.Config.ThumbWidthReply
thumbHeight = config.Config.ThumbHeightReply
case size == "catalog":
thumbWidth = config.Config.ThumbWidthCatalog
thumbHeight = config.Config.ThumbHeightCatalog
}
if w == h {
newWidth = thumbWidth
newHeight = thumbHeight
} else {
var percent float32
if w > h {
percent = float32(thumbWidth) / float32(w)
} else {
percent = float32(thumbHeight) / float32(h)
}
newWidth = int(float32(w) * percent)
newHeight = int(float32(h) * percent)
}
return
}

View file

@ -0,0 +1,98 @@
package serverutil
import (
"errors"
"io/ioutil"
"net/http"
"net/url"
"strings"
"github.com/gochan-org/gochan/pkg/config"
"github.com/gochan-org/gochan/pkg/gclog"
)
// CheckAkismetAPIKey checks the validity of the Akismet API key given in the config file.
func CheckAkismetAPIKey(key string) error {
if key == "" {
return errors.New("blank key given, Akismet spam checking won't be used")
}
resp, err := http.PostForm("https://rest.akismet.com/1.1/verify-key", url.Values{"key": {key}, "blog": {"http://" + config.Config.SiteDomain}})
if err != nil {
return err
}
if resp.Body != nil {
defer resp.Body.Close()
}
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return err
}
if string(body) == "invalid" {
// This should disable the Akismet checks if the API key is not valid.
errmsg := "Akismet API key is invalid, Akismet spam protection will be disabled."
gclog.Print(gclog.LErrorLog, errmsg)
return errors.New(errmsg)
}
return nil
}
// CheckPostForSpam checks a given post for spam with Akismet. Only checks if Akismet API key is set.
func CheckPostForSpam(userIP string, userAgent string, referrer string,
author string, email string, postContent string) string {
if config.Config.AkismetAPIKey != "" {
client := &http.Client{}
data := url.Values{"blog": {"http://" + config.Config.SiteDomain}, "user_ip": {userIP}, "user_agent": {userAgent}, "referrer": {referrer},
"comment_type": {"forum-post"}, "comment_author": {author}, "comment_author_email": {email},
"comment_content": {postContent}}
req, err := http.NewRequest("POST", "https://"+config.Config.AkismetAPIKey+".rest.akismet.com/1.1/comment-check",
strings.NewReader(data.Encode()))
if err != nil {
gclog.Print(gclog.LErrorLog, err.Error())
return "other_failure"
}
req.Header.Set("User-Agent", "gochan/1.0 | Akismet/0.1")
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := client.Do(req)
if err != nil {
gclog.Print(gclog.LErrorLog, err.Error())
return "other_failure"
}
if resp.Body != nil {
resp.Body.Close()
}
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
gclog.Print(gclog.LErrorLog, err.Error())
return "other_failure"
}
gclog.Print(gclog.LErrorLog, "Response from Akismet: ", string(body))
if string(body) == "true" {
if proTip, ok := resp.Header["X-akismet-pro-tip"]; ok && proTip[0] == "discard" {
return "discard"
}
return "spam"
} else if string(body) == "invalid" {
return "invalid"
} else if string(body) == "false" {
return "ham"
}
}
return "other_failure"
}
// ValidReferer checks to make sure that the incoming request is from the same domain (or if debug mode is enabled)
func ValidReferer(request *http.Request) bool {
if config.Config.DebugMode {
return true
}
rURL, err := url.ParseRequestURI(request.Referer())
if err != nil {
gclog.Println(gclog.LAccessLog|gclog.LErrorLog, "Error parsing referer URL:", err.Error())
return false
}
return rURL.Host == config.Config.SiteDomain && strings.Index(rURL.Path, config.Config.SiteWebfolder) == 0
}

35
pkg/serverutil/util.go Normal file
View file

@ -0,0 +1,35 @@
package serverutil
import (
"io/ioutil"
"net/http"
"github.com/gochan-org/gochan/pkg/config"
"github.com/gochan-org/gochan/pkg/gclog"
"github.com/gochan-org/gochan/pkg/gctemplates"
"github.com/gochan-org/gochan/pkg/gcutil"
)
// ServeErrorPage shows a general error page if something goes wrong
func ServeErrorPage(writer http.ResponseWriter, err string) {
gcutil.MinifyTemplate(gctemplates.ErrorPage, map[string]interface{}{
"config": config.Config,
"ErrorTitle": "Error :c",
// "ErrorImage": "/error/lol 404.gif",
"ErrorHeader": "Error",
"ErrorText": err,
}, writer, "text/html")
}
// ServeNotFound shows an error page if a requested file is not found
func ServeNotFound(writer http.ResponseWriter, request *http.Request) {
writer.Header().Add("Content-Type", "text/html; charset=utf-8")
writer.WriteHeader(404)
errorPage, err := ioutil.ReadFile(config.Config.DocumentRoot + "/error/404.html")
if err != nil {
writer.Write([]byte("Requested page not found, and /error/404.html not found"))
} else {
gcutil.MinifyWriter(writer, errorPage, "text/html")
}
gclog.Printf(gclog.LAccessLog, "Error: 404 Not Found from %s @ %s", gcutil.GetRealIP(request), request.URL.Path)
}

40
refactorgcfs.sh Executable file
View file

@ -0,0 +1,40 @@
#!/usr/bin/env bash
set -eo pipefail
rootPkg="github.com/gochan-org/gochan"
pkgBase="pkg"
gopath=`go env GOPATH`
pkgDir=$gopath/pkg/linux_amd64/$rootPkg
if [ -z "$1" ]||[ "$1" == "--help" ]; then
cat - <<EOF
Usage:
$0 --all
Build/install all subpackages
$0 --clean
Removes the source and compiled package from the GOPATH
$0 [--help]
Show this message
$0 $pkgBase/path/to/subpkg
Compiles and installes the package to the GOPATH
EOF
elif [ "$1" == "--all" ]; then
for f in pkg/*; do
$0 $f
done
echo
find $pkgDir
elif [ "$1" == "--clean" ]; then
rm -rf ${pkgDir%/gochan}
else
target=${1#*$pkgBase/}
target=${target%/}
echo "Building/installing $target in $rootPkg/$pkgBase/$target"
go install $rootPkg/$pkgBase/$target
if [ "$2" == "-v" ]; then
find $pkgDir
fi
fi

View file

@ -30,7 +30,6 @@
"SiteDomain": "127.0.0.1",
"SiteHeaderURL": "",
"SiteWebfolder": "/",
"DomainRegex": "(https|http):\\/\\/(gochan\\.org)\\/(.*)",
"Styles": [
{ "Name": "Pipes", "Filename": "pipes.css" },

View file

@ -1,527 +0,0 @@
// functions for post, thread, board, and page building
package main
import (
"encoding/json"
"fmt"
"io"
"io/ioutil"
"os"
"path"
"strconv"
"syscall"
"text/template"
"github.com/tdewolff/minify"
minifyHTML "github.com/tdewolff/minify/html"
minifyJS "github.com/tdewolff/minify/js"
minifyJSON "github.com/tdewolff/minify/json"
)
var minifier *minify.M
func initMinifier() {
if !config.MinifyHTML && !config.MinifyJS {
return
}
minifier = minify.New()
if config.MinifyHTML {
minifier.AddFunc("text/html", minifyHTML.Minify)
}
if config.MinifyJS {
minifier.AddFunc("text/javascript", minifyJS.Minify)
minifier.AddFunc("application/json", minifyJSON.Minify)
}
}
func canMinify(mediaType string) bool {
return (mediaType == "text/html" && config.MinifyHTML) || ((mediaType == "application/json" || mediaType == "text/javascript") && config.MinifyJS)
}
func minifyTemplate(tmpl *template.Template, data interface{}, writer io.Writer, mediaType string) error {
if !canMinify(mediaType) {
return tmpl.Execute(writer, data)
}
minWriter := minifier.Writer(mediaType, writer)
defer closeHandle(minWriter)
return tmpl.Execute(minWriter, data)
}
func minifyWriter(writer io.Writer, data []byte, mediaType string) (int, error) {
if !canMinify(mediaType) {
return writer.Write(data)
}
minWriter := minifier.Writer(mediaType, writer)
defer closeHandle(minWriter)
return minWriter.Write(data)
}
// build front page using templates/front.html
func buildFrontPage() string {
err := initTemplates("front")
if err != nil {
return gclog.Print(lErrorLog, "Error loading front page template: ", err.Error())
}
os.Remove(path.Join(config.DocumentRoot, "index.html"))
frontFile, err := os.OpenFile(path.Join(config.DocumentRoot, "index.html"), os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0777)
defer closeHandle(frontFile)
if err != nil {
return gclog.Print(lErrorLog, "Failed opening front page for writing: ", err.Error()) + "<br />"
}
var recentPostsArr []RecentPost
recentPostsArr, err = GetRecentPostsGlobal(config.MaxRecentPosts, !config.RecentPostsWithNoFile)
if err == nil {
return gclog.Print(lErrorLog, "Failed loading recent posts: "+err.Error()) + "<br />"
}
for _, board := range allBoards {
if board.Section == 0 {
board.Section = 1
}
}
if err = minifyTemplate(frontPageTmpl, map[string]interface{}{
"config": config,
"sections": allSections,
"boards": allBoards,
"recent_posts": recentPostsArr,
}, frontFile, "text/html"); err != nil {
return gclog.Print(lErrorLog, "Failed executing front page template: "+err.Error()) + "<br />"
}
return "Front page rebuilt successfully."
}
func buildBoardListJSON() (html string) {
boardListFile, err := os.OpenFile(path.Join(config.DocumentRoot, "boards.json"), os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0777)
defer closeHandle(boardListFile)
if err != nil {
return gclog.Print(lErrorLog, "Failed opening boards.json for writing: ", err.Error()) + "<br />"
}
boardsMap := map[string][]Board{
"boards": []Board{},
}
// Our cooldowns are site-wide currently.
cooldowns := BoardCooldowns{NewThread: config.NewThreadDelay, Reply: config.ReplyDelay, ImageReply: config.ReplyDelay}
for _, board := range allBoards {
board.Cooldowns = cooldowns
boardsMap["boards"] = append(boardsMap["boards"], board)
}
boardJSON, err := json.Marshal(boardsMap)
if err != nil {
return gclog.Print(lErrorLog, "Failed to create boards.json: ", err.Error()) + "<br />"
}
if _, err = minifyWriter(boardListFile, boardJSON, "application/json"); err != nil {
return gclog.Print(lErrorLog, "Failed writing boards.json file: ", err.Error()) + "<br />"
}
return "Board list JSON rebuilt successfully.<br />"
}
// buildBoardPages builds the pages for the board archive.
// `board` is a Board object representing the board to build archive pages for.
// The return value is a string of HTML with debug information from the build process.
func buildBoardPages(board *Board) (html string) {
err := initTemplates("boardpage")
if err != nil {
return err.Error()
}
var currentPageFile *os.File
var threads []interface{}
var threadPages [][]interface{}
var stickiedThreads []interface{}
var nonStickiedThreads []interface{}
var opPosts []Post
// Get all top level posts for the board.
if opPosts, err = GetTopPosts(board.ID, true); err != nil {
return html + gclog.Printf(lErrorLog, "Error getting OP posts for /%s/: %s", board.Dir, err.Error()) + "<br />"
}
// For each top level post, start building a Thread struct
for _, op := range opPosts {
var thread Thread
var postsInThread []Post
var replyCount, err = GetReplyCount(op.ID)
if err == nil {
return html + gclog.Printf(lErrorLog,
"Error getting replies to /%s/%d: %s",
board.Dir, op.ID, err.Error()) + "<br />"
}
thread.NumReplies = replyCount
fileCount, err := GetReplyFileCount(op.ID)
if err == nil {
return html + gclog.Printf(lErrorLog,
"Error getting file count to /%s/%d: %s",
board.Dir, op.ID, err.Error()) + "<br />"
}
thread.NumImages = fileCount
thread.OP = op
var numRepliesOnBoardPage int
if op.Stickied {
// If the thread is stickied, limit replies on the archive page to the
// configured value for stickied threads.
numRepliesOnBoardPage = config.StickyRepliesOnBoardPage
} else {
// Otherwise, limit the replies to the configured value for normal threads.
numRepliesOnBoardPage = config.RepliesOnBoardPage
}
postsInThread, err = GetExistingRepliesLimitedRev(op.ID, numRepliesOnBoardPage)
if err != nil {
return html + gclog.Printf(lErrorLog,
"Error getting posts in /%s/%d: %s",
board.Dir, op.ID, err.Error()) + "<br />"
}
var reversedPosts []Post
for i := len(postsInThread); i > 0; i-- {
reversedPosts = append(reversedPosts, postsInThread[i-1])
}
if len(postsInThread) > 0 {
// Store the posts to show on board page
//thread.BoardReplies = postsInThread
thread.BoardReplies = reversedPosts
// Count number of images on board page
imageCount := 0
for _, reply := range postsInThread {
if reply.Filesize != 0 {
imageCount++
}
}
// Then calculate number of omitted images.
thread.OmittedImages = thread.NumImages - imageCount
}
// Add thread struct to appropriate list
if op.Stickied {
stickiedThreads = append(stickiedThreads, thread)
} else {
nonStickiedThreads = append(nonStickiedThreads, thread)
}
}
deleteMatchingFiles(path.Join(config.DocumentRoot, board.Dir), "\\d.html$")
// Order the threads, stickied threads first, then nonstickied threads.
threads = append(stickiedThreads, nonStickiedThreads...)
// If there are no posts on the board
if len(threads) == 0 {
board.CurrentPage = 1
// Open board.html for writing to the first page.
boardPageFile, err := os.OpenFile(path.Join(config.DocumentRoot, board.Dir, "board.html"), os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0777)
if err != nil {
return html + gclog.Printf(lErrorLog,
"Failed opening /%s/board.html: %s",
board.Dir, err.Error()) + "<br />"
}
// Render board page template to the file,
// packaging the board/section list, threads, and board info
if err = minifyTemplate(boardpageTmpl, map[string]interface{}{
"config": config,
"boards": allBoards,
"sections": allSections,
"threads": threads,
"board": board,
}, boardPageFile, "text/html"); err != nil {
return html + gclog.Printf(lErrorLog,
"Failed building /%s/: %s",
board.Dir, err.Error()) + "<br />"
}
html += "/" + board.Dir + "/ built successfully.\n"
return
}
// Create the archive pages.
threadPages = paginate(config.ThreadsPerPage, threads)
board.NumPages = len(threadPages)
// Create array of page wrapper objects, and open the file.
pagesArr := make([]map[string]interface{}, board.NumPages)
catalogJSONFile, err := os.OpenFile(path.Join(config.DocumentRoot, board.Dir, "catalog.json"), os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0777)
defer closeHandle(catalogJSONFile)
if err != nil {
return gclog.Printf(lErrorLog,
"Failed opening /%s/catalog.json: %s",
board.Dir, err.Error()) + "<br />"
}
currentBoardPage := board.CurrentPage
for _, pageThreads := range threadPages {
board.CurrentPage++
var currentPageFilepath string
pageFilename := strconv.Itoa(board.CurrentPage) + ".html"
currentPageFilepath = path.Join(config.DocumentRoot, board.Dir, pageFilename)
currentPageFile, err = os.OpenFile(currentPageFilepath, os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0777)
defer closeHandle(currentPageFile)
if err != nil {
html += gclog.Printf(lErrorLog,
"Failed opening /%s/%s: %s",
board.Dir, pageFilename, err.Error()) + "<br />"
continue
}
// Render the boardpage template
if err = minifyTemplate(boardpageTmpl, map[string]interface{}{
"config": config,
"boards": allBoards,
"sections": allSections,
"threads": pageThreads,
"board": board,
"posts": []interface{}{
Post{BoardID: board.ID},
},
}, currentPageFile, "text/html"); err != nil {
return html + gclog.Printf(lErrorLog,
"Failed building /%s/ boardpage: %s", board.Dir, err.Error()) + "<br />"
}
if board.CurrentPage == 1 {
boardPage := path.Join(config.DocumentRoot, board.Dir, "board.html")
os.Remove(boardPage)
if err = syscall.Symlink(currentPageFilepath, boardPage); !os.IsExist(err) && err != nil {
html += gclog.Printf(lErrorLog, "Failed building /%s/: %s",
board.Dir, err.Error())
}
}
// Collect up threads for this page.
pageMap := make(map[string]interface{})
pageMap["page"] = board.CurrentPage
pageMap["threads"] = pageThreads
pagesArr = append(pagesArr, pageMap)
}
board.CurrentPage = currentBoardPage
catalogJSON, err := json.Marshal(pagesArr)
if err != nil {
return html + gclog.Print(lErrorLog, "Failed to marshal to JSON: ", err.Error()) + "<br />"
}
if _, err = catalogJSONFile.Write(catalogJSON); err != nil {
return html + gclog.Printf(lErrorLog,
"Failed writing /%s/catalog.json: %s", board.Dir, err.Error()) + "<br />"
}
html += "/" + board.Dir + "/ built successfully."
return
}
// buildBoards builds the specified board IDs, or all boards if no arguments are passed
// The return value is a string of HTML with debug information produced by the build process.
func buildBoards(which ...int) (html string) {
var boards []Board
var err error
if which == nil {
boards = allBoards
} else {
for b, id := range which {
boards = append(boards, Board{})
if err = boards[b].PopulateData(id); err != nil {
return gclog.Printf(lErrorLog, "Error getting board information (ID: %d)", id)
}
}
}
if len(boards) == 0 {
return "No boards to build."
}
for _, board := range boards {
if err = board.Build(false, true); err != nil {
return gclog.Printf(lErrorLog,
"Error building /%s/: %s", board.Dir, err.Error()) + "<br />"
}
html += "Built /" + board.Dir + "/ successfully."
}
return
}
func buildJS() string {
// minify gochan.js (if enabled)
gochanMinJSPath := path.Join(config.DocumentRoot, "javascript", "gochan.min.js")
gochanMinJSFile, err := os.OpenFile(gochanMinJSPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
defer closeHandle(gochanMinJSFile)
if err != nil {
return gclog.Printf(lErrorLog, "Error opening %q for writing: %s",
gochanMinJSPath, err.Error()) + "<br />"
}
gochanJSPath := path.Join(config.DocumentRoot, "javascript", "gochan.js")
gochanJSBytes, err := ioutil.ReadFile(gochanJSPath)
if err != nil {
return gclog.Printf(lErrorLog, "Error opening %q for writing: %s",
gochanJSPath, err.Error()) + "<br />"
}
if _, err := minifyWriter(gochanMinJSFile, gochanJSBytes, "text/javascript"); err != nil {
config.UseMinifiedGochanJS = false
return gclog.Printf(lErrorLog, "Error minifying %q: %s:",
gochanMinJSPath, err.Error()) + "<br />"
}
config.UseMinifiedGochanJS = true
// build consts.js from template
if err = initTemplates("js"); err != nil {
return gclog.Print(lErrorLog, "Error loading consts.js template: ", err.Error())
}
constsJSPath := path.Join(config.DocumentRoot, "javascript", "consts.js")
constsJSFile, err := os.OpenFile(constsJSPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0644)
defer closeHandle(constsJSFile)
if err != nil {
return gclog.Printf(lErrorLog, "Error opening %q for writing: %s",
constsJSPath, err.Error()) + "<br />"
}
if err = minifyTemplate(jsTmpl, config, constsJSFile, "text/javascript"); err != nil {
return gclog.Printf(lErrorLog, "Error building %q: %s",
constsJSPath, err.Error()) + "<br />"
}
return "Built gochan.min.js and consts.js successfully.<br />"
}
func buildCatalog(which int) string {
err := initTemplates("catalog")
if err != nil {
return err.Error()
}
var board Board
if err = board.PopulateData(which); err != nil {
return gclog.Printf(lErrorLog, "Error getting board information (ID: %d)", which)
}
catalogPath := path.Join(config.DocumentRoot, board.Dir, "catalog.html")
catalogFile, err := os.OpenFile(catalogPath, os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0777)
if err != nil {
return gclog.Printf(lErrorLog,
"Failed opening /%s/catalog.html: %s", board.Dir, err.Error()) + "<br />"
}
threadOPs, err := GetTopPosts(which, false)
if err != nil {
return gclog.Printf(lErrorLog,
"Error building catalog for /%s/: %s", board.Dir, err.Error()) + "<br />"
}
var threadInterfaces []interface{}
for _, thread := range threadOPs {
threadInterfaces = append(threadInterfaces, thread)
}
if err = minifyTemplate(catalogTmpl, map[string]interface{}{
"boards": allBoards,
"config": config,
"board": board,
"sections": allSections,
}, catalogFile, "text/html"); err != nil {
return gclog.Printf(lErrorLog,
"Error building catalog for /%s/: %s", board.Dir, err.Error()) + "<br />"
}
return fmt.Sprintf("Built catalog for /%s/ successfully", board.Dir)
}
// buildThreadPages builds the pages for a thread given by a Post object.
func buildThreadPages(op *Post) error {
err := initTemplates("threadpage")
if err != nil {
return err
}
var replies []Post
var threadPageFile *os.File
var board Board
if err = board.PopulateData(op.BoardID); err != nil {
return err
}
replies, err = GetExistingReplies(op.ID)
if err != nil {
return fmt.Errorf("Error building thread %d: %s", op.ID, err.Error())
}
os.Remove(path.Join(config.DocumentRoot, board.Dir, "res", strconv.Itoa(op.ID)+".html"))
var repliesInterface []interface{}
for _, reply := range replies {
repliesInterface = append(repliesInterface, reply)
}
threadPageFilepath := path.Join(config.DocumentRoot, board.Dir, "res", strconv.Itoa(op.ID)+".html")
threadPageFile, err = os.OpenFile(threadPageFilepath, os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0777)
if err != nil {
return fmt.Errorf("Failed opening /%s/res/%d.html: %s", board.Dir, op.ID, err.Error())
}
// render thread page
if err = minifyTemplate(threadpageTmpl, map[string]interface{}{
"config": config,
"boards": allBoards,
"board": board,
"sections": allSections,
"posts": replies,
"op": op,
}, threadPageFile, "text/html"); err != nil {
return fmt.Errorf("Failed building /%s/res/%d threadpage: %s", board.Dir, op.ID, err.Error())
}
// Put together the thread JSON
threadJSONFile, err := os.OpenFile(path.Join(config.DocumentRoot, board.Dir, "res", strconv.Itoa(op.ID)+".json"), os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0777)
defer closeHandle(threadJSONFile)
if err != nil {
return fmt.Errorf("Failed opening /%s/res/%d.json: %s", board.Dir, op.ID, err.Error())
}
threadMap := make(map[string][]Post)
// Handle the OP, of type *Post
threadMap["posts"] = []Post{*op}
// Iterate through each reply, which are of type Post
threadMap["posts"] = append(threadMap["posts"], replies...)
threadJSON, err := json.Marshal(threadMap)
if err != nil {
return fmt.Errorf("Failed to marshal to JSON: %s", err.Error())
}
if _, err = threadJSONFile.Write(threadJSON); err != nil {
return fmt.Errorf("Failed writing /%s/res/%d.json: %s", board.Dir, op.ID, err.Error())
}
return nil
}
// buildThreads builds thread(s) given a boardid, or if all = false, also given a threadid.
// if all is set to true, ignore which, otherwise, which = build only specified boardid
// TODO: make it variadic
func buildThreads(all bool, boardid, threadid int) error {
var threads []Post
var err error
if all {
threads, err = GetTopPostsNoSort(boardid)
} else {
var post Post
post, err = GetSpecificTopPost(threadid)
threads = []Post{post}
}
if err != nil {
return err
}
for _, op := range threads {
if err = buildThreadPages(&op); err != nil {
return err
}
}
return nil
}

View file

@ -1,15 +0,0 @@
package main
import (
"fmt"
"testing"
)
func TestGetCaptchaImage(t *testing.T) {
config.UseCaptcha = true
config.CaptchaWidth = 240
config.CaptchaHeight = 80
initCaptcha()
captchaID, captchaB64 := getCaptchaImage()
fmt.Printf("captchaID: %s\ncaptchaB64: %s\n", captchaID, captchaB64)
}

View file

@ -1,85 +0,0 @@
package main
import (
"flag"
"fmt"
"os"
"os/signal"
"strings"
"syscall"
"time"
)
var versionStr string
var buildtimeString string // set with build command, format: YRMMDD.HHMM
func main() {
defer func() {
gclog.Print(lErrorLog|lStdLog, "Cleaning up")
execSQL("DROP TABLE DBPREFIXsessions")
closeHandle(db)
}()
initConfig()
initMinifier()
gclog.Printf(lErrorLog|lStdLog, "Starting gochan v%s", versionStr)
connectToSQLServer()
parseCommandLine()
gclog.Print(lErrorLog|lStdLog, "Loading and parsing templates")
if err := initTemplates("all"); err != nil {
gclog.Printf(lErrorLog|lStdLog|lFatal, err.Error())
}
buildJS()
initCaptcha()
tempCleanerTicker = time.NewTicker(time.Minute * 5)
go tempCleaner()
sc := make(chan os.Signal, 1)
signal.Notify(sc, syscall.SIGINT, syscall.SIGTERM, os.Interrupt)
go func() {
initServer()
}()
<-sc
}
func parseCommandLine() {
var newstaff string
var delstaff string
var rank int
var err error
flag.StringVar(&newstaff, "newstaff", "", "<newusername>:<newpassword>")
flag.StringVar(&delstaff, "delstaff", "", "<username>")
flag.IntVar(&rank, "rank", 0, "New staff member rank, to be used with -newstaff or -delstaff")
flag.Parse()
if newstaff != "" {
arr := strings.Split(newstaff, ":")
if len(arr) < 2 || delstaff != "" {
flag.Usage()
os.Exit(1)
}
gclog.Printf(lStdLog|lErrorLog, "Creating new staff: %q, with password: %q and rank: %d", arr[0], arr[1], rank)
if err = newStaff(arr[0], arr[1], rank); err != nil {
gclog.Print(lStdLog|lFatal, err.Error())
}
os.Exit(0)
}
if delstaff != "" {
if newstaff != "" {
flag.Usage()
os.Exit(1)
}
gclog.Printf(lStdLog, "Are you sure you want to delete the staff account %q? [y/N]: ", delstaff)
var answer string
fmt.Scanln(&answer)
answer = strings.ToLower(answer)
if answer == "y" || answer == "yes" {
if err = deleteStaff(delstaff); err != nil {
gclog.Printf(lStdLog|lFatal, "Error deleting %q: %s", delstaff, err.Error())
}
} else {
gclog.Print(lStdLog|lFatal, "Not deleting.")
}
}
}

View file

@ -1,17 +0,0 @@
package main
import (
"testing"
)
const (
tName = "UrA"
tTripcode = "#EAA30Kmm"
tEmail = "something@example.com"
tSubject = "Subject line"
tMessage = "Message text"
)
func TestGochan(t *testing.T) {
t.Log("This doesn't do anything interesting yet.")
}

View file

@ -1,123 +0,0 @@
package main
import (
"errors"
"fmt"
"io"
"os"
"runtime"
"time"
)
var gclog *GcLogger
const (
logTimeFmt = "2006/01/02 15:04:05 "
logFileFlags = os.O_APPEND | os.O_CREATE | os.O_RDWR
lAccessLog = 1 << iota
lErrorLog
lStaffLog
lStdLog
lFatal
)
type GcLogger struct {
accessFile *os.File
errorFile *os.File
staffFile *os.File
}
func (gcl *GcLogger) selectLogs(flags int) []*os.File {
var logs []*os.File
if flags&lAccessLog > 0 {
logs = append(logs, gcl.accessFile)
}
if flags&lErrorLog > 0 {
logs = append(logs, gcl.errorFile)
}
if flags&lStaffLog > 0 {
logs = append(logs, gcl.staffFile)
}
if (flags&lStdLog > 0) || (config.DebugMode) {
logs = append(logs, os.Stdout)
}
return logs
}
func (gcl *GcLogger) getPrefix() string {
prefix := time.Now().Format(logTimeFmt)
_, file, line, _ := runtime.Caller(2)
prefix += fmt.Sprint(file, ":", line, ": ")
return prefix
}
func (gcl *GcLogger) Print(flags int, v ...interface{}) string {
str := fmt.Sprint(v...)
logs := gcl.selectLogs(flags)
for _, l := range logs {
if l == os.Stdout {
io.WriteString(l, str+"\n")
} else {
io.WriteString(l, gcl.getPrefix()+str+"\n")
}
}
if flags&lFatal > 0 {
os.Exit(1)
}
return str
}
func (gcl *GcLogger) Printf(flags int, format string, v ...interface{}) string {
str := fmt.Sprintf(format, v...)
logs := gcl.selectLogs(flags)
for _, l := range logs {
if l == os.Stdout {
io.WriteString(l, str+"\n")
} else {
io.WriteString(l, gcl.getPrefix()+str+"\n")
}
}
if flags&lFatal > 0 {
os.Exit(1)
}
return str
}
func (gcl *GcLogger) Println(flags int, v ...interface{}) string {
str := fmt.Sprintln(v...)
logs := gcl.selectLogs(flags)
for _, l := range logs {
if l == os.Stdout {
io.WriteString(l, str+"\n")
} else {
io.WriteString(l, gcl.getPrefix()+str+"\n")
}
}
if flags&lFatal > 0 {
os.Exit(1)
}
return str
}
func (gcl *GcLogger) Close() {
closeHandle(gcl.accessFile)
closeHandle(gcl.errorFile)
closeHandle(gcl.staffFile)
}
func initLogs(accessLogPath, errorLogPath, staffLogPath string) (*GcLogger, error) {
var gcl GcLogger
var err error
if gcl.accessFile, err = os.OpenFile(accessLogPath, logFileFlags, 0777); err != nil {
return nil, errors.New("Error loading " + accessLogPath + ": " + err.Error())
}
if gcl.errorFile, err = os.OpenFile(errorLogPath, logFileFlags, 0777); err != nil {
return nil, errors.New("Error loading " + errorLogPath + ": " + err.Error())
}
if gcl.staffFile, err = os.OpenFile(staffLogPath, logFileFlags, 0777); err != nil {
return nil, errors.New("Error loading " + staffLogPath + ": " + err.Error())
}
return &gcl, nil
}

View file

@ -1,18 +0,0 @@
package main
import "testing"
func TestGochanLog(t *testing.T) {
gcl, err := initLogs("../access.log", "../error.log", "../staff.log")
if err != nil {
t.Fatal(err.Error())
}
gcl.Print(lStdLog, "os.Stdout log")
gcl.Print(lStdLog|lAccessLog|lErrorLog|lStaffLog, "all logs")
gcl.Print(lAccessLog, "Access log")
gcl.Print(lErrorLog, "Error log")
gcl.Print(lStaffLog, "Staff log")
gcl.Print(lAccessLog|lErrorLog, "Access and error log")
gcl.Print(lAccessLog|lStaffLog|lFatal, "Fatal access and staff log")
gcl.Print(lAccessLog, "This shouldn't be here")
}

View file

@ -1,923 +0,0 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"html"
"io/ioutil"
"net"
"net/http"
"os"
"path"
"regexp"
"strconv"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
)
var (
chopPortNumRegex = regexp.MustCompile(`(.+|\w+):(\d+)$`)
)
// ManageFunction represents the functions accessed by staff members at /manage?action=<functionname>.
type ManageFunction struct {
Title string
Permissions int // 0 -> non-staff, 1 => janitor, 2 => moderator, 3 => administrator
Callback func(writer http.ResponseWriter, request *http.Request) string `json:"-"` //return string of html output
}
func getManageFunctionsJSON() string {
var jsonStr string
return jsonStr
}
func callManageFunction(writer http.ResponseWriter, request *http.Request) {
var err error
if err = request.ParseForm(); err != nil {
serveErrorPage(writer,
gclog.Print(lErrorLog, "Error parsing form data: ", err.Error()))
}
action := request.FormValue("action")
staffRank := getStaffRank(request)
var managePageBuffer bytes.Buffer
if action == "" {
action = "announcements"
} else if action == "postinfo" {
writer.Header().Add("Content-Type", "application/json")
writer.Header().Add("Cache-Control", "max-age=5, must-revalidate")
}
if action != "getstaffjquery" && action != "postinfo" {
managePageBuffer.WriteString("<!DOCTYPE html><html><head>")
if err = manageHeaderTmpl.Execute(&managePageBuffer, config); err != nil {
serveErrorPage(writer, gclog.Print(lErrorLog|lStaffLog,
"Error executing manage page header template: ", err.Error()))
return
}
}
if _, ok := manageFunctions[action]; ok {
if staffRank >= manageFunctions[action].Permissions {
managePageBuffer.Write([]byte(manageFunctions[action].Callback(writer, request)))
} else if staffRank == 0 && manageFunctions[action].Permissions == 0 {
managePageBuffer.Write([]byte(manageFunctions[action].Callback(writer, request)))
} else if staffRank == 0 {
managePageBuffer.Write([]byte(manageFunctions["login"].Callback(writer, request)))
} else {
managePageBuffer.Write([]byte(action + " is undefined."))
}
} else {
managePageBuffer.Write([]byte(action + " is undefined."))
}
if action != "getstaffjquery" && action != "postinfo" {
managePageBuffer.Write([]byte("</body></html>"))
}
writer.Write(managePageBuffer.Bytes())
}
func getCurrentStaff(request *http.Request) (string, error) { //TODO after refactor, check if still used
sessionCookie, err := request.Cookie("sessiondata")
if err != nil {
return "", err
}
name, err := GetStaffName(sessionCookie.Value)
if err == nil {
return "", err
}
return name, nil
}
func getCurrentFullStaff(request *http.Request) (*Staff, error) {
sessionCookie, err := request.Cookie("sessiondata")
if err != nil {
return nil, err
}
return GetStaffBySession(sessionCookie.Value)
}
func getStaffRank(request *http.Request) int {
staff, err := getCurrentFullStaff(request)
if err != nil {
gclog.Print(lErrorLog, "Error getting current staff: ", err.Error())
return 0
}
return staff.Rank
}
func createSession(key string, username string, password string, request *http.Request, writer http.ResponseWriter) int {
//returns 0 for successful, 1 for password mismatch, and 2 for other
domain := request.Host
var err error
domain = chopPortNumRegex.Split(domain, -1)[0]
if !validReferrer(request) {
gclog.Print(lStaffLog, "Rejected login from possible spambot @ "+request.RemoteAddr)
return 2
}
staff, err := GetStaffByName(username)
if err != nil {
gclog.Print(lErrorLog, err.Error())
return 1
}
success := bcrypt.CompareHashAndPassword([]byte(staff.PasswordChecksum), []byte(password))
if success == bcrypt.ErrMismatchedHashAndPassword {
// password mismatch
gclog.Print(lStaffLog, "Failed login (password mismatch) from "+request.RemoteAddr+" at "+getSQLDateTime())
return 1
}
// successful login, add cookie that expires in one month
http.SetCookie(writer, &http.Cookie{
Name: "sessiondata",
Value: key,
Path: "/",
Domain: domain,
MaxAge: 60 * 60 * 24 * 7,
})
if err = CreateSession(key, username); err != nil {
gclog.Print(lErrorLog, "Error creating new staff session: ", err.Error())
return 2
}
return 0
}
var manageFunctions = map[string]ManageFunction{
"cleanup": {
Title: "Cleanup",
Permissions: 3,
Callback: func(writer http.ResponseWriter, request *http.Request) (html string) {
html = "<h2 class=\"manage-header\">Cleanup</h2><br />"
var err error
if request.FormValue("run") == "Run Cleanup" {
html += "Removing deleted posts from the database.<hr />"
if err = PermanentlyRemoveDeletedPosts(); err != nil {
return html + "<tr><td>" +
gclog.Print(lErrorLog, "Error removing deleted posts from database: ", err.Error()) +
"</td></tr></table>"
}
// TODO: remove orphaned replies and uploads
html += "Optimizing all tables in database.<hr />"
err = OptimizeDatabase()
if err != nil {
return html + "<tr><td>" +
gclog.Print(lErrorLog, "Error optimizing SQL tables: ", err.Error()) +
"</td></tr></table>"
}
html += "Cleanup finished"
} else {
html += "<form action=\"/manage?action=cleanup\" method=\"post\">\n" +
" <input name=\"run\" id=\"run\" type=\"submit\" value=\"Run Cleanup\" />\n" +
"</form>"
}
return
}},
"config": {
Title: "Configuration",
Permissions: 3,
Callback: func(writer http.ResponseWriter, request *http.Request) (html string) {
do := request.FormValue("do")
var status string
if do == "save" {
configJSON, err := json.MarshalIndent(config, "", "\t")
if err != nil {
status += err.Error() + "<br />\n"
} else if err = ioutil.WriteFile("gochan.json", configJSON, 0777); err != nil {
status += "Error backing up old gochan.json, cancelling save: " + err.Error() + "\n"
} else {
config.Lockdown = (request.PostFormValue("Lockdown") == "on")
config.LockdownMessage = request.PostFormValue("LockdownMessage")
Sillytags_arr := strings.Split(request.PostFormValue("Sillytags"), "\n")
var Sillytags []string
for _, tag := range Sillytags_arr {
Sillytags = append(Sillytags, strings.Trim(tag, " \n\r"))
}
config.Sillytags = Sillytags
config.UseSillytags = (request.PostFormValue("UseSillytags") == "on")
config.Modboard = request.PostFormValue("Modboard")
config.SiteName = request.PostFormValue("SiteName")
config.SiteSlogan = request.PostFormValue("SiteSlogan")
config.SiteHeaderURL = request.PostFormValue("SiteHeaderURL")
config.SiteWebfolder = request.PostFormValue("SiteWebfolder")
// TODO: Change this to match the new Style type in gochan.json
/* Styles_arr := strings.Split(request.PostFormValue("Styles"), "\n")
var Styles []string
for _, style := range Styles_arr {
Styles = append(Styles, strings.Trim(style, " \n\r"))
}
config.Styles = Styles */
config.DefaultStyle = request.PostFormValue("DefaultStyle")
config.AllowDuplicateImages = (request.PostFormValue("AllowDuplicateImages") == "on")
config.AllowVideoUploads = (request.PostFormValue("AllowVideoUploads") == "on")
NewThreadDelay, err := strconv.Atoi(request.PostFormValue("NewThreadDelay"))
if err != nil {
status += err.Error() + "<br />\n"
} else {
config.NewThreadDelay = NewThreadDelay
}
ReplyDelay, err := strconv.Atoi(request.PostFormValue("ReplyDelay"))
if err != nil {
status += err.Error() + "<br />\n"
} else {
config.ReplyDelay = ReplyDelay
}
MaxLineLength, err := strconv.Atoi(request.PostFormValue("MaxLineLength"))
if err != nil {
status += err.Error() + "<br />\n"
} else {
config.MaxLineLength = MaxLineLength
}
ReservedTrips_arr := strings.Split(request.PostFormValue("ReservedTrips"), "\n")
var ReservedTrips []string
for _, trip := range ReservedTrips_arr {
ReservedTrips = append(ReservedTrips, strings.Trim(trip, " \n\r"))
}
config.ReservedTrips = ReservedTrips
ThumbWidth, err := strconv.Atoi(request.PostFormValue("ThumbWidth"))
if err != nil {
status += err.Error() + "<br />\n"
} else {
config.ThumbWidth = ThumbWidth
}
ThumbHeight, err := strconv.Atoi(request.PostFormValue("ThumbHeight"))
if err != nil {
status += err.Error() + "<br />\n"
} else {
config.ThumbHeight = ThumbHeight
}
ThumbWidth_reply, err := strconv.Atoi(request.PostFormValue("ThumbWidth_reply"))
if err != nil {
status += err.Error() + "<br />\n"
} else {
config.ThumbWidth_reply = ThumbWidth_reply
}
ThumbHeight_reply, err := strconv.Atoi(request.PostFormValue("ThumbHeight_reply"))
if err != nil {
status += err.Error() + "<br />\n"
} else {
config.ThumbHeight_reply = ThumbHeight_reply
}
ThumbWidth_catalog, err := strconv.Atoi(request.PostFormValue("ThumbWidth_catalog"))
if err != nil {
status += err.Error() + "<br />\n"
} else {
config.ThumbWidth_catalog = ThumbWidth_catalog
}
ThumbHeight_catalog, err := strconv.Atoi(request.PostFormValue("ThumbHeight_catalog"))
if err != nil {
status += err.Error() + "<br />\n"
} else {
config.ThumbHeight_catalog = ThumbHeight_catalog
}
RepliesOnBoardPage, err := strconv.Atoi(request.PostFormValue("RepliesOnBoardPage"))
if err != nil {
status += err.Error() + "<br />\n"
} else {
config.RepliesOnBoardPage = RepliesOnBoardPage
}
StickyRepliesOnBoardPage, err := strconv.Atoi(request.PostFormValue("StickyRepliesOnBoardPage"))
if err != nil {
status += err.Error() + "<br />\n"
} else {
config.StickyRepliesOnBoardPage = StickyRepliesOnBoardPage
}
BanColors_arr := strings.Split(request.PostFormValue("BanColors"), "\n")
var BanColors []string
for _, color := range BanColors_arr {
BanColors = append(BanColors, strings.Trim(color, " \n\r"))
}
config.BanColors = BanColors
config.BanMsg = request.PostFormValue("BanMsg")
EmbedWidth, err := strconv.Atoi(request.PostFormValue("EmbedWidth"))
if err != nil {
status += err.Error() + "<br />\n"
} else {
config.EmbedWidth = EmbedWidth
}
EmbedHeight, err := strconv.Atoi(request.PostFormValue("EmbedHeight"))
if err != nil {
status += err.Error() + "<br />\n"
} else {
config.EmbedHeight = EmbedHeight
}
config.ExpandButton = (request.PostFormValue("ExpandButton") == "on")
config.ImagesOpenNewTab = (request.PostFormValue("ImagesOpenNewTab") == "on")
config.MakeURLsHyperlinked = (request.PostFormValue("MakeURLsHyperlinked") == "on")
config.NewTabOnOutlinks = (request.PostFormValue("NewTabOnOutlinks") == "on")
config.MinifyHTML = (request.PostFormValue("MinifyHTML") == "on")
config.MinifyJS = (request.PostFormValue("MinifyJS") == "on")
config.DateTimeFormat = request.PostFormValue("DateTimeFormat")
AkismetAPIKey := request.PostFormValue("AkismetAPIKey")
if err = checkAkismetAPIKey(AkismetAPIKey); err != nil {
status += err.Error() + "<br />"
} else {
config.AkismetAPIKey = AkismetAPIKey
}
config.UseCaptcha = (request.PostFormValue("UseCaptcha") == "on")
CaptchaWidth, err := strconv.Atoi(request.PostFormValue("CaptchaWidth"))
if err != nil {
status += err.Error() + "<br />\n"
} else {
config.CaptchaWidth = CaptchaWidth
}
CaptchaHeight, err := strconv.Atoi(request.PostFormValue("CaptchaHeight"))
if err != nil {
status += err.Error() + "<br />\n"
} else {
config.CaptchaHeight = CaptchaHeight
}
config.EnableGeoIP = (request.PostFormValue("EnableGeoIP") == "on")
config.GeoIPDBlocation = request.PostFormValue("GeoIPDBlocation")
MaxRecentPosts, err := strconv.Atoi(request.PostFormValue("MaxRecentPosts"))
if err != nil {
status += err.Error() + "<br />\n"
} else {
config.MaxRecentPosts = MaxRecentPosts
}
config.EnableAppeals = (request.PostFormValue("EnableAppeals") == "on")
MaxLogDays, err := strconv.Atoi(request.PostFormValue("MaxLogDays"))
if err != nil {
status += err.Error() + "<br />\n"
} else {
config.MaxLogDays = MaxLogDays
}
configJSON, err = json.MarshalIndent(config, "", "\t")
if err != nil {
status += err.Error() + "<br />\n"
} else if err = ioutil.WriteFile("gochan.json", configJSON, 0777); err != nil {
status = "Error writing gochan.json: %s\n" + err.Error()
} else {
status = "Wrote gochan.json successfully <br />"
buildJS()
}
}
}
manageConfigBuffer := bytes.NewBufferString("")
if err := manageConfigTmpl.Execute(manageConfigBuffer,
map[string]interface{}{"config": config, "status": status},
); err != nil {
return html + gclog.Print(lErrorLog, "Error executing config management page: ", err.Error())
}
html += manageConfigBuffer.String()
return
}},
"login": {
Title: "Login",
Permissions: 0,
Callback: func(writer http.ResponseWriter, request *http.Request) (html string) {
if getStaffRank(request) > 0 {
http.Redirect(writer, request, path.Join(config.SiteWebfolder, "manage"), http.StatusFound)
}
username := request.FormValue("username")
password := request.FormValue("password")
redirect_action := request.FormValue("action")
if redirect_action == "" {
redirect_action = "announcements"
}
if username == "" || password == "" {
//assume that they haven't logged in
html = "\t<form method=\"POST\" action=\"" + config.SiteWebfolder + "manage?action=login\" id=\"login-box\" class=\"staff-form\">\n" +
"\t\t<input type=\"hidden\" name=\"redirect\" value=\"" + redirect_action + "\" />\n" +
"\t\t<input type=\"text\" name=\"username\" class=\"logindata\" /><br />\n" +
"\t\t<input type=\"password\" name=\"password\" class=\"logindata\" /> <br />\n" +
"\t\t<input type=\"submit\" value=\"Login\" />\n" +
"\t</form>"
} else {
key := md5Sum(request.RemoteAddr + username + password + config.RandomSeed + randomString(3))[0:10]
createSession(key, username, password, request, writer)
http.Redirect(writer, request, path.Join(config.SiteWebfolder, "manage?action="+request.FormValue("redirect")), http.StatusFound)
}
return
}},
"logout": {
Title: "Logout",
Permissions: 1,
Callback: func(writer http.ResponseWriter, request *http.Request) (html string) {
cookie, _ := request.Cookie("sessiondata")
cookie.MaxAge = 0
cookie.Expires = time.Now().Add(-7 * 24 * time.Hour)
http.SetCookie(writer, cookie)
return "Logged out successfully"
}},
"announcements": {
Title: "Announcements",
Permissions: 1,
Callback: func(writer http.ResponseWriter, request *http.Request) (html string) {
html = "<h1 class=\"manage-header\">Announcements</h1><br />"
//get all announcements to announcement list
//loop to html if exist, no announcement if empty
announcements, err := GetAllAccouncements()
if err != nil {
return html + gclog.Print(lErrorLog, "Error getting announcements: ", err.Error())
}
if len(announcements) == 0 {
html += "No announcements"
} else {
for _, announcement := range announcements {
html += "<div class=\"section-block\">\n" +
"<div class=\"section-title-block\"><b>" + announcement.Subject + "</b> by " + announcement.Poster + " at " + humanReadableTime(announcement.Timestamp) + "</div>\n" +
"<div class=\"section-body\">" + announcement.Message + "\n</div></div>\n"
}
}
return html
}},
"bans": {
Title: "Bans",
Permissions: 1,
Callback: func(writer http.ResponseWriter, request *http.Request) (pageHTML string) { //TODO whatever this does idk man
var post Post
if request.FormValue("do") == "add" {
ip := net.ParseIP(request.FormValue("ip"))
name := request.FormValue("name")
nameIsRegex := (request.FormValue("nameregex") == "on")
checksum := request.FormValue("checksum")
filename := request.FormValue("filename")
durationForm := request.FormValue("duration")
permaban := (durationForm == "" || durationForm == "0" || durationForm == "forever")
duration, err := parseDurationString(durationForm)
if err != nil {
serveErrorPage(writer, err.Error())
}
expires := time.Now().Add(duration)
boards := request.FormValue("boards")
reason := html.EscapeString(request.FormValue("reason"))
staffNote := html.EscapeString(request.FormValue("staffnote"))
currentStaff, _ := getCurrentStaff(request)
err = nil
if filename != "" {
err = FileNameBan(filename, nameIsRegex, currentStaff, expires, permaban, staffNote, boards)
}
if err != nil {
pageHTML += err.Error()
err = nil
}
if name != "" {
err = UserNameBan(name, nameIsRegex, currentStaff, expires, permaban, staffNote, boards)
}
if err != nil {
pageHTML += err.Error()
err = nil
}
if request.FormValue("fullban") == "on" {
err = UserBan(ip, false, currentStaff, boards, expires, permaban, staffNote, reason, true, time.Now())
if err != nil {
pageHTML += err.Error()
err = nil
}
} else {
if request.FormValue("threadban") == "on" {
err = UserBan(ip, true, currentStaff, boards, expires, permaban, staffNote, reason, true, time.Now())
if err != nil {
pageHTML += err.Error()
err = nil
}
}
if request.FormValue("imageban") == "on" {
err = FileBan(checksum, currentStaff, expires, permaban, staffNote, boards)
if err != nil {
pageHTML += err.Error()
err = nil
}
}
}
}
if request.FormValue("postid") != "" {
var err error
post, err = GetSpecificPostByString(request.FormValue("postid"))
if err != nil {
return pageHTML + gclog.Print(lErrorLog, "Error getting post: ", err.Error())
}
}
banlist, err := GetAllBans()
if err != nil {
return pageHTML + gclog.Print(lErrorLog, "Error getting ban list: ", err.Error())
}
manageBansBuffer := bytes.NewBufferString("")
if err := manageBansTmpl.Execute(manageBansBuffer,
map[string]interface{}{"config": config, "banlist": banlist, "post": post},
); err != nil {
return pageHTML + gclog.Print(lErrorLog, "Error executing ban management page template: ", err.Error())
}
pageHTML += manageBansBuffer.String()
return
}},
"getstaffjquery": {
Permissions: 0,
Callback: func(writer http.ResponseWriter, request *http.Request) (html string) {
staff, err := getCurrentFullStaff(request)
if err != nil {
html = "nobody;0;"
return
}
html = staff.Username + ";" + strconv.Itoa(staff.Rank) + ";" + staff.Boards
return
}},
"boards": {
Title: "Boards",
Permissions: 3,
Callback: func(writer http.ResponseWriter, request *http.Request) (html string) {
do := request.FormValue("do")
var done bool
board := new(Board)
var boardCreationStatus string
var err error
for !done {
switch {
case do == "add":
board.Dir = request.FormValue("dir")
if board.Dir == "" {
boardCreationStatus = `Error: "Directory" cannot be blank`
do = ""
continue
}
orderStr := request.FormValue("order")
board.ListOrder, err = strconv.Atoi(orderStr)
if err != nil {
board.ListOrder = 0
}
board.Title = request.FormValue("title")
if board.Title == "" {
boardCreationStatus = `Error: "Title" cannot be blank`
do = ""
continue
}
board.Subtitle = request.FormValue("subtitle")
board.Description = request.FormValue("description")
sectionStr := request.FormValue("section")
if sectionStr == "none" {
sectionStr = "0"
}
board.CreatedOn = time.Now()
board.Section, err = strconv.Atoi(sectionStr)
if err != nil {
board.Section = 0
}
board.MaxFilesize, err = strconv.Atoi(request.FormValue("maximagesize"))
if err != nil {
board.MaxFilesize = 1024 * 4
}
board.MaxPages, err = strconv.Atoi(request.FormValue("maxpages"))
if err != nil {
board.MaxPages = 11
}
board.DefaultStyle = strings.Trim(request.FormValue("defaultstyle"), "\n")
board.Locked = (request.FormValue("locked") == "on")
board.ForcedAnon = (request.FormValue("forcedanon") == "on")
board.Anonymous = request.FormValue("anonymous")
if board.Anonymous == "" {
board.Anonymous = "Anonymous"
}
board.MaxAge, err = strconv.Atoi(request.FormValue("maxage"))
if err != nil {
board.MaxAge = 0
}
board.AutosageAfter, err = strconv.Atoi(request.FormValue("autosageafter"))
if err != nil {
board.AutosageAfter = 200
}
board.NoImagesAfter, err = strconv.Atoi(request.FormValue("noimagesafter"))
if err != nil {
board.NoImagesAfter = 0
}
board.MaxMessageLength, err = strconv.Atoi(request.FormValue("maxmessagelength"))
if err != nil {
board.MaxMessageLength = 1024 * 8
}
board.EmbedsAllowed = (request.FormValue("embedsallowed") == "on")
board.RedirectToThread = (request.FormValue("redirecttothread") == "on")
board.RequireFile = (request.FormValue("require_file") == "on")
board.EnableCatalog = (request.FormValue("enablecatalog") == "on")
//actually start generating stuff
if err = os.Mkdir(path.Join(config.DocumentRoot, board.Dir), 0666); err != nil {
do = ""
boardCreationStatus = gclog.Printf(lStaffLog|lErrorLog, "Directory %s/%s/ already exists.",
config.DocumentRoot, board.Dir)
break
}
if err = os.Mkdir(path.Join(config.DocumentRoot, board.Dir, "res"), 0666); err != nil {
do = ""
boardCreationStatus = gclog.Printf(lStaffLog|lErrorLog, "Directory %s/%s/res/ already exists.",
config.DocumentRoot, board.Dir)
break
}
if err = os.Mkdir(path.Join(config.DocumentRoot, board.Dir, "src"), 0666); err != nil {
do = ""
boardCreationStatus = gclog.Printf(lStaffLog|lErrorLog, "Directory %s/%s/src/ already exists.",
config.DocumentRoot, board.Dir)
break
}
if err = os.Mkdir(path.Join(config.DocumentRoot, board.Dir, "thumb"), 0666); err != nil {
do = ""
boardCreationStatus = gclog.Printf(lStaffLog|lErrorLog, "Directory %s/%s/thumb/ already exists.",
config.DocumentRoot, board.Dir)
break
}
if err := CreateBoard(board); err != nil {
do = ""
boardCreationStatus = gclog.Print(lErrorLog, "Error creating board: ", err.Error())
break
} else {
boardCreationStatus = "Board created successfully"
buildBoards()
resetBoardSectionArrays()
gclog.Print(lStaffLog, "Boards rebuilt successfully")
done = true
}
case do == "del":
// resetBoardSectionArrays()
case do == "edit":
// resetBoardSectionArrays()
default:
// put the default column values in the text boxes
board.Section = 1
board.MaxFilesize = 4718592
board.MaxPages = 11
board.DefaultStyle = "pipes.css"
board.Anonymous = "Anonymous"
board.AutosageAfter = 200
board.MaxMessageLength = 8192
board.EmbedsAllowed = true
board.EnableCatalog = true
board.Worksafe = true
board.ThreadsPerPage = config.ThreadsPerPage
}
html = "<h1 class=\"manage-header\">Manage boards</h1>\n<form action=\"/manage?action=boards\" method=\"POST\">\n<input type=\"hidden\" name=\"do\" value=\"existing\" /><select name=\"boardselect\">\n<option>Select board...</option>\n"
boards, err := GetBoardUris()
if err != nil {
return html + gclog.Print(lErrorLog, "Error getting board list: ", err.Error())
}
for _, boardDir := range boards {
html += "<option>" + boardDir + "</option>"
}
html += "</select> <input type=\"submit\" value=\"Edit\" /> <input type=\"submit\" value=\"Delete\" /></form><hr />" +
"<h2 class=\"manage-header\">Create new board</h2>\n<span id=\"board-creation-message\">" + boardCreationStatus + "</span><br />"
manageBoardsBuffer := bytes.NewBufferString("")
allSections, _ = GetAllSectionsOrCreateDefault()
if err := manageBoardsTmpl.Execute(manageBoardsBuffer, map[string]interface{}{
"config": config,
"board": board,
"section_arr": allSections,
}); err != nil {
return html + gclog.Print(lErrorLog, "Error executing board management page template: ", err.Error())
}
html += manageBoardsBuffer.String()
return
}
resetBoardSectionArrays()
return
}},
"staffmenu": {
Title: "Staff menu",
Permissions: 1,
Callback: func(writer http.ResponseWriter, request *http.Request) (html string) {
rank := getStaffRank(request)
html = "<a href=\"javascript:void(0)\" id=\"logout\" class=\"staffmenu-item\">Log out</a><br />\n" +
"<a href=\"javascript:void(0)\" id=\"announcements\" class=\"staffmenu-item\">Announcements</a><br />\n"
if rank == 3 {
html += "<b>Admin stuff</b><br />\n<a href=\"javascript:void(0)\" id=\"staff\" class=\"staffmenu-item\">Manage staff</a><br />\n" +
//"<a href=\"javascript:void(0)\" id=\"purgeeverything\" class=\"staffmenu-item\">Purge everything!</a><br />\n" +
"<a href=\"javascript:void(0)\" id=\"executesql\" class=\"staffmenu-item\">Execute SQL statement(s)</a><br />\n" +
"<a href=\"javascript:void(0)\" id=\"cleanup\" class=\"staffmenu-item\">Run cleanup</a><br />\n" +
"<a href=\"javascript:void(0)\" id=\"rebuildall\" class=\"staffmenu-item\">Rebuild all</a><br />\n" +
"<a href=\"javascript:void(0)\" id=\"rebuildfront\" class=\"staffmenu-item\">Rebuild front page</a><br />\n" +
"<a href=\"javascript:void(0)\" id=\"rebuildboards\" class=\"staffmenu-item\">Rebuild board pages</a><br />\n" +
"<a href=\"javascript:void(0)\" id=\"reparsehtml\" class=\"staffmenu-item\">Reparse all posts</a><br />\n" +
"<a href=\"javascript:void(0)\" id=\"boards\" class=\"staffmenu-item\">Add/edit/delete boards</a><br />\n"
}
if rank >= 2 {
html += "<b>Mod stuff</b><br />\n" +
"<a href=\"javascript:void(0)\" id=\"bans\" class=\"staffmenu-item\">Ban User(s)</a><br />\n"
}
if rank >= 1 {
html += "<a href=\"javascript:void(0)\" id=\"recentimages\" class=\"staffmenu-item\">Recently uploaded images</a><br />\n" +
"<a href=\"javascript:void(0)\" id=\"recentposts\" class=\"staffmenu-item\">Recent posts</a><br />\n" +
"<a href=\"javascript:void(0)\" id=\"searchip\" class=\"staffmenu-item\">Search posts by IP</a><br />\n"
}
return
}},
"rebuildfront": {
Title: "Rebuild front page",
Permissions: 3,
Callback: func(writer http.ResponseWriter, request *http.Request) (html string) {
initTemplates()
return buildFrontPage()
}},
"rebuildall": {
Title: "Rebuild everything",
Permissions: 3,
Callback: func(writer http.ResponseWriter, request *http.Request) (html string) {
initTemplates()
resetBoardSectionArrays()
return buildFrontPage() + "<hr />" +
buildBoardListJSON() + "<hr />" +
buildBoards() + "<hr />" +
buildJS() + "<hr />"
}},
"rebuildboards": {
Title: "Rebuild boards",
Permissions: 3,
Callback: func(writer http.ResponseWriter, request *http.Request) (html string) {
initTemplates()
return buildBoards()
}},
"reparsehtml": {
Title: "Reparse HTML",
Permissions: 3,
Callback: func(writer http.ResponseWriter, request *http.Request) (html string) {
messages, err := GetAllNondeletedMessageRaw()
if err != nil {
html += err.Error() + "<br />"
return
}
for _, message := range messages {
message.Message = formatMessage(message.MessageRaw)
}
err = SetMessages(messages)
if err != nil {
return html + gclog.Printf(lErrorLog, err.Error())
}
html += "Done reparsing HTML<hr />" +
buildFrontPage() + "<hr />" +
buildBoardListJSON() + "<hr />" +
buildBoards() + "<hr />"
return
}},
"recentposts": {
Title: "Recent posts",
Permissions: 1,
Callback: func(writer http.ResponseWriter, request *http.Request) (html string) {
limit := request.FormValue("limit")
if limit == "" {
limit = "50"
}
html = "<h1 class=\"manage-header\">Recent posts</h1>\nLimit by: <select id=\"limit\"><option>25</option><option>50</option><option>100</option><option>200</option></select>\n<br />\n<table width=\"100%%d\" border=\"1\">\n<colgroup><col width=\"25%%\" /><col width=\"50%%\" /><col width=\"17%%\" /></colgroup><tr><th></th><th>Message</th><th>Time</th></tr>"
recentposts, err := GetRecentPostsGlobal(HackyStringToInt(limit), false) //only uses boardname, boardid, postid, parentid, message, ip and timestamp
if err != nil {
return html + "<tr><td>" + gclog.Print(lErrorLog, "Error getting recent posts: ",
err.Error()) + "</td></tr></table>"
}
for _, recentpost := range recentposts {
html += fmt.Sprintf(
`<tr><td><b>Post:</b> <a href="%s">%s/%d</a><br /><b>IP:</b> %s</td><td>%s</td><td>%s</td></tr>`,
path.Join(config.SiteWebfolder, recentpost.BoardName, "/res/", strconv.Itoa(recentpost.ParentID)+".html#"+strconv.Itoa(recentpost.PostID)),
recentpost.BoardName, recentpost.PostID, recentpost.IP, recentpost.Message,
recentpost.Timestamp.Format("01/02/06, 15:04"),
)
}
html += "</table>"
return
}},
"postinfo": {
Title: "Post info",
Permissions: 2,
Callback: func(writer http.ResponseWriter, request *http.Request) (html string) {
errMap := map[string]interface{}{
"action": "postInfo",
"success": false,
}
post, err := GetSpecificPost(HackyStringToInt(request.FormValue("postid")), false)
if err != nil {
errMap["message"] = err.Error()
jsonErr, _ := marshalJSON(errMap, false)
return jsonErr
}
jsonStr, _ := marshalJSON(post, false)
return jsonStr
}},
"staff": {
Title: "Staff",
Permissions: 3,
Callback: func(writer http.ResponseWriter, request *http.Request) (html string) {
do := request.FormValue("do")
html = `<h1 class="manage-header">Staff</h1><br />` +
`<table id="stafftable" border="1">` +
"<tr><td><b>Username</b></td><td><b>Rank</b></td><td><b>Boards</b></td><td><b>Added on</b></td><td><b>Action</b></td></tr>"
allStaff, err := GetAllStaffNopass()
if err != nil {
return html + gclog.Print(lErrorLog, "Error getting staff list: ", err.Error())
}
for _, staff := range allStaff {
username := request.FormValue("username")
password := request.FormValue("password")
rank := request.FormValue("rank")
rankI, _ := strconv.Atoi(rank)
if do == "add" {
if err := newStaff(username, password, rankI); err != nil {
serveErrorPage(writer, gclog.Printf(lErrorLog,
"Error creating new staff account %q: %s", username, err.Error()))
return
}
} else if do == "del" && username != "" {
if err = deleteStaff(request.FormValue("username")); err != nil {
serveErrorPage(writer, gclog.Printf(lErrorLog,
"Error deleting staff account %q : %s", username, err.Error()))
return
}
}
switch {
case staff.Rank == 3:
rank = "admin"
case staff.Rank == 2:
rank = "mod"
case staff.Rank == 1:
rank = "janitor"
}
html += fmt.Sprintf(
`<tr><td>%s</td><td>%s</td><td>%s</td><td>%s</td><td><a href="/manage?action=staff&amp;do=del&amp;username=%s" style="float:right;color:red;">X</a></td></tr>`,
staff.Username, rank, staff.Boards, humanReadableTime(staff.AddedOn), staff.Username)
}
html += "</table>\n\n<hr />\n<h2 class=\"manage-header\">Add new staff</h2>\n\n" +
"<form action=\"/manage?action=staff\" onsubmit=\"return makeNewStaff();\" method=\"POST\">\n" +
"\t<input type=\"hidden\" name=\"do\" value=\"add\" />\n" +
"\tUsername: <input id=\"username\" name=\"username\" type=\"text\" /><br />\n" +
"\tPassword: <input id=\"password\" name=\"password\" type=\"password\" /><br />\n" +
"\tRank: <select id=\"rank\" name=\"rank\">\n" +
"\t\t<option value=\"3\">Admin</option>\n" +
"\t\t<option value=\"2\">Moderator</option>\n" +
"\t\t<option value=\"1\">Janitor</option>\n" +
"\t\t</select><br />\n" +
"\t\t<input id=\"submitnewstaff\" type=\"submit\" value=\"Add\" />\n" +
"\t\t</form>"
return
}},
"tempposts": {
Title: "Temporary posts lists",
Permissions: 3,
Callback: func(writer http.ResponseWriter, request *http.Request) (html string) {
html += "<h1 class=\"manage-header\">Temporary posts</h1>"
if len(tempPosts) == 0 {
html += "No temporary posts<br />\n"
return
}
for p, post := range tempPosts {
html += fmt.Sprintf("Post[%d]: %#v<br />\n", p, post)
}
return
}},
}

View file

@ -1,679 +0,0 @@
// functions for handling posting, uploading, and bans
package main
import (
"bytes"
"crypto/md5"
"database/sql"
"errors"
"fmt"
"html"
"image"
"io/ioutil"
"math/rand"
"net/http"
"net/url"
"os"
"os/exec"
"path"
"strconv"
"strings"
"syscall"
"time"
"github.com/aquilax/tripcode"
"github.com/disintegration/imaging"
)
const (
gt = "&gt;"
yearInSeconds = 31536000
)
var (
allSections []BoardSection
allBoards []Board
tempPosts []Post
tempCleanerTicker *time.Ticker
)
// Checks check poster's name/tripcode/file checksum (from Post post) for banned status
// returns ban table if the user is banned or sql.ErrNoRows if they aren't
func getBannedStatus(request *http.Request) (*BanInfo, error) {
formName := request.FormValue("postname")
var tripcode string
if formName != "" {
parsedName := parseName(formName)
tripcode += parsedName["name"]
if tc, ok := parsedName["tripcode"]; ok {
tripcode += "!" + tc
}
}
ip := getRealIP(request)
var filename string
var checksum string
file, fileHandler, err := request.FormFile("imagefile")
defer closeHandle(file)
if err == nil {
html.EscapeString(fileHandler.Filename)
if data, err2 := ioutil.ReadAll(file); err2 == nil {
checksum = fmt.Sprintf("%x", md5.Sum(data))
}
}
return CheckBan(ip, tripcode, filename, checksum)
}
func isBanned(ban *BanInfo, board string) bool {
if ban.Boards == "" && (ban.Expires.After(time.Now()) || ban.Permaban) {
return true
}
boardsArr := strings.Split(ban.Boards, ",")
for _, b := range boardsArr {
if b == board && (ban.Expires.After(time.Now()) || ban.Permaban) {
return true
}
}
return false
}
func createImageThumbnail(imageObj image.Image, size string) image.Image {
var thumbWidth int
var thumbHeight int
switch size {
case "op":
thumbWidth = config.ThumbWidth
thumbHeight = config.ThumbHeight
case "reply":
thumbWidth = config.ThumbWidth_reply
thumbHeight = config.ThumbHeight_reply
case "catalog":
thumbWidth = config.ThumbWidth_catalog
thumbHeight = config.ThumbHeight_catalog
}
oldRect := imageObj.Bounds()
if thumbWidth >= oldRect.Max.X && thumbHeight >= oldRect.Max.Y {
return imageObj
}
thumbW, thumbH := getThumbnailSize(oldRect.Max.X, oldRect.Max.Y, size)
imageObj = imaging.Resize(imageObj, thumbW, thumbH, imaging.CatmullRom) // resize to 600x400 px using CatmullRom cubic filter
return imageObj
}
func createVideoThumbnail(video, thumb string, size int) error {
sizeStr := strconv.Itoa(size)
outputBytes, err := exec.Command("ffmpeg", "-y", "-itsoffset", "-1", "-i", video, "-vframes", "1", "-filter:v", "scale='min("+sizeStr+"\\, "+sizeStr+"):-1'", thumb).CombinedOutput()
if err != nil {
outputStringArr := strings.Split(string(outputBytes), "\n")
if len(outputStringArr) > 1 {
outputString := outputStringArr[len(outputStringArr)-2]
err = errors.New(outputString)
}
}
return err
}
func getVideoInfo(path string) (map[string]int, error) {
vidInfo := make(map[string]int)
outputBytes, err := exec.Command("ffprobe", "-v quiet", "-show_format", "-show_streams", path).CombinedOutput()
if err == nil && outputBytes != nil {
outputStringArr := strings.Split(string(outputBytes), "\n")
for _, line := range outputStringArr {
lineArr := strings.Split(line, "=")
if len(lineArr) < 2 {
continue
}
if lineArr[0] == "width" || lineArr[0] == "height" || lineArr[0] == "size" {
value, _ := strconv.Atoi(lineArr[1])
vidInfo[lineArr[0]] = value
}
}
}
return vidInfo, err
}
func getNewFilename() string {
now := time.Now().Unix()
rand.Seed(now)
return strconv.Itoa(int(now)) + strconv.Itoa(rand.Intn(98)+1)
}
// find out what out thumbnail's width and height should be, partially ripped from Kusaba X
func getThumbnailSize(w int, h int, size string) (newWidth int, newHeight int) {
var thumbWidth int
var thumbHeight int
switch {
case size == "op":
thumbWidth = config.ThumbWidth
thumbHeight = config.ThumbHeight
case size == "reply":
thumbWidth = config.ThumbWidth_reply
thumbHeight = config.ThumbHeight_reply
case size == "catalog":
thumbWidth = config.ThumbWidth_catalog
thumbHeight = config.ThumbHeight_catalog
}
if w == h {
newWidth = thumbWidth
newHeight = thumbHeight
} else {
var percent float32
if w > h {
percent = float32(thumbWidth) / float32(w)
} else {
percent = float32(thumbHeight) / float32(h)
}
newWidth = int(float32(w) * percent)
newHeight = int(float32(h) * percent)
}
return
}
func parseName(name string) map[string]string {
parsed := make(map[string]string)
if !strings.Contains(name, "#") {
parsed["name"] = name
parsed["tripcode"] = ""
} else if strings.Index(name, "#") == 0 {
parsed["tripcode"] = tripcode.Tripcode(name[1:])
} else if strings.Index(name, "#") > 0 {
postNameArr := strings.SplitN(name, "#", 2)
parsed["name"] = postNameArr[0]
parsed["tripcode"] = tripcode.Tripcode(postNameArr[1])
}
return parsed
}
// called when a user accesses /post. Parse form data, then insert and build
func makePost(writer http.ResponseWriter, request *http.Request) {
var maxMessageLength int
var post Post
// domain := request.Host
var formName string
var nameCookie string
var formEmail string
if request.Method == "GET" {
http.Redirect(writer, request, config.SiteWebfolder, http.StatusFound)
return
}
// fix new cookie domain for when you use a port number
// domain = chopPortNumRegex.Split(domain, -1)[0]
post.ParentID, _ = strconv.Atoi(request.FormValue("threadid"))
post.BoardID, _ = strconv.Atoi(request.FormValue("boardid"))
var emailCommand string
formName = request.FormValue("postname")
parsedName := parseName(formName)
post.Name = parsedName["name"]
post.Tripcode = parsedName["tripcode"]
formEmail = request.FormValue("postemail")
http.SetCookie(writer, &http.Cookie{Name: "email", Value: formEmail, MaxAge: yearInSeconds})
if !strings.Contains(formEmail, "noko") && !strings.Contains(formEmail, "sage") {
post.Email = formEmail
} else if strings.Index(formEmail, "#") > 1 {
formEmailArr := strings.SplitN(formEmail, "#", 2)
post.Email = formEmailArr[0]
emailCommand = formEmailArr[1]
} else if formEmail == "noko" || formEmail == "sage" {
emailCommand = formEmail
post.Email = ""
}
post.Subject = request.FormValue("postsubject")
post.MessageText = strings.Trim(request.FormValue("postmsg"), "\r\n")
var err error
if maxMessageLength, err = GetMaxMessageLength(post.BoardID); err != nil {
serveErrorPage(writer, gclog.Print(lErrorLog,
"Error getting board info: ", err.Error()))
}
if len(post.MessageText) > maxMessageLength {
serveErrorPage(writer, "Post body is too long")
return
}
post.MessageHTML = formatMessage(post.MessageText)
password := request.FormValue("postpassword")
if password == "" {
password = randomString(8)
}
post.Password = md5Sum(password)
// Reverse escapes
nameCookie = strings.Replace(formName, "&amp;", "&", -1)
nameCookie = strings.Replace(nameCookie, "\\&#39;", "'", -1)
nameCookie = strings.Replace(url.QueryEscape(nameCookie), "+", "%20", -1)
// add name and email cookies that will expire in a year (31536000 seconds)
http.SetCookie(writer, &http.Cookie{Name: "name", Value: nameCookie, MaxAge: yearInSeconds})
http.SetCookie(writer, &http.Cookie{Name: "password", Value: password, MaxAge: yearInSeconds})
post.IP = getRealIP(request)
post.Timestamp = time.Now()
// post.PosterAuthority = getStaffRank(request)
post.Bumped = time.Now()
post.Stickied = request.FormValue("modstickied") == "on"
post.Locked = request.FormValue("modlocked") == "on"
//post has no referrer, or has a referrer from a different domain, probably a spambot
if !validReferrer(request) {
gclog.Print(lAccessLog, "Rejected post from possible spambot @ "+post.IP)
return
}
switch checkPostForSpam(post.IP, request.Header["User-Agent"][0], request.Referer(),
post.Name, post.Email, post.MessageText) {
case "discard":
serveErrorPage(writer, "Your post looks like spam.")
gclog.Print(lAccessLog, "Akismet recommended discarding post from: "+post.IP)
return
case "spam":
serveErrorPage(writer, "Your post looks like spam.")
gclog.Print(lAccessLog, "Akismet suggested post is spam from "+post.IP)
return
default:
}
file, handler, err := request.FormFile("imagefile")
defer closeHandle(file)
if err != nil || handler.Size == 0 {
// no file was uploaded
post.Filename = ""
gclog.Printf(lAccessLog, "Receiving post from %s, referred from: %s", post.IP, request.Referer())
} else {
data, err := ioutil.ReadAll(file)
if err != nil {
serveErrorPage(writer, gclog.Print(lErrorLog, "Error while trying to read file: ", err.Error()))
return
}
post.FilenameOriginal = html.EscapeString(handler.Filename)
filetype := getFileExtension(post.FilenameOriginal)
thumbFiletype := strings.ToLower(filetype)
if thumbFiletype == "gif" || thumbFiletype == "webm" {
thumbFiletype = "jpg"
}
post.Filename = getNewFilename() + "." + getFileExtension(post.FilenameOriginal)
boardExists, err := DoesBoardExistByID(HackyStringToInt(request.FormValue("boardid")))
if err != nil {
serveErrorPage(writer, "Server error: "+err.Error())
return
}
if !boardExists {
serveErrorPage(writer, "No boards have been created yet")
return
}
var _board = Board{}
err = _board.PopulateData(HackyStringToInt(request.FormValue("boardid")))
if err != nil {
serveErrorPage(writer, "Server error: "+err.Error())
return
}
boardDir := _board.Dir
filePath := path.Join(config.DocumentRoot, "/"+boardDir+"/src/", post.Filename)
thumbPath := path.Join(config.DocumentRoot, "/"+boardDir+"/thumb/", strings.Replace(post.Filename, "."+filetype, "t."+thumbFiletype, -1))
catalogThumbPath := path.Join(config.DocumentRoot, "/"+boardDir+"/thumb/", strings.Replace(post.Filename, "."+filetype, "c."+thumbFiletype, -1))
if err = ioutil.WriteFile(filePath, data, 0777); err != nil {
gclog.Printf(lErrorLog, "Couldn't write file %q: %s", post.Filename, err.Error())
serveErrorPage(writer, `Couldn't write file "`+post.FilenameOriginal+`"`)
return
}
// Calculate image checksum
post.FileChecksum = fmt.Sprintf("%x", md5.Sum(data))
var allowsVids bool
if allowsVids, err = GetEmbedsAllowed(post.BoardID); err != nil {
serveErrorPage(writer, gclog.Print(lErrorLog,
"Couldn't get board info: ", err.Error()))
return
}
if filetype == "webm" {
if !allowsVids || !config.AllowVideoUploads {
serveErrorPage(writer, gclog.Print(lAccessLog,
"Video uploading is not currently enabled for this board."))
os.Remove(filePath)
return
}
gclog.Printf(lAccessLog, "Receiving post with video: %s from %s, referrer: %s",
handler.Filename, post.IP, request.Referer())
if post.ParentID == 0 {
if err := createVideoThumbnail(filePath, thumbPath, config.ThumbWidth); err != nil {
serveErrorPage(writer, gclog.Print(lErrorLog,
"Error creating video thumbnail: ", err.Error()))
return
}
} else {
if err := createVideoThumbnail(filePath, thumbPath, config.ThumbWidth_reply); err != nil {
serveErrorPage(writer, gclog.Print(lErrorLog,
"Error creating video thumbnail: ", err.Error()))
return
}
}
if err := createVideoThumbnail(filePath, catalogThumbPath, config.ThumbWidth_catalog); err != nil {
serveErrorPage(writer, gclog.Print(lErrorLog,
"Error creating video thumbnail: ", err.Error()))
return
}
outputBytes, err := exec.Command("ffprobe", "-v", "quiet", "-show_format", "-show_streams", filePath).CombinedOutput()
if err != nil {
serveErrorPage(writer, gclog.Print(lErrorLog,
"Error getting video info: ", err.Error()))
return
}
if outputBytes != nil {
outputStringArr := strings.Split(string(outputBytes), "\n")
for _, line := range outputStringArr {
lineArr := strings.Split(line, "=")
if len(lineArr) < 2 {
continue
}
value, _ := strconv.Atoi(lineArr[1])
switch lineArr[0] {
case "width":
post.ImageW = value
case "height":
post.ImageH = value
case "size":
post.Filesize = value
}
}
if post.ParentID == 0 {
post.ThumbW, post.ThumbH = getThumbnailSize(post.ImageW, post.ImageH, "op")
} else {
post.ThumbW, post.ThumbH = getThumbnailSize(post.ImageW, post.ImageH, "reply")
}
}
} else {
// Attempt to load uploaded file with imaging library
img, err := imaging.Open(filePath)
if err != nil {
os.Remove(filePath)
gclog.Printf(lErrorLog, "Couldn't open uploaded file %q: %s", post.Filename, err.Error())
serveErrorPage(writer, "Upload filetype not supported")
return
}
// Get image filesize
stat, err := os.Stat(filePath)
if err != nil {
serveErrorPage(writer, gclog.Print(lErrorLog,
"Couldn't get image filesize: "+err.Error()))
return
}
post.Filesize = int(stat.Size())
// Get image width and height, as well as thumbnail width and height
post.ImageW = img.Bounds().Max.X
post.ImageH = img.Bounds().Max.Y
if post.ParentID == 0 {
post.ThumbW, post.ThumbH = getThumbnailSize(post.ImageW, post.ImageH, "op")
} else {
post.ThumbW, post.ThumbH = getThumbnailSize(post.ImageW, post.ImageH, "reply")
}
gclog.Printf(lAccessLog, "Receiving post with image: %q from %s, referrer: %s",
handler.Filename, post.IP, request.Referer())
if request.FormValue("spoiler") == "on" {
// If spoiler is enabled, symlink thumbnail to spoiler image
if _, err := os.Stat(path.Join(config.DocumentRoot, "spoiler.png")); err != nil {
serveErrorPage(writer, "missing /spoiler.png")
return
}
if err = syscall.Symlink(path.Join(config.DocumentRoot, "spoiler.png"), thumbPath); err != nil {
serveErrorPage(writer, err.Error())
return
}
} else if config.ThumbWidth >= post.ImageW && config.ThumbHeight >= post.ImageH {
// If image fits in thumbnail size, symlink thumbnail to original
post.ThumbW = img.Bounds().Max.X
post.ThumbH = img.Bounds().Max.Y
if err := syscall.Symlink(filePath, thumbPath); err != nil {
serveErrorPage(writer, err.Error())
return
}
} else {
var thumbnail image.Image
var catalogThumbnail image.Image
if post.ParentID == 0 {
// If this is a new thread, generate thumbnail and catalog thumbnail
thumbnail = createImageThumbnail(img, "op")
catalogThumbnail = createImageThumbnail(img, "catalog")
if err = imaging.Save(catalogThumbnail, catalogThumbPath); err != nil {
serveErrorPage(writer, gclog.Print(lErrorLog,
"Couldn't generate catalog thumbnail: ", err.Error()))
return
}
} else {
thumbnail = createImageThumbnail(img, "reply")
}
if err = imaging.Save(thumbnail, thumbPath); err != nil {
serveErrorPage(writer, gclog.Print(lErrorLog,
"Couldn't save thumbnail: ", err.Error()))
return
}
}
}
}
if strings.TrimSpace(post.MessageText) == "" && post.Filename == "" {
serveErrorPage(writer, "Post must contain a message if no image is uploaded.")
return
}
postDelay := SinceLastPost(&post)
if postDelay > -1 {
if post.ParentID == 0 && postDelay < config.NewThreadDelay {
serveErrorPage(writer, "Please wait before making a new thread.")
return
} else if post.ParentID > 0 && postDelay < config.ReplyDelay {
serveErrorPage(writer, "Please wait before making a reply.")
return
}
}
banStatus, err := getBannedStatus(request)
if err != nil && err != sql.ErrNoRows {
serveErrorPage(writer, gclog.Print(lErrorLog,
"Error getting banned status: ", err.Error()))
return
}
boards, _ := GetAllBoards()
postBoard, _ := GetBoardFromID(post.BoardID)
if isBanned(banStatus, postBoard.Dir) {
var banpageBuffer bytes.Buffer
if err = minifyTemplate(banpageTmpl, map[string]interface{}{
"config": config, "ban": banStatus, "banBoards": boards[post.BoardID-1].Dir,
}, writer, "text/html"); err != nil {
serveErrorPage(writer, gclog.Print(lErrorLog, "Error minifying page: ", err.Error()))
return
}
writer.Write(banpageBuffer.Bytes())
return
}
post.Sanitize()
if config.UseCaptcha {
captchaID := request.FormValue("captchaid")
captchaAnswer := request.FormValue("captchaanswer")
if captchaID == "" && captchaAnswer == "" {
// browser isn't using JS, save post data to tempPosts and show captcha
request.Form.Add("temppostindex", strconv.Itoa(len(tempPosts)))
request.Form.Add("emailcmd", emailCommand)
tempPosts = append(tempPosts, post)
serveCaptcha(writer, request)
return
}
}
if err = InsertPost(&post, emailCommand != "sage"); err != nil {
serveErrorPage(writer, gclog.Print(lErrorLog, "Error inserting post: ", err.Error()))
return
}
// rebuild the board page
buildBoards(post.BoardID)
buildFrontPage()
if emailCommand == "noko" {
if post.ParentID < 1 {
http.Redirect(writer, request, config.SiteWebfolder+postBoard.Dir+"/res/"+strconv.Itoa(post.ID)+".html", http.StatusFound)
} else {
http.Redirect(writer, request, config.SiteWebfolder+postBoard.Dir+"/res/"+strconv.Itoa(post.ParentID)+".html#"+strconv.Itoa(post.ID), http.StatusFound)
}
} else {
http.Redirect(writer, request, config.SiteWebfolder+postBoard.Dir+"/", http.StatusFound)
}
}
func tempCleaner() {
for {
select {
case <-tempCleanerTicker.C:
for p, post := range tempPosts {
if !time.Now().After(post.Timestamp.Add(time.Minute * 5)) {
continue
}
// temporary post is >= 5 minutes, time to prune it
tempPosts[p] = tempPosts[len(tempPosts)-1]
tempPosts = tempPosts[:len(tempPosts)-1]
if post.FilenameOriginal == "" {
continue
}
var board Board
err := board.PopulateData(post.BoardID)
if err != nil {
continue
}
fileSrc := path.Join(config.DocumentRoot, board.Dir, "src", post.FilenameOriginal)
if err = os.Remove(fileSrc); err != nil {
gclog.Printf(lErrorLog|lStdLog,
"Error pruning temporary upload for %q: %s", fileSrc, err.Error())
}
thumbSrc := getThumbnailPath("thread", fileSrc)
if err = os.Remove(thumbSrc); err != nil {
gclog.Printf(lErrorLog|lStdLog,
"Error pruning temporary upload for %q: %s", thumbSrc, err.Error())
}
if post.ParentID == 0 {
catalogSrc := getThumbnailPath("catalog", fileSrc)
if err = os.Remove(catalogSrc); err != nil {
gclog.Printf(lErrorLog|lStdLog,
"Error pruning temporary upload for %s: %s", catalogSrc, err.Error())
}
}
}
}
}
}
func formatMessage(message string) string {
message = msgfmtr.Compile(message)
// prepare each line to be formatted
postLines := strings.Split(message, "<br>")
for i, line := range postLines {
trimmedLine := strings.TrimSpace(line)
lineWords := strings.Split(trimmedLine, " ")
isGreentext := false // if true, append </span> to end of line
for w, word := range lineWords {
if strings.LastIndex(word, gt+gt) == 0 {
//word is a backlink
if postID, err := strconv.Atoi(word[8:]); err == nil {
// the link is in fact, a valid int
var boardDir string
var linkParent int
if boardDir, err = GetBoardFromPostID(postID); err != nil {
gclog.Print(lErrorLog, "Error getting board dir for backlink: ", err.Error())
}
if linkParent, err = GetThreadIDZeroIfTopPost(postID); err != nil {
gclog.Print(lErrorLog, "Error getting post parent for backlink: ", err.Error())
}
// get post board dir
if boardDir == "" {
lineWords[w] = "<a href=\"javascript:;\"><strike>" + word + "</strike></a>"
} else if linkParent == 0 {
lineWords[w] = "<a href=\"" + config.SiteWebfolder + boardDir + "/res/" + word[8:] + ".html\" class=\"postref\">" + word + "</a>"
} else {
lineWords[w] = "<a href=\"" + config.SiteWebfolder + boardDir + "/res/" + strconv.Itoa(linkParent) + ".html#" + word[8:] + "\" class=\"postref\">" + word + "</a>"
}
}
} else if strings.Index(word, gt) == 0 && w == 0 {
// word is at the beginning of a line, and is greentext
isGreentext = true
lineWords[w] = "<span class=\"greentext\">" + word
}
}
line = strings.Join(lineWords, " ")
if isGreentext {
line += "</span>"
}
postLines[i] = line
}
return strings.Join(postLines, "<br />")
}
func bannedForever(ban *BanInfo) bool {
return ban.Permaban && !ban.CanAppeal && ban.Type == 3 && ban.Boards == ""
}
func banHandler(writer http.ResponseWriter, request *http.Request) {
appealMsg := request.FormValue("appealmsg")
banStatus, err := getBannedStatus(request)
if appealMsg != "" {
if bannedForever(banStatus) {
fmt.Fprint(writer, "No.")
return
}
escapedMsg := html.EscapeString(appealMsg)
if err = AddBanAppeal(banStatus.ID, escapedMsg); err != nil {
serveErrorPage(writer, err.Error())
}
fmt.Fprint(writer,
"Appeal sent. It will (hopefully) be read by a staff member. check "+config.SiteWebfolder+"banned occasionally for a response",
)
return
}
if err != nil && err != sql.ErrNoRows {
serveErrorPage(writer, gclog.Print(lErrorLog,
"Error getting banned status:", err.Error()))
return
}
if err = minifyTemplate(banpageTmpl, map[string]interface{}{
"config": config, "ban": banStatus, "banBoards": banStatus.Boards, "post": Post{},
}, writer, "text/html"); err != nil {
serveErrorPage(writer, gclog.Print(lErrorLog,
"Error minifying page template: ", err.Error()))
return
}
}

View file

@ -1,246 +0,0 @@
package main
import (
"database/sql"
"fmt"
"io/ioutil"
"regexp"
"strings"
"time"
_ "github.com/go-sql-driver/mysql"
_ "github.com/lib/pq"
_ "github.com/mattn/go-sqlite3"
)
const (
mysqlDatetimeFormat = "2006-01-02 15:04:05"
unsupportedSQLVersionMsg = `Received syntax error while preparing a SQL string.
This means that either there is a bug in gochan's code (hopefully not) or that you are using an unsupported My/Postgre/SQLite version.
Before reporting an error, make sure that you are using the up to date version of your selected SQL server.
Error text: %s
`
)
var (
db *sql.DB
nilTimestamp string
sqlReplacer *strings.Replacer // used during SQL string preparation
)
func connectToSQLServer() {
var err error
var connStr string
sqlReplacer = strings.NewReplacer(
"DBNAME", config.DBname,
"DBPREFIX", config.DBprefix,
"\n", " ")
gclog.Print(lStdLog|lErrorLog, "Initializing server...")
switch config.DBtype {
case "mysql":
connStr = fmt.Sprintf("%s:%s@%s/%s?parseTime=true&collation=utf8mb4_unicode_ci",
config.DBusername, config.DBpassword, config.DBhost, config.DBname)
nilTimestamp = "0000-00-00 00:00:00"
case "postgres":
connStr = fmt.Sprintf("postgres://%s:%s@%s/%s?sslmode=disable",
config.DBusername, config.DBpassword, config.DBhost, config.DBname)
nilTimestamp = "0001-01-01 00:00:00"
case "sqlite3":
gclog.Print(lErrorLog|lStdLog, "sqlite3 support is still flaky, consider using mysql or postgres")
connStr = fmt.Sprintf("file:%s?mode=rwc&_auth&_auth_user=%s&_auth_pass=%s&cache=shared",
config.DBhost, config.DBusername, config.DBpassword)
nilTimestamp = "0001-01-01 00:00:00+00:00"
default:
gclog.Printf(lErrorLog|lStdLog|lFatal,
`Invalid DBtype %q in gochan.json, valid values are "mysql", "postgres", and "sqlite3"`, config.DBtype)
}
if db, err = sql.Open(config.DBtype, connStr); err != nil {
gclog.Print(lErrorLog|lStdLog|lFatal, "Failed to connect to the database: ", err.Error())
}
if err = initDB("initdb_" + config.DBtype + ".sql"); err != nil {
gclog.Print(lErrorLog|lStdLog|lFatal, "Failed initializing DB: ", err.Error())
}
//Not needed anymore
// var truncateStr string
// switch config.DBtype {
// case "mysql":
// fallthrough
// case "postgres":
// truncateStr = "TRUNCATE TABLE DBPREFIXsessions"
// case "sqlite3":
// truncateStr = "DELETE FROM DBPREFIXsessions"
// }
// if _, err = execSQL(truncateStr); err != nil {
// gclog.Print(lErrorLog|lStdLog|lFatal, "Failed initializing DB: ", err.Error())
// }
// Create generic "Main" section if one doesn't already exist
if err = CreateDefaultSectionIfNotExist(); err != nil {
gclog.Print(lErrorLog|lStdLog|lFatal, "Failed initializing DB: ", err.Error())
}
//TODO fix new install thing once it works with existing database
// var sqlVersionStr string
// isNewInstall := false
// if err = queryRowSQL("SELECT value FROM DBPREFIXinfo WHERE name = 'version'",
// []interface{}{}, []interface{}{&sqlVersionStr},
// ); err == sql.ErrNoRows {
// isNewInstall = true
// } else if err != nil {
// gclog.Print(lErrorLog|lStdLog|lFatal, "Failed initializing DB: ", err.Error())
// }
err = CreateDefaultBoardIfNoneExist()
if err != nil {
gclog.Print(lErrorLog|lStdLog|lFatal, "Failed creating default board: ", err.Error())
}
err = CreateDefaultAdminIfNoStaff()
if err != nil {
gclog.Print(lErrorLog|lStdLog|lFatal, "Failed creating default admin account: ", err.Error())
}
//fix versioning thing
}
func initDB(initFile string) error {
var err error
filePath := findResource(initFile,
"/usr/local/share/gochan/"+initFile,
"/usr/share/gochan/"+initFile)
if filePath == "" {
return fmt.Errorf("SQL database initialization file (%s) missing. Please reinstall gochan", initFile)
}
sqlBytes, err := ioutil.ReadFile(filePath)
if err != nil {
return err
}
sqlStr := regexp.MustCompile("--.*\n?").ReplaceAllString(string(sqlBytes), " ")
sqlArr := strings.Split(sqlReplacer.Replace(sqlStr), ";")
for _, statement := range sqlArr {
if statement != "" && statement != " " {
if _, err = db.Exec(statement); err != nil {
return err
}
}
}
return nil
}
// checks to see if the given error is a syntax error (used for built-in strings)
func sqlVersionErr(err error) error {
if err == nil {
return nil
}
errText := err.Error()
switch config.DBtype {
case "mysql":
if !strings.Contains(errText, "You have an error in your SQL syntax") {
return err
}
case "postgres":
if !strings.Contains(errText, "syntax error at or near") {
return err
}
case "sqlite3":
if !strings.Contains(errText, "Error: near ") {
return err
}
}
return fmt.Errorf(unsupportedSQLVersionMsg, errText)
}
// used for generating a prepared SQL statement formatted according to config.DBtype
func prepareSQL(query string) (*sql.Stmt, error) {
var preparedStr string
switch config.DBtype {
case "mysql":
fallthrough
case "sqlite3":
preparedStr = query
case "postgres":
arr := strings.Split(query, "?")
for i := range arr {
if i == len(arr)-1 {
break
}
arr[i] += fmt.Sprintf("$%d", i+1)
}
preparedStr = strings.Join(arr, "")
}
stmt, err := db.Prepare(sqlReplacer.Replace(preparedStr))
return stmt, sqlVersionErr(err)
}
/*
* Automatically escapes the given values and caches the statement
* Example:
* var intVal int
* var stringVal string
* result, err := execSQL("INSERT INTO tablename (intval,stringval) VALUES(?,?)", intVal, stringVal)
*/
func execSQL(query string, values ...interface{}) (sql.Result, error) {
stmt, err := prepareSQL(query)
defer closeHandle(stmt)
if err != nil {
return nil, err
}
return stmt.Exec(values...)
}
/*
* Gets a row from the db with the values in values[] and fills the respective pointers in out[]
* Automatically escapes the given values and caches the query
* Example:
* id := 32
* var intVal int
* var stringVal string
* err := queryRowSQL("SELECT intval,stringval FROM table WHERE id = ?",
* []interface{}{&id},
* []interface{}{&intVal, &stringVal}
* )
*/
func queryRowSQL(query string, values []interface{}, out []interface{}) error {
stmt, err := prepareSQL(query)
defer closeHandle(stmt)
if err != nil {
return err
}
return stmt.QueryRow(values...).Scan(out...)
}
/*
* Gets all rows from the db with the values in values[] and fills the respective pointers in out[]
* Automatically escapes the given values and caches the query
* Example:
* rows, err := querySQL("SELECT * FROM table")
* if err == nil {
* for rows.Next() {
* var intVal int
* var stringVal string
* rows.Scan(&intVal, &stringVal)
* // do something with intVal and stringVal
* }
* }
*/
func querySQL(query string, a ...interface{}) (*sql.Rows, error) {
stmt, err := prepareSQL(query)
defer closeHandle(stmt)
if err != nil {
return nil, err
}
return stmt.Query(a...)
}
func getSQLDateTime() string {
return time.Now().Format(mysqlDatetimeFormat)
}
func getSpecificSQLDateTime(t time.Time) string {
return t.Format(mysqlDatetimeFormat)
}

View file

@ -1,713 +0,0 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"html"
"io/ioutil"
"math/rand"
"os"
"path"
"strconv"
"time"
"github.com/frustra/bbcode"
)
const (
dirIsAFileStr = "unable to create \"%s\", path exists and is a file"
pathExistsStr = "unable to create \"%s\", path already exists"
genericErrStr = "unable to create \"%s\": %s"
)
var (
config GochanConfig
readBannedIPs []string
msgfmtr MsgFormatter
version *GochanVersion
)
type MsgFormatter struct {
// Go's garbage collection does weird things with bbcode's internal tag map.
// Moving the bbcode compiler isntance (and eventually a Markdown compiler) to a struct
// appears to fix this
bbCompiler bbcode.Compiler
}
func (mf *MsgFormatter) InitBBcode() {
if config.DisableBBcode {
return
}
mf.bbCompiler = bbcode.NewCompiler(true, true)
mf.bbCompiler.SetTag("center", nil)
mf.bbCompiler.SetTag("code", nil)
mf.bbCompiler.SetTag("color", nil)
mf.bbCompiler.SetTag("img", nil)
mf.bbCompiler.SetTag("quote", nil)
mf.bbCompiler.SetTag("size", nil)
}
func (mf *MsgFormatter) Compile(msg string) string {
if config.DisableBBcode {
return msg
}
return mf.bbCompiler.Compile(msg)
}
type RecentPost struct {
BoardName string
BoardID int
PostID int
ParentID int
Name string
Tripcode string
Message string
Filename string
ThumbW int
ThumbH int
IP string
Timestamp time.Time
}
func (p *RecentPost) GetURL(includeDomain bool) string {
postURL := ""
if includeDomain {
postURL += config.SiteDomain
}
idStr := strconv.Itoa(p.PostID)
postURL += config.SiteWebfolder + p.BoardName + "/res/"
if p.ParentID == 0 {
postURL += idStr + ".html#" + idStr
} else {
postURL += strconv.Itoa(p.ParentID) + ".html#" + idStr
}
return postURL
}
type Thread struct {
OP Post `json:"-"`
NumReplies int `json:"replies"`
NumImages int `json:"images"`
OmittedPosts int `json:"omitted_posts"`
OmittedImages int `json:"omitted_images"`
BoardReplies []Post `json:"-"`
Sticky int `json:"sticky"`
Locked int `json:"locked"`
ThreadPage int `json:"-"`
}
// SQL Table structs
type Announcement struct {
ID uint `json:"no"`
Subject string `json:"sub"`
Message string `json:"com"`
Poster string `json:"name"`
Timestamp time.Time
}
type BanAppeal struct {
ID int
Ban int
Message string
Denied bool
StaffResponse string
}
type BanInfo struct {
ID uint
AllowRead bool
IP string
Name string
NameIsRegex bool
SilentBan uint8
Boards string
Staff string
Timestamp time.Time
Expires time.Time
Permaban bool
Reason string
Type int
StaffNote string
AppealAt time.Time
CanAppeal bool
}
type BannedHash struct {
ID uint
Checksum string
Description string
}
type Board struct {
ID int `json:"-"`
CurrentPage int `json:"-"`
NumPages int `json:"pages"`
ListOrder int `json:"-"`
Dir string `json:"board"`
Type int `json:"-"`
UploadType int `json:"-"`
Title string `json:"title"`
Subtitle string `json:"meta_description"`
Description string `json:"-"`
Section int `json:"-"`
MaxFilesize int `json:"max_filesize"`
MaxPages int `json:"max_pages"`
DefaultStyle string `json:"-"`
Locked bool `json:"is_archived"`
CreatedOn time.Time `json:"-"`
Anonymous string `json:"-"`
ForcedAnon bool `json:"-"`
MaxAge int `json:"-"`
AutosageAfter int `json:"bump_limit"`
NoImagesAfter int `json:"image_limit"`
MaxMessageLength int `json:"max_comment_chars"`
EmbedsAllowed bool `json:"-"`
RedirectToThread bool `json:"-"`
ShowID bool `json:"-"`
RequireFile bool `json:"-"`
EnableCatalog bool `json:"-"`
EnableSpoileredImages bool `json:"-"`
EnableSpoileredThreads bool `json:"-"`
Worksafe bool `json:"ws_board"`
ThreadPage int `json:"-"`
Cooldowns BoardCooldowns `json:"cooldowns"`
ThreadsPerPage int `json:"per_page"`
}
// AbsolutePath returns the full filepath of the board directory
func (board *Board) AbsolutePath(subpath ...string) string {
return path.Join(config.DocumentRoot, board.Dir, path.Join(subpath...))
}
// WebPath returns a string that represents the file's path as accessible by a browser
// fileType should be "boardPage", "threadPage", "upload", or "thumb"
func (board *Board) WebPath(fileName string, fileType string) string {
var filePath string
switch fileType {
case "":
fallthrough
case "boardPage":
filePath = path.Join(config.SiteWebfolder, board.Dir, fileName)
case "threadPage":
filePath = path.Join(config.SiteWebfolder, board.Dir, "res", fileName)
case "upload":
filePath = path.Join(config.SiteWebfolder, board.Dir, "src", fileName)
case "thumb":
filePath = path.Join(config.SiteWebfolder, board.Dir, "thumb", fileName)
}
return filePath
}
func (board *Board) PagePath(pageNum interface{}) string {
var page string
pageNumStr := fmt.Sprintf("%v", pageNum)
if pageNumStr == "prev" {
if board.CurrentPage < 2 {
page = "1"
} else {
page = strconv.Itoa(board.CurrentPage - 1)
}
} else if pageNumStr == "next" {
if board.CurrentPage >= board.NumPages {
page = strconv.Itoa(board.NumPages)
} else {
page = strconv.Itoa(board.CurrentPage + 1)
}
} else {
page = pageNumStr
}
return board.WebPath(page+".html", "boardPage")
}
// Build builds the board and its thread files
// if newBoard is true, it adds a row to DBPREFIXboards and fails if it exists
// if force is true, it doesn't fail if the directories exist but does fail if it is a file
func (board *Board) Build(newBoard bool, force bool) error {
var err error
if board.Dir == "" {
return errors.New("board must have a directory before it is built")
}
if board.Title == "" {
return errors.New("board must have a title before it is built")
}
dirPath := board.AbsolutePath()
resPath := board.AbsolutePath("res")
srcPath := board.AbsolutePath("src")
thumbPath := board.AbsolutePath("thumb")
dirInfo, _ := os.Stat(dirPath)
resInfo, _ := os.Stat(resPath)
srcInfo, _ := os.Stat(srcPath)
thumbInfo, _ := os.Stat(thumbPath)
if dirInfo != nil {
if !force {
return fmt.Errorf(pathExistsStr, dirPath)
}
if !dirInfo.IsDir() {
return fmt.Errorf(dirIsAFileStr, dirPath)
}
} else {
if err = os.Mkdir(dirPath, 0666); err != nil {
return fmt.Errorf(genericErrStr, dirPath, err.Error())
}
}
if resInfo != nil {
if !force {
return fmt.Errorf(pathExistsStr, resPath)
}
if !resInfo.IsDir() {
return fmt.Errorf(dirIsAFileStr, resPath)
}
} else {
if err = os.Mkdir(resPath, 0666); err != nil {
return fmt.Errorf(genericErrStr, resPath, err.Error())
}
}
if srcInfo != nil {
if !force {
return fmt.Errorf(pathExistsStr, srcPath)
}
if !srcInfo.IsDir() {
return fmt.Errorf(dirIsAFileStr, srcPath)
}
} else {
if err = os.Mkdir(srcPath, 0666); err != nil {
return fmt.Errorf(genericErrStr, srcPath, err.Error())
}
}
if thumbInfo != nil {
if !force {
return fmt.Errorf(pathExistsStr, thumbPath)
}
if !thumbInfo.IsDir() {
return fmt.Errorf(dirIsAFileStr, thumbPath)
}
} else {
if err = os.Mkdir(thumbPath, 0666); err != nil {
return fmt.Errorf(genericErrStr, thumbPath, err.Error())
}
}
if newBoard {
board.CreatedOn = time.Now()
err := CreateBoard(board)
if err != nil {
return err
}
} else {
if err = board.UpdateID(); err != nil {
return err
}
}
buildBoardPages(board)
buildThreads(true, board.ID, 0)
resetBoardSectionArrays()
buildFrontPage()
if board.EnableCatalog {
buildCatalog(board.ID)
}
buildBoardListJSON()
return nil
}
func (board *Board) SetDefaults() {
board.ListOrder = 0
board.Section = 1
board.MaxFilesize = 4096
board.MaxPages = 11
board.DefaultStyle = config.DefaultStyle
board.Locked = false
board.Anonymous = "Anonymous"
board.ForcedAnon = false
board.MaxAge = 0
board.AutosageAfter = 200
board.NoImagesAfter = 0
board.MaxMessageLength = 8192
board.EmbedsAllowed = true
board.RedirectToThread = false
board.ShowID = false
board.RequireFile = false
board.EnableCatalog = true
board.EnableSpoileredImages = true
board.EnableSpoileredThreads = true
board.Worksafe = true
board.ThreadsPerPage = 10
}
type BoardSection struct {
ID int
ListOrder int
Hidden bool
Name string
Abbreviation string
}
// Post represents each post in the database
type Post struct {
ID int `json:"no"`
ParentID int `json:"resto"`
CurrentPage int `json:"-"`
BoardID int `json:"-"`
Name string `json:"name"`
Tripcode string `json:"trip"`
Email string `json:"email"`
Subject string `json:"sub"`
MessageHTML string `json:"com"`
MessageText string `json:"-"`
Password string `json:"-"`
Filename string `json:"tim"`
FilenameOriginal string `json:"filename"`
FileChecksum string `json:"md5"`
FileExt string `json:"extension"`
Filesize int `json:"fsize"`
ImageW int `json:"w"`
ImageH int `json:"h"`
ThumbW int `json:"tn_w"`
ThumbH int `json:"tn_h"`
IP string `json:"-"`
Capcode string `json:"capcode"`
Timestamp time.Time `json:"time"`
Autosage bool `json:"-"`
DeletedTimestamp time.Time `json:"-"`
Bumped time.Time `json:"last_modified"`
Stickied bool `json:"-"`
Locked bool `json:"-"`
Reviewed bool `json:"-"`
}
func (p *Post) GetURL(includeDomain bool) string {
postURL := ""
if includeDomain {
postURL += config.SiteDomain
}
var board Board
if err := board.PopulateData(p.BoardID); err != nil {
return postURL
}
idStr := strconv.Itoa(p.ID)
postURL += config.SiteWebfolder + board.Dir + "/res/"
if p.ParentID == 0 {
postURL += idStr + ".html#" + idStr
} else {
postURL += strconv.Itoa(p.ParentID) + ".html#" + idStr
}
return postURL
}
// Sanitize escapes HTML strings in a post. This should be run immediately before
// the post is inserted into the database
func (p *Post) Sanitize() {
p.Name = html.EscapeString(p.Name)
p.Email = html.EscapeString(p.Email)
p.Subject = html.EscapeString(p.Subject)
p.Password = html.EscapeString(p.Password)
}
type Report struct {
ID uint
Board string
PostID uint
Timestamp time.Time
IP string
Reason string
Cleared bool
IsTemp bool
}
type LoginSession struct {
ID uint
Name string
Data string
Expires string
}
// Staff represents a single staff member's info stored in the database
type Staff struct {
ID int
Username string
PasswordChecksum string
Rank int
Boards string
AddedOn time.Time
LastActive time.Time
}
type WordFilter struct {
ID int
From string
To string
Boards string
RegEx bool
}
type BoardCooldowns struct {
NewThread int `json:"threads"`
Reply int `json:"replies"`
ImageReply int `json:"images"`
}
type Style struct {
Name string
Filename string
}
// GochanConfig stores crucial info and is read from/written to gochan.json
type GochanConfig struct {
ListenIP string
Port int
FirstPage []string
Username string
UseFastCGI bool
DebugMode bool `description:"Disables several spam/browser checks that can cause problems when hosting an instance locally."`
DocumentRoot string
TemplateDir string
LogDir string
DBtype string
DBhost string
DBname string
DBusername string
DBpassword string
DBprefix string
Lockdown bool `description:"Disables posting." default:"unchecked"`
LockdownMessage string `description:"Message displayed when someone tries to post while the site is on lockdown."`
Sillytags []string `description:"List of randomly selected staff tags separated by line, e.g. <span style=\"color: red;\">## Mod</span>, to be randomly assigned to posts if UseSillytags is checked. Don't include the \"## \""`
UseSillytags bool `description:"Use Sillytags" default:"unchecked"`
Modboard string `description:"A super secret clubhouse board that only staff can view/post to." default:"staff"`
SiteName string `description:"The name of the site that appears in the header of the front page." default:"Gochan"`
SiteSlogan string `description:"The text that appears below SiteName on the home page"`
SiteHeaderURL string `description:"To be honest, I'm not even sure what this does. It'll probably be removed later."`
SiteWebfolder string `description:"The HTTP root appearing in the browser (e.g. https://gochan.org/&lt;SiteWebFolder&gt;" default:"/"`
SiteDomain string `description:"The server's domain (duh). Do not edit this unless you know what you are doing or BAD THINGS WILL HAPPEN!" default:"127.0.0.1" critical:"true"`
DomainRegex string `description:"Regular expression used for incoming request validation. Do not edit this unless you know what you are doing or BAD THINGS WILL HAPPEN!" default:"(https|http):\\\\/\\\\/(gochan\\\\.lunachan\\.net|gochan\\\\.org)\\/(.*)" critical:"true"`
Styles []Style `description:"List of styles (one per line) that should be accessed online at &lt;SiteWebFolder&gt;/css/&lt;Style&gt;/"`
DefaultStyle string `description:"Filename of the default Style. This should appear in the list above or bad things might happen."`
AllowDuplicateImages bool `description:"Disabling this will cause gochan to reject a post if the image has already been uploaded for another post.<br />This may end up being removed or being made board-specific in the future." default:"checked"`
AllowVideoUploads bool `description:"Allows users to upload .webm videos. <br />This may end up being removed or being made board-specific in the future."`
NewThreadDelay int `description:"The amount of time in seconds that is required before an IP can make a new thread.<br />This may end up being removed or being made board-specific in the future." default:"30"`
ReplyDelay int `description:"Same as the above, but for replies." default:"7"`
MaxLineLength int `description:"Any line in a post that exceeds this will be split into two (or more) lines.<br />I'm not really sure why this is here, so it may end up being removed." default:"150"`
ReservedTrips []string `description:"Secure tripcodes (!!Something) can be reserved here.<br />Each reservation should go on its own line and should look like this:<br />TripPassword1##Tripcode1<br />TripPassword2##Tripcode2"`
ThumbWidth int `description:"OP thumbnails use this as their max width.<br />To keep the aspect ratio, the image will be scaled down to the ThumbWidth or ThumbHeight, whichever is larger." default:"200"`
ThumbHeight int `description:"OP thumbnails use this as their max height.<br />To keep the aspect ratio, the image will be scaled down to the ThumbWidth or ThumbHeight, whichever is larger." default:"200"`
ThumbWidth_reply int `description:"Same as ThumbWidth and ThumbHeight but for reply images." default:"125"`
ThumbHeight_reply int `description:"Same as ThumbWidth and ThumbHeight but for reply images." default:"125"`
ThumbWidth_catalog int `description:"Same as ThumbWidth and ThumbHeight but for catalog images." default:"50"`
ThumbHeight_catalog int `description:"Same as ThumbWidth and ThumbHeight but for catalog images." default:"50"`
ThreadsPerPage int `default:"15"`
RepliesOnBoardPage int `description:"Number of replies to a thread to show on the board page." default:"3"`
StickyRepliesOnBoardPage int `description:"Same as above for stickied threads." default:"1"`
BanColors []string `description:"Colors to be used for public ban messages (e.g. USER WAS BANNED FOR THIS POST).<br />Each entry should be on its own line, and should look something like this:<br />username1:#FF0000<br />username2:#FAF00F<br />username3:blue<br />Invalid entries/nonexistent usernames will show a warning and use the default red."`
BanMsg string `description:"The default public ban message." default:"USER WAS BANNED FOR THIS POST"`
EmbedWidth int `description:"The width for inline/expanded webm videos." default:"200"`
EmbedHeight int `description:"The height for inline/expanded webm videos." default:"164"`
ExpandButton bool `description:"If checked, adds [Embed] after a Youtube, Vimeo, etc link to toggle an inline video frame." default:"checked"`
ImagesOpenNewTab bool `description:"If checked, thumbnails will open the respective image/video in a new tab instead of expanding them." default:"unchecked"`
MakeURLsHyperlinked bool `description:"If checked, URLs in posts will be turned into a hyperlink. If unchecked, ExpandButton and NewTabOnOutlinks are ignored." default:"checked"`
NewTabOnOutlinks bool `description:"If checked, links to external sites will open in a new tab." default:"checked"`
DisableBBcode bool `description:"If checked, gochan will not compile bbcode into HTML" default:"unchecked"`
MinifyHTML bool `description:"If checked, gochan will minify html files when building" default:"checked"`
MinifyJS bool `description:"If checked, gochan will minify js and json files when building" default:"checked"`
UseMinifiedGochanJS bool `json:"-"`
DateTimeFormat string `description:"The format used for dates. See <a href=\"https://golang.org/pkg/time/#Time.Format\">here</a> for more info." default:"Mon, January 02, 2006 15:04 PM"`
AkismetAPIKey string `description:"The API key to be sent to Akismet for post spam checking. If the key is invalid, Akismet won't be used."`
UseCaptcha bool `description:"If checked, a captcha will be generated"`
CaptchaWidth int `description:"Width of the generated captcha image" default:"240"`
CaptchaHeight int `description:"Height of the generated captcha image" default:"80"`
CaptchaMinutesExpire int `description:"Number of minutes before a user has to enter a new CAPTCHA before posting. If <1 they have to submit one for every post." default:"15"`
EnableGeoIP bool `description:"If checked, this enables the usage of GeoIP for posts." default:"checked"`
GeoIPDBlocation string `description:"Specifies the location of the GeoIP database file. If you're using CloudFlare, you can set it to cf to rely on CloudFlare for GeoIP information." default:"/usr/share/GeoIP/GeoIP.dat"`
MaxRecentPosts int `description:"The maximum number of posts to show on the Recent Posts list on the front page." default:"3"`
RecentPostsWithNoFile bool `description:"If checked, recent posts with no image/upload are shown on the front page (as well as those with images" default:"unchecked"`
EnableAppeals bool `description:"If checked, allow banned users to appeal their bans.<br />This will likely be removed (permanently allowing appeals) or made board-specific in the future." default:"checked"`
MaxLogDays int `description:"The maximum number of days to keep messages in the moderation/staff log file."`
RandomSeed string `critical:"true"`
TimeZone int `json:"-"`
}
type MessagePostContainer struct {
ID int
MessageRaw string
Message string
}
func (cfg *GochanConfig) CheckString(val, defaultVal string, critical bool, msg string) string {
if val == "" {
val = defaultVal
flags := lStdLog | lErrorLog
if critical {
flags |= lFatal
}
gclog.Print(flags, msg)
}
return val
}
func (cfg *GochanConfig) CheckInt(val, defaultVal int, critical bool, msg string) int {
if val == 0 {
val = defaultVal
flags := lStdLog | lErrorLog
if critical {
flags |= lFatal
}
gclog.Print(flags, msg)
}
return val
}
func initConfig() {
cfgPath := findResource("gochan.json", "/etc/gochan/gochan.json")
if cfgPath == "" {
fmt.Println("gochan.json not found")
os.Exit(1)
}
jfile, err := ioutil.ReadFile(cfgPath)
if err != nil {
fmt.Printf("Error reading %s: %s\n", cfgPath, err.Error())
os.Exit(1)
}
if err = json.Unmarshal(jfile, &config); err != nil {
fmt.Printf("Error parsing %s: %s\n", cfgPath, err.Error())
os.Exit(1)
}
config.LogDir = findResource(config.LogDir, "log", "/var/log/gochan/")
if gclog, err = initLogs(
path.Join(config.LogDir, "access.log"),
path.Join(config.LogDir, "error.log"),
path.Join(config.LogDir, "staff.log"),
); err != nil {
fmt.Println(err.Error())
os.Exit(1)
}
config.CheckString(config.ListenIP, "", true, "ListenIP not set in gochan.json, halting.")
if config.Port == 0 {
config.Port = 80
}
if len(config.FirstPage) == 0 {
config.FirstPage = []string{"index.html", "board.html", "firstrun.html"}
}
config.Username = config.CheckString(config.Username, "gochan", false, "Username not set in gochan.json, using 'gochan' as default")
config.DocumentRoot = config.CheckString(config.DocumentRoot, "gochan", true, "DocumentRoot not set in gochan.json, halting.")
wd, wderr := os.Getwd()
if wderr == nil {
_, staterr := os.Stat(path.Join(wd, config.DocumentRoot, "css"))
if staterr == nil {
config.DocumentRoot = path.Join(wd, config.DocumentRoot)
}
}
config.TemplateDir = config.CheckString(
findResource(config.TemplateDir, "templates", "/usr/local/share/gochan/templates/", "/usr/share/gochan/templates/"),
"", true, "Unable to locate template directory, halting.")
config.CheckString(config.DBtype, "", true, "DBtype not set in gochan.json, halting (currently supported values: mysql,postgresql,sqlite3)")
config.CheckString(config.DBhost, "", true, "DBhost not set in gochan.json, halting.")
config.DBname = config.CheckString(config.DBname, "gochan", false,
"DBname not set in gochan.json, setting to 'gochan'")
config.CheckString(config.DBusername, "", true, "DBusername not set in gochan, halting.")
config.CheckString(config.DBpassword, "", true, "DBpassword not set in gochan, halting.")
config.LockdownMessage = config.CheckString(config.LockdownMessage,
"The administrator has temporarily disabled posting. We apologize for the inconvenience", false, "")
config.CheckString(config.SiteName, "", true, "SiteName not set in gochan.json, halting.")
config.CheckString(config.SiteDomain, "", true, "SiteName not set in gochan.json, halting.")
if config.SiteWebfolder == "" {
gclog.Print(lErrorLog|lStdLog, "SiteWebFolder not set in gochan.json, using / as default.")
} else if string(config.SiteWebfolder[0]) != "/" {
config.SiteWebfolder = "/" + config.SiteWebfolder
}
if config.SiteWebfolder[len(config.SiteWebfolder)-1:] != "/" {
config.SiteWebfolder += "/"
}
config.CheckString(config.DomainRegex, "", true, `DomainRegex not set in gochan.json. Consider using something like "(https|http):\\/\\/(gochan\\.org)\\/(.*)"`)
//config.DomainRegex = "(https|http):\\/\\/(" + config.SiteDomain + ")\\/(.*)"
if config.Styles == nil {
gclog.Print(lErrorLog|lStdLog|lFatal, "Styles not set in gochan.json, halting.")
}
config.DefaultStyle = config.CheckString(config.DefaultStyle, config.Styles[0].Filename, false, "")
config.NewThreadDelay = config.CheckInt(config.NewThreadDelay, 30, false, "")
config.ReplyDelay = config.CheckInt(config.ReplyDelay, 7, false, "")
config.MaxLineLength = config.CheckInt(config.MaxLineLength, 150, false, "")
//ReservedTrips string //eventually this will be map[string]string
config.ThumbWidth = config.CheckInt(config.ThumbWidth, 200, false, "")
config.ThumbHeight = config.CheckInt(config.ThumbHeight, 200, false, "")
config.ThumbWidth_reply = config.CheckInt(config.ThumbWidth_reply, 125, false, "")
config.ThumbHeight_reply = config.CheckInt(config.ThumbHeight_reply, 125, false, "")
config.ThumbWidth_catalog = config.CheckInt(config.ThumbWidth_catalog, 50, false, "")
config.ThumbHeight_catalog = config.CheckInt(config.ThumbHeight_catalog, 50, false, "")
config.ThreadsPerPage = config.CheckInt(config.ThreadsPerPage, 10, false, "")
config.RepliesOnBoardPage = config.CheckInt(config.RepliesOnBoardPage, 3, false, "")
config.StickyRepliesOnBoardPage = config.CheckInt(config.StickyRepliesOnBoardPage, 1, false, "")
/*config.BanColors, err = c.GetString("threads", "ban_colors") //eventually this will be map[string] string
if err != nil {
config.BanColors = "admin:#CC0000"
}*/
config.BanMsg = config.CheckString(config.BanMsg, "(USER WAS BANNED FOR THIS POST)", false, "")
config.DateTimeFormat = config.CheckString(config.DateTimeFormat, "Mon, January 02, 2006 15:04 PM", false, "")
config.CaptchaWidth = config.CheckInt(config.CaptchaWidth, 240, false, "")
config.CaptchaHeight = config.CheckInt(config.CaptchaHeight, 80, false, "")
if config.EnableGeoIP {
if config.GeoIPDBlocation == "" {
gclog.Print(lErrorLog|lStdLog, "GeoIPDBlocation not set in gochan.json, disabling EnableGeoIP")
config.EnableGeoIP = false
}
}
if config.MaxLogDays == 0 {
config.MaxLogDays = 15
}
if config.RandomSeed == "" {
gclog.Print(lErrorLog|lStdLog, "RandomSeed not set in gochan.json, Generating a random one.")
for i := 0; i < 8; i++ {
num := rand.Intn(127-32) + 32
config.RandomSeed += fmt.Sprintf("%c", num)
}
configJSON, _ := json.MarshalIndent(config, "", "\t")
if err = ioutil.WriteFile(cfgPath, configJSON, 0777); err != nil {
gclog.Printf(lErrorLog|lStdLog|lFatal, "Unable to write %s with randomly generated seed: %s", cfgPath, err.Error())
}
}
_, zoneOffset := time.Now().Zone()
config.TimeZone = zoneOffset / 60 / 60
msgfmtr.InitBBcode()
version = ParseVersion(versionStr)
version.Normalize()
}

View file

@ -1,367 +0,0 @@
package main
import (
"crypto/md5"
"crypto/sha1"
"encoding/json"
"errors"
"fmt"
"io"
"io/ioutil"
"math/rand"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
"time"
libgeo "github.com/nranchev/go-libGeoIP"
"golang.org/x/crypto/bcrypt"
)
var (
errEmptyDurationString = errors.New("Empty Duration string")
errInvalidDurationString = errors.New("Invalid Duration string")
durationRegexp = regexp.MustCompile(`^((\d+)\s?ye?a?r?s?)?\s?((\d+)\s?mon?t?h?s?)?\s?((\d+)\s?we?e?k?s?)?\s?((\d+)\s?da?y?s?)?\s?((\d+)\s?ho?u?r?s?)?\s?((\d+)\s?mi?n?u?t?e?s?)?\s?((\d+)\s?s?e?c?o?n?d?s?)?$`)
)
func arrToString(arr []string) string {
var out string
for i, val := range arr {
out += val
if i < len(arr)-1 {
out += ","
}
}
return out
}
func md5Sum(str string) string {
hash := md5.New()
io.WriteString(hash, str)
return fmt.Sprintf("%x", hash.Sum(nil))
}
func sha1Sum(str string) string {
hash := sha1.New()
io.WriteString(hash, str)
return fmt.Sprintf("%x", hash.Sum(nil))
}
func bcryptSum(str string) string {
digest, err := bcrypt.GenerateFromPassword([]byte(str), 4)
if err == nil {
return string(digest)
}
return ""
}
func byteByByteReplace(input, from, to string) string {
if len(from) != len(to) {
return ""
}
for i := 0; i < len(from); i++ {
input = strings.Replace(input, from[i:i+1], to[i:i+1], -1)
}
return input
}
func closeHandle(handle io.Closer) {
if handle != nil {
handle.Close()
}
}
/*
* Deletes files in a folder (root) that match a given regular expression.
* Returns the number of files that were deleted, and any error encountered.
*/
func deleteMatchingFiles(root, match string) (filesDeleted int, err error) {
files, err := ioutil.ReadDir(root)
if err != nil {
return 0, err
}
for _, f := range files {
match, _ := regexp.MatchString(match, f.Name())
if match {
os.Remove(filepath.Join(root, f.Name()))
filesDeleted++
}
}
return filesDeleted, err
}
func getCountryCode(ip string) (string, error) {
if config.EnableGeoIP && config.GeoIPDBlocation != "" {
gi, err := libgeo.Load(config.GeoIPDBlocation)
if err != nil {
return "", err
}
return gi.GetLocationByIP(ip).CountryCode, nil
}
return "", nil
}
func randomString(length int) string {
var str string
for i := 0; i < length; i++ {
num := rand.Intn(127)
if num < 32 {
num += 32
}
str += string(num)
}
return str
}
func getFileExtension(filename string) (extension string) {
if !strings.Contains(filename, ".") {
extension = ""
} else {
extension = filename[strings.LastIndex(filename, ".")+1:]
}
return
}
func getFormattedFilesize(size float64) string {
if size < 1000 {
return fmt.Sprintf("%dB", int(size))
} else if size <= 100000 {
return fmt.Sprintf("%fKB", size/1024)
} else if size <= 100000000 {
return fmt.Sprintf("%fMB", size/1024.0/1024.0)
}
return fmt.Sprintf("%0.2fGB", size/1024.0/1024.0/1024.0)
}
func humanReadableTime(t time.Time) string {
return t.Format(config.DateTimeFormat)
}
func getThumbnailPath(thumbType string, img string) string {
filetype := strings.ToLower(img[strings.LastIndex(img, ".")+1:])
if filetype == "gif" || filetype == "webm" {
filetype = "jpg"
}
index := strings.LastIndex(img, ".")
if index < 0 || index > len(img) {
return ""
}
thumbSuffix := "t." + filetype
if thumbType == "catalog" {
thumbSuffix = "c." + filetype
}
return img[0:index] + thumbSuffix
}
// findResource searches for a file in the given paths and returns the first one it finds
// or a blank string if none of the paths exist
func findResource(paths ...string) string {
var err error
for _, filepath := range paths {
if _, err = os.Stat(filepath); err == nil {
return filepath
}
}
return ""
}
// paginate returns a 2d array of a specified interface from a 1d array passed in,
// with a specified number of values per array in the 2d array.
// interfaceLength is the number of interfaces per array in the 2d array (e.g, threads per page)
// interf is the array of interfaces to be split up.
func paginate(interfaceLength int, interf []interface{}) [][]interface{} {
// paginatedInterfaces = the finished interface array
// numArrays = the current number of arrays (before remainder overflow)
// interfacesRemaining = if greater than 0, these are the remaining interfaces
// that will be added to the super-interface
var paginatedInterfaces [][]interface{}
numArrays := len(interf) / interfaceLength
interfacesRemaining := len(interf) % interfaceLength
currentInterface := 0
for l := 0; l < numArrays; l++ {
paginatedInterfaces = append(paginatedInterfaces,
interf[currentInterface:currentInterface+interfaceLength])
currentInterface += interfaceLength
}
if interfacesRemaining > 0 {
paginatedInterfaces = append(paginatedInterfaces, interf[len(interf)-interfacesRemaining:])
}
return paginatedInterfaces
}
func resetBoardSectionArrays() {
// run when the board list needs to be changed (board/section is added, deleted, etc)
allBoards = nil
allSections = nil
allBoardsArr, _ := GetAllBoards()
allBoards = append(allBoards, allBoardsArr...)
allSectionsArr, _ := GetAllSections()
allSections = append(allSections, allSectionsArr...)
}
func searchStrings(item string, arr []string, permissive bool) int {
for i, str := range arr {
if item == str {
return i
}
}
return -1
}
// Checks the validity of the Akismet API key given in the config file.
func checkAkismetAPIKey(key string) error {
if key == "" {
return errors.New("blank key given, Akismet spam checking won't be used")
}
resp, err := http.PostForm("https://rest.akismet.com/1.1/verify-key", url.Values{"key": {key}, "blog": {"http://" + config.SiteDomain}})
if resp != nil {
defer closeHandle(resp.Body)
}
if err != nil {
return err
}
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return err
}
if string(body) == "invalid" {
// This should disable the Akismet checks if the API key is not valid.
errmsg := "Akismet API key is invalid, Akismet spam protection will be disabled."
gclog.Print(lErrorLog, errmsg)
return errors.New(errmsg)
}
return nil
}
// Checks a given post for spam with Akismet. Only checks if Akismet API key is set.
func checkPostForSpam(userIP string, userAgent string, referrer string,
author string, email string, postContent string) string {
if config.AkismetAPIKey != "" {
client := &http.Client{}
data := url.Values{"blog": {"http://" + config.SiteDomain}, "user_ip": {userIP}, "user_agent": {userAgent}, "referrer": {referrer},
"comment_type": {"forum-post"}, "comment_author": {author}, "comment_author_email": {email},
"comment_content": {postContent}}
req, err := http.NewRequest("POST", "https://"+config.AkismetAPIKey+".rest.akismet.com/1.1/comment-check",
strings.NewReader(data.Encode()))
if err != nil {
gclog.Print(lErrorLog, err.Error())
return "other_failure"
}
req.Header.Set("User-Agent", "gochan/1.0 | Akismet/0.1")
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := client.Do(req)
if resp != nil {
closeHandle(resp.Body)
}
if err != nil {
gclog.Print(lErrorLog, err.Error())
return "other_failure"
}
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
gclog.Print(lErrorLog, err.Error())
return "other_failure"
}
gclog.Print(lErrorLog, "Response from Akismet: ", string(body))
if string(body) == "true" {
if proTip, ok := resp.Header["X-akismet-pro-tip"]; ok && proTip[0] == "discard" {
return "discard"
}
return "spam"
} else if string(body) == "invalid" {
return "invalid"
} else if string(body) == "false" {
return "ham"
}
}
return "other_failure"
}
func marshalJSON(data interface{}, indent bool) (string, error) {
var jsonBytes []byte
var err error
if indent {
jsonBytes, err = json.MarshalIndent(data, "", " ")
} else {
jsonBytes, err = json.Marshal(data)
}
if err != nil {
jsonBytes, _ = json.Marshal(map[string]string{"error": err.Error()})
}
return string(jsonBytes), err
}
func limitArraySize(arr []string, maxSize int) []string {
if maxSize > len(arr)-1 || maxSize < 0 {
return arr
}
return arr[:maxSize]
}
func numReplies(boardid, threadid int) int {
num, err := GetReplyCount(threadid)
if err != nil {
return 0
}
return num
}
// based on TinyBoard's parse_time function
func parseDurationString(str string) (time.Duration, error) {
if str == "" {
return 0, errEmptyDurationString
}
matches := durationRegexp.FindAllStringSubmatch(str, -1)
if len(matches) == 0 {
return 0, errInvalidDurationString
}
var expire int
if matches[0][2] != "" {
years, _ := strconv.Atoi(matches[0][2])
expire += years * 60 * 60 * 24 * 365
}
if matches[0][4] != "" {
months, _ := strconv.Atoi(matches[0][4])
expire += months * 60 * 60 * 24 * 30
}
if matches[0][6] != "" {
weeks, _ := strconv.Atoi(matches[0][6])
expire += weeks * 60 * 60 * 24 * 7
}
if matches[0][8] != "" {
days, _ := strconv.Atoi(matches[0][8])
expire += days * 60 * 60 * 24
}
if matches[0][10] != "" {
hours, _ := strconv.Atoi(matches[0][10])
expire += hours * 60 * 60
}
if matches[0][12] != "" {
minutes, _ := strconv.Atoi(matches[0][12])
expire += minutes * 60
}
if matches[0][14] != "" {
seconds, _ := strconv.Atoi(matches[0][14])
expire += seconds
}
return time.ParseDuration(strconv.Itoa(expire) + "s")
}
func timezone() int {
_, offset := time.Now().Zone()
return offset / 60 / 60
}

2
vagrant/Vagrantfile vendored
View file

@ -21,7 +21,7 @@ Vagrant.configure("2") do |config|
config.vm.provision :shell, path: "bootstrap.sh", env: {
:DBTYPE => DBTYPE,
:GOPATH => "/vagrant/lib",
:GOPATH => "/home/vagrant/go",
:FROMDOCKER => ""
}, args: "install"
end

View file

@ -9,6 +9,7 @@ if [ -z "$DBTYPE" ]; then
exit 1
fi
add-apt-repository -y ppa:gophers/archive
apt-get -y update && apt-get -y upgrade
if [ "$DBTYPE" == "mysql" ]; then
@ -63,11 +64,9 @@ else
exit 1
fi
apt-get -y install git subversion mercurial nginx ffmpeg golang-1.10
mkdir -p /root/bin
ln -s /usr/lib/go-1.10/bin/* /root/bin/
export PATH=$PATH:/root/bin
echo "export PATH=$PATH:/root/bin" >> /root/.bashrc
apt-get -y install git subversion mercurial nginx ffmpeg make golang-1.11
ln -s /usr/lib/go-1.11/bin/* /usr/local/bin/
rm -f /etc/nginx/sites-enabled/* /etc/nginx/sites-available/*
ln -sf /vagrant/sample-configs/gochan-fastcgi.nginx /etc/nginx/sites-available/gochan.nginx
@ -90,7 +89,6 @@ cp /vagrant/sample-configs/gochan.example.json /etc/gochan/gochan.json
sed -i /etc/gochan/gochan.json \
-e 's/"Port": 8080/"Port": 9000/' \
-e 's/"UseFastCGI": false/"UseFastCGI": true/' \
-e 's/"DomainRegex": ".*"/"DomainRegex": "(https|http):\\\/\\\/(.*)\\\/(.*)"/' \
-e 's#"DocumentRoot": "html"#"DocumentRoot": "/srv/gochan"#' \
-e 's#"TemplateDir": "templates"#"TemplateDir": "/usr/local/share/gochan/templates"#' \
-e 's#"LogDir": "log"#"LogDir": "/var/log/gochan"#' \
@ -124,27 +122,22 @@ EOF
chmod +x /home/vagrant/dbconnect.sh
cat <<EOF >>/home/vagrant/.bashrc
export PATH=$PATH:/home/vagrant/bin
export DBTYPE=$DBTYPE
export GOPATH=/vagrant/lib
export GOPATH=/home/vagrant/go
EOF
cat <<EOF >>/root.bashrc
export GOPATH=/vagrant/lib
EOF
export GOPATH=/vagrant/lib
ln -s /usr/lib/go-1.10/bin/* /usr/local/bin/
cd /vagrant
su - vagrant <<EOF
mkdir -p /vagrant/lib
mkdir -p /home/vagrant/go
source /home/vagrant/.bashrc
export GOPATH=/vagrant/lib
cd /vagrant
cd /vagrant/devtools
python build_initdb.py
cd ..
mkdir -p $GOPATH/src/github.com/gochan-org/gochan
cp -r pkg $GOPATH/src/github.com/gochan-org/gochan
make dependencies
make
EOF
make install
# make install
# if [ -d /lib/systemd ]; then
# systemctl start gochan.service