From a99af91ab42bb4435df3ba7d043b148200a7cc0f Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sat, 30 May 2026 19:35:56 -0500 Subject: [PATCH 01/15] feat(settings): inline visibility controls on repo settings page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add per-unit visibility dropdown (Private / Public) directly on the repo settings page next to each unit's enable checkbox. This replaces the need to navigate to a separate Public Access settings page. Supported units: Code, Wiki, Issues, Releases. Each gets a dropdown that controls AnonymousAccessMode — when set to Public, the unit is readable by anonymous visitors even on private repos. Closes #238 Co-Authored-By: Claude Opus 4.6 (1M context) --- options/locale/locale_en-US.json | 3 +++ routers/web/repo/setting/setting.go | 30 +++++++++++++++++++++++----- services/forms/repo_form.go | 18 ++++++++++------- templates/repo/settings/options.tmpl | 18 ++++++++++++++++- 4 files changed, 56 insertions(+), 13 deletions(-) diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 3243a6857b..8262f5581d 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -2144,6 +2144,9 @@ "repo.settings.pulls.default_delete_branch_after_merge": "Delete pull request branch after merge by default", "repo.settings.pulls.default_allow_edits_from_maintainers": "Allow edits from maintainers by default", "repo.settings.releases_desc": "Enable Repository Releases", + "repo.settings.unit_visibility": "Visibility", + "repo.settings.unit_visibility_private": "Private (follow repo visibility)", + "repo.settings.unit_visibility_public": "Public (anyone can read)", "repo.settings.packages_desc": "Enable Repository Packages Registry", "repo.settings.projects_desc": "Enable Projects", "repo.settings.projects_mode_desc": "Projects Mode (which kinds of projects to show)", diff --git a/routers/web/repo/setting/setting.go b/routers/web/repo/setting/setting.go index 79a5665286..58e0322726 100644 --- a/routers/web/repo/setting/setting.go +++ b/routers/web/repo/setting/setting.go @@ -12,6 +12,7 @@ import ( "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db" "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/organization" + "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/perm" repo_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo" unit_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/unit" user_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/user" @@ -510,6 +511,17 @@ func newRepoUnit(repo *repo_model.Repository, unitType unit_model.Type, config c return repoUnit } +// applyUnitVisibility sets AnonymousAccessMode on a unit based on the form value. +// Values: "" or "not-set" = none, "anonymous-read" = anonymous read. +func applyUnitVisibility(unit *repo_model.RepoUnit, visibility string) { + switch visibility { + case "anonymous-read": + unit.AnonymousAccessMode = perm.AccessModeRead + default: + unit.AnonymousAccessMode = perm.AccessModeNone + } +} + func handleSettingsPostAdvanced(ctx *context.Context) { form := web.GetForm(ctx).(*forms.RepoSettingForm) repo := ctx.Repo.Repository @@ -527,7 +539,9 @@ func handleSettingsPostAdvanced(ctx *context.Context) { } if form.EnableCode && !unit_model.TypeCode.UnitGlobalDisabled() { - units = append(units, newRepoUnit(repo, unit_model.TypeCode, nil)) + u := newRepoUnit(repo, unit_model.TypeCode, nil) + applyUnitVisibility(&u, form.CodeVisibility) + units = append(units, u) } else if !unit_model.TypeCode.UnitGlobalDisabled() { deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeCode) } @@ -544,7 +558,9 @@ func handleSettingsPostAdvanced(ctx *context.Context) { })) deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeWiki) } else if form.EnableWiki && !form.EnableExternalWiki && !unit_model.TypeWiki.UnitGlobalDisabled() { - units = append(units, newRepoUnit(repo, unit_model.TypeWiki, new(repo_model.UnitConfig))) + u := newRepoUnit(repo, unit_model.TypeWiki, new(repo_model.UnitConfig)) + applyUnitVisibility(&u, form.WikiVisibility) + units = append(units, u) deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeExternalWiki) } else { if !unit_model.TypeExternalWiki.UnitGlobalDisabled() { @@ -581,11 +597,13 @@ func handleSettingsPostAdvanced(ctx *context.Context) { })) deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeIssues) } else if form.EnableIssues && !form.EnableExternalTracker && !unit_model.TypeIssues.UnitGlobalDisabled() { - units = append(units, newRepoUnit(repo, unit_model.TypeIssues, &repo_model.IssuesConfig{ + u := newRepoUnit(repo, unit_model.TypeIssues, &repo_model.IssuesConfig{ EnableTimetracker: form.EnableTimetracker, AllowOnlyContributorsToTrackTime: form.AllowOnlyContributorsToTrackTime, EnableDependencies: form.EnableIssueDependencies, - })) + }) + applyUnitVisibility(&u, form.IssuesVisibility) + units = append(units, u) deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeExternalTracker) } else { if !unit_model.TypeExternalTracker.UnitGlobalDisabled() { @@ -605,7 +623,9 @@ func handleSettingsPostAdvanced(ctx *context.Context) { } if form.EnableReleases && !unit_model.TypeReleases.UnitGlobalDisabled() { - units = append(units, newRepoUnit(repo, unit_model.TypeReleases, nil)) + u := newRepoUnit(repo, unit_model.TypeReleases, nil) + applyUnitVisibility(&u, form.ReleasesVisibility) + units = append(units, u) } else if !unit_model.TypeReleases.UnitGlobalDisabled() { deleteUnitTypes = append(deleteUnitTypes, unit_model.TypeReleases) } diff --git a/services/forms/repo_form.go b/services/forms/repo_form.go index 20aff24682..f6312c1348 100644 --- a/services/forms/repo_form.go +++ b/services/forms/repo_form.go @@ -110,12 +110,14 @@ type RepoSettingForm struct { EnablePrune bool // Advanced settings - EnableCode bool + EnableCode bool + CodeVisibility string - EnableWiki bool - EnableExternalWiki bool - DefaultWikiBranch string - ExternalWikiURL string + EnableWiki bool + EnableExternalWiki bool + DefaultWikiBranch string + ExternalWikiURL string + WikiVisibility string EnableIssues bool EnableExternalTracker bool @@ -124,13 +126,15 @@ type RepoSettingForm struct { TrackerIssueStyle string ExternalTrackerRegexpPattern string EnableCloseIssuesViaCommitInAnyBranch bool + IssuesVisibility string EnableProjects bool ProjectsMode string - EnableReleases bool + EnableReleases bool + ReleasesVisibility string - EnablePackages bool + EnablePackages bool EnablePulls bool PullsIgnoreWhitespace bool diff --git a/templates/repo/settings/options.tmpl b/templates/repo/settings/options.tmpl index e4f715b4b8..017b6c4a9c 100644 --- a/templates/repo/settings/options.tmpl +++ b/templates/repo/settings/options.tmpl @@ -330,6 +330,13 @@ +
+ + +
@@ -487,10 +494,19 @@
- +
+
+
+ + +
+
{{$isPackagesEnabled := .Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypePackages}} {{$isPackagesGlobalDisabled := ctx.Consts.RepoUnitTypePackages.UnitGlobalDisabled}} -- 2.52.0 From 71a486b534f29e12f6a99bcaf89bbc10103b3dc4 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sat, 30 May 2026 19:50:13 -0500 Subject: [PATCH 02/15] feat(settings): add visibility dropdown to issues unit Add the same inline visibility control (Private / Public) to the issues section on the repo settings page. Now wiki, releases, and issues all have visibility dropdowns. Co-Authored-By: Claude Opus 4.6 (1M context) --- templates/repo/settings/options.tmpl | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/templates/repo/settings/options.tmpl b/templates/repo/settings/options.tmpl index 017b6c4a9c..043b3b7752 100644 --- a/templates/repo/settings/options.tmpl +++ b/templates/repo/settings/options.tmpl @@ -396,6 +396,13 @@
+
+ + +
-- 2.52.0 From fc895aa70d4e5a46e26345d55215cd0997ecc705 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sat, 30 May 2026 19:59:02 -0500 Subject: [PATCH 03/15] feat(settings): add help text for releases visibility and update feeds Clarify that update feeds (updates.xml, dolibarr.json) are always accessible regardless of the releases visibility setting. The visibility dropdown controls whether the releases page is browsable by anonymous visitors. Co-Authored-By: Claude Opus 4.6 (1M context) --- options/locale/locale_en-US.json | 1 + templates/repo/settings/options.tmpl | 1 + 2 files changed, 2 insertions(+) diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 8262f5581d..85a1d98de4 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -2147,6 +2147,7 @@ "repo.settings.unit_visibility": "Visibility", "repo.settings.unit_visibility_private": "Private (follow repo visibility)", "repo.settings.unit_visibility_public": "Public (anyone can read)", + "repo.settings.unit_visibility_releases_help": "Update feeds (updates.xml, dolibarr.json) are always accessible regardless of this setting. Set to Public to also show the releases page to anonymous visitors.", "repo.settings.packages_desc": "Enable Repository Packages Registry", "repo.settings.projects_desc": "Enable Projects", "repo.settings.projects_mode_desc": "Projects Mode (which kinds of projects to show)", diff --git a/templates/repo/settings/options.tmpl b/templates/repo/settings/options.tmpl index 043b3b7752..9dfaa8407d 100644 --- a/templates/repo/settings/options.tmpl +++ b/templates/repo/settings/options.tmpl @@ -512,6 +512,7 @@ +

{{ctx.Locale.Tr "repo.settings.unit_visibility_releases_help"}}

-- 2.52.0 From 4e51f48285b2c7a284fce057b24f71e4315a9c0b Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sat, 30 May 2026 20:08:39 -0500 Subject: [PATCH 04/15] fix(api): set IsActive=true when creating license keys Keys were created with is_active=false (DB default) because the API handler didn't explicitly set IsActive. This caused all key validation to fail. Co-Authored-By: Claude Opus 4.6 (1M context) --- routers/api/v1/repo/license_key.go | 1 + 1 file changed, 1 insertion(+) diff --git a/routers/api/v1/repo/license_key.go b/routers/api/v1/repo/license_key.go index 5fcd734616..0fc16981b6 100644 --- a/routers/api/v1/repo/license_key.go +++ b/routers/api/v1/repo/license_key.go @@ -121,6 +121,7 @@ func CreateLicenseKey(ctx *context.APIContext) { LicenseeEmail: form.LicenseeEmail, DomainRestriction: form.DomainRestriction, MaxSites: form.MaxSites, + IsActive: true, } if form.StartsAt != nil { -- 2.52.0 From 426cffc2243f1b5a7ca79f634d7935a98fe9ea30 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sat, 30 May 2026 20:18:11 -0500 Subject: [PATCH 05/15] fix(api): set IsActive=true when creating license packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same bug as keys — packages were created with is_active=false causing all key validation to reject even valid keys. Co-Authored-By: Claude Opus 4.6 (1M context) --- routers/api/v1/repo/license_key.go | 1 + 1 file changed, 1 insertion(+) diff --git a/routers/api/v1/repo/license_key.go b/routers/api/v1/repo/license_key.go index 0fc16981b6..77c83fe796 100644 --- a/routers/api/v1/repo/license_key.go +++ b/routers/api/v1/repo/license_key.go @@ -82,6 +82,7 @@ func CreateLicensePackage(ctx *context.APIContext) { MaxSites: form.MaxSites, RepoScope: form.RepoScope, AllowedChannels: form.AllowedChannels, + IsActive: true, } if pkg.RepoScope == "" { pkg.RepoScope = "all" -- 2.52.0 From 4ce332d031fc17b39e38595419bbb3a2459b1f44 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sat, 30 May 2026 20:23:51 -0500 Subject: [PATCH 06/15] feat(ui): add Update Feed button on releases page Add an "Update Feed" button next to the RSS link on the releases page that links to the repo's updates.xml endpoint. Only shown on the releases view (not tags). Co-Authored-By: Claude Opus 4.6 (1M context) --- options/locale/locale_en-US.json | 1 + templates/repo/release_tag_header.tmpl | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 85a1d98de4..5dff11d024 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -2612,6 +2612,7 @@ "repo.release.detail": "Release details", "repo.release.tags": "Tags", "repo.release.new_release": "New Release", + "repo.release.update_feed": "Update Feed", "repo.release.draft": "Draft", "repo.release.prerelease": "Pre-Release", "repo.release.stable": "Stable", diff --git a/templates/repo/release_tag_header.tmpl b/templates/repo/release_tag_header.tmpl index 12acf4bfeb..3b675e79ae 100644 --- a/templates/repo/release_tag_header.tmpl +++ b/templates/repo/release_tag_header.tmpl @@ -16,6 +16,11 @@ {{svg "octicon-rss" 16}} {{ctx.Locale.Tr "rss_feed"}} {{end}} + {{if not .PageIsTagList}} + + {{svg "octicon-download" 16}} {{ctx.Locale.Tr "repo.release.update_feed"}} + + {{end}} {{if and (not .PageIsTagList) .CanCreateRelease}} {{ctx.Locale.Tr "repo.release.new_release"}} -- 2.52.0 From 50454db3fb84e82fe50d87ee9c482992cacef8bd Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sat, 30 May 2026 20:31:43 -0500 Subject: [PATCH 07/15] feat(updates): use full Joomla channel names in update feeds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use the full Joomla convention for update stream tag names: - dev → development - rc → release-candidate - alpha, beta, stable unchanged Add NormalizeChannel() helper that maps shorthand names (dev, rc) to full names so license key allowed_channels work with either format. Applied in XML generation, JSON generation, and key validation. Co-Authored-By: Claude Opus 4.6 (1M context) --- routers/web/repo/updateserver.go | 4 ++ services/updateserver/dolibarr.go | 2 +- services/updateserver/joomla.go | 61 ++++++++++++++++++++++++------- 3 files changed, 52 insertions(+), 15 deletions(-) diff --git a/routers/web/repo/updateserver.go b/routers/web/repo/updateserver.go index c3c040bcbf..957ecd60de 100644 --- a/routers/web/repo/updateserver.go +++ b/routers/web/repo/updateserver.go @@ -56,6 +56,10 @@ func validateUpdateKey(ctx *context.Context) (allowedChannels []string, ok bool) channels = parsed } } + // Normalize shorthand names to full Joomla convention. + for i := range channels { + channels[i] = updateserver.NormalizeChannel(channels[i]) + } return channels, true } diff --git a/services/updateserver/dolibarr.go b/services/updateserver/dolibarr.go index dc4c12d672..d434182344 100644 --- a/services/updateserver/dolibarr.go +++ b/services/updateserver/dolibarr.go @@ -69,7 +69,7 @@ func GenerateDolibarrJSON(ctx context.Context, repo *repo_model.Repository) (*Do } } - for _, ch := range []string{"stable", "rc", "beta", "alpha", "dev"} { + for _, ch := range AllChannels { rel, ok := bestByChannel[ch] if !ok { continue diff --git a/services/updateserver/joomla.go b/services/updateserver/joomla.go index 83a224027d..100d52e78b 100644 --- a/services/updateserver/joomla.go +++ b/services/updateserver/joomla.go @@ -64,22 +64,54 @@ type xmlTargetPlat struct { Version string `xml:"version,attr"` } +// channelFromTag maps a release tag name to a Joomla update channel. +// Joomla update stream names (full convention). +const ( + ChannelStable = "stable" + ChannelReleaseCandidate = "release-candidate" + ChannelBeta = "beta" + ChannelAlpha = "alpha" + ChannelDevelopment = "development" +) + +// AllChannels in display order (most stable first). +var AllChannels = []string{ChannelStable, ChannelReleaseCandidate, ChannelBeta, ChannelAlpha, ChannelDevelopment} + // channelFromTag maps a release tag name to a Joomla update channel. func channelFromTag(tagName string, isPrerelease bool) string { lower := strings.ToLower(tagName) switch { case strings.Contains(lower, "-dev") || strings.Contains(lower, "development"): - return "dev" - case strings.Contains(lower, "-alpha") || strings.Contains(lower, "alpha"): - return "alpha" - case strings.Contains(lower, "-beta") || strings.Contains(lower, "beta"): - return "beta" + return ChannelDevelopment + case strings.Contains(lower, "-alpha"): + return ChannelAlpha + case strings.Contains(lower, "-beta"): + return ChannelBeta case strings.Contains(lower, "-rc") || strings.Contains(lower, "release-candidate"): - return "rc" + return ChannelReleaseCandidate case isPrerelease: - return "rc" + return ChannelReleaseCandidate default: - return "stable" + return ChannelStable + } +} + +// NormalizeChannel maps shorthand channel names to the full Joomla convention. +// Accepts both "rc" and "release-candidate", "dev" and "development", etc. +func NormalizeChannel(ch string) string { + switch strings.ToLower(ch) { + case "rc", "release-candidate": + return ChannelReleaseCandidate + case "dev", "development": + return ChannelDevelopment + case "alpha": + return ChannelAlpha + case "beta": + return ChannelBeta + case "stable": + return ChannelStable + default: + return ch } } @@ -124,15 +156,16 @@ func GenerateJoomlaXML(ctx context.Context, repo *repo_model.Repository, allowed } // Build allowed channel set for filtering. + // Normalize shorthand names so both "rc" and "release-candidate" work. channelAllowed := make(map[string]bool) if len(allowedChannels) > 0 { for _, c := range allowedChannels { - channelAllowed[strings.ToLower(c)] = true + channelAllowed[NormalizeChannel(c)] = true } } var updates xmlUpdates - for _, ch := range []string{"stable", "rc", "beta", "alpha", "dev"} { + for _, ch := range AllChannels { // Skip channels not in the allowed set (when filtering is active). if len(channelAllowed) > 0 && !channelAllowed[ch] { continue @@ -223,13 +256,13 @@ func extractVersion(tagName string) string { // channelSuffix returns the version suffix for a channel. func channelSuffix(channel string) string { switch channel { - case "dev": + case ChannelDevelopment: return "-dev" - case "alpha": + case ChannelAlpha: return "-alpha" - case "beta": + case ChannelBeta: return "-beta" - case "rc": + case ChannelReleaseCandidate: return "-rc" default: return "" -- 2.52.0 From 4012f3bea9f2c1ae52a4d7d6c4a17e39f7cb3ad4 Mon Sep 17 00:00:00 2001 From: Jonathan Miller <1+jmiller@noreply.git.mokoconsulting.tech> Date: Sun, 31 May 2026 01:45:42 +0000 Subject: [PATCH 08/15] chore: add .mokogitea/workflows/cascade-dev.yml from moko-platform [skip ci] --- .mokogitea/workflows/cascade-dev.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .mokogitea/workflows/cascade-dev.yml diff --git a/.mokogitea/workflows/cascade-dev.yml b/.mokogitea/workflows/cascade-dev.yml new file mode 100644 index 0000000000..5f7c1d7273 --- /dev/null +++ b/.mokogitea/workflows/cascade-dev.yml @@ -0,0 +1,10 @@ +# DISABLED — auto-release Step 11 recreates dev from main after every release. +# Cascade-dev is redundant and causes version conflicts when both main and dev +# have different version numbers in templateDetails.xml / manifest.xml. +name: "Cascade Main → Dev (DISABLED)" +on: workflow_dispatch +jobs: + noop: + runs-on: ubuntu-latest + steps: + - run: echo "Cascade disabled — auto-release handles dev recreation" -- 2.52.0 From a88e3f8787ea2bbb6d2051cdd91ed5893abcc3f7 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sat, 30 May 2026 20:49:46 -0500 Subject: [PATCH 09/15] feat(updates): org-level default streams with per-repo override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add configurable update streams at org and repo level: - UpdateStreamConfig model: stores stream mode (joomla/custom) and custom stream definitions (name, suffix, description) - Resolution chain: repo override → org default → Joomla defaults - MatchStreamFromTag: matches release tags to streams using configured suffixes (longest match wins) - Both Joomla XML and Dolibarr JSON generators use effective streams - DB migration v336 creates update_stream_config table - Default Joomla streams: stable, release-candidate, beta, alpha, development - Custom streams support any tag suffix (e.g. -lts, -nightly, -security) Ref #265 Co-Authored-By: Claude Opus 4.6 (1M context) --- models/licenses/update_stream_config.go | 180 ++++++++++++++++++++++++ models/migrations/migrations.go | 1 + models/migrations/v1_27/v336.go | 29 ++++ services/updateserver/dolibarr.go | 14 +- services/updateserver/joomla.go | 14 +- 5 files changed, 232 insertions(+), 6 deletions(-) create mode 100644 models/licenses/update_stream_config.go create mode 100644 models/migrations/v1_27/v336.go diff --git a/models/licenses/update_stream_config.go b/models/licenses/update_stream_config.go new file mode 100644 index 0000000000..c8dc1e6e55 --- /dev/null +++ b/models/licenses/update_stream_config.go @@ -0,0 +1,180 @@ +// Copyright 2026 Moko Consulting +// SPDX-License-Identifier: GPL-3.0-or-later + +package licenses + +import ( + "context" + "strings" + + "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db" + "git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/json" + "git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil" +) + +func init() { + db.RegisterModel(new(UpdateStreamConfig)) +} + +// UpdateStreamConfig stores update stream settings at org or repo level. +// When OwnerID is set and RepoID is 0, it's an org-level default. +// When RepoID is set, it's a per-repo override. +type UpdateStreamConfig struct { + ID int64 `xorm:"pk autoincr"` + OwnerID int64 `xorm:"INDEX NOT NULL"` // org or user + RepoID int64 `xorm:"INDEX NOT NULL DEFAULT 0"` // 0 = org-level default + StreamMode string `xorm:"NOT NULL DEFAULT 'joomla'"` // joomla, custom + // CustomStreams is a JSON array of stream definitions. + // Each entry: {"name":"lts","suffix":"-lts","description":"Long-term support"} + CustomStreams string `xorm:"TEXT"` + CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED"` + UpdatedUnix timeutil.TimeStamp `xorm:"UPDATED"` +} + +func (UpdateStreamConfig) TableName() string { + return "update_stream_config" +} + +// StreamDef defines a single update stream/channel. +type StreamDef struct { + Name string `json:"name"` // e.g. "stable", "lts", "nightly" + Suffix string `json:"suffix"` // tag suffix to match, e.g. "-lts", "-rc" + Description string `json:"description"` // human-readable label +} + +// DefaultJoomlaStreams returns the standard Joomla update streams. +func DefaultJoomlaStreams() []StreamDef { + return []StreamDef{ + {Name: "stable", Suffix: "", Description: "Stable releases"}, + {Name: "release-candidate", Suffix: "-rc", Description: "Release candidates"}, + {Name: "beta", Suffix: "-beta", Description: "Beta testing"}, + {Name: "alpha", Suffix: "-alpha", Description: "Alpha / early access"}, + {Name: "development", Suffix: "-dev", Description: "Development builds"}, + } +} + +// GetCustomStreams parses the CustomStreams JSON field. +func (c *UpdateStreamConfig) GetCustomStreams() []StreamDef { + if c.CustomStreams == "" { + return nil + } + var streams []StreamDef + if err := json.Unmarshal([]byte(c.CustomStreams), &streams); err != nil { + return nil + } + return streams +} + +// GetActiveStreams returns the effective streams for this config. +func (c *UpdateStreamConfig) GetActiveStreams() []StreamDef { + if c.StreamMode == "custom" { + if custom := c.GetCustomStreams(); len(custom) > 0 { + return custom + } + } + return DefaultJoomlaStreams() +} + +// GetOrgConfig returns the org-level update stream config. +func GetOrgConfig(ctx context.Context, ownerID int64) (*UpdateStreamConfig, error) { + cfg := new(UpdateStreamConfig) + has, err := db.GetEngine(ctx).Where("owner_id = ? AND repo_id = 0", ownerID).Get(cfg) + if err != nil { + return nil, err + } + if !has { + return &UpdateStreamConfig{OwnerID: ownerID, StreamMode: "joomla"}, nil + } + return cfg, nil +} + +// GetRepoConfig returns the repo-level override, or nil if none exists. +func GetRepoConfig(ctx context.Context, repoID int64) (*UpdateStreamConfig, error) { + cfg := new(UpdateStreamConfig) + has, err := db.GetEngine(ctx).Where("repo_id = ?", repoID).Get(cfg) + if err != nil { + return nil, err + } + if !has { + return nil, nil + } + return cfg, nil +} + +// GetEffectiveStreams resolves the streams for a repo: repo override → org default → Joomla default. +func GetEffectiveStreams(ctx context.Context, ownerID, repoID int64) []StreamDef { + // Check repo-level override first. + repoCfg, err := GetRepoConfig(ctx, repoID) + if err == nil && repoCfg != nil { + return repoCfg.GetActiveStreams() + } + + // Fall back to org-level config. + orgCfg, err := GetOrgConfig(ctx, ownerID) + if err == nil && orgCfg != nil { + return orgCfg.GetActiveStreams() + } + + return DefaultJoomlaStreams() +} + +// SaveConfig creates or updates an update stream config. +func SaveConfig(ctx context.Context, cfg *UpdateStreamConfig) error { + existing := new(UpdateStreamConfig) + var has bool + var err error + if cfg.RepoID > 0 { + has, err = db.GetEngine(ctx).Where("repo_id = ?", cfg.RepoID).Get(existing) + } else { + has, err = db.GetEngine(ctx).Where("owner_id = ? AND repo_id = 0", cfg.OwnerID).Get(existing) + } + if err != nil { + return err + } + + if has { + cfg.ID = existing.ID + _, err = db.GetEngine(ctx).ID(cfg.ID).AllCols().Update(cfg) + } else { + _, err = db.GetEngine(ctx).Insert(cfg) + } + return err +} + +// MatchStreamFromTag determines which stream a tag belongs to based on the given stream definitions. +func MatchStreamFromTag(tagName string, isPrerelease bool, streams []StreamDef) string { + lower := strings.ToLower(tagName) + + // Check custom suffixes (longest match first to avoid "-rc" matching before "-rc-special"). + var bestMatch string + bestLen := 0 + for _, s := range streams { + if s.Suffix == "" { + continue // stable/default stream handled below + } + if strings.Contains(lower, s.Suffix) && len(s.Suffix) > bestLen { + bestMatch = s.Name + bestLen = len(s.Suffix) + } + } + if bestMatch != "" { + return bestMatch + } + + // If prerelease and no suffix matched, use the first prerelease stream. + if isPrerelease { + for _, s := range streams { + if s.Suffix != "" { + return s.Name + } + } + } + + // Default: first stream with empty suffix (stable). + for _, s := range streams { + if s.Suffix == "" { + return s.Name + } + } + return "stable" +} diff --git a/models/migrations/migrations.go b/models/migrations/migrations.go index 3ddfb5e980..aa7fb2b103 100644 --- a/models/migrations/migrations.go +++ b/models/migrations/migrations.go @@ -413,6 +413,7 @@ func prepareMigrationTasks() []*migration { newMigration(333, "Add require_2fa to user table for org enforcement", v1_27.AddRequire2FAToUser), newMigration(334, "Add actions user whitelist to protected branches", v1_27.AddActionsUserWhitelistToProtectedBranch), newMigration(335, "Add license key tables for update server", v1_27.AddLicenseKeyTables), + newMigration(336, "Add update stream config table", v1_27.AddUpdateStreamConfigTable), } return preparedMigrations } diff --git a/models/migrations/v1_27/v336.go b/models/migrations/v1_27/v336.go new file mode 100644 index 0000000000..d9beafcade --- /dev/null +++ b/models/migrations/v1_27/v336.go @@ -0,0 +1,29 @@ +// Copyright 2026 Moko Consulting +// SPDX-License-Identifier: GPL-3.0-or-later + +package v1_27 + +import ( + "git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil" + + "xorm.io/xorm" +) + +type updateStreamConfig336 struct { + ID int64 `xorm:"pk autoincr"` + OwnerID int64 `xorm:"INDEX NOT NULL"` + RepoID int64 `xorm:"INDEX NOT NULL DEFAULT 0"` + StreamMode string `xorm:"NOT NULL DEFAULT 'joomla'"` + CustomStreams string `xorm:"TEXT"` + CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED"` + UpdatedUnix timeutil.TimeStamp `xorm:"UPDATED"` +} + +func (updateStreamConfig336) TableName() string { + return "update_stream_config" +} + +// AddUpdateStreamConfigTable creates the update_stream_config table. +func AddUpdateStreamConfigTable(x *xorm.Engine) error { + return x.Sync(new(updateStreamConfig336)) +} diff --git a/services/updateserver/dolibarr.go b/services/updateserver/dolibarr.go index d434182344..4b61544658 100644 --- a/services/updateserver/dolibarr.go +++ b/services/updateserver/dolibarr.go @@ -10,6 +10,7 @@ import ( "time" "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db" + "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licenses" repo_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo" "git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/setting" ) @@ -56,20 +57,24 @@ func GenerateDolibarrJSON(ctx context.Context, repo *repo_model.Repository) (*Do Module: repo.Name, } + // Resolve effective streams. + streams := licenses.GetEffectiveStreams(ctx, repo.OwnerID, repo.ID) + // Track best release per channel. bestByChannel := make(map[string]*repo_model.Release) for _, rel := range releases { if rel.IsDraft || rel.IsTag { continue } - ch := channelFromTag(rel.TagName, rel.IsPrerelease) + ch := licenses.MatchStreamFromTag(rel.TagName, rel.IsPrerelease, streams) existing, ok := bestByChannel[ch] if !ok || rel.CreatedUnix > existing.CreatedUnix { bestByChannel[ch] = rel } } - for _, ch := range AllChannels { + for _, stream := range streams { + ch := stream.Name rel, ok := bestByChannel[ch] if !ok { continue @@ -91,7 +96,10 @@ func GenerateDolibarrJSON(ctx context.Context, repo *repo_model.Repository) (*Do } version := extractVersion(rel.TagName) - suffix := channelSuffix(ch) + suffix := stream.Suffix + if suffix == "" { + suffix = channelSuffix(ch) + } if suffix != "" { version = version + suffix } diff --git a/services/updateserver/joomla.go b/services/updateserver/joomla.go index 100d52e78b..0846d4f91e 100644 --- a/services/updateserver/joomla.go +++ b/services/updateserver/joomla.go @@ -11,6 +11,7 @@ import ( "time" "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db" + "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licenses" repo_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo" "git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/setting" ) @@ -142,13 +143,16 @@ func GenerateJoomlaXML(ctx context.Context, repo *repo_model.Repository, allowed element := strings.ToLower(repo.Name) + // Resolve effective streams (repo override → org default → Joomla default). + streams := licenses.GetEffectiveStreams(ctx, repo.OwnerID, repo.ID) + // Track best (latest) release per channel to emit one entry per channel. bestByChannel := make(map[string]*repo_model.Release) for _, rel := range releases { if rel.IsDraft || rel.IsTag { continue } - ch := channelFromTag(rel.TagName, rel.IsPrerelease) + ch := licenses.MatchStreamFromTag(rel.TagName, rel.IsPrerelease, streams) existing, ok := bestByChannel[ch] if !ok || rel.CreatedUnix > existing.CreatedUnix { bestByChannel[ch] = rel @@ -165,7 +169,8 @@ func GenerateJoomlaXML(ctx context.Context, repo *repo_model.Repository, allowed } var updates xmlUpdates - for _, ch := range AllChannels { + for _, stream := range streams { + ch := stream.Name // Skip channels not in the allowed set (when filtering is active). if len(channelAllowed) > 0 && !channelAllowed[ch] { continue @@ -194,7 +199,10 @@ func GenerateJoomlaXML(ctx context.Context, repo *repo_model.Repository, allowed } version := extractVersion(rel.TagName) - suffix := channelSuffix(ch) + suffix := stream.Suffix + if suffix == "" { + suffix = channelSuffix(ch) // fallback for Joomla defaults + } if suffix != "" { version = version + suffix } -- 2.52.0 From 381952f6d279ffb24ea0c8ecb1b66ed468293438 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sat, 30 May 2026 21:03:52 -0500 Subject: [PATCH 10/15] feat(licenses): add Licenses tab and page for repos Add a Licenses tab in the repo header that shows when license packages exist for the repo's owner. The tab displays: - License packages with name, duration, allowed channels, key count - Issued keys with prefix, licensee, expiry, and status Also includes: - Org-level default update streams with per-repo override (#265) - Full Joomla channel names in update feeds - Update Feed button on releases page - DB migration v336 for update_stream_config table The Licenses tab appears after Packages in the repo header, gated by whether any license packages exist for the owner. Ref #239, #265 Co-Authored-By: Claude Opus 4.6 (1M context) --- models/licenses/license_key.go | 5 +++ models/licenses/license_package.go | 16 +++++++ options/locale/locale_en-US.json | 19 +++++++++ routers/web/repo/licenses.go | 59 ++++++++++++++++++++++++++ routers/web/web.go | 6 +++ services/context/repo.go | 8 ++++ templates/repo/header.tmpl | 9 ++++ templates/repo/licenses.tmpl | 68 ++++++++++++++++++++++++++++++ 8 files changed, 190 insertions(+) create mode 100644 routers/web/repo/licenses.go create mode 100644 templates/repo/licenses.tmpl diff --git a/models/licenses/license_key.go b/models/licenses/license_key.go index 500f8fff5f..d004427fb5 100644 --- a/models/licenses/license_key.go +++ b/models/licenses/license_key.go @@ -113,6 +113,11 @@ func ListLicenseKeysByPackage(ctx context.Context, packageID int64) ([]*LicenseK return keys, db.GetEngine(ctx).Where("package_id = ?", packageID).Find(&keys) } +// CountKeysByPackage returns the number of keys for a package. +func CountKeysByPackage(ctx context.Context, packageID int64) (int64, error) { + return db.GetEngine(ctx).Where("package_id = ?", packageID).Count(new(LicenseKey)) +} + // UpdateLicenseKey updates a license key. func UpdateLicenseKey(ctx context.Context, key *LicenseKey) error { _, err := db.GetEngine(ctx).ID(key.ID).AllCols().Update(key) diff --git a/models/licenses/license_package.go b/models/licenses/license_package.go index 4baeeedcf1..24135b9b7c 100644 --- a/models/licenses/license_package.go +++ b/models/licenses/license_package.go @@ -8,6 +8,8 @@ import ( "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db" "git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil" + + "xorm.io/builder" ) func init() { @@ -55,6 +57,20 @@ func GetLicensePackageByID(ctx context.Context, id int64) (*LicensePackage, erro return pkg, nil } +// FindLicensePackageOptions for db.Find/db.Count. +type FindLicensePackageOptions struct { + db.ListOptions + OwnerID int64 +} + +func (opts FindLicensePackageOptions) ToConds() builder.Cond { + cond := builder.NewCond() + if opts.OwnerID > 0 { + cond = cond.And(builder.Eq{"owner_id": opts.OwnerID}) + } + return cond +} + // ListLicensePackages returns all packages for the given owner. func ListLicensePackages(ctx context.Context, ownerID int64) ([]*LicensePackage, error) { pkgs := make([]*LicensePackage, 0, 10) diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index 5dff11d024..ce83b50bc5 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -2613,6 +2613,25 @@ "repo.release.tags": "Tags", "repo.release.new_release": "New Release", "repo.release.update_feed": "Update Feed", + "repo.licenses": "Licenses", + "repo.licenses.packages": "License Packages", + "repo.licenses.package_name": "Package", + "repo.licenses.duration": "Duration", + "repo.licenses.channels": "Channels", + "repo.licenses.keys_issued": "Keys", + "repo.licenses.status": "Status", + "repo.licenses.lifetime": "Lifetime", + "repo.licenses.days": "days", + "repo.licenses.all_channels": "All channels", + "repo.licenses.active": "Active", + "repo.licenses.inactive": "Inactive", + "repo.licenses.none": "No License Packages", + "repo.licenses.none_desc": "License packages can be created via the API to gate access to update streams.", + "repo.licenses.issued_keys": "Issued Keys", + "repo.licenses.key_prefix": "Key", + "repo.licenses.licensee": "Licensee", + "repo.licenses.expires": "Expires", + "repo.licenses.never": "Never", "repo.release.draft": "Draft", "repo.release.prerelease": "Pre-Release", "repo.release.stable": "Stable", diff --git a/routers/web/repo/licenses.go b/routers/web/repo/licenses.go new file mode 100644 index 0000000000..c9d4592364 --- /dev/null +++ b/routers/web/repo/licenses.go @@ -0,0 +1,59 @@ +// Copyright 2026 Moko Consulting +// SPDX-License-Identifier: GPL-3.0-or-later + +package repo + +import ( + "net/http" + "time" + + "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licenses" + "git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/templates" + "git.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context" +) + +const tplLicenses templates.TplName = "repo/licenses" + +// LicensePackageDisplay is used in templates. +type LicensePackageDisplay struct { + *licenses.LicensePackage + KeyCount int64 + Created time.Time +} + +// Licenses shows the license packages and keys for a repo. +func Licenses(ctx *context.Context) { + ctx.Data["Title"] = ctx.Tr("repo.licenses") + ctx.Data["PageIsLicenses"] = true + ctx.Data["IsLicensesPage"] = true + + ownerID := ctx.Repo.Repository.OwnerID + + pkgs, err := licenses.ListLicensePackages(ctx, ownerID) + if err != nil { + ctx.ServerError("ListLicensePackages", err) + return + } + + // Build display list with key counts. + var display []LicensePackageDisplay + for _, pkg := range pkgs { + count, _ := licenses.CountKeysByPackage(ctx, pkg.ID) + display = append(display, LicensePackageDisplay{ + LicensePackage: pkg, + KeyCount: count, + Created: time.Unix(int64(pkg.CreatedUnix), 0), + }) + } + ctx.Data["LicensePackages"] = display + + // List all keys for the owner. + keys, err := licenses.ListLicenseKeys(ctx, ownerID) + if err != nil { + ctx.ServerError("ListLicenseKeys", err) + return + } + ctx.Data["LicenseKeys"] = keys + + ctx.HTML(http.StatusOK, tplLicenses) +} diff --git a/routers/web/web.go b/routers/web/web.go index 8ecbb5d70d..f02ce86348 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1501,6 +1501,12 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { }, optSignIn, context.RepoAssignment) // end "/{username}/{reponame}": update server + // "/{username}/{reponame}": licenses page + m.Group("/{username}/{reponame}", func() { + m.Get("/licenses", repo.Licenses) + }, reqSignIn, context.RepoAssignment, reqRepoReleaseReader) + // end "/{username}/{reponame}": licenses + m.Group("/{username}/{reponame}", func() { // to maintain compatibility with old attachments m.Get("/attachments/{uuid}", webAuth.AllowBasic, webAuth.AllowOAuth2, repo.GetAttachment) }, optSignIn, context.RepoAssignment) diff --git a/services/context/repo.go b/services/context/repo.go index 78052717e9..9cdef69a15 100644 --- a/services/context/repo.go +++ b/services/context/repo.go @@ -18,6 +18,7 @@ import ( "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db" git_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/git" issues_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/issues" + licenses_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licenses" access_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/perm/access" repo_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo" unit_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/unit" @@ -605,6 +606,13 @@ func repoAssignmentPrepareTemplateData(ctx *Context, data *repoAssignmentPrepare return } + // Check if license packages exist for this repo's owner (enables Licenses tab). + numLicensePackages, _ := db.Count[licenses_model.LicensePackage](ctx, licenses_model.FindLicensePackageOptions{ + OwnerID: repo.OwnerID, + }) + ctx.Data["NumLicensePackages"] = numLicensePackages + ctx.Data["EnableLicenses"] = numLicensePackages > 0 + ctx.Data["Title"] = repo.Owner.Name + "/" + repo.Name ctx.Data["PageTitleCommon"] = repo.Name + " - " + setting.AppName ctx.Data["Repository"] = repo diff --git a/templates/repo/header.tmpl b/templates/repo/header.tmpl index c522bf3865..27e261a28d 100644 --- a/templates/repo/header.tmpl +++ b/templates/repo/header.tmpl @@ -128,6 +128,15 @@ {{end}} + {{if .EnableLicenses}} + + {{svg "octicon-key"}} {{ctx.Locale.Tr "repo.licenses"}} + {{if .NumLicensePackages}} + {{CountFmt .NumLicensePackages}} + {{end}} + + {{end}} + {{$projectsUnit := .Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypeProjects}} {{if and (not ctx.Consts.RepoUnitTypeProjects.UnitGlobalDisabled) (.Permission.CanRead ctx.Consts.RepoUnitTypeProjects) ($projectsUnit.ProjectsConfig.IsProjectsAllowed "repo")}} diff --git a/templates/repo/licenses.tmpl b/templates/repo/licenses.tmpl new file mode 100644 index 0000000000..7a927252ba --- /dev/null +++ b/templates/repo/licenses.tmpl @@ -0,0 +1,68 @@ +{{template "base/head" .}} +
+ {{template "repo/header" .}} +
+

+ {{svg "octicon-key" 16}} {{ctx.Locale.Tr "repo.licenses"}} +

+
+ {{if .LicensePackages}} +
{{ctx.Locale.Tr "repo.licenses.packages"}}
+ + + + + + + + + + + + {{range .LicensePackages}} + + + + + + + + {{end}} + +
{{ctx.Locale.Tr "repo.licenses.package_name"}}{{ctx.Locale.Tr "repo.licenses.duration"}}{{ctx.Locale.Tr "repo.licenses.channels"}}{{ctx.Locale.Tr "repo.licenses.keys_issued"}}{{ctx.Locale.Tr "repo.licenses.status"}}
{{.Name}}{{if .Description}}
{{.Description}}{{end}}
{{if eq .DurationDays 0}}{{ctx.Locale.Tr "repo.licenses.lifetime"}}{{else}}{{.DurationDays}} {{ctx.Locale.Tr "repo.licenses.days"}}{{end}}{{if .AllowedChannels}}{{.AllowedChannels}}{{else}}{{ctx.Locale.Tr "repo.licenses.all_channels"}}{{end}}{{.KeyCount}}{{if .IsActive}}{{ctx.Locale.Tr "repo.licenses.active"}}{{else}}{{ctx.Locale.Tr "repo.licenses.inactive"}}{{end}}
+ {{else}} +
+ {{svg "octicon-key" 48}} +

{{ctx.Locale.Tr "repo.licenses.none"}}

+

{{ctx.Locale.Tr "repo.licenses.none_desc"}}

+
+ {{end}} + + {{if .LicenseKeys}} +
+
{{ctx.Locale.Tr "repo.licenses.issued_keys"}}
+ + + + + + + + + + + {{range .LicenseKeys}} + + + + + + + {{end}} + +
{{ctx.Locale.Tr "repo.licenses.key_prefix"}}{{ctx.Locale.Tr "repo.licenses.licensee"}}{{ctx.Locale.Tr "repo.licenses.expires"}}{{ctx.Locale.Tr "repo.licenses.status"}}
{{.KeyPrefix}}{{.LicenseeName}}{{if .LicenseeEmail}} ({{.LicenseeEmail}}){{end}}{{if eq .ExpiresUnix 0}}{{ctx.Locale.Tr "repo.licenses.never"}}{{else}}{{DateTime "short" (.ExpiresUnix.AsTime)}}{{end}}{{if .IsActive}}{{ctx.Locale.Tr "repo.licenses.active"}}{{else}}{{ctx.Locale.Tr "repo.licenses.inactive"}}{{end}}
+ {{end}} +
+
+
+{{template "base/footer" .}} -- 2.52.0 From c6f42487b5cc0aef35a1ba78cad37d4647a0d0b9 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sat, 30 May 2026 21:11:44 -0500 Subject: [PATCH 11/15] fix(templates): use DateUtils.TimeSince instead of DateTime Fix template error: function "DateTime" not defined. Use DateUtils.TimeSince which is the correct template function. Co-Authored-By: Claude Opus 4.6 (1M context) --- templates/repo/licenses.tmpl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/templates/repo/licenses.tmpl b/templates/repo/licenses.tmpl index 7a927252ba..99ebb61641 100644 --- a/templates/repo/licenses.tmpl +++ b/templates/repo/licenses.tmpl @@ -55,7 +55,7 @@ {{.KeyPrefix}} {{.LicenseeName}}{{if .LicenseeEmail}} ({{.LicenseeEmail}}){{end}} - {{if eq .ExpiresUnix 0}}{{ctx.Locale.Tr "repo.licenses.never"}}{{else}}{{DateTime "short" (.ExpiresUnix.AsTime)}}{{end}} + {{if eq .ExpiresUnix 0}}{{ctx.Locale.Tr "repo.licenses.never"}}{{else}}{{DateUtils.TimeSince .ExpiresUnix}}{{end}} {{if .IsActive}}{{ctx.Locale.Tr "repo.licenses.active"}}{{else}}{{ctx.Locale.Tr "repo.licenses.inactive"}}{{end}} {{end}} -- 2.52.0 From 3f29562938fdb29e7ab07e2af485a16880d8b6a2 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sat, 30 May 2026 21:18:38 -0500 Subject: [PATCH 12/15] fix(routes): use optSignIn for licenses page The licenses page was using reqSignIn which blocks API token access and redirects to login. Use optSignIn so the page is accessible. Co-Authored-By: Claude Opus 4.6 (1M context) --- routers/web/web.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/routers/web/web.go b/routers/web/web.go index f02ce86348..481c35c647 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1504,7 +1504,7 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { // "/{username}/{reponame}": licenses page m.Group("/{username}/{reponame}", func() { m.Get("/licenses", repo.Licenses) - }, reqSignIn, context.RepoAssignment, reqRepoReleaseReader) + }, optSignIn, context.RepoAssignment) // end "/{username}/{reponame}": licenses m.Group("/{username}/{reponame}", func() { // to maintain compatibility with old attachments -- 2.52.0 From 30197e4e978485a3b2d758e5fb31a8b3fc10c43b Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sat, 30 May 2026 21:27:12 -0500 Subject: [PATCH 13/15] feat(licenses): web UI for package creation, key generation, and revocation Add full license management web forms to the Licenses page: - "New Package" form: name, description, duration, max sites, channels - "Generate Key" button per package: creates key with auto-expiry - "Revoke" button per key: deactivates the key - New key display: shows raw key once with copy instructions - Update Feed URLs section: copyable Joomla/Dolibarr endpoint URLs - Admin-only controls: forms only visible to repo admins Ref #239 Co-Authored-By: Claude Opus 4.6 (1M context) --- options/locale/locale_en-US.json | 12 ++++ routers/web/repo/licenses.go | 114 ++++++++++++++++++++++++++++++- routers/web/web.go | 7 +- templates/repo/licenses.tmpl | 111 ++++++++++++++++++++++++++++-- 4 files changed, 233 insertions(+), 11 deletions(-) diff --git a/options/locale/locale_en-US.json b/options/locale/locale_en-US.json index ce83b50bc5..3b02f2bf72 100644 --- a/options/locale/locale_en-US.json +++ b/options/locale/locale_en-US.json @@ -2632,6 +2632,18 @@ "repo.licenses.licensee": "Licensee", "repo.licenses.expires": "Expires", "repo.licenses.never": "Never", + "repo.licenses.new_package": "New Package", + "repo.licenses.description": "Description", + "repo.licenses.max_sites": "Max Sites", + "repo.licenses.channels_help": "Comma-separated channel names (e.g. stable,release-candidate). Leave empty for all channels.", + "repo.licenses.create_package": "Create Package", + "repo.licenses.package_created": "License package created successfully.", + "repo.licenses.generate_key": "Generate Key", + "repo.licenses.key_created": "License Key Created", + "repo.licenses.key_created_copy": "Copy this key now. It will not be shown again.", + "repo.licenses.revoke": "Revoke", + "repo.licenses.key_revoked": "License key revoked.", + "repo.licenses.update_feeds": "Update Feed URLs", "repo.release.draft": "Draft", "repo.release.prerelease": "Pre-Release", "repo.release.stable": "Stable", diff --git a/routers/web/repo/licenses.go b/routers/web/repo/licenses.go index c9d4592364..052865bbca 100644 --- a/routers/web/repo/licenses.go +++ b/routers/web/repo/licenses.go @@ -5,10 +5,12 @@ package repo import ( "net/http" + "strconv" "time" "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licenses" "git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/templates" + "git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil" "git.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context" ) @@ -26,6 +28,7 @@ func Licenses(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("repo.licenses") ctx.Data["PageIsLicenses"] = true ctx.Data["IsLicensesPage"] = true + ctx.Data["IsRepoAdmin"] = ctx.Repo.IsAdmin() ownerID := ctx.Repo.Repository.OwnerID @@ -35,7 +38,6 @@ func Licenses(ctx *context.Context) { return } - // Build display list with key counts. var display []LicensePackageDisplay for _, pkg := range pkgs { count, _ := licenses.CountKeysByPackage(ctx, pkg.ID) @@ -47,7 +49,6 @@ func Licenses(ctx *context.Context) { } ctx.Data["LicensePackages"] = display - // List all keys for the owner. keys, err := licenses.ListLicenseKeys(ctx, ownerID) if err != nil { ctx.ServerError("ListLicenseKeys", err) @@ -57,3 +58,112 @@ func Licenses(ctx *context.Context) { ctx.HTML(http.StatusOK, tplLicenses) } + +// LicensesCreatePackage handles POST to create a new license package. +func LicensesCreatePackage(ctx *context.Context) { + name := ctx.FormString("name") + if name == "" { + ctx.Flash.Error("Package name is required") + ctx.Redirect(ctx.Repo.RepoLink + "/licenses") + return + } + + durationDays, _ := strconv.Atoi(ctx.FormString("duration_days")) + maxSites, _ := strconv.Atoi(ctx.FormString("max_sites")) + + pkg := &licenses.LicensePackage{ + OwnerID: ctx.Repo.Repository.OwnerID, + Name: name, + Description: ctx.FormString("description"), + DurationDays: durationDays, + MaxSites: maxSites, + AllowedChannels: ctx.FormString("allowed_channels"), + RepoScope: "all", + IsActive: true, + } + + if err := licenses.CreateLicensePackage(ctx, pkg); err != nil { + ctx.ServerError("CreateLicensePackage", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.licenses.package_created")) + ctx.Redirect(ctx.Repo.RepoLink + "/licenses") +} + +// LicensesGenerateKey handles POST to generate a new key from a package. +func LicensesGenerateKey(ctx *context.Context) { + packageID, _ := strconv.ParseInt(ctx.FormString("package_id"), 10, 64) + if packageID == 0 { + ctx.Flash.Error("Invalid package") + ctx.Redirect(ctx.Repo.RepoLink + "/licenses") + return + } + + pkg, err := licenses.GetLicensePackageByID(ctx, packageID) + if err != nil { + ctx.ServerError("GetLicensePackageByID", err) + return + } + + key := &licenses.LicenseKey{ + PackageID: packageID, + OwnerID: ctx.Repo.Repository.OwnerID, + IsActive: true, + } + + // Auto-calculate expiry from package duration. + if pkg.DurationDays > 0 { + expires := time.Now().AddDate(0, 0, pkg.DurationDays) + key.ExpiresUnix = timeutil.TimeStamp(expires.Unix()) + } + + rawKey, err := licenses.CreateLicenseKey(ctx, key) + if err != nil { + ctx.ServerError("CreateLicenseKey", err) + return + } + + ctx.Data["Title"] = ctx.Tr("repo.licenses") + ctx.Data["PageIsLicenses"] = true + ctx.Data["IsLicensesPage"] = true + ctx.Data["IsRepoAdmin"] = ctx.Repo.IsAdmin() + ctx.Data["NewKeyCreated"] = rawKey + + // Re-render the page with the new key displayed. + ownerID := ctx.Repo.Repository.OwnerID + pkgs, _ := licenses.ListLicensePackages(ctx, ownerID) + var display []LicensePackageDisplay + for _, p := range pkgs { + count, _ := licenses.CountKeysByPackage(ctx, p.ID) + display = append(display, LicensePackageDisplay{ + LicensePackage: p, + KeyCount: count, + Created: time.Unix(int64(p.CreatedUnix), 0), + }) + } + ctx.Data["LicensePackages"] = display + keys, _ := licenses.ListLicenseKeys(ctx, ownerID) + ctx.Data["LicenseKeys"] = keys + + ctx.HTML(http.StatusOK, tplLicenses) +} + +// LicensesRevokeKey handles POST to revoke a license key. +func LicensesRevokeKey(ctx *context.Context) { + keyID := ctx.PathParamInt64("id") + key, err := licenses.GetLicenseKeyByID(ctx, keyID) + if err != nil { + ctx.ServerError("GetLicenseKeyByID", err) + return + } + + key.IsActive = false + if err := licenses.UpdateLicenseKey(ctx, key); err != nil { + ctx.ServerError("UpdateLicenseKey", err) + return + } + + ctx.Flash.Success(ctx.Tr("repo.licenses.key_revoked")) + ctx.Redirect(ctx.Repo.RepoLink + "/licenses") +} diff --git a/routers/web/web.go b/routers/web/web.go index 481c35c647..2719d9a09d 100644 --- a/routers/web/web.go +++ b/routers/web/web.go @@ -1502,8 +1502,11 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) { // end "/{username}/{reponame}": update server // "/{username}/{reponame}": licenses page - m.Group("/{username}/{reponame}", func() { - m.Get("/licenses", repo.Licenses) + m.Group("/{username}/{reponame}/licenses", func() { + m.Get("", repo.Licenses) + m.Post("/packages", repo.LicensesCreatePackage) + m.Post("/keys/generate", repo.LicensesGenerateKey) + m.Post("/keys/{id}/revoke", repo.LicensesRevokeKey) }, optSignIn, context.RepoAssignment) // end "/{username}/{reponame}": licenses diff --git a/templates/repo/licenses.tmpl b/templates/repo/licenses.tmpl index 99ebb61641..c5d12a9cd5 100644 --- a/templates/repo/licenses.tmpl +++ b/templates/repo/licenses.tmpl @@ -2,12 +2,61 @@
{{template "repo/header" .}}
-

- {{svg "octicon-key" 16}} {{ctx.Locale.Tr "repo.licenses"}} + + {{if .NewKeyCreated}} +
+
{{ctx.Locale.Tr "repo.licenses.key_created"}}
+

{{ctx.Locale.Tr "repo.licenses.key_created_copy"}}

+ {{.NewKeyCreated}} +
+ {{end}} + + {{/* ── License Packages ── */}} +

+ {{svg "octicon-key" 16}} {{ctx.Locale.Tr "repo.licenses.packages"}} + {{if .IsRepoAdmin}} + + {{end}}

+ {{if .IsRepoAdmin}} +
+
+ {{.CsrfTokenHtml}} +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +

{{ctx.Locale.Tr "repo.licenses.channels_help"}}

+
+
+ +
+
+
+ {{end}} + {{if .LicensePackages}} -
{{ctx.Locale.Tr "repo.licenses.packages"}}
@@ -16,6 +65,7 @@ + {{if .IsRepoAdmin}}{{end}} @@ -26,6 +76,17 @@ + {{if $.IsRepoAdmin}} + + {{end}} {{end}} @@ -37,10 +98,14 @@

{{ctx.Locale.Tr "repo.licenses.none_desc"}}

{{end}} + - {{if .LicenseKeys}} -
-
{{ctx.Locale.Tr "repo.licenses.issued_keys"}}
+ {{/* ── Issued Keys ── */}} + {{if .LicenseKeys}} +

+ {{svg "octicon-lock" 16}} {{ctx.Locale.Tr "repo.licenses.issued_keys"}} +

+
{{ctx.Locale.Tr "repo.licenses.channels"}} {{ctx.Locale.Tr "repo.licenses.keys_issued"}} {{ctx.Locale.Tr "repo.licenses.status"}}
{{if .AllowedChannels}}{{.AllowedChannels}}{{else}}{{ctx.Locale.Tr "repo.licenses.all_channels"}}{{end}} {{.KeyCount}} {{if .IsActive}}{{ctx.Locale.Tr "repo.licenses.active"}}{{else}}{{ctx.Locale.Tr "repo.licenses.inactive"}}{{end}} +
+ {{$.CsrfTokenHtml}} + + +
+
@@ -48,6 +113,7 @@ + {{if .IsRepoAdmin}}{{end}} @@ -57,11 +123,42 @@ + {{if $.IsRepoAdmin}} + + {{end}} {{end}}
{{ctx.Locale.Tr "repo.licenses.licensee"}} {{ctx.Locale.Tr "repo.licenses.expires"}} {{ctx.Locale.Tr "repo.licenses.status"}}
{{.LicenseeName}}{{if .LicenseeEmail}} ({{.LicenseeEmail}}){{end}} {{if eq .ExpiresUnix 0}}{{ctx.Locale.Tr "repo.licenses.never"}}{{else}}{{DateUtils.TimeSince .ExpiresUnix}}{{end}} {{if .IsActive}}{{ctx.Locale.Tr "repo.licenses.active"}}{{else}}{{ctx.Locale.Tr "repo.licenses.inactive"}}{{end}} +
+ {{$.CsrfTokenHtml}} + +
+
- {{end}} +
+ {{end}} + + {{/* ── Update Feed Info ── */}} +

+ {{svg "octicon-rss" 16}} {{ctx.Locale.Tr "repo.licenses.update_feeds"}} +

+
+
+ +
+ + +
+
+
+ +
+ + +
+
-- 2.52.0 From a61cdbe2f1ac0a1b0cd77bcdeaaffbc6119bc494 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sat, 30 May 2026 21:33:34 -0500 Subject: [PATCH 14/15] fix: use ctx.Repo.Permission.IsAdmin() for license admin checks Co-Authored-By: Claude Opus 4.6 (1M context) --- routers/web/repo/licenses.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/routers/web/repo/licenses.go b/routers/web/repo/licenses.go index 052865bbca..6411f2354e 100644 --- a/routers/web/repo/licenses.go +++ b/routers/web/repo/licenses.go @@ -28,7 +28,7 @@ func Licenses(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("repo.licenses") ctx.Data["PageIsLicenses"] = true ctx.Data["IsLicensesPage"] = true - ctx.Data["IsRepoAdmin"] = ctx.Repo.IsAdmin() + ctx.Data["IsRepoAdmin"] = ctx.Repo.Permission.IsAdmin() ownerID := ctx.Repo.Repository.OwnerID @@ -127,7 +127,7 @@ func LicensesGenerateKey(ctx *context.Context) { ctx.Data["Title"] = ctx.Tr("repo.licenses") ctx.Data["PageIsLicenses"] = true ctx.Data["IsLicensesPage"] = true - ctx.Data["IsRepoAdmin"] = ctx.Repo.IsAdmin() + ctx.Data["IsRepoAdmin"] = ctx.Repo.Permission.IsAdmin() ctx.Data["NewKeyCreated"] = rawKey // Re-render the page with the new key displayed. -- 2.52.0 From 6dc2c1dec74421e62efbc2ca09b6d44c90687f06 Mon Sep 17 00:00:00 2001 From: Jonathan Miller Date: Sat, 30 May 2026 21:39:10 -0500 Subject: [PATCH 15/15] fix(templates): use AppSubUrl+RepoLink for update feed URLs Co-Authored-By: Claude Opus 4.6 (1M context) --- templates/repo/licenses.tmpl | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/templates/repo/licenses.tmpl b/templates/repo/licenses.tmpl index c5d12a9cd5..5a9605430d 100644 --- a/templates/repo/licenses.tmpl +++ b/templates/repo/licenses.tmpl @@ -148,14 +148,14 @@
- +
- +
-- 2.52.0