maybe 0.1

This commit is contained in:
Ted Unangst 2019-04-09 07:59:33 -04:00
commit 7420c88f84
21 changed files with 3502 additions and 0 deletions

8
Makefile Normal file
View File

@ -0,0 +1,8 @@
all: honk
honk: *.go
go build -o honk
clean:
rm -f honk

38
README Normal file
View File

@ -0,0 +1,38 @@
honk honk
-- features
Take control of your honks and join the federation in the fight against the
evil empire.
Send honks. Receive honks. And not just honks.
Bonk, donk, tonk, all your favorite activities are here.
Purple color scheme.
The button to submit a new honk says "it's gonna be honked".
Ein Honk is a stupid person auf deutsch.
-- requirements
github.com/gorilla/mux
golang.org/x/crypto
golang.org/x/net
golang.org/x/text
humungus.tedunangst.com/r/go-sqlite3
It should be sufficient to type make after unpacking a release under go/src.
Mind your gopath.
Even on a fast machine, building from source can take several seconds.
Busy honk instances may require megabytes of memory.
-- setup
./honk init
./honk
honk expects to be fronted by a TLS terminating reverse proxy.

629
activity.go Normal file
View File

@ -0,0 +1,629 @@
//
// Copyright (c) 2019 Ted Unangst <tedu@tedunangst.com>
//
// Permission to use, copy, modify, and distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
package main
import (
"bytes"
"compress/gzip"
"crypto/rsa"
"crypto/sha256"
"database/sql"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"strconv"
"strings"
"sync"
"time"
)
func NewJunk() map[string]interface{} {
return make(map[string]interface{})
}
func WriteJunk(w io.Writer, j map[string]interface{}) error {
e := json.NewEncoder(w)
e.SetEscapeHTML(false)
e.SetIndent("", " ")
err := e.Encode(j)
return err
}
func ReadJunk(r io.Reader) (map[string]interface{}, error) {
decoder := json.NewDecoder(r)
var j map[string]interface{}
err := decoder.Decode(&j)
if err != nil {
return nil, err
}
return j, nil
}
var theonetruename = `application/ld+json; profile="https://www.w3.org/ns/activitystreams"`
var falsenames = []string{
`application/ld+json`,
`application/activity+json`,
}
var itiswhatitis = "https://www.w3.org/ns/activitystreams"
var thewholeworld = "https://www.w3.org/ns/activitystreams#Public"
func friendorfoe(ct string) bool {
ct = strings.ToLower(ct)
for _, at := range falsenames {
if strings.HasPrefix(ct, at) {
return true
}
}
return false
}
func PostJunk(keyname string, key *rsa.PrivateKey, url string, j map[string]interface{}) error {
client := http.DefaultClient
var buf bytes.Buffer
WriteJunk(&buf, j)
req, err := http.NewRequest("POST", url, &buf)
if err != nil {
return err
}
zig(keyname, key, req, buf.Bytes())
req.Header.Set("Content-Type", theonetruename)
resp, err := client.Do(req)
if err != nil {
return err
}
if resp.StatusCode != 200 && resp.StatusCode != 202 {
resp.Body.Close()
return fmt.Errorf("http post status: %d", resp.StatusCode)
}
log.Printf("successful post: %s %d", url, resp.StatusCode)
return nil
}
type gzCloser struct {
r *gzip.Reader
under io.ReadCloser
}
func (gz *gzCloser) Read(p []byte) (int, error) {
return gz.r.Read(p)
}
func (gz *gzCloser) Close() error {
defer gz.under.Close()
return gz.r.Close()
}
func GetJunk(url string) (map[string]interface{}, error) {
client := http.DefaultClient
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", theonetruename)
req.Header.Set("Accept-Encoding", "gzip")
resp, err := client.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode != 200 {
resp.Body.Close()
return nil, fmt.Errorf("http get status: %d", resp.StatusCode)
}
if strings.EqualFold(resp.Header.Get("Content-Encoding"), "gzip") {
gz, err := gzip.NewReader(resp.Body)
if err != nil {
resp.Body.Close()
return nil, err
}
resp.Body = &gzCloser{r: gz, under: resp.Body}
}
defer resp.Body.Close()
j, err := ReadJunk(resp.Body)
return j, err
}
func jsonfindinterface(ii interface{}, keys []string) interface{} {
for _, key := range keys {
idx, err := strconv.Atoi(key)
if err == nil {
m := ii.([]interface{})
if idx >= len(m) {
return nil
}
ii = m[idx]
} else {
m := ii.(map[string]interface{})
ii = m[key]
if ii == nil {
return nil
}
}
}
return ii
}
func jsonfindstring(j interface{}, keys []string) (string, bool) {
s, ok := jsonfindinterface(j, keys).(string)
return s, ok
}
func jsonfindarray(j interface{}, keys []string) ([]interface{}, bool) {
a, ok := jsonfindinterface(j, keys).([]interface{})
return a, ok
}
func jsonfindmap(j interface{}, keys []string) (map[string]interface{}, bool) {
m, ok := jsonfindinterface(j, keys).(map[string]interface{})
return m, ok
}
func jsongetstring(j interface{}, key string) (string, bool) {
return jsonfindstring(j, []string{key})
}
func jsongetarray(j interface{}, key string) ([]interface{}, bool) {
return jsonfindarray(j, []string{key})
}
func jsongetmap(j interface{}, key string) (map[string]interface{}, bool) {
return jsonfindmap(j, []string{key})
}
func sha256string(s string) string {
hasher := sha256.New()
io.WriteString(hasher, s)
sum := hasher.Sum(nil)
return fmt.Sprintf("%x", sum)
}
func savedonk(url string, name, media string) *Donk {
log.Printf("saving donk: %s", url)
var donk Donk
row := stmtFindFile.QueryRow(url)
err := row.Scan(&donk.FileID)
if err == nil {
return &donk
}
if err != nil && err != sql.ErrNoRows {
log.Printf("err querying: %s", err)
}
resp, err := http.Get(url)
if err != nil {
log.Printf("errer fetching %s: %s", url, err)
return nil
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil
}
var buf bytes.Buffer
io.Copy(&buf, resp.Body)
xid := xfiltrate()
res, err := stmtSaveFile.Exec(xid, name, url, media, buf.Bytes())
if err != nil {
log.Printf("error saving file %s: %s", url, err)
return nil
}
donk.FileID, _ = res.LastInsertId()
return &donk
}
func needxonk(userid int64, x *Honk) bool {
row := stmtFindXonk.QueryRow(userid, x.XID, x.What)
err := row.Scan(&x.ID)
if err == nil {
return false
}
if err != sql.ErrNoRows {
log.Printf("err querying xonk: %s", err)
}
return true
}
func savexonk(x *Honk) {
if x.What == "eradicate" {
log.Printf("eradicating %s by %s", x.RID, x.Honker)
_, err := stmtDeleteHonk.Exec(x.RID, x.Honker)
if err != nil {
log.Printf("error eradicating: %s", err)
}
return
}
dt := x.Date.UTC().Format(dbtimeformat)
aud := strings.Join(x.Audience, " ")
res, err := stmtSaveHonk.Exec(x.UserID, x.What, x.Honker, x.XID, x.RID, dt, x.URL, aud, x.Noise)
if err != nil {
log.Printf("err saving xonk: %s", err)
return
}
x.ID, _ = res.LastInsertId()
for _, d := range x.Donks {
_, err = stmtSaveDonk.Exec(x.ID, d.FileID)
if err != nil {
log.Printf("err saving donk: %s", err)
return
}
}
}
var boxofboxes = make(map[string]string)
var boxlock sync.Mutex
func getboxes(ident string) (string, string, error) {
boxlock.Lock()
defer boxlock.Unlock()
b, ok := boxofboxes[ident]
if ok {
if b == "" {
return "", "", fmt.Errorf("error?")
}
m := strings.Split(b, "\n")
return m[0], m[1], nil
}
j, err := GetJunk(ident)
if err != nil {
boxofboxes[ident] = ""
return "", "", err
}
inbox, _ := jsongetstring(j, "inbox")
outbox, _ := jsongetstring(j, "outbox")
boxofboxes[ident] = inbox + "\n" + outbox
return inbox, outbox, err
}
func peeppeep() {
user, _ := butwhatabout("")
honkers := gethonkers(user.ID)
for _, f := range honkers {
if f.Flavor != "peep" {
continue
}
log.Printf("getting updates: %s", f.XID)
_, outbox, err := getboxes(f.XID)
if err != nil {
log.Printf("error getting outbox: %s", err)
continue
}
log.Printf("getting outbox")
j, err := GetJunk(outbox)
if err != nil {
log.Printf("err: %s", err)
continue
}
t, _ := jsongetstring(j, "type")
if t == "OrderedCollection" {
items, _ := jsongetarray(j, "orderedItems")
if items == nil {
page1, _ := jsongetstring(j, "first")
j, err = GetJunk(page1)
if err != nil {
log.Printf("err: %s", err)
continue
}
items, _ = jsongetarray(j, "orderedItems")
}
for _, item := range items {
xonk := xonkxonk(item)
if xonk != nil && needxonk(user.ID, xonk) {
xonk.UserID = user.ID
savexonk(xonk)
}
}
}
}
}
func newphone(a []string, obj map[string]interface{}) []string {
for _, addr := range []string{"to", "cc", "attributedTo"} {
who, _ := jsongetstring(obj, addr)
if who != "" {
a = append(a, who)
}
whos, _ := jsongetarray(obj, addr)
for _, w := range whos {
who, _ := w.(string)
if who != "" {
a = append(a, who)
}
}
}
return a
}
func oneofakind(a []string) []string {
var x []string
for n, s := range a {
for i := n + 1; i < len(a); i++ {
if a[i] == s {
a[i] = ""
}
}
}
for _, s := range a {
if s != "" {
x = append(x, s)
}
}
return x
}
func xonkxonk(item interface{}) *Honk {
// id, _ := jsongetstring(item, "id")
what, _ := jsongetstring(item, "type")
dt, _ := jsongetstring(item, "published")
var audience []string
var err error
var xid, rid, url, content string
var obj map[string]interface{}
switch what {
case "Announce":
xid, _ = jsongetstring(item, "object")
log.Printf("getting bonk: %s", xid)
obj, err = GetJunk(xid)
if err != nil {
log.Printf("error regetting: %s", err)
}
what = "bonk"
case "Create":
obj, _ = jsongetmap(item, "object")
what = "honk"
case "Delete":
obj, _ = jsongetmap(item, "object")
what = "eradicate"
default:
log.Printf("unknown activity: %s", what)
return nil
}
who, _ := jsongetstring(item, "actor")
var xonk Honk
if obj != nil {
ot, _ := jsongetstring(obj, "type")
url, _ = jsongetstring(obj, "url")
if ot == "Note" {
audience = newphone(audience, obj)
xid, _ = jsongetstring(obj, "id")
content, _ = jsongetstring(obj, "content")
rid, _ = jsongetstring(obj, "inReplyTo")
if what == "honk" && rid != "" {
what = "tonk"
}
}
if ot == "Tombstone" {
rid, _ = jsongetstring(obj, "id")
}
atts, _ := jsongetarray(obj, "attachment")
for _, att := range atts {
at, _ := jsongetstring(att, "type")
mt, _ := jsongetstring(att, "mediaType")
u, _ := jsongetstring(att, "url")
name, _ := jsongetstring(att, "name")
if at == "Document" {
mt = strings.ToLower(mt)
log.Printf("attachment: %s %s", mt, u)
if mt == "image/jpeg" || mt == "image/png" ||
mt == "image/gif" {
donk := savedonk(u, name, mt)
if donk != nil {
xonk.Donks = append(xonk.Donks, donk)
}
}
}
}
}
audience = append(audience, who)
audience = oneofakind(audience)
xonk.What = what
xonk.Honker = who
xonk.XID = xid
xonk.RID = rid
xonk.Date, _ = time.Parse(time.RFC3339, dt)
xonk.URL = url
xonk.Noise = content
xonk.Audience = audience
return &xonk
}
func rubadubdub(user *WhatAbout, req map[string]interface{}) {
xid, _ := jsongetstring(req, "id")
reqactor, _ := jsongetstring(req, "actor")
j := NewJunk()
j["@context"] = itiswhatitis
j["id"] = user.URL + "/dub/" + xid
j["type"] = "Accept"
j["actor"] = user.URL
j["to"] = reqactor
j["published"] = time.Now().UTC().Format(time.RFC3339)
j["object"] = req
WriteJunk(os.Stdout, j)
actor, _ := jsongetstring(req, "actor")
inbox, _, err := getboxes(actor)
if err != nil {
log.Printf("can't get dub box: %s", err)
return
}
keyname, key := ziggy(user)
err = PostJunk(keyname, key, inbox, j)
if err != nil {
log.Printf("can't rub a dub: %s", err)
return
}
stmtSaveDub.Exec(user.ID, actor, actor, "dub")
}
func subsub(user *WhatAbout, xid string) {
j := NewJunk()
j["@context"] = itiswhatitis
j["id"] = user.URL + "/sub/" + xid
j["type"] = "Follow"
j["actor"] = user.URL
j["to"] = xid
j["object"] = xid
j["published"] = time.Now().UTC().Format(time.RFC3339)
inbox, _, err := getboxes(xid)
if err != nil {
log.Printf("can't send follow: %s", err)
return
}
WriteJunk(os.Stdout, j)
keyname, key := ziggy(user)
err = PostJunk(keyname, key, inbox, j)
if err != nil {
log.Printf("failed to subsub: %s", err)
}
}
func jonkjonk(user *WhatAbout, h *Honk) (map[string]interface{}, map[string]interface{}) {
dt := h.Date.Format(time.RFC3339)
var jo map[string]interface{}
j := NewJunk()
j["id"] = user.URL + "/" + h.What + "/" + h.XID
j["actor"] = user.URL
j["published"] = dt
j["to"] = h.Audience[0]
if len(h.Audience) > 1 {
j["cc"] = h.Audience[1:]
}
switch h.What {
case "tonk":
fallthrough
case "honk":
j["type"] = "Create"
jo = NewJunk()
jo["id"] = user.URL + "/h/" + h.XID
jo["type"] = "Note"
jo["published"] = dt
jo["url"] = user.URL + "/h/" + h.XID
jo["attributedTo"] = user.URL
if h.RID != "" {
jo["inReplyTo"] = h.RID
}
jo["to"] = h.Audience[0]
if len(h.Audience) > 1 {
jo["cc"] = h.Audience[1:]
}
jo["content"] = h.Noise
g := bunchofgrapes(h.Noise)
if len(g) > 0 {
var tags []interface{}
for _, m := range g {
t := NewJunk()
t["type"] = "Mention"
t["name"] = m.who
t["href"] = m.where
tags = append(tags, t)
}
jo["tag"] = tags
}
var atts []interface{}
for _, d := range h.Donks {
jd := NewJunk()
jd["mediaType"] = d.Media
jd["name"] = d.Name
jd["type"] = "Document"
jd["url"] = d.URL
atts = append(atts, jd)
}
if len(atts) > 0 {
jo["attachment"] = atts
}
j["object"] = jo
case "bonk":
j["type"] = "Announce"
j["object"] = h.XID
}
return j, jo
}
func honkworldwide(user *WhatAbout, honk *Honk) {
aud := append([]string{}, honk.Audience...)
for i, a := range aud {
if a == thewholeworld || a == user.URL {
aud[i] = ""
}
}
keyname, key := ziggy(user)
jonk, _ := jonkjonk(user, honk)
jonk["@context"] = itiswhatitis
for _, f := range getdubs(user.ID) {
inbox, _, err := getboxes(f.XID)
if err != nil {
log.Printf("error getting inbox %s: %s", f.XID, err)
continue
}
err = PostJunk(keyname, key, inbox, jonk)
if err != nil {
log.Printf("failed to post json to %s: %s", inbox, err)
}
for i, a := range aud {
if a == f.XID {
aud[i] = ""
}
}
}
for _, a := range aud {
if a != "" && !strings.HasSuffix(a, "/followers") {
inbox, _, err := getboxes(a)
if err != nil {
log.Printf("error getting inbox %s: %s", a, err)
continue
}
err = PostJunk(keyname, key, inbox, jonk)
if err != nil {
log.Printf("failed to post json to %s: %s", inbox, err)
}
}
}
}
func asjonker(user *WhatAbout) map[string]interface{} {
whatabout := obfusbreak(user.About)
j := NewJunk()
j["@context"] = itiswhatitis
j["id"] = user.URL
j["type"] = "Person"
j["inbox"] = user.URL + "/inbox"
j["outbox"] = user.URL + "/outbox"
j["name"] = user.Display
j["preferredUsername"] = user.Name
j["summary"] = whatabout
j["url"] = user.URL
a := NewJunk()
a["type"] = "icon"
a["mediaType"] = "image/png"
a["url"] = fmt.Sprintf("https://%s/a?a=%s", serverName, url.QueryEscape(user.URL))
j["icon"] = a
k := NewJunk()
k["id"] = user.URL + "#key"
k["owner"] = user.URL
k["publicKeyPem"] = user.Key
j["publicKey"] = k
return j
}

46
avatar.go Normal file
View File

@ -0,0 +1,46 @@
package main
import (
"bytes"
"crypto/sha512"
"image"
"image/png"
)
func avatar(name string) []byte {
h := sha512.New()
h.Write([]byte(name))
s := h.Sum(nil)
img := image.NewNRGBA(image.Rect(0, 0, 64, 64))
for i := 0; i < 64; i++ {
for j := 0; j < 64; j++ {
p := i*img.Stride + j*4
xx := i/16*16 + j/16
x := s[xx]
if x < 64 {
img.Pix[p+0] = 32
img.Pix[p+1] = 0
img.Pix[p+2] = 64
img.Pix[p+3] = 255
} else if x < 128 {
img.Pix[p+0] = 32
img.Pix[p+1] = 0
img.Pix[p+2] = 92
img.Pix[p+3] = 255
} else if x < 192 {
img.Pix[p+0] = 64
img.Pix[p+1] = 0
img.Pix[p+2] = 128
img.Pix[p+3] = 255
} else {
img.Pix[p+0] = 96
img.Pix[p+1] = 0
img.Pix[p+2] = 160
img.Pix[p+3] = 255
}
}
}
var buf bytes.Buffer
png.Encode(&buf, img)
return buf.Bytes()
}

1217
honk.go Normal file

File diff suppressed because it is too large Load Diff

191
html.go Normal file
View File

@ -0,0 +1,191 @@
//
// Copyright (c) 2019 Ted Unangst <tedu@tedunangst.com>
//
// Permission to use, copy, modify, and distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
package main
import (
"fmt"
"html/template"
"io"
"log"
"net/url"
"regexp"
"sort"
"strings"
"golang.org/x/net/html"
)
var permittedtags = []string{"div", "h1", "h2", "h3", "h4", "h5", "h6",
"table", "thead", "tbody", "th", "tr", "td",
"p", "br", "pre", "code", "blockquote", "strong", "em", "b", "i", "s", "sup",
"ol", "ul", "li"}
var permittedattr = []string{"colspan", "rowspan"}
var bannedtags = []string{"script", "style"}
func init() {
sort.Strings(permittedtags)
sort.Strings(permittedattr)
sort.Strings(bannedtags)
}
func contains(array []string, tag string) bool {
idx := sort.SearchStrings(array, tag)
return idx < len(array) && array[idx] == tag
}
func getattr(node *html.Node, attr string) string {
for _, a := range node.Attr {
if a.Key == attr {
return a.Val
}
}
return ""
}
func hasclass(node *html.Node, class string) bool {
return strings.Contains(" "+getattr(node, "class")+" ", " "+class+" ")
}
func writetag(w io.Writer, node *html.Node) {
io.WriteString(w, "<")
io.WriteString(w, node.Data)
for _, attr := range node.Attr {
if contains(permittedattr, attr.Key) {
fmt.Fprintf(w, ` %s="%s"`, attr.Key, html.EscapeString(attr.Val))
}
}
io.WriteString(w, ">")
}
func render(w io.Writer, node *html.Node) {
switch node.Type {
case html.ElementNode:
tag := node.Data
switch {
case tag == "a":
href := getattr(node, "href")
hrefurl, err := url.Parse(href)
if err != nil {
href = "#BROKEN-" + href
} else {
href = hrefurl.String()
}
fmt.Fprintf(w, `<a href="%s" rel=noreferrer>`, html.EscapeString(href))
case tag == "img":
div := replaceimg(node)
if div != "skip" {
io.WriteString(w, div)
}
case tag == "span":
case tag == "iframe":
src := html.EscapeString(getattr(node, "src"))
fmt.Fprintf(w, `&lt;iframe src="<a href="%s">%s</a>"&gt;`, src, src)
case contains(permittedtags, tag):
writetag(w, node)
case contains(bannedtags, tag):
return
}
case html.TextNode:
io.WriteString(w, html.EscapeString(node.Data))
}
for c := node.FirstChild; c != nil; c = c.NextSibling {
render(w, c)
}
if node.Type == html.ElementNode {
tag := node.Data
if tag == "a" || (contains(permittedtags, tag) && tag != "br") {
fmt.Fprintf(w, "</%s>", tag)
}
if tag == "p" || tag == "div" {
io.WriteString(w, "\n")
}
}
}
func replaceimg(node *html.Node) string {
src := getattr(node, "src")
alt := getattr(node, "alt")
//title := getattr(node, "title")
if hasclass(node, "Emoji") && alt != "" {
return html.EscapeString(alt)
}
return html.EscapeString(fmt.Sprintf(`<img src="%s">`, src))
}
func cleannode(node *html.Node) template.HTML {
var buf strings.Builder
render(&buf, node)
return template.HTML(buf.String())
}
func cleanstring(shtml string) template.HTML {
reader := strings.NewReader(shtml)
body, err := html.Parse(reader)
if err != nil {
log.Printf("error parsing html: %s", err)
return ""
}
return cleannode(body)
}
func textonly(w io.Writer, node *html.Node) {
switch node.Type {
case html.ElementNode:
tag := node.Data
switch {
case tag == "a":
href := getattr(node, "href")
fmt.Fprintf(w, `<a href="%s">`, href)
case tag == "img":
io.WriteString(w, "<img>")
case contains(bannedtags, tag):
return
}
case html.TextNode:
io.WriteString(w, node.Data)
}
for c := node.FirstChild; c != nil; c = c.NextSibling {
textonly(w, c)
}
if node.Type == html.ElementNode {
tag := node.Data
if tag == "a" {
fmt.Fprintf(w, "</%s>", tag)
}
if tag == "p" || tag == "div" {
io.WriteString(w, "\n")
}
}
}
var re_whitespaceeater = regexp.MustCompile("[ \t\r]*\n[ \t\r]*")
var re_blanklineeater = regexp.MustCompile("\n\n+")
var re_tabeater = regexp.MustCompile("[ \t]+")
func htmltotext(shtml template.HTML) string {
reader := strings.NewReader(string(shtml))
body, _ := html.Parse(reader)
var buf strings.Builder
textonly(&buf, body)
rv := buf.String()
rv = re_whitespaceeater.ReplaceAllLiteralString(rv, "\n")
rv = re_blanklineeater.ReplaceAllLiteralString(rv, "\n\n")
rv = re_tabeater.ReplaceAllLiteralString(rv, " ")
for len(rv) > 0 && rv[0] == '\n' {
rv = rv[1:]
}
return rv
}

148
image.go Normal file
View File

@ -0,0 +1,148 @@
//
// Copyright (c) 2019 Ted Unangst <tedu@tedunangst.com>
//
// Permission to use, copy, modify, and distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
package main
import (
"bytes"
"fmt"
"image"
"image/jpeg"
"image/png"
"math"
)
func lineate(s uint8) float64 {
x := float64(s)
x /= 255.0
if x < 0.04045 {
x /= 12.92
} else {
x += 0.055
x /= 1.055
x = math.Pow(x, 2.4)
}
return x
}
func delineate(x float64) uint8 {
if x > 0.0031308 {
x = math.Pow(x, 1/2.4)
x *= 1.055
x -= 0.055
} else {
x *= 12.92
}
x *= 255.0
return uint8(x)
}
func blend(d []byte, s1, s2, s3, s4 int) byte {
l1 := lineate(d[s1])
l2 := lineate(d[s2])
l3 := lineate(d[s3])
l4 := lineate(d[s4])
return delineate((l1 + l2 + l3 + l4) / 4.0)
}
func squish(d []byte, s1, s2, s3, s4 int) byte {
return uint8((uint32(s1) + uint32(s2)) / 2)
}
func vacuumwrap(img image.Image, format string) ([]byte, string, error) {
maxdimension := 2048
for img.Bounds().Max.X > maxdimension || img.Bounds().Max.Y > maxdimension {
switch oldimg := img.(type) {
case *image.NRGBA:
w, h := oldimg.Rect.Max.X/2, oldimg.Rect.Max.Y/2
newimg := image.NewNRGBA(image.Rectangle{Max: image.Point{X: w, Y: h}})
for j := 0; j < h; j++ {
for i := 0; i < w; i++ {
p := newimg.Stride*j + i*4
q1 := oldimg.Stride*(j*2+0) + i*4*2
q2 := oldimg.Stride*(j*2+1) + i*4*2
newimg.Pix[p+0] = blend(oldimg.Pix, q1+0, q1+4, q2+0, q2+4)
newimg.Pix[p+1] = blend(oldimg.Pix, q1+1, q1+5, q2+1, q2+5)
newimg.Pix[p+2] = blend(oldimg.Pix, q1+2, q1+6, q2+2, q2+6)
newimg.Pix[p+3] = squish(oldimg.Pix, q1+3, q1+7, q2+3, q2+7)
}
}
img = newimg
case *image.YCbCr:
w, h := oldimg.Rect.Max.X/2, oldimg.Rect.Max.Y/2
newimg := image.NewYCbCr(image.Rectangle{Max: image.Point{X: w, Y: h}},
oldimg.SubsampleRatio)
for j := 0; j < h; j++ {
for i := 0; i < w; i++ {
p := newimg.YStride*j + i
q1 := oldimg.YStride*(j*2+0) + i*2
q2 := oldimg.YStride*(j*2+1) + i*2
newimg.Y[p+0] = blend(oldimg.Y, q1+0, q1+1, q2+0, q2+1)
}
}
switch newimg.SubsampleRatio {
case image.YCbCrSubsampleRatio444:
w, h = w, h
case image.YCbCrSubsampleRatio422:
w, h = w/2, h
case image.YCbCrSubsampleRatio420:
w, h = w/2, h/2
case image.YCbCrSubsampleRatio440:
w, h = w, h/2
case image.YCbCrSubsampleRatio411:
w, h = w/4, h
case image.YCbCrSubsampleRatio410:
w, h = w/4, h/2
}
for j := 0; j < h; j++ {
for i := 0; i < w; i++ {
p := newimg.CStride*j + i
q1 := oldimg.CStride*(j*2+0) + i*2
q2 := oldimg.CStride*(j*2+1) + i*2
newimg.Cb[p+0] = blend(oldimg.Cb, q1+0, q1+1, q2+0, q2+1)
newimg.Cr[p+0] = blend(oldimg.Cr, q1+0, q1+1, q2+0, q2+1)
}
}
img = newimg
default:
return nil, "", fmt.Errorf("can't support image format")
}
}
maxsize := 512 * 1024
quality := 80
var buf bytes.Buffer
for {
switch format {
case "png":
png.Encode(&buf, img)
case "jpeg":
jpeg.Encode(&buf, img, &jpeg.Options{Quality: quality})
default:
return nil, "", fmt.Errorf("can't encode format: %s", format)
}
if buf.Len() > maxsize && quality > 30 {
switch format {
case "png":
format = "jpeg"
case "jpeg":
quality -= 10
}
buf.Reset()
continue
}
break
}
return buf.Bytes(), format, nil
}

331
login.go Normal file
View File

@ -0,0 +1,331 @@
//
// Copyright (c) 2019 Ted Unangst <tedu@tedunangst.com>
//
// Permission to use, copy, modify, and distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
package main
import (
"context"
"crypto/rand"
"crypto/sha512"
"crypto/subtle"
"database/sql"
"fmt"
"hash"
"io"
"log"
"net/http"
"reflect"
"regexp"
"strings"
"sync"
"time"
"golang.org/x/crypto/bcrypt"
)
type keytype struct{}
var thekey keytype
func LoginChecker(handler http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userinfo, ok := checkauthcookie(r)
if ok {
ctx := context.WithValue(r.Context(), thekey, userinfo)
r = r.WithContext(ctx)
}
handler.ServeHTTP(w, r)
})
}
func LoginRequired(handler http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ok := GetUserInfo(r) != nil
if !ok {
loginredirect(w, r)
return
}
handler.ServeHTTP(w, r)
})
}
func GetUserInfo(r *http.Request) *UserInfo {
userinfo, ok := r.Context().Value(thekey).(*UserInfo)
if !ok {
return nil
}
return userinfo
}
func calculateCSRF(salt, action, auth string) string {
hasher := sha512.New512_256()
zero := []byte{0}
hasher.Write(zero)
hasher.Write([]byte(auth))
hasher.Write(zero)
hasher.Write([]byte(csrfkey))
hasher.Write(zero)
hasher.Write([]byte(salt))
hasher.Write(zero)
hasher.Write([]byte(action))
hasher.Write(zero)
hash := hexsum(hasher)
return salt + hash
}
func GetCSRF(action string, r *http.Request) string {
auth := getauthcookie(r)
if auth == "" {
return ""
}
hasher := sha512.New512_256()
io.CopyN(hasher, rand.Reader, 32)
salt := hexsum(hasher)
return calculateCSRF(salt, action, auth)
}
func CheckCSRF(action string, r *http.Request) bool {
auth := getauthcookie(r)
if auth == "" {
return false
}
csrf := r.FormValue("CSRF")
if len(csrf) != authlen*2 {
return false
}
salt := csrf[0:authlen]
rv := calculateCSRF(salt, action, auth)
ok := subtle.ConstantTimeCompare([]byte(rv), []byte(csrf)) == 1
return ok
}
func CSRFWrap(action string, handler http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ok := CheckCSRF(action, r)
if !ok {
http.Error(w, "invalid csrf", 403)
return
}
handler.ServeHTTP(w, r)
})
}
func loginredirect(w http.ResponseWriter, r *http.Request) {
http.SetCookie(w, &http.Cookie{
Name: "auth",
Value: "",
MaxAge: -1,
Secure: securecookies,
HttpOnly: true,
})
http.Redirect(w, r, "/login", http.StatusSeeOther)
}
var authregex = regexp.MustCompile("^[[:alnum:]]+$")
var authlen = 32
var stmtUserName, stmtUserAuth, stmtSaveAuth, stmtDeleteAuth *sql.Stmt
var csrfkey string
var securecookies bool
func LoginInit(db *sql.DB) {
var err error
stmtUserName, err = db.Prepare("select userid, hash from users where username = ?")
if err != nil {
log.Fatal(err)
}
var userinfo UserInfo
t := reflect.TypeOf(userinfo)
var fields []string
for i := 0; i < t.NumField(); i++ {
f := t.Field(i)
fields = append(fields, strings.ToLower(f.Name))
}
stmtUserAuth, err = db.Prepare(fmt.Sprintf("select %s from users where userid = (select userid from auth where hash = ?)", strings.Join(fields, ", ")))
if err != nil {
log.Fatal(err)
}
stmtSaveAuth, err = db.Prepare("insert into auth (userid, hash) values (?, ?)")
if err != nil {
log.Fatal(err)
}
stmtDeleteAuth, err = db.Prepare("delete from auth where userid = ?")
if err != nil {
log.Fatal(err)
}
debug := false
getconfig("debug", &debug)
securecookies = !debug
getconfig("csrfkey", &csrfkey)
}
var authinprogress = make(map[string]bool)
var authprogressmtx sync.Mutex
func rateandwait(username string) bool {
authprogressmtx.Lock()
defer authprogressmtx.Unlock()
if authinprogress[username] {
return false
}
authinprogress[username] = true
go func(name string) {
time.Sleep(1 * time.Second / 2)
authprogressmtx.Lock()
authinprogress[name] = false
authprogressmtx.Unlock()
}(username)
return true
}
func getauthcookie(r *http.Request) string {
cookie, err := r.Cookie("auth")
if err != nil {
return ""
}
auth := cookie.Value
if !(len(auth) == authlen && authregex.MatchString(auth)) {
log.Printf("login: bad auth: %s", auth)
return ""
}
return auth
}
func checkauthcookie(r *http.Request) (*UserInfo, bool) {
auth := getauthcookie(r)
if auth == "" {
return nil, false
}
hasher := sha512.New512_256()
hasher.Write([]byte(auth))
authhash := hexsum(hasher)
row := stmtUserAuth.QueryRow(authhash)
var userinfo UserInfo
v := reflect.ValueOf(&userinfo).Elem()
var ptrs []interface{}
for i := 0; i < v.NumField(); i++ {
f := v.Field(i)
ptrs = append(ptrs, f.Addr().Interface())
}
err := row.Scan(ptrs...)
if err != nil {
if err == sql.ErrNoRows {
log.Printf("login: no auth found")
} else {
log.Printf("login: error scanning auth row: %s", err)
}
return nil, false
}
return &userinfo, true
}
func loaduser(username string) (int64, string, bool) {
row := stmtUserName.QueryRow(username)
var userid int64
var hash string
err := row.Scan(&userid, &hash)
if err != nil {
if err == sql.ErrNoRows {
log.Printf("login: no username found")
} else {
log.Printf("login: error loading username: %s", err)
}
return -1, "", false
}
return userid, hash, true
}
var userregex = regexp.MustCompile("^[[:alnum:]]+$")
var userlen = 32
var passlen = 128
func hexsum(h hash.Hash) string {
return fmt.Sprintf("%x", h.Sum(nil))[0:authlen]
}
func dologin(w http.ResponseWriter, r *http.Request) {
username := r.FormValue("username")
password := r.FormValue("password")
if len(username) == 0 || len(username) > userlen ||
!userregex.MatchString(username) || len(password) == 0 ||
len(password) > passlen {
log.Printf("login: invalid password attempt")
loginredirect(w, r)
return
}
userid, hash, ok := loaduser(username)
if !ok {
loginredirect(w, r)
return
}
if !rateandwait(username) {
loginredirect(w, r)
return
}
err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(password))
if err != nil {
log.Printf("login: incorrect password")
loginredirect(w, r)
return
}
hasher := sha512.New512_256()
io.CopyN(hasher, rand.Reader, 32)
hash = hexsum(hasher)
http.SetCookie(w, &http.Cookie{
Name: "auth",
Value: hash,
MaxAge: 3600 * 24 * 30,
Secure: securecookies,
HttpOnly: true,
})
hasher.Reset()
hasher.Write([]byte(hash))
authhash := hexsum(hasher)
_, err = stmtSaveAuth.Exec(userid, authhash)
if err != nil {
log.Printf("error saving auth: %s", err)
}
log.Printf("login: successful login")
http.Redirect(w, r, "/", http.StatusSeeOther)
}
func dologout(w http.ResponseWriter, r *http.Request) {
userinfo, ok := checkauthcookie(r)
if ok && CheckCSRF("logout", r) {
_, err := stmtDeleteAuth.Exec(userinfo.UserID)
if err != nil {
log.Printf("login: error deleting old auth: %s", err)
http.Redirect(w, r, "/", http.StatusSeeOther)
return
}
http.SetCookie(w, &http.Cookie{
Name: "auth",
Value: "",
MaxAge: -1,
Secure: securecookies,
HttpOnly: true,
})
}
http.Redirect(w, r, "/", http.StatusSeeOther)
}

65
rss.go Normal file
View File

@ -0,0 +1,65 @@
//
// Copyright (c) 2018 Ted Unangst <tedu@tedunangst.com>
//
// Permission to use, copy, modify, and distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
package main
import (
"encoding/xml"
"io"
)
type Rss struct {
XMLName xml.Name `xml:"rss"`
Version string `xml:"version,attr"`
Feed *RssFeed
}
type RssFeed struct {
XMLName xml.Name `xml:"channel"`
Title string `xml:"title"`
Link string `xml:"link"`
Description string `xml:"description"`
FeedImage *RssFeedImage
Items []*RssItem
}
type RssFeedImage struct {
XMLName xml.Name `xml:"image"`
URL string `xml:"url"`
Title string `xml:"title"`
Link string `xml:"link"`
}
type RssItem struct {
XMLName xml.Name `xml:"item"`
Title string `xml:"title"`
Description RssCData `xml:"description"`
Link string `xml:"link"`
PubDate string `xml:"pubDate"`
}
type RssCData struct {
Data string `xml:",cdata"`
}
func (fd *RssFeed) Write(w io.Writer) error {
r := Rss{Version: "2.0", Feed: fd}
io.WriteString(w, xml.Header)
enc := xml.NewEncoder(w)
enc.Indent("", " ")
err := enc.Encode(r)
io.WriteString(w, "\n")
return err
}

20
schema.sql Normal file
View File

@ -0,0 +1,20 @@
CREATE TABLE honks (honkid integer primary key, userid integer, what text, honker text, xid text, rid text, dt text, url text, noise text);
CREATE TABLE donks (honkid integer, fileid integer);
CREATE TABLE files(fileid integer primary key, xid text, name text, url text, media text, content blob);
CREATE TABLE honkers (honkerid integer primary key, userid integer, name text, xid text, flavor text, pubkey text);
create index idx_honksxid on honks(xid);
create index idx_honkshonker on honks(honker);
create index idx_honkerxid on honkers(xid);
create index idx_filesxid on files(xid);
create index idx_filesurl on files(url);
CREATE TABLE config (key text, value text);
CREATE TABLE users (userid integer primary key, username text, hash text, displayname text, about text, pubkey text, seckey text);
CREATE TABLE auth (authid integer primary key, userid integer, hash text);
CREATE INDEX idxusers_username on users(username);
CREATE INDEX idxauth_userid on auth(userid);
CREATE INDEX idxauth_hash on auth(hash);

81
template.go Normal file
View File

@ -0,0 +1,81 @@
//
// Copyright (c) 2019 Ted Unangst <tedu@tedunangst.com>
//
// Permission to use, copy, modify, and distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
package main
import (
"errors"
"html/template"
"io"
"log"
)
type Template struct {
names []string
templates *template.Template
reload bool
}
func mapmaker(values ...interface{}) (map[string]interface{}, error) {
if len(values)%2 != 0 {
return nil, errors.New("need arguments in pairs")
}
dict := make(map[string]interface{}, len(values)/2)
for i := 0; i < len(values); i += 2 {
key, ok := values[i].(string)
if !ok {
return nil, errors.New("key must be string")
}
dict[key] = values[i+1]
}
return dict, nil
}
func loadtemplates(filenames ...string) (*template.Template, error) {
templates := template.New("")
templates.Funcs(template.FuncMap{
"map": mapmaker,
})
templates, err := templates.ParseFiles(filenames...)
if err != nil {
return nil, err
}
return templates, nil
}
func (t *Template) ExecuteTemplate(w io.Writer, name string, data interface{}) error {
if t.reload {
templates, err := loadtemplates(t.names...)
if err != nil {
return err
}
return templates.ExecuteTemplate(w, name, data)
}
return t.templates.ExecuteTemplate(w, name, data)
}
func ParseTemplates(reload bool, filenames ...string) *Template {
t := new(Template)
t.names = filenames
t.reload = reload
templates, err := loadtemplates(filenames...)
if err != nil {
log.Panic(err)
}
if !reload {
t.templates = templates
}
return t
}

227
util.go Normal file
View File

@ -0,0 +1,227 @@
//
// Copyright (c) 2019 Ted Unangst <tedu@tedunangst.com>
//
// Permission to use, copy, modify, and distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
package main
import (
"bufio"
"crypto/rand"
"crypto/sha512"
"database/sql"
"fmt"
"io/ioutil"
"log"
"net"
"os"
"os/signal"
"strings"
"golang.org/x/crypto/bcrypt"
_ "humungus.tedunangst.com/r/go-sqlite3"
)
var savedstyleparam string
func getstyleparam() string {
if savedstyleparam != "" {
return savedstyleparam
}
data, _ := ioutil.ReadFile("views/style.css")
hasher := sha512.New()
hasher.Write(data)
return fmt.Sprintf("?v=%.8x", hasher.Sum(nil))
}
var dbtimeformat = "2006-01-02 15:04:05"
var alreadyopendb *sql.DB
var dbname = "honk.db"
var stmtConfig *sql.Stmt
func initdb() {
schema, err := ioutil.ReadFile("schema.sql")
if err != nil {
log.Fatal(err)
}
_, err = os.Stat(dbname)
if err == nil {
log.Fatalf("%s already exists", dbname)
}
db, err := sql.Open("sqlite3", dbname)
if err != nil {
log.Fatal(err)
}
defer func() {
os.Remove(dbname)
os.Exit(1)
}()
c := make(chan os.Signal)
signal.Notify(c, os.Interrupt)
go func() {
<-c
fmt.Printf("\x1b[?12;25h\x1b[0m")
fmt.Printf("\n")
os.Remove(dbname)
os.Exit(1)
}()
for _, line := range strings.Split(string(schema), ";") {
_, err = db.Exec(line)
if err != nil {
log.Print(err)
return
}
}
defer db.Close()
r := bufio.NewReader(os.Stdin)
fmt.Printf("username: ")
name, err := r.ReadString('\n')
if err != nil {
log.Print(err)
return
}
name = name[:len(name)-1]
if len(name) < 1 {
log.Print("that's way too short")
return
}
fmt.Printf("password: \x1b[?25l\x1b[%d;%dm \x1b[16D", 30, 40)
pass, err := r.ReadString('\n')
fmt.Printf("\x1b[0m\x1b[?12;25h")
if err != nil {
log.Fatal(err)
return
}
pass = pass[:len(pass)-1]
if len(pass) < 6 {
log.Print("that's way too short")
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(pass), 12)
if err != nil {
log.Print(err)
return
}
_, err = db.Exec("insert into users (username, hash) values (?, ?)", name, hash)
if err != nil {
log.Print(err)
return
}
fmt.Printf("listen address: ")
addr, err := r.ReadString('\n')
if err != nil {
log.Print(err)
return
}
addr = addr[:len(addr)-1]
if len(addr) < 1 {
log.Print("that's way too short")
return
}
_, err = db.Exec("insert into config (key, value) values (?, ?)", "listenaddr", addr)
if err != nil {
log.Print(err)
return
}
fmt.Printf("server name: ")
addr, err = r.ReadString('\n')
if err != nil {
log.Print(err)
return
}
addr = addr[:len(addr)-1]
if len(addr) < 1 {
log.Print("that's way too short")
return
}
_, err = db.Exec("insert into config (key, value) values (?, ?)", "servername", addr)
if err != nil {
log.Print(err)
return
}
var randbytes [16]byte
rand.Read(randbytes[:])
key := fmt.Sprintf("%x", randbytes)
_, err = db.Exec("insert into config (key, value) values (?, ?)", "csrfkey", key)
if err != nil {
log.Print(err)
return
}
err = finishusersetup()
if err != nil {
log.Print(err)
return
}
db.Exec("insert into config (key, value) values (?, ?)", "debug", 1)
db.Close()
fmt.Printf("done.\n")
os.Exit(0)
}
func opendatabase() *sql.DB {
if alreadyopendb != nil {
return alreadyopendb
}
var err error
_, err = os.Stat(dbname)
if err != nil {
log.Fatalf("unable to open database: %s", err)
}
db, err := sql.Open("sqlite3", dbname)
if err != nil {
log.Fatalf("unable to open database: %s", err)
}
stmtConfig, err = db.Prepare("select value from config where key = ?")
if err != nil {
log.Fatal(err)
}
alreadyopendb = db
return db
}
func getconfig(key string, value interface{}) error {
row := stmtConfig.QueryRow(key)
err := row.Scan(value)
if err == sql.ErrNoRows {
err = nil
}
return err
}
func openListener() (net.Listener, error) {
var listenAddr string
err := getconfig("listenaddr", &listenAddr)
if err != nil {
return nil, err
}
if listenAddr == "" {
return nil, fmt.Errorf("must have listenaddr")
}
proto := "tcp"
if listenAddr[0] == '/' {
proto = "unix"
err := os.Remove(listenAddr)
if err != nil && !os.IsNotExist(err) {
log.Printf("unable to unlink socket: %s", err)
}
}
listener, err := net.Listen(proto, listenAddr)
if err != nil {
return nil, err
}
if proto == "unix" {
os.Chmod(listenAddr, 0777)
}
return listener, nil
}

21
views/header.html Normal file
View File

@ -0,0 +1,21 @@
<!doctype html>
<html>
<head>
<title>honk</title>
<link href="/style.css{{ .StyleParam }}" rel="stylesheet">
<link href="/icon.png" rel="icon">
</head>
<body>
<div class="header">
<span><a href="/">honk</a></span>
{{ if .ShowRSS }}
<span><a href="/rss">rss</a></span>
{{ end }}
{{ if .UserInfo }}
<span><a href="/u/{{ .UserInfo.Username }}">{{ .UserInfo.Username }}</a></span>
<span><a href="/honkers">honkers</a></span>
<span><a href="/logout?CSRF={{ .LogoutCSRF }}">logout</a></span>
{{ else }}
<span><a href="/login">login</a></span>
{{ end }}
</div>

27
views/homepage.html Normal file
View File

@ -0,0 +1,27 @@
{{ template "header.html" . }}
<div class="center">
<div class="info">
<p>{{ .ServerMessage }}
{{ if .HonkCSRF }}
{{ template "honkform.html" . }}
{{ end }}
</div>
<div>
{{ $BonkCSRF := .HonkCSRF }}
{{ range .Honks }}
{{ template "honk.html" map "Honk" . "Bonk" $BonkCSRF }}
{{ end }}
</div>
{{ if $BonkCSRF }}
<script>
function post(url, data) {
var x = new XMLHttpRequest()
x.open("POST", url)
x.setRequestHeader("Content-Type", "application/x-www-form-urlencoded")
x.send(data)
}
function bonk(xid) {
post("/bonk", "CSRF={{ $BonkCSRF }}&xid=" + xid)
}
</script>
{{ end }}

14
views/honk.html Normal file
View File

@ -0,0 +1,14 @@
<div class="honk {{ if eq .Honk.What "tonked" }} tonk {{ end }}">
{{ with .Honk }}
<div class="title"><img alt="avatar" src="/a?a={{ .Honker}}"><p><a href="{{ .Honker }}" rel=noreferrer>{{ .Username }}</a> <span class="clip">{{ .What }} {{ .Date.Format "02 Jan 2006 15:04" }} <a href="{{ .URL }}" rel=noreferrer>{{ .URL }}</a></span></div>
<div class="noise"><p>{{ .HTML }}</div>
{{ range .Donks }}
<p><a href="/d/{{ .XID }}"><img src="/d/{{ .XID }}" title="{{ .URL }}"></a>
{{ end }}
{{ end }}
{{ if .Bonk }}
<p>
<button onclick="bonk('{{ .Honk.XID }}'); return false;"><a href="/bonk">bonk</a></button>
<button style="margin-left: 4em;" onclick="showhonkform('{{ .Honk.XID }}', '{{ .Honk.Username }}'); return false;"><a href="/newhonk">tonk</a></button>
{{ end }}
</div>

25
views/honkers.html Normal file
View File

@ -0,0 +1,25 @@
{{ template "header.html" . }}
<div class="center">
<div class="info">
<p>
<form action="/savehonker" method="POST">
<span class="title">add new honker</span>
<input type="hidden" name="CSRF" value="{{ .HonkerCSRF }}">
<p><input tabindex=1 type="text" name="name" value="" autocomplete=off> - name
<p><input tabindex=1 type="text" name="url" value="" autocomplete=off> - url
<p><span><label for="peep">just peeping:</label>
<input tabindex=1 type="checkbox" id="peep" name="peep" value="peep"><span></span></span>
<p><input tabindex=1 type="submit" name="add honker" value="add honker">
</form>
</div>
{{ range .Honkers }}
<div class="honk" id="honker{{ .ID }}">
<p>
<span class="linktitle">{{ .Name }}</span>
<p>url: {{ .XID }}
<p><a href="/h/{{ .Name }}">honks</a>
<p>flavor: {{ .Flavor }}
</div>
{{ end }}
</div>
</div>

30
views/honkform.html Normal file
View File

@ -0,0 +1,30 @@
<p>
<button onclick="showhonkform(); return false"><a href="/newhonk">it's honking time</a></button>
<form id="honkform" action="/honk" method="POST" enctype="multipart/form-data" style="display: none">
<p></p>
<input type="hidden" name="rid" value="">
<input type="hidden" name="CSRF" value="{{ .HonkCSRF }}">
<textarea name="noise" id="honknoise"></textarea>
<p>
<input type="submit" value="it's gonna be honked">
<label id="donker" style="margin-left:4em;">attach: <input onchange="updatedonker();" type="file" name="donk"><span></span></label>
</form>
<script>
function showhonkform(rid, hname) {
var el = document.getElementById("honkform")
el.style = "display: block"
if (rid) {
el.children[0].innerHTML = "tonking " + rid
el.children[1].value = rid
el.children[3].value = "@" + hname
} else {
el.children[0].innerHTML = ""
el.children[1].value = ""
}
el.scrollIntoView()
}
function updatedonker() {
var el = document.getElementById("donker")
el.children[1].textContent = el.children[0].value
}
</script>

24
views/honkpage.html Normal file
View File

@ -0,0 +1,24 @@
{{ template "header.html" . }}
<div class="center">
{{ if .Name }}
<div class="info">
<p>{{ .Name }} <span style="margin-left:1em;"><a href="/u/{{ .Name }}/rss">rss</a></span>
{{ if .HonkCSRF }}
<div>
<form id="aboutform" action="/saveuser" method="POST">
<input type="hidden" name="CSRF" value="{{ .UserCSRF }}">
<textarea name="whatabout">{{ .RawWhatAbout }}</textarea>
<p>
<input type="submit" value="update">
</form>
</div>
{{ else }}
<p>{{ .WhatAbout }}
{{ end }}
</div>
{{ end }}
<div>
{{ range .Honks }}
{{ template "honk.html" map "Honk" . }}
{{ end }}
</div>

11
views/login.html Normal file
View File

@ -0,0 +1,11 @@
{{ template "header.html" . }}
<div class="center">
<div class="info">
<form action="/dologin" method="POST">
<p><span class="title">login</span>
<p><input tabindex=1 type="text" name="username" autocomplete=off> - username
<p><input tabindex=1 type="password" name="password"> - password
<p><input tabindex=1 type="submit" name="login" value="login">
</form>
</div>
</div>

146
views/style.css Normal file
View File

@ -0,0 +1,146 @@
body {
background: #305;
color: #dde;
font-size: 1em;
word-wrap: break-word;
}
a {
color: #dde;
}
form {
font-family: monospace;
}
p {
margin-top 1em;
margin-bottom: 1em;
}
input {
font-family: monospace;
background: #305;
color: #dde;
font-size: 1.0em;
line-height: 1.2em;
padding: 0.5em;
}
.header {
max-width: 1200px;
margin: 1em auto;
font-size: 1.5em;
text-align: right;
}
.header span {
margin-right: 2em;
}
.center {
max-width: 1200px;
margin: auto;
font-size: 1.5em;
}
.info {
background: #002;
border: 2px solid #dde;
margin-bottom: 1em;
padding: 0em 1em 0em 1em;
}
.info div {
margin-top 1em;
margin-bottom: 1em;
}
button, form input[type=submit] {
font-size: 0.8em;
font-family: monospace;
color: #dde;
background: #305;
border: 1px solid #dde;
padding: 0.5em;
}
button a {
text-decoration: none;
}
.info form {
margin-top: 1em;
}
.info textarea {
padding: 0.5em;
font-size: 1em;
background: #305;
color: #dde;
width: 700px;
height: 8em;
margin-bottom: 0.5em;
box-sizing: border-box;
}
@media screen and (max-width: 1024px) {
.info textarea {
width: 500px;
}
}
.info input[type="checkbox"] {
position: fixed;
top: -9999px;
}
.info input[type="checkbox"] + span:after {
content: "no";
}
.info input[type="checkbox"]:checked + span:after {
content: "yes";
}
.info input[type="checkbox"]:focus + span:after {
outline: 1px solid #dde;
}
.info input[type=file] {
display: none;
}
.info label {
border: 1px solid #dde;
font-size: 0.8em;
padding: 0.5em;
font-size: 0.8em;
background: #305;
}
.honk {
width: 90%;
margin: auto;
background: #002;
border: 2px solid #dde;
border-radius: 1em;
margin-bottom: 1em;
padding-left: 1em;
padding-right: 1em;
padding-top: 0;
}
.tonk {
}
.tonk .noise {
color: #aab;
font-size: 0.8em;
}
.tonk .noise a {
color: #aab;
}
.honk a {
color: #dde;
}
.honk .title .clip a {
color: #88a;
}
.honk .title {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 0.8em;
margin-top: 1em;
}
.honk .title img {
float: left;
margin-right: 1em;
width: 64px;
height: 64px;
}
.honk .title p {
margin-top: 0px;
}
img {
max-width: 100%
}

203
zig.go Normal file
View File

@ -0,0 +1,203 @@
//
// Copyright (c) 2019 Ted Unangst <tedu@tedunangst.com>
//
// Permission to use, copy, modify, and distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
// WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
// MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
// ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
// WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
// ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
// OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
package main
import (
"bytes"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"fmt"
"io"
"net/http"
"regexp"
"strconv"
"strings"
"time"
)
func sb64(data []byte) string {
var sb strings.Builder
b64 := base64.NewEncoder(base64.StdEncoding, &sb)
b64.Write(data)
b64.Close()
return sb.String()
}
func b64s(s string) []byte {
var buf bytes.Buffer
b64 := base64.NewDecoder(base64.StdEncoding, strings.NewReader(s))
io.Copy(&buf, b64)
return buf.Bytes()
}
func sb64sha256(content []byte) string {
h := sha256.New()
h.Write(content)
return sb64(h.Sum(nil))
}
func zig(keyname string, key *rsa.PrivateKey, req *http.Request, content []byte) {
headers := []string{"(request-target)", "date", "host", "content-length", "digest"}
var stuff []string
for _, h := range headers {
var s string
switch h {
case "(request-target)":
s = strings.ToLower(req.Method) + " " + req.URL.RequestURI()
case "date":
s = req.Header.Get(h)
if s == "" {
s = time.Now().UTC().Format(http.TimeFormat)
req.Header.Set(h, s)
}
case "host":
s = req.Header.Get(h)
if s == "" {
s = req.URL.Hostname()
req.Header.Set(h, s)
}
case "content-length":
s = req.Header.Get(h)
if s == "" {
s = strconv.Itoa(len(content))
req.Header.Set(h, s)
req.ContentLength = int64(len(content))
}
case "digest":
s = req.Header.Get(h)
if s == "" {
s = "SHA-256=" + sb64sha256(content)
req.Header.Set(h, s)
}
}
stuff = append(stuff, h+": "+s)
}
h := sha256.New()
h.Write([]byte(strings.Join(stuff, "\n")))
sig, _ := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, h.Sum(nil))
bsig := sb64(sig)
sighdr := fmt.Sprintf(`keyId="%s",algorithm="%s",headers="%s",signature="%s"`,
keyname, "rsa-sha256", strings.Join(headers, " "), bsig)
req.Header.Set("Signature", sighdr)
}
var re_sighdrval = regexp.MustCompile(`(.*)="(.*)"`)
func zag(req *http.Request, content []byte) (string, error) {
sighdr := req.Header.Get("Signature")
var keyname, algo, heads, bsig string
for _, v := range strings.Split(sighdr, ",") {
m := re_sighdrval.FindStringSubmatch(v)
if len(m) != 3 {
return "", fmt.Errorf("bad scan: %s from %s\n", v, sighdr)
}
switch m[1] {
case "keyId":
keyname = m[2]
case "algorithm":
algo = m[2]
case "headers":
heads = m[2]
case "signature":
bsig = m[2]
default:
return "", fmt.Errorf("bad sig val: %s", m[1])
}
}
if keyname == "" || algo == "" || heads == "" || bsig == "" {
return "", fmt.Errorf("missing a sig value")
}
key := zaggy(keyname)
if key == nil {
return "", fmt.Errorf("no key for %s", keyname)
}
headers := strings.Split(heads, " ")
var stuff []string
for _, h := range headers {
var s string
switch h {
case "(request-target)":
s = strings.ToLower(req.Method) + " " + req.URL.RequestURI()
case "host":
s = req.Host
default:
s = req.Header.Get(h)
}
stuff = append(stuff, h+": "+s)
}
h := sha256.New()
h.Write([]byte(strings.Join(stuff, "\n")))
sig := b64s(bsig)
err := rsa.VerifyPKCS1v15(key, crypto.SHA256, h.Sum(nil), sig)
if err != nil {
return "", err
}
return keyname, nil
}
func pez(s string) (pri *rsa.PrivateKey, pub *rsa.PublicKey, err error) {
block, _ := pem.Decode([]byte(s))
if block == nil {
err = fmt.Errorf("no pem data")
return
}
switch block.Type {
case "PUBLIC KEY":
var k interface{}
k, err = x509.ParsePKIXPublicKey(block.Bytes)
if k != nil {
pub, _ = k.(*rsa.PublicKey)
}
case "RSA PUBLIC KEY":
pub, err = x509.ParsePKCS1PublicKey(block.Bytes)
case "RSA PRIVATE KEY":
pri, err = x509.ParsePKCS1PrivateKey(block.Bytes)
if err == nil {
pub = &pri.PublicKey
}
default:
err = fmt.Errorf("unknown key type")
}
return
}
func zem(i interface{}) (string, error) {
var b pem.Block
var err error
switch k := i.(type) {
case *rsa.PrivateKey:
b.Type = "RSA PRIVATE KEY"
b.Bytes = x509.MarshalPKCS1PrivateKey(k)
case *rsa.PublicKey:
b.Type = "PUBLIC KEY"
b.Bytes, err = x509.MarshalPKIXPublicKey(k)
default:
err = fmt.Errorf("unknown key type: %s", k)
}
if err != nil {
return "", err
}
return string(pem.EncodeToMemory(&b)), nil
}