@ryuu-reinzz/ryuu-cli 0.4.2 → 0.5.0

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,252 @@
1
+ import fsp from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { randomBytes } from 'node:crypto';
4
+ import { ConfigError } from '../../src/utils/errors.js';
5
+ import { settingsDir } from '../../src/config/settings.js';
6
+
7
+ /**
8
+ * Configuration for the google-drive plugin.
9
+ *
10
+ * The non-sensitive settings live next to the user's config.json:
11
+ * <config dir>/integrations/google-drive/config.json
12
+ * and the OAuth tokens received from the API backend live beside them in
13
+ * `credentials.enc`.
14
+ *
15
+ * No Google Client ID or Client Secret exists in the CLI: both belong to the
16
+ * root `website/api` project, which exposes `/admin/tools/drive-cli`. The
17
+ * backend URL is a constant, so no command or environment variable can
18
+ * redirect credentials elsewhere.
19
+ */
20
+ export const DEFAULT_GOOGLE_DRIVE_CONFIG = Object.freeze({
21
+ scopes: Object.freeze(['https://www.googleapis.com/auth/drive.file']),
22
+ enabled: false,
23
+ autoRefresh: true,
24
+ // Backend OAuth milik proyek website/api. Keduanya konstanta aplikasi:
25
+ // tidak ada command / env var yang boleh mengubahnya, dan CLI tidak
26
+ // pernah menyimpan Client ID maupun Client Secret Google.
27
+ oauthBackend: 'https://restapi.ryuu-dev.my.id',
28
+ oauthCliPath: '/admin/tools/drive-cli',
29
+ defaultBackupFolder: null,
30
+ backupDirectory: 'Backups',
31
+ confirmDestructive: true,
32
+ maxUploadSize: 1073741824,
33
+ excludePatterns: Object.freeze([
34
+ 'node_modules',
35
+ '.git',
36
+ '.env',
37
+ '.env.*',
38
+ '*.pem',
39
+ '*.key',
40
+ '*.p12',
41
+ '*.pfx',
42
+ 'credentials.json',
43
+ 'service-account*.json',
44
+ '*.log',
45
+ 'cache',
46
+ 'tmp',
47
+ 'temp',
48
+ ]),
49
+ });
50
+
51
+ /** Keys the user may change with `/drive config set` (credentials are not). */
52
+ export const EDITABLE_KEYS = Object.freeze([
53
+ 'enabled',
54
+ 'defaultBackupFolder',
55
+ 'backupDirectory',
56
+ 'excludePatterns',
57
+ 'maxUploadSize',
58
+ 'confirmDestructive',
59
+ 'autoRefresh',
60
+ ]);
61
+
62
+ // Client ID, Client Secret, endpoint backend, scope, dan kunci login Drive
63
+ // (lihat secret.js) adalah milik aplikasi: tidak ada jalur command untuk
64
+ // mengubahnya, jadi tidak ada nilai sensitif yang bisa ditanamkan lewat config.
65
+ const BLOCKED_KEYS = new Set([
66
+ 'clientId',
67
+ 'clientSecret',
68
+ 'scopes',
69
+ 'oauthBackend',
70
+ 'oauthCliPath',
71
+ 'rootKey',
72
+ 'driveKey',
73
+ 'secret',
74
+ '__proto__',
75
+ 'prototype',
76
+ 'constructor',
77
+ ]);
78
+
79
+ /** Absolute paths of the integration's own files. */
80
+ export function drivePaths({ env = process.env, home = undefined, platform = undefined } = {}) {
81
+ const root = path.join(settingsDir({ env, home, platform }), 'integrations', 'google-drive');
82
+ return {
83
+ dir: root,
84
+ configFile: path.join(root, 'config.json'),
85
+ credentialFile: path.join(root, 'credentials.enc'),
86
+ // The encryption key lives outside the integration folder on purpose: the
87
+ // key and the ciphertext must not travel together.
88
+ keyFile: path.join(settingsDir({ env, home, platform }), '.keys', 'google-drive.key'),
89
+ };
90
+ }
91
+
92
+ function boolValue(name, value, issues) {
93
+ if (typeof value === 'boolean') return value;
94
+ const text = String(value ?? '').trim().toLowerCase();
95
+ if (['1', 'true', 'yes', 'y', 'on', 'enable', 'enabled', 'aktif'].includes(text)) return true;
96
+ if (['0', 'false', 'no', 'n', 'off', 'disable', 'disabled', 'nonaktif'].includes(text)) return false;
97
+ issues.push(`${name} harus berupa boolean (true/false), diterima: ${JSON.stringify(value)}`);
98
+ return undefined;
99
+ }
100
+
101
+ /** Validate one `/drive config set <key> <value>` write. */
102
+ export function validateConfigEntry(key, rawValue) {
103
+ const name = String(key ?? '').trim();
104
+ if (BLOCKED_KEYS.has(name)) {
105
+ throw new ConfigError(
106
+ `Key "${name}" tidak dapat diubah: Client ID/Secret Google, endpoint backend, dan kunci login Drive dikelola aplikasi (proyek API) dan tidak boleh diubah dari command.`,
107
+ );
108
+ }
109
+ if (!EDITABLE_KEYS.includes(name)) {
110
+ throw new ConfigError(
111
+ `Key tidak dikenal: "${name}". Key yang didukung: ${EDITABLE_KEYS.join(', ')}.`,
112
+ );
113
+ }
114
+ const issues = [];
115
+ let value;
116
+ switch (name) {
117
+ case 'enabled':
118
+ case 'confirmDestructive':
119
+ case 'autoRefresh':
120
+ value = boolValue(name, rawValue, issues);
121
+ break;
122
+ case 'defaultBackupFolder': {
123
+ const text = String(rawValue ?? '').trim();
124
+ if (!text || ['none', 'null', '-', 'off'].includes(text.toLowerCase())) value = null;
125
+ else if (!/^[A-Za-z0-9_-]{10,200}$/.test(text)) {
126
+ issues.push('defaultBackupFolder harus berupa folder ID Google Drive (10-200 karakter huruf/angka/-/_).');
127
+ } else value = text;
128
+ break;
129
+ }
130
+ case 'backupDirectory': {
131
+ const text = String(rawValue ?? '').trim().replace(/^\/+|\/+$/g, '');
132
+ if (!text) issues.push('backupDirectory tidak boleh kosong.');
133
+ else if (text.length > 120 || text.split('/').some((part) => part === '..')) {
134
+ issues.push('backupDirectory tidak valid: gunakan nama folder biasa (maksimal 120 karakter, tanpa "..").');
135
+ } else value = text;
136
+ break;
137
+ }
138
+ case 'excludePatterns': {
139
+ const text = String(rawValue ?? '').trim();
140
+ if (['none', 'empty', '-'].includes(text.toLowerCase())) value = [];
141
+ else value = text.split(',').map((part) => part.trim()).filter(Boolean);
142
+ if (Array.isArray(value) && value.some((pattern) => pattern.includes('..') || pattern.length > 100)) {
143
+ issues.push('excludePatterns berisi pola yang tidak valid (tanpa ".." dan maksimal 100 karakter per pola).');
144
+ }
145
+ break;
146
+ }
147
+ case 'maxUploadSize': {
148
+ const parsed = Number(String(rawValue ?? '').trim());
149
+ if (!Number.isFinite(parsed) || !Number.isInteger(parsed)) issues.push('maxUploadSize harus berupa angka byte.');
150
+ else if (parsed < 1024 || parsed > 1073741824) {
151
+ issues.push('maxUploadSize harus antara 1024 dan 1073741824 byte (1 GB).');
152
+ } else value = parsed;
153
+ break;
154
+ }
155
+ default:
156
+ issues.push(`Key tidak dikenal: ${name}`);
157
+ }
158
+ if (issues.length > 0) throw new ConfigError(issues.join(' '), { key: name });
159
+ return value;
160
+ }
161
+
162
+ function normalize(data) {
163
+ const source = data && typeof data === 'object' && !Array.isArray(data) ? data : {};
164
+ // Only keys the application knows about survive; anything else (in
165
+ // particular credentials smuggled into the file by hand) is dropped.
166
+ const stored = {};
167
+ for (const key of Object.keys(DEFAULT_GOOGLE_DRIVE_CONFIG)) {
168
+ if (key in source) stored[key] = source[key];
169
+ }
170
+ const merged = { ...DEFAULT_GOOGLE_DRIVE_CONFIG, ...stored };
171
+ // These are application-owned and always come from the defaults.
172
+ merged.scopes = [...DEFAULT_GOOGLE_DRIVE_CONFIG.scopes];
173
+ merged.oauthBackend = DEFAULT_GOOGLE_DRIVE_CONFIG.oauthBackend;
174
+ merged.oauthCliPath = DEFAULT_GOOGLE_DRIVE_CONFIG.oauthCliPath;
175
+ merged.enabled = Boolean(merged.enabled);
176
+ merged.autoRefresh = merged.autoRefresh !== false;
177
+ merged.confirmDestructive = merged.confirmDestructive !== false;
178
+ merged.defaultBackupFolder =
179
+ typeof merged.defaultBackupFolder === 'string' && merged.defaultBackupFolder.trim()
180
+ ? merged.defaultBackupFolder.trim()
181
+ : null;
182
+ merged.backupDirectory =
183
+ typeof merged.backupDirectory === 'string' && merged.backupDirectory.trim()
184
+ ? merged.backupDirectory.trim().replace(/^\/+|\/+$/g, '')
185
+ : DEFAULT_GOOGLE_DRIVE_CONFIG.backupDirectory;
186
+ merged.maxUploadSize = Number.isInteger(merged.maxUploadSize)
187
+ ? merged.maxUploadSize
188
+ : DEFAULT_GOOGLE_DRIVE_CONFIG.maxUploadSize;
189
+ merged.excludePatterns = Array.isArray(merged.excludePatterns)
190
+ ? merged.excludePatterns.map((item) => String(item)).filter(Boolean)
191
+ : [...DEFAULT_GOOGLE_DRIVE_CONFIG.excludePatterns];
192
+ return merged;
193
+ }
194
+
195
+ /** Read the integration config; a missing file yields the built-in defaults. */
196
+ export async function readDriveConfig({ paths = drivePaths() } = {}) {
197
+ let text;
198
+ try {
199
+ text = await fsp.readFile(paths.configFile, 'utf8');
200
+ } catch (error) {
201
+ if (error.code === 'ENOENT') return normalize({});
202
+ throw new ConfigError(`Tidak dapat membaca konfigurasi Google Drive (${paths.configFile}): ${error.message}`);
203
+ }
204
+ try {
205
+ return normalize(JSON.parse(text));
206
+ } catch (error) {
207
+ throw new ConfigError(
208
+ `Konfigurasi Google Drive tidak valid (${paths.configFile}): ${error.message}. File tidak diubah — perbaiki atau hapus file tersebut.`,
209
+ );
210
+ }
211
+ }
212
+
213
+ /** Write the integration config atomically with owner-only permissions. */
214
+ export async function writeDriveConfig(config, { paths = drivePaths() } = {}) {
215
+ const data = normalize(config);
216
+ await fsp.mkdir(paths.dir, { recursive: true, mode: 0o700 });
217
+ const tmp = path.join(paths.dir, `.config.${process.pid}.${randomBytes(6).toString('hex')}.tmp`);
218
+ try {
219
+ await fsp.writeFile(tmp, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
220
+ await fsp.rename(tmp, paths.configFile);
221
+ if (process.platform !== 'win32') await fsp.chmod(paths.configFile, 0o600).catch(() => {});
222
+ } catch (error) {
223
+ await fsp.rm(tmp, { force: true }).catch(() => {});
224
+ throw new ConfigError(`Gagal menyimpan konfigurasi Google Drive: ${error.message}`);
225
+ }
226
+ return data;
227
+ }
228
+
229
+ /** Read-modify-write one setting so concurrent saves are not lost. */
230
+ export async function updateDriveConfig(key, value, { paths = drivePaths() } = {}) {
231
+ const current = await readDriveConfig({ paths });
232
+ current[key] = value;
233
+ return writeDriveConfig(current, { paths });
234
+ }
235
+
236
+ /** Short human-readable view of the non-sensitive configuration. */
237
+ export function describeDriveConfig(config) {
238
+ return {
239
+ enabled: config.enabled ? 'true' : 'false',
240
+ autoRefresh: config.autoRefresh ? 'true' : 'false',
241
+ oauthBackend: config.oauthBackend,
242
+ oauthCliPath: config.oauthCliPath,
243
+ loginKey: 'bawaan aplikasi (tidak dapat diubah, tidak pernah ditampilkan)',
244
+ confirmDestructive: config.confirmDestructive ? 'true' : 'false',
245
+ defaultBackupFolder: config.defaultBackupFolder || '(belum diatur)',
246
+ backupDirectory: config.backupDirectory,
247
+ maxUploadSize: `${config.maxUploadSize} byte`,
248
+ excludePatterns: config.excludePatterns.join(', ') || '(kosong)',
249
+ scopes: config.scopes.join(', '),
250
+ credentials: 'disimpan di backend API (CLI tidak memegang Client ID/Secret)',
251
+ };
252
+ }
@@ -0,0 +1,171 @@
1
+ import fsp from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ import { RyuuError } from '../../src/utils/errors.js';
5
+
6
+ /**
7
+ * Credential storage for the google-drive integration.
8
+ *
9
+ * Tokens are never written in plaintext. The file is AES-256-GCM encrypted and
10
+ * the key lives in a different directory (`<config>/.keys/google-drive.key`,
11
+ * mode 0600) so a copied credential file is useless on its own. `RYUU_DRIVE_KEY`
12
+ * (hex or base64, 32 bytes) can supply the key instead, which is what a VPS or
13
+ * a secret manager should use.
14
+ *
15
+ * Nothing in this module ever logs or returns a token to the UI: callers get
16
+ * metadata only.
17
+ */
18
+
19
+ const KEY_BYTES = 32;
20
+ const MAGIC = 'RYUUDRIVE1';
21
+
22
+ function credentialError(message, { cause = null } = {}) {
23
+ return new RyuuError(message, { code: 'DRIVE_CREDENTIAL', cause });
24
+ }
25
+
26
+ function decodeEnvKey(raw) {
27
+ const text = String(raw || '').trim();
28
+ if (!text) return null;
29
+ const buffer = /^[0-9a-fA-F]{64}$/.test(text) ? Buffer.from(text, 'hex') : Buffer.from(text, 'base64');
30
+ if (buffer.length !== KEY_BYTES) {
31
+ throw credentialError('RYUU_DRIVE_KEY tidak valid: butuh 32 byte (hex 64 karakter atau base64).');
32
+ }
33
+ return buffer;
34
+ }
35
+
36
+ async function readOrCreateKey(keyFile, { env = process.env } = {}) {
37
+ const fromEnv = decodeEnvKey(env.RYUU_DRIVE_KEY);
38
+ if (fromEnv) return fromEnv;
39
+ try {
40
+ const existing = await fsp.readFile(keyFile);
41
+ if (existing.length === KEY_BYTES) return existing;
42
+ throw credentialError(`File kunci Google Drive rusak (${keyFile}). Hapus file tersebut lalu jalankan /drive login.`);
43
+ } catch (error) {
44
+ if (error.code !== 'ENOENT') throw error;
45
+ }
46
+ const key = crypto.randomBytes(KEY_BYTES);
47
+ await fsp.mkdir(path.dirname(keyFile), { recursive: true, mode: 0o700 });
48
+ // `wx` fails if another process created it first; read theirs in that case.
49
+ try {
50
+ await fsp.writeFile(keyFile, key, { mode: 0o600, flag: 'wx' });
51
+ if (process.platform !== 'win32') await fsp.chmod(keyFile, 0o600).catch(() => {});
52
+ return key;
53
+ } catch (error) {
54
+ if (error.code !== 'EEXIST') throw credentialError(`Gagal menyimpan kunci kredensial: ${error.message}`);
55
+ const again = await fsp.readFile(keyFile);
56
+ if (again.length !== KEY_BYTES) throw credentialError(`File kunci Google Drive rusak (${keyFile}).`);
57
+ return again;
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Encrypt and store the OAuth tokens.
63
+ *
64
+ * Only the fields below are persisted; anything else is dropped so an API
65
+ * response can never smuggle a token into the file by accident.
66
+ */
67
+ export async function saveCredentials({ tokens, account = null }, { paths, env = process.env } = {}) {
68
+ if (!tokens || typeof tokens !== 'object') throw credentialError('Token OAuth tidak tersedia.');
69
+ const payload = {
70
+ version: 2,
71
+ // Izin sempit dari backend: cukup untuk refresh/revoke tanpa kunci aplikasi.
72
+ grantId: typeof tokens.grant_id === 'string' ? tokens.grant_id : null,
73
+ accessToken: typeof tokens.access_token === 'string' ? tokens.access_token : null,
74
+ refreshToken: typeof tokens.refresh_token === 'string' ? tokens.refresh_token : null,
75
+ expiryDate: Number.isFinite(tokens.expiry_date) ? tokens.expiry_date : null,
76
+ tokenType: typeof tokens.token_type === 'string' ? tokens.token_type : 'Bearer',
77
+ scope: typeof tokens.scope === 'string' ? tokens.scope : '',
78
+ account: account
79
+ ? {
80
+ email: account.email || null,
81
+ name: account.name || null,
82
+ permissionId: account.permissionId || null,
83
+ storageQuota: account.storageQuota || null,
84
+ }
85
+ : null,
86
+ savedAt: new Date().toISOString(),
87
+ };
88
+ const key = await readOrCreateKey(paths.keyFile, { env });
89
+ const iv = crypto.randomBytes(12);
90
+ const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
91
+ const encrypted = Buffer.concat([cipher.update(JSON.stringify(payload), 'utf8'), cipher.final()]);
92
+ const tag = cipher.getAuthTag();
93
+ const file = Buffer.concat([Buffer.from(MAGIC, 'utf8'), iv, tag, encrypted]);
94
+
95
+ await fsp.mkdir(paths.dir, { recursive: true, mode: 0o700 });
96
+ const tmp = `${paths.credentialFile}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
97
+ try {
98
+ await fsp.writeFile(tmp, file, { mode: 0o600 });
99
+ await fsp.rename(tmp, paths.credentialFile);
100
+ if (process.platform !== 'win32') await fsp.chmod(paths.credentialFile, 0o600).catch(() => {});
101
+ } catch (error) {
102
+ await fsp.rm(tmp, { force: true }).catch(() => {});
103
+ throw credentialError(`Gagal menyimpan kredensial Google Drive: ${error.message}`);
104
+ }
105
+ return { file: paths.credentialFile, savedAt: payload.savedAt };
106
+ }
107
+
108
+ /** Read and decrypt the stored tokens. Returns null when nothing is stored. */
109
+ export async function loadCredentials({ paths, env = process.env } = {}) {
110
+ let raw;
111
+ try {
112
+ raw = await fsp.readFile(paths.credentialFile);
113
+ } catch (error) {
114
+ if (error.code === 'ENOENT') return null;
115
+ if (error.code === 'EACCES' || error.code === 'EPERM') {
116
+ throw credentialError(`Izin ditolak saat membaca kredensial Google Drive (${paths.credentialFile}).`);
117
+ }
118
+ throw credentialError(`Gagal membaca kredensial Google Drive: ${error.message}`);
119
+ }
120
+ if (raw.length < MAGIC.length + 28) throw credentialError('File kredensial Google Drive rusak. Jalankan /drive login.');
121
+ if (raw.subarray(0, MAGIC.length).toString('utf8') !== MAGIC) {
122
+ throw credentialError('Format file kredensial Google Drive tidak dikenali. Jalankan /drive login.');
123
+ }
124
+ let key;
125
+ try {
126
+ key = await readOrCreateKey(paths.keyFile, { env });
127
+ } catch (error) {
128
+ if (error.code === 'DRIVE_CREDENTIAL') throw error;
129
+ throw credentialError(`Kunci kredensial Google Drive tidak tersedia: ${error.message}`);
130
+ }
131
+ const iv = raw.subarray(MAGIC.length, MAGIC.length + 12);
132
+ const tag = raw.subarray(MAGIC.length + 12, MAGIC.length + 28);
133
+ const body = raw.subarray(MAGIC.length + 28);
134
+ try {
135
+ const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
136
+ decipher.setAuthTag(tag);
137
+ const json = Buffer.concat([decipher.update(body), decipher.final()]).toString('utf8');
138
+ return JSON.parse(json);
139
+ } catch {
140
+ throw credentialError(
141
+ 'Kredensial Google Drive tidak dapat didekripsi (kunci berbeda atau file rusak). Jalankan /drive login untuk menghubungkan ulang.',
142
+ );
143
+ }
144
+ }
145
+
146
+ /** Delete the stored credentials. The encryption key is removed too. */
147
+ export async function clearCredentials({ paths, env = process.env } = {}) {
148
+ let removed = false;
149
+ try {
150
+ await fsp.rm(paths.credentialFile, { force: true });
151
+ removed = true;
152
+ } catch (error) {
153
+ throw credentialError(`Gagal menghapus kredensial Google Drive: ${error.message}`);
154
+ }
155
+ if (!env.RYUU_DRIVE_KEY) await fsp.rm(paths.keyFile, { force: true }).catch(() => {});
156
+ return removed;
157
+ }
158
+
159
+ /** Metadata only — safe to print. Never contains a token. */
160
+ export function describeCredentials(stored) {
161
+ if (!stored) return { connected: false };
162
+ return {
163
+ connected: Boolean(stored.refreshToken || stored.accessToken || stored.grantId),
164
+ hasRefreshToken: Boolean(stored.refreshToken || stored.grantId),
165
+ expiresAt: stored.expiryDate ? new Date(stored.expiryDate).toISOString() : null,
166
+ scope: stored.scope || '',
167
+ email: stored.account?.email || null,
168
+ name: stored.account?.name || null,
169
+ savedAt: stored.savedAt || null,
170
+ };
171
+ }