new changes

This commit is contained in:
Niranjan
2026-04-07 13:23:35 +05:30
parent df015e4d5a
commit 6dea3b4307
38 changed files with 1332 additions and 119 deletions

View File

@@ -16,6 +16,9 @@ const CrontabPage = lazy(() => import('./pages/CrontabPage').then((m) => ({ defa
const ConfigPage = lazy(() => import('./pages/ConfigPage').then((m) => ({ default: m.ConfigPage })))
const LogsPage = lazy(() => import('./pages/LogsPage').then((m) => ({ default: m.LogsPage })))
const FirewallPage = lazy(() => import('./pages/FirewallPage').then((m) => ({ default: m.FirewallPage })))
const SecurityChecklistPage = lazy(() =>
import('./pages/SecurityChecklistPage').then((m) => ({ default: m.SecurityChecklistPage })),
)
const DomainsPage = lazy(() => import('./pages/DomainsPage').then((m) => ({ default: m.DomainsPage })))
const DockerPage = lazy(() => import('./pages/DockerPage').then((m) => ({ default: m.DockerPage })))
const NodePage = lazy(() => import('./pages/NodePage').then((m) => ({ default: m.NodePage })))
@@ -125,6 +128,14 @@ export default function App() {
</Suspense>
}
/>
<Route
path="security-checklist"
element={
<Suspense fallback={<PageSkeleton />}>
<SecurityChecklistPage />
</Suspense>
}
/>
<Route
path="files"
element={

View File

@@ -67,7 +67,19 @@ export async function login(username: string, password: string) {
return data
}
export async function createSite(data: { name: string; domains: string[]; path?: string; ps?: string; php_version?: string; force_https?: boolean }) {
export async function createSite(data: {
name: string
domains: string[]
path?: string
ps?: string
php_version?: string
force_https?: boolean
proxy_upstream?: string
proxy_websocket?: boolean
dir_auth_path?: string
dir_auth_user_file?: string
php_deny_execute?: boolean
}) {
return apiRequest<{ status: boolean; msg: string; id?: number }>('/site/create', {
method: 'POST',
body: JSON.stringify(data),
@@ -108,14 +120,38 @@ export async function siteBatch(action: 'enable' | 'disable' | 'delete', ids: nu
}
export async function getSite(siteId: number) {
return apiRequest<{ id: number; name: string; path: string; status: number; ps: string; project_type: string; php_version: string; force_https: number; domains: string[] }>(
`/site/${siteId}`
)
return apiRequest<{
id: number
name: string
path: string
status: number
ps: string
project_type: string
php_version: string
force_https: number
domains: string[]
proxy_upstream?: string
proxy_websocket?: number
dir_auth_path?: string
dir_auth_user_file?: string
php_deny_execute?: number
}>(`/site/${siteId}`)
}
export async function updateSite(
siteId: number,
data: { path?: string; domains?: string[]; ps?: string; php_version?: string; force_https?: boolean }
data: {
path?: string
domains?: string[]
ps?: string
php_version?: string
force_https?: boolean
proxy_upstream?: string
proxy_websocket?: boolean
dir_auth_path?: string
dir_auth_user_file?: string
php_deny_execute?: boolean
}
) {
return apiRequest<{ status: boolean; msg: string }>(`/site/${siteId}`, {
method: 'PUT',
@@ -530,17 +566,50 @@ export async function getMonitorNetwork() {
}
export async function listBackupPlans() {
return apiRequest<{ id: number; name: string; plan_type: string; target_id: number; schedule: string; enabled: boolean }[]>('/backup/plans')
return apiRequest<
{
id: number
name: string
plan_type: string
target_id: number
schedule: string
enabled: boolean
s3_bucket?: string
s3_endpoint?: string
s3_key_prefix?: string
}[]
>('/backup/plans')
}
export async function createBackupPlan(data: { name: string; plan_type: string; target_id: number; schedule: string; enabled?: boolean }) {
export async function createBackupPlan(data: {
name: string
plan_type: string
target_id: number
schedule: string
enabled?: boolean
s3_bucket?: string
s3_endpoint?: string
s3_key_prefix?: string
}) {
return apiRequest<{ status: boolean; msg: string; id: number }>('/backup/plans', {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function updateBackupPlan(planId: number, data: { name: string; plan_type: string; target_id: number; schedule: string; enabled?: boolean }) {
export async function updateBackupPlan(
planId: number,
data: {
name: string
plan_type: string
target_id: number
schedule: string
enabled?: boolean
s3_bucket?: string
s3_endpoint?: string
s3_key_prefix?: string
}
) {
return apiRequest<{ status: boolean; msg: string }>(`/backup/plans/${planId}`, {
method: 'PUT',
body: JSON.stringify(data),

View File

@@ -15,17 +15,24 @@ export const menuItems: MenuItem[] = [
{ title: 'Docker', href: '/docker', id: 'menuDocker', sort: 5, iconClass: 'ti ti-brand-docker' },
{ title: 'Monitor', href: '/control', id: 'menuControl', sort: 6, iconClass: 'ti ti-heart-rate-monitor' },
{ title: 'Security', href: '/firewall', id: 'menuFirewall', sort: 7, iconClass: 'ti ti-shield-lock' },
{ title: 'Files', href: '/files', id: 'menuFiles', sort: 8, iconClass: 'ti ti-folders' },
{ title: 'Node', href: '/node', id: 'menuNode', sort: 9, iconClass: 'ti ti-brand-nodejs' },
{ title: 'Logs', href: '/logs', id: 'menuLogs', sort: 10, iconClass: 'ti ti-file-text' },
{ title: 'Domains', href: '/ssl_domain', id: 'menuDomains', sort: 11, iconClass: 'ti ti-world-www' },
{ title: 'Terminal', href: '/xterm', id: 'menuXterm', sort: 12, iconClass: 'ti ti-terminal-2' },
{ title: 'Cron', href: '/crontab', id: 'menuCrontab', sort: 13, iconClass: 'ti ti-clock' },
{ title: 'App Store', href: '/soft', id: 'menuSoft', sort: 14, iconClass: 'ti ti-package' },
{ title: 'Services', href: '/services', id: 'menuServices', sort: 15, iconClass: 'ti ti-server' },
{ title: 'Plugins', href: '/plugins', id: 'menuPlugins', sort: 16, iconClass: 'ti ti-puzzle' },
{ title: 'Backup Plans', href: '/backup-plans', id: 'menuBackupPlans', sort: 17, iconClass: 'ti ti-archive' },
{ title: 'Users', href: '/users', id: 'menuUsers', sort: 18, iconClass: 'ti ti-users' },
{ title: 'Settings', href: '/config', id: 'menuConfig', sort: 19, iconClass: 'ti ti-settings' },
{ title: 'Log out', href: '/logout', id: 'menuLogout', sort: 20, iconClass: 'ti ti-logout' },
{
title: 'Security checklist',
href: '/security-checklist',
id: 'menuSecurityChecklist',
sort: 8,
iconClass: 'ti ti-checklist',
},
{ title: 'Files', href: '/files', id: 'menuFiles', sort: 9, iconClass: 'ti ti-folders' },
{ title: 'Node', href: '/node', id: 'menuNode', sort: 10, iconClass: 'ti ti-brand-nodejs' },
{ title: 'Logs', href: '/logs', id: 'menuLogs', sort: 11, iconClass: 'ti ti-file-text' },
{ title: 'Domains', href: '/ssl_domain', id: 'menuDomains', sort: 12, iconClass: 'ti ti-world-www' },
{ title: 'Terminal', href: '/xterm', id: 'menuXterm', sort: 13, iconClass: 'ti ti-terminal-2' },
{ title: 'Cron', href: '/crontab', id: 'menuCrontab', sort: 14, iconClass: 'ti ti-clock' },
{ title: 'App Store', href: '/soft', id: 'menuSoft', sort: 15, iconClass: 'ti ti-package' },
{ title: 'Services', href: '/services', id: 'menuServices', sort: 16, iconClass: 'ti ti-server' },
{ title: 'Plugins', href: '/plugins', id: 'menuPlugins', sort: 17, iconClass: 'ti ti-puzzle' },
{ title: 'Backup Plans', href: '/backup-plans', id: 'menuBackupPlans', sort: 18, iconClass: 'ti ti-archive' },
{ title: 'Users', href: '/users', id: 'menuUsers', sort: 19, iconClass: 'ti ti-users' },
{ title: 'Settings', href: '/config', id: 'menuConfig', sort: 20, iconClass: 'ti ti-settings' },
{ title: 'Log out', href: '/logout', id: 'menuLogout', sort: 21, iconClass: 'ti ti-logout' },
]

View File

@@ -7,6 +7,7 @@ export const routeTitleMap: Record<string, string> = {
'/docker': 'Docker',
'/control': 'Monitor',
'/firewall': 'Security',
'/security-checklist': 'Security checklist',
'/files': 'Files',
'/node': 'Node',
'/logs': 'Logs',

View File

@@ -17,6 +17,9 @@ interface BackupPlanRecord {
target_id: number
schedule: string
enabled: boolean
s3_bucket?: string
s3_endpoint?: string
s3_key_prefix?: string
}
interface SiteRecord {
@@ -74,13 +77,16 @@ export function BackupPlansPage() {
const target_id = Number((form.elements.namedItem('target_id') as HTMLSelectElement).value)
const schedule = (form.elements.namedItem('schedule') as HTMLInputElement).value.trim()
const enabled = (form.elements.namedItem('enabled') as HTMLInputElement).checked
const s3_bucket = (form.elements.namedItem('s3_bucket') as HTMLInputElement).value.trim()
const s3_endpoint = (form.elements.namedItem('s3_endpoint') as HTMLInputElement).value.trim()
const s3_key_prefix = (form.elements.namedItem('s3_key_prefix') as HTMLInputElement).value.trim()
if (!name || !schedule || !target_id) {
setError('Name, target and schedule are required')
return
}
setCreating(true)
createBackupPlan({ name, plan_type, target_id, schedule, enabled })
createBackupPlan({ name, plan_type, target_id, schedule, enabled, s3_bucket, s3_endpoint, s3_key_prefix })
.then(() => {
setShowCreate(false)
form.reset()
@@ -110,9 +116,21 @@ export function BackupPlansPage() {
const target_id = Number((form.elements.namedItem('edit_target_id') as HTMLSelectElement).value)
const schedule = (form.elements.namedItem('edit_schedule') as HTMLInputElement).value.trim()
const enabled = (form.elements.namedItem('edit_enabled') as HTMLInputElement).checked
const s3_bucket = (form.elements.namedItem('edit_s3_bucket') as HTMLInputElement).value.trim()
const s3_endpoint = (form.elements.namedItem('edit_s3_endpoint') as HTMLInputElement).value.trim()
const s3_key_prefix = (form.elements.namedItem('edit_s3_key_prefix') as HTMLInputElement).value.trim()
if (!name || !schedule || !target_id) return
updateBackupPlan(editPlan.id, { name, plan_type: editPlanType, target_id, schedule, enabled })
updateBackupPlan(editPlan.id, {
name,
plan_type: editPlanType,
target_id,
schedule,
enabled,
s3_bucket,
s3_endpoint,
s3_key_prefix,
})
.then(() => {
setEditPlan(null)
loadPlans()
@@ -188,7 +206,9 @@ export function BackupPlansPage() {
<p className="small text-secondary mb-3">
Schedule automated backups. Add a cron entry (e.g. <code>0 * * * *</code> hourly) to call{' '}
<code>POST /api/v1/backup/run-scheduled</code> with your auth token.
<code>POST /api/v1/backup/run-scheduled</code> with your auth token. Optional S3-compatible upload uses{' '}
<code>AWS_ACCESS_KEY_ID</code> and <code>AWS_SECRET_ACCESS_KEY</code> in the panel environment when a bucket
name is set.
</p>
<div className="card">
@@ -199,6 +219,7 @@ export function BackupPlansPage() {
<th>Type</th>
<th>Target</th>
<th>Schedule</th>
<th>S3</th>
<th>Enabled</th>
<th className="text-end">Actions</th>
</tr>
@@ -206,7 +227,7 @@ export function BackupPlansPage() {
<tbody>
{plans.length === 0 ? (
<tr>
<td colSpan={6} className="p-0">
<td colSpan={7} className="p-0">
<EmptyState
title="No backup plans"
description='Click "Add Plan" to create one.'
@@ -222,6 +243,9 @@ export function BackupPlansPage() {
<td>
<code className="small">{p.schedule}</code>
</td>
<td className="small">
{p.s3_bucket ? <span className="badge text-bg-info">{p.s3_bucket}</span> : '—'}
</td>
<td>{p.enabled ? 'Yes' : 'No'}</td>
<td className="text-end">
<button
@@ -314,6 +338,36 @@ export function BackupPlansPage() {
required
/>
</div>
<div className="mb-3">
<label className="form-label">S3 bucket (optional)</label>
<input
name="edit_s3_bucket"
type="text"
placeholder="my-backups"
defaultValue={editPlan.s3_bucket || ''}
className="form-control"
/>
</div>
<div className="mb-3">
<label className="form-label">S3 endpoint (optional)</label>
<input
name="edit_s3_endpoint"
type="text"
placeholder="https://s3.example.com or leave empty for AWS"
defaultValue={editPlan.s3_endpoint || ''}
className="form-control"
/>
</div>
<div className="mb-3">
<label className="form-label">S3 key prefix (optional)</label>
<input
name="edit_s3_key_prefix"
type="text"
placeholder="yakpanel/backups"
defaultValue={editPlan.s3_key_prefix || ''}
className="form-control"
/>
</div>
<div className="form-check">
<input name="edit_enabled" type="checkbox" defaultChecked={editPlan.enabled} className="form-check-input" id="edit_enabled" />
<label className="form-check-label" htmlFor="edit_enabled">
@@ -383,6 +437,18 @@ export function BackupPlansPage() {
<input name="schedule" type="text" placeholder="0 2 * * *" className="form-control" required />
<div className="form-text">e.g. 0 2 * * * = daily at 2am, 0 */6 * * * = every 6 hours</div>
</div>
<div className="mb-3">
<label className="form-label">S3 bucket (optional)</label>
<input name="s3_bucket" type="text" placeholder="my-backups" className="form-control" />
</div>
<div className="mb-3">
<label className="form-label">S3 endpoint (optional)</label>
<input name="s3_endpoint" type="text" placeholder="Custom S3 API URL" className="form-control" />
</div>
<div className="mb-3">
<label className="form-label">S3 key prefix (optional)</label>
<input name="s3_key_prefix" type="text" placeholder="yakpanel/backups" className="form-control" />
</div>
<div className="form-check">
<input name="enabled" type="checkbox" defaultChecked className="form-check-input" id="plan_enabled" />
<label className="form-check-label" htmlFor="plan_enabled">

View File

@@ -11,6 +11,14 @@ interface CronJob {
execstr: string
}
interface CronTemplate {
id: string
name: string
schedule: string
execstr: string
description?: string
}
const SCHEDULE_PRESETS = [
{ label: 'Every minute', value: '* * * * *' },
{ label: 'Every 5 min', value: '*/5 * * * *' },
@@ -29,6 +37,9 @@ export function CrontabPage() {
const [saving, setSaving] = useState(false)
const [formError, setFormError] = useState('')
const [applying, setApplying] = useState(false)
const [templates, setTemplates] = useState<CronTemplate[]>([])
const [prefill, setPrefill] = useState<{ name?: string; schedule?: string; execstr?: string } | null>(null)
const [formNonce, setFormNonce] = useState(0)
const loadJobs = () => {
setLoading(true)
@@ -42,6 +53,12 @@ export function CrontabPage() {
loadJobs()
}, [])
useEffect(() => {
apiRequest<{ templates: CronTemplate[] }>('/crontab/templates')
.then((r) => setTemplates(Array.isArray(r.templates) ? r.templates : []))
.catch(() => setTemplates([]))
}, [])
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
const form = e.currentTarget
@@ -63,6 +80,8 @@ export function CrontabPage() {
.then(() => {
setShowForm(false)
setEditingId(null)
setEditJob(null)
setPrefill(null)
form.reset()
loadJobs()
})
@@ -71,11 +90,20 @@ export function CrontabPage() {
}
const handleEdit = (job: CronJob) => {
setPrefill(null)
setEditingId(job.id)
setEditJob(job)
setShowForm(true)
}
const useTemplate = (t: CronTemplate) => {
setEditingId(null)
setEditJob(null)
setPrefill({ name: t.name, schedule: t.schedule, execstr: t.execstr })
setFormNonce((n) => n + 1)
setShowForm(true)
}
const handleDelete = (id: number) => {
if (!confirm('Delete this cron job?')) return
apiRequest(`/crontab/${id}`, { method: 'DELETE' })
@@ -131,11 +159,21 @@ export function CrontabPage() {
{error ? <AdminAlert className="mb-3">{error}</AdminAlert> : null}
<Modal show={showForm} onHide={() => { setShowForm(false); setEditingId(null); setEditJob(null) }} centered size="lg">
<Modal
show={showForm}
onHide={() => {
setShowForm(false)
setEditingId(null)
setEditJob(null)
setPrefill(null)
}}
centered
size="lg"
>
<Modal.Header closeButton>
<Modal.Title>{editingId ? 'Edit Cron Job' : 'Create Cron Job'}</Modal.Title>
</Modal.Header>
<form key={editingId ?? 'new'} onSubmit={handleSubmit}>
<form key={`${editingId ?? 'new'}-${formNonce}`} onSubmit={handleSubmit}>
<Modal.Body>
{formError ? <AdminAlert className="mb-3">{formError}</AdminAlert> : null}
<div className="mb-3">
@@ -145,7 +183,7 @@ export function CrontabPage() {
name="name"
type="text"
placeholder="My task"
defaultValue={editJob?.name}
defaultValue={editJob?.name ?? prefill?.name ?? ''}
className="form-control"
/>
</div>
@@ -170,7 +208,7 @@ export function CrontabPage() {
name="schedule"
type="text"
placeholder="* * * * *"
defaultValue={editJob?.schedule}
defaultValue={editJob?.schedule ?? prefill?.schedule ?? ''}
className="form-control"
required
/>
@@ -182,7 +220,7 @@ export function CrontabPage() {
name="execstr"
rows={3}
placeholder="/usr/bin/php /www/wwwroot/script.php"
defaultValue={editJob?.execstr}
defaultValue={editJob?.execstr ?? prefill?.execstr ?? ''}
className="form-control"
required
/>
@@ -196,6 +234,7 @@ export function CrontabPage() {
setShowForm(false)
setEditingId(null)
setEditJob(null)
setPrefill(null)
}}
>
Cancel
@@ -211,6 +250,32 @@ export function CrontabPage() {
Jobs are stored in the panel. Click &quot;Apply to System&quot; to sync them to the system crontab (root).
</div>
{templates.length > 0 ? (
<div className="card mb-3">
<div className="card-header">YakPanel starter templates</div>
<div className="card-body py-3">
<p className="small text-secondary mb-2">Review and edit commands before saving adjust paths and tokens for your server.</p>
<div className="list-group list-group-flush border rounded">
{templates.map((t) => (
<div
key={t.id}
className="list-group-item d-flex flex-wrap align-items-center justify-content-between gap-2"
>
<div className="flex-grow-1" style={{ minWidth: 200 }}>
<div className="fw-medium">{t.name}</div>
{t.description ? <div className="small text-muted">{t.description}</div> : null}
<code className="small d-block mt-1 text-break">{t.schedule}</code>
</div>
<button type="button" className="btn btn-sm btn-outline-primary flex-shrink-0" onClick={() => useTemplate(t)}>
Use in new job
</button>
</div>
))}
</div>
</div>
</div>
) : null}
<div className="card">
<AdminTable>
<thead>

View File

@@ -17,9 +17,19 @@ interface Certificate {
path: string
}
interface NginxWizard {
detected_layout: string
include_snippet: string
dropin_file_suggested: string
debian: { sites_available_file: string; sites_enabled_symlink: string; steps: string[] }
rhel: { conf_d_file: string; steps: string[] }
note: string
}
interface SslDiagnostics {
vhost_dir: string
include_snippet: string
nginx_wizard?: NginxWizard
vhosts: { file: string; has_listen_80: boolean; has_listen_443: boolean; has_ssl_directives: boolean }[]
any_vhost_listen_ssl: boolean
nginx_effective_listen_443: boolean
@@ -41,6 +51,13 @@ export function DomainsPage() {
const [requesting, setRequesting] = useState<string | null>(null)
const [requestDomain, setRequestDomain] = useState<Domain | null>(null)
const [requestEmail, setRequestEmail] = useState('')
const [showDnsCf, setShowDnsCf] = useState(false)
const [cfDomain, setCfDomain] = useState('')
const [cfEmail, setCfEmail] = useState('')
const [cfToken, setCfToken] = useState('')
const [cfBusy, setCfBusy] = useState(false)
const [manualDom, setManualDom] = useState('')
const [manualOut, setManualOut] = useState<{ txt_record_name?: string; certbot_example?: string; note?: string } | null>(null)
const load = () => {
setLoading(true)
@@ -155,9 +172,107 @@ export function DomainsPage() {
nginx -T probe: {diag.nginx_t_probe_errors.join(' | ')}
</div>
) : null}
{diag.nginx_wizard ? (
<div className="mt-4 border-top pt-3">
<div className="fw-semibold mb-2">Nginx include wizard</div>
<p className="small text-secondary mb-2">{diag.nginx_wizard.note}</p>
<p className="small mb-1">
Detected layout: <code>{diag.nginx_wizard.detected_layout}</code> suggested file:{' '}
<code className="user-select-all">{diag.nginx_wizard.dropin_file_suggested}</code>
</p>
<div className="row g-2 small">
<div className="col-md-6">
<strong className="d-block mb-1">Debian / Ubuntu</strong>
<ol className="ps-3 mb-0">
{diag.nginx_wizard.debian.steps.map((s, i) => (
<li key={i} className="mb-1">
<code className="text-break user-select-all small d-block bg-body-secondary p-1 rounded">{s}</code>
</li>
))}
</ol>
</div>
<div className="col-md-6">
<strong className="d-block mb-1">RHEL / Rocky / Alma</strong>
<ol className="ps-3 mb-0">
{diag.nginx_wizard.rhel.steps.map((s, i) => (
<li key={i} className="mb-1">
<code className="text-break user-select-all small d-block bg-body-secondary p-1 rounded">{s}</code>
</li>
))}
</ol>
</div>
</div>
</div>
) : null}
</div>
) : null}
<div className="card mb-4">
<div className="card-header">DNS-01 Let&apos;s Encrypt (CDN / no HTTP)</div>
<div className="card-body small">
<p className="text-secondary mb-3">
Use when HTTP validation cannot reach this server (e.g. Cloudflare &quot;orange cloud&quot;). Requires{' '}
<code>certbot-dns-cloudflare</code> on the server for the Cloudflare option.
</p>
<div className="d-flex flex-wrap gap-2 mb-3">
<AdminButton type="button" size="sm" variant="primary" onClick={() => { setShowDnsCf(true); setCfDomain(''); setCfEmail(''); setCfToken('') }}>
Cloudflare DNS-01
</AdminButton>
<AdminButton
type="button"
size="sm"
variant="outline-secondary"
onClick={() => {
setManualDom('')
setManualOut(null)
}}
>
Clear manual help
</AdminButton>
</div>
<div className="row g-2 align-items-end">
<div className="col-md-4">
<label className="form-label small mb-0">Domain (manual TXT hint)</label>
<input
className="form-control form-control-sm"
value={manualDom}
onChange={(e) => setManualDom(e.target.value)}
placeholder="example.com"
/>
</div>
<div className="col-auto">
<AdminButton
type="button"
size="sm"
variant="outline-primary"
disabled={!manualDom.trim()}
onClick={() => {
apiRequest<{ txt_record_name: string; certbot_example: string; note: string }>('/ssl/dns-request/manual-instructions', {
method: 'POST',
body: JSON.stringify({ domain: manualDom.trim() }),
})
.then(setManualOut)
.catch((err) => setError(err.message))
}}
>
Get TXT / certbot hint
</AdminButton>
</div>
</div>
{manualOut ? (
<div className="mt-2 p-2 bg-body-secondary rounded small">
<div>
<strong>TXT name:</strong> <code className="user-select-all">{manualOut.txt_record_name}</code>
</div>
<div className="mt-1">
<strong>Example:</strong> <code className="user-select-all text-break">{manualOut.certbot_example}</code>
</div>
{manualOut.note ? <p className="mb-0 mt-1 text-secondary">{manualOut.note}</p> : null}
</div>
) : null}
</div>
</div>
<div className="row g-4">
<div className="col-lg-6">
<div className="card h-100">
@@ -219,7 +334,7 @@ export function DomainsPage() {
<i className="ti ti-shield-check text-success flex-shrink-0" aria-hidden />
<span className="font-monospace small text-break">{c.name}</span>
</div>
))
))
)}
</div>
</div>
@@ -269,6 +384,51 @@ export function DomainsPage() {
</form>
) : null}
</Modal>
<Modal show={showDnsCf} onHide={() => setShowDnsCf(false)} centered>
<Modal.Header closeButton>
<Modal.Title>Let&apos;s Encrypt via Cloudflare DNS-01</Modal.Title>
</Modal.Header>
<form
onSubmit={(e) => {
e.preventDefault()
setCfBusy(true)
apiRequest<{ status: boolean; msg?: string }>('/ssl/dns-request/cloudflare', {
method: 'POST',
body: JSON.stringify({ domain: cfDomain.trim(), email: cfEmail.trim(), api_token: cfToken.trim() }),
})
.then(() => {
setShowDnsCf(false)
load()
})
.catch((err) => setError(err.message))
.finally(() => setCfBusy(false))
}}
>
<Modal.Body>
<div className="mb-3">
<label className="form-label">Domain (primary)</label>
<input className="form-control" value={cfDomain} onChange={(e) => setCfDomain(e.target.value)} required placeholder="example.com" />
</div>
<div className="mb-3">
<label className="form-label">Let&apos;s Encrypt email</label>
<input type="email" className="form-control" value={cfEmail} onChange={(e) => setCfEmail(e.target.value)} required />
</div>
<div className="mb-0">
<label className="form-label">Cloudflare API token (DNS:Edit)</label>
<input type="password" className="form-control" value={cfToken} onChange={(e) => setCfToken(e.target.value)} required autoComplete="off" />
</div>
</Modal.Body>
<Modal.Footer>
<AdminButton type="button" variant="secondary" onClick={() => setShowDnsCf(false)}>
Cancel
</AdminButton>
<AdminButton type="submit" variant="primary" disabled={cfBusy}>
{cfBusy ? 'Running certbot…' : 'Issue certificate'}
</AdminButton>
</Modal.Footer>
</form>
</Modal>
</>
)
}

View File

@@ -12,6 +12,10 @@ interface FtpAccount {
export function FtpPage() {
const [accounts, setAccounts] = useState<FtpAccount[]>([])
const [logPath, setLogPath] = useState<string | null>(null)
const [logContent, setLogContent] = useState('')
const [logLoading, setLogLoading] = useState(false)
const [logError, setLogError] = useState('')
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [showCreate, setShowCreate] = useState(false)
@@ -32,6 +36,18 @@ export function FtpPage() {
loadAccounts()
}, [])
const loadFtpLogs = () => {
setLogLoading(true)
setLogError('')
apiRequest<{ path: string | null; content: string }>('/ftp/logs?lines=400')
.then((r) => {
setLogPath(r.path)
setLogContent(r.content || '')
})
.catch((err) => setLogError(err.message))
.finally(() => setLogLoading(false))
}
const handleCreate = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
const form = e.currentTarget
@@ -119,6 +135,33 @@ export function FtpPage() {
<code>apt install pure-ftpd pure-ftpd-common</code>
</div>
<div className="card shadow-sm border-0 mb-4">
<div className="card-header d-flex flex-wrap align-items-center justify-content-between gap-2">
<span>FTP log (tail)</span>
<button type="button" className="btn btn-sm btn-outline-primary" disabled={logLoading} onClick={loadFtpLogs}>
{logLoading ? 'Loading…' : logContent ? 'Refresh' : 'Load log'}
</button>
</div>
<div className="card-body">
{logError ? <AdminAlert variant="danger">{logError}</AdminAlert> : null}
{logPath ? (
<p className="small text-muted mb-2">
Source: <code className="user-select-all">{logPath}</code>
</p>
) : null}
{logContent ? (
<pre
className="small bg-body-secondary border rounded p-3 mb-0 text-body"
style={{ maxHeight: '22rem', overflow: 'auto', whiteSpace: 'pre-wrap' }}
>
{logContent}
</pre>
) : !logLoading && !logError ? (
<p className="text-muted small mb-0">Click &quot;Load log&quot; to tail common Pure-FTPd paths on this server.</p>
) : null}
</div>
</div>
<Modal show={showCreate} onHide={() => setShowCreate(false)} centered>
<Modal.Header closeButton>
<Modal.Title>Create FTP Account</Modal.Title>

View File

@@ -0,0 +1,71 @@
import { useEffect, useState } from 'react'
import { apiRequest } from '../api/client'
import { PageHeader, AdminAlert } from '../components/admin'
interface CheckItem {
id: string
ok: boolean | null
title: string
detail: string
}
export function SecurityChecklistPage() {
const [items, setItems] = useState<CheckItem[]>([])
const [disclaimer, setDisclaimer] = useState('')
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
useEffect(() => {
apiRequest<{ items: CheckItem[]; disclaimer?: string }>('/security/checklist')
.then((r) => {
setItems(r.items || [])
setDisclaimer(r.disclaimer || '')
})
.catch((err) => setError(err.message))
.finally(() => setLoading(false))
}, [])
if (loading) {
return (
<>
<PageHeader title="Security checklist" />
<p className="text-secondary">Loading</p>
</>
)
}
return (
<>
<PageHeader title="Security checklist" />
{error ? <AdminAlert className="mb-3">{error}</AdminAlert> : null}
{disclaimer ? (
<p className="small text-secondary mb-3">{disclaimer}</p>
) : null}
<div className="list-group shadow-sm">
{items.length === 0 ? (
<div className="list-group-item text-muted">No checks returned.</div>
) : (
items.map((it) => (
<div key={it.id} className="list-group-item d-flex gap-3 align-items-start">
<span
className={`badge rounded-pill flex-shrink-0 mt-1 ${
it.ok === true ? 'text-bg-success' : it.ok === false ? 'text-bg-warning' : 'text-bg-secondary'
}`}
aria-hidden
>
{it.ok === true ? 'OK' : it.ok === false ? '!' : '?'}
</span>
<div>
<div className="fw-semibold">{it.title}</div>
<div className="small text-secondary">{it.detail}</div>
</div>
</div>
))
)}
</div>
</>
)
}

View File

@@ -46,6 +46,11 @@ export function SitePage() {
ps: string
php_version: string
force_https: boolean
proxy_upstream: string
proxy_websocket: boolean
dir_auth_path: string
dir_auth_user_file: string
php_deny_execute: boolean
} | null>(null)
const [editLoading, setEditLoading] = useState(false)
const [editError, setEditError] = useState('')
@@ -241,6 +246,11 @@ export function SitePage() {
ps: s.ps || '',
php_version: s.php_version || '74',
force_https: !!(s.force_https && s.force_https !== 0),
proxy_upstream: s.proxy_upstream || '',
proxy_websocket: !!(s.proxy_websocket && Number(s.proxy_websocket) !== 0),
dir_auth_path: s.dir_auth_path || '',
dir_auth_user_file: s.dir_auth_user_file || '',
php_deny_execute: !!(s.php_deny_execute && Number(s.php_deny_execute) !== 0),
})
)
.catch((err) => setEditError(err.message))
@@ -262,6 +272,11 @@ export function SitePage() {
ps: editForm.ps || undefined,
php_version: editForm.php_version,
force_https: editForm.force_https,
proxy_upstream: editForm.proxy_upstream,
proxy_websocket: editForm.proxy_websocket,
dir_auth_path: editForm.dir_auth_path,
dir_auth_user_file: editForm.dir_auth_user_file,
php_deny_execute: editForm.php_deny_execute,
})
.then(() => {
setEditSiteId(null)
@@ -415,6 +430,31 @@ export function SitePage() {
Force HTTPS (redirect HTTP to HTTPS)
</label>
</div>
<hr className="my-3" />
<p className="small text-secondary mb-2">Reverse proxy (optional): leave empty for PHP/static site.</p>
<div className="mb-3">
<label className="form-label">Upstream URL</label>
<input name="proxy_upstream" type="text" className="form-control" placeholder="http://127.0.0.1:3000" />
</div>
<div className="form-check mb-3">
<input name="proxy_websocket" type="checkbox" id="create_proxy_ws" className="form-check-input" />
<label htmlFor="create_proxy_ws" className="form-check-label">
WebSocket headers (Upgrade)
</label>
</div>
<p className="small text-secondary mb-2">Directory HTTP auth (requires htpasswd file on server).</p>
<div className="mb-2">
<input name="dir_auth_path" type="text" className="form-control form-control-sm" placeholder="Path prefix e.g. /staff" />
</div>
<div className="mb-3">
<input name="dir_auth_user_file" type="text" className="form-control form-control-sm" placeholder="Absolute path to htpasswd" />
</div>
<div className="form-check mb-3">
<input name="php_deny_execute" type="checkbox" id="create_php_deny" className="form-check-input" />
<label htmlFor="create_php_deny" className="form-check-label">
Block PHP execution under /uploads and /storage
</label>
</div>
<div className="mb-0">
<label className="form-label">Note (optional)</label>
<input name="ps" type="text" placeholder="My website" className="form-control" />
@@ -900,6 +940,59 @@ export function SitePage() {
Force HTTPS
</label>
</div>
<hr className="my-2" />
<div className="mb-2">
<label className="form-label small">Reverse proxy upstream</label>
<input
value={editForm.proxy_upstream}
onChange={(e) => setEditForm({ ...editForm, proxy_upstream: e.target.value })}
type="text"
className="form-control form-control-sm"
placeholder="http://127.0.0.1:3000"
/>
</div>
<div className="form-check mb-3">
<input
type="checkbox"
id="edit_proxy_ws"
className="form-check-input"
checked={editForm.proxy_websocket}
onChange={(e) => setEditForm({ ...editForm, proxy_websocket: e.target.checked })}
/>
<label htmlFor="edit_proxy_ws" className="form-check-label small">
WebSocket proxy headers
</label>
</div>
<div className="mb-2">
<label className="form-label small">Auth path prefix</label>
<input
value={editForm.dir_auth_path}
onChange={(e) => setEditForm({ ...editForm, dir_auth_path: e.target.value })}
type="text"
className="form-control form-control-sm"
/>
</div>
<div className="mb-3">
<label className="form-label small">htpasswd file path</label>
<input
value={editForm.dir_auth_user_file}
onChange={(e) => setEditForm({ ...editForm, dir_auth_user_file: e.target.value })}
type="text"
className="form-control form-control-sm"
/>
</div>
<div className="form-check mb-3">
<input
type="checkbox"
id="edit_php_deny"
className="form-check-input"
checked={editForm.php_deny_execute}
onChange={(e) => setEditForm({ ...editForm, php_deny_execute: e.target.checked })}
/>
<label htmlFor="edit_php_deny" className="form-check-label small">
Deny PHP under /uploads and /storage
</label>
</div>
<div className="mb-0">
<label className="form-label">Note (optional)</label>
<input