Merge remote-tracking branch 'plomlompom/master'
このコミットが含まれているのは:
コミット
651d8b3e95
63
README.md
63
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
|
||||
<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
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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
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)
|
||||
|
|
|
@ -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" }}
|
|
@ -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 />
|
|
@ -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" }}
|
|
@ -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>
|
||||
|
|
|
@ -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" }}
|
|
@ -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" }}
|
||||
|
|
@ -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>
|
||||
|
|
|
@ -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" }}
|
|
@ -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" }}
|
|
@ -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;
|
読み込み中…
新しいイシューから参照