This commit is contained in:
the1daBread 2026-08-14 18:12:55 -07:00 committed by GitHub
commit b0fc9af971
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 1019 additions and 158 deletions

3
.gitattributes vendored Normal file
View File

@ -0,0 +1,3 @@
*.sh text eol=lf
Dockerfile text eol=lf
.gitattributes text eol=lf

View File

@ -110,7 +110,8 @@ COPY install/calibre-empty-library/metadata.db /app/assets/calibre/metadata.db
# Copy entrypoint script and ensure it's executable
COPY install/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
RUN sed -i 's/\r$//' /usr/local/bin/entrypoint.sh \
&& chmod +x /usr/local/bin/entrypoint.sh
EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]

View File

@ -8,6 +8,9 @@ LOG_LEVEL=info
APP_KEY=some_random_key
NODE_ENV=development
SESSION_DRIVER=cookie
# Admin Login credentials. ADMIN_PASS must be non-empty before admin login works.
ADMIN_USER=admin
ADMIN_PASS=replaceme
DB_HOST=localhost
DB_PORT=3306
DB_USER=root
@ -27,4 +30,4 @@ NOMAD_STORAGE_PATH=/opt/project-nomad/storage
# CREATOR_PACKS_APP_KEY at build time; leave unset for a build that can't install
# packs. CREATOR_PACKS_WORKER_BASE overrides the entitlement Worker origin.
# CREATOR_PACKS_APP_KEY=
# CREATOR_PACKS_WORKER_BASE=https://nomad-packs-worker.chris-556.workers.dev
# CREATOR_PACKS_WORKER_BASE=https://nomad-packs-worker.chris-556.workers.dev

View File

@ -0,0 +1,48 @@
import AdminAuthService from '#services/admin_auth_service'
import type { HttpContext } from '@adonisjs/core/http'
export default class AdminAuthController {
async login({ request, response, session }: HttpContext) {
const user = String(request.input('user', '')).trim()
const password = String(request.input('password', ''))
const redirectTo = this.safeRedirect(request.input('redirect'))
if (!AdminAuthService.authenticate(user, password)) {
session.flash('errors', {
password: AdminAuthService.isConfigured()
? 'The admin user or password was incorrect.'
: 'Admin login is not configured. Set ADMIN_USER and ADMIN_PASS in Docker Compose.',
})
return response.redirect().back()
}
await session.regenerate()
session.put('admin.isLoggedIn', true)
return response.redirect().toPath(redirectTo)
}
async logout({ response, session }: HttpContext) {
session.forget('admin.isLoggedIn')
await session.regenerate()
return response.redirect().toPath('/home')
}
/**
* Restrict redirects to local paths and keep auth routes from looping.
*/
private safeRedirect(value: unknown): string {
const redirectTo = typeof value === 'string' ? value : '/home'
if (!redirectTo.startsWith('/') || redirectTo.startsWith('//')) {
return '/home'
}
if (redirectTo.startsWith('/admin/login') || redirectTo.startsWith('/admin/logout')) {
return '/home'
}
return redirectTo
}
}

View File

@ -10,6 +10,12 @@ import env from '#start/env'
@inject()
export default class SettingsController {
private static publicWritableSettings = new Set([
'chat.lastModel',
'rag.defaultIngestPolicy',
'ui.theme',
])
constructor(
private systemService: SystemService,
private mapService: MapService,
@ -144,8 +150,15 @@ export default class SettingsController {
return response.status(200).send({ key, value });
}
async updateSetting({ request, response }: HttpContext) {
async updateSetting({ request, response, session }: HttpContext) {
const reqData = await request.validateUsing(updateSettingSchema)
if (
!session.get('admin.isLoggedIn') &&
!SettingsController.publicWritableSettings.has(reqData.key)
) {
return response.status(403).send({ success: false, message: 'Admin login is required.' })
}
const valueError = validateSettingValue(reqData.key, reqData.value)
if (valueError) {
return response.status(422).send({ success: false, message: valueError })

View File

@ -13,6 +13,7 @@ import {
checkLatestVersionValidator,
customAppValidator,
deleteCustomAppValidator,
existingAppValidator,
installServiceValidator,
preflightCustomValidator,
preflightValidator,
@ -35,6 +36,8 @@ import type { HttpContext } from '@adonisjs/core/http'
import logger from '@adonisjs/core/services/logger'
import Service from '#models/service'
const CUSTOM_APP_HOME_DISPLAY_ORDER = 49
@inject()
export default class SystemController {
constructor(
@ -440,6 +443,7 @@ export default class SystemController {
is_dependency_service: false,
is_custom: true,
category: payload.category ?? 'custom',
display_order: uiLocation ? CUSTOM_APP_HOME_DISPLAY_ORDER : null,
depends_on: null,
})
@ -450,7 +454,52 @@ export default class SystemController {
return response.status(400).send({ success: false, message: result.message })
}
/** Delete a custom app: stop + remove its container, then delete the DB record. */
/** Register an existing Docker container in Supply Depot as a managed app entry. */
async createExistingApp({ request, response }: HttpContext) {
const payload = await request.validateUsing(existingAppValidator)
const existing = await Service.query().where('service_name', payload.container_name).first()
if (existing) {
return response.status(409).send({
success: false,
message: `A service named "${payload.container_name}" already exists. Choose a different container name.`,
})
}
const inspect = await this.dockerService.inspectContainerByName(payload.container_name)
if (!inspect) {
return response.status(404).send({
success: false,
message: `Docker container ${payload.container_name} not found.`,
})
}
const publishedHostPort = DockerService.getFirstPublishedHostPort(inspect)
await Service.create({
service_name: payload.container_name,
friendly_name: payload.friendly_name,
container_image: inspect.Config?.Image || '',
container_config: null,
// Published existing apps are launchable from the Command Center. Containers without
// a published host port remain manageable in Supply Depot but do not get a dead tile.
ui_location: publishedHostPort,
icon: payload.icon || 'IconBrandDocker',
installed: true,
installation_status: 'idle',
is_dependency_service: false,
is_custom: true,
is_existing: true,
category: payload.category ?? 'custom',
display_order: publishedHostPort ? CUSTOM_APP_HOME_DISPLAY_ORDER : null,
depends_on: null,
})
this.dockerService.invalidateServicesStatusCache()
return response.send({ success: true, message: `Existing app ${payload.friendly_name} added.`, service_name: payload.container_name })
}
/** Delete a custom app, or unregister an existing app without touching its container. */
async deleteCustomApp({ request, response }: HttpContext) {
const payload = await request.validateUsing(deleteCustomAppValidator)
@ -462,10 +511,13 @@ export default class SystemController {
return response.status(403).send({ error: 'Only custom apps can be deleted.' })
}
await this.dockerService.removeCustomAppContainer(payload.service_name, payload.remove_image ?? false)
if (!service.is_existing) {
await this.dockerService.removeCustomAppContainer(payload.service_name, payload.remove_image ?? false)
}
await service.delete()
return response.send({ success: true, message: `Custom app ${payload.service_name} deleted` })
const action = service.is_existing ? 'removed from Supply Depot' : 'deleted'
return response.send({ success: true, message: `Custom app ${payload.service_name} ${action}` })
}
/** Uninstall a curated catalog app: stop + remove its container (optionally its image) and
@ -524,6 +576,19 @@ export default class SystemController {
}
service.custom_url = normalized
if (
normalized &&
(service.display_order === null || service.display_order >= 50)
) {
service.display_order = CUSTOM_APP_HOME_DISPLAY_ORDER
}
if (
!normalized &&
!service.ui_location &&
service.display_order === CUSTOM_APP_HOME_DISPLAY_ORDER
) {
service.display_order = null
}
await service.save()
return response.send({ success: true, custom_url: service.custom_url })
@ -540,6 +605,9 @@ export default class SystemController {
if (!service.is_custom) {
return response.status(403).send({ success: false, message: 'Only custom apps can be updated this way.' })
}
if (service.is_existing) {
return response.status(403).send({ success: false, message: 'Existing apps are not recreated or updated by NOMAD.' })
}
const result = await this.dockerService.recreateCustomAppContainer(payload.service_name, {
forcePull: true,
@ -607,6 +675,37 @@ export default class SystemController {
if (service.is_dependency_service) {
return response.status(403).send({ success: false, message: 'This service cannot be edited.' })
}
if (service.is_existing) {
service.friendly_name = payload.friendly_name
service.container_image = payload.image
service.category = payload.category ?? service.category ?? 'custom'
if (payload.icon) service.icon = payload.icon
const inspect = await this.dockerService.inspectContainerByName(payload.service_name)
const publishedHostPort = inspect ? DockerService.getFirstPublishedHostPort(inspect) : null
service.ui_location = publishedHostPort
if (
(service.ui_location || service.custom_url) &&
(service.display_order === null || service.display_order >= 50)
) {
service.display_order = CUSTOM_APP_HOME_DISPLAY_ORDER
}
if (
!service.ui_location &&
!service.custom_url &&
service.display_order === CUSTOM_APP_HOME_DISPLAY_ORDER
) {
service.display_order = null
}
service.is_user_modified = true
await service.save()
return response.send({
success: true,
message: `Existing app ${payload.service_name} updated.`,
service_name: payload.service_name,
})
}
// Reject duplicate host ports within the request.
const hostPorts = (payload.ports ?? []).map((p) => p.host)
@ -659,6 +758,19 @@ export default class SystemController {
? `${prevScheme}:${uiLocation}`
: uiLocation
service.category = payload.category ?? service.category ?? 'custom'
if (
(service.ui_location || service.custom_url) &&
(service.display_order === null || service.display_order >= 50)
) {
service.display_order = CUSTOM_APP_HOME_DISPLAY_ORDER
}
if (
!service.ui_location &&
!service.custom_url &&
service.display_order === CUSTOM_APP_HOME_DISPLAY_ORDER
) {
service.display_order = null
}
if (payload.icon) service.icon = payload.icon
// Flag as user-modified so the seeder stops overwriting this app's config on future runs.
service.is_user_modified = true
@ -794,6 +906,7 @@ export default class SystemController {
return {
service_name: service.service_name,
friendly_name: service.friendly_name,
is_existing: service.is_existing,
image: service.container_image,
category: service.category ?? 'custom',
icon: service.icon ?? 'IconBrandDocker',
@ -804,4 +917,4 @@ export default class SystemController {
cpus: hostConfig.NanoCpus ? hostConfig.NanoCpus / 1e9 : undefined,
}
}
}
}

View File

@ -26,7 +26,9 @@ export class CheckServiceUpdatesJob {
// Determine host architecture
const hostArch = await this.getHostArch(dockerService)
const installedServices = await Service.query().where('installed', true)
const installedServices = await Service.query()
.where('installed', true)
.where('is_existing', false)
let updatesFound = 0
for (const service of installedServices) {

View File

@ -0,0 +1,20 @@
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
export default class RequireAdminMiddleware {
async handle(ctx: HttpContext, next: NextFn) {
if (ctx.session.get('admin.isLoggedIn')) {
return next()
}
if (ctx.request.accepts(['html', 'json']) === 'json') {
return ctx.response.status(403).send({
success: false,
message: 'Admin login is required.',
})
}
const redirectTo = encodeURIComponent(ctx.request.url(true))
return ctx.response.redirect().toPath(`/home?adminLogin=1&redirect=${redirectTo}`)
}
}

View File

@ -75,6 +75,15 @@ export default class Service extends BaseModel {
})
declare is_custom: boolean
// True for Docker containers the user registered after creating them outside NOMAD. These
// records are metadata-only: NOMAD may start/stop them, but must not recreate or delete them.
@column({
serialize(value) {
return Boolean(value)
},
})
declare is_existing: boolean
@column({
serialize(value) {
return Boolean(value)

View File

@ -0,0 +1,48 @@
import { timingSafeEqual } from 'node:crypto'
import env from '#start/env'
const FALLBACK_ADMIN_USER = 'admin'
export default class AdminAuthService {
/**
* Resolve the configured admin username. The password must still be provided
* with ADMIN_PASS before login is enabled.
*/
static user(): string {
return env.get('ADMIN_USER', FALLBACK_ADMIN_USER).trim() || FALLBACK_ADMIN_USER
}
/**
* Admin login is disabled until ADMIN_PASS has a non-empty value.
*/
static isConfigured(): boolean {
return Boolean(env.get('ADMIN_PASS')?.trim())
}
/**
* Compare supplied credentials to environment-controlled credentials.
*/
static authenticate(user: string, password: string): boolean {
const configuredPassword = env.get('ADMIN_PASS')?.trim()
if (!configuredPassword) {
return false
}
return (
this.secureCompare(user, this.user()) &&
this.secureCompare(password, configuredPassword)
)
}
private static secureCompare(input: string, expected: string): boolean {
const inputBuffer = Buffer.from(input)
const expectedBuffer = Buffer.from(expected)
if (inputBuffer.length !== expectedBuffer.length) {
return false
}
return timingSafeEqual(inputBuffer, expectedBuffer)
}
}

View File

@ -179,7 +179,10 @@ export class AppAutoUpdateService {
/** Installed, opted-in apps that are eligible to update right now. */
async getEligibleApps(config: AppAutoUpdateConfig, now: DateTime): Promise<AppUpdateTarget[]> {
const apps = await Service.query().where('installed', true).where('auto_update_enabled', true)
const apps = await Service.query()
.where('installed', true)
.where('auto_update_enabled', true)
.where('is_existing', false)
const targets: AppUpdateTarget[] = []
for (const service of apps) {
const verdict = this.appEligibility(service, config.cooloffHours, now)
@ -340,7 +343,10 @@ export class AppAutoUpdateService {
const config = await this.getConfig()
const now = DateTime.now()
const apps = await Service.query().where('installed', true).where('auto_update_enabled', true)
const apps = await Service.query()
.where('installed', true)
.where('auto_update_enabled', true)
.where('is_existing', false)
const appStatuses: AppAutoUpdateAppStatus[] = apps.map((service) => {
const verdict = this.appEligibility(service, config.cooloffHours, now)
return {

View File

@ -165,9 +165,10 @@ export class DockerService {
}
/**
* Fetches the status of all Docker containers related to Nomad services. (those prefixed with 'nomad_')
* Results are cached for 5 seconds and concurrent callers share a single in-flight request,
* preventing Docker socket congestion during rapid page navigation.
* Fetches the status of all Docker containers on the host and stores their
* names so the system can detect existing app containers by name.
* Results are cached for 5 seconds and concurrent callers share a single
* in-flight request, preventing Docker socket congestion during rapid page navigation.
*/
async getServicesStatus(): Promise<{ service_name: string; status: string }[]> {
const now = Date.now()
@ -201,9 +202,11 @@ export class DockerService {
const containers = await this.docker.listContainers({ all: true })
const containerMap = new Map<string, Docker.ContainerInfo>()
containers.forEach((container) => {
const name = container.Names[0]?.replace('/', '')
if (name && name.startsWith('nomad_')) {
containerMap.set(name, container)
for (const rawName of container.Names ?? []) {
const name = rawName?.replace(/^\//, '')
if (name) {
containerMap.set(name, container)
}
}
})
@ -359,6 +362,12 @@ export class DockerService {
message: `Service ${serviceName} not found`,
}
}
if (service.is_existing) {
return {
success: false,
message: `Existing app ${serviceName} is registered only and cannot be force reinstalled by NOMAD`,
}
}
// Check if installation is already in progress
if (this.activeInstallations.has(serviceName)) {
@ -1585,6 +1594,12 @@ export class DockerService {
if (!service.installed) {
return { success: false, message: `Service ${serviceName} is not installed` }
}
if (service.is_existing) {
return {
success: false,
message: `Existing app ${serviceName} is registered only and cannot be updated by NOMAD`,
}
}
if (this.activeInstallations.has(serviceName)) {
return { success: false, message: `Service ${serviceName} already has an operation in progress` }
}
@ -2026,6 +2041,37 @@ export class DockerService {
return containers.find((c) => c.Names.includes(`/${serviceName}`)) ?? null
}
async findContainerByName(serviceName: string) {
return this._findContainerByName(serviceName)
}
async inspectContainerByName(serviceName: string) {
const info = await this._findContainerByName(serviceName)
if (!info) return null
const container = this.docker.getContainer(info.Id)
return container.inspect()
}
/**
* Return the first host port published by a Docker container inspect payload.
* Existing apps are already running, so their launch target comes from Docker's
* active port bindings rather than NOMAD's generated container config.
*/
static getFirstPublishedHostPort(inspect: any): string | null {
const ports = inspect?.NetworkSettings?.Ports ?? {}
const bindings = Object.values(ports).flat() as Array<{
HostIp?: string
HostPort?: string
} | null>
const published = bindings
.filter((binding): binding is { HostIp?: string; HostPort: string } =>
Boolean(binding?.HostPort)
)
.sort((a, b) => Number.parseInt(a.HostPort, 10) - Number.parseInt(b.HostPort, 10))
return published[0]?.HostPort ?? null
}
/**
* Decode the multiplexed stream Docker returns for non-TTY container logs. Each frame is an
* 8-byte header ([streamType, 0,0,0, big-endian payloadSize]) followed by the payload.

View File

@ -24,6 +24,8 @@ import { isNewerVersion } from '../utils/version.js'
import { invalidateAssistantNameCache } from '../../config/inertia.js'
import { KiwixLibraryService } from '#services/kiwix_library_service'
const CUSTOM_APP_HOME_DISPLAY_ORDER = 49
@inject()
export class SystemService {
private static appVersion: string | null = null
@ -319,6 +321,7 @@ export class SystemService {
async getServices({ installedOnly = true }: { installedOnly?: boolean }): Promise<ServiceSlim[]> {
const statuses = await this._syncContainersWithDatabase() // Sync and reuse the fetched status list
await this._syncCustomAppHomeLinks()
const query = Service.query()
.orderBy('display_order', 'asc')
@ -339,6 +342,7 @@ export class SystemService {
'available_update_version',
'auto_update_enabled',
'is_custom',
'is_existing',
'is_user_modified',
'is_deprecated',
'category'
@ -379,6 +383,7 @@ export class SystemService {
available_update_version: service.available_update_version,
auto_update_enabled: service.auto_update_enabled,
is_custom: service.is_custom,
is_existing: service.is_existing,
is_user_modified: service.is_user_modified,
is_deprecated: service.is_deprecated,
category: service.category,
@ -388,6 +393,53 @@ export class SystemService {
return toReturn
}
/**
* Backfill launch metadata for custom app records. Launchable apps get the same pre-system
* Command Center sort order whether they were created by NOMAD or registered from an existing
* Docker container.
*/
private async _syncCustomAppHomeLinks(): Promise<void> {
try {
const customApps = await Service.query()
.where('installed', true)
.where('is_custom', true)
.where('is_dependency_service', false)
for (const service of customApps) {
const inspect = await this.dockerService.inspectContainerByName(service.service_name)
const publishedHostPort = inspect ? DockerService.getFirstPublishedHostPort(inspect) : null
let changed = false
if (publishedHostPort && service.ui_location !== publishedHostPort) {
service.ui_location = publishedHostPort
changed = true
}
if (
(publishedHostPort || service.ui_location || service.custom_url) &&
(service.display_order === null || service.display_order >= 50)
) {
service.display_order = CUSTOM_APP_HOME_DISPLAY_ORDER
changed = true
}
if (!publishedHostPort && service.ui_location === service.service_name) {
service.ui_location = null
changed = true
}
if (changed) {
await service.save()
}
}
} catch (error) {
logger.warn(
`[SystemService] Custom app launch metadata sync failed: ${
error instanceof Error ? error.message : error
}`
)
}
}
static getAppVersion(): string {
try {
if (this.appVersion) {

View File

@ -0,0 +1,10 @@
/**
* Decide whether cookies should be marked Secure from the public URL users visit.
*/
export function shouldUseSecureCookies(publicUrl: string): boolean {
try {
return new URL(publicUrl).protocol === 'https:'
} catch {
return false
}
}

View File

@ -95,6 +95,22 @@ export const customAppValidator = vine.compile(
})
)
export const existingAppValidator = vine.compile(
vine.object({
container_name: vine
.string()
.trim()
.regex(/^[A-Za-z0-9_.-]+$/)
.minLength(1)
.maxLength(100),
friendly_name: vine.string().trim().minLength(1).maxLength(100),
category: vine
.enum(['productivity', 'media', 'security', 'networking', 'utility', 'ai', 'education', 'custom'])
.optional(),
icon: vine.string().trim().optional(),
})
)
// Set or clear an app's custom launch URL. A null/empty value clears the override; a non-empty
// value is normalized + validated to a http(s) URL by normalizeCustomUrl in the controller.
export const setServiceCustomUrlValidator = vine.compile(

View File

@ -1,7 +1,7 @@
import env from '#start/env'
import app from '@adonisjs/core/services/app'
import { Secret } from '@adonisjs/core/helpers'
import { defineConfig } from '@adonisjs/core/http'
import { shouldUseSecureCookies } from '../app/utils/cookie_security.js'
/**
* The app key is used for encrypting cookies, generating signed URLs,
@ -11,6 +11,7 @@ import { defineConfig } from '@adonisjs/core/http'
* changed. Therefore it is recommended to keep the app key secure.
*/
export const appKey = new Secret(env.get('APP_KEY'))
const secureCookies = shouldUseSecureCookies(env.get('URL'))
/**
* The configuration settings used by the HTTP server
@ -34,7 +35,7 @@ export const http = defineConfig({
path: '/',
maxAge: '2h',
httpOnly: true,
secure: app.inProduction,
secure: secureCookies,
sameSite: 'lax',
},
})

View File

@ -1,6 +1,8 @@
import KVStore from '#models/kv_store'
import AdminAuthService from '#services/admin_auth_service'
import { SystemService } from '#services/system_service'
import { defineConfig } from '@adonisjs/inertia'
import type { HttpContext } from '@adonisjs/core/http'
import type { InferSharedProps } from '@adonisjs/inertia/types'
let _assistantNameCache: { value: string; expiresAt: number } | null = null
@ -21,6 +23,11 @@ const inertiaConfig = defineConfig({
sharedData: {
appVersion: () => SystemService.getAppVersion(),
environment: process.env.NODE_ENV || 'production',
admin: ({ session }: HttpContext) => ({
isConfigured: AdminAuthService.isConfigured(),
isLoggedIn: Boolean(session?.get('admin.isLoggedIn')),
user: AdminAuthService.user(),
}),
aiAssistantName: async () => {
const now = Date.now()
if (_assistantNameCache && now < _assistantNameCache.expiresAt) {
@ -46,4 +53,4 @@ export default inertiaConfig
declare module '@adonisjs/inertia/types' {
export interface SharedProps extends InferSharedProps<typeof inertiaConfig> {}
}
}

View File

@ -1,48 +1,44 @@
// import env from '#start/env'
// import app from '@adonisjs/core/services/app'
// import { defineConfig, stores } from '@adonisjs/session'
import env from '#start/env'
import { shouldUseSecureCookies } from '../app/utils/cookie_security.js'
import { defineConfig, stores } from '@adonisjs/session'
// const sessionConfig = defineConfig({
// enabled: false,
// cookieName: 'adonis-session',
const secureCookies = shouldUseSecureCookies(env.get('URL'))
// /**
// * When set to true, the session id cookie will be deleted
// * once the user closes the browser.
// */
// clearWithBrowser: false,
const sessionConfig = defineConfig({
enabled: true,
cookieName: 'nomad-admin-session',
// /**
// * Define how long to keep the session data alive without
// * any activity.
// */
// age: '2h',
/**
* Keep the browser session available until it expires or the admin logs out.
*/
clearWithBrowser: false,
// /**
// * Configuration for session cookie and the
// * cookie store
// */
// cookie: {
// path: '/',
// httpOnly: true,
// secure: app.inProduction,
// sameSite: 'lax',
// },
/**
* Define how long to keep session data alive without activity.
*/
age: '2h',
// /**
// * The store to use. Make sure to validate the environment
// * variable in order to infer the store name without any
// * errors.
// */
// store: env.get('SESSION_DRIVER'),
/**
* HTTP-only cookies keep the admin session marker out of client JavaScript.
*/
cookie: {
path: '/',
httpOnly: true,
secure: secureCookies,
sameSite: 'lax',
},
// /**
// * List of configured stores. Refer documentation to see
// * list of available stores and their config.
// */
// stores: {
// cookie: stores.cookie(),
// },
// })
/**
* Cookie storage avoids adding a users table for a single local admin gate.
*/
store: env.get('SESSION_DRIVER', 'cookie'),
// export default sessionConfig
/**
* List of configured stores.
*/
stores: {
cookie: stores.cookie(),
},
})
export default sessionConfig

View File

@ -0,0 +1,27 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'services'
async up() {
this.schema.alterTable(this.tableName, (table) => {
table.boolean('is_existing').notNullable().defaultTo(false)
})
this.defer(async (db) => {
// Earlier Add Existing App records were saved as custom apps with no generated
// container_config. Backfill those so they keep their external-container semantics.
await db
.from(this.tableName)
.where('is_custom', true)
.whereNull('container_config')
.update({ is_existing: true })
})
}
async down() {
this.schema.alterTable(this.tableName, (table) => {
table.dropColumn('is_existing')
})
}
}

View File

@ -13,6 +13,7 @@ type ServiceSeedRecord = Omit<
| 'available_update_version'
| 'update_checked_at'
| 'metadata'
| 'is_existing'
| 'is_user_modified'
| 'is_deprecated'
| 'custom_url'

View File

@ -25,6 +25,7 @@ interface EnvVar {
export interface CustomAppInitial {
service_name: string
friendly_name: string | null
is_existing?: boolean
image: string
category: string
icon: string
@ -84,6 +85,7 @@ export default function CustomAppModal({
initial = null,
}: CustomAppModalProps) {
const isEdit = mode === 'edit'
const isExisting = isEdit && Boolean(initial?.is_existing)
const [friendlyName, setFriendlyName] = useState('')
const [image, setImage] = useState('')
const [category, setCategory] = useState('custom')
@ -132,6 +134,13 @@ export default function CustomAppModal({
// conflicts, resource/guard warnings and hard blocks so the user gets feedback before submitting.
useEffect(() => {
if (!open) return
if (isExisting) {
setPortConflicts([])
setResourceWarnings([])
setBlocked([])
setCheckingPreflight(false)
return
}
const validPorts = ports
.map((p) => parseInt(p.host, 10))
.filter((p) => !isNaN(p))
@ -162,7 +171,7 @@ export default function CustomAppModal({
}, 400)
return () => clearTimeout(handle)
}, [open, ports, volumes, image])
}, [open, isExisting, ports, volumes, image])
function resetForm() {
setFriendlyName('')
@ -226,7 +235,7 @@ export default function CustomAppModal({
showError('Name and image are required.')
return
}
if (blocked.length > 0) {
if (!isExisting && blocked.length > 0) {
showError('Resolve the blocked issues before installing.')
return
}
@ -282,17 +291,19 @@ export default function CustomAppModal({
const hasWarnings = portConflicts.length > 0 || resourceWarnings.length > 0
const hasBlocks = blocked.length > 0
const canSubmit =
friendlyName.trim() && image.trim() && !hasBlocks && (!hasWarnings || forceInstall)
friendlyName.trim() &&
image.trim() &&
(isExisting || (!hasBlocks && (!hasWarnings || forceInstall)))
return (
<StyledModal
title={isEdit ? 'Edit App' : 'Add Custom App'}
title={isExisting ? 'Edit Existing App' : isEdit ? 'Edit App' : 'Add Custom App'}
open={open}
onCancel={handleClose}
cancelText="Cancel"
onConfirm={handleSubmit}
confirmVariant='primary'
confirmText={isEdit ? 'Save & Recreate' : 'Install'}
confirmText={isExisting ? 'Save' : isEdit ? 'Save & Recreate' : 'Install'}
confirmIcon="IconBrandDocker"
confirmLoading={submitting}
confirmDisabled={!canSubmit}

View File

@ -0,0 +1,162 @@
import { useEffect, useState } from 'react'
import StyledModal from './StyledModal'
import StyledButton from './StyledButton'
import api from '~/lib/api'
import Input from './inputs/Input'
import Select from './inputs/Select'
import DynamicIcon, { DynamicIconName } from './DynamicIcon'
const CATEGORY_OPTIONS = [
{ value: 'custom', label: 'Custom' },
{ value: 'productivity', label: 'Productivity' },
{ value: 'media', label: 'Media' },
{ value: 'security', label: 'Security' },
{ value: 'networking', label: 'Networking' },
{ value: 'utility', label: 'Utility' },
{ value: 'ai', label: 'AI' },
{ value: 'education', label: 'Education' },
]
const ICON_OPTIONS = [
{ value: 'IconBrandDocker', label: 'Docker (default)' },
{ value: 'IconBox', label: 'Box' },
{ value: 'IconServer', label: 'Server' },
{ value: 'IconDatabase', label: 'Database' },
{ value: 'IconCode', label: 'Code' },
{ value: 'IconTool', label: 'Tool' },
{ value: 'IconWorld', label: 'Web' },
{ value: 'IconShieldLock', label: 'Security' },
{ value: 'IconMovie', label: 'Media' },
{ value: 'IconBook', label: 'Book' },
{ value: 'IconNotes', label: 'Notes' },
{ value: 'IconCpu', label: 'Compute' },
{ value: 'IconRobot', label: 'AI / Bot' },
{ value: 'IconWifi', label: 'Network' },
{ value: 'IconHome', label: 'Home' },
]
interface ExistingAppModalProps {
open: boolean
onClose: () => void
onCreated: (serviceName: string) => void
showError: (msg: string) => void
}
export default function ExistingAppModal({
open,
onClose,
onCreated,
showError,
}: ExistingAppModalProps) {
const [containerName, setContainerName] = useState('')
const [friendlyName, setFriendlyName] = useState('')
const [category, setCategory] = useState('custom')
const [icon, setIcon] = useState('IconBrandDocker')
const [submitting, setSubmitting] = useState(false)
useEffect(() => {
if (!open) return
setContainerName('')
setFriendlyName('')
setCategory('custom')
setIcon('IconBrandDocker')
setSubmitting(false)
}, [open])
async function handleSubmit() {
if (!containerName.trim() || !friendlyName.trim()) {
showError('Container name and display name are required.')
return
}
setSubmitting(true)
try {
const result = await api.createExistingApp({
container_name: containerName.trim(),
friendly_name: friendlyName.trim(),
category,
icon,
})
if (result?.success && result.service_name) {
onCreated(result.service_name)
} else {
showError(result?.message || 'Failed to add existing app.')
}
} catch (err: any) {
showError(err?.message || 'Unexpected error adding existing app.')
} finally {
setSubmitting(false)
}
}
return (
<StyledModal
title="Add Existing App"
open={open}
onCancel={onClose}
cancelText="Cancel"
onConfirm={handleSubmit}
confirmVariant="primary"
confirmText="Add"
confirmIcon="IconBrandDocker"
confirmLoading={submitting}
confirmDisabled={!containerName.trim() || !friendlyName.trim()}
large
>
<div className="space-y-6 text-sm">
<div className="grid grid-cols-2 gap-4">
<Input
name="containerName"
label="Container Name"
placeholder="e.g. myapp"
value={containerName}
onChange={(e) => setContainerName(e.target.value)}
required
/>
<Input
name="friendlyName"
label="Display Name"
placeholder="My App"
value={friendlyName}
onChange={(e) => setFriendlyName(e.target.value)}
required
/>
</div>
<div className="grid grid-cols-2 gap-4 items-start">
<Select
name="category"
label="Category"
helpText="Select the most relevant category for this app. This helps with visual organization and filtering."
value={category}
onChange={(newVal) => setCategory(newVal)}
options={CATEGORY_OPTIONS}
/>
<div className="flex items-end gap-2">
<Select
name="icon"
label="Icon"
helpText="Pick an icon shown on the app card."
value={icon}
onChange={(newVal) => setIcon(newVal)}
options={ICON_OPTIONS}
className="flex-1 min-w-0"
/>
<div
className="flex-shrink-0 flex items-center justify-center h-[42px] w-[42px] rounded-md border border-border-default bg-surface-secondary"
title="Icon preview"
>
<DynamicIcon icon={icon as DynamicIconName} className="h-6 w-6 text-desert-green" />
</div>
</div>
</div>
<p className="text-xs text-text-muted">
Add an existing Docker container by its name so it appears in the Supply Depot.
Published containers also appear on the home dashboard.
</p>
</div>
</StyledModal>
)
}

View File

@ -1165,14 +1165,48 @@ class API {
cpus?: number
force?: boolean
}) {
return catchInternal(async () => {
try {
const response = await this.client.post<{
success: boolean
message: string
service_name: string
}>('/system/services/custom', payload)
return response.data
})()
} catch (error) {
if (error instanceof AxiosError && error.response?.data) {
return error.response.data as {
success: false
message: string
warnings?: string[]
portConflicts?: Array<{ port: number; usedBy: string }>
blocked?: string[]
}
}
console.error('Error creating custom app:', error)
return undefined
}
}
async createExistingApp(payload: {
container_name: string
friendly_name: string
category?: string
icon?: string
}) {
try {
const response = await this.client.post<{
success: boolean
message: string
service_name: string
}>('/system/services/existing', payload)
return response.data
} catch (error) {
if (error instanceof AxiosError && error.response?.data) {
return error.response.data as { success: false; message: string }
}
console.error('Error adding existing app:', error)
return undefined
}
}
async setServiceCustomUrl(service_name: string, custom_url: string | null) {
@ -1248,6 +1282,7 @@ class API {
app: {
service_name: string
friendly_name: string | null
is_existing: boolean
image: string
category: string
icon: string
@ -1275,14 +1310,26 @@ class API {
cpus?: number
force?: boolean
}) {
return catchInternal(async () => {
try {
const response = await this.client.put<{
success: boolean
message: string
service_name: string
}>('/system/services/custom', payload)
return response.data
})()
} catch (error) {
if (error instanceof AxiosError && error.response?.data) {
return error.response.data as {
success: false
message: string
warnings?: string[]
portConflicts?: Array<{ port: number; usedBy: string }>
blocked?: string[]
}
}
console.error('Error updating custom app:', error)
return undefined
}
}
}

View File

@ -7,7 +7,8 @@ import {
IconSettings,
IconWifiOff,
} from '@tabler/icons-react'
import { Head, Link, router, usePage } from '@inertiajs/react'
import { FormEvent, useEffect, useMemo, useState } from 'react'
import { Head, Link, router, useForm, usePage } from '@inertiajs/react'
import AppLayout from '~/layouts/AppLayout'
import { getServiceLink } from '~/lib/navigation'
import { ServiceSlim } from '../../types/services'
@ -21,9 +22,13 @@ import {
import { useQueryClient } from '@tanstack/react-query'
import api from '~/lib/api'
import Alert from '~/components/Alert'
import Input from '~/components/inputs/Input'
import StyledModal from '~/components/StyledModal'
import WhatsNewBanner from '~/components/WhatsNewBanner'
import { SERVICE_NAMES } from '../../constants/service_names'
const APP_FALLBACK_DISPLAY_ORDER = 49
// Maps is a Core Capability (display_order: 4)
const MAPS_ITEM = {
label: 'Maps',
@ -95,6 +100,8 @@ const SYSTEM_ITEMS = [
},
]
const ADMIN_ONLY_LABELS = new Set(['Easy Setup', 'Supply Depot', 'Settings'])
interface DashboardItem {
label: string
to: string
@ -119,7 +126,52 @@ export default function Home(props: {
const updateInfo = useUpdateAvailable();
const rerunBanner = useBenchmarkRerunBanner()
const queryClient = useQueryClient()
const { aiAssistantName } = usePage<{ aiAssistantName: string }>().props
const { admin, aiAssistantName } = usePage<{
admin: { isConfigured: boolean; isLoggedIn: boolean; user: string }
aiAssistantName: string
}>().props
const [adminLoginOpen, setAdminLoginOpen] = useState(false)
const adminLoginRedirect = useMemo(() => {
const params = new URLSearchParams(window.location.search)
const redirectTo = params.get('redirect') || '/home'
if (!redirectTo.startsWith('/') || redirectTo.startsWith('//')) {
return '/home'
}
return redirectTo
}, [])
const adminLoginForm = useForm({
user: admin.user || 'admin',
password: '',
redirect: adminLoginRedirect,
})
useEffect(() => {
const params = new URLSearchParams(window.location.search)
if (params.get('adminLogin') === '1' && !admin.isLoggedIn) {
setAdminLoginOpen(true)
}
}, [admin.isLoggedIn])
const handleAdminLogin = (event?: FormEvent<HTMLFormElement>) => {
event?.preventDefault()
adminLoginForm.post('/admin/login', {
preserveScroll: true,
onSuccess: () => setAdminLoginOpen(false),
onFinish: () => adminLoginForm.reset('password'),
})
}
const openAdminLogin = () => {
adminLoginForm.clearErrors()
adminLoginForm.setData('redirect', '/home')
setAdminLoginOpen(true)
}
const handleAdminLogout = () => {
router.post('/admin/logout', {}, { preserveScroll: true })
}
const handleDismissRerunBanner = async () => {
await api.updateSetting('benchmark.rerunBannerDismissed', true)
@ -153,7 +205,8 @@ export default function Home(props: {
<IconWifiOff size={48} />
),
installed: service.installed,
displayOrder: service.display_order ?? 100,
// Launchable apps without an explicit order still belong before system tiles.
displayOrder: service.display_order ?? APP_FALLBACK_DISPLAY_ORDER,
poweredBy: service.powered_by ?? null,
})
})
@ -168,8 +221,10 @@ export default function Home(props: {
items.push(DRUG_REFERENCE_ITEM)
}
// Add system items
items.push(...SYSTEM_ITEMS)
// Add system items, hiding admin-only controls until the admin logs in.
items.push(
...SYSTEM_ITEMS.filter((item) => admin.isLoggedIn || !ADMIN_ONLY_LABELS.has(item.label))
)
// Sort all items by display order
items.sort((a, b) => a.displayOrder - b.displayOrder)
@ -177,8 +232,72 @@ export default function Home(props: {
return (
<AppLayout>
<Head title="Command Center" />
<div className="fixed left-4 top-4 z-40">
<button
type="button"
onClick={admin.isLoggedIn ? handleAdminLogout : openAdminLogin}
className="inline-flex h-12 items-center gap-2 rounded-md border-2 border-desert-green bg-surface-primary px-3 text-sm font-semibold text-desert-green shadow-sm transition-colors hover:bg-desert-green hover:text-white focus:outline-none focus:ring-2 focus:ring-desert-green-light focus:ring-offset-2 focus:ring-offset-desert-sand"
>
<span
aria-hidden="true"
className="h-6 w-6 bg-current"
style={{
maskImage: "url('/admin-profile.png')",
maskPosition: 'center',
maskRepeat: 'no-repeat',
maskSize: 'contain',
WebkitMaskImage: "url('/admin-profile.png')",
WebkitMaskPosition: 'center',
WebkitMaskRepeat: 'no-repeat',
WebkitMaskSize: 'contain',
}}
/>
<span>{admin.isLoggedIn ? 'Admin Logout' : 'Admin Login'}</span>
</button>
</div>
<StyledModal
open={adminLoginOpen}
title="Admin Login"
cancelText="Cancel"
confirmText="Log In"
confirmIcon="IconShieldLock"
confirmLoading={adminLoginForm.processing}
confirmDisabled={!admin.isConfigured}
onCancel={() => setAdminLoginOpen(false)}
onClose={() => setAdminLoginOpen(false)}
onConfirm={() => handleAdminLogin()}
>
<form id="admin-login-form" className="space-y-4 text-left" onSubmit={handleAdminLogin}>
<Input
name="user"
label="User"
value={adminLoginForm.data.user}
onChange={(event) => adminLoginForm.setData('user', event.target.value)}
autoComplete="username"
required
/>
<Input
name="password"
label="Password"
type="password"
value={adminLoginForm.data.password}
onChange={(event) => adminLoginForm.setData('password', event.target.value)}
autoComplete="current-password"
error={Boolean(adminLoginForm.errors.password)}
required
/>
{adminLoginForm.errors.password && (
<p className="text-sm font-medium text-desert-red">{adminLoginForm.errors.password}</p>
)}
{!admin.isConfigured && !adminLoginForm.errors.password && (
<p className="text-sm font-medium text-desert-orange-dark">
Admin login is not configured.
</p>
)}
</form>
</StyledModal>
{
updateInfo?.updateAvailable && (
admin.isLoggedIn && updateInfo?.updateAvailable && (
<div className='flex justify-center items-center p-4 w-full'>
<Alert
title="An update is available for Project NOMAD!"
@ -197,7 +316,7 @@ export default function Home(props: {
}
<WhatsNewBanner />
{
rerunBanner?.show && (
admin.isLoggedIn && rerunBanner?.show && (
<div className='flex justify-center items-center px-4 pt-4 w-full'>
<Alert
title="Your benchmark can be re-scored with Score v2"

View File

@ -28,6 +28,7 @@ import InstallActivityFeed from '~/components/InstallActivityFeed'
import LoadingSpinner from '~/components/LoadingSpinner'
import Alert from '~/components/Alert'
import CustomAppModal, { CustomAppInitial } from '~/components/CustomAppModal'
import ExistingAppModal from '~/components/ExistingAppModal'
import AppUrlModal from '~/components/AppUrlModal'
import ServiceLogsModal from '~/components/ServiceLogsModal'
import ServiceStatsModal from '~/components/ServiceStatsModal'
@ -108,6 +109,7 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
const [checkingUpdates, setCheckingUpdates] = useState(false)
const [openDropdown, setOpenDropdown] = useState<string | null>(null)
const [customAppOpen, setCustomAppOpen] = useState(false)
const [existingAppOpen, setExistingAppOpen] = useState(false)
const [editApp, setEditApp] = useState<CustomAppInitial | null>(null)
// App whose custom launch URL is being configured (null while the modal is closed).
const [urlApp, setUrlApp] = useState<ServiceSlim | null>(null)
@ -324,6 +326,11 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
// Page will reload when installation completes via broadcast
}
function handleExistingAppCreated() {
setExistingAppOpen(false)
window.location.reload()
}
async function handleEdit(service: ServiceSlim) {
setOpenDropdown(null)
setLoading(true)
@ -442,6 +449,13 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
>
Add Custom App
</StyledButton>
<StyledButton
icon="IconBrandDocker"
variant="outline"
onClick={() => setExistingAppOpen(true)}
>
Add Existing App
</StyledButton>
</div>
{/* Category filters */}
@ -689,7 +703,7 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
{/* Delete custom app modal */}
{modal?.type === 'delete' && (
<StyledModal
title={`Delete ${modal.service.friendly_name ?? modal.service.service_name}`}
title={`${modal.service.is_existing ? 'Remove' : 'Delete'} ${modal.service.friendly_name ?? modal.service.service_name}`}
open
onCancel={() => {
if (loading) return
@ -697,24 +711,33 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
setModal(null)
}}
onConfirm={() => handleDelete(modal.service)}
confirmText="Delete"
confirmText={modal.service.is_existing ? 'Remove' : 'Delete'}
confirmIcon="IconTrash"
confirmVariant="danger"
confirmLoading={loading}
icon={<IconAlertTriangle className="text-desert-red" size={40} />}
>
<div className="space-y-3 text-sm text-text-muted">
<p className="font-semibold text-desert-red">This will permanently remove this custom app.</p>
<p>The container will be stopped and removed. Host volume data will remain on disk.</p>
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={removeImage}
onChange={(e) => setRemoveImage(e.target.checked)}
className="accent-desert-red h-4 w-4 rounded"
/>
<span className="text-text-muted text-xs">Also remove the Docker image to reclaim disk space</span>
</label>
{modal.service.is_existing ? (
<>
<p className="font-semibold text-desert-red">This will remove this existing app from Supply Depot.</p>
<p>The Docker container and image will not be stopped, removed, or changed.</p>
</>
) : (
<>
<p className="font-semibold text-desert-red">This will permanently remove this custom app.</p>
<p>The container will be stopped and removed. Host volume data will remain on disk.</p>
<label className="flex items-center gap-2 cursor-pointer select-none">
<input
type="checkbox"
checked={removeImage}
onChange={(e) => setRemoveImage(e.target.checked)}
className="accent-desert-red h-4 w-4 rounded"
/>
<span className="text-text-muted text-xs">Also remove the Docker image to reclaim disk space</span>
</label>
</>
)}
</div>
</StyledModal>
)}
@ -799,6 +822,13 @@ export default function SupplyDepotPage(props: { system: { services: ServiceSlim
showError={showError}
/>
<ExistingAppModal
open={existingAppOpen}
onClose={() => setExistingAppOpen(false)}
onCreated={handleExistingAppCreated}
showError={showError}
/>
{/* Custom app edit modal */}
<CustomAppModal
open={!!editApp}
@ -877,6 +907,7 @@ function AppCard({
const isRunning = service.status === 'running'
const isStopped = service.installed && !isRunning
const catColor = service.category ? CATEGORY_COLORS[service.category] ?? CATEGORY_COLORS.custom : CATEGORY_COLORS.custom
const customKindLabel = service.is_existing ? 'existing' : 'custom'
const isDropdownOpen = openDropdown === service.service_name
// Port pill: an ui_location may carry an explicit scheme ("https:8480") — show just the port,
// with a lock when it's served over HTTPS, rather than the raw "https:8480" string.
@ -973,7 +1004,7 @@ function AppCard({
)}
{service.is_custom ? (
<span className="text-xs px-2 py-0.5 rounded-full font-medium bg-surface-secondary text-text-muted border border-surface-secondary">
custom
{customKindLabel}
</span>
) : null}
{service.is_user_modified && !service.is_custom ? (
@ -1111,12 +1142,19 @@ function AppCard({
onClick={onUpdateVersion}
/>
) : null}
{service.is_custom ? (
{service.is_custom && !service.is_existing ? (
<DropdownItem icon={<IconCloudDownload className="h-4 w-4" />} label="Update (pull latest)" onClick={onUpdate} />
) : null}
<DropdownItem icon={<IconRefresh className="h-4 w-4 text-desert-orange" />} label="Force Reinstall" onClick={onReinstall} danger />
{!service.is_existing ? (
<DropdownItem icon={<IconRefresh className="h-4 w-4 text-desert-orange" />} label="Force Reinstall" onClick={onReinstall} danger />
) : null}
{service.is_custom ? (
<DropdownItem icon={<IconTrash className="h-4 w-4 text-desert-red" />} label="Delete" onClick={onDelete} danger />
<DropdownItem
icon={<IconTrash className="h-4 w-4 text-desert-red" />}
label={service.is_existing ? 'Remove' : 'Delete'}
onClick={onDelete}
danger
/>
) : (
<DropdownItem icon={<IconTrash className="h-4 w-4 text-desert-red" />} label="Uninstall" onClick={onUninstall} danger />
)}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

@ -33,7 +33,15 @@ export default await Env.create(new URL('../', import.meta.url), {
| Variables for configuring session package
|----------------------------------------------------------
*/
//SESSION_DRIVER: Env.schema.enum(['cookie', 'memory'] as const),
SESSION_DRIVER: Env.schema.enum.optional(['cookie'] as const),
/*
|----------------------------------------------------------
| Variables for configuring the built-in admin login
|----------------------------------------------------------
*/
ADMIN_USER: Env.schema.string.optional(),
ADMIN_PASS: Env.schema.string.optional(),
/*
|----------------------------------------------------------

View File

@ -37,7 +37,7 @@ server.use([
*/
router.use([
() => import('@adonisjs/core/bodyparser_middleware'),
// () => import('@adonisjs/session/session_middleware'),
() => import('@adonisjs/session/session_middleware'),
() => import('@adonisjs/shield/shield_middleware'),
() => import('#middleware/compression_middleware'),
])
@ -46,4 +46,6 @@ router.use([
* Named middleware collection must be explicitly assigned to
* the routes or the routes group.
*/
export const middleware = router.named({})
export const middleware = router.named({
admin: () => import('#middleware/require_admin_middleware'),
})

View File

@ -7,6 +7,7 @@
|
*/
import BenchmarkController from '#controllers/benchmark_controller'
import AdminAuthController from '#controllers/admin_auth_controller'
import ChatsController from '#controllers/chats_controller'
import ConditionsController from '#controllers/conditions_controller'
import DocsController from '#controllers/docs_controller'
@ -28,6 +29,7 @@ import ZimController from '#controllers/zim_controller'
import router from '@adonisjs/core/services/router'
import transmit from '@adonisjs/transmit/services/main'
import { documented } from '#start/openapi/documented'
import { middleware } from '#start/kernel'
import {
remoteDownloadValidator,
remoteDownloadWithMetadataValidator,
@ -64,6 +66,7 @@ import {
setServiceAutoUpdateValidator,
preflightCustomValidator,
customAppValidator,
existingAppValidator,
setServiceCustomUrlValidator,
deleteCustomAppValidator,
uninstallServiceValidator,
@ -100,22 +103,29 @@ router.get('/home', [HomeController, 'home'])
router.on('/about').renderInertia('about')
router.get('/chat', [ChatsController, 'inertia'])
router.get('/maps', [MapsController, 'index'])
router.get('/supply-depot', [SupplyDepotController, 'index'])
router.post('/admin/login', [AdminAuthController, 'login'])
router.post('/admin/logout', [AdminAuthController, 'logout'])
router.get('/supply-depot', [SupplyDepotController, 'index']).use(middleware.admin())
router.on('/knowledge-base').redirectToPath('/chat?knowledge_base=true') // redirect for legacy knowledge-base links
router.get('/easy-setup', [EasySetupController, 'index'])
router.get('/easy-setup/complete', [EasySetupController, 'complete'])
router.get('/easy-setup', [EasySetupController, 'index']).use(middleware.admin())
router.get('/easy-setup/complete', [EasySetupController, 'complete']).use(middleware.admin())
documented(
router.get('/api/easy-setup/curated-categories', [EasySetupController, 'listCuratedCategories']),
router
.get('/api/easy-setup/curated-categories', [EasySetupController, 'listCuratedCategories'])
.use(middleware.admin()),
{
summary: 'List curated easy-setup categories',
tags: ['easy-setup'],
}
)
documented(router.post('/api/manifests/refresh', [EasySetupController, 'refreshManifests']), {
summary: 'Refresh content manifests',
tags: ['easy-setup'],
})
documented(
router.post('/api/manifests/refresh', [EasySetupController, 'refreshManifests']).use(middleware.admin()),
{
summary: 'Refresh content manifests',
tags: ['easy-setup'],
}
)
router
.group(() => {
documented(router.post('/check', [CollectionUpdatesController, 'checkForUpdates']), {
@ -134,6 +144,7 @@ router
})
})
.prefix('/api/content-updates')
.use(middleware.admin())
router
.group(() => {
@ -151,6 +162,7 @@ router
router.get('/advanced', [SettingsController, 'advanced'])
})
.prefix('/settings')
.use(middleware.admin())
router
.group(() => {
@ -180,26 +192,26 @@ router
summary: 'Fetch the latest map collections',
tags: ['maps'],
})
documented(router.post('/download-base-assets', [MapsController, 'downloadBaseAssets']), {
documented(router.post('/download-base-assets', [MapsController, 'downloadBaseAssets']).use(middleware.admin()), {
summary: 'Download base map assets',
tags: ['maps'],
request: remoteDownloadValidatorOptional,
})
documented(router.post('/setup-world-basemap', [MapsController, 'setupWorldBasemap']), {
documented(router.post('/setup-world-basemap', [MapsController, 'setupWorldBasemap']).use(middleware.admin()), {
summary: 'Provision the world base map',
tags: ['maps'],
})
documented(router.post('/download-remote', [MapsController, 'downloadRemote']), {
documented(router.post('/download-remote', [MapsController, 'downloadRemote']).use(middleware.admin()), {
summary: 'Queue a remote map download',
tags: ['maps'],
request: remoteDownloadValidator,
})
documented(router.post('/download-remote-preflight', [MapsController, 'downloadRemotePreflight']), {
documented(router.post('/download-remote-preflight', [MapsController, 'downloadRemotePreflight']).use(middleware.admin()), {
summary: 'Preflight a remote map download',
tags: ['maps'],
request: remoteDownloadValidator,
})
documented(router.post('/download-collection', [MapsController, 'downloadCollection']), {
documented(router.post('/download-collection', [MapsController, 'downloadCollection']).use(middleware.admin()), {
summary: 'Download a map collection',
tags: ['maps'],
request: downloadCollectionValidator,
@ -208,7 +220,7 @@ router
summary: 'Get global map information',
tags: ['maps'],
})
documented(router.post('/download-global-map', [MapsController, 'downloadGlobalMap']), {
documented(router.post('/download-global-map', [MapsController, 'downloadGlobalMap']).use(middleware.admin()), {
summary: 'Download the global map',
tags: ['maps'],
})
@ -220,12 +232,12 @@ router
summary: 'List country groups',
tags: ['maps'],
})
documented(router.post('/extract-preflight', [MapsController, 'extractPreflight']), {
documented(router.post('/extract-preflight', [MapsController, 'extractPreflight']).use(middleware.admin()), {
summary: 'Preflight a map region extraction',
tags: ['maps'],
request: mapExtractPreflightValidator,
})
documented(router.post('/extract', [MapsController, 'extractRegion']), {
documented(router.post('/extract', [MapsController, 'extractRegion']).use(middleware.admin()), {
summary: 'Extract a map region',
tags: ['maps'],
request: mapExtractValidator,
@ -246,7 +258,7 @@ router
summary: 'Delete a map marker',
tags: ['maps'],
})
documented(router.delete('/:filename', [MapsController, 'delete']), {
documented(router.delete('/:filename', [MapsController, 'delete']).use(middleware.admin()), {
summary: 'Delete a map file',
tags: ['maps'],
params: filenameParamValidator,
@ -533,105 +545,110 @@ router
summary: 'List services',
tags: ['system'],
})
documented(router.post('/services/affect', [SystemController, 'affectService']), {
documented(router.post('/services/affect', [SystemController, 'affectService']).use(middleware.admin()), {
summary: 'Start, stop, or restart a service',
tags: ['system'],
request: affectServiceValidator,
})
documented(router.post('/services/install', [SystemController, 'installService']), {
documented(router.post('/services/install', [SystemController, 'installService']).use(middleware.admin()), {
summary: 'Install a service',
tags: ['system'],
request: installServiceValidator,
})
documented(router.post('/services/force-reinstall', [SystemController, 'forceReinstallService']), {
documented(router.post('/services/force-reinstall', [SystemController, 'forceReinstallService']).use(middleware.admin()), {
summary: 'Force reinstall a service',
tags: ['system'],
request: installServiceValidator,
})
documented(router.post('/services/uninstall', [SystemController, 'uninstallService']), {
documented(router.post('/services/uninstall', [SystemController, 'uninstallService']).use(middleware.admin()), {
summary: 'Uninstall a service',
tags: ['system'],
request: uninstallServiceValidator,
})
documented(router.post('/services/check-updates', [SystemController, 'checkServiceUpdates']), {
documented(router.post('/services/check-updates', [SystemController, 'checkServiceUpdates']).use(middleware.admin()), {
summary: 'Check for service updates',
tags: ['system'],
})
documented(router.get('/services/preflight', [SystemController, 'preflightCheck']), {
documented(router.get('/services/preflight', [SystemController, 'preflightCheck']).use(middleware.admin()), {
summary: 'Preflight a service install',
tags: ['system'],
query: preflightValidator,
})
documented(router.get('/services/suggest-port', [SystemController, 'suggestCustomPort']), {
documented(router.get('/services/suggest-port', [SystemController, 'suggestCustomPort']).use(middleware.admin()), {
summary: 'Suggest an available custom port',
tags: ['system'],
})
documented(router.post('/services/preflight-custom', [SystemController, 'preflightCustomApp']), {
documented(router.post('/services/preflight-custom', [SystemController, 'preflightCustomApp']).use(middleware.admin()), {
summary: 'Preflight a custom app install',
tags: ['system'],
request: preflightCustomValidator,
})
documented(router.post('/services/custom', [SystemController, 'createCustomApp']), {
documented(router.post('/services/custom', [SystemController, 'createCustomApp']).use(middleware.admin()), {
summary: 'Create a custom app',
tags: ['system'],
request: customAppValidator,
})
documented(router.put('/services/custom', [SystemController, 'updateCustomApp']), {
documented(router.post('/services/existing', [SystemController, 'createExistingApp']).use(middleware.admin()), {
summary: 'Add an existing Docker container as an app',
tags: ['system'],
request: existingAppValidator,
})
documented(router.put('/services/custom', [SystemController, 'updateCustomApp']).use(middleware.admin()), {
summary: 'Update a custom app',
tags: ['system'],
request: updateCustomAppValidator,
})
documented(router.post('/services/custom/update', [SystemController, 'updateCustomApp_pullLatest']), {
documented(router.post('/services/custom/update', [SystemController, 'updateCustomApp_pullLatest']).use(middleware.admin()), {
summary: 'Pull the latest version of a custom app',
tags: ['system'],
request: installServiceValidator,
})
documented(router.delete('/services/custom', [SystemController, 'deleteCustomApp']), {
documented(router.delete('/services/custom', [SystemController, 'deleteCustomApp']).use(middleware.admin()), {
summary: 'Delete a custom app',
tags: ['system'],
request: deleteCustomAppValidator,
})
documented(router.get('/services/custom/:name', [SystemController, 'getCustomApp']), {
documented(router.get('/services/custom/:name', [SystemController, 'getCustomApp']).use(middleware.admin()), {
summary: 'Get a custom app',
tags: ['system'],
})
documented(router.put('/services/custom-url', [SystemController, 'setServiceCustomUrl']), {
documented(router.put('/services/custom-url', [SystemController, 'setServiceCustomUrl']).use(middleware.admin()), {
summary: 'Set a service custom URL',
tags: ['system'],
request: setServiceCustomUrlValidator,
})
documented(router.get('/services/:name/logs', [SystemController, 'getServiceLogs']), {
documented(router.get('/services/:name/logs', [SystemController, 'getServiceLogs']).use(middleware.admin()), {
summary: 'Get service logs',
tags: ['system'],
query: serviceLogsValidator,
})
documented(router.get('/services/:name/stats', [SystemController, 'getServiceStats']), {
documented(router.get('/services/:name/stats', [SystemController, 'getServiceStats']).use(middleware.admin()), {
summary: 'Get service stats',
tags: ['system'],
})
documented(router.get('/services/:name/available-versions', [SystemController, 'getAvailableVersions']), {
documented(router.get('/services/:name/available-versions', [SystemController, 'getAvailableVersions']).use(middleware.admin()), {
summary: 'List available service versions',
tags: ['system'],
})
documented(router.post('/services/update', [SystemController, 'updateService']), {
documented(router.post('/services/update', [SystemController, 'updateService']).use(middleware.admin()), {
summary: 'Update a service',
tags: ['system'],
request: updateServiceValidator,
})
documented(router.post('/services/auto-update', [SystemController, 'setServiceAutoUpdate']), {
documented(router.post('/services/auto-update', [SystemController, 'setServiceAutoUpdate']).use(middleware.admin()), {
summary: 'Set service auto-update',
tags: ['system'],
request: setServiceAutoUpdateValidator,
})
documented(router.get('/apps/auto-update/status', [SystemController, 'getAppAutoUpdateStatus']), {
documented(router.get('/apps/auto-update/status', [SystemController, 'getAppAutoUpdateStatus']).use(middleware.admin()), {
summary: 'Get app auto-update status',
tags: ['system'],
})
documented(router.get('/content/auto-update/status', [SystemController, 'getContentAutoUpdateStatus']), {
documented(router.get('/content/auto-update/status', [SystemController, 'getContentAutoUpdateStatus']).use(middleware.admin()), {
summary: 'Get content auto-update status',
tags: ['system'],
})
documented(router.post('/subscribe-release-notes', [SystemController, 'subscribeToReleaseNotes']), {
documented(router.post('/subscribe-release-notes', [SystemController, 'subscribeToReleaseNotes']).use(middleware.admin()), {
summary: 'Subscribe to release notes',
tags: ['system'],
request: subscribeToReleaseNotesValidator,
@ -641,19 +658,19 @@ router
tags: ['system'],
query: checkLatestVersionValidator,
})
documented(router.post('/update', [SystemController, 'requestSystemUpdate']), {
documented(router.post('/update', [SystemController, 'requestSystemUpdate']).use(middleware.admin()), {
summary: 'Request a system update',
tags: ['system'],
})
documented(router.get('/update/status', [SystemController, 'getSystemUpdateStatus']), {
documented(router.get('/update/status', [SystemController, 'getSystemUpdateStatus']).use(middleware.admin()), {
summary: 'Get system update status',
tags: ['system'],
})
documented(router.get('/update/logs', [SystemController, 'getSystemUpdateLogs']), {
documented(router.get('/update/logs', [SystemController, 'getSystemUpdateLogs']).use(middleware.admin()), {
summary: 'Get system update logs',
tags: ['system'],
})
documented(router.get('/auto-update/status', [SystemController, 'getAutoUpdateStatus']), {
documented(router.get('/auto-update/status', [SystemController, 'getAutoUpdateStatus']).use(middleware.admin()), {
summary: 'Get system auto-update status',
tags: ['system'],
})
@ -685,18 +702,18 @@ router
summary: 'List curated ZIM categories',
tags: ['zim'],
})
documented(router.post('/download-remote', [ZimController, 'downloadRemote']), {
documented(router.post('/download-remote', [ZimController, 'downloadRemote']).use(middleware.admin()), {
summary: 'Queue a remote ZIM download',
tags: ['zim'],
request: remoteDownloadWithMetadataValidator,
})
documented(router.post('/download-category-tier', [ZimController, 'downloadCategoryTier']), {
documented(router.post('/download-category-tier', [ZimController, 'downloadCategoryTier']).use(middleware.admin()), {
summary: 'Download a ZIM category tier',
tags: ['zim'],
request: downloadCategoryTierValidator,
})
documented(router.post('/upload', [ZimController, 'upload']), {
documented(router.post('/upload', [ZimController, 'upload']).use(middleware.admin()), {
summary: 'Upload a ZIM file',
tags: ['zim'],
})
@ -704,7 +721,7 @@ router
summary: 'Get Wikipedia ZIM state',
tags: ['zim'],
})
documented(router.post('/wikipedia/select', [ZimController, 'selectWikipedia']), {
documented(router.post('/wikipedia/select', [ZimController, 'selectWikipedia']).use(middleware.admin()), {
summary: 'Select a Wikipedia ZIM edition',
tags: ['zim'],
request: selectWikipediaValidator,
@ -714,12 +731,12 @@ router
summary: 'List custom ZIM libraries',
tags: ['zim'],
})
documented(router.post('/custom-libraries', [ZimController, 'addCustomLibrary']), {
documented(router.post('/custom-libraries', [ZimController, 'addCustomLibrary']).use(middleware.admin()), {
summary: 'Add a custom ZIM library',
tags: ['zim'],
request: addCustomLibraryValidator,
})
documented(router.delete('/custom-libraries/:id', [ZimController, 'removeCustomLibrary']), {
documented(router.delete('/custom-libraries/:id', [ZimController, 'removeCustomLibrary']).use(middleware.admin()), {
summary: 'Remove a custom ZIM library',
tags: ['zim'],
params: idParamValidator,
@ -730,12 +747,12 @@ router
query: browseLibraryValidator,
})
documented(router.post('/rescan-library', [ZimController, 'rescanLibrary']), {
documented(router.post('/rescan-library', [ZimController, 'rescanLibrary']).use(middleware.admin()), {
summary: 'Rescan the ZIM library',
tags: ['zim'],
})
documented(router.delete('/:filename', [ZimController, 'delete']), {
documented(router.delete('/:filename', [ZimController, 'delete']).use(middleware.admin()), {
summary: 'Delete a ZIM file',
tags: ['zim'],
params: filenameParamValidator,
@ -749,11 +766,11 @@ router
summary: 'List creator packs',
tags: ['creator-packs'],
})
documented(router.post('/:id/install', [CreatorPacksController, 'install']), {
documented(router.post('/:id/install', [CreatorPacksController, 'install']).use(middleware.admin()), {
summary: 'Install a creator pack',
tags: ['creator-packs'],
})
documented(router.delete('/:id', [CreatorPacksController, 'uninstall']), {
documented(router.delete('/:id', [CreatorPacksController, 'uninstall']).use(middleware.admin()), {
summary: 'Uninstall a creator pack',
tags: ['creator-packs'],
})

View File

@ -0,0 +1,20 @@
import * as assert from 'node:assert/strict'
import { test } from 'node:test'
import { shouldUseSecureCookies } from '../../app/utils/cookie_security.js'
test('enables secure cookies for HTTPS public URLs', () => {
assert.equal(shouldUseSecureCookies('https://nomad.example.com'), true)
assert.equal(shouldUseSecureCookies('https://nomad.example.com:8443/admin'), true)
})
test('disables secure cookies for HTTP public URLs', () => {
assert.equal(shouldUseSecureCookies('http://home'), false)
assert.equal(shouldUseSecureCookies('http://localhost:8080'), false)
assert.equal(shouldUseSecureCookies('http://192.168.1.10:8080'), false)
})
test('disables secure cookies when the public URL is invalid', () => {
assert.equal(shouldUseSecureCookies('replaceme'), false)
assert.equal(shouldUseSecureCookies(''), false)
})

View File

@ -17,6 +17,7 @@ export type ServiceSlim = Pick<
| 'available_update_version'
| 'auto_update_enabled'
| 'is_custom'
| 'is_existing'
| 'is_user_modified'
| 'is_deprecated'
| 'category'

View File

@ -24,6 +24,12 @@ export type SystemInformationResponse = {
export type UsePageProps = {
appVersion: string
environment: string
admin: {
isConfigured: boolean
isLoggedIn: boolean
user: string
}
aiAssistantName: string
}
export type LSBlockDevice = {
@ -160,4 +166,4 @@ export type ContentAutoUpdateStatus = {
lastError: string | null
autoDisabledReason: string | null
resources: ContentAutoUpdateResourceStatus[]
}
}

View File

@ -420,6 +420,7 @@ download_management_compose_file() {
local app_key=$(generateRandomPass)
local db_root_password=$(generateRandomPass)
local db_user_password=$(generateRandomPass)
admin_password=$(generateRandomPass)
# If MySQL data directory exists from a previous install attempt, remove it.
# MySQL only initializes credentials on first startup when the data dir is empty.
@ -434,6 +435,7 @@ download_management_compose_file() {
echo -e "${YELLOW}#${RESET} Configuring docker-compose file env variables...\\n"
sed -i "s|URL=replaceme|URL=http://${local_ip_address}:8080|g" "$compose_file_path"
sed -i "s|APP_KEY=replaceme|APP_KEY=${app_key}|g" "$compose_file_path"
sed -i "s|ADMIN_PASS=replaceme|ADMIN_PASS=${admin_password}|g" "$compose_file_path"
sed -i "s|DB_PASSWORD=replaceme|DB_PASSWORD=${db_user_password}|g" "$compose_file_path"
sed -i "s|MYSQL_ROOT_PASSWORD=replaceme|MYSQL_ROOT_PASSWORD=${db_root_password}|g" "$compose_file_path"
@ -605,6 +607,8 @@ success_message() {
echo -e "${GREEN}#${RESET} Installation files are located at /opt/project-nomad\\n\n"
echo -e "${GREEN}#${RESET} Project NOMAD's Command Center should automatically start whenever your device reboots. However, if you need to start it manually, you can always do so by running: ${WHITE_R}${NOMAD_DIR}/start_nomad.sh${RESET}\\n"
echo -e "${GREEN}#${RESET} You can now access the management interface at http://localhost:8080 or http://${local_ip_address}:8080\\n"
echo -e "${GREEN}#${RESET} Admin Login user: ${WHITE_R}admin${RESET}"
echo -e "${GREEN}#${RESET} Admin Login password: ${WHITE_R}${admin_password}${RESET}\\n"
echo -e "${GREEN}#${RESET} Thank you for supporting Project NOMAD!\\n"
}

View File

@ -29,20 +29,24 @@ services:
# 3. Set it EXACTLY the same in NOMAD_STORAGE_PATH and the disk-collector volume (both below).
# Paths are case-sensitive (/mnt/Data != /mnt/data); a mismatch makes Docker create a new
# empty folder, which is the usual cause of "my content disappeared after moving it".
- /opt/project-nomad/storage:/app/storage
- C:/opt/project-nomad/data/storage:/app/storage
- /var/run/docker.sock:/var/run/docker.sock # Allows the admin service to communicate with the Host's Docker daemon
- nomad-update-shared:/app/update-shared # Shared volume for update communication
environment:
- NODE_ENV=production
- SESSION_DRIVER=cookie
# NOMAD_STORAGE_PATH should equal the host path of the /app/storage volume above. The admin
# normally auto-detects that mount, so this is a fallback used only if the container can't be
# inspected. Keep it in sync anyway so the fallback never sends child apps to the wrong place.
- NOMAD_STORAGE_PATH=/opt/project-nomad/storage
- NOMAD_STORAGE_PATH=C:/opt/project-nomad/data/storage
# PORT is the port the admin server listens on *inside* the container and should not be changed. If you want to change which port the admin interface is accessible from on the host, you can change the port mapping in the "ports" section (e.g. "9090:8080" to access it on port 9090 from the host)
- PORT=8080
- LOG_LEVEL=info
# APP_KEY needs to be at least 16 chars or will fail validation and container won't start!
- APP_KEY=replaceme
- APP_KEY=1q2w3e4r5t6y7u8i9o0p
# Admin Login credentials. Change ADMIN_PASS before exposing NOMAD beyond a trusted machine.
- ADMIN_USER=admin
- ADMIN_PASS=replaceme
# # Leave HOST as is so the admin server listens all interfaces within the container - this doesn't affect how you access it from the host, it's just for internal container networking
- HOST=0.0.0.0
# URL should be set to the URL you will access the admin interface at (e.g. http://localhost:8080 or http://192.168.1.x:8080)
@ -93,7 +97,7 @@ services:
# Needs to match DB_PASSWORD in the admin service!
- MYSQL_PASSWORD=replaceme
volumes:
- /opt/project-nomad/mysql:/var/lib/mysql # Persist MySQL data on the host. This path can be changed if needed, just make sure it's writable by the container. Host persistence is important for the database to ensure your data isn't lost when the container is removed or updated.
- C:/opt/project-nomad/data/mysql:/var/lib/mysql # Persist MySQL data on the host. This path can be changed if needed, just make sure it's writable by the container. Host persistence is important for the database to ensure your data isn't lost when the container is removed or updated.
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 30s
@ -104,7 +108,7 @@ services:
container_name: nomad_redis
restart: unless-stopped
volumes:
- /opt/project-nomad/redis:/data # Persist Redis data on the host. This path can be changed if needed, just make sure it's writable by the container. Host persistence is important for Redis to ensure your data isn't lost when the container is removed or updated.
- C:/opt/project-nomad/data/redis:/data # Persist Redis data on the host. This path can be changed if needed, just make sure it's writable by the container. Host persistence is important for Redis to ensure your data isn't lost when the container is removed or updated.
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 30s
@ -128,10 +132,10 @@ services:
container_name: nomad_disk_collector
restart: unless-stopped
volumes:
- /:/host:ro,rslave # Read-only view of host FS with rslave propagation so /sys and /proc submounts are visible
- /:/host:ro # Read-only view of host FS with rslave propagation so /sys and /proc submounts are visible
# If you relocated storage (see the admin service above), set this host path to match EXACTLY,
# or the host disk-usage figures shown in the UI will point at the wrong location.
- /opt/project-nomad/storage:/storage
- C:/opt/project-nomad/data/storage:/storage
volumes:
nomad-update-shared: