+ {linksApply(repository) ?
: null}
diff --git a/internal/frontend/src/routes/repository.tsx b/internal/frontend/src/routes/repository.tsx
index 626575c..d9df1d4 100644
--- a/internal/frontend/src/routes/repository.tsx
+++ b/internal/frontend/src/routes/repository.tsx
@@ -8,6 +8,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { EmptyState, ErrorBlock, LoadingBlock } from '@/components/ui/feedback'
import { Coordinate, CopyButton } from '@/components/copy-button'
+import { DeploymentLinks, linksApply } from '@/components/deployment-links'
import { MavenUsage } from '@/components/snippets/maven'
import { NPMUsage } from '@/components/snippets/npm'
import { P2Usage } from '@/components/snippets/p2'
@@ -339,6 +340,20 @@ export function RepositoryRoute() {
) : null}
+ {data.aliases.length > 0 ? (
+
+ Also answers to{' '}
+ {data.aliases.map((alias, index) => (
+
+ {index > 0 ? ', ' : ''}
+ {alias}
+
+ ))}
+ , which {data.aliases.length === 1 ? 'redirects' : 'redirect'} here from before it was
+ renamed.
+
+ ) : null}
+
{data.type === 'proxy' ? (
Mirrors {data.remoteUrl}. Artifacts are
@@ -368,6 +383,7 @@ export function RepositoryRoute() {
Overview
Browse
Usage
+ {linksApply(data) ? Links : null}
@@ -383,6 +399,11 @@ export function RepositoryRoute() {
+ {linksApply(data) ? (
+
+
+
+ ) : null}
>
)
diff --git a/internal/link/pattern.go b/internal/link/pattern.go
new file mode 100644
index 0000000..c78bc6a
--- /dev/null
+++ b/internal/link/pattern.go
@@ -0,0 +1,164 @@
+// Package link matches the filename a deployment link points at. A link names
+// an artifact rather than a file, because the filename of a Maven artifact
+// carries the version and therefore changes with every release.
+package link
+
+import (
+ "fmt"
+ "regexp"
+ "strings"
+)
+
+const (
+ // MatchTemplate is a filename written out with {version} and friends left
+ // as placeholders, plus '*' for anything else that varies.
+ MatchTemplate = "template"
+ // MatchRegex is a regular expression matched against the whole filename,
+ // for the cases a template cannot express.
+ MatchRegex = "regex"
+
+ MaxPatternLength = 200
+ // wildcard stands in for whatever a template leaves open. It stops at a
+ // separator so a pattern cannot reach across directories.
+ wildcard = `[^/]*`
+)
+
+// MatchTypes are the two ways a pattern can be read, in the order the UI offers
+// them.
+var MatchTypes = []string{MatchTemplate, MatchRegex}
+
+// Fields are the coordinates one candidate version supplies to a pattern.
+type Fields struct {
+ Namespace string
+ Name string
+ Version string
+ BaseVersion string
+}
+
+func (f Fields) value(placeholder string) (string, bool) {
+ switch placeholder {
+ case "namespace", "groupId", "group":
+ return f.Namespace, true
+ case "name", "artifactId", "artifact":
+ return f.Name, true
+ case "version":
+ return f.Version, true
+ case "baseVersion":
+ return f.BaseVersion, true
+ default:
+ return "", false
+ }
+}
+
+// Placeholders lists what a pattern may interpolate, in the order the UI offers
+// them. The aliases above are accepted but not advertised.
+var Placeholders = []string{"version", "baseVersion", "name", "namespace"}
+
+var placeholderPattern = regexp.MustCompile(`\{([A-Za-z]+)\}`)
+
+// Compile turns a stored pattern into a matcher for one version's filenames.
+// Placeholders are substituted before compilation and quoted as they go, so a
+// version like "1.0.0+build.1" cannot smuggle regex syntax into the expression.
+func Compile(matchType, pattern string, fields Fields) (*regexp.Regexp, error) {
+ if err := Validate(matchType, pattern); err != nil {
+ return nil, err
+ }
+
+ expression, err := substitute(matchType, pattern, fields)
+ if err != nil {
+ return nil, err
+ }
+
+ compiled, err := regexp.Compile("^(?:" + expression + ")$")
+ if err != nil {
+ return nil, fmt.Errorf("pattern is not a valid regular expression: %w", err)
+ }
+ return compiled, nil
+}
+
+// Validate checks a pattern without a version to substitute, which is what
+// creating or editing a link has to do before anything has been published.
+func Validate(matchType, pattern string) error {
+ switch {
+ case strings.TrimSpace(pattern) == "":
+ return fmt.Errorf("pattern is required")
+ case len(pattern) > MaxPatternLength:
+ return fmt.Errorf("pattern must be at most %d characters", MaxPatternLength)
+ case strings.Contains(pattern, "/"):
+ return fmt.Errorf("pattern matches a filename, so it cannot contain '/'")
+ }
+
+ for _, match := range placeholderPattern.FindAllStringSubmatch(pattern, -1) {
+ if _, ok := (Fields{}).value(match[1]); !ok {
+ return fmt.Errorf("%s is not a known placeholder, use one of {%s}",
+ match[0], strings.Join(Placeholders, "}, {"))
+ }
+ }
+
+ switch matchType {
+ case MatchTemplate:
+ return nil
+ case MatchRegex:
+ // Substituting a specimen version keeps a placeholder from splitting a
+ // group open, so what compiles here is what compiles at download time.
+ specimen := Fields{Namespace: "namespace", Name: "name", Version: "1", BaseVersion: "1"}
+ expression, err := substitute(MatchRegex, pattern, specimen)
+ if err != nil {
+ return err
+ }
+ if _, err := regexp.Compile("^(?:" + expression + ")$"); err != nil {
+ return fmt.Errorf("pattern is not a valid regular expression: %w", err)
+ }
+ return nil
+ default:
+ return fmt.Errorf("matchType must be one of: %s", strings.Join(MatchTypes, ", "))
+ }
+}
+
+// Expand renders a pattern for display, with the placeholders filled in and no
+// escaping applied. It is what the UI shows a link as currently resolving to.
+func Expand(pattern string, fields Fields) string {
+ return placeholderPattern.ReplaceAllStringFunc(pattern, func(match string) string {
+ value, ok := fields.value(strings.Trim(match, "{}"))
+ if !ok {
+ return match
+ }
+ return value
+ })
+}
+
+// substitute walks the pattern once, quoting each placeholder value as it is
+// spliced in. A regex keeps everything between the placeholders verbatim; a
+// template has only '*' as syntax, so the rest of it is escaped here too.
+func substitute(matchType, pattern string, fields Fields) (string, error) {
+ var builder strings.Builder
+ var failure error
+
+ between := func(text string) string {
+ if matchType == MatchRegex {
+ return text
+ }
+ parts := strings.Split(text, "*")
+ for index, part := range parts {
+ parts[index] = regexp.QuoteMeta(part)
+ }
+ return strings.Join(parts, wildcard)
+ }
+
+ end := 0
+ for _, location := range placeholderPattern.FindAllStringSubmatchIndex(pattern, -1) {
+ builder.WriteString(between(pattern[end:location[0]]))
+
+ name := pattern[location[2]:location[3]]
+ value, ok := fields.value(name)
+ if !ok {
+ failure = fmt.Errorf("{%s} is not a known placeholder", name)
+ return "", failure
+ }
+ builder.WriteString(regexp.QuoteMeta(value))
+ end = location[1]
+ }
+ builder.WriteString(between(pattern[end:]))
+
+ return builder.String(), failure
+}
diff --git a/internal/link/pattern_test.go b/internal/link/pattern_test.go
new file mode 100644
index 0000000..895e910
--- /dev/null
+++ b/internal/link/pattern_test.go
@@ -0,0 +1,135 @@
+package link
+
+import (
+ "strings"
+ "testing"
+)
+
+var release = Fields{
+ Namespace: "dev.pixelib.pixelscript",
+ Name: "pixelscript-paper",
+ Version: "39",
+ BaseVersion: "39",
+}
+
+func TestTemplateMatchesTheVersionedFilename(t *testing.T) {
+ cases := []struct {
+ name string
+ pattern string
+ filename string
+ want bool
+ }{
+ {"the version spelled out", "{name}-{version}.jar", "pixelscript-paper-39.jar", true},
+ {"a different version", "{name}-{version}.jar", "pixelscript-paper-38.jar", false},
+ {"a classifier the pattern does not name", "{name}-{version}.jar", "pixelscript-paper-39-sources.jar", false},
+ {"a classifier the pattern does name", "{name}-{version}-sources.jar", "pixelscript-paper-39-sources.jar", true},
+ {"a literal name", "pixelscript-paper-{version}.jar", "pixelscript-paper-39.jar", true},
+ {"a wildcard tail", "{name}-{version}*.jar", "pixelscript-paper-39-shaded.jar", true},
+ {"a wildcard that does not reach the extension", "{name}-{version}*.jar", "pixelscript-paper-39.pom", false},
+ {"a leading wildcard", "*-{version}.jar", "pixelscript-paper-39.jar", true},
+ {"the pom rather than the jar", "{name}-{version}.pom", "pixelscript-paper-39.pom", true},
+ {"a partial match is not enough", "{name}-{version}.jar", "prefix-pixelscript-paper-39.jar", false},
+ }
+
+ for _, testCase := range cases {
+ t.Run(testCase.name, func(t *testing.T) {
+ matcher, err := Compile(MatchTemplate, testCase.pattern, release)
+ if err != nil {
+ t.Fatalf("Compile(%q): %v", testCase.pattern, err)
+ }
+ if got := matcher.MatchString(testCase.filename); got != testCase.want {
+ t.Fatalf("%q matched %q = %v, want %v", testCase.pattern, testCase.filename, got, testCase.want)
+ }
+ })
+ }
+}
+
+// A template is literal text apart from '*', so regex syntax in it has to match
+// itself rather than compile.
+func TestTemplateTreatsRegexSyntaxAsLiteral(t *testing.T) {
+ matcher, err := Compile(MatchTemplate, "release+{version}.zip", release)
+ if err != nil {
+ t.Fatalf("Compile: %v", err)
+ }
+ if !matcher.MatchString("release+39.zip") {
+ t.Fatal("expected the '+' to match itself")
+ }
+ if matcher.MatchString("releasee39.zip") {
+ t.Fatal("expected the '+' not to act as a repeat")
+ }
+}
+
+func TestRegexMatchesTheWholeFilename(t *testing.T) {
+ matcher, err := Compile(MatchRegex, `pixelscript-paper-\d+\.jar`, release)
+ if err != nil {
+ t.Fatalf("Compile: %v", err)
+ }
+
+ if !matcher.MatchString("pixelscript-paper-39.jar") {
+ t.Fatal("expected the versioned jar to match")
+ }
+ if matcher.MatchString("pixelscript-paper-39.jar.sha1") {
+ t.Fatal("expected an unanchored tail not to match")
+ }
+ if matcher.MatchString("other-pixelscript-paper-39.jar") {
+ t.Fatal("expected an unanchored head not to match")
+ }
+}
+
+// A version is data, not syntax. Substituting one into a regex has to quote it,
+// or a version containing '.' or '+' would widen what the link resolves to.
+func TestSubstitutedValuesAreQuoted(t *testing.T) {
+ fields := Fields{Name: "app", Version: "1.0.0+build.1"}
+
+ matcher, err := Compile(MatchRegex, `{name}-{version}\.jar`, fields)
+ if err != nil {
+ t.Fatalf("Compile: %v", err)
+ }
+ if !matcher.MatchString("app-1.0.0+build.1.jar") {
+ t.Fatal("expected the exact version to match")
+ }
+ if matcher.MatchString("app-1x0y0+buildz1.jar") {
+ t.Fatal("expected the dots in the version not to act as wildcards")
+ }
+}
+
+func TestValidateRejectsWhatCannotWork(t *testing.T) {
+ cases := []struct {
+ name string
+ matchType string
+ pattern string
+ message string
+ }{
+ {"an empty pattern", MatchTemplate, " ", "required"},
+ {"a path", MatchTemplate, "target/{name}-{version}.jar", "cannot contain"},
+ {"an unknown placeholder", MatchTemplate, "{revision}.jar", "not a known placeholder"},
+ {"an unparseable regex", MatchRegex, `app-(\d+\.jar`, "not a valid regular expression"},
+ {"an unknown match type", "glob", "*.jar", "matchType must be one of"},
+ {"an overlong pattern", MatchTemplate, strings.Repeat("a", MaxPatternLength+1), "at most"},
+ }
+
+ for _, testCase := range cases {
+ t.Run(testCase.name, func(t *testing.T) {
+ err := Validate(testCase.matchType, testCase.pattern)
+ if err == nil {
+ t.Fatalf("Validate(%q, %q) = nil, want an error", testCase.matchType, testCase.pattern)
+ }
+ if !strings.Contains(err.Error(), testCase.message) {
+ t.Fatalf("error = %q, want it to mention %q", err, testCase.message)
+ }
+ })
+ }
+}
+
+func TestValidateAcceptsAPlaceholderInsideARegexGroup(t *testing.T) {
+ if err := Validate(MatchRegex, `{name}-{version}(-shaded)?\.jar`); err != nil {
+ t.Fatalf("Validate: %v", err)
+ }
+}
+
+func TestExpandFillsPlaceholdersForDisplay(t *testing.T) {
+ got := Expand("{name}-{version}.jar", release)
+ if want := "pixelscript-paper-39.jar"; got != want {
+ t.Fatalf("Expand = %q, want %q", got, want)
+ }
+}
diff --git a/internal/server/api_aliases.go b/internal/server/api_aliases.go
new file mode 100644
index 0000000..458cd54
--- /dev/null
+++ b/internal/server/api_aliases.go
@@ -0,0 +1,92 @@
+package server
+
+import (
+ "net/http"
+ "strings"
+
+ "arca/internal/store"
+)
+
+type aliasResponse struct {
+ Name string `json:"name"`
+ CreatedAt int64 `json:"createdAt"`
+}
+
+func (s *Server) routeListAliases(writer http.ResponseWriter, request *http.Request) error {
+ repository, err := s.loadAdministered(request)
+ if err != nil {
+ return err
+ }
+
+ aliases, err := s.store.Aliases(repository.ID)
+ if err != nil {
+ return err
+ }
+
+ responses := make([]aliasResponse, 0, len(aliases))
+ for _, alias := range aliases {
+ responses = append(responses, aliasResponse{Name: alias.Name, CreatedAt: alias.CreatedAt})
+ }
+
+ writeJSON(writer, http.StatusOK, map[string]any{"aliases": responses})
+ return nil
+}
+
+// routeCreateAlias adds a redirect by hand, which is how a name that was never
+// this repository's own is pointed at it: a rename in some other tool, or a
+// repository that was deleted and recreated under a different name.
+func (s *Server) routeCreateAlias(writer http.ResponseWriter, request *http.Request) error {
+ repository, err := s.loadAdministered(request)
+ if err != nil {
+ return err
+ }
+
+ var body struct {
+ Name string `json:"name"`
+ }
+ if err := decodeBody(request, &body); err != nil {
+ return err
+ }
+
+ name, err := requireText(body.Name, "name", 64)
+ if err != nil {
+ return err
+ }
+ name = strings.ToLower(name)
+
+ if !store.IsValidRepositoryName(name) {
+ return fail(http.StatusBadRequest, "name must be lowercase alphanumeric with dots, dashes or underscores")
+ }
+ if name == repository.Name {
+ return fail(http.StatusBadRequest, "a repository cannot redirect to itself")
+ }
+ // A live repository owns its name outright. Letting an alias shadow it would
+ // make which one answers depend on lookup order.
+ if _, err := s.store.RepositoryByName(name); err == nil {
+ return fail(http.StatusConflict, "%q is the name of a repository", name)
+ }
+
+ if err := s.store.CreateAlias(name, repository.ID); err != nil {
+ return err
+ }
+
+ writeJSON(writer, http.StatusCreated, aliasResponse{Name: name, CreatedAt: store.NowMillis()})
+ return nil
+}
+
+func (s *Server) routeDeleteAlias(writer http.ResponseWriter, request *http.Request) error {
+ repository, err := s.loadAdministered(request)
+ if err != nil {
+ return err
+ }
+
+ deleted, err := s.store.DeleteAlias(routeVar(request, "alias"), repository.ID)
+ if err != nil {
+ return err
+ }
+ if deleted == 0 {
+ return fail(http.StatusNotFound, "Alias not found")
+ }
+
+ return noContent(writer)
+}
diff --git a/internal/server/api_links.go b/internal/server/api_links.go
new file mode 100644
index 0000000..2b210d7
--- /dev/null
+++ b/internal/server/api_links.go
@@ -0,0 +1,379 @@
+package server
+
+import (
+ "net/http"
+ "regexp"
+ "strings"
+
+ "arca/internal/format"
+ "arca/internal/link"
+ "arca/internal/store"
+ "arca/internal/store/models"
+)
+
+// filenamePattern guards the name a link offers a file under. It ends up in a
+// Content-Disposition header, so a separator or a quote in it has no business
+// being stored in the first place.
+var filenamePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._+-]{0,119}$`)
+
+var matchTypes = []string{link.MatchTemplate, link.MatchRegex}
+
+type resolvedResponse struct {
+ Version string `json:"version"`
+ Filename string `json:"filename"`
+ Path string `json:"path"`
+ Size int64 `json:"size"`
+ UpdatedAt int64 `json:"updatedAt"`
+}
+
+type linkResponse struct {
+ ID string `json:"id"`
+ Slug string `json:"slug"`
+ URL string `json:"url"`
+ Repository string `json:"repository"`
+ Description string `json:"description"`
+ Namespace string `json:"namespace"`
+ Artifact string `json:"artifact"`
+ MatchType string `json:"matchType"`
+ Pattern string `json:"pattern"`
+ Policy string `json:"policy"`
+ Filename string `json:"filename"`
+ Disabled bool `json:"disabled"`
+ Downloads int64 `json:"downloads"`
+ LastUsedAt int64 `json:"lastUsedAt"`
+ CreatedAt int64 `json:"createdAt"`
+ // Resolved is what the link points at as of this request, or null when
+ // nothing published matches it yet.
+ Resolved *resolvedResponse `json:"resolved"`
+}
+
+// downloadPath is where a slug is served from. It is short on purpose: the URL
+// is meant to be pasted into a build script or an installer.
+func downloadPath(slug string) string { return "/download/" + slug }
+
+// linkable rejects the two shapes a link cannot mean. A group holds no
+// artifacts of its own, and a docker repository holds blobs and manifests that
+// are only meaningful to a registry client, not files to hand out one at a time.
+func linkable(repository *models.Repository) error {
+ if repository.IsGroup() {
+ return fail(http.StatusBadRequest, "A group repository holds no artifacts of its own to link to")
+ }
+ if repository.Format == format.Docker {
+ return fail(http.StatusBadRequest, "Docker images are pulled by a registry client, so there is no single file to link to")
+ }
+ return nil
+}
+
+func (s *Server) serializeLink(request *http.Request, repository *models.Repository, deployment models.DeploymentLink) linkResponse {
+ response := linkResponse{
+ ID: deployment.ID,
+ Slug: deployment.Slug,
+ URL: publicBase(request) + downloadPath(deployment.Slug),
+ Repository: repository.Name,
+ Description: deployment.Description,
+ Namespace: deployment.Namespace,
+ Artifact: deployment.Artifact,
+ MatchType: deployment.MatchType,
+ Pattern: deployment.Pattern,
+ Policy: deployment.Policy,
+ Filename: deployment.Filename,
+ Disabled: deployment.Disabled,
+ Downloads: deployment.Downloads,
+ LastUsedAt: deployment.LastUsedAt,
+ CreatedAt: deployment.CreatedAt,
+ }
+
+ // A link that cannot be resolved is still a link. Reporting the failure as
+ // "nothing matches yet" keeps a broken pattern from failing the listing that
+ // is the only place it can be fixed from.
+ resolved, err := s.resolveLink(repository, &deployment)
+ if err != nil || resolved == nil {
+ return response
+ }
+
+ response.Resolved = &resolvedResponse{
+ Version: resolved.version,
+ Filename: resolved.filename(deployment.Filename),
+ Path: resolved.asset.Path,
+ Size: resolved.asset.Size,
+ UpdatedAt: resolved.asset.UpdatedAt,
+ }
+ return response
+}
+
+// routeListLinks needs read access rather than admin. The URLs it lists are
+// public by design, so anyone who can see the repository can see them.
+func (s *Server) routeListLinks(writer http.ResponseWriter, request *http.Request) error {
+ repository, _, err := s.loadAccessible(request, models.PermissionRead)
+ if err != nil {
+ return err
+ }
+
+ links, err := s.store.ListLinks(repository.ID)
+ if err != nil {
+ return err
+ }
+
+ responses := make([]linkResponse, 0, len(links))
+ for _, deployment := range links {
+ responses = append(responses, s.serializeLink(request, repository, deployment))
+ }
+
+ writeJSON(writer, http.StatusOK, map[string]any{"links": responses})
+ return nil
+}
+
+type linkBody struct {
+ Slug *string `json:"slug"`
+ Description *string `json:"description"`
+ Namespace *string `json:"namespace"`
+ Artifact *string `json:"artifact"`
+ MatchType *string `json:"matchType"`
+ Pattern *string `json:"pattern"`
+ Policy *string `json:"policy"`
+ Filename *string `json:"filename"`
+ Disabled *bool `json:"disabled"`
+}
+
+func (s *Server) routeCreateLink(writer http.ResponseWriter, request *http.Request) error {
+ repository, err := s.loadAdministered(request)
+ if err != nil {
+ return err
+ }
+ if err := linkable(repository); err != nil {
+ return err
+ }
+
+ var body linkBody
+ if err := decodeBody(request, &body); err != nil {
+ return err
+ }
+
+ artifact, err := requireText(orBlank(body.Artifact), "artifact", 200)
+ if err != nil {
+ return err
+ }
+
+ matchType, err := requireOneOf(orDefault(orBlank(body.MatchType), link.MatchTemplate), matchTypes, "matchType")
+ if err != nil {
+ return err
+ }
+ pattern := strings.TrimSpace(orBlank(body.Pattern))
+ if err := link.Validate(matchType, pattern); err != nil {
+ return fail(http.StatusBadRequest, "%s", err.Error())
+ }
+
+ policy, err := requireOneOf(orDefault(orBlank(body.Policy), models.PolicyRelease), policies, "policy")
+ if err != nil {
+ return err
+ }
+
+ namespace, err := requireNamespace(orBlank(body.Namespace))
+ if err != nil {
+ return err
+ }
+ filename, err := requireFilename(orBlank(body.Filename))
+ if err != nil {
+ return err
+ }
+ description, err := requireDescription(orBlank(body.Description))
+ if err != nil {
+ return err
+ }
+
+ slug, err := s.resolveSlug(orBlank(body.Slug), artifact)
+ if err != nil {
+ return err
+ }
+
+ deployment := &models.DeploymentLink{
+ RepositoryID: repository.ID,
+ Slug: slug,
+ Description: description,
+ Namespace: namespace,
+ Artifact: artifact,
+ MatchType: matchType,
+ Pattern: pattern,
+ Policy: policy,
+ Filename: filename,
+ Disabled: body.Disabled != nil && *body.Disabled,
+ CreatedBy: userIDFrom(request),
+ }
+ if err := s.store.CreateLink(deployment); err != nil {
+ return err
+ }
+
+ writeJSON(writer, http.StatusCreated, s.serializeLink(request, repository, *deployment))
+ return nil
+}
+
+func (s *Server) routeUpdateLink(writer http.ResponseWriter, request *http.Request) error {
+ repository, err := s.loadAdministered(request)
+ if err != nil {
+ return err
+ }
+
+ deployment, err := s.store.LinkByID(routeVar(request, "id"), repository.ID)
+ if err != nil {
+ return fail(http.StatusNotFound, "Link not found")
+ }
+
+ var body linkBody
+ if err := decodeBody(request, &body); err != nil {
+ return err
+ }
+
+ changes := map[string]any{}
+
+ // The pattern and how to read it are validated together, because changing
+ // either one alone can still leave a pattern that does not compile.
+ matchType, pattern := deployment.MatchType, deployment.Pattern
+ if body.MatchType != nil {
+ matchType, err = requireOneOf(*body.MatchType, matchTypes, "matchType")
+ if err != nil {
+ return err
+ }
+ }
+ if body.Pattern != nil {
+ pattern = strings.TrimSpace(*body.Pattern)
+ }
+ if matchType != deployment.MatchType || pattern != deployment.Pattern {
+ if err := link.Validate(matchType, pattern); err != nil {
+ return fail(http.StatusBadRequest, "%s", err.Error())
+ }
+ changes["match_type"] = matchType
+ changes["pattern"] = pattern
+ }
+
+ if body.Slug != nil {
+ slug := strings.ToLower(strings.TrimSpace(*body.Slug))
+ if slug != deployment.Slug {
+ resolved, err := s.resolveSlug(slug, deployment.Artifact)
+ if err != nil {
+ return err
+ }
+ changes["slug"] = resolved
+ }
+ }
+ if body.Artifact != nil {
+ artifact, err := requireText(*body.Artifact, "artifact", 200)
+ if err != nil {
+ return err
+ }
+ changes["artifact"] = artifact
+ }
+ if body.Namespace != nil {
+ namespace, err := requireNamespace(*body.Namespace)
+ if err != nil {
+ return err
+ }
+ changes["namespace"] = namespace
+ }
+ if body.Policy != nil {
+ policy, err := requireOneOf(*body.Policy, policies, "policy")
+ if err != nil {
+ return err
+ }
+ changes["policy"] = policy
+ }
+ if body.Filename != nil {
+ filename, err := requireFilename(*body.Filename)
+ if err != nil {
+ return err
+ }
+ changes["filename"] = filename
+ }
+ if body.Description != nil {
+ description, err := requireDescription(*body.Description)
+ if err != nil {
+ return err
+ }
+ changes["description"] = description
+ }
+ if body.Disabled != nil {
+ changes["disabled"] = *body.Disabled
+ }
+
+ if len(changes) == 0 {
+ return fail(http.StatusBadRequest, "No changes supplied")
+ }
+ if err := s.store.UpdateLink(deployment.ID, changes); err != nil {
+ return err
+ }
+
+ updated, err := s.store.LinkByID(deployment.ID, repository.ID)
+ if err != nil {
+ return err
+ }
+
+ writeJSON(writer, http.StatusOK, s.serializeLink(request, repository, *updated))
+ return nil
+}
+
+func (s *Server) routeDeleteLink(writer http.ResponseWriter, request *http.Request) error {
+ repository, err := s.loadAdministered(request)
+ if err != nil {
+ return err
+ }
+
+ deleted, err := s.store.DeleteLink(routeVar(request, "id"), repository.ID)
+ if err != nil {
+ return err
+ }
+ if deleted == 0 {
+ return fail(http.StatusNotFound, "Link not found")
+ }
+
+ return noContent(writer)
+}
+
+// resolveSlug takes the slug the caller asked for, or derives one from the
+// artifact when they left it out. Slugs are unique across the instance because
+// the download URL carries no repository name to disambiguate them.
+func (s *Server) resolveSlug(requested, artifact string) (string, error) {
+ slug := strings.ToLower(strings.TrimSpace(requested))
+ if slug == "" {
+ slug = strings.ToLower(artifact) + "-latest"
+ }
+ if !store.IsValidSlug(slug) {
+ return "", fail(http.StatusBadRequest, "slug must be lowercase alphanumeric with dots, dashes or underscores")
+ }
+ if _, err := s.store.LinkBySlug(slug); err == nil {
+ return "", fail(http.StatusConflict, "A link with that slug already exists")
+ }
+ return slug, nil
+}
+
+func requireNamespace(raw string) (string, error) {
+ namespace := strings.TrimSpace(raw)
+ if len(namespace) > 300 {
+ return "", fail(http.StatusBadRequest, "namespace is too long")
+ }
+ return namespace, nil
+}
+
+func requireFilename(raw string) (string, error) {
+ filename := strings.TrimSpace(raw)
+ if filename == "" {
+ return "", nil
+ }
+ if !filenamePattern.MatchString(filename) {
+ return "", fail(http.StatusBadRequest, "filename must be a plain filename, without a path")
+ }
+ return filename, nil
+}
+
+func requireDescription(raw string) (string, error) {
+ description := strings.TrimSpace(raw)
+ if len(description) > 500 {
+ return "", fail(http.StatusBadRequest, "description is too long")
+ }
+ return description, nil
+}
+
+func orBlank(value *string) string {
+ if value == nil {
+ return ""
+ }
+ return *value
+}
diff --git a/internal/server/api_repositories.go b/internal/server/api_repositories.go
index 60087fe..527982c 100644
--- a/internal/server/api_repositories.go
+++ b/internal/server/api_repositories.go
@@ -50,6 +50,8 @@ type repositoryResponse struct {
UpdatedAt *int64 `json:"updatedAt,omitempty"`
Stats *store.RepositoryStats `json:"stats,omitempty"`
Members []string `json:"members,omitempty"`
+ // Aliases are the former names that still redirect here, newest first.
+ Aliases []string `json:"aliases"`
}
// serializeRepository never returns RemotePassword, which is write-only from
@@ -72,6 +74,7 @@ func serializeRepository(repository models.Repository, permission, categoryName
NegativeTTLSeconds: repository.NegativeTTLSeconds,
CacheRetentionDays: repository.CacheRetentionDays,
CreatedAt: repository.CreatedAt,
+ Aliases: []string{},
}
if permission != "" {
response.Permission = &permission
@@ -104,8 +107,11 @@ func requireRetention(value *int64, repositoryType string) (int64, error) {
return *value, nil
}
+// loadAccessible resolves the repository an API path addresses, following an
+// alias left behind by a rename so a bookmarked page keeps working. Responses
+// always carry the current name back, which is how a caller learns of the move.
func (s *Server) loadAccessible(request *http.Request, required string) (*models.Repository, string, error) {
- repository, err := s.store.RepositoryByName(routeVar(request, "name"))
+ repository, _, err := s.store.ResolveRepository(routeVar(request, "name"))
if err != nil {
return nil, "", fail(http.StatusNotFound, "Repository not found")
}
@@ -131,7 +137,7 @@ func (s *Server) loadAdministered(request *http.Request) (*models.Repository, er
return nil, err
}
- repository, err := s.store.RepositoryByName(routeVar(request, "name"))
+ repository, _, err := s.store.ResolveRepository(routeVar(request, "name"))
if err != nil {
return nil, fail(http.StatusNotFound, "Repository not found")
}
@@ -165,6 +171,11 @@ func (s *Server) routeListRepositories(writer http.ResponseWriter, request *http
names[category.ID] = category.Name
}
+ aliases, err := s.store.AliasNames()
+ if err != nil {
+ return err
+ }
+
responses := make([]repositoryResponse, 0, len(repositories))
for _, repository := range repositories {
permission, err := s.store.EffectivePermission(&repository, user)
@@ -181,6 +192,9 @@ func (s *Server) routeListRepositories(writer http.ResponseWriter, request *http
response := serializeRepository(repository, permission, categoryName)
response.Components = &summary.Components
response.UpdatedAt = &summary.UpdatedAt
+ if named := aliases[repository.ID]; named != nil {
+ response.Aliases = named
+ }
responses = append(responses, response)
}
@@ -319,6 +333,14 @@ func (s *Server) routeRepository(writer http.ResponseWriter, request *http.Reque
response := serializeRepository(*repository, permission, s.categoryNameFor(repository.CategoryID))
response.Stats = &stats
+ aliases, err := s.store.Aliases(repository.ID)
+ if err != nil {
+ return err
+ }
+ for _, alias := range aliases {
+ response.Aliases = append(response.Aliases, alias.Name)
+ }
+
if repository.IsGroup() {
members, err := s.store.Members(repository.ID)
if err != nil {
@@ -341,6 +363,7 @@ func (s *Server) routeUpdateRepository(writer http.ResponseWriter, request *http
}
var body struct {
+ Name *string `json:"name"`
Format *string `json:"format"`
Policy *string `json:"policy"`
Visibility *string `json:"visibility"`
@@ -365,6 +388,19 @@ func (s *Server) routeUpdateRepository(writer http.ResponseWriter, request *http
return fail(http.StatusBadRequest, "format cannot be changed after a repository is created")
}
+ // A rename is applied at the end rather than through the changes map,
+ // because it also has to leave the old name behind as a redirect and carry
+ // every by-name reference across. It is resolved here so a bad new name
+ // fails before anything else has been written.
+ renamed, err := s.resolveRename(repository, body.Name)
+ if err != nil {
+ return err
+ }
+ name := repository.Name
+ if renamed != "" {
+ name = renamed
+ }
+
changes := map[string]any{}
if body.Policy != nil {
policy, err := requireOneOf(*body.Policy, policies, "policy")
@@ -432,12 +468,12 @@ func (s *Server) routeUpdateRepository(writer http.ResponseWriter, request *http
}
changes["category_id"] = categoryID
}
- if len(changes) == 0 && body.Members == nil {
+ if len(changes) == 0 && body.Members == nil && renamed == "" {
return fail(http.StatusBadRequest, "No changes supplied")
}
if body.Members != nil {
- members, err := s.validateMembers(repository.Name, repository.Format, repository.Type, *body.Members)
+ members, err := s.validateMembers(name, repository.Format, repository.Type, *body.Members)
if err != nil {
return err
}
@@ -450,16 +486,55 @@ func (s *Server) routeUpdateRepository(writer http.ResponseWriter, request *http
return err
}
}
+ if renamed != "" {
+ if err := s.store.RenameRepository(repository.ID, repository.Name, renamed); err != nil {
+ return err
+ }
+ }
- updated, err := s.store.RepositoryByName(repository.Name)
+ updated, err := s.store.RepositoryByName(name)
if err != nil {
return err
}
- writeJSON(writer, http.StatusOK, serializeRepository(*updated, models.PermissionAdmin, s.categoryNameFor(updated.CategoryID)))
+ response := serializeRepository(*updated, models.PermissionAdmin, s.categoryNameFor(updated.CategoryID))
+
+ aliases, err := s.store.Aliases(updated.ID)
+ if err != nil {
+ return err
+ }
+ for _, alias := range aliases {
+ response.Aliases = append(response.Aliases, alias.Name)
+ }
+
+ writeJSON(writer, http.StatusOK, response)
return nil
}
+// resolveRename validates a requested new name and reports it, or an empty
+// string when the request leaves the name alone.
+func (s *Server) resolveRename(repository *models.Repository, requested *string) (string, error) {
+ if requested == nil {
+ return "", nil
+ }
+
+ name, err := requireText(*requested, "name", 64)
+ if err != nil {
+ return "", err
+ }
+ name = strings.ToLower(name)
+ if name == repository.Name {
+ return "", nil
+ }
+ if !store.IsValidRepositoryName(name) {
+ return "", fail(http.StatusBadRequest, "name must be lowercase alphanumeric with dots, dashes or underscores")
+ }
+ if _, err := s.store.RepositoryByName(name); err == nil {
+ return "", fail(http.StatusConflict, "A repository with that name already exists")
+ }
+ return name, nil
+}
+
func (s *Server) routeDeleteRepository(writer http.ResponseWriter, request *http.Request) error {
repository, err := s.loadAdministered(request)
if err != nil {
diff --git a/internal/server/docker.go b/internal/server/docker.go
index 7fc392f..fe24b3c 100644
--- a/internal/server/docker.go
+++ b/internal/server/docker.go
@@ -141,9 +141,14 @@ func (s *Server) authorizeDocker(writer http.ResponseWriter, request *http.Reque
// repository, so "docker-hosted/team/api" is the team/api image of docker-hosted.
// Otherwise the whole name resolves against the instance default, which is what
// makes a bare "docker pull host/nginx" work on a single-repository install.
+//
+// A former name resolves in place rather than redirecting the way the Maven and
+// npm routes do. A Docker client derives every URL of a pull or a push from the
+// reference it was given, so a redirect would only move the one request that
+// received it, and a push would lose its body on the way.
func (s *Server) resolveDockerRepository(name string) (*models.Repository, string, bool) {
if prefix, rest, found := strings.Cut(name, "/"); found && rest != "" {
- repository, err := s.store.RepositoryByNameAndFormat(prefix, format.Docker)
+ repository, _, err := s.store.ResolveRepositoryOfFormat(prefix, format.Docker)
if err == nil && docker.IsValidName(rest) {
return repository, rest, true
}
@@ -154,7 +159,7 @@ func (s *Server) resolveDockerRepository(name string) (*models.Repository, strin
return nil, "", false
}
- repository, err := s.store.RepositoryByNameAndFormat(fallback, format.Docker)
+ repository, _, err := s.store.ResolveRepositoryOfFormat(fallback, format.Docker)
if err != nil {
return nil, "", false
}
diff --git a/internal/server/download.go b/internal/server/download.go
new file mode 100644
index 0000000..a8f4032
--- /dev/null
+++ b/internal/server/download.go
@@ -0,0 +1,67 @@
+package server
+
+import (
+ "fmt"
+ "net/http"
+
+ "github.com/charmbracelet/log"
+
+ "arca/internal/store/models"
+)
+
+// handleDownload serves a deployment link. It is the one artifact route that
+// never authenticates: the point of a link is that a private repository can
+// publish exactly one file of one artifact to anyone holding the URL, so the
+// slug is the credential and nothing else is consulted.
+//
+// Nothing else about the repository leaks through it. A link resolves to one
+// file of one artifact, and a slug that names no link is indistinguishable from
+// one whose artifact has never been published.
+func (s *Server) handleDownload(writer http.ResponseWriter, request *http.Request) {
+ slug := routeVar(request, "slug")
+
+ deployment, err := s.store.LinkBySlug(slug)
+ if err != nil || deployment.Disabled {
+ plain(writer, http.StatusNotFound, "Not Found")
+ return
+ }
+
+ repository, err := s.store.RepositoryByID(deployment.RepositoryID)
+ if err != nil {
+ plain(writer, http.StatusNotFound, "Not Found")
+ return
+ }
+
+ resolved, err := s.resolveLink(repository, deployment)
+ if err != nil {
+ log.Errorf("resolving deployment link %q failed: %v", slug, err)
+ plain(writer, http.StatusInternalServerError, "Internal Server Error")
+ return
+ }
+ if resolved == nil {
+ plain(writer, http.StatusNotFound, "No published version matches this link yet")
+ return
+ }
+
+ header := writer.Header()
+ header.Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", resolved.filename(deployment.Filename)))
+ // Which file the link resolves to changes with every release, so a cache
+ // has to revalidate. The ETag writeAsset sets still makes that a 304 when
+ // the newest version has not moved.
+ header.Set("Cache-Control", "no-cache")
+ header.Set("X-Arca-Repository", repository.Name)
+ header.Set("X-Arca-Version", resolved.version)
+
+ if !s.writeAsset(writer, request, resolved.asset) {
+ plain(writer, http.StatusNotFound, "Not Found")
+ return
+ }
+
+ if request.Method != http.MethodGet {
+ return
+ }
+ s.recordAssetTraffic(request, models.TrafficDownload, repository, resolved.asset)
+ if err := s.store.RecordLinkDownload(deployment.ID); err != nil {
+ log.Errorf("counting a deployment link download failed: %v", err)
+ }
+}
diff --git a/internal/server/download_test.go b/internal/server/download_test.go
new file mode 100644
index 0000000..c4f6804
--- /dev/null
+++ b/internal/server/download_test.go
@@ -0,0 +1,303 @@
+package server
+
+import (
+ "encoding/json"
+ "net/http"
+ "strings"
+ "testing"
+)
+
+func decodeLink(t *testing.T, body string) struct {
+ ID string `json:"id"`
+ Slug string `json:"slug"`
+ URL string `json:"url"`
+ Resolved *struct {
+ Version string `json:"version"`
+ Filename string `json:"filename"`
+ } `json:"resolved"`
+} {
+ t.Helper()
+
+ var payload struct {
+ ID string `json:"id"`
+ Slug string `json:"slug"`
+ URL string `json:"url"`
+ Resolved *struct {
+ Version string `json:"version"`
+ Filename string `json:"filename"`
+ } `json:"resolved"`
+ }
+ if err := json.Unmarshal([]byte(body), &payload); err != nil {
+ t.Fatalf("decoding a link: %v (body: %s)", err, body)
+ }
+ return payload
+}
+
+// privateWithVersions is the shape the feature exists for: a private repository
+// holding several releases of one artifact.
+func privateWithVersions(t *testing.T, versions ...string) *testInstance {
+ t.Helper()
+
+ instance := newTestInstance(t)
+ instance.setup()
+
+ expectStatus(t, instance.api(http.MethodPost, "/api/repositories",
+ `{"name":"internal","visibility":"private","policy":"mixed"}`), http.StatusCreated)
+
+ for _, version := range versions {
+ publish(t, instance, "internal", version)
+ }
+ return instance
+}
+
+func TestDeploymentLinkServesTheLatestReleaseWithoutAuthentication(t *testing.T) {
+ instance := privateWithVersions(t, "38", "39")
+
+ body := expectStatus(t, instance.api(http.MethodPost, "/api/repositories/internal/links", `{
+ "slug":"pixelscript-latest",
+ "namespace":"com.example",
+ "artifact":"app",
+ "pattern":"{name}-{version}.jar"
+ }`), http.StatusCreated)
+
+ created := decodeLink(t, body)
+ if created.Resolved == nil || created.Resolved.Version != "39" {
+ t.Fatalf("the link did not resolve to the newest version: %s", body)
+ }
+ if !strings.HasSuffix(created.URL, "/download/pixelscript-latest") {
+ t.Fatalf("url = %q", created.URL)
+ }
+
+ t.Run("the repository itself still needs credentials", func(t *testing.T) {
+ expectStatus(t, instance.maven(http.MethodGet, "/repository/internal/com/example/app/39/app-39.jar", "", false), http.StatusUnauthorized)
+ })
+
+ t.Run("the link does not", func(t *testing.T) {
+ response := instance.doAnonymously(instance.request(http.MethodGet, "/download/pixelscript-latest", ""))
+ payload := expectStatus(t, response, http.StatusOK)
+
+ if payload != "payload-39" {
+ t.Fatalf("body = %q, want the newest release", payload)
+ }
+ if version := response.Header.Get("X-Arca-Version"); version != "39" {
+ t.Fatalf("X-Arca-Version = %q", version)
+ }
+ if disposition := response.Header.Get("Content-Disposition"); !strings.Contains(disposition, `"app-39.jar"`) {
+ t.Fatalf("Content-Disposition = %q", disposition)
+ }
+ })
+
+ t.Run("a newer release moves the link", func(t *testing.T) {
+ publish(t, instance, "internal", "40")
+
+ response := instance.doAnonymously(instance.request(http.MethodGet, "/download/pixelscript-latest", ""))
+ if payload := expectStatus(t, response, http.StatusOK); payload != "payload-40" {
+ t.Fatalf("body = %q, want the release published since", payload)
+ }
+ })
+
+ t.Run("following it is counted", func(t *testing.T) {
+ body := expectStatus(t, instance.api(http.MethodGet, "/api/repositories/internal/links", ""), http.StatusOK)
+ if strings.Contains(body, `"downloads":0`) {
+ t.Fatalf("downloads were not counted: %s", body)
+ }
+ })
+}
+
+func TestDeploymentLinkRenamesTheFileItServes(t *testing.T) {
+ instance := privateWithVersions(t, "1.2.3")
+
+ expectStatus(t, instance.api(http.MethodPost, "/api/repositories/internal/links", `{
+ "slug":"app-jar",
+ "namespace":"com.example",
+ "artifact":"app",
+ "pattern":"{name}-{version}.jar",
+ "filename":"app.jar"
+ }`), http.StatusCreated)
+
+ response := instance.doAnonymously(instance.request(http.MethodGet, "/download/app-jar", ""))
+ expectStatus(t, response, http.StatusOK)
+
+ if disposition := response.Header.Get("Content-Disposition"); !strings.Contains(disposition, `"app.jar"`) {
+ t.Fatalf("Content-Disposition = %q, want the configured filename", disposition)
+ }
+}
+
+func TestDeploymentLinkPolicySelectsWhichVersionsCount(t *testing.T) {
+ instance := privateWithVersions(t, "1.0.0")
+
+ // A snapshot is newer than the release but only eligible under a policy that
+ // admits prereleases.
+ expectStatus(t, instance.maven(http.MethodPut,
+ "/repository/internal/com/example/app/2.0.0-SNAPSHOT/app-2.0.0-SNAPSHOT.jar", "payload-snapshot", true), http.StatusCreated)
+
+ cases := []struct {
+ policy string
+ want string
+ }{
+ {"release", "1.0.0"},
+ {"mixed", "2.0.0-SNAPSHOT"},
+ {"snapshot", "2.0.0-SNAPSHOT"},
+ }
+
+ body := expectStatus(t, instance.api(http.MethodPost, "/api/repositories/internal/links", `{
+ "slug":"by-policy",
+ "namespace":"com.example",
+ "artifact":"app",
+ "pattern":"{name}-{version}.jar",
+ "policy":"release"
+ }`), http.StatusCreated)
+ created := decodeLink(t, body)
+
+ for _, testCase := range cases {
+ t.Run(testCase.policy, func(t *testing.T) {
+ body := expectStatus(t, instance.api(http.MethodPatch,
+ "/api/repositories/internal/links/"+created.ID, `{"policy":"`+testCase.policy+`"}`), http.StatusOK)
+
+ updated := decodeLink(t, body)
+ if updated.Resolved == nil || updated.Resolved.Version != testCase.want {
+ t.Fatalf("policy %q resolved to %v, want %s", testCase.policy, updated.Resolved, testCase.want)
+ }
+ })
+ }
+}
+
+// A release that shipped without the file a link names should not break the
+// link, it should keep serving the newest release that does have one.
+func TestDeploymentLinkFallsBackToTheNewestMatchingVersion(t *testing.T) {
+ instance := privateWithVersions(t, "1.0.0")
+
+ expectStatus(t, instance.maven(http.MethodPut,
+ "/repository/internal/com/example/app/2.0.0/app-2.0.0.pom", "just-a-pom", true), http.StatusCreated)
+
+ expectStatus(t, instance.api(http.MethodPost, "/api/repositories/internal/links", `{
+ "slug":"app-latest",
+ "namespace":"com.example",
+ "artifact":"app",
+ "pattern":"{name}-{version}.jar"
+ }`), http.StatusCreated)
+
+ response := instance.doAnonymously(instance.request(http.MethodGet, "/download/app-latest", ""))
+ expectStatus(t, response, http.StatusOK)
+
+ if version := response.Header.Get("X-Arca-Version"); version != "1.0.0" {
+ t.Fatalf("X-Arca-Version = %q, want the newest version that has a jar", version)
+ }
+}
+
+func TestDeploymentLinkValidationAndLifecycle(t *testing.T) {
+ instance := privateWithVersions(t, "1.0.0")
+
+ cases := []struct {
+ name string
+ body string
+ want int
+ }{
+ {"no artifact", `{"pattern":"*.jar"}`, http.StatusBadRequest},
+ {"no pattern", `{"artifact":"app"}`, http.StatusBadRequest},
+ {"a pattern with a path", `{"artifact":"app","pattern":"a/b.jar"}`, http.StatusBadRequest},
+ {"an unknown placeholder", `{"artifact":"app","pattern":"{revision}.jar"}`, http.StatusBadRequest},
+ {"an unparseable regex", `{"artifact":"app","matchType":"regex","pattern":"app-(\\d+"}`, http.StatusBadRequest},
+ {"an unknown match type", `{"artifact":"app","matchType":"glob","pattern":"*.jar"}`, http.StatusBadRequest},
+ {"a filename with a path", `{"artifact":"app","pattern":"*.jar","filename":"a/b.jar"}`, http.StatusBadRequest},
+ {"an invalid slug", `{"artifact":"app","pattern":"*.jar","slug":"Not A Slug"}`, http.StatusBadRequest},
+ }
+
+ for _, testCase := range cases {
+ t.Run(testCase.name, func(t *testing.T) {
+ expectStatus(t, instance.api(http.MethodPost, "/api/repositories/internal/links", testCase.body), testCase.want)
+ })
+ }
+
+ t.Run("a slug is derived from the artifact when it is left out", func(t *testing.T) {
+ body := expectStatus(t, instance.api(http.MethodPost, "/api/repositories/internal/links",
+ `{"namespace":"com.example","artifact":"app","pattern":"{name}-{version}.jar"}`), http.StatusCreated)
+
+ if slug := decodeLink(t, body).Slug; slug != "app-latest" {
+ t.Fatalf("slug = %q, want app-latest", slug)
+ }
+ })
+
+ t.Run("slugs are unique across the instance", func(t *testing.T) {
+ expectStatus(t, instance.api(http.MethodPost, "/api/repositories/internal/links",
+ `{"artifact":"app","pattern":"*.jar","slug":"app-latest"}`), http.StatusConflict)
+ })
+
+ t.Run("a disabled link stops serving", func(t *testing.T) {
+ body := expectStatus(t, instance.api(http.MethodGet, "/api/repositories/internal/links", ""), http.StatusOK)
+ var payload struct {
+ Links []struct {
+ ID string `json:"id"`
+ } `json:"links"`
+ }
+ if err := json.Unmarshal([]byte(body), &payload); err != nil {
+ t.Fatalf("decoding links: %v", err)
+ }
+
+ identifier := payload.Links[0].ID
+ expectStatus(t, instance.api(http.MethodPatch, "/api/repositories/internal/links/"+identifier, `{"disabled":true}`), http.StatusOK)
+ expectStatus(t, instance.doAnonymously(instance.request(http.MethodGet, "/download/app-latest", "")), http.StatusNotFound)
+
+ expectStatus(t, instance.api(http.MethodPatch, "/api/repositories/internal/links/"+identifier, `{"disabled":false}`), http.StatusOK)
+ expectStatus(t, instance.doAnonymously(instance.request(http.MethodGet, "/download/app-latest", "")), http.StatusOK)
+
+ expectStatus(t, instance.api(http.MethodDelete, "/api/repositories/internal/links/"+identifier, ""), http.StatusNoContent)
+ expectStatus(t, instance.doAnonymously(instance.request(http.MethodGet, "/download/app-latest", "")), http.StatusNotFound)
+ })
+
+ t.Run("an unknown slug is a plain not found", func(t *testing.T) {
+ expectStatus(t, instance.doAnonymously(instance.request(http.MethodGet, "/download/nothing-here", "")), http.StatusNotFound)
+ })
+}
+
+func TestDeploymentLinksAreRefusedWhereTheyCannotMeanAnything(t *testing.T) {
+ instance := newTestInstance(t)
+ instance.setup()
+
+ expectStatus(t, instance.api(http.MethodPost, "/api/repositories", `{"name":"images","format":"docker"}`), http.StatusCreated)
+ expectStatus(t, instance.api(http.MethodPost, "/api/repositories", `{"name":"site","format":"p2"}`), http.StatusCreated)
+ expectStatus(t, instance.api(http.MethodPost, "/api/repositories",
+ `{"name":"sites","format":"p2","type":"group","members":["site"]}`), http.StatusCreated)
+
+ for _, repository := range []string{"images", "sites"} {
+ t.Run(repository, func(t *testing.T) {
+ expectStatus(t, instance.api(http.MethodPost, "/api/repositories/"+repository+"/links",
+ `{"artifact":"app","pattern":"*.jar"}`), http.StatusBadRequest)
+ })
+ }
+}
+
+func TestDeploymentLinkNeedsAdministratorAccessToManage(t *testing.T) {
+ instance := privateWithVersions(t, "1.0.0")
+
+ request := instance.request(http.MethodPost, "/api/repositories/internal/links",
+ `{"artifact":"app","pattern":"*.jar"}`)
+ expectStatus(t, instance.doAnonymously(request), http.StatusUnauthorized)
+}
+
+func TestDeploymentLinkMatchesWithARegex(t *testing.T) {
+ instance := privateWithVersions(t, "39")
+
+ expectStatus(t, instance.api(http.MethodPost, "/api/repositories/internal/links", `{
+ "slug":"any-jar",
+ "namespace":"com.example",
+ "artifact":"app",
+ "matchType":"regex",
+ "pattern":"app-\\d+\\.jar"
+ }`), http.StatusCreated)
+
+ response := instance.doAnonymously(instance.request(http.MethodGet, "/download/any-jar", ""))
+ if body := expectStatus(t, response, http.StatusOK); body != "payload-39" {
+ t.Fatalf("body = %q", body)
+ }
+}
+
+func TestDeploymentLinksAreRemovedWithTheirRepository(t *testing.T) {
+ instance := privateWithVersions(t, "1.0.0")
+
+ expectStatus(t, instance.api(http.MethodPost, "/api/repositories/internal/links",
+ `{"slug":"gone-soon","namespace":"com.example","artifact":"app","pattern":"*.jar"}`), http.StatusCreated)
+ expectStatus(t, instance.api(http.MethodDelete, "/api/repositories/internal", ""), http.StatusNoContent)
+
+ expectStatus(t, instance.doAnonymously(instance.request(http.MethodGet, "/download/gone-soon", "")), http.StatusNotFound)
+}
diff --git a/internal/server/http.go b/internal/server/http.go
index 00f50bf..aa2bb7e 100644
--- a/internal/server/http.go
+++ b/internal/server/http.go
@@ -44,6 +44,13 @@ func (s *Server) routes() {
api.Handle("/repositories/{name}/cache", handler(s.routePurgeCache)).Methods(http.MethodDelete)
api.Handle("/repositories/{name}/grants", handler(s.routeListGrants)).Methods(http.MethodGet)
api.Handle("/repositories/{name}/grants", handler(s.routeReplaceGrants)).Methods(http.MethodPut)
+ api.Handle("/repositories/{name}/aliases", handler(s.routeListAliases)).Methods(http.MethodGet)
+ api.Handle("/repositories/{name}/aliases", handler(s.routeCreateAlias)).Methods(http.MethodPost)
+ api.Handle("/repositories/{name}/aliases/{alias}", handler(s.routeDeleteAlias)).Methods(http.MethodDelete)
+ api.Handle("/repositories/{name}/links", handler(s.routeListLinks)).Methods(http.MethodGet)
+ api.Handle("/repositories/{name}/links", handler(s.routeCreateLink)).Methods(http.MethodPost)
+ api.Handle("/repositories/{name}/links/{id}", handler(s.routeUpdateLink)).Methods(http.MethodPatch)
+ api.Handle("/repositories/{name}/links/{id}", handler(s.routeDeleteLink)).Methods(http.MethodDelete)
api.Handle("/repositories/{name}/browse", handler(s.routeBrowse)).Methods(http.MethodGet)
api.Handle("/repositories/{name}/artifacts", handler(s.routeArtifact)).Methods(http.MethodGet)
@@ -75,6 +82,12 @@ func (s *Server) routes() {
writeJSON(writer, http.StatusNotFound, map[string]string{"error": "Not found"})
})
+ // Deployment links sit at the host root rather than under /repository,
+ // because the slug is the whole address: the repository a link reads from is
+ // private more often than not and has no business being in the URL.
+ s.router.Handle("/download/{slug}", http.HandlerFunc(s.handleDownload)).
+ Methods(http.MethodGet, http.MethodHead)
+
repositoryMethods := []string{http.MethodGet, http.MethodHead, http.MethodPut, http.MethodPost, http.MethodDelete}
s.router.Handle("/repository/{repository}", http.HandlerFunc(s.handleRepository)).Methods(repositoryMethods...)
s.router.PathPrefix("/repository/{repository}/").HandlerFunc(s.handleRepository).Methods(repositoryMethods...)
diff --git a/internal/server/links.go b/internal/server/links.go
new file mode 100644
index 0000000..2e83909
--- /dev/null
+++ b/internal/server/links.go
@@ -0,0 +1,99 @@
+package server
+
+import (
+ "sort"
+ "strings"
+
+ "arca/internal/format"
+ "arca/internal/link"
+ "arca/internal/store/models"
+)
+
+// resolution is what a deployment link points at right now. A link stores an
+// artifact rather than a path, so every follow of it resolves again.
+type resolution struct {
+ version string
+ asset *models.Asset
+}
+
+// filename is the name the file is offered under: the link's own if it sets
+// one, and otherwise whatever the artifact was published as.
+func (r resolution) filename(configured string) string {
+ if configured != "" {
+ return configured
+ }
+ return r.asset.Path[strings.LastIndex(r.asset.Path, "/")+1:]
+}
+
+// eligibleVersions orders the versions a link may resolve to, newest first, with
+// the ones its policy excludes dropped. The policy values are the repository's
+// own, so a link on a mixed repository can still pin itself to releases.
+func eligibleVersions(layout format.Layout, policy string, components []models.Component) []models.Component {
+ eligible := make([]models.Component, 0, len(components))
+
+ for _, component := range components {
+ prerelease := layout.Prerelease(component.Version)
+ switch policy {
+ case models.PolicySnapshot:
+ if !prerelease {
+ continue
+ }
+ case models.PolicyMixed:
+ default:
+ if prerelease {
+ continue
+ }
+ }
+ eligible = append(eligible, component)
+ }
+
+ sort.SliceStable(eligible, func(i, j int) bool {
+ return layout.Compare(eligible[i].Version, eligible[j].Version) > 0
+ })
+ return eligible
+}
+
+// resolveLink walks the eligible versions newest first and answers with the
+// first file that matches. It does not stop at the newest version: a release
+// that shipped without the file the link names should not break the link, it
+// should keep serving the newest release that does have one.
+func (s *Server) resolveLink(repository *models.Repository, deployment *models.DeploymentLink) (*resolution, error) {
+ components, err := s.store.ComponentVersions(repository.ID, deployment.Namespace, deployment.Artifact)
+ if err != nil {
+ return nil, err
+ }
+
+ layout := layoutFor(repository)
+
+ for _, component := range eligibleVersions(layout, deployment.Policy, components) {
+ matcher, err := link.Compile(deployment.MatchType, deployment.Pattern, link.Fields{
+ Namespace: component.Namespace,
+ Name: component.Name,
+ Version: component.Version,
+ BaseVersion: component.BaseVersion,
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ files, err := s.store.ComponentFiles(repository.ID, deployment.Namespace, deployment.Artifact, component.Version)
+ if err != nil {
+ return nil, err
+ }
+
+ for _, file := range files {
+ if !matcher.MatchString(file.Name) {
+ continue
+ }
+ asset, err := s.store.FindAsset(repository.ID, file.Path)
+ if err != nil {
+ // The listing and the asset came from the same row, so this only
+ // happens if the file was deleted between the two queries.
+ continue
+ }
+ return &resolution{version: component.Version, asset: asset}, nil
+ }
+ }
+
+ return nil, nil
+}
diff --git a/internal/server/rename_test.go b/internal/server/rename_test.go
new file mode 100644
index 0000000..04d39b2
--- /dev/null
+++ b/internal/server/rename_test.go
@@ -0,0 +1,159 @@
+package server
+
+import (
+ "encoding/json"
+ "net/http"
+ "strings"
+ "testing"
+)
+
+// publish puts one jar into a hosted Maven repository and returns the path it
+// was stored at.
+func publish(t *testing.T, instance *testInstance, repository, version string) string {
+ t.Helper()
+
+ path := "/repository/" + repository + "/com/example/app/" + version + "/app-" + version + ".jar"
+ expectStatus(t, instance.maven(http.MethodPut, path, "payload-"+version, true), http.StatusCreated)
+ return path
+}
+
+func TestRenamingARepositoryLeavesTheOldNameRedirecting(t *testing.T) {
+ instance := newTestInstance(t)
+ instance.setup()
+
+ expectStatus(t, instance.api(http.MethodPost, "/api/repositories", `{"name":"internal","visibility":"public"}`), http.StatusCreated)
+ publish(t, instance, "internal", "1.0.0")
+
+ body := expectStatus(t, instance.api(http.MethodPatch, "/api/repositories/internal", `{"name":"pixelib-internal"}`), http.StatusOK)
+ if !strings.Contains(body, `"name":"pixelib-internal"`) {
+ t.Fatalf("the response did not carry the new name: %s", body)
+ }
+ if !strings.Contains(body, `"aliases":["internal"]`) {
+ t.Fatalf("the response did not list the old name as an alias: %s", body)
+ }
+
+ t.Run("the new name serves the artifact", func(t *testing.T) {
+ body := expectStatus(t, instance.maven(http.MethodGet, "/repository/pixelib-internal/com/example/app/1.0.0/app-1.0.0.jar", "", true), http.StatusOK)
+ if body != "payload-1.0.0" {
+ t.Fatalf("body = %q", body)
+ }
+ })
+
+ t.Run("the old name redirects to it", func(t *testing.T) {
+ response := instance.maven(http.MethodGet, "/repository/internal/com/example/app/1.0.0/app-1.0.0.jar", "", true)
+ expectStatus(t, response, http.StatusMovedPermanently)
+
+ want := "/repository/pixelib-internal/com/example/app/1.0.0/app-1.0.0.jar"
+ if location := response.Header.Get("Location"); location != want {
+ t.Fatalf("Location = %q, want %q", location, want)
+ }
+ })
+
+ t.Run("an upload to the old name keeps its method", func(t *testing.T) {
+ response := instance.maven(http.MethodPut, "/repository/internal/com/example/app/2.0.0/app-2.0.0.jar", "payload", true)
+ expectStatus(t, response, http.StatusPermanentRedirect)
+ })
+
+ t.Run("the API answers under either name", func(t *testing.T) {
+ for _, name := range []string{"internal", "pixelib-internal"} {
+ body := expectStatus(t, instance.api(http.MethodGet, "/api/repositories/"+name, ""), http.StatusOK)
+ if !strings.Contains(body, `"name":"pixelib-internal"`) {
+ t.Fatalf("%s did not resolve to the current name: %s", name, body)
+ }
+ }
+ })
+}
+
+func TestReusingAFormerNameEndsTheRedirect(t *testing.T) {
+ instance := newTestInstance(t)
+ instance.setup()
+
+ expectStatus(t, instance.api(http.MethodPost, "/api/repositories", `{"name":"internal"}`), http.StatusCreated)
+ expectStatus(t, instance.api(http.MethodPatch, "/api/repositories/internal", `{"name":"renamed"}`), http.StatusOK)
+
+ // The new repository takes the name over rather than being shadowed by the
+ // redirect that still points at the old one.
+ expectStatus(t, instance.api(http.MethodPost, "/api/repositories", `{"name":"internal","description":"the second one"}`), http.StatusCreated)
+
+ body := expectStatus(t, instance.api(http.MethodGet, "/api/repositories/internal", ""), http.StatusOK)
+ if !strings.Contains(body, `"description":"the second one"`) {
+ t.Fatalf("the reused name did not resolve to the new repository: %s", body)
+ }
+
+ body = expectStatus(t, instance.api(http.MethodGet, "/api/repositories/renamed", ""), http.StatusOK)
+ if !strings.Contains(body, `"aliases":[]`) {
+ t.Fatalf("the renamed repository kept an alias it no longer owns: %s", body)
+ }
+}
+
+func TestRenameValidation(t *testing.T) {
+ instance := newTestInstance(t)
+ instance.setup()
+
+ expectStatus(t, instance.api(http.MethodPost, "/api/repositories", `{"name":"source"}`), http.StatusCreated)
+ expectStatus(t, instance.api(http.MethodPost, "/api/repositories", `{"name":"taken"}`), http.StatusCreated)
+
+ cases := []struct {
+ name string
+ body string
+ want int
+ }{
+ {"a name another repository holds", `{"name":"taken"}`, http.StatusConflict},
+ {"an invalid name", `{"name":"Not A Name"}`, http.StatusBadRequest},
+ {"an empty name", `{"name":" "}`, http.StatusBadRequest},
+ {"the name it already has", `{"name":"source"}`, http.StatusBadRequest},
+ }
+
+ for _, testCase := range cases {
+ t.Run(testCase.name, func(t *testing.T) {
+ expectStatus(t, instance.api(http.MethodPatch, "/api/repositories/source", testCase.body), testCase.want)
+ })
+ }
+}
+
+func TestAliasesAreManagedByHand(t *testing.T) {
+ instance := newTestInstance(t)
+ instance.setup()
+
+ expectStatus(t, instance.api(http.MethodPost, "/api/repositories", `{"name":"current"}`), http.StatusCreated)
+
+ expectStatus(t, instance.api(http.MethodPost, "/api/repositories/current/aliases", `{"name":"legacy"}`), http.StatusCreated)
+
+ body := expectStatus(t, instance.api(http.MethodGet, "/api/repositories/current/aliases", ""), http.StatusOK)
+ var payload struct {
+ Aliases []struct {
+ Name string `json:"name"`
+ } `json:"aliases"`
+ }
+ if err := json.Unmarshal([]byte(body), &payload); err != nil {
+ t.Fatalf("decoding aliases: %v", err)
+ }
+ if len(payload.Aliases) != 1 || payload.Aliases[0].Name != "legacy" {
+ t.Fatalf("aliases = %v, want one named legacy", payload.Aliases)
+ }
+
+ t.Run("a name a repository holds is refused", func(t *testing.T) {
+ expectStatus(t, instance.api(http.MethodPost, "/api/repositories/current/aliases", `{"name":"maven-releases"}`), http.StatusConflict)
+ })
+
+ t.Run("a repository cannot redirect to itself", func(t *testing.T) {
+ expectStatus(t, instance.api(http.MethodPost, "/api/repositories/current/aliases", `{"name":"current"}`), http.StatusBadRequest)
+ })
+
+ t.Run("deleting one stops the redirect", func(t *testing.T) {
+ expectStatus(t, instance.api(http.MethodDelete, "/api/repositories/current/aliases/legacy", ""), http.StatusNoContent)
+ expectStatus(t, instance.api(http.MethodGet, "/api/repositories/legacy", ""), http.StatusNotFound)
+ expectStatus(t, instance.api(http.MethodDelete, "/api/repositories/current/aliases/legacy", ""), http.StatusNotFound)
+ })
+}
+
+func TestDeletingARepositoryDropsItsAliases(t *testing.T) {
+ instance := newTestInstance(t)
+ instance.setup()
+
+ expectStatus(t, instance.api(http.MethodPost, "/api/repositories", `{"name":"temporary"}`), http.StatusCreated)
+ expectStatus(t, instance.api(http.MethodPatch, "/api/repositories/temporary", `{"name":"moved"}`), http.StatusOK)
+ expectStatus(t, instance.api(http.MethodDelete, "/api/repositories/moved", ""), http.StatusNoContent)
+
+ expectStatus(t, instance.maven(http.MethodGet, "/repository/temporary/", "", true), http.StatusNotFound)
+}
diff --git a/internal/server/repository.go b/internal/server/repository.go
index 2603067..173f33e 100644
--- a/internal/server/repository.go
+++ b/internal/server/repository.go
@@ -31,11 +31,19 @@ func (s *Server) challenge(writer http.ResponseWriter) {
func (s *Server) handleRepository(writer http.ResponseWriter, request *http.Request) {
name := routeVar(request, "repository")
- repository, err := s.store.RepositoryByName(name)
+ repository, aliased, err := s.store.ResolveRepository(name)
if err != nil {
plain(writer, http.StatusNotFound, "Repository not found")
return
}
+ // A name left behind by a rename redirects rather than serving in place, so
+ // a build that follows it learns the new URL and a build that does not
+ // follow redirects fails loudly instead of resolving from a stale name
+ // forever.
+ if aliased {
+ redirectRenamed(writer, request, name, repository.Name)
+ return
+ }
path := strings.TrimPrefix(strings.TrimPrefix(request.URL.Path, "/repository/"+name), "/")
@@ -51,6 +59,25 @@ func (s *Server) handleRepository(writer http.ResponseWriter, request *http.Requ
}
}
+// redirectRenamed points a request at the same path under the repository's
+// current name. A read is answered with a 301 because the move is permanent; a
+// write gets a 308, which is the one redirect that keeps the method and the body
+// rather than turning an upload into a GET.
+func redirectRenamed(writer http.ResponseWriter, request *http.Request, from, to string) {
+ target := "/repository/" + to + strings.TrimPrefix(request.URL.EscapedPath(), "/repository/"+from)
+ if query := request.URL.RawQuery; query != "" {
+ target += "?" + query
+ }
+
+ status := http.StatusMovedPermanently
+ if request.Method != http.MethodGet && request.Method != http.MethodHead {
+ status = http.StatusPermanentRedirect
+ }
+
+ writer.Header().Set("X-Arca-Renamed-From", from)
+ http.Redirect(writer, request, target, status)
+}
+
func (s *Server) authorize(writer http.ResponseWriter, request *http.Request, repository *models.Repository, required string) bool {
user := currentUser(request)
diff --git a/internal/store/aliases.go b/internal/store/aliases.go
new file mode 100644
index 0000000..4b45191
--- /dev/null
+++ b/internal/store/aliases.go
@@ -0,0 +1,167 @@
+package store
+
+import (
+ "gorm.io/gorm"
+ "gorm.io/gorm/clause"
+
+ "arca/internal/store/models"
+)
+
+// ResolveRepository finds the repository a name addresses. The second return
+// reports that the name was an alias rather than the repository's own, which is
+// what tells a caller to redirect instead of serving in place.
+func (s *Store) ResolveRepository(name string) (repository *models.Repository, aliased bool, err error) {
+ repository, err = s.RepositoryByName(name)
+ if err == nil {
+ return repository, false, nil
+ }
+ if !isNotFound(err) {
+ return nil, false, err
+ }
+
+ var alias models.RepositoryAlias
+ if err := s.db.First(&alias, "name = ?", name).Error; err != nil {
+ return nil, false, err
+ }
+
+ repository, err = s.RepositoryByID(alias.RepositoryID)
+ if err != nil {
+ return nil, false, err
+ }
+ return repository, true, nil
+}
+
+// ResolveRepositoryOfFormat is ResolveRepository narrowed to one format, so an
+// alias cannot pull a docker request onto a Maven repository any more than a
+// name can.
+func (s *Store) ResolveRepositoryOfFormat(name, repositoryFormat string) (*models.Repository, bool, error) {
+ repository, aliased, err := s.ResolveRepository(name)
+ if err != nil {
+ return nil, false, err
+ }
+ if repository.Format != repositoryFormat {
+ return nil, false, gorm.ErrRecordNotFound
+ }
+ return repository, aliased, nil
+}
+
+func (s *Store) RepositoryByID(id string) (*models.Repository, error) {
+ var repository models.Repository
+ if err := s.db.First(&repository, "id = ?", id).Error; err != nil {
+ return nil, err
+ }
+ return &repository, nil
+}
+
+func (s *Store) Aliases(repositoryID string) ([]models.RepositoryAlias, error) {
+ aliases := []models.RepositoryAlias{}
+ err := s.db.
+ Where("repository_id = ?", repositoryID).
+ Order("created_at DESC").
+ Find(&aliases).Error
+ return aliases, err
+}
+
+// CreateAlias points a name at a repository, taking the name over from whichever
+// repository held it as an alias before. A name that belongs to a live
+// repository is rejected by the caller, not here: this layer only knows aliases.
+func (s *Store) CreateAlias(name, repositoryID string) error {
+ now := NowMillis()
+
+ return s.query().Clauses(clause.OnConflict{
+ Columns: []clause.Column{{Name: "name"}},
+ DoUpdates: clause.Assignments(map[string]any{
+ "repository_id": repositoryID,
+ "created_at": now,
+ }),
+ }).Create(&models.RepositoryAlias{
+ Name: name,
+ RepositoryID: repositoryID,
+ CreatedAt: now,
+ }).Error
+}
+
+func (s *Store) DeleteAlias(name, repositoryID string) (int64, error) {
+ result := s.query().Where("name = ? AND repository_id = ?", name, repositoryID).Delete(&models.RepositoryAlias{})
+ return result.RowsAffected, result.Error
+}
+
+// ReleaseAlias drops a name from the alias table whoever holds it, which is what
+// makes a name reusable: the repository that claims it wins over the redirect.
+func (s *Store) ReleaseAlias(name string) error {
+ return s.query().Where("name = ?", name).Delete(&models.RepositoryAlias{}).Error
+}
+
+// AliasNames maps repository identifiers to the names that redirect to them,
+// which is one query for a whole listing rather than one per row.
+func (s *Store) AliasNames() (map[string][]string, error) {
+ var aliases []models.RepositoryAlias
+ if err := s.db.Order("created_at DESC").Find(&aliases).Error; err != nil {
+ return nil, err
+ }
+
+ names := map[string][]string{}
+ for _, alias := range aliases {
+ names[alias.RepositoryID] = append(names[alias.RepositoryID], alias.Name)
+ }
+ return names, nil
+}
+
+// RenameRepository moves a repository to a new name and leaves the old one
+// behind pointing at it. Everything that referred to the repository by name
+// moves with it in the same transaction, because a rename that half applied
+// would strand the old name, lose the new one, or empty a group.
+func (s *Store) RenameRepository(repositoryID, from, to string) error {
+ return s.Transaction(func(tx *Store) error {
+ if err := tx.ReleaseAlias(to); err != nil {
+ return err
+ }
+ err := tx.query().Model(&models.Repository{}).
+ Where("id = ?", repositoryID).
+ Update("name", to).Error
+ if err != nil {
+ return err
+ }
+ if err := tx.renameMemberTargets(from, to); err != nil {
+ return err
+ }
+ if err := tx.renameDockerDefault(from, to); err != nil {
+ return err
+ }
+ return tx.CreateAlias(from, repositoryID)
+ })
+}
+
+// renameMemberTargets follows the rename into the groups that list this
+// repository as a child. A member is a repository name, optionally followed by a
+// path inside it, so only the first segment moves.
+func (s *Store) renameMemberTargets(from, to string) error {
+ var members []models.RepositoryMember
+ err := s.query().
+ Where("target = ? OR target LIKE ?", from, from+"/%").
+ Find(&members).Error
+ if err != nil {
+ return err
+ }
+
+ for _, member := range members {
+ target := to + member.Target[len(from):]
+ err := s.query().Model(&models.RepositoryMember{}).
+ Where("repository_id = ? AND position = ?", member.RepositoryID, member.Position).
+ Update("target", target).Error
+ if err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// renameDockerDefault keeps a bare "docker pull host/image" pointing at the same
+// repository, since that setting stores a name rather than an identifier.
+func (s *Store) renameDockerDefault(from, to string) error {
+ current, err := s.Setting(SettingDockerRepository)
+ if err != nil || current != from {
+ return err
+ }
+ return s.SetSetting(SettingDockerRepository, to)
+}
diff --git a/internal/store/links.go b/internal/store/links.go
new file mode 100644
index 0000000..65c0546
--- /dev/null
+++ b/internal/store/links.go
@@ -0,0 +1,69 @@
+package store
+
+import (
+ "regexp"
+
+ "gorm.io/gorm"
+
+ "arca/internal/store/models"
+)
+
+var slugPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{1,63}$`)
+
+func IsValidSlug(slug string) bool { return slugPattern.MatchString(slug) }
+
+func (s *Store) CreateLink(link *models.DeploymentLink) error {
+ if link.ID == "" {
+ link.ID = NewID()
+ }
+ if link.CreatedAt == 0 {
+ link.CreatedAt = NowMillis()
+ }
+ return s.db.Create(link).Error
+}
+
+func (s *Store) ListLinks(repositoryID string) ([]models.DeploymentLink, error) {
+ links := []models.DeploymentLink{}
+ err := s.db.
+ Where("repository_id = ?", repositoryID).
+ Order("slug").
+ Find(&links).Error
+ return links, err
+}
+
+func (s *Store) LinkBySlug(slug string) (*models.DeploymentLink, error) {
+ var link models.DeploymentLink
+ if err := s.db.First(&link, "slug = ?", slug).Error; err != nil {
+ return nil, err
+ }
+ return &link, nil
+}
+
+func (s *Store) LinkByID(id, repositoryID string) (*models.DeploymentLink, error) {
+ var link models.DeploymentLink
+ if err := s.db.First(&link, "id = ? AND repository_id = ?", id, repositoryID).Error; err != nil {
+ return nil, err
+ }
+ return &link, nil
+}
+
+func (s *Store) UpdateLink(id string, changes map[string]any) error {
+ return s.db.Model(&models.DeploymentLink{}).Where("id = ?", id).Updates(changes).Error
+}
+
+func (s *Store) DeleteLink(id, repositoryID string) (int64, error) {
+ result := s.db.Where("id = ? AND repository_id = ?", id, repositoryID).Delete(&models.DeploymentLink{})
+ return result.RowsAffected, result.Error
+}
+
+// RecordLinkDownload counts a follow of the link. It runs after the file has
+// been resolved and is deliberately not part of serving it: a failed count must
+// not fail a download.
+func (s *Store) RecordLinkDownload(id string) error {
+ return s.db.Model(&models.DeploymentLink{}).
+ Where("id = ?", id).
+ Updates(map[string]any{
+ "downloads": gorm.Expr("downloads + 1"),
+ "last_used_at": NowMillis(),
+ }).Error
+}
diff --git a/internal/store/models/alias.go b/internal/store/models/alias.go
new file mode 100644
index 0000000..ff985c2
--- /dev/null
+++ b/internal/store/models/alias.go
@@ -0,0 +1,13 @@
+package models
+
+// RepositoryAlias is a name a repository used to answer to. Renaming leaves one
+// behind so builds that still ask for the old name keep resolving, and the name
+// is the primary key because only one repository can own a name at a time: when
+// a new repository claims it, the alias is dropped rather than shadowed.
+type RepositoryAlias struct {
+ Name string `gorm:"primaryKey"`
+ RepositoryID string `gorm:"not null;index"`
+ CreatedAt int64 `gorm:"autoCreateTime:milli"`
+}
+
+func (RepositoryAlias) TableName() string { return "repository_aliases" }
diff --git a/internal/store/models/link.go b/internal/store/models/link.go
new file mode 100644
index 0000000..9da327e
--- /dev/null
+++ b/internal/store/models/link.go
@@ -0,0 +1,38 @@
+package models
+
+// DeploymentLink is a stable URL that resolves to one file of whatever the
+// newest version of an artifact happens to be. It is served without
+// authentication so a build or an installer can follow it, which is the whole
+// point: the repository stays private while one file of it is publishable.
+type DeploymentLink struct {
+ ID string `gorm:"primaryKey"`
+ RepositoryID string `gorm:"not null;index"`
+ // Slug is the last segment of the public URL and is unique across the
+ // instance, because the URL carries no repository name of its own.
+ Slug string `gorm:"not null;uniqueIndex"`
+ Description string `gorm:"not null;default:''"`
+ Namespace string `gorm:"not null;default:''"`
+ // Artifact is the component name: an artifactId in Maven, a package name in
+ // npm. The column is not called "name" because Slug is what names the link.
+ Artifact string `gorm:"not null"`
+ // MatchType is one of the values the link package defines: a template with
+ // placeholders, or a regular expression over the filename.
+ MatchType string `gorm:"not null;default:template"`
+ Pattern string `gorm:"not null"`
+ // Policy picks which versions are eligible, using the same three values a
+ // repository policy does: releases, prereleases, or both.
+ Policy string `gorm:"not null;default:release"`
+ // Filename overrides the name the file is offered under. Empty serves the
+ // stored filename, which carries the version and so changes every release.
+ Filename string `gorm:"not null;default:''"`
+ // Disabled rather than Enabled, because GORM leaves a zero-valued field out
+ // of an insert whenever the column carries a default, which would make a
+ // link impossible to create switched off.
+ Disabled bool `gorm:"not null;default:false"`
+ Downloads int64 `gorm:"not null;default:0"`
+ LastUsedAt int64 `gorm:"not null;default:0"`
+ CreatedBy *string
+ CreatedAt int64 `gorm:"autoCreateTime:milli"`
+}
+
+func (DeploymentLink) TableName() string { return "deployment_links" }
diff --git a/internal/store/repositories.go b/internal/store/repositories.go
index c31d02d..63a3002 100644
--- a/internal/store/repositories.go
+++ b/internal/store/repositories.go
@@ -31,7 +31,15 @@ func (s *Store) CreateRepository(repository *models.Repository) error {
if repository.Format == "" {
repository.Format = format.Maven2
}
- return s.db.Create(repository).Error
+
+ // A repository that claims a name takes it back from whichever rename left
+ // it behind as an alias, which is what stops a redirect outliving its point.
+ return s.Transaction(func(tx *Store) error {
+ if err := tx.ReleaseAlias(repository.Name); err != nil {
+ return err
+ }
+ return tx.query().Create(repository).Error
+ })
}
func (s *Store) RepositoryByName(name string) (*models.Repository, error) {
@@ -184,7 +192,11 @@ func (s *Store) DeleteRepository(repositoryID string) ([]string, error) {
Pluck("storage_key", &keys).Error; err != nil {
return err
}
- for _, model := range []any{&models.Asset{}, &models.Component{}, &models.DistTag{}, &models.Grant{}, &models.RemoteMiss{}} {
+ related := []any{
+ &models.Asset{}, &models.Component{}, &models.DistTag{}, &models.Grant{},
+ &models.RemoteMiss{}, &models.RepositoryAlias{}, &models.DeploymentLink{},
+ }
+ for _, model := range related {
if err := tx.query().Where("repository_id = ?", repositoryID).Delete(model).Error; err != nil {
return err
}
diff --git a/internal/store/store.go b/internal/store/store.go
index a75cf91..af8eb93 100644
--- a/internal/store/store.go
+++ b/internal/store/store.go
@@ -91,8 +91,10 @@ func (s *Store) migrate() error {
&models.APIToken{},
&models.Category{},
&models.Repository{},
+ &models.RepositoryAlias{},
&models.Grant{},
&models.RepositoryMember{},
+ &models.DeploymentLink{},
&models.Component{},
&models.DistTag{},
&models.Asset{},