package main
import (
"bufio"
"bytes"
"database/sql"
"flag"
"fmt"
"html/template"
"io"
"io/ioutil"
"log"
"log/syslog"
"net/http"
"os"
"strconv"
"github.com/dchest/captcha"
_ "github.com/mattn/go-sqlite3"
)
const page = `<!DOCTYPE html>
<html>
<head lang="en">
<meta charset="utf-8" />
<meta name="referrer" content="none" />
<meta name="viewport" content="width=device-width" />
<title>Survey {{ .Name }}</title>
<style>
html {
font-family: sans-serif;
}
main {
max-width: 80ch;
margin: 16px auto;
}
h1 {
text-align: center;
}
input[type="submit"],
input[name="captcha"] {
display: block;
margin: auto;
}
textarea {
min-width: 100%;
max-width: 100%;
}
#captcha {
display: table;
margin: 16px auto;
}
sup.req {
color: red;
}
</style>
</head>
<body>
<main>
<h1>{{ .Name }}</h1>
<p>{{ .Comment }}</p>
<hr />
<form method="POST" action="/submit">
<h2>Questions</h2>
{{ range $id, $q := .Questions }}
{{ with $q }}
<section>
{{ if eq "heading" .Type }}
<h3>{{ .Text }}</h3>
{{ else }}
<h4>
{{ .Text }}
{{ if $q.Require }}
<sup class="req" title="Required">*</sup>
{{ end }}
</h4>
{{ if eq "single" .Type }}
{{ range $idx, $opt := .Options }}
<div>
<input type="radio"
name="question-{{ $id }}"
{{ if $q.Require }} required {{ end }}
value="{{ $idx }}" />
<label for="question-{{ $id }}">{{ $opt }}</label>
</div>
{{ end }}
{{ if .Other }}
<div>
<input type="checkbox"
name="question-{{ $id }}"
value="-1" />
<input type="text"
name="question-{{ $id }}-other"
autocomplete="off"
placeholder="Other" />
</div>
{{ end }}
{{ else if eq "multiple" .Type }}
{{ range $idx, $opt := .Options }}
<div>
<input type="checkbox"
name="question-{{ $id }}-{{ $idx }}"
value="t" />
<label for="question-{{ $id }}-{{ $idx }}">{{ $opt }}</label>
</div>
{{ end }}
{{ if .Other }}
<div>
<input type="checkbox"
name="question-{{ $id }}-other"
value="-1" />
<input type="text"
name="question-{{ $id }}-other-val"
autocomplete="off"
placeholder="Other" />
</div>
{{ end }}
{{ else if eq "text" .Type }}
<textarea name="question-{{ $id }}"
{{ if $q.Require }} required {{ end }}
cols="80" rows="8">
</textarea>
{{ else }}
<strong>INVALID TYPE</strong>
{{ end }}
{{ end }}
</section>
{{ end }}
{{ end }}
<hr/>
<h2>Contact</h2>
<label for="contact">Optionally add Email if you wish to be contacted for additonal questions:</label>
<input type="email" name="contact" />
<hr/>
<h2>Captcha</h2>
<label for="captcha">
Sovle the captcha to finish the survey (if necessary, reload the page):
</label>`
const captchat = `
<img id="captcha" alt="Captcha Image" src="/captcha/{{ . }}.png" />
<input type="hidden" name="captcha-id" value="{{ . }}" />`
const footer = `<input type="text" name="captcha" autocomplete="off" required />
<hr/>
<input type="submit" value="Submit Survey" />
</form>
</main>
</body>
</html>`
const (
TypeSingle = "single"
TypeMultiple = "multiple"
TypeText = "text"
TypeHeading = "heading"
)
type Question struct {
Type string
Text string
Options []string
Other bool
Require bool
}
type Survey struct {
Name string
Comment string
Questions []*Question
}
var (
T *template.Template
db *sql.DB
insertResponse *sql.Stmt
insertAnswer *sql.Stmt
survey *Survey
rawPage []byte
l = log.New(ioutil.Discard, "", 0)
)
func index(w http.ResponseWriter, r *http.Request) {
w.Write(rawPage)
err := T.Execute(w, captcha.New())
if err != nil {
l.Print(err)
}
fmt.Fprint(w, footer)
}
func submit(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
// only accept post requests
if r.Method != "POST" {
http.Error(w, "invalid HTTP method", http.StatusMethodNotAllowed)
return
}
// ensure captcha has been solved correctly
if !captcha.VerifyString(r.FormValue("captcha-id"), r.FormValue("captcha")) {
http.Error(w, "captcha was not correctly solved",
http.StatusBadRequest)
return
}
tx, err := db.Begin()
if err != nil {
l.Print(err)
http.Error(w, "couldn't save response",
http.StatusInternalServerError)
return
}
contact := r.FormValue("contact")
if contact != "" {
_, err = tx.Stmt(insertResponse).Exec(contact)
} else {
_, err = tx.Stmt(insertResponse).Exec(nil)
}
if err != nil {
l.Print(err)
http.Error(w, "couldn't save response",
http.StatusInternalServerError)
tx.Rollback()
return
}
for i, q := range survey.Questions {
stmt := tx.Stmt(insertAnswer)
switch q.Type {
case TypeSingle:
other := r.FormValue(fmt.Sprintf("question-%d-other", i))
if other != "" {
_, err = stmt.Exec(i, -1, other)
break
}
value := r.FormValue(fmt.Sprintf("question-%d", i))
if value == "" {
break
}
qid, err := strconv.Atoi(value)
if err != nil {
http.Error(w, "invalid question ID",
http.StatusBadRequest)
tx.Rollback()
return
}
_, err = stmt.Exec(i, qid, nil)
case TypeMultiple:
for j, _ := range q.Options {
value := r.FormValue(fmt.Sprintf("question-%d-%d", i, j))
if value == "" {
continue
}
_, err = stmt.Exec(i, j, nil)
if err != nil {
break
}
}
other := r.FormValue(fmt.Sprintf("question-%d-other", i))
if other != "" {
val := r.FormValue(fmt.Sprintf("question-%d-other-val", i))
_, err = stmt.Exec(i, -1, val)
}
case TypeText:
value := r.FormValue(fmt.Sprintf("question-%d", i))
if value != "" {
_, err = stmt.Exec(i, nil, value)
}
default:
l.Fatalf("Invalid question type '%v'", q.Type)
}
if err != nil {
l.Print(err)
http.Error(w, "couldn't save response",
http.StatusInternalServerError)
tx.Rollback()
return
}
}
err = tx.Commit()
if err != nil {
l.Print(err)
http.Error(w, "couldn't save response",
http.StatusInternalServerError)
tx.Rollback()
}
fmt.Fprint(w, "Sucsessfuly submitted survey results! Thank you for participating.")
}
func parseSummary(in io.Reader) *Survey {
var q *Question
var s Survey
var comment bytes.Buffer
const (
title = iota
summary
question
option
)
state := title
scan := bufio.NewScanner(os.Stdin)
for scan.Scan() {
line := scan.Text()
switch state {
case title:
s.Name = line
state = summary
case summary:
if line == "." {
state = question
s.Comment = comment.String()
continue
}
comment.WriteString(line)
comment.WriteByte(' ')
case question:
if line == "" {
continue
}
q = &Question{Text: line[1:]}
s.Questions = append(s.Questions, q)
if line[0] == '*' {
q.Require = true
q.Text = line[2:]
line = line[1:]
}
switch line[0] {
case 'm':
q.Other = true
fallthrough
case 'M':
q.Type = TypeMultiple
case 's':
q.Other = true
fallthrough
case 'S':
q.Type = TypeSingle
case 'T':
q.Type = TypeText
case 'H':
q.Type = TypeHeading
continue
default:
l.Fatalf("Invalid type %v", line[0])
}
state = option
case option:
if line == "" {
state = question
continue
}
q.Options = append(q.Options, line)
}
}
if scan.Err() != nil {
l.Fatal(scan.Err())
}
return &s
}
func main() {
var (
err error
slog = flag.Bool("syslog", false, "Enable syslog support")
debug = flag.Bool("debug", false, "Log to stderr")
)
flag.Parse()
switch {
case *debug:
l = log.New(os.Stderr, "", log.LstdFlags)
case *slog:
l, err = syslog.NewLogger(syslog.LOG_INFO, log.LstdFlags)
if err != nil {
log.Fatal(err)
}
}
survey = parseSummary(os.Stdin)
l.Printf("Parsed %d questions.", len(survey.Questions))
var buf bytes.Buffer
T, err = template.New("page").Parse(page)
if err != nil {
l.Fatal(err)
}
err = T.Execute(&buf, survey)
rawPage = buf.Bytes()
l.Printf("Rendered survey.")
T, err = template.New("captcha").Parse(captchat)
if err != nil {
l.Fatal(err)
}
l.Printf("Loaded captcha template.")
db, err = sql.Open("sqlite3", "./survey.db?mode=rwc&_journal=wal")
if err != nil {
l.Fatal(err)
}
defer db.Close()
l.Print("Opened database connection.")
_, err = db.Exec(`
CREATE TABLE IF NOT EXISTS response (
id INTEGER PRIMARY KEY,
date INTEGER,
contact TEXT);`)
if err != nil {
l.Fatal(err)
}
_, err = db.Exec(`
CREATE TABLE IF NOT EXISTS answer (
response INTEGER,
question INTEGER,
answer INTEGER,
text TEXT,
FOREIGN KEY(response) REFERENCES response(id));`)
if err != nil {
l.Fatal(err)
}
insertResponse, err = db.Prepare(`
INSERT INTO response (date, contact)
VALUES (strftime('%s', 'now'), ?);`)
if err != nil {
l.Fatal(err)
}
insertAnswer, err = db.Prepare(`
INSERT INTO answer (response, question, answer, text)
VALUES ((SELECT MAX(id) FROM response), ?, ?, ?);`)
if err != nil {
l.Fatal(err)
}
l.Print("Prepared database connection.")
http.HandleFunc("/", index)
http.HandleFunc("/submit", submit)
http.Handle("/captcha/", captcha.Server(captcha.StdWidth, captcha.StdHeight))
listen := os.Getenv("LISTEN")
if listen == "" {
listen = "localhost:8080"
}
l.Printf("Starting server on %s.", listen)
l.Fatal(http.ListenAndServe(listen, nil))
}