Initial commit
This commit is contained in:
118
pocketbase/CLAUDE.md
Normal file
118
pocketbase/CLAUDE.md
Normal file
@@ -0,0 +1,118 @@
|
||||
# CLAUDE.md — pocketbase/
|
||||
|
||||
## Runtime Environment
|
||||
|
||||
PocketBase hooks (`pb_hooks/*.pb.js`) execute in PocketBase's **embedded JSVM** — not Node.js, not Deno, not browser JS. Available APIs are PocketBase-specific:
|
||||
|
||||
- `$app` — app instance (`findFirstRecordByFilter`, `findFirstRecordByData`, `findRecordsByFilter`, `save`, `delete`, etc.)
|
||||
- `$os.getenv(key)` — read environment variables
|
||||
- `$http.send({url, method, headers, body, timeout})` — make HTTP requests
|
||||
- `$template.loadFiles(path)` / `$template.loadString(str)` — render Go templates
|
||||
- `$security.randomString(n)` — generate random strings
|
||||
- `routerAdd(method, path, handler, ...middlewares)` — register custom API endpoints
|
||||
- `onMailerRecord*Send` — intercept outgoing emails
|
||||
- `onRecordAfterCreateSuccess` — post-creation hooks
|
||||
- `onRecordRequestOTPRequest` — OTP request hook
|
||||
- `e.next()` — continue the middleware chain (required in hooks)
|
||||
- `require(path)` — load other hook files (CommonJS-style)
|
||||
- `__hooks` — absolute path to the `pb_hooks/` directory
|
||||
|
||||
**Reference:** https://pocketbase.io/docs/js-overview/
|
||||
|
||||
No `import`, no npm packages. Use `e.app.logger().error(...)` for error logging.
|
||||
|
||||
## Migrations
|
||||
|
||||
Files in `pb_migrations/` run in order on PocketBase startup. Naming: `{number}_{description}.js`.
|
||||
|
||||
Each migration exports `migrate(up, down)`. The `down` function reverts the change.
|
||||
|
||||
Current migrations:
|
||||
|
||||
| File | What it does |
|
||||
|------|-------------|
|
||||
| `0_setup.js` | SMTP config (`SMTP_HOST`, `SMTP_PORT`), app meta (`APP_NAME`, `POCKETBASE_URL`), OAuth2 providers (Google, Apple), OTP (6 digits, 300s), creates superuser from `SUPERUSER_EMAIL`/`SUPERUSER_PW` |
|
||||
| `1_users_language.js` | Adds `language` select field (`'de'`, `'en'`) to users collection |
|
||||
| `2_notifications.js` | Creates `notifications` collection |
|
||||
| `3_fcm_tokens.js` | Creates `fcm_tokens` collection |
|
||||
| `4_counters.js` | Creates `counters` collection |
|
||||
|
||||
**To add a new collection:** Create the next numbered migration. Use `$app.save(collection)` pattern from existing migrations. After adding, run `pnpm pocketbase:types` from the project root.
|
||||
|
||||
## Collection Rules
|
||||
|
||||
| Collection | listRule | viewRule | createRule | updateRule | deleteRule |
|
||||
|------------|----------|----------|------------|------------|------------|
|
||||
| `notifications` | `userId = @request.auth.id` | `userId = @request.auth.id` | `null` | `userId = @request.auth.id` | `null` |
|
||||
| `fcm_tokens` | `userId = @request.auth.id` | `userId = @request.auth.id` | `userId = @request.auth.id` | `null` | `null` |
|
||||
| `counters` | `userId = @request.auth.id` | `userId = @request.auth.id` | `userId = @request.auth.id` | `userId = @request.auth.id` | `userId = @request.auth.id` |
|
||||
|
||||
## Hooks
|
||||
|
||||
| File | Trigger | Purpose |
|
||||
|------|---------|---------|
|
||||
| `createOtpUser.pb.js` | `onRecordRequestOTPRequest` on `users` | Auto-creates user with random password if none exists; reads `language` header to set user language |
|
||||
| `mailTemplates.pb.js` | All 5 `onMailerRecord*Send` events | Replaces default PocketBase emails with localized HTML templates |
|
||||
| `resetCounter.pb.js` | `POST /counter/reset` (requires auth) | Resets counter to 0, creates notification with localized title/body |
|
||||
| `sendFcmPush.pb.js` | `onRecordAfterCreateSuccess` on `notifications` | Looks up user's FCM tokens, POSTs to sidecar `/notify` |
|
||||
|
||||
## Email Templates
|
||||
|
||||
HTML templates in `pb_hooks/templates/{lang}/` use Go template syntax with **lowercase** variables:
|
||||
- `{{.password}}` — OTP code or reset token
|
||||
- `{{.appName}}` — from `app.settings().meta.appName`
|
||||
- `{{.appUrl}}` — from `app.settings().meta.appUrl`
|
||||
- Plus any fields from `e.meta`
|
||||
|
||||
Mail subjects are in `pb_hooks/locales/{lang}.json` under `mailSubject.*`. The key for password reset is `passwortReset` (consistent across all locale files and template filenames).
|
||||
|
||||
Template types: `authAlert`, `emailChange`, `otp`, `passwortReset`, `verification`
|
||||
|
||||
The rendering pipeline (`utils/renderMailTemplate.js`):
|
||||
1. Reads `e.record.getString('language')`, falls back to `'en'`
|
||||
2. Loads `locales/{lang}.json`, renders subject with `$template.loadString()`
|
||||
3. Loads `templates/{lang}/{type}.html`, renders HTML with `$template.loadFiles()`
|
||||
4. Sets `e.message.subject` and `e.message.html`, then calls `e.next()`
|
||||
|
||||
## Sidecar (Deno)
|
||||
|
||||
The `sidecar/` directory is a standalone Deno HTTP service (port 8091) that handles FCM push notification delivery. It authenticates with Google Cloud using a cached JWT from a service account (`GOOGLE_CREDENTIALS_JSON` env var). Access tokens are cached and refreshed automatically before expiry.
|
||||
|
||||
**Endpoint:** `POST /notify`
|
||||
|
||||
Request:
|
||||
```json
|
||||
{ "tokens": ["fcm-token-1"], "title": "Hello", "body": "World", "data": {} }
|
||||
```
|
||||
|
||||
- Requires `x-sidecar-secret: <SIDECAR_SECRET>` header
|
||||
- `data` is optional (`Record<string, string>`)
|
||||
- Returns `{ successCount, failureCount }`
|
||||
|
||||
PocketBase sends to `$os.getenv('SIDECAR_URL') + '/notify'` (defaults to `http://localhost:8091`). In Docker Compose this is `http://sidecar:8091`.
|
||||
|
||||
## Docker Compose Services (local dev)
|
||||
|
||||
```bash
|
||||
docker compose up
|
||||
```
|
||||
|
||||
| Service | Config | Details |
|
||||
|---------|--------|---------|
|
||||
| `pocketbase` | Build target `dev` (Alpine + PocketBase binary only) | Port 8090; volumes mount `pb_data/`, `pb_migrations/`, `pb_hooks/` to `/pb/` for live reload |
|
||||
| `sidecar` | `image: denoland/deno:latest` + volume mount | Port 8091; runs `deno task dev` (watch mode) against `./sidecar` mounted to `/app` |
|
||||
| `mailpit` | `image: axllent/mailpit` | Port 8025; catches all SMTP |
|
||||
|
||||
PocketBase admin dashboard: `http://localhost:8090/_/`
|
||||
|
||||
**Note:** The `dev` stage does not include Deno. In dev, the sidecar runs as a separate container using the official Deno image with a live volume mount — not from a built image.
|
||||
|
||||
## Production Dockerfile Stages
|
||||
|
||||
| Stage | Base | Purpose |
|
||||
|-------|------|---------|
|
||||
| `dev` | `alpine:latest` | Downloads PocketBase binary only |
|
||||
| `build` | `denoland/deno:latest` | Copies `sidecar/`, runs `deno install` to pre-cache dependencies |
|
||||
| `prod` | `denoland/deno:latest` | Copies PocketBase binary from `dev`, pre-cached sidecar from `build`, plus migrations/hooks; runs `start.sh` |
|
||||
|
||||
In `prod`, all files live under `/app/` (working directory). `start.sh` starts PocketBase in the background (`./pocketbase serve --http=0.0.0.0:8090`) and then runs `cd sidecar && deno task start` as the foreground process.
|
||||
44
pocketbase/Dockerfile
Normal file
44
pocketbase/Dockerfile
Normal file
@@ -0,0 +1,44 @@
|
||||
# Development stage - pocketbase only
|
||||
FROM alpine:latest AS dev
|
||||
|
||||
ARG PB_VERSION=0.36.8
|
||||
|
||||
RUN apk add --no-cache \
|
||||
unzip \
|
||||
ca-certificates
|
||||
|
||||
# Download and unzip PocketBase
|
||||
ADD https://github.com/pocketbase/pocketbase/releases/download/v${PB_VERSION}/pocketbase_${PB_VERSION}_linux_amd64.zip /tmp/pb.zip
|
||||
RUN unzip /tmp/pb.zip -d /pb/
|
||||
|
||||
EXPOSE 8090 8091
|
||||
|
||||
CMD ["/pb/pocketbase", "serve", "--http=0.0.0.0:8090"]
|
||||
|
||||
# Build stage
|
||||
FROM denoland/deno:latest AS build
|
||||
WORKDIR /app
|
||||
|
||||
COPY ./sidecar .
|
||||
|
||||
# Install dependencies
|
||||
RUN deno install
|
||||
|
||||
# Production stage
|
||||
FROM denoland/deno:latest AS prod
|
||||
WORKDIR /app
|
||||
|
||||
# Copy sidecar
|
||||
COPY --from=build /app ./sidecar
|
||||
|
||||
# Copy pocketbase binary
|
||||
COPY --from=dev /pb/pocketbase ./pocketbase
|
||||
|
||||
COPY ./pb_migrations ./pb_migrations
|
||||
COPY ./pb_hooks ./pb_hooks
|
||||
COPY ./sidecar ./sidecar
|
||||
COPY ./start.sh ./start.sh
|
||||
|
||||
RUN chmod +x ./start.sh
|
||||
|
||||
CMD ["./start.sh"]
|
||||
52
pocketbase/docker-compose.yml
Normal file
52
pocketbase/docker-compose.yml
Normal file
@@ -0,0 +1,52 @@
|
||||
services:
|
||||
pocketbase:
|
||||
build:
|
||||
context: .
|
||||
target: dev
|
||||
container_name: pocketbase
|
||||
restart: on-failure:3
|
||||
ports:
|
||||
- 8090:8090
|
||||
volumes:
|
||||
- ./pb_data:/pb/pb_data
|
||||
- ./pb_migrations:/pb/pb_migrations
|
||||
- ./pb_hooks:/pb/pb_hooks
|
||||
env_file:
|
||||
- ../.env
|
||||
environment:
|
||||
SIDECAR_URL: http://sidecar:8091
|
||||
|
||||
sidecar:
|
||||
image: denoland/deno:latest
|
||||
working_dir: /app
|
||||
command: deno task dev
|
||||
container_name: sidecar
|
||||
restart: on-failure:3
|
||||
ports:
|
||||
- 8091:8091
|
||||
volumes:
|
||||
- ./sidecar:/app
|
||||
env_file:
|
||||
- ../.env
|
||||
environment:
|
||||
POCKETBASE_URL: http://pocketbase:8090
|
||||
depends_on:
|
||||
- pocketbase
|
||||
|
||||
mailpit:
|
||||
image: axllent/mailpit
|
||||
container_name: mailpit
|
||||
restart: on-failure:3
|
||||
ports:
|
||||
- 8025:8025
|
||||
volumes:
|
||||
- ./mp_data:/data
|
||||
environment:
|
||||
MP_MAX_MESSAGES: 5000
|
||||
MP_DATABASE: /data/mailpit.db
|
||||
MP_SMTP_AUTH_ACCEPT_ANY: 1
|
||||
MP_SMTP_AUTH_ALLOW_INSECURE: 1
|
||||
|
||||
volumes:
|
||||
pocketbase_data:
|
||||
mailpit-data:
|
||||
21
pocketbase/pb_hooks/createOtpUser.pb.js
Normal file
21
pocketbase/pb_hooks/createOtpUser.pb.js
Normal file
@@ -0,0 +1,21 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
onRecordRequestOTPRequest((e) => {
|
||||
// create a user with the OTP email if it doesn't exist
|
||||
|
||||
if (!e.record) {
|
||||
const email = e.requestInfo().body['email']
|
||||
const record = new Record(e.collection)
|
||||
record.setEmail(email)
|
||||
record.setPassword($security.randomString(30))
|
||||
|
||||
const language = e.requestInfo().headers['language'] || 'en'
|
||||
record.set('language', language)
|
||||
|
||||
e.app.save(record)
|
||||
|
||||
e.record = record
|
||||
}
|
||||
|
||||
return e.next()
|
||||
}, 'users')
|
||||
13
pocketbase/pb_hooks/locales/de.json
Normal file
13
pocketbase/pb_hooks/locales/de.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"mailSubject": {
|
||||
"authAlert": "Anmeldung von einem neuen Standort",
|
||||
"emailChange": "Bestätige deine neue {{.appName}}-E-Mail-Adresse",
|
||||
"otp": "Einmalpasswort für {{.appName}}",
|
||||
"passwortReset": "Setze dein {{.appName}}-Passwort zurück",
|
||||
"verification": "Bestätige deine {{.appName}}-E-Mail-Adresse"
|
||||
},
|
||||
"resetCounter": {
|
||||
"title": "Zähler zurückgesetzt",
|
||||
"body": "Ihr Zähler wurde auf null zurückgesetzt."
|
||||
}
|
||||
}
|
||||
13
pocketbase/pb_hooks/locales/en.json
Normal file
13
pocketbase/pb_hooks/locales/en.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"mailSubject": {
|
||||
"authAlert": "Login from a new location",
|
||||
"emailChange": "Confirm your {{.appName}} new email address",
|
||||
"otp": "OTP for {{.appName}}",
|
||||
"passwortReset": "Reset your {{.appName}} password",
|
||||
"verification": "Verify your {{.appName}} email"
|
||||
},
|
||||
"resetCounter": {
|
||||
"title": "Counter reset",
|
||||
"body": "Your counter has been reset to zero."
|
||||
}
|
||||
}
|
||||
26
pocketbase/pb_hooks/mailTemplates.pb.js
Normal file
26
pocketbase/pb_hooks/mailTemplates.pb.js
Normal file
@@ -0,0 +1,26 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
onMailerRecordAuthAlertSend((e) => {
|
||||
const utils = require(`${__hooks}/utils/renderMailTemplate.js`)
|
||||
utils.renderMailTemplate(e, 'authAlert')
|
||||
})
|
||||
|
||||
onMailerRecordPasswordResetSend((e) => {
|
||||
const utils = require(`${__hooks}/utils/renderMailTemplate.js`)
|
||||
utils.renderMailTemplate(e, 'passwordReset')
|
||||
})
|
||||
|
||||
onMailerRecordVerificationSend((e) => {
|
||||
const utils = require(`${__hooks}/utils/renderMailTemplate.js`)
|
||||
utils.renderMailTemplate(e, 'verification')
|
||||
})
|
||||
|
||||
onMailerRecordEmailChangeSend((e) => {
|
||||
const utils = require(`${__hooks}/utils/renderMailTemplate.js`)
|
||||
utils.renderMailTemplate(e, 'emailChange')
|
||||
})
|
||||
|
||||
onMailerRecordOTPSend((e) => {
|
||||
const utils = require(`${__hooks}/utils/renderMailTemplate.js`)
|
||||
utils.renderMailTemplate(e, 'otp')
|
||||
})
|
||||
40
pocketbase/pb_hooks/resetCounter.pb.js
Normal file
40
pocketbase/pb_hooks/resetCounter.pb.js
Normal file
@@ -0,0 +1,40 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
/**
|
||||
* POST endpoint to reset the user's counter to 0,
|
||||
* additionally sends a notification to the user.
|
||||
* Requires authentication.
|
||||
*/
|
||||
routerAdd('POST', '/counter/reset', (e) => {
|
||||
try {
|
||||
const authRecord = e.auth
|
||||
|
||||
// find the user's counter record
|
||||
const counterRecord = $app.findFirstRecordByData('counters', 'userId', authRecord.id)
|
||||
|
||||
if (counterRecord) {
|
||||
// reset the counter to 0 and save
|
||||
counterRecord.set('count', 0)
|
||||
$app.save(counterRecord)
|
||||
}
|
||||
|
||||
// get the user's language and load the locale
|
||||
const userRecord = $app.findFirstRecordByData('users', 'id', authRecord.id)
|
||||
const language = userRecord?.getString('language') ?? 'en'
|
||||
const locale = require(`${__hooks}/locales/${language}.json`)
|
||||
const t = locale.resetCounter
|
||||
|
||||
// add notification
|
||||
const collection = $app.findCollectionByNameOrId('notifications')
|
||||
const notificationRecord = new Record(collection)
|
||||
notificationRecord.set('userId', authRecord.id)
|
||||
notificationRecord.set('title', t.title)
|
||||
notificationRecord.set('body', t.body)
|
||||
$app.save(notificationRecord)
|
||||
|
||||
return e.noContent(204)
|
||||
} catch (error) {
|
||||
console.error('Error', error)
|
||||
return e.json(500, { message: error.message })
|
||||
}
|
||||
}, $apis.requireAuth())
|
||||
55
pocketbase/pb_hooks/sendFcmPush.pb.js
Normal file
55
pocketbase/pb_hooks/sendFcmPush.pb.js
Normal file
@@ -0,0 +1,55 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
|
||||
onRecordAfterCreateSuccess((e) => {
|
||||
const record = e.record
|
||||
const userId = record.getString('userId')
|
||||
|
||||
if (!userId) return e.next()
|
||||
|
||||
const tokenRecords = $app.findRecordsByFilter(
|
||||
'fcm_tokens',
|
||||
`userId = "${userId}"`,
|
||||
'-created',
|
||||
500,
|
||||
0
|
||||
)
|
||||
|
||||
if (!tokenRecords || tokenRecords.length === 0) return e.next()
|
||||
|
||||
const tokens = tokenRecords
|
||||
.map(r => r.getString('token'))
|
||||
.filter(t => t.length > 0)
|
||||
|
||||
if (tokens.length === 0) return e.next()
|
||||
|
||||
const sidecarUrl = $os.getenv('SIDECAR_URL') || 'http://localhost:8091'
|
||||
const sidecarSecret = $os.getenv('SIDECAR_SECRET') || ''
|
||||
|
||||
try {
|
||||
const res = $http.send({
|
||||
url: sidecarUrl + '/notify',
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
'x-sidecar-secret': sidecarSecret
|
||||
},
|
||||
body: JSON.stringify({
|
||||
tokens: tokens,
|
||||
title: record.getString('title'),
|
||||
body: record.getString('body')
|
||||
}),
|
||||
timeout: 10
|
||||
})
|
||||
|
||||
if (res.statusCode !== 200) {
|
||||
e.app.logger().error('FCM relay failed',
|
||||
'status', res.statusCode,
|
||||
'response', JSON.stringify(res.json)
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
e.app.logger().error('FCM relay error', 'error', err)
|
||||
}
|
||||
|
||||
return e.next()
|
||||
}, 'notifications')
|
||||
14
pocketbase/pb_hooks/templates/de/authAlert.html
Normal file
14
pocketbase/pb_hooks/templates/de/authAlert.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<p>Hallo,</p>
|
||||
<p>Wir haben eine Anmeldung bei deinem {{.appName}}-Konto von einem neuen Standort festgestellt:</p>
|
||||
<p><em>{{.info}}</em></p>
|
||||
<p><strong>Wenn du das nicht warst, solltest du sofort dein {{.appName}}-Passwort ändern, um den Zugriff von allen anderen Standorten zu widerrufen.</strong></p>
|
||||
<p>Wenn du das warst, kannst du diese E-Mail ignorieren.</p>
|
||||
<p>
|
||||
Viele Grüße,<br/>
|
||||
Das {{.appName}}-Team
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
15
pocketbase/pb_hooks/templates/de/emailChange.html
Normal file
15
pocketbase/pb_hooks/templates/de/emailChange.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<p>Hallo,</p>
|
||||
<p>Klicke auf den Button unten, um deine neue E-Mail-Adresse zu bestätigen.</p>
|
||||
<p>
|
||||
<a class="btn" href="{{.appUrl}}/_/#/auth/confirm-email-change/{{.token}}" target="_blank" rel="noopener">Neue E-Mail-Adresse bestätigen</a>
|
||||
</p>
|
||||
<p><i>Wenn du keine Änderung deiner E-Mail-Adresse beantragt hast, kannst du diese E-Mail ignorieren.</i></p>
|
||||
<p>
|
||||
Viele Grüße,<br/>
|
||||
Das {{.appName}}-Team
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
12
pocketbase/pb_hooks/templates/de/otp.html
Normal file
12
pocketbase/pb_hooks/templates/de/otp.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<p>Hallo,</p>
|
||||
<p>Dein Einmalpasswort lautet: <strong>{{.password}}</strong></p>
|
||||
<p><i>Wenn du kein Einmalpasswort angefordert hast, kannst du diese E-Mail ignorieren.</i></p>
|
||||
<p>
|
||||
Viele Grüße,<br/>
|
||||
Das {{.appName}}-Team
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
15
pocketbase/pb_hooks/templates/de/passwortReset.html
Normal file
15
pocketbase/pb_hooks/templates/de/passwortReset.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<p>Hallo,</p>
|
||||
<p>Klicke auf den Button unten, um dein Passwort zurückzusetzen.</p>
|
||||
<p>
|
||||
<a class="btn" href="{{.appUrl}}/_/#/auth/confirm-password-reset/{{.token}}" target="_blank" rel="noopener">Passwort zurücksetzen</a>
|
||||
</p>
|
||||
<p><i>Wenn du keine Passwortzurücksetzung beantragt hast, kannst du diese E-Mail ignorieren.</i></p>
|
||||
<p>
|
||||
Viele Grüße,<br/>
|
||||
Das {{.appName}}-Team
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
15
pocketbase/pb_hooks/templates/de/verification.html
Normal file
15
pocketbase/pb_hooks/templates/de/verification.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<p>Hallo,</p>
|
||||
<p>Willkommen bei {{.appName}}.</p>
|
||||
<p>Klicke auf den Button unten, um deine E-Mail-Adresse zu bestätigen.</p>
|
||||
<p>
|
||||
<a class="btn" href="{{.appUrl}}/_/#/auth/confirm-verification/{{.token}}" target="_blank" rel="noopener">E-Mail-Adresse bestätigen</a>
|
||||
</p>
|
||||
<p>
|
||||
Viele Grüße,<br/>
|
||||
Das {{.appName}}-Team
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
14
pocketbase/pb_hooks/templates/en/authAlert.html
Normal file
14
pocketbase/pb_hooks/templates/en/authAlert.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<p>Hello,</p>
|
||||
<p>We noticed a login to your {{.appName}} account from a new location:</p>
|
||||
<p><em>{{.info}}</em></p>
|
||||
<p><strong>If this wasn't you, you should immediately change your {{.appName}} account password to revoke access from all other locations.</strong></p>
|
||||
<p>If this was you, you may disregard this email.</p>
|
||||
<p>
|
||||
Thanks,<br/>
|
||||
{{.appName}} team
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
15
pocketbase/pb_hooks/templates/en/emailChange.html
Normal file
15
pocketbase/pb_hooks/templates/en/emailChange.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<p>Hello,</p>
|
||||
<p>Click on the button below to confirm your new email address.</p>
|
||||
<p>
|
||||
<a class="btn" href="{{.appUrl}}/_/#/auth/confirm-email-change/{{.token}}" target="_blank" rel="noopener">Confirm new email</a>
|
||||
</p>
|
||||
<p><i>If you didn't ask to change your email address, you can ignore this email.</i></p>
|
||||
<p>
|
||||
Thanks,<br/>
|
||||
{{.appName}} team
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
12
pocketbase/pb_hooks/templates/en/otp.html
Normal file
12
pocketbase/pb_hooks/templates/en/otp.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<p>Hello,</p>
|
||||
<p>Your one-time password is: <strong>{{.password}}</strong></p>
|
||||
<p><i>If you didn't ask for the one-time password, you can ignore this email.</i></p>
|
||||
<p>
|
||||
Thanks,<br/>
|
||||
{{.appName}} team
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
15
pocketbase/pb_hooks/templates/en/passwortReset.html
Normal file
15
pocketbase/pb_hooks/templates/en/passwortReset.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<p>Hello,</p>
|
||||
<p>Click on the button below to reset your password.</p>
|
||||
<p>
|
||||
<a class="btn" href="{{.appUrl}}/_/#/auth/confirm-password-reset/{{.token}}" target="_blank" rel="noopener">Reset password</a>
|
||||
</p>
|
||||
<p><i>If you didn't ask to reset your password, you can ignore this email.</i></p>
|
||||
<p>
|
||||
Thanks,<br/>
|
||||
{{.appName}} team
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
15
pocketbase/pb_hooks/templates/en/verification.html
Normal file
15
pocketbase/pb_hooks/templates/en/verification.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<body>
|
||||
<p>Hello,</p>
|
||||
<p>Thank you for joining us at {{.appName}}.</p>
|
||||
<p>Click on the button below to verify your email address.</p>
|
||||
<p>
|
||||
<a class="btn" href="{{.appUrl}}/_/#/auth/confirm-verification/{{.token}}" target="_blank" rel="noopener">Verify</a>
|
||||
</p>
|
||||
<p>
|
||||
Thanks,<br/>
|
||||
{{.appName}} team
|
||||
</p>
|
||||
</body>
|
||||
</html>
|
||||
29
pocketbase/pb_hooks/utils/renderMailTemplate.js
Normal file
29
pocketbase/pb_hooks/utils/renderMailTemplate.js
Normal file
@@ -0,0 +1,29 @@
|
||||
const renderMailTemplate = (e, type) => {
|
||||
try {
|
||||
const language = e.record?.getString('language') ?? 'en'
|
||||
const locale = require(`${__hooks}/locales/${language}.json`)
|
||||
|
||||
const subject = $template.loadString(
|
||||
locale.mailSubject[type]
|
||||
).render({
|
||||
...e.meta,
|
||||
...e.app.settings().meta
|
||||
})
|
||||
|
||||
const html = $template.loadFiles(
|
||||
`${__hooks}/templates/${language}/${type}.html`
|
||||
).render({
|
||||
...e.meta,
|
||||
...e.app.settings().meta
|
||||
})
|
||||
|
||||
e.message.subject = subject
|
||||
e.message.html = html
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
|
||||
e.next()
|
||||
}
|
||||
|
||||
module.exports = { renderMailTemplate }
|
||||
94
pocketbase/pb_migrations/0_setup.js
Normal file
94
pocketbase/pb_migrations/0_setup.js
Normal file
@@ -0,0 +1,94 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((app) => {
|
||||
// Set basic settings
|
||||
let settings = app.settings()
|
||||
|
||||
const appUrl = $os.getenv('POCKETBASE_URL') || 'http://127.0.0.1:8090'
|
||||
const appName = $os.getenv('APP_NAME') || 'PocketBase'
|
||||
|
||||
const smtpHost = $os.getenv('SMTP_HOST') || 'mailpit'
|
||||
const smtpPort = Number($os.getenv('SMTP_PORT') || 1025)
|
||||
|
||||
unmarshal({
|
||||
meta: {
|
||||
appName,
|
||||
appUrl
|
||||
},
|
||||
smtp: {
|
||||
enabled: true,
|
||||
host: smtpHost,
|
||||
port: smtpPort
|
||||
}
|
||||
}, settings)
|
||||
|
||||
app.save(settings)
|
||||
|
||||
// Enable auth methods
|
||||
const collection = app.findCollectionByNameOrId('_pb_users_auth_')
|
||||
|
||||
const googleClientId = $os.getenv('AUTH_GOOGLE_CLIENT_ID')
|
||||
const googleClientSecret = $os.getenv('AUTH_GOOGLE_CLIENT_SECRET')
|
||||
|
||||
const appleClientId = $os.getenv('AUTH_APPLE_CLIENT_ID')
|
||||
const appleClientSecret = $os.getenv('AUTH_APPLE_CLIENT_SECRET')
|
||||
|
||||
const providers = []
|
||||
if (googleClientId?.length && googleClientSecret?.length) {
|
||||
providers.push({
|
||||
name: 'google',
|
||||
clientId: googleClientId,
|
||||
clientSecret: googleClientSecret
|
||||
})
|
||||
}
|
||||
if (appleClientId?.length && appleClientSecret?.length) {
|
||||
providers.push({
|
||||
name: 'apple',
|
||||
clientId: appleClientId,
|
||||
clientSecret: appleClientSecret
|
||||
})
|
||||
}
|
||||
|
||||
unmarshal({
|
||||
oauth2: {
|
||||
enabled: true,
|
||||
providers
|
||||
},
|
||||
otp: {
|
||||
duration: 300,
|
||||
enabled: true,
|
||||
length: 6
|
||||
}
|
||||
}, collection)
|
||||
app.save(collection)
|
||||
|
||||
// Create admin user if it doesn't exist
|
||||
const email = $os.getenv('SUPERUSER_EMAIL')
|
||||
const password = $os.getenv('SUPERUSER_PW')
|
||||
|
||||
if (email?.length && password?.length) {
|
||||
const existingUser = app.findFirstRecordByData('_superusers', 'email', email)
|
||||
|
||||
if (!existingUser) {
|
||||
let superusers = app.findCollectionByNameOrId('_superusers')
|
||||
let record = new Record(superusers)
|
||||
record.set('email', email)
|
||||
record.set('password', password)
|
||||
app.save(record)
|
||||
}
|
||||
}
|
||||
}, (app) => {
|
||||
const collection = app.findCollectionByNameOrId('_pb_users_auth_')
|
||||
|
||||
unmarshal({
|
||||
oauth2: {
|
||||
enabled: false
|
||||
},
|
||||
otp: {
|
||||
duration: 180,
|
||||
enabled: false,
|
||||
length: 8
|
||||
}
|
||||
}, collection)
|
||||
|
||||
app.save(collection)
|
||||
})
|
||||
29
pocketbase/pb_migrations/1_users_language.js
Normal file
29
pocketbase/pb_migrations/1_users_language.js
Normal file
@@ -0,0 +1,29 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((app) => {
|
||||
const collection = app.findCollectionByNameOrId('_pb_users_auth_')
|
||||
|
||||
// add field
|
||||
collection.fields.addAt(8, new Field({
|
||||
hidden: false,
|
||||
id: 'select3571151285',
|
||||
maxSelect: 1,
|
||||
name: 'language',
|
||||
presentable: false,
|
||||
required: false,
|
||||
system: false,
|
||||
type: 'select',
|
||||
values: [
|
||||
'de',
|
||||
'en'
|
||||
]
|
||||
}))
|
||||
|
||||
return app.save(collection)
|
||||
}, (app) => {
|
||||
const collection = app.findCollectionByNameOrId('_pb_users_auth_')
|
||||
|
||||
// remove field
|
||||
collection.fields.removeById('select3571151285')
|
||||
|
||||
return app.save(collection)
|
||||
})
|
||||
110
pocketbase/pb_migrations/2_notifications.js
Normal file
110
pocketbase/pb_migrations/2_notifications.js
Normal file
@@ -0,0 +1,110 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((app) => {
|
||||
if (app.hasTable('notifications')) {
|
||||
return
|
||||
}
|
||||
const collection = new Collection({
|
||||
fields: [
|
||||
{
|
||||
autogeneratePattern: '[a-z0-9]{15}',
|
||||
hidden: false,
|
||||
id: 'text3208210256',
|
||||
max: 15,
|
||||
min: 15,
|
||||
name: 'id',
|
||||
pattern: '^[a-z0-9]+$',
|
||||
presentable: false,
|
||||
primaryKey: true,
|
||||
required: true,
|
||||
system: true,
|
||||
type: 'text'
|
||||
},
|
||||
{
|
||||
cascadeDelete: false,
|
||||
collectionId: '_pb_users_auth_',
|
||||
hidden: false,
|
||||
id: 'relation1689669068',
|
||||
maxSelect: 1,
|
||||
minSelect: 0,
|
||||
name: 'userId',
|
||||
presentable: false,
|
||||
required: false,
|
||||
system: false,
|
||||
type: 'relation'
|
||||
},
|
||||
{
|
||||
autogeneratePattern: '',
|
||||
hidden: false,
|
||||
id: 'text724990059',
|
||||
max: 0,
|
||||
min: 0,
|
||||
name: 'title',
|
||||
pattern: '',
|
||||
presentable: false,
|
||||
primaryKey: false,
|
||||
required: false,
|
||||
system: false,
|
||||
type: 'text'
|
||||
},
|
||||
{
|
||||
autogeneratePattern: '',
|
||||
hidden: false,
|
||||
id: 'text3685223346',
|
||||
max: 0,
|
||||
min: 0,
|
||||
name: 'body',
|
||||
pattern: '',
|
||||
presentable: false,
|
||||
primaryKey: false,
|
||||
required: false,
|
||||
system: false,
|
||||
type: 'text'
|
||||
},
|
||||
{
|
||||
hidden: false,
|
||||
id: 'bool963269739',
|
||||
name: 'isRead',
|
||||
presentable: false,
|
||||
required: false,
|
||||
system: false,
|
||||
type: 'bool'
|
||||
},
|
||||
{
|
||||
hidden: false,
|
||||
id: 'autodate2990389176',
|
||||
name: 'created',
|
||||
onCreate: true,
|
||||
onUpdate: false,
|
||||
presentable: false,
|
||||
system: false,
|
||||
type: 'autodate'
|
||||
},
|
||||
{
|
||||
hidden: false,
|
||||
id: 'autodate3332085495',
|
||||
name: 'updated',
|
||||
onCreate: true,
|
||||
onUpdate: true,
|
||||
presentable: false,
|
||||
system: false,
|
||||
type: 'autodate'
|
||||
}
|
||||
],
|
||||
id: 'pbc_2301922722',
|
||||
indexes: [],
|
||||
listRule: 'userId = @request.auth.id',
|
||||
name: 'notifications',
|
||||
system: false,
|
||||
type: 'base',
|
||||
createRule: null,
|
||||
deleteRule: null,
|
||||
updateRule: 'userId = @request.auth.id',
|
||||
viewRule: 'userId = @request.auth.id'
|
||||
})
|
||||
|
||||
return app.save(collection)
|
||||
}, (app) => {
|
||||
const collection = app.findCollectionByNameOrId('pbc_2301922722')
|
||||
|
||||
return app.delete(collection)
|
||||
})
|
||||
87
pocketbase/pb_migrations/3_fcm_tokens.js
Normal file
87
pocketbase/pb_migrations/3_fcm_tokens.js
Normal file
@@ -0,0 +1,87 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((app) => {
|
||||
if (app.hasTable('fcm_tokens')) {
|
||||
return
|
||||
}
|
||||
const collection = new Collection({
|
||||
fields: [
|
||||
{
|
||||
autogeneratePattern: '[a-z0-9]{15}',
|
||||
hidden: false,
|
||||
id: 'text3208210256',
|
||||
max: 15,
|
||||
min: 15,
|
||||
name: 'id',
|
||||
pattern: '^[a-z0-9]+$',
|
||||
presentable: false,
|
||||
primaryKey: true,
|
||||
required: true,
|
||||
system: true,
|
||||
type: 'text'
|
||||
},
|
||||
{
|
||||
cascadeDelete: false,
|
||||
collectionId: '_pb_users_auth_',
|
||||
hidden: false,
|
||||
id: 'relation1689669068',
|
||||
maxSelect: 1,
|
||||
minSelect: 0,
|
||||
name: 'userId',
|
||||
presentable: false,
|
||||
required: false,
|
||||
system: false,
|
||||
type: 'relation'
|
||||
},
|
||||
{
|
||||
autogeneratePattern: '',
|
||||
hidden: false,
|
||||
id: 'text1597481275',
|
||||
max: 0,
|
||||
min: 0,
|
||||
name: 'token',
|
||||
pattern: '',
|
||||
presentable: false,
|
||||
primaryKey: false,
|
||||
required: false,
|
||||
system: false,
|
||||
type: 'text'
|
||||
},
|
||||
{
|
||||
hidden: false,
|
||||
id: 'autodate2990389176',
|
||||
name: 'created',
|
||||
onCreate: true,
|
||||
onUpdate: false,
|
||||
presentable: false,
|
||||
system: false,
|
||||
type: 'autodate'
|
||||
},
|
||||
{
|
||||
hidden: false,
|
||||
id: 'autodate3332085495',
|
||||
name: 'updated',
|
||||
onCreate: true,
|
||||
onUpdate: true,
|
||||
presentable: false,
|
||||
system: false,
|
||||
type: 'autodate'
|
||||
}
|
||||
],
|
||||
id: 'pbc_566627753',
|
||||
indexes: [],
|
||||
listRule: 'userId = @request.auth.id',
|
||||
name: 'fcm_tokens',
|
||||
system: false,
|
||||
type: 'base',
|
||||
deleteRule: null,
|
||||
updateRule: null,
|
||||
viewRule: 'userId = @request.auth.id',
|
||||
createRule: 'userId = @request.auth.id'
|
||||
})
|
||||
|
||||
return app.save(collection)
|
||||
}, (app) => {
|
||||
const collection = app.findCollectionByNameOrId('pbc_566627753')
|
||||
|
||||
return app.delete(collection)
|
||||
})
|
||||
85
pocketbase/pb_migrations/4_counters.js
Normal file
85
pocketbase/pb_migrations/4_counters.js
Normal file
@@ -0,0 +1,85 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((app) => {
|
||||
if (app.hasTable('counters')) {
|
||||
return
|
||||
}
|
||||
const collection = new Collection({
|
||||
fields: [
|
||||
{
|
||||
autogeneratePattern: '[a-z0-9]{15}',
|
||||
hidden: false,
|
||||
id: 'text3208210256',
|
||||
max: 15,
|
||||
min: 15,
|
||||
name: 'id',
|
||||
pattern: '^[a-z0-9]+$',
|
||||
presentable: false,
|
||||
primaryKey: true,
|
||||
required: true,
|
||||
system: true,
|
||||
type: 'text'
|
||||
},
|
||||
{
|
||||
cascadeDelete: false,
|
||||
collectionId: '_pb_users_auth_',
|
||||
hidden: false,
|
||||
id: 'relation1689669068',
|
||||
maxSelect: 1,
|
||||
minSelect: 0,
|
||||
name: 'userId',
|
||||
presentable: false,
|
||||
required: false,
|
||||
system: false,
|
||||
type: 'relation'
|
||||
},
|
||||
{
|
||||
hidden: false,
|
||||
id: 'number2245608546',
|
||||
max: null,
|
||||
min: null,
|
||||
name: 'count',
|
||||
onlyInt: false,
|
||||
presentable: false,
|
||||
required: false,
|
||||
system: false,
|
||||
type: 'number'
|
||||
},
|
||||
{
|
||||
hidden: false,
|
||||
id: 'autodate2990389176',
|
||||
name: 'created',
|
||||
onCreate: true,
|
||||
onUpdate: false,
|
||||
presentable: false,
|
||||
system: false,
|
||||
type: 'autodate'
|
||||
},
|
||||
{
|
||||
hidden: false,
|
||||
id: 'autodate3332085495',
|
||||
name: 'updated',
|
||||
onCreate: true,
|
||||
onUpdate: true,
|
||||
presentable: false,
|
||||
system: false,
|
||||
type: 'autodate'
|
||||
}
|
||||
],
|
||||
id: 'pbc_90131592',
|
||||
indexes: [],
|
||||
listRule: 'userId = @request.auth.id',
|
||||
name: 'counters',
|
||||
system: false,
|
||||
type: 'base',
|
||||
deleteRule: 'userId = @request.auth.id',
|
||||
updateRule: 'userId = @request.auth.id',
|
||||
viewRule: 'userId = @request.auth.id',
|
||||
createRule: 'userId = @request.auth.id'
|
||||
})
|
||||
|
||||
return app.save(collection)
|
||||
}, (app) => {
|
||||
const collection = app.findCollectionByNameOrId('pbc_90131592')
|
||||
|
||||
return app.delete(collection)
|
||||
})
|
||||
6
pocketbase/sidecar/deno.json
Normal file
6
pocketbase/sidecar/deno.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"tasks": {
|
||||
"dev": "deno run --watch --allow-net --allow-env main.ts",
|
||||
"start": "deno run --allow-net --allow-env main.ts"
|
||||
}
|
||||
}
|
||||
104
pocketbase/sidecar/fcmAuth.ts
Normal file
104
pocketbase/sidecar/fcmAuth.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
interface ServiceAccount {
|
||||
client_email: string
|
||||
private_key: string
|
||||
project_id: string
|
||||
}
|
||||
|
||||
interface CachedToken {
|
||||
token: string
|
||||
expiresAt: number
|
||||
}
|
||||
|
||||
let serviceAccount: ServiceAccount | null = null
|
||||
let privateKey: CryptoKey | null = null
|
||||
let cachedToken: CachedToken | null = null
|
||||
|
||||
export function getServiceAccount(): ServiceAccount {
|
||||
if (serviceAccount) return serviceAccount
|
||||
|
||||
const credentialsJson = Deno.env.get('GOOGLE_CREDENTIALS_JSON')
|
||||
if (!credentialsJson?.trim()) {
|
||||
throw new Error('GOOGLE_CREDENTIALS_JSON environment variable is required')
|
||||
}
|
||||
|
||||
serviceAccount = JSON.parse(credentialsJson)
|
||||
return serviceAccount!
|
||||
}
|
||||
|
||||
async function getPrivateKey(): Promise<CryptoKey> {
|
||||
if (privateKey) return privateKey
|
||||
|
||||
const pem = getServiceAccount().private_key
|
||||
const pemContents = pem
|
||||
.replace(/-----BEGIN PRIVATE KEY-----/, '')
|
||||
.replace(/-----END PRIVATE KEY-----/, '')
|
||||
.replace(/\s/g, '')
|
||||
|
||||
const der = Uint8Array.from(atob(pemContents), c => c.charCodeAt(0))
|
||||
|
||||
privateKey = await crypto.subtle.importKey(
|
||||
'pkcs8',
|
||||
der.buffer,
|
||||
{ name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign']
|
||||
)
|
||||
|
||||
return privateKey
|
||||
}
|
||||
|
||||
function b64url(data: ArrayBuffer | string): string {
|
||||
const str = typeof data === 'string'
|
||||
? btoa(data)
|
||||
: btoa(String.fromCharCode(...new Uint8Array(data)))
|
||||
return str.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
}
|
||||
|
||||
export async function getAccessToken(): Promise<string> {
|
||||
const now = Date.now()
|
||||
|
||||
if (cachedToken && cachedToken.expiresAt > now + 60_000) {
|
||||
return cachedToken.token
|
||||
}
|
||||
|
||||
const sa = getServiceAccount()
|
||||
const iat = Math.floor(now / 1000)
|
||||
const exp = iat + 3600
|
||||
|
||||
const header = b64url(JSON.stringify({ alg: 'RS256', typ: 'JWT' }))
|
||||
const payload = b64url(JSON.stringify({
|
||||
iss: sa.client_email,
|
||||
scope: 'https://www.googleapis.com/auth/firebase.messaging',
|
||||
aud: 'https://oauth2.googleapis.com/token',
|
||||
exp,
|
||||
iat
|
||||
}))
|
||||
|
||||
const signingInput = `${header}.${payload}`
|
||||
const key = await getPrivateKey()
|
||||
const signature = await crypto.subtle.sign(
|
||||
'RSASSA-PKCS1-v1_5',
|
||||
key,
|
||||
new TextEncoder().encode(signingInput)
|
||||
)
|
||||
|
||||
const jwt = `${signingInput}.${b64url(signature)}`
|
||||
|
||||
const res = await fetch('https://oauth2.googleapis.com/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||
assertion: jwt
|
||||
})
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to get FCM access token: ${await res.text()}`)
|
||||
}
|
||||
|
||||
const { access_token, expires_in } = await res.json()
|
||||
cachedToken = { token: access_token, expiresAt: now + expires_in * 1000 }
|
||||
|
||||
return access_token
|
||||
}
|
||||
74
pocketbase/sidecar/fcmPush.ts
Normal file
74
pocketbase/sidecar/fcmPush.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { getAccessToken, getServiceAccount } from './fcmAuth.ts'
|
||||
|
||||
export async function handleFcmPush(req: Request): Promise<Response> {
|
||||
const secret = Deno.env.get('SIDECAR_SECRET')
|
||||
if (secret && req.headers.get('x-sidecar-secret') !== secret) {
|
||||
return new Response('Unauthorized', { status: 401 })
|
||||
}
|
||||
|
||||
let body: {
|
||||
tokens: string[]
|
||||
title: string
|
||||
body: string
|
||||
data?: Record<string, string>
|
||||
}
|
||||
|
||||
try {
|
||||
body = await req.json()
|
||||
} catch {
|
||||
return new Response('Invalid JSON', { status: 400 })
|
||||
}
|
||||
|
||||
const { tokens, title, body: messageBody, data } = body
|
||||
|
||||
if (!tokens?.length) {
|
||||
return new Response('No tokens provided', { status: 400 })
|
||||
}
|
||||
|
||||
const { project_id } = getServiceAccount()
|
||||
const accessToken = await getAccessToken()
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
tokens.map(token =>
|
||||
fetch(
|
||||
`https://fcm.googleapis.com/v1/projects/${project_id}/messages:send`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message: {
|
||||
token,
|
||||
notification: { title, body: messageBody },
|
||||
...(data ? { data } : {})
|
||||
}
|
||||
})
|
||||
}
|
||||
).then(async (r) => {
|
||||
if (!r.ok) {
|
||||
const err = await r.json()
|
||||
throw new Error(err.error?.message ?? r.statusText)
|
||||
}
|
||||
return r.json()
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
let successCount = 0
|
||||
let failureCount = 0
|
||||
|
||||
results.forEach((result, i) => {
|
||||
if (result.status === 'fulfilled') {
|
||||
successCount++
|
||||
} else {
|
||||
failureCount++
|
||||
console.error(`FCM failed for token ${tokens[i]}:`, result.reason)
|
||||
}
|
||||
})
|
||||
|
||||
console.log(`FCM: ${successCount} sent, ${failureCount} failed`)
|
||||
|
||||
return Response.json({ successCount, failureCount })
|
||||
}
|
||||
13
pocketbase/sidecar/main.ts
Normal file
13
pocketbase/sidecar/main.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { handleFcmPush } from './fcmPush.ts'
|
||||
|
||||
const port = Number(Deno.env.get('SIDECAR_PORT') ?? '8091')
|
||||
|
||||
Deno.serve({ port }, (req: Request) => {
|
||||
const url = new URL(req.url)
|
||||
|
||||
if (req.method === 'POST' && url.pathname === '/notify') {
|
||||
return handleFcmPush(req)
|
||||
}
|
||||
|
||||
return new Response('Not Found', { status: 404 })
|
||||
})
|
||||
8
pocketbase/start.sh
Normal file
8
pocketbase/start.sh
Normal file
@@ -0,0 +1,8 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
# Start PocketBase in background
|
||||
./pocketbase serve --http=0.0.0.0:8090 &
|
||||
|
||||
# Start Deno sidecar as main process (keeps container alive)
|
||||
cd sidecar && deno task start
|
||||
Reference in New Issue
Block a user