This commit is contained in:
aggie 2026-03-11 19:14:41 +00:00
commit 1301fad3a9
85 changed files with 3596 additions and 0 deletions

9
.env.example Normal file
View 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
View file

@ -0,0 +1,14 @@
.idea
keys
nkey.sh
users
bin
.env
db.sql
*crt
*key
Makefile2
*~
db/
db

12
Dockerfile Executable file
View 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
View 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
View file

@ -0,0 +1,2 @@
build:
CGO_ENABLED=0 GOOS=linux go build -o bin/statuscafe main.go

13
README.md Normal file
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 730 B

BIN
assets/button.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

BIN
assets/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 B

82
assets/style.css Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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(&currentVersion)
fmt.Println("Current schema version:", currentVersion)
fmt.Println("Latest schema version:", schemaVersion)
for version := currentVersion + 1; version <= schemaVersion; version++ {
fmt.Println("Migrating to version:", version)
tx, err := db.Begin()
if err != nil {
log.Fatal("[Migrate] ", err)
}
rawSQL := SqlMap["schema_version_"+strconv.Itoa(version)]
if rawSQL == "" {
log.Fatalf("[Migrate] missing migration %d", version)
}
_, err = tx.Exec(string(rawSQL))
if err != nil {
tx.Rollback()
log.Fatal("[Migrate] ", err)
}
if _, err := tx.Exec(`delete from schema_version`); err != nil {
tx.Rollback()
log.Fatal("[Migrate] ", err)
}
if _, err := tx.Exec(`INSERT INTO schema_version (version) VALUES ($1)`, version); err != nil {
tx.Rollback()
log.Fatal("[Migrate] ", err)
}
if err := tx.Commit(); err != nil {
log.Fatal("[Migrate] ", err)
}
}
}

56
storage/sql.go Normal file
View 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;`,
}

View 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()
);

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

View file

@ -0,0 +1,2 @@
ALTER TABLE statuses
ALTER COLUMN face TYPE VARCHAR(2);

View file

@ -0,0 +1,2 @@
alter table users
add column status_id int references statuses(id);

View file

@ -0,0 +1,3 @@
alter table users
add column homepage varchar(500) not null DEFAULT '',
add column about varchar(500) not null DEFAULT '';

View file

@ -0,0 +1,2 @@
alter table users
alter column about TYPE TEXT;

View file

@ -0,0 +1,2 @@
alter table users
add column style TEXT not null DEFAULT '';

View file

@ -0,0 +1,2 @@
alter table users
add column picture varchar(500) not null DEFAULT '';

View file

@ -0,0 +1,2 @@
alter table users
add column email varchar(500) not null DEFAULT '';

View file

@ -0,0 +1,2 @@
alter table statuses
add column face varchar(1) not null DEFAULT '🙂';

View file

@ -0,0 +1,2 @@
alter table users
drop column style;

219
storage/status.go Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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"),
}
}

View 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"),
}
}

View 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"),
}
}

View 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
View 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
View 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 }}`,
}

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

View file

@ -0,0 +1,5 @@
{{ define "flash" }}
{{ if . }}
<p class="flash">{{ . }}</p>
{{ end }}
{{ end }}

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

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

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

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

View 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>

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

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

View file

@ -0,0 +1,6 @@
{{ define "content" }}
<h1>Forum key</h1>
<p>Your forum key is:</p>
<pre>{{ .key }}</pre>
{{ end }}

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

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

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

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

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

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

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

View file

@ -0,0 +1,6 @@
{{ define "content" }}
<section>
<h1>Status</h1>
{{ template "status" .status }}
</section>
{{ end }}

12
web/handler/html/tos.html Normal file
View 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 }}

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

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

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

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

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

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

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

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

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

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

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