Merge branch 'main' of github.com:pockethost/pockethost

This commit is contained in:
Ben Allfree 2025-07-19 14:20:08 +00:00
commit 2624833221
15 changed files with 248 additions and 138 deletions

View File

@ -1,8 +1,8 @@
<script lang="ts">
import CodeSample from '$components/CodeSample.svelte'
import CardHeader from '$src/components/cards/CardHeader.svelte'
import { DISCORD_URL, INSTANCE_URL } from '$src/env'
import { isCnameActive } from 'pockethost/common'
import { INSTANCE_URL } from '$src/env'
import { instance } from './store'
let installSnippet = `npm i pocketbase`

View File

@ -92,15 +92,16 @@ export const init = () => {
onAuthChange((authStoreProps) => {
const isLoggedIn = authStoreProps.isValid
isUserLoggedIn.set(isLoggedIn)
console.log(`onAuthChange update`, { isLoggedIn, authStoreProps })
const user = authStoreProps.model as UserFields
userStore.set(isLoggedIn ? user : undefined)
isAuthStateInitialized.set(true)
isUserLoggedIn.set(isLoggedIn)
tryUserSubscribe(user?.id)
})
userStore.subscribe((user) => {
console.log(`userStore.subscribe`, { user })
console.log(`userStore.subscribe update`, { user })
const isPaid = [SubscriptionType.Founder, SubscriptionType.Premium, SubscriptionType.Flounder].includes(
user?.subscription || SubscriptionType.Free
)
@ -113,22 +114,25 @@ export const init = () => {
// This holds an array of all the user's instances and their data
/** Listen for instances */
let unsubInstanceWatch: UnsubscribeFunc | undefined
isUserLoggedIn.subscribe(async (isLoggedIn) => {
let unsub: UnsubscribeFunc | undefined
console.log(`isUserLoggedIn.subscribe update`, { isLoggedIn })
if (!isLoggedIn) {
userStore.set(undefined)
globalInstancesStore.set({})
globalInstancesStoreReady.set(false)
unsub?.()
unsubInstanceWatch?.()
.then(() => {
unsub = undefined
unsubInstanceWatch = undefined
})
.catch(console.error)
return
}
const { getAllInstancesById } = client()
console.log('Getting all instances by ID')
const instances = await getAllInstancesById()
console.log('Instances', instances)
globalInstancesStore.set(instances)
globalInstancesStoreReady.set(true)
@ -137,13 +141,14 @@ export const init = () => {
client()
.client.collection('instances')
.subscribe<InstanceFields>('*', (data) => {
console.log('Instance subscribe update', data)
globalInstancesStore.update((instances) => {
instances[data.record.id] = data.record
return instances
})
})
.then((u) => {
unsub = u
unsubInstanceWatch = u
})
.catch(() => {
console.error('Failed to subscribe to instances')
@ -171,6 +176,7 @@ const tryUserSubscribe = (() => {
client().client.collection('users').authRefresh().catch(console.error)
})
.then((u) => {
console.log('Subscribed to user', id)
unsub = async () => {
console.log('Unsubscribing from user', id)
await u()

View File

@ -42,9 +42,9 @@ export const firewall = async () => {
app.use(cors())
app.use(enforce.HTTPS())
app.get(`/_api/firewall/health`, (req, res, next) => {
app.get(`/api/firewall/health`, (req, res, next) => {
dbg(`Health check`)
res.json({ status: 'firewall ok' })
res.json({ status: 'firewall ok', code: 200 })
res.end()
})

View File

@ -91,7 +91,7 @@ const HandleInstanceDelete = (c) => {
const HandleInstanceResolve = (c) => {
const dao = $app.dao();
const log = mkLog(`GET:instance/resolve`);
log(`***TOP OF GET`);
log(`TOP OF GET`);
const host = c.queryParam("host");
if (!host) throw new BadRequestError(`Host is required when resolving an instance.`);
const instance = (() => {
@ -203,6 +203,43 @@ const removeEmptyKeys = (obj) => {
//#endregion
//#region src/lib/handlers/instance/api/HandleInstanceUpdate.ts
const callCloudflareAPI = (endpoint, method, body, log) => {
const apiToken = $os.getenv("MOTHERSHIP_CLOUDFLARE_API_TOKEN");
const zoneId = $os.getenv("MOTHERSHIP_CLOUDFLARE_ZONE_ID");
if (!apiToken || !zoneId) {
if (log) log("Cloudflare API credentials not configured - skipping Cloudflare operations");
return null;
}
const url = `https://api.cloudflare.com/client/v4/zones/${zoneId}/${endpoint}`;
try {
const config = {
url,
method,
headers: {
Authorization: `Bearer ${apiToken}`,
"Content-Type": "application/json"
},
timeout: 30
};
if (body) config.body = JSON.stringify(body);
if (log) log(`Making Cloudflare API call: ${method} ${url}`, config);
const response = $http.send(config);
if (log) log(`Cloudflare API response:`, response);
return response;
} catch (error$1) {
if (log) log(`Cloudflare API error:`, error$1);
return null;
}
};
const createCloudflareCustomHostname = (hostname, log) => {
return callCloudflareAPI("custom_hostnames", "POST", {
hostname,
ssl: {
method: "http",
type: "dv"
}
}, log);
};
const HandleInstanceUpdate = (c) => {
const dao = $app.dao();
const log = mkLog(`PUT:instance`);
@ -239,6 +276,14 @@ const HandleInstanceUpdate = (c) => {
log(`authRecord`, JSON.stringify(authRecord));
if (!authRecord) throw new Error(`Expected authRecord here`);
if (record.get("uid") !== authRecord.id) throw new BadRequestError(`Not authorized`);
const oldCname = record.getString("cname").trim();
const newCname = cname ? cname.trim() : "";
const cnameChanged = oldCname !== newCname;
if (cnameChanged && newCname) {
log(`CNAME changed from "${oldCname}" to "${newCname}" - adding to Cloudflare`);
const createResponse = createCloudflareCustomHostname(newCname, log);
if (createResponse) log(`Cloudflare API call completed for "${newCname}" - frontend will poll for health`);
}
const sanitized = removeEmptyKeys({
subdomain,
version,
@ -385,45 +430,6 @@ const HandleMigrateRegions = (e) => {
log(`Migrated regions`);
};
//#endregion
//#region src/lib/handlers/instance/model/HandleInstanceBeforeUpdate.ts
const HandleInstanceBeforeUpdate = (e) => {
const dao = e.dao || $app.dao();
const log = mkLog(`instances-validate-before-update`);
const id = e.model.getId();
const version = e.model.get("version");
if (!versions.includes(version)) {
const msg = `[ERROR] Invalid version '${version}' for [${id}]. Version must be one of: ${versions.join(", ")}`;
log(`${msg}`);
throw new BadRequestError(msg);
}
const cname = e.model.get("cname");
if (cname.length > 0) {
const result = new DynamicModel({ id: "" });
const inUse = (() => {
try {
dao.db().newQuery(`select id from instances where cname='${cname}' and id <> '${id}'`).one(result);
} catch (e$1) {
return false;
}
return true;
})();
if (inUse) {
const msg = `[ERROR] [${id}] Custom domain ${cname} already in use.`;
log(`${msg}`);
throw new BadRequestError(msg);
}
}
};
//#endregion
//#region src/lib/handlers/instance/model/HandleInstanceVersionValidation.ts
const HandleInstanceVersionValidation = (e) => {
const dao = e.dao || $app.dao();
const version = e.model.get("version");
if (!versions.includes(version)) throw new BadRequestError(`Invalid version ${version}. Version must be one of: ${versions.join(", ")}`);
};
//#endregion
//#region src/lib/util/mkAudit.ts
const mkAudit = (log, dao) => {
@ -439,8 +445,8 @@ const mkAudit = (log, dao) => {
};
//#endregion
//#region src/lib/handlers/instance/model/HandleNotifyDiscordAfterCreate.ts
const HandleNotifyDiscordAfterCreate = (e) => {
//#region src/lib/handlers/instance/model/AfterCreate_notify_discord.ts
const AfterCreate_notify_discord = (e) => {
const dao = e.dao || $app.dao();
const log = mkLog(`instances:create:discord:notify`);
const audit = mkAudit(log, dao);
@ -460,6 +466,46 @@ const HandleNotifyDiscordAfterCreate = (e) => {
}
};
//#endregion
//#region src/lib/handlers/instance/model/BeforeUpdate_cname.ts
const BeforeUpdate_cname = (e) => {
const dao = e.dao || $app.dao();
const log = mkLog(`BeforeUpdate_cname`);
const id = e.model.getId();
const newCname = e.model.get("cname").trim();
if (newCname.length > 0) {
const result = new DynamicModel({ id: "" });
const inUse = (() => {
try {
dao.db().newQuery(`select id from instances where cname='${newCname}' and id <> '${id}'`).one(result);
} catch (e$1) {
return false;
}
return true;
})();
if (inUse) {
const msg = `[ERROR] [${id}] Custom domain ${newCname} already in use.`;
log(`${msg}`);
throw new BadRequestError(msg);
}
}
log(`CNAME validation passed for: "${newCname}"`);
};
//#endregion
//#region src/lib/handlers/instance/model/BeforeUpdate_version.ts
const BeforeUpdate_version = (e) => {
const dao = e.dao || $app.dao();
const log = mkLog(`BeforeUpdate_version`);
const version = e.model.get("version");
log(`Validating version ${version}`);
if (!versions.includes(version)) {
const msg = `Invalid version ${version}. Version must be one of: ${versions.join(", ")}`;
log(`[ERROR] ${msg}`);
throw new BadRequestError(msg);
}
};
//#endregion
//#region src/lib/util/mkNotifier.ts
const mkNotifier = (log, dao) => (channel, template, user_id, context = {}) => {
@ -3088,12 +3134,13 @@ const HandleVersionsRequest = (c) => {
};
//#endregion
exports.HandleInstanceBeforeUpdate = HandleInstanceBeforeUpdate;
exports.AfterCreate_notify_discord = AfterCreate_notify_discord;
exports.BeforeUpdate_cname = BeforeUpdate_cname;
exports.BeforeUpdate_version = BeforeUpdate_version;
exports.HandleInstanceCreate = HandleInstanceCreate;
exports.HandleInstanceDelete = HandleInstanceDelete;
exports.HandleInstanceResolve = HandleInstanceResolve;
exports.HandleInstanceUpdate = HandleInstanceUpdate;
exports.HandleInstanceVersionValidation = HandleInstanceVersionValidation;
exports.HandleInstancesResetIdle = HandleInstancesResetIdle;
exports.HandleLemonSqueezySale = HandleLemonSqueezySale;
exports.HandleMailSend = HandleMailSend;
@ -3102,7 +3149,6 @@ exports.HandleMigrateCnamesToDomains = HandleMigrateCnamesToDomains;
exports.HandleMigrateInstanceVersions = HandleMigrateInstanceVersions;
exports.HandleMigrateRegions = HandleMigrateRegions;
exports.HandleMirrorData = HandleMirrorData;
exports.HandleNotifyDiscordAfterCreate = HandleNotifyDiscordAfterCreate;
exports.HandleProcessNotification = HandleProcessNotification;
exports.HandleProcessSingleNotification = HandleProcessSingleNotification;
exports.HandleSesError = HandleSesError;

View File

@ -13,24 +13,20 @@ routerAdd("GET", "/api/instance/resolve", (c) => {
return require(`${__hooks}/mothership`).HandleInstanceResolve(c);
}, $apis.requireAdminAuth());
/** Validate instance version */
onModelBeforeCreate((e) => {
return require(`${__hooks}/mothership`).HandleInstanceVersionValidation(e);
onModelBeforeUpdate((e) => {
return require(`${__hooks}/mothership`).BeforeUpdate_version(e);
}, "instances");
onModelAfterCreate((e) => {}, "instances");
/** Validate cname */
onModelBeforeUpdate((e) => {
return require(`${__hooks}/mothership`).BeforeUpdate_cname(e);
}, "instances");
/** Notify discord on instance create */
onAfterBootstrap((e) => {});
onAfterBootstrap((e) => {});
/** Reset instance status to idle on start */
onAfterBootstrap((e) => {
return require(`${__hooks}/mothership`).HandleInstancesResetIdle(e);
});
/** Migrate existing cnames to domains table */
onAfterBootstrap((e) => {
return require(`${__hooks}/mothership`).HandleMigrateCnamesToDomains(e);
});
/** Validate instance version */
onModelBeforeUpdate((e) => {
return require(`${__hooks}/mothership`).HandleInstanceBeforeUpdate(e);
}, "instances");
//#endregion
//#region src/lib/handlers/lemon/hooks.ts

View File

@ -5,7 +5,7 @@ export const HandleInstanceResolve = (c: echo.Context) => {
const log = mkLog(`GET:instance/resolve`)
log(`***TOP OF GET`)
log(`TOP OF GET`)
const host = c.queryParam('host')
if (!host) {

View File

@ -1,6 +1,62 @@
import { mkLog, StringKvLookup } from '$util/Logger'
import { removeEmptyKeys } from '$util/removeEmptyKeys'
// Helper function to make Cloudflare API calls
const callCloudflareAPI = (endpoint: string, method: string, body?: any, log?: any) => {
const apiToken = $os.getenv('MOTHERSHIP_CLOUDFLARE_API_TOKEN')
const zoneId = $os.getenv('MOTHERSHIP_CLOUDFLARE_ZONE_ID')
if (!apiToken || !zoneId) {
if (log) log('Cloudflare API credentials not configured - skipping Cloudflare operations')
return null
}
const url = `https://api.cloudflare.com/client/v4/zones/${zoneId}/${endpoint}`
try {
const config: any = {
url: url,
method: method,
headers: {
Authorization: `Bearer ${apiToken}`,
'Content-Type': 'application/json',
},
timeout: 30,
}
if (body) {
config.body = JSON.stringify(body)
}
if (log) log(`Making Cloudflare API call: ${method} ${url}`, config)
const response = $http.send(config)
if (log) log(`Cloudflare API response:`, response)
return response
} catch (error) {
if (log) log(`Cloudflare API error:`, error)
return null
}
}
// Helper to create custom hostname in Cloudflare
const createCloudflareCustomHostname = (hostname: string, log: any) => {
return callCloudflareAPI(
'custom_hostnames',
'POST',
{
hostname: hostname,
ssl: {
method: 'http',
type: 'dv',
},
},
log
)
}
export const HandleInstanceUpdate = (c: echo.Context) => {
const dao = $app.dao()
const log = mkLog(`PUT:instance`)
@ -67,6 +123,21 @@ export const HandleInstanceUpdate = (c: echo.Context) => {
throw new BadRequestError(`Not authorized`)
}
// Check if CNAME changed and handle Cloudflare
const oldCname = record.getString('cname').trim()
const newCname = cname ? cname.trim() : ''
const cnameChanged = oldCname !== newCname
if (cnameChanged && newCname) {
log(`CNAME changed from "${oldCname}" to "${newCname}" - adding to Cloudflare`)
// Blindly add to Cloudflare
const createResponse = createCloudflareCustomHostname(newCname, log)
if (createResponse) {
log(`Cloudflare API call completed for "${newCname}" - frontend will poll for health`)
}
}
const sanitized = removeEmptyKeys({
subdomain,
version,

View File

@ -31,14 +31,20 @@ routerAdd(
$apis.requireAdminAuth()
)
/** Validate instance version */
onModelBeforeCreate((e) => {
return require(`${__hooks}/mothership`).HandleInstanceVersionValidation(e)
onModelBeforeUpdate((e) => {
return require(`${__hooks}/mothership`).BeforeUpdate_version(e)
}, 'instances')
onModelAfterCreate((e) => {
// return require(`${__hooks}/mothership`).HandleNotifyDiscordAfterCreate(e)
/** Validate cname */
onModelBeforeUpdate((e) => {
return require(`${__hooks}/mothership`).BeforeUpdate_cname(e)
}, 'instances')
/** Notify discord on instance create */
// onModelAfterCreate((e) => {
// return require(`${__hooks}/mothership`).AfterCreate_notify_discord(e)
// }, 'instances')
onAfterBootstrap((e) => {
// return require(`${__hooks}/mothership`).HandleMigrateInstanceVersions(e)
})
@ -51,13 +57,3 @@ onAfterBootstrap((e) => {
onAfterBootstrap((e) => {
return require(`${__hooks}/mothership`).HandleInstancesResetIdle(e)
})
/** Migrate existing cnames to domains table */
onAfterBootstrap((e) => {
return require(`${__hooks}/mothership`).HandleMigrateCnamesToDomains(e)
})
/** Validate instance version */
onModelBeforeUpdate((e) => {
return require(`${__hooks}/mothership`).HandleInstanceBeforeUpdate(e)
}, 'instances')

View File

@ -6,6 +6,6 @@ export * from './bootstrap/HandleInstancesResetIdle'
export * from './bootstrap/HandleMigrateCnamesToDomains'
export * from './bootstrap/HandleMigrateInstanceVersions'
export * from './bootstrap/HandleMigrateRegions'
export * from './model/HandleInstanceBeforeUpdate'
export * from './model/HandleInstanceVersionValidation'
export * from './model/HandleNotifyDiscordAfterCreate'
export * from './model/AfterCreate_notify_discord'
export * from './model/BeforeUpdate_cname'
export * from './model/BeforeUpdate_version'

View File

@ -1,7 +1,7 @@
import { mkLog } from '$util/Logger'
import { mkAudit } from '$util/mkAudit'
export const HandleNotifyDiscordAfterCreate = (e: core.ModelEvent) => {
export const AfterCreate_notify_discord = (e: core.ModelEvent) => {
const dao = e.dao || $app.dao()
const log = mkLog(`instances:create:discord:notify`)

View File

@ -0,0 +1,32 @@
import { mkLog } from '$util/Logger'
export const BeforeUpdate_cname = (e: core.ModelEvent) => {
const dao = e.dao || $app.dao()
const log = mkLog(`BeforeUpdate_cname`)
const id = e.model.getId()
const newCname = e.model.get('cname').trim()
// Only check if cname is already in use locally
if (newCname.length > 0) {
const result = new DynamicModel({
id: '',
})
const inUse = (() => {
try {
dao.db().newQuery(`select id from instances where cname='${newCname}' and id <> '${id}'`).one(result)
} catch (e) {
return false
}
return true
})()
if (inUse) {
const msg = `[ERROR] [${id}] Custom domain ${newCname} already in use.`
log(`${msg}`)
throw new BadRequestError(msg)
}
}
log(`CNAME validation passed for: "${newCname}"`)
}

View File

@ -0,0 +1,16 @@
import { mkLog } from '$util/Logger'
import { versions } from '$util/versions'
export const BeforeUpdate_version = (e: core.ModelEvent) => {
const dao = e.dao || $app.dao()
const log = mkLog(`BeforeUpdate_version`)
const version = e.model.get('version')
log(`Validating version ${version}`)
if (!versions.includes(version)) {
const msg = `Invalid version ${version}. Version must be one of: ${versions.join(', ')}`
log(`[ERROR] ${msg}`)
throw new BadRequestError(msg)
}
}

View File

@ -1,40 +0,0 @@
import { mkLog } from '$util/Logger'
import { versions } from '$util/versions'
export const HandleInstanceBeforeUpdate = (e: core.ModelEvent) => {
const dao = e.dao || $app.dao()
const log = mkLog(`instances-validate-before-update`)
const id = e.model.getId()
const version = e.model.get('version')
if (!versions.includes(version)) {
const msg = `[ERROR] Invalid version '${version}' for [${id}]. Version must be one of: ${versions.join(', ')}`
log(`${msg}`)
throw new BadRequestError(msg)
}
const cname = e.model.get('cname')
if (cname.length > 0) {
const result = new DynamicModel({
id: '',
})
const inUse = (() => {
try {
dao.db().newQuery(`select id from instances where cname='${cname}' and id <> '${id}'`).one(result)
} catch (e) {
// log(` cname OK ${cname}`)
return false
}
return true
})()
if (inUse) {
const msg = `[ERROR] [${id}] Custom domain ${cname} already in use.`
log(`${msg}`)
throw new BadRequestError(msg)
}
}
}

View File

@ -1,10 +0,0 @@
import { versions } from '$util/versions'
export const HandleInstanceVersionValidation = (e: core.ModelEvent) => {
const dao = e.dao || $app.dao()
const version = e.model.get('version')
if (!versions.includes(version)) {
throw new BadRequestError(`Invalid version ${version}. Version must be one of: ${versions.join(', ')}`)
}
}

View File

@ -254,9 +254,6 @@ export const instanceService = mkSingleton(async (config: InstanceServiceConfig)
}
})
if (instance) {
if (!instance.cname_active) {
throw new Error(`CNAME blocked.`)
}
dbg(`${host} is a cname`)
cache.setItem(instance)
return instance