From b9281c89737419216b710a87c31686d21adf86bc Mon Sep 17 00:00:00 2001 From: Nicolas Sterchele Date: Wed, 31 Aug 2022 23:08:52 +0200 Subject: initial commit Thanks to Ted Unangst for its work. Originally available here https://humungus.tedunangst.com/r/inks --- .gitignore | 2 + .hgtags | 1 + LICENSE | 24 ++ Makefile | 8 + README | 25 ++ activitypub.go | 430 +++++++++++++++++++++++++++++++++++ go.mod | 10 + go.sum | 18 ++ inks.go | 654 +++++++++++++++++++++++++++++++++++++++++++++++++++++ schema.sql | 22 ++ text.go | 80 +++++++ text_test.go | 39 ++++ upgradedb.go | 55 +++++ util.go | 281 +++++++++++++++++++++++ views/addlink.html | 18 ++ views/header.html | 23 ++ views/inks.html | 38 ++++ views/login.html | 10 + views/sources.html | 22 ++ views/style.css | 112 +++++++++ views/tags.html | 17 ++ 21 files changed, 1889 insertions(+) create mode 100644 .gitignore create mode 100755 .hgtags create mode 100755 LICENSE create mode 100755 Makefile create mode 100755 README create mode 100755 activitypub.go create mode 100755 go.mod create mode 100755 go.sum create mode 100755 inks.go create mode 100755 schema.sql create mode 100755 text.go create mode 100755 text_test.go create mode 100755 upgradedb.go create mode 100755 util.go create mode 100755 views/addlink.html create mode 100755 views/header.html create mode 100755 views/inks.html create mode 100755 views/login.html create mode 100755 views/sources.html create mode 100755 views/style.css create mode 100755 views/tags.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..48fcaab --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +inks.db +inks diff --git a/.hgtags b/.hgtags new file mode 100755 index 0000000..478a768 --- /dev/null +++ b/.hgtags @@ -0,0 +1 @@ +f98c91b540e28318fa0e46e01c0292f43865b054 v0.9.0 diff --git a/LICENSE b/LICENSE new file mode 100755 index 0000000..9d91e08 --- /dev/null +++ b/LICENSE @@ -0,0 +1,24 @@ +The license for inks and components is generally ISC or compatible. + +Individual source files are licensed per license at the top. + +Distributed components and dependenices in the vendor directory should have +compatible licenses. + +Files without explicit licenses and the conglomeration as a whole is subject +to the license below. + +// Copyright (c) 2019 Ted Unangst +// +// 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. + diff --git a/Makefile b/Makefile new file mode 100755 index 0000000..09af327 --- /dev/null +++ b/Makefile @@ -0,0 +1,8 @@ + +all: inks + +inks: go.mod *.go + go build -o inks + +clean: + rm -f inks diff --git a/README b/README new file mode 100755 index 0000000..273c144 --- /dev/null +++ b/README @@ -0,0 +1,25 @@ +Based on Ted Unangst's work - https://humungus.tedunangst.com/r/inks + + +inks + +-- description + +a nonsocial aggregator + +rss and activitypub support + +a catalog of inks for people without l keys + +-- build + +Install go. Run make. + +-- setup + +./inks init + +-- running + +./inks + diff --git a/activitypub.go b/activitypub.go new file mode 100755 index 0000000..7f3690a --- /dev/null +++ b/activitypub.go @@ -0,0 +1,430 @@ +// +// Copyright (c) 2019 Ted Unangst +// +// 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" + "context" + "crypto/rand" + "fmt" + "html" + "io" + "log" + "net/http" + "os" + "strings" + "sync" + "time" + + "humungus.tedunangst.com/r/webs/httpsig" + "humungus.tedunangst.com/r/webs/junk" +) + +var apContext = "https://www.w3.org/ns/activitystreams" +var apPublic = "https://www.w3.org/ns/activitystreams#Public" +var apTypes = []string{ + `application/activity+json`, + `application/ld+json`, +} +var apBestType = `application/ld+json; profile="https://www.w3.org/ns/activitystreams"` + +var serverPubKey = "somekey" +var serverPrivateKey httpsig.PrivateKey + +func isActivity(ct string) bool { + ct = strings.ToLower(ct) + for _, at := range apTypes { + if strings.HasPrefix(ct, at) { + return true + } + } + return false +} + +func apDeliver(tries int, rcpt string, msg []byte) error { + if tries > 0 { + time.Sleep(1 * time.Hour) + } + err := postMsg(rcpt, msg) + if err != nil { + log.Printf("error posting to %s: %s", rcpt, err) + if tries != -1 && tries < 3 { + go apDeliver(tries+1, rcpt, msg) + } + } + return err +} + +func oneLink(linkid int64) *Link { + rows, err := stmtGetLink.Query(linkid) + links, _ := readlinks(rows, err) + if len(links) == 0 { + return nil + } + return links[0] +} + +func apHandle(w http.ResponseWriter, r *http.Request, linkid int64) { + w.Header().Set("Cache-Control", "max-age=300") + if r.URL.Path == "/" { + apActor(w, r) + return + } + link := oneLink(linkid) + if link == nil { + http.NotFound(w, r) + return + } + + jlink := apNote(link) + jlink["@context"] = apContext + + w.Header().Set("Content-Type", apBestType) + jlink.Write(w) +} + +func apFinger(w http.ResponseWriter, r *http.Request) { + j := junk.New() + j["subject"] = fmt.Sprintf("acct:%s@%s", "inks", serverName) + j["aliases"] = []string{serverURL} + var links []junk.Junk + l := junk.New() + l["rel"] = "self" + l["type"] = `application/activity+json` + l["href"] = serverURL + links = append(links, l) + j["links"] = links + + w.Header().Set("Content-Type", "application/jrd+json") + j.Write(w) +} + +func apActor(w http.ResponseWriter, r *http.Request) { + j := junk.New() + j["@context"] = apContext + j["id"] = serverURL + j["type"] = "Application" + j["inbox"] = serverURL + "/inbox" + j["outbox"] = serverURL + "/outbox" + j["followers"] = serverURL + "/followers" + j["following"] = serverURL + "/following" + j["name"] = serverName + j["preferredUsername"] = "inks" + j["summary"] = serverName + j["url"] = serverURL + a := junk.New() + a["type"] = "Image" + a["mediaType"] = "image/png" + a["url"] = serverURL + "/icon.png" + j["icon"] = a + k := junk.New() + k["id"] = serverURL + "#key" + k["owner"] = serverURL + k["publicKeyPem"] = serverPubKey + j["publicKey"] = k + + w.Header().Set("Content-Type", apBestType) + j.Write(w) +} + +type Box struct { + In string + Out string + Shared string +} + +var boxcache = make(map[string]*Box) +var boxlock sync.Mutex + +func getBoxes(actor string) (*Box, error) { + boxlock.Lock() + b, ok := boxcache[actor] + boxlock.Unlock() + if ok { + return b, nil + } + j, err := junk.Get(actor, junk.GetArgs{Accept: apTypes[0], Timeout: 5 * time.Second}) + if err != nil { + return nil, err + } + b = new(Box) + b.In, _ = j.GetString("inbox") + b.Shared, _ = j.GetString("endpoints", "sharedInbox") + boxlock.Lock() + boxcache[actor] = b + boxlock.Unlock() + return b, nil +} + +func apAccept(req junk.Junk) { + actor, _ := req.GetString("actor") + + j := junk.New() + j["@context"] = apContext + j["id"] = serverURL + "/accept/" + randomxid() + j["type"] = "Accept" + j["actor"] = serverURL + j["to"] = actor + j["published"] = time.Now().UTC().Format(time.RFC3339) + j["object"] = req + + var buf bytes.Buffer + j.Write(&buf) + msg := buf.Bytes() + + box, err := getBoxes(actor) + if err != nil { + return + } + err = apDeliver(-1, box.In, msg) + if err == nil { + stmtSaveFollower.Exec(actor) + } +} + +func apPong(who string, obj string) { + j := junk.New() + j["@context"] = apContext + j["id"] = serverURL + "/pong/" + randomxid() + j["type"] = "Pong" + j["actor"] = serverURL + j["to"] = who + j["published"] = time.Now().UTC().Format(time.RFC3339) + j["object"] = obj + + box, err := getBoxes(who) + if err == nil { + err = apDeliver(-1, box.In, j.ToBytes()) + } + if err != nil { + log.Printf("can't send pong: %s", err) + return + } +} + +func apInbox(w http.ResponseWriter, r *http.Request) { + var buf bytes.Buffer + io.Copy(&buf, r.Body) + payload := buf.Bytes() + j, err := junk.Read(bytes.NewReader(payload)) + if err != nil { + log.Printf("bad payload: %s", err) + http.Error(w, "bad payload", http.StatusNotAcceptable) + } + what, _ := j.GetString("type") + switch what { + case "Create": + case "Follow": + case "Undo": + case "Ping": + default: + return + } + keyname, err := httpsig.VerifyRequest(r, payload, httpsig.ActivityPubKeyGetter) + if err != nil { + log.Printf("httpsig error: %s", err) + return + } + who, _ := j.GetString("actor") + if !strings.HasPrefix(keyname, who) { + log.Printf("suspected forgery: %s vs %s", keyname, who) + return + } + switch what { + case "Create": + fd, _ := os.OpenFile("savedinbox.json", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666) + j.Write(fd) + io.WriteString(fd, "\n") + fd.Close() + case "Follow": + obj, _ := j.GetString("object") + if obj == serverURL { + go apAccept(j) + } + case "Undo": + obj, ok := j.GetMap("object") + if ok { + what, _ := obj.GetString("type") + if what == "Follow" { + stmtDeleteFollower.Exec(who) + } + } + case "Ping": + obj, _ := j.GetString("id") + apPong(who, obj) + } +} + +func apContent(link *Link) string { + var sb strings.Builder + sb.WriteString(fmt.Sprintf(`

%s

`, html.EscapeString(link.URL), html.EscapeString(link.URL))) + + sb.WriteString(string(link.Summary)) + + sb.WriteString("

") + for _, tag := range link.Tags { + sb.WriteString(fmt.Sprintf(`#%s `, serverURL, tag, tag)) + } + return sb.String() +} + +func apNote(link *Link) junk.Junk { + j := junk.New() + j["attributedTo"] = serverURL + j["content"] = apContent(link) + j["context"] = fmt.Sprintf("tag:%s:inks-%d", tagName, link.ID) + j["conversation"] = j["context"] + j["id"] = fmt.Sprintf("%s/l/%d", serverURL, link.ID) + j["published"] = link.Posted.Format(time.RFC3339) + j["summary"] = html.EscapeString(link.Title) + j["to"] = apPublic + j["cc"] = serverURL + "/followers" + j["type"] = "Note" + j["url"] = j["id"] + var tags []junk.Junk + for _, tag := range link.Tags { + t := junk.New() + t["type"] = "Hashtag" + t["name"] = "#" + tag + t["url"] = serverURL + "/tag/" + tag + tags = append(tags, t) + } + j["tag"] = tags + + return j +} + +func apCreate(link *Link, update bool) junk.Junk { + j := junk.New() + j["actor"] = serverURL + j["id"] = fmt.Sprintf("%s/l/%d/create", serverURL, link.ID) + j["object"] = apNote(link) + j["published"] = link.Posted.Format(time.RFC3339) + j["to"] = apPublic + j["cc"] = serverURL + "/followers" + if update { + j["type"] = "Update" + } else { + j["type"] = "Create" + } + return j +} + +func apPublish(linkid int64, update bool) { + if !update { + // wait a minute for things to settle + time.Sleep(1 * time.Minute) + } + link := oneLink(linkid) + if link == nil { + return + } + if update && link.Posted.After(time.Now().Add(-1*time.Minute)) { + log.Printf("skipping update for new link") + return + } + addrs := make(map[string]bool) + rows, err := stmtGetFollowers.Query() + if err != nil { + log.Printf("error getting followers") + return + } + defer rows.Close() + for rows.Next() { + var actor string + rows.Scan(&actor) + box, _ := getBoxes(actor) + if box != nil { + if box.Shared != "" { + addrs[box.Shared] = true + } else { + addrs[box.In] = true + } + } + } + j := apCreate(link, update) + j["@context"] = apContext + var buf bytes.Buffer + j.Write(&buf) + msg := buf.Bytes() + for addr := range addrs { + apDeliver(0, addr, msg) + } +} + +func apOutbox(w http.ResponseWriter, r *http.Request) { + lastlink := 123456789012 + rows, err := stmtGetLinks.Query(lastlink) + links, _ := readlinks(rows, err) + + var jlinks []junk.Junk + for _, l := range links { + j := apCreate(l, false) + jlinks = append(jlinks, j) + } + + j := junk.New() + j["@context"] = apContext + j["id"] = serverURL + "/outbox" + j["type"] = "OrderedCollection" + j["totalItems"] = len(jlinks) + j["orderedItems"] = jlinks + + w.Header().Set("Content-Type", apBestType) + j.Write(w) +} + +func ap403(w http.ResponseWriter, r *http.Request) { + http.Error(w, "no", http.StatusForbidden) +} + +func randomxid() string { + letters := "BCDFGHJKLMNPQRSTVWXYZbcdfghjklmnpqrstvwxyz1234567891234567891234" + var b [18]byte + rand.Read(b[:]) + for i, c := range b { + b[i] = letters[c&63] + } + s := string(b[:]) + return s +} + +func postMsg(url string, msg []byte) error { + client := http.DefaultClient + req, err := http.NewRequest("POST", url, bytes.NewReader(msg)) + if err != nil { + return err + } + req.Header.Set("Content-Type", apBestType) + httpsig.SignRequest(serverURL+"#key", serverPrivateKey, req, msg) + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Minute) + defer cancel() + req = req.WithContext(ctx) + resp, err := client.Do(req) + if err != nil { + return err + } + resp.Body.Close() + switch resp.StatusCode { + case 200: + case 201: + case 202: + default: + return fmt.Errorf("http post status: %d", resp.StatusCode) + } + log.Printf("successful post: %s %d", url, resp.StatusCode) + return nil +} diff --git a/go.mod b/go.mod new file mode 100755 index 0000000..9767e82 --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module humungus.tedunangst.com/r/inks + +go 1.13 + +require ( + github.com/gorilla/mux v1.8.0 + golang.org/x/crypto v0.0.0-20201208171446-5f87f3452ae9 + humungus.tedunangst.com/r/go-sqlite3 v1.1.3 + humungus.tedunangst.com/r/webs v0.6.45 +) diff --git a/go.sum b/go.sum new file mode 100755 index 0000000..6725e6c --- /dev/null +++ b/go.sum @@ -0,0 +1,18 @@ +github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI= +github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20190424203555-c05e17bb3b2d h1:adrbvkTDn9rGnXg2IJDKozEpXXLZN89pdIA+Syt4/u0= +golang.org/x/crypto v0.0.0-20190424203555-c05e17bb3b2d/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20201208171446-5f87f3452ae9 h1:sYNJzB4J8toYPQTM6pAkcmBRgw9SnQKP9oXCHfgy604= +golang.org/x/crypto v0.0.0-20201208171446-5f87f3452ae9/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= +golang.org/x/image v0.0.0-20190523035834-f03afa92d3ff/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +humungus.tedunangst.com/r/go-sqlite3 v1.1.3 h1:G2N4wzDS0NbuvrZtQJhh4F+3X+s7BF8b9ga8k38geUI= +humungus.tedunangst.com/r/go-sqlite3 v1.1.3/go.mod h1:FtEEmQM7U2Ey1TuEEOyY1BmphTZnmiEjPsNLEAkpf/M= +humungus.tedunangst.com/r/webs v0.6.45 h1:Pcq9OMomYBJf91/VTq+MDdf0D7GY9LCnNA+JNb0+mB0= +humungus.tedunangst.com/r/webs v0.6.45/go.mod h1:S9sXpVSbgAIa24yYhnMN0C94LKHG+2rioS+NsiDimps= diff --git a/inks.go b/inks.go new file mode 100755 index 0000000..8315213 --- /dev/null +++ b/inks.go @@ -0,0 +1,654 @@ +// +// Copyright (c) 2019 Ted Unangst +// +// 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 ( + "database/sql" + "flag" + "fmt" + "html/template" + "log" + "net/http" + "regexp" + "sort" + "strconv" + "strings" + "sync" + "time" + + "github.com/gorilla/mux" + "humungus.tedunangst.com/r/webs/httpsig" + "humungus.tedunangst.com/r/webs/login" + "humungus.tedunangst.com/r/webs/rss" + "humungus.tedunangst.com/r/webs/templates" +) + +var readviews *templates.Template + +var serverName = "localhost" +var serverURL = "https://localhost" +var tagName = "inks,2019" + +func getInfo(r *http.Request) map[string]interface{} { + templinfo := make(map[string]interface{}) + templinfo["StyleParam"] = getstyleparam("views/style.css") + templinfo["UserInfo"] = login.GetUserInfo(r) + templinfo["LogoutCSRF"] = login.GetCSRF("logout", r) + templinfo["ServerName"] = serverName + return templinfo +} + +type Link struct { + ID int64 + URL string + Posted time.Time + Source string + Site string + Title string + Tags []string + PlainSummary string + Summary template.HTML + Edit string +} + +type Tag struct { + Name string + Count int64 +} + +func taglinks(links []*Link) { + db := opendatabase() + var ids []string + lmap := make(map[int64]*Link) + for _, l := range links { + ids = append(ids, fmt.Sprintf("%d", l.ID)) + lmap[l.ID] = l + } + q := fmt.Sprintf("select linkid, tag from tags where linkid in (%s)", strings.Join(ids, ",")) + rows, err := db.Query(q) + if err != nil { + log.Printf("can't load tags: %s", err) + return + } + defer rows.Close() + for rows.Next() { + var lid int64 + var t string + err = rows.Scan(&lid, &t) + if err != nil { + log.Printf("can't scan tag: %s", err) + continue + } + l := lmap[lid] + l.Tags = append(l.Tags, t) + } + for _, l := range links { + sort.Strings(l.Tags) + } +} + +func searchlinks(search string, lastlink int64) ([]*Link, int64) { + if !regexp.MustCompile(`^["[:alnum:]_ -]*$`).MatchString(search) { + search = "" + } + quotes := 0 + for _, c := range search { + if c == '"' { + quotes++ + } + } + if quotes%2 == 1 { + search = search + `"` + } + log.Printf("searching for '%s'", search) + rows, err := stmtSearchLinks.Query(search, lastlink) + return readlinks(rows, err) +} + +func readlinks(rows *sql.Rows, err error) ([]*Link, int64) { + if err != nil { + log.Printf("error getting links: %s", err) + return nil, 0 + } + var lastlink int64 + var links []*Link + for rows.Next() { + var link Link + var dt string + err = rows.Scan(&link.ID, &link.URL, &dt, &link.Source, &link.Site, &link.Title, &link.PlainSummary) + if err != nil { + log.Printf("error scanning link: %s", err) + continue + } + link.Posted, _ = time.Parse(dbtimeformat, dt) + link.Summary = htmlify(link.PlainSummary) + links = append(links, &link) + lastlink = link.ID + } + rows.Close() + taglinks(links) + return links, lastlink +} + +func showlinks(w http.ResponseWriter, r *http.Request) { + lastlink, _ := strconv.ParseInt(mux.Vars(r)["lastlink"], 10, 0) + linkid, _ := strconv.ParseInt(mux.Vars(r)["linkid"], 10, 0) + sourcename := mux.Vars(r)["sourcename"] + sitename := mux.Vars(r)["sitename"] + tagname := mux.Vars(r)["tagname"] + search := r.FormValue("q") + + if isActivity(r.Header.Get("Accept")) { + apHandle(w, r, linkid) + return + } + + var pageinfo template.HTML + var links []*Link + if linkid > 0 { + rows, err := stmtGetLink.Query(linkid) + links, _ = readlinks(rows, err) + } else if r.URL.Path == "/random" { + rows, err := stmtRandomLinks.Query() + links, _ = readlinks(rows, err) + pageinfo = "random" + } else { + if lastlink == 0 { + lastlink = 123456789012 + } + if search != "" { + links, lastlink = searchlinks(search, lastlink) + pageinfo = templates.Sprintf("search: %s", search) + } else if tagname != "" { + rows, err := stmtTagLinks.Query(tagname, lastlink) + links, lastlink = readlinks(rows, err) + pageinfo = templates.Sprintf("tag: %s", tagname) + } else if sourcename != "" { + rows, err := stmtSourceLinks.Query(sourcename, lastlink) + links, lastlink = readlinks(rows, err) + sourceinfo := htmlify(getsourceinfo(sourcename)) + pageinfo = templates.Sprintf("source: %s

%s", sourcename, sourceinfo) + } else if sitename != "" { + rows, err := stmtSiteLinks.Query(sitename, lastlink) + links, lastlink = readlinks(rows, err) + pageinfo = templates.Sprintf("site: %s", sitename) + } else { + rows, err := stmtGetLinks.Query(lastlink) + links, lastlink = readlinks(rows, err) + } + } + + if login.GetUserInfo(r) == nil { + if r.URL.Path == "/random" { + w.Header().Set("Cache-Control", "max-age=10") + } else { + w.Header().Set("Cache-Control", "max-age=300") + } + } + + templinfo := getInfo(r) + templinfo["Links"] = links + templinfo["LastLink"] = lastlink + templinfo["SaveCSRF"] = login.GetCSRF("savelink", r) + if pageinfo != "" { + templinfo["PageInfo"] = pageinfo + } + err := readviews.Execute(w, "inks.html", templinfo) + if err != nil { + log.Printf("error templating inks: %s", err) + } +} + +func lastlinkurl() string { + row := stmtLastLink.QueryRow() + var url string + row.Scan(&url) + return url +} + +var re_sitename = regexp.MustCompile("//([^/]+)/") + +var savemtx sync.Mutex + +func savelink(w http.ResponseWriter, r *http.Request) { + url := strings.TrimSpace(r.FormValue("url")) + title := strings.TrimSpace(r.FormValue("title")) + summary := strings.TrimSpace(r.FormValue("summary")) + tags := strings.TrimSpace(r.FormValue("tags")) + source := strings.TrimSpace(r.FormValue("source")) + linkid, _ := strconv.ParseInt(r.FormValue("linkid"), 10, 0) + + savemtx.Lock() + defer savemtx.Unlock() + + if url == "" || title == "" { + http.Error(w, "need a little more info please", 400) + return + } + + if linkid == 0 && url == lastlinkurl() { + http.Error(w, "check again before posting again", 400) + return + } + + if strings.ToUpper(title) == title && strings.IndexByte(title, ' ') != -1 { + title = strings.Title(strings.ToLower(title)) + } + site := re_sitename.FindString(url) + if site != "" { + site = site[2 : len(site)-1] + } + dt := time.Now().UTC().Format(dbtimeformat) + + log.Printf("save link: %s", url) + + res, err := stmtSaveSummary.Exec(title, summary, url) + if err != nil { + log.Printf("error saving summary: %s", err) + return + } + textid, _ := res.LastInsertId() + if linkid > 0 { + stmtDeleteTags.Exec(linkid) + _, err = stmtUpdateLink.Exec(textid, url, source, site, linkid) + go apPublish(linkid, true) + } else { + res, err = stmtSaveLink.Exec(textid, url, dt, source, site) + linkid, _ = res.LastInsertId() + go apPublish(linkid, false) + } + if err != nil { + log.Printf("error saving link: %s", err) + return + } + for _, t := range strings.Split(tags, " ") { + if t == "" { + continue + } + stmtSaveTag.Exec(linkid, t) + } + http.Redirect(w, r, "/", http.StatusSeeOther) +} + +func getsourceinfo(name string) string { + row := stmtSourceInfo.QueryRow(name) + var notes string + err := row.Scan(¬es) + if err != nil { + } + return notes +} + +func alltags() []Tag { + rows, err := stmtAllTags.Query() + if err != nil { + log.Printf("error querying tags: %s", err) + return nil + } + defer rows.Close() + var tags []Tag + for rows.Next() { + var t Tag + err = rows.Scan(&t.Name, &t.Count) + if err != nil { + log.Printf("error scanning tag: %s", err) + continue + } + tags = append(tags, t) + } + sort.Slice(tags, func(i, j int) bool { + return tags[i].Name < tags[j].Name + }) + return tags +} + +func showtags(w http.ResponseWriter, r *http.Request) { + templinfo := getInfo(r) + templinfo["Tags"] = alltags() + + if login.GetUserInfo(r) == nil { + w.Header().Set("Cache-Control", "max-age=300") + } + + err := readviews.Execute(w, "tags.html", templinfo) + if err != nil { + log.Printf("error templating inks: %s", err) + } +} + +type Source struct { + Name string + Notes string + Info template.HTML +} + +func getsources() []Source { + m := make(map[string]*Source) + rows, err := stmtKnownSources.Query() + if err != nil { + log.Fatal(err) + } + for rows.Next() { + s := new(Source) + err = rows.Scan(&s.Name, &s.Notes) + if err != nil { + log.Fatal(err) + } + s.Info = htmlify(s.Notes) + m[s.Name] = s + } + rows.Close() + rows, err = stmtOtherSources.Query() + if err != nil { + log.Fatal(err) + } + var sources []Source + for rows.Next() { + var s Source + err = rows.Scan(&s.Name) + if err != nil { + log.Fatal(err) + } + if s.Name == "" { + continue + } + if i := m[s.Name]; i != nil { + s.Notes = i.Notes + s.Info = i.Info + } + sources = append(sources, s) + } + sort.Slice(sources, func(i, j int) bool { + return sources[i].Name < sources[j].Name + }) + return sources +} + +func showsources(w http.ResponseWriter, r *http.Request) { + templinfo := getInfo(r) + templinfo["SaveCSRF"] = login.GetCSRF("savesource", r) + templinfo["Sources"] = getsources() + err := readviews.Execute(w, "sources.html", templinfo) + if err != nil { + log.Print(err) + } +} +func savesource(w http.ResponseWriter, r *http.Request) { + name := r.FormValue("sourcename") + notes := r.FormValue("sourcenotes") + stmtDeleteSource.Exec(name) + stmtSaveSource.Exec(name, notes) + + http.Redirect(w, r, "/sources", http.StatusSeeOther) +} + +func fillrss(links []*Link, feed *rss.Feed) time.Time { + var modtime time.Time + for _, link := range links { + tag := fmt.Sprintf("tag:%s:inks-%d", tagName, link.ID) + summary := string(link.Summary) + if link.Source != "" { + summary += "\n

source: " + link.Source + } + feed.Items = append(feed.Items, &rss.Item{ + Title: link.Title, + Description: rss.CData{string(link.Summary)}, + Category: link.Tags, + Link: link.URL, + PubDate: link.Posted.Format(time.RFC1123), + Guid: &rss.Guid{Value: tag}, + }) + if link.Posted.After(modtime) { + modtime = link.Posted + } + } + return modtime + +} + +func showrss(w http.ResponseWriter, r *http.Request) { + log.Printf("view rss") + home := fmt.Sprintf("https://%s/", serverName) + feed := rss.Feed{ + Title: "inks", + Link: home, + Description: "inks rss", + Image: &rss.Image{ + URL: home + "icon.png", + Title: "inks rss", + Link: home, + }, + } + rows, err := stmtGetLinks.Query(123456789012) + links, _ := readlinks(rows, err) + + modtime := fillrss(links, &feed) + + 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 showrandomrss(w http.ResponseWriter, r *http.Request) { + log.Printf("view random rss") + home := fmt.Sprintf("https://%s/", serverName) + feed := rss.Feed{ + Title: "random inks", + Link: home, + Description: "random inks rss", + Image: &rss.Image{ + URL: home + "icon.png", + Title: "random inks rss", + Link: home, + }, + } + rows, err := stmtRandomLinks.Query() + links, _ := readlinks(rows, err) + + fillrss(links, &feed) + + w.Header().Set("Cache-Control", "max-age=86400") + + err = feed.Write(w) + if err != nil { + log.Printf("error writing rss: %s", err) + } +} + +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.Execute(w, r.URL.Path[1:]+".html", templinfo) + if err != nil { + log.Print(err) + } +} +func serveform(w http.ResponseWriter, r *http.Request) { + linkid, _ := strconv.ParseInt(mux.Vars(r)["linkid"], 10, 0) + link := new(Link) + if linkid > 0 { + rows, err := stmtGetLink.Query(linkid) + links, _ := readlinks(rows, err) + link = links[0] + } + templinfo := getInfo(r) + templinfo["SaveCSRF"] = login.GetCSRF("savelink", r) + templinfo["Link"] = link + err := readviews.Execute(w, "addlink.html", templinfo) + if err != nil { + log.Print(err) + } +} + +var stmtGetLink, stmtGetLinks, stmtSearchLinks, stmtSaveSummary, stmtSaveLink *sql.Stmt +var stmtLastLink *sql.Stmt +var stmtTagLinks, stmtSiteLinks, stmtSourceLinks, stmtDeleteTags, stmtUpdateLink, stmtSaveTag *sql.Stmt +var stmtAllTags, stmtRandomLinks *sql.Stmt +var stmtGetFollowers, stmtSaveFollower, stmtDeleteFollower *sql.Stmt +var stmtSaveSource, stmtDeleteSource, stmtSourceInfo, stmtKnownSources, stmtOtherSources *sql.Stmt + +func preparetodie(db *sql.DB, s string) *sql.Stmt { + stmt, err := db.Prepare(s) + if err != nil { + log.Fatalf("error %s: %s", err, s) + } + return stmt +} + +func prepareStatements(db *sql.DB) { + stmtGetLink = preparetodie(db, "select linkid, url, dt, source, site, title, summary from links join linktext on links.textid = linktext.docid where linkid = ?") + stmtLastLink = preparetodie(db, "select url from links order by linkid desc limit 1") + stmtGetLinks = preparetodie(db, "select linkid, url, dt, source, site, title, summary from links join linktext on links.textid = linktext.docid where linkid < ? order by linkid desc limit 20") + stmtSearchLinks = preparetodie(db, "select linkid, url, dt, source, site, title, summary from links join linktext on links.textid = linktext.docid where linktext match ? and linkid < ? order by linkid desc limit 20") + stmtTagLinks = preparetodie(db, "select linkid, url, dt, source, site, title, summary from links join linktext on links.textid = linktext.docid where linkid in (select linkid from tags where tag = ?) and linkid < ? order by linkid desc limit 20") + stmtSourceLinks = preparetodie(db, "select linkid, url, dt, source, site, title, summary from links join linktext on links.textid = linktext.docid where source = ? and linkid < ? order by linkid desc limit 20") + stmtSiteLinks = preparetodie(db, "select linkid, url, dt, source, site, title, summary from links join linktext on links.textid = linktext.docid where site = ? and linkid < ? order by linkid desc limit 20") + stmtRandomLinks = preparetodie(db, "select linkid, url, dt, source, site, title, summary from links join linktext on links.textid = linktext.docid order by random() limit 20") + stmtSaveSummary = preparetodie(db, "insert into linktext (title, summary, remnants) values (?, ?, ?)") + stmtSaveLink = preparetodie(db, "insert into links (textid, url, dt, source, site) values (?, ?, ?, ?, ?)") + stmtUpdateLink = preparetodie(db, "update links set textid = ?, url = ?, source = ?, site = ? where linkid = ?") + stmtDeleteTags = preparetodie(db, "delete from tags where linkid = ?") + stmtSaveTag = preparetodie(db, "insert into tags (linkid, tag) values (?, ?)") + stmtAllTags = preparetodie(db, "select tag as tag, count(tag) as cnt from tags group by tag") + stmtGetFollowers = preparetodie(db, "select url from followers") + stmtSaveFollower = preparetodie(db, "insert into followers (url) values (?)") + stmtDeleteFollower = preparetodie(db, "delete from followers where url = ?") + stmtSourceInfo = preparetodie(db, "select notes from sources where name = ?") + stmtSaveSource = preparetodie(db, "insert into sources (name, notes) values (?, ?)") + stmtDeleteSource = preparetodie(db, "delete from sources where name = ?") + stmtKnownSources = preparetodie(db, "select name, notes from sources") + stmtOtherSources = preparetodie(db, "select distinct(source) from links") +} + +func serve() { + db := opendatabase() + ver := 0 + getconfig("dbversion", &ver) + if ver != dbVersion { + log.Fatal("incorrect database version. run upgrade.") + } + + prepareStatements(db) + login.Init(db) + + listener, err := openListener() + if err != nil { + log.Fatal(err) + } + + getconfig("servername", &serverName) + serverURL = "https://" + serverName + getconfig("pubkey", &serverPubKey) + var seckey string + getconfig("seckey", &seckey) + serverPrivateKey, _, _ = httpsig.DecodeKey(seckey) + + tagName = fmt.Sprintf("%s,%d", serverName, 2019) + + debug := false + getconfig("debug", &debug) + + readviews = templates.Load(debug, + "views/header.html", + "views/inks.html", + "views/tags.html", + "views/addlink.html", + "views/sources.html", + "views/login.html", + ) + if !debug { + s := "views/style.css" + savedstyleparams[s] = getstyleparam(s) + + } + + mux := mux.NewRouter() + mux.Use(login.Checker) + + getters := mux.Methods("GET").Subrouter() + getters.HandleFunc("/", showlinks) + getters.HandleFunc("/search", showlinks) + getters.HandleFunc("/before/{lastlink:[0-9]+}", showlinks) + getters.HandleFunc("/l/{linkid:[0-9]+}", showlinks) + getters.Handle("/edit/{linkid:[0-9]+}", login.Required(http.HandlerFunc(serveform))) + getters.HandleFunc("/site/{sitename:[[:alnum:].-]+}", showlinks) + getters.HandleFunc("/source/{sourcename:[[:alnum:].-]+}", showlinks) + getters.HandleFunc("/tag/{tagname:[[:alnum:].-]+}", showlinks) + getters.HandleFunc("/random", showlinks) + getters.HandleFunc("/tags", showtags) + getters.HandleFunc("/sources", showsources) + getters.HandleFunc("/rss", showrss) + getters.HandleFunc("/random/rss", showrandomrss) + getters.HandleFunc("/style.css", servecss) + getters.HandleFunc("/login", servehtml) + getters.Handle("/addlink", login.Required(http.HandlerFunc(serveform))) + getters.HandleFunc("/logout", login.LogoutFunc) + + posters := mux.Methods("POST").Subrouter() + posters.Handle("/savelink", login.CSRFWrap("savelink", http.HandlerFunc(savelink))) + posters.Handle("/savesource", login.CSRFWrap("savesource", http.HandlerFunc(savesource))) + posters.HandleFunc("/dologin", login.LoginFunc) + + getters.HandleFunc("/.well-known/webfinger", apFinger) + getters.HandleFunc("/outbox", apOutbox) + getters.HandleFunc("/followers", ap403) + getters.HandleFunc("/following", ap403) + posters.HandleFunc("/inbox", apInbox) + + err = http.Serve(listener, mux) + if err != nil { + log.Fatal(err) + } +} + +func main() { + flag.Parse() + args := flag.Args() + cmd := "run" + if len(args) > 0 { + cmd = args[0] + } + switch cmd { + case "init": + initdb() + case "run": + serve() + case "debug": + if len(args) != 2 { + log.Fatal("need an argument: debug (on|off)") + } + switch args[1] { + case "on": + setconfig("debug", 1) + case "off": + setconfig("debug", 0) + default: + log.Fatal("argument must be on or off") + } + + case "upgrade": + upgradedb() + default: + log.Fatal("unknown command") + } +} diff --git a/schema.sql b/schema.sql new file mode 100755 index 0000000..e9fec23 --- /dev/null +++ b/schema.sql @@ -0,0 +1,22 @@ + +create table links(linkid integer primary key, textid integer, url text, dt text, source text, site text); +create virtual table linktext using fts4 (title, summary, remnants); +create table tags (tagid integer primary key, linkid integer, tag text); +create table sources (sourceid integer primary key, name text, notes text); + +create table followers(followerid integer primary key, url text); + +create index idx_linkstextid on links(textid); +create index idx_linkssite on links(site); +create index idx_linkssource on links(source); +create index idx_tagstag on tags(tag); +create index idx_tagslinkid on tags(linkid); + +CREATE TABLE config (key text, value text); + +CREATE TABLE users (userid integer primary key, username text, hash text); +CREATE TABLE auth (authid integer primary key, userid integer, hash text, expiry text); +CREATE INDEX idxusers_username on users(username); +CREATE INDEX idxauth_userid on auth(userid); +CREATE INDEX idxauth_hash on auth(hash); + diff --git a/text.go b/text.go new file mode 100755 index 0000000..41e4219 --- /dev/null +++ b/text.go @@ -0,0 +1,80 @@ +package main + +import ( + "fmt" + "html" + "html/template" + "regexp" + "strings" +) + +func htmlify(s string) template.HTML { + s = strings.Replace(s, "\r", "", -1) + s = prettyquotes(s) + 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(`%s`, url, url) + if adddot { + url += "." + } + if addparen { + url += ")" + } + return url + } + re_link := regexp.MustCompile(`https?://[^\s"]+[\w/)]`) + s = re_link.ReplaceAllStringFunc(s, linkfn) + + re_i := regexp.MustCompile("> (.*)\n?") + s = re_i.ReplaceAllString(s, "

$1
\n") + s = strings.Replace(s, "\n
", "\n", -1) + s = strings.TrimSpace(s) + renl := regexp.MustCompile("\n+") + nlrepl := func(s string) string { + if len(s) > 1 { + return "\n

" + } + return "
\n" + } + s = renl.ReplaceAllStringFunc(s, nlrepl) + + return template.HTML(s) +} + +func prettyquotes(s string) string { + lq := "\u201c" + rq := "\u201d" + ls := "\u2018" + rs := "\u2019" + ap := rs + re_lq := regexp.MustCompile(`"[^.,\s]`) + lq_fn := func(s string) string { + return lq + s[1:] + } + s = re_lq.ReplaceAllStringFunc(s, lq_fn) + s = strings.Replace(s, `"`, rq, -1) + re_ap := regexp.MustCompile(`\w'`) + ap_fn := func(s string) string { + return s[0:len(s)-1] + ap + } + s = re_ap.ReplaceAllStringFunc(s, ap_fn) + re_ls := regexp.MustCompile(`'\w`) + ls_fn := func(s string) string { + return ls + s[1:] + } + s = re_ls.ReplaceAllStringFunc(s, ls_fn) + s = strings.Replace(s, `'`, rs, -1) + + return s +} diff --git a/text_test.go b/text_test.go new file mode 100755 index 0000000..3ec30af --- /dev/null +++ b/text_test.go @@ -0,0 +1,39 @@ +package main + +import "testing" + +func TestHtml(t *testing.T) { + in := + `> we start with a quote + +A comment. I liked it. + +> feature one +> feature two +> feature three + +nice! +` + + out := `

we start with a quote
+

A comment. I liked it. +

feature one
+feature two
+feature three
+

nice!` + rv := string(htmlify(in)) + if rv != out { + t.Errorf("failure.\nresult: %s\nexpected: %s\n", rv, out) + } + + in = `> one quote + +> two quote` + out = `

one quote
+

two quote
` + + rv = string(htmlify(in)) + if rv != out { + t.Errorf("failure.\nresult: %s\nexpected: %s\n", rv, out) + } +} diff --git a/upgradedb.go b/upgradedb.go new file mode 100755 index 0000000..ec7a8cb --- /dev/null +++ b/upgradedb.go @@ -0,0 +1,55 @@ +// +// Copyright (c) 2019 Ted Unangst +// +// 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 ( + "database/sql" + "log" + "os" +) + +var dbVersion = 2 + +func doordie(db *sql.DB, s string, args ...interface{}) { + _, err := db.Exec(s, args...) + if err != nil { + log.Fatalf("can't run %s: %s", s, err) + } +} + +func upgradedb() { + db := opendatabase() + ver := 0 + getconfig("dbversion", &ver) + + switch ver { + case 0: + doordie(db, "drop table auth") + doordie(db, "CREATE TABLE auth (authid integer primary key, userid integer, hash text, expiry text)") + doordie(db, "CREATE INDEX idxauth_hash on auth(hash)") + doordie(db, "insert into config (key, value) values ('dbversion', 1)") + fallthrough + case 1: + doordie(db, "create table sources (sourceid integer primary key, name text, notes text)") + doordie(db, "update config set value = 2 where key = 'dbversion'") + fallthrough + case 2: + + default: + log.Fatalf("can't upgrade unknown version %d", ver) + } + os.Exit(0) +} diff --git a/util.go b/util.go new file mode 100755 index 0000000..ec912fe --- /dev/null +++ b/util.go @@ -0,0 +1,281 @@ +// +// Copyright (c) 2019 Ted Unangst +// +// 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 + +/* +#include + +void +termecho(int on) +{ + struct termios t; + tcgetattr(1, &t); + if (on) + t.c_lflag |= ECHO; + else + t.c_lflag &= ~ECHO; + tcsetattr(1, TCSADRAIN, &t); +} +*/ +import "C" + +import ( + "bufio" + "crypto/rand" + "crypto/rsa" + "crypto/sha512" + "database/sql" + "fmt" + "io/ioutil" + "log" + "net" + "os" + "os/signal" + "strings" + + "golang.org/x/crypto/bcrypt" + _ "humungus.tedunangst.com/r/go-sqlite3" + "humungus.tedunangst.com/r/webs/httpsig" +) + +var savedstyleparams = make(map[string]string) + +func getstyleparam(file string) string { + if p, ok := savedstyleparams[file]; ok { + return p + } + data, err := ioutil.ReadFile(file) + if err != nil { + return "" + } + 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 = "inks.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) + } + alreadyopendb = db + defer func() { + os.Remove(dbname) + os.Exit(1) + }() + c := make(chan os.Signal) + signal.Notify(c, os.Interrupt) + go func() { + <-c + C.termecho(1) + 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 + } + C.termecho(0) + fmt.Printf("password: ") + pass, err := r.ReadString('\n') + C.termecho(1) + fmt.Printf("\n") + if err != nil { + log.Print(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 + } + setconfig("dbversion", dbVersion) + k, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + log.Print(err) + return + } + pubkey, err := httpsig.EncodeKey(&k.PublicKey) + if err != nil { + log.Print(err) + return + } + _, err = db.Exec("insert into config (key, value) values (?, ?)", "pubkey", pubkey) + if err != nil { + log.Print(err) + return + } + seckey, err := httpsig.EncodeKey(k) + if err != nil { + log.Print(err) + return + } + _, err = db.Exec("insert into config (key, value) values (?, ?)", "seckey", seckey) + if err != nil { + log.Print(err) + return + } + prepareStatements(db) + db.Close() + fmt.Printf("done.\n") + os.Exit(0) +} + +func setconfig(key string, val interface{}) error { + db := opendatabase() + _, err := db.Exec("insert into config (key, value) values (?, ?)", key, val) + return err +} + +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 +} diff --git a/views/addlink.html b/views/addlink.html new file mode 100755 index 0000000..2a0031c --- /dev/null +++ b/views/addlink.html @@ -0,0 +1,18 @@ +{{ template "header.html" . }} +
+ +
+ + diff --git a/views/header.html b/views/header.html new file mode 100755 index 0000000..7cf03f3 --- /dev/null +++ b/views/header.html @@ -0,0 +1,23 @@ + +inks + + + + + + +
+@inks@{{ .ServerName }} +tags +sources +random +{{ if .UserInfo }} +add link +logout +{{ else }} +rss +{{ end }} +
+ +
+
diff --git a/views/inks.html b/views/inks.html new file mode 100755 index 0000000..e3e61b0 --- /dev/null +++ b/views/inks.html @@ -0,0 +1,38 @@ +{{ template "header.html" . }} +
+{{ if .PageInfo }} + +{{ end }} +{{ $csrf := .SaveCSRF }} +{{ range .Links }} + +{{ end }} +
+ + diff --git a/views/login.html b/views/login.html new file mode 100755 index 0000000..e9eb564 --- /dev/null +++ b/views/login.html @@ -0,0 +1,10 @@ +{{ template "header.html" . }} +
+ +
+ + diff --git a/views/sources.html b/views/sources.html new file mode 100755 index 0000000..9997209 --- /dev/null +++ b/views/sources.html @@ -0,0 +1,22 @@ +{{ template "header.html" . }} +
+{{ $csrf := .SaveCSRF }} + +{{ range .Sources }} + +{{ if $csrf }} + + + +
+
+ + diff --git a/views/style.css b/views/style.css new file mode 100755 index 0000000..d5ccf19 --- /dev/null +++ b/views/style.css @@ -0,0 +1,112 @@ +body { + background: #121; + font-size: 18px; + font-family: monospace; + margin: 2em; + word-break: break-word; +} +:focus { + outline: 2px solid #aea; +} +a { + color: #aea; +} +main, header { + max-width: 1260px; + margin: auto; +} +header { + margin-bottom: 2em; +} +header form { + display: inline; +} +header span, header form { + margin-right: 1em; + white-space: nowrap; +} +blockquote { + margin-left: 1.5em; + padding-left: 0.5em; + margin-right: 2em; + border-left: 1px solid #474; +} +.link { + padding: 0em; + color: #8d8; + background: #121; + border: 2px solid #474; + margin-bottom: 2em; +} + +article.link p { + margin: 0; + margin-left: 2em; + margin-right: 2em; +} +.link .title { + font-size: 1.5em; + margin-left: 0em; + margin-bottom: 0.5em; +} +.link .summary p { + margin-top: 1em; +} +.link .tail { + margin-top: 1em; +} + +form.link { + padding: 1em; + padding-top: 0em; + padding-bottom: 0em; + font-size: 1.0em; + margin: 0em; + border: 2px solid #aea; +} +tr.link td { + padding: 1em; + font-size: 1.5em; + margin: 1em; + border: 2px solid #aea; +} +input, textarea { + background: #121; + color: #aea; + font-size: 0.9em; + font-family: monospace; + border: 2px solid #474; +} +form.link input { + width: 85%; +} +form.link textarea { + height: 12em; + width: 85%; +} +@media screen and (max-width: 740px) { + body { + margin: 0.5em; + } + header { + margin: 1.0em; + line-height: 2; + } + header form { + display: block; + } + .link .title { + font-size: 1.3em; + } + article.link p { + margin-left: 1em; + margin-right: 1em; + } + blockquote { + margin-left: 0.5em; + margin-right: 1.0em; + } + form.link input, form.link textarea { + width: 100%; + } +} diff --git a/views/tags.html b/views/tags.html new file mode 100755 index 0000000..0273065 --- /dev/null +++ b/views/tags.html @@ -0,0 +1,17 @@ +{{ template "header.html" . }} +
+{{ $letter := 0 }} + +
+ + -- cgit v1.2.3