1// Copyright 2014 Gary Burd
2//
3// Licensed under the Apache License, Version 2.0 (the "License"): you may
4// not use this file except in compliance with the License. You may obtain
5// a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
11// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
12// License for the specific language governing permissions and limitations
13// under the License.
14
15package app
16
17import (
18	"encoding/json"
19	"fmt"
20	"io/ioutil"
21	"net/http"
22	"text/template"
23
24	"github.com/garyburd/go-oauth/oauth"
25
26	"golang.org/x/net/context"
27
28	"google.golang.org/appengine"
29	"google.golang.org/appengine/datastore"
30	"google.golang.org/appengine/log"
31	"google.golang.org/appengine/memcache"
32	"google.golang.org/appengine/urlfetch"
33	"google.golang.org/appengine/user"
34)
35
36var oauthClient = oauth.Client{
37	TemporaryCredentialRequestURI: "https://api.twitter.com/oauth/request_token",
38	ResourceOwnerAuthorizationURI: "https://api.twitter.com/oauth/authorize",
39	TokenRequestURI:               "https://api.twitter.com/oauth/access_token",
40}
41
42// context stores context associated with an HTTP request.
43type Context struct {
44	c context.Context
45	r *http.Request
46	w http.ResponseWriter
47	u *user.User
48}
49
50// handler adapts a function to an http.Handler
51type handler func(c *Context) error
52
53func (h handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
54	ctx := appengine.NewContext(r)
55	c := Context{
56		c: ctx,
57		r: r,
58		w: w,
59		u: user.Current(ctx),
60	}
61
62	if c.u == nil {
63		url, _ := user.LoginURL(c.c, c.r.URL.Path)
64		http.Redirect(w, r, url, 301)
65		return
66	}
67
68	err := h(&c)
69	if err != nil {
70		http.Error(w, "server error", 500)
71		log.Errorf(c.c, "error %v", err)
72	}
73}
74
75// userInfo is stored in the App Engine datastore with key email.
76type userInfo struct {
77	TwitterCred oauth.Credentials
78}
79
80// getUserInfo returns information about the currently logged in user.
81func (c *Context) getUserInfo() (*userInfo, error) {
82	key := datastore.NewKey(c.c, "user", c.u.Email, 0, nil)
83	var u userInfo
84	err := datastore.Get(c.c, key, &u)
85	if err == datastore.ErrNoSuchEntity {
86		err = nil
87	}
88	return &u, err
89}
90
91// updateUserInfo updates information about the currently logged in user.
92func (c *Context) updateUserInfo(f func(u *userInfo)) error {
93	key := datastore.NewKey(c.c, "user", c.u.Email, 0, nil)
94	return datastore.RunInTransaction(c.c, func(ctx context.Context) error {
95		var u userInfo
96		err := datastore.Get(ctx, key, &u)
97		if err != nil && err != datastore.ErrNoSuchEntity {
98			return err
99		}
100		f(&u)
101		_, err = datastore.Put(ctx, key, &u)
102		return err
103	}, nil)
104}
105
106type connectInfo struct {
107	Secret   string
108	Redirect string
109}
110
111// serveTwitterConnect gets the OAuth temp credentials and redirects the user to the
112// Twitter's authorization page.
113func serveTwitterConnect(c *Context) error {
114	httpClient := urlfetch.Client(c.c)
115	callback := "http://" + c.r.Host + "/twitter/callback"
116	tempCred, err := oauthClient.RequestTemporaryCredentials(httpClient, callback, nil)
117	if err != nil {
118		return err
119	}
120
121	ci := connectInfo{Secret: tempCred.Secret, Redirect: c.r.FormValue("redirect")}
122	err = memcache.Gob.Set(c.c, &memcache.Item{Key: tempCred.Token, Object: &ci})
123	if err != nil {
124		return err
125	}
126	http.Redirect(c.w, c.r, oauthClient.AuthorizationURL(tempCred, nil), 302)
127	return nil
128}
129
130// serveTwitterCallback handles callbacks from the Twitter OAuth server.
131func serveTwitterCallback(c *Context) error {
132	token := c.r.FormValue("oauth_token")
133	var ci connectInfo
134	_, err := memcache.Gob.Get(c.c, token, &ci)
135	if err != nil {
136		return err
137	}
138	memcache.Delete(c.c, token)
139	tempCred := &oauth.Credentials{
140		Token:  token,
141		Secret: ci.Secret,
142	}
143
144	httpClient := urlfetch.Client(c.c)
145	tokenCred, _, err := oauthClient.RequestToken(httpClient, tempCred, c.r.FormValue("oauth_verifier"))
146	if err != nil {
147		return err
148	}
149
150	if err := c.updateUserInfo(func(u *userInfo) { u.TwitterCred = *tokenCred }); err != nil {
151		return err
152	}
153	http.Redirect(c.w, c.r, ci.Redirect, 302)
154	return nil
155}
156
157// serveTwitterDisconnect clears the user's Twitter credentials.
158func serveTwitterDisconnect(c *Context) error {
159	if err := c.updateUserInfo(func(u *userInfo) { u.TwitterCred = oauth.Credentials{} }); err != nil {
160		return err
161	}
162	http.Redirect(c.w, c.r, c.r.FormValue("redirect"), 302)
163	return nil
164}
165
166func serveHome(c *Context) error {
167	if c.r.URL.Path != "/" {
168		http.NotFound(c.w, c.r)
169		return nil
170	}
171	u, err := c.getUserInfo()
172	if err != nil {
173		return err
174	}
175
176	var data = struct {
177		Connected bool
178		Timeline  []map[string]interface{}
179	}{
180		Connected: u.TwitterCred.Token != "" && u.TwitterCred.Secret != "",
181	}
182
183	if data.Connected {
184		httpClient := urlfetch.Client(c.c)
185		resp, err := oauthClient.Get(httpClient, &u.TwitterCred, "https://api.twitter.com/1.1/statuses/home_timeline.json", nil)
186		if err != nil {
187			return err
188		}
189		defer resp.Body.Close()
190		if resp.StatusCode != 200 {
191			p, _ := ioutil.ReadAll(resp.Body)
192			return fmt.Errorf("get %s returned status %d, %s", resp.Request.URL, resp.StatusCode, p)
193		}
194		if err := json.NewDecoder(resp.Body).Decode(&data.Timeline); err != nil {
195			return err
196		}
197	}
198
199	c.w.Header().Set("Content-Type", "text/html; charset=utf-8")
200	return homeTmpl.Execute(c.w, &data)
201}
202
203func init() {
204	b, err := ioutil.ReadFile("config.json")
205	if err != nil {
206		panic(err)
207	}
208	if err := json.Unmarshal(b, &oauthClient.Credentials); err != nil {
209		panic(err)
210	}
211	http.Handle("/", handler(serveHome))
212	http.Handle("/twitter/connect", handler(serveTwitterConnect))
213	http.Handle("/twitter/disconnect", handler(serveTwitterDisconnect))
214	http.Handle("/twitter/callback", handler(serveTwitterCallback))
215}
216
217var homeTmpl = template.Must(template.New("home").Parse(
218	`<html>
219<head>
220</head>
221<body>
222{{if .Connected}}
223    <a href="/twitter/disconnect?redirect=/">Disconnect Twitter account</a>
224    {{range .Timeline}}
225    <p><b>{{html .user.name}}</b> {{html .text}}
226    {{end}}
227{{else}}
228    <a href="/twitter/connect?redirect=/">Connect Twitter account</a>
229{{end}}
230</body>
231</html>`))
232