diff options
| -rw-r--r-- | .gitignore | 2 | ||||
| -rwxr-xr-x | .hgtags | 1 | ||||
| -rwxr-xr-x | LICENSE | 24 | ||||
| -rwxr-xr-x | Makefile | 8 | ||||
| -rwxr-xr-x | README | 25 | ||||
| -rwxr-xr-x | activitypub.go | 430 | ||||
| -rwxr-xr-x | go.mod | 10 | ||||
| -rwxr-xr-x | go.sum | 18 | ||||
| -rwxr-xr-x | inks.go | 654 | ||||
| -rwxr-xr-x | schema.sql | 22 | ||||
| -rwxr-xr-x | text.go | 80 | ||||
| -rwxr-xr-x | text_test.go | 39 | ||||
| -rwxr-xr-x | upgradedb.go | 55 | ||||
| -rwxr-xr-x | util.go | 281 | ||||
| -rwxr-xr-x | views/addlink.html | 18 | ||||
| -rwxr-xr-x | views/header.html | 23 | ||||
| -rwxr-xr-x | views/inks.html | 38 | ||||
| -rwxr-xr-x | views/login.html | 10 | ||||
| -rwxr-xr-x | views/sources.html | 22 | ||||
| -rwxr-xr-x | views/style.css | 112 | ||||
| -rwxr-xr-x | views/tags.html | 17 |
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 @@ -0,0 +1 @@ +f98c91b540e28318fa0e46e01c0292f43865b054 v0.9.0 @@ -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 @@ -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 +} @@ -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 +) @@ -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= @@ -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(¬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<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); + @@ -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("> (.*)\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) +} @@ -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> |
