1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
16
17
18
19 package gphotos
20
21 import (
22 "context"
23 "errors"
24 "fmt"
25 "io"
26 "log"
27 "net/http"
28 "net/url"
29 "os"
30 "strconv"
31 "strings"
32 "time"
33
34 "perkeep.org/internal/httputil"
35 "perkeep.org/pkg/blob"
36 "perkeep.org/pkg/importer"
37 "perkeep.org/pkg/importer/picasa"
38 "perkeep.org/pkg/schema"
39 "perkeep.org/pkg/schema/nodeattr"
40 "perkeep.org/pkg/search"
41
42 "go4.org/ctxutil"
43 "go4.org/syncutil"
44 "golang.org/x/oauth2"
45 "golang.org/x/oauth2/google"
46 "golang.org/x/sync/errgroup"
47 )
48
49 const (
50
51
52
53
54
55
56 runCompleteVersion = "0"
57
58
59 attrDriveId = "driveId"
60
61
62
63 acctAttrOAuthToken = "oauthToken"
64
65
66 acctSinceToken = "sinceToken"
67 )
68
69 var (
70 logger = log.New(os.Stderr, "gphotos: ", log.LstdFlags)
71 logf = logger.Printf
72 )
73
74 var (
75 _ importer.Importer = imp{}
76 _ importer.ImporterSetupHTMLer = imp{}
77 )
78
79 func init() {
80 importer.Register("gphotos", imp{})
81 }
82
83
84 type imp struct {
85 importer.OAuth2
86 }
87
88 func (imp) Properties() importer.Properties {
89 return importer.Properties{
90 Title: "Google Photos (via Drive API)",
91 Description: "import all your photos from Google Photos, via Google Drive. (requires settings changes in Drive)",
92 SupportsIncremental: true,
93 NeedsAPIKey: true,
94 }
95 }
96
97 type userInfo struct {
98 ID string
99 Name string
100 Email string
101 }
102
103 func (imp) getUserInfo(ctx context.Context) (*userInfo, error) {
104 u, err := getUser(ctx, ctxutil.Client(ctx))
105 if err != nil {
106 return nil, err
107 }
108 return &userInfo{ID: u.PermissionId, Email: u.EmailAddress, Name: u.DisplayName}, nil
109 }
110
111 func (imp) IsAccountReady(acctNode *importer.Object) (ok bool, err error) {
112 if acctNode.Attr(importer.AcctAttrUserID) != "" && acctNode.Attr(acctAttrOAuthToken) != "" {
113 return true, nil
114 }
115 return false, nil
116 }
117
118 func (im imp) SummarizeAccount(acct *importer.Object) string {
119 ok, err := im.IsAccountReady(acct)
120 if err != nil || !ok {
121 return ""
122 }
123 if acct.Attr(importer.AcctAttrUserName) == "" || acct.Attr(importer.AcctAttrName) == "" {
124 return fmt.Sprintf("userid %s", acct.Attr(importer.AcctAttrUserID))
125 }
126 return fmt.Sprintf("%s <%s>, userid %s",
127 acct.Attr(importer.AcctAttrName),
128 acct.Attr(importer.AcctAttrUserName),
129 acct.Attr(importer.AcctAttrUserID),
130 )
131 }
132
133 func (im imp) ServeSetup(w http.ResponseWriter, r *http.Request, ctx *importer.SetupContext) error {
134 oauthConfig, err := im.auth(ctx)
135 if err == nil {
136
137 state := "acct:" + ctx.AccountNode.PermanodeRef().String()
138
139
140
141
142
143 http.Redirect(w, r, oauthConfig.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.ApprovalForce), http.StatusFound)
144 }
145 return err
146 }
147
148
149 func (im imp) CallbackURLParameters(acctRef blob.Ref) url.Values {
150 return url.Values{}
151 }
152
153 func (im imp) ServeCallback(w http.ResponseWriter, r *http.Request, ctx *importer.SetupContext) {
154 oauthConfig, err := im.auth(ctx)
155 if err != nil {
156 httputil.ServeError(w, r, fmt.Errorf("Error getting oauth config: %v", err))
157 return
158 }
159
160 if r.Method != "GET" {
161 http.Error(w, "Expected a GET", http.StatusBadRequest)
162 return
163 }
164 code := r.FormValue("code")
165 if code == "" {
166 http.Error(w, "Expected a code", http.StatusBadRequest)
167 return
168 }
169
170 token, err := oauthConfig.Exchange(ctx, code)
171 if err != nil {
172 logf("token exchange error: %v", err)
173 httputil.ServeError(w, r, fmt.Errorf("token exchange error: %v", err))
174 return
175 }
176
177 gphotosCtx := context.WithValue(ctx, ctxutil.HTTPClient, oauthConfig.Client(ctx, token))
178
179 userInfo, err := im.getUserInfo(gphotosCtx)
180 if err != nil {
181 logf("couldn't get username: %v", err)
182 httputil.ServeError(w, r, fmt.Errorf("can't get username: %v", err))
183 return
184 }
185
186 if err := ctx.AccountNode.SetAttrs(
187 importer.AcctAttrUserID, userInfo.ID,
188 importer.AcctAttrName, userInfo.Name,
189 importer.AcctAttrUserName, userInfo.Email,
190 acctAttrOAuthToken, encodeToken(token),
191 ); err != nil {
192 httputil.ServeError(w, r, fmt.Errorf("Error setting attribute: %v", err))
193 return
194 }
195 http.Redirect(w, r, ctx.AccountURL(), http.StatusFound)
196 }
197
198
199
200 func encodeToken(token *oauth2.Token) string {
201 if token == nil {
202 return ""
203 }
204 var seconds int64
205 if !token.Expiry.IsZero() {
206 seconds = token.Expiry.Unix()
207 }
208 return token.AccessToken + " " + token.RefreshToken + " " + strconv.FormatInt(seconds, 10)
209 }
210
211
212
213
214 func decodeToken(encoded string) *oauth2.Token {
215 t := new(oauth2.Token)
216 f := strings.Fields(encoded)
217 if len(f) > 0 {
218 t.AccessToken = f[0]
219 }
220 if len(f) > 1 {
221 t.RefreshToken = f[1]
222 }
223 if len(f) > 2 && f[2] != "0" {
224 sec, err := strconv.ParseInt(f[2], 10, 64)
225 if err == nil {
226 t.Expiry = time.Unix(sec, 0)
227 }
228 }
229 return t
230 }
231
232 func (im imp) auth(ctx *importer.SetupContext) (*oauth2.Config, error) {
233 clientID, secret, err := ctx.Credentials()
234 if err != nil {
235 return nil, err
236 }
237 conf := &oauth2.Config{
238 Endpoint: google.Endpoint,
239 RedirectURL: ctx.CallbackURL(),
240 ClientID: clientID,
241 ClientSecret: secret,
242 Scopes: scopeURLs,
243 }
244 return conf, nil
245 }
246
247 func (imp) AccountSetupHTML(host *importer.Host) string {
248
249
250 origin := host.ImporterBaseURL()
251 if u, err := url.Parse(origin); err == nil {
252 u.Path = ""
253 origin = u.String()
254 }
255
256 callback := host.ImporterBaseURL() + "gphotos/callback"
257 return fmt.Sprintf(`
258 <h1>Configuring Google Photos</h1>
259 <p>Please note that because of limitations of the Google Photos folder, this importer can only retrieve photos as they were originally uploaded, and not as they currently are in Google Photos, if modified.</p>
260 <p>First, you need to enable the Google Photos folder in the <a href='https://drive.google.com/'>Google Drive</a> settings.</p>
261 <p>Then visit <a href='https://console.developers.google.com/'>https://console.developers.google.com/</a>
262 and create a new project.</p>
263 <p>Next, go to the <a href='https://console.cloud.google.com/apis/library'>API Library</a> of your project, and enable the <em>Google Drive API</em>. You may have to wait a few minutes after this step, before the API is enabled on Google's side.</p>
264 <p>Finally, go to the <a href='https://console.cloud.google.com/apis/credentials'>API Credentials</a> of your project. Click the button <b>"Create credentials"</b>, and pick <b>"OAuth client ID"</b>.</p>
265 <p>Use the following settings:</p>
266 <ul>
267 <li>Web application</li>
268 <li>Authorized JavaScript origins: <b>%s</b></li>
269 <li>Authorized Redirect URI: <b>%s</b></li>
270 </ul>
271 <p>Click "Create Client ID". Copy the "Client ID" and "Client Secret" into the boxes above.</p>
272 `, origin, callback)
273 }
274
275
276 type run struct {
277 *importer.RunContext
278 photoGate *syncutil.Gate
279 setNextToken func(string) error
280 dl *downloader
281 }
282
283 func (imp) Run(rctx *importer.RunContext) error {
284 clientID, secret, err := rctx.Credentials()
285 if err != nil {
286 return err
287 }
288 acctNode := rctx.AccountNode()
289
290 ocfg := &oauth2.Config{
291 Endpoint: google.Endpoint,
292 ClientID: clientID,
293 ClientSecret: secret,
294 Scopes: scopeURLs,
295 }
296
297 token := decodeToken(acctNode.Attr(acctAttrOAuthToken))
298 sinceToken := acctNode.Attr(acctSinceToken)
299 baseCtx := rctx.Context()
300 ctx := context.WithValue(baseCtx, ctxutil.HTTPClient, ocfg.Client(baseCtx, token))
301
302 root := rctx.RootNode()
303 if root.Attr(nodeattr.Title) == "" {
304 if err := root.SetAttr(
305 nodeattr.Title,
306 fmt.Sprintf("%s's Google Photos Data", acctNode.Attr(importer.AcctAttrName)),
307 ); err != nil {
308 return err
309 }
310 }
311
312 dl, err := newDownloader(ctxutil.Client(ctx))
313 if err != nil {
314 return err
315 }
316 r := &run{
317 RunContext: rctx,
318 photoGate: syncutil.NewGate(3),
319 setNextToken: func(nextToken string) error { return acctNode.SetAttr(acctSinceToken, nextToken) },
320 dl: dl,
321 }
322 if err := r.importPhotos(ctx, sinceToken); err != nil {
323 return err
324 }
325
326 if err := acctNode.SetAttrs(importer.AcctAttrCompletedVersion, runCompleteVersion); err != nil {
327 return err
328 }
329
330 return nil
331 }
332
333 func (r *run) importPhotos(ctx context.Context, sinceToken string) error {
334 photosNode, err := r.getTopLevelNode("photos")
335 if err != nil {
336 return fmt.Errorf("gphotos importer: get top level node: %v", err)
337 }
338
339 grp, grpCtx := errgroup.WithContext(ctx)
340
341 nextToken, err := r.dl.foreachPhoto(grpCtx, sinceToken, func(ctx context.Context, ph *photo) error {
342 select {
343 case <-ctx.Done():
344 return ctx.Err()
345 default:
346 }
347
348 r.photoGate.Start()
349 grp.Go(func() error {
350 defer r.photoGate.Done()
351 return r.updatePhoto(ctx, photosNode, ph)
352 })
353 return nil
354
355 })
356 if gerr := grp.Wait(); gerr != nil {
357 if err == nil || err == context.Canceled || err == context.DeadlineExceeded {
358 err = gerr
359 }
360 }
361 if err != nil {
362 return fmt.Errorf("gphotos importer: %v", err)
363 }
364 if r.setNextToken != nil {
365 r.setNextToken(nextToken)
366 }
367 return nil
368 }
369
370 func (ph photo) filename() string {
371 filename := ph.Name
372 if filename == "" {
373 filename = ph.OriginalFilename
374 }
375 if filename == "" {
376 filename = ph.ID
377 }
378 return strings.Replace(filename, "/", "-", -1)
379 }
380
381 func orAltAttr(attr, alt string) string {
382 if attr != "" {
383 return attr
384 }
385 return alt
386 }
387
388 func (ph photo) title(altTitle string) string {
389 title := strings.TrimSpace(ph.Description)
390 if title == "" {
391 title = orAltAttr(title, altTitle)
392 }
393 filename := ph.filename()
394 if title == "" && schema.IsInterestingTitle(filename) {
395 title = filename
396 }
397 if strings.Contains(title, "\n") {
398 title = title[:strings.Index(title, "\n")]
399 }
400 return title
401 }
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416 func (r *run) updatePhoto(ctx context.Context, parent *importer.Object, ph *photo) error {
417 if ph.ID == "" {
418 return errors.New("photo has no ID")
419 }
420 select {
421 case <-ctx.Done():
422 return ctx.Err()
423 default:
424 }
425
426
427
428
429
430 var fileRefStr string
431
432 var picasAttrs url.Values
433
434 filename := ph.filename()
435
436 photoNode, err := parent.ChildPathObjectOrFunc(ph.ID, func() (*importer.Object, error) {
437 h := blob.NewHash()
438 rc, err := r.dl.openPhoto(ctx, *ph)
439 if err != nil {
440 return nil, err
441 }
442 fileRef, err := schema.WriteFileFromReader(r.Context(), r.Host.Target(), filename, io.TeeReader(rc, h))
443 rc.Close()
444 if err != nil {
445 return nil, err
446 }
447 fileRefStr = fileRef.String()
448 wholeRef := blob.RefFromHash(h)
449 pn, attrs, err := findExistingPermanode(r.Context(), r.Host.Searcher(), wholeRef)
450 if err != nil {
451 if err != os.ErrNotExist {
452 return nil, fmt.Errorf("could not look for permanode with %v as camliContent : %v", fileRefStr, err)
453 }
454 return r.Host.NewObject()
455 }
456 if attrs != nil {
457 picasAttrs = attrs
458 }
459 return r.Host.ObjectFromRef(pn)
460 })
461 if err != nil {
462 if fileRefStr != "" {
463 return fmt.Errorf("error getting permanode for photo %q, with content %v: %v", ph.ID, fileRefStr, err)
464 }
465 return fmt.Errorf("error getting permanode for photo %q: %v", ph.ID, err)
466 }
467
468 if fileRefStr == "" {
469
470
471
472 if camliContent := photoNode.Attr(nodeattr.CamliContent); camliContent == "" {
473
474 rc, err := r.dl.openPhoto(ctx, *ph)
475 if err != nil {
476 return err
477 }
478 fileRef, err := schema.WriteFileFromReader(r.Context(), r.Host.Target(), filename, rc)
479 rc.Close()
480 if err != nil {
481 return err
482 }
483 fileRefStr = fileRef.String()
484 }
485 } else {
486 if picasAttrs.Get(nodeattr.CamliContent) != "" {
487
488
489
490
491
492
493
494 fileRefStr = picasAttrs.Get(nodeattr.CamliContent)
495 }
496 }
497
498 attrs := []string{
499 attrDriveId, ph.ID,
500 nodeattr.Version, strconv.FormatInt(ph.Version, 10),
501 nodeattr.Title, ph.title(picasAttrs.Get(nodeattr.Title)),
502 nodeattr.Description, orAltAttr(ph.Description, picasAttrs.Get(nodeattr.Description)),
503 nodeattr.DateCreated, schema.RFC3339FromTime(ph.CreatedTime),
504 nodeattr.DateModified, orAltAttr(schema.RFC3339FromTime(ph.ModifiedTime), picasAttrs.Get(nodeattr.DateModified)),
505
506
507
508
509 nodeattr.URL, ph.WebContentLink,
510 }
511
512 if ph.Location != nil {
513 if ph.Location.Altitude != 0 {
514 attrs = append(attrs, nodeattr.Altitude, floatToString(ph.Location.Altitude))
515 }
516 if ph.Location.Latitude != 0 || ph.Location.Longitude != 0 {
517 attrs = append(attrs,
518 nodeattr.Latitude, floatToString(ph.Location.Latitude),
519 nodeattr.Longitude, floatToString(ph.Location.Longitude),
520 )
521 }
522 }
523 if err := photoNode.SetAttrs(attrs...); err != nil {
524 return err
525 }
526
527 if fileRefStr != "" {
528
529
530 if err := photoNode.SetAttr(nodeattr.CamliContent, fileRefStr); err != nil {
531 return err
532 }
533 }
534
535 return nil
536 }
537
538 func (r *run) displayName() string {
539 acctNode := r.AccountNode()
540
541
542 if name := acctNode.Attr(importer.AcctAttrName); name != "" {
543
544
545 return strings.Fields(name)[0]
546 }
547
548
549 if name := acctNode.Attr(importer.AcctAttrUserName); name != "" {
550
551
552 return strings.SplitN(name, ".", 2)[0]
553 }
554
555
556 return acctNode.Attr(importer.AcctAttrUserID)
557 }
558
559 func (r *run) getTopLevelNode(path string) (*importer.Object, error) {
560 root := r.RootNode()
561 name := r.displayName()
562 rootTitle := fmt.Sprintf("%s's Google Photos Data", name)
563 logf("root title = %q; want %q", root.Attr(nodeattr.Title), rootTitle)
564 if err := root.SetAttr(nodeattr.Title, rootTitle); err != nil {
565 return nil, err
566 }
567
568 obj, err := root.ChildPathObject(path)
569 if err != nil {
570 return nil, err
571 }
572 var title string
573 switch path {
574 case "photos":
575 title = fmt.Sprintf("%s's Google Photos", name)
576 }
577 return obj, obj.SetAttr(nodeattr.Title, title)
578 }
579
580 var sensitiveAttrs = []string{
581 nodeattr.Type,
582 attrDriveId,
583 nodeattr.Title,
584 nodeattr.DateModified,
585 nodeattr.DatePublished,
586 nodeattr.Latitude,
587 nodeattr.Longitude,
588 nodeattr.Description,
589 }
590
591
592
593
594
595
596
597
598
599 func findExistingPermanode(ctx context.Context, qs search.QueryDescriber, wholeRef blob.Ref) (pn blob.Ref, picasaAttrs url.Values, err error) {
600 res, err := qs.Query(ctx, &search.SearchQuery{
601 Constraint: &search.Constraint{
602 Permanode: &search.PermanodeConstraint{
603 Attr: "camliContent",
604 ValueInSet: &search.Constraint{
605 File: &search.FileConstraint{
606 WholeRef: wholeRef,
607 },
608 },
609 },
610 },
611 Describe: &search.DescribeRequest{
612 Depth: 1,
613 },
614 })
615 if err != nil {
616 return
617 }
618 if res.Describe == nil {
619 return pn, nil, os.ErrNotExist
620 }
621 Res:
622 for _, resBlob := range res.Blobs {
623 br := resBlob.Blob
624 desBlob, ok := res.Describe.Meta[br.String()]
625 if !ok || desBlob.Permanode == nil {
626 continue
627 }
628 attrs := desBlob.Permanode.Attr
629 if attrs.Get(picasa.AttrMediaURL) != "" {
630
631
632
633
634 return br, attrs, nil
635 }
636
637 for _, attr := range sensitiveAttrs {
638 if attrs.Get(attr) != "" {
639 continue Res
640 }
641 }
642 return br, nil, nil
643 }
644 return pn, nil, os.ErrNotExist
645 }
646
647 func floatToString(f float64) string { return strconv.FormatFloat(f, 'f', -1, 64) }