Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

web server instead of batch report generation #15

Merged
merged 15 commits into from
Dec 11, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 40 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ a Gmail label from the Google Scholar alerts, grouping papers by title and produ
1. Search on Google Scholar for a paper of author
2. Create an Alert (for citations, new or similar publications)
3. Create a Gmail filter, moving all those emails under a dedicated Label
4. Run this tool to get an aggregated report (in Markdown or HTML) of all the paper from all unread emails
4. Run this tool to get an aggregated report (in Markdown or HTML) of all the papers from unread emails

# Install

Expand All @@ -20,20 +20,17 @@ either build a `scholar-alert-digest` binary and put it under `$GOPATH/bin` with
cd "$(mktemp -d)" && go mod init scholar-alert-digest && go get github.com/bzz/scholar-alert-digest
```

or run from the sources by:
or run it directly from the clone of the sources using `go` command, as described below.

```
git clone https://github.com/bzz/scholar-alert-digest.git
cd scholar-alert-digest
go run main.go -h
```
# CLI

# Configure
CLI tool for Markdown/HTML report generation.

## Configure

Turn on Gmail API & download `credentials.json` following [these steps](https://developers.google.com/gmail/api/quickstart/go#step_1_turn_on_the).</br>
_That will create a new 'Quickstart' app in API console under your account and authorize it to get access to your Gmail_

# Run

To find your specific label name:

Expand All @@ -50,6 +47,7 @@ export SAD_LABEL='<your-label-name>'
go run main.go
```

## Run
In order to output rendered HTML instead of the default Markdown, use
```
go run main.go -html
Expand All @@ -65,6 +63,39 @@ To include read emails in the separate section of the report, do
go run main.go -read
```

To only aggregate the email subjects do
```
go run main.go -subj | uniq -c | sort -dr
```

# Web server
Web UI that exposes basic HTML report generation to multiple concurrent users.

## Configure
It does not support same OAuth client credentials as CLI from `credentials.json`.
It requires
- a different of .
Create new credentials in your API project `https://console.developers.google.com/apis/credentials?project=quickstart-<NNN>`
- "Create credentials" -> "Web application" type
- Add http://localhost/login/authorized value to `Authorized redirect URIs` field
- Copy the `Client ID` and `Client secret`

Pass in the ID and the secret as env vars e.g by
```
export SAD_GOOGLE_ID='<client id>'
export SAD_GOOGLE_SECRET='<client secret>'
```

You do not need to pass the label name on the startup as it can be chosen at
runtime at [/labels](http://localhost:8080/labels).

## Run
The basic report generation is exposed though a web server that can be started with
```
go run ./cmd/server
```

will start a serve on http://localhost:8080

# License

Expand Down
262 changes: 262 additions & 0 deletions cmd/server/server.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,262 @@
package main

import (
"bytes"
"fmt"
"html/template"
"log"
"net/http"
"os"
"sort"

"github.com/bzz/scholar-alert-digest/gmailutils"
"github.com/bzz/scholar-alert-digest/gmailutils/token"
mrkdwn "github.com/bzz/scholar-alert-digest/markdown"
"github.com/bzz/scholar-alert-digest/papers"

"gitlab.com/golang-commonmark/markdown"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
"google.golang.org/api/gmail/v1"
)

var ( // templates
layout = `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{{ template "title" }}</title>
<link rel="icon" href="http://emojipedia-us.s3.dualstack.us-west-1.amazonaws.com/thumbs/240/apple/232/page-with-curl_1f4c3.png">
</head>
<body>{{ template "body" . }}</body>
</html>
`
chooseLabelsForm = `
{{ define "title"}}Chose a label{{ end }}
{{ define "body" }}
<p>Please, chosse a Gmail label to aggregate:</p>
<form action="/labels" method="POST">
{{ range . }}
<div>
<input type="radio" id="{{.}}" name="label" value="{{.}}">
<label for="{{.}}">{{.}}</label>
</div>
{{ end }}

<input type="submit" value="Select Label"/>
</form>
{{ end }}
`

newMdTemplText = `# Google Scholar Alert Digest

**Date**: {{.Date}}
**Unread emails**: {{.UnreadEmails}}
**Paper titles**: {{.TotalPapers}}
**Uniq paper titles**: {{.UniqPapers}}

## New papers
{{ range $paper := sortedKeys .Papers }}
- [{{ .Title }}]({{ .URL }}) ({{index $.Papers .}})
{{- if .Abstract.FirstLine }}
<details>
<summary>{{.Abstract.FirstLine}}</summary>{{.Abstract.Rest}}
</details>
{{ end }}
{{ end }}
`
)

var ( // configuration
addr = "localhost:8080"
oauthCfg = &oauth2.Config{
// from https://console.developers.google.com/project/<your-project-id>/apiui/credential
ClientID: os.Getenv("SAD_GOOGLE_ID"),
ClientSecret: os.Getenv("SAD_GOOGLE_SECRET"),
RedirectURL: "http://localhost:8080/login/authorized",
Endpoint: google.Endpoint,
Scopes: []string{gmail.GmailReadonlyScope},
}
)

func main() {
// TODO(bzz):
// - configure the log level, to include requests in debug
// - add default req timeouts + throttling, to prevent abuse

log.Printf("starting the web server at http://%s", addr)
defer log.Printf("stoping the web server")

// TODO(bzz): migrate to chi-router
mux := http.NewServeMux()
mux.HandleFunc("/", handleRoot)
mux.HandleFunc("/labels", handleLabels)
mux.HandleFunc("/login", handleLogin)
mux.HandleFunc("/login/authorized", handleAuth)

http.ListenAndServe(addr, sessionMiddleware(mux))
}

func handleRoot(w http.ResponseWriter, r *http.Request) {
// get token, stored in context by middleware (from cookies)
tok, authorized := token.FromContext(r.Context())
if !authorized { // TODO(bzz): move this to middleware
log.Printf("Redirecting to /login as three is no session")
http.Redirect(w, r, "/login", http.StatusFound)
return
}

gmailLabel, hasLabel := token.LabelFromContext(r.Context())
if !hasLabel {
log.Printf("Redirecting to /labels as there is no label")
http.Redirect(w, r, "/labels", http.StatusFound)
return
}

// fetch messages
srv, _ := gmail.New(oauthCfg.Client(r.Context(), tok)) // ignore as client != nil
urMsgs, err := gmailutils.Fetch(r.Context(), srv, "me", fmt.Sprintf("label:%s is:unread", gmailLabel))
if err != nil {
// TODO(bzz): token expiration looks ugly here and must be handled elsewhere
w.WriteHeader(http.StatusServiceUnavailable)
w.Write([]byte(err.Error()))
return
}

// aggregate
errCnt, urTitlesCnt, urTitles := papers.ExtractPapersFromMsgs(urMsgs)
if errCnt != 0 {
log.Printf("%d errors found, extracting the papers", errCnt)
}

// render: generate Md, render to HTML
var mdBuf bytes.Buffer
mrkdwn.GenerateMd(&mdBuf, newMdTemplText, "", len(urMsgs), urTitlesCnt, urTitles, nil)

htmlTemplText := `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="icon" href="http://emojipedia-us.s3.dualstack.us-west-1.amazonaws.com/thumbs/240/apple/232/page-with-curl_1f4c3.png">
<title>Scholar Alert Digest</title>
</head>
<body>%s</body>
</html>
`
md := markdown.New(markdown.XHTMLOutput(true), markdown.HTML(true))
w.Write([]byte(fmt.Sprintf(htmlTemplText, md.RenderToString([]byte(mdBuf.String())))))

// TODO(bzz):
// add spniner! fetching takes ~20 sec easy
// use html/tmeplate tempate

// tmpl := template.Must( // render combination of the nested templates
// template.Must(
// template.New("papers-list").Parse(layout)).
// Parse(papersListPage))
// err = tmpl.Execute(w, papers)
// if err != nil {
// log.Printf("Failed to render a papersList template: %v", err)
// }
}

func handleLabels(w http.ResponseWriter, r *http.Request) {
tok, authorized := token.FromContext(r.Context())
if !authorized { // TODO(bzz): move this to middleware
http.Redirect(w, r, "/login", http.StatusFound)
return
}

switch r.Method {
case http.MethodGet:
fetchLabelsAndServeForm(w, r, tok)
case http.MethodPost:
saveLabelToCookies(w, r)
http.Redirect(w, r, "/", http.StatusFound)
}
}

func fetchLabelsAndServeForm(w http.ResponseWriter, r *http.Request, tok *oauth2.Token) {
labelsResp, err := gmailutils.FetchLabels(r.Context(), oauthCfg, tok)
if err != nil {
log.Printf("Unable to retrieve all labels: %v", err)
w.WriteHeader(http.StatusBadRequest)
return
}

var labels []string // user labels, sorted
for _, l := range labelsResp.Labels {
if l.Type == "system" {
continue
}
labels = append(labels, l.Name)
}
sort.Strings(labels)

tmpl := template.Must( // render combination of the nested templates
template.Must(
template.New("choose-label").Parse(layout)).
Parse(chooseLabelsForm))
err = tmpl.Execute(w, labels)
if err != nil {
log.Printf("Failed to render a template: %v", err)
}
}

func saveLabelToCookies(w http.ResponseWriter, r *http.Request) {
err := r.ParseForm()
if err != nil { // url query part is not valid
log.Printf("Unable to parse query string: %v", err)
w.WriteHeader(http.StatusBadRequest)
return
}
humanLabel := r.FormValue("label")
label := gmailutils.FormatAsID(humanLabel)

cookie := token.NewLabelCookie(label)
log.Printf("Saving new cookie: %s", cookie.String())
http.SetCookie(w, cookie)
}

func handleLogin(w http.ResponseWriter, r *http.Request) {
// the URL which shows the Google Auth page to the user
url := oauthCfg.AuthCodeURL("")
http.Redirect(w, r, url, http.StatusFound)
}

func handleAuth(w http.ResponseWriter, r *http.Request) {
// get the code from URL
err := r.ParseForm()
if err != nil { // url query part is not valid
log.Printf("Unable to parse query string: %v", err)
w.WriteHeader(http.StatusBadRequest)
return
}

// exchange the received code for a bearer token
code := r.FormValue("code")
tok, err := oauthCfg.Exchange(r.Context(), code)
if err != nil {
log.Printf("Unable to exchange the code %q for token: %v", code, err)
w.WriteHeader(http.StatusBadRequest)
return
}

// save token in the session cookie
cookie := token.NewSessionCookie(tok)
log.Printf("Saving new cookie: %s", cookie.String())
http.SetCookie(w, cookie)

http.Redirect(w, r, "/", http.StatusFound)
}

// sessionMiddleware reads token from session cookie, saves it into the Context.
func sessionMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
log.Println(r.Method, "-", r.RequestURI /*, r.Cookies()*/) // TODO(bzz): make cookies debug level only
ctx := token.NewSessionContext(r.Context(), r.Cookies())
ctx = token.NewLabelContext(ctx, r.Cookies())
next.ServeHTTP(w, r.WithContext(ctx))
})
}
Loading