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

Add basic captcha support

This commit is contained in:
Eggbertx 2019-11-24 14:42:39 -08:00
parent 9a247cc7b2
commit 21e01d7708
15 changed files with 292 additions and 46 deletions

View file

@ -292,11 +292,11 @@ while [ -n "$1" ]; do
fi
done
# if [ -d /lib/systemd/system ]; then
# echo "Installing systemd service file"
# cp gochan.service /lib/systemd/system/gochan.service
# systemctl daemon-reload
# fi
if [ -d /lib/systemd/system ]; then
echo "Installing systemd service file"
cp $symarg $PWD/gochan.service /lib/systemd/system/gochan.service
systemctl daemon-reload
fi
echo "Installation complete. Make sure to set the following values in gochan.json:"
echo "DocumentRoot => $documentroot"

View file

@ -73,6 +73,10 @@
"DateTimeFormat": "Mon, January 02, 2006 15:04 PM",
"AkismetAPIKey": "",
"UseCaptcha": false,
"CaptchaWidth": 240,
"CaptchaHeight": 80,
"CaptchaMinutesTimeout": 15,
"EnableGeoIP": true,
"_comment": "set GeoIPDBlocation to cf to use Cloudflare's GeoIP",
"GeoIPDBlocation": "/usr/share/GeoIP/GeoIP.dat",

107
src/captcha.go Normal file
View file

@ -0,0 +1,107 @@
package main
import (
"fmt"
"net/http"
"strconv"
"github.com/mojocn/base64Captcha"
)
var (
charCaptchaCfg base64Captcha.ConfigCharacter
)
type captchaJSON struct {
CaptchaID string `json:"id"`
Base64String string `json:"image"`
Result string `json:"-"`
TempPostIndex string `json:"-"`
EmailCmd string `json:"-"`
}
func initCaptcha() {
charCaptchaCfg = base64Captcha.ConfigCharacter{
Height: config.CaptchaHeight, // originally 60
Width: config.CaptchaWidth, // originally 240
Mode: base64Captcha.CaptchaModeNumberAlphabet,
ComplexOfNoiseText: base64Captcha.CaptchaComplexLower,
ComplexOfNoiseDot: base64Captcha.CaptchaComplexLower,
IsUseSimpleFont: true,
IsShowHollowLine: false,
IsShowNoiseDot: true,
IsShowNoiseText: false,
IsShowSlimeLine: true,
IsShowSineLine: false,
CaptchaLen: 8,
}
}
func serveCaptcha(writer http.ResponseWriter, request *http.Request) {
var err error
if err = request.ParseForm(); err != nil {
serveErrorPage(writer, err.Error())
errorLog.Println(customError(err))
return
}
tempPostIndexStr := request.FormValue("temppostindex")
var tempPostIndex int
if tempPostIndex, err = strconv.Atoi(tempPostIndexStr); err != nil {
tempPostIndexStr = "-1"
tempPostIndex = 0
}
emailCommand := request.FormValue("emailcmd")
id, b64 := getCaptchaImage()
captchaStruct := captchaJSON{id, b64, "", tempPostIndexStr, emailCommand}
useJSON := request.FormValue("json") == "1"
if useJSON {
writer.Header().Add("Content-Type", "application/json")
str, _ := marshalJSON("", captchaStruct, false)
writer.Write([]byte(str))
return
}
if request.FormValue("reload") == "Reload" {
request.Form.Del("reload")
request.Form.Add("didreload", "1")
serveCaptcha(writer, request)
return
}
writer.Header().Add("Content-Type", "text/html")
captchaID := request.FormValue("captchaid")
captchaAnswer := request.FormValue("captchaanswer")
if captchaID != "" && request.FormValue("didreload") != "1" {
goodAnswer := base64Captcha.VerifyCaptcha(captchaID, captchaAnswer)
if goodAnswer {
if tempPostIndex > -1 && tempPostIndex < len(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)
// 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]
http.Redirect(writer, request, url, http.StatusFound)
return
}
} else {
captchaStruct.Result = "Incorrect CAPTCHA"
}
}
if err = captcha_tmpl.Execute(writer, captchaStruct); err != nil {
handleError(0, customError(err))
fmt.Fprintf(writer, "Error executing captcha template")
}
}
func getCaptchaImage() (captchaID string, chaptchaB64 string) {
var captchaInstance base64Captcha.CaptchaInterface
captchaID, captchaInstance = base64Captcha.GenerateCaptcha("", charCaptchaCfg)
chaptchaB64 = base64Captcha.CaptchaWriteToBase64Encoding(captchaInstance)
return
}

12
src/captcha_test.go Normal file
View file

@ -0,0 +1,12 @@
package main
import (
"fmt"
"testing"
)
func TestGetCaptchaImage(t *testing.T) {
initCaptcha()
captchaID, captchaB64 := getCaptchaImage()
fmt.Println("captchaID:", captchaID, "\ncaptchaB64:", captchaB64)
}

View file

@ -7,6 +7,7 @@ import (
"os/signal"
"strings"
"syscall"
"time"
)
var versionStr string
@ -30,6 +31,9 @@ func main() {
handleError(0, customError(err))
os.Exit(2)
}
initCaptcha()
tempCleanerTicker = time.NewTicker(time.Minute * 5)
go tempCleaner()
sc := make(chan os.Signal, 1)
signal.Notify(sc, syscall.SIGINT, syscall.SIGTERM, os.Interrupt, os.Kill)

View file

@ -1115,4 +1115,17 @@ var manage_functions = map[string]ManageFunction{
"\t\t</form>"
return
}},
"tempposts": {
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

@ -17,7 +17,6 @@ import (
"os"
"os/exec"
"path"
"regexp"
"strconv"
"strings"
"syscall"
@ -28,14 +27,16 @@ import (
)
const (
whitespaceMatch = "[\000-\040]"
gt = "&gt;"
yearInSeconds = 31536000
gt = "&gt;"
yearInSeconds = 31536000
)
var (
allSections []BoardSection
allBoards []Board
allSections []BoardSection
allBoards []Board
tempPosts []Post
tempCleanerTicker *time.Ticker
tempCleanerQuit = make(chan struct{})
)
// bumps the given thread on the given board and returns true if there were no errors
@ -282,14 +283,18 @@ func makePost(writer http.ResponseWriter, request *http.Request) {
startTime := benchmarkTimer("makePost", time.Now(), true)
var maxMessageLength int
var post Post
domain := request.Host
// 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
chopPortNumRegex := regexp.MustCompile(`(.+|\w+):(\d+)$`)
domain = chopPortNumRegex.Split(domain, -1)[0]
// chopPortNumRegex := regexp.MustCompile(`(.+|\w+):(\d+)$`)
// domain = chopPortNumRegex.Split(domain, -1)[0]
post.ParentID, _ = strconv.Atoi(request.FormValue("threadid"))
post.BoardID, _ = strconv.Atoi(request.FormValue("boardid"))
@ -301,7 +306,8 @@ func makePost(writer http.ResponseWriter, request *http.Request) {
post.Tripcode = parsedName["tripcode"]
formEmail = request.FormValue("postemail")
http.SetCookie(writer, &http.Cookie{Name: "email", Value: formEmail, Path: "/", Domain: domain, RawExpires: getSpecificSQLDateTime(time.Now().Add(time.Duration(yearInSeconds))), MaxAge: yearInSeconds})
http.SetCookie(writer, &http.Cookie{Name: "email", Value: formEmail, MaxAge: yearInSeconds})
if !strings.Contains(formEmail, "noko") && !strings.Contains(formEmail, "sage") {
post.Email = formEmail
@ -345,8 +351,8 @@ func makePost(writer http.ResponseWriter, request *http.Request) {
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, Path: "/", Domain: domain, RawExpires: getSpecificSQLDateTime(time.Now().Add(time.Duration(yearInSeconds))), MaxAge: yearInSeconds})
http.SetCookie(writer, &http.Cookie{Name: "password", Value: password, Path: "/", Domain: domain, RawExpires: getSpecificSQLDateTime(time.Now().Add(time.Duration(yearInSeconds))), MaxAge: yearInSeconds})
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()
@ -383,12 +389,12 @@ func makePost(writer http.ResponseWriter, request *http.Request) {
if err != nil || handler.Size == 0 {
// no file was uploaded
post.Filename = ""
accessLog.Print("Receiving post from " + post.IP + ", referred from: " + request.Referer())
accessLog.Printf("Receiving post from %s, referred from: %s", post.IP, request.Referer())
} else {
data, err := ioutil.ReadAll(file)
if err != nil {
serveErrorPage(writer, handleError(1, "Couldn't read file: "+err.Error()))
return
} else {
post.FilenameOriginal = html.EscapeString(handler.Filename)
filetype := getFileExtension(post.FilenameOriginal)
@ -410,7 +416,7 @@ func makePost(writer http.ResponseWriter, request *http.Request) {
catalogThumbPath := path.Join(config.DocumentRoot, "/"+boardDir+"/thumb/", strings.Replace(post.Filename, "."+filetype, "c."+thumbFiletype, -1))
if err = ioutil.WriteFile(filePath, data, 0777); err != nil {
handleError(0, "Couldn't write file \""+post.Filename+"\""+err.Error())
handleError(0, "Couldn't write file '%s': %s\n", post.Filename, err.Error())
serveErrorPage(writer, "Couldn't write file \""+post.FilenameOriginal+"\"")
return
}
@ -434,16 +440,14 @@ func makePost(writer http.ResponseWriter, request *http.Request) {
return
}
accessLog.Print("Receiving post with video: " + handler.Filename + " from " + post.IP + ", referrer: " + request.Referer())
accessLog.Printf("Receiving post with video: %s from %s, referrer: %s", handler.Filename, post.IP, request.Referer())
if post.ParentID == 0 {
err := createVideoThumbnail(filePath, thumbPath, config.ThumbWidth)
if err != nil {
if err := createVideoThumbnail(filePath, thumbPath, config.ThumbWidth); err != nil {
serveErrorPage(writer, handleError(1, err.Error()))
return
}
} else {
err := createVideoThumbnail(filePath, thumbPath, config.ThumbWidth_reply)
if err != nil {
if err := createVideoThumbnail(filePath, thumbPath, config.ThumbWidth_reply); err != nil {
serveErrorPage(writer, handleError(1, err.Error()))
return
}
@ -489,6 +493,7 @@ func makePost(writer http.ResponseWriter, request *http.Request) {
if err != nil {
os.Remove(filePath)
handleError(1, "Couldn't open uploaded file \""+post.Filename+"\""+err.Error())
handleError(1, "Couldn't open uploaded file \"%s\": %s\n", post.Filename, err.Error())
serveErrorPage(writer, "Upload filetype not supported")
return
} else {
@ -517,12 +522,10 @@ func makePost(writer http.ResponseWriter, request *http.Request) {
if _, err := os.Stat(path.Join(config.DocumentRoot, "spoiler.png")); err != nil {
serveErrorPage(writer, "missing /spoiler.png")
return
} else {
err = syscall.Symlink(path.Join(config.DocumentRoot, "spoiler.png"), thumbPath)
if err != nil {
serveErrorPage(writer, err.Error())
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
@ -596,6 +599,20 @@ func makePost(writer http.ResponseWriter, request *http.Request) {
}
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, handleError(1, err.Error()))
return
@ -617,6 +634,46 @@ func makePost(writer http.ResponseWriter, request *http.Request) {
benchmarkTimer("makePost", startTime, false)
}
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
}
board, err := getBoardFromID(post.BoardID)
if err != nil {
continue
}
fileSrc := path.Join(config.DocumentRoot, board.Dir, "src", post.FilenameOriginal)
if err = os.Remove(fileSrc); err != nil {
printf(0, "Error pruning temporary upload for %s: %s", fileSrc, err.Error())
}
thumbSrc := getThumbnailPath("thread", fileSrc)
if err = os.Remove(thumbSrc); err != nil {
printf(0, "Error pruning temporary upload for %s: %s", thumbSrc, err.Error())
}
if post.ParentID == 0 {
catalogSrc := getThumbnailPath("catalog", fileSrc)
if err = os.Remove(catalogSrc); err != nil {
printf(0, "Error pruning temporary upload for %s: %s", catalogSrc, err.Error())
}
}
}
}
}
}
func formatMessage(message string) string {
message = bbcompiler.Compile(message)
// prepare each line to be formatted

View file

@ -142,6 +142,7 @@ func initServer() {
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)

View file

@ -63,6 +63,11 @@ func connectToSQLServer() {
os.Exit(2)
}
if _, err = execSQL("TRUNCATE TABLE " + config.DBprefix + "sessions"); err != nil {
handleError(0, "failed: %s\n", customError(err))
os.Exit(2)
}
var sqlVersionStr string
err = queryRowSQL("SELECT value FROM "+config.DBprefix+"info WHERE name = 'version'",
[]interface{}{}, []interface{}{&sqlVersionStr})

View file

@ -291,6 +291,7 @@ var funcMap = template.FuncMap{
var (
banpage_tmpl *template.Template
captcha_tmpl *template.Template
catalog_tmpl *template.Template
errorpage_tmpl *template.Template
front_page_tmpl *template.Template
@ -328,7 +329,7 @@ func templateError(name string, err error) 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" {
banpage_tmpl, err = loadTemplate("banpage.html", "global_footer.html")
@ -336,6 +337,12 @@ func initTemplates(which ...string) error {
return templateError("banpage.html", err)
}
}
if buildAll || t == "captcha" {
captcha_tmpl, err = loadTemplate("captcha.html")
if err != nil {
return templateError("captcha.html", err)
}
}
if buildAll || t == "catalog" {
catalog_tmpl, err = loadTemplate("catalog.html", "img_header.html", "global_footer.html")
if err != nil {

View file

@ -341,8 +341,12 @@ type GochanConfig struct {
NewTabOnOutlinks bool `description:"If checked, links to external sites will open in a new tab." default:"checked"`
EnableQuickReply bool `description:"If checked, an optional quick reply box is used. This may end up being removed." default:"checked"`
DateTimeFormat string `description:"The format used for dates. See <a href=\"https://golang.org/pkg/time/#Time.Format\">here</a> for more info."`
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"`
@ -587,6 +591,14 @@ DefaultStyle must refer to a given Style's Filename field. If DefaultStyle does
config.DateTimeFormat = "Mon, January 02, 2006 15:04 PM"
}
if config.CaptchaWidth == 0 {
config.CaptchaWidth = 240
}
if config.CaptchaHeight == 0 {
config.CaptchaHeight = 80
}
if config.EnableGeoIP {
if config.GeoIPDBlocation == "" {
println(0, "GeoIPDBlocation not set in gochan.json, disabling EnableGeoIP.")

View file

@ -536,6 +536,11 @@ func marshalJSON(tag string, data interface{}, indent bool) (string, error) {
return string(jsonBytes), err
}
func jsonError(err string) string {
errJSON, _ := marshalJSON("error", err, false)
return errJSON
}
func limitArraySize(arr []string, maxSize int) []string {
if maxSize > len(arr)-1 || maxSize < 0 {
return arr

18
templates/captcha.html Normal file
View file

@ -0,0 +1,18 @@
<!DOCTYPE html>
<html>
<head>
<title>Gochan CAPTCHA</title>
</head>
<body>
{{with .Result}}{{$.Result}}{{end}}
<form action="/captcha" method="POST">
<img src="{{.Base64String}}" /><br />
<input type="text" name="captchaanswer" autocomplete="off" />
<input type="hidden" name="captchaid" value="{{.CaptchaID}}" />
{{with .EmailCmd}}<input type="hidden" name="emailcmd" value="{{$.EmailCmd}}" />{{end}}
{{with .TempPostIndex}}<input type="hidden" name="temppostindex" value="{{$.TempPostIndex}}" />{{end}}
<input type="submit" value="Submit" /><br />
<input type="submit" name="reload" value="Reload" />
</form>
</body>
</html>

3
vagrant/Vagrantfile vendored
View file

@ -16,6 +16,7 @@ Vagrant.configure("2") do |config|
end
config.vm.provision :shell, path: "bootstrap.sh", env: {
:DBTYPE => DBTYPE
:DBTYPE => DBTYPE,
:FROMDOCKER => ""
}, args: "install"
end

View file

@ -76,20 +76,21 @@ ln -sf /etc/nginx/sites-available/gochan.nginx /etc/nginx/sites-enabled/
sed -e 's/sendfile on;/sendfile off;/' -i /etc/nginx/nginx.conf
# Make sure our shared directories are mounted before nginx starts
# service nginx disable
update-rc.d nginx enable
systemctl disable nginx
sed -i 's/WantedBy=multi-user.target/WantedBy=vagrant.mount/' /lib/systemd/system/nginx.service
# systemctl daemon-reload
# service nginx enable
# service nginx restart &
systemctl daemon-reload
systemctl enable nginx
systemctl restart nginx &
wait
mkdir -p /vagrant/lib
cd /opt/gochan
export GOPATH=/opt/gochan/lib
# mkdir /home/vagrant/bin
# ln -s /usr/lib/go-1.10/bin/* /home/vagrant/bin/
# export PATH="$PATH:/home/vagrant/bin"
cd /vagrant
export GOPATH=/vagrant/lib
echo "export GOPATH=/vagrant/lib" >> /home/vagrant/.bashrc
mkdir /home/vagrant/bin
ln -s /usr/lib/go-1.10/bin/* /home/vagrant/bin/
export PATH="$PATH:/home/vagrant/bin"
echo 'export PATH="$$PATH:/home/vagrant/bin"'
function changePerms {
chmod -R 755 $1
@ -153,6 +154,5 @@ fi
# systemctl start gochan.service
# fi
echo
echo "Server set up, please run \"vagrant ssh\" on your host machine."
echo "Then browse to http://172.27.0.3/manage to complete installation."
echo "Server set up. You can access it from a browser at http://172.27.0.3/"
echo "The first time gochan is run, it will create a simple /test/ board."