summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore2
-rwxr-xr-x.hgtags1
-rwxr-xr-xLICENSE24
-rwxr-xr-xMakefile8
-rwxr-xr-xREADME25
-rwxr-xr-xactivitypub.go430
-rwxr-xr-xgo.mod10
-rwxr-xr-xgo.sum18
-rwxr-xr-xinks.go654
-rwxr-xr-xschema.sql22
-rwxr-xr-xtext.go80
-rwxr-xr-xtext_test.go39
-rwxr-xr-xupgradedb.go55
-rwxr-xr-xutil.go281
-rwxr-xr-xviews/addlink.html18
-rwxr-xr-xviews/header.html23
-rwxr-xr-xviews/inks.html38
-rwxr-xr-xviews/login.html10
-rwxr-xr-xviews/sources.html22
-rwxr-xr-xviews/style.css112
-rwxr-xr-xviews/tags.html17
21 files changed, 1889 insertions, 0 deletions
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 <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.
+
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 <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"
+ "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(`<p><a href="%s">%s</a><p>`, html.EscapeString(link.URL), html.EscapeString(link.URL)))
+
+ sb.WriteString(string(link.Summary))
+
+ sb.WriteString("<p>")
+ for _, tag := range link.Tags {
+ sb.WriteString(fmt.Sprintf(`<a href="%s/tag/%s">#%s</a> `, 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 <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 (
+ "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<p>%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(&notes)
+ 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<p>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(`<a href="%s">%s</a>`, 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("&gt; (.*)\n?")
+ s = re_i.ReplaceAllString(s, "<blockquote>$1</blockquote>\n")
+ s = strings.Replace(s, "</blockquote>\n<blockquote>", "\n", -1)
+ s = strings.TrimSpace(s)
+ renl := regexp.MustCompile("\n+")
+ nlrepl := func(s string) string {
+ if len(s) > 1 {
+ return "\n<p>"
+ }
+ return "<br>\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 := `<blockquote>we start with a quote</blockquote>
+<p>A comment. I liked it.
+<p><blockquote>feature one<br>
+feature two<br>
+feature three</blockquote>
+<p>nice!`
+ rv := string(htmlify(in))
+ if rv != out {
+ t.Errorf("failure.\nresult: %s\nexpected: %s\n", rv, out)
+ }
+
+ in = `> one quote
+
+> two quote`
+ out = `<blockquote>one quote</blockquote>
+<p><blockquote>two quote</blockquote>`
+
+ 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 <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 (
+ "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 <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
+
+/*
+#include <termios.h>
+
+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" . }}
+<main>
+<form action="/savelink" method="POST" class="link">
+<input type="hidden" name="CSRF" value="{{ .SaveCSRF }}">
+{{ with .Link }}
+<input type="hidden" name="linkid" value="{{ .ID }}">
+<p><input tabindex=1 type="text" name="url" value="{{ .URL }}" autocomplete=off> - url
+<p><input tabindex=1 type="text" name="title" value="{{ .Title }}" autocomplete=off> - title
+<p>
+<textarea tabindex=1 name="summary">{{ .PlainSummary }}</textarea>
+<p><input tabindex=1 type="text" name="tags" value="{{ range .Tags }}{{.}} {{ end }}" autocomplete=off> - tags
+<p><input tabindex=1 type="text" name="source" value="{{ .Source }}" autocomplete=off> - source
+{{ end }}
+<p><input tabindex=1 type="submit" name="submit" value="submit">
+</form>
+</main>
+</body>
+</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 @@
+<head>
+<title>inks</title>
+<link href="/style.css{{ .StyleParam }}" rel="stylesheet">
+<link href="/rss" rel="alternate" type="application/rss+xml" title="inks rss">
+<link href="/icon.png" rel="icon">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+</head>
+<body>
+<header>
+<span><a href="/" title="follow via activitypub">@inks@{{ .ServerName }}</a></span>
+<span><a href="/tags">tags</a></span>
+<span><a href="/sources">sources</a></span>
+<span><a href="/random">random</a></span>
+{{ if .UserInfo }}
+<span><a href="/addlink">add link</a></span>
+<span><a href="/logout?CSRF={{ .LogoutCSRF }}">logout</a></span>
+{{ else }}
+<span><a href="/rss">rss</a></span>
+{{ end }}
+<form action="/search" method="GET">
+<input tabindex=10 type="text" name="q" autocomplete=off size=18 placeholder="search">
+</form>
+</header>
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" . }}
+<main>
+{{ if .PageInfo }}
+<div class="link">
+<div class="summary">
+<p>{{ .PageInfo }}
+</div>
+</div>
+{{ end }}
+{{ $csrf := .SaveCSRF }}
+{{ range .Links }}
+<article class="link">
+<p class="title">{{ .Title }}
+<p class="url"><a href="{{ .URL }}">{{ .URL }}</a> [<a href="/site/{{ .Site }}">{{ .Site }}</a>]
+<p>{{ .Posted.Format "2006-01-02 15:04" }}
+<p class="tags">tags:
+{{ range .Tags }}
+<a class="tag" href="/tag/{{ . }}">{{ . }}</a>
+{{ end }}
+<div class="summary">
+<p>
+{{ .Summary }}
+{{ if .Source }}
+<p>source: <a href="/source/{{ .Source }}">{{ .Source }}</a>
+{{ end }}
+</div>
+<div class="tail">
+<a href="/l/{{ .ID }}">#</a>
+{{ if $csrf }}
+<span style="margin-left:0.75em"><a href="/edit/{{ .ID }}">edit</a>
+</span>
+{{ end }}
+</div>
+</article>
+{{ end }}
+</main>
+</body>
+</html>
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" . }}
+<main>
+<form action="/dologin" method="POST" class="link">
+<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>
+</main>
+</body>
+</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" . }}
+<main>
+{{ $csrf := .SaveCSRF }}
+<table>
+{{ range .Sources }}
+<tr class="link">
+{{ if $csrf }}
+<form action="/savesource" method="POST" class="link">
+<input type="hidden" name="CSRF" value="{{ $csrf }}">
+<input type="hidden" name="sourcename" value="{{ .Name }}" autocomplete=off>
+<td><a href="/source/{{ .Name }}">{{ .Name }}</a>
+<td><input tabindex=1 type="text" name="sourcenotes" value="{{ .Notes }}" autocomplete=off>
+<td><input tabindex=1 type="submit" name="submit" value="submit">
+</form>
+{{ else }}
+<td><a href="/source/{{ .Name }}">{{ .Name }}</a>{{ with .Info }} - {{ . }}{{ end }}
+{{ end }}
+{{ end }}
+</table>
+</main>
+</body>
+</html>
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" . }}
+<main>
+{{ $letter := 0 }}
+<div class=link>
+<ul>
+{{ range .Tags }}
+{{ if not (eq $letter (index .Name 0)) }}
+{{ $letter = (index .Name 0) }}
+<li><p>
+{{ end }}
+<a href="/tag/{{ .Name }}">{{ .Name }}</a> ({{ .Count }})
+{{ end }}
+</ul>
+</div>
+</main>
+</body>
+</html>