diff --git a/README.md b/README.md index f6c78d6..135d3f0 100644 --- a/README.md +++ b/README.md @@ -8,11 +8,28 @@ text files with URLs accessible over the Internet. *htwtxt* is a web server to host and grow such text files for users without trivial access to their own web space. +## Features + +- individual twtxt feeds mapped to user accounts with password-protected write + access +- no sessions, no cookies: few POST-writable resources (feeds, account data) + expect credentials, which to store between requests if desired is up to the + user / browser +- account registration may be open to the public, or (default) closed (with the + site operator adding new accounts manually) +- users may add e-mail addresses and optional security questions to their + accounts to use for a password reset mechanism (if enabled by site operator) +- HTTPS / TLS support (if paths to key and certificate files are provided) +- all HTML+CSS is read from a templates directory, which can be freely chosen at + server start so as to ease customization of the interface + ## Online demo -A demo instance with frequent downtimes can be tested at -http://test.plomlompom.com:8000 – don't expect any of its feeds' URLs to be -stable. It's just for testing, and data frequently gets deleted. +A demo instance with frequent downtimes and public sign-up can be tested at + (don't expect any of its feeds' URLs to be +stable; it's just for testing, and data frequently gets deleted). A somewhat +more conservatively managed instance can be found at +. ## Setup and run @@ -20,8 +37,9 @@ stable. It's just for testing, and data frequently gets deleted. With htwtxt written in Go, the setup instructions below expect a Go development environment – with a somewhat current [go tool](https://golang.org/cmd/go/) -installed, and a `$GOPATH` set. If your system does not have such an -environment, here's some hints on how to set it up: +installed, and a `$GOPATH` set. (Note that the golang package of version 1.3.3 +that is part of Debian Jessie is a bit too old already.) If your system does not +have such an environment, here's some hints on how to set it up: wget https://storage.googleapis.com/golang/go1.5.3.linux-amd64.tar.gz sudo tar -C /usr/local -xzf go1.5.3.linux-amd64.tar.gz @@ -47,7 +65,7 @@ This will build and start the server, which will store login and feed data below ## Tweaking -### Configuring port number and TLS +### Configure port number and TLS By default, htwtxt serves unencrypted HTTP over port 8000. But the executable accepts the flag `--port` to provide an alternate port number, and the flags @@ -64,14 +82,41 @@ This is [a common privilege problem](http://stackoverflow.com/q/413807) and sudo setcap 'cap_net_bind_service=+ep' $GOPATH/bin/htwtxt -### Changing HTML templates +### Public or closed sign-up + +By default, sign up / account creation is not open to the web-browsing public. +The `--signup` flag must be set explicitely to change that. Alternatively, new +accounts can be added by starting the program with the `--adduser` flag, +followed by an argument of the form `NAME:PASSWORD`. + +### Set site owner contact info + +The server serves a `/info` page (from the `info.html` template) that may +include the site owner's contact info, as given with the `--info` flag. + +### Activate password reset mails + +Feed owners may add e-mail addresses to their login data to authenticate +themselves to the site operator and receive password reset links when requested. +The password reset mechanism by mail is inactive by default. To activate it, a +set of flags `--mailserver`, `--mailport`, `--mailuser` must be set to describe +a SMTP server and its login from which to send password reset mails to users' +mail addresses. (The site operator will be prompted for his SMTP login password +on program start.) Whether this mechanism is trustworthy or not is up to the +site operator's imagination. Users may set up optional security questions to be +posed on the password reset links they enable by setting their mail address. + +### Change HTML templates By default, HTML templates are read out of `$GOPATH/src/htwtxt/templates/`. An alternate directory can be given with the flag `--templates` (it should contain template files of the same names as the default ones, however). -## Copyright, license +## Copyright, license, version -htwtxt (c) 2016 Christian Heller a.k.a. plomlompom +htwtxt (c) 2016 Christian Heller a.k.a. [plomlompom](http://www.plomlompom.de), +with template design input by [Kai Kubasta](http://kaikubasta.de). License: Affero GPL version 3, see `./LICENSE` + +Current version number: 1.0.1 diff --git a/handlers.go b/handlers.go new file mode 100644 index 0000000..a8d9c72 --- /dev/null +++ b/handlers.go @@ -0,0 +1,318 @@ +package main + +import "bufio" +import "crypto/rand" +import "encoding/base64" +import "golang.org/x/crypto/bcrypt" +import "gopkg.in/gomail.v2" +import "log" +import "github.com/gorilla/mux" +import "net/http" +import "os" +import "strconv" +import "strings" +import "time" + +func passwordResetRequestGetHandler(w http.ResponseWriter, r *http.Request) { + if "" == mailuser { + execTemplate(w, "nopwresetrequest.html", "") + } else { + execTemplate(w, "pwresetrequest.html", "") + } +} + +func passwordResetRequestPostHandler(w http.ResponseWriter, r *http.Request) { + preparePasswordReset := func(name string) { + if "" == mailuser { + return + } + now := int(time.Now().Unix()) + tokens, errWait := getFromFileEntryFor(pwResetWaitPath, name, 2) + if errWait == nil { + lastTime, err := strconv.Atoi(tokens[0]) + if err != nil { + log.Fatal("Trouble parsing password reset "+ + "wait times", err) + } + if lastTime+resetWaitTime >= now { + return + } + } + var target string + tokens, err := getFromFileEntryFor(loginsPath, name, 5) + if err != nil { + return + } else if "" == tokens[1] { + return + } + target = tokens[1] + b := make([]byte, 64) + if _, err := rand.Read(b); err != nil { + log.Fatal("Random string generation failed", err) + } + urlPart := base64.URLEncoding.EncodeToString(b) + strTime := strconv.Itoa(now) + appendToFile(pwResetPath, urlPart+"\t"+name+"\t"+strTime) + m := gomail.NewMessage() + m.SetHeader("From", mailuser) + m.SetHeader("To", target) + m.SetHeader("Subject", "password reset link") + msg := myself + "/passwordreset/" + urlPart + m.SetBody("text/plain", msg) + if err := dialer.DialAndSend(m); err != nil { + log.Fatal("Can't send mail", err) + } + line := name + "\t" + strTime + if nil == errWait { + replaceLineStartingWith(pwResetWaitPath, name, line) + } else { + appendToFile(pwResetWaitPath, line) + } + } + go preparePasswordReset(r.FormValue("name")) + http.Redirect(w, r, "/", 302) +} + +func passwordResetLinkGetHandler(w http.ResponseWriter, r *http.Request) { + urlPart := mux.Vars(r)["secret"] + tokens, err := getFromFileEntryFor(pwResetPath, urlPart, 3) + if err != nil { + http.Redirect(w, r, "/404", 302) + return + } + createTime, err := strconv.Atoi(tokens[1]) + if err != nil { + log.Fatal("Can't read time from pw reset file", err) + } + if createTime+resetLinkExp < int(time.Now().Unix()) { + http.Redirect(w, r, "/404", 302) + return + } + name := tokens[0] + tokensUser, err := getFromFileEntryFor(loginsPath, name, + 5) + if err != nil { + log.Fatal("Can't read from loings file", err) + } + if "" != tokensUser[2] { + type data struct { + Secret string + Question string + } + err := templ.ExecuteTemplate(w, + "pwresetquestion.html", data{ + Secret: urlPart, + Question: tokensUser[2]}) + if err != nil { + log.Fatal("Trouble executing template", err) + } + return + } + execTemplate(w, "pwreset.html", urlPart) +} + +func passwordResetLinkPostHandler(w http.ResponseWriter, r *http.Request) { + urlPart := mux.Vars(r)["secret"] + name := r.FormValue("name") + tokens, err := getFromFileEntryFor(pwResetPath, urlPart, 3) + if err != nil { + http.Redirect(w, r, "/404", 302) + return + } + createTime, err := strconv.Atoi(tokens[1]) + if err != nil { + log.Fatal("Can't read time from pw reset file", err) + } + if createTime+resetLinkExp < int(time.Now().Unix()) { + http.Redirect(w, r, "/404", 302) + return + } + if tokens[0] != name { + execTemplate(w, "error.html", "Wrong answer(s).") + removeLineStartingWith(pwResetPath, urlPart) + return + } + tokensUser, err := getFromFileEntryFor(loginsPath, name, 5) + if err != nil { + log.Fatal("Can't get entry for user", err) + } + if "" != tokensUser[2] && + nil != bcrypt.CompareHashAndPassword([]byte(tokensUser[3]), + []byte(r.FormValue("secanswer"))) { + execTemplate(w, "error.html", "Wrong answer(s).") + removeLineStartingWith(pwResetPath, urlPart) + return + } + hash, err := newPassword(w, r) + if err != nil { + execTemplate(w, "error.html", err.Error()) + return + } + tokensUser[0] = hash + line := name + "\t" + strings.Join(tokensUser, "\t") + replaceLineStartingWith(loginsPath, tokens[0], line) + removeLineStartingWith(pwResetPath, urlPart) + execTemplate(w, "feedset.html", "") +} + +func signUpFormHandler(w http.ResponseWriter, r *http.Request) { + if !signupOpen { + execTemplate(w, "nosignup.html", "") + return + } + execTemplate(w, "signupform.html", "") +} + +func signUpHandler(w http.ResponseWriter, r *http.Request) { + if !signupOpen { + execTemplate(w, "error.html", + "Account creation currently not allowed.") + return + } + name := r.FormValue("name") + if !nameIsLegal(name) { + execTemplate(w, "error.html", "Illegal name.") + return + } + if _, err := getFromFileEntryFor(loginsPath, name, 5); err == nil { + execTemplate(w, "error.html", "Username taken.") + return + } + hash, err := newPassword(w, r) + if err != nil { + execTemplate(w, "error.html", err.Error()) + return + } + mail := "" + if "" != r.FormValue("mail") { + mail, err = newMailAddress(w, r) + if err != nil { + execTemplate(w, "error.html", err.Error()) + return + } + } + var secquestion, secanswer string + if "" != r.FormValue("secquestion") || "" != r.FormValue("secanswer") { + secquestion, secanswer, err = newSecurityQuestion(w, r) + if err != nil { + execTemplate(w, "error.html", err.Error()) + return + } + } + appendToFile(loginsPath, + name+"\t"+hash+"\t"+mail+"\t"+secquestion+"\t"+secanswer) + execTemplate(w, "feedset.html", "") +} + +func accountSetPwHandler(w http.ResponseWriter, r *http.Request) { + changeLoginField(w, r, newPassword, 0) +} + +func accountSetMailHandler(w http.ResponseWriter, r *http.Request) { + changeLoginField(w, r, newMailAddress, 1) +} + +func accountSetQuestionHandler(w http.ResponseWriter, r *http.Request) { + name, err := login(w, r) + if err != nil { + return + } + var secquestion, secanswer string + if "" != r.FormValue("secquestion") || "" != r.FormValue("secanswer") { + secquestion, secanswer, err = newSecurityQuestion(w, r) + if err != nil { + execTemplate(w, "error.html", err.Error()) + return + } + } + tokens, err := getFromFileEntryFor(loginsPath, name, 5) + if err != nil { + log.Fatal("Can't get entry for user", err) + } + tokens[2] = secquestion + tokens[3] = secanswer + replaceLineStartingWith(loginsPath, name, + name+"\t"+strings.Join(tokens, "\t")) + execTemplate(w, "feedset.html", "") +} + +func listHandler(w http.ResponseWriter, r *http.Request) { + file := openFile(loginsPath) + defer file.Close() + scanner := bufio.NewScanner(bufio.NewReader(file)) + var dir []string + tokens := tokensFromLine(scanner, 5) + for 0 != len(tokens) { + dir = append(dir, tokens[0]) + tokens = tokensFromLine(scanner, 5) + } + type data struct{ Dir []string } + err := templ.ExecuteTemplate(w, "list.html", data{Dir: dir}) + if err != nil { + log.Fatal("Trouble executing template", err) + } +} + +func twtxtPostHandler(w http.ResponseWriter, r *http.Request) { + name, err := login(w, r) + if err != nil { + return + } + text := r.FormValue("twt") + twtsFile := feedsPath + "/" + name + createFileIfNotExists(twtsFile) + text = strings.Replace(text, "\n", " ", -1) + appendToFile(twtsFile, time.Now().Format(time.RFC3339)+"\t"+text) + http.Redirect(w, r, "/"+feedsDir+"/"+name, 302) +} + +func twtxtHandler(w http.ResponseWriter, r *http.Request) { + name := mux.Vars(r)["name"] + if !onlyLegalRunes(name) { + execTemplate(w, "error.html", "Bad path.") + return + } + path := feedsPath + "/" + name + if _, err := os.Stat(path); err != nil { + execTemplate(w, "error.html", "Empty twtxt for user.") + return + } + http.ServeFile(w, r, path) +} + +func handleRoutes() *mux.Router { + router := mux.NewRouter() + router.HandleFunc("/", handleTemplate("index.html", "")) + router.HandleFunc("/feeds", listHandler).Methods("GET") + router.HandleFunc("/feeds/", listHandler) + router.HandleFunc("/accountsetquestion", + handleTemplate("accountsetquestion.html", "")).Methods("GET") + router.HandleFunc("/accountsetquestion", accountSetQuestionHandler). + Methods("POST") + router.HandleFunc("/accountsetmail", + handleTemplate("accountsetmail.html", "")).Methods("GET") + router.HandleFunc("/accountsetmail", accountSetMailHandler). + Methods("POST") + router.HandleFunc("/accountsetpw", handleTemplate("accountsetpw.html", + "")).Methods("GET") + router.HandleFunc("/accountsetpw", accountSetPwHandler).Methods("POST") + router.HandleFunc("/account", handleTemplate("account.html", "")) + router.HandleFunc("/signup", signUpFormHandler).Methods("GET") + router.HandleFunc("/signup", signUpHandler).Methods("POST") + router.HandleFunc("/feeds", twtxtPostHandler).Methods("POST") + router.HandleFunc("/feeds/{name}", twtxtHandler) + router.HandleFunc("/info", handleTemplate("info.html", contact)) + router.HandleFunc("/passwordreset", passwordResetRequestPostHandler). + Methods("POST") + router.HandleFunc("/passwordreset", passwordResetRequestGetHandler). + Methods("GET") + router.HandleFunc("/passwordreset/{secret}", + passwordResetLinkGetHandler).Methods("GET") + router.HandleFunc("/passwordreset/{secret}", + passwordResetLinkPostHandler).Methods("POST") + router.HandleFunc("/style.css", + func(w http.ResponseWriter, r *http.Request) { + http.ServeFile(w, r, templPath+"/style.css") + }) + return router +} diff --git a/io.go b/io.go new file mode 100644 index 0000000..5414c81 --- /dev/null +++ b/io.go @@ -0,0 +1,169 @@ +// htwtxt – hosted twtxt server; see README for copyright and license info + +package main + +import "bufio" +import "errors" +import "log" +import "os" +import "strings" +import "io/ioutil" + +const loginsFile = "logins.txt" +const feedsDir = "feeds" +const ipDelaysFile = "ip_delays.txt" +const pwResetFile = "password_reset.txt" +const pwResetWaitFile = "password_reset_wait.txt" + +var certPath string +var dataDir string +var feedsPath string +var ipDelaysPath string +var keyPath string +var loginsPath string +var pwResetPath string +var pwResetWaitPath string +var templPath string + +func createFileIfNotExists(path string) { + if _, err := os.Stat(path); err != nil { + file, err := os.Create(path) + if err != nil { + log.Fatal("Can't create file: ", err) + } + file.Close() + } +} + +func openFile(path string) *os.File { + file, err := os.Open(path) + if err != nil { + file.Close() + log.Fatal("Can't open file for reading", err) + } + return file +} + +func linesFromFile(path string) []string { + text, err := ioutil.ReadFile(path) + if err != nil { + log.Fatal("Can't read file", err) + } + if string(text) == "" { + return []string{} + } + return strings.Split(string(text), "\n") +} + +func writeAtomic(path, text string) { + tmpFile := path + "_tmp" + if err := ioutil.WriteFile(tmpFile, []byte(text), 0600); err != nil { + log.Fatal("Trouble writing file", err) + } + if err := os.Rename(path, path+"_"); err != nil { + log.Fatal("Trouble moving file", err) + } + if err := os.Rename(tmpFile, path); err != nil { + log.Fatal("Trouble moving file", err) + } + if err := os.Remove(path + "_"); err != nil { + log.Fatal("Trouble removing file", err) + } +} + +func writeLinesAtomic(path string, lines []string) { + writeAtomic(path, strings.Join(lines, "\n")) +} + +func appendToFile(path string, msg string) { + text, err := ioutil.ReadFile(path) + if err != nil { + log.Fatal("Can't read file", err) + } + writeAtomic(path, string(text)+msg+"\n") +} + +func removeLineStartingWith(path, token string) { + lines := linesFromFile(path) + lineNumber := -1 + for lineCount := 0; lineCount < len(lines); lineCount += 1 { + line := lines[lineCount] + tokens := strings.Split(line, "\t") + if 0 == strings.Compare(token, tokens[0]) { + lineNumber = lineCount + break + } + } + lines = append(lines[:lineNumber], lines[lineNumber+1:]...) + writeLinesAtomic(path, lines) +} + +func removeLineFromFile(path string, lineNumber int) { + lines := linesFromFile(ipDelaysPath) + lines = append(lines[:lineNumber], lines[lineNumber+1:]...) + writeLinesAtomic(path, lines) +} + +func replaceLineStartingWith(path, token, newLine string) { + lines := linesFromFile(path) + for i, line := range lines { + tokens := strings.Split(line, "\t") + if 0 == strings.Compare(token, tokens[0]) { + lines[i] = newLine + break + } + } + writeLinesAtomic(path, lines) +} + +func tokensFromLine(scanner *bufio.Scanner, nTokensExpected int) []string { + if !scanner.Scan() { + return []string{} + } + line := scanner.Text() + tokens := strings.Split(line, "\t") + if len(tokens) != nTokensExpected { + log.Fatal("Line in file had unexpected number of tokens") + } + return tokens +} + +func getFromFileEntryFor(path, token string, + numberTokensExpected int) ([]string, error) { + file := openFile(path) + defer file.Close() + scanner := bufio.NewScanner(bufio.NewReader(file)) + tokens := tokensFromLine(scanner, numberTokensExpected) + for 0 != len(tokens) { + if 0 == strings.Compare(tokens[0], token) { + return tokens[1:], nil + } + tokens = tokensFromLine(scanner, numberTokensExpected) + } + return []string{}, errors.New("") +} + +func initFilesAndDirs() { + log.Println("Using as templates dir:", templPath) + log.Println("Using as data dir:", dataDir) + loginsPath = dataDir + "/" + loginsFile + feedsPath = dataDir + "/" + feedsDir + ipDelaysPath = dataDir + "/" + ipDelaysFile + pwResetPath = dataDir + "/" + pwResetFile + pwResetWaitPath = dataDir + "/" + pwResetWaitFile + if "" != keyPath { + log.Println("Using TLS.") + if _, err := os.Stat(certPath); err != nil { + log.Fatal("No certificate file found.") + } + if _, err := os.Stat(keyPath); err != nil { + log.Fatal("No server key file found.") + } + } + createFileIfNotExists(loginsPath) + createFileIfNotExists(pwResetPath) + createFileIfNotExists(pwResetWaitPath) + createFileIfNotExists(ipDelaysPath) + // TODO: Handle err here. + _ = os.Mkdir(feedsPath, 0700) +} diff --git a/main.go b/main.go index 00a49eb..2dad4fe 100644 --- a/main.go +++ b/main.go @@ -2,60 +2,34 @@ package main -import "bufio" import "errors" import "flag" -import "github.com/gorilla/mux" +import "fmt" import "golang.org/x/crypto/bcrypt" +import "golang.org/x/crypto/ssh/terminal" +import "gopkg.in/gomail.v2" import "html/template" import "io/ioutil" import "log" +import "net" import "net/http" import "os" import "strconv" import "strings" +import "syscall" import "time" -const loginsFile = "logins.txt" -const feedsDir = "feeds" +const resetLinkExp = 1800 +const resetWaitTime = 3600 * 24 +const version = "1.0" -var dataDir string -var loginsPath string -var feedsPath string +var contact string +var dialer *gomail.Dialer +var mailuser string +var myself string +var signupOpen bool var templ *template.Template -func createFileIfNotExists(path string) { - if _, err := os.Stat(path); err != nil { - file, err := os.Create(path) - if err != nil { - log.Fatal("Can't create file: ", err) - } - file.Close() - } -} - -func appendToFile(path string, msg string) { - fileWrite, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0600) - defer fileWrite.Close() - if err != nil { - log.Fatal("Can't open file for appending", err) - } - if _, err = fileWrite.WriteString(msg); err != nil { - log.Fatal("Can't write to file", err) - } -} - -func onlyLegalRunes(str string) bool { - alphabet := "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz" + - "0123456789_" - for _, ru := range str { - if !(strings.ContainsRune(alphabet, ru)) { - return false - } - } - return true -} - func execTemplate(w http.ResponseWriter, file string, input string) { type data struct{ Msg string } err := templ.ExecuteTemplate(w, file, data{Msg: input}) @@ -64,239 +38,273 @@ func execTemplate(w http.ResponseWriter, file string, input string) { } } +func handleTemplate(path, msg string) func(w http.ResponseWriter, + r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + execTemplate(w, path, msg) + } +} + +func onlyLegalRunes(str string) bool { + const legalUrlChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + + "abcdefghijklmnopqrstuvwxyz0123456789_" + for _, ru := range str { + if !(strings.ContainsRune(legalUrlChars, ru)) { + return false + } + } + return true +} + +func checkDelay(w http.ResponseWriter, ip string) (int, error) { + var err error + var openTime int + delay := -1 + if tokens, e := getFromFileEntryFor(ipDelaysPath, ip, 3); e == nil { + openTime, err = strconv.Atoi(tokens[0]) + if err != nil { + log.Fatal("Can't parse IP delays file", err) + } + delay, err = strconv.Atoi(tokens[1]) + if err != nil { + log.Fatal("Can't parse IP delays file", err) + } + if int(time.Now().Unix()) < openTime { + execTemplate(w, "error.html", + "This IP must wait a while for its "+ + "next login attempt.") + err = errors.New("") + } + } + return delay, err +} + func login(w http.ResponseWriter, r *http.Request) (string, error) { + ip, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + log.Fatal("Can't parse ip from request", err) + } + delay, err := checkDelay(w, ip) + if err != nil { + return "", err + } name := r.FormValue("name") pw := r.FormValue("password") loginValid := false - file, err := os.Open(loginsPath) - defer file.Close() - if err != nil { - log.Fatal("Can't open file for reading", err) - } - scanner := bufio.NewScanner(bufio.NewReader(file)) - for { - if !scanner.Scan() { - break - } - line := scanner.Text() - tokens := strings.Split(line, " ") - if len(tokens) == 3 { - if 0 == strings.Compare(tokens[0], name) && - nil == bcrypt.CompareHashAndPassword( - []byte(tokens[1]), []byte(pw)) { - loginValid = true - - } + tokens, err := getFromFileEntryFor(loginsPath, name, 5) + if err == nil && nil == bcrypt.CompareHashAndPassword([]byte(tokens[0]), + []byte(pw)) { + loginValid = true + if 0 <= delay { + removeLineStartingWith(ipDelaysPath, ip) } } if !loginValid { - execTemplate(w, "error.html", "Bad login.") + newLine := delay == -1 + delay = 2 * delay + if -2 == delay { + delay = 1 + } + strOpenTime := strconv.Itoa(int(time.Now().Unix()) + delay) + strDelay := strconv.Itoa(delay) + line := ip + "\t" + strOpenTime + "\t" + strDelay + if newLine { + appendToFile(ipDelaysPath, line) + } else { + replaceLineStartingWith(ipDelaysPath, ip, line) + } + execTemplate(w, "error_login.html", "Bad login.") return name, errors.New("") } return name, nil } -func accountLine(w http.ResponseWriter, r *http.Request, - checkDupl bool) (string, error) { - name := r.FormValue("name") - pw := r.FormValue("new_password") - pw2 := r.FormValue("new_password2") - mail := r.FormValue("mail") - if 0 != strings.Compare(pw, pw2) || 0 == strings.Compare("name", "") || - 0 == strings.Compare(pw, "") || !onlyLegalRunes(name) || - len(name) > 140 { - execTemplate(w, "error.html", "Invalid values.") - return "", errors.New("") - } - if checkDupl { - fileRead, err := os.Open(loginsPath) - defer fileRead.Close() - if err != nil { - log.Fatal("Can't open file for reading", err) - } - scanner := bufio.NewScanner(bufio.NewReader(fileRead)) - for { - if !scanner.Scan() { - break - } - line := scanner.Text() - tokens := strings.Split(line, " ") - if 0 == strings.Compare(name, tokens[0]) { - execTemplate(w, "error.html", "Username taken.") - return "", errors.New("") - } - } - } +func nameIsLegal(name string) bool { + return !("" == name || !onlyLegalRunes(name) || len(name) > 140) +} + +func passwordIsLegal(password string) bool { + return !("" == password) +} + +func hashFromPw(pw string) string { hash, err := bcrypt.GenerateFromPassword([]byte(pw), bcrypt.DefaultCost) if err != nil { - log.Fatal("Can't generate password hash", err) + log.Fatal("Can't generate hash", err) } - return name + " " + string(hash) + " " + mail, nil + return string(hash) } -func indexHandler(w http.ResponseWriter, r *http.Request) { - execTemplate(w, "index.html", "") -} - -func signUpFormHandler(w http.ResponseWriter, r *http.Request) { - execTemplate(w, "signupform.html", "") -} - -func signUpHandler(w http.ResponseWriter, r *http.Request) { - newLine, err := accountLine(w, r, true) - if err != nil { - return +func newPassword(w http.ResponseWriter, r *http.Request) (string, error) { + pw := r.FormValue("new_password") + pw2 := r.FormValue("new_password2") + if 0 != strings.Compare(pw, pw2) { + return "", errors.New("Password values did not match") + } else if !passwordIsLegal(pw) { + return "", errors.New("Illegal password.") } - appendToFile(loginsPath, newLine+"\n") - execTemplate(w, "feedset.html", "") + return hashFromPw(pw), nil } -func accountFormHandler(w http.ResponseWriter, r *http.Request) { - execTemplate(w, "accountform.html", "") +func newMailAddress(w http.ResponseWriter, r *http.Request) (string, error) { + mail := r.FormValue("mail") + if len(mail) > 140 || strings.ContainsRune(mail, '\n') || + strings.ContainsRune(mail, '\t') { + return "", errors.New("Illegal mail address.") + } + return mail, nil } -func accountPostHandler(w http.ResponseWriter, r *http.Request) { +func newSecurityQuestion(w http.ResponseWriter, r *http.Request) (string, + string, error) { + secquestion := r.FormValue("secquestion") + secanswer := r.FormValue("secanswer") + if "" == secquestion || len(secquestion) > 140 || + strings.ContainsRune(secquestion, '\n') || + strings.ContainsRune(secquestion, '\t') { + return "", "", errors.New("Illegal security question.") + } else if "" == secanswer { + return "", "", errors.New("Illegal security question answer.") + } + return secquestion, hashFromPw(secanswer), nil +} + +func changeLoginField(w http.ResponseWriter, r *http.Request, + getter func(w http.ResponseWriter, r *http.Request) (string, error), + position int) { name, err := login(w, r) if err != nil { return } - newLine, err := accountLine(w, r, false) + input, err := getter(w, r) if err != nil { + execTemplate(w, "error.html", err.Error()) return } - text, err := ioutil.ReadFile(loginsPath) + tokens, err := getFromFileEntryFor(loginsPath, name, 5) if err != nil { - log.Fatal("Can't read file", err) - } - lines := strings.Split(string(text), "\n") - for i, line := range lines { - tokens := strings.Split(line, " ") - if 0 == strings.Compare(name, tokens[0]) { - lines[i] = newLine - break - } - } - text = []byte(strings.Join(lines, "\n")) - tmpFile := "tmp_" + loginsPath - if err := ioutil.WriteFile(tmpFile, []byte(text), 0600); err != nil { - log.Fatal("Trouble writing file", err) - } - if err := os.Rename(loginsPath, "_"+loginsFile); err != nil { - log.Fatal("Trouble moving file", err) - } - if err := os.Rename(tmpFile, loginsPath); err != nil { - log.Fatal("Trouble moving file", err) - } - if err := os.Remove("_" + loginsPath); err != nil { - log.Fatal("Trouble removing file", err) + log.Fatal("Can't get entry for user", err) } + tokens[position] = input + replaceLineStartingWith(loginsPath, name, + name+"\t"+strings.Join(tokens, "\t")) execTemplate(w, "feedset.html", "") } -func listHandler(w http.ResponseWriter, r *http.Request) { - file, err := os.Open(loginsPath) - defer file.Close() +func nameMyself(ssl bool, port int) string { + resp, err := http.Get("http://myexternalip.com/raw") + defer resp.Body.Close() if err != nil { - log.Fatal("Can't open file for reading", err) + log.Fatal("Trouble getting IP", err) } - scanner := bufio.NewScanner(bufio.NewReader(file)) - var dir []string - for { - if !scanner.Scan() { - break - } - line := scanner.Text() - tokens := strings.Split(line, " ") - if len(tokens) == 3 { - dir = append(dir, tokens[0]) - } - } - type data struct{ Dir []string } - err = templ.ExecuteTemplate(w, "list.html", data{Dir: dir}) + body, err := ioutil.ReadAll(resp.Body) if err != nil { - log.Fatal("Trouble executing template", err) + log.Fatal("Trouble reading IP message body", err) } + ip := strings.Replace(string(body), "\n", "", -1) + s := "" + if ssl { + s = "s" + } + return "http" + s + "://" + ip + ":" + strconv.Itoa(port) } -func twtxtPostHandler(w http.ResponseWriter, r *http.Request) { - name, err := login(w, r) - if err != nil { - return +func addUser(login string) { + fields := strings.Split(login, ":") + if len(fields) != 2 { + log.Fatal("Malformed adduser string, must be NAME:PASSWORD") } - text := r.FormValue("twt") - twtsFile := feedsPath + "/" + name - createFileIfNotExists(twtsFile) - text = strings.Replace(text, "\n", " ", -1) - appendToFile(twtsFile, time.Now().Format(time.RFC3339)+"\t"+text+"\n") - http.Redirect(w, r, "/"+feedsDir+"/"+name, 302) + name := fields[0] + password := fields[1] + if !nameIsLegal(name) { + log.Fatal("Malformed adduser NAME argument.") + } + if !passwordIsLegal(password) { + log.Fatal("Malformed adduser PASSWORD argument.") + } + if _, err := getFromFileEntryFor(loginsPath, name, 5); err == nil { + log.Fatal("Username already taken.") + } + hash := hashFromPw(password) + appendToFile(loginsPath, name+"\t"+hash+"\t\t\t") + fmt.Println("Added user.") } -func twtxtHandler(w http.ResponseWriter, r *http.Request) { - name := mux.Vars(r)["name"] - if !onlyLegalRunes(name) { - execTemplate(w, "error.html", "Bad path.") - return - } - path := feedsPath + "/" + name - if _, err := os.Stat(path); err != nil { - execTemplate(w, "error.html", "Empty twtxt for user.") - return - } - http.ServeFile(w, r, path) -} - -func main() { - var err error - portPtr := flag.Int("port", 8000, "port to serve") - keyPtr := flag.String("key", "", "SSL key file") - certPtr := flag.String("cert", "", "SSL certificate file") - templDirPtr := flag.String("templates", +func readOptions() (string, int, string, int, string, bool) { + var mailpw string + var mailport int + var mailserver string + var port int + var newLogin string + var showVersion bool + flag.StringVar(&newLogin, "adduser", "", "instead of starting as "+ + "server, add user with login NAME:PASSWORD") + flag.IntVar(&port, "port", 8000, "port to serve") + flag.StringVar(&keyPath, "key", "", "SSL key file") + flag.StringVar(&certPath, "cert", "", "SSL certificate file") + flag.StringVar(&templPath, "templates", os.Getenv("GOPATH")+"/src/htwtxt/templates", "directory where to expect HTML templates") flag.StringVar(&dataDir, "dir", os.Getenv("HOME")+"/htwtxt", "directory to store feeds and login data") + flag.StringVar(&contact, "contact", + "[operator passed no contact info to server]", + "operator contact info to display on info page") + flag.BoolVar(&signupOpen, "signup", false, + "enable on-site account creation") + flag.BoolVar(&showVersion, "version", false, "show version number") + flag.StringVar(&mailserver, "mailserver", "", + "SMTP server to send mails through") + flag.IntVar(&mailport, "mailport", 0, + "port of SMTP server to send mails through") + flag.StringVar(&mailuser, "mailuser", "", + "username to login with on SMTP server to send mails through") flag.Parse() - log.Println("Using as templates dir:", *templDirPtr) - log.Println("Using as data dir:", dataDir) - loginsPath = dataDir + "/" + loginsFile - feedsPath = dataDir + "/" + feedsDir - if ("" == *keyPtr && "" != *certPtr) || - ("" != *keyPtr && "" == *certPtr) { + if "" != mailserver && ("" == mailuser || 0 == mailport) { + log.Fatal("Mail server usage needs username and port number") + } + if ("" == keyPath && "" != certPath) || + ("" != keyPath && "" == certPath) { log.Fatal("Expect either both key and certificate or none.") } - if "" != *keyPtr { - log.Println("Using TLS.") - if _, err := os.Stat(*certPtr); err != nil { - log.Fatal("No certificate file found.") - } - if _, err := os.Stat(*keyPtr); err != nil { - log.Fatal("No server key file found.") + if "" != mailserver { + fmt.Print("Enter password for smtp server: ") + bytePassword, err := terminal.ReadPassword(int(syscall.Stdin)) + if err != nil { + log.Fatal("Trouble reading password") } + mailpw = string(bytePassword) + fmt.Println("") } - createFileIfNotExists(loginsPath) - // TODO: Handle err here. - _ = os.Mkdir(feedsPath, 0700) - templ, err = template.New("main").ParseGlob(*templDirPtr + "/*.html") + return mailserver, mailport, mailpw, port, newLogin, showVersion +} + +func main() { + var err error + mailserver, mailport, mailpw, port, newLogin, showVersion := + readOptions() + if showVersion { + fmt.Println("htwtxt", version) + return + } + initFilesAndDirs() + if "" != newLogin { + addUser(newLogin) + return + } + myself = nameMyself("" != keyPath, port) + templ, err = template.New("main").ParseGlob(templPath + "/*.html") if err != nil { log.Fatal("Can't set up new template: ", err) } - router := mux.NewRouter() - router.HandleFunc("/", indexHandler) - router.HandleFunc("/feeds", listHandler).Methods("GET") - router.HandleFunc("/feeds/", listHandler) - router.HandleFunc("/account", accountFormHandler).Methods("GET") - router.HandleFunc("/account", accountPostHandler).Methods("POST") - router.HandleFunc("/signup", signUpFormHandler).Methods("GET") - router.HandleFunc("/signup", signUpHandler).Methods("POST") - router.HandleFunc("/feeds", twtxtPostHandler).Methods("POST") - router.HandleFunc("/feeds/{name}", twtxtHandler) - http.Handle("/", router) - log.Println("serving at port", *portPtr) - if "" != *keyPtr { - err = http.ListenAndServeTLS(":"+strconv.Itoa(*portPtr), - *certPtr, *keyPtr, nil) + http.Handle("/", handleRoutes()) + dialer = gomail.NewPlainDialer(mailserver, mailport, mailuser, mailpw) + log.Println("serving at port", port) + if "" != keyPath { + err = http.ListenAndServeTLS(":"+strconv.Itoa(port), + certPath, keyPath, nil) } else { - err = http.ListenAndServe(":"+strconv.Itoa(*portPtr), nil) + err = http.ListenAndServe(":"+strconv.Itoa(port), nil) } if err != nil { log.Fatal("ListenAndServe: ", err) diff --git a/templates/account.html b/templates/account.html new file mode 100644 index 0000000..26bf010 --- /dev/null +++ b/templates/account.html @@ -0,0 +1,11 @@ +{{ template "header" }} +
+

Account settings

+ +
+{{ template "footer" }} diff --git a/templates/accountsetmail.html b/templates/accountsetmail.html new file mode 100644 index 0000000..2754045 --- /dev/null +++ b/templates/accountsetmail.html @@ -0,0 +1,29 @@ +{{ template "header" }} +
+
+ Set account mail address + +
+ + +

Used for password reset and feed owner authentication.

+
+ +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+{{ template "footer" }} diff --git a/templates/accountform.html b/templates/accountsetpw.html similarity index 57% rename from templates/accountform.html rename to templates/accountsetpw.html index d730a3b..f4d60dc 100644 --- a/templates/accountform.html +++ b/templates/accountsetpw.html @@ -1,22 +1,16 @@ {{ template "header" }} -
+
- Edit account + Change account password -
- - -

Used for password reset and feed owner authentication.

-
-
- +
- +

diff --git a/templates/accountsetquestion.html b/templates/accountsetquestion.html new file mode 100644 index 0000000..4529f36 --- /dev/null +++ b/templates/accountsetquestion.html @@ -0,0 +1,34 @@ +{{ template "header" }} + +
+ Set account security question + +
+ + +

To pose on password reset request.

+
+ +
+ + +
+ +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +{{ template "footer" }} diff --git a/templates/error_login.html b/templates/error_login.html new file mode 100644 index 0000000..13f640c --- /dev/null +++ b/templates/error_login.html @@ -0,0 +1,7 @@ +{{ template "header" }} +
+

Error

+

Something went wrong: Bad login. (Have you forgotten your password? If you added a mail address to your login data, you might be able to reset it.)

+
+{{ template "footer" }} + diff --git a/templates/feedset.html b/templates/feedset.html index d5be2e7..11c6048 100644 --- a/templates/feedset.html +++ b/templates/feedset.html @@ -1,6 +1,6 @@ {{ template "header" }}

Success

-

Your account has been created.

+

Your account has been set up.

{{ template "footer" }} diff --git a/templates/index.html b/templates/index.html index 1972ca8..bdecd0b 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,7 +1,7 @@ {{ template "header" }}
- Send tweet + Send twtxt
diff --git a/templates/info.html b/templates/info.html new file mode 100644 index 0000000..256dcde --- /dev/null +++ b/templates/info.html @@ -0,0 +1,8 @@ +{{ template "header" }} +
+

About this site

+

This site is a web server to host and grow twtxt feeds for users without trivial access to their own web space.

+

Its operator gives no guarantees regarding long-term availability of feeds, stability of their paths, or quality and integrity of their contents. If that does not satisfy your needs, consider hosting your own instance: The underlying server software htwtxt is freely available, and should not be too difficult to set up.

+

Site operator's contact info: {{ .Msg }}

+
+{{ template "footer" }} diff --git a/templates/nopwresetrequest.html b/templates/nopwresetrequest.html new file mode 100644 index 0000000..c841f03 --- /dev/null +++ b/templates/nopwresetrequest.html @@ -0,0 +1,7 @@ +{{ template "header" }} +
+

Password reset inactive

+

The site operator has currently not activated automatic password resetting.

+
+{{ template "footer" }} + diff --git a/templates/nosignup.html b/templates/nosignup.html new file mode 100644 index 0000000..3ae71a0 --- /dev/null +++ b/templates/nosignup.html @@ -0,0 +1,6 @@ +{{ template "header" }} +
+

Account creation closed

+

The site operator has not decided to currently open up account creation on this site to the public.

+
+{{ template "footer" }} diff --git a/templates/partials.html b/templates/partials.html index 2422213..856cb93 100644 --- a/templates/partials.html +++ b/templates/partials.html @@ -9,7 +9,7 @@ hosted twtxt server - + @@ -29,7 +29,7 @@ {{ end }} {{ define "footer" }} diff --git a/templates/pwreset.html b/templates/pwreset.html new file mode 100644 index 0000000..4d534b5 --- /dev/null +++ b/templates/pwreset.html @@ -0,0 +1,27 @@ +{{ template "header" }} + +
+ Reset account data + +
+ + +

(please repeat for verification)

+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +{{ template "footer" }} diff --git a/templates/pwresetquestion.html b/templates/pwresetquestion.html new file mode 100644 index 0000000..372dda5 --- /dev/null +++ b/templates/pwresetquestion.html @@ -0,0 +1,32 @@ +{{ template "header" }} +
+
+ Reset account data + +
+ + +

(please repeat for verification)

+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+{{ template "footer" }} diff --git a/templates/pwresetrequest.html b/templates/pwresetrequest.html new file mode 100644 index 0000000..b58a589 --- /dev/null +++ b/templates/pwresetrequest.html @@ -0,0 +1,24 @@ +{{ template "header" }} +
+
+ Request password reset + +
+ + + +
+ +

To reset the password for an account, an e-mail address must have been set for it. The request here will (if not hindered by the wait time described below) then be answered with an e-mail to the address which contains nothing but a randomized URL pointing back to a page on this server.

+

Said page will only be available for about 30 minutes. It will ask for the account name, a new password (must be entered twice), and, if a security question has been set for the account in question, an answer to that. The new password will only be set when the account name given there is the same given here, and, if a security question was set for that account, the answer is the one previously defined for it. Wrong answers will remove the temporary account reset page at once. A new one can be requested here only once every 24 hours.

+

Note that clicking the button below will always redirect you to this site's start page – no matter whether the server actually sends out a password reset link for any account or not.

+ +
+ +
+ + +
+
+{{ template "footer" }} + diff --git a/templates/signupform.html b/templates/signupform.html index 82ea936..6e2bddd 100644 --- a/templates/signupform.html +++ b/templates/signupform.html @@ -1,5 +1,5 @@ {{ template "header" }} -
+
Create account @@ -14,7 +14,18 @@

Used for password reset and feed owner authentication.

+ +
+ + +

To pose on password reset request.

+
+
+ + +
+
diff --git a/templates/css/style.css b/templates/style.css similarity index 97% rename from templates/css/style.css rename to templates/style.css index ef1ef42..321a805 100644 --- a/templates/css/style.css +++ b/templates/style.css @@ -2,6 +2,7 @@ body { background-color: white; color: black; font: 1rem/1.5 -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Open Sans', 'Helvetica Neue', sans-serif; + word-wrap: break-word; max-width: 30rem; padding: 0 1rem; margin: 0 auto;