diff --git a/.github/workflows/build-primary-image.yml b/.github/workflows/build-primary-image.yml index 6a740ea..cf6cebc 100644 --- a/.github/workflows/build-primary-image.yml +++ b/.github/workflows/build-primary-image.yml @@ -52,3 +52,4 @@ jobs: VERSION=${{ inputs.version }} BUILD_DATE=${{ github.event.workflow_run.created_at }} VCS_REF=${{ github.sha }} + CREATOR_PACKS_APP_KEY=${{ secrets.CREATOR_PACKS_APP_KEY }} diff --git a/Dockerfile b/Dockerfile index c9224e6..99affee 100644 --- a/Dockerfile +++ b/Dockerfile @@ -73,6 +73,17 @@ LABEL org.opencontainers.image.title="Project N.O.M.A.D" \ org.opencontainers.image.licenses="Apache-2.0" ENV NODE_ENV=production + +# Creator Packs entitlement key, injected into OFFICIAL release builds at build +# time (--build-arg CREATOR_PACKS_APP_KEY=... from the CREATOR_PACKS_APP_KEY CI +# secret; see build-primary-image.yml). Baked as an ENV so admin/start/env.ts +# reads it at runtime. Empty by default, so builds from source (and any build +# without the secret) ship UNCONFIGURED and hide the Creator Packs UI. The key +# lands in this public image layer (extractable — the accepted ceiling); rotate +# via `wrangler secret put APP_KEY` + a new image if it leaks. +ARG CREATOR_PACKS_APP_KEY="" +ENV CREATOR_PACKS_APP_KEY=$CREATOR_PACKS_APP_KEY + WORKDIR /app COPY --from=production-deps /app/node_modules /app/node_modules COPY --from=build /app/build /app diff --git a/admin/app/controllers/creator_packs_controller.ts b/admin/app/controllers/creator_packs_controller.ts index 0a90a10..c07d164 100644 --- a/admin/app/controllers/creator_packs_controller.ts +++ b/admin/app/controllers/creator_packs_controller.ts @@ -47,4 +47,16 @@ export default class CreatorPacksController { }) } } + + async uninstall({ params, response }: HttpContext) { + const packId = params.id as string + const result = await this.creatorPackService.uninstallPack(packId) + + switch (result.code) { + case 'uninstalled': + return { message: 'Pack uninstalled', filename: result.filename } + case 'not_installed': + return response.status(404).send({ message: `Creator pack is not installed: ${packId}` }) + } + } } diff --git a/admin/app/services/creator_pack_service.ts b/admin/app/services/creator_pack_service.ts index 8352b4a..fa5ec10 100644 --- a/admin/app/services/creator_pack_service.ts +++ b/admin/app/services/creator_pack_service.ts @@ -2,6 +2,7 @@ import env from '#start/env' import logger from '@adonisjs/core/services/logger' import { join } from 'node:path' import { DockerService } from '#services/docker_service' +import { ZimService } from '#services/zim_service' import { CollectionManifestService } from '#services/collection_manifest_service' import { RunDownloadJob } from '#jobs/run_download_job' import { ZIM_STORAGE_PATH } from '../utils/fs.js' @@ -28,6 +29,10 @@ export type InstallPackResult = | { code: 'not_found' } | { code: 'not_configured' } +export type UninstallPackResult = + | { code: 'uninstalled'; filename: string } + | { code: 'not_installed' } + /** * Creator Packs install rail. Diverges from the curated-collections rail in * exactly one way: instead of a static manifest URL, the ZIM is fetched from the @@ -129,6 +134,30 @@ export class CreatorPackService { return { code: 'dispatched', filename } } + /** + * Uninstall an installed pack: delete the ZIM (which via ZimService.delete also + * removes it from the Kiwix library and clears its InstalledResource row). + * Uses the INSTALLED version (not the catalog's) so we remove the file that's + * actually on disk even if a newer version has since been published. + */ + async uninstallPack(packId: string): Promise { + const { default: InstalledResource } = await import('#models/installed_resource') + const installed = await InstalledResource.query() + .where('resource_type', 'zim') + .where('resource_id', packId) + .first() + if (!installed) { + return { code: 'not_installed' } + } + + const filename = `${packId}_${installed.version}.zim` + const zimService = new ZimService(this.dockerService) + await zimService.delete(filename) + + logger.info(`[CreatorPackService] Uninstalled pack ${packId} (${filename})`) + return { code: 'uninstalled', filename } + } + private async ensureKiwixInstalled(): Promise { try { const kiwixUrl = await this.dockerService.getServiceURL(SERVICE_NAMES.KIWIX) diff --git a/admin/inertia/components/CreatorPackCard.tsx b/admin/inertia/components/CreatorPackCard.tsx index 820d0e4..c36c202 100644 --- a/admin/inertia/components/CreatorPackCard.tsx +++ b/admin/inertia/components/CreatorPackCard.tsx @@ -7,6 +7,7 @@ import { IconCircleCheck, IconLoader2, IconMovie, + IconTrash, } from '@tabler/icons-react' export interface CreatorPackCardProps { @@ -14,9 +15,11 @@ export interface CreatorPackCardProps { /** In-session wizard selection highlight (before anything is installed). */ selected?: boolean onClick?: (pack: CreatorPackWithStatus) => void + /** When set, an installed pack shows an uninstall control (settings surface only). */ + onUninstall?: (pack: CreatorPackWithStatus) => void } -const CreatorPackCard: React.FC = ({ pack, selected, onClick }) => { +const CreatorPackCard: React.FC = ({ pack, selected, onClick, onUninstall }) => { const isInstalled = pack.status === 'installed' const isDownloading = pack.status === 'downloading' const hasUpdate = !!pack.available_update_version @@ -91,7 +94,23 @@ const CreatorPackCard: React.FC = ({ pack, selected, onCli )} - {statusBadge} +
+ {statusBadge} + {onUninstall && isInstalled && ( + + )} +
) diff --git a/admin/inertia/components/CreatorPacksSection.tsx b/admin/inertia/components/CreatorPacksSection.tsx index 3ee636f..61ef3dc 100644 --- a/admin/inertia/components/CreatorPacksSection.tsx +++ b/admin/inertia/components/CreatorPacksSection.tsx @@ -9,24 +9,37 @@ import StyledModal from '~/components/StyledModal' import { formatBytes } from '~/lib/util' import type { CreatorPackWithStatus } from '../../types/collections' +// Canonical Creator Pack License (one license across the seed packs). Opened in a +// new tab from the install modal; install is an online action so an external link +// is fine. A per-pack catalog `license_url` can supersede this later if needed. +const LICENSE_URL = + 'https://github.com/Crosstalk-Solutions/project-nomad/blob/main/collections/creator-pack-license.md' + +export interface CreatorPacksSectionProps { + /** Show uninstall controls on installed packs (the settings "manage" surface). */ + allowUninstall?: boolean +} + /** - * Install-on-click grid of Creator Packs + a confirm modal. Shared by the - * Content Explorer block and the /settings/creator-packs page. Renders NOTHING - * when the build isn't configured (fork / key unset) — a fork never sees a - * broken install button. The Easy Setup wizard does NOT use this (it needs - * selection semantics, not install-on-click) and drives CreatorPackCard itself. + * Install-on-click grid of Creator Packs + confirm modals. Shared by the Content + * Explorer block and the /settings/creator-packs page. Renders NOTHING when the + * build isn't configured (fork / key unset) — a fork never sees a broken install + * button. The Easy Setup wizard does NOT use this (it needs selection semantics, + * not install-on-click) and drives CreatorPackCard itself. */ -const CreatorPacksSection: React.FC = () => { +const CreatorPacksSection: React.FC = ({ allowUninstall }) => { const { configured, packs, invalidate: invalidateCreatorPacks } = useCreatorPacks() const { invalidate: invalidateDownloads } = useDownloads({ filetype: 'zim' }) const { addNotification } = useNotifications() const [packToInstall, setPackToInstall] = useState(null) const [installing, setInstalling] = useState(false) + const [packToUninstall, setPackToUninstall] = useState(null) + const [uninstalling, setUninstalling] = useState(false) if (!configured) return null - const handleConfirm = async () => { + const handleConfirmInstall = async () => { if (!packToInstall) return setInstalling(true) try { @@ -43,6 +56,23 @@ const CreatorPacksSection: React.FC = () => { } } + const handleConfirmUninstall = async () => { + if (!packToUninstall) return + setUninstalling(true) + try { + await api.uninstallCreatorPack(packToUninstall.id) + addNotification({ message: `Uninstalled "${packToUninstall.name}"`, type: 'success' }) + invalidateCreatorPacks() + invalidateDownloads() + setPackToUninstall(null) + } catch (error) { + console.error('Error uninstalling creator pack:', error) + addNotification({ message: 'An error occurred while uninstalling.', type: 'error' }) + } finally { + setUninstalling(false) + } + } + return ( <>
@@ -60,7 +90,12 @@ const CreatorPacksSection: React.FC = () => { {packs.length > 0 ? (
{packs.map((pack) => ( - + ))}
) : ( @@ -72,10 +107,8 @@ const CreatorPacksSection: React.FC = () => { title={packToInstall ? `Install ${packToInstall.name}?` : 'Install Creator Pack'} onClose={() => !installing && setPackToInstall(null)} onCancel={() => setPackToInstall(null)} - onConfirm={handleConfirm} - confirmText={ - packToInstall?.available_update_version ? 'Update pack' : 'Install pack' - } + onConfirm={handleConfirmInstall} + confirmText={packToInstall?.available_update_version ? 'Update pack' : 'Install pack'} confirmIcon="IconDownload" confirmLoading={installing} icon={} @@ -88,11 +121,41 @@ const CreatorPacksSection: React.FC = () => { background and appear in Kiwix when ready.

- Licensed content — personal use, not for redistribution. + Licensed content — personal use, not for redistribution.{' '} + e.stopPropagation()} + > + View license +

)} + + !uninstalling && setPackToUninstall(null)} + onCancel={() => setPackToUninstall(null)} + onConfirm={handleConfirmUninstall} + confirmText="Uninstall pack" + confirmIcon="IconTrash" + confirmVariant="danger" + confirmLoading={uninstalling} + icon={} + > + {packToUninstall && ( +

+ This removes the downloaded videos ( + {formatBytes(packToUninstall.size_mb * 1024 * 1024, 0)}) from this NOMAD. You can + reinstall the pack anytime. +

+ )} +
) } diff --git a/admin/inertia/lib/api.ts b/admin/inertia/lib/api.ts index a068b53..e027b8e 100644 --- a/admin/inertia/lib/api.ts +++ b/admin/inertia/lib/api.ts @@ -717,6 +717,15 @@ class API { })() } + async uninstallCreatorPack(id: string) { + return catchInternal(async () => { + const response = await this.client.delete<{ message: string; filename?: string }>( + `/creator-packs/${id}` + ) + return response.data + })() + } + async listDocs() { return catchInternal(async () => { const response = await this.client.get>('/docs/list') diff --git a/admin/inertia/pages/settings/creator-packs.tsx b/admin/inertia/pages/settings/creator-packs.tsx index fecdd62..2454404 100644 --- a/admin/inertia/pages/settings/creator-packs.tsx +++ b/admin/inertia/pages/settings/creator-packs.tsx @@ -20,7 +20,7 @@ export default function CreatorPacksPage() { {configured ? ( <> - +
diff --git a/admin/start/routes.ts b/admin/start/routes.ts index 8427865..0ebef02 100644 --- a/admin/start/routes.ts +++ b/admin/start/routes.ts @@ -231,6 +231,7 @@ router .group(() => { router.get('/', [CreatorPacksController, 'index']) router.post('/:id/install', [CreatorPacksController, 'install']) + router.delete('/:id', [CreatorPacksController, 'uninstall']) }) .prefix('/api/creator-packs') diff --git a/collections/creator-pack-license.md b/collections/creator-pack-license.md new file mode 100644 index 0000000..1ac960c --- /dev/null +++ b/collections/creator-pack-license.md @@ -0,0 +1,88 @@ +# Project NOMAD Creator Pack License + +**Version 1.0**  ·  License ID: `nomad-creator-pack-1.0` + +> **DRAFT — pending legal review.** This document is a starting point drafted for +> convenience, not legal advice, and has not been reviewed by an attorney. The +> terms below describe the intended arrangement; have counsel review and finalize +> before relying on it. Nothing here is a binding offer until finalized. + +--- + +## 1. What this covers + +This license governs **Creator Pack content** — the curated video collections +(and their titles, descriptions, thumbnails, and packaging) distributed through +Project NOMAD as "Creator Packs." It does **not** cover the Project NOMAD +software itself, which is licensed separately under Apache License 2.0. Where the +two ever appear to conflict, the Apache 2.0 license governs the software and this +license governs the pack content. + +## 2. Ownership + +Each Creator retains all copyright and other rights in their own videos and +associated materials. Distribution through Project NOMAD does not transfer +ownership. "Creator" means the individual or organization whose content a pack +contains (for example, Crosstalk Solutions or Project NOMAD). + +## 3. Grant to Project NOMAD + +Each Creator grants Project NOMAD (Crosstalk Solutions, LLC) a non-exclusive, +revocable right to reproduce, package, host, and distribute their Creator Pack +**through the official Project NOMAD distribution channel only**. This grant does +not permit Project NOMAD to sublicense the content for redistribution outside +that official channel. + +## 4. License to end users + +Subject to these terms, an end user who installs a Creator Pack on their own +Project NOMAD server is granted a **personal, non-commercial, non-transferable, +non-exclusive** license to store and view that pack's content offline on their +own device(s) for their own use and that of their household or immediate +organization. + +## 5. Restrictions + +Except as expressly permitted above, you may **not**: + +1. **Redistribute or re-host** any Creator Pack or its contents — including + copying pack files to another server, mirror, CDN, bucket, torrent, or file + share, or making them available for download by others. +2. **Bundle or ship** Creator Pack content with any fork, derivative, or + third-party distribution of Project NOMAD or any other product. +3. **Serve** Creator Pack content from any distribution channel other than the + official Project NOMAD channel, or circumvent the entitlement controls that + gate access to it. +4. **Sell, rent, sublicense, or commercially exploit** the content, or use it to + train machine-learning models. +5. **Remove or alter** creator branding, attribution, or license notices. + +For clarity: the Apache 2.0 license on the Project NOMAD **software** permits +forking the software, but it grants **no rights** to the Creator Pack **content**, +which remains governed exclusively by this license. A fork may not distribute or +serve Creator Packs. + +## 6. Termination + +This license terminates automatically if you breach it, and Project NOMAD or a +Creator may revoke it at any time. On termination you must stop using and delete +the affected Creator Pack content. Sections 2, 5, 7, and 8 survive termination. + +## 7. No warranty + +Creator Pack content is provided **"as is," without warranty of any kind**, +express or implied, including merchantability, fitness for a particular purpose, +and non-infringement. + +## 8. Limitation of liability + +To the maximum extent permitted by law, neither Project NOMAD, Crosstalk +Solutions, LLC, nor any Creator is liable for any indirect, incidental, special, +consequential, or punitive damages arising from the Creator Pack content or this +license. + +--- + +*Questions about this license or Creator Pack participation: contact Project NOMAD +via https://www.projectnomad.us. Governing law and venue to be specified on legal +review.*