diff --git a/README.rst b/README.rst index 4807f47b..143c63fa 100644 --- a/README.rst +++ b/README.rst @@ -132,6 +132,10 @@ Finally, build the doc: :: make html +The standalone package browser is published from ``_extra/browser/`` through +``html_extra_path``. It is plain HTML, CSS, and JavaScript, so local and Read +the Docs builds do not require a separate frontend toolchain. + Localization workflow --------------------- diff --git a/_extra/browser/browser.css b/_extra/browser/browser.css new file mode 100644 index 00000000..469d39ba --- /dev/null +++ b/_extra/browser/browser.css @@ -0,0 +1,402 @@ +:root { + color-scheme: light; + --pst-color-primary: #0a7d91; + --pst-color-primary-bg: #d0ecf1; + --pst-color-secondary: #8045e5; + --pst-color-secondary-bg: #e0c7ff; + --pst-color-accent: #c132af; + --pst-color-accent-bg: #f8dff5; + --pst-color-text-base: #222832; + --pst-color-text-muted: #48566b; + --pst-color-border: #d1d5da; + --pst-color-link-higher-contrast: #085d6c; + --pst-color-background: #fff; + --pst-color-on-background: #fff; + --pst-color-surface: #f3f4f5; + --pst-color-heading: var(--pst-color-text-base); + --pst-color-link: var(--pst-color-primary); + --pst-color-link-hover: var(--pst-color-secondary); + --pst-font-family-base: system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + --pst-font-family-heading: var(--pst-font-family-base); + --shell-width: min(88rem, calc(100vw - 2rem)); + --surface-shadow: 0 0.2rem 0.5rem rgba(34, 40, 50, 0.04); +} + +@media (prefers-color-scheme: dark) { + :root { + color-scheme: dark; + --pst-color-primary-bg: #042c33; + --pst-color-secondary-bg: #341a61; + --pst-color-accent: #e47fd7; + --pst-color-accent-bg: #46123f; + --pst-color-text-base: #ced6dd; + --pst-color-text-muted: #9ca4af; + --pst-color-border: #48566b; + --pst-color-link-higher-contrast: #3fb1c5; + --pst-color-background: #14181e; + --pst-color-on-background: #222832; + --pst-color-surface: #29313d; + --pst-color-heading: var(--pst-color-text-base); + --pst-color-link: var(--pst-color-primary); + --pst-color-link-hover: var(--pst-color-secondary); + } +} + +* { + box-sizing: border-box; +} + +html { + min-height: 100%; + background: var(--pst-color-background); +} + +body { + margin: 0; + min-width: 320px; + min-height: 100vh; + background: var(--pst-color-background); + color: var(--pst-color-text-base); + font-family: var(--pst-font-family-base); + font-size: 1rem; + line-height: 1.6; + font-weight: 400; +} + +button, +a, +input { + font: inherit; +} + +button { + border: 0; + cursor: pointer; +} + +a { + color: var(--pst-color-link); + text-decoration: none; +} + +a:hover { + color: var(--pst-color-link-hover); +} + +button:focus-visible, +a:focus-visible, +input:focus-visible { + outline: 2px solid var(--pst-color-primary); + outline-offset: 2px; +} + +#root { + width: 100%; + min-height: 100vh; +} + +.app-shell { + width: var(--shell-width); + margin: 0 auto; + padding: 1.5rem 0 3rem; +} + +.hero { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 1.5rem; + padding-bottom: 1.5rem; + border-bottom: 1px solid var(--pst-color-border); +} + +.eyebrow { + margin: 0 0 0.35rem; + font-size: 0.9rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--pst-color-primary); +} + +.hero h1 { + margin: 0; + font-family: var(--pst-font-family-heading); + font-size: clamp(2rem, 4vw, 2.625rem); + line-height: 1.1; + font-weight: 600; + color: var(--pst-color-heading); +} + +.hero-copy { + margin: 0.75rem 0 0; + max-width: 60ch; + color: var(--pst-color-text-muted); +} + +.hero-actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + justify-content: flex-end; + align-items: flex-start; +} + +.button, +.crumb, +.entry-button, +.entry-link { + transition: + color 150ms ease, + background-color 150ms ease, + border-color 150ms ease, + box-shadow 150ms ease, + transform 150ms ease; +} + +.button { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.7rem 1rem; + border-radius: 999px; + background: var(--pst-color-primary); + color: #fff; + box-shadow: 0 0.125rem 0.25rem rgba(34, 40, 50, 0.08); +} + +.button:hover { + background: var(--pst-color-link-higher-contrast); + color: #fff; + transform: translateY(-1px); +} + +.button-secondary { + background: transparent; + color: var(--pst-color-primary); + border: 1px solid var(--pst-color-border); + box-shadow: none; +} + +.button-secondary:hover { + background: var(--pst-color-surface); + border-color: var(--pst-color-primary); + color: var(--pst-color-link-higher-contrast); +} + +.breadcrumbs { + display: flex; + align-items: center; + gap: 0.45rem; + flex-wrap: wrap; + margin: 1rem 0 1.1rem; + color: var(--pst-color-text-muted); + font-size: 0.95rem; +} + +.crumb { + padding: 0; + background: none; + color: var(--pst-color-link); + font-weight: 600; +} + +.crumb:hover { + color: var(--pst-color-link-hover); + text-decoration: underline; + text-underline-offset: 0.2em; +} + +.crumb-separator { + color: var(--pst-color-text-muted); +} + +.panel { + background: var(--pst-color-background); + border: 1px solid var(--pst-color-border); + border-radius: 0.5rem; + box-shadow: var(--surface-shadow); + overflow: hidden; +} + +.panel-noscript { + margin-top: 2rem; + padding: 1.5rem; +} + +.panel-noscript h1 { + margin-top: 0; +} + +.toolbar { + display: flex; + justify-content: flex-end; + padding: 1rem 1rem 0; +} + +.filter { + width: min(100%, 24rem); +} + +.meta-label { + display: block; + margin-bottom: 0.35rem; + font-size: 0.9rem; + font-weight: 600; + color: var(--pst-color-text-muted); +} + +.filter-input { + width: 100%; + padding: 0.75rem 0.9rem; + border: 1px solid var(--pst-color-border); + border-radius: 0.5rem; + background: var(--pst-color-surface); + color: var(--pst-color-text-base); + outline: none; +} + +.filter-input::placeholder { + color: var(--pst-color-text-muted); +} + +.filter-input:focus { + border-color: var(--pst-color-primary); + background: var(--pst-color-background); + box-shadow: 0 0 0 0.2rem rgba(10, 125, 145, 0.15); +} + +.status { + min-height: 1.5rem; + padding: 0.85rem 1rem 0; + color: var(--pst-color-text-muted); + font-size: 0.95rem; +} + +.table-wrapper { + margin-top: 0.9rem; + border-top: 1px solid var(--pst-color-border); + overflow-x: auto; +} + +.entries-table { + width: 100%; + border-collapse: separate; + border-spacing: 0; + font-size: 0.96rem; +} + +.entries-table thead th { + padding: 0.9rem 1rem; + text-align: left; + background: var(--pst-color-surface); + color: var(--pst-color-text-muted); + font-weight: 600; + border-bottom: 1px solid var(--pst-color-border); +} + +.entries-table tbody tr { + background: var(--pst-color-background); +} + +.entries-table tbody tr:nth-child(even) { + background: var(--pst-color-surface); +} + +.entries-table tbody tr:hover { + background: rgba(10, 125, 145, 0.05); +} + +.entries-table td { + padding: 0.95rem 1rem; + border-bottom: 1px solid var(--pst-color-border); + vertical-align: middle; +} + +.entries-table tbody tr:last-child td { + border-bottom: 0; +} + +.entry-row td:first-child { + max-width: 50%; + word-break: break-word; +} + +.entry-button { + padding: 0; + background: transparent; + border: 0; + color: var(--pst-color-link); + cursor: pointer; + text-align: left; + font-weight: 600; + text-decoration: none; +} + +.entry-button:hover { + color: var(--pst-color-link-hover); + text-decoration: underline; + text-underline-offset: 0.2em; +} + +.entry-link { + color: var(--pst-color-link); + text-decoration: none; + cursor: pointer; + font-weight: 500; +} + +.entry-link:hover { + color: var(--pst-color-link-hover); + text-decoration: underline; + text-underline-offset: 0.2em; +} + +.entry-parent { + background: var(--pst-color-surface); +} + +.entry-parent .entry-button { + color: var(--pst-color-text-muted); +} + +.entry-parent .entry-button:hover { + color: var(--pst-color-primary); +} + +@media (max-width: 720px) { + .app-shell { + width: min(100vw - 1rem, 88rem); + padding-top: 1rem; + } + + .hero { + flex-direction: column; + } + + .hero-actions { + width: 100%; + justify-content: flex-start; + } + + .toolbar { + justify-content: stretch; + } + + .filter { + width: 100%; + } + + .entries-table { + font-size: 0.9rem; + } + + .entries-table th, + .entries-table td { + padding: 0.75rem 0.85rem; + } + + .entry-row td:first-child { + max-width: 100%; + } +} diff --git a/_extra/browser/browser.js b/_extra/browser/browser.js new file mode 100644 index 00000000..bcb693e9 --- /dev/null +++ b/_extra/browser/browser.js @@ -0,0 +1,367 @@ +const root = document.querySelector('#root'); + +if (!root) { + throw new Error('Missing browser root element.'); +} + +const config = { + bucket: document.body.dataset.bucket || 'nethsecurity', + endpoint: (document.body.dataset.endpoint || 'https://ams3.digitaloceanspaces.com').replace(/\/$/, ''), + cdnEndpoint: (document.body.dataset.cdnEndpoint || 'https://updates.nethsecurity.nethserver.org').replace(/\/$/, ''), + docsHome: document.body.dataset.docsHome || '../index.html', + downloadPage: document.body.dataset.downloadPage || '../download.html', +}; + +const state = { + bucket: config.bucket, + endpoint: config.endpoint, + cdnEndpoint: config.cdnEndpoint, + prefix: '', + filter: '', + currentFolders: [], + currentFiles: [], +}; + +root.innerHTML = ` +
+
+
+

Public repository browser

+

NethSecurity

+

Browse folders and download release artifacts from the public repository.

+
+
+ Documentation + Download page + + +
+
+ + + +
+
+ +
+
Loading...
+
+ + + + + + + + + + +
NameTypeSizeModified
+
+
+
+`; + +const breadcrumbs = document.querySelector('#breadcrumbs'); +const filterInput = document.querySelector('#filter-input'); +const status = document.querySelector('#status'); +const entriesBody = document.querySelector('#entries-body'); + +document.querySelector('#home-button').addEventListener('click', () => navigateTo('')); +document.querySelector('#refresh-button').addEventListener('click', () => loadEntries()); +filterInput.addEventListener('input', (event) => { + state.filter = event.target.value.trim().toLowerCase(); + renderEntries(); +}); + +window.addEventListener('hashchange', syncFromHash); + +syncFromHash(); + +function syncFromHash() { + const hashPrefix = decodeURIComponent(window.location.hash.replace(/^#/, '')); + state.prefix = normalizePrefix(hashPrefix); + renderBreadcrumbs(); + loadEntries(); +} + +function navigateTo(prefix) { + const normalizedPrefix = normalizePrefix(prefix); + const nextHash = normalizedPrefix ? `#${encodeURIComponent(normalizedPrefix)}` : ''; + if (window.location.hash === nextHash) { + state.prefix = normalizedPrefix; + renderBreadcrumbs(); + loadEntries(); + return; + } + + window.location.hash = nextHash; +} + +async function loadEntries() { + const requestedPrefix = state.prefix; + status.textContent = 'Loading...'; + entriesBody.innerHTML = ''; + + try { + const xmlText = await fetchListing(requestedPrefix); + if (requestedPrefix !== state.prefix) { + return; + } + + const { folders, files } = parseListBucketResult(xmlText, requestedPrefix); + state.currentFolders = folders; + state.currentFiles = files; + renderEntries(); + } catch (error) { + if (requestedPrefix !== state.prefix) { + return; + } + + state.currentFolders = []; + state.currentFiles = []; + status.textContent = `Unable to load bucket contents: ${error.message}`; + } +} + +function renderEntries() { + entriesBody.innerHTML = ''; + + const visibleFolders = state.currentFolders.filter((folder) => matchesFilter(folder.prefix)); + const visibleFiles = state.currentFiles.filter((file) => matchesFilter(file.key)); + + if (state.prefix) { + entriesBody.appendChild(createParentEntry(state.prefix)); + } + + visibleFolders.forEach((folder) => entriesBody.appendChild(createFolderEntry(folder))); + visibleFiles.forEach((file) => entriesBody.appendChild(createFileEntry(file))); + + const totalCount = state.currentFolders.length + state.currentFiles.length; + const visibleCount = visibleFolders.length + visibleFiles.length; + + if (totalCount === 0) { + status.textContent = 'This folder is empty.'; + return; + } + + if (!state.filter) { + status.textContent = `${totalCount} item${totalCount === 1 ? '' : 's'}`; + return; + } + + status.textContent = `${visibleCount} of ${totalCount} item${totalCount === 1 ? '' : 's'} match "${state.filter}"`; +} + +async function fetchListing(prefix) { + const url = new URL(`${state.endpoint}/${state.bucket}/`); + url.searchParams.set('list-type', '2'); + url.searchParams.set('delimiter', '/'); + if (prefix) { + url.searchParams.set('prefix', prefix); + } + + const response = await fetch(url.toString()); + if (!response.ok) { + throw new Error(`${response.status} ${response.statusText}`.trim()); + } + + return response.text(); +} + +function parseListBucketResult(xmlText, prefix) { + const parser = new DOMParser(); + const xml = parser.parseFromString(xmlText, 'application/xml'); + const errorNode = xml.querySelector('parsererror'); + if (errorNode) { + throw new Error('invalid XML response'); + } + + const folders = getNodes(xml, 'CommonPrefixes') + .map((node) => ({ + prefix: getChildText(node, 'Prefix'), + })) + .filter((item) => item.prefix && item.prefix !== prefix); + + const files = getNodes(xml, 'Contents') + .map((node) => ({ + key: getChildText(node, 'Key'), + lastModified: getChildText(node, 'LastModified'), + size: Number.parseInt(getChildText(node, 'Size') || '0', 10), + })) + .filter((item) => item.key && item.key !== prefix); + + return { folders, files }; +} + +function createParentEntry(prefix) { + const row = document.createElement('tr'); + row.className = 'entry-row entry-parent'; + + const nameCell = document.createElement('td'); + const button = document.createElement('button'); + button.className = 'entry-button'; + button.type = 'button'; + button.textContent = '..'; + button.addEventListener('click', () => navigateTo(parentPrefix(prefix))); + nameCell.appendChild(button); + row.appendChild(nameCell); + + row.appendChild(createTextCell('Parent')); + row.appendChild(createTextCell('')); + row.appendChild(createTextCell('')); + + return row; +} + +function createFolderEntry(folder) { + const row = document.createElement('tr'); + row.className = 'entry-row entry-folder'; + + const nameCell = document.createElement('td'); + const button = document.createElement('button'); + button.className = 'entry-button'; + button.type = 'button'; + button.textContent = displayName(folder.prefix); + button.addEventListener('click', () => navigateTo(folder.prefix)); + nameCell.appendChild(button); + row.appendChild(nameCell); + + row.appendChild(createTextCell('Folder')); + row.appendChild(createTextCell('')); + row.appendChild(createTextCell('')); + + return row; +} + +function createFileEntry(file) { + const row = document.createElement('tr'); + row.className = 'entry-row entry-file'; + + const nameCell = document.createElement('td'); + const link = document.createElement('a'); + link.className = 'entry-link'; + link.href = publicObjectUrl(file.key); + link.target = '_blank'; + link.rel = 'noreferrer'; + link.textContent = displayName(file.key); + nameCell.appendChild(link); + row.appendChild(nameCell); + + row.appendChild(createTextCell('File')); + row.appendChild(createTextCell(formatBytes(file.size))); + row.appendChild(createTextCell(formatDate(file.lastModified))); + + return row; +} + +function createTextCell(value) { + const cell = document.createElement('td'); + cell.textContent = value; + return cell; +} + +function renderBreadcrumbs() { + breadcrumbs.innerHTML = ''; + + const segments = state.prefix.split('/').filter(Boolean); + if (segments.length === 0) { + return; + } + + const home = document.createElement('button'); + home.type = 'button'; + home.className = 'crumb'; + home.textContent = state.bucket; + home.addEventListener('click', () => navigateTo('')); + breadcrumbs.appendChild(home); + + let accumulated = ''; + segments.forEach((segment) => { + accumulated += `${segment}/`; + + const separator = document.createElement('span'); + separator.className = 'crumb-separator'; + separator.textContent = '/'; + breadcrumbs.appendChild(separator); + + const crumb = document.createElement('button'); + crumb.type = 'button'; + crumb.className = 'crumb'; + crumb.textContent = segment; + crumb.addEventListener('click', () => navigateTo(accumulated)); + breadcrumbs.appendChild(crumb); + }); +} + +function getNodes(xml, tagName) { + return Array.from(xml.getElementsByTagNameNS('*', tagName)); +} + +function getChildText(node, tagName) { + return node.getElementsByTagNameNS('*', tagName)[0]?.textContent || ''; +} + +function parentPrefix(prefix) { + const segments = prefix.split('/').filter(Boolean); + segments.pop(); + return segments.length > 0 ? `${segments.join('/')}/` : ''; +} + +function normalizePrefix(prefix) { + if (!prefix) { + return ''; + } + + return prefix.endsWith('/') ? prefix : `${prefix}/`; +} + +function displayName(value) { + const trimmed = value.endsWith('/') ? value.slice(0, -1) : value; + const parts = trimmed.split('/'); + return parts[parts.length - 1] || '/'; +} + +function matchesFilter(value) { + if (!state.filter) { + return true; + } + + return value.toLowerCase().includes(state.filter); +} + +function publicObjectUrl(key) { + const encodedKey = key + .split('/') + .map((segment) => encodeURIComponent(segment)) + .join('/'); + return new URL(encodedKey, `${state.cdnEndpoint}/`).toString(); +} + +function formatBytes(size) { + if (size < 1024) { + return `${size} B`; + } + + const units = ['KB', 'MB', 'GB', 'TB']; + let value = size; + let unitIndex = -1; + + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024; + unitIndex += 1; + } + + return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unitIndex]}`; +} + +function formatDate(value) { + if (!value) { + return 'unknown date'; + } + + return new Date(value).toLocaleString(); +} diff --git a/_extra/browser/index.html b/_extra/browser/index.html new file mode 100644 index 00000000..3cd4145b --- /dev/null +++ b/_extra/browser/index.html @@ -0,0 +1,32 @@ + + + + + + NethSecurity package browser + + + + + +
+ + + diff --git a/conf.py b/conf.py index 27cf2cf6..74863210 100644 --- a/conf.py +++ b/conf.py @@ -59,6 +59,7 @@ # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ['_static'] +html_extra_path = ['_extra'] html_css_files = ["custom.css"] html_js_files = ['kapa.js'] diff --git a/download.rst b/download.rst index 34be1f5c..0f057368 100644 --- a/download.rst +++ b/download.rst @@ -21,6 +21,11 @@ For verification, download also the hash file and execute the following command To proceed with the installation of NethSecurity, you have two options: write the downloaded image directly to your disk or create a bootable USB stick. Refer to the :ref:`installation ` page for detailed instructions on both methods. +You can navigate inside the package repository using the `package browser `_ to find all availables releases +and download directly images, hash files and packages. + +The tables below list the available releases along with their respective download links. + .. csv-table:: Stable releases :file: stable.csv :widths: 60, 10, 10, 10