@arela/uploader 1.0.19 → 1.0.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,217 @@
1
+ import fs from 'fs';
2
+ import { google } from 'googleapis';
3
+
4
+ import appConfig from '../config/config.js';
5
+ import PathNormalizer from '../utils/PathNormalizer.js';
6
+ import logger from './LoggingService.js';
7
+
8
+ const NATIVE_DOC_PREFIX = 'application/vnd.google-apps.';
9
+ const FOLDER_MIME = 'application/vnd.google-apps.folder';
10
+ const SHORTCUT_MIME = 'application/vnd.google-apps.shortcut';
11
+
12
+ const DEFAULT_FILE_FIELDS =
13
+ 'id,name,mimeType,modifiedTime,size,md5Checksum,parents,trashed,shortcutDetails';
14
+
15
+ /**
16
+ * Google Drive Service
17
+ * Thin wrapper around googleapis Drive v3 with Service Account auth.
18
+ * Read-only by design (scope: drive.readonly).
19
+ */
20
+ export class GoogleDriveService {
21
+ constructor() {
22
+ this.config = appConfig.getGDriveConfig();
23
+ this.drive = null;
24
+ this.commonParams = {
25
+ supportsAllDrives: true,
26
+ includeItemsFromAllDrives: true,
27
+ };
28
+ }
29
+
30
+ /**
31
+ * Lazy-init Drive v3 client.
32
+ * @returns {import('googleapis').drive_v3.Drive}
33
+ */
34
+ async getClient() {
35
+ if (this.drive) return this.drive;
36
+
37
+ let credentials = null;
38
+ let keyFile = null;
39
+
40
+ if (this.config.serviceAccountJson) {
41
+ try {
42
+ credentials = JSON.parse(this.config.serviceAccountJson);
43
+ } catch (err) {
44
+ throw new Error(
45
+ `GDRIVE_SERVICE_ACCOUNT_JSON is not valid JSON: ${err.message}`,
46
+ );
47
+ }
48
+ } else if (this.config.serviceAccountFile) {
49
+ keyFile = PathNormalizer.toAbsolutePath(this.config.serviceAccountFile);
50
+ if (!fs.existsSync(keyFile)) {
51
+ throw new Error(`Service account file not found: ${keyFile}`);
52
+ }
53
+ } else {
54
+ throw new Error(
55
+ 'No Google Drive credentials configured (GDRIVE_SERVICE_ACCOUNT_FILE or GDRIVE_SERVICE_ACCOUNT_JSON)',
56
+ );
57
+ }
58
+
59
+ const auth = new google.auth.GoogleAuth({
60
+ credentials,
61
+ keyFile,
62
+ scopes: ['https://www.googleapis.com/auth/drive.readonly'],
63
+ });
64
+
65
+ this.drive = google.drive({ version: 'v3', auth });
66
+ return this.drive;
67
+ }
68
+
69
+ /**
70
+ * Get metadata for a single file/folder.
71
+ */
72
+ async getFile(fileId, fields = DEFAULT_FILE_FIELDS) {
73
+ const drive = await this.getClient();
74
+ const res = await drive.files.get({
75
+ fileId,
76
+ fields,
77
+ ...this.commonParams,
78
+ });
79
+ return res.data;
80
+ }
81
+
82
+ /**
83
+ * List direct children of a folder, paginated.
84
+ * Yields each child file metadata one at a time.
85
+ * @param {string} folderId
86
+ * @param {Object} [options]
87
+ * @param {string} [options.fields]
88
+ * @param {number} [options.pageSize]
89
+ * @returns {AsyncGenerator<Object>}
90
+ */
91
+ async *listChildren(folderId, options = {}) {
92
+ const drive = await this.getClient();
93
+ const fields = options.fields || DEFAULT_FILE_FIELDS;
94
+ const pageSize = options.pageSize || this.config.pageSize || 1000;
95
+
96
+ let pageToken = undefined;
97
+
98
+ do {
99
+ const res = await this.#withRetry(() =>
100
+ drive.files.list({
101
+ q: `'${folderId}' in parents and trashed = false`,
102
+ fields: `nextPageToken, files(${fields})`,
103
+ pageSize,
104
+ pageToken,
105
+ orderBy: 'folder,name',
106
+ ...this.commonParams,
107
+ }),
108
+ );
109
+
110
+ const files = res.data.files || [];
111
+ for (const file of files) {
112
+ yield file;
113
+ }
114
+ pageToken = res.data.nextPageToken;
115
+ } while (pageToken);
116
+ }
117
+
118
+ /**
119
+ * Download a file's binary content to a writable stream.
120
+ * Returns total bytes written.
121
+ * @param {string} fileId
122
+ * @param {import('stream').Writable} destStream
123
+ * @returns {Promise<number>}
124
+ */
125
+ async downloadFile(fileId, destStream) {
126
+ const drive = await this.getClient();
127
+
128
+ const res = await this.#withRetry(() =>
129
+ drive.files.get(
130
+ {
131
+ fileId,
132
+ alt: 'media',
133
+ ...this.commonParams,
134
+ },
135
+ { responseType: 'stream' },
136
+ ),
137
+ );
138
+
139
+ return await new Promise((resolve, reject) => {
140
+ let bytes = 0;
141
+ res.data.on('data', (chunk) => {
142
+ bytes += chunk.length;
143
+ });
144
+ res.data.on('error', reject);
145
+ destStream.on('error', reject);
146
+ destStream.on('finish', () => resolve(bytes));
147
+ res.data.pipe(destStream);
148
+ });
149
+ }
150
+
151
+ /**
152
+ * Static helpers
153
+ */
154
+ static isFolder(file) {
155
+ return file?.mimeType === FOLDER_MIME;
156
+ }
157
+
158
+ static isShortcut(file) {
159
+ return file?.mimeType === SHORTCUT_MIME;
160
+ }
161
+
162
+ static isNativeGoogleDoc(file) {
163
+ return (
164
+ typeof file?.mimeType === 'string' &&
165
+ file.mimeType.startsWith(NATIVE_DOC_PREFIX) &&
166
+ !GoogleDriveService.isFolder(file) &&
167
+ !GoogleDriveService.isShortcut(file)
168
+ );
169
+ }
170
+
171
+ /**
172
+ * Retry wrapper with exponential backoff for transient errors.
173
+ * @private
174
+ */
175
+ async #withRetry(fn, maxAttempts = 5) {
176
+ let attempt = 0;
177
+ let lastErr;
178
+
179
+ while (attempt < maxAttempts) {
180
+ try {
181
+ return await fn();
182
+ } catch (err) {
183
+ lastErr = err;
184
+ const status = err?.code || err?.response?.status;
185
+ const reason =
186
+ err?.errors?.[0]?.reason ||
187
+ err?.response?.data?.error?.errors?.[0]?.reason;
188
+
189
+ const retryable =
190
+ status === 429 ||
191
+ status === 500 ||
192
+ status === 502 ||
193
+ status === 503 ||
194
+ status === 504 ||
195
+ reason === 'rateLimitExceeded' ||
196
+ reason === 'userRateLimitExceeded' ||
197
+ reason === 'backendError';
198
+
199
+ if (!retryable || attempt === maxAttempts - 1) {
200
+ throw err;
201
+ }
202
+
203
+ const delayMs =
204
+ Math.min(60_000, 2 ** attempt * 1000) + Math.random() * 500;
205
+ logger.warn(
206
+ `⚠️ Drive API ${status || ''} ${reason || ''} — retry ${attempt + 1}/${maxAttempts} in ${Math.round(delayMs)}ms`,
207
+ );
208
+ await new Promise((r) => setTimeout(r, delayMs));
209
+ attempt += 1;
210
+ }
211
+ }
212
+
213
+ throw lastErr;
214
+ }
215
+ }
216
+
217
+ export default GoogleDriveService;
@@ -46,7 +46,7 @@ export class LoggingService {
46
46
  });
47
47
  process.on('unhandledRejection', (reason, promise) => {
48
48
  this.error(`Unhandled Rejection at: ${promise}, reason: ${reason}`);
49
- flushAndExit(1);
49
+ // Don't kill the process — allow it to continue processing remaining files
50
50
  });
51
51
  }
52
52