Initial commit

This commit is contained in:
2026-04-17 23:26:01 +00:00
commit 2ea4ca5d52
409 changed files with 63459 additions and 0 deletions

118
pocketbase/CLAUDE.md Normal file
View 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
View 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"]

View 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:

View 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')

View 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."
}
}

View 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."
}
}

View 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')
})

View 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())

View 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')

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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 }

View 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)
})

View 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)
})

View 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)
})

View 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)
})

View 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)
})

View 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"
}
}

View 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
}

View 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 })
}

View 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
View 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