@codaijs/keel 0.1.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.
- package/dist/__tests__/cli.test.d.ts +2 -0
- package/dist/__tests__/cli.test.d.ts.map +1 -0
- package/dist/__tests__/cli.test.js +173 -0
- package/dist/__tests__/cli.test.js.map +1 -0
- package/dist/__tests__/registry.test.d.ts +2 -0
- package/dist/__tests__/registry.test.d.ts.map +1 -0
- package/dist/__tests__/registry.test.js +86 -0
- package/dist/__tests__/registry.test.js.map +1 -0
- package/dist/__tests__/sail-installer.test.d.ts +2 -0
- package/dist/__tests__/sail-installer.test.d.ts.map +1 -0
- package/dist/__tests__/sail-installer.test.js +158 -0
- package/dist/__tests__/sail-installer.test.js.map +1 -0
- package/dist/create-runner.d.ts +11 -0
- package/dist/create-runner.d.ts.map +1 -0
- package/dist/create-runner.js +63 -0
- package/dist/create-runner.js.map +1 -0
- package/dist/create.d.ts +10 -0
- package/dist/create.d.ts.map +1 -0
- package/dist/create.js +15 -0
- package/dist/create.js.map +1 -0
- package/dist/manage.d.ts +24 -0
- package/dist/manage.d.ts.map +1 -0
- package/dist/manage.js +1461 -0
- package/dist/manage.js.map +1 -0
- package/dist/prompts.d.ts +36 -0
- package/dist/prompts.d.ts.map +1 -0
- package/dist/prompts.js +208 -0
- package/dist/prompts.js.map +1 -0
- package/dist/sail-installer.d.ts +37 -0
- package/dist/sail-installer.d.ts.map +1 -0
- package/dist/sail-installer.js +935 -0
- package/dist/sail-installer.js.map +1 -0
- package/dist/scaffold.d.ts +10 -0
- package/dist/scaffold.d.ts.map +1 -0
- package/dist/scaffold.js +297 -0
- package/dist/scaffold.js.map +1 -0
- package/package.json +57 -0
- package/sails/_template/addon.json +20 -0
- package/sails/_template/install.ts +402 -0
- package/sails/admin-dashboard/README.md +117 -0
- package/sails/admin-dashboard/addon.json +28 -0
- package/sails/admin-dashboard/files/backend/middleware/admin.ts +34 -0
- package/sails/admin-dashboard/files/backend/routes/admin.ts +243 -0
- package/sails/admin-dashboard/files/frontend/components/admin/StatsCard.tsx +40 -0
- package/sails/admin-dashboard/files/frontend/components/admin/UsersTable.tsx +240 -0
- package/sails/admin-dashboard/files/frontend/hooks/useAdmin.ts +149 -0
- package/sails/admin-dashboard/files/frontend/pages/admin/Dashboard.tsx +173 -0
- package/sails/admin-dashboard/files/frontend/pages/admin/UserDetail.tsx +203 -0
- package/sails/admin-dashboard/install.ts +305 -0
- package/sails/analytics/README.md +178 -0
- package/sails/analytics/addon.json +27 -0
- package/sails/analytics/files/frontend/components/AnalyticsProvider.tsx +58 -0
- package/sails/analytics/files/frontend/hooks/useAnalytics.ts +64 -0
- package/sails/analytics/files/frontend/lib/analytics.ts +103 -0
- package/sails/analytics/install.ts +297 -0
- package/sails/file-uploads/README.md +191 -0
- package/sails/file-uploads/addon.json +30 -0
- package/sails/file-uploads/files/backend/routes/files.ts +198 -0
- package/sails/file-uploads/files/backend/schema/files.ts +36 -0
- package/sails/file-uploads/files/backend/services/file-storage.ts +128 -0
- package/sails/file-uploads/files/frontend/components/FileList.tsx +248 -0
- package/sails/file-uploads/files/frontend/components/FileUploadButton.tsx +147 -0
- package/sails/file-uploads/files/frontend/hooks/useFileUpload.ts +106 -0
- package/sails/file-uploads/files/frontend/hooks/useFiles.ts +118 -0
- package/sails/file-uploads/files/frontend/pages/Files.tsx +37 -0
- package/sails/file-uploads/install.ts +466 -0
- package/sails/gdpr/README.md +174 -0
- package/sails/gdpr/addon.json +27 -0
- package/sails/gdpr/files/backend/routes/gdpr.ts +140 -0
- package/sails/gdpr/files/backend/services/gdpr.ts +293 -0
- package/sails/gdpr/files/frontend/components/auth/ConsentCheckboxes.tsx +97 -0
- package/sails/gdpr/files/frontend/components/gdpr/AccountDeletionRequest.tsx +192 -0
- package/sails/gdpr/files/frontend/components/gdpr/DataExportButton.tsx +75 -0
- package/sails/gdpr/files/frontend/pages/PrivacyPolicy.tsx +186 -0
- package/sails/gdpr/install.ts +756 -0
- package/sails/google-oauth/README.md +121 -0
- package/sails/google-oauth/addon.json +22 -0
- package/sails/google-oauth/files/GoogleButton.tsx +50 -0
- package/sails/google-oauth/install.ts +252 -0
- package/sails/i18n/README.md +193 -0
- package/sails/i18n/addon.json +30 -0
- package/sails/i18n/files/frontend/components/LanguageSwitcher.tsx +108 -0
- package/sails/i18n/files/frontend/hooks/useLanguage.ts +31 -0
- package/sails/i18n/files/frontend/lib/i18n.ts +32 -0
- package/sails/i18n/files/frontend/locales/de/common.json +44 -0
- package/sails/i18n/files/frontend/locales/en/common.json +44 -0
- package/sails/i18n/install.ts +407 -0
- package/sails/push-notifications/README.md +163 -0
- package/sails/push-notifications/addon.json +31 -0
- package/sails/push-notifications/files/backend/routes/notifications.ts +153 -0
- package/sails/push-notifications/files/backend/schema/notifications.ts +31 -0
- package/sails/push-notifications/files/backend/services/notifications.ts +117 -0
- package/sails/push-notifications/files/frontend/components/PushNotificationInit.tsx +12 -0
- package/sails/push-notifications/files/frontend/hooks/usePushNotifications.ts +154 -0
- package/sails/push-notifications/install.ts +384 -0
- package/sails/r2-storage/README.md +101 -0
- package/sails/r2-storage/addon.json +29 -0
- package/sails/r2-storage/files/backend/services/storage.ts +71 -0
- package/sails/r2-storage/files/frontend/components/ProfilePictureUpload.tsx +167 -0
- package/sails/r2-storage/install.ts +412 -0
- package/sails/rate-limiting/README.md +145 -0
- package/sails/rate-limiting/addon.json +20 -0
- package/sails/rate-limiting/files/backend/middleware/rate-limit-store.ts +104 -0
- package/sails/rate-limiting/files/backend/middleware/rate-limit.ts +137 -0
- package/sails/rate-limiting/install.ts +300 -0
- package/sails/registry.json +107 -0
- package/sails/stripe/README.md +214 -0
- package/sails/stripe/addon.json +24 -0
- package/sails/stripe/files/backend/routes/stripe.ts +154 -0
- package/sails/stripe/files/backend/schema/stripe.ts +74 -0
- package/sails/stripe/files/backend/services/stripe.ts +224 -0
- package/sails/stripe/files/frontend/components/SubscriptionStatus.tsx +135 -0
- package/sails/stripe/files/frontend/hooks/useSubscription.ts +86 -0
- package/sails/stripe/files/frontend/pages/Checkout.tsx +116 -0
- package/sails/stripe/files/frontend/pages/Pricing.tsx +226 -0
- package/sails/stripe/install.ts +378 -0
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Push Notifications Sail Installer
|
|
3
|
+
*
|
|
4
|
+
* Adds push notification support via Capacitor + Firebase Cloud Messaging.
|
|
5
|
+
* Includes device token registration, server-side sending with firebase-admin,
|
|
6
|
+
* and a React hook for managing notification lifecycle.
|
|
7
|
+
*
|
|
8
|
+
* Usage:
|
|
9
|
+
* npx tsx sails/push-notifications/install.ts
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
readFileSync,
|
|
14
|
+
writeFileSync,
|
|
15
|
+
copyFileSync,
|
|
16
|
+
existsSync,
|
|
17
|
+
mkdirSync,
|
|
18
|
+
} from "node:fs";
|
|
19
|
+
import { resolve, dirname, join } from "node:path";
|
|
20
|
+
import { execSync } from "node:child_process";
|
|
21
|
+
import { input, confirm } from "@inquirer/prompts";
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Paths
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
const SAIL_DIR = dirname(new URL(import.meta.url).pathname);
|
|
28
|
+
const PROJECT_ROOT = resolve(SAIL_DIR, "../..");
|
|
29
|
+
const BACKEND_ROOT = join(PROJECT_ROOT, "packages/backend");
|
|
30
|
+
const FRONTEND_ROOT = join(PROJECT_ROOT, "packages/frontend");
|
|
31
|
+
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
// Helpers
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
interface SailManifest {
|
|
37
|
+
name: string;
|
|
38
|
+
displayName: string;
|
|
39
|
+
version: string;
|
|
40
|
+
requiredEnvVars: { key: string; description: string }[];
|
|
41
|
+
dependencies: { backend: Record<string, string>; frontend: Record<string, string> };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function loadManifest(): SailManifest {
|
|
45
|
+
return JSON.parse(readFileSync(join(SAIL_DIR, "addon.json"), "utf-8"));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function insertAtMarker(filePath: string, marker: string, code: string): void {
|
|
49
|
+
if (!existsSync(filePath)) {
|
|
50
|
+
console.warn(` Warning: File not found: ${filePath}`);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
let content = readFileSync(filePath, "utf-8");
|
|
54
|
+
if (!content.includes(marker)) {
|
|
55
|
+
console.warn(` Warning: Marker "${marker}" not found in ${filePath}`);
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
if (content.includes(code.trim())) {
|
|
59
|
+
console.log(` Skipped (already present) -> ${filePath}`);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
content = content.replace(marker, `${marker}\n${code}`);
|
|
63
|
+
writeFileSync(filePath, content, "utf-8");
|
|
64
|
+
console.log(` Modified -> ${filePath}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function copyFile(src: string, dest: string, label: string): void {
|
|
68
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
69
|
+
copyFileSync(src, dest);
|
|
70
|
+
console.log(` Copied -> ${label}`);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function appendToEnvFiles(section: string, entries: Record<string, string>): void {
|
|
74
|
+
for (const envFile of [".env.example", ".env"]) {
|
|
75
|
+
const envPath = join(PROJECT_ROOT, envFile);
|
|
76
|
+
if (!existsSync(envPath)) continue;
|
|
77
|
+
let content = readFileSync(envPath, "utf-8");
|
|
78
|
+
const lines: string[] = [];
|
|
79
|
+
for (const [key, val] of Object.entries(entries)) {
|
|
80
|
+
if (!content.includes(key)) lines.push(`${key}=${val}`);
|
|
81
|
+
}
|
|
82
|
+
if (lines.length > 0) {
|
|
83
|
+
content += `\n# ${section}\n${lines.join("\n")}\n`;
|
|
84
|
+
writeFileSync(envPath, content, "utf-8");
|
|
85
|
+
console.log(` Updated ${envFile}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function installDeps(deps: Record<string, string>, workspace: string): void {
|
|
91
|
+
const entries = Object.entries(deps);
|
|
92
|
+
if (entries.length === 0) return;
|
|
93
|
+
const packages = entries.map(([n, v]) => `${n}@${v}`).join(" ");
|
|
94
|
+
const cmd = `npm install ${packages} --workspace=${workspace}`;
|
|
95
|
+
console.log(` Running: ${cmd}`);
|
|
96
|
+
execSync(cmd, { cwd: PROJECT_ROOT, stdio: "inherit" });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
// Main
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
|
|
103
|
+
async function main(): Promise<void> {
|
|
104
|
+
const manifest = loadManifest();
|
|
105
|
+
|
|
106
|
+
// -- Step 1: Welcome --------------------------------------------------------
|
|
107
|
+
console.log("\n------------------------------------------------------------");
|
|
108
|
+
console.log(` Push Notifications Sail Installer (v${manifest.version})`);
|
|
109
|
+
console.log("------------------------------------------------------------");
|
|
110
|
+
console.log();
|
|
111
|
+
console.log(" This sail adds push notification support to your app:");
|
|
112
|
+
console.log(" - Firebase Cloud Messaging (FCM) for delivery");
|
|
113
|
+
console.log(" - Capacitor integration for native iOS/Android");
|
|
114
|
+
console.log(" - Device token registration and management");
|
|
115
|
+
console.log(" - Server-side notification sending with firebase-admin");
|
|
116
|
+
console.log(" - React hook for permission handling and token lifecycle");
|
|
117
|
+
console.log();
|
|
118
|
+
|
|
119
|
+
const pkgPath = join(PROJECT_ROOT, "package.json");
|
|
120
|
+
if (existsSync(pkgPath)) {
|
|
121
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
122
|
+
console.log(` Template version: ${pkg.version ?? "unknown"}`);
|
|
123
|
+
console.log();
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// -- Step 2: Firebase setup guide -------------------------------------------
|
|
127
|
+
const hasFirebase = await confirm({
|
|
128
|
+
message: "Do you already have a Firebase project with Cloud Messaging enabled?",
|
|
129
|
+
default: false,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
if (!hasFirebase) {
|
|
133
|
+
console.log();
|
|
134
|
+
console.log(" Follow these steps to set up Firebase Cloud Messaging:");
|
|
135
|
+
console.log();
|
|
136
|
+
console.log(" 1. Go to https://console.firebase.google.com");
|
|
137
|
+
console.log(" 2. Click 'Add project' (or select an existing project)");
|
|
138
|
+
console.log(" 3. Follow the setup wizard (you can disable Google Analytics)");
|
|
139
|
+
console.log(" 4. In the project, go to Project Settings > Cloud Messaging");
|
|
140
|
+
console.log(" Make sure Cloud Messaging API (V1) is enabled");
|
|
141
|
+
console.log(" 5. Go to Project Settings > Service Accounts");
|
|
142
|
+
console.log(' 6. Click "Generate new private key" to download the JSON file');
|
|
143
|
+
console.log(" 7. Open the JSON file — you will need:");
|
|
144
|
+
console.log(" - project_id");
|
|
145
|
+
console.log(" - private_key (the full PEM string)");
|
|
146
|
+
console.log(" - client_email");
|
|
147
|
+
console.log();
|
|
148
|
+
|
|
149
|
+
await confirm({
|
|
150
|
+
message: "I have my Firebase project set up and service account JSON ready",
|
|
151
|
+
default: false,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// -- Step 3: Collect credentials --------------------------------------------
|
|
156
|
+
console.log();
|
|
157
|
+
console.log(" Enter your Firebase service account credentials.");
|
|
158
|
+
console.log(" These are found in the service account JSON file you downloaded.");
|
|
159
|
+
console.log();
|
|
160
|
+
|
|
161
|
+
const firebaseProjectId = await input({
|
|
162
|
+
message: "Firebase Project ID (project_id):",
|
|
163
|
+
validate: (value) => {
|
|
164
|
+
if (!value || value.trim().length === 0) return "Project ID is required.";
|
|
165
|
+
return true;
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
const firebaseClientEmail = await input({
|
|
170
|
+
message: "Firebase Client Email (client_email):",
|
|
171
|
+
validate: (value) => {
|
|
172
|
+
if (!value || value.trim().length === 0) return "Client email is required.";
|
|
173
|
+
if (!value.includes("@")) return "Should be a valid service account email.";
|
|
174
|
+
return true;
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
console.log();
|
|
179
|
+
console.log(" For the private key, paste the full PEM key from the JSON file.");
|
|
180
|
+
console.log(" It starts with -----BEGIN PRIVATE KEY----- and ends with -----END PRIVATE KEY-----");
|
|
181
|
+
console.log(" You can paste it as a single line with \\n for newlines.");
|
|
182
|
+
console.log();
|
|
183
|
+
|
|
184
|
+
const firebasePrivateKey = await input({
|
|
185
|
+
message: "Firebase Private Key (private_key):",
|
|
186
|
+
validate: (value) => {
|
|
187
|
+
if (!value || value.trim().length === 0) return "Private key is required.";
|
|
188
|
+
if (!value.includes("PRIVATE KEY")) return "Should contain 'PRIVATE KEY' — paste the full PEM key.";
|
|
189
|
+
return true;
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// -- Step 4: Summary --------------------------------------------------------
|
|
194
|
+
console.log();
|
|
195
|
+
console.log(" Summary of changes:");
|
|
196
|
+
console.log(" -------------------");
|
|
197
|
+
console.log(" Files to copy (backend):");
|
|
198
|
+
console.log(" + packages/backend/src/db/schema/notifications.ts");
|
|
199
|
+
console.log(" + packages/backend/src/routes/notifications.ts");
|
|
200
|
+
console.log(" + packages/backend/src/services/notifications.ts");
|
|
201
|
+
console.log();
|
|
202
|
+
console.log(" Files to copy (frontend):");
|
|
203
|
+
console.log(" + packages/frontend/src/hooks/usePushNotifications.ts");
|
|
204
|
+
console.log(" + packages/frontend/src/components/PushNotificationInit.tsx");
|
|
205
|
+
console.log();
|
|
206
|
+
console.log(" Files to modify:");
|
|
207
|
+
console.log(" ~ packages/backend/src/db/schema/index.ts");
|
|
208
|
+
console.log(" ~ packages/backend/src/index.ts");
|
|
209
|
+
console.log(" ~ packages/backend/src/env.ts");
|
|
210
|
+
console.log(" ~ packages/frontend/src/components/layout/Layout.tsx");
|
|
211
|
+
console.log(" ~ .env.example / .env");
|
|
212
|
+
console.log();
|
|
213
|
+
console.log(" Environment variables:");
|
|
214
|
+
console.log(` FIREBASE_PROJECT_ID=${firebaseProjectId}`);
|
|
215
|
+
console.log(` FIREBASE_CLIENT_EMAIL=${firebaseClientEmail.slice(0, 20)}...`);
|
|
216
|
+
console.log(` FIREBASE_PRIVATE_KEY=-----BEGIN PRIVATE KEY-----...`);
|
|
217
|
+
console.log();
|
|
218
|
+
|
|
219
|
+
// -- Step 5: Confirm --------------------------------------------------------
|
|
220
|
+
const proceed = await confirm({ message: "Proceed with installation?", default: true });
|
|
221
|
+
if (!proceed) { console.log("\n Installation cancelled.\n"); process.exit(0); }
|
|
222
|
+
|
|
223
|
+
console.log();
|
|
224
|
+
console.log(" Installing...");
|
|
225
|
+
console.log();
|
|
226
|
+
|
|
227
|
+
// -- Step 6: Copy files and insert at markers --------------------------------
|
|
228
|
+
console.log(" Copying backend files...");
|
|
229
|
+
const backendFiles = [
|
|
230
|
+
{ src: "backend/schema/notifications.ts", dest: "src/db/schema/notifications.ts" },
|
|
231
|
+
{ src: "backend/routes/notifications.ts", dest: "src/routes/notifications.ts" },
|
|
232
|
+
{ src: "backend/services/notifications.ts", dest: "src/services/notifications.ts" },
|
|
233
|
+
];
|
|
234
|
+
for (const f of backendFiles) {
|
|
235
|
+
copyFile(join(SAIL_DIR, "files", f.src), join(BACKEND_ROOT, f.dest), f.dest);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
console.log();
|
|
239
|
+
console.log(" Copying frontend files...");
|
|
240
|
+
const frontendFiles = [
|
|
241
|
+
{ src: "frontend/hooks/usePushNotifications.ts", dest: "src/hooks/usePushNotifications.ts" },
|
|
242
|
+
{ src: "frontend/components/PushNotificationInit.tsx", dest: "src/components/PushNotificationInit.tsx" },
|
|
243
|
+
];
|
|
244
|
+
for (const f of frontendFiles) {
|
|
245
|
+
copyFile(join(SAIL_DIR, "files", f.src), join(FRONTEND_ROOT, f.dest), f.dest);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
console.log();
|
|
249
|
+
console.log(" Modifying backend files...");
|
|
250
|
+
|
|
251
|
+
insertAtMarker(
|
|
252
|
+
join(BACKEND_ROOT, "src/db/schema/index.ts"),
|
|
253
|
+
"// [SAIL_SCHEMA]",
|
|
254
|
+
'export * from "./notifications.js";',
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
insertAtMarker(
|
|
258
|
+
join(BACKEND_ROOT, "src/index.ts"),
|
|
259
|
+
"// [SAIL_IMPORTS]",
|
|
260
|
+
'import { notificationsRouter } from "./routes/notifications.js";',
|
|
261
|
+
);
|
|
262
|
+
|
|
263
|
+
insertAtMarker(
|
|
264
|
+
join(BACKEND_ROOT, "src/index.ts"),
|
|
265
|
+
"// [SAIL_ROUTES]",
|
|
266
|
+
'app.use("/api/notifications", notificationsRouter);',
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
insertAtMarker(
|
|
270
|
+
join(BACKEND_ROOT, "src/env.ts"),
|
|
271
|
+
"// [SAIL_ENV_VARS]",
|
|
272
|
+
` FIREBASE_PROJECT_ID: z.string().min(1, "FIREBASE_PROJECT_ID is required"),\n FIREBASE_PRIVATE_KEY: z.string().min(1, "FIREBASE_PRIVATE_KEY is required"),\n FIREBASE_CLIENT_EMAIL: z.string().min(1, "FIREBASE_CLIENT_EMAIL is required"),`,
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
console.log();
|
|
276
|
+
console.log(" Modifying frontend files...");
|
|
277
|
+
|
|
278
|
+
// Add PushNotificationInit to Layout.tsx
|
|
279
|
+
const layoutPath = join(FRONTEND_ROOT, "src/components/layout/Layout.tsx");
|
|
280
|
+
if (existsSync(layoutPath)) {
|
|
281
|
+
let layoutContent = readFileSync(layoutPath, "utf-8");
|
|
282
|
+
|
|
283
|
+
// Add import if not present
|
|
284
|
+
const importLine = 'import { PushNotificationInit } from "@/components/PushNotificationInit.js";';
|
|
285
|
+
if (!layoutContent.includes("PushNotificationInit")) {
|
|
286
|
+
// Insert import after the last import statement
|
|
287
|
+
const lastImportIndex = layoutContent.lastIndexOf("import ");
|
|
288
|
+
const lineEnd = layoutContent.indexOf("\n", lastImportIndex);
|
|
289
|
+
layoutContent =
|
|
290
|
+
layoutContent.slice(0, lineEnd + 1) +
|
|
291
|
+
importLine + "\n" +
|
|
292
|
+
layoutContent.slice(lineEnd + 1);
|
|
293
|
+
|
|
294
|
+
// Insert <PushNotificationInit /> after useDeepLinks() call
|
|
295
|
+
layoutContent = layoutContent.replace(
|
|
296
|
+
"useDeepLinks();",
|
|
297
|
+
"useDeepLinks();\n\n return (\n",
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
// Actually, let's just insert the component in the JSX
|
|
301
|
+
// Revert the above — re-read the file cleanly
|
|
302
|
+
layoutContent = readFileSync(layoutPath, "utf-8");
|
|
303
|
+
|
|
304
|
+
// Add import at the top after last import
|
|
305
|
+
const lastImport = layoutContent.lastIndexOf("import ");
|
|
306
|
+
const importLineEnd = layoutContent.indexOf("\n", lastImport);
|
|
307
|
+
layoutContent =
|
|
308
|
+
layoutContent.slice(0, importLineEnd + 1) +
|
|
309
|
+
importLine + "\n" +
|
|
310
|
+
layoutContent.slice(importLineEnd + 1);
|
|
311
|
+
|
|
312
|
+
// Add component in JSX — insert right after the opening <div>
|
|
313
|
+
layoutContent = layoutContent.replace(
|
|
314
|
+
'<div className="flex min-h-screen flex-col bg-keel-navy">',
|
|
315
|
+
'<div className="flex min-h-screen flex-col bg-keel-navy">\n <PushNotificationInit />',
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
writeFileSync(layoutPath, layoutContent, "utf-8");
|
|
319
|
+
console.log(` Modified -> Layout.tsx`);
|
|
320
|
+
} else {
|
|
321
|
+
console.log(` Skipped (already present) -> Layout.tsx`);
|
|
322
|
+
}
|
|
323
|
+
} else {
|
|
324
|
+
console.warn(" Warning: Layout.tsx not found. Add <PushNotificationInit /> manually.");
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
console.log();
|
|
328
|
+
console.log(" Installing dependencies...");
|
|
329
|
+
installDeps(manifest.dependencies.backend, "packages/backend");
|
|
330
|
+
installDeps(manifest.dependencies.frontend, "packages/frontend");
|
|
331
|
+
|
|
332
|
+
console.log();
|
|
333
|
+
console.log(" Generating database migrations...");
|
|
334
|
+
try {
|
|
335
|
+
execSync("npx drizzle-kit generate", { cwd: BACKEND_ROOT, stdio: "inherit" });
|
|
336
|
+
} catch {
|
|
337
|
+
console.warn(" Warning: Could not generate migrations. Run manually: cd packages/backend && npx drizzle-kit generate");
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
console.log();
|
|
341
|
+
console.log(" Updating environment files...");
|
|
342
|
+
appendToEnvFiles("Push Notifications (Firebase)", {
|
|
343
|
+
FIREBASE_PROJECT_ID: firebaseProjectId,
|
|
344
|
+
FIREBASE_PRIVATE_KEY: firebasePrivateKey,
|
|
345
|
+
FIREBASE_CLIENT_EMAIL: firebaseClientEmail,
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
// -- Step 7: Next steps ------------------------------------------------------
|
|
349
|
+
console.log();
|
|
350
|
+
console.log("------------------------------------------------------------");
|
|
351
|
+
console.log(" Push Notifications installed successfully!");
|
|
352
|
+
console.log("------------------------------------------------------------");
|
|
353
|
+
console.log();
|
|
354
|
+
console.log(" Next steps:");
|
|
355
|
+
console.log();
|
|
356
|
+
console.log(" 1. Run database migrations:");
|
|
357
|
+
console.log(" npm run db:migrate");
|
|
358
|
+
console.log();
|
|
359
|
+
console.log(" 2. Configure iOS (APNs):");
|
|
360
|
+
console.log(" - Go to https://developer.apple.com/account/resources/authkeys/list");
|
|
361
|
+
console.log(' - Create an APNs authentication key (check "Apple Push Notifications service")');
|
|
362
|
+
console.log(" - Download the .p8 key file");
|
|
363
|
+
console.log(" - In Firebase Console > Project Settings > Cloud Messaging > iOS:");
|
|
364
|
+
console.log(" Upload the APNs key, enter Key ID and Team ID");
|
|
365
|
+
console.log();
|
|
366
|
+
console.log(" 3. Configure Android:");
|
|
367
|
+
console.log(" - In Firebase Console, add an Android app to your project");
|
|
368
|
+
console.log(" - Download google-services.json");
|
|
369
|
+
console.log(" - Place it in your Android project: android/app/google-services.json");
|
|
370
|
+
console.log();
|
|
371
|
+
console.log(" 4. Sync Capacitor:");
|
|
372
|
+
console.log(" npx cap sync");
|
|
373
|
+
console.log();
|
|
374
|
+
console.log(" 5. Start your dev server:");
|
|
375
|
+
console.log(" npm run dev");
|
|
376
|
+
console.log();
|
|
377
|
+
console.log(" Testing push notifications:");
|
|
378
|
+
console.log(" - Push notifications only work on physical devices (not simulators)");
|
|
379
|
+
console.log(" - Use the Firebase Console > Messaging to send test notifications");
|
|
380
|
+
console.log(" - Or use the POST /api/notifications/send endpoint from your backend");
|
|
381
|
+
console.log();
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
main().catch((err) => { console.error("Installation failed:", err); process.exit(1); });
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# Cloudflare R2 Storage Sail
|
|
2
|
+
|
|
3
|
+
File uploads via Cloudflare R2 with presigned URLs. Adds profile picture upload support.
|
|
4
|
+
|
|
5
|
+
## What this sail adds
|
|
6
|
+
|
|
7
|
+
### Backend
|
|
8
|
+
- **`src/services/storage.ts`** — S3-compatible client for Cloudflare R2 with helpers for generating presigned upload/download URLs and deleting objects.
|
|
9
|
+
- **Avatar endpoints on `src/routes/profile.ts`**:
|
|
10
|
+
- `POST /avatar/upload-url` — generates a presigned upload URL for direct browser-to-R2 uploads
|
|
11
|
+
- `DELETE /avatar` — deletes the current user's avatar from R2 and clears the image field
|
|
12
|
+
|
|
13
|
+
### Frontend
|
|
14
|
+
- **`src/components/profile/ProfilePictureUpload.tsx`** — a drop-in avatar upload component with:
|
|
15
|
+
- Click-to-upload with file picker
|
|
16
|
+
- Native camera support via Capacitor on mobile
|
|
17
|
+
- 5 MB file size limit and image type validation
|
|
18
|
+
- Loading state and error handling
|
|
19
|
+
|
|
20
|
+
### Environment variables
|
|
21
|
+
| Variable | Description |
|
|
22
|
+
|----------|-------------|
|
|
23
|
+
| `R2_ACCOUNT_ID` | Your Cloudflare account ID |
|
|
24
|
+
| `R2_ACCESS_KEY_ID` | R2 API token access key ID |
|
|
25
|
+
| `R2_SECRET_ACCESS_KEY` | R2 API token secret access key |
|
|
26
|
+
| `R2_BUCKET_NAME` | Name of your R2 bucket (e.g., `avatars`) |
|
|
27
|
+
| `R2_PUBLIC_URL` | Public URL for serving uploaded files |
|
|
28
|
+
|
|
29
|
+
## Prerequisites
|
|
30
|
+
|
|
31
|
+
1. A Cloudflare account
|
|
32
|
+
2. An R2 bucket created in the Cloudflare dashboard
|
|
33
|
+
3. An R2 API token with **Object Read & Write** permissions
|
|
34
|
+
|
|
35
|
+
## Setup
|
|
36
|
+
|
|
37
|
+
### 1. Create an R2 bucket
|
|
38
|
+
|
|
39
|
+
1. Go to [Cloudflare Dashboard > R2](https://dash.cloudflare.com/?to=/:account/r2/new)
|
|
40
|
+
2. Click **Create bucket**
|
|
41
|
+
3. Choose a name (e.g., `avatars`) and location
|
|
42
|
+
4. Click **Create bucket**
|
|
43
|
+
|
|
44
|
+
### 2. Create an API token
|
|
45
|
+
|
|
46
|
+
1. Go to **R2 > Overview > Manage R2 API Tokens**
|
|
47
|
+
2. Click **Create API token**
|
|
48
|
+
3. Select **Object Read & Write** permissions
|
|
49
|
+
4. Scope it to your bucket
|
|
50
|
+
5. Copy the **Access Key ID** and **Secret Access Key**
|
|
51
|
+
|
|
52
|
+
### 3. Enable public access (optional)
|
|
53
|
+
|
|
54
|
+
If you want files to be publicly accessible via URL:
|
|
55
|
+
|
|
56
|
+
1. Go to your bucket **Settings > Public Access**
|
|
57
|
+
2. Enable the R2.dev subdomain or configure a custom domain
|
|
58
|
+
3. Use that URL as your `R2_PUBLIC_URL`
|
|
59
|
+
|
|
60
|
+
### 4. Configure CORS
|
|
61
|
+
|
|
62
|
+
In your bucket settings, add a CORS policy:
|
|
63
|
+
|
|
64
|
+
```json
|
|
65
|
+
[
|
|
66
|
+
{
|
|
67
|
+
"AllowedOrigins": ["http://localhost:5173", "https://yourdomain.com"],
|
|
68
|
+
"AllowedMethods": ["GET", "PUT"],
|
|
69
|
+
"AllowedHeaders": ["Content-Type"],
|
|
70
|
+
"MaxAgeSeconds": 3600
|
|
71
|
+
}
|
|
72
|
+
]
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
### 5. Run the installer
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
npx tsx cli/sails/r2-storage/install.ts
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Or use the CLI:
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
npx @codaijs/keel sail add r2-storage
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## Upload flow
|
|
88
|
+
|
|
89
|
+
1. Frontend requests a presigned upload URL from `POST /api/profile/avatar/upload-url`
|
|
90
|
+
2. Backend generates a presigned PUT URL using the S3-compatible R2 API
|
|
91
|
+
3. Frontend uploads the file directly to R2 using the presigned URL
|
|
92
|
+
4. Frontend updates the user profile with the new image URL via `PATCH /api/profile`
|
|
93
|
+
|
|
94
|
+
This approach keeps large file uploads off your server — files go directly from the browser to R2.
|
|
95
|
+
|
|
96
|
+
## Troubleshooting
|
|
97
|
+
|
|
98
|
+
- **CORS errors**: Make sure your R2 bucket CORS policy includes your frontend origin
|
|
99
|
+
- **403 Forbidden**: Verify your API token has the correct permissions and is scoped to the right bucket
|
|
100
|
+
- **Upload succeeds but image does not display**: Check that `R2_PUBLIC_URL` is correct and public access is enabled
|
|
101
|
+
- **Presigned URL expired**: The default expiry is 10 minutes — upload should happen immediately after requesting the URL
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "r2-storage",
|
|
3
|
+
"displayName": "Cloudflare R2 Storage",
|
|
4
|
+
"description": "File uploads via Cloudflare R2 with presigned URLs. Adds profile picture upload.",
|
|
5
|
+
"version": "1.0.0",
|
|
6
|
+
"compatibility": ">=1.0.0",
|
|
7
|
+
"requiredEnvVars": [
|
|
8
|
+
{ "key": "R2_ACCOUNT_ID", "description": "Cloudflare R2 account ID" },
|
|
9
|
+
{ "key": "R2_ACCESS_KEY_ID", "description": "R2 access key ID" },
|
|
10
|
+
{ "key": "R2_SECRET_ACCESS_KEY", "description": "R2 secret access key" },
|
|
11
|
+
{ "key": "R2_BUCKET_NAME", "description": "R2 bucket name (e.g., avatars)" },
|
|
12
|
+
{ "key": "R2_PUBLIC_URL", "description": "R2 public URL for serving files" }
|
|
13
|
+
],
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"backend": {
|
|
16
|
+
"@aws-sdk/client-s3": "^3.700.0",
|
|
17
|
+
"@aws-sdk/s3-request-presigner": "^3.700.0"
|
|
18
|
+
},
|
|
19
|
+
"frontend": {}
|
|
20
|
+
},
|
|
21
|
+
"modifies": {
|
|
22
|
+
"backend": ["src/routes/profile.ts", "src/env.ts"],
|
|
23
|
+
"frontend": ["src/components/profile/ProfilePage.tsx"]
|
|
24
|
+
},
|
|
25
|
+
"adds": {
|
|
26
|
+
"backend": ["src/services/storage.ts"],
|
|
27
|
+
"frontend": ["src/components/profile/ProfilePictureUpload.tsx"]
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import {
|
|
2
|
+
S3Client,
|
|
3
|
+
PutObjectCommand,
|
|
4
|
+
GetObjectCommand,
|
|
5
|
+
DeleteObjectCommand,
|
|
6
|
+
} from "@aws-sdk/client-s3";
|
|
7
|
+
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
|
8
|
+
import { env } from "../env.js";
|
|
9
|
+
|
|
10
|
+
const s3Client = new S3Client({
|
|
11
|
+
region: "auto",
|
|
12
|
+
endpoint: `https://${env.R2_ACCOUNT_ID}.r2.cloudflarestorage.com`,
|
|
13
|
+
credentials: {
|
|
14
|
+
accessKeyId: env.R2_ACCESS_KEY_ID,
|
|
15
|
+
secretAccessKey: env.R2_SECRET_ACCESS_KEY,
|
|
16
|
+
},
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const UPLOAD_URL_EXPIRY = 60 * 10; // 10 minutes
|
|
20
|
+
const DOWNLOAD_URL_EXPIRY = 60 * 60; // 1 hour
|
|
21
|
+
|
|
22
|
+
function getExtensionFromMimeType(fileType: string): string {
|
|
23
|
+
const mimeMap: Record<string, string> = {
|
|
24
|
+
"image/jpeg": "jpg",
|
|
25
|
+
"image/png": "png",
|
|
26
|
+
"image/webp": "webp",
|
|
27
|
+
"image/gif": "gif",
|
|
28
|
+
"image/svg+xml": "svg",
|
|
29
|
+
};
|
|
30
|
+
return mimeMap[fileType] ?? "bin";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function generateUploadUrl(
|
|
34
|
+
userId: string,
|
|
35
|
+
fileType: string,
|
|
36
|
+
): Promise<{ uploadUrl: string; key: string }> {
|
|
37
|
+
const ext = getExtensionFromMimeType(fileType);
|
|
38
|
+
const key = `avatars/${userId}/${Date.now()}.${ext}`;
|
|
39
|
+
|
|
40
|
+
const command = new PutObjectCommand({
|
|
41
|
+
Bucket: env.R2_BUCKET_NAME,
|
|
42
|
+
Key: key,
|
|
43
|
+
ContentType: fileType,
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const uploadUrl = await getSignedUrl(s3Client, command, {
|
|
47
|
+
expiresIn: UPLOAD_URL_EXPIRY,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
return { uploadUrl, key };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function generateDownloadUrl(key: string): Promise<string> {
|
|
54
|
+
const command = new GetObjectCommand({
|
|
55
|
+
Bucket: env.R2_BUCKET_NAME,
|
|
56
|
+
Key: key,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
return getSignedUrl(s3Client, command, {
|
|
60
|
+
expiresIn: DOWNLOAD_URL_EXPIRY,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function deleteObject(key: string): Promise<void> {
|
|
65
|
+
const command = new DeleteObjectCommand({
|
|
66
|
+
Bucket: env.R2_BUCKET_NAME,
|
|
67
|
+
Key: key,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
await s3Client.send(command);
|
|
71
|
+
}
|