From 1301fad3a9e11b3748e9dee6a74b14f1ab1cade0 Mon Sep 17 00:00:00 2001 From: aggie Date: Wed, 11 Mar 2026 19:14:41 +0000 Subject: [PATCH] first --- .env.example | 9 + .gitignore | 14 + Dockerfile | 12 + LICENSE | 29 ++ Makefile | 2 + README.md | 13 + assets/badge.png | Bin 0 -> 730 bytes assets/button.png | Bin 0 -> 1064 bytes assets/favicon.ico | Bin 0 -> 132 bytes assets/style.css | 82 ++++ config/cfg.go | 37 ++ docker-compose.yml | 16 + generate.go | 86 ++++ go.mod | 14 + go.sum | 27 ++ main.go | 31 ++ model/status.go | 86 ++++ model/user.go | 44 ++ model/user_test.go | 43 ++ storage/db.go | 15 + storage/migration.go | 51 +++ storage/sql.go | 56 +++ storage/sql/schema_version_1.sql | 21 + storage/sql/schema_version_10.sql | 7 + storage/sql/schema_version_11.sql | 2 + storage/sql/schema_version_2.sql | 2 + storage/sql/schema_version_3.sql | 3 + storage/sql/schema_version_4.sql | 2 + storage/sql/schema_version_5.sql | 2 + storage/sql/schema_version_6.sql | 2 + storage/sql/schema_version_7.sql | 2 + storage/sql/schema_version_8.sql | 2 + storage/sql/schema_version_9.sql | 2 + storage/status.go | 219 ++++++++++ storage/storage.go | 13 + storage/user.go | 115 ++++++ vpub/vpub.go | 45 +++ web/handler/admin_show.go | 75 ++++ web/handler/common.go | 78 ++++ web/handler/feed_show.go | 131 ++++++ web/handler/form/login.go | 18 + web/handler/form/register.go | 50 +++ web/handler/form/settings.go | 30 ++ web/handler/form/status.go | 23 ++ web/handler/handler.go | 100 +++++ web/handler/html.go | 422 ++++++++++++++++++++ web/handler/html/admin.html | 11 + web/handler/html/common/flash.html | 5 + web/handler/html/common/layout.html | 38 ++ web/handler/html/common/status.html | 4 + web/handler/html/common/status_form.html | 23 ++ web/handler/html/confirm_remove_status.html | 11 + web/handler/html/create_status.html | 47 +++ web/handler/html/current_status.html | 65 +++ web/handler/html/edit_status.html | 11 + web/handler/html/forum-key.html | 6 + web/handler/html/index.html | 39 ++ web/handler/html/login.html | 20 + web/handler/html/manage.html | 26 ++ web/handler/html/register-success.html | 7 + web/handler/html/register.html | 37 ++ web/handler/html/settings.html | 30 ++ web/handler/html/status-updater.html | 22 + web/handler/html/status.html | 6 + web/handler/html/tos.html | 12 + web/handler/html/user.html | 63 +++ web/handler/index_show.go | 38 ++ web/handler/login_check.go | 29 ++ web/handler/login_show.go | 12 + web/handler/logout.go | 11 + web/handler/register.go | 135 +++++++ web/handler/settings_show.go | 53 +++ web/handler/settings_update.go | 32 ++ web/handler/status_create.go | 17 + web/handler/status_edit.go | 58 +++ web/handler/status_remove.go | 51 +++ web/handler/status_save.go | 46 +++ web/handler/status_show.go | 18 + web/handler/status_update.go | 53 +++ web/handler/tos_show.go | 13 + web/handler/tpl.go | 37 ++ web/handler/user_show.go | 356 +++++++++++++++++ web/handler/widget_show.go | 33 ++ web/session/session.go | 63 +++ web/web.go | 25 ++ 85 files changed, 3596 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100755 Dockerfile create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 README.md create mode 100644 assets/badge.png create mode 100644 assets/button.png create mode 100644 assets/favicon.ico create mode 100644 assets/style.css create mode 100644 config/cfg.go create mode 100755 docker-compose.yml create mode 100644 generate.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 model/status.go create mode 100644 model/user.go create mode 100644 model/user_test.go create mode 100644 storage/db.go create mode 100644 storage/migration.go create mode 100644 storage/sql.go create mode 100644 storage/sql/schema_version_1.sql create mode 100644 storage/sql/schema_version_10.sql create mode 100644 storage/sql/schema_version_11.sql create mode 100644 storage/sql/schema_version_2.sql create mode 100644 storage/sql/schema_version_3.sql create mode 100644 storage/sql/schema_version_4.sql create mode 100644 storage/sql/schema_version_5.sql create mode 100644 storage/sql/schema_version_6.sql create mode 100644 storage/sql/schema_version_7.sql create mode 100644 storage/sql/schema_version_8.sql create mode 100644 storage/sql/schema_version_9.sql create mode 100644 storage/status.go create mode 100644 storage/storage.go create mode 100644 storage/user.go create mode 100644 vpub/vpub.go create mode 100644 web/handler/admin_show.go create mode 100644 web/handler/common.go create mode 100644 web/handler/feed_show.go create mode 100644 web/handler/form/login.go create mode 100644 web/handler/form/register.go create mode 100644 web/handler/form/settings.go create mode 100644 web/handler/form/status.go create mode 100644 web/handler/handler.go create mode 100644 web/handler/html.go create mode 100644 web/handler/html/admin.html create mode 100644 web/handler/html/common/flash.html create mode 100644 web/handler/html/common/layout.html create mode 100644 web/handler/html/common/status.html create mode 100644 web/handler/html/common/status_form.html create mode 100644 web/handler/html/confirm_remove_status.html create mode 100644 web/handler/html/create_status.html create mode 100644 web/handler/html/current_status.html create mode 100644 web/handler/html/edit_status.html create mode 100644 web/handler/html/forum-key.html create mode 100644 web/handler/html/index.html create mode 100644 web/handler/html/login.html create mode 100644 web/handler/html/manage.html create mode 100644 web/handler/html/register-success.html create mode 100644 web/handler/html/register.html create mode 100644 web/handler/html/settings.html create mode 100644 web/handler/html/status-updater.html create mode 100644 web/handler/html/status.html create mode 100644 web/handler/html/tos.html create mode 100644 web/handler/html/user.html create mode 100644 web/handler/index_show.go create mode 100644 web/handler/login_check.go create mode 100644 web/handler/login_show.go create mode 100644 web/handler/logout.go create mode 100644 web/handler/register.go create mode 100644 web/handler/settings_show.go create mode 100644 web/handler/settings_update.go create mode 100644 web/handler/status_create.go create mode 100644 web/handler/status_edit.go create mode 100644 web/handler/status_remove.go create mode 100644 web/handler/status_save.go create mode 100644 web/handler/status_show.go create mode 100644 web/handler/status_update.go create mode 100644 web/handler/tos_show.go create mode 100644 web/handler/tpl.go create mode 100644 web/handler/user_show.go create mode 100644 web/handler/widget_show.go create mode 100644 web/session/session.go create mode 100644 web/web.go diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..a1d35be --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d3b41d9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +.idea +keys +nkey.sh +users +bin +.env +db.sql +*crt +*key +Makefile2 +*~ +db/ +db + diff --git a/Dockerfile b/Dockerfile new file mode 100755 index 0000000..95ec75f --- /dev/null +++ b/Dockerfile @@ -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 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6967b1d --- /dev/null +++ b/LICENSE @@ -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. \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..22889c5 --- /dev/null +++ b/Makefile @@ -0,0 +1,2 @@ +build: + CGO_ENABLED=0 GOOS=linux go build -o bin/statuscafe main.go diff --git a/README.md b/README.md new file mode 100644 index 0000000..358c183 --- /dev/null +++ b/README.md @@ -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. diff --git a/assets/badge.png b/assets/badge.png new file mode 100644 index 0000000000000000000000000000000000000000..86c8497b5edf954b8829ec011900e3196e156c22 GIT binary patch literal 730 zcmV<00ww*4P)EX>4Tx04R}tkv&MmKpe$i(@Kj}9PFUtkfAzR5S8MnRVYG*P%E_RU~>J0CJjl7 zi=*ILaPVWX>fqw6tAnc`2!4RLxj8AiNQwVT3N2zhIPS;0dyl(!fY7Wm)eKAks%9DK zWJ1j5R>j~e!WcpbaRg*$>T{Bmg6H_UhmWs!F`ngp?$6PeFPIGQiNv!^H!R`};`pYe zbKWP8u(F&dJ|`YG=z_$LTvuFv<6LrB;F%F4lb$Dz5R0WQR=Sv#4V8GBIGR^A$``UO z=Q(e2R;zW^z9)ZSxS*{pbDic0l32tNB#2N@M+H?_h|#K%Vj@lZ2@n6Ut;i~_-3pw+PL?_=9;odEu4;7aTGYfWJ0lk`SM ziyi^}+rY(jM^pBI%N=0wNtX@Tk^D4;Vi9;hqi@Oq1Ghl$n%i4@AEysMhPq1K00)P_ zSc$UNJ>DJa?(N?*?f!lMZgX;_e~I0=00006VoOIv02u%o0C3kqr%wO?010qNS#tmY zE+YT{E+YYWr9XB6000McNlirueSad^gZEa<4bO1wgWnpw> zWFU8GbZ8()Nlj2!fese{006Q{L_t(o!|j$q4udcZ1c$13?)fDzNc;kR$-OUV4^^cU zStyt`!pM69C$QSp+VW6Jc@i+=4v=#WCkzmgw}AkUKN5{VO4FsxTb3DsTjb~%!oXk5 zz_YdoRqJcDx~|pM-)qlX|8{BMT3jtm6Rw$}-QCOQ*1(VW)-=@V>A~k>2BtK932V9A zi_b+1Tj!A_X-D}^*}|=7_50S@-de0Nu?ITb1ILcD?Q#Z+NJPnq7qU@SM`Zc~VgLXD M07*qoM6N<$g2Apt#{d8T literal 0 HcmV?d00001 diff --git a/assets/button.png b/assets/button.png new file mode 100644 index 0000000000000000000000000000000000000000..c94631b0118427ac9c1428d2bdffe4d38d3b813b GIT binary patch literal 1064 zcmV+@1lRkCP)EX>4Tx04R}tkv&MmKpe$iTSbwGs2xNcB2*^}q9Tr3g(6f4wL+^7CYOFelZGV4 z#ZhoAIQX$xb#QUk)xlK|1V2FB+?*6$q{ROvg%&X$9QWhhy~o`0zx9s$1IMR}J0xj#p@nza}Z5Q%4*VcNtS#M7I$ z!FiuJ%nGtfd`>)O(glehxvqHp#yRhDkY|R?RC1m;Of2SGSZQHaFg4;S;)ts0lpjoc zta9Gstd%OPaZmn2A*-(}bDic8;#kBIB#2N@K@la`h|;Q)Vj)TUF(3b+>zBx-kgEtr zjs=vVL3aJ%fAG6oD>pgeB?&R0{l#%UMu3i8pk8&H?_eSad^gZEa<4bO1wgWnpw> zWFU8GbZ8()Nlj2!fese{00IC>L_t(&-tAhwPQx$|K392wh_NFQLkAv#mDfOGWUF`r z9-zv`&c-uzmE&*WslAEhd`}}y%a&U2Z zWhl;3!65)1hi7U%jDrrg#XeH{c#QR9`BHH?`^i6*kKRM=930VgbAK+jj(w!`@SIei z(+lfdp0i!{3{5Un31yGhrTV$~1wCv#+g-$%KM=Rx*OBUD@N#T(se!HF*Qa}xHjSzV zs`{k6Af?a6;C6c2(B6g6(s)OTKiLXWW!CC;9&T$>cki+y> zR&Z%&K=sa?uN05ZDU$;%IjlIKrG4<90$5T}rIpvWTO$9eaT~~7!evFwjE-e8m>vCL@Rb)N`G1>*^df_E1`-v_@9yq(6_GP#=w}o;&ScmoaK=YO d+ChtrK{efU+DrDGHb7$;JYD@<);T3K0RXuqEMEWs literal 0 HcmV?d00001 diff --git a/assets/style.css b/assets/style.css new file mode 100644 index 0000000..8dfa343 --- /dev/null +++ b/assets/style.css @@ -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; +} diff --git a/config/cfg.go b/config/cfg.go new file mode 100644 index 0000000..1ddb182 --- /dev/null +++ b/config/cfg.go @@ -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"), + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100755 index 0000000..bf94743 --- /dev/null +++ b/docker-compose.yml @@ -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" diff --git a/generate.go b/generate.go new file mode 100644 index 0000000..2149b0d --- /dev/null +++ b/generate.go @@ -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")) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7c35bf4 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..2f4ae9c --- /dev/null +++ b/go.sum @@ -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= diff --git a/main.go b/main.go new file mode 100644 index 0000000..36b8989 --- /dev/null +++ b/main.go @@ -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), + ) +} diff --git a/model/status.go b/model/status.go new file mode 100644 index 0000000..c3d5d66 --- /dev/null +++ b/model/status.go @@ -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("%s", 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("%s", 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) + } +} diff --git a/model/user.go b/model/user.go new file mode 100644 index 0000000..3105ef5 --- /dev/null +++ b/model/user.go @@ -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)) +} diff --git a/model/user_test.go b/model/user_test.go new file mode 100644 index 0000000..474a0cc --- /dev/null +++ b/model/user_test.go @@ -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") + } + } +} diff --git a/storage/db.go b/storage/db.go new file mode 100644 index 0000000..fd877fb --- /dev/null +++ b/storage/db.go @@ -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 +} diff --git a/storage/migration.go b/storage/migration.go new file mode 100644 index 0000000..6c6375b --- /dev/null +++ b/storage/migration.go @@ -0,0 +1,51 @@ +package storage + +import ( + "database/sql" + "fmt" + "log" + "strconv" +) + +const schemaVersion = 11 + +func Migrate(db *sql.DB) { + var currentVersion int + db.QueryRow(`SELECT version FROM schema_version`).Scan(¤tVersion) + + fmt.Println("Current schema version:", currentVersion) + fmt.Println("Latest schema version:", schemaVersion) + + for version := currentVersion + 1; version <= schemaVersion; version++ { + fmt.Println("Migrating to version:", version) + + tx, err := db.Begin() + if err != nil { + log.Fatal("[Migrate] ", err) + } + + rawSQL := SqlMap["schema_version_"+strconv.Itoa(version)] + if rawSQL == "" { + log.Fatalf("[Migrate] missing migration %d", version) + } + _, err = tx.Exec(string(rawSQL)) + if err != nil { + tx.Rollback() + log.Fatal("[Migrate] ", err) + } + + if _, err := tx.Exec(`delete from schema_version`); err != nil { + tx.Rollback() + log.Fatal("[Migrate] ", err) + } + + if _, err := tx.Exec(`INSERT INTO schema_version (version) VALUES ($1)`, version); err != nil { + tx.Rollback() + log.Fatal("[Migrate] ", err) + } + + if err := tx.Commit(); err != nil { + log.Fatal("[Migrate] ", err) + } + } +} diff --git a/storage/sql.go b/storage/sql.go new file mode 100644 index 0000000..726054a --- /dev/null +++ b/storage/sql.go @@ -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;`, +} diff --git a/storage/sql/schema_version_1.sql b/storage/sql/schema_version_1.sql new file mode 100644 index 0000000..0754654 --- /dev/null +++ b/storage/sql/schema_version_1.sql @@ -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() +); diff --git a/storage/sql/schema_version_10.sql b/storage/sql/schema_version_10.sql new file mode 100644 index 0000000..d4d293d --- /dev/null +++ b/storage/sql/schema_version_10.sql @@ -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; \ No newline at end of file diff --git a/storage/sql/schema_version_11.sql b/storage/sql/schema_version_11.sql new file mode 100644 index 0000000..b4a64cf --- /dev/null +++ b/storage/sql/schema_version_11.sql @@ -0,0 +1,2 @@ +ALTER TABLE statuses + ALTER COLUMN face TYPE VARCHAR(2); \ No newline at end of file diff --git a/storage/sql/schema_version_2.sql b/storage/sql/schema_version_2.sql new file mode 100644 index 0000000..926f310 --- /dev/null +++ b/storage/sql/schema_version_2.sql @@ -0,0 +1,2 @@ +alter table users + add column status_id int references statuses(id); diff --git a/storage/sql/schema_version_3.sql b/storage/sql/schema_version_3.sql new file mode 100644 index 0000000..f5f2b0f --- /dev/null +++ b/storage/sql/schema_version_3.sql @@ -0,0 +1,3 @@ +alter table users + add column homepage varchar(500) not null DEFAULT '', + add column about varchar(500) not null DEFAULT ''; diff --git a/storage/sql/schema_version_4.sql b/storage/sql/schema_version_4.sql new file mode 100644 index 0000000..dff65a9 --- /dev/null +++ b/storage/sql/schema_version_4.sql @@ -0,0 +1,2 @@ +alter table users +alter column about TYPE TEXT; \ No newline at end of file diff --git a/storage/sql/schema_version_5.sql b/storage/sql/schema_version_5.sql new file mode 100644 index 0000000..04faed7 --- /dev/null +++ b/storage/sql/schema_version_5.sql @@ -0,0 +1,2 @@ +alter table users + add column style TEXT not null DEFAULT ''; \ No newline at end of file diff --git a/storage/sql/schema_version_6.sql b/storage/sql/schema_version_6.sql new file mode 100644 index 0000000..589a347 --- /dev/null +++ b/storage/sql/schema_version_6.sql @@ -0,0 +1,2 @@ +alter table users + add column picture varchar(500) not null DEFAULT ''; \ No newline at end of file diff --git a/storage/sql/schema_version_7.sql b/storage/sql/schema_version_7.sql new file mode 100644 index 0000000..ed7537c --- /dev/null +++ b/storage/sql/schema_version_7.sql @@ -0,0 +1,2 @@ +alter table users + add column email varchar(500) not null DEFAULT ''; \ No newline at end of file diff --git a/storage/sql/schema_version_8.sql b/storage/sql/schema_version_8.sql new file mode 100644 index 0000000..d8245a0 --- /dev/null +++ b/storage/sql/schema_version_8.sql @@ -0,0 +1,2 @@ +alter table statuses + add column face varchar(1) not null DEFAULT '🙂'; \ No newline at end of file diff --git a/storage/sql/schema_version_9.sql b/storage/sql/schema_version_9.sql new file mode 100644 index 0000000..4bfd461 --- /dev/null +++ b/storage/sql/schema_version_9.sql @@ -0,0 +1,2 @@ +alter table users + drop column style; \ No newline at end of file diff --git a/storage/status.go b/storage/status.go new file mode 100644 index 0000000..4ce0288 --- /dev/null +++ b/storage/status.go @@ -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 +} diff --git a/storage/storage.go b/storage/storage.go new file mode 100644 index 0000000..008513f --- /dev/null +++ b/storage/storage.go @@ -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} +} diff --git a/storage/user.go b/storage/user.go new file mode 100644 index 0000000..f14475c --- /dev/null +++ b/storage/user.go @@ -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 +} diff --git a/vpub/vpub.go b/vpub/vpub.go new file mode 100644 index 0000000..9a828a5 --- /dev/null +++ b/vpub/vpub.go @@ -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 +} diff --git a/web/handler/admin_show.go b/web/handler/admin_show.go new file mode 100644 index 0000000..da2432a --- /dev/null +++ b/web/handler/admin_show.go @@ -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) +} diff --git a/web/handler/common.go b/web/handler/common.go new file mode 100644 index 0000000..3e614ce --- /dev/null +++ b/web/handler/common.go @@ -0,0 +1,78 @@ +// Code generated by go generate; DO NOT EDIT. + +package handler + +var TplCommonMap = map[string]string{ + "flash": `{{ define "flash" }} +{{ if . }} +

{{ . }}

+{{ end }} +{{ end }}`, + "layout": `{{ define "layout" }} + + + + + + {{ template "title" . }}Minty Cafe + + + {{ if .face }} + + {{ else }} + + {{ end }} + {{ template "head" . }} + + +
+ +
+
+ {{ template "content" . }} +
+
+
+ + + + +{{ end }} +{{ define "head" }}{{ end }} +{{ define "title" }}{{ end }} +`, + "status": `{{ define "status" }} +
{{ .User }} {{ .Face }} {{ .TimeAgo }}
+

{{ .ContentHtml }}

+{{ end }}`, + "status_form": `{{ define "status_form" }} +
+ {{ range $i, $v := faces }} +
+ + +
+ {{ end }} +
+
+ +
+ +{{ end }} +`, +} diff --git a/web/handler/feed_show.go b/web/handler/feed_show.go new file mode 100644 index 0000000..7acb61d --- /dev/null +++ b/web/handler/feed_show.go @@ -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))) +} diff --git a/web/handler/form/login.go b/web/handler/form/login.go new file mode 100644 index 0000000..2d49373 --- /dev/null +++ b/web/handler/form/login.go @@ -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"), + } +} diff --git a/web/handler/form/register.go b/web/handler/form/register.go new file mode 100644 index 0000000..507bbb4 --- /dev/null +++ b/web/handler/form/register.go @@ -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"), + } +} diff --git a/web/handler/form/settings.go b/web/handler/form/settings.go new file mode 100644 index 0000000..9c6ff4c --- /dev/null +++ b/web/handler/form/settings.go @@ -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, " +

Admin

+ {{ range .inactive }} +
+
{{ .Name }} ({{ .SignupEmail }}) Activate | Delete
+

{{ .SignupMsg }}

+
+ {{ end }} + +{{ end }}`, + "confirm_remove_status": `{{ define "content" }} +
+ Are you sure you you want to delete the following status? +

{{ .status.Content }}

+
+ {{ .csrfField }} + + +
+
+{{ end }}`, + "create_status": ` + + + + + status cafe + + + + + +
+ {{ if .form.Error }} +

{{ .form.Error }}

+ {{ end }} + {{ if .flash }} +

{{ .flash }}

+ {{ end }} +
+ {{ .csrfField }} + {{ template "status_form" .status }} +
+
+ + + +`, + "current_status": `{{ define "content" }} + + +
+
+

Status widget

+
+ + + + + + + + + +
name:
+
+

Past this code into your HTML file:

+ +

Past this code into your CSS file:

+ +

Make it your own! The CSS above is only an example. Tweak it so that it integrates well with your sites's colors.

+
+
+

Preview

+ {{ if .name }} + + {{ else }} +

Add your name and click "generate HTML" to see a preview.

+ {{ end }} +
+
+{{ end }} +`, + "edit_status": `{{ define "content" }} +

Edit status

+{{ if .form.Error }} +

{{ .form.Error }}

+{{ end }} +{{ template "flash" .flash }} +
+ {{ .csrfField }} + {{ template "status_form" .status }} +
+{{ end }}`, + "forum-key": `{{ define "content" }} +

Forum key

+ +

Your forum key is:

+
{{ .key }}
+{{ end }}`, + "index": `{{ define "head" }} + +{{ end }} + +{{ define "content" }} +
+
+ {{ if .logged }} +

Set your status

+ {{ if .form.Error }} +

{{ .form.Error }}

+ {{ end }} +
+ {{ .csrfField }} + {{ template "status_form" .status }} +
+

+ status updater bookmarklet
+ status widget for your homepage THESE WILL HAVE BUTTONS SOON I PROMMY +

+


+ {{ else }} +

Welcome!

+

minty is a place to share your current status.

+

Register now!

+ {{ end }} +


+

Subscribe via Atom

+
+
+

Status stream

+ {{ range .statuses }} +
+ {{ template "status" . }} +
+ {{ end }} +
+
+{{ end }} +`, + "login": `{{ define "content" }} +
+

Login

+ {{ if .form.Error }} +

{{ .form.Error }}

+ {{ end }} +
+ {{ .csrfField }} +
+ + +
+
+ + +
+ +
+
+{{ end }}`, + "manage": `{{ define "content" }} +

Manage statuses

+{{ template "flash" .flash }} +{{ range .statuses }} +
+ {{ template "status" . }} + {{ if eq $.logged .User }} + + {{ end }} +
+{{ end }} +{{ if or .showMore (ne 0 .page) }} +

+ {{ if ne 0 .page }} + {{ if eq 0 .prev_page }} + Newer statuses + {{ else }} + Newer statuses + {{ end }} + {{ end }} + {{ if .showMore }} + Older statuses + {{- end }} +

+{{ end }} +{{ end }}`, + "register": `{{ define "content" }} +
+

Register

+ {{ if .form.Error }} +

{{ .form.Error }}

+ {{ end }} +
+ {{ .csrfField }} +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+

By clicking the following button you agree to our Terms of Service.

+ +
+
+{{ end }}`, + "register-success": `{{ define "content" }} +
+

Thank you!

+

Thanks for registering, {{ .name }}!

+

You should receive a confirmation email on {{ .email }} as soon as your account is activated.

+
+{{ end }}`, + "settings": `{{ define "content" }} +

Settings

+{{ if .flash }} +

{{ .flash }}

+{{ end }} +

Manage statuses

+
+ {{ .csrfField }} +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+{{ end }}`, + "status": `{{ define "content" }} +
+

Status

+ {{ template "status" .status }} +
+{{ end }}`, + "status-updater": `{{ define "content" }} +
+

Status Updater

+

+ 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. +

+

+ 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. +

+

Instructions

+

+ Drag the following link to your bookmarks toolbar: +

+

+ status updater +

+

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.

+
+{{ end }} +`, + "tos": `{{ define "content" }} +
+

Terms of service

+

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.

+

Racist, bigoted or otherwise hate speech is not permitted. Status Cafe is an inclusive place that will not tolerate anyone promoting hateful ideas and language.

+

Illegal activities such as promoting malware, phishing or publishing something that promotes content that infringes copyright, patent or trademark you do not own is not permitted.

+

Pornographic content is not allowed.

+

Spamming, 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.

+

Harassing, bullying, picking on a user isn't permitted.

+

Revealing information (doxing) from a user isn't allowed.

+
+{{ end }}`, + "user": `{{ define "head" }} + +{{ end }} + +{{ define "title" }}{{ .user }} - {{ end }} + +{{ define "content" }} +
+
+

{{ .user }}

+ {{ if .picture }} + + {{ end }} +

Subscribe via Atom

+
+
Homepage
+
+ {{ if .homepage }} + {{ .homepage }}
+ {{ else }} + Not defined + {{ end }} + + + {{ else }} + Not defined + {{ end }} +
About
+
+ {{ if .about }} + {{ .about }} + {{ else }} + Not defined + {{ end }} +
+
+
+
+

Statuses

+ {{ range .statuses }} +
+ {{ template "status" . }} +
+ {{ end }} + {{ if or .showMore (ne 0 .page) }} +

+ {{ if ne 0 .page }} + {{ if eq 0 .prev_page }} + Newer statuses + {{ else }} + Newer statuses + {{ end }} + {{ end }} + {{ if .showMore }} + Older statuses + {{- end }} +

+ {{ end }} +
+
+{{ end }}`, +} diff --git a/web/handler/html/admin.html b/web/handler/html/admin.html new file mode 100644 index 0000000..2ecb2b9 --- /dev/null +++ b/web/handler/html/admin.html @@ -0,0 +1,11 @@ +{{ define "content" }} +
+

Admin

+ {{ range .inactive }} +
+
{{ .Name }} ({{ .SignupEmail }}) Activate | Delete
+

{{ .SignupMsg }}

+
+ {{ end }} +
+{{ end }} \ No newline at end of file diff --git a/web/handler/html/common/flash.html b/web/handler/html/common/flash.html new file mode 100644 index 0000000..cd91c90 --- /dev/null +++ b/web/handler/html/common/flash.html @@ -0,0 +1,5 @@ +{{ define "flash" }} +{{ if . }} +

{{ . }}

+{{ end }} +{{ end }} \ No newline at end of file diff --git a/web/handler/html/common/layout.html b/web/handler/html/common/layout.html new file mode 100644 index 0000000..a718b37 --- /dev/null +++ b/web/handler/html/common/layout.html @@ -0,0 +1,38 @@ +{{ define "layout" }} + + + + + + {{ template "title" . }}Minty Cafe + + + {{ if .face }} + + {{ else }} + + {{ end }} + {{ template "head" . }} + + +
+ +
+
+ {{ template "content" . }} +
+
+
+ + + + +{{ end }} +{{ define "head" }}{{ end }} +{{ define "title" }}{{ end }} diff --git a/web/handler/html/common/status.html b/web/handler/html/common/status.html new file mode 100644 index 0000000..b2f66cc --- /dev/null +++ b/web/handler/html/common/status.html @@ -0,0 +1,4 @@ +{{ define "status" }} +
{{ .User }} {{ .Face }} {{ .TimeAgo }}
+

{{ .ContentHtml }}

+{{ end }} \ No newline at end of file diff --git a/web/handler/html/common/status_form.html b/web/handler/html/common/status_form.html new file mode 100644 index 0000000..ad1a416 --- /dev/null +++ b/web/handler/html/common/status_form.html @@ -0,0 +1,23 @@ +{{ define "status_form" }} +
+ {{ range $i, $v := faces }} +
+ + +
+ {{ end }} +
+
+ +
+ +{{ end }} diff --git a/web/handler/html/confirm_remove_status.html b/web/handler/html/confirm_remove_status.html new file mode 100644 index 0000000..e6cf79a --- /dev/null +++ b/web/handler/html/confirm_remove_status.html @@ -0,0 +1,11 @@ +{{ define "content" }} +
+ Are you sure you you want to delete the following status? +

{{ .status.Content }}

+
+ {{ .csrfField }} + + +
+
+{{ end }} \ No newline at end of file diff --git a/web/handler/html/create_status.html b/web/handler/html/create_status.html new file mode 100644 index 0000000..fccd94b --- /dev/null +++ b/web/handler/html/create_status.html @@ -0,0 +1,47 @@ + + + + + + status cafe + + + + + +
+ {{ if .form.Error }} +

{{ .form.Error }}

+ {{ end }} + {{ if .flash }} +

{{ .flash }}

+ {{ end }} +
+ {{ .csrfField }} + {{ template "status_form" .status }} +
+
+ + + + \ No newline at end of file diff --git a/web/handler/html/current_status.html b/web/handler/html/current_status.html new file mode 100644 index 0000000..f5280f6 --- /dev/null +++ b/web/handler/html/current_status.html @@ -0,0 +1,65 @@ +{{ define "content" }} + + +
+
+

Status widget

+
+ + + + + + + + + +
name:
+
+

Past this code into your HTML file:

+ +

Past this code into your CSS file:

+ +

Make it your own! The CSS above is only an example. Tweak it so that it integrates well with your sites's colors.

+
+
+

Preview

+ {{ if .name }} + + {{ else }} +

Add your name and click "generate HTML" to see a preview.

+ {{ end }} +
+
+{{ end }} diff --git a/web/handler/html/edit_status.html b/web/handler/html/edit_status.html new file mode 100644 index 0000000..9137eb6 --- /dev/null +++ b/web/handler/html/edit_status.html @@ -0,0 +1,11 @@ +{{ define "content" }} +

Edit status

+{{ if .form.Error }} +

{{ .form.Error }}

+{{ end }} +{{ template "flash" .flash }} +
+ {{ .csrfField }} + {{ template "status_form" .status }} +
+{{ end }} \ No newline at end of file diff --git a/web/handler/html/forum-key.html b/web/handler/html/forum-key.html new file mode 100644 index 0000000..e06fe8c --- /dev/null +++ b/web/handler/html/forum-key.html @@ -0,0 +1,6 @@ +{{ define "content" }} +

Forum key

+ +

Your forum key is:

+
{{ .key }}
+{{ end }} \ No newline at end of file diff --git a/web/handler/html/index.html b/web/handler/html/index.html new file mode 100644 index 0000000..97f7aaa --- /dev/null +++ b/web/handler/html/index.html @@ -0,0 +1,39 @@ +{{ define "head" }} + +{{ end }} + +{{ define "content" }} +
+
+ {{ if .logged }} +

Set your status

+ {{ if .form.Error }} +

{{ .form.Error }}

+ {{ end }} +
+ {{ .csrfField }} + {{ template "status_form" .status }} +
+

+ status updater bookmarklet
+ status widget for your homepage THESE WILL HAVE BUTTONS SOON I PROMMY +

+


+ {{ else }} +

Welcome!

+

minty is a place to share your current status.

+

Register now!

+ {{ end }} +


+

Subscribe via Atom

+
+
+

Status stream

+ {{ range .statuses }} +
+ {{ template "status" . }} +
+ {{ end }} +
+
+{{ end }} diff --git a/web/handler/html/login.html b/web/handler/html/login.html new file mode 100644 index 0000000..ca74c0c --- /dev/null +++ b/web/handler/html/login.html @@ -0,0 +1,20 @@ +{{ define "content" }} +
+

Login

+ {{ if .form.Error }} +

{{ .form.Error }}

+ {{ end }} +
+ {{ .csrfField }} +
+ + +
+
+ + +
+ +
+
+{{ end }} \ No newline at end of file diff --git a/web/handler/html/manage.html b/web/handler/html/manage.html new file mode 100644 index 0000000..335aea7 --- /dev/null +++ b/web/handler/html/manage.html @@ -0,0 +1,26 @@ +{{ define "content" }} +

Manage statuses

+{{ template "flash" .flash }} +{{ range .statuses }} +
+ {{ template "status" . }} + {{ if eq $.logged .User }} + + {{ end }} +
+{{ end }} +{{ if or .showMore (ne 0 .page) }} +

+ {{ if ne 0 .page }} + {{ if eq 0 .prev_page }} + Newer statuses + {{ else }} + Newer statuses + {{ end }} + {{ end }} + {{ if .showMore }} + Older statuses + {{- end }} +

+{{ end }} +{{ end }} \ No newline at end of file diff --git a/web/handler/html/register-success.html b/web/handler/html/register-success.html new file mode 100644 index 0000000..255edec --- /dev/null +++ b/web/handler/html/register-success.html @@ -0,0 +1,7 @@ +{{ define "content" }} +
+

Thank you!

+

Thanks for registering, {{ .name }}!

+

You should receive a confirmation email on {{ .email }} as soon as your account is activated.

+
+{{ end }} \ No newline at end of file diff --git a/web/handler/html/register.html b/web/handler/html/register.html new file mode 100644 index 0000000..905fdd8 --- /dev/null +++ b/web/handler/html/register.html @@ -0,0 +1,37 @@ +{{ define "content" }} +
+

Register

+ {{ if .form.Error }} +

{{ .form.Error }}

+ {{ end }} +
+ {{ .csrfField }} +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+

By clicking the following button you agree to our Terms of Service.

+ +
+
+{{ end }} \ No newline at end of file diff --git a/web/handler/html/settings.html b/web/handler/html/settings.html new file mode 100644 index 0000000..73ee21f --- /dev/null +++ b/web/handler/html/settings.html @@ -0,0 +1,30 @@ +{{ define "content" }} +

Settings

+{{ if .flash }} +

{{ .flash }}

+{{ end }} +

Manage statuses

+
+ {{ .csrfField }} +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+{{ end }} \ No newline at end of file diff --git a/web/handler/html/status-updater.html b/web/handler/html/status-updater.html new file mode 100644 index 0000000..63b3059 --- /dev/null +++ b/web/handler/html/status-updater.html @@ -0,0 +1,22 @@ +{{ define "content" }} +
+

Status Updater

+

+ 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. +

+

+ 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. +

+

Instructions

+

+ Drag the following link to your bookmarks toolbar: +

+

+ status updater +

+

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.

+
+{{ end }} diff --git a/web/handler/html/status.html b/web/handler/html/status.html new file mode 100644 index 0000000..8f67542 --- /dev/null +++ b/web/handler/html/status.html @@ -0,0 +1,6 @@ +{{ define "content" }} +
+

Status

+ {{ template "status" .status }} +
+{{ end }} \ No newline at end of file diff --git a/web/handler/html/tos.html b/web/handler/html/tos.html new file mode 100644 index 0000000..23e9bd3 --- /dev/null +++ b/web/handler/html/tos.html @@ -0,0 +1,12 @@ +{{ define "content" }} +
+

Terms of service

+

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.

+

Racist, bigoted or otherwise hate speech is not permitted. Status Cafe is an inclusive place that will not tolerate anyone promoting hateful ideas and language.

+

Illegal activities such as promoting malware, phishing or publishing something that promotes content that infringes copyright, patent or trademark you do not own is not permitted.

+

Pornographic content is not allowed.

+

Spamming, 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.

+

Harassing, bullying, picking on a user isn't permitted.

+

Revealing information (doxing) from a user isn't allowed.

+
+{{ end }} \ No newline at end of file diff --git a/web/handler/html/user.html b/web/handler/html/user.html new file mode 100644 index 0000000..a5eb5c1 --- /dev/null +++ b/web/handler/html/user.html @@ -0,0 +1,63 @@ +{{ define "head" }} + +{{ end }} + +{{ define "title" }}{{ .user }} - {{ end }} + +{{ define "content" }} +
+
+

{{ .user }}

+ {{ if .picture }} + + {{ end }} +

Subscribe via Atom

+
+
Homepage
+
+ {{ if .homepage }} + {{ .homepage }}
+ {{ else }} + Not defined + {{ end }} + + + {{ else }} + Not defined + {{ end }} +
About
+
+ {{ if .about }} + {{ .about }} + {{ else }} + Not defined + {{ end }} +
+
+
+
+

Statuses

+ {{ range .statuses }} +
+ {{ template "status" . }} +
+ {{ end }} + {{ if or .showMore (ne 0 .page) }} +

+ {{ if ne 0 .page }} + {{ if eq 0 .prev_page }} + Newer statuses + {{ else }} + Newer statuses + {{ end }} + {{ end }} + {{ if .showMore }} + Older statuses + {{- end }} +

+ {{ end }} +
+
+{{ end }} \ No newline at end of file diff --git a/web/handler/index_show.go b/web/handler/index_show.go new file mode 100644 index 0000000..0fcb37f --- /dev/null +++ b/web/handler/index_show.go @@ -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) +} diff --git a/web/handler/login_check.go b/web/handler/login_check.go new file mode 100644 index 0000000..29318d7 --- /dev/null +++ b/web/handler/login_check.go @@ -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) +} diff --git a/web/handler/login_show.go b/web/handler/login_show.go new file mode 100644 index 0000000..c4c3c7e --- /dev/null +++ b/web/handler/login_show.go @@ -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), + }, "") +} diff --git a/web/handler/logout.go b/web/handler/logout.go new file mode 100644 index 0000000..03c3784 --- /dev/null +++ b/web/handler/logout.go @@ -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) +} diff --git a/web/handler/register.go b/web/handler/register.go new file mode 100644 index 0000000..439d525 --- /dev/null +++ b/web/handler/register.go @@ -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) + } + } +} diff --git a/web/handler/settings_show.go b/web/handler/settings_show.go new file mode 100644 index 0000000..4c524e2 --- /dev/null +++ b/web/handler/settings_show.go @@ -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) +} diff --git a/web/handler/settings_update.go b/web/handler/settings_update.go new file mode 100644 index 0000000..caab6ec --- /dev/null +++ b/web/handler/settings_update.go @@ -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) +} diff --git a/web/handler/status_create.go b/web/handler/status_create.go new file mode 100644 index 0000000..d7fd610 --- /dev/null +++ b/web/handler/status_create.go @@ -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)}) +} diff --git a/web/handler/status_edit.go b/web/handler/status_edit.go new file mode 100644 index 0000000..17e10e3 --- /dev/null +++ b/web/handler/status_edit.go @@ -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) +} diff --git a/web/handler/status_remove.go b/web/handler/status_remove.go new file mode 100644 index 0000000..a8b4534 --- /dev/null +++ b/web/handler/status_remove.go @@ -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) + } +} diff --git a/web/handler/status_save.go b/web/handler/status_save.go new file mode 100644 index 0000000..2ccfd01 --- /dev/null +++ b/web/handler/status_save.go @@ -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) + } +} diff --git a/web/handler/status_show.go b/web/handler/status_show.go new file mode 100644 index 0000000..8b90453 --- /dev/null +++ b/web/handler/status_show.go @@ -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) +} diff --git a/web/handler/status_update.go b/web/handler/status_update.go new file mode 100644 index 0000000..293c7db --- /dev/null +++ b/web/handler/status_update.go @@ -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) +} diff --git a/web/handler/tos_show.go b/web/handler/tos_show.go new file mode 100644 index 0000000..9388b29 --- /dev/null +++ b/web/handler/tos_show.go @@ -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) +} diff --git a/web/handler/tpl.go b/web/handler/tpl.go new file mode 100644 index 0000000..6315e33 --- /dev/null +++ b/web/handler/tpl.go @@ -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] +} diff --git a/web/handler/user_show.go b/web/handler/user_show.go new file mode 100644 index 0000000..4dd06fb --- /dev/null +++ b/web/handler/user_show.go @@ -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))) +} diff --git a/web/handler/widget_show.go b/web/handler/widget_show.go new file mode 100644 index 0000000..bb537f5 --- /dev/null +++ b/web/handler/widget_show.go @@ -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('
'); +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 = '' + r.author + ' ' + 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) +} diff --git a/web/session/session.go b/web/session/session.go new file mode 100644 index 0000000..dbf92d0 --- /dev/null +++ b/web/session/session.go @@ -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 +} diff --git a/web/web.go b/web/web.go new file mode 100644 index 0000000..1848a6a --- /dev/null +++ b/web/web.go @@ -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 +}