From 0ecd62e796f24edb1cbf3cbaec967269495f5120 Mon Sep 17 00:00:00 2001 From: 1dabread Date: Wed, 12 Aug 2026 13:24:00 -0500 Subject: [PATCH] admin login working --- admin/.env.example | 5 +- .../app/controllers/admin_auth_controller.ts | 48 +++++++ admin/app/controllers/settings_controller.ts | 15 +- .../middleware/require_admin_middleware.ts | 20 +++ admin/app/services/admin_auth_service.ts | 48 +++++++ admin/config/inertia.ts | 9 +- admin/config/session.ts | 76 +++++------ admin/inertia/pages/home.tsx | 128 +++++++++++++++++- admin/public/admin-profile.png | Bin 0 -> 2943 bytes admin/start/env.ts | 10 +- admin/start/kernel.ts | 6 +- admin/start/routes.ts | 119 ++++++++-------- admin/types/system.ts | 8 +- install/install_nomad.sh | 4 + install/management_compose.yaml | 4 + install/management_compose_new.yaml | 6 +- 16 files changed, 397 insertions(+), 109 deletions(-) create mode 100644 admin/app/controllers/admin_auth_controller.ts create mode 100644 admin/app/middleware/require_admin_middleware.ts create mode 100644 admin/app/services/admin_auth_service.ts create mode 100644 admin/public/admin-profile.png diff --git a/admin/.env.example b/admin/.env.example index f91ebe6..35ebc63 100644 --- a/admin/.env.example +++ b/admin/.env.example @@ -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 \ No newline at end of file +# CREATOR_PACKS_WORKER_BASE=https://nomad-packs-worker.chris-556.workers.dev diff --git a/admin/app/controllers/admin_auth_controller.ts b/admin/app/controllers/admin_auth_controller.ts new file mode 100644 index 0000000..b83dfe0 --- /dev/null +++ b/admin/app/controllers/admin_auth_controller.ts @@ -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 + } +} diff --git a/admin/app/controllers/settings_controller.ts b/admin/app/controllers/settings_controller.ts index bab7c5e..2ae73ba 100644 --- a/admin/app/controllers/settings_controller.ts +++ b/admin/app/controllers/settings_controller.ts @@ -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, @@ -140,8 +146,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 }) diff --git a/admin/app/middleware/require_admin_middleware.ts b/admin/app/middleware/require_admin_middleware.ts new file mode 100644 index 0000000..324c556 --- /dev/null +++ b/admin/app/middleware/require_admin_middleware.ts @@ -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}`) + } +} diff --git a/admin/app/services/admin_auth_service.ts b/admin/app/services/admin_auth_service.ts new file mode 100644 index 0000000..62177a4 --- /dev/null +++ b/admin/app/services/admin_auth_service.ts @@ -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) + } +} diff --git a/admin/config/inertia.ts b/admin/config/inertia.ts index 11ad747..674a028 100644 --- a/admin/config/inertia.ts +++ b/admin/config/inertia.ts @@ -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 {} -} \ No newline at end of file +} diff --git a/admin/config/session.ts b/admin/config/session.ts index fc49762..7c2c017 100644 --- a/admin/config/session.ts +++ b/admin/config/session.ts @@ -1,48 +1,42 @@ -// import env from '#start/env' -// import app from '@adonisjs/core/services/app' -// import { defineConfig, stores } from '@adonisjs/session' +import env from '#start/env' +import app from '@adonisjs/core/services/app' +import { defineConfig, stores } from '@adonisjs/session' -// const sessionConfig = defineConfig({ -// enabled: false, -// cookieName: 'adonis-session', +const sessionConfig = defineConfig({ + enabled: true, + cookieName: 'nomad-admin-session', -// /** -// * When set to true, the session id cookie will be deleted -// * once the user closes the browser. -// */ -// clearWithBrowser: false, + /** + * Keep the browser session available until it expires or the admin logs out. + */ + clearWithBrowser: false, -// /** -// * Define how long to keep the session data alive without -// * any activity. -// */ -// age: '2h', + /** + * Define how long to keep session data alive without activity. + */ + age: '2h', -// /** -// * Configuration for session cookie and the -// * cookie store -// */ -// cookie: { -// path: '/', -// httpOnly: true, -// secure: app.inProduction, -// sameSite: 'lax', -// }, + /** + * HTTP-only cookies keep the admin session marker out of client JavaScript. + */ + cookie: { + path: '/', + httpOnly: true, + secure: app.inProduction, + sameSite: 'lax', + }, -// /** -// * 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'), + /** + * Cookie storage avoids adding a users table for a single local admin gate. + */ + store: env.get('SESSION_DRIVER', 'cookie'), -// /** -// * List of configured stores. Refer documentation to see -// * list of available stores and their config. -// */ -// stores: { -// cookie: stores.cookie(), -// }, -// }) + /** + * List of configured stores. + */ + stores: { + cookie: stores.cookie(), + }, +}) -// export default sessionConfig +export default sessionConfig diff --git a/admin/inertia/pages/home.tsx b/admin/inertia/pages/home.tsx index cf3f158..ec1dcb8 100644 --- a/admin/inertia/pages/home.tsx +++ b/admin/inertia/pages/home.tsx @@ -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,6 +22,8 @@ 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' @@ -95,6 +98,8 @@ const SYSTEM_ITEMS = [ }, ] +const ADMIN_ONLY_LABELS = new Set(['Easy Setup', 'Supply Depot', 'Settings']) + interface DashboardItem { label: string to: string @@ -119,7 +124,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) => { + 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) @@ -168,8 +218,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 +229,72 @@ export default function Home(props: { return ( +
+ +
+ setAdminLoginOpen(false)} + onClose={() => setAdminLoginOpen(false)} + onConfirm={() => handleAdminLogin()} + > +
+ adminLoginForm.setData('user', event.target.value)} + autoComplete="username" + required + /> + adminLoginForm.setData('password', event.target.value)} + autoComplete="current-password" + error={Boolean(adminLoginForm.errors.password)} + required + /> + {adminLoginForm.errors.password && ( +

{adminLoginForm.errors.password}

+ )} + {!admin.isConfigured && !adminLoginForm.errors.password && ( +

+ Admin login is not configured. +

+ )} +
+
{ - updateInfo?.updateAvailable && ( + admin.isLoggedIn && updateInfo?.updateAvailable && (
{ - rerunBanner?.show && ( + admin.isLoggedIn && rerunBanner?.show && (
AP)QRTVxr@GU5!5>zPnswHWK^t4PVl}bUQm}aUm zrY1Gfw5GCgP@^Z8(z^0A$RS^&gmnuQlOT;O8XqP3fSQC!Q?vjDdE5*4e$ATy`{l4% z`@GLSkIOgn&)oa}`<%7TKIiPc_FCUs+w&?40EYk=0-z1RXnxxG-62KmS4#jG3}7sP z%K^*=@H+s{B08SsF>>sed9J}lUsu(h`ZEDc1Mmod9^`|60k{#sL;!~uech!3fc*iC z1F#T47u-W+F6PbI-|MRYa58|I05+gJ_#bsT+3PC+@E!oa2GE=EX?~i=-^O959l+K6 zwDY^$*hboRCy*n$bpm*oS62Y|0Dz@2I;9)H3IJ09oD1N10R0ol?ayOnSo}7CKgPUC zzXforR~OI>y*c6=@gjf@03XTIbOmrIW!wPZr3iY7?h61M?Nt>3UI*X;j>|#V;hR+V zD?rbFwCv=e*&3qjCYs>=yea{}u>e+vT-;`k`9r*t0Kn@3Ob4(fME5@dIL5020DPQJ zk_{UHTnpd`ucQEQ1m~`>>p0IVD>!*h3z#c~paFPtNpjOK` z?Nt;2P6P1wfb07`fWy2p0KiZHe+zhm+KZU1vxZy)fFB0DEsx0@RJ4*K0Q|$EpjcPu zcr^nWYa*NF_>8Q1ic;)2054b+`!j$ydNl=rHv#yI<=UlB+FUDibikV-bIGAz%>m$G z0QUrVlZU!eWFW^&4Y$-`zKCgA&bRc%s@z@sbd^77QQ#*4TD(3604+3qZPr(QCUJn^ zTl?b8FmB3CmcF^p%h9^fOuoC5cwy@XG|XacPLV!4KM(VElf{}kI8$NO4?%0z*M`h| zU-X7*j>7-e*|bdx0K+X&(1*kWo`rx*EwADD)F-bg0DR0cykC?>a7i1g+B4HWS)64Q zfXe{P;kf+(KlhOGBfq^PlP5B{oi8J0eLZ<3Q~?(_w@Xl~cxa{yjJ zc6eFB%8su=ycZ^xB%$h<*yJj>gob4|H%3QqjSvs_V~(r1IK#s7AeeY(oRbKr$;7p} zYQCp`Xi>=Uvy2n>NB{$T#|{ASR`Ph4g$#3xTrFq;d`FT0%Q%d}UWJh0BnA$<3qo}I z1%MM`oa+R3ye+6SKi{i)O^+iL;)LjQQj~*d6=miEm~81d2?%{S#d*&PpsZe;?N!7w zgJ(oB)y!zvb{A9~vJ1B>N7yR(uJB43*u?NlNFzfuku=Rnc-DRR33ygw%fi5LbZ-kr59MfQ1$+(ki= zz%Jzp&3#o4;7&!+SvktnOzJ!Gl4nc_GEDigLk)Q|-eID{fs%uixnoP7^A@Q~uNpG9 zyWmza|HQ;cwT9Oz4aYiAP)OD$<`raKPjDdj97XPQt>IsdMqMtlSuIb@xenxxSLEK{ zK<)q~KjjfE+tp04T#>hOlr2O6Ke_=N=s@m4%H+N*Z@H8$!74wb?+5uCNb-t_sg9Ie zXUP4x3%QhSrdY3aB=@^Yf1mAdAW10BhYiX$17Is%$fayE{=LDGT+RHw)Zaj&Y$lv= zR23i311{uJwnQ2I*z8E|o0a~a>2DyOQFB6f zXZ-k-3&kk=(Sn}R7G*8+unXCgzgnF;shAlzSnN=-+m+Y%?GEG$QNqNndCT?5H%WCn z{p2naw>VTx0!_|?VQAzKnz$ryxgihYK~1-?i8&4xdy67}b)F$sMkUuMa-AjHSo~>2 znsJEmrM%mDMDT?iB{i|_?Dvg?($VMeS3=t_-9mPtd*bAR;ddic1YhJX*Lp4vq>Dwo&C|*0N_A{ z82E(NP;)DMs1U7d3`whql$Id=oJbA#Ar&n7H=nTid(2nqg)*3lbpg$I(^8Z^A80$^ zH_F{R-MQv9g-iiT|8@o)AU+}!w>#Ck#;~Mad^+fWfy$D;t4!6Ws|s=#%>0|pp3OST(Skw~5(N4t$%%52Q~#?pEln^e9YbIl+0{mFHex@tJ%1-8=?>CUdpkTI7nI zBafB^9Btzm0LztwugYEXJp)zaSU6^7Mq43u@m$lHEp~Xp@$=xW%+hXNkTR~(9<$`qPVQ+q zn?u@oQkMp4QBAIf<+qdA_6i>Rs3kw$2GQ{cX>N>#MYAw%jq|OEs@Uw^NuOuldlWp$ z3OZ+GnRrTK`52~@Jrz-x^&HA#hqRT7a?+Q>(_X|ShNTj#2#nN)3MgFf$67`OPfVq7 zJl#f8rkUrcK@5P=DH-o7l-7QT5)RB#KrpuGVfmo#jz8nt&<1zg9A zX=->m0f5g0?4sGtO~Wd)odp_+Jy!;p(_Mky+9JU^hdlvrMe6lSQJ|SEOco8jnN8Li zvhc(|WW$U)?4E!|Dzg@TZKAytTXv9HXnUP6^lAn)*pIeh`AcO`P}t}iCVDjm^paA7 z2g{(GR=K%Jkf`itk|9>4t^WLOer{0!@CKzsMH4-Afz)9R=}(u7&2W)j>xOwHqH_jq zz4~~SH;G3hG1g{ffoT)F>s^)T3DT;=`QMhgRpH5%Va6BK!xnpwyH(?v!h6~cv zQ>pW&@e?@NK9&=CZWk#*9h9|iFW;y@BdcpH^XTW&J+n6AjoQVwOL^?qNW9+n7UZ~^ p9>!udV!_Er{+91b>jenI{{aAhQbk4)0B-;Q002ovPDHLkV1hZIhb903 literal 0 HcmV?d00001 diff --git a/admin/start/env.ts b/admin/start/env.ts index 40a323a..24f6533 100644 --- a/admin/start/env.ts +++ b/admin/start/env.ts @@ -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(), /* |---------------------------------------------------------- diff --git a/admin/start/kernel.ts b/admin/start/kernel.ts index cde1fb0..d92f534 100644 --- a/admin/start/kernel.ts +++ b/admin/start/kernel.ts @@ -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'), +}) diff --git a/admin/start/routes.ts b/admin/start/routes.ts index de415d9..24ce7af 100644 --- a/admin/start/routes.ts +++ b/admin/start/routes.ts @@ -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, @@ -101,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']), { @@ -135,6 +144,7 @@ router }) }) .prefix('/api/content-updates') + .use(middleware.admin()) router .group(() => { @@ -152,6 +162,7 @@ router router.get('/advanced', [SettingsController, 'advanced']) }) .prefix('/settings') + .use(middleware.admin()) router .group(() => { @@ -181,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, @@ -209,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'], }) @@ -221,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, @@ -247,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, @@ -534,110 +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.post('/services/existing', [SystemController, 'createExistingApp']), { + 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']), { + 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, @@ -647,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'], }) @@ -691,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'], }) @@ -710,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, @@ -720,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, @@ -736,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, @@ -755,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'], }) diff --git a/admin/types/system.ts b/admin/types/system.ts index fb7dfb8..cea726e 100644 --- a/admin/types/system.ts +++ b/admin/types/system.ts @@ -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[] -} \ No newline at end of file +} diff --git a/install/install_nomad.sh b/install/install_nomad.sh index df34cb5..6e1a3da 100644 --- a/install/install_nomad.sh +++ b/install/install_nomad.sh @@ -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" } diff --git a/install/management_compose.yaml b/install/management_compose.yaml index 363abfb..45196ef 100644 --- a/install/management_compose.yaml +++ b/install/management_compose.yaml @@ -34,6 +34,7 @@ services: - 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. @@ -43,6 +44,9 @@ services: - LOG_LEVEL=info # APP_KEY needs to be at least 16 chars or will fail validation and container won't start! - 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) diff --git a/install/management_compose_new.yaml b/install/management_compose_new.yaml index 51288a0..6185da1 100644 --- a/install/management_compose_new.yaml +++ b/install/management_compose_new.yaml @@ -9,7 +9,7 @@ name: project-nomad services: admin: - image: nomad:1.0 + image: nomad:1.1 container_name: nomad_admin restart: unless-stopped extra_hosts: @@ -33,6 +33,7 @@ services: - 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. @@ -42,6 +43,9 @@ services: - LOG_LEVEL=info # APP_KEY needs to be at least 16 chars or will fail validation and container won't start! - APP_KEY=1q2w3e4r5t6y7u8i9o0p + # Admin Login credentials. Change ADMIN_PASS before exposing NOMAD beyond a trusted machine. + - ADMIN_USER=admin + - ADMIN_PASS=1q2w3e4r5t6y7u8i9o0p # # 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)