~m15o/riku

921eb93dc7b1ffca09be82f5c73abf369be0773f — m15o 2 years ago
first commit
A  => .idea/.gitignore +8 -0
@@ 1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

A  => .idea/dataSources.xml +12 -0
@@ 1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
  <component name="DataSourceManagerImpl" format="xml" multifile-model="true">
    <data-source source="LOCAL" name="riku@localhost" uuid="7c09fe96-a3ff-42d2-a844-90ab840feb2a">
      <driver-ref>postgresql</driver-ref>
      <synchronize>true</synchronize>
      <jdbc-driver>org.postgresql.Driver</jdbc-driver>
      <jdbc-url>jdbc:postgresql://localhost:5432/riku</jdbc-url>
      <working-dir>$ProjectFileDir$</working-dir>
    </data-source>
  </component>
</project>
\ No newline at end of file

A  => .idea/modules.xml +8 -0
@@ 1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
  <component name="ProjectModuleManager">
    <modules>
      <module fileurl="file://$PROJECT_DIR$/.idea/riku.iml" filepath="$PROJECT_DIR$/.idea/riku.iml" />
    </modules>
  </component>
</project>
\ No newline at end of file

A  => .idea/riku.iml +9 -0
@@ 1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
  <component name="Go" enabled="true" />
  <component name="NewModuleRootManager">
    <content url="file://$MODULE_DIR$" />
    <orderEntry type="inheritedJdk" />
    <orderEntry type="sourceFolder" forTests="false" />
  </component>
</module>
\ No newline at end of file

A  => .idea/watcherTasks.xml +29 -0
@@ 1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
  <component name="ProjectTasksOptions">
    <TaskOptions isEnabled="true">
      <option name="arguments" value="fmt $FilePath$" />
      <option name="checkSyntaxErrors" value="true" />
      <option name="description" />
      <option name="exitCodeBehavior" value="ERROR" />
      <option name="fileExtension" value="go" />
      <option name="immediateSync" value="false" />
      <option name="name" value="go fmt" />
      <option name="output" value="$FilePath$" />
      <option name="outputFilters">
        <array />
      </option>
      <option name="outputFromStdout" value="false" />
      <option name="program" value="$GoExecPath$" />
      <option name="runOnExternalChanges" value="false" />
      <option name="scopeName" value="Project Files" />
      <option name="trackOnlyRoot" value="true" />
      <option name="workingDir" value="$ProjectFileDir$" />
      <envs>
        <env name="GOROOT" value="$GOROOT$" />
        <env name="GOPATH" value="$GOPATH$" />
        <env name="PATH" value="$GoBinDirs$" />
      </envs>
    </TaskOptions>
  </component>
</project>
\ No newline at end of file

A  => Makefile +2 -0
@@ 1,2 @@
build:
	CGO_ENABLED=0 GOOS=linux go build -o bin/riku main.go

A  => assets/static_stylesheet.go +251 -0
@@ 1,251 @@
// Code generated by go generate; DO NOT EDIT.

package assets

var AssetsMap = map[string]string{
	"style": `body {
	max-width: 800px;
	margin: auto;
	padding: 5px;
	font: 14px/1.4 system-ui, sans-serif;
	background-color: lemonchiffon;
}

/* Lists ************************************************************/
.errors {
	background-color: mistyrose;
	color: red;
}
.info {
	background-color: paleturquoise;
	border: 1px dashed;
	padding: 0 1em;
	margin: 1em 0;
}
.topic img {
	max-width: 100%;
}

main {
	margin-bottom: 1em;
}
ol.posts, ol.replies {
	padding: 0;
	list-style: none;
}

/*ol.posts > li:not(:last-child),*/
ol.replies > li:not(:last-child) {
	margin-bottom: 1em;
}

/* Posts ************************************************************/



.topic > tbody > tr:nth-child(2n) {
	background-color: whitesmoke;
}
.signature > * {
	margin-bottom: 0;
}

.signature {
	border-top: 1px solid gray;
	padding-top: 8px;
}

.action {
	margin: 1em 1em 1em 0;
}
nav.breadcrumb {
	padding: 1em;
	border: 1px solid;
	margin: 1em 0;
}
.breadcrumb ul {
	list-style: none;
	padding-left: 1em;
}
.breadcrumb > ul {
	padding: 0;
	margin: 0;
}
.col-author {
	text-align: center;
	width: 100px;
}
table.post {
	background-color: white;
}
table.post .header {
	background-color: palegreen;
}

article {
	border: 1px solid;
	background-color: white;
	margin-bottom: 1em;
}

article > header {
	background-color: palegreen;
	border-bottom: 1px solid;
	padding: 5px 1em;
}

article > div {
	padding: 0 1em;
}

.forum { background-color: cornsilk; }

/* Start post */
/* With a table */
.post-aside {
	text-align: center;
	width: 150px;
	background-color: palegreen;
}

.post-body {
	height: 100%;
}

.post-body, .post-body td, .post-body tr {
	padding: 0;
	border: 0;
}

.post-body tbody {
	background-color: inherit;
}
/* With articles */
/*article {*/
/*	padding: 5px;*/
/*	border: 1px solid;*/
/*	margin-bottom: 1em;*/
/*}*/
/*article:after {*/
/*	clear: both;*/
/*	content: "";*/
/*	display: block;*/
/*	visibility: hidden;*/
/*}*/
/*.post-aside {*/
/*	text-align: center;*/
/*	width: 150px;*/
/*	float: left;*/
/*}*/
/*.post-content {*/
/*	max-width: 650px;*/
/*	margin-left: 150px;*/
/*}*/
/* End */
table {
	border-collapse: collapse;
	border: 1px solid;
	width: 100%;
}
tr, td, th {
	vertical-align: top;
	border: 1px solid;
	padding: .5em;
}
thead {
	background-color: palegreen;
}
tbody {
	background-color: white;
}


.content h1 { font-size: 1.5em; }
.content h2 { font-size: 1.2em; }
.content h3 { font-size: 1em; }
.content { margin: 1em 0; }

/* Forms ************************************************************/

.auth-form {
	max-width: 200px;
}

.field {
	margin-bottom: 1em;
}

.field label {
	display: block;
}

input[type=text], input[type=password] {
	width: 100%;
	box-sizing: border-box;
}

textarea {
	width: 100%;
	height: 250px;
	display: block;
	box-sizing: border-box;
}

/* Misc *************************************************************/

blockquote {
	margin: 0;
	color: green;
	font-style: italic;
}

.center { text-align: center; }
.grow { width: 100%; }

hr {
	border: none;
	height: 1px;
	background-color: grey;
}

.small {
	font-size: 14px;
	color: grey;
}

.tabs {
	border-bottom: 2px solid palevioletred;
	margin: 1em 0;
}

.tabs a {
	padding: 0 1em;
}

.tabs .selected {
	background-color: palevioletred;
	color: white;
}

pre {
	background-color: palegoldenrod;
	border: 1px dashed;
	padding: 1em;
	font-family: monospace;
	font-size: initial;
	line-height: initial;
	overflow-x: auto;
}

dt {
	font-weight: bold;
}

dd {
	margin-left: 0;
}

.actions {
	display: flex;
}`,
}

A  => assets/style.css +245 -0
@@ 1,245 @@
body {
	max-width: 800px;
	margin: auto;
	padding: 5px;
	font: 14px/1.4 system-ui, sans-serif;
	background-color: lemonchiffon;
}

/* Lists ************************************************************/
.errors {
	background-color: mistyrose;
	color: red;
}
.info {
	background-color: paleturquoise;
	border: 1px dashed;
	padding: 0 1em;
	margin: 1em 0;
}
.topic img {
	max-width: 100%;
}

main {
	margin-bottom: 1em;
}
ol.posts, ol.replies {
	padding: 0;
	list-style: none;
}

/*ol.posts > li:not(:last-child),*/
ol.replies > li:not(:last-child) {
	margin-bottom: 1em;
}

/* Posts ************************************************************/



.topic > tbody > tr:nth-child(2n) {
	background-color: whitesmoke;
}
.signature > * {
	margin-bottom: 0;
}

.signature {
	border-top: 1px solid gray;
	padding-top: 8px;
}

.action {
	margin: 1em 1em 1em 0;
}
nav.breadcrumb {
	padding: 1em;
	border: 1px solid;
	margin: 1em 0;
}
.breadcrumb ul {
	list-style: none;
	padding-left: 1em;
}
.breadcrumb > ul {
	padding: 0;
	margin: 0;
}
.col-author {
	text-align: center;
	width: 100px;
}
table.post {
	background-color: white;
}
table.post .header {
	background-color: palegreen;
}

article {
	border: 1px solid;
	background-color: white;
	margin-bottom: 1em;
}

article > header {
	background-color: palegreen;
	border-bottom: 1px solid;
	padding: 5px 1em;
}

article > div {
	padding: 0 1em;
}

.forum { background-color: cornsilk; }

/* Start post */
/* With a table */
.post-aside {
	text-align: center;
	width: 150px;
	background-color: palegreen;
}

.post-body {
	height: 100%;
}

.post-body, .post-body td, .post-body tr {
	padding: 0;
	border: 0;
}

.post-body tbody {
	background-color: inherit;
}
/* With articles */
/*article {*/
/*	padding: 5px;*/
/*	border: 1px solid;*/
/*	margin-bottom: 1em;*/
/*}*/
/*article:after {*/
/*	clear: both;*/
/*	content: "";*/
/*	display: block;*/
/*	visibility: hidden;*/
/*}*/
/*.post-aside {*/
/*	text-align: center;*/
/*	width: 150px;*/
/*	float: left;*/
/*}*/
/*.post-content {*/
/*	max-width: 650px;*/
/*	margin-left: 150px;*/
/*}*/
/* End */
table {
	border-collapse: collapse;
	border: 1px solid;
	width: 100%;
}
tr, td, th {
	vertical-align: top;
	border: 1px solid;
	padding: .5em;
}
thead {
	background-color: palegreen;
}
tbody {
	background-color: white;
}


.content h1 { font-size: 1.5em; }
.content h2 { font-size: 1.2em; }
.content h3 { font-size: 1em; }
.content { margin: 1em 0; }

/* Forms ************************************************************/

.auth-form {
	max-width: 200px;
}

.field {
	margin-bottom: 1em;
}

.field label {
	display: block;
}

input[type=text], input[type=password] {
	width: 100%;
	box-sizing: border-box;
}

textarea {
	width: 100%;
	height: 250px;
	display: block;
	box-sizing: border-box;
}

/* Misc *************************************************************/

blockquote {
	margin: 0;
	color: green;
	font-style: italic;
}

.center { text-align: center; }
.grow { width: 100%; }

hr {
	border: none;
	height: 1px;
	background-color: grey;
}

.small {
	font-size: 14px;
	color: grey;
}

.tabs {
	border-bottom: 2px solid palevioletred;
	margin: 1em 0;
}

.tabs a {
	padding: 0 1em;
}

.tabs .selected {
	background-color: palevioletred;
	color: white;
}

pre {
	background-color: palegoldenrod;
	border: 1px dashed;
	padding: 1em;
	font-family: monospace;
	font-size: initial;
	line-height: initial;
	overflow-x: auto;
}

dt {
	font-weight: bold;
}

dd {
	margin-left: 0;
}

.actions {
	display: flex;
}
\ No newline at end of file

A  => bin/riku +0 -0
A  => config/config.go +21 -0
@@ 1,21 @@
package config

import "os"

type Config struct {
	Port        string
	DatabaseURL string
	SessionKey  string
}

func New() *Config {
	cfg := &Config{
		Port:        os.Getenv("PORT"),
		DatabaseURL: os.Getenv("DATABASE_URL"),
		SessionKey:  os.Getenv("SESSION_KEY"),
	}
	if cfg.Port == "" {
		cfg.Port = "8888"
	}
	return cfg
}

A  => generate.go +87 -0
@@ 1,87 @@
// +build ignore

package main

import (
	"io/ioutil"
	"os"
	"path"
	"path/filepath"
	"strings"
	"text/template"
)

const tpl = `// Code generated by go generate; DO NOT EDIT.

package {{ .Package }}

var {{ .Map }} = map[string]string{
{{ range $constant, $content := .Files }}` + "\t" + `"{{ $constant }}": ` + "`{{ $content }}`" + `,
{{ end }}}
`

var bundleTpl = template.Must(template.New("").Parse(tpl))

type Bundle struct {
	Package string
	Map     string
	Files   map[string]string
}

func (b *Bundle) Write(filename string) {
	f, err := os.Create(filename)
	if err != nil {
		panic(err)
	}
	defer f.Close()

	bundleTpl.Execute(f, b)
}

func NewBundle(pkg, mapName string) *Bundle {
	return &Bundle{
		Package: pkg,
		Map:     mapName,
		Files:   make(map[string]string),
	}
}

func stripExtension(filename string) string {
	filename = strings.TrimSuffix(filename, path.Ext(filename))
	return strings.Replace(filename, " ", "_", -1)
}

func readFile(filename string) []byte {
	data, err := ioutil.ReadFile(filename)
	if err != nil {
		panic(err)
	}
	return data
}

func glob(pattern string) []string {
	files, _ := filepath.Glob(pattern)
	for i := range files {
		if strings.Contains(files[i], "\\") {
			files[i] = filepath.ToSlash(files[i])
		}
	}
	return files
}

func generateMap(target string, pkg string, mapName string, srcFiles []string) {
	bundle := NewBundle(pkg, mapName)
	for _, srcFile := range srcFiles {
		data := readFile(srcFile)
		filename := stripExtension(path.Base(srcFile))
		bundle.Files[filename] = string(data)
	}
	bundle.Write(target)
}

func main() {
	generateMap(path.Join("storage", "sql.go"), "storage", "SqlMap", glob("storage/sql/*.sql"))
	generateMap(path.Join("web", "handler", "html.go"), "handler", "TplMap", glob("web/handler/html/*.html"))
	generateMap(path.Join("web", "handler", "common.go"), "handler", "TplCommonMap", glob("web/handler/html/common/*.html"))
	generateMap(path.Join("assets", "static_stylesheet.go"), "assets", "AssetsMap", glob("assets/*.css"))
}

A  => go.mod +10 -0
@@ 1,10 @@
module riku

go 1.16

require (
	github.com/gorilla/mux v1.8.0
	github.com/gorilla/sessions v1.2.1
	github.com/lib/pq v1.10.4
	golang.org/x/crypto v0.0.0-20220214200702-86341886e292
)

A  => go.sum +17 -0
@@ 1,17 @@
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
github.com/gorilla/securecookie v1.1.1 h1:miw7JPhV+b/lAHSXz4qd/nN9jRiAFV5FwjeKyCS8BvQ=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/sessions v1.2.1 h1:DHd3rPN5lE3Ts3D8rKkQ8x/0kqfeNmBAaiSi+o7FsgI=
github.com/gorilla/sessions v1.2.1/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM=
github.com/lib/pq v1.10.4 h1:SO9z7FRPzA03QhHKJrH5BXA6HU1rS4V2nIVrrNC1iYk=
github.com/lib/pq v1.10.4/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
golang.org/x/crypto v0.0.0-20220214200702-86341886e292 h1:f+lwQ+GtmgoY+A2YaQxlSOnDjXcQ7ZRLWOHbC6HtRqE=
golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=

A  => mailer/mailer.go +29 -0
@@ 1,29 @@
package mailer

import (
	"riku/model"
)

func Notify(email string, r model.Request) error {
	return nil
	//from := "m15o@posteo.net"
	//
	//user := "8481258e-e868-4569-ae04-a0a745cc185c"
	//password := "8481258e-e868-4569-ae04-a0a745cc185c"
	//
	//to := []string{
	//	email,
	//}
	//
	//addr := "smtp.postmarkapp.com:2525"
	//host := "smtp.postmarkapp.com"
	//
	//msg := []byte("From: m15o@posteo.net\r\n" +
	//	"To: " + "email" + "\r\n" +
	//	"Subject: " + "[" + r.Tag + "]" + " New request\r\n\r\n" +
	//	r.Data + "\r\n")
	//
	//auth := smtp.PlainAuth("", user, password, host)
	//
	//return smtp.SendMail(addr, auth, from, to, msg)
}

A  => main.go +27 -0
@@ 1,27 @@
//go:generate go run generate.go
package main

import (
	"log"
	"net/http"
	"riku/config"
	"riku/storage"
	"riku/web/handler"
	"riku/web/session"
)

func main() {
	cfg := config.New()

	db, err := storage.InitDB(cfg)
	if err != nil {
		log.Fatal(err)
	}

	store := storage.New(db)
	sess := session.New(cfg.SessionKey, store)

	mux := handler.New(store, sess)

	log.Fatal(http.ListenAndServe(":"+cfg.Port, mux))
}

A  => model/request.go +23 -0
@@ 1,23 @@
package model

import (
	"encoding/json"
	"fmt"
	"time"
)

type Request struct {
	Id        int64
	CreatedAt time.Time
	Closed    bool
	Label     string
	Data      string
}

func (r Request) Form() map[string][]string {
	var rv map[string][]string
	if err := json.Unmarshal([]byte(r.Data), &rv); err != nil {
		fmt.Println(err)
	}
	return rv
}

A  => model/user.go +22 -0
@@ 1,22 @@
package model

import "golang.org/x/crypto/bcrypt"

type User struct {
	Id       int64
	Name     string
	Email    string
	Password string
	Hash     string
}

// UserCreationRequest represents the request to create a user.
type UserCreationRequest struct {
	Name     string
	Password string
	Email    string
}

func (u User) CompareHashToPassword(hash string) error {
	return bcrypt.CompareHashAndPassword([]byte(hash), []byte(u.Password))
}

A  => storage/db.go +16 -0
@@ 1,16 @@
package storage

import (
	"database/sql"
	_ "github.com/lib/pq"
	"riku/config"
)

func InitDB(cfg *config.Config) (*sql.DB, error) {
	db, err := sql.Open("postgres", cfg.DatabaseURL)
	if err != nil {
		return db, err
	}
	Migrate(db)
	return db, err
}

A  => storage/migration.go +51 -0
@@ 1,51 @@
package storage

import (
	"database/sql"
	"fmt"
	"log"
	"strconv"
)

const schemaVersion = 1

func Migrate(db *sql.DB) {
	var currentVersion int
	db.QueryRow(`SELECT version FROM schema_version`).Scan(&currentVersion)

	fmt.Println("Current schema version:", currentVersion)
	fmt.Println("Latest schema version:", schemaVersion)

	for version := currentVersion + 1; version <= schemaVersion; version++ {
		fmt.Println("Migrating to version:", version)

		tx, err := db.Begin()
		if err != nil {
			log.Fatal("[Migrate] ", err)
		}

		rawSQL := SqlMap["schema_version_"+strconv.Itoa(version)]
		if rawSQL == "" {
			log.Fatalf("[Migrate] missing migration %d", version)
		}
		_, err = tx.Exec(string(rawSQL))
		if err != nil {
			tx.Rollback()
			log.Fatal("[Migrate] ", err)
		}

		if _, err := tx.Exec(`delete from schema_version`); err != nil {
			tx.Rollback()
			log.Fatal("[Migrate] ", err)
		}

		if _, err := tx.Exec(`INSERT INTO schema_version (version) VALUES ($1)`, version); err != nil {
			tx.Rollback()
			log.Fatal("[Migrate] ", err)
		}

		if err := tx.Commit(); err != nil {
			log.Fatal("[Migrate] ", err)
		}
	}
}

A  => storage/request.go +162 -0
@@ 1,162 @@
package storage

import (
	"database/sql"
	"riku/model"
)

func (s *Storage) Save(userId int64, label, data string) error {
	query := `
        INSERT INTO requests (
            user_id,
            data,
            label
        ) VALUES ($1, $2, $3)
    `
	_, err := s.db.Exec(query, userId, data, label)
	return err
}

func populateRequest(rows *sql.Rows) (model.Request, error) {
	var rv model.Request
	err := rows.Scan(
		&rv.Id,
		&rv.CreatedAt,
		&rv.Closed,
		&rv.Label,
		&rv.Data,
	)
	return rv, err
}

func (s *Storage) Requests(userId int64, label string, closed bool) ([]model.Request, error) {
	if label == "" {
		label = "%"
	}
	rows, err := s.db.Query(`
        SELECT
            id,
            created_at,
            closed,
		    label,
            data
        FROM requests
        WHERE
            user_id=$1
            AND label LIKE $2
            AND closed=$3
    `, userId, label, closed)

	if err != nil {
		return nil, err
	}

	var requests []model.Request

	for rows.Next() {
		r, err := populateRequest(rows)

		if err != nil {
			return requests, err
		}

		requests = append(requests, r)
	}

	return requests, nil
}

func (s *Storage) RequestById(userId, requestId int64) (model.Request, error) {
	var rv model.Request

	rows, err := s.db.Query(`
        SELECT
            id,
            created_at,
            closed,
		    label,
            data
        FROM requests
        WHERE
            user_id=$1
            AND id=$2
    `, userId, requestId)

	if err != nil {
		return rv, err
	}

	for rows.Next() {
		rv, err = populateRequest(rows)

		if err != nil {
			return rv, err
		}
	}

	return rv, nil
}

func (s *Storage) Close(requestId, userId int64) error {
	query := `
        UPDATE requests
        SET closed=true
        WHERE id=$1 AND user_id=$2
    `

	_, err := s.db.Exec(query, requestId, userId)

	return err
}

func (s *Storage) Open(requestId, userId int64) error {
	query := `
        UPDATE requests
        SET closed=false
        WHERE id=$1 AND user_id=$2
    `

	_, err := s.db.Exec(query, requestId, userId)

	return err
}

func (s *Storage) Remove(requestId, userId int64) error {
	query := `
        DELETE from requests
        WHERE id=$1 AND user_id=$2
    `

	_, err := s.db.Exec(query, requestId, userId)

	return err
}

func (s *Storage) Labels(userId int64, closed bool) ([]string, error) {
	rows, err := s.db.Query(`
        SELECT
            DISTINCT label
        FROM requests
        WHERE user_id=$1 AND closed=$2
    `, userId, closed)

	if err != nil {
		return nil, err
	}

	var labels []string

	for rows.Next() {
		var l string

		if err := rows.Scan(
			&l,
		); err != nil {
			return labels, err
		}

		labels = append(labels, l)
	}

	return labels, nil
}

A  => storage/sql.go +26 -0
@@ 1,26 @@
// Code generated by go generate; DO NOT EDIT.

package storage

var SqlMap = map[string]string{
	"schema_version_1": `create table schema_version (
    version text not null
);

create table users (
    id int primary key generated always as identity,
    name varchar(15) unique CHECK (name <> ''),
    hash varchar(100) not null CHECK (hash <> ''),
    email varchar(300) not null check (email <> '')
);

create table requests (
    id integer primary key generated always as identity,
    created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
    closed bool not null default false,
    user_id integer not null references users(id),
    label varchar(50) not null default '',
    data text not null default ''
);
`,
}

A  => storage/sql/schema_version_1.sql +19 -0
@@ 1,19 @@
create table schema_version (
    version text not null
);

create table users (
    id int primary key generated always as identity,
    name varchar(15) unique CHECK (name <> ''),
    hash varchar(100) not null CHECK (hash <> ''),
    email varchar(300) not null check (email <> '')
);

create table requests (
    id integer primary key generated always as identity,
    created_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
    closed bool not null default false,
    user_id integer not null references users(id),
    label varchar(50) not null default '',
    data text not null default ''
);

A  => storage/storage.go +11 -0
@@ 1,11 @@
package storage

import "database/sql"

type Storage struct {
	db *sql.DB
}

func New(db *sql.DB) *Storage {
	return &Storage{db: db}
}
\ No newline at end of file

A  => storage/user.go +77 -0
@@ 1,77 @@
package storage

import (
	"context"
	"golang.org/x/crypto/bcrypt"
	"riku/model"
)

type ErrUserNotFound struct{}

func (u ErrUserNotFound) Error() string {
	return "user not found"
}

type ErrWrongPassword struct{}

func (u ErrWrongPassword) Error() string {
	return "wrong password"
}

type ErrUserExists struct{}

func (u ErrUserExists) Error() string {
	return "user already exists"
}

const queryFindName = `SELECT id, name, hash, email FROM users WHERE name=lower($1);`

func (s *Storage) queryUser(q string, params ...interface{}) (user model.User, err error) {
	err = s.db.QueryRow(q, params...).Scan(&user.Id, &user.Name, &user.Hash, &user.Email)
	return
}

func (s *Storage) UserExists(name string) bool {
	var rv bool
	s.db.QueryRow(`SELECT true FROM users WHERE name=lower($1)`, name).Scan(&rv)
	return rv
}

func (s *Storage) KeyExists(key string) bool {
	return key == "the_coffee_tastes_great"
}

func hashPassword(password string) ([]byte, error) {
	return bcrypt.GenerateFromPassword([]byte(password), bcrypt.MinCost)
}

func (s *Storage) CreateUser(request model.UserCreationRequest) (int64, error) {
	var userId int64
	hash, err := hashPassword(request.Password)
	ctx := context.Background()
	tx, err := s.db.BeginTx(ctx, nil)
	if err != nil {
		return userId, err
	}
	if err := tx.QueryRowContext(ctx, `insert into users (name, hash, email) values (lower($1), $2, $3) returning id`, request.Name, string(hash), request.Email).Scan(&userId); err != nil {
		tx.Rollback()
		return userId, err
	}
	err = tx.Commit()
	return userId, err
}

func (s *Storage) UserById(id int64) (model.User, error) {
	return s.queryUser(`SELECT id, name, hash, email FROM users WHERE id=$1;`, id)
}

func (s *Storage) VerifyUser(user model.User) (model.User, error) {
	u, err := s.queryUser(queryFindName, user.Name)
	if err != nil {
		return u, ErrUserNotFound{}
	}
	if err := user.CompareHashToPassword(u.Hash); err != nil {
		return u, ErrWrongPassword{}
	}
	return u, nil
}

A  => validator/user.go +32 -0
@@ 1,32 @@
package validator

import (
	"errors"
	"regexp"
	"riku/model"
	"riku/storage"
)

func ValidateUserCreation(store *storage.Storage, key string, r model.UserCreationRequest) error {
	if len(r.Name) < 3 {
		return errors.New("Username needs to be at least 3 characters")
	}

	if len(r.Name) > 20 {
		return errors.New("Username should be 20 characters or less")
	}

	if match, _ := regexp.MatchString("^[a-z0-9-_]+$", r.Name); !match {
		return errors.New("Only lowercase letters and digits are accepted for username")
	}

	if store.UserExists(r.Name) {
		return errors.New("Username already exists")
	}

	if !store.KeyExists(key) {
		return errors.New("Key not found or already used")
	}

	return nil
}

A  => web/handler/common.go +39 -0
@@ 1,39 @@
// Code generated by go generate; DO NOT EDIT.

package handler

var TplCommonMap = map[string]string{
	"layout": `{{ define "layout" }}
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link rel="stylesheet" href="/style.css"/>
    <title>{{ .settings.Name }}</title>
    {{ template "head" . }}
</head>
<body>
<header>
    {{ if logged }}

    <a href="/">inbox</a>
    <a href="/?closed=true">archive</a>
    <a href="/manual">manual</a>
    {{ .logged.Name }} (<a href="/logout">logout</a>)

    {{ else }}

    <a href="/">home</a>
    <a href="/manual">manual</a>
    <a href="/login">login</a>
    <a href="/register">register</a>

    {{ end }}
</header>
    {{ template "content" . }}
</body>
</html>
{{ end }}
{{ define "head" }}{{ end }}`,
}

A  => web/handler/form/login.go +17 -0
@@ 1,17 @@
package form

import (
	"net/http"
)

type LoginForm struct {
	Username string
	Password string
}

func NewLoginForm(r *http.Request) *LoginForm {
	return &LoginForm{
		Username: r.FormValue("name"),
		Password: r.FormValue("password"),
	}
}

A  => web/handler/form/user.go +39 -0
@@ 1,39 @@
package form

import (
	"errors"
	"net/http"
	"riku/model"
)

type UserForm struct {
	Username string
	Password string
	Confirm  string
	Email    string
	Key      string
}

func (f *UserForm) Validate() error {
	if f.Password != f.Confirm {
		return errors.New("Password doesn't match confirmation")
	}

	return nil
}

func (f *UserForm) Merge(u *model.User) *model.User {
	u.Name = f.Username
	u.Password = f.Password
	return u
}

func NewUserForm(r *http.Request) *UserForm {
	return &UserForm{
		Username: r.FormValue("name"),
		Confirm:  r.FormValue("confirm"),
		Password: r.FormValue("password"),
		Email:    r.FormValue("email"),
		Key:      r.FormValue("key"),
	}
}

A  => web/handler/form_show.go +23 -0
@@ 1,23 @@
package handler

import (
	"fmt"
	"net/http"
	"riku/web/handler/request"
	"strconv"
)

func (h *handler) showFormView(w http.ResponseWriter, r *http.Request) {
	user := request.GetUserContextKey(r)

	id, _ := strconv.ParseInt(r.URL.Query().Get("id"), 10, 64)

	form, err := h.store.RequestById(user.Id, id)
	if err != nil {
		fmt.Println(err)
	}

	v := NewView(w, r, "form")
	v.Set("form", form)
	v.Render()
}

A  => web/handler/handler.go +64 -0
@@ 1,64 @@
package handler

import (
	"context"
	"fmt"
	"github.com/gorilla/mux"
	"net/http"
	"riku/storage"
	"riku/web/handler/request"
	"riku/web/session"
)

type handler struct {
	store   *storage.Storage
	session *session.Manager
}

func list(closed, label string) string {
	return fmt.Sprintf("/?label=%s&closed=%s", label, closed)
}

func (h *handler) handleSessionMiddleware(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		user, _ := h.session.GetUser(r)
		session, err := h.session.GetSession(r)
		if err != nil {
			fmt.Println("Unable to create session")
		}
		ctx := r.Context()
		ctx = context.WithValue(ctx, request.SessionKey, session)
		ctx = context.WithValue(ctx, request.UserKey, user)
		next.ServeHTTP(w, r.WithContext(ctx))
	})
}

func New(store *storage.Storage, s *session.Manager) http.Handler {
	router := mux.NewRouter()

	h := handler{
		store:   store,
		session: s,
	}

	h.initTpl()

	router.Use(h.handleSessionMiddleware)

	router.HandleFunc("/style.css", h.showStylesheet).Methods(http.MethodGet)
	router.HandleFunc("/login", h.showLoginView).Methods(http.MethodGet)
	router.HandleFunc("/login", h.checkLogin).Methods(http.MethodPost)
	router.HandleFunc("/register", h.showRegisterView).Methods(http.MethodGet)
	router.HandleFunc("/register", h.register).Methods(http.MethodPost)
	router.HandleFunc("/submit", h.submitRequest).Methods(http.MethodPost)
	router.HandleFunc("/close", h.closeRequest).Methods(http.MethodGet)
	router.HandleFunc("/open", h.openRequest).Methods(http.MethodGet)
	router.HandleFunc("/", h.requestList).Methods(http.MethodGet)
	router.HandleFunc("/delete", h.removeRequest).Methods(http.MethodGet)
	router.HandleFunc("/logout", h.logout).Methods(http.MethodGet)
	router.HandleFunc("/thank-you", h.showThankYouView).Methods(http.MethodGet)
	router.HandleFunc("/manual", h.showManualView).Methods(http.MethodGet)
	router.HandleFunc("/form", h.showFormView).Methods(http.MethodGet)

	return router
}

A  => web/handler/html.go +317 -0
@@ 1,317 @@
// Code generated by go generate; DO NOT EDIT.

package handler

var TplMap = map[string]string{
	"form": `{{ define "content" }}
<!--<nav class="tabs">-->
<!--    <a href="/?closed=false" {{ if not .form.Closed }}class="selected"{{ end }}>inbox</a> <a href="/?closed=true" {{ if .form.Closed }}class="selected"{{ end }}>archive</a>-->
<!--</nav>-->

<nav class="breadcrumb">
    <a href="/?closed={{ .form.Closed }}">{{ if .form.Closed }}archive{{ else }}inbox{{ end }}</a> › Form #{{ .form.Id }}
</nav>

<h1>Form #{{ .form.Id }}</h1>

<div>
<table>
    <tr>
        <td>Received</td>
        <td class="grow">{{ timeAgo .form.CreatedAt }}</td>
    </tr>
    <tr>
        <td>Label</td>
        <td class="grow">{{ .form.Label }}</td>
    </tr>
</table>

<div class="actions">
    {{ if .form.Closed }}
        <form action="/open" method="get" class="action">
            <input type="hidden" name="id" value="{{ .form.Id }}">
            <input type="submit" value="move to inbox">
        </form>
    {{ else }}
        <form action="/close" method="get" class="action">
            <input type="hidden" name="id" value="{{ .form.Id }}">
            <input type="submit" value="move to archive">
        </form>
    {{ end }}

    <form action="/delete" method="get" class="action">
        <input type="hidden" name="id" value="{{ .form.Id }}">
        <input type="hidden" name="closed" value="{{ .form.Closed }}">
        <input type="submit" value="delete">
    </form>
</div>



<dl>
    {{ range $key, $value := .form.Form }}
    <dt>{{ $key }}</dt>
    <dd>
        {{ range $value }}
        <p>{{ . }}</p>
        {{ end }}
    </dd>
    {{ end }}
</dl>
</div>
{{ end }}`,
	"index": `{{ define "title" }}Login{{ end }}

{{ define "content" }}
<h1>Add forms to your site</h1>

<p>Riku is a tiny service to process requests through web forms. For example:</p>

<ul>
    <li>Request to join a mailing list</li>
    <li>To join a beta</li>
    <li>To exchange banners</li>
    <li>To join a webring</li>
    <li>To comment a blog post</li>
    <li>To contact yourself</li>
    <li> much more!</li>
</ul>

<p>Riku provides a special URL to use on your forms:</p>

<pre>&lt;form action=&quot;https://riku.miso.town/submit?user_id=1&quot; method=&quot;post&quot;&gt;
    &lt;label for=&quot;email&quot;&gt;Email:&lt;/label&gt;
    &lt;input type=&quot;email&quot; name=&quot;email&quot; id=&quot;email&quot; required&gt;
    &lt;input type=&quot;submit&quot; value=&quot;submit&quot;&gt;
&lt;/form&gt;
</pre>

<p>...and lets you manage all your requests through the web interface. Add your email to receive a key:</p>
<form action="https://riku.miso.town/submit?user_id=1&label=riku" method="post">
    <label for="email">Email:</label>
    <input type="email" name="email" id="email" required>
    <input type="submit" value="submit">
</form>
{{ end }}`,
	"login": `{{ define "title" }}Login{{ end }}

{{ define "content" }}
<h1>Login</h1>
{{ if .errorMessage }}
    <p class="errors">{{ .errorMessage }}</p>
{{ end }}
<form action="/login" method="post" class="auth-form">
    {{ .csrfField }}
    <div class="field">
        <label for="name">Username</label>
        <input type="text" name="name" id="name" autocomplete="off" required/>
    </div>
    <div class="field">
        <label for="password">Password</label>
        <input type="password" name="password" id="password" required/>
    </div>
    <input type="submit" value="Login">
</form>
{{ end }}`,
	"manual": `{{ define "title" }}Login{{ end }}

{{ define "content" }}
<h1>Manual</h1>

<p>Riku lets you capture requests through HTML forms. Here's the manual.</p>

<h2>Submitting requests</h2>
<p>Let's build a simple contact form for <a href="https://m15o.ichi.city">my site</a>. I would like the form to include a name, an optional email and a message field. In HTML, it would look like that:</p>

<pre>
&lt;form action=&quot;&quot; method=&quot;post&quot;&gt;

  &lt;div class=&quot;field&quot;&gt;
    &lt;label for=&quot;name&quot;&gt;Name&lt;/label&gt;
    &lt;input type=&quot;text&quot; name=&quot;name&quot; required&gt;
  &lt;/div&gt;

  &lt;div class=&quot;field&quot;&gt;
    &lt;label for=&quot;email&quot;&gt;Email (optional)&lt;/label&gt;
    &lt;input type=&quot;email&quot; name=&quot;email&quot;&gt;
  &lt;/div&gt;

  &lt;div class=&quot;field&quot;&gt;
    &lt;label for=&quot;message&quot;&gt;Message&lt;/label&gt;
    &lt;textarea name=&quot;message&quot; style=&quot;height: 200px;&quot; required&gt;&lt;/textarea&gt;
  &lt;/div&gt;

  &lt;input type=&quot;submit&quot; value=&quot;Submit&quot;&gt;
&lt;/form&gt;
</pre>

<p>You can follow along by creating a local .html file or by creating a site on <a href="https://ichi.city">ichi</a>. You'll notice we haven't added an action URL to the form yet. When you login to Riku, you will be given your own URL that looks something like that:</p>

<pre>https://riku.miso.town/submit?user_id=1</pre>

<p>The <b>user_id</b> part will be different for you. It tells Riku who you are, so that your requests arrive to you. Let's add it to our form:</p>

<pre>
&lt;form action=&quot;https://riku.miso.town/submit?user_id=1&quot; method=&quot;post&quot;&gt;
    ...
&lt;/form&gt;
</pre>

<p>From now on, you will be able to see all the messages on Riku.</p>

<h3>Redirecting users</h3>

<p>If you'd like your user to be shown a custom page when the form is submitted, you can add a special <b>redirect</b> field to the form:</p>

<pre>
&lt;input type=&quot;hidden&quot; name=&quot;redirect&quot; value=&quot;https://m15o.ichi.city/message-sent.html&quot;&gt;
</pre>

<p>Riku will redirect the user there once the form data is captured.</p>

<h3>Labeling requests</h3>

<p>If you are like me and use Riku in many places, a good practice is to label your requests to help you triage them. You do it by appending a query string parameter to your Riku URL:</p>

<pre>https://riku.miso.town/submit?user_id=1&label=contact</pre>

<p>Now all requests coming from the contact form will be labeled with "contact".</p>

<h2>Managing requests</h2>

<p>All new requests coming to Riku are listed as "open". Once you've read them (and maybe acted on them), you can "close" them.</p>

{{ end }}`,
	"register": `{{ define "title" }}Register{{ end }}

{{ define "content" }}
<h1>Register</h1>
{{ if .errorMessage }}
<p class="errors">{{ .errorMessage }}</p>
{{ end }}
<form action="/register" method="post" class="auth-form">
    <div class="field">
        <label for="name">Username</label>
        <input type="text" id="name" name="name" autocomplete="off" value="{{ .form.Username }}" maxlength="15" required/>
    </div>
    <div class="field">
        <label for="password">Password</label>
        <input type="password" id="password" name="password" required/>
    </div>
    <div class="field">
        <label for="confirm">Confirm password</label>
        <input type="password" id="confirm" name="confirm" required/>
    </div>
    <div class="field">
        <label for="email">Email</label>
        <input type="email" id="email" name="email" required/>
    </div>
    <div class="field">
        <label for="key">Key</label>
        <input type="text" id="key" name="key" required/>
    </div>
    <input type="submit" value="Submit">
</form>
{{ end }}
`,
	"requests": `{{ define "content" }}

<!--<nav class="tabs">-->
<!--    <a href="?closed=false" {{ if not .closed }}class="selected"{{ end }}>inbox</a> <a href="?closed=true" {{ if .closed }}class="selected"{{ end }}>archive</a>-->
<!--</nav>-->



{{ if not .closed }}
<h1>Inbox</h1>
<div class="info"><p>POST to https://riku.miso.town/submit?user_id={{ .logged.Id }}</p></div>
{{ else }}
<h1>Archive</h1>
{{ end }}

{{ if .requests }}

{{ if gt (len .labels) 1 }}
<p>
    <a href="/?closed={{ .closed }}">all</a>{{ range .labels }} {{ if eq . $.label }}{{ . }}{{ else }}<a href="?label={{ . }}&closed={{ $.closed }}">{{ . }}</a>{{ end }}{{ end }}
</p>
{{ end }}

<!--<table>-->
<!--    <thead>-->
<!--    <tr>-->
<!--        <th>ID</th>-->
<!--        <th>Label</th>-->
<!--        <th>Request</th>-->
<!--&lt;!&ndash;        <th>Action</th>&ndash;&gt;-->
<!--    </tr>-->
<!--    </thead>-->
<!--    <tbody>-->
<!--    {{ range .requests }}-->
<!--    <tr>-->
<!--        <td style="text-align: center;">{{ .Id }}</a></td>-->
<!--        <td style="text-align: center;"><a href="?label={{ .Label }}&closed={{ $.closed }}">{{ .Label }}</a></td>-->
<!--        <td>-->
<!--            <a href="/form?id={{ .Id }}">Received on {{ iso8601Time .CreatedAt }}</a>-->
<!--&lt;!&ndash;            <div>&ndash;&gt;-->
<!--&lt;!&ndash;                <details>&ndash;&gt;-->
<!--&lt;!&ndash;                    <summary></summary>&ndash;&gt;-->
<!--&lt;!&ndash;                    <dl>&ndash;&gt;-->
<!--&lt;!&ndash;                        {{ range $key, $value := .Form }}&ndash;&gt;-->
<!--&lt;!&ndash;                        <dt>{{ $key }}</dt>&ndash;&gt;-->
<!--&lt;!&ndash;                        <dd>&ndash;&gt;-->
<!--&lt;!&ndash;                            {{ range $value }}&ndash;&gt;-->
<!--&lt;!&ndash;                            <p>{{ . }}</p>&ndash;&gt;-->
<!--&lt;!&ndash;                            {{ end }}&ndash;&gt;-->
<!--&lt;!&ndash;                        </dd>&ndash;&gt;-->
<!--&lt;!&ndash;                        {{ end }}&ndash;&gt;-->
<!--&lt;!&ndash;                    </dl>&ndash;&gt;-->
<!--&lt;!&ndash;                </details>&ndash;&gt;-->

<!--&lt;!&ndash;            </div>&ndash;&gt;-->
<!--        </td>-->
<!--&lt;!&ndash;        <td style="text-align: center;">&ndash;&gt;-->
<!--&lt;!&ndash;            {{ if .Closed }}&ndash;&gt;-->
<!--&lt;!&ndash;            <a href="/open?id={{ .Id }}&label={{ $.Label }}&closed={{ $.closed }}">move to inbox</a>&ndash;&gt;-->
<!--&lt;!&ndash;            {{ else }}&ndash;&gt;-->
<!--&lt;!&ndash;            <a href="/close?id={{ .Id }}&label={{ $.Label }}&closed={{ $.closed }}">archive</a>&ndash;&gt;-->
<!--&lt;!&ndash;            {{ end }}&ndash;&gt;-->
<!--&lt;!&ndash;        </td>&ndash;&gt;-->
<!--&lt;!&ndash;        <td style="text-align: center;"><a href="/delete?id={{ .Id }}&label={{ $.Label }}&closed={{ $.closed }}">Delete</a></td>&ndash;&gt;-->
<!--    </tr>-->
<!--    {{ end }}-->
<!--    </tbody>-->
<!--</table>-->

<table>
    <thead>
    <tr>
        <th>Label</th>
        <th>Received</th>
        <th>Response</th>
    </tr>
    </thead>
    <tbody>
    {{ range .requests }}
    <tr>
        <td style="text-align: center;"><a href="?label={{ .Label }}&closed={{ $.closed }}">{{ .Label }}</a></td>
        <td style="white-space: nowrap;">{{ timeAgo .CreatedAt }}</td>
        <td class="grow"><a href="/form?id={{ .Id }}">Response #{{ .Id }}</a></td>
    </tr>
    {{ end }}
    </tbody>
</table>

{{ else }}
<p>No forms found.</p>
{{ end }}

{{ end }}`,
	"thank-you": `{{ define "title" }}Thank you{{ end }}

{{ define "content" }}
<h1>Thank you!</h1>
<p>The form has been submitted.</p>
{{ end }}
`,
}

A  => web/handler/html/common/layout.html +33 -0
@@ 1,33 @@
{{ define "layout" }}
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <link rel="stylesheet" href="/style.css"/>
    <title>{{ .settings.Name }}</title>
    {{ template "head" . }}
</head>
<body>
<header>
    {{ if logged }}

    <a href="/">inbox</a>
    <a href="/?closed=true">archive</a>
    <a href="/manual">manual</a>
    {{ .logged.Name }} (<a href="/logout">logout</a>)

    {{ else }}

    <a href="/">home</a>
    <a href="/manual">manual</a>
    <a href="/login">login</a>
    <a href="/register">register</a>

    {{ end }}
</header>
    {{ template "content" . }}
</body>
</html>
{{ end }}
{{ define "head" }}{{ end }}
\ No newline at end of file

A  => web/handler/html/form.html +57 -0
@@ 1,57 @@
{{ define "content" }}
<!--<nav class="tabs">-->
<!--    <a href="/?closed=false" {{ if not .form.Closed }}class="selected"{{ end }}>inbox</a> <a href="/?closed=true" {{ if .form.Closed }}class="selected"{{ end }}>archive</a>-->
<!--</nav>-->

<nav class="breadcrumb">
    <a href="/?closed={{ .form.Closed }}">{{ if .form.Closed }}archive{{ else }}inbox{{ end }}</a> › Form #{{ .form.Id }}
</nav>

<h1>Form #{{ .form.Id }}</h1>

<div>
<table>
    <tr>
        <td>Received</td>
        <td class="grow">{{ timeAgo .form.CreatedAt }}</td>
    </tr>
    <tr>
        <td>Label</td>
        <td class="grow">{{ .form.Label }}</td>
    </tr>
</table>

<div class="actions">
    {{ if .form.Closed }}
        <form action="/open" method="get" class="action">
            <input type="hidden" name="id" value="{{ .form.Id }}">
            <input type="submit" value="move to inbox">
        </form>
    {{ else }}
        <form action="/close" method="get" class="action">
            <input type="hidden" name="id" value="{{ .form.Id }}">
            <input type="submit" value="move to archive">
        </form>
    {{ end }}

    <form action="/delete" method="get" class="action">
        <input type="hidden" name="id" value="{{ .form.Id }}">
        <input type="hidden" name="closed" value="{{ .form.Closed }}">
        <input type="submit" value="delete">
    </form>
</div>



<dl>
    {{ range $key, $value := .form.Form }}
    <dt>{{ $key }}</dt>
    <dd>
        {{ range $value }}
        <p>{{ . }}</p>
        {{ end }}
    </dd>
    {{ end }}
</dl>
</div>
{{ end }}
\ No newline at end of file

A  => web/handler/html/index.html +33 -0
@@ 1,33 @@
{{ define "title" }}Login{{ end }}

{{ define "content" }}
<h1>Add forms to your site</h1>

<p>Riku is a tiny service to process requests through web forms. For example:</p>

<ul>
    <li>Request to join a mailing list</li>
    <li>To join a beta</li>
    <li>To exchange banners</li>
    <li>To join a webring</li>
    <li>To comment a blog post</li>
    <li>To contact yourself</li>
    <li> much more!</li>
</ul>

<p>Riku provides a special URL to use on your forms:</p>

<pre>&lt;form action=&quot;https://riku.miso.town/submit?user_id=1&quot; method=&quot;post&quot;&gt;
    &lt;label for=&quot;email&quot;&gt;Email:&lt;/label&gt;
    &lt;input type=&quot;email&quot; name=&quot;email&quot; id=&quot;email&quot; required&gt;
    &lt;input type=&quot;submit&quot; value=&quot;submit&quot;&gt;
&lt;/form&gt;
</pre>

<p>...and lets you manage all your requests through the web interface. Add your email to receive a key:</p>
<form action="https://riku.miso.town/submit?user_id=1&label=riku" method="post">
    <label for="email">Email:</label>
    <input type="email" name="email" id="email" required>
    <input type="submit" value="submit">
</form>
{{ end }}
\ No newline at end of file

A  => web/handler/html/login.html +20 -0
@@ 1,20 @@
{{ define "title" }}Login{{ end }}

{{ define "content" }}
<h1>Login</h1>
{{ if .errorMessage }}
    <p class="errors">{{ .errorMessage }}</p>
{{ end }}
<form action="/login" method="post" class="auth-form">
    {{ .csrfField }}
    <div class="field">
        <label for="name">Username</label>
        <input type="text" name="name" id="name" autocomplete="off" required/>
    </div>
    <div class="field">
        <label for="password">Password</label>
        <input type="password" name="password" id="password" required/>
    </div>
    <input type="submit" value="Login">
</form>
{{ end }}
\ No newline at end of file

A  => web/handler/html/manual.html +69 -0
@@ 1,69 @@
{{ define "title" }}Login{{ end }}

{{ define "content" }}
<h1>Manual</h1>

<p>Riku lets you capture requests through HTML forms. Here's the manual.</p>

<h2>Submitting requests</h2>
<p>Let's build a simple contact form for <a href="https://m15o.ichi.city">my site</a>. I would like the form to include a name, an optional email and a message field. In HTML, it would look like that:</p>

<pre>
&lt;form action=&quot;&quot; method=&quot;post&quot;&gt;

  &lt;div class=&quot;field&quot;&gt;
    &lt;label for=&quot;name&quot;&gt;Name&lt;/label&gt;
    &lt;input type=&quot;text&quot; name=&quot;name&quot; required&gt;
  &lt;/div&gt;

  &lt;div class=&quot;field&quot;&gt;
    &lt;label for=&quot;email&quot;&gt;Email (optional)&lt;/label&gt;
    &lt;input type=&quot;email&quot; name=&quot;email&quot;&gt;
  &lt;/div&gt;

  &lt;div class=&quot;field&quot;&gt;
    &lt;label for=&quot;message&quot;&gt;Message&lt;/label&gt;
    &lt;textarea name=&quot;message&quot; style=&quot;height: 200px;&quot; required&gt;&lt;/textarea&gt;
  &lt;/div&gt;

  &lt;input type=&quot;submit&quot; value=&quot;Submit&quot;&gt;
&lt;/form&gt;
</pre>

<p>You can follow along by creating a local .html file or by creating a site on <a href="https://ichi.city">ichi</a>. You'll notice we haven't added an action URL to the form yet. When you login to Riku, you will be given your own URL that looks something like that:</p>

<pre>https://riku.miso.town/submit?user_id=1</pre>

<p>The <b>user_id</b> part will be different for you. It tells Riku who you are, so that your requests arrive to you. Let's add it to our form:</p>

<pre>
&lt;form action=&quot;https://riku.miso.town/submit?user_id=1&quot; method=&quot;post&quot;&gt;
    ...
&lt;/form&gt;
</pre>

<p>From now on, you will be able to see all the messages on Riku.</p>

<h3>Redirecting users</h3>

<p>If you'd like your user to be shown a custom page when the form is submitted, you can add a special <b>redirect</b> field to the form:</p>

<pre>
&lt;input type=&quot;hidden&quot; name=&quot;redirect&quot; value=&quot;https://m15o.ichi.city/message-sent.html&quot;&gt;
</pre>

<p>Riku will redirect the user there once the form data is captured.</p>

<h3>Labeling requests</h3>

<p>If you are like me and use Riku in many places, a good practice is to label your requests to help you triage them. You do it by appending a query string parameter to your Riku URL:</p>

<pre>https://riku.miso.town/submit?user_id=1&label=contact</pre>

<p>Now all requests coming from the contact form will be labeled with "contact".</p>

<h2>Managing requests</h2>

<p>All new requests coming to Riku are listed as "open". Once you've read them (and maybe acted on them), you can "close" them.</p>

{{ end }}
\ No newline at end of file

A  => web/handler/html/register.html +31 -0
@@ 1,31 @@
{{ define "title" }}Register{{ end }}

{{ define "content" }}
<h1>Register</h1>
{{ if .errorMessage }}
<p class="errors">{{ .errorMessage }}</p>
{{ end }}
<form action="/register" method="post" class="auth-form">
    <div class="field">
        <label for="name">Username</label>
        <input type="text" id="name" name="name" autocomplete="off" value="{{ .form.Username }}" maxlength="15" required/>
    </div>
    <div class="field">
        <label for="password">Password</label>
        <input type="password" id="password" name="password" required/>
    </div>
    <div class="field">
        <label for="confirm">Confirm password</label>
        <input type="password" id="confirm" name="confirm" required/>
    </div>
    <div class="field">
        <label for="email">Email</label>
        <input type="email" id="email" name="email" required/>
    </div>
    <div class="field">
        <label for="key">Key</label>
        <input type="text" id="key" name="key" required/>
    </div>
    <input type="submit" value="Submit">
</form>
{{ end }}

A  => web/handler/html/requests.html +93 -0
@@ 1,93 @@
{{ define "content" }}

<!--<nav class="tabs">-->
<!--    <a href="?closed=false" {{ if not .closed }}class="selected"{{ end }}>inbox</a> <a href="?closed=true" {{ if .closed }}class="selected"{{ end }}>archive</a>-->
<!--</nav>-->



{{ if not .closed }}
<h1>Inbox</h1>
<div class="info"><p>POST to https://riku.miso.town/submit?user_id={{ .logged.Id }}</p></div>
{{ else }}
<h1>Archive</h1>
{{ end }}

{{ if .requests }}

{{ if gt (len .labels) 1 }}
<p>
    <a href="/?closed={{ .closed }}">all</a>{{ range .labels }} {{ if eq . $.label }}{{ . }}{{ else }}<a href="?label={{ . }}&closed={{ $.closed }}">{{ . }}</a>{{ end }}{{ end }}
</p>
{{ end }}

<!--<table>-->
<!--    <thead>-->
<!--    <tr>-->
<!--        <th>ID</th>-->
<!--        <th>Label</th>-->
<!--        <th>Request</th>-->
<!--&lt;!&ndash;        <th>Action</th>&ndash;&gt;-->
<!--    </tr>-->
<!--    </thead>-->
<!--    <tbody>-->
<!--    {{ range .requests }}-->
<!--    <tr>-->
<!--        <td style="text-align: center;">{{ .Id }}</a></td>-->
<!--        <td style="text-align: center;"><a href="?label={{ .Label }}&closed={{ $.closed }}">{{ .Label }}</a></td>-->
<!--        <td>-->
<!--            <a href="/form?id={{ .Id }}">Received on {{ iso8601Time .CreatedAt }}</a>-->
<!--&lt;!&ndash;            <div>&ndash;&gt;-->
<!--&lt;!&ndash;                <details>&ndash;&gt;-->
<!--&lt;!&ndash;                    <summary></summary>&ndash;&gt;-->
<!--&lt;!&ndash;                    <dl>&ndash;&gt;-->
<!--&lt;!&ndash;                        {{ range $key, $value := .Form }}&ndash;&gt;-->
<!--&lt;!&ndash;                        <dt>{{ $key }}</dt>&ndash;&gt;-->
<!--&lt;!&ndash;                        <dd>&ndash;&gt;-->
<!--&lt;!&ndash;                            {{ range $value }}&ndash;&gt;-->
<!--&lt;!&ndash;                            <p>{{ . }}</p>&ndash;&gt;-->
<!--&lt;!&ndash;                            {{ end }}&ndash;&gt;-->
<!--&lt;!&ndash;                        </dd>&ndash;&gt;-->
<!--&lt;!&ndash;                        {{ end }}&ndash;&gt;-->
<!--&lt;!&ndash;                    </dl>&ndash;&gt;-->
<!--&lt;!&ndash;                </details>&ndash;&gt;-->

<!--&lt;!&ndash;            </div>&ndash;&gt;-->
<!--        </td>-->
<!--&lt;!&ndash;        <td style="text-align: center;">&ndash;&gt;-->
<!--&lt;!&ndash;            {{ if .Closed }}&ndash;&gt;-->
<!--&lt;!&ndash;            <a href="/open?id={{ .Id }}&label={{ $.Label }}&closed={{ $.closed }}">move to inbox</a>&ndash;&gt;-->
<!--&lt;!&ndash;            {{ else }}&ndash;&gt;-->
<!--&lt;!&ndash;            <a href="/close?id={{ .Id }}&label={{ $.Label }}&closed={{ $.closed }}">archive</a>&ndash;&gt;-->
<!--&lt;!&ndash;            {{ end }}&ndash;&gt;-->
<!--&lt;!&ndash;        </td>&ndash;&gt;-->
<!--&lt;!&ndash;        <td style="text-align: center;"><a href="/delete?id={{ .Id }}&label={{ $.Label }}&closed={{ $.closed }}">Delete</a></td>&ndash;&gt;-->
<!--    </tr>-->
<!--    {{ end }}-->
<!--    </tbody>-->
<!--</table>-->

<table>
    <thead>
    <tr>
        <th>Label</th>
        <th>Received</th>
        <th>Response</th>
    </tr>
    </thead>
    <tbody>
    {{ range .requests }}
    <tr>
        <td style="text-align: center;"><a href="?label={{ .Label }}&closed={{ $.closed }}">{{ .Label }}</a></td>
        <td style="white-space: nowrap;">{{ timeAgo .CreatedAt }}</td>
        <td class="grow"><a href="/form?id={{ .Id }}">Response #{{ .Id }}</a></td>
    </tr>
    {{ end }}
    </tbody>
</table>

{{ else }}
<p>No forms found.</p>
{{ end }}

{{ end }}
\ No newline at end of file

A  => web/handler/html/thank-you.html +6 -0
@@ 1,6 @@
{{ define "title" }}Thank you{{ end }}

{{ define "content" }}
<h1>Thank you!</h1>
<p>The form has been submitted.</p>
{{ end }}

A  => web/handler/login_check.go +34 -0
@@ 1,34 @@
package handler

import (
	"fmt"
	"net/http"
	"riku/model"
	"riku/storage"
	"riku/web/handler/form"
	"riku/web/handler/request"
)

func (h *handler) checkLogin(w http.ResponseWriter, r *http.Request) {
	loginForm := form.NewLoginForm(r)
	user, err := h.store.VerifyUser(model.User{
		Name:     loginForm.Username,
		Password: loginForm.Password,
	})
	if err != nil {
		v := NewView(w, r, "login")
		v.Set("form", loginForm)
		switch err.(type) {
		case storage.ErrUserNotFound:
			v.Set("errorMessage", fmt.Sprintf("User %s not found", loginForm.Username))
		case storage.ErrWrongPassword:
			v.Set("errorMessage", "Wrong password")
		}
		v.Render()
		return
	}
	session := request.GetSessionContextKey(r)
	session.SetUserId(user.Id)
	session.Save(r, w)
	http.Redirect(w, r, "/", http.StatusFound)
}

A  => web/handler/login_show.go +8 -0
@@ 1,8 @@
package handler

import "net/http"

func (h *handler) showLoginView(w http.ResponseWriter, r *http.Request) {
	v := NewView(w, r, "login")
	v.Render()
}

A  => web/handler/logout.go +10 -0
@@ 1,10 @@
package handler

import "net/http"

func (h *handler) logout(w http.ResponseWriter, r *http.Request) {
	if err := h.session.Delete(w, r); err != nil {
		return
	}
	http.Redirect(w, r, "/", http.StatusFound)
}

A  => web/handler/manual_show.go +8 -0
@@ 1,8 @@
package handler

import "net/http"

func (h *handler) showManualView(w http.ResponseWriter, r *http.Request) {
	v := NewView(w, r, "manual")
	v.Render()
}

A  => web/handler/register.go +47 -0
@@ 1,47 @@
package handler

import (
	"net/http"
	"riku/model"
	"riku/validator"
	"riku/web/handler/form"
	"riku/web/handler/request"
)

func (h *handler) register(w http.ResponseWriter, r *http.Request) {
	userForm := form.NewUserForm(r)

	v := NewView(w, r, "register")
	v.Set("form", userForm)

	if err := userForm.Validate(); err != nil {
		v.Set("errorMessage", err.Error())
		v.Render()
		return
	}

	userCreationRequest := model.UserCreationRequest{
		Name:     userForm.Username,
		Password: userForm.Password,
		Email:    userForm.Email,
	}

	if err := validator.ValidateUserCreation(h.store, userForm.Key, userCreationRequest); err != nil {
		v.Set("errorMessage", err.Error())
		v.Render()
		return
	}

	id, err := h.store.CreateUser(userCreationRequest)
	if err != nil {
		v.Set("errorMessage", "Unable to create user")
		v.Render()
		return
	}

	session := request.GetSessionContextKey(r)
	session.SetUserId(id)
	session.Save(r, w)

	http.Redirect(w, r, "/", http.StatusFound)
}

A  => web/handler/register_show.go +8 -0
@@ 1,8 @@
package handler

import "net/http"

func (h *handler) showRegisterView(w http.ResponseWriter, r *http.Request) {
	v := NewView(w, r, "register")
	v.Render()
}

A  => web/handler/request/context.go +29 -0
@@ 1,29 @@
package request

import (
	"net/http"
	"riku/model"
	"riku/web/session"
)

const (
	UserKey = iota
	SessionKey
	SettingsKey
)

func GetSessionContextKey(r *http.Request) *session.Session {
	session, ok := r.Context().Value(SessionKey).(*session.Session)
	if !ok {
		return nil
	}
	return session
}

func GetUserContextKey(r *http.Request) model.User {
	user, ok := r.Context().Value(UserKey).(model.User)
	if !ok {
		return model.User{}
	}
	return user
}

A  => web/handler/request_close.go +20 -0
@@ 1,20 @@
package handler

import (
	"fmt"
	"net/http"
	"riku/web/handler/request"
	"strconv"
)

func (h *handler) closeRequest(w http.ResponseWriter, r *http.Request) {
	user := request.GetUserContextKey(r)

	id, _ := strconv.ParseInt(r.URL.Query().Get("id"), 10, 64)

	h.store.Close(id, user.Id)

	//status, label := r.URL.Query().Get("status"), r.URL.Query().Get("label")

	http.Redirect(w, r, fmt.Sprintf("/form?id=%d", id), http.StatusFound)
}

A  => web/handler/request_list.go +37 -0
@@ 1,37 @@
package handler

import (
	"fmt"
	"net/http"
	"riku/web/handler/request"
)

func (h *handler) requestList(w http.ResponseWriter, r *http.Request) {
	user := request.GetUserContextKey(r)

	if user.Id == 0 {
		v := NewView(w, r, "index")
		v.Render()
		return
	}

	label := r.URL.Query().Get("label")
	closed := r.URL.Query().Get("closed") == "true"

	requests, err := h.store.Requests(user.Id, label, closed)
	if err != nil {
		fmt.Println(err)
	}

	labels, err := h.store.Labels(user.Id, closed)
	if err != nil {
		fmt.Println(err)
	}

	v := NewView(w, r, "requests")
	v.Set("requests", requests)
	v.Set("labels", labels)
	v.Set("label", label)
	v.Set("closed", closed)
	v.Render()
}

A  => web/handler/request_open.go +20 -0
@@ 1,20 @@
package handler

import (
	"fmt"
	"net/http"
	"riku/web/handler/request"
	"strconv"
)

func (h *handler) openRequest(w http.ResponseWriter, r *http.Request) {
	user := request.GetUserContextKey(r)

	id, _ := strconv.ParseInt(r.URL.Query().Get("id"), 10, 64)

	h.store.Open(id, user.Id)

	//closed, label := r.URL.Query().Get("closed"), r.URL.Query().Get("label")

	http.Redirect(w, r, fmt.Sprintf("/form?id=%d", id), http.StatusFound)
}

A  => web/handler/request_remove.go +19 -0
@@ 1,19 @@
package handler

import (
	"net/http"
	"riku/web/handler/request"
	"strconv"
)

func (h *handler) removeRequest(w http.ResponseWriter, r *http.Request) {
	user := request.GetUserContextKey(r)

	id, _ := strconv.ParseInt(r.URL.Query().Get("id"), 10, 64)

	h.store.Remove(id, user.Id)

	closed, label := r.URL.Query().Get("closed"), r.URL.Query().Get("label")

	http.Redirect(w, r, list(closed, label), http.StatusFound)
}

A  => web/handler/request_submit.go +55 -0
@@ 1,55 @@
package handler

import (
	"encoding/json"
	"fmt"
	"net/http"
	"strconv"
)

func (h *handler) submitRequest(w http.ResponseWriter, r *http.Request) {
	r.ParseForm()

	userId, _ := strconv.ParseInt(r.URL.Query().Get("user_id"), 10, 64)
	label := r.URL.Query().Get("label")

	redirect := r.FormValue("redirect")

	form := r.Form
	delete(form, "redirect")
	delete(form, "user_id")
	delete(form, "label")

	v, err := json.MarshalIndent(form, "", "\t")
	if err != nil {
		fmt.Println(err)
	}

	data := string(v)

	err = h.store.Save(userId, label, data)
	if err != nil {
		fmt.Println(err)
		return
	}

	//user, _ := h.store.UserById(userId)

	//err = mailer.Notify(user.Email, model.Request{
	//	Tag:  tag,
	//	Data: data,
	//})

	if err != nil {
		fmt.Println(err)
		return
	}

	if redirect != "" {
		http.Redirect(w, r, redirect, http.StatusFound)
		return
	}

	view := NewView(w, r, "thank-you")
	view.Render()
}

A  => web/handler/static_stylesheet.go +11 -0
@@ 1,11 @@
package handler

import (
	"net/http"
	"riku/assets"
)

func (h *handler) showStylesheet(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "text/css; charset=utf-8")
	w.Write([]byte(assets.AssetsMap["style"]))
}

A  => web/handler/thank_you_show.go +8 -0
@@ 1,8 @@
package handler

import "net/http"

func (h *handler) showThankYouView(w http.ResponseWriter, r *http.Request) {
	v := NewView(w, r, "thank-you")
	v.Render()
}

A  => web/handler/tpl.go +103 -0
@@ 1,103 @@
package handler

import (
	"fmt"
	"html/template"
	"net/http"
	"riku/web/handler/request"
	"time"
)

var views = make(map[string]*template.Template)

type View struct {
	w      http.ResponseWriter
	r      *http.Request
	tpl    string
	params map[string]interface{}
}

func NewView(w http.ResponseWriter, r *http.Request, tpl string) View {
	params := make(map[string]interface{})
	return View{
		w:      w,
		r:      r,
		tpl:    tpl,
		params: params,
	}
}

func (v View) Set(key string, val interface{}) {
	v.params[key] = val
}

func (v View) Render() {
	data := v.params
	user := request.GetUserContextKey(v.r)
	data["logged"] = user
	if err := views[v.tpl].Funcs(template.FuncMap{
		"hasPermission": func(name string) bool {
			return user.Name == name
		},
		"logged": func() bool {
			return user.Name != ""
		},
	}).ExecuteTemplate(v.w, "layout", data); err != nil {
		fmt.Println(err)
	}
}

func (h *handler) initTpl() {
	commonTemplates := ""
	for _, content := range TplCommonMap {
		commonTemplates += content
	}

	for name, content := range TplMap {
		views[name] = template.Must(template.New("main").Funcs(template.FuncMap{
			"iso8601": func(t time.Time) string {
				return t.Format("2006-01-02")
			},
			"iso8601Time": func(t time.Time) string {
				return t.Format("2006-01-02 15:04:05")
			},
			"logged": func() bool {
				return false
			},
			"timeAgo": func(t time.Time) string {
				d := time.Since(t)
				if d.Seconds() < 60 {
					seconds := int(d.Seconds())
					if seconds == 1 {
						return "1 second ago"
					}
					return fmt.Sprintf("%d seconds ago", seconds)
				} else if d.Minutes() < 60 {
					minutes := int(d.Minutes())
					if minutes == 1 {
						return "1 minute ago"
					}
					return fmt.Sprintf("%d minutes ago", minutes)
				} else if d.Hours() < 24 {
					hours := int(d.Hours())
					if hours == 1 {
						return "1 hour ago"
					}
					return fmt.Sprintf("%d hours ago", hours)
				} else {
					days := int(d.Hours()) / 24
					if days == 1 {
						return "1 day ago"
					}
					return fmt.Sprintf("%d days ago", days)
				}
			},
			"inc": func(v int64) int64 {
				return v + 1
			},
			"dec": func(v int64) int64 {
				return v - 1
			},
		}).Parse(commonTemplates + content))
	}
}

A  => web/session/session.go +107 -0
@@ 1,107 @@
package session

import (
	"errors"
	"fmt"
	"github.com/gorilla/sessions"
	"net/http"
	"riku/model"
	"riku/storage"
)

const cookieName = "riku"

type Manager struct {
	Store   *sessions.CookieStore
	Storage *storage.Storage
}

type Session struct {
	session *sessions.Session
}

func (s *Session) FlashError(msg string) {
	s.session.AddFlash(msg, "errors")
}

func (s *Session) FlashInfo(msg string) {
	s.session.AddFlash(msg, "info")
}

func (s *Session) Save(r *http.Request, w http.ResponseWriter) {
	if err := s.session.Save(r, w); err != nil {
		fmt.Println("error saving session")
	}
}

func (s *Session) SetUserId(id int64) {
	s.session.Values["id"] = id
}

func (s *Session) GetFlashErrors() []string {
	var errors []string
	if msgs := s.session.Flashes("errors"); len(msgs) > 0 {
		for _, m := range msgs {
			errors = append(errors, m.(string))
		}
	}
	return errors
}

func (s *Session) GetFlashInfo() []string {
	var info []string
	if msgs := s.session.Flashes("info"); len(msgs) > 0 {
		for _, m := range msgs {
			info = append(info, m.(string))
		}
	}
	return info
}

func New(key string, storage *storage.Storage) *Manager {
	store := sessions.NewCookieStore([]byte(key))
	store.Options = &sessions.Options{
		HttpOnly: true,
		MaxAge:   86400 * 30,
		Path:     "/",
	}
	return &Manager{
		Store:   store,
		Storage: storage,
	}
}

func (s *Manager) Delete(w http.ResponseWriter, r *http.Request) error {
	session, err := s.GetSession(r)
	if err != nil {
		return err
	}
	session.session.Options.MaxAge = -1
	session.Save(r, w)
	return err
}

func (s *Manager) GetSession(r *http.Request) (*Session, error) {
	sess, err := s.Store.Get(r, cookieName)
	if err != nil {
		return nil, err
	}
	return &Session{session: sess}, nil
}

// GetUser Returns an error if the user doesn't exist
func (s *Manager) GetUser(r *http.Request) (model.User, error) {
	session, err := s.GetSession(r)
	if err != nil {
		return model.User{}, err
	}
	id, ok := session.session.Values["id"].(int64)
	if id == 0 || !ok {
		return model.User{}, errors.New("error extracting session")
	}
	user, err := s.Storage.UserById(id)
	if err != nil {
		return model.User{}, errors.New("user not found")
	}
	return user, nil
}