From 322809de818b170399b86243dad44021bbd7c87e Mon Sep 17 00:00:00 2001 From: Mats Date: Fri, 31 Jul 2026 11:07:08 +0200 Subject: [PATCH] Setup deployment and redirecting --- README.md | 3 + docs/index.md | 5 + docs/managing.md | 81 +++ docs/maven.md | 36 ++ internal/frontend/dist/index.html | 6 +- .../src/components/deployment-links.tsx | 462 ++++++++++++++++++ internal/frontend/src/lib/api.ts | 37 ++ .../src/routes/admin-repositories.tsx | 68 ++- internal/frontend/src/routes/repository.tsx | 21 + internal/link/pattern.go | 164 +++++++ internal/link/pattern_test.go | 135 +++++ internal/server/api_aliases.go | 92 ++++ internal/server/api_links.go | 379 ++++++++++++++ internal/server/api_repositories.go | 87 +++- internal/server/docker.go | 9 +- internal/server/download.go | 67 +++ internal/server/download_test.go | 303 ++++++++++++ internal/server/http.go | 13 + internal/server/links.go | 99 ++++ internal/server/rename_test.go | 159 ++++++ internal/server/repository.go | 29 +- internal/store/aliases.go | 167 +++++++ internal/store/links.go | 69 +++ internal/store/models/alias.go | 13 + internal/store/models/link.go | 38 ++ internal/store/repositories.go | 16 +- internal/store/store.go | 2 + 27 files changed, 2545 insertions(+), 15 deletions(-) create mode 100644 internal/frontend/src/components/deployment-links.tsx create mode 100644 internal/link/pattern.go create mode 100644 internal/link/pattern_test.go create mode 100644 internal/server/api_aliases.go create mode 100644 internal/server/api_links.go create mode 100644 internal/server/download.go create mode 100644 internal/server/download_test.go create mode 100644 internal/server/links.go create mode 100644 internal/server/rename_test.go create mode 100644 internal/store/aliases.go create mode 100644 internal/store/links.go create mode 100644 internal/store/models/alias.go create mode 100644 internal/store/models/link.go diff --git a/README.md b/README.md index 585765b..b3bbc50 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,9 @@ is no separate database to run and no config file to write before the first star in the UI - Release, prerelease and mixed version policies - Public repositories readable without an account, private ones granted per user +- Deployment links: a fixed URL that downloads the newest matching file of an artifact, without + authentication, even from a private repository +- Repositories can be renamed, with the old name left redirecting until something else claims it - Generated `maven-metadata.xml` and npm packuments, built from the database - Traffic, storage and artifact management dashboards - A markdown welcome hero, an accent colour and a logo of your own on the landing page diff --git a/docs/index.md b/docs/index.md index 8ae937d..b8f8dea 100644 --- a/docs/index.md +++ b/docs/index.md @@ -24,6 +24,11 @@ before the first start. - **Version policies.** A repository takes releases only, prereleases only, or both. - **Per user permissions.** Public repositories are readable without an account. Private ones are granted per user, per repository. +- **Deployment links.** A fixed URL that always downloads the newest matching file of an artifact, + without authentication, so a private repository can publish exactly one file to an installer or a + build that has no credentials. +- **Repositories can be renamed.** The old name keeps redirecting, until another repository claims + it. - **Generated metadata.** `maven-metadata.xml` and npm packuments are built from the database, so they cannot drift from what is actually stored. - **Container images as first-class artifacts.** Push and pull with any Docker client, then read the diff --git a/docs/managing.md b/docs/managing.md index 1951191..827ef58 100644 --- a/docs/managing.md +++ b/docs/managing.md @@ -24,6 +24,87 @@ Creating a repository asks for four things that cannot all be changed later: `allowRedeploy` lets a hosted Maven repository overwrite an existing release. It is off by default, and it does not apply to npm, where a published version is always immutable. +## Renaming a repository + +The name is editable in the settings dialog. Renaming does not move anything on disk: artifacts are +stored against the repository's identifier, so the rename is one column and a redirect. + +The old name is kept, and everything that still asks for it is answered with a redirect to the new +one: + +``` +GET /repository/internal/dev/pixelib/app/39/app-39.jar +301 Moved Permanently +Location: /repository/pixelib-internal/dev/pixelib/app/39/app-39.jar +``` + +A read gets a `301`. A publish gets a `308`, which is the one redirect that keeps the method and the +body rather than turning an upload into a `GET`. Maven, Gradle and npm all follow both, so a build +pointed at the old URL keeps working while you update it. Docker is the exception: a client builds +every URL of a pull or a push from the reference it was given, so a former name resolves in place +there instead of redirecting. + +Renaming also carries the name across everything that referred to it: p2 group members that listed +the repository as a child, and the default Docker repository if it was this one. + +Former names are listed under the name in the Repositories table and in the settings dialog, where +each can be removed to free it. **A former name stops redirecting the moment another repository +claims it.** The new repository owns the name outright; the redirect is not shadowing it and does +not come back if that repository is later deleted. + +Old names live only as redirects. Nothing prunes them, so a repository that has been renamed a few +times keeps answering to every name it ever had until you remove them. + +## Deployment links + +A deployment link is a fixed URL that downloads one file from the newest version of an artifact, +**without authentication, whatever the repository's visibility.** It is how a private repository +publishes exactly one file to something that has no credentials: an installer, a server that pulls +its own plugins, a CI job in someone else's account. + +The problem it solves is that the filename carries the version: + +``` +/repository/pixelib-internal/dev/pixelib/pixelscript/pixelscript-paper/39/pixelscript-paper-39.jar +``` + +That URL breaks on the next release. A link names the artifact and a pattern instead, and resolves +the file on every request: + +``` +https://repo.example.com/download/pixelscript-paper-latest +``` + +Links are managed per repository, from the link icon in the Repositories table or the Links tab on +the repository page. A link is: + +| Field | What it does | +| --- | --- | +| Group ID and Artifact ID | the artifact to track, as the repository's format names it | +| Pattern | which file of a version to serve | +| Match | how to read the pattern, as a template or a regular expression | +| Resolves to | releases, prereleases, or the latest of either | +| Slug | the last segment of the URL, unique across the instance | +| Download as | serve the file under a fixed name instead of the published one | + +A template is literal text with `{version}`, `{baseVersion}`, `{name}` and `{namespace}` filled in, +plus `*` for anything else that varies. `{name}-{version}.jar` is the usual one. For anything a +template cannot say, switch to a regular expression: it is matched against the whole filename, and +placeholders are substituted into it quoted, so a version containing `.` or `+` cannot widen what +the link matches. + +Resolution walks versions newest first and serves the first file that matches. It does not stop at +the newest version, so a release that shipped without the file the link names does not break the +link: it keeps serving the newest release that has one. + +The listing shows what each link currently resolves to, so a pattern that matches nothing is +visible before someone follows it rather than after. A link can be disabled, which answers `404` +without giving the URL up, and downloads through it are counted. + +The slug is the credential. It is the whole address, so treat it the way you would treat a token: +anyone holding it can download that file, and nothing else about the repository is reachable +through it. + ## Proxy repositories ![A proxy repository]({{ site.baseurl }}/assets/img/proxy.jpg) diff --git a/docs/maven.md b/docs/maven.md index 9421e42..6e99e44 100644 --- a/docs/maven.md +++ b/docs/maven.md @@ -88,6 +88,42 @@ repositories { A release repository refuses to overwrite an existing version unless `allowRedeploy` is set on it. Snapshot repositories accept redeployment by default. +## Pulling the latest jar without credentials + +A build that resolves through Maven does not need this, but something that only wants one file often +does: an installer, a server that pulls its own plugins, a job with no credentials to give. The +direct URL is no good on its own, because the version is in the filename and so the URL breaks with +every release. + +``` +https://repo.example.com/repository/pixelib-internal/dev/pixelib/pixelscript/pixelscript-paper/39/pixelscript-paper-39.jar +``` + +An administrator can create a [deployment link]({{ site.baseurl }}/managing#deployment-links) for +the artifact, which resolves the version on every request and needs no authentication even when the +repository is private: + +```sh +curl -L -O https://repo.example.com/download/pixelscript-paper-latest +``` + +The response carries the version it resolved to, so a script can tell whether anything moved without +downloading the file twice: + +```sh +curl -sI https://repo.example.com/download/pixelscript-paper-latest | grep -i x-arca-version +# X-Arca-Version: 39 +``` + +The file is served under its published name unless the link sets one. Set **Download as** on the +link when whatever consumes it wants a stable filename. + +## Renamed repositories + +If a repository has been renamed, its old URL answers with a redirect rather than a `404`. Maven and +Gradle both follow it, so a build keeps resolving until you update the URL. Update it anyway: the +old name stops redirecting the moment another repository claims it. + ## Metadata and checksums `maven-metadata.xml` is generated from the database at both the artifact and the snapshot version diff --git a/internal/frontend/dist/index.html b/internal/frontend/dist/index.html index 2860016..0bb7619 100644 --- a/internal/frontend/dist/index.html +++ b/internal/frontend/dist/index.html @@ -11,10 +11,10 @@ const dark = stored ? stored === 'dark' : window.matchMedia('(prefers-color-scheme: dark)').matches document.documentElement.classList.toggle('dark', dark) - + - - + +
diff --git a/internal/frontend/src/components/deployment-links.tsx b/internal/frontend/src/components/deployment-links.tsx new file mode 100644 index 0000000..bbe87c5 --- /dev/null +++ b/internal/frontend/src/components/deployment-links.tsx @@ -0,0 +1,462 @@ +import { useState, type FormEvent } from 'react' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { Link2, Pencil, Plus, Trash2 } from 'lucide-react' +import { Badge, PolicyBadge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { + Dialog, + DialogBody, + DialogContent, + DialogDescription, + DialogForm, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@/components/ui/dialog' +import { Checkbox, Field, Input, Select, Textarea } from '@/components/ui/input' +import { EmptyState, ErrorBlock, LoadingBlock } from '@/components/ui/feedback' +import { CopyButton } from '@/components/copy-button' +import { + ApiError, + api, + del, + patch, + post, + type DeploymentLink, + type MatchType, + type Policy, + type Repository, + type RepositoryFormat, +} from '@/lib/api' +import { useSession } from '@/lib/session' +import { formatBytes, formatRelative } from '@/lib/utils' + +const iconAction = + 'inline-flex size-8 items-center justify-center rounded-full text-faint transition-colors hover:bg-accent hover:text-foreground' + +export function linksQuery(repository: string) { + return { + queryKey: ['links', repository], + queryFn: () => api<{ links: DeploymentLink[] }>(`/repositories/${repository}/links`), + } +} + +// namespaceLabel names the coordinate the way the format does, so a Maven user +// reads "Group ID" rather than the internal word for it. +function namespaceLabel(format: RepositoryFormat): string { + if (format === 'maven2') return 'Group ID' + if (format === 'npm') return 'Scope' + return 'Namespace' +} + +function artifactLabel(format: RepositoryFormat): string { + if (format === 'maven2') return 'Artifact ID' + if (format === 'npm') return 'Package' + return 'Artifact' +} + +interface LinkDraft { + slug: string + description: string + namespace: string + artifact: string + matchType: MatchType + pattern: string + policy: Policy + filename: string + disabled: boolean +} + +function emptyDraft(): LinkDraft { + return { + slug: '', + description: '', + namespace: '', + artifact: '', + matchType: 'template', + pattern: '{name}-{version}.jar', + policy: 'release', + filename: '', + disabled: false, + } +} + +function draftOf(deployment: DeploymentLink): LinkDraft { + return { + slug: deployment.slug, + description: deployment.description, + namespace: deployment.namespace, + artifact: deployment.artifact, + matchType: deployment.matchType, + pattern: deployment.pattern, + policy: deployment.policy, + filename: deployment.filename, + disabled: deployment.disabled, + } +} + +// LinkForm is the whole of what a link is, shared by creating one and editing +// one so the two cannot drift apart. +function LinkForm({ + draft, + onChange, + format, +}: { + draft: LinkDraft + onChange: (draft: LinkDraft) => void + format: RepositoryFormat +}) { + const set = (key: K, value: LinkDraft[K]) => + onChange({ ...draft, [key]: value }) + + return ( + <> +
+ + set('namespace', event.target.value)} + placeholder="dev.pixelib.pixelscript" + className="font-mono" + /> + + + set('artifact', event.target.value)} + placeholder="pixelscript-paper" + className="font-mono" + autoFocus + /> + +
+ + + + + + + set('pattern', event.target.value)} + placeholder={draft.matchType === 'template' ? '{name}-{version}.jar' : String.raw`.*-\d+\.jar`} + className="font-mono" + /> + + +
+ + + + + set('slug', event.target.value.toLowerCase())} + placeholder="pixelscript-paper-latest" + className="font-mono" + /> + +
+ + + set('filename', event.target.value)} + placeholder="pixelscript-paper.jar" + className="font-mono" + /> + + + +