all repos — honk @ 847cd0107be87eff16cd9e03aa274d43fbd313d7

my fork of honk

maybe 0.1
Ted Unangst tedu@tedunangst.com
Tue, 09 Apr 2019 07:59:33 -0400
commit

847cd0107be87eff16cd9e03aa274d43fbd313d7

A Makefile

@@ -0,0 +1,8 @@

+ +all: honk + +honk: *.go + go build -o honk + +clean: + rm -f honk
A README

@@ -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.
A activity.go

@@ -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 +}
A avatar.go

@@ -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() +}
A honk.go

@@ -0,0 +1,1217 @@

+// +// 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/rand" + "crypto/rsa" + "database/sql" + "fmt" + "html" + "html/template" + "image" + _ "image/gif" + _ "image/jpeg" + _ "image/png" + "io" + "log" + "net/http" + "os" + "regexp" + "sort" + "strings" + "sync" + "time" + + "github.com/gorilla/mux" +) + +type UserInfo struct { + UserID int64 + Username string +} + +type WhatAbout struct { + ID int64 + Name string + Display string + About string + Key string + URL string +} + +var serverName string +var iconName = "icon.png" + +var readviews *Template + +func ziggy(user *WhatAbout) (keyname string, key *rsa.PrivateKey) { + db := opendatabase() + row := db.QueryRow("select seckey from users where userid = ?", user.ID) + var data string + row.Scan(&data) + var err error + key, _, err = pez(data) + if err != nil { + log.Printf("error loading %s seckey: %s", user.Name, err) + } + keyname = user.URL + "#key" + return +} + +func zaggy(keyname string) (key *rsa.PublicKey) { + db := opendatabase() + row := db.QueryRow("select pubkey from honkers where xid = ?", keyname) + var data string + err := row.Scan(&data) + savekey := false + if err != nil { + savekey = true + j, err := GetJunk(keyname) + if err != nil { + log.Printf("error getting %s pubkey: %s", keyname, err) + return + } + var ok bool + data, ok = jsonfindstring(j, []string{"publicKey", "publicKeyPem"}) + if !ok { + log.Printf("error getting %s pubkey", keyname) + return + } + _, ok = jsonfindstring(j, []string{"publicKey", "owner"}) + if !ok { + log.Printf("error getting %s pubkey owner", keyname) + return + } + } + _, key, err = pez(data) + if err != nil { + log.Printf("error getting %s pubkey: %s", keyname, err) + return + } + if savekey { + db.Exec("insert into honkers (name, xid, flavor, pubkey) values (?, ?, ?, ?)", + "", keyname, "key", data) + } + return +} + +func keymatch(keyname string, actor string) bool { + return strings.HasPrefix(keyname, actor) +} + +func getInfo(r *http.Request) map[string]interface{} { + templinfo := make(map[string]interface{}) + templinfo["StyleParam"] = getstyleparam() + templinfo["ServerName"] = serverName + templinfo["IconName"] = iconName + templinfo["UserInfo"] = GetUserInfo(r) + templinfo["LogoutCSRF"] = GetCSRF("logout", r) + return templinfo +} + +var re_unurl = regexp.MustCompile("https://([^/]+).*/([^/]+)") + +func honkerhandle(h string) string { + m := re_unurl.FindStringSubmatch(h) + if len(m) > 2 { + return fmt.Sprintf("%s@%s", m[2], m[1]) + } + return "" +} + +func reverbolate(honks []*Honk) { + for _, h := range honks { + h.What += "ed" + if h.Honker == "" { + h.Honker = "https://" + serverName + "/u/" + h.Username + if strings.IndexByte(h.XID, '/') == -1 { + h.URL = h.Honker + "/h/" + h.XID + } else { + h.URL = h.XID + } + } else { + idx := strings.LastIndexByte(h.Honker, '/') + if idx != -1 { + h.Username = honkerhandle(h.Honker) + } else { + h.Username = h.Honker + } + if h.URL == "" { + h.URL = h.XID + } + } + h.HTML = cleanstring(h.Noise) + } +} + +func homepage(w http.ResponseWriter, r *http.Request) { + templinfo := getInfo(r) + honks := gethonks("") + u := GetUserInfo(r) + if u != nil { + morehonks := gethonksforuser(u.UserID) + honks = append(honks, morehonks...) + templinfo["HonkCSRF"] = GetCSRF("honkhonk", r) + } + sort.Slice(honks, func(i, j int) bool { + return honks[i].Date.After(honks[j].Date) + }) + reverbolate(honks) + msg := "Things happen." + getconfig("servermsg", &msg) + templinfo["Honks"] = honks + templinfo["ShowRSS"] = true + templinfo["ServerMessage"] = msg + err := readviews.ExecuteTemplate(w, "homepage.html", templinfo) + if err != nil { + log.Print(err) + } +} + +func showrss(w http.ResponseWriter, r *http.Request) { + name := mux.Vars(r)["name"] + + honks := gethonks(name) + sort.Slice(honks, func(i, j int) bool { + return honks[i].Date.After(honks[j].Date) + }) + reverbolate(honks) + + home := fmt.Sprintf("https://%s/", serverName) + base := home + if name != "" { + home += "u/" + name + name += " " + } + feed := RssFeed{ + Title: name + "honk", + Link: home, + Description: name + "honk rss", + FeedImage: &RssFeedImage{ + URL: base + "icon.png", + Title: name + "honk rss", + Link: home, + }, + } + var modtime time.Time + past := time.Now().UTC().Add(-3 * 24 * time.Hour) + for _, honk := range honks { + if honk.Date.Before(past) { + break + } + if honk.URL[0] == '/' { + honk.URL = "https://" + serverName + honk.URL + } + feed.Items = append(feed.Items, &RssItem{ + Title: fmt.Sprintf("%s %s %s", honk.Username, honk.What, honk.XID), + Description: RssCData{string(honk.HTML)}, + Link: honk.URL, + PubDate: honk.Date.Format(time.RFC1123), + }) + if honk.Date.After(modtime) { + modtime = honk.Date + } + } + w.Header().Set("Cache-Control", "max-age=300") + w.Header().Set("Last-Modified", modtime.Format(http.TimeFormat)) + + err := feed.Write(w) + if err != nil { + log.Printf("error writing rss: %s", err) + } +} + +func butwhatabout(name string) (*WhatAbout, error) { + row := stmtWhatAbout.QueryRow(name) + var user WhatAbout + err := row.Scan(&user.ID, &user.Name, &user.Display, &user.About, &user.Key) + user.URL = fmt.Sprintf("https://%s/u/%s", serverName, user.Name) + return &user, err +} + +func crappola(j map[string]interface{}) bool { + t, _ := jsongetstring(j, "type") + a, _ := jsongetstring(j, "actor") + o, _ := jsongetstring(j, "object") + if t == "Delete" && a == o { + log.Printf("crappola from %s", a) + return true + } + return false +} + +func inbox(w http.ResponseWriter, r *http.Request) { + name := mux.Vars(r)["name"] + user, err := butwhatabout(name) + if err != nil { + http.NotFound(w, r) + return + } + var buf bytes.Buffer + io.Copy(&buf, r.Body) + payload := buf.Bytes() + j, err := ReadJunk(bytes.NewReader(payload)) + if err != nil { + log.Printf("bad payload: %s", err) + io.WriteString(os.Stdout, "bad payload\n") + os.Stdout.Write(payload) + io.WriteString(os.Stdout, "\n") + return + } + if crappola(j) { + return + } + keyname, err := zag(r, payload) + if err != nil { + log.Printf("inbox message failed signature: %s", err) + fd, _ := os.OpenFile("savedinbox.json", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) + io.WriteString(fd, "bad signature:\n") + WriteJunk(fd, j) + io.WriteString(fd, "\n") + fd.Close() + return + } + fd, _ := os.OpenFile("savedinbox.json", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) + WriteJunk(fd, j) + io.WriteString(fd, "\n") + fd.Close() + who, _ := jsongetstring(j, "actor") + if !keymatch(keyname, who) { + log.Printf("keyname actor mismatch: %s <> %s", keyname, who) + return + } + what, _ := jsongetstring(j, "type") + switch what { + case "Follow": + log.Printf("updating honker follow: %s", who) + rubadubdub(user, j) + case "Accept": + db := opendatabase() + log.Printf("updating honker accept: %s", who) + db.Exec("update honkers set flavor = 'sub' where xid = ? and flavor = 'presub'", who) + case "Undo": + obj, ok := jsongetmap(j, "object") + if !ok { + log.Printf("unknown undo no object") + } else { + what, _ := jsongetstring(obj, "type") + if what != "Follow" { + log.Printf("unknown undo: %s", what) + } else { + log.Printf("updating honker undo: %s", who) + db := opendatabase() + db.Exec("update honkers set flavor = 'undub' where xid = ? and flavor = 'dub'", who) + } + } + default: + xonk := xonkxonk(j) + if xonk != nil && needxonk(user.ID, xonk) { + xonk.UserID = user.ID + savexonk(xonk) + } + } +} + +func outbox(w http.ResponseWriter, r *http.Request) { + name := mux.Vars(r)["name"] + user, err := butwhatabout(name) + if err != nil { + http.NotFound(w, r) + return + } + honks := gethonks(name) + + var jonks []map[string]interface{} + for _, h := range honks { + j, _ := jonkjonk(user, h) + jonks = append(jonks, j) + } + + j := NewJunk() + j["@context"] = itiswhatitis + j["id"] = user.URL + "/outbox" + j["type"] = "OrderedCollection" + j["totalItems"] = len(jonks) + j["orderedItems"] = jonks + + w.Header().Set("Content-Type", theonetruename) + WriteJunk(w, j) +} + +func viewuser(w http.ResponseWriter, r *http.Request) { + name := mux.Vars(r)["name"] + user, err := butwhatabout(name) + if err != nil { + http.NotFound(w, r) + return + } + if friendorfoe(r.Header.Get("Accept")) { + j := asjonker(user) + w.Header().Set("Content-Type", theonetruename) + WriteJunk(w, j) + return + } + honks := gethonks(name) + u := GetUserInfo(r) + honkpage(w, r, u, user, honks) +} + +func viewhonker(w http.ResponseWriter, r *http.Request) { + name := mux.Vars(r)["name"] + u := GetUserInfo(r) + honks := gethonksbyhonker(u.UserID, name) + honkpage(w, r, nil, nil, honks) +} + +func fingerlicker(w http.ResponseWriter, r *http.Request) { + orig := r.FormValue("resource") + + log.Printf("finger lick: %s", orig) + + if strings.HasPrefix(orig, "acct:") { + orig = orig[5:] + } + + name := orig + idx := strings.LastIndexByte(name, '/') + if idx != -1 { + name = name[idx+1:] + if "https://"+serverName+"/u/"+name != orig { + log.Printf("foreign request rejected") + name = "" + } + } else { + idx = strings.IndexByte(name, '@') + if idx != -1 { + name = name[:idx] + if name+"@"+serverName != orig { + log.Printf("foreign request rejected") + name = "" + } + } + } + user, err := butwhatabout(name) + if err != nil { + http.NotFound(w, r) + return + } + + j := NewJunk() + j["subject"] = fmt.Sprintf("acct:%s@%s", user.Name, serverName) + j["aliases"] = []string{user.URL} + var links []map[string]interface{} + l := NewJunk() + l["rel"] = "self" + l["type"] = `application/activity+json` + l["href"] = user.URL + links = append(links, l) + j["links"] = links + + w.Header().Set("Content-Type", "application/jrd+json") + WriteJunk(w, j) +} + +func viewhonk(w http.ResponseWriter, r *http.Request) { + name := mux.Vars(r)["name"] + xid := mux.Vars(r)["xid"] + user, err := butwhatabout(name) + if err != nil { + http.NotFound(w, r) + return + } + h := getxonk(name, xid) + if h == nil { + http.NotFound(w, r) + return + } + if friendorfoe(r.Header.Get("Accept")) { + _, j := jonkjonk(user, h) + j["@context"] = itiswhatitis + w.Header().Set("Content-Type", theonetruename) + WriteJunk(w, j) + return + } + honkpage(w, r, nil, nil, []*Honk{h}) +} + +func honkpage(w http.ResponseWriter, r *http.Request, u *UserInfo, user *WhatAbout, honks []*Honk) { + reverbolate(honks) + templinfo := getInfo(r) + if u != nil && u.Username == user.Name { + templinfo["UserCSRF"] = GetCSRF("saveuser", r) + templinfo["HonkCSRF"] = GetCSRF("honkhonk", r) + } + if user != nil { + templinfo["Name"] = user.Name + whatabout := user.About + templinfo["RawWhatAbout"] = whatabout + whatabout = obfusbreak(whatabout) + templinfo["WhatAbout"] = cleanstring(whatabout) + } + templinfo["Honks"] = honks + err := readviews.ExecuteTemplate(w, "honkpage.html", templinfo) + if err != nil { + log.Print(err) + } +} + +func saveuser(w http.ResponseWriter, r *http.Request) { + whatabout := r.FormValue("whatabout") + u := GetUserInfo(r) + db := opendatabase() + _, err := db.Exec("update users set about = ? where username = ?", whatabout, u.Username) + if err != nil { + log.Printf("error bouting what: %s", err) + } + + http.Redirect(w, r, "/u/"+u.Username, http.StatusSeeOther) +} + +type Donk struct { + FileID int64 + XID string + Name string + URL string + Media string + Content []byte +} + +type Honk struct { + ID int64 + UserID int64 + Username string + What string + Honker string + XID string + RID string + Date time.Time + URL string + Noise string + Audience []string + HTML template.HTML + Donks []*Donk +} + +type Honker struct { + ID int64 + UserID int64 + Name string + XID string + Flavor string +} + +func gethonkers(userid int64) []*Honker { + rows, err := stmtHonkers.Query(userid) + if err != nil { + log.Printf("error querying honkers: %s", err) + return nil + } + defer rows.Close() + var honkers []*Honker + for rows.Next() { + var f Honker + err = rows.Scan(&f.ID, &f.UserID, &f.Name, &f.XID, &f.Flavor) + if err != nil { + log.Printf("error scanning honker: %s", err) + return nil + } + honkers = append(honkers, &f) + } + return honkers +} + +func getdubs(userid int64) []*Honker { + rows, err := stmtDubbers.Query(userid) + if err != nil { + log.Printf("error querying dubs: %s", err) + return nil + } + defer rows.Close() + var honkers []*Honker + for rows.Next() { + var f Honker + err = rows.Scan(&f.ID, &f.UserID, &f.Name, &f.XID, &f.Flavor) + if err != nil { + log.Printf("error scanning honker: %s", err) + return nil + } + honkers = append(honkers, &f) + } + return honkers +} + +func gethonk(honkid int64) *Honk { + var h Honk + var dt, aud string + row := stmtOneHonk.QueryRow(honkid) + err := row.Scan(&h.ID, &h.UserID, &h.Username, &h.What, &h.Honker, &h.XID, &h.RID, + &dt, &h.URL, &aud, &h.Noise) + if err != nil { + log.Printf("error scanning honk: %s", err) + return nil + } + h.Date, _ = time.Parse(dbtimeformat, dt) + h.Audience = strings.Split(aud, " ") + return &h +} + +func getxonk(name, xid string) *Honk { + var h Honk + var dt, aud string + row := stmtOneXonk.QueryRow(xid) + err := row.Scan(&h.ID, &h.UserID, &h.Username, &h.What, &h.Honker, &h.XID, &h.RID, + &dt, &h.URL, &aud, &h.Noise) + if err != nil { + log.Printf("error scanning xonk: %s", err) + return nil + } + if name != "" && h.Username != name { + log.Printf("user xonk mismatch") + return nil + } + h.Date, _ = time.Parse(dbtimeformat, dt) + h.Audience = strings.Split(aud, " ") + donksforhonks([]*Honk{&h}) + return &h +} + +func gethonks(username string) []*Honk { + return getsomehonks(username, 0, "") +} + +func gethonksforuser(userid int64) []*Honk { + return getsomehonks("", userid, "") +} +func gethonksbyhonker(userid int64, honker string) []*Honk { + return getsomehonks("", userid, honker) +} + +func getsomehonks(username string, userid int64, honkername string) []*Honk { + var rows *sql.Rows + var err error + if username != "" { + rows, err = stmtUserHonks.Query(username) + } else if honkername != "" { + rows, err = stmtHonksByHonker.Query(userid, honkername) + } else if userid > 0 { + rows, err = stmtHonksForUser.Query(userid) + } else { + rows, err = stmtHonks.Query() + } + if err != nil { + log.Printf("error querying honks: %s", err) + return nil + } + defer rows.Close() + var honks []*Honk + for rows.Next() { + var h Honk + var dt, aud string + err = rows.Scan(&h.ID, &h.UserID, &h.Username, &h.What, &h.Honker, &h.XID, &h.RID, + &dt, &h.URL, &aud, &h.Noise) + if err != nil { + log.Printf("error scanning honks: %s", err) + return nil + } + h.Date, _ = time.Parse(dbtimeformat, dt) + h.Audience = strings.Split(aud, " ") + honks = append(honks, &h) + } + rows.Close() + donksforhonks(honks) + return honks +} + +func donksforhonks(honks []*Honk) { + db := opendatabase() + var ids []string + for _, h := range honks { + ids = append(ids, fmt.Sprintf("%d", h.ID)) + } + q := fmt.Sprintf("select honkid, donks.fileid, xid, name, url, media from donks join files on donks.fileid = files.fileid where honkid in (%s)", strings.Join(ids, ",")) + rows, err := db.Query(q) + if err != nil { + log.Printf("error querying donks: %s", err) + return + } + defer rows.Close() + for rows.Next() { + var hid int64 + var d Donk + err = rows.Scan(&hid, &d.FileID, &d.XID, &d.Name, &d.URL, &d.Media) + if err != nil { + log.Printf("error scanning donk: %s", err) + continue + } + for _, h := range honks { + if h.ID == hid { + h.Donks = append(h.Donks, &d) + } + } + } +} + +func xfiltrate() string { + letters := "BCDFGHJKLMNPQRSTVWXYZbcdfghjklmnpqrstvwxyz1234567891234567891234" + db := opendatabase() + for { + var x int64 + var b [16]byte + rand.Read(b[:]) + for i, c := range b { + b[i] = letters[c&63] + } + s := string(b[:]) + r := db.QueryRow("select honkid from honks where xid = ?", s) + err := r.Scan(&x) + if err == nil { + continue + } + if err != sql.ErrNoRows { + log.Printf("err picking xid: %s", err) + return "" + } + r = db.QueryRow("select fileid from files where name = ?", s) + err = r.Scan(&x) + if err == nil { + continue + } + if err != sql.ErrNoRows { + log.Printf("err picking xid: %s", err) + return "" + } + return s + } +} + +type Mention struct { + who string + where string +} + +var re_mentions = regexp.MustCompile(`@[[:alnum:]]+@[[:alnum:].]+`) + +func grapevine(s string) []string { + m := re_mentions.FindAllString(s, -1) + var mentions []string + for i := range m { + where := gofish(m[i]) + if where != "" { + mentions = append(mentions, where) + } + } + return mentions +} + +func bunchofgrapes(s string) []Mention { + m := re_mentions.FindAllString(s, -1) + var mentions []Mention + for i := range m { + where := gofish(m[i]) + if where != "" { + mentions = append(mentions, Mention{who: m[i], where: where}) + } + } + return mentions +} + +var re_link = regexp.MustCompile(`https?://[^\s"]+[\w/)]`) + +func obfusbreak(s string) string { + s = strings.TrimSpace(s) + s = strings.Replace(s, "\r", "", -1) + s = html.EscapeString(s) + linkfn := func(url string) string { + addparen := false + adddot := false + if strings.HasSuffix(url, ")") && strings.IndexByte(url, '(') == -1 { + url = url[:len(url)-1] + addparen = true + } + if strings.HasSuffix(url, ".") { + url = url[:len(url)-1] + adddot = true + } + url = fmt.Sprintf(`<a href="%s">%s</a>`, url, url) + if adddot { + url += "." + } + if addparen { + url += ")" + } + return url + } + s = re_link.ReplaceAllStringFunc(s, linkfn) + + s = strings.Replace(s, "\n", "<br>", -1) + s = re_mentions.ReplaceAllStringFunc(s, func(m string) string { + return fmt.Sprintf(`<a href="%s">%s</a>`, html.EscapeString(gofish(m)), + html.EscapeString(m)) + }) + return s +} + +func prepend(s string, x []string) []string { + return append([]string{s}, x...) +} + +func savebonk(w http.ResponseWriter, r *http.Request) { + xid := r.FormValue("xid") + + log.Printf("bonking %s", xid) + + xonk := getxonk("", xid) + if xonk == nil { + return + } + if xonk.Honker == "" { + xonk.XID = fmt.Sprintf("https://%s/u/%s/h/%s", serverName, xonk.Username, xonk.XID) + } + + userinfo := GetUserInfo(r) + + dt := time.Now().UTC() + bonk := Honk{ + UserID: userinfo.UserID, + Username: userinfo.Username, + Honker: xonk.Honker, + What: "bonk", + XID: xonk.XID, + Date: dt, + Noise: xonk.Noise, + Donks: xonk.Donks, + Audience: oneofakind(prepend(thewholeworld, xonk.Audience)), + } + + aud := strings.Join(bonk.Audience, " ") + res, err := stmtSaveHonk.Exec(userinfo.UserID, "bonk", "", xid, "", + dt.Format(dbtimeformat), "", aud, bonk.Noise) + if err != nil { + log.Printf("error saving bonk: %s", err) + return + } + bonk.ID, _ = res.LastInsertId() + for _, d := range bonk.Donks { + _, err = stmtSaveDonk.Exec(bonk.ID, d.FileID) + if err != nil { + log.Printf("err saving donk: %s", err) + return + } + } + + user, _ := butwhatabout(userinfo.Username) + + go honkworldwide(user, &bonk) + +} + +func savehonk(w http.ResponseWriter, r *http.Request) { + rid := r.FormValue("rid") + noise := r.FormValue("noise") + + userinfo := GetUserInfo(r) + + dt := time.Now().UTC() + xid := xfiltrate() + if xid == "" { + return + } + what := "honk" + if rid != "" { + what = "tonk" + } + honk := Honk{ + UserID: userinfo.UserID, + Username: userinfo.Username, + What: "honk", + XID: xid, + RID: rid, + Date: dt, + } + if noise[0] == '@' { + honk.Audience = append(grapevine(noise), thewholeworld) + } else { + honk.Audience = append([]string{thewholeworld}, grapevine(noise)...) + } + if rid != "" { + xonk := getxonk("", rid) + honk.Audience = append(honk.Audience, xonk.Audience...) + } + honk.Audience = oneofakind(honk.Audience) + noise = obfusbreak(noise) + honk.Noise = noise + + file, _, err := r.FormFile("donk") + if err == nil { + var buf bytes.Buffer + io.Copy(&buf, file) + file.Close() + data := buf.Bytes() + img, format, err := image.Decode(&buf) + if err != nil { + log.Printf("bad image: %s", err) + return + } + data, format, err = vacuumwrap(img, format) + if err != nil { + log.Printf("can't vacuum image: %s", err) + return + } + name := xfiltrate() + media := "image/" + format + if format == "jpeg" { + format = "jpg" + } + name = name + "." + format + url := fmt.Sprintf("https://%s/d/%s", serverName, name) + res, err := stmtSaveFile.Exec(name, name, url, media, data) + if err != nil { + log.Printf("unable to save image: %s", err) + return + } + var d Donk + d.FileID, _ = res.LastInsertId() + d.XID = name + d.Name = name + d.Media = media + d.URL = url + honk.Donks = append(honk.Donks, &d) + } + + aud := strings.Join(honk.Audience, " ") + res, err := stmtSaveHonk.Exec(userinfo.UserID, what, "", xid, rid, + dt.Format(dbtimeformat), "", aud, noise) + if err != nil { + log.Printf("error saving honk: %s", err) + return + } + honk.ID, _ = res.LastInsertId() + for _, d := range honk.Donks { + _, err = stmtSaveDonk.Exec(honk.ID, d.FileID) + if err != nil { + log.Printf("err saving donk: %s", err) + return + } + } + + user, _ := butwhatabout(userinfo.Username) + + go honkworldwide(user, &honk) + + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +func showhonkers(w http.ResponseWriter, r *http.Request) { + userinfo := GetUserInfo(r) + templinfo := getInfo(r) + templinfo["Honkers"] = gethonkers(userinfo.UserID) + templinfo["HonkerCSRF"] = GetCSRF("savehonker", r) + err := readviews.ExecuteTemplate(w, "honkers.html", templinfo) + if err != nil { + log.Print(err) + } +} + +var handfull = make(map[string]string) +var handlock sync.Mutex + +func gofish(name string) string { + if name[0] == '@' { + name = name[1:] + } + m := strings.Split(name, "@") + if len(m) != 2 { + log.Printf("bad far name: %s", name) + return "" + } + handlock.Lock() + defer handlock.Unlock() + ref, ok := handfull[name] + if ok { + return ref + } + j, err := GetJunk(fmt.Sprintf("https://%s/.well-known/webfinger?resource=acct:%s", m[1], name)) + if err != nil { + log.Printf("failed to get far name: %s", err) + handfull[name] = "" + return "" + } + links, _ := jsongetarray(j, "links") + for _, l := range links { + href, _ := jsongetstring(l, "href") + rel, _ := jsongetstring(l, "rel") + t, _ := jsongetstring(l, "type") + if rel == "self" && friendorfoe(t) { + handfull[name] = href + return href + } + } + handfull[name] = "" + return "" +} + +func savehonker(w http.ResponseWriter, r *http.Request) { + name := r.FormValue("name") + url := r.FormValue("url") + peep := r.FormValue("peep") + flavor := "presub" + if peep == "peep" { + flavor = "peep" + } + + if url == "" { + return + } + if url[0] == '@' { + url = gofish(url) + } + if url == "" { + return + } + + u := GetUserInfo(r) + db := opendatabase() + _, err := db.Exec("insert into honkers (userid, name, xid, flavor) values (?, ?, ?, ?)", + u.UserID, name, url, flavor) + if err != nil { + log.Print(err) + } + if flavor == "presub" { + user, _ := butwhatabout(u.Username) + go subsub(user, url) + } + http.Redirect(w, r, "/honkers", http.StatusSeeOther) +} + +func avatate(w http.ResponseWriter, r *http.Request) { + n := r.FormValue("a") + a := avatar(n) + w.Header().Set("Cache-Control", "max-age=76000") + w.Write(a) +} + +func servecss(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "max-age=7776000") + http.ServeFile(w, r, "views"+r.URL.Path) +} +func servehtml(w http.ResponseWriter, r *http.Request) { + templinfo := getInfo(r) + err := readviews.ExecuteTemplate(w, r.URL.Path[1:]+".html", templinfo) + if err != nil { + log.Print(err) + } +} + +func servefile(w http.ResponseWriter, r *http.Request) { + xid := mux.Vars(r)["xid"] + row := stmtFileData.QueryRow(xid) + var data []byte + err := row.Scan(&data) + if err != nil { + log.Printf("error loading file: %s", err) + http.NotFound(w, r) + return + } + w.Header().Set("Cache-Control", "max-age=432000") + w.Write(data) +} + +func serve() { + db := opendatabase() + LoginInit(db) + + getconfig("servername", &serverName) + listener, err := openListener() + if err != nil { + log.Fatal(err) + } + debug := false + getconfig("debug", &debug) + readviews = ParseTemplates(debug, + "views/homepage.html", + "views/honkpage.html", + "views/honkers.html", + "views/honkform.html", + "views/honk.html", + "views/login.html", + "views/header.html", + ) + if !debug { + savedstyleparam = getstyleparam() + } + + mux := mux.NewRouter() + mux.Use(LoginChecker) + + posters := mux.Methods("POST").Subrouter() + getters := mux.Methods("GET").Subrouter() + + getters.HandleFunc("/", homepage) + getters.HandleFunc("/rss", showrss) + getters.HandleFunc("/u/{name:[[:alnum:]]+}", viewuser) + getters.HandleFunc("/u/{name:[[:alnum:]]+}/h/{xid:[[:alnum:]]+}", viewhonk) + getters.HandleFunc("/u/{name:[[:alnum:]]+}/rss", showrss) + posters.HandleFunc("/u/{name:[[:alnum:]]+}/inbox", inbox) + getters.HandleFunc("/u/{name:[[:alnum:]]+}/outbox", outbox) + getters.HandleFunc("/a", avatate) + getters.HandleFunc("/d/{xid:[[:alnum:].]+}", servefile) + getters.HandleFunc("/h/{name:[[:alnum:]]+}", viewhonker) + getters.HandleFunc("/.well-known/webfinger", fingerlicker) + + getters.HandleFunc("/style.css", servecss) + getters.HandleFunc("/login", servehtml) + posters.HandleFunc("/dologin", dologin) + getters.HandleFunc("/logout", dologout) + + loggedin := mux.NewRoute().Subrouter() + loggedin.Use(LoginRequired) + loggedin.Handle("/honk", CSRFWrap("honkhonk", http.HandlerFunc(savehonk))) + loggedin.Handle("/bonk", CSRFWrap("honkhonk", http.HandlerFunc(savebonk))) + loggedin.Handle("/saveuser", CSRFWrap("saveuser", http.HandlerFunc(saveuser))) + loggedin.HandleFunc("/honkers", showhonkers) + loggedin.Handle("/savehonker", CSRFWrap("savehonker", http.HandlerFunc(savehonker))) + + err = http.Serve(listener, mux) + if err != nil { + log.Fatal(err) + } +} + +var stmtHonkers, stmtDubbers, stmtOneHonk, stmtOneXonk, stmtHonks, stmtUserHonks *sql.Stmt +var stmtHonksForUser, stmtDeleteHonk, stmtSaveDub *sql.Stmt +var stmtHonksByHonker, stmtSaveHonk, stmtFileData, stmtWhatAbout *sql.Stmt +var stmtFindXonk, stmtSaveDonk, stmtFindFile, stmtSaveFile *sql.Stmt + +func prepareStatements(db *sql.DB) { + var err error + stmtHonkers, err = db.Prepare("select honkerid, userid, name, xid, flavor from honkers where userid = ? and flavor = 'sub' or flavor = 'peep'") + if err != nil { + log.Fatal(err) + } + stmtDubbers, err = db.Prepare("select honkerid, userid, name, xid, flavor from honkers where userid = ? and flavor = 'dub'") + if err != nil { + log.Fatal(err) + } + stmtOneHonk, err = db.Prepare("select honkid, honks.userid, users.username, what, honker, xid, rid, dt, url, audience, noise from honks join users on honks.userid = users.userid where honkid = ? limit 50") + if err != nil { + log.Fatal(err) + } + stmtOneXonk, err = db.Prepare("select honkid, honks.userid, users.username, what, honker, xid, rid, dt, url, audience, noise from honks join users on honks.userid = users.userid where xid = ?") + if err != nil { + log.Fatal(err) + } + stmtHonks, err = db.Prepare("select honkid, honks.userid, users.username, what, honker, xid, rid, dt, url, audience, noise from honks join users on honks.userid = users.userid where honker = '' order by honkid desc limit 50") + if err != nil { + log.Fatal(err) + } + stmtUserHonks, err = db.Prepare("select honkid, honks.userid, username, what, honker, xid, rid, dt, url, audience, noise from honks join users on honks.userid = users.userid where honker = '' and username = ? order by honkid desc limit 50") + if err != nil { + log.Fatal(err) + } + stmtHonksForUser, err = db.Prepare("select honkid, honks.userid, users.username, what, honker, xid, rid, dt, url, audience, noise from honks join users on honks.userid = users.userid where honks.userid = ? and honker <> '' and what <> 'zonk' order by honkid desc limit 150") + if err != nil { + log.Fatal(err) + } + stmtHonksByHonker, err = db.Prepare("select honkid, honks.userid, users.username, what, honker, honks.xid, rid, dt, url, audience, noise from honks join users on honks.userid = users.userid join honkers on honkers.xid = honks.honker where honks.userid = ? and honkers.name = ? order by honkid desc limit 50") + if err != nil { + log.Fatal(err) + } + stmtSaveHonk, err = db.Prepare("insert into honks (userid, what, honker, xid, rid, dt, url, audience, noise) values (?, ?, ?, ?, ?, ?, ?, ?, ?)") + if err != nil { + log.Fatal(err) + } + stmtFileData, err = db.Prepare("select content from files where xid = ?") + if err != nil { + log.Fatal(err) + } + stmtFindXonk, err = db.Prepare("select honkid from honks where userid = ? and xid = ? and what = ?") + if err != nil { + log.Fatal(err) + } + stmtSaveDonk, err = db.Prepare("insert into donks (honkid, fileid) values (?, ?)") + if err != nil { + log.Fatal(err) + } + stmtDeleteHonk, err = db.Prepare("update honks set what = 'zonk' where xid = ? and honker = ?") + if err != nil { + log.Fatal(err) + } + stmtFindFile, err = db.Prepare("select fileid from files where url = ?") + if err != nil { + log.Fatal(err) + } + stmtSaveFile, err = db.Prepare("insert into files (xid, name, url, media, content) values (?, ?, ?, ?, ?)") + if err != nil { + log.Fatal(err) + } + stmtWhatAbout, err = db.Prepare("select userid, username, displayname, about, pubkey from users where username = ?") + if err != nil { + log.Fatal(err) + } + stmtSaveDub, err = db.Prepare("insert into honkers (userid, name, xid, flavor) values (?, ?, ?, ?)") + if err != nil { + log.Fatal(err) + } +} + +func ElaborateUnitTests() { +} + +func finishusersetup() error { + db := opendatabase() + k, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return err + } + pubkey, err := zem(&k.PublicKey) + if err != nil { + return err + } + seckey, err := zem(k) + if err != nil { + return err + } + _, err = db.Exec("update users set displayname = username, about = ?, pubkey = ?, seckey = ? where userid = 1", "what about me?", pubkey, seckey) + if err != nil { + return err + } + return nil +} + +func main() { + cmd := "run" + if len(os.Args) > 1 { + cmd = os.Args[1] + } + if cmd != "init" { + db := opendatabase() + prepareStatements(db) + } + switch cmd { + case "peep": + peeppeep() + case "init": + initdb() + case "run": + serve() + case "test": + ElaborateUnitTests() + default: + log.Fatal("unknown command") + } +}
A html.go

@@ -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 +}
A image.go

@@ -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 +}
A login.go

@@ -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) +}
A rss.go

@@ -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 +}
A schema.sql

@@ -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); +
A template.go

@@ -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 +}
A util.go

@@ -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 +}
A views/header.html

@@ -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>
A views/homepage.html

@@ -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 }}
A views/honk.html

@@ -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>
A views/honkers.html

@@ -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>
A views/honkform.html

@@ -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>
A views/honkpage.html

@@ -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>
A views/login.html

@@ -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>
A views/style.css

@@ -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% +}
A zig.go

@@ -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 +}