Merge remote-tracking branch 'plomlompom/master'

このコミットが含まれているのは:
kaikubasta 2016-02-16 21:40:26 +01:00
コミット 651d8b3e95
20個のファイルの変更971行の追加240行の削除

ファイルの表示

@ -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
<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 somewhat
more conservatively managed instance can be found at
<http://htwtxt.plomlompom.com:80/>.
## 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

318
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
}

169
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)
}

440
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)

11
templates/account.html ノーマルファイル
ファイルの表示

@ -0,0 +1,11 @@
{{ template "header" }}
<section>
<h2>Account settings</h2>
<ul>
<li><a href="/accountsetpw">change password</a></li>
<li><a href="/accountsetmail">set mail address</a></li>
<li><a href="/accountsetquestion">set security question</a></li>
<li><a href="/passwordreset">request password reset</a></li>
</ul>
</section>
{{ template "footer" }}

29
templates/accountsetmail.html ノーマルファイル
ファイルの表示

@ -0,0 +1,29 @@
{{ template "header" }}
<form method="post" action="accountsetmail">
<fieldset>
<legend>Set account mail address</legend>
<div>
<label for="mail">New e-mail address</label>
<input type="email" id="mail" name="mail" aria-describedby="mail-desc" required/>
<p id="mail-desc">Used for password reset and feed owner authentication.</p>
</div>
<hr />
<div>
<label for="name">Name</label>
<input type="text" id="name" name="name" maxlength="140" required />
</div>
<div>
<label for="password">Password</label>
<input type="password" id="password" name="password" required />
</div>
<hr />
<button type="submit">Update</button>
</fieldset>
</form>
{{ template "footer" }}

ファイルの表示

@ -1,22 +1,16 @@
{{ template "header" }}
<form method="post" action="feeds">
<form method="post" action="accountsetpw">
<fieldset>
<legend>Edit account</legend>
<legend>Change account password</legend>
<div>
<label for="mail">New e-mail address</label>
<input type="email" id="mail" name="mail" aria-describedby="mail-desc"/>
<p id="mail-desc">Used for password reset and feed owner authentication.</p>
</div>
<div>
<label for="password">New password</label>
<input type="password" id="password" name="new_password" />
<input type="password" id="password" name="new_password" required />
</div>
<div>
<label for="password2">New password <span>(repeat)</span></label>
<input type="password" id="password2" name="new_password2" />
<input type="password" id="password2" name="new_password2" required />
</div>
<hr />

34
templates/accountsetquestion.html ノーマルファイル
ファイルの表示

@ -0,0 +1,34 @@
{{ template "header" }}
<form method="post" action="accountsetquestion">
<fieldset>
<legend>Set account security question</legend>
<div>
<label for="secquestion">New security question</label>
<input type="text" id="secquestion" name="secquestion" required />
<p id="sequestion-desc">To pose on password reset request.</p>
</div>
<div>
<label for="secanswer">New security question answer</label>
<input type="text" id="secanswer" name="secanswer" required />
</div>
<hr />
<div>
<label for="name">Name</label>
<input type="text" id="name" name="name" maxlength="140" required />
</div>
<div>
<label for="password">Password</label>
<input type="password" id="password" name="password" required />
</div>
<hr />
<button type="submit">Update</button>
</fieldset>
</form>
{{ template "footer" }}

7
templates/error_login.html ノーマルファイル
ファイルの表示

@ -0,0 +1,7 @@
{{ template "header" }}
<section class="error">
<h2>Error</h2>
<p>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 <a href="/passwordreset">reset</a> it.)</p>
</section>
{{ template "footer" }}

ファイルの表示

@ -1,6 +1,6 @@
{{ template "header" }}
<section class="success">
<h2>Success</h2>
<p>Your account has been created.</p>
<p>Your account has been set up.</p>
</section>
{{ template "footer" }}

ファイルの表示

@ -1,7 +1,7 @@
{{ template "header" }}
<form method="post" action="feeds">
<fieldset>
<legend>Send tweet</legend>
<legend>Send twtxt</legend>
<div>
<label for="twt">Message</label>

8
templates/info.html ノーマルファイル
ファイルの表示

@ -0,0 +1,8 @@
{{ template "header" }}
<section>
<h2>About this site</h2>
<p>This site is a web server to host and grow <a href="https://github.com/buckket/twtxt">twtxt</a> feeds for users without trivial access to their own web space.</p>
<p>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 <a href="https://github.com/plomlompom/htwtxt">htwtxt</a> is freely available, and should not be too difficult to set up.</p>
<p>Site operator's contact info: {{ .Msg }}</p>
</section>
{{ template "footer" }}

7
templates/nopwresetrequest.html ノーマルファイル
ファイルの表示

@ -0,0 +1,7 @@
{{ template "header" }}
<section>
<h2>Password reset inactive</h2>
<p>The site operator has currently not activated automatic password resetting.</p>
</section>
{{ template "footer" }}

6
templates/nosignup.html ノーマルファイル
ファイルの表示

@ -0,0 +1,6 @@
{{ template "header" }}
<section>
<h2>Account creation closed</h2>
<p>The site operator has not decided to currently open up account creation on this site to the public.</p>
</section>
{{ template "footer" }}

ファイルの表示

@ -9,7 +9,7 @@
<title>hosted twtxt server</title>
<link rel="stylesheet" type="text/css" href="css/style.css" media="all" />
<link rel="stylesheet" type="text/css" href="/style.css" media="all" />
</head>
@ -29,7 +29,7 @@
{{ end }}
{{ define "footer" }}
<footer>
<p>Licensed under <a href="http://www.gnu.org/licenses/agpl-3.0.html" rel="license">AGPLv3</a>. Source code on <a href="https://github.com/plomlompom/htwtxt">GitHub</a>.</p>
<p><a href="/info">About this site</a>.<br /> Licensed under <a href="http://www.gnu.org/licenses/agpl-3.0.html" rel="license">AGPLv3</a>. Source code <a href="https://github.com/plomlompom/htwtxt">on GitHub</a>.</p>
</footer>
</body>

27
templates/pwreset.html ノーマルファイル
ファイルの表示

@ -0,0 +1,27 @@
{{ template "header" }}
<form method="post" action="/passwordreset/{{ .Msg }}">
<fieldset>
<legend>Reset account data</legend>
<div>
<label for="name">Name</label>
<input type="text" id="name" name="name" required />
<p id="name-desc">(please repeat for verification)</p>
</div>
<div>
<label for="password">New password</label>
<input type="password" id="password" name="new_password" required />
</div>
<div>
<label for="password2">New password <span>(repeat)</span></label>
<input type="password" id="password2" name="new_password2" required />
</div>
<hr />
<button type="submit">Reset</button>
</fieldset>
</form>
{{ template "footer" }}

32
templates/pwresetquestion.html ノーマルファイル
ファイルの表示

@ -0,0 +1,32 @@
{{ template "header" }}
<form method="post" action="/passwordreset/{{ .Secret }}">
<fieldset>
<legend>Reset account data</legend>
<div>
<label for="name">Name</label>
<input type="text" id="name" name="name" required />
<p id="name-desc">(please repeat for verification)</p>
</div>
<div>
<label for="secanswer">Security question: {{ .Question }}</label>
<input type="text" id="secanswer" name="secanswer" required />
</div>
<div>
<label for="password">New password</label>
<input type="password" id="password" name="new_password" required />
</div>
<div>
<label for="password2">New password <span>(repeat)</span></label>
<input type="password" id="password2" name="new_password2" required />
</div>
<hr />
<button type="submit">Reset</button>
</fieldset>
</form>
{{ template "footer" }}

24
templates/pwresetrequest.html ノーマルファイル
ファイルの表示

@ -0,0 +1,24 @@
{{ template "header" }}
<form method="post" action="passwordreset">
<fieldset>
<legend>Request password reset</legend>
<div>
<label for="name">Account whose login to reset</label>
<input type="text" id="name" name="name" />
<hr />
<p>To reset the password for an account, an e-mail address <a href="/accountsetmail">must have been set</a> 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.</p>
<p>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 <a href="/accountsetquestion">has been set</a> 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.</p>
<p>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.</p>
</div>
<hr />
<button type="submit">Request</button>
</fieldset>
</form>
{{ template "footer" }}

ファイルの表示

@ -1,5 +1,5 @@
{{ template "header" }}
<form method="post" action="feeds">
<form method="post" action="signup">
<fieldset>
<legend>Create account</legend>
@ -14,7 +14,18 @@
<input type="email" id="mail" name="mail" aria-describedby="mail-desc"/>
<p id="mail-desc">Used for password reset and feed owner authentication.</p>
</div>
<div>
<label for="secquestion">Security question <span>(optional)</span></label>
<input type="text" id="secquestion" name="secquestion" />
<p id="sequestion-desc">To pose on password reset request.</p>
</div>
<div>
<label for="secanswer">Security question answer <span>(optional)</span></label>
<input type="text" id="secanswer" name="secanswer" />
</div>
<div>
<label for="password">Password</label>
<input type="password" id="password" name="new_password" required />

ファイルの表示

@ -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;