first
This commit is contained in:
commit
1301fad3a9
85 changed files with 3596 additions and 0 deletions
9
.env.example
Normal file
9
.env.example
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
# status
|
||||||
|
DATABASE_URL=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_DB}:5432/status?sslmode=disable
|
||||||
|
ASSETS_DIR=/var/www/status/assets
|
||||||
|
MANUAL_REGISTRATION=0 # set to 1 for automatic approval of users
|
||||||
|
|
||||||
|
# postgres
|
||||||
|
POSTGRES_USER=postgres # change me
|
||||||
|
POSTGRES_PASSWORD=password # change me
|
||||||
|
POSTGRES_DB=status
|
||||||
14
.gitignore
vendored
Normal file
14
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
.idea
|
||||||
|
keys
|
||||||
|
nkey.sh
|
||||||
|
users
|
||||||
|
bin
|
||||||
|
.env
|
||||||
|
db.sql
|
||||||
|
*crt
|
||||||
|
*key
|
||||||
|
Makefile2
|
||||||
|
*~
|
||||||
|
db/
|
||||||
|
db
|
||||||
|
|
||||||
12
Dockerfile
Executable file
12
Dockerfile
Executable file
|
|
@ -0,0 +1,12 @@
|
||||||
|
FROM golang:1.17-alpine
|
||||||
|
|
||||||
|
RUN apk update && apk add make git bash
|
||||||
|
|
||||||
|
WORKDIR /var/www/status
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN make
|
||||||
|
|
||||||
|
CMD ["/bin/bash", "-c", "go run generate.go && go run main.go"]
|
||||||
|
|
||||||
|
EXPOSE 8798
|
||||||
29
LICENSE
Normal file
29
LICENSE
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
BSD 3-Clause License
|
||||||
|
|
||||||
|
Copyright (c) 2021,
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without
|
||||||
|
modification, are permitted provided that the following conditions are met:
|
||||||
|
|
||||||
|
* Redistributions of source code must retain the above copyright notice, this
|
||||||
|
list of conditions and the following disclaimer.
|
||||||
|
|
||||||
|
* Redistributions in binary form must reproduce the above copyright notice,
|
||||||
|
this list of conditions and the following disclaimer in the documentation
|
||||||
|
and/or other materials provided with the distribution.
|
||||||
|
|
||||||
|
* Neither the name of the copyright holder nor the names of its
|
||||||
|
contributors may be used to endorse or promote products derived from
|
||||||
|
this software without specific prior written permission.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||||
|
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||||
|
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||||
|
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||||
|
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||||
|
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||||
|
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||||
|
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||||
|
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||||
|
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
2
Makefile
Normal file
2
Makefile
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
build:
|
||||||
|
CGO_ENABLED=0 GOOS=linux go build -o bin/statuscafe main.go
|
||||||
13
README.md
Normal file
13
README.md
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
# docker deploy of ~m15o/status
|
||||||
|
|
||||||
|
this is just a clone of [~m15o/status](https://git.sr.ht/~m15o/status/) that adds a dockerfile and compose file for containerized deployment. i use this for my own instance of status, [synchro](https://synchro.girlonthemoon.xyz/).
|
||||||
|
|
||||||
|
## customization and administration
|
||||||
|
|
||||||
|
the following environment variables in ``.env.example`` configure administration and customization of the site. please copy the example file to ``.env`` for it to work with the compose file.
|
||||||
|
|
||||||
|
- `DATABASE_URL` - the value for this will fill in with variables that configure postgres in the same file, which you should change for security.
|
||||||
|
- `ASSETS_DIR` - the value provided should not be changed unless you wish to move the assets folder in the source code. on the host, the asset directory can contain `style2.css`, which is mounted over the `style.css` file in the container via the compose file, to allow for customization of the site's styling.
|
||||||
|
- `MANUAL_REGISTRATION` - by default this is disabled, which means all users are automatically approved upon registration and are able to post. after registering a user you will use as admin, i recommend setting this to `0` to enable manual approval of users via the `/admin` page.
|
||||||
|
|
||||||
|
in `web/handler/admin_show.go`, you must edit all instances of `m15o` to reflect your admin account's username, or else you will not be able to access the admin page and approve/deny users.
|
||||||
BIN
assets/badge.png
Normal file
BIN
assets/badge.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 730 B |
BIN
assets/button.png
Normal file
BIN
assets/button.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1 KiB |
BIN
assets/favicon.ico
Normal file
BIN
assets/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 132 B |
82
assets/style.css
Normal file
82
assets/style.css
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
body {
|
||||||
|
max-width: 940px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 1em;
|
||||||
|
font-family: Comic Sans MS;
|
||||||
|
background-color: #baffed;
|
||||||
|
color: #000000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.faces {
|
||||||
|
margin-bottom: 1em;
|
||||||
|
max-width: 500px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-picture {
|
||||||
|
max-width: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flash {
|
||||||
|
background-color: #bdffe4;
|
||||||
|
padding: 0.5em 1em;
|
||||||
|
color: darkgreen;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cols {
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
a, a:visited, a:hover {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #700036;
|
||||||
|
}
|
||||||
|
|
||||||
|
.minty-username {
|
||||||
|
margin-bottom: .5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.minty-content {
|
||||||
|
margin: 0 1em 0.5em 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status nav {
|
||||||
|
font-size: 0.8em;
|
||||||
|
margin-left: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
margin-bottom: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
dt {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
dd {
|
||||||
|
margin-bottom: 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-status {
|
||||||
|
width: 100%; box-sizing: border-box; height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tools {
|
||||||
|
list-style-position: inside;
|
||||||
|
padding-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 650px) {
|
||||||
|
.cols {
|
||||||
|
grid-template-columns: 1fr 2fr;
|
||||||
|
grid-gap: 2em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.radio { display: inline-block }
|
||||||
|
.field { margin-bottom: 1rem; max-width: 500px; }
|
||||||
|
.field > label { margin-bottom: .25rem; }
|
||||||
|
.field > * { display: block; width: 100%; box-sizing: border-box; }
|
||||||
|
|
||||||
|
.info {
|
||||||
|
margin-bottom: 1em;
|
||||||
|
}
|
||||||
37
config/cfg.go
Normal file
37
config/cfg.go
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
package config
|
||||||
|
|
||||||
|
import "os"
|
||||||
|
|
||||||
|
type (
|
||||||
|
Config struct {
|
||||||
|
DatabaseURL string
|
||||||
|
VpubDatabaseURL string
|
||||||
|
VpubAESKey string
|
||||||
|
SessionKey string
|
||||||
|
Env string
|
||||||
|
CertFile string
|
||||||
|
KeyFile string
|
||||||
|
AssetsDir string
|
||||||
|
EmailUsername string
|
||||||
|
EmailPassword string
|
||||||
|
EmailHost string
|
||||||
|
EmailHostAddr string
|
||||||
|
ManualRegistration bool
|
||||||
|
EmojiFolder string
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func New() *Config {
|
||||||
|
return &Config{
|
||||||
|
DatabaseURL: os.Getenv("DATABASE_URL"),
|
||||||
|
VpubDatabaseURL: os.Getenv("VPUB_DATABASE_URL"),
|
||||||
|
VpubAESKey: os.Getenv("VPUB_AES_KEY"),
|
||||||
|
SessionKey: os.Getenv("SESSION_KEY"),
|
||||||
|
Env: os.Getenv("ENV"),
|
||||||
|
CertFile: os.Getenv("CERT_FILE"),
|
||||||
|
KeyFile: os.Getenv("CERT_KEY_FILE"),
|
||||||
|
AssetsDir: os.Getenv("ASSETS_DIR"),
|
||||||
|
ManualRegistration: len(os.Getenv("MANUAL_REGISTRATION")) > 0,
|
||||||
|
EmojiFolder: os.Getenv("EMOJI_FOLDER"),
|
||||||
|
}
|
||||||
|
}
|
||||||
16
docker-compose.yml
Executable file
16
docker-compose.yml
Executable file
|
|
@ -0,0 +1,16 @@
|
||||||
|
services:
|
||||||
|
status:
|
||||||
|
build: .
|
||||||
|
ports:
|
||||||
|
- "8798:8000"
|
||||||
|
env_file: .env # copy .env.example to .env for this to work
|
||||||
|
volumes:
|
||||||
|
- .:/var/www/status:rw
|
||||||
|
- ./assets/style2.css:/var/www/status/assets/style2.css
|
||||||
|
postgres:
|
||||||
|
image: postgres:13
|
||||||
|
env_file: .env # copy .env.example to .env for this to work
|
||||||
|
volumes:
|
||||||
|
- ./db:/var/lib/postgresql/data
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
86
generate.go
Normal file
86
generate.go
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
// +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"))
|
||||||
|
}
|
||||||
14
go.mod
Normal file
14
go.mod
Normal file
|
|
@ -0,0 +1,14 @@
|
||||||
|
module status
|
||||||
|
|
||||||
|
go 1.16
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/fogleman/gg v1.3.0
|
||||||
|
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0
|
||||||
|
github.com/gorilla/csrf v1.7.1
|
||||||
|
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-20211117183948-ae814b36b871
|
||||||
|
golang.org/x/image v0.0.0-20211028202545-6944b10bf410
|
||||||
|
)
|
||||||
27
go.sum
Normal file
27
go.sum
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8=
|
||||||
|
github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k=
|
||||||
|
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g=
|
||||||
|
github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k=
|
||||||
|
github.com/gorilla/csrf v1.7.1 h1:Ir3o2c1/Uzj6FBxMlAUB6SivgVMy1ONXwYgXn+/aHPE=
|
||||||
|
github.com/gorilla/csrf v1.7.1/go.mod h1:+a/4tCmqhG6/w4oafeAZ9pEa3/NZOWYVbD9fV0FwIQA=
|
||||||
|
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=
|
||||||
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
golang.org/x/crypto v0.0.0-20211117183948-ae814b36b871 h1:/pEO3GD/ABYAjuakUS6xSEmmlyVS4kxBNkeA9tLJiTI=
|
||||||
|
golang.org/x/crypto v0.0.0-20211117183948-ae814b36b871/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||||
|
golang.org/x/image v0.0.0-20211028202545-6944b10bf410 h1:hTftEOvwiOq2+O8k2D5/Q7COC7k5Qcrgc2TFURJYnvQ=
|
||||||
|
golang.org/x/image v0.0.0-20211028202545-6944b10bf410/go.mod h1:023OzeP/+EPmXeapQh35lcL3II3LrY8Ic+EFFKVhULM=
|
||||||
|
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=
|
||||||
31
main.go
Normal file
31
main.go
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
//go:generate go run generate.go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"status/config"
|
||||||
|
"status/storage"
|
||||||
|
"status/vpub"
|
||||||
|
"status/web"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfg := config.New()
|
||||||
|
db, err := storage.InitDB(cfg.DatabaseURL)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
data := storage.New(db)
|
||||||
|
if err := data.ActivateAllUsers(); err != nil {
|
||||||
|
log.Println("Failed to activate users:", err)
|
||||||
|
} else {
|
||||||
|
log.Println("All users activated.")
|
||||||
|
}
|
||||||
|
v, err := vpub.New(cfg.VpubDatabaseURL, []byte(cfg.VpubAESKey))
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
log.Fatal(
|
||||||
|
web.Serve(data, v, cfg),
|
||||||
|
)
|
||||||
|
}
|
||||||
86
model/status.go
Normal file
86
model/status.go
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"html"
|
||||||
|
"html/template"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Status struct {
|
||||||
|
Id int64
|
||||||
|
User string
|
||||||
|
Content string
|
||||||
|
Face string
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
var urlRegexp = regexp.MustCompile(`https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)`)
|
||||||
|
var userRegexp = regexp.MustCompile(`@([a-z0-9-_]+)\b`)
|
||||||
|
|
||||||
|
func (s Status) ContentDisplay() string {
|
||||||
|
content := html.EscapeString(s.Content)
|
||||||
|
if urlRegexp.MatchString(s.Content) {
|
||||||
|
matches := urlRegexp.FindAllStringSubmatch(s.Content, -1)
|
||||||
|
for _, m := range matches {
|
||||||
|
url := m[0]
|
||||||
|
content = strings.Replace(content, url, fmt.Sprintf("<a href=\"%s\" target=\"_blank\">%s</a>", url, url), 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if userRegexp.MatchString(s.Content) {
|
||||||
|
matches := userRegexp.FindAllStringSubmatch(s.Content, -1)
|
||||||
|
for _, m := range matches {
|
||||||
|
content = strings.Replace(content, m[0], fmt.Sprintf("<a href=\"https://minty.anteater.monster/users/%s\" target=\"_blank\">%s</a>", m[1], m[0]), 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return content
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Status) ContentHtml() template.HTML {
|
||||||
|
return template.HTML(s.ContentDisplay())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Status) Validate() error {
|
||||||
|
if len(s.Content) == 0 {
|
||||||
|
return errors.New("content is empty")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Status) Date() string {
|
||||||
|
return s.CreatedAt.Format("2006-01-02")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s Status) TimeAgo() string {
|
||||||
|
// Taken from flounder.online <3
|
||||||
|
// https://github.com/alexwennerberg/flounder
|
||||||
|
d := time.Since(s.CreatedAt)
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
44
model/user.go
Normal file
44
model/user.go
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
"regexp"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
Name string
|
||||||
|
Password string
|
||||||
|
Hash []byte
|
||||||
|
Homepage string
|
||||||
|
About string
|
||||||
|
Picture string
|
||||||
|
Email string
|
||||||
|
SignupEmail string
|
||||||
|
SignupMsg string
|
||||||
|
Active bool
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u User) Validate() error {
|
||||||
|
if u.Name == "" {
|
||||||
|
return errors.New("username is mandatory")
|
||||||
|
}
|
||||||
|
if u.Password == "" {
|
||||||
|
return errors.New("password is mandatory")
|
||||||
|
}
|
||||||
|
match, _ := regexp.MatchString("^[a-z0-9-_]+$", u.Name)
|
||||||
|
if !match {
|
||||||
|
return errors.New("username should match [a-z0-9-_]")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u User) HashPassword() ([]byte, error) {
|
||||||
|
return bcrypt.GenerateFromPassword([]byte(u.Password), bcrypt.MinCost)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u User) CompareHashToPassword(hash []byte) error {
|
||||||
|
return bcrypt.CompareHashAndPassword(hash, []byte(u.Password))
|
||||||
|
}
|
||||||
43
model/user_test.go
Normal file
43
model/user_test.go
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
package model
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestValidateUser(t *testing.T) {
|
||||||
|
user := User{
|
||||||
|
Name: "",
|
||||||
|
Password: "password",
|
||||||
|
}
|
||||||
|
|
||||||
|
user.Name = ""
|
||||||
|
if err := user.Validate(); err == nil {
|
||||||
|
t.Fatal("Empty username not allowed")
|
||||||
|
}
|
||||||
|
|
||||||
|
user.Name = "miso"
|
||||||
|
if err := user.Validate(); err != nil {
|
||||||
|
t.Fatal("Regular characters allowed")
|
||||||
|
}
|
||||||
|
|
||||||
|
user.Name = "m15o"
|
||||||
|
if err := user.Validate(); err != nil {
|
||||||
|
t.Fatal("Digits allowed")
|
||||||
|
}
|
||||||
|
|
||||||
|
user.Name = "has space"
|
||||||
|
if err := user.Validate(); err == nil {
|
||||||
|
t.Fatal("Space is not allowed")
|
||||||
|
}
|
||||||
|
|
||||||
|
user.Name = "M15O"
|
||||||
|
if err := user.Validate(); err == nil {
|
||||||
|
t.Fatal("Capital letters aren't allowed")
|
||||||
|
}
|
||||||
|
|
||||||
|
characters := []string{"#", ":", "/", "@", "?"}
|
||||||
|
for _, c := range characters {
|
||||||
|
user.Name = c
|
||||||
|
if err := user.Validate(); err == nil {
|
||||||
|
t.Fatal("Special characters not allowed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
15
storage/db.go
Normal file
15
storage/db.go
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
_ "github.com/lib/pq"
|
||||||
|
)
|
||||||
|
|
||||||
|
func InitDB(databaseURL string) (*sql.DB, error) {
|
||||||
|
db, err := sql.Open("postgres", databaseURL)
|
||||||
|
if err != nil {
|
||||||
|
return db, err
|
||||||
|
}
|
||||||
|
Migrate(db)
|
||||||
|
return db, err
|
||||||
|
}
|
||||||
51
storage/migration.go
Normal file
51
storage/migration.go
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
const schemaVersion = 11
|
||||||
|
|
||||||
|
func Migrate(db *sql.DB) {
|
||||||
|
var currentVersion int
|
||||||
|
db.QueryRow(`SELECT version FROM schema_version`).Scan(¤tVersion)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
56
storage/sql.go
Normal file
56
storage/sql.go
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
// Code generated by go generate; DO NOT EDIT.
|
||||||
|
|
||||||
|
package storage
|
||||||
|
|
||||||
|
var SqlMap = map[string]string{
|
||||||
|
"schema_version_1": `-- create schema version table
|
||||||
|
create table schema_version (
|
||||||
|
version text not null
|
||||||
|
);
|
||||||
|
|
||||||
|
-- create users table
|
||||||
|
create table users
|
||||||
|
(
|
||||||
|
name text primary key CHECK (name <> ''),
|
||||||
|
hash text not null CHECK (hash <> ''),
|
||||||
|
created_at timestamp with time zone DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- create posts status
|
||||||
|
create table statuses
|
||||||
|
(
|
||||||
|
id serial primary key,
|
||||||
|
author TEXT references users(name) NOT NULL,
|
||||||
|
content VARCHAR(500) NOT NULL CHECK (content <> ''),
|
||||||
|
created_at timestamp with time zone DEFAULT now()
|
||||||
|
);
|
||||||
|
`,
|
||||||
|
"schema_version_10": `alter table users
|
||||||
|
add column signup_email TEXT not null DEFAULT '',
|
||||||
|
add column signup_msg TEXT not null DEFAULT '',
|
||||||
|
add column active bool default false;
|
||||||
|
|
||||||
|
-- All existing users are active
|
||||||
|
update users set active=true;`,
|
||||||
|
"schema_version_11": `ALTER TABLE statuses
|
||||||
|
ALTER COLUMN face TYPE VARCHAR(2);`,
|
||||||
|
"schema_version_2": `alter table users
|
||||||
|
add column status_id int references statuses(id);
|
||||||
|
`,
|
||||||
|
"schema_version_3": `alter table users
|
||||||
|
add column homepage varchar(500) not null DEFAULT '',
|
||||||
|
add column about varchar(500) not null DEFAULT '';
|
||||||
|
`,
|
||||||
|
"schema_version_4": `alter table users
|
||||||
|
alter column about TYPE TEXT;`,
|
||||||
|
"schema_version_5": `alter table users
|
||||||
|
add column style TEXT not null DEFAULT '';`,
|
||||||
|
"schema_version_6": `alter table users
|
||||||
|
add column picture varchar(500) not null DEFAULT '';`,
|
||||||
|
"schema_version_7": `alter table users
|
||||||
|
add column email varchar(500) not null DEFAULT '';`,
|
||||||
|
"schema_version_8": `alter table statuses
|
||||||
|
add column face varchar(1) not null DEFAULT '🙂';`,
|
||||||
|
"schema_version_9": `alter table users
|
||||||
|
drop column style;`,
|
||||||
|
}
|
||||||
21
storage/sql/schema_version_1.sql
Normal file
21
storage/sql/schema_version_1.sql
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
-- create schema version table
|
||||||
|
create table schema_version (
|
||||||
|
version text not null
|
||||||
|
);
|
||||||
|
|
||||||
|
-- create users table
|
||||||
|
create table users
|
||||||
|
(
|
||||||
|
name text primary key CHECK (name <> ''),
|
||||||
|
hash text not null CHECK (hash <> ''),
|
||||||
|
created_at timestamp with time zone DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- create posts status
|
||||||
|
create table statuses
|
||||||
|
(
|
||||||
|
id serial primary key,
|
||||||
|
author TEXT references users(name) NOT NULL,
|
||||||
|
content VARCHAR(500) NOT NULL CHECK (content <> ''),
|
||||||
|
created_at timestamp with time zone DEFAULT now()
|
||||||
|
);
|
||||||
7
storage/sql/schema_version_10.sql
Normal file
7
storage/sql/schema_version_10.sql
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
alter table users
|
||||||
|
add column signup_email TEXT not null DEFAULT '',
|
||||||
|
add column signup_msg TEXT not null DEFAULT '',
|
||||||
|
add column active bool default false;
|
||||||
|
|
||||||
|
-- All existing users are active
|
||||||
|
update users set active=true;
|
||||||
2
storage/sql/schema_version_11.sql
Normal file
2
storage/sql/schema_version_11.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
ALTER TABLE statuses
|
||||||
|
ALTER COLUMN face TYPE VARCHAR(2);
|
||||||
2
storage/sql/schema_version_2.sql
Normal file
2
storage/sql/schema_version_2.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
alter table users
|
||||||
|
add column status_id int references statuses(id);
|
||||||
3
storage/sql/schema_version_3.sql
Normal file
3
storage/sql/schema_version_3.sql
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
alter table users
|
||||||
|
add column homepage varchar(500) not null DEFAULT '',
|
||||||
|
add column about varchar(500) not null DEFAULT '';
|
||||||
2
storage/sql/schema_version_4.sql
Normal file
2
storage/sql/schema_version_4.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
alter table users
|
||||||
|
alter column about TYPE TEXT;
|
||||||
2
storage/sql/schema_version_5.sql
Normal file
2
storage/sql/schema_version_5.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
alter table users
|
||||||
|
add column style TEXT not null DEFAULT '';
|
||||||
2
storage/sql/schema_version_6.sql
Normal file
2
storage/sql/schema_version_6.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
alter table users
|
||||||
|
add column picture varchar(500) not null DEFAULT '';
|
||||||
2
storage/sql/schema_version_7.sql
Normal file
2
storage/sql/schema_version_7.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
alter table users
|
||||||
|
add column email varchar(500) not null DEFAULT '';
|
||||||
2
storage/sql/schema_version_8.sql
Normal file
2
storage/sql/schema_version_8.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
alter table statuses
|
||||||
|
add column face varchar(1) not null DEFAULT '🙂';
|
||||||
2
storage/sql/schema_version_9.sql
Normal file
2
storage/sql/schema_version_9.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
alter table users
|
||||||
|
drop column style;
|
||||||
219
storage/status.go
Normal file
219
storage/status.go
Normal file
|
|
@ -0,0 +1,219 @@
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"status/model"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type statusQueryBuilder struct {
|
||||||
|
where string
|
||||||
|
limit string
|
||||||
|
offset string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p statusQueryBuilder) build() string {
|
||||||
|
query := []string{`SELECT id, author, content, created_at, face from statuses`}
|
||||||
|
if p.where != "" {
|
||||||
|
query = append(query, `WHERE`, p.where)
|
||||||
|
}
|
||||||
|
query = append(query, `ORDER BY created_at desc`)
|
||||||
|
if p.limit != "" {
|
||||||
|
query = append(query, `LIMIT`, p.limit)
|
||||||
|
}
|
||||||
|
if p.offset != "" {
|
||||||
|
query = append(query, `OFFSET`, p.offset)
|
||||||
|
}
|
||||||
|
return strings.Join(query, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) populateStatus(rows *sql.Rows) (model.Status, error) {
|
||||||
|
var status model.Status
|
||||||
|
err := rows.Scan(&status.Id, &status.User, &status.Content, &status.CreatedAt, &status.Face)
|
||||||
|
if err != nil {
|
||||||
|
return status, err
|
||||||
|
}
|
||||||
|
return status, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) CreateStatus(status model.Status) error {
|
||||||
|
var statusId int64
|
||||||
|
ctx := context.Background()
|
||||||
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.QueryRowContext(ctx, `INSERT INTO statuses (author, content, face) VALUES ($1, $2, $3) RETURNING id`,
|
||||||
|
status.User, status.Content, status.Face).Scan(&statusId); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := tx.ExecContext(ctx, `UPDATE users set status_id=$1 where name=$2`, statusId, status.User); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = tx.Commit()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) StatusById(id int64) (model.Status, error) {
|
||||||
|
var status model.Status
|
||||||
|
err := s.db.QueryRow(
|
||||||
|
`SELECT id, author, content, face, created_at from statuses WHERE id=$1`, id).Scan(
|
||||||
|
&status.Id,
|
||||||
|
&status.User,
|
||||||
|
&status.Content,
|
||||||
|
&status.Face,
|
||||||
|
&status.CreatedAt,
|
||||||
|
)
|
||||||
|
return status, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) StatusByUsername(user string, perPage int, page int64) ([]model.Status, bool, error) {
|
||||||
|
rows, err := s.db.Query(statusQueryBuilder{
|
||||||
|
where: `author = $1`,
|
||||||
|
limit: strconv.Itoa(perPage + 1),
|
||||||
|
offset: `$2`,
|
||||||
|
}.build(), user, page*int64(perPage))
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
var statuses []model.Status
|
||||||
|
for rows.Next() {
|
||||||
|
post, err := s.populateStatus(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
statuses = append(statuses, post)
|
||||||
|
}
|
||||||
|
if len(statuses) > perPage {
|
||||||
|
return statuses[0:perPage], true, err
|
||||||
|
}
|
||||||
|
return statuses, false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) StatusFeed() ([]model.Status, error) {
|
||||||
|
rows, err := s.db.Query(statusQueryBuilder{
|
||||||
|
limit: strconv.Itoa(20),
|
||||||
|
}.build())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var statuses []model.Status
|
||||||
|
for rows.Next() {
|
||||||
|
post, err := s.populateStatus(rows)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
statuses = append(statuses, post)
|
||||||
|
}
|
||||||
|
return statuses, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) Statuses(page int64, perPage int) ([]model.Status, bool, error) {
|
||||||
|
rows, err := s.db.Query(statusQueryBuilder{
|
||||||
|
limit: strconv.Itoa(perPage + 1),
|
||||||
|
offset: `$1`,
|
||||||
|
}.build(), page*int64(perPage))
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
var statuses []model.Status
|
||||||
|
for rows.Next() {
|
||||||
|
post, err := s.populateStatus(rows)
|
||||||
|
if err != nil {
|
||||||
|
return statuses, false, err
|
||||||
|
}
|
||||||
|
statuses = append(statuses, post)
|
||||||
|
}
|
||||||
|
if len(statuses) > perPage {
|
||||||
|
return statuses[0:perPage], true, err
|
||||||
|
}
|
||||||
|
return statuses, false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) LatestStatuses() ([]model.Status, error) {
|
||||||
|
rows, err := s.db.Query(`
|
||||||
|
select
|
||||||
|
statuses.id,
|
||||||
|
users.name,
|
||||||
|
statuses.content,
|
||||||
|
statuses.created_at,
|
||||||
|
statuses.face
|
||||||
|
from
|
||||||
|
users
|
||||||
|
inner join statuses
|
||||||
|
on users.status_id = statuses.id
|
||||||
|
order by statuses.created_at desc;
|
||||||
|
`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var statuses []model.Status
|
||||||
|
for rows.Next() {
|
||||||
|
post, err := s.populateStatus(rows)
|
||||||
|
if err != nil {
|
||||||
|
return statuses, err
|
||||||
|
}
|
||||||
|
statuses = append(statuses, post)
|
||||||
|
}
|
||||||
|
return statuses, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) UpdateStatus(status model.Status) error {
|
||||||
|
stmt, err := s.db.Prepare(`UPDATE statuses SET content = $1, face = $2 WHERE id = $3 and author = $4;`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = stmt.Exec(status.Content, status.Face, status.Id, status.User)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) DeleteStatus(id int64, author string) error {
|
||||||
|
ctx := context.Background()
|
||||||
|
tx, err := s.db.BeginTx(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var latestId int64
|
||||||
|
if err := tx.QueryRowContext(ctx, `select status_id from users where name = $1`,
|
||||||
|
author).Scan(&latestId); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `UPDATE users set status_id=$1 where name=$2`, nil, author); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `DELETE from statuses WHERE id = $1 and author = $2;`, id, author); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if latestId == id {
|
||||||
|
var newId int64
|
||||||
|
if err := tx.QueryRowContext(ctx, `select id from statuses where author = $1 order by created_at desc limit 1;`,
|
||||||
|
author).Scan(&newId); err != nil {
|
||||||
|
if _, err := tx.ExecContext(ctx, `UPDATE users set status_id=$1 where name=$2`, nil, author); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = tx.Commit()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if _, err := tx.ExecContext(ctx, `UPDATE users set status_id=$1 where name=$2`, newId, author); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if _, err := tx.ExecContext(ctx, `UPDATE users set status_id=$1 where name=$2`, latestId, author); err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
err = tx.Commit()
|
||||||
|
return err
|
||||||
|
}
|
||||||
13
storage/storage.go
Normal file
13
storage/storage.go
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Storage struct {
|
||||||
|
db *sql.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(db *sql.DB) *Storage {
|
||||||
|
return &Storage{db: db}
|
||||||
|
}
|
||||||
115
storage/user.go
Normal file
115
storage/user.go
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
package storage
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"status/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
const queryFindName = `SELECT name, hash, created_at, homepage, about, picture, email, active, signup_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.Name, &user.Hash, &user.CreatedAt, &user.Homepage, &user.About, &user.Picture, &user.Email, &user.Active, &user.SignupEmail)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
func (s *Storage) ActivateAllUsers() error {
|
||||||
|
_, err := s.db.Exec(`UPDATE users SET active = true;`)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
func (s *Storage) VerifyUser(user model.User) (model.User, error) {
|
||||||
|
u, err := s.queryUser(queryFindName, user.Name)
|
||||||
|
if err != nil {
|
||||||
|
return u, err
|
||||||
|
}
|
||||||
|
if err := user.CompareHashToPassword(u.Hash); err != nil {
|
||||||
|
return u, errors.New("incorrect password")
|
||||||
|
}
|
||||||
|
if !u.Active {
|
||||||
|
return u, errors.New("user not active")
|
||||||
|
}
|
||||||
|
return u, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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) UserByName(name string) (model.User, error) {
|
||||||
|
return s.queryUser(queryFindName, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) CreateUser(user model.User) error {
|
||||||
|
hash, err := user.HashPassword()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
insertUser := `INSERT INTO users (name, hash, email, signup_email, signup_msg, active) VALUES (lower($1), $2, $3, $4, $5, $6)`
|
||||||
|
statement, err := s.db.Prepare(insertUser)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = statement.Exec(user.Name, hash, user.Email, user.SignupEmail, user.SignupMsg, user.Active)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) Users() ([]string, error) {
|
||||||
|
rows, err := s.db.Query("select name from users")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var users []string
|
||||||
|
for rows.Next() {
|
||||||
|
var user string
|
||||||
|
err := rows.Scan(&user)
|
||||||
|
if err != nil {
|
||||||
|
return users, err
|
||||||
|
}
|
||||||
|
users = append(users, user)
|
||||||
|
}
|
||||||
|
return users, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) InactiveUsers() ([]model.User, error) {
|
||||||
|
rows, err := s.db.Query("select name, signup_email, signup_msg from users where active is false")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var users []model.User
|
||||||
|
for rows.Next() {
|
||||||
|
var user model.User
|
||||||
|
err := rows.Scan(&user.Name, &user.SignupEmail, &user.SignupMsg)
|
||||||
|
if err != nil {
|
||||||
|
return users, err
|
||||||
|
}
|
||||||
|
users = append(users, user)
|
||||||
|
}
|
||||||
|
return users, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) ActivateUser(name string) error {
|
||||||
|
stmt, err := s.db.Prepare(`UPDATE users SET active = true WHERE name = $1;`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = stmt.Exec(name)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) DeleteUser(username string) error {
|
||||||
|
stmt, err := s.db.Prepare(`DELETE from users WHERE name = $1;`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = stmt.Exec(username)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Storage) UpdateSettings(username, homepage, about, picture, email string) error {
|
||||||
|
stmt, err := s.db.Prepare(`UPDATE users SET homepage = $1, about = $2, picture = $3, email = $4 WHERE name = $5;`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_, err = stmt.Exec(homepage, about, picture, email, username)
|
||||||
|
return err
|
||||||
|
}
|
||||||
45
vpub/vpub.go
Normal file
45
vpub/vpub.go
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
package vpub
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/aes"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Vpub struct {
|
||||||
|
db *sql.DB
|
||||||
|
key []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(databaseURL string, key []byte) (Vpub, error) {
|
||||||
|
db, err := sql.Open("postgres", databaseURL)
|
||||||
|
return Vpub{db: db, key: key}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func EncryptAES(key []byte, plaintext string) string {
|
||||||
|
c, err := aes.NewCipher(key)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
out := make([]byte, len(plaintext))
|
||||||
|
c.Encrypt(out, []byte(plaintext))
|
||||||
|
return hex.EncodeToString(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v Vpub) FindOrCreateKey(name string) string {
|
||||||
|
padded := fmt.Sprintf("%16s", name)
|
||||||
|
key := EncryptAES(v.key, padded)[0:20]
|
||||||
|
|
||||||
|
query := `
|
||||||
|
insert into keys (key) values ($1) ON CONFLICT do nothing
|
||||||
|
`
|
||||||
|
|
||||||
|
_, err := v.db.Exec(query, key[:20])
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return key
|
||||||
|
}
|
||||||
75
web/handler/admin_show.go
Normal file
75
web/handler/admin_show.go
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *Handler) showAdminView(w http.ResponseWriter, r *http.Request) {
|
||||||
|
protectClickJacking(w)
|
||||||
|
username, err := h.getUser(r)
|
||||||
|
if err != nil {
|
||||||
|
unauthorized(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if username != "aggie" {
|
||||||
|
unauthorized(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
users, err := h.storage.InactiveUsers()
|
||||||
|
if err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.renderLayout(w, "admin", map[string]interface{}{
|
||||||
|
"inactive": users,
|
||||||
|
}, username)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) activateUser(w http.ResponseWriter, r *http.Request) {
|
||||||
|
protectClickJacking(w)
|
||||||
|
username, err := h.getUser(r)
|
||||||
|
if err != nil {
|
||||||
|
unauthorized(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if username != "aggie" {
|
||||||
|
unauthorized(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := r.URL.Query().Get("name")
|
||||||
|
if err := h.storage.ActivateUser(name); err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/admin"), http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) deleteUser(w http.ResponseWriter, r *http.Request) {
|
||||||
|
protectClickJacking(w)
|
||||||
|
username, err := h.getUser(r)
|
||||||
|
if err != nil {
|
||||||
|
unauthorized(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if username != "aggie" {
|
||||||
|
unauthorized(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
name := r.URL.Query().Get("name")
|
||||||
|
user, err := h.storage.UserByName(name)
|
||||||
|
if err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if user.Active {
|
||||||
|
serverError(w, errors.New("user is active"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.storage.DeleteUser(name); err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/admin"), http.StatusFound)
|
||||||
|
}
|
||||||
78
web/handler/common.go
Normal file
78
web/handler/common.go
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
// Code generated by go generate; DO NOT EDIT.
|
||||||
|
|
||||||
|
package handler
|
||||||
|
|
||||||
|
var TplCommonMap = map[string]string{
|
||||||
|
"flash": `{{ define "flash" }}
|
||||||
|
{{ if . }}
|
||||||
|
<p class="flash">{{ . }}</p>
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}`,
|
||||||
|
"layout": `{{ define "layout" }}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{ template "title" . }}Minty Cafe</title>
|
||||||
|
<meta name="description" content="your friends' updates">
|
||||||
|
<link rel="stylesheet" href="/assets/style.css"/>
|
||||||
|
{{ if .face }}
|
||||||
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>{{ .face }}</text></svg>">
|
||||||
|
{{ else }}
|
||||||
|
<link rel="icon" href="/assets/favicon.ico">
|
||||||
|
{{ end }}
|
||||||
|
{{ template "head" . }}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<nav>
|
||||||
|
<a href="/">minty cafe</a>
|
||||||
|
{{ if .logged }}
|
||||||
|
<a href="/settings">settings</a> <a href="/users/{{ .logged }}">{{ .logged }}</a> (<a href="/logout">logout</a>)
|
||||||
|
{{ else }}
|
||||||
|
<a href="/register">register</a> <a href="/login">login</a>
|
||||||
|
{{ end }}</nav>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
{{ template "content" . }}
|
||||||
|
</main>
|
||||||
|
<footer>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{ end }}
|
||||||
|
{{ define "head" }}{{ end }}
|
||||||
|
{{ define "title" }}{{ end }}
|
||||||
|
`,
|
||||||
|
"status": `{{ define "status" }}
|
||||||
|
<div class="status-username"><a href="/users/{{ .User }}">{{ .User }}</a> {{ .Face }} {{ .TimeAgo }}</div>
|
||||||
|
<p class="status-content">{{ .ContentHtml }}</p>
|
||||||
|
{{ end }}`,
|
||||||
|
"status_form": `{{ define "status_form" }}
|
||||||
|
<div class="faces">
|
||||||
|
{{ range $i, $v := faces }}
|
||||||
|
<div class="radio">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
id="face{{ $i }}"
|
||||||
|
name="face"
|
||||||
|
value="{{ $v }}"
|
||||||
|
{{ if eq $i 0 }}
|
||||||
|
{{ if or (eq $.Face $v) (eq $.Face "") }}checked{{ end }}
|
||||||
|
{{ else }}
|
||||||
|
{{ if eq $.Face $v }}checked{{ end }}
|
||||||
|
{{ end }}>
|
||||||
|
<label for="face{{ $i }}">{{ $v }}</label>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<textarea class="edit-status" name="content" maxlength="140" placeholder="What's new?" required autofocus>{{ .Content }}</textarea>
|
||||||
|
</div>
|
||||||
|
<input type="submit" value="Submit">
|
||||||
|
{{ end }}
|
||||||
|
`,
|
||||||
|
}
|
||||||
131
web/handler/feed_show.go
Normal file
131
web/handler/feed_show.go
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"status/model"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/xml"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Feed struct {
|
||||||
|
XMLName xml.Name `xml:"http://www.w3.org/2005/Atom feed"`
|
||||||
|
Title string `xml:"title"`
|
||||||
|
ID string `xml:"id"`
|
||||||
|
Link []Link `xml:"link"`
|
||||||
|
Updated TimeStr `xml:"updated"`
|
||||||
|
Author *Person `xml:"author"`
|
||||||
|
Icon string `xml:"icon,omitempty"`
|
||||||
|
Logo string `xml:"logo,omitempty"`
|
||||||
|
Subtitle string `xml:"subtitle,omitempty"`
|
||||||
|
Entry []*Entry `xml:"entry"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Entry struct {
|
||||||
|
Title string `xml:"title"`
|
||||||
|
ID string `xml:"id"`
|
||||||
|
Link []Link `xml:"link"`
|
||||||
|
Published TimeStr `xml:"published"`
|
||||||
|
Updated TimeStr `xml:"updated"`
|
||||||
|
Author *Person `xml:"author"`
|
||||||
|
Summary *Text `xml:"summary"`
|
||||||
|
Content *Text `xml:"content"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Link struct {
|
||||||
|
Rel string `xml:"rel,attr,omitempty"`
|
||||||
|
Href string `xml:"href,attr"`
|
||||||
|
Type string `xml:"type,attr,omitempty"`
|
||||||
|
HrefLang string `xml:"hreflang,attr,omitempty"`
|
||||||
|
Title string `xml:"title,attr,omitempty"`
|
||||||
|
Length uint `xml:"length,attr,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Person struct {
|
||||||
|
Name string `xml:"name"`
|
||||||
|
URI string `xml:"uri,omitempty"`
|
||||||
|
Email string `xml:"email,omitempty"`
|
||||||
|
InnerXML string `xml:",innerxml"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Text struct {
|
||||||
|
Type string `xml:"type,attr"`
|
||||||
|
Body string `xml:",chardata"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type TimeStr string
|
||||||
|
|
||||||
|
func Time(t time.Time) TimeStr {
|
||||||
|
return TimeStr(t.Format("2006-01-02T15:04:05-07:00"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func createAtomEntryFromStatus(status model.Status) *Entry {
|
||||||
|
return &Entry{
|
||||||
|
Title: fmt.Sprintf("%s %s %s", status.User, status.Face, truncate(status.Content, 50)),
|
||||||
|
ID: fmt.Sprintf("https://minty.anteater.monster/users/%s/%d", status.User, status.Id),
|
||||||
|
Link: []Link{
|
||||||
|
{
|
||||||
|
Rel: "alternate",
|
||||||
|
Href: fmt.Sprintf("https://minty.anteater.monster/statuses/%d", status.Id),
|
||||||
|
Type: "text/html",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Updated: Time(status.CreatedAt),
|
||||||
|
Published: Time(status.CreatedAt),
|
||||||
|
Author: &Person{
|
||||||
|
Name: status.User,
|
||||||
|
URI: fmt.Sprintf("https://minty.anteater.monster/users/%s", status.User),
|
||||||
|
},
|
||||||
|
Content: &Text{
|
||||||
|
Type: "html",
|
||||||
|
Body: status.ContentDisplay(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) showFeedView(w http.ResponseWriter, r *http.Request) {
|
||||||
|
feed := Feed{
|
||||||
|
Title: "minty cafe",
|
||||||
|
ID: "https://minty.anteater.monster/",
|
||||||
|
Subtitle: "Your friends' updates",
|
||||||
|
Icon: "/assets/icon.png",
|
||||||
|
Author: &Person{
|
||||||
|
Name: "minty cafe",
|
||||||
|
URI: "https://minty.anteater.monster",
|
||||||
|
},
|
||||||
|
Updated: Time(time.Now()),
|
||||||
|
Link: []Link{
|
||||||
|
{
|
||||||
|
Rel: "self",
|
||||||
|
Href: "https://minty.anteater.monster/feed.atom",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Rel: "alternate",
|
||||||
|
Type: "text/html",
|
||||||
|
Href: "https://minty.anteater.monster",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
statuses, err := h.storage.StatusFeed()
|
||||||
|
if err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, status := range statuses {
|
||||||
|
feed.Entry = append(feed.Entry, createAtomEntryFromStatus(status))
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
w.Header().Set("Content-Type", "application/atom+xml")
|
||||||
|
var data []byte
|
||||||
|
data, err = xml.MarshalIndent(&feed, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
}
|
||||||
|
w.Write([]byte(xml.Header + string(data)))
|
||||||
|
}
|
||||||
18
web/handler/form/login.go
Normal file
18
web/handler/form/login.go
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
package form
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LoginForm struct {
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLoginForm(r *http.Request) *LoginForm {
|
||||||
|
return &LoginForm{
|
||||||
|
Username: r.FormValue("name"),
|
||||||
|
Password: r.FormValue("password"),
|
||||||
|
}
|
||||||
|
}
|
||||||
50
web/handler/form/register.go
Normal file
50
web/handler/form/register.go
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
package form
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RegisterForm struct {
|
||||||
|
Username string
|
||||||
|
Password string
|
||||||
|
Email string
|
||||||
|
ShowEmail bool
|
||||||
|
Answer string
|
||||||
|
Confirm string
|
||||||
|
Key string
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *RegisterForm) Validate() error {
|
||||||
|
if f.Password != f.Confirm {
|
||||||
|
return errors.New("password doesn't match confirmation")
|
||||||
|
}
|
||||||
|
if len(f.Username) < 3 {
|
||||||
|
return errors.New("username needs to be at least 3 characters")
|
||||||
|
}
|
||||||
|
if len(f.Username) > 20 {
|
||||||
|
return errors.New("username should be 20 characters or less")
|
||||||
|
}
|
||||||
|
match, _ := regexp.MatchString("^[a-z0-9-_]+$", f.Username)
|
||||||
|
if !match {
|
||||||
|
return errors.New("only lowercase letters and digits are accepted for username")
|
||||||
|
}
|
||||||
|
if len(f.Password) < 6 {
|
||||||
|
return errors.New("password needs to be at least 6 characters")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRegisterForm(r *http.Request) *RegisterForm {
|
||||||
|
return &RegisterForm{
|
||||||
|
Username: r.FormValue("name"),
|
||||||
|
Password: r.FormValue("password"),
|
||||||
|
Confirm: r.FormValue("password-confirm"),
|
||||||
|
Email: r.FormValue("email"),
|
||||||
|
ShowEmail: r.FormValue("show-email") == "1",
|
||||||
|
Answer: r.FormValue("answer"),
|
||||||
|
Key: r.FormValue("key"),
|
||||||
|
}
|
||||||
|
}
|
||||||
30
web/handler/form/settings.go
Normal file
30
web/handler/form/settings.go
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
package form
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type SettingsForm struct {
|
||||||
|
Homepage string
|
||||||
|
About string
|
||||||
|
Picture string
|
||||||
|
Email string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *SettingsForm) Validate() error {
|
||||||
|
if strings.Contains(f.About, "<script") {
|
||||||
|
return errors.New("script tag is forbidden")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewSettingsForm(r *http.Request) *SettingsForm {
|
||||||
|
return &SettingsForm{
|
||||||
|
Homepage: r.FormValue("homepage"),
|
||||||
|
About: r.FormValue("about"),
|
||||||
|
Picture: r.FormValue("picture"),
|
||||||
|
Email: r.FormValue("email"),
|
||||||
|
}
|
||||||
|
}
|
||||||
23
web/handler/form/status.go
Normal file
23
web/handler/form/status.go
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
package form
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
type StatusForm struct {
|
||||||
|
Id int64
|
||||||
|
Content string
|
||||||
|
Face string
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewStatusForm(r *http.Request) *StatusForm {
|
||||||
|
var id int64
|
||||||
|
id, _ = strconv.ParseInt(r.FormValue("id"), 10, 64)
|
||||||
|
return &StatusForm{
|
||||||
|
Id: id,
|
||||||
|
Content: r.FormValue("content"),
|
||||||
|
Face: r.FormValue("face"),
|
||||||
|
}
|
||||||
|
}
|
||||||
100
web/handler/handler.go
Normal file
100
web/handler/handler.go
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"status/config"
|
||||||
|
"status/storage"
|
||||||
|
"status/vpub"
|
||||||
|
"status/web/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
func serverError(w http.ResponseWriter, err error) {
|
||||||
|
log.Println("[server error]", err)
|
||||||
|
http.Error(w, fmt.Sprintf("server error: %s", err), http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
func notFound(w http.ResponseWriter) {
|
||||||
|
http.Error(w, "Page Not Found", http.StatusNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func unauthorized(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/login"), http.StatusFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Handler struct {
|
||||||
|
cfg *config.Config
|
||||||
|
mux *mux.Router
|
||||||
|
storage *storage.Storage
|
||||||
|
sess *session.Session
|
||||||
|
vpub vpub.Vpub
|
||||||
|
}
|
||||||
|
|
||||||
|
func protectClickJacking(w http.ResponseWriter) {
|
||||||
|
w.Header().Set("X-Frame-Options", "DENY")
|
||||||
|
w.Header().Set("Content-Security-Policy", "frame-ancestors 'none'")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) getUser(r *http.Request) (string, error) {
|
||||||
|
user, err := h.sess.Get(r)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return user, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(cfg *config.Config, sess *session.Session, data *storage.Storage, v vpub.Vpub) (http.Handler, error) {
|
||||||
|
router := mux.NewRouter()
|
||||||
|
h := &Handler{
|
||||||
|
cfg: cfg,
|
||||||
|
mux: router,
|
||||||
|
storage: data,
|
||||||
|
sess: sess,
|
||||||
|
vpub: v,
|
||||||
|
}
|
||||||
|
h.initTpl()
|
||||||
|
|
||||||
|
router.HandleFunc("/", h.showIndexView).Methods(http.MethodGet)
|
||||||
|
router.HandleFunc("/login", h.showLoginView).Methods(http.MethodGet)
|
||||||
|
router.HandleFunc("/check-login", h.checkLogin).Methods(http.MethodPost)
|
||||||
|
router.HandleFunc("/register", h.handleRegister)
|
||||||
|
router.HandleFunc("/logout", h.logout).Methods(http.MethodGet)
|
||||||
|
router.HandleFunc("/feed.atom", h.showFeedView).Methods(http.MethodGet)
|
||||||
|
|
||||||
|
router.HandleFunc("/admin", h.showAdminView).Methods(http.MethodGet)
|
||||||
|
router.HandleFunc("/activate-user", h.activateUser).Methods(http.MethodGet)
|
||||||
|
router.HandleFunc("/delete-user", h.deleteUser).Methods(http.MethodGet)
|
||||||
|
|
||||||
|
router.HandleFunc("/settings", h.showSettingsView).Methods(http.MethodGet)
|
||||||
|
router.HandleFunc("/settings-update", h.updateSettings).Methods(http.MethodPost)
|
||||||
|
router.HandleFunc("/forum-key", h.showKeyView).Methods(http.MethodGet)
|
||||||
|
|
||||||
|
router.HandleFunc("/add", h.showNewStatusView).Methods(http.MethodGet)
|
||||||
|
router.HandleFunc("/add", h.saveStatus).Methods(http.MethodPost)
|
||||||
|
router.HandleFunc("/remove", h.handleRemoveStatus)
|
||||||
|
router.HandleFunc("/edit", h.showEditStatusView).Methods(http.MethodGet)
|
||||||
|
router.HandleFunc("/edit", h.updateStatus).Methods(http.MethodPost)
|
||||||
|
router.HandleFunc("/current-status.js", h.showCurrentStatusJSView).Methods(http.MethodGet)
|
||||||
|
router.HandleFunc("/current-status", h.showCurrentStatusView).Methods(http.MethodGet)
|
||||||
|
router.HandleFunc("/manage", h.showManageView).Methods(http.MethodGet)
|
||||||
|
router.HandleFunc("/tos", h.showTOSView).Methods(http.MethodGet)
|
||||||
|
router.HandleFunc("/about/status-updater", h.showStatusUpdaterView).Methods(http.MethodGet)
|
||||||
|
router.HandleFunc("/users/{user}.atom", h.showAtomView).Methods(http.MethodGet)
|
||||||
|
router.HandleFunc("/users/{user}", h.showUserView).Methods(http.MethodGet)
|
||||||
|
router.HandleFunc("/users/{user}/status", h.showUserStatusView).Methods(http.MethodGet)
|
||||||
|
router.HandleFunc("/users/{user}/status.json", h.showUserStatusJSONView).Methods(http.MethodGet)
|
||||||
|
router.HandleFunc("/statuses/{id}", h.showStatusView).Methods(http.MethodGet)
|
||||||
|
|
||||||
|
router.HandleFunc("/users/{user}/badge.png", h.showUserStatusImageViewEmoji).Methods(http.MethodGet)
|
||||||
|
router.PathPrefix("/assets/").Handler(
|
||||||
|
http.StripPrefix("/assets/",
|
||||||
|
http.FileServer(
|
||||||
|
http.Dir(cfg.AssetsDir),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
return router, nil
|
||||||
|
}
|
||||||
422
web/handler/html.go
Normal file
422
web/handler/html.go
Normal file
|
|
@ -0,0 +1,422 @@
|
||||||
|
// Code generated by go generate; DO NOT EDIT.
|
||||||
|
|
||||||
|
package handler
|
||||||
|
|
||||||
|
var TplMap = map[string]string{
|
||||||
|
"admin": `{{ define "content" }}
|
||||||
|
<section>
|
||||||
|
<h1>Admin</h1>
|
||||||
|
{{ range .inactive }}
|
||||||
|
<div>
|
||||||
|
<div><b>{{ .Name }}</b> ({{ .SignupEmail }}) <a href="/activate-user?name={{ .Name }}">Activate</a> | <a href="/delete-user?name={{ .Name }}">Delete</a></div>
|
||||||
|
<p>{{ .SignupMsg }}</p>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
</section>
|
||||||
|
{{ end }}`,
|
||||||
|
"confirm_remove_status": `{{ define "content" }}
|
||||||
|
<section>
|
||||||
|
Are you sure you you want to delete the following status?
|
||||||
|
<p>{{ .status.Content }}</p>
|
||||||
|
<form action="/remove?id={{ .status.Id }}" method="post">
|
||||||
|
{{ .csrfField }}
|
||||||
|
<input type="hidden" name="id"/>
|
||||||
|
<input type="submit" value="Submit">
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{{ end }}`,
|
||||||
|
"create_status": `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>status cafe</title>
|
||||||
|
<link rel="stylesheet" href="/assets/style.css"/>
|
||||||
|
<meta name="description" content="your friends' updates">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
background-color: azure;
|
||||||
|
font-family: Verdana;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
{{ if .form.Error }}
|
||||||
|
<p>{{ .form.Error }}</p>
|
||||||
|
{{ end }}
|
||||||
|
{{ if .flash }}
|
||||||
|
<p>{{ .flash }}</p>
|
||||||
|
{{ end }}
|
||||||
|
<form action="/add?silent=1" method="post" name="update-status">
|
||||||
|
{{ .csrfField }}
|
||||||
|
{{ template "status_form" .status }}
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
document.forms["update-status"].onsubmit = function (event) {
|
||||||
|
event.preventDefault();
|
||||||
|
fetch("/add?silent=1", {
|
||||||
|
method: "POST",
|
||||||
|
body: new FormData(document.forms["update-status"])
|
||||||
|
})
|
||||||
|
.then(response => response.ok)
|
||||||
|
.then(submitted => {
|
||||||
|
if (submitted) {
|
||||||
|
window.close()
|
||||||
|
window.location = "/"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`,
|
||||||
|
"current_status": `{{ define "content" }}
|
||||||
|
<style>
|
||||||
|
#minty {
|
||||||
|
padding: .5em;
|
||||||
|
background-color: azure;
|
||||||
|
border: 1px solid midnightblue;
|
||||||
|
}
|
||||||
|
|
||||||
|
#minty-username {
|
||||||
|
margin-bottom: .5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#minty-content {
|
||||||
|
margin: 0 1em 0.5em 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 650px) {
|
||||||
|
.cols {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
grid-gap: 2em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="cols">
|
||||||
|
<div>
|
||||||
|
<h1>Status widget</h1>
|
||||||
|
<form method="get" action="/current-status">
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td>name:</td>
|
||||||
|
<td><input type="text" name="name" id="name" value="{{ .name }}"/></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td></td>
|
||||||
|
<td><input type="submit" value="create HTML"/></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</form>
|
||||||
|
<p>Past this code into your HTML file:</p>
|
||||||
|
<textarea style="width: 100%; max-width: 500px; height: 75px;"><div id="minty"><div id="minty-username"></div><div id="minty-content"></div></div><script src="https://minty.anteater.monster/current-status.js?name={{ .name }}" defer></script></textarea>
|
||||||
|
<p>Past this code into your CSS file:</p>
|
||||||
|
<textarea style="width: 100%; max-width: 500px; height: 175px;">#minty {
|
||||||
|
padding: .5em;
|
||||||
|
background-color: #bdffe4;
|
||||||
|
border: 1px solid hotpink;
|
||||||
|
}
|
||||||
|
#minty-username {
|
||||||
|
margin-bottom: .5em;
|
||||||
|
}
|
||||||
|
#minty-content {
|
||||||
|
margin: 0 1em 0.5em 1em;
|
||||||
|
}</textarea>
|
||||||
|
<p>Make it your own! The CSS above is only an example. Tweak it so that it integrates well with your sites's colors.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2>Preview</h2>
|
||||||
|
{{ if .name }}
|
||||||
|
<script src="https://minty.anteater.monster/current-status.js?name={{ .name }}"></script>
|
||||||
|
{{ else }}
|
||||||
|
<p>Add your name and click "generate HTML" to see a preview.</p>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
`,
|
||||||
|
"edit_status": `{{ define "content" }}
|
||||||
|
<h1>Edit status</h1>
|
||||||
|
{{ if .form.Error }}
|
||||||
|
<p>{{ .form.Error }}</p>
|
||||||
|
{{ end }}
|
||||||
|
{{ template "flash" .flash }}
|
||||||
|
<form action="/edit?id={{ .status.Id }}" method="post">
|
||||||
|
{{ .csrfField }}
|
||||||
|
{{ template "status_form" .status }}
|
||||||
|
</form>
|
||||||
|
{{ end }}`,
|
||||||
|
"forum-key": `{{ define "content" }}
|
||||||
|
<h1>Forum key</h1>
|
||||||
|
|
||||||
|
<p>Your forum key is:</p>
|
||||||
|
<pre>{{ .key }}</pre>
|
||||||
|
{{ end }}`,
|
||||||
|
"index": `{{ define "head" }}
|
||||||
|
<link rel="alternate" type="application/atom+xml" title="Atom feed" href="/feed.atom" />
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ define "content" }}
|
||||||
|
<div class="cols">
|
||||||
|
<section>
|
||||||
|
{{ if .logged }}
|
||||||
|
<h2>Set your status</h2>
|
||||||
|
{{ if .form.Error }}
|
||||||
|
<p>{{ .form.Error }}</p>
|
||||||
|
{{ end }}
|
||||||
|
<form action="/add" method="post">
|
||||||
|
{{ .csrfField }}
|
||||||
|
{{ template "status_form" .status }}
|
||||||
|
</form>
|
||||||
|
<p>
|
||||||
|
<a href="/about/status-updater">status updater</a> bookmarklet<br>
|
||||||
|
<a href="/current-status">status widget</a> for your homepage THESE WILL HAVE BUTTONS SOON I PROMMY
|
||||||
|
</p>
|
||||||
|
<p><img src="/users/{{ .logged }}/badge.png"/><br><textarea style="width: 100%; max-width: 500px; box-sizing: border-box;"><a href="https://minty.anteater.monster/users/{{ .logged }}"><img src="https://minty.anteater.monster/users/{{ .logged }}/badge.png" alt="Minty Cafe Profile"/></a></textarea></p>
|
||||||
|
{{ else }}
|
||||||
|
<h2>Welcome!</h2>
|
||||||
|
<p>minty is a place to share your current status.</p>
|
||||||
|
<p><a href="/register">Register now!</a></p>
|
||||||
|
{{ end }}
|
||||||
|
<p><img src="https://file.garden/aJzQmzrHVB4BLKwu/button(1).png"/><br><textarea style="width: 100%; max-width: 500px; box-sizing: border-box;"><a href="https://minty.anteater.monster"><img src="https://file.garden/aJzQmzrHVB4BLKwu/button(1).png" alt="Status Cafe"/></a></textarea></p></p>
|
||||||
|
<p><a href="/feed.atom">Subscribe via Atom</a></p>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h2>Status stream</h2>
|
||||||
|
{{ range .statuses }}
|
||||||
|
<article class="status">
|
||||||
|
{{ template "status" . }}
|
||||||
|
</article>
|
||||||
|
{{ end }}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
`,
|
||||||
|
"login": `{{ define "content" }}
|
||||||
|
<section>
|
||||||
|
<h1>Login</h1>
|
||||||
|
{{ if .form.Error }}
|
||||||
|
<p>{{ .form.Error }}</p>
|
||||||
|
{{ end }}
|
||||||
|
<form action="/check-login" method="post" class="auth-form">
|
||||||
|
{{ .csrfField }}
|
||||||
|
<div class="field">
|
||||||
|
<label for="name">Username</label>
|
||||||
|
<input type="text" id="name" name="name" autocomplete="off" required autofocus/>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input type="password" id="password" name="password" required/>
|
||||||
|
</div>
|
||||||
|
<input type="submit" value="Submit">
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{{ end }}`,
|
||||||
|
"manage": `{{ define "content" }}
|
||||||
|
<h1>Manage statuses</h1>
|
||||||
|
{{ template "flash" .flash }}
|
||||||
|
{{ range .statuses }}
|
||||||
|
<article class="status">
|
||||||
|
{{ template "status" . }}
|
||||||
|
{{ if eq $.logged .User }}
|
||||||
|
<nav><a href="/edit?id={{ .Id }}">Edit</a> <a href="/remove?id={{ .Id }}">Delete</a></nav>
|
||||||
|
{{ end }}
|
||||||
|
</article>
|
||||||
|
{{ end }}
|
||||||
|
{{ if or .showMore (ne 0 .page) }}
|
||||||
|
<p>
|
||||||
|
{{ if ne 0 .page }}
|
||||||
|
{{ if eq 0 .prev_page }}
|
||||||
|
<a href="manage">Newer statuses</a>
|
||||||
|
{{ else }}
|
||||||
|
<a href="manage?page={{ .prev_page }}">Newer statuses</a>
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
|
{{ if .showMore }}
|
||||||
|
<a href="manage?page={{ .next_page }}">Older statuses</a>
|
||||||
|
{{- end }}
|
||||||
|
</p>
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}`,
|
||||||
|
"register": `{{ define "content" }}
|
||||||
|
<section>
|
||||||
|
<h1>Register</h1>
|
||||||
|
{{ if .form.Error }}
|
||||||
|
<p>{{ .form.Error }}</p>
|
||||||
|
{{ end }}
|
||||||
|
<form action="/register" method="post" class="auth-form">
|
||||||
|
{{ .csrfField }}
|
||||||
|
<div class="field">
|
||||||
|
<label for="name">Username</label>
|
||||||
|
<input type="text" id="name" name="name" autocomplete="off" required maxlength="20" autofocus/>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="email">Email</label>
|
||||||
|
<input type="email" id="email" name="email" autocomplete="off" required/>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="show-email">Show e-mail</label>
|
||||||
|
<input type="checkbox" name="show-email" value="1" id="show-email" style="width: inherit;">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input type="password" id="password" name="password" required/>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="password-confirm">Confirm password</label>
|
||||||
|
<input type="password" id="password-confirm" name="password-confirm" required/>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="answer">How did you discover status.cafe?</label>
|
||||||
|
<textarea id="answer" name="answer" required></textarea>
|
||||||
|
</div>
|
||||||
|
<p>By clicking the following button you agree to our <a href="/tos" target="_blank">Terms of Service</a>.</p>
|
||||||
|
<input type="submit" value="Submit">
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{{ end }}`,
|
||||||
|
"register-success": `{{ define "content" }}
|
||||||
|
<section>
|
||||||
|
<h1>Thank you!</h1>
|
||||||
|
<p>Thanks for registering, {{ .name }}!</p>
|
||||||
|
<p>You should receive a confirmation email on {{ .email }} as soon as your account is activated.</p>
|
||||||
|
</section>
|
||||||
|
{{ end }}`,
|
||||||
|
"settings": `{{ define "content" }}
|
||||||
|
<h1>Settings</h1>
|
||||||
|
{{ if .flash }}
|
||||||
|
<p>{{ .flash }}</p>
|
||||||
|
{{ end }}
|
||||||
|
<p><a href="/manage">Manage statuses</a></p>
|
||||||
|
<form action="/settings-update" method="post">
|
||||||
|
{{ .csrfField }}
|
||||||
|
<div class="field">
|
||||||
|
<label for="homepage">Homepage</label>
|
||||||
|
<input type="text" name="homepage" id="homepage" value="{{ .User.Homepage }}" autocomplete="off"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="email">Email</label>
|
||||||
|
<input type="text" name="email" id="email" value="{{ .User.Email }}" autocomplete="off"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="picture">Picture URL</label>
|
||||||
|
<input type="text" name="picture" id="picture" value="{{ .User.Picture }}" autocomplete="off"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="about">About (accepts HTML, including a style tag)</label>
|
||||||
|
<textarea name="about" id="about" rows="20">{{ .User.About }}</textarea>
|
||||||
|
</div>
|
||||||
|
<input type="submit" value="Submit">
|
||||||
|
</form>
|
||||||
|
{{ end }}`,
|
||||||
|
"status": `{{ define "content" }}
|
||||||
|
<section>
|
||||||
|
<h1>Status</h1>
|
||||||
|
{{ template "status" .status }}
|
||||||
|
</section>
|
||||||
|
{{ end }}`,
|
||||||
|
"status-updater": `{{ define "content" }}
|
||||||
|
<section>
|
||||||
|
<h1>Status Updater</h1>
|
||||||
|
<p>
|
||||||
|
Instead of having to come back each time you want to set a new status, you can install the
|
||||||
|
status updater bookmarklet directly to your web browser. That way, you will be able to update your status
|
||||||
|
from anywhere.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Curious about what a bookmarklet is? It's simply a little javascript link that's placed on your browser's bookmark toolbar.
|
||||||
|
You can think about them as tiny programs.
|
||||||
|
</p>
|
||||||
|
<h2>Instructions</h2>
|
||||||
|
<p>
|
||||||
|
Drag the following link to your bookmarks toolbar:
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<a href="javascript:void(open('https://minty.anteater.monster/add','minty.anteater.monster','resizable,scrollbars,width=350,height=350'))">status updater</a>
|
||||||
|
</p>
|
||||||
|
<p>That's it! From now on, whenever you want to update your status, click the status updater button from your bookmarks and a pop-up window will launch to let you update it.</p>
|
||||||
|
</section>
|
||||||
|
{{ end }}
|
||||||
|
`,
|
||||||
|
"tos": `{{ define "content" }}
|
||||||
|
<section>
|
||||||
|
<h1>Terms of service</h1>
|
||||||
|
<p>In order to use Status Cafe, you must agree to the following rules. A user not respecting these rules will have their account removed and will be banned from the service. The general rule is to be nice, friendly and respectful to anyone and their status.</p>
|
||||||
|
<p><b>Racist, bigoted or otherwise hate speech</b> is not permitted. Status Cafe is an inclusive place that will not tolerate anyone promoting hateful ideas and language.</p>
|
||||||
|
<p><b>Illegal activities</b> such as promoting malware, phishing or publishing something that promotes content that infringes copyright, patent or trademark you do not own is not permitted.</p>
|
||||||
|
<p><b>Pornographic content</b> is not allowed.</p>
|
||||||
|
<p><b>Spamming</b>, including unsolicited advertising isn't allowed. While it's perfectly fine to talk about your projects and link them, using Status Cafe only as a way to drive traffic to an external site isn't allowed.</p>
|
||||||
|
<p><b>Harassing</b>, bullying, picking on a user isn't permitted.</p>
|
||||||
|
<p><b>Revealing information (doxing)</b> from a user isn't allowed.</p>
|
||||||
|
</section>
|
||||||
|
{{ end }}`,
|
||||||
|
"user": `{{ define "head" }}
|
||||||
|
<link rel="alternate" type="application/atom+xml" title="Atom feed" href="/users/{{ .user }}.atom" />
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ define "title" }}{{ .user }} - {{ end }}
|
||||||
|
|
||||||
|
{{ define "content" }}
|
||||||
|
<div class="cols">
|
||||||
|
<section>
|
||||||
|
<h2>{{ .user }}</h2>
|
||||||
|
{{ if .picture }}
|
||||||
|
<img src="{{ .picture }}" class="profile-picture"/>
|
||||||
|
{{ end }}
|
||||||
|
<p><a href="/users/{{ .user }}.atom">Subscribe via Atom</a></p>
|
||||||
|
<dl>
|
||||||
|
<dt class="homepage">Homepage</dt>
|
||||||
|
<dd class="homepage">
|
||||||
|
{{ if .homepage }}
|
||||||
|
<a href="{{ .homepage }}" target="_blank">{{ .homepage }}</a></dd>
|
||||||
|
{{ else }}
|
||||||
|
Not defined
|
||||||
|
{{ end }}
|
||||||
|
<dt class="email">Email</dt>
|
||||||
|
<dd class="email">
|
||||||
|
{{ if .email }}
|
||||||
|
<a href="mailto:{{ .email }}" target="_blank">{{ .email }}</a></dd>
|
||||||
|
{{ else }}
|
||||||
|
Not defined
|
||||||
|
{{ end }}
|
||||||
|
<dt class="about">About</dt>
|
||||||
|
<dd class="about">
|
||||||
|
{{ if .about }}
|
||||||
|
{{ .about }}
|
||||||
|
{{ else }}
|
||||||
|
Not defined
|
||||||
|
{{ end }}
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h2>Statuses</h2>
|
||||||
|
{{ range .statuses }}
|
||||||
|
<article class="status">
|
||||||
|
{{ template "status" . }}
|
||||||
|
</article>
|
||||||
|
{{ end }}
|
||||||
|
{{ if or .showMore (ne 0 .page) }}
|
||||||
|
<p>
|
||||||
|
{{ if ne 0 .page }}
|
||||||
|
{{ if eq 0 .prev_page }}
|
||||||
|
<a href="{{ .user }}">Newer statuses</a>
|
||||||
|
{{ else }}
|
||||||
|
<a href="{{ .user }}?page={{ .prev_page }}">Newer statuses</a>
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
|
{{ if .showMore }}
|
||||||
|
<a href="{{ .user }}?page={{ .next_page }}">Older statuses</a>
|
||||||
|
{{- end }}
|
||||||
|
</p>
|
||||||
|
{{ end }}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{{ end }}`,
|
||||||
|
}
|
||||||
11
web/handler/html/admin.html
Normal file
11
web/handler/html/admin.html
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
{{ define "content" }}
|
||||||
|
<section>
|
||||||
|
<h1>Admin</h1>
|
||||||
|
{{ range .inactive }}
|
||||||
|
<div>
|
||||||
|
<div><b>{{ .Name }}</b> ({{ .SignupEmail }}) <a href="/activate-user?name={{ .Name }}">Activate</a> | <a href="/delete-user?name={{ .Name }}">Delete</a></div>
|
||||||
|
<p>{{ .SignupMsg }}</p>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
</section>
|
||||||
|
{{ end }}
|
||||||
5
web/handler/html/common/flash.html
Normal file
5
web/handler/html/common/flash.html
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
{{ define "flash" }}
|
||||||
|
{{ if . }}
|
||||||
|
<p class="flash">{{ . }}</p>
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
38
web/handler/html/common/layout.html
Normal file
38
web/handler/html/common/layout.html
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
{{ define "layout" }}
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{ template "title" . }}Minty Cafe</title>
|
||||||
|
<meta name="description" content="your friends' updates">
|
||||||
|
<link rel="stylesheet" href="/assets/style.css"/>
|
||||||
|
{{ if .face }}
|
||||||
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>{{ .face }}</text></svg>">
|
||||||
|
{{ else }}
|
||||||
|
<link rel="icon" href="/assets/favicon.ico">
|
||||||
|
{{ end }}
|
||||||
|
{{ template "head" . }}
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<nav>
|
||||||
|
<a href="/">minty cafe</a>
|
||||||
|
{{ if .logged }}
|
||||||
|
<a href="/settings">settings</a> <a href="/users/{{ .logged }}">{{ .logged }}</a> (<a href="/logout">logout</a>)
|
||||||
|
{{ else }}
|
||||||
|
<a href="/register">register</a> <a href="/login">login</a>
|
||||||
|
{{ end }}</nav>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
{{ template "content" . }}
|
||||||
|
</main>
|
||||||
|
<footer>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
{{ end }}
|
||||||
|
{{ define "head" }}{{ end }}
|
||||||
|
{{ define "title" }}{{ end }}
|
||||||
4
web/handler/html/common/status.html
Normal file
4
web/handler/html/common/status.html
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
{{ define "status" }}
|
||||||
|
<div class="status-username"><a href="/users/{{ .User }}">{{ .User }}</a> {{ .Face }} {{ .TimeAgo }}</div>
|
||||||
|
<p class="status-content">{{ .ContentHtml }}</p>
|
||||||
|
{{ end }}
|
||||||
23
web/handler/html/common/status_form.html
Normal file
23
web/handler/html/common/status_form.html
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
{{ define "status_form" }}
|
||||||
|
<div class="faces">
|
||||||
|
{{ range $i, $v := faces }}
|
||||||
|
<div class="radio">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
id="face{{ $i }}"
|
||||||
|
name="face"
|
||||||
|
value="{{ $v }}"
|
||||||
|
{{ if eq $i 0 }}
|
||||||
|
{{ if or (eq $.Face $v) (eq $.Face "") }}checked{{ end }}
|
||||||
|
{{ else }}
|
||||||
|
{{ if eq $.Face $v }}checked{{ end }}
|
||||||
|
{{ end }}>
|
||||||
|
<label for="face{{ $i }}">{{ $v }}</label>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<textarea class="edit-status" name="content" maxlength="140" placeholder="What's new?" required autofocus>{{ .Content }}</textarea>
|
||||||
|
</div>
|
||||||
|
<input type="submit" value="Submit">
|
||||||
|
{{ end }}
|
||||||
11
web/handler/html/confirm_remove_status.html
Normal file
11
web/handler/html/confirm_remove_status.html
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
{{ define "content" }}
|
||||||
|
<section>
|
||||||
|
Are you sure you you want to delete the following status?
|
||||||
|
<p>{{ .status.Content }}</p>
|
||||||
|
<form action="/remove?id={{ .status.Id }}" method="post">
|
||||||
|
{{ .csrfField }}
|
||||||
|
<input type="hidden" name="id"/>
|
||||||
|
<input type="submit" value="Submit">
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{{ end }}
|
||||||
47
web/handler/html/create_status.html
Normal file
47
web/handler/html/create_status.html
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>status cafe</title>
|
||||||
|
<link rel="stylesheet" href="/assets/style.css"/>
|
||||||
|
<meta name="description" content="your friends' updates">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
background-color: azure;
|
||||||
|
font-family: Verdana;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
{{ if .form.Error }}
|
||||||
|
<p>{{ .form.Error }}</p>
|
||||||
|
{{ end }}
|
||||||
|
{{ if .flash }}
|
||||||
|
<p>{{ .flash }}</p>
|
||||||
|
{{ end }}
|
||||||
|
<form action="/add?silent=1" method="post" name="update-status">
|
||||||
|
{{ .csrfField }}
|
||||||
|
{{ template "status_form" .status }}
|
||||||
|
</form>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
document.forms["update-status"].onsubmit = function (event) {
|
||||||
|
event.preventDefault();
|
||||||
|
fetch("/add?silent=1", {
|
||||||
|
method: "POST",
|
||||||
|
body: new FormData(document.forms["update-status"])
|
||||||
|
})
|
||||||
|
.then(response => response.ok)
|
||||||
|
.then(submitted => {
|
||||||
|
if (submitted) {
|
||||||
|
window.close()
|
||||||
|
window.location = "/"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
65
web/handler/html/current_status.html
Normal file
65
web/handler/html/current_status.html
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
{{ define "content" }}
|
||||||
|
<style>
|
||||||
|
#minty {
|
||||||
|
padding: .5em;
|
||||||
|
background-color: azure;
|
||||||
|
border: 1px solid midnightblue;
|
||||||
|
}
|
||||||
|
|
||||||
|
#minty-username {
|
||||||
|
margin-bottom: .5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
#minty-content {
|
||||||
|
margin: 0 1em 0.5em 1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 650px) {
|
||||||
|
.cols {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
grid-gap: 2em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="cols">
|
||||||
|
<div>
|
||||||
|
<h1>Status widget</h1>
|
||||||
|
<form method="get" action="/current-status">
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td>name:</td>
|
||||||
|
<td><input type="text" name="name" id="name" value="{{ .name }}"/></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td></td>
|
||||||
|
<td><input type="submit" value="create HTML"/></td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</form>
|
||||||
|
<p>Past this code into your HTML file:</p>
|
||||||
|
<textarea style="width: 100%; max-width: 500px; height: 75px;"><div id="minty"><div id="minty-username"></div><div id="minty-content"></div></div><script src="https://minty.anteater.monster/current-status.js?name={{ .name }}" defer></script></textarea>
|
||||||
|
<p>Past this code into your CSS file:</p>
|
||||||
|
<textarea style="width: 100%; max-width: 500px; height: 175px;">#minty {
|
||||||
|
padding: .5em;
|
||||||
|
background-color: #bdffe4;
|
||||||
|
border: 1px solid hotpink;
|
||||||
|
}
|
||||||
|
#minty-username {
|
||||||
|
margin-bottom: .5em;
|
||||||
|
}
|
||||||
|
#minty-content {
|
||||||
|
margin: 0 1em 0.5em 1em;
|
||||||
|
}</textarea>
|
||||||
|
<p>Make it your own! The CSS above is only an example. Tweak it so that it integrates well with your sites's colors.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2>Preview</h2>
|
||||||
|
{{ if .name }}
|
||||||
|
<script src="https://minty.anteater.monster/current-status.js?name={{ .name }}"></script>
|
||||||
|
{{ else }}
|
||||||
|
<p>Add your name and click "generate HTML" to see a preview.</p>
|
||||||
|
{{ end }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
11
web/handler/html/edit_status.html
Normal file
11
web/handler/html/edit_status.html
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
{{ define "content" }}
|
||||||
|
<h1>Edit status</h1>
|
||||||
|
{{ if .form.Error }}
|
||||||
|
<p>{{ .form.Error }}</p>
|
||||||
|
{{ end }}
|
||||||
|
{{ template "flash" .flash }}
|
||||||
|
<form action="/edit?id={{ .status.Id }}" method="post">
|
||||||
|
{{ .csrfField }}
|
||||||
|
{{ template "status_form" .status }}
|
||||||
|
</form>
|
||||||
|
{{ end }}
|
||||||
6
web/handler/html/forum-key.html
Normal file
6
web/handler/html/forum-key.html
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{{ define "content" }}
|
||||||
|
<h1>Forum key</h1>
|
||||||
|
|
||||||
|
<p>Your forum key is:</p>
|
||||||
|
<pre>{{ .key }}</pre>
|
||||||
|
{{ end }}
|
||||||
39
web/handler/html/index.html
Normal file
39
web/handler/html/index.html
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
{{ define "head" }}
|
||||||
|
<link rel="alternate" type="application/atom+xml" title="Atom feed" href="/feed.atom" />
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ define "content" }}
|
||||||
|
<div class="cols">
|
||||||
|
<section>
|
||||||
|
{{ if .logged }}
|
||||||
|
<h2>Set your status</h2>
|
||||||
|
{{ if .form.Error }}
|
||||||
|
<p>{{ .form.Error }}</p>
|
||||||
|
{{ end }}
|
||||||
|
<form action="/add" method="post">
|
||||||
|
{{ .csrfField }}
|
||||||
|
{{ template "status_form" .status }}
|
||||||
|
</form>
|
||||||
|
<p>
|
||||||
|
<a href="/about/status-updater">status updater</a> bookmarklet<br>
|
||||||
|
<a href="/current-status">status widget</a> for your homepage THESE WILL HAVE BUTTONS SOON I PROMMY
|
||||||
|
</p>
|
||||||
|
<p><img src="/users/{{ .logged }}/badge.png"/><br><textarea style="width: 100%; max-width: 500px; box-sizing: border-box;"><a href="https://minty.anteater.monster/users/{{ .logged }}"><img src="https://minty.anteater.monster/users/{{ .logged }}/badge.png" alt="Minty Cafe Profile"/></a></textarea></p>
|
||||||
|
{{ else }}
|
||||||
|
<h2>Welcome!</h2>
|
||||||
|
<p>minty is a place to share your current status.</p>
|
||||||
|
<p><a href="/register">Register now!</a></p>
|
||||||
|
{{ end }}
|
||||||
|
<p><img src="https://file.garden/aJzQmzrHVB4BLKwu/button(1).png"/><br><textarea style="width: 100%; max-width: 500px; box-sizing: border-box;"><a href="https://minty.anteater.monster"><img src="https://file.garden/aJzQmzrHVB4BLKwu/button(1).png" alt="Status Cafe"/></a></textarea></p></p>
|
||||||
|
<p><a href="/feed.atom">Subscribe via Atom</a></p>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h2>Status stream</h2>
|
||||||
|
{{ range .statuses }}
|
||||||
|
<article class="status">
|
||||||
|
{{ template "status" . }}
|
||||||
|
</article>
|
||||||
|
{{ end }}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
20
web/handler/html/login.html
Normal file
20
web/handler/html/login.html
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
{{ define "content" }}
|
||||||
|
<section>
|
||||||
|
<h1>Login</h1>
|
||||||
|
{{ if .form.Error }}
|
||||||
|
<p>{{ .form.Error }}</p>
|
||||||
|
{{ end }}
|
||||||
|
<form action="/check-login" method="post" class="auth-form">
|
||||||
|
{{ .csrfField }}
|
||||||
|
<div class="field">
|
||||||
|
<label for="name">Username</label>
|
||||||
|
<input type="text" id="name" name="name" autocomplete="off" required autofocus/>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input type="password" id="password" name="password" required/>
|
||||||
|
</div>
|
||||||
|
<input type="submit" value="Submit">
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{{ end }}
|
||||||
26
web/handler/html/manage.html
Normal file
26
web/handler/html/manage.html
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
{{ define "content" }}
|
||||||
|
<h1>Manage statuses</h1>
|
||||||
|
{{ template "flash" .flash }}
|
||||||
|
{{ range .statuses }}
|
||||||
|
<article class="status">
|
||||||
|
{{ template "status" . }}
|
||||||
|
{{ if eq $.logged .User }}
|
||||||
|
<nav><a href="/edit?id={{ .Id }}">Edit</a> <a href="/remove?id={{ .Id }}">Delete</a></nav>
|
||||||
|
{{ end }}
|
||||||
|
</article>
|
||||||
|
{{ end }}
|
||||||
|
{{ if or .showMore (ne 0 .page) }}
|
||||||
|
<p>
|
||||||
|
{{ if ne 0 .page }}
|
||||||
|
{{ if eq 0 .prev_page }}
|
||||||
|
<a href="manage">Newer statuses</a>
|
||||||
|
{{ else }}
|
||||||
|
<a href="manage?page={{ .prev_page }}">Newer statuses</a>
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
|
{{ if .showMore }}
|
||||||
|
<a href="manage?page={{ .next_page }}">Older statuses</a>
|
||||||
|
{{- end }}
|
||||||
|
</p>
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
7
web/handler/html/register-success.html
Normal file
7
web/handler/html/register-success.html
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
{{ define "content" }}
|
||||||
|
<section>
|
||||||
|
<h1>Thank you!</h1>
|
||||||
|
<p>Thanks for registering, {{ .name }}!</p>
|
||||||
|
<p>You should receive a confirmation email on {{ .email }} as soon as your account is activated.</p>
|
||||||
|
</section>
|
||||||
|
{{ end }}
|
||||||
37
web/handler/html/register.html
Normal file
37
web/handler/html/register.html
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
{{ define "content" }}
|
||||||
|
<section>
|
||||||
|
<h1>Register</h1>
|
||||||
|
{{ if .form.Error }}
|
||||||
|
<p>{{ .form.Error }}</p>
|
||||||
|
{{ end }}
|
||||||
|
<form action="/register" method="post" class="auth-form">
|
||||||
|
{{ .csrfField }}
|
||||||
|
<div class="field">
|
||||||
|
<label for="name">Username</label>
|
||||||
|
<input type="text" id="name" name="name" autocomplete="off" required maxlength="20" autofocus/>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="email">Email</label>
|
||||||
|
<input type="email" id="email" name="email" autocomplete="off" required/>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="show-email">Show e-mail</label>
|
||||||
|
<input type="checkbox" name="show-email" value="1" id="show-email" style="width: inherit;">
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="password">Password</label>
|
||||||
|
<input type="password" id="password" name="password" required/>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="password-confirm">Confirm password</label>
|
||||||
|
<input type="password" id="password-confirm" name="password-confirm" required/>
|
||||||
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="answer">How did you discover status.cafe?</label>
|
||||||
|
<textarea id="answer" name="answer" required></textarea>
|
||||||
|
</div>
|
||||||
|
<p>By clicking the following button you agree to our <a href="/tos" target="_blank">Terms of Service</a>.</p>
|
||||||
|
<input type="submit" value="Submit">
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{{ end }}
|
||||||
30
web/handler/html/settings.html
Normal file
30
web/handler/html/settings.html
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
{{ define "content" }}
|
||||||
|
<h1>Settings</h1>
|
||||||
|
{{ if .flash }}
|
||||||
|
<p>{{ .flash }}</p>
|
||||||
|
{{ end }}
|
||||||
|
<p><a href="/manage">Manage statuses</a></p>
|
||||||
|
<form action="/settings-update" method="post">
|
||||||
|
{{ .csrfField }}
|
||||||
|
<div class="field">
|
||||||
|
<label for="homepage">Homepage</label>
|
||||||
|
<input type="text" name="homepage" id="homepage" value="{{ .User.Homepage }}" autocomplete="off"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="email">Email</label>
|
||||||
|
<input type="text" name="email" id="email" value="{{ .User.Email }}" autocomplete="off"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="picture">Picture URL</label>
|
||||||
|
<input type="text" name="picture" id="picture" value="{{ .User.Picture }}" autocomplete="off"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="field">
|
||||||
|
<label for="about">About (accepts HTML, including a style tag)</label>
|
||||||
|
<textarea name="about" id="about" rows="20">{{ .User.About }}</textarea>
|
||||||
|
</div>
|
||||||
|
<input type="submit" value="Submit">
|
||||||
|
</form>
|
||||||
|
{{ end }}
|
||||||
22
web/handler/html/status-updater.html
Normal file
22
web/handler/html/status-updater.html
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
{{ define "content" }}
|
||||||
|
<section>
|
||||||
|
<h1>Status Updater</h1>
|
||||||
|
<p>
|
||||||
|
Instead of having to come back each time you want to set a new status, you can install the
|
||||||
|
status updater bookmarklet directly to your web browser. That way, you will be able to update your status
|
||||||
|
from anywhere.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Curious about what a bookmarklet is? It's simply a little javascript link that's placed on your browser's bookmark toolbar.
|
||||||
|
You can think about them as tiny programs.
|
||||||
|
</p>
|
||||||
|
<h2>Instructions</h2>
|
||||||
|
<p>
|
||||||
|
Drag the following link to your bookmarks toolbar:
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<a href="javascript:void(open('https://minty.anteater.monster/add','minty.anteater.monster','resizable,scrollbars,width=350,height=350'))">status updater</a>
|
||||||
|
</p>
|
||||||
|
<p>That's it! From now on, whenever you want to update your status, click the status updater button from your bookmarks and a pop-up window will launch to let you update it.</p>
|
||||||
|
</section>
|
||||||
|
{{ end }}
|
||||||
6
web/handler/html/status.html
Normal file
6
web/handler/html/status.html
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{{ define "content" }}
|
||||||
|
<section>
|
||||||
|
<h1>Status</h1>
|
||||||
|
{{ template "status" .status }}
|
||||||
|
</section>
|
||||||
|
{{ end }}
|
||||||
12
web/handler/html/tos.html
Normal file
12
web/handler/html/tos.html
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{{ define "content" }}
|
||||||
|
<section>
|
||||||
|
<h1>Terms of service</h1>
|
||||||
|
<p>In order to use Status Cafe, you must agree to the following rules. A user not respecting these rules will have their account removed and will be banned from the service. The general rule is to be nice, friendly and respectful to anyone and their status.</p>
|
||||||
|
<p><b>Racist, bigoted or otherwise hate speech</b> is not permitted. Status Cafe is an inclusive place that will not tolerate anyone promoting hateful ideas and language.</p>
|
||||||
|
<p><b>Illegal activities</b> such as promoting malware, phishing or publishing something that promotes content that infringes copyright, patent or trademark you do not own is not permitted.</p>
|
||||||
|
<p><b>Pornographic content</b> is not allowed.</p>
|
||||||
|
<p><b>Spamming</b>, including unsolicited advertising isn't allowed. While it's perfectly fine to talk about your projects and link them, using Status Cafe only as a way to drive traffic to an external site isn't allowed.</p>
|
||||||
|
<p><b>Harassing</b>, bullying, picking on a user isn't permitted.</p>
|
||||||
|
<p><b>Revealing information (doxing)</b> from a user isn't allowed.</p>
|
||||||
|
</section>
|
||||||
|
{{ end }}
|
||||||
63
web/handler/html/user.html
Normal file
63
web/handler/html/user.html
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
{{ define "head" }}
|
||||||
|
<link rel="alternate" type="application/atom+xml" title="Atom feed" href="/users/{{ .user }}.atom" />
|
||||||
|
{{ end }}
|
||||||
|
|
||||||
|
{{ define "title" }}{{ .user }} - {{ end }}
|
||||||
|
|
||||||
|
{{ define "content" }}
|
||||||
|
<div class="cols">
|
||||||
|
<section>
|
||||||
|
<h2>{{ .user }}</h2>
|
||||||
|
{{ if .picture }}
|
||||||
|
<img src="{{ .picture }}" class="profile-picture"/>
|
||||||
|
{{ end }}
|
||||||
|
<p><a href="/users/{{ .user }}.atom">Subscribe via Atom</a></p>
|
||||||
|
<dl>
|
||||||
|
<dt class="homepage">Homepage</dt>
|
||||||
|
<dd class="homepage">
|
||||||
|
{{ if .homepage }}
|
||||||
|
<a href="{{ .homepage }}" target="_blank">{{ .homepage }}</a></dd>
|
||||||
|
{{ else }}
|
||||||
|
Not defined
|
||||||
|
{{ end }}
|
||||||
|
<dt class="email">Email</dt>
|
||||||
|
<dd class="email">
|
||||||
|
{{ if .email }}
|
||||||
|
<a href="mailto:{{ .email }}" target="_blank">{{ .email }}</a></dd>
|
||||||
|
{{ else }}
|
||||||
|
Not defined
|
||||||
|
{{ end }}
|
||||||
|
<dt class="about">About</dt>
|
||||||
|
<dd class="about">
|
||||||
|
{{ if .about }}
|
||||||
|
{{ .about }}
|
||||||
|
{{ else }}
|
||||||
|
Not defined
|
||||||
|
{{ end }}
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h2>Statuses</h2>
|
||||||
|
{{ range .statuses }}
|
||||||
|
<article class="status">
|
||||||
|
{{ template "status" . }}
|
||||||
|
</article>
|
||||||
|
{{ end }}
|
||||||
|
{{ if or .showMore (ne 0 .page) }}
|
||||||
|
<p>
|
||||||
|
{{ if ne 0 .page }}
|
||||||
|
{{ if eq 0 .prev_page }}
|
||||||
|
<a href="{{ .user }}">Newer statuses</a>
|
||||||
|
{{ else }}
|
||||||
|
<a href="{{ .user }}?page={{ .prev_page }}">Newer statuses</a>
|
||||||
|
{{ end }}
|
||||||
|
{{ end }}
|
||||||
|
{{ if .showMore }}
|
||||||
|
<a href="{{ .user }}?page={{ .next_page }}">Older statuses</a>
|
||||||
|
{{- end }}
|
||||||
|
</p>
|
||||||
|
{{ end }}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
{{ end }}
|
||||||
38
web/handler/index_show.go
Normal file
38
web/handler/index_show.go
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gorilla/csrf"
|
||||||
|
"net/http"
|
||||||
|
"status/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Update struct {
|
||||||
|
UpdatedAgo string
|
||||||
|
Author string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) showIndexView(w http.ResponseWriter, r *http.Request) {
|
||||||
|
protectClickJacking(w)
|
||||||
|
user, _ := h.sess.Get(r)
|
||||||
|
statuses, err := h.storage.LatestStatuses()
|
||||||
|
if err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
session, err := h.sess.Store.Get(r, "status")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
flash := ""
|
||||||
|
if flashes := session.Flashes(); len(flashes) > 0 {
|
||||||
|
flash = flashes[0].(string)
|
||||||
|
}
|
||||||
|
session.Save(r, w)
|
||||||
|
h.renderLayout(w, "index", map[string]interface{}{
|
||||||
|
"statuses": statuses,
|
||||||
|
"flash": flash,
|
||||||
|
"status": &model.Status{},
|
||||||
|
csrf.TemplateTag: csrf.TemplateField(r),
|
||||||
|
}, user)
|
||||||
|
}
|
||||||
29
web/handler/login_check.go
Normal file
29
web/handler/login_check.go
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gorilla/csrf"
|
||||||
|
"net/http"
|
||||||
|
"status/model"
|
||||||
|
"status/web/handler/form"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *Handler) checkLogin(w http.ResponseWriter, r *http.Request) {
|
||||||
|
f := form.NewLoginForm(r)
|
||||||
|
user, err := h.storage.VerifyUser(model.User{
|
||||||
|
Name: f.Username,
|
||||||
|
Password: f.Password,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
f.Error = err.Error()
|
||||||
|
h.renderLayout(w, "login", map[string]interface{}{
|
||||||
|
"form": f,
|
||||||
|
csrf.TemplateTag: csrf.TemplateField(r),
|
||||||
|
}, "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.sess.Save(r, w, user.Name); err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/", http.StatusFound)
|
||||||
|
}
|
||||||
12
web/handler/login_show.go
Normal file
12
web/handler/login_show.go
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gorilla/csrf"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *Handler) showLoginView(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.renderLayout(w, "login", map[string]interface{}{
|
||||||
|
csrf.TemplateTag: csrf.TemplateField(r),
|
||||||
|
}, "")
|
||||||
|
}
|
||||||
11
web/handler/logout.go
Normal file
11
web/handler/logout.go
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
|
func (h *Handler) logout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if err := h.sess.Delete(w, r); err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/", http.StatusFound)
|
||||||
|
}
|
||||||
135
web/handler/register.go
Normal file
135
web/handler/register.go
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"github.com/gorilla/csrf"
|
||||||
|
"html/template"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"status/model"
|
||||||
|
"status/web/handler/form"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *Handler) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case "GET":
|
||||||
|
h.showRegisterView(w, r)
|
||||||
|
case "POST":
|
||||||
|
h.register(w, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) showRegisterView(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case "GET":
|
||||||
|
h.renderLayout(w, "register", map[string]interface{}{
|
||||||
|
csrf.TemplateTag: csrf.TemplateField(r),
|
||||||
|
}, "")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type keyStatus struct {
|
||||||
|
Success bool `json:"success"`
|
||||||
|
Uses int `json:"uses"`
|
||||||
|
}
|
||||||
|
|
||||||
|
//func verifyKey(key string) error {
|
||||||
|
// hc := http.Client{}
|
||||||
|
//
|
||||||
|
// f := url.Values{}
|
||||||
|
// f.Add("product_permalink", "YccHL")
|
||||||
|
// f.Add("license_key", key)
|
||||||
|
// req, err := http.NewRequest("POST", "https://api.gumroad.com/v2/licenses/verify", strings.NewReader(f.Encode()))
|
||||||
|
// if err != nil {
|
||||||
|
// return err
|
||||||
|
// }
|
||||||
|
// req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
//
|
||||||
|
// resp, err := hc.Do(req)
|
||||||
|
// if err != nil {
|
||||||
|
// return err
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// if resp.Body != nil {
|
||||||
|
// defer resp.Body.Close()
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// body, readErr := ioutil.ReadAll(resp.Body)
|
||||||
|
// if readErr != nil {
|
||||||
|
// return err
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// ks := keyStatus{}
|
||||||
|
// jsonErr := json.Unmarshal(body, &ks)
|
||||||
|
// if jsonErr != nil {
|
||||||
|
// return err
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// if ks.Success != true || ks.Uses > 1 {
|
||||||
|
// return errors.New("invalid license key")
|
||||||
|
// }
|
||||||
|
//
|
||||||
|
// return nil
|
||||||
|
//}
|
||||||
|
|
||||||
|
func buildIndex(t *template.Template, name string) []byte {
|
||||||
|
html := bytes.NewBufferString("")
|
||||||
|
err := t.Execute(html, name)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
return html.Bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) register(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case "POST":
|
||||||
|
f := form.NewRegisterForm(r)
|
||||||
|
showError := func(err error) {
|
||||||
|
f.Error = err.Error()
|
||||||
|
h.renderLayout(w, "register", map[string]interface{}{"form": *f, csrf.TemplateTag: csrf.TemplateField(r)}, "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := f.Validate(); err != nil {
|
||||||
|
showError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user := model.User{
|
||||||
|
Name: f.Username,
|
||||||
|
Password: f.Password,
|
||||||
|
SignupEmail: f.Email,
|
||||||
|
SignupMsg: f.Answer,
|
||||||
|
}
|
||||||
|
if f.ShowEmail {
|
||||||
|
user.Email = f.Email
|
||||||
|
}
|
||||||
|
if err := user.Validate(); err != nil {
|
||||||
|
showError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if h.storage.UserExists(user.Name) {
|
||||||
|
showError(errors.New("username already exists"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !h.cfg.ManualRegistration {
|
||||||
|
user.Active = true
|
||||||
|
}
|
||||||
|
if err := h.storage.CreateUser(user); err != nil {
|
||||||
|
showError(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if h.cfg.ManualRegistration {
|
||||||
|
h.renderLayout(w, "register-success", map[string]interface{}{
|
||||||
|
"name": user.Name,
|
||||||
|
"email": user.SignupEmail,
|
||||||
|
}, "")
|
||||||
|
} else {
|
||||||
|
if err := h.sess.Save(r, w, r.FormValue("name")); err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/", http.StatusFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
53
web/handler/settings_show.go
Normal file
53
web/handler/settings_show.go
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gorilla/csrf"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *Handler) showKeyView(w http.ResponseWriter, r *http.Request) {
|
||||||
|
protectClickJacking(w)
|
||||||
|
username, err := h.getUser(r)
|
||||||
|
if err != nil {
|
||||||
|
unauthorized(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, err := h.storage.UserByName(username)
|
||||||
|
if err != nil {
|
||||||
|
unauthorized(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
key := h.vpub.FindOrCreateKey(user.Name)
|
||||||
|
h.renderLayout(w, "forum-key", map[string]interface{}{
|
||||||
|
"key": key,
|
||||||
|
}, username)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) showSettingsView(w http.ResponseWriter, r *http.Request) {
|
||||||
|
protectClickJacking(w)
|
||||||
|
username, err := h.getUser(r)
|
||||||
|
if err != nil {
|
||||||
|
unauthorized(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
user, err := h.storage.UserByName(username)
|
||||||
|
if err != nil {
|
||||||
|
unauthorized(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
session, err := h.sess.Store.Get(r, "status")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
flash := ""
|
||||||
|
if flashes := session.Flashes(); len(flashes) > 0 {
|
||||||
|
flash = flashes[0].(string)
|
||||||
|
}
|
||||||
|
session.Save(r, w)
|
||||||
|
h.renderLayout(w, "settings", map[string]interface{}{
|
||||||
|
"flash": flash,
|
||||||
|
"User": user,
|
||||||
|
csrf.TemplateTag: csrf.TemplateField(r),
|
||||||
|
}, username)
|
||||||
|
}
|
||||||
32
web/handler/settings_update.go
Normal file
32
web/handler/settings_update.go
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"status/web/handler/form"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *Handler) updateSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, err := h.getUser(r)
|
||||||
|
if err != nil {
|
||||||
|
unauthorized(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f := form.NewSettingsForm(r)
|
||||||
|
if err := f.Validate(); err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.storage.UpdateSettings(user, f.Homepage, f.About, f.Picture, f.Email); err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
session, err := h.sess.Store.Get(r, "status")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
session.AddFlash("Settings updated!")
|
||||||
|
err = session.Save(r, w)
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/settings"), http.StatusFound)
|
||||||
|
}
|
||||||
17
web/handler/status_create.go
Normal file
17
web/handler/status_create.go
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gorilla/csrf"
|
||||||
|
"net/http"
|
||||||
|
"status/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *Handler) showNewStatusView(w http.ResponseWriter, r *http.Request) {
|
||||||
|
protectClickJacking(w)
|
||||||
|
_, err := h.getUser(r)
|
||||||
|
if err != nil {
|
||||||
|
unauthorized(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.view("create_status").Execute(w, map[string]interface{}{"status": &model.Status{}, csrf.TemplateTag: csrf.TemplateField(r)})
|
||||||
|
}
|
||||||
58
web/handler/status_edit.go
Normal file
58
web/handler/status_edit.go
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gorilla/csrf"
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
func RouteInt64Param(r *http.Request, param string) int64 {
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
value, err := strconv.ParseInt(vars[param], 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if value < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) showEditStatusView(w http.ResponseWriter, r *http.Request) {
|
||||||
|
protectClickJacking(w)
|
||||||
|
user, err := h.getUser(r)
|
||||||
|
if err != nil {
|
||||||
|
unauthorized(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := strconv.ParseInt(r.URL.Query().Get("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
status, err := h.storage.StatusById(id)
|
||||||
|
if err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if user != status.User {
|
||||||
|
unauthorized(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
session, err := h.sess.Store.Get(r, "status")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
flash := ""
|
||||||
|
if flashes := session.Flashes(); len(flashes) > 0 {
|
||||||
|
flash = flashes[0].(string)
|
||||||
|
}
|
||||||
|
session.Save(r, w)
|
||||||
|
h.renderLayout(w, "edit_status", map[string]interface{}{
|
||||||
|
"status": status,
|
||||||
|
"flash": flash,
|
||||||
|
csrf.TemplateTag: csrf.TemplateField(r),
|
||||||
|
}, user)
|
||||||
|
}
|
||||||
51
web/handler/status_remove.go
Normal file
51
web/handler/status_remove.go
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gorilla/csrf"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *Handler) handleRemoveStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
protectClickJacking(w)
|
||||||
|
user, err := h.getUser(r)
|
||||||
|
if err != nil {
|
||||||
|
unauthorized(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := strconv.ParseInt(r.URL.Query().Get("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
status, err := h.storage.StatusById(id)
|
||||||
|
if err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if user != status.User {
|
||||||
|
unauthorized(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch r.Method {
|
||||||
|
case "GET":
|
||||||
|
h.renderLayout(w, "confirm_remove_status", map[string]interface{}{
|
||||||
|
"status": status,
|
||||||
|
csrf.TemplateTag: csrf.TemplateField(r),
|
||||||
|
}, user)
|
||||||
|
case "POST":
|
||||||
|
err = h.storage.DeleteStatus(status.Id, user)
|
||||||
|
if err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
session, err := h.sess.Store.Get(r, "status")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
session.AddFlash("Status successfully removed!")
|
||||||
|
err = session.Save(r, w)
|
||||||
|
http.Redirect(w, r, "/manage", http.StatusFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
46
web/handler/status_save.go
Normal file
46
web/handler/status_save.go
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"status/model"
|
||||||
|
"status/web/handler/form"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *Handler) saveStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, err := h.getUser(r)
|
||||||
|
if err != nil {
|
||||||
|
unauthorized(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f := form.NewStatusForm(r)
|
||||||
|
status := model.Status{
|
||||||
|
User: user,
|
||||||
|
Content: f.Content,
|
||||||
|
Face: f.Face,
|
||||||
|
}
|
||||||
|
if err := status.Validate(); err != nil {
|
||||||
|
f.Error = err.Error()
|
||||||
|
h.renderLayout(w, "create_status", map[string]interface{}{
|
||||||
|
"form": f,
|
||||||
|
}, "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.storage.CreateStatus(status); err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.URL.Query().Get("silent") != "1" {
|
||||||
|
session, err := h.sess.Store.Get(r, "status")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
session.AddFlash("Status updated!")
|
||||||
|
err = session.Save(r, w)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, "/", http.StatusFound)
|
||||||
|
}
|
||||||
|
}
|
||||||
18
web/handler/status_show.go
Normal file
18
web/handler/status_show.go
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *Handler) showStatusView(w http.ResponseWriter, r *http.Request) {
|
||||||
|
protectClickJacking(w)
|
||||||
|
user, _ := h.sess.Get(r)
|
||||||
|
status, err := h.storage.StatusById(RouteInt64Param(r, "id"))
|
||||||
|
if err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.renderLayout(w, "status", map[string]interface{}{
|
||||||
|
"status": status,
|
||||||
|
}, user)
|
||||||
|
}
|
||||||
53
web/handler/status_update.go
Normal file
53
web/handler/status_update.go
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"status/web/handler/form"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *Handler) updateStatus(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, err := h.getUser(r)
|
||||||
|
if err != nil {
|
||||||
|
unauthorized(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, err := strconv.ParseInt(r.URL.Query().Get("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
status, err := h.storage.StatusById(id)
|
||||||
|
if err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if user != status.User {
|
||||||
|
unauthorized(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
f := form.NewStatusForm(r)
|
||||||
|
status.Content = f.Content
|
||||||
|
status.Face = f.Face
|
||||||
|
if err := status.Validate(); err != nil {
|
||||||
|
f.Error = err.Error()
|
||||||
|
h.renderLayout(w, "edit_post", map[string]interface{}{
|
||||||
|
"form": f,
|
||||||
|
}, "")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.storage.UpdateStatus(status); err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
session, err := h.sess.Store.Get(r, "status")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
session.AddFlash("Status edited!")
|
||||||
|
err = session.Save(r, w)
|
||||||
|
http.Redirect(w, r, fmt.Sprintf("/edit?id=%d", status.Id), http.StatusFound)
|
||||||
|
}
|
||||||
13
web/handler/tos_show.go
Normal file
13
web/handler/tos_show.go
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
|
func (h *Handler) showTOSView(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, _ := h.getUser(r)
|
||||||
|
h.renderLayout(w, "tos", nil, user)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) showStatusUpdaterView(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user, _ := h.getUser(r)
|
||||||
|
h.renderLayout(w, "status-updater", nil, user)
|
||||||
|
}
|
||||||
37
web/handler/tpl.go
Normal file
37
web/handler/tpl.go
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"html/template"
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
var views = make(map[string]*template.Template)
|
||||||
|
|
||||||
|
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{
|
||||||
|
"faces": func() []string {
|
||||||
|
return []string{"🙂", "😎", "😛", "🥰", "😂", "❤️", "👽", "😱", "😭", "🤔", "😶", "😯", "🤒", "😡", "🥺", "🥳", "🤖", "💀", "😴", "😭", "🤐", "💾", "👀", "☕", "🍺", "📖", "🔥", "❄️", "✨", "💡", "🎶", "✈️", "🚄", "🍿", "📰", "✏️", "🍱", "⛵", "🎁", "🌧️", "🌙", "🎨", "📺", "🍕", "✅", "🐶", "🐱", "🌱"}
|
||||||
|
}}).Parse(commonTemplates + content))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) renderLayout(w io.Writer, view string, params map[string]interface{}, user string) {
|
||||||
|
data := make(map[string]interface{})
|
||||||
|
if params != nil {
|
||||||
|
for k, v := range params {
|
||||||
|
data[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
data["logged"] = user
|
||||||
|
views[view].ExecuteTemplate(w, "layout", data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) view(view string) *template.Template {
|
||||||
|
return views[view]
|
||||||
|
}
|
||||||
356
web/handler/user_show.go
Normal file
356
web/handler/user_show.go
Normal file
|
|
@ -0,0 +1,356 @@
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"encoding/xml"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"github.com/golang/freetype/truetype"
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
"golang.org/x/image/draw"
|
||||||
|
"golang.org/x/image/font"
|
||||||
|
"golang.org/x/image/font/gofont/gobold"
|
||||||
|
_ "golang.org/x/image/font/gofont/gobold"
|
||||||
|
"golang.org/x/image/math/fixed"
|
||||||
|
"html/template"
|
||||||
|
"image"
|
||||||
|
"image/jpeg"
|
||||||
|
"image/png"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"status/model"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
//"image/png"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func exist(path string) bool {
|
||||||
|
_, err := os.Stat(path)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func getPath(r rune, emojipath string) (string, error) {
|
||||||
|
if r > 47 && r < 58 {
|
||||||
|
return "", errors.New("numeric char")
|
||||||
|
}
|
||||||
|
name := fmt.Sprintf("%.4x", r)
|
||||||
|
var path string
|
||||||
|
|
||||||
|
path = fmt.Sprintf("%s/emoji_u%s.png", emojipath, name)
|
||||||
|
|
||||||
|
if !exist(path) {
|
||||||
|
return "", fmt.Errorf("%s does NOT exist", path)
|
||||||
|
}
|
||||||
|
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadEmoji(r rune, size int, emojipath string) (image.Image, bool) {
|
||||||
|
var img image.Image
|
||||||
|
|
||||||
|
path, err := getPath(r, emojipath)
|
||||||
|
if err != nil {
|
||||||
|
//fmt.Fprintln(os.Stderr, err)
|
||||||
|
return img, false
|
||||||
|
}
|
||||||
|
|
||||||
|
fp, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
return img, false
|
||||||
|
}
|
||||||
|
defer fp.Close()
|
||||||
|
|
||||||
|
img, _, err = image.Decode(fp)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintln(os.Stderr, err)
|
||||||
|
return img, false
|
||||||
|
}
|
||||||
|
|
||||||
|
rect := image.Rect(0, 0, size, size)
|
||||||
|
dst := image.NewRGBA(rect)
|
||||||
|
draw.ApproxBiLinear.Scale(dst, rect, img, img.Bounds(), draw.Over, nil)
|
||||||
|
|
||||||
|
return dst, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderLine(img image.Image, dr *font.Drawer, s, emojipath string) {
|
||||||
|
size := dr.Face.Metrics().Ascent.Floor() + dr.Face.Metrics().Descent.Floor()
|
||||||
|
for _, r := range s {
|
||||||
|
emoji, ok := loadEmoji(r, size, emojipath)
|
||||||
|
|
||||||
|
if ok {
|
||||||
|
// Drawer.Dot is glyph baseline of next glyph
|
||||||
|
// get left/top coordinates for draw.Draw().
|
||||||
|
p := image.Pt(dr.Dot.X.Floor(), dr.Dot.Y.Floor()-dr.Face.Metrics().Ascent.Floor())
|
||||||
|
rect := image.Rect(0, 0, size, size).Add(p)
|
||||||
|
|
||||||
|
// draw emoji and ascend baseline
|
||||||
|
draw.Draw(img.(draw.Image), rect, emoji, image.ZP, draw.Over)
|
||||||
|
dr.Dot.X += fixed.I(size)
|
||||||
|
} else {
|
||||||
|
// fallback: use normal glyph
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderText(img image.Image, face font.Face, text, emojipath string) error {
|
||||||
|
dr := &font.Drawer{
|
||||||
|
Dst: img.(draw.Image),
|
||||||
|
Src: image.White,
|
||||||
|
Face: face,
|
||||||
|
Dot: fixed.Point26_6{},
|
||||||
|
}
|
||||||
|
for _, s := range strings.Split(text, "\n") {
|
||||||
|
dr.Dot.X = fixed.I(6)
|
||||||
|
dr.Dot.Y = fixed.I(13)
|
||||||
|
|
||||||
|
renderLine(img, dr, s, emojipath)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func outputJPEG(img image.Image, w io.Writer) error {
|
||||||
|
buf := new(bytes.Buffer)
|
||||||
|
err := jpeg.Encode(buf, img, &jpeg.Options{Quality: 100})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = io.Copy(w, buf)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) showManageView(w http.ResponseWriter, r *http.Request) {
|
||||||
|
logged, err := h.sess.Get(r)
|
||||||
|
if err != nil {
|
||||||
|
unauthorized(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var page int64 = 0
|
||||||
|
if val, ok := r.URL.Query()["page"]; ok && len(val[0]) == 1 {
|
||||||
|
page, _ = strconv.ParseInt(val[0], 10, 64)
|
||||||
|
}
|
||||||
|
statuses, showMore, err := h.storage.StatusByUsername(logged, 20, page)
|
||||||
|
if err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
session, err := h.sess.Store.Get(r, "status")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
flash := ""
|
||||||
|
if flashes := session.Flashes(); len(flashes) > 0 {
|
||||||
|
flash = flashes[0].(string)
|
||||||
|
}
|
||||||
|
session.Save(r, w)
|
||||||
|
h.renderLayout(w, "manage", map[string]interface{}{
|
||||||
|
"statuses": statuses,
|
||||||
|
"showMore": showMore,
|
||||||
|
"page": page,
|
||||||
|
"flash": flash,
|
||||||
|
"next_page": page + 1,
|
||||||
|
"prev_page": page - 1,
|
||||||
|
}, logged)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) showUserView(w http.ResponseWriter, r *http.Request) {
|
||||||
|
logged, _ := h.sess.Get(r)
|
||||||
|
var page int64 = 0
|
||||||
|
if val, ok := r.URL.Query()["page"]; ok && len(val[0]) == 1 {
|
||||||
|
page, _ = strconv.ParseInt(val[0], 10, 64)
|
||||||
|
}
|
||||||
|
username := mux.Vars(r)["user"]
|
||||||
|
user, err := h.storage.UserByName(username)
|
||||||
|
if err != nil {
|
||||||
|
notFound(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
statuses, showMore, err := h.storage.StatusByUsername(mux.Vars(r)["user"], 20, page)
|
||||||
|
if err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
face := ""
|
||||||
|
if len(statuses) > 0 {
|
||||||
|
face = statuses[0].Face
|
||||||
|
}
|
||||||
|
h.renderLayout(w, "user", map[string]interface{}{
|
||||||
|
"user": username,
|
||||||
|
"statuses": statuses,
|
||||||
|
"face": face,
|
||||||
|
"homepage": user.Homepage,
|
||||||
|
"about": template.HTML(user.About),
|
||||||
|
"picture": user.Picture,
|
||||||
|
"email": user.Email,
|
||||||
|
"showMore": showMore,
|
||||||
|
"page": page,
|
||||||
|
"next_page": page + 1,
|
||||||
|
"prev_page": page - 1,
|
||||||
|
}, logged)
|
||||||
|
}
|
||||||
|
|
||||||
|
type statusjson struct {
|
||||||
|
Author string `json:"author"`
|
||||||
|
Content string `json:"content"`
|
||||||
|
Face string `json:"face"`
|
||||||
|
TimeAgo string `json:"timeAgo"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) showUserStatusJSONView(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := mux.Vars(r)["user"]
|
||||||
|
if !h.storage.UserExists(user) {
|
||||||
|
notFound(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
statuses, _, err := h.storage.StatusByUsername(mux.Vars(r)["user"], 1, 0)
|
||||||
|
if err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
var res statusjson
|
||||||
|
if len(statuses) > 0 {
|
||||||
|
res.Author = statuses[0].User
|
||||||
|
res.Content = statuses[0].ContentDisplay()
|
||||||
|
res.Face = statuses[0].Face
|
||||||
|
res.TimeAgo = statuses[0].TimeAgo()
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(res)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) showUserStatusView(w http.ResponseWriter, r *http.Request) {
|
||||||
|
statuses, _, err := h.storage.StatusByUsername(mux.Vars(r)["user"], 1, 0)
|
||||||
|
if err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var status model.Status
|
||||||
|
if len(statuses) > 0 {
|
||||||
|
status = statuses[0]
|
||||||
|
}
|
||||||
|
h.view("status").Execute(w, map[string]interface{}{"status": status})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) showUserStatusImageViewEmoji(w http.ResponseWriter, r *http.Request) {
|
||||||
|
user := mux.Vars(r)["user"]
|
||||||
|
if !h.storage.UserExists(user) {
|
||||||
|
notFound(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
statuses, _, err := h.storage.StatusByUsername(mux.Vars(r)["user"], 1, 0)
|
||||||
|
if err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
face := "🙂"
|
||||||
|
if len(statuses) > 0 {
|
||||||
|
face = statuses[0].Face
|
||||||
|
}
|
||||||
|
|
||||||
|
f, err := os.Open(filepath.Join(h.cfg.AssetsDir, "badge.png"))
|
||||||
|
if err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
img, err := png.Decode(f)
|
||||||
|
if err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ft, err := truetype.Parse(gobold.TTF)
|
||||||
|
if err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
opt := truetype.Options{
|
||||||
|
Size: 14,
|
||||||
|
DPI: 0,
|
||||||
|
Hinting: 0,
|
||||||
|
GlyphCacheEntries: 0,
|
||||||
|
SubPixelsX: 0,
|
||||||
|
SubPixelsY: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
err = renderText(img, truetype.NewFace(ft, &opt), fmt.Sprintf("%s", face), h.cfg.EmojiFolder)
|
||||||
|
if err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
err = outputJPEG(img, w)
|
||||||
|
if err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncate(s string, max int) string {
|
||||||
|
if len(s) > max {
|
||||||
|
return s[:max] + "..."
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) showAtomView(w http.ResponseWriter, r *http.Request) {
|
||||||
|
username := mux.Vars(r)["user"]
|
||||||
|
user, err := h.storage.UserByName(username)
|
||||||
|
if err != nil {
|
||||||
|
notFound(w)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
feed := Feed{
|
||||||
|
Title: user.Name,
|
||||||
|
ID: fmt.Sprintf("https://status.cafe/users/%s/", user.Name),
|
||||||
|
Author: &Person{
|
||||||
|
Name: user.Name,
|
||||||
|
URI: fmt.Sprintf("https://status.cafe/users/%s", user.Name),
|
||||||
|
},
|
||||||
|
Updated: Time(time.Now()),
|
||||||
|
Link: []Link{
|
||||||
|
{
|
||||||
|
Rel: "self",
|
||||||
|
Href: fmt.Sprintf("https://status.cafe/users/%s.atom", user.Name),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Rel: "alternate",
|
||||||
|
Href: fmt.Sprintf("https://status.cafe/users/%s", user.Name),
|
||||||
|
Type: "text/html",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Icon: user.Picture,
|
||||||
|
Logo: user.Picture,
|
||||||
|
}
|
||||||
|
statuses, _, err := h.storage.StatusByUsername(user.Name, 20, 0)
|
||||||
|
if err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, status := range statuses {
|
||||||
|
feed.Entry = append(feed.Entry, createAtomEntryFromStatus(status))
|
||||||
|
}
|
||||||
|
w.Header().Set("Access-Control-Allow-Origin", "*")
|
||||||
|
w.Header().Set("Content-Type", "application/atom+xml")
|
||||||
|
var data []byte
|
||||||
|
data, err = xml.MarshalIndent(&feed, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
serverError(w, err)
|
||||||
|
}
|
||||||
|
w.Write([]byte(xml.Header + string(data)))
|
||||||
|
}
|
||||||
33
web/handler/widget_show.go
Normal file
33
web/handler/widget_show.go
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (h *Handler) showCurrentStatusJSView(w http.ResponseWriter, r *http.Request) {
|
||||||
|
name := r.URL.Query().Get("name")
|
||||||
|
w.Write([]byte(`
|
||||||
|
document.writeln('<div id="minty"><div id="minty-username"></div><div id="minty-content"></div></div>');
|
||||||
|
fetch("https://minty.anteater.monster/users/` + name + `/status.json")
|
||||||
|
.then( r => r.json() )
|
||||||
|
.then( r => {
|
||||||
|
if (!r.content.length) {
|
||||||
|
document.getElementById("minty-content").innerHTML = "No status yet."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
document.getElementById("minty-username").innerHTML = '<a href="https://minty.anteater.monster/users/` + name + `" target="_blank">' + r.author + '</a> ' + r.face + ' ' + r.timeAgo
|
||||||
|
document.getElementById("minty-content").innerHTML = r.content
|
||||||
|
})
|
||||||
|
`))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) showCurrentStatusView(w http.ResponseWriter, r *http.Request) {
|
||||||
|
logged, _ := h.getUser(r)
|
||||||
|
name := r.URL.Query().Get("name")
|
||||||
|
if name == "" {
|
||||||
|
name = logged
|
||||||
|
}
|
||||||
|
h.renderLayout(w, "current_status", map[string]interface{}{
|
||||||
|
"name": name,
|
||||||
|
}, logged)
|
||||||
|
}
|
||||||
63
web/session/session.go
Normal file
63
web/session/session.go
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
package session
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"github.com/gorilla/sessions"
|
||||||
|
"net/http"
|
||||||
|
"status/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
const cookieName = "status"
|
||||||
|
|
||||||
|
type Session struct {
|
||||||
|
Store *sessions.CookieStore
|
||||||
|
Storage *storage.Storage
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(key string, storage *storage.Storage) *Session {
|
||||||
|
store := sessions.NewCookieStore([]byte(key))
|
||||||
|
store.Options = &sessions.Options{
|
||||||
|
HttpOnly: true,
|
||||||
|
Secure: true,
|
||||||
|
MaxAge: 86400 * 30,
|
||||||
|
}
|
||||||
|
return &Session{
|
||||||
|
Store: store,
|
||||||
|
Storage: storage,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) Delete(w http.ResponseWriter, r *http.Request) error {
|
||||||
|
session, err := s.Store.Get(r, cookieName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
session.Options.MaxAge = -1
|
||||||
|
err = session.Save(r, w)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) Save(r *http.Request, w http.ResponseWriter, name string) error {
|
||||||
|
session, _ := s.Store.Get(r, cookieName)
|
||||||
|
session.Values["name"] = name
|
||||||
|
return session.Save(r, w)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Session) Get(r *http.Request) (string, error) {
|
||||||
|
session, err := s.Store.Get(r, cookieName)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
name, ok := session.Values["name"].(string)
|
||||||
|
if name == "" || !ok {
|
||||||
|
return "", errors.New("error extracting session")
|
||||||
|
}
|
||||||
|
u, err := s.Storage.UserByName(name)
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.New("error getting user")
|
||||||
|
}
|
||||||
|
if !u.Active {
|
||||||
|
return "", errors.New("user not active")
|
||||||
|
}
|
||||||
|
return name, nil
|
||||||
|
}
|
||||||
25
web/web.go
Normal file
25
web/web.go
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
package web
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"github.com/gorilla/csrf"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"status/config"
|
||||||
|
"status/storage"
|
||||||
|
"status/vpub"
|
||||||
|
"status/web/handler"
|
||||||
|
"status/web/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Serve(data *storage.Storage, v vpub.Vpub, cfg *config.Config) error {
|
||||||
|
var err error
|
||||||
|
sess := session.New(cfg.SessionKey, data)
|
||||||
|
s, err := handler.New(cfg, sess, data, v)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
fmt.Printf("Starting HTTP server on port 8000\n")
|
||||||
|
err = http.ListenAndServe(":8000", csrf.Protect([]byte("32-byte-long-auth-key"), csrf.MaxAge(0))(s))
|
||||||
|
return err
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue