feat(creator-packs): Phase 3 — uninstall, license, release key-injection
Completes the Creator Packs feature.
- Uninstall: CreatorPackService.uninstallPack reuses ZimService.delete (removes
the ZIM, drops it from the Kiwix library, clears the InstalledResource) behind
DELETE /api/creator-packs/:id. A trash control on installed cards appears only
on the settings "manage" surface (allowUninstall) — never on the Content
Explorer block or the wizard — with a danger confirm modal.
- License: draft "Project NOMAD Creator Pack License v1.0" at
collections/creator-pack-license.md (marked DRAFT — pending legal review;
personal-use, no-redistribution, official-channel-only, creators retain
copyright). The install modal links to it ("View license").
- Release key-injection: Dockerfile ARG/ENV CREATOR_PACKS_APP_KEY in the runtime
stage (empty default → source/CI builds ship unconfigured and hide the UI) +
build-primary-image.yml passes it from the CREATOR_PACKS_APP_KEY CI secret.
NOTE FOR JAKE: add that repo secret = the entitlement Worker's key.
- Update flow was already functional (installed-with-update cards → "Update
pack"); verified.
Browser-tested on NOMAD3: uninstall round-trip (file + DB row + kiwix library
cleaned, then reinstalled), settings-only uninstall control, install-modal
license link, and the "Update available" pill via a temporary catalog bump.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
9a864cb41b
commit
311171f963
|
|
@ -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 }}
|
||||
|
|
|
|||
11
Dockerfile
11
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
|
||||
|
|
|
|||
|
|
@ -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}` })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<UninstallPackResult> {
|
||||
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<void> {
|
||||
try {
|
||||
const kiwixUrl = await this.dockerService.getServiceURL(SERVICE_NAMES.KIWIX)
|
||||
|
|
|
|||
|
|
@ -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<CreatorPackCardProps> = ({ pack, selected, onClick }) => {
|
||||
const CreatorPackCard: React.FC<CreatorPackCardProps> = ({ 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<CreatorPackCardProps> = ({ pack, selected, onCli
|
|||
</span>
|
||||
)}
|
||||
</div>
|
||||
{statusBadge}
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{statusBadge}
|
||||
{onUninstall && isInstalled && (
|
||||
<button
|
||||
type="button"
|
||||
title="Uninstall pack"
|
||||
aria-label="Uninstall pack"
|
||||
className="p-1 rounded text-text-muted hover:text-red-500 hover:bg-red-500/10 transition-colors"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onUninstall(pack)
|
||||
}}
|
||||
>
|
||||
<IconTrash className="w-5 h-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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<CreatorPacksSectionProps> = ({ allowUninstall }) => {
|
||||
const { configured, packs, invalidate: invalidateCreatorPacks } = useCreatorPacks()
|
||||
const { invalidate: invalidateDownloads } = useDownloads({ filetype: 'zim' })
|
||||
const { addNotification } = useNotifications()
|
||||
|
||||
const [packToInstall, setPackToInstall] = useState<CreatorPackWithStatus | null>(null)
|
||||
const [installing, setInstalling] = useState(false)
|
||||
const [packToUninstall, setPackToUninstall] = useState<CreatorPackWithStatus | null>(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 (
|
||||
<>
|
||||
<div className="flex items-center gap-3 mt-8 mb-4">
|
||||
|
|
@ -60,7 +90,12 @@ const CreatorPacksSection: React.FC = () => {
|
|||
{packs.length > 0 ? (
|
||||
<div className="mt-4 grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{packs.map((pack) => (
|
||||
<CreatorPackCard key={pack.id} pack={pack} onClick={setPackToInstall} />
|
||||
<CreatorPackCard
|
||||
key={pack.id}
|
||||
pack={pack}
|
||||
onClick={setPackToInstall}
|
||||
onUninstall={allowUninstall ? setPackToUninstall : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
|
|
@ -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={<IconMovie className="w-6 h-6" />}
|
||||
|
|
@ -88,11 +121,41 @@ const CreatorPacksSection: React.FC = () => {
|
|||
background and appear in Kiwix when ready.
|
||||
</p>
|
||||
<p className="text-sm text-text-muted">
|
||||
Licensed content — personal use, not for redistribution.
|
||||
Licensed content — personal use, not for redistribution.{' '}
|
||||
<a
|
||||
href={LICENSE_URL}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-desert-green underline hover:no-underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
View license
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</StyledModal>
|
||||
|
||||
<StyledModal
|
||||
open={!!packToUninstall}
|
||||
title={packToUninstall ? `Uninstall ${packToUninstall.name}?` : 'Uninstall Creator Pack'}
|
||||
onClose={() => !uninstalling && setPackToUninstall(null)}
|
||||
onCancel={() => setPackToUninstall(null)}
|
||||
onConfirm={handleConfirmUninstall}
|
||||
confirmText="Uninstall pack"
|
||||
confirmIcon="IconTrash"
|
||||
confirmVariant="danger"
|
||||
confirmLoading={uninstalling}
|
||||
icon={<IconMovie className="w-6 h-6" />}
|
||||
>
|
||||
{packToUninstall && (
|
||||
<p className="text-text-secondary">
|
||||
This removes the downloaded videos (
|
||||
{formatBytes(packToUninstall.size_mb * 1024 * 1024, 0)}) from this NOMAD. You can
|
||||
reinstall the pack anytime.
|
||||
</p>
|
||||
)}
|
||||
</StyledModal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Array<{ title: string; slug: string }>>('/docs/list')
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ export default function CreatorPacksPage() {
|
|||
|
||||
{configured ? (
|
||||
<>
|
||||
<CreatorPacksSection />
|
||||
<CreatorPacksSection allowUninstall />
|
||||
<div className="mt-10">
|
||||
<ActiveDownloads filetype="zim" withHeader />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
||||
|
|
|
|||
|
|
@ -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.*
|
||||
Loading…
Reference in New Issue