require 'json' require 'jwt' require 'net/http' require 'openssl' require 'tempfile' require 'uri' module GoogleDrive class ApiClient DRIVE_ENDPOINT = 'https://www.googleapis.com/drive/v3' TOKEN_ENDPOINT = 'https://oauth2.googleapis.com/token' FOLDER_MIME_TYPE = 'application/vnd.google-apps.folder' NATIVE_FILE_MIME_TYPE_PREFIX = 'application/vnd.google-apps.' DRIVE_SCOPE = 'https://www.googleapis.com/auth/drive.readonly' def initialize service_account_email: ENV['GOOGLE_DRIVE_SERVICE_ACCOUNT_EMAIL'], private_key: ENV['GOOGLE_DRIVE_PRIVATE_KEY'], private_key_path: ENV['GOOGLE_DRIVE_PRIVATE_KEY_PATH'], subject: ENV['GOOGLE_DRIVE_SUBJECT'] @service_account_email = service_account_email.to_s @private_key = load_private_key(private_key, private_key_path) @subject = subject.to_s.presence @access_token = nil end def list_material_files_under_folder folder_id files = [] walk_folder(folder_id, nil) do |entry, relative_path| next if entry['mimeType'] == FOLDER_MIME_TYPE next if native_file?(entry['mimeType']) files << build_file_entry(entry, relative_path) end files end def fetch_material_file file_id metadata = get_file(file_id) return nil if native_file?(metadata['mimeType']) build_file_entry(metadata, metadata['name']) end def download_to_tempfile file_id, filename: tempfile = Tempfile.new(['material-sync', File.extname(filename.to_s)]) request_binary("/files/#{ file_id }", { alt: 'media', supportsAllDrives: true }) do |chunk| tempfile.write(chunk) end tempfile.rewind tempfile rescue StandardError tempfile&.close! raise end def extract_file_id value raw = value.to_s.strip return nil if raw.blank? return raw unless raw.include?('/') uri = URI.parse(raw) return uri.query.to_s[%r{(?:^|&)id=([^&]+)}, 1] if uri.query.present? return uri.path[%r{/folders/([^/]+)}, 1] if uri.path.include?('/folders/') uri.path[%r{/d/([^/]+)}, 1] rescue URI::InvalidURIError nil end private def walk_folder folder_id, prefix, &block list_children(folder_id).each do |entry| relative_path = MaterialSyncExportPath.build(prefix:, relative_path: entry['name']) if entry['mimeType'] == FOLDER_MIME_TYPE walk_folder(entry['id'], relative_path, &block) next end block.call(entry, relative_path) end end def build_file_entry entry, relative_path { id: entry['id'], name: entry['name'], mime_type: entry['mimeType'], relative_path: MaterialSyncExportPath.normalize_path(relative_path), sha256_checksum: entry['sha256Checksum'], web_view_link: entry['webViewLink'], web_content_link: entry['webContentLink'] } end def list_children folder_id files = [] page_token = nil loop do response = request_json('/files', { q: "'#{ folder_id }' in parents and trashed = false", fields: 'nextPageToken,files(id,name,mimeType,sha256Checksum,' \ 'webViewLink,webContentLink)', orderBy: 'folder,name', pageSize: 1000, supportsAllDrives: true, includeItemsFromAllDrives: true, pageToken: page_token }.compact) files.concat(response.fetch('files')) page_token = response['nextPageToken'] break if page_token.blank? end files end def get_file file_id request_json("/files/#{ file_id }", fields: 'id,name,mimeType,sha256Checksum,webViewLink,webContentLink', supportsAllDrives: true) end def request_json path, params = {} response = request(:get, path, params:) unless response.is_a?(Net::HTTPSuccess) raise "Google Drive API error: #{ response.code } #{ response.body }" end JSON.parse(response.body) end def request_binary path, params = {} request(:get, path, params:) do |response| unless response.is_a?(Net::HTTPSuccess) raise "Google Drive download error: #{ response.code } #{ response.body }" end response.read_body do |chunk| yield chunk end end end def request method, path, params: nil uri = URI(DRIVE_ENDPOINT + path) uri.query = URI.encode_www_form(params) if params.present? klass = case method when :get Net::HTTP::Get else raise ArgumentError, "Unsupported Google Drive request method: #{ method }" end request = klass.new(uri) request['Authorization'] = "Bearer #{ access_token }" Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 10, read_timeout: 60) do |http| if block_given? http.request(request) do |response| return yield response end else http.request(request) end end end def access_token @access_token ||= fetch_access_token end def fetch_access_token raise 'GOOGLE_DRIVE_SERVICE_ACCOUNT_EMAIL is required' if @service_account_email.blank? raise 'Google Drive private key is required' if @private_key.blank? payload = { iss: @service_account_email, scope: DRIVE_SCOPE, aud: TOKEN_ENDPOINT, exp: 1.hour.from_now.to_i, iat: Time.current.to_i } payload[:sub] = @subject if @subject.present? assertion = JWT.encode(payload, @private_key, 'RS256') uri = URI(TOKEN_ENDPOINT) request = Net::HTTP::Post.new(uri) request.set_form_data( grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer', assertion:) response = Net::HTTP.start(uri.host, uri.port, use_ssl: true, open_timeout: 10, read_timeout: 30) do |http| http.request(request) end unless response.is_a?(Net::HTTPSuccess) raise "Google OAuth error: #{ response.code } #{ response.body }" end JSON.parse(response.body).fetch('access_token') end def load_private_key private_key, private_key_path raw = private_key.to_s raw = raw.gsub('\n', "\n") if raw.present? raw = File.read(private_key_path) if raw.blank? && private_key_path.present? return nil if raw.blank? OpenSSL::PKey::RSA.new(raw) end def native_file? mime_type mime_type.to_s.start_with?(NATIVE_FILE_MIME_TYPE_PREFIX) end end end