1/*
2Copyright 2013 The Perkeep Authors
3
4Licensed under the Apache License, Version 2.0 (the "License");
5you may not use this file except in compliance with the License.
6You may obtain a copy of the License at
7
8     http://www.apache.org/licenses/LICENSE-2.0
9
10Unless required by applicable law or agreed to in writing, software
11distributed under the License is distributed on an "AS IS" BASIS,
12WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13See the License for the specific language governing permissions and
14limitations under the License.
15*/
16
17// Package service translates blobserver.Storage methods
18// into Google Drive API methods.
19package service // import "perkeep.org/pkg/blobserver/google/drive/service"
20
21import (
22	"context"
23	"errors"
24	"fmt"
25	"io"
26	"math"
27	"net/http"
28	"os"
29
30	client "google.golang.org/api/drive/v2"
31)
32
33const (
34	MimeTypeDriveFolder = "application/vnd.google-apps.folder"
35	MimeTypeCamliBlob   = "application/vnd.camlistore.blob"
36)
37
38// DriveService wraps Google Drive API to implement utility methods to
39// be performed on the root Drive destination folder.
40type DriveService struct {
41	client     *http.Client
42	apiservice *client.Service
43	parentID   string
44}
45
46// New initiates a new DriveService. parentID is the ID of the directory
47// that will be used as the current directory in methods on the returned
48// DriveService (such as Get). If empty, it defaults to the root of the
49// drive.
50func New(oauthClient *http.Client, parentID string) (*DriveService, error) {
51	apiservice, err := client.New(oauthClient)
52	if err != nil {
53		return nil, err
54	}
55	if parentID == "" {
56		// because "root" is known as a special alias for the root directory in drive.
57		parentID = "root"
58	}
59	service := &DriveService{client: oauthClient, apiservice: apiservice, parentID: parentID}
60	return service, err
61}
62
63// Get retrieves a file with its title equal to the provided title and a child of
64// the parentID as given to New. If not found, os.ErrNotExist is returned.
65func (s *DriveService) Get(ctx context.Context, title string) (*client.File, error) {
66	// TODO: use field selectors
67	query := fmt.Sprintf("'%s' in parents and title = '%s'", s.parentID, title)
68	req := s.apiservice.Files.List().Context(ctx).Q(query)
69	files, err := req.Do()
70	if err != nil {
71		return nil, err
72	}
73	if len(files.Items) < 1 {
74		return nil, os.ErrNotExist
75	}
76	return files.Items[0], nil
77}
78
79// List returns a list of files. When limit is greater than zero a paginated list is returned
80// using the next response as a pageToken in subsequent calls.
81func (s *DriveService) List(pageToken string, limit int) (files []*client.File, next string, err error) {
82	req := s.apiservice.Files.List()
83	req.Q(fmt.Sprintf("'%s' in parents and mimeType != '%s'", s.parentID, MimeTypeDriveFolder))
84
85	if pageToken != "" {
86		req.PageToken(pageToken)
87	}
88
89	if limit > 0 {
90		req.MaxResults(int64(limit))
91	}
92
93	result, err := req.Do()
94	if err != nil {
95		return
96	}
97	return result.Items, result.NextPageToken, err
98}
99
100// Upsert inserts a file, or updates if such a file exists.
101func (s *DriveService) Upsert(ctx context.Context, title string, data io.Reader) (file *client.File, err error) {
102	if file, err = s.Get(ctx, title); err != nil {
103		if !os.IsNotExist(err) {
104			return
105		}
106	}
107	if file == nil {
108		file = &client.File{Title: title}
109		file.Parents = []*client.ParentReference{
110			{Id: s.parentID},
111		}
112		file.MimeType = MimeTypeCamliBlob
113		return s.apiservice.Files.Insert(file).Media(data).Context(ctx).Do()
114	}
115
116	// TODO: handle large blobs
117	return s.apiservice.Files.Update(file.Id, file).Media(data).Context(ctx).Do()
118}
119
120var errNoDownload = errors.New("file can not be downloaded directly (conversion needed?)")
121
122// Fetch retrieves the metadata and contents of a file.
123func (s *DriveService) Fetch(ctx context.Context, title string) (body io.ReadCloser, size uint32, err error) {
124	file, err := s.Get(ctx, title)
125	if err != nil {
126		return
127	}
128	// TODO: maybe in the case of no download link, remove the file.
129	// The file should have malformed or converted to a Docs file
130	// unwantedly.
131	// TODO(mpl): I do not think the above comment is accurate. It
132	// looks like at least one case we do not get a DownloadUrl is when
133	// the UI would make you pick a conversion format first (spreadsheet,
134	// doc, etc). -> we should see if the API offers the possibility to do
135	// that conversion. and we could pass the type(s) we want (pdf, xls, doc...)
136	// as arguments (in an options struct) to Fetch.
137	if file.DownloadUrl == "" {
138		err = errNoDownload
139		return
140	}
141
142	req, _ := http.NewRequest("GET", file.DownloadUrl, nil)
143	req.WithContext(ctx)
144	var resp *http.Response
145	if resp, err = s.client.Transport.RoundTrip(req); err != nil {
146		return
147	}
148	if file.FileSize > math.MaxUint32 || file.FileSize < 0 {
149		err = errors.New("file too big")
150	}
151	return resp.Body, uint32(file.FileSize), err
152}
153
154// Stat retrieves file metadata and returns
155// file size. Returns error if file is not found.
156func (s *DriveService) Stat(ctx context.Context, title string) (int64, error) {
157	file, err := s.Get(ctx, title)
158	if err != nil || file == nil {
159		return 0, err
160	}
161	return file.FileSize, err
162}
163
164// Trash trashes the file with the given title.
165func (s *DriveService) Trash(ctx context.Context, title string) error {
166	file, err := s.Get(ctx, title)
167	if err != nil {
168		if os.IsNotExist(err) {
169			return nil
170		}
171		return err
172	}
173	_, err = s.apiservice.Files.Trash(file.Id).Context(ctx).Do()
174	return err
175}
176