twitterchiver: init viewer
This commit is contained in:
parent
36682c25cf
commit
c5a53d9334
7 changed files with 610 additions and 3 deletions
|
@ -10,6 +10,7 @@ require (
|
||||||
github.com/dghubble/gologin/v2 v2.2.0
|
github.com/dghubble/gologin/v2 v2.2.0
|
||||||
github.com/dghubble/oauth1 v0.6.0
|
github.com/dghubble/oauth1 v0.6.0
|
||||||
github.com/dgrijalva/jwt-go v3.2.0+incompatible
|
github.com/dgrijalva/jwt-go v3.2.0+incompatible
|
||||||
|
github.com/google/safehtml v0.0.2 // indirect
|
||||||
github.com/gorilla/mux v1.8.0
|
github.com/gorilla/mux v1.8.0
|
||||||
github.com/gorilla/securecookie v1.1.1
|
github.com/gorilla/securecookie v1.1.1
|
||||||
github.com/gorilla/sessions v1.2.1
|
github.com/gorilla/sessions v1.2.1
|
||||||
|
|
|
@ -37,6 +37,8 @@ github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO
|
||||||
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
|
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
|
||||||
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||||
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
||||||
|
github.com/google/safehtml v0.0.2 h1:ZOt2VXg4x24bW0m2jtzAOkhoXV0iM8vNKc0paByCZqM=
|
||||||
|
github.com/google/safehtml v0.0.2/go.mod h1:L4KWwDsUJdECRAEpZoBn3O64bQaywRscowZjJAzjHnU=
|
||||||
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
|
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
|
||||||
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
|
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
|
||||||
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
|
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
|
||||||
|
|
|
@ -11,4 +11,23 @@
|
||||||
depot.third_party.gopkgs."github.com".jackc.pgx.v4
|
depot.third_party.gopkgs."github.com".jackc.pgx.v4
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
viewer = depot.third_party.buildGo.program {
|
||||||
|
name = "viewer";
|
||||||
|
srcs = [ ./viewer/viewer.go ];
|
||||||
|
deps = [
|
||||||
|
depot.third_party.gopkgs."github.com".google.safehtml
|
||||||
|
depot.third_party.gopkgs."github.com".google.safehtml.template
|
||||||
|
depot.third_party.gopkgs."github.com".google.safehtml.uncheckedconversions
|
||||||
|
depot.third_party.gopkgs."github.com".gorilla.mux
|
||||||
|
depot.third_party.gopkgs."github.com".jackc.pgtype
|
||||||
|
depot.third_party.gopkgs."github.com".jackc.pgx.v4.pgxpool
|
||||||
|
depot.go.openshiftauth.openshiftauth
|
||||||
|
];
|
||||||
|
dockerData = [ (
|
||||||
|
depot.pkgs.runCommand "source" {} ''
|
||||||
|
cp -R ${builtins.filterSource (path: type: (type == "directory" && depot.lib.hasSuffix "/templates" path) || (depot.lib.hasInfix "/templates/" path)) ./viewer} $out
|
||||||
|
''
|
||||||
|
) ];
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
8
go/twitterchiver/viewer/templates/index.html
Normal file
8
go/twitterchiver/viewer/templates/index.html
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<h1>Twitterchiver</h1>
|
||||||
|
<h2>Accounts visible to {{.Username}}</h2>
|
||||||
|
<ul>
|
||||||
|
{{range .TwitterAccounts}}
|
||||||
|
<li><strong><a href="/view/{{.Username}}">{{.Username}}</a></strong> ({{.TweetCount}} tweets, latest tweet at {{.LatestTweet}})
|
||||||
|
{{end}}
|
||||||
|
</ul>
|
158
go/twitterchiver/viewer/templates/tweets.html
Normal file
158
go/twitterchiver/viewer/templates/tweets.html
Normal file
|
@ -0,0 +1,158 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<style>
|
||||||
|
html, body {
|
||||||
|
background-color: rgb(21, 32, 43);
|
||||||
|
color: white;
|
||||||
|
font-family: sans-serif;
|
||||||
|
}
|
||||||
|
.tweet-list {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.tweet-list-item {
|
||||||
|
display: block;
|
||||||
|
padding-top: 8px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid rgb(136, 153, 166);
|
||||||
|
width: clamp(calc(45ch+64px), 100%, calc(75ch+64px));
|
||||||
|
}
|
||||||
|
.tweet-list-item:first-of-type {
|
||||||
|
margin-top: 8px;
|
||||||
|
border-top: 1px solid rgb(136, 153, 166);
|
||||||
|
}
|
||||||
|
.tweet {
|
||||||
|
margin-left: 64px;
|
||||||
|
}
|
||||||
|
.tweet::after {
|
||||||
|
clear: both;
|
||||||
|
content: "";
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
text-decoration-skip-ink: auto;
|
||||||
|
}
|
||||||
|
.retweeted-byline {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.tweet-author-img {
|
||||||
|
float: left;
|
||||||
|
margin-left: -58px;
|
||||||
|
|
||||||
|
border-radius: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.byline-link:hover {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.byline-link:hover .byline-name {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
.byline-name {
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.byline, .retweeted-byline {
|
||||||
|
color: rgb(136, 153, 166);
|
||||||
|
}
|
||||||
|
.retweeted-byline {
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.retweeted-icon {
|
||||||
|
color: rgb(23, 191, 99);
|
||||||
|
width: 19px;
|
||||||
|
fill: currentcolor;
|
||||||
|
float: left;
|
||||||
|
margin-left: -24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
overflow-wrap: break-word;
|
||||||
|
}
|
||||||
|
.content a {
|
||||||
|
color: rgb(27, 149, 224);
|
||||||
|
}
|
||||||
|
|
||||||
|
.media {
|
||||||
|
margin-top: 8px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 50% 50%;
|
||||||
|
grid-template-rows: 50% 50%;
|
||||||
|
grid-auto-flow: dense;
|
||||||
|
}
|
||||||
|
.media-item:only-child {
|
||||||
|
grid-column-end: span 2;
|
||||||
|
}
|
||||||
|
.media-link {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.media-link::before {
|
||||||
|
content: "";
|
||||||
|
display: block;
|
||||||
|
padding-bottom: calc(100% / (1920/1080));
|
||||||
|
}
|
||||||
|
.media-img {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<h1>Twitterchiver: {{.TwitterUsername}}</h1>
|
||||||
|
{{if .Query}}
|
||||||
|
<h2>Searching for: {{.Query}}</h2>
|
||||||
|
{{end}}
|
||||||
|
<form action="" method="GET">
|
||||||
|
<input type="search" value="{{.Query}}" name="q">
|
||||||
|
</form>
|
||||||
|
<ol class="tweet-list">
|
||||||
|
{{range .Tweets}}
|
||||||
|
<li class="tweet-list-item">
|
||||||
|
<div class="tweet">
|
||||||
|
{{$status := .Object}}
|
||||||
|
{{if .Object.retweeted_status}}
|
||||||
|
{{$status = .Object.retweeted_status}}
|
||||||
|
<svg viewBox="0 0 24 24" class="retweeted-icon"><g><path d="M23.615 15.477c-.47-.47-1.23-.47-1.697 0l-1.326 1.326V7.4c0-2.178-1.772-3.95-3.95-3.95h-5.2c-.663 0-1.2.538-1.2 1.2s.537 1.2 1.2 1.2h5.2c.854 0 1.55.695 1.55 1.55v9.403l-1.326-1.326c-.47-.47-1.23-.47-1.697 0s-.47 1.23 0 1.697l3.374 3.375c.234.233.542.35.85.35s.613-.116.848-.35l3.375-3.376c.467-.47.467-1.23-.002-1.697zM12.562 18.5h-5.2c-.854 0-1.55-.695-1.55-1.55V7.547l1.326 1.326c.234.235.542.352.848.352s.614-.117.85-.352c.468-.47.468-1.23 0-1.697L5.46 3.8c-.47-.468-1.23-.468-1.697 0L.388 7.177c-.47.47-.47 1.23 0 1.697s1.23.47 1.697 0L3.41 7.547v9.403c0 2.178 1.773 3.95 3.95 3.95h5.2c.664 0 1.2-.538 1.2-1.2s-.535-1.2-1.198-1.2z"></path></g></svg>
|
||||||
|
<a class="retweeted-byline" href="https://twitter.com/{{.Object.user.screen_name}}"><!--
|
||||||
|
-->{{.Object.user.name}} (@{{.Object.user.screen_name}}) retweeted<!--
|
||||||
|
--></a>
|
||||||
|
{{end}}
|
||||||
|
<a href="https://twitter.com/{{$status.user.screen_name}}"><img class="tweet-author-img" src="{{$status.user.profile_image_url_https}}"></a>
|
||||||
|
<div class="byline">
|
||||||
|
<a href="https://twitter.com/{{$status.user.screen_name}}" class="byline-link"><!--
|
||||||
|
--><strong class="byline-name">{{$status.user.name}}</strong>
|
||||||
|
<span class="byline-username">@{{$status.user.screen_name}}</span><!--
|
||||||
|
--></a>
|
||||||
|
·
|
||||||
|
<a href="https://twitter.com/{{$status.user.screen_name}}/status/{{$status.id_str}}" class="timestamp"><time datetime="{{.CreatedAt.Format "2006-01-02T15:04:05-0700"}}">{{.CreatedAtFriendly}}</time></a>
|
||||||
|
</div>
|
||||||
|
<div class="content"><!--
|
||||||
|
-->{{call $.FormatTweetText $status.full_text $status}}<!--
|
||||||
|
--></div>
|
||||||
|
{{if .Object.extended_entities}}
|
||||||
|
<div class="media">
|
||||||
|
{{range $entity := .Object.extended_entities.media}}
|
||||||
|
<div class="media-item">
|
||||||
|
<a href="{{$entity.expanded_url}}" class="media-link">
|
||||||
|
<img src="{{$entity.media_url_https}}" class="media-img">
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
{{end}}
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
{{if .NextTweetID}}
|
||||||
|
<a href="?start_from={{.NextTweetID}}&q={{.Query}}">...next</a>
|
||||||
|
{{end}}
|
418
go/twitterchiver/viewer/viewer.go
Normal file
418
go/twitterchiver/viewer/viewer.go
Normal file
|
@ -0,0 +1,418 @@
|
||||||
|
// SPDX-FileCopyrightText: 2020 Luke Granger-Brown <depot@lukegb.com>
|
||||||
|
//
|
||||||
|
// SPDX-License-IDentifier: Apache-2.0
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/safehtml"
|
||||||
|
"github.com/google/safehtml/template"
|
||||||
|
"github.com/google/safehtml/uncheckedconversions"
|
||||||
|
"github.com/gorilla/mux"
|
||||||
|
"github.com/jackc/pgtype"
|
||||||
|
"github.com/jackc/pgx/v4/pgxpool"
|
||||||
|
"hg.lukegb.com/lukegb/depot/go/openshiftauth"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
databaseURL = flag.String("database_url", "", "Database URL")
|
||||||
|
userMapping = userMap{}
|
||||||
|
localDisableAuth = flag.String("local_auth_override_user", "", "Disable authn/authz - used for dev - set to username")
|
||||||
|
|
||||||
|
indexTmpl = template.Must(template.ParseFiles("templates/index.html"))
|
||||||
|
tweetsTmpl = template.Must(template.ParseFiles("templates/tweets.html"))
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
flag.Var(userMapping, "user_to_twitter", "Space-separated list of <username>:<comma-separated Twitter usernames>")
|
||||||
|
}
|
||||||
|
|
||||||
|
type userMap map[string][]string
|
||||||
|
|
||||||
|
func (um userMap) String() string {
|
||||||
|
var bits []string
|
||||||
|
for u, ts := range um {
|
||||||
|
ts2 := make([]string, len(ts))
|
||||||
|
copy(ts2, ts)
|
||||||
|
sort.Strings(ts2)
|
||||||
|
bits = append(bits, fmt.Sprintf("%s:%s", u, strings.Join(ts2, ",")))
|
||||||
|
}
|
||||||
|
sort.Strings(bits)
|
||||||
|
return strings.Join(bits, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (um userMap) Set(v string) error {
|
||||||
|
bits := strings.Split(v, " ")
|
||||||
|
for _, b := range bits {
|
||||||
|
utsPair := strings.Split(b, ":")
|
||||||
|
if len(utsPair) != 2 {
|
||||||
|
return fmt.Errorf("%v is not a <username>:<comma-separated twitter usernames> string", b)
|
||||||
|
}
|
||||||
|
u := utsPair[0]
|
||||||
|
ts := utsPair[1]
|
||||||
|
um[u] = append(um[u], strings.Split(ts, ",")...)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
flag.Parse()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
pool, err := pgxpool.Connect(ctx, *databaseURL)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("pgxpool.Connect: %v", err)
|
||||||
|
}
|
||||||
|
defer pool.Close()
|
||||||
|
|
||||||
|
r := mux.NewRouter()
|
||||||
|
r.HandleFunc("/healthz", func(rw http.ResponseWriter, r *http.Request) {
|
||||||
|
rw.Header().Set("Content-Type", "text/plain")
|
||||||
|
fmt.Fprintf(rw, "ok")
|
||||||
|
})
|
||||||
|
|
||||||
|
var authR *mux.Router
|
||||||
|
if *localDisableAuth != "" {
|
||||||
|
authR = r
|
||||||
|
} else {
|
||||||
|
authR, err = openshiftauth.NewRouter(r)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("openshiftauth.NewRouter: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
userFromContext := func(ctx context.Context) string {
|
||||||
|
if *localDisableAuth != "" {
|
||||||
|
return *localDisableAuth
|
||||||
|
}
|
||||||
|
return openshiftauth.UserFromContext(ctx).Metadata.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
writeError := func(rw http.ResponseWriter, status int, wrap string, err error) {
|
||||||
|
log.Printf("Error in HTTP handler: %v: %v", wrap, err)
|
||||||
|
rw.WriteHeader(status)
|
||||||
|
fmt.Fprintf(rw, "<h1>Oops. Something went wrong.</h1>")
|
||||||
|
fmt.Fprintf(rw, "<p>%s</p>", wrap)
|
||||||
|
}
|
||||||
|
|
||||||
|
authR.HandleFunc("/", func(rw http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
user := userFromContext(ctx)
|
||||||
|
twitterAccounts := userMapping[user]
|
||||||
|
|
||||||
|
rows, err := pool.Query(ctx, "SELECT ua.username, COUNT(uat.tweetid) tweet_count, (SELECT CAST(object->>'created_at' AS timestamp with time zone) FROM tweets WHERE id=MAX(uat.tweetid)) latest_tweet FROM user_accounts ua LEFT JOIN user_accounts_tweets uat ON uat.userid=ua.userid WHERE ua.username = ANY($1::text[]) GROUP BY 1 ORDER BY 1", twitterAccounts)
|
||||||
|
if err != nil {
|
||||||
|
writeError(rw, http.StatusInternalServerError, "querying database", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
type twitterData struct {
|
||||||
|
Username string
|
||||||
|
TweetCount int
|
||||||
|
LatestTweet time.Time
|
||||||
|
}
|
||||||
|
var tds []twitterData
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var td twitterData
|
||||||
|
if err := rows.Scan(&td.Username, &td.TweetCount, &td.LatestTweet); err != nil {
|
||||||
|
writeError(rw, http.StatusInternalServerError, "reading from database", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tds = append(tds, td)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
|
||||||
|
indexTmpl.Execute(rw, struct {
|
||||||
|
Username string
|
||||||
|
TwitterAccounts []twitterData
|
||||||
|
}{
|
||||||
|
Username: user,
|
||||||
|
TwitterAccounts: tds,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
isAllowedToSee := func(ctx context.Context, twitterUser string) bool {
|
||||||
|
twitterAccounts := userMapping[userFromContext(ctx)]
|
||||||
|
for _, a := range twitterAccounts {
|
||||||
|
if a == twitterUser {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
toInt := func(s string, def int) int {
|
||||||
|
n, err := strconv.ParseInt(s, 10, 0)
|
||||||
|
if err != nil {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return int(n)
|
||||||
|
}
|
||||||
|
clamp := func(min, n, max int) int {
|
||||||
|
if n < min {
|
||||||
|
return min
|
||||||
|
} else if n > max {
|
||||||
|
return max
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
authR.HandleFunc("/view/{twitterUser}", func(rw http.ResponseWriter, r *http.Request) {
|
||||||
|
ctx := r.Context()
|
||||||
|
vars := mux.Vars(r)
|
||||||
|
twitterUser := vars["twitterUser"]
|
||||||
|
if !isAllowedToSee(ctx, twitterUser) {
|
||||||
|
writeError(rw, http.StatusNotFound, "no such twitter user being archived", fmt.Errorf("user %q attempted to access %q", userFromContext(ctx), twitterUser))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
q := r.URL.Query()
|
||||||
|
pageSize := clamp(1, toInt(q.Get("page_size"), 20), 200)
|
||||||
|
startFrom := toInt(q.Get("start_from"), 0)
|
||||||
|
query := q.Get("q")
|
||||||
|
|
||||||
|
rows, err := pool.Query(ctx, `
|
||||||
|
SELECT
|
||||||
|
t.id,
|
||||||
|
t.text,
|
||||||
|
t.object,
|
||||||
|
CAST(COALESCE(t.object->'retweeted_status'->>'created_at', t.object->>'created_at') AS timestamp with time zone) created_at
|
||||||
|
FROM
|
||||||
|
user_accounts_tweets uat
|
||||||
|
INNER JOIN
|
||||||
|
user_accounts ua ON ua.userid=uat.userid
|
||||||
|
INNER JOIN tweets t ON t.id=uat.tweetid
|
||||||
|
WHERE 1=1
|
||||||
|
AND ua.username=$1
|
||||||
|
AND ($3::bigint=0 OR t.id <= $3::bigint)
|
||||||
|
AND ($4='' OR ($4<>'' AND (to_tsvector('english', text) @@ to_tsquery('english', $4) OR to_tsvector('english', object->'retweeted_status'->>'full_text') @@ to_tsquery('english', $4) OR object->'user'->>'screen_name'=$4)))
|
||||||
|
ORDER BY t.id DESC
|
||||||
|
LIMIT $2
|
||||||
|
`, twitterUser, pageSize+1, startFrom, query)
|
||||||
|
if err != nil {
|
||||||
|
writeError(rw, http.StatusInternalServerError, "querying database", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
type tweet struct {
|
||||||
|
ID int64
|
||||||
|
Text string
|
||||||
|
CreatedAt time.Time
|
||||||
|
CreatedAtFriendly string
|
||||||
|
Object interface{}
|
||||||
|
}
|
||||||
|
type twitterData struct {
|
||||||
|
TwitterUsername string
|
||||||
|
Query string
|
||||||
|
Tweets []tweet
|
||||||
|
NextTweetID *int64
|
||||||
|
|
||||||
|
FormatTweetText func(string, interface{}) safehtml.HTML
|
||||||
|
}
|
||||||
|
pullIndices := func(m map[string]interface{}) [2]int {
|
||||||
|
midx := m["indices"].([]interface{})
|
||||||
|
midx0 := int(midx[0].(float64))
|
||||||
|
midx1 := int(midx[1].(float64))
|
||||||
|
return [2]int{midx0, midx1}
|
||||||
|
}
|
||||||
|
td := twitterData{
|
||||||
|
TwitterUsername: twitterUser,
|
||||||
|
Query: query,
|
||||||
|
FormatTweetText: func(t string, tw interface{}) safehtml.HTML {
|
||||||
|
ltRep := string([]rune{0xe000})
|
||||||
|
gtRep := string([]rune{0xe001})
|
||||||
|
t = strings.ReplaceAll(t, "<", ltRep)
|
||||||
|
t = strings.ReplaceAll(t, ">", gtRep)
|
||||||
|
|
||||||
|
type span struct {
|
||||||
|
span [2]int
|
||||||
|
whatDo string // remove, link
|
||||||
|
|
||||||
|
linkTo string // link only
|
||||||
|
linkText string // link only
|
||||||
|
}
|
||||||
|
// Delete native media and add links.
|
||||||
|
var spans []span
|
||||||
|
obj := tw.(map[string]interface{})
|
||||||
|
if ee, ok := obj["extended_entities"].(map[string]interface{}); ok {
|
||||||
|
ems := ee["media"].([]interface{})
|
||||||
|
for _, emi := range ems {
|
||||||
|
em := emi.(map[string]interface{})
|
||||||
|
spans = append(spans, span{
|
||||||
|
span: pullIndices(em),
|
||||||
|
whatDo: "remove",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if es, ok := obj["entities"].(map[string]interface{}); ok {
|
||||||
|
if hts, ok := es["hashtags"].([]interface{}); ok {
|
||||||
|
for _, hti := range hts {
|
||||||
|
ht := hti.(map[string]interface{})
|
||||||
|
htt := ht["text"].(string)
|
||||||
|
spans = append(spans, span{
|
||||||
|
span: pullIndices(ht),
|
||||||
|
whatDo: "link",
|
||||||
|
linkTo: fmt.Sprintf("https://twitter.com/hashtag/%s", htt),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if urls, ok := es["urls"].([]interface{}); ok {
|
||||||
|
for _, urli := range urls {
|
||||||
|
url := urli.(map[string]interface{})
|
||||||
|
urldisp := url["display_url"].(string)
|
||||||
|
urlexp := url["expanded_url"].(string)
|
||||||
|
spans = append(spans, span{
|
||||||
|
span: pullIndices(url),
|
||||||
|
whatDo: "link",
|
||||||
|
linkTo: urlexp,
|
||||||
|
linkText: urldisp,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if mentions, ok := es["user_mentions"].([]interface{}); ok {
|
||||||
|
for _, mentioni := range mentions {
|
||||||
|
mention := mentioni.(map[string]interface{})
|
||||||
|
mentionuser := mention["screen_name"].(string)
|
||||||
|
spans = append(spans, span{
|
||||||
|
span: pullIndices(mention),
|
||||||
|
whatDo: "link",
|
||||||
|
linkTo: fmt.Sprintf("https://twitter.com/%s", mentionuser),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if symbols, ok := es["symbols"].([]interface{}); ok {
|
||||||
|
for _, symboli := range symbols {
|
||||||
|
symbol := symboli.(map[string]interface{})
|
||||||
|
symbolname := symbol["text"].(string)
|
||||||
|
spans = append(spans, span{
|
||||||
|
span: pullIndices(symbol),
|
||||||
|
whatDo: "link",
|
||||||
|
linkTo: fmt.Sprintf("?q=$%s", symbolname),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Sort removeSpans from the end to the beginning.
|
||||||
|
sort.Slice(spans, func(a, b int) bool {
|
||||||
|
return spans[a].span[0] > spans[b].span[0]
|
||||||
|
})
|
||||||
|
// Expand overlapping remove spans.
|
||||||
|
newSpans := make([]span, 0, len(spans))
|
||||||
|
for i := 0; i < len(spans)-1; i++ {
|
||||||
|
span := spans[i]
|
||||||
|
prevSpan := spans[i+1]
|
||||||
|
if prevSpan.span[0] <= span.span[0] && prevSpan.span[1] >= span.span[1] {
|
||||||
|
// Spans overlap.
|
||||||
|
if span.whatDo != "remove" || prevSpan.whatDo != "remove" {
|
||||||
|
log.Printf("found overlapping non-remove spans!")
|
||||||
|
}
|
||||||
|
if span.span[1] > prevSpan.span[1] {
|
||||||
|
prevSpan.span[1] = span.span[1]
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
newSpans = append(newSpans, span)
|
||||||
|
}
|
||||||
|
if len(spans) > 0 {
|
||||||
|
newSpans = append(newSpans, spans[len(spans)-1])
|
||||||
|
}
|
||||||
|
spans = newSpans
|
||||||
|
runed := []rune(t)
|
||||||
|
for _, span := range spans {
|
||||||
|
switch span.whatDo {
|
||||||
|
case "remove":
|
||||||
|
// Delete text from span[0] to span[1].
|
||||||
|
runed = append(runed[:span.span[0]], runed[span.span[1]:]...)
|
||||||
|
case "link":
|
||||||
|
// Add a link.
|
||||||
|
var text []rune
|
||||||
|
if span.linkText == "" {
|
||||||
|
text = runed[span.span[0]:span.span[1]]
|
||||||
|
} else {
|
||||||
|
text = []rune(span.linkText)
|
||||||
|
}
|
||||||
|
runedBits := [][]rune{
|
||||||
|
runed[:span.span[0]],
|
||||||
|
[]rune("<a href=\""),
|
||||||
|
[]rune(span.linkTo),
|
||||||
|
[]rune("\">"),
|
||||||
|
[]rune(text),
|
||||||
|
[]rune("</a>"),
|
||||||
|
runed[span.span[1]:],
|
||||||
|
}
|
||||||
|
finalLen := 0
|
||||||
|
for _, s := range runedBits {
|
||||||
|
finalLen += len(s)
|
||||||
|
}
|
||||||
|
runed = make([]rune, finalLen)
|
||||||
|
p := 0
|
||||||
|
for _, s := range runedBits {
|
||||||
|
p += copy(runed[p:], s)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
log.Printf("unknown span operation %v", span.whatDo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t = string(runed)
|
||||||
|
|
||||||
|
// HTML escape any <>
|
||||||
|
t = strings.ReplaceAll(t, ltRep, "<")
|
||||||
|
t = strings.ReplaceAll(t, gtRep, ">")
|
||||||
|
|
||||||
|
return uncheckedconversions.HTMLFromStringKnownToSatisfyTypeContract(t)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
for rows.Next() {
|
||||||
|
var t tweet
|
||||||
|
var o pgtype.JSONB
|
||||||
|
if err := rows.Scan(&t.ID, &t.Text, &o, &t.CreatedAt); err != nil {
|
||||||
|
writeError(rw, http.StatusInternalServerError, "reading from database", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(o.Bytes, &t.Object); err != nil {
|
||||||
|
writeError(rw, http.StatusInternalServerError, "parsing JSON from database", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ago := now.Sub(t.CreatedAt)
|
||||||
|
switch {
|
||||||
|
case t.CreatedAt.Year() != now.Year():
|
||||||
|
t.CreatedAtFriendly = t.CreatedAt.Format("Jan 2, 2006")
|
||||||
|
case t.CreatedAt.YearDay() != now.YearDay():
|
||||||
|
t.CreatedAtFriendly = t.CreatedAt.Format("Jan 2")
|
||||||
|
case ago.Hours() >= 1.0:
|
||||||
|
t.CreatedAtFriendly = fmt.Sprintf("%dh", int(ago.Hours()))
|
||||||
|
case ago.Minutes() >= 1.0:
|
||||||
|
t.CreatedAtFriendly = fmt.Sprintf("%dm", int(ago.Minutes()))
|
||||||
|
case ago.Seconds() >= 0.0:
|
||||||
|
t.CreatedAtFriendly = fmt.Sprintf("%ds", int(ago.Seconds()))
|
||||||
|
default:
|
||||||
|
t.CreatedAtFriendly = fmt.Sprintf("in %ds", -int(ago.Seconds()))
|
||||||
|
}
|
||||||
|
td.Tweets = append(td.Tweets, t)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
|
||||||
|
if len(td.Tweets) > pageSize {
|
||||||
|
td.NextTweetID = &td.Tweets[pageSize].ID
|
||||||
|
td.Tweets = td.Tweets[:pageSize]
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tweetsTmpl.Execute(rw, td); err != nil {
|
||||||
|
log.Printf("tweets: executing template: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
log.Printf("now listening on :8080")
|
||||||
|
log.Print(http.ListenAndServe(":8080", r))
|
||||||
|
}
|
7
third_party/default.nix
vendored
7
third_party/default.nix
vendored
|
@ -19,14 +19,15 @@ rec {
|
||||||
buildGo =
|
buildGo =
|
||||||
let orig = import ./tvl/nix/buildGo { pkgs = nixpkgs; };
|
let orig = import ./tvl/nix/buildGo { pkgs = nixpkgs; };
|
||||||
in orig // {
|
in orig // {
|
||||||
program = args:
|
program = { dockerData ? [], ... }@args:
|
||||||
let
|
let
|
||||||
origOut = orig.program args;
|
origOut = orig.program (nixpkgs.lib.filterAttrs (n: v: n != "dockerData") args);
|
||||||
in origOut // {
|
in origOut // {
|
||||||
dockerImage = nixpkgs.dockerTools.buildImage {
|
dockerImage = nixpkgs.dockerTools.buildImage {
|
||||||
name = args.name;
|
name = args.name;
|
||||||
|
contents = dockerData;
|
||||||
config = {
|
config = {
|
||||||
Cmd = [ "${origOut}/bin/${args.name}" ];
|
Entrypoint = [ "${origOut}/bin/${args.name}" ];
|
||||||
Env = [
|
Env = [
|
||||||
"SSL_CERT_FILE=${nixpkgs.cacert}/etc/ssl/certs/ca-bundle.crt"
|
"SSL_CERT_FILE=${nixpkgs.cacert}/etc/ssl/certs/ca-bundle.crt"
|
||||||
];
|
];
|
||||||
|
|
Loading…
Reference in a new issue