mirror of
https://github.com/pockethost/pockethost.git
synced 2026-03-11 11:14:56 +00:00
Merge branch 'master' of github.com:benallfree/pockethost
This commit is contained in:
@@ -8,4 +8,5 @@ build
|
||||
*.njk
|
||||
_site
|
||||
forks
|
||||
src/mothership-app/migrations
|
||||
src/mothership-app/pb_hooks/src/versions.pb.js
|
||||
@@ -6,7 +6,10 @@
|
||||
<div class="flex items-center justify-between mb-4 flex-wrap gap-2">
|
||||
<h3 class="text-xl font-bold"><slot /></h3>
|
||||
|
||||
<a href={documentation} class="btn btn-sm btn-outline btn-primary"
|
||||
<a
|
||||
href={documentation}
|
||||
class="btn btn-sm btn-outline btn-primary"
|
||||
target="_blank"
|
||||
>Full documentation <i
|
||||
class="fa-regular fa-arrow-up-right-from-square opacity-50 text-sm"
|
||||
></i></a
|
||||
|
||||
@@ -30,14 +30,21 @@ export const PUBLIC_MOTHERSHIP_URL =
|
||||
export const PUBLIC_DEBUG = boolean(import.meta.env.PUBLIC_DEBUG || 'true')
|
||||
|
||||
// Derived
|
||||
export const LANDER_URL = (path = '') => `${PUBLIC_BLOG_URL}/${path}`
|
||||
export const BLOG_URL = (path = '') => LANDER_URL(`blog/${path}`)
|
||||
export const DOCS_URL = (path = '') => LANDER_URL(`docs/${path}`)
|
||||
export const APP_URL = (path = '') => `${PUBLIC_APP_URL}/${path}`
|
||||
export const INSTANCE_URL = (name: string, path = '') =>
|
||||
`${PUBLIC_HTTP_PROTOCOL}//${name}.${PUBLIC_APEX_DOMAIN}/${path}`
|
||||
export const INSTANCE_ADMIN_URL = (name: string, path = '') =>
|
||||
INSTANCE_URL(name, `_/${path}`)
|
||||
const mkPath = (...paths: string[]) =>
|
||||
paths
|
||||
.flatMap((path) => path.split('/'))
|
||||
.filter((v) => !!v)
|
||||
.join('/')
|
||||
export const LANDER_URL = (...paths: string[]) =>
|
||||
`${PUBLIC_BLOG_URL}/${mkPath(...paths)}`
|
||||
export const BLOG_URL = (...paths: string[]) => LANDER_URL(`blog`, ...paths)
|
||||
export const DOCS_URL = (...paths: string[]) => LANDER_URL(`docs`, ...paths)
|
||||
export const APP_URL = (...paths: string[]) =>
|
||||
`${PUBLIC_APP_URL}/${mkPath(...paths)}`
|
||||
export const INSTANCE_URL = (name: string, ...paths: string[]) =>
|
||||
`${PUBLIC_HTTP_PROTOCOL}//${name}.${PUBLIC_APEX_DOMAIN}/${mkPath(...paths)}`
|
||||
export const INSTANCE_ADMIN_URL = (name: string, ...paths: string[]) =>
|
||||
INSTANCE_URL(name, `_/${mkPath(...paths)}`)
|
||||
export const FTP_URL = (email: string) =>
|
||||
`ftp://${encodeURIComponent(email)}@ftp.sfo-1.${PUBLIC_APEX_DOMAIN}`
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
UpdateInstanceResult,
|
||||
assertExists,
|
||||
createRestHelper,
|
||||
createWatchHelper,
|
||||
type CreateInstancePayload,
|
||||
type CreateInstanceResult,
|
||||
type InstanceFields,
|
||||
@@ -23,8 +22,6 @@ import PocketBase, {
|
||||
BaseAuthStore,
|
||||
ClientResponseError,
|
||||
type AuthModel,
|
||||
type RecordSubscription,
|
||||
type UnsubscribeFunc,
|
||||
} from 'pocketbase'
|
||||
|
||||
export type AuthChangeHandler = (user: BaseAuthStore) => void
|
||||
@@ -98,20 +95,18 @@ export const createPocketbaseClient = (config: PocketbaseClientConfig) => {
|
||||
|
||||
const refreshAuthToken = () => client.collection('users').authRefresh()
|
||||
|
||||
const watchHelper = createWatchHelper({ client })
|
||||
const { watchById, watchAllById } = watchHelper
|
||||
const restMixin = createRestHelper({ client, watchHelper })
|
||||
const restMixin = createRestHelper({ client })
|
||||
const { mkRest } = restMixin
|
||||
|
||||
const createInstance = mkRest<CreateInstancePayload, CreateInstanceResult>(
|
||||
RestCommands.Instance,
|
||||
RestMethods.Create,
|
||||
RestMethods.Post,
|
||||
CreateInstancePayloadSchema,
|
||||
)
|
||||
|
||||
const updateInstance = mkRest<UpdateInstancePayload, UpdateInstanceResult>(
|
||||
RestCommands.Instance,
|
||||
RestMethods.Update,
|
||||
RestMethods.Put,
|
||||
UpdateInstancePayloadSchema,
|
||||
)
|
||||
|
||||
@@ -120,11 +115,6 @@ export const createPocketbaseClient = (config: PocketbaseClientConfig) => {
|
||||
): Promise<InstanceFields | undefined> =>
|
||||
client.collection('instances').getOne<InstanceFields>(id)
|
||||
|
||||
const watchInstanceById = async (
|
||||
id: InstanceId,
|
||||
cb: (data: RecordSubscription<InstanceFields>) => void,
|
||||
): Promise<UnsubscribeFunc> => watchById('instances', id, cb)
|
||||
|
||||
const getAllInstancesById = async () =>
|
||||
(await client.collection('instances').getFullList()).reduce(
|
||||
(c, v) => {
|
||||
@@ -287,7 +277,6 @@ export const createPocketbaseClient = (config: PocketbaseClientConfig) => {
|
||||
onAuthChange,
|
||||
isLoggedIn,
|
||||
user,
|
||||
watchInstanceById,
|
||||
getAllInstancesById,
|
||||
resendVerificationEmail,
|
||||
updateInstance,
|
||||
|
||||
@@ -27,9 +27,7 @@
|
||||
</UserLoggedIn>
|
||||
|
||||
<div class="lg:p-4 lg:pt-0 xl:pt-4 min-h-screen grow">
|
||||
<div
|
||||
class="bg-base-300 border-base-300 border-[16px] xl:h-[calc(100vh-32px)] lg:p-4 rounded-2xl xl:overflow-hidden xl:overflow-y-auto"
|
||||
>
|
||||
<div class="bg-base-300 border-base-300 border-[16px] lg:p-4 rounded-2xl">
|
||||
<VerifyAccountBar />
|
||||
|
||||
<slot />
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import { INSTANCE_ADMIN_URL } from '$src/env'
|
||||
import { slide } from 'svelte/transition'
|
||||
import Code from './Code.svelte'
|
||||
import AdminSync from './Danger/AdminSync.svelte'
|
||||
import DangerZoneTitle from './Danger/DangerZoneTitle.svelte'
|
||||
import Maintenance from './Danger/Maintenance.svelte'
|
||||
import RenameInstance from './Danger/RenameInstance.svelte'
|
||||
@@ -11,7 +12,6 @@
|
||||
import Ftp from './Ftpx.svelte'
|
||||
import Logging from './Logging.svelte'
|
||||
import Secrets from './Secrets/Secrets.svelte'
|
||||
import UsageChart from './UsageChart.svelte'
|
||||
import { instance } from './store'
|
||||
|
||||
const { instanceId } = $page.params
|
||||
@@ -67,16 +67,13 @@
|
||||
{/if}
|
||||
|
||||
<div class="grid lg:grid-cols-2 grid-cols-1 gap-4 mb-4">
|
||||
<UsageChart />
|
||||
|
||||
<Code />
|
||||
<Logging />
|
||||
</div>
|
||||
|
||||
<div class="grid lg:grid-cols-3 grid-cols-1 gap-4 mb-16">
|
||||
<div class="grid lg:grid-cols-2 grid-cols-1 gap-4 mb-16">
|
||||
<Ftp />
|
||||
|
||||
<Logging />
|
||||
|
||||
<Secrets />
|
||||
</div>
|
||||
|
||||
@@ -88,4 +85,6 @@
|
||||
<Maintenance />
|
||||
|
||||
<VersionChange />
|
||||
|
||||
<AdminSync />
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import CodeSample from '$components/CodeSample.svelte'
|
||||
import Card from '$components/cards/Card.svelte'
|
||||
import CardHeader from '$components/cards/CardHeader.svelte'
|
||||
import { DOCS_URL, INSTANCE_URL } from '$src/env'
|
||||
import { INSTANCE_URL } from '$src/env'
|
||||
import { instance } from './store'
|
||||
|
||||
let installSnippet = `npm i pocketbase`
|
||||
@@ -39,8 +39,10 @@
|
||||
<p>Additional Resources:</p>
|
||||
<ul class="list-disc pl-4">
|
||||
<li>
|
||||
<a href={DOCS_URL(`/api-records/`)} target="_blank" class="link"
|
||||
>PocketBase Web APIs</a
|
||||
<a
|
||||
href={`https://pocketbase.io/docs/api-records/`}
|
||||
target="_blank"
|
||||
class="link">PocketBase Web APIs</a
|
||||
>
|
||||
</li>
|
||||
<li>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<script lang="ts">
|
||||
import Card from '$components/cards/Card.svelte'
|
||||
import CardHeader from '$components/cards/CardHeader.svelte'
|
||||
import { DOCS_URL } from '$src/env'
|
||||
import { client } from '$src/pocketbase-client'
|
||||
import { instance } from '../store'
|
||||
|
||||
const { updateInstance } = client()
|
||||
|
||||
$: ({ id, syncAdmin } = $instance)
|
||||
|
||||
const handleChange = (e: Event) => {
|
||||
const target = e.target as HTMLInputElement
|
||||
const isChecked = target.checked
|
||||
|
||||
// Update the database with the new value
|
||||
updateInstance({ id, fields: { syncAdmin: isChecked } }).then(() => 'saved')
|
||||
}
|
||||
</script>
|
||||
|
||||
<Card>
|
||||
<CardHeader documentation={DOCS_URL(`/usage/admin-sync`)}>
|
||||
Admin Sync
|
||||
</CardHeader>
|
||||
|
||||
<p class="mb-8">
|
||||
Your instance will have an admin login that matches your pockethost.io
|
||||
login.
|
||||
</p>
|
||||
|
||||
<label class="label cursor-pointer justify-center gap-4">
|
||||
<span class="label-text">Admin Sync</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="toggle toggle-warning"
|
||||
checked={!!syncAdmin}
|
||||
on:change={handleChange}
|
||||
/>
|
||||
</label>
|
||||
</Card>
|
||||
@@ -14,7 +14,7 @@
|
||||
const isChecked = target.checked
|
||||
|
||||
// Update the database with the new value
|
||||
updateInstance({ instanceId: id, fields: { maintenance: isChecked } }).then(
|
||||
updateInstance({ id, fields: { maintenance: isChecked } }).then(
|
||||
() => 'saved',
|
||||
)
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
// If they select yes, then update the version in pocketbase
|
||||
if (confirmVersionChange) {
|
||||
updateInstance({
|
||||
instanceId: id,
|
||||
id,
|
||||
fields: {
|
||||
subdomain: instanceNameValidation,
|
||||
},
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
errorMessage = ''
|
||||
client()
|
||||
.updateInstance({
|
||||
instanceId: id,
|
||||
id,
|
||||
fields: { version: selectedVersion },
|
||||
})
|
||||
.then(() => {
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
// Save to the database
|
||||
items.upsert({ name: secretKey, value: secretValue })
|
||||
await client().updateInstance({
|
||||
instanceId: $instance.id,
|
||||
id: $instance.id,
|
||||
fields: {
|
||||
secrets: reduce(
|
||||
$items,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { slide } from 'svelte/transition'
|
||||
import { handleUnauthenticatedPasswordReset } from '$util/database'
|
||||
import { slide } from 'svelte/transition'
|
||||
|
||||
let email: string = ''
|
||||
let formError: string = ''
|
||||
@@ -72,7 +72,7 @@
|
||||
class="btn btn-primary w-100"
|
||||
disabled={isFormButtonDisabled}
|
||||
>
|
||||
Send Verification Email <i class="bi bi-arrow-right-short" />
|
||||
Reset Password <i class="bi bi-arrow-right-short" />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
19
frontends/lander/content/docs/usage/admin-sync.md
Normal file
19
frontends/lander/content/docs/usage/admin-sync.md
Normal file
@@ -0,0 +1,19 @@
|
||||
---
|
||||
title: Admin Sync
|
||||
category: usage
|
||||
description: Admin Sync keeps your instance secure by syncing your pockethost.io account with the admin account of your instance.
|
||||
---
|
||||
|
||||
Admin Sync will make sure your instance always has an admin account that matches the login credentials of your pockethost.io account.
|
||||
|
||||
## Admin Sync `Enabled` (default)
|
||||
|
||||
When Admin Sync is enabled, your pockethost.io account credentials will be copied as an admin login to your instance before the instance is launched.
|
||||
|
||||
If you change your pockethost.io credentials while an instance is running, it will not be updated until the next time it is launched. To force your instance to shut down, place it in Maintenance Mode and then wait for the status to show as `idle`.
|
||||
|
||||
Admin Sync is enabeld by default. When an instance is first created, it will have an admin account matching your pockethost.io login. This is a security measure to prevent someone from creating the initial admin account before you've had a chance.
|
||||
|
||||
## Admin Sync `Disabled`
|
||||
|
||||
Your pockethost.io credentails will not be copied to your instance on future invocations.
|
||||
@@ -11,12 +11,9 @@
|
||||
"lint:fix": "prettier -w \"./**/*.{ts,js,cjs,svelte,json}\"",
|
||||
"download-versions": "tsx ./src/cli/download.ts",
|
||||
"build": "concurrently 'pnpm:build:*'",
|
||||
"build-pockethost": "concurrently 'pnpm:build:pockethost:*'",
|
||||
"build-frontends": "concurrently 'pnpm:build:frontend:*'",
|
||||
"build:frontend:dashboard": "cd frontends/dashboard && pnpm build",
|
||||
"build:frontend:lander": "cd frontends/lander && pnpm build",
|
||||
"build:pockethost:docker": "cd src/services/PocketBaseService && docker build -t benallfree/pockethost-instance .",
|
||||
"push:docker": "docker tag benallfree/pockethost-instance benallfree/pockethost-instance:latest && docker push benallfree/pockethost-instance:latest",
|
||||
"dev": "concurrently 'pnpm:dev:*'",
|
||||
"dev-daemon": "concurrently 'pnpm:dev:daemon:*'",
|
||||
"dev:lander": "cd frontends/lander && pnpm start",
|
||||
|
||||
@@ -205,10 +205,12 @@ export const INSTANCE_APP_MIGRATIONS_DIR = () =>
|
||||
/**
|
||||
* Helpers
|
||||
*/
|
||||
export const MOTHERSHIP_DATA_ROOT = () =>
|
||||
join(settings().DATA_ROOT, settings().MOTHERSHIP_NAME)
|
||||
export const mkAppUrl = (path = '') => `${settings().APP_URL}${path}`
|
||||
export const mkBlogUrl = (path = '') => `${settings().BLOG_URL}${path}`
|
||||
export const MOTHERSHIP_DATA_ROOT = () => INSTANCE_DATA_ROOT(MOTHERSHIP_NAME())
|
||||
export const INSTANCE_DATA_ROOT = (id: InstanceId) => join(DATA_ROOT(), id)
|
||||
export const INSTANCE_DATA_DB = (id: InstanceId) =>
|
||||
join(DATA_ROOT(), id, `pb_data`, `data.db`)
|
||||
export const mkAppUrl = (path = '') => `${APP_URL()}${path}`
|
||||
export const mkBlogUrl = (path = '') => `${BLOG_URL()}${path}`
|
||||
export const mkDocUrl = (path = '') => mkBlogUrl(join('/docs', path))
|
||||
export const mkEdgeSubdomain = (subdomain: string) =>
|
||||
`${settings().HTTP_PROTOCOL}//${subdomain}.${settings().EDGE_APEX_DOMAIN}`
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
migrate(
|
||||
(db) => {
|
||||
try {
|
||||
const APP_DEFAULTS = {
|
||||
appName: 'Acme',
|
||||
appUrl: 'http://localhost:8090',
|
||||
senderName: 'Support',
|
||||
senderAddress: 'support@example.com',
|
||||
}
|
||||
|
||||
const { PH_APP_NAME, PH_INSTANCE_URL, PH_APEX_DOMAIN } = process.env
|
||||
|
||||
const dao = new Dao(db)
|
||||
|
||||
const settings = dao.findSettings()
|
||||
if (!settings) {
|
||||
throw new Error(`Expected settings here`)
|
||||
}
|
||||
|
||||
const fix = (field, newValue) => {
|
||||
if (!newValue || settings.meta[field] !== APP_DEFAULTS[field]) return
|
||||
settings.meta[field] = newValue
|
||||
}
|
||||
const { hostname } = new URL(PH_INSTANCE_URL)
|
||||
fix(`appName`, PH_APP_NAME)
|
||||
fix(`appUrl`, PH_INSTANCE_URL)
|
||||
fix(`senderName`, PH_APP_NAME)
|
||||
fix(`senderAddress`, `${PH_APP_NAME}@${hostname}`)
|
||||
|
||||
dao.saveSettings(settings)
|
||||
} catch (e) {
|
||||
console.error(`***error applying defaults: ${e}`)
|
||||
}
|
||||
},
|
||||
(db) => {
|
||||
// add down queries...
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,27 @@
|
||||
/// <reference path="../pb_data/types.d.ts" />
|
||||
migrate((db) => {
|
||||
const dao = new Dao(db)
|
||||
const collection = dao.findCollectionByNameOrId("etae8tuiaxl6xfv")
|
||||
|
||||
// add
|
||||
collection.schema.addField(new SchemaField({
|
||||
"system": false,
|
||||
"id": "hkt4q8yk",
|
||||
"name": "syncAdmin",
|
||||
"type": "bool",
|
||||
"required": false,
|
||||
"presentable": false,
|
||||
"unique": false,
|
||||
"options": {}
|
||||
}))
|
||||
|
||||
return dao.saveCollection(collection)
|
||||
}, (db) => {
|
||||
const dao = new Dao(db)
|
||||
const collection = dao.findCollectionByNameOrId("etae8tuiaxl6xfv")
|
||||
|
||||
// remove
|
||||
collection.schema.removeField("hkt4q8yk")
|
||||
|
||||
return dao.saveCollection(collection)
|
||||
})
|
||||
@@ -48,6 +48,7 @@ routerAdd(
|
||||
record.set('subdomain', subdomain)
|
||||
record.set('status', 'idle')
|
||||
record.set('version', versions[0])
|
||||
record.set('syncAdmin', true)
|
||||
|
||||
const form = new RecordUpsertForm($app, record)
|
||||
form.submit()
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
/*
|
||||
{
|
||||
"instanceId": "kz4ngg77eaw1ho0",
|
||||
"id": "kz4ngg77eaw1ho0",
|
||||
"fields": {
|
||||
"maintenance": true
|
||||
"name": '',
|
||||
@@ -12,36 +12,44 @@
|
||||
*/
|
||||
routerAdd(
|
||||
'PUT',
|
||||
'/api/instance',
|
||||
'/api/instance/:id',
|
||||
(c) => {
|
||||
console.log(`***TOP OF PUt`)
|
||||
console.log(`***TOP OF PUT`)
|
||||
let data = new DynamicModel({
|
||||
// describe the shape of the fields to read (used also as initial values)
|
||||
instanceId: '',
|
||||
id: '',
|
||||
fields: {
|
||||
maintenance: null,
|
||||
name: null,
|
||||
version: null,
|
||||
secrets: null,
|
||||
syncAdmin: null,
|
||||
},
|
||||
})
|
||||
|
||||
c.bind(data)
|
||||
console.log(`***After bind`)
|
||||
|
||||
// This is necessary for destructuring to work correctly
|
||||
data = JSON.parse(JSON.stringify(data))
|
||||
|
||||
const id = c.pathParam('id')
|
||||
const {
|
||||
instanceId,
|
||||
fields: { maintenance, name, version, secrets },
|
||||
fields: { maintenance, name, version, secrets, syncAdmin },
|
||||
} = data
|
||||
|
||||
console.log(
|
||||
`***vars`,
|
||||
JSON.stringify({ instanceId, maintenance, name, version, secrets }),
|
||||
JSON.stringify({
|
||||
id,
|
||||
maintenance,
|
||||
name,
|
||||
version,
|
||||
secrets,
|
||||
syncAdmin,
|
||||
}),
|
||||
)
|
||||
|
||||
const record = $app.dao().findRecordById('instances', instanceId)
|
||||
const record = $app.dao().findRecordById('instances', id)
|
||||
const authRecord = c.get('authRecord') // empty if not authenticated as regular auth record
|
||||
console.log(`***authRecord`, JSON.stringify(authRecord))
|
||||
|
||||
@@ -53,22 +61,24 @@ routerAdd(
|
||||
}
|
||||
|
||||
function cleanObject(obj) {
|
||||
return Object.entries(obj).reduce((acc, [key, value]) => {
|
||||
console.log(`***original`, JSON.stringify(obj))
|
||||
const sanitized = Object.entries(obj).reduce((acc, [key, value]) => {
|
||||
if (value !== null && value !== undefined) {
|
||||
acc[key] = value
|
||||
}
|
||||
return acc
|
||||
}, {})
|
||||
console.log(`***sanitized`, JSON.stringify(sanitized))
|
||||
return sanitized
|
||||
}
|
||||
|
||||
console.log(`***original`, JSON.stringify(data))
|
||||
const sanitized = cleanObject({
|
||||
subdomain: name,
|
||||
version,
|
||||
maintenance,
|
||||
secrets,
|
||||
syncAdmin,
|
||||
})
|
||||
console.log(`***sanitized`, JSON.stringify(sanitized))
|
||||
|
||||
const form = new RecordUpsertForm($app, record)
|
||||
form.loadData(sanitized)
|
||||
|
||||
29
src/mothership-app/pb_hooks/src/user-token.pb.js
Normal file
29
src/mothership-app/pb_hooks/src/user-token.pb.js
Normal file
@@ -0,0 +1,29 @@
|
||||
/// <reference path="../types/types.d.ts" />
|
||||
|
||||
/*
|
||||
{
|
||||
"id": "user-id"
|
||||
}
|
||||
*/
|
||||
routerAdd(
|
||||
'GET',
|
||||
'/api/userToken/:id',
|
||||
(c) => {
|
||||
const id = c.pathParam('id')
|
||||
|
||||
console.log(`***vars`, JSON.stringify({ id }))
|
||||
|
||||
if (!id) {
|
||||
throw new BadRequestError(`User ID is required.`)
|
||||
}
|
||||
|
||||
const rec = $app.dao().findRecordById('users', id)
|
||||
const tokenKey = rec.getString('tokenKey')
|
||||
const passwordHash = rec.getString('passwordHash')
|
||||
const email = rec.getString(`email`)
|
||||
console.log(`***tokenKey`, tokenKey)
|
||||
|
||||
return c.json(200, { email, passwordHash, tokenKey })
|
||||
},
|
||||
$apis.requireAdminAuth(),
|
||||
)
|
||||
@@ -2,8 +2,10 @@ import {
|
||||
DAEMON_PB_IDLE_TTL,
|
||||
INSTANCE_APP_HOOK_DIR,
|
||||
INSTANCE_APP_MIGRATIONS_DIR,
|
||||
INSTANCE_DATA_DB,
|
||||
mkAppUrl,
|
||||
mkDocUrl,
|
||||
mkEdgeUrl,
|
||||
MOTHERSHIP_NAME,
|
||||
} from '$constants'
|
||||
import {
|
||||
@@ -12,6 +14,7 @@ import {
|
||||
PocketbaseService,
|
||||
PortService,
|
||||
proxyService,
|
||||
SqliteService,
|
||||
} from '$services'
|
||||
import {
|
||||
assertTruthy,
|
||||
@@ -243,10 +246,31 @@ export const instanceService = mkSingleton(
|
||||
})
|
||||
healthyGuard()
|
||||
|
||||
/**
|
||||
* Sync admin account
|
||||
*/
|
||||
if (instance.syncAdmin) {
|
||||
const id = instance.uid
|
||||
dbg(`Fetching token info for uid ${id}`)
|
||||
const { email, tokenKey, passwordHash } =
|
||||
await client.getUserTokenInfo({ id })
|
||||
dbg(`Token info is`, { email, tokenKey, passwordHash })
|
||||
const sqliteService = await SqliteService()
|
||||
const db = await sqliteService.getDatabase(
|
||||
INSTANCE_DATA_DB(instance.id),
|
||||
)
|
||||
await db(`_admins`)
|
||||
.insert({ id, email, tokenKey, passwordHash })
|
||||
.onConflict('id')
|
||||
.merge({ email, tokenKey, passwordHash })
|
||||
.catch((e) => {
|
||||
userInstanceLogger.error(`Failed to sync admin account: ${e}`)
|
||||
})
|
||||
}
|
||||
|
||||
/*
|
||||
Spawn the child process
|
||||
*/
|
||||
|
||||
const childProcess = await (async () => {
|
||||
try {
|
||||
const cp = await pbService.spawn({
|
||||
@@ -254,17 +278,23 @@ export const instanceService = mkSingleton(
|
||||
name: instance.subdomain,
|
||||
slug: instance.id,
|
||||
port: newPort,
|
||||
env: instance.secrets || {},
|
||||
extraBinds: flatten([
|
||||
globSync(join(INSTANCE_APP_MIGRATIONS_DIR(), '*.js')).map(
|
||||
(file) =>
|
||||
`${file}:/home/pocketbase/pb_migrations/${basename(file)}`,
|
||||
`${file}:/home/pocketbase/pb_migrations/${basename(
|
||||
file,
|
||||
)}:ro`,
|
||||
),
|
||||
globSync(join(INSTANCE_APP_HOOK_DIR(), '*.js')).map(
|
||||
(file) =>
|
||||
`${file}:/home/pocketbase/pb_hooks/${basename(file)})`,
|
||||
`${file}:/home/pocketbase/pb_hooks/${basename(file)}:ro`,
|
||||
),
|
||||
]),
|
||||
env: {
|
||||
...instance.secrets,
|
||||
PH_APP_NAME: instance.subdomain,
|
||||
PH_INSTANCE_URL: mkEdgeUrl(instance.subdomain),
|
||||
},
|
||||
version,
|
||||
})
|
||||
return cp
|
||||
@@ -348,7 +378,10 @@ export const instanceService = mkSingleton(
|
||||
healthyGuard()
|
||||
await updateInstanceStatus(instance.id, InstanceStatus.Running)
|
||||
})().catch((e) => {
|
||||
warn(`Instance failed to start with ${e}`)
|
||||
warn(
|
||||
`Instance failed to start with ${e}`,
|
||||
(e as ClientResponseError).originalError?.message,
|
||||
)
|
||||
_safeShutdown(e).catch(error)
|
||||
})
|
||||
|
||||
@@ -418,7 +451,7 @@ export const instanceService = mkSingleton(
|
||||
*/
|
||||
dbg(`Checking for verified account`)
|
||||
if (!owner.verified) {
|
||||
throw new Error(`Log in at ${mkAppUrl()}} to verify your account.`)
|
||||
throw new Error(`Log in at ${mkAppUrl()} to verify your account.`)
|
||||
}
|
||||
|
||||
const api = await getInstanceApi(instance)
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { LoggerService } from '$shared'
|
||||
import {
|
||||
GetUserTokenPayload,
|
||||
GetUserTokenPayloadSchema,
|
||||
GetUserTokenResult,
|
||||
LoggerService,
|
||||
RestCommands,
|
||||
RestMethods,
|
||||
createRestHelper,
|
||||
} from '$shared'
|
||||
import { default as PocketBase } from 'pocketbase'
|
||||
import { MixinContext } from '.'
|
||||
import { createInstanceMixin } from './InstanceMIxin'
|
||||
@@ -29,11 +37,21 @@ export const createAdminPbClient = (url: string) => {
|
||||
const context: MixinContext = { client, logger: _clientLogger }
|
||||
const instanceApi = createInstanceMixin(context)
|
||||
|
||||
const restHelper = createRestHelper({ client })
|
||||
const { mkRest } = restHelper
|
||||
|
||||
const getUserTokenInfo = mkRest<GetUserTokenPayload, GetUserTokenResult>(
|
||||
RestCommands.UserToken,
|
||||
RestMethods.Get,
|
||||
GetUserTokenPayloadSchema,
|
||||
)
|
||||
|
||||
const api = {
|
||||
client,
|
||||
url,
|
||||
createFirstAdmin,
|
||||
adminAuthViaEmail,
|
||||
getUserTokenInfo,
|
||||
...instanceApi,
|
||||
}
|
||||
|
||||
|
||||
@@ -1,23 +1,19 @@
|
||||
import Ajv, { JSONSchemaType } from 'ajv'
|
||||
import type pocketbaseEs from 'pocketbase'
|
||||
import { ClientResponseError } from 'pocketbase'
|
||||
import type { JsonObject } from 'type-fest'
|
||||
import { LoggerService } from '../Logger'
|
||||
import { RestCommands, RestMethods } from '../schema'
|
||||
import type { WatchHelper } from './WatchHelper'
|
||||
|
||||
export type RestHelperConfig = {
|
||||
client: pocketbaseEs
|
||||
watchHelper: WatchHelper
|
||||
}
|
||||
|
||||
export type RestHelper = ReturnType<typeof createRestHelper>
|
||||
|
||||
export const createRestHelper = (config: RestHelperConfig) => {
|
||||
const _logger = LoggerService().create(`RestHelper`)
|
||||
const {
|
||||
client,
|
||||
watchHelper: { watchById },
|
||||
} = config
|
||||
const { client } = config
|
||||
|
||||
const mkRest = <TPayload extends JsonObject, TResult extends JsonObject>(
|
||||
cmd: RestCommands,
|
||||
@@ -33,13 +29,41 @@ export const createRestHelper = (config: RestHelperConfig) => {
|
||||
if (!validator(payload)) {
|
||||
throw new Error(`Invalid REST payload: ${validator.errors}`)
|
||||
}
|
||||
const _payload = { ...payload }
|
||||
|
||||
const res = await client.send(`/api/${cmd}`, {
|
||||
method: method,
|
||||
body: payload,
|
||||
const url = `/api/${cmd}${
|
||||
method === RestMethods.Post ? '' : '/:id'
|
||||
}`.replace(/:([a-zA-Z]+)/g, (_, key) => {
|
||||
if (!(key in _payload)) {
|
||||
throw new Error(`Payload must include '${key}`)
|
||||
}
|
||||
const value = _payload[key]!
|
||||
delete _payload[key]
|
||||
return encodeURIComponent(value.toString())
|
||||
})
|
||||
dbg(res)
|
||||
return res
|
||||
|
||||
const options: any = {
|
||||
method: method,
|
||||
}
|
||||
|
||||
if (method !== RestMethods.Get) {
|
||||
options.body = _payload
|
||||
}
|
||||
|
||||
dbg({ url, options })
|
||||
|
||||
try {
|
||||
const res = await client.send(url, options)
|
||||
dbg(res)
|
||||
return res
|
||||
} catch (e) {
|
||||
if (e instanceof ClientResponseError) {
|
||||
error(`REST error: ${e.originalError}`)
|
||||
throw e.originalError
|
||||
}
|
||||
error(`REST error: ${e}`)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,114 +0,0 @@
|
||||
import type pocketbaseEs from 'pocketbase'
|
||||
import type { RecordSubscription, UnsubscribeFunc } from 'pocketbase'
|
||||
import { LoggerService } from '../Logger'
|
||||
import { UnixTimestampMs, createTimerManager } from '../TimerManager'
|
||||
import { BaseFields, RecordId } from '../schema'
|
||||
|
||||
export type WatchHelperConfig = {
|
||||
client: pocketbaseEs
|
||||
}
|
||||
|
||||
export type WatchConfig = {
|
||||
initialFetch: boolean
|
||||
pollIntervalMs: UnixTimestampMs
|
||||
}
|
||||
|
||||
export type WatchHelper = ReturnType<typeof createWatchHelper>
|
||||
|
||||
export const createWatchHelper = (config: WatchHelperConfig) => {
|
||||
const { client } = config
|
||||
|
||||
const watchById = async <TRec>(
|
||||
collectionName: string,
|
||||
id: RecordId,
|
||||
cb: (data: RecordSubscription<TRec>, unsub: UnsubscribeFunc) => void,
|
||||
options?: Partial<WatchConfig>,
|
||||
): Promise<UnsubscribeFunc> => {
|
||||
const { dbg } = LoggerService().create(`watchById:${collectionName}:${id}`)
|
||||
const config: WatchConfig = {
|
||||
initialFetch: true,
|
||||
pollIntervalMs: 0,
|
||||
...options,
|
||||
}
|
||||
const { initialFetch, pollIntervalMs } = config
|
||||
const tm = createTimerManager({})
|
||||
dbg(`watching ${collectionName}:${id}`)
|
||||
let hasUpdate = false
|
||||
let hasFinished = false
|
||||
if (pollIntervalMs) {
|
||||
dbg(`Configuring polling for ${pollIntervalMs}ms`)
|
||||
tm.repeat(async () => {
|
||||
dbg(`Checking ${id} by polling`)
|
||||
try {
|
||||
const rec = await client.collection(collectionName).getOne<TRec>(id)
|
||||
hasUpdate = true
|
||||
dbg(`Got an update polling ${collectionName}:${id}`)
|
||||
cb({ action: 'poll', record: rec }, _unsub)
|
||||
} catch (e) {
|
||||
dbg(`Failed to poll at interval`, e)
|
||||
}
|
||||
return true
|
||||
}, pollIntervalMs)
|
||||
}
|
||||
|
||||
const _unsub = async () => {
|
||||
dbg(`Unsubbing ${collectionName}:${id}`)
|
||||
tm.shutdown()
|
||||
hasFinished = true
|
||||
await unsub()
|
||||
}
|
||||
|
||||
const unsub = await client
|
||||
.collection(collectionName)
|
||||
.subscribe<TRec>(id, (e) => {
|
||||
dbg(`Got an update watching ${collectionName}:${id}`, e)
|
||||
cb(e, _unsub)
|
||||
})
|
||||
|
||||
if (initialFetch) {
|
||||
try {
|
||||
const initial = await client.collection(collectionName).getOne<TRec>(id)
|
||||
if (!hasUpdate && !hasFinished) {
|
||||
// No update has been sent yet, send at least one
|
||||
dbg(`Sending initial update for ${collectionName}:${id}`, initial)
|
||||
cb({ action: 'initial', record: initial }, _unsub)
|
||||
}
|
||||
} catch (e) {
|
||||
throw new Error(`Expected ${collectionName}.${id} to exist.`)
|
||||
}
|
||||
}
|
||||
return _unsub
|
||||
}
|
||||
|
||||
const watchAllById = async <TRec extends BaseFields>(
|
||||
collectionName: string,
|
||||
idName: keyof TRec,
|
||||
idValue: RecordId,
|
||||
cb: (data: RecordSubscription<TRec>) => void,
|
||||
initialFetch = true,
|
||||
): Promise<UnsubscribeFunc> => {
|
||||
let hasUpdate: { [_: RecordId]: boolean } = {}
|
||||
const unsub = client
|
||||
.collection(collectionName)
|
||||
.subscribe<TRec>('*', (e) => {
|
||||
// console.log(e.record.instanceId, id)
|
||||
if (e.record[idName] !== idValue) return
|
||||
hasUpdate[e.record.id] = true
|
||||
cb(e)
|
||||
})
|
||||
if (initialFetch) {
|
||||
const existing = await client
|
||||
.collection(collectionName)
|
||||
.getFullList<TRec>(100, {
|
||||
filter: `${idName.toString()} = '${idValue}'`,
|
||||
})
|
||||
existing.forEach((record) => {
|
||||
if (hasUpdate[record.id]) return
|
||||
cb({ action: 'initial', record })
|
||||
})
|
||||
}
|
||||
return unsub
|
||||
}
|
||||
|
||||
return { watchById, watchAllById }
|
||||
}
|
||||
@@ -1,2 +1 @@
|
||||
export * from './RestHelper'
|
||||
export * from './WatchHelper'
|
||||
|
||||
@@ -27,6 +27,7 @@ export type InstanceFields = BaseFields & {
|
||||
version: VersionId
|
||||
secrets: InstanceSecretCollection | null
|
||||
maintenance: boolean
|
||||
syncAdmin: boolean
|
||||
}
|
||||
|
||||
export type WithUser = {
|
||||
|
||||
22
src/shared/schema/Rest/GetUserTokenInfo.ts
Normal file
22
src/shared/schema/Rest/GetUserTokenInfo.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { JSONSchemaType } from 'ajv'
|
||||
import { UserId } from '../types'
|
||||
|
||||
export type GetUserTokenPayload = {
|
||||
id: UserId
|
||||
}
|
||||
|
||||
export type GetUserTokenResult = {
|
||||
id: string
|
||||
email: string
|
||||
tokenKey: string
|
||||
passwordHash: string
|
||||
}
|
||||
|
||||
export const GetUserTokenPayloadSchema: JSONSchemaType<GetUserTokenPayload> = {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string' },
|
||||
},
|
||||
required: ['id'],
|
||||
additionalProperties: false,
|
||||
}
|
||||
@@ -1,16 +1,15 @@
|
||||
import { JSONSchemaType } from 'ajv'
|
||||
import { InstanceId, Semver } from '../types'
|
||||
import { InstanceFields } from '../Instance'
|
||||
import { InstanceId } from '../types'
|
||||
|
||||
export type UpdateInstancePayload = {
|
||||
instanceId: InstanceId
|
||||
fields: {
|
||||
subdomain?: string
|
||||
maintenance?: boolean
|
||||
version?: Semver
|
||||
secrets?: {
|
||||
[_: string]: string
|
||||
}
|
||||
}
|
||||
id: InstanceId
|
||||
fields: Partial<
|
||||
Pick<
|
||||
InstanceFields,
|
||||
'maintenance' | 'secrets' | 'subdomain' | 'syncAdmin' | 'version'
|
||||
>
|
||||
>
|
||||
}
|
||||
|
||||
export const SECRET_KEY_REGEX = /^[A-Z][A-Z0-9_]*$/
|
||||
@@ -24,10 +23,11 @@ export const UpdateInstancePayloadSchema: JSONSchemaType<UpdateInstancePayload>
|
||||
{
|
||||
type: 'object',
|
||||
properties: {
|
||||
instanceId: { type: 'string' },
|
||||
id: { type: 'string' },
|
||||
fields: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
syncAdmin: { type: 'boolean', nullable: true },
|
||||
subdomain: { type: 'string', nullable: true },
|
||||
maintenance: { type: 'boolean', nullable: true },
|
||||
version: {
|
||||
@@ -47,6 +47,6 @@ export const UpdateInstancePayloadSchema: JSONSchemaType<UpdateInstancePayload>
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['instanceId', 'fields'],
|
||||
required: ['id', 'fields'],
|
||||
additionalProperties: false,
|
||||
}
|
||||
|
||||
@@ -2,12 +2,14 @@ import Ajv from 'ajv'
|
||||
import { JsonObject } from 'type-fest'
|
||||
|
||||
export enum RestMethods {
|
||||
Create = 'POST',
|
||||
Update = 'PUT',
|
||||
Get = 'GET',
|
||||
Post = 'POST',
|
||||
Put = 'PUT',
|
||||
}
|
||||
|
||||
export enum RestCommands {
|
||||
Instance = 'instance',
|
||||
UserToken = 'userToken',
|
||||
}
|
||||
|
||||
export type RestPayloadBase = JsonObject
|
||||
@@ -15,5 +17,5 @@ export type RestPayloadBase = JsonObject
|
||||
export const ajv = new Ajv()
|
||||
|
||||
export * from './CreateInstance'
|
||||
export * from './GetUserTokenInfo'
|
||||
export * from './UpdateInstance'
|
||||
// gen:export
|
||||
|
||||
@@ -4,4 +4,3 @@ export * from './Rest'
|
||||
export * from './User'
|
||||
export * from './types'
|
||||
export * from './util'
|
||||
// gen:export
|
||||
|
||||
@@ -25,5 +25,5 @@
|
||||
}
|
||||
},
|
||||
"include": ["./src"],
|
||||
"exclude": ["src/mothership-app"]
|
||||
"exclude": ["src/mothership-app", "src/instance-app"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user