Compare commits

..

136 Commits

Author SHA1 Message Date
gitea-actions[bot] 508185f7ad chore(release): build 05.14.00 [skip ci] 2026-05-31 03:45:25 +00:00
jmiller c8ba0647d3 Merge pull request 'chore: merge dev into main' (#283) from dev into main
Deploy MokoGitea / deploy (push) Successful in 5m56s
2026-05-31 03:44:31 +00:00
jmiller d55da332cf Merge branch 'main' into dev
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Branch Policy Check / Verify merge target (pull_request) Successful in 3s
Universal: PR Check / Branch Policy (pull_request) Successful in 3s
Universal: PR Check / Validate PR (pull_request) Failing after 10s
Universal: Auto Version Bump / Version Bump (push) Failing after 9s
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
PR RC Release / Build RC Release (pull_request) Successful in 32s
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 5m29s
2026-05-31 03:44:16 +00:00
jmiller a04e237f17 Merge pull request 'feat(licenses): org settings, copyable keys, master keys' (#282) from feat/org-licensing into dev
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: Auto Version Bump / Version Bump (push) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been skipped
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Failing after 7s
Universal: Build & Release / Promote to RC (pull_request) Failing after 12s
PR RC Release / Build RC Release (pull_request) Successful in 28s
2026-05-31 03:44:06 +00:00
jmiller e7cc4c120f Merge branch 'dev' into feat/org-licensing
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
PR RC Release / Build RC Release (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Branch Cleanup / Delete merged branch (pull_request) Successful in 2s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || 'development' }}) (pull_request) Successful in 1m13s
2026-05-31 03:43:55 +00:00
jmiller aa54f3834e chore: sync updates.xml 05.13.00 from main [skip ci] 2026-05-31 03:38:14 +00:00
gitea-actions[bot] 4d93f23037 chore: update channels for 05.13.00 [skip ci] 2026-05-31 03:37:46 +00:00
gitea-actions[bot] d7e2ffd02b chore(release): build 05.13.00 [skip ci] 2026-05-31 03:37:12 +00:00
jmiller b9d81ca5c5 Merge pull request 'chore: merge dev into main — URL fix' (#281) from dev into main
Deploy MokoGitea / deploy (push) Successful in 4m34s
2026-05-31 03:36:12 +00:00
jmiller 59c62dc687 Merge branch 'main' into dev
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Branch Policy Check / Verify merge target (pull_request) Successful in 5s
Universal: PR Check / Branch Policy (pull_request) Successful in 4s
Universal: PR Check / Validate PR (pull_request) Failing after 14s
Universal: Auto Version Bump / Version Bump (push) Failing after 13s
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 4m8s
2026-05-31 03:35:42 +00:00
jmiller b14ffa083e Merge pull request 'fix(ui): full domain URL in update feed fields' (#280) from fix/update-feed-urls into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been skipped
Branch Policy Check / Verify merge target (pull_request) Successful in 3s
Universal: PR Check / Branch Policy (pull_request) Successful in 3s
Universal: PR Check / Validate PR (pull_request) Failing after 20s
Universal: Build & Release / Promote to RC (pull_request) Failing after 30s
PR RC Release / Build RC Release (pull_request) Successful in 50s
2026-05-31 03:34:56 +00:00
Jonathan Miller 2cc57bbbbc fix(ui): show full domain URL in update feed copy fields
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Branch Policy Check / Verify merge target (pull_request) Successful in 3s
PR RC Release / Build RC Release (pull_request) Successful in 5s
Universal: PR Check / Validate PR (pull_request) Failing after 17s
Branch Cleanup / Delete merged branch (pull_request) Successful in 4s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || 'development' }}) (pull_request) Successful in 1m40s
Use .Repository.HTMLURL instead of AppSubUrl+RepoLink so the
copyable update feed URLs include the full domain name.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-30 22:34:12 -05:00
jmiller 3cd7687c06 chore: sync updates.xml 05.12.00 from main [skip ci] 2026-05-31 03:32:48 +00:00
gitea-actions[bot] c3b2643b0c chore: update channels for 05.12.00 [skip ci] 2026-05-31 03:32:46 +00:00
gitea-actions[bot] 0159e567e2 chore(release): build 05.12.00 [skip ci] 2026-05-31 03:32:10 +00:00
jmiller f194b204b4 Merge pull request 'chore: merge dev into main — org licenses + master keys' (#279) from dev into main
Deploy MokoGitea / deploy (push) Successful in 5m50s
2026-05-31 03:31:24 +00:00
jmiller f118f084ce Merge branch 'main' into dev
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Branch Policy Check / Verify merge target (pull_request) Successful in 2s
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Universal: Auto Version Bump / Version Bump (push) Failing after 8s
Universal: PR Check / Validate PR (pull_request) Failing after 8s
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
PR RC Release / Build RC Release (pull_request) Successful in 35s
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 5m30s
2026-05-31 03:31:09 +00:00
jmiller 2821c35326 Merge pull request 'feat(licenses): org licenses page + master keys + menu fixes' (#278) from feat/org-licensing into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been skipped
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 9s
Universal: Build & Release / Promote to RC (pull_request) Failing after 17s
PR RC Release / Build RC Release (pull_request) Successful in 29s
2026-05-31 03:30:59 +00:00
Jonathan Miller 5b02cf188e feat(licenses): org-level licenses page, master keys, and menu fixes
Branch Cleanup / Delete merged branch (pull_request) Successful in 2s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || 'development' }}) (pull_request) Successful in 1m13s
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
PR RC Release / Build RC Release (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Failing after 5s
Major licensing UI improvements:
- Org-level Licenses tab in org menu (visible to org members)
- Org-level Licenses page with full CRUD (packages, keys, revoke)
- Auto-created master key: when admin first visits Licenses page,
  a Master (Internal) package + key is auto-generated
- Master keys marked with orange "Master" badge in key list
- Revoking a master key auto-creates a new one on next visit
- Fixed "New Package" button toggle (was using tw-hidden class
  that didn't work, now uses style.display)
- IsRepoAdmin set as context data for template access
- Master keys have IsInternal=true, lifetime, all channels

Ref #239

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-30 22:30:33 -05:00
Jonathan Miller 689173ecab feat(licenses): auto-create master key for org/repo
When an admin first visits the Licenses page, a master license package
and key are automatically created:
- Master package: lifetime, unlimited, all channels, all repos
- Master key: IsInternal=true, never expires
- Raw key shown once with copy instructions
- If master key is revoked, a new one is created on next visit

The master key is always present — revoking it and revisiting the page
generates a fresh one.

Ref #239

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-30 22:26:24 -05:00
jmiller b2fe44fbc3 chore: sync updates.xml 05.11.00 from main [skip ci] 2026-05-31 03:17:54 +00:00
gitea-actions[bot] 0e89ef9944 chore: update channels for 05.11.00 [skip ci] 2026-05-31 03:17:53 +00:00
gitea-actions[bot] 522dadecf0 chore(release): build 05.11.00 [skip ci] 2026-05-31 03:17:24 +00:00
jmiller f1b9bb2f3d Merge pull request 'chore: merge dev into main — licenses tab fix v2' (#277) from dev into main
Deploy MokoGitea / deploy (push) Failing after 4m28s
2026-05-31 03:16:44 +00:00
jmiller 7bbaf218d5 Merge branch 'main' into dev
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Branch Policy Check / Verify merge target (pull_request) Successful in 2s
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Universal: Auto Version Bump / Version Bump (push) Failing after 8s
Universal: PR Check / Validate PR (pull_request) Failing after 8s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
PR RC Release / Build RC Release (pull_request) Successful in 32s
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 4m11s
2026-05-31 03:16:31 +00:00
jmiller 33a550f838 Merge pull request 'fix(ui): IsRepoAdmin for Licenses tab' (#276) from fix/licenses-tab-v2 into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 6s
PR RC Release / Build RC Release (pull_request) Successful in 26s
2026-05-31 03:16:21 +00:00
Jonathan Miller e29ee5f91b fix(ui): set IsRepoAdmin context data for Licenses tab visibility
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Branch Policy Check / Verify merge target (pull_request) Successful in 2s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
PR RC Release / Build RC Release (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Failing after 4s
Branch Cleanup / Delete merged branch (pull_request) Successful in 1s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || 'development' }}) (pull_request) Successful in 1m9s
The template couldn't call .Permission.IsAdmin() directly. Set
IsRepoAdmin as a context data variable so the template can use it.
Licenses tab now shows for repo admins even without packages.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-30 22:15:39 -05:00
jmiller 984a99188e chore: sync updates.xml 05.10.00 from main [skip ci] 2026-05-31 03:07:16 +00:00
gitea-actions[bot] 92fc77a6d1 chore: update channels for 05.10.00 [skip ci] 2026-05-31 03:07:15 +00:00
gitea-actions[bot] ea411e09be chore(release): build 05.10.00 [skip ci] 2026-05-31 03:06:36 +00:00
jmiller 9b141b39c5 Merge pull request 'chore: merge dev into main — licenses tab fix' (#275) from dev into main
Deploy MokoGitea / deploy (push) Successful in 4m35s
chore: merge dev into main — licenses tab fix (#275)
2026-05-31 03:05:51 +00:00
jmiller 85e4356fce Merge branch 'main' into dev
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Branch Policy Check / Verify merge target (pull_request) Successful in 3s
Universal: PR Check / Branch Policy (pull_request) Successful in 3s
Universal: Auto Version Bump / Version Bump (push) Failing after 13s
Universal: PR Check / Validate PR (pull_request) Failing after 13s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
PR RC Release / Build RC Release (pull_request) Successful in 34s
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 4m13s
2026-05-31 03:05:36 +00:00
jmiller 1654181a9e Merge pull request 'fix(ui): show Licenses tab for admins always' (#274) from fix/licenses-tab-always-admin into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been skipped
Branch Policy Check / Verify merge target (pull_request) Successful in 2s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 9s
Universal: Build & Release / Promote to RC (pull_request) Failing after 14s
PR RC Release / Build RC Release (pull_request) Successful in 28s
2026-05-31 03:05:25 +00:00
Jonathan Miller 282ef8f3e7 fix(ui): show Licenses tab for repo admins even without packages
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
PR RC Release / Build RC Release (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Branch Cleanup / Delete merged branch (pull_request) Successful in 2s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || 'development' }}) (pull_request) Successful in 1m14s
The Licenses tab was hidden when no packages existed, making it
impossible for admins to find the page to create their first package.
Now shows for repo admins always, and for everyone when packages exist.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-30 22:04:44 -05:00
jmiller a34eb53b2a chore: sync updates.xml 05.09.00 from main [skip ci] 2026-05-31 02:47:56 +00:00
gitea-actions[bot] 75d53c11b4 chore: update channels for 05.09.00 [skip ci] 2026-05-31 02:47:52 +00:00
gitea-actions[bot] 8556314468 chore(release): build 05.09.00 [skip ci] 2026-05-31 02:47:16 +00:00
jmiller 22624d662c Merge pull request 'chore: merge dev into main — licenses UI, update server, visibility' (#273) from dev into main
Deploy MokoGitea / deploy (push) Successful in 5m5s
chore: merge dev into main — all features (#273)
2026-05-31 02:46:35 +00:00
jmiller 91646c505b Merge branch 'main' into dev
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Branch Policy Check / Verify merge target (pull_request) Successful in 2s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: Auto Version Bump / Version Bump (push) Failing after 8s
Universal: PR Check / Validate PR (pull_request) Failing after 8s
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
PR RC Release / Build RC Release (pull_request) Successful in 30s
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 4m43s
2026-05-31 02:46:22 +00:00
jmiller b994fcdb9a Merge pull request 'fix(templates): AppSubUrl for feed URLs' (#272) from feat/inline-visibility-settings into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been skipped
Universal: PR Check / Validate PR (pull_request) Failing after 5s
Universal: Build & Release / Promote to RC (pull_request) Failing after 8s
PR RC Release / Build RC Release (pull_request) Successful in 20s
2026-05-31 02:39:45 +00:00
Jonathan Miller 6dc2c1dec7 fix(templates): use AppSubUrl+RepoLink for update feed URLs
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
PR RC Release / Build RC Release (pull_request) Successful in 3s
Branch Cleanup / Delete merged branch (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 7s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || 'development' }}) (pull_request) Successful in 1m17s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-30 21:39:10 -05:00
jmiller 4372e956de Merge pull request 'fix: Permission.IsAdmin for licenses' (#271) from feat/inline-visibility-settings into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
2026-05-31 02:34:00 +00:00
Jonathan Miller a61cdbe2f1 fix: use ctx.Repo.Permission.IsAdmin() for license admin checks
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
PR RC Release / Build RC Release (pull_request) Successful in 3s
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Branch Cleanup / Delete merged branch (pull_request) Successful in 1s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || 'development' }}) (pull_request) Successful in 1m13s
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-30 21:33:34 -05:00
jmiller ac4092fbab Merge pull request 'feat(licenses): web UI for license management' (#270) from feat/inline-visibility-settings into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
2026-05-31 02:27:36 +00:00
Jonathan Miller 30197e4e97 feat(licenses): web UI for package creation, key generation, and revocation
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
PR RC Release / Build RC Release (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Failing after 5s
Branch Cleanup / Delete merged branch (pull_request) Successful in 1s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || 'development' }}) (pull_request) Successful in 1m20s
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) <noreply@anthropic.com>
2026-05-30 21:27:12 -05:00
jmiller 12132486a0 Merge pull request 'fix(routes): use optSignIn for licenses page' (#269) from feat/inline-visibility-settings into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
2026-05-31 02:20:29 +00:00
Jonathan Miller 3f29562938 fix(routes): use optSignIn for licenses page
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
PR RC Release / Build RC Release (pull_request) Successful in 3s
Universal: PR Check / Validate PR (pull_request) Failing after 7s
Branch Cleanup / Delete merged branch (pull_request) Successful in 2s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || 'development' }}) (pull_request) Successful in 1m24s
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) <noreply@anthropic.com>
2026-05-30 21:18:38 -05:00
jmiller 4a931dddab Merge pull request 'fix(templates): use DateUtils.TimeSince in licenses template' (#268) from feat/inline-visibility-settings into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
2026-05-31 02:12:01 +00:00
Jonathan Miller c6f42487b5 fix(templates): use DateUtils.TimeSince instead of DateTime
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
PR RC Release / Build RC Release (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Branch Cleanup / Delete merged branch (pull_request) Successful in 1s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || 'development' }}) (pull_request) Successful in 54s
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) <noreply@anthropic.com>
2026-05-30 21:11:44 -05:00
jmiller b101a2304a Merge pull request 'feat(licenses): add Licenses tab, page, and stream config' (#267) from feat/inline-visibility-settings into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
2026-05-31 02:04:12 +00:00
Jonathan Miller 381952f6d2 feat(licenses): add Licenses tab and page for repos
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
PR RC Release / Build RC Release (pull_request) Successful in 3s
Universal: PR Check / Validate PR (pull_request) Failing after 7s
Branch Cleanup / Delete merged branch (pull_request) Successful in 1s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || 'development' }}) (pull_request) Successful in 54s
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) <noreply@anthropic.com>
2026-05-30 21:03:52 -05:00
jmiller 1c667d9da9 Merge pull request 'feat(updates): org-level default streams with per-repo override' (#266) from feat/inline-visibility-settings into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
2026-05-31 01:50:03 +00:00
Jonathan Miller a88e3f8787 feat(updates): org-level default streams with per-repo override
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
PR RC Release / Build RC Release (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Failing after 5s
Branch Cleanup / Delete merged branch (pull_request) Successful in 1s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || 'development' }}) (pull_request) Successful in 1m29s
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) <noreply@anthropic.com>
2026-05-30 20:49:46 -05:00
jmiller 4012f3bea9 chore: add .mokogitea/workflows/cascade-dev.yml from moko-platform [skip ci] 2026-05-31 01:45:42 +00:00
jmiller 095b78b2a7 chore: add .mokogitea/workflows/cascade-dev.yml from moko-platform [skip ci] 2026-05-31 01:42:13 +00:00
jmiller ff72cd0cb0 Merge pull request 'feat(updates): use full Joomla channel names in update feeds' (#264) from feat/inline-visibility-settings into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
2026-05-31 01:32:01 +00:00
Jonathan Miller 50454db3fb feat(updates): use full Joomla channel names in update feeds
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
PR RC Release / Build RC Release (pull_request) Successful in 3s
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Branch Cleanup / Delete merged branch (pull_request) Successful in 2s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || 'development' }}) (pull_request) Successful in 1m0s
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) <noreply@anthropic.com>
2026-05-30 20:31:43 -05:00
jmiller eab36f26aa Merge pull request 'feat(ui): add Update Feed button on releases page' (#263) from feat/inline-visibility-settings into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
2026-05-31 01:24:10 +00:00
Jonathan Miller 4ce332d031 feat(ui): add Update Feed button on releases page
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
PR RC Release / Build RC Release (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Failing after 7s
Branch Cleanup / Delete merged branch (pull_request) Successful in 1s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || 'development' }}) (pull_request) Successful in 50s
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) <noreply@anthropic.com>
2026-05-30 20:23:51 -05:00
jmiller 1b1ad35df4 Merge pull request 'fix(api): set IsActive=true when creating license packages' (#262) from feat/inline-visibility-settings into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
2026-05-31 01:19:13 +00:00
Jonathan Miller 426cffc224 fix(api): set IsActive=true when creating license packages
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
PR RC Release / Build RC Release (pull_request) Successful in 3s
Universal: PR Check / Validate PR (pull_request) Failing after 7s
Branch Cleanup / Delete merged branch (pull_request) Successful in 1s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || 'development' }}) (pull_request) Successful in 53s
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) <noreply@anthropic.com>
2026-05-30 20:18:11 -05:00
jmiller 0716ad0edd chore: sync CONTRIBUTING.md from moko-platform [skip ci] 2026-05-31 01:10:19 +00:00
jmiller 0572e6a164 Merge pull request 'fix(api): set IsActive=true when creating license keys' (#261) from feat/inline-visibility-settings into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
2026-05-31 01:09:00 +00:00
Jonathan Miller 4e51f48285 fix(api): set IsActive=true when creating license keys
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
PR RC Release / Build RC Release (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Branch Cleanup / Delete merged branch (pull_request) Successful in 2s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || 'development' }}) (pull_request) Successful in 56s
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) <noreply@anthropic.com>
2026-05-30 20:08:39 -05:00
jmiller aa56925bba Merge pull request 'feat(settings): releases visibility help text + issues dropdown' (#260) from feat/inline-visibility-settings into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
2026-05-31 00:59:26 +00:00
Jonathan Miller fc895aa70d feat(settings): add help text for releases visibility and update feeds
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
PR RC Release / Build RC Release (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Failing after 5s
Branch Cleanup / Delete merged branch (pull_request) Successful in 1s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || 'development' }}) (pull_request) Successful in 49s
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) <noreply@anthropic.com>
2026-05-30 19:59:02 -05:00
jmiller 1db8435737 Merge pull request 'feat(settings): add visibility dropdown to issues unit' (#259) from feat/inline-visibility-settings into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
2026-05-31 00:50:44 +00:00
Jonathan Miller 71a486b534 feat(settings): add visibility dropdown to issues unit
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Branch Policy Check / Verify merge target (pull_request) Successful in 2s
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
PR RC Release / Build RC Release (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Branch Cleanup / Delete merged branch (pull_request) Successful in 1s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || 'development' }}) (pull_request) Successful in 1m5s
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) <noreply@anthropic.com>
2026-05-30 19:50:13 -05:00
jmiller 90b9af6e3e Merge pull request 'feat(settings): inline visibility controls on repo settings page' (#258) from feat/inline-visibility-settings into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
feat(settings): inline visibility controls on repo settings page (#258)
2026-05-31 00:42:06 +00:00
Jonathan Miller a99af91ab4 feat(settings): inline visibility controls on repo settings page
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
PR RC Release / Build RC Release (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Failing after 5s
Branch Cleanup / Delete merged branch (pull_request) Successful in 2s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || 'development' }}) (pull_request) Successful in 58s
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) <noreply@anthropic.com>
2026-05-30 19:35:56 -05:00
gitea-actions[bot] 0eb81f9c1a chore(release): build 05.08.00 [skip ci] 2026-05-30 21:49:04 +00:00
jmiller 6498459e49 Merge pull request 'chore: merge dev into main — all build fixes' (#256) from dev into main
Deploy MokoGitea / deploy (push) Failing after 7m17s
chore: merge dev into main — all build fixes (#256)
2026-05-30 21:47:57 +00:00
jmiller 2b82312b4e Merge branch 'main' into dev
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Branch Policy Check / Verify merge target (pull_request) Successful in 2s
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Failing after 11s
Universal: Auto Version Bump / Version Bump (push) Failing after 10s
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
PR RC Release / Build RC Release (pull_request) Successful in 47s
Universal: Build & Release / Build & Release Pipeline (pull_request) Failing after 17m51s
2026-05-30 21:47:41 +00:00
jmiller 8808dfc3ce Merge pull request 'fix(api): fix route registration for license key API' (#255) from fix/api-route-registration into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been skipped
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Failing after 10s
Universal: Build & Release / Promote to RC (pull_request) Failing after 14s
PR RC Release / Build RC Release (pull_request) Successful in 23s
fix(api): fix route registration for license key API (#255)
2026-05-30 21:44:19 +00:00
Jonathan Miller 470364e50c fix(api): fix route registration — reqAdmin and m.Delete pattern
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
PR RC Release / Build RC Release (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Failing after 5s
Branch Cleanup / Delete merged branch (pull_request) Successful in 1s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || 'development' }}) (pull_request) Successful in 52s
Fix two compilation errors in license key API routes:
- reqRepoAdmin() does not exist, use reqAdmin() instead
- m.Delete(handler) needs route pattern: m.Delete("", handler)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-30 16:42:22 -05:00
Jonathan Miller 69ad436ebb fix(api): fix route registration errors in license key API
Deploy MokoGitea / deploy (push) Failing after 20m27s
Fix reqRepoAdmin() -> reqAdmin() and m.Delete(handler) -> m.Delete("", handler)
to match the Gitea API route registration patterns.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-30 16:17:37 -05:00
jmiller 65c5e3d213 Merge pull request 'fix(api): fix compilation errors in license key handlers' (#254) from fix/api-build-errors into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
Deploy MokoGitea / deploy (push) Failing after 5m1s
2026-05-30 21:08:18 +00:00
Jonathan Miller d40c8e1b85 fix(api): fix compilation errors in license key API handlers
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Branch Policy Check / Verify merge target (pull_request) Successful in 2s
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
PR RC Release / Build RC Release (pull_request) Successful in 3s
Universal: PR Check / Validate PR (pull_request) Failing after 9s
Branch Cleanup / Delete merged branch (pull_request) Successful in 2s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || 'development' }}) (pull_request) Successful in 1m18s
Replace ctx.Bind/ctx.APIErrorValidation (which don't exist on
APIContext) with web.GetForm() pattern used by all other API handlers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-30 15:56:54 -05:00
jmiller 39c373975e Merge branch 'main' into dev
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Branch Policy Check / Verify merge target (pull_request) Successful in 3s
Universal: PR Check / Branch Policy (pull_request) Successful in 3s
Universal: Auto Version Bump / Version Bump (push) Failing after 15s
Universal: PR Check / Validate PR (pull_request) Failing after 14s
PR RC Release / Build RC Release (pull_request) Successful in 47s
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been skipped
Deploy MokoGitea / deploy (push) Failing after 3m35s
2026-05-30 20:16:29 +00:00
jmiller b14fcb11f9 Merge pull request 'fix(ci): pre-deploy cleanup for memory' (#252) from fix/deploy-cleanup into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been skipped
Branch Policy Check / Verify merge target (pull_request) Successful in 2s
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Failing after 14s
Universal: Build & Release / Promote to RC (pull_request) Failing after 21s
PR RC Release / Build RC Release (pull_request) Successful in 50s
2026-05-30 20:16:18 +00:00
Jonathan Miller 60a686ce63 fix(ci): pre-deploy cleanup — clear Docker cache and swap before build
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
PR RC Release / Build RC Release (pull_request) Successful in 2s
Branch Cleanup / Delete merged branch (pull_request) Successful in 2s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || 'development' }}) (pull_request) Successful in 1m49s
Add automatic cleanup step before Docker build in deploy workflow:
- Prune Docker build cache (can accumulate 30+ GB)
- Remove unused Docker images
- Clear swap to prevent thrashing during Go compilation

Fixes deploy failures caused by server memory exhaustion.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-30 14:30:44 -05:00
jmiller 17ac356100 Merge pull request 'feat(updates): license key API + all remaining phases (Phase 4-6)' (#251) from feat/update-server-phase4-6 into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
feat(updates): license key API (Phase 4) (#251)
2026-05-30 19:09:05 +00:00
Jonathan Miller 68845abd59 feat(updates): license key management API endpoints (Phase 4)
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
PR RC Release / Build RC Release (pull_request) Successful in 3s
Universal: PR Check / Validate PR (pull_request) Failing after 7s
Branch Cleanup / Delete merged branch (pull_request) Successful in 3s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || 'development' }}) (pull_request) Successful in 49s
Add REST API for managing license packages and keys:

- GET/POST /api/v1/repos/{owner}/{repo}/license-packages
- GET/POST /api/v1/repos/{owner}/{repo}/license-keys
- DELETE /api/v1/repos/{owner}/{repo}/license-keys/{id}
- GET /api/v1/repos/{owner}/{repo}/license-keys/{id}/usage

API structs for create/edit/response, with raw key only returned on
creation. Requires repo admin permissions.

Ref #239

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-30 14:08:35 -05:00
gitea-actions[bot] ba0fdf3df1 chore(release): build 05.07.00 [skip ci] 2026-05-30 18:59:33 +00:00
jmiller ba0b17d9b5 Merge pull request 'chore: merge dev into main - startup fix' (#250) from dev into main
Deploy MokoGitea / deploy (push) Failing after 8m7s
chore: merge dev into main - startup fix (#250)
2026-05-30 18:57:42 +00:00
jmiller 29341b2b9b Merge branch 'main' into dev
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Branch Policy Check / Verify merge target (pull_request) Successful in 4s
Universal: PR Check / Branch Policy (pull_request) Successful in 4s
Universal: Auto Version Bump / Version Bump (push) Failing after 14s
Universal: PR Check / Validate PR (pull_request) Failing after 13s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
PR RC Release / Build RC Release (pull_request) Successful in 1m27s
Universal: Build & Release / Build & Release Pipeline (pull_request) Failing after 18m27s
2026-05-30 18:57:17 +00:00
jmiller eef72a5b00 Merge pull request 'fix(config): remove WithFileConfig from LandingPage option' (#249) from fix/startup-crash into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Branch Policy Check / Verify merge target (pull_request) Successful in 4s
Universal: PR Check / Branch Policy (pull_request) Successful in 4s
Universal: PR Check / Validate PR (pull_request) Failing after 12s
PR RC Release / Build RC Release (pull_request) Successful in 1m22s
fix(config): remove WithFileConfig from LandingPage (#249)
2026-05-30 18:56:43 +00:00
Jonathan Miller 530cfc91b1 fix(config): remove WithFileConfig from LandingPage option
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Branch Policy Check / Verify merge target (pull_request) Successful in 3s
Universal: PR Check / Branch Policy (pull_request) Successful in 3s
PR RC Release / Build RC Release (pull_request) Successful in 5s
Universal: PR Check / Validate PR (pull_request) Failing after 12s
Branch Cleanup / Delete merged branch (pull_request) Successful in 4s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || 'development' }}) (pull_request) Successful in 2m30s
The WithFileConfig for LandingPage tried to parse the app.ini
LANDING_PAGE string value (e.g. "home") as JSON into a struct,
causing a startup hang. The landing page setting should only be
managed through the dynamic config system (admin UI).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-30 13:55:33 -05:00
gitea-actions[bot] 39249dd0e7 chore(release): build 05.06.00 [skip ci] 2026-05-30 18:12:57 +00:00
jmiller aee484780b Merge pull request 'chore: merge dev into main' (#248) from dev into main
Deploy MokoGitea / deploy (push) Failing after 35m55s
chore: merge dev into main - license keys + dolibarr (#248)
2026-05-30 18:12:24 +00:00
jmiller e9ab1fd01d Merge branch 'main' into dev
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
Universal: Auto Version Bump / Version Bump (push) Failing after 5s
PR RC Release / Build RC Release (pull_request) Successful in 26s
Universal: Build & Release / Build & Release Pipeline (pull_request) Failing after 35m56s
2026-05-30 18:12:15 +00:00
jmiller 6e78d49e5a Merge pull request 'feat(updates): license key system and Dolibarr endpoint (Phase 2-3)' (#247) from feat/update-server-phase2-3 into dev
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been skipped
Universal: Auto Version Bump / Version Bump (push) Has been skipped
Branch Policy Check / Verify merge target (pull_request) Successful in 2s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 7s
Universal: Build & Release / Promote to RC (pull_request) Failing after 12s
PR RC Release / Build RC Release (pull_request) Successful in 25s
feat(updates): license key system and Dolibarr endpoint (#247)
2026-05-30 18:10:16 +00:00
Jonathan Miller 627a22ee53 feat(updates): license key system and Dolibarr endpoint (Phase 2-3)
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
PR RC Release / Build RC Release (pull_request) Successful in 3s
Universal: PR Check / Validate PR (pull_request) Failing after 5s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || 'development' }}) (pull_request) Successful in 54s
Add license key data model and Dolibarr update feed endpoint:

License key system:
- license_package table: subscription tiers with duration, max sites,
  repo scope (org-wide or specific repos), and allowed update channels
- license_key table: individual keys with SHA-256 hashed storage,
  domain restriction, custom start/end dates, internal/master key flag
- license_key_usage table: tracks update check activity per key
- DB migration v335 creates all three tables

Update server enhancements:
- Dolibarr JSON endpoint at /{owner}/{repo}/updates/dolibarr.json
- License key validation on update endpoints via ?key=MOKO-XXXX param
- Channel filtering: packages restrict which update streams keys access
- Invalid keys get empty XML response (Joomla-compatible "no updates")
- Usage tracking records domain, IP, user agent, version on each check

Key design decisions:
- Org-level master keys: IsInternal=true, package RepoScope="all"
- Keys stored as SHA-256 hashes, raw key only shown at creation
- Packages define allowed channels (e.g. ["stable","rc"] for Pro tier)
- MOKO-XXXX-XXXX-XXXX-XXXX format for license keys

Ref #239

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-30 13:09:47 -05:00
jmiller 3c5fc21976 chore: sync updates.xml 05.05.00 from main [skip ci] 2026-05-30 17:59:35 +00:00
gitea-actions[bot] 23d453a786 chore: update channels for 05.05.00 [skip ci] 2026-05-30 17:59:34 +00:00
gitea-actions[bot] ef99c7461d chore(release): build 05.05.00 [skip ci] 2026-05-30 17:55:54 +00:00
jmiller 658aa524c6 Merge pull request 'chore: merge dev into main' (#246) from dev into main
Deploy MokoGitea / deploy (push) Successful in 4m5s
chore: merge dev into main - update server + visibility (#246)
2026-05-30 17:55:19 +00:00
jmiller 44f6823292 Merge pull request 'feat(updates): built-in Joomla update server endpoint' (#245) from feat/update-server into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Branch Policy Check / Verify merge target (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
Universal: Build & Release / Promote to RC (pull_request) Failing after 12s
PR RC Release / Build RC Release (pull_request) Successful in 27s
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 8m27s
feat(updates): built-in Joomla update server endpoint (#239) (#245)
2026-05-30 17:55:02 +00:00
Jonathan Miller 6c06384966 feat(updates): built-in Joomla update server endpoint
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
PR RC Release / Build RC Release (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Failing after 6s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || 'development' }}) (pull_request) Successful in 1m4s
Add GET /{owner}/{repo}/updates.xml that dynamically generates a
Joomla-compatible updates.xml from the repository's releases.

Features:
- Automatically maps release tags to channels (stable/rc/beta/alpha/dev)
- Finds .zip attachments for download URLs, falls back to archive URL
- Emits one entry per channel (latest release wins)
- Extracts version from tag names, strips common prefixes
- Publicly accessible (no auth required) for Joomla update clients

This is Phase 1 of #239 — the core dynamic update feed generation.
Future phases will add license key gating, Dolibarr support, and
repo settings UI.

Ref #239

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-30 12:54:31 -05:00
jmiller d4f2dc33b9 chore: sync updates.xml 05.04.00 from main [skip ci] 2026-05-30 17:34:17 +00:00
gitea-actions[bot] 3807dbbb2e chore: update channels for 05.04.00 [skip ci] 2026-05-30 17:34:15 +00:00
gitea-actions[bot] fd481329a5 chore(release): build 05.04.00 [skip ci] 2026-05-30 17:30:43 +00:00
jmiller 04ed2c7ed5 Merge pull request 'chore: merge dev into main' (#244) from dev into main
Deploy MokoGitea / deploy (push) Successful in 3m44s
chore: merge dev into main - visibility + landing page (#244)
2026-05-30 17:30:03 +00:00
jmiller c322bfae23 Merge pull request 'feat(permissions): section-based visibility - public units on private repos' (#243) from feat/section-based-visibility into dev
Universal: Auto Version Bump / Version Bump (push) Has been skipped
Universal: PR Check / Build RC Package (pull_request) Blocked by required conditions
Branch Policy Check / Verify merge target (pull_request) Successful in 1s
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Failing after 7s
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
Universal: Build & Release / Promote to RC (pull_request) Failing after 13s
PR RC Release / Build RC Release (pull_request) Successful in 27s
Universal: Build & Release / Build & Release Pipeline (pull_request) Successful in 8m21s
feat(permissions): section-based visibility (#238) (#243)
2026-05-30 17:29:45 +00:00
jmiller b0acd521e5 chore: sync updates.xml 05.03.00 from main [skip ci] 2026-05-30 17:23:06 +00:00
gitea-actions[bot] 9c0e2b48cf chore: update channels for 05.03.00 [skip ci] 2026-05-30 17:23:05 +00:00
gitea-actions[bot] 1bff46b220 chore(release): build 05.03.00 [skip ci] 2026-05-30 17:19:34 +00:00
jmiller 44fd865ee6 Merge pull request 'chore: merge dev into main' (#242) from dev into main
Deploy MokoGitea / deploy (push) Successful in 4m8s
chore: merge dev into main - landing page admin setting (#242)
2026-05-30 17:18:44 +00:00
gitea-actions[bot] 0438ed1b73 chore: update channels for 05.02.00 [skip ci] 2026-05-30 16:12:30 +00:00
gitea-actions[bot] 6045bf87d9 chore(release): build 05.02.00 [skip ci] 2026-05-30 16:08:48 +00:00
jmiller 540e3e129a Merge pull request 'chore: merge dev into main' (#237) from dev into main
Deploy MokoGitea / deploy (push) Successful in 4m5s
chore: merge dev into main — deploy trigger + changelog (#237)
2026-05-30 16:08:02 +00:00
jmiller 203d090123 chore: add .mokogitea/workflows/pr-check.yml from moko-platform [skip ci] 2026-05-30 16:01:47 +00:00
jmiller 7aa930227e Merge pull request 'chore: merge dev into main [skip ci]' (#104) from dev into main
chore: merge dev into main — actions bot rebrand + branch protection whitelist (#104)
2026-05-30 15:49:05 +00:00
jmiller 0cb4ece382 chore: sync CONTRIBUTING.md from moko-platform [skip ci] 2026-05-30 14:59:52 +00:00
jmiller 8af880073f chore: sync .mokogitea/workflows/auto-release.yml from moko-platform [skip ci] 2026-05-30 14:56:17 +00:00
jmiller 8ee7e9fcde chore: sync .mokogitea/workflows/auto-bump.yml from moko-platform [skip ci] 2026-05-30 14:54:25 +00:00
jmiller 7bd66ae74c chore: sync .mokogitea/workflows/auto-release.yml from moko-platform [skip ci] 2026-05-30 05:53:24 +00:00
jmiller d10c6ece9b chore: sync .mokogitea/workflows/auto-bump.yml from moko-platform [skip ci] 2026-05-30 05:51:27 +00:00
jmiller aeda83c664 chore: sync .mokogitea/workflows/auto-release.yml from moko-platform [skip ci] 2026-05-30 03:41:15 +00:00
jmiller e0698e73bc chore: sync .mokogitea/workflows/auto-release.yml from moko-platform [skip ci] 2026-05-30 01:15:07 +00:00
jmiller 25257b9e31 chore: add .mokogitea/branch-protection.yml from moko-platform [skip ci] 2026-05-29 10:30:21 +00:00
jmiller a5bdc89faa chore: sync CONTRIBUTING.md from moko-platform [skip ci] 2026-05-29 10:27:49 +00:00
jmiller 0ecba968a0 chore: add .mokogitea/workflows/branch-cleanup.yml from moko-platform [skip ci] 2026-05-29 10:26:11 +00:00
jmiller bed7adcf1c chore: sync .mokogitea/workflows/auto-release.yml from moko-platform [skip ci] 2026-05-29 10:24:44 +00:00
jmiller df59b5f6d5 chore: sync .mokogitea/workflows/auto-bump.yml from moko-platform [skip ci] 2026-05-29 10:23:14 +00:00
jmiller 5786f0dfc4 chore: sync .mokogitea/workflows/pre-release.yml from moko-platform [skip ci] 2026-05-28 20:53:51 +00:00
jmiller 2de87d8ff4 chore: sync .mokogitea/workflows/update-server.yml from moko-platform [skip ci] 2026-05-28 20:48:43 +00:00
jmiller b241acf650 chore: sync .mokogitea/workflows/auto-release.yml from moko-platform [skip ci] 2026-05-28 20:43:45 +00:00
jmiller 173dfd0f26 chore: sync .mokogitea/workflows/auto-release.yml from moko-platform [skip ci] 2026-05-28 20:38:05 +00:00
Jonathan Miller 1ad277cd73 Merge branch 'dev' 2026-05-26 21:11:54 -05:00
Jonathan Miller e084c7f4b4 Merge branch 'dev' 2026-05-26 21:07:38 -05:00
jmiller eb15990510 chore(ci): update pre-release.yml from moko-platform [skip ci] 2026-05-26 22:51:10 +00:00
jmiller 411ba858f5 chore(ci): update auto-bump.yml from moko-platform [skip ci] 2026-05-26 22:49:59 +00:00
jmiller bd899bcbb1 chore(ci): update auto-release.yml from moko-platform [skip ci] 2026-05-26 22:48:46 +00:00
jmiller 4c4d2ac956 Merge pull request 'rc(v05.06.00): security backports, actions deadlock fix, dep bumps' (#228) from rc/05.06.00 into main
Universal: Auto Version Bump / Patch Bump (push) Successful in 6s
rc(v05.06.00): security backports, actions deadlock fix, dep bumps (#228)
2026-05-26 22:37:06 +00:00
jmiller 47ddd6a277 chore(ci): update auto-release.yml from moko-platform [skip ci] 2026-05-26 22:35:53 +00:00
37 changed files with 2819 additions and 5688 deletions
+251
View File
@@ -0,0 +1,251 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
# SPDX-License-Identifier: GPL-3.0-or-later
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Automation
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /.gitea/workflows/branch-protection.yml
# BRIEF: Apply standardised branch protection rules to all governed repositories
#
# +========================================================================+
# | BRANCH PROTECTION SETUP |
# +========================================================================+
# | |
# | Applies protection rules for: main, dev, rc, beta, alpha |
# | |
# | main — Require PR, block rejected reviews, no force push |
# | dev — Allow push, no force push, no delete |
# | rc — Allow push, no force push, no delete |
# | beta — Allow push, no force push, no delete |
# | alpha — Allow push, no force push, no delete |
# | |
# | jmiller has override authority on all branches. |
# | |
# +========================================================================+
name: Branch Protection Setup
on:
schedule:
- cron: '0 2 * * 1' # Weekly Monday 02:00 UTC
workflow_dispatch:
inputs:
dry_run:
description: 'Preview mode (no changes)'
required: false
type: boolean
default: false
repos:
description: 'Comma-separated repo names (empty = all governed repos)'
required: false
type: string
default: ''
env:
GITEA_URL: https://git.mokoconsulting.tech
GITEA_ORG: MokoConsulting
permissions:
contents: read
jobs:
protect:
name: Apply Branch Protection Rules
runs-on: ubuntu-latest
steps:
- name: Determine target repos
id: repos
env:
GA_TOKEN: ${{ secrets.GA_TOKEN }}
run: |
API="${GITEA_URL}/api/v1"
# Platform/standards/infra repos to exclude
EXCLUDE="gitea-org-config org-profile gitea-private .mokogitea-private MokoStandards moko-platform MokoTesting"
EXCLUDE="$EXCLUDE MokoStandards-Template-Client MokoStandards-Template-Dolibarr MokoStandards-Template-Generic MokoStandards-Template-Joomla MokoDoliProjTemplate"
if [ -n "${{ inputs.repos }}" ]; then
# User-specified repos
REPOS=$(echo "${{ inputs.repos }}" | tr ',' ' ')
else
# Fetch all org repos
PAGE=1
REPOS=""
while true; do
BATCH=$(curl -sS \
-H "Authorization: token ${GA_TOKEN}" \
"${API}/orgs/${GITEA_ORG}/repos?page=${PAGE}&limit=50" \
| jq -r '.[].name // empty')
[ -z "$BATCH" ] && break
REPOS="$REPOS $BATCH"
PAGE=$((PAGE + 1))
done
# Filter out excluded repos
FILTERED=""
for REPO in $REPOS; do
SKIP=false
for EX in $EXCLUDE; do
if [ "$REPO" = "$EX" ]; then
SKIP=true
break
fi
done
if [ "$SKIP" = "false" ]; then
FILTERED="$FILTERED $REPO"
fi
done
REPOS="$FILTERED"
fi
echo "repos=$REPOS" >> "$GITHUB_OUTPUT"
COUNT=$(echo "$REPOS" | wc -w)
echo "📋 Target repos (${COUNT}): $REPOS"
- name: Apply protection rules
env:
GA_TOKEN: ${{ secrets.GA_TOKEN }}
DRY_RUN: ${{ inputs.dry_run || 'false' }}
run: |
API="${GITEA_URL}/api/v1"
REPOS="${{ steps.repos.outputs.repos }}"
SUCCESS=0
FAILED=0
SKIPPED=0
# ── Rule definitions ──────────────────────────────────────
# Only the CI bot (jmiller token) can push directly.
# All human contributors must use PRs.
# Force push disabled on all branches.
RULE_MAIN='{
"rule_name": "main",
"enable_push": true,
"enable_push_whitelist": true,
"push_whitelist_usernames": ["jmiller"],
"enable_force_push": false,
"enable_force_push_allowlist": false,
"force_push_allowlist_usernames": [],
"enable_merge_whitelist": false,
"required_approvals": 0,
"dismiss_stale_approvals": true,
"block_on_rejected_reviews": true,
"block_on_outdated_branch": false,
"priority": 1
}'
RULE_DEV='{
"rule_name": "dev",
"enable_push": true,
"enable_push_whitelist": true,
"push_whitelist_usernames": ["jmiller"],
"enable_force_push": false,
"enable_force_push_allowlist": false,
"force_push_allowlist_usernames": [],
"enable_merge_whitelist": false,
"required_approvals": 0,
"block_on_rejected_reviews": false,
"priority": 2
}'
RULE_RC='{
"rule_name": "rc",
"enable_push": true,
"enable_push_whitelist": true,
"push_whitelist_usernames": ["jmiller"],
"enable_force_push": false,
"enable_force_push_allowlist": false,
"force_push_allowlist_usernames": [],
"enable_merge_whitelist": false,
"required_approvals": 0,
"block_on_rejected_reviews": false,
"priority": 3
}'
RULE_BETA='{
"rule_name": "beta",
"enable_push": true,
"enable_push_whitelist": true,
"push_whitelist_usernames": ["jmiller"],
"enable_force_push": false,
"enable_force_push_allowlist": false,
"force_push_allowlist_usernames": [],
"enable_merge_whitelist": false,
"required_approvals": 0,
"block_on_rejected_reviews": false,
"priority": 4
}'
RULE_ALPHA='{
"rule_name": "alpha",
"enable_push": true,
"enable_push_whitelist": true,
"push_whitelist_usernames": ["jmiller"],
"enable_force_push": false,
"enable_force_push_allowlist": false,
"force_push_allowlist_usernames": [],
"enable_merge_whitelist": false,
"required_approvals": 0,
"block_on_rejected_reviews": false,
"priority": 5
}'
RULES=("$RULE_MAIN" "$RULE_DEV" "$RULE_RC" "$RULE_BETA" "$RULE_ALPHA")
RULE_NAMES=("main" "dev" "rc" "beta" "alpha")
# ── Apply rules to each repo ──────────────────────────────
for REPO in $REPOS; do
echo ""
echo "═══ ${REPO} ═══"
for i in "${!RULES[@]}"; do
RULE="${RULES[$i]}"
NAME="${RULE_NAMES[$i]}"
if [ "$DRY_RUN" = "true" ]; then
echo " [DRY RUN] Would apply rule: ${NAME}"
SKIPPED=$((SKIPPED + 1))
continue
fi
# Delete existing rule if present (idempotent recreate)
ENCODED_NAME=$(echo "$NAME" | sed 's|/|%2F|g')
curl -sS -o /dev/null -w "" \
-X DELETE \
-H "Authorization: token ${GA_TOKEN}" \
"${API}/repos/${GITEA_ORG}/${REPO}/branch_protections/${ENCODED_NAME}" 2>/dev/null || true
# Create rule
RESPONSE=$(curl -sS -w "\n%{http_code}" \
-X POST \
-H "Authorization: token ${GA_TOKEN}" \
-H "Content-Type: application/json" \
-d "$RULE" \
"${API}/repos/${GITEA_ORG}/${REPO}/branch_protections")
HTTP=$(echo "$RESPONSE" | tail -1)
BODY=$(echo "$RESPONSE" | sed '$d')
if [ "$HTTP" = "201" ]; then
echo " ✅ ${NAME}"
SUCCESS=$((SUCCESS + 1))
else
echo " ❌ ${NAME} (HTTP ${HTTP}): $(echo "$BODY" | jq -r '.message // .' 2>/dev/null | head -1)"
FAILED=$((FAILED + 1))
fi
done
done
# ── Summary ───────────────────────────────────────────────
echo ""
echo "════════════════════════════════════════"
echo " ✅ Success: ${SUCCESS}"
echo " ❌ Failed: ${FAILED}"
echo " ⏭️ Skipped: ${SKIPPED}"
echo "════════════════════════════════════════"
if [ "$FAILED" -gt 0 ]; then
echo "::warning::${FAILED} rule(s) failed to apply"
fi
+1 -1
View File
@@ -4,7 +4,7 @@
<name>MokoGitea</name>
<org>MokoConsulting</org>
<description>Moko fork of Gitea — adding project board REST API endpoints and custom enhancements</description>
<version>01.00.00</version>
<version>05.14.00</version>
<license spdx="GPL-3.0-or-later">GNU General Public License v3</license>
</identity>
<governance>
+48
View File
@@ -0,0 +1,48 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: MokoStandards.Universal
# REPO: https://git.mokoconsulting.tech/MokoConsulting/moko-platform
# PATH: /.mokogitea/workflows/branch-cleanup.yml
# VERSION: 01.00.00
# BRIEF: Delete feature branches after PR merge
name: "Branch Cleanup"
on:
pull_request:
types: [closed]
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
cleanup:
name: Delete merged branch
runs-on: ubuntu-latest
if: >-
github.event.pull_request.merged == true &&
github.event.pull_request.head.ref != 'dev' &&
github.event.pull_request.head.ref != 'main'
steps:
- name: Delete source branch
run: |
BRANCH="${{ github.event.pull_request.head.ref }}"
API="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}/api/v1/repos/${{ github.repository }}/branches"
ENCODED=$(php -r "echo rawurlencode('${BRANCH}');")
STATUS=$(curl -sf -o /dev/null -w "%{http_code}" -X DELETE \
-H "Authorization: token ${{ secrets.MOKOGITEA_TOKEN }}" \
"${API}/${ENCODED}" 2>/dev/null || true)
if [ "$STATUS" = "204" ]; then
echo "Deleted branch: ${BRANCH}" >> $GITHUB_STEP_SUMMARY
elif [ "$STATUS" = "404" ]; then
echo "Branch already deleted: ${BRANCH}" >> $GITHUB_STEP_SUMMARY
else
echo "::warning::Failed to delete branch ${BRANCH} (HTTP ${STATUS})"
fi
+10
View File
@@ -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"
+11
View File
@@ -103,6 +103,17 @@ jobs:
$SSH_CMD "echo 'SSH connected'"
# Pre-deploy cleanup: free disk and memory for the build
$SSH_CMD "
echo 'Cleaning Docker build cache and unused images...'
docker builder prune -af 2>/dev/null || true
docker image prune -af 2>/dev/null || true
echo 'Clearing swap...'
sudo swapoff -a && sudo swapon -a 2>/dev/null || true
echo 'Cleanup complete'
free -m | head -3
"
# Pull latest source
$SSH_CMD "
set -e
+1 -1
View File
@@ -5,7 +5,7 @@
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Automation
# VERSION: 01.00.00
# VERSION: 05.14.00
# BRIEF: Auto-create feature branch when an issue is opened
name: "Universal: Issue Branch"
-5376
View File
File diff suppressed because it is too large Load Diff
+161 -293
View File
@@ -1,293 +1,161 @@
# Contribution Guidelines
This document explains how to contribute changes to the Gitea project. Topic-specific guides live in separate files so the essentials are easier to find.
| Topic | Document |
| :---- | :------- |
| Backend (Go modules, API v1) | [docs/guideline-backend.md](docs/guideline-backend.md) |
| Frontend (npm, UI guidelines) | [docs/guideline-frontend.md](docs/guideline-frontend.md) |
| Maintainers, TOC, labels, merge queue, commit format for mergers | [docs/community-governance.md](docs/community-governance.md) |
| Release cycle, backports, tagging releases | [docs/release-management.md](docs/release-management.md) |
<details><summary>Table of Contents</summary>
- [Contribution Guidelines](#contribution-guidelines)
- [Introduction](#introduction)
- [AI Contribution Policy](#ai-contribution-policy)
- [Issues](#issues)
- [How to report issues](#how-to-report-issues)
- [Types of issues](#types-of-issues)
- [Discuss your design before the implementation](#discuss-your-design-before-the-implementation)
- [Issue locking](#issue-locking)
- [Building Gitea](#building-gitea)
- [Styleguide](#styleguide)
- [Copyright](#copyright)
- [Testing](#testing)
- [Translation](#translation)
- [Code review](#code-review)
- [Pull request format](#pull-request-format)
- [PR title and summary](#pr-title-and-summary)
- [Breaking PRs](#breaking-prs)
- [What is a breaking PR?](#what-is-a-breaking-pr)
- [How to handle breaking PRs?](#how-to-handle-breaking-prs)
- [Maintaining open PRs](#maintaining-open-prs)
- [Reviewing PRs](#reviewing-prs)
- [For PR authors](#for-pr-authors)
- [Documentation](#documentation)
- [Developer Certificate of Origin (DCO)](#developer-certificate-of-origin-dco)
</details>
## Introduction
It assumes you have followed the [installation instructions](https://docs.gitea.com/category/installation). \
Sensitive security-related issues should be reported to [security@gitea.io](mailto:security@gitea.io).
For configuring IDEs for Gitea development, see the [contributed IDE configurations](contrib/ide/).
## AI Contribution Policy
Contributions made with the assistance of AI tools are welcome, but contributors must use them responsibly and disclose that use clearly.
1. Review AI-generated code closely before marking a pull request ready for review.
2. Manually test the changes and add appropriate automated tests where feasible.
3. Only use AI to assist in contributions that you understand well enough to explain, defend, and revise yourself during review.
4. Disclose AI-assisted content clearly.
5. Do not use AI to reply to questions about your issue or pull request. The questions are for you, not an AI model.
6. AI may be used to help draft issues and pull requests, but contributors remain responsible for the accuracy, completeness, and intent of what they submit.
Maintainers reserve the right to close pull requests and issues that do not disclose AI assistance, that appear to be low-quality AI-generated content, or where the contributor cannot explain or defend the proposed changes themselves.
We welcome new contributors, but cannot sustain the effort of supporting contributors who primarily defer to AI rather than engaging substantively with the review process.
## Issues
### How to report issues
Please search the issues on the issue tracker with a variety of related keywords to ensure that your issue has not already been reported.
If your issue has not been reported yet, [open an issue](https://github.com/go-gitea/gitea/issues/new)
and answer the questions so we can understand and reproduce the problematic behavior. \
Please write clear and concise instructions so that we can reproduce the behavior — even if it seems obvious. \
The more detailed and specific you are, the faster we can fix the issue. \
It is really helpful if you can reproduce your problem on a site running on the latest commits, i.e. <https://demo.gitea.com>, as perhaps your problem has already been fixed on a current version. \
Please follow the guidelines described in [How to Report Bugs Effectively](http://www.chiark.greenend.org.uk/~sgtatham/bugs.html) for your report.
Please be kind—remember that Gitea comes at no cost to you, and you're getting free help.
### Types of issues
Typically, issues fall in one of the following categories:
- `bug`: Something in the frontend or backend behaves unexpectedly
- `security issue`: bug that has serious implications such as leaking another users data. Please do not file such issues on the public tracker and send a mail to security@gitea.io instead
- `feature`: Completely new functionality. You should describe this feature in enough detail that anyone who reads the issue can understand how it is supposed to be implemented
- `enhancement`: An existing feature should get an upgrade
- `refactoring`: Parts of the code base don't conform with other parts and should be changed to improve Gitea's maintainability
### Discuss your design before the implementation
We welcome submissions. \
If you want to change or add something, please let everyone know what you're working on — [file an issue](https://github.com/go-gitea/gitea/issues/new) or comment on an existing one before starting your work!
Significant changes such as new features must go through the change proposal process before they can be accepted. \
This is mainly to save yourself the trouble of implementing it, only to find out that your proposed implementation has some potential problems. \
Furthermore, this process gives everyone a chance to validate the design, helps prevent duplication of effort, and ensures that the idea fits inside
the goals for the project and tools.
Pull requests should not be the place for architecture discussions.
### Issue locking
Commenting on closed or merged issues/PRs is strongly discouraged.
Such comments will likely be overlooked as some maintainers may not view notifications on closed issues, thinking that the item is resolved.
As such, commenting on closed/merged issues/PRs may be disabled prior to the scheduled auto-locking if a discussion starts or if unrelated comments are posted.
If further discussion is needed, we encourage you to open a new issue instead and we recommend linking to the issue/PR in question for context.
## Building Gitea
See the [development setup instructions](https://docs.gitea.com/development/hacking-on-gitea).
## Styleguide
You should always run `make fmt` before committing to conform to Gitea's styleguide.
## Copyright
New code files that you contribute should use the standard copyright header:
```
// Copyright <current year> The Gitea Authors. All rights reserved.
// SPDX-License-Identifier: MIT
```
Afterwards, copyright should only be modified when the copyright author changes.
## Testing
Before submitting a pull request, run all tests to make sure your changes don't cause a regression elsewhere.
Here's how to run the test suite:
- code lint
| | |
| :-------------------- | :--------------------------------------------------------------------------- |
|``make lint`` | lint everything (not needed if you only change the front- **or** backend) |
|``make lint-frontend`` | lint frontend files |
|``make lint-backend`` | lint backend files |
- run tests (we suggest running them on Linux)
| Command | Action | |
|:----------------------------------------------|:-----------------------------------------------------| ------------------------------------------- |
| ``make test-backend[\#SpecificTestName]`` | run unit test(s) | |
| ``make test-integration[\#SpecificTestName]`` | run [integration](tests/integration) test(s) | [More details](tests/integration/README.md) |
| ``make test-e2e`` | run [end-to-end](tests/e2e) test(s) using Playwright | |
- E2E test environment variables
| Variable | Description |
| :-------------------------------- | :---------------------------------------------------------- |
| ``GITEA_TEST_E2E_DEBUG`` | When set, show Gitea server output |
| ``GITEA_TEST_E2E_FLAGS`` | Additional flags passed to Playwright, for example ``--ui`` |
| ``GITEA_TEST_E2E_TIMEOUT_FACTOR`` | Timeout multiplier (default: 4 on CI, 1 locally) |
## Translation
All translation work happens on [Crowdin](https://translate.gitea.com).
The only translation that is maintained in this repository is [the English translation](https://github.com/go-gitea/gitea/blob/main/options/locale/locale_en-US.json).
It is synced regularly with Crowdin. \
Other locales on main branch **should not** be updated manually as they will be overwritten with each sync. \
Once a language has reached a **satisfactory percentage** of translated keys (~25%), it will be synced back into this repo and included in the next released version.
The tool `go run build/backport-locale.go` can be used to backport locales from the main branch to release branches that were missed.
## Code review
How labels, milestones, and the merge queue work is documented in [docs/community-governance.md](docs/community-governance.md).
### Pull request format
Please try to make your pull request easy to review for us. \
For that, please read the [*Best Practices for Faster Reviews*](https://github.com/kubernetes/community/blob/261cb0fd089b64002c91e8eddceebf032462ccd6/contributors/guide/pull-requests.md#best-practices-for-faster-reviews) guide. \
It has lots of useful tips for any project you may want to contribute to. \
Some of the key points:
- Make small pull requests. \
The smaller, the faster to review and the more likely it will be merged soon.
- Don't make changes unrelated to your PR. \
Maybe there are typos on some comments, maybe refactoring would be welcome on a function... \
but if that is not related to your PR, please make *another* PR for that.
- Split big pull requests into multiple small ones. \
An incremental change will be faster to review than a huge PR.
- Allow edits by maintainers. This way, the maintainers will take care of merging the PR later on instead of you.
### PR title and summary
In the PR title, describe the problem you are fixing, not how you are fixing it. \
Use the first comment as a summary of your PR. \
In the PR summary, you can describe exactly how you are fixing this problem.
PR titles must follow the [Conventional Commits](https://www.conventionalcommits.org/) format, because PRs are squash-merged and the PR title becomes the resulting commit message:
```text
type(scope)!: subject
```
The allowed types are `build`, `chore`, `ci`, `docs`, `feat`, `fix`, `perf`, `refactor`, `revert`, `style`, and `test`. The generic `chore` type is intentionally not accepted; pick a more descriptive type instead.
Examples:
```text
fix(web): prevent avatar upload crash on empty file
feat(api): add pagination to repo hooks list
ci(workflows): lint PR titles with commitlint
```
Keep this summary up-to-date as the PR evolves. \
If your PR changes the UI, you must add **after** screenshots in the PR summary. \
If you are not implementing a new feature, you should also post **before** screenshots for comparison.
If you are implementing a new feature, your PR will only be merged if your screenshots are up to date.\
Furthermore, feature PRs will only be merged if their summary contains a clear usage description (understandable for users) and testing description (understandable for reviewers).
You should strive to combine both into a single description.
Another requirement for merging PRs is that the PR is labeled correctly.\
However, this is not your job as a contributor, but the job of the person merging your PR.\
If you think that your PR was labeled incorrectly, or notice that it was merged without labels, please let us know.
If your PR closes some issues, you must note that in a way that both GitHub and Gitea understand, i.e. by appending a paragraph like
```text
Fixes/Closes/Resolves #<ISSUE_NR_X>.
Fixes/Closes/Resolves #<ISSUE_NR_Y>.
```
to your summary. \
Each issue that will be closed must stand on a separate line.
### Breaking PRs
#### What is a breaking PR?
A PR is breaking if it meets one of the following criteria:
- It changes API output in an incompatible way for existing users
- It removes a setting that an admin could previously set (i.e. via `app.ini`)
- An admin must do something manually to restore the old behavior
In particular, this means that adding new settings is not breaking.\
Changing the default value of a setting or replacing the setting with another one is breaking, however.
#### How to handle breaking PRs?
If your PR has a breaking change, you must add two things to the summary of your PR:
1. A reasoning why this breaking change is necessary
2. A `BREAKING` section explaining in simple terms (understandable for a typical user) how this PR affects users and how to mitigate these changes. This section can look for example like
```md
## :warning: BREAKING :warning:
```
Breaking PRs will not be merged as long as not both of these requirements are met.
### Maintaining open PRs
Code review starts when you open a non-draft PR or move a draft out of draft state. After that, do not rebase or squash your branch; it makes new changes harder to review.
Merge the base branch into yours only when you need to, for example because of conflicting changes elsewhere. That limits unnecessary CI runs.
Every PR is squash-merged, so merge commits on your branch do not matter for final history. The squash produces a single commit; mergers follow the [commit message format](docs/community-governance.md#commit-messages) in the governance guide.
### Reviewing PRs
Maintainers are encouraged to review pull requests in areas where they have expertise or particular interest.
#### For PR authors
- **Response**: When answering reviewer questions, use real-world cases or examples and avoid speculation.
- **Discussion**: A discussion is always welcome and should be used to clarify the changes and the intent of the PR.
- **Help**: If you need help with the PR or comments are unclear, ask for clarification.
Guidance for reviewers, the merge queue, and the squash commit message format is in [docs/community-governance.md](docs/community-governance.md).
## Documentation
If you add a new feature or change an existing aspect of Gitea, the documentation for that feature must be created or updated in another PR at [https://gitea.com/gitea/docs](https://gitea.com/gitea/docs).
**The docs directory on main repository will be removed at some time. We will have a yaml file to store configuration file's meta data. After that completed, configuration documentation should be in the main repository.**
## Developer Certificate of Origin (DCO)
We consider the act of contributing to the code by submitting a Pull Request as the "Sign off" or agreement to the certifications and terms of the [DCO](DCO) and [MIT license](LICENSE). \
No further action is required. \
You can also decide to sign off your commits by adding the following line at the end of your commit messages:
```
Signed-off-by: Joe Smith <joe.smith@email.com>
```
If you set the `user.name` and `user.email` Git config options, you can add the line to the end of your commits automatically with `git commit -s`.
We assume in good faith that the information you provide is legally binding.
# Contributing to Moko Consulting Projects
Thank you for your interest in contributing. All Moko Consulting repositories follow this universal workflow and version policy.
## Branching Workflow
```
feature/* ──PR──> dev ──draft PR──> (renamed to rc) ──merge──> main
```
### Step by step
1. **Create a feature branch** from `dev`:
```bash
git checkout dev && git pull
git checkout -b feature/my-change
```
2. **Work and commit** on your feature branch. Push to origin.
3. **Open a PR**: `feature/my-change` → `dev`. After review and checks, merge it.
4. **When ready for release**, open a **draft PR**: `dev` → `main`.
- This automatically renames the source branch to `rc` (release candidate)
- An RC pre-release is built and uploaded
5. **Alpha and beta branches** are created by manually renaming the branch before the RC stage:
- Rename `dev` to `alpha` for early testing → alpha pre-release is built
- Rename `alpha` to `beta` for feature-complete testing → beta pre-release is built
- When the draft PR is created, the branch is renamed to `rc`
6. **Once PR checks pass** on the `rc` branch, mark the PR as ready and merge to `main`.
7. **Merging to main** triggers the stable release pipeline:
- Minor version bump (e.g., `02.09.xx` → `02.10.00`)
- Stability suffix stripped (clean version)
- Gitea release created with ZIP/tar.gz packages
- `updates.xml` updated (Joomla extensions)
- `dev` branch recreated from `main`
### Branch summary
| Branch | Purpose | Created by |
|--------|---------|-----------|
| `feature/*` | New features and fixes | Developer |
| `dev` | Integration branch | Auto-recreated after release |
| `alpha` | Alpha pre-release testing | Manual rename from `dev` |
| `beta` | Beta pre-release testing | Manual rename from `alpha` |
| `rc` | Release candidate | Auto-renamed on draft PR to main |
| `main` | Stable releases | Protected, merge only |
| `version/XX.YY.ZZ` | Archived release snapshots | Auto-created by CI |
### Protected branches
| Branch | Direct push | Merge via |
|--------|------------|-----------|
| `main` | Blocked (CI bot whitelisted) | PR merge only |
| `dev` | Blocked (CI bot whitelisted) | PR merge from feature/* |
| `rc` | Blocked (CI bot whitelisted) | Auto-created on draft PR |
| `alpha` | Blocked (CI bot whitelisted) | Manual rename |
| `beta` | Blocked (CI bot whitelisted) | Manual rename |
| `feature/*` | Open | N/A (source branch) |
## Version Policy
### Format
All versions use `XX.YY.ZZ` — three two-digit segments, zero-padded:
- **XX** — Major version (breaking changes)
- **YY** — Minor version (new features, bumped on release to main)
- **ZZ** — Patch version (auto-incremented on every push to dev/feature branches)
Rollover: patch `99` → `00` increments minor; minor `99` → `00` increments major.
### Stability suffixes
Each branch appends a suffix to indicate stability:
| Branch | Suffix | Example |
|--------|--------|---------|
| `main` | (none) | `02.09.00` |
| `dev` | `-dev` | `02.09.01-dev` |
| `feature/*` | `-dev` | `02.09.01-dev` |
| `alpha` | `-alpha` | `02.09.01-alpha` |
| `beta` | `-beta` | `02.09.01-beta` |
| `rc` | `-rc` | `02.09.01-rc` |
### Auto version bump
On every push to `dev`, `feature/*`, or `patch/*`:
1. Patch version incremented
2. Stability suffix `-dev` applied
3. All version-bearing files updated (manifests, CHANGELOG, PHP headers, etc.)
4. Commit created with `[skip ci]` to avoid loops
### Release version flow
Version bumps happen at specific release events:
| Event | Bump | Example |
|-------|------|---------|
| Feature merged to dev | Patch bump after dev release | `02.09.01-dev` → release → `02.09.02-dev` |
| Dev promoted to RC | Minor bump | `02.09.02-dev` → `02.10.00-rc` |
| RC merged to main | Minor bump | `02.10.00-rc` → `02.11.00` (stable) |
| Dev recreated from main | Patch bump | `02.11.00` → `02.11.01-dev` |
### Release stream copies
When a higher-stability release is published, copies are created for all lesser streams with the same base version:
- **RC `02.10.00-rc`** also creates: `02.10.00-dev`, `02.10.00-alpha`, `02.10.00-beta`
- **Stable `02.11.00`** also creates: `02.11.00-dev`, `02.11.00-alpha`, `02.11.00-beta`, `02.11.00-rc`
This ensures Joomla sites on ANY stability channel see the update (Joomla only shows versions higher than what's installed).
### Version files
The version tools update all files containing version stamps:
- `.mokogitea/manifest.xml` (canonical source)
- Joomla XML manifests (`<version>` tag)
- `README.md`, `CHANGELOG.md` (`VERSION:` pattern)
- `package.json`, `pyproject.toml`
- Any text file with a `VERSION: XX.YY.ZZ` label
Files synced from other repos (with a `# REPO:` header) are not touched.
## Code Standards
- **PHP**: PSR-12, tabs for indentation
- **Copyright**: all files must include the Moko Consulting copyright header
- **License**: SPDX identifier `GPL-3.0-or-later` (or as specified per repo)
- **Attribution**: use `Authored-by: Moko Consulting` in commits, not individual names
## Commit Messages
Use conventional commit format:
```
type(scope): short description
Optional body with context.
Authored-by: Moko Consulting
```
Types: `feat`, `fix`, `chore`, `docs`, `style`, `refactor`, `test`, `ci`
Special flags in commit messages:
- `[skip ci]` — skip all CI workflows
- `[skip bump]` — skip auto version bump only
## Reporting Issues
Use the repository's issue tracker with the appropriate template.
---
*Moko Consulting <hello@mokoconsulting.tech>*
+164
View File
@@ -0,0 +1,164 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package licenses
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
)
func init() {
db.RegisterModel(new(LicenseKey))
}
// LicenseKey represents an individual key issued from a LicensePackage.
type LicenseKey struct {
ID int64 `xorm:"pk autoincr"`
PackageID int64 `xorm:"INDEX NOT NULL"` // FK to license_package
OwnerID int64 `xorm:"INDEX NOT NULL"` // org or user that issued it
KeyHash string `xorm:"UNIQUE NOT NULL"` // SHA-256 of the raw key
KeyPrefix string `xorm:"NOT NULL"` // first 8 chars for display
LicenseeName string `xorm:""` // customer name
LicenseeEmail string `xorm:""` // customer email
DomainRestriction string `xorm:"TEXT"` // comma-separated allowed domains
MaxSites int `xorm:"NOT NULL DEFAULT 0"` // 0 = use package default
IsInternal bool `xorm:"NOT NULL DEFAULT false"` // true = base org/repo key
IsActive bool `xorm:"NOT NULL DEFAULT true"`
StartsUnix timeutil.TimeStamp `xorm:"NOT NULL DEFAULT 0"` // custom start, 0 = creation
ExpiresUnix timeutil.TimeStamp `xorm:"NOT NULL DEFAULT 0"` // 0 = never
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED"`
UpdatedUnix timeutil.TimeStamp `xorm:"UPDATED"`
}
func (LicenseKey) TableName() string {
return "license_key"
}
// GenerateKeyString creates a random license key in MOKO-XXXX-XXXX-XXXX-XXXX format.
func GenerateKeyString() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
hex := strings.ToUpper(hex.EncodeToString(b))
return fmt.Sprintf("MOKO-%s-%s-%s-%s", hex[0:4], hex[4:8], hex[8:12], hex[12:16]), nil
}
// HashKey returns the SHA-256 hash of a raw key string.
func HashKey(rawKey string) string {
h := sha256.Sum256([]byte(rawKey))
return hex.EncodeToString(h[:])
}
// CreateLicenseKey generates a new key, hashes it, stores it, and returns the raw key.
// The raw key is only available at creation time.
func CreateLicenseKey(ctx context.Context, key *LicenseKey) (rawKey string, err error) {
rawKey, err = GenerateKeyString()
if err != nil {
return "", fmt.Errorf("GenerateKeyString: %w", err)
}
key.KeyHash = HashKey(rawKey)
key.KeyPrefix = rawKey[:12] + "..."
if _, err := db.GetEngine(ctx).Insert(key); err != nil {
return "", err
}
return rawKey, nil
}
// GetLicenseKeyByHash looks up a key by its SHA-256 hash.
func GetLicenseKeyByHash(ctx context.Context, hash string) (*LicenseKey, error) {
key := new(LicenseKey)
has, err := db.GetEngine(ctx).Where("key_hash = ?", hash).Get(key)
if err != nil {
return nil, err
}
if !has {
return nil, db.ErrNotExist{Resource: "LicenseKey"}
}
return key, nil
}
// GetLicenseKeyByID returns a key by its ID.
func GetLicenseKeyByID(ctx context.Context, id int64) (*LicenseKey, error) {
key := new(LicenseKey)
has, err := db.GetEngine(ctx).ID(id).Get(key)
if err != nil {
return nil, err
}
if !has {
return nil, db.ErrNotExist{Resource: "LicenseKey", ID: id}
}
return key, nil
}
// ListLicenseKeys returns all keys for the given owner.
func ListLicenseKeys(ctx context.Context, ownerID int64) ([]*LicenseKey, error) {
keys := make([]*LicenseKey, 0, 20)
return keys, db.GetEngine(ctx).Where("owner_id = ?", ownerID).Find(&keys)
}
// ListLicenseKeysByPackage returns all keys for a specific package.
func ListLicenseKeysByPackage(ctx context.Context, packageID int64) ([]*LicenseKey, error) {
keys := make([]*LicenseKey, 0, 20)
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)
return err
}
// DeleteLicenseKey deletes a license key by ID.
func DeleteLicenseKey(ctx context.Context, id int64) error {
_, err := db.GetEngine(ctx).ID(id).Delete(new(LicenseKey))
return err
}
// ValidateLicenseKey validates a raw key string against the database.
// Returns the key record and its associated package, or an error.
func ValidateLicenseKey(ctx context.Context, rawKey string) (*LicenseKey, *LicensePackage, error) {
hash := HashKey(rawKey)
key, err := GetLicenseKeyByHash(ctx, hash)
if err != nil {
return nil, nil, fmt.Errorf("invalid license key")
}
if !key.IsActive {
return nil, nil, fmt.Errorf("license key is deactivated")
}
now := timeutil.TimeStampNow()
if key.StartsUnix > 0 && now < key.StartsUnix {
return nil, nil, fmt.Errorf("license key not yet active")
}
if key.ExpiresUnix > 0 && now > key.ExpiresUnix {
return nil, nil, fmt.Errorf("license key has expired")
}
pkg, err := GetLicensePackageByID(ctx, key.PackageID)
if err != nil {
return nil, nil, fmt.Errorf("license package not found")
}
if !pkg.IsActive {
return nil, nil, fmt.Errorf("license package is deactivated")
}
return key, pkg, nil
}
+49
View File
@@ -0,0 +1,49 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package licenses
import (
"context"
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
)
func init() {
db.RegisterModel(new(LicenseKeyUsage))
}
// LicenseKeyUsage tracks update check activity for a license key.
type LicenseKeyUsage struct {
ID int64 `xorm:"pk autoincr"`
KeyID int64 `xorm:"INDEX NOT NULL"`
RepoID int64 `xorm:"INDEX NOT NULL"`
Domain string `xorm:""` // requesting domain from extra_query
IPAddress string `xorm:""`
UserAgent string `xorm:"TEXT"`
VersionFrom string `xorm:""` // version the client is updating from
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED"`
}
func (LicenseKeyUsage) TableName() string {
return "license_key_usage"
}
// RecordUsage inserts a usage tracking entry.
func RecordUsage(ctx context.Context, usage *LicenseKeyUsage) error {
_, err := db.GetEngine(ctx).Insert(usage)
return err
}
// GetRecentUsage returns the most recent usage entries for a key.
func GetRecentUsage(ctx context.Context, keyID int64, limit int) ([]*LicenseKeyUsage, error) {
usages := make([]*LicenseKeyUsage, 0, limit)
return usages, db.GetEngine(ctx).Where("key_id = ?", keyID).
OrderBy("created_unix DESC").Limit(limit).Find(&usages)
}
// CountUsageByKey returns the total number of update checks for a key.
func CountUsageByKey(ctx context.Context, keyID int64) (int64, error) {
return db.GetEngine(ctx).Where("key_id = ?", keyID).Count(new(LicenseKeyUsage))
}
+90
View File
@@ -0,0 +1,90 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package licenses
import (
"context"
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
"xorm.io/builder"
)
func init() {
db.RegisterModel(new(LicensePackage))
}
// LicensePackage defines a purchasable subscription tier that determines
// what update streams a group of license keys can access.
type LicensePackage struct {
ID int64 `xorm:"pk autoincr"`
OwnerID int64 `xorm:"INDEX NOT NULL"` // org or user that owns this package
Name string `xorm:"NOT NULL"` // e.g. "Pro Annual", "Lifetime"
Description string `xorm:"TEXT"`
DurationDays int `xorm:"NOT NULL DEFAULT 0"` // 0 = unlimited/lifetime
MaxSites int `xorm:"NOT NULL DEFAULT 0"` // 0 = unlimited
RepoScope string `xorm:"TEXT NOT NULL DEFAULT 'all'"` // "all" = org-wide, or JSON array of repo IDs
// AllowedChannels defines which update streams keys from this package
// can access. JSON array, e.g. ["stable","rc"]. Empty = all channels.
AllowedChannels string `xorm:"TEXT"`
IsActive bool `xorm:"NOT NULL DEFAULT true"`
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED"`
UpdatedUnix timeutil.TimeStamp `xorm:"UPDATED"`
}
func (LicensePackage) TableName() string {
return "license_package"
}
// CreateLicensePackage creates a new license package.
func CreateLicensePackage(ctx context.Context, pkg *LicensePackage) error {
_, err := db.GetEngine(ctx).Insert(pkg)
return err
}
// GetLicensePackageByID returns a license package by ID.
func GetLicensePackageByID(ctx context.Context, id int64) (*LicensePackage, error) {
pkg := new(LicensePackage)
has, err := db.GetEngine(ctx).ID(id).Get(pkg)
if err != nil {
return nil, err
}
if !has {
return nil, db.ErrNotExist{Resource: "LicensePackage", ID: id}
}
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)
return pkgs, db.GetEngine(ctx).Where("owner_id = ?", ownerID).Find(&pkgs)
}
// UpdateLicensePackage updates a license package.
func UpdateLicensePackage(ctx context.Context, pkg *LicensePackage) error {
_, err := db.GetEngine(ctx).ID(pkg.ID).AllCols().Update(pkg)
return err
}
// DeleteLicensePackage deletes a license package by ID.
func DeleteLicensePackage(ctx context.Context, id int64) error {
_, err := db.GetEngine(ctx).ID(id).Delete(new(LicensePackage))
return err
}
+89
View File
@@ -0,0 +1,89 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package licenses
import (
"context"
"fmt"
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
)
const (
MasterPackageName = "Master (Internal)"
MasterPackageDesc = "Auto-created master package with unlimited access to all channels."
)
// EnsureMasterKey ensures that a master license package and key exist for the given owner.
// Returns the master key's raw key string only if it was just created (empty string otherwise).
func EnsureMasterKey(ctx context.Context, ownerID int64) (rawKey string, err error) {
// Check if a master package already exists.
pkgs, err := ListLicensePackages(ctx, ownerID)
if err != nil {
return "", err
}
var masterPkg *LicensePackage
for _, pkg := range pkgs {
if pkg.Name == MasterPackageName {
masterPkg = pkg
break
}
}
// Create master package if it doesn't exist.
if masterPkg == nil {
masterPkg = &LicensePackage{
OwnerID: ownerID,
Name: MasterPackageName,
Description: MasterPackageDesc,
DurationDays: 0, // lifetime
MaxSites: 0, // unlimited
RepoScope: "all",
IsActive: true,
}
if err := CreateLicensePackage(ctx, masterPkg); err != nil {
return "", fmt.Errorf("CreateLicensePackage: %w", err)
}
}
// Check if a master key already exists for this package.
keys, err := ListLicenseKeysByPackage(ctx, masterPkg.ID)
if err != nil {
return "", err
}
for _, key := range keys {
if key.IsInternal {
return "", nil // already exists, don't return raw key
}
}
// Create the master key.
masterKey := &LicenseKey{
PackageID: masterPkg.ID,
OwnerID: ownerID,
IsInternal: true,
IsActive: true,
}
rawKey, err = CreateLicenseKey(ctx, masterKey)
if err != nil {
return "", fmt.Errorf("CreateLicenseKey: %w", err)
}
return rawKey, nil
}
// GetMasterKey returns the master key for an owner, if it exists.
func GetMasterKey(ctx context.Context, ownerID int64) (*LicenseKey, error) {
key := new(LicenseKey)
has, err := db.GetEngine(ctx).Where("owner_id = ? AND is_internal = ?", ownerID, true).Get(key)
if err != nil {
return nil, err
}
if !has {
return nil, nil
}
return key, nil
}
+180
View File
@@ -0,0 +1,180 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// 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"
}
+2
View File
@@ -412,6 +412,8 @@ func prepareMigrationTasks() []*migration {
newMigration(332, "Add org-level branch protection rulesets", v1_27.AddOrgProtectedBranchTable),
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
}
+75
View File
@@ -0,0 +1,75 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package v1_27
import (
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
"xorm.io/xorm"
)
type licensePackage335 struct {
ID int64 `xorm:"pk autoincr"`
OwnerID int64 `xorm:"INDEX NOT NULL"`
Name string `xorm:"NOT NULL"`
Description string `xorm:"TEXT"`
DurationDays int `xorm:"NOT NULL DEFAULT 0"`
MaxSites int `xorm:"NOT NULL DEFAULT 0"`
RepoScope string `xorm:"TEXT NOT NULL DEFAULT 'all'"`
AllowedChannels string `xorm:"TEXT"`
IsActive bool `xorm:"NOT NULL DEFAULT true"`
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED"`
UpdatedUnix timeutil.TimeStamp `xorm:"UPDATED"`
}
func (licensePackage335) TableName() string {
return "license_package"
}
type licenseKey335 struct {
ID int64 `xorm:"pk autoincr"`
PackageID int64 `xorm:"INDEX NOT NULL"`
OwnerID int64 `xorm:"INDEX NOT NULL"`
KeyHash string `xorm:"UNIQUE NOT NULL"`
KeyPrefix string `xorm:"NOT NULL"`
LicenseeName string `xorm:""`
LicenseeEmail string `xorm:""`
DomainRestriction string `xorm:"TEXT"`
MaxSites int `xorm:"NOT NULL DEFAULT 0"`
IsInternal bool `xorm:"NOT NULL DEFAULT false"`
IsActive bool `xorm:"NOT NULL DEFAULT true"`
StartsUnix timeutil.TimeStamp `xorm:"NOT NULL DEFAULT 0"`
ExpiresUnix timeutil.TimeStamp `xorm:"NOT NULL DEFAULT 0"`
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED"`
UpdatedUnix timeutil.TimeStamp `xorm:"UPDATED"`
}
func (licenseKey335) TableName() string {
return "license_key"
}
type licenseKeyUsage335 struct {
ID int64 `xorm:"pk autoincr"`
KeyID int64 `xorm:"INDEX NOT NULL"`
RepoID int64 `xorm:"INDEX NOT NULL"`
Domain string `xorm:""`
IPAddress string `xorm:""`
UserAgent string `xorm:"TEXT"`
VersionFrom string `xorm:""`
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED"`
}
func (licenseKeyUsage335) TableName() string {
return "license_key_usage"
}
// AddLicenseKeyTables creates the license_package, license_key, and
// license_key_usage tables for the update server license system.
func AddLicenseKeyTables(x *xorm.Engine) error {
return x.Sync(
new(licensePackage335),
new(licenseKey335),
new(licenseKeyUsage335),
)
}
+29
View File
@@ -0,0 +1,29 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// 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))
}
+1 -1
View File
@@ -81,7 +81,7 @@ func initDefaultConfig() {
Instance: &InstanceStruct{
WebBanner: config.NewOption[WebBannerType]("instance.web_banner"),
MaintenanceMode: config.NewOption[MaintenanceModeType]("instance.maintenance_mode"),
LandingPage: config.NewOption[LandingPageType]("instance.landing_page").WithFileConfig(config.CfgSecKey{Sec: "server", Key: "LANDING_PAGE"}),
LandingPage: config.NewOption[LandingPageType]("instance.landing_page"),
},
}
}
+107
View File
@@ -0,0 +1,107 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package structs
import "time"
// LicensePackage represents a license package (subscription tier).
type LicensePackage struct {
ID int64 `json:"id"`
OwnerID int64 `json:"owner_id"`
Name string `json:"name"`
Description string `json:"description"`
DurationDays int `json:"duration_days"`
MaxSites int `json:"max_sites"`
RepoScope string `json:"repo_scope"`
AllowedChannels string `json:"allowed_channels"`
IsActive bool `json:"is_active"`
// swagger:strfmt date-time
Created time.Time `json:"created_at"`
// swagger:strfmt date-time
Updated time.Time `json:"updated_at"`
}
// CreateLicensePackageOption options for creating a license package.
type CreateLicensePackageOption struct {
Name string `json:"name" binding:"Required"`
Description string `json:"description"`
DurationDays int `json:"duration_days"`
MaxSites int `json:"max_sites"`
RepoScope string `json:"repo_scope"`
AllowedChannels string `json:"allowed_channels"`
}
// EditLicensePackageOption options for editing a license package.
type EditLicensePackageOption struct {
Name *string `json:"name"`
Description *string `json:"description"`
DurationDays *int `json:"duration_days"`
MaxSites *int `json:"max_sites"`
RepoScope *string `json:"repo_scope"`
AllowedChannels *string `json:"allowed_channels"`
IsActive *bool `json:"is_active"`
}
// LicenseKey represents a license key (response — never includes raw key except on creation).
type LicenseKey struct {
ID int64 `json:"id"`
PackageID int64 `json:"package_id"`
OwnerID int64 `json:"owner_id"`
KeyPrefix string `json:"key_prefix"`
LicenseeName string `json:"licensee_name"`
LicenseeEmail string `json:"licensee_email"`
DomainRestriction string `json:"domain_restriction"`
MaxSites int `json:"max_sites"`
IsInternal bool `json:"is_internal"`
IsActive bool `json:"is_active"`
// swagger:strfmt date-time
StartsAt *time.Time `json:"starts_at"`
// swagger:strfmt date-time
ExpiresAt *time.Time `json:"expires_at"`
// swagger:strfmt date-time
Created time.Time `json:"created_at"`
}
// LicenseKeyCreated is the response when a key is first created (includes raw key).
type LicenseKeyCreated struct {
LicenseKey
// RawKey is the full license key string. Only returned on creation.
RawKey string `json:"raw_key"`
}
// CreateLicenseKeyOption options for creating a license key.
type CreateLicenseKeyOption struct {
PackageID int64 `json:"package_id" binding:"Required"`
LicenseeName string `json:"licensee_name"`
LicenseeEmail string `json:"licensee_email"`
DomainRestriction string `json:"domain_restriction"`
MaxSites int `json:"max_sites"`
// StartsAt is optional; defaults to now.
StartsAt *time.Time `json:"starts_at"`
// ExpiresAt is optional; auto-calculated from package duration if not set.
ExpiresAt *time.Time `json:"expires_at"`
}
// EditLicenseKeyOption options for editing a license key.
type EditLicenseKeyOption struct {
LicenseeName *string `json:"licensee_name"`
LicenseeEmail *string `json:"licensee_email"`
DomainRestriction *string `json:"domain_restriction"`
MaxSites *int `json:"max_sites"`
IsActive *bool `json:"is_active"`
ExpiresAt *time.Time `json:"expires_at"`
}
// LicenseKeyUsage represents a usage tracking entry.
type LicenseKeyUsage struct {
ID int64 `json:"id"`
KeyID int64 `json:"key_id"`
RepoID int64 `json:"repo_id"`
Domain string `json:"domain"`
IPAddress string `json:"ip_address"`
UserAgent string `json:"user_agent"`
VersionFrom string `json:"version_from"`
// swagger:strfmt date-time
Created time.Time `json:"created_at"`
}
+38
View File
@@ -2144,6 +2144,10 @@
"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.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)",
@@ -2608,6 +2612,40 @@
"repo.release.detail": "Release details",
"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.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.master_key_created": "Master License Key Created",
"repo.licenses.master_key_created_copy": "This is your organization master key with unlimited access to all update channels. Copy it now — it will not be shown again.",
"repo.licenses.update_feeds": "Update Feed URLs",
"repo.release.draft": "Draft",
"repo.release.prerelease": "Pre-Release",
"repo.release.stable": "Stable",
+12
View File
@@ -1347,6 +1347,18 @@ func Routes() *web.Router {
Delete(reqToken(), reqRepoWriter(unit.TypeReleases), repo.DeleteReleaseByTag)
})
}, reqRepoReader(unit.TypeReleases))
m.Group("/license-packages", func() {
m.Combo("").Get(repo.ListLicensePackages).
Post(bind(api.CreateLicensePackageOption{}), repo.CreateLicensePackage)
}, reqToken(), reqAdmin())
m.Group("/license-keys", func() {
m.Combo("").Get(repo.ListLicenseKeys).
Post(bind(api.CreateLicenseKeyOption{}), repo.CreateLicenseKey)
m.Group("/{id}", func() {
m.Delete("", repo.DeleteLicenseKey)
m.Get("/usage", repo.GetLicenseKeyUsage)
})
}, reqToken(), reqAdmin())
m.Post("/mirror-sync", reqToken(), reqRepoWriter(unit.TypeCode), mustNotBeArchived, repo.MirrorSync)
m.Post("/push_mirrors-sync", reqAdmin(), reqToken(), mustNotBeArchived, repo.PushMirrorSync)
m.Group("/push_mirrors", func() {
+195
View File
@@ -0,0 +1,195 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// 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/structs"
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/web"
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
)
func toLicensePackageAPI(pkg *licenses.LicensePackage) *structs.LicensePackage {
return &structs.LicensePackage{
ID: pkg.ID,
OwnerID: pkg.OwnerID,
Name: pkg.Name,
Description: pkg.Description,
DurationDays: pkg.DurationDays,
MaxSites: pkg.MaxSites,
RepoScope: pkg.RepoScope,
AllowedChannels: pkg.AllowedChannels,
IsActive: pkg.IsActive,
Created: time.Unix(int64(pkg.CreatedUnix), 0),
Updated: time.Unix(int64(pkg.UpdatedUnix), 0),
}
}
func toLicenseKeyAPI(key *licenses.LicenseKey) *structs.LicenseKey {
lk := &structs.LicenseKey{
ID: key.ID,
PackageID: key.PackageID,
OwnerID: key.OwnerID,
KeyPrefix: key.KeyPrefix,
LicenseeName: key.LicenseeName,
LicenseeEmail: key.LicenseeEmail,
DomainRestriction: key.DomainRestriction,
MaxSites: key.MaxSites,
IsInternal: key.IsInternal,
IsActive: key.IsActive,
Created: time.Unix(int64(key.CreatedUnix), 0),
}
if key.StartsUnix > 0 {
t := time.Unix(int64(key.StartsUnix), 0)
lk.StartsAt = &t
}
if key.ExpiresUnix > 0 {
t := time.Unix(int64(key.ExpiresUnix), 0)
lk.ExpiresAt = &t
}
return lk
}
// ListLicensePackages lists license packages for the repo owner.
func ListLicensePackages(ctx *context.APIContext) {
pkgs, err := licenses.ListLicensePackages(ctx, ctx.Repo.Repository.OwnerID)
if err != nil {
ctx.APIErrorInternal(err)
return
}
result := make([]*structs.LicensePackage, len(pkgs))
for i, pkg := range pkgs {
result[i] = toLicensePackageAPI(pkg)
}
ctx.JSON(http.StatusOK, result)
}
// CreateLicensePackage creates a new license package.
func CreateLicensePackage(ctx *context.APIContext) {
form := web.GetForm(ctx).(*structs.CreateLicensePackageOption)
pkg := &licenses.LicensePackage{
OwnerID: ctx.Repo.Repository.OwnerID,
Name: form.Name,
Description: form.Description,
DurationDays: form.DurationDays,
MaxSites: form.MaxSites,
RepoScope: form.RepoScope,
AllowedChannels: form.AllowedChannels,
IsActive: true,
}
if pkg.RepoScope == "" {
pkg.RepoScope = "all"
}
if err := licenses.CreateLicensePackage(ctx, pkg); err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.JSON(http.StatusCreated, toLicensePackageAPI(pkg))
}
// ListLicenseKeys lists license keys for the repo owner.
func ListLicenseKeys(ctx *context.APIContext) {
keys, err := licenses.ListLicenseKeys(ctx, ctx.Repo.Repository.OwnerID)
if err != nil {
ctx.APIErrorInternal(err)
return
}
result := make([]*structs.LicenseKey, len(keys))
for i, key := range keys {
result[i] = toLicenseKeyAPI(key)
}
ctx.JSON(http.StatusOK, result)
}
// CreateLicenseKey creates a new license key.
func CreateLicenseKey(ctx *context.APIContext) {
form := web.GetForm(ctx).(*structs.CreateLicenseKeyOption)
key := &licenses.LicenseKey{
PackageID: form.PackageID,
OwnerID: ctx.Repo.Repository.OwnerID,
LicenseeName: form.LicenseeName,
LicenseeEmail: form.LicenseeEmail,
DomainRestriction: form.DomainRestriction,
MaxSites: form.MaxSites,
IsActive: true,
}
if form.StartsAt != nil {
key.StartsUnix = timeutil.TimeStamp(form.StartsAt.Unix())
}
if form.ExpiresAt != nil {
key.ExpiresUnix = timeutil.TimeStamp(form.ExpiresAt.Unix())
} else {
// Auto-calculate from package duration.
pkg, err := licenses.GetLicensePackageByID(ctx, form.PackageID)
if err != nil {
ctx.APIErrorInternal(err)
return
}
if pkg.DurationDays > 0 {
start := time.Now()
if form.StartsAt != nil {
start = *form.StartsAt
}
expires := start.AddDate(0, 0, pkg.DurationDays)
key.ExpiresUnix = timeutil.TimeStamp(expires.Unix())
}
}
rawKey, err := licenses.CreateLicenseKey(ctx, key)
if err != nil {
ctx.APIErrorInternal(err)
return
}
resp := &structs.LicenseKeyCreated{
LicenseKey: *toLicenseKeyAPI(key),
RawKey: rawKey,
}
ctx.JSON(http.StatusCreated, resp)
}
// DeleteLicenseKey deletes a license key.
func DeleteLicenseKey(ctx *context.APIContext) {
if err := licenses.DeleteLicenseKey(ctx, ctx.PathParamInt64("id")); err != nil {
ctx.APIErrorInternal(err)
return
}
ctx.Status(http.StatusNoContent)
}
// GetLicenseKeyUsage returns usage logs for a license key.
func GetLicenseKeyUsage(ctx *context.APIContext) {
usages, err := licenses.GetRecentUsage(ctx, ctx.PathParamInt64("id"), 100)
if err != nil {
ctx.APIErrorInternal(err)
return
}
result := make([]*structs.LicenseKeyUsage, len(usages))
for i, u := range usages {
result[i] = &structs.LicenseKeyUsage{
ID: u.ID,
KeyID: u.KeyID,
RepoID: u.RepoID,
Domain: u.Domain,
IPAddress: u.IPAddress,
UserAgent: u.UserAgent,
VersionFrom: u.VersionFrom,
Created: time.Unix(int64(u.CreatedUnix), 0),
}
}
ctx.JSON(http.StatusOK, result)
}
+179
View File
@@ -0,0 +1,179 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package org
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"
)
const tplOrgLicenses templates.TplName = "org/licenses"
// LicensePackageDisplay is used in templates.
type LicensePackageDisplay struct {
*licenses.LicensePackage
KeyCount int64
Created time.Time
}
// Licenses shows the org-level license packages and keys.
func Licenses(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("repo.licenses")
ctx.Data["IsLicensesPage"] = true
org := ctx.Org.Organization
ownerID := org.ID
// Auto-create master key if org owner.
if ctx.Org.IsOwner {
newMasterKey, err := licenses.EnsureMasterKey(ctx, ownerID)
if err != nil {
ctx.ServerError("EnsureMasterKey", err)
return
}
if newMasterKey != "" {
ctx.Data["NewMasterKey"] = newMasterKey
}
}
pkgs, err := licenses.ListLicensePackages(ctx, ownerID)
if err != nil {
ctx.ServerError("ListLicensePackages", err)
return
}
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
keys, err := licenses.ListLicenseKeys(ctx, ownerID)
if err != nil {
ctx.ServerError("ListLicenseKeys", err)
return
}
ctx.Data["LicenseKeys"] = keys
ctx.Data["IsRepoAdmin"] = ctx.Org.IsOwner
ctx.HTML(http.StatusOK, tplOrgLicenses)
}
// LicensesCreatePackage handles POST to create a new org-level license package.
func LicensesCreatePackage(ctx *context.Context) {
name := ctx.FormString("name")
if name == "" {
ctx.Flash.Error("Package name is required")
ctx.Redirect(ctx.Org.OrgLink + "/-/licenses")
return
}
durationDays, _ := strconv.Atoi(ctx.FormString("duration_days"))
maxSites, _ := strconv.Atoi(ctx.FormString("max_sites"))
pkg := &licenses.LicensePackage{
OwnerID: ctx.Org.Organization.ID,
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.Org.OrgLink + "/-/licenses")
}
// LicensesGenerateKey handles POST to generate a key from an org 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.Org.OrgLink + "/-/licenses")
return
}
pkg, err := licenses.GetLicensePackageByID(ctx, packageID)
if err != nil {
ctx.ServerError("GetLicensePackageByID", err)
return
}
key := &licenses.LicenseKey{
PackageID: packageID,
OwnerID: ctx.Org.Organization.ID,
IsActive: true,
}
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
}
// Re-render with the new key shown.
ctx.Data["Title"] = ctx.Tr("repo.licenses")
ctx.Data["IsLicensesPage"] = true
ctx.Data["IsRepoAdmin"] = ctx.Org.IsOwner
ctx.Data["NewKeyCreated"] = rawKey
ownerID := ctx.Org.Organization.ID
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, tplOrgLicenses)
}
// LicensesRevokeKey handles POST to revoke an org 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.Org.OrgLink + "/-/licenses")
}
+181
View File
@@ -0,0 +1,181 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
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"
)
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
ctx.Data["IsRepoAdmin"] = ctx.Repo.Permission.IsAdmin()
ownerID := ctx.Repo.Repository.OwnerID
// Auto-create master package + key if admin and none exist.
if ctx.Repo.Permission.IsAdmin() {
newMasterKey, err := licenses.EnsureMasterKey(ctx, ownerID)
if err != nil {
ctx.ServerError("EnsureMasterKey", err)
return
}
if newMasterKey != "" {
ctx.Data["NewMasterKey"] = newMasterKey
}
}
pkgs, err := licenses.ListLicensePackages(ctx, ownerID)
if err != nil {
ctx.ServerError("ListLicensePackages", err)
return
}
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
keys, err := licenses.ListLicenseKeys(ctx, ownerID)
if err != nil {
ctx.ServerError("ListLicenseKeys", err)
return
}
ctx.Data["LicenseKeys"] = keys
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.Permission.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")
}
+25 -5
View File
@@ -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)
}
+111
View File
@@ -0,0 +1,111 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package repo
import (
"net/http"
"strings"
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licenses"
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/json"
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/services/updateserver"
)
// validateUpdateKey checks for a license key in the request and validates it.
// Returns allowed channels (nil = all channels) and whether access is granted.
func validateUpdateKey(ctx *context.Context) (allowedChannels []string, ok bool) {
rawKey := ctx.FormString("key")
if rawKey == "" {
rawKey = ctx.FormString("download_key")
}
if rawKey == "" {
// No key provided — allow public access (all channels).
return nil, true
}
key, pkg, err := licenses.ValidateLicenseKey(ctx, rawKey)
if err != nil {
log.Debug("License key validation failed: %v", err)
return nil, false
}
// Record usage.
_ = licenses.RecordUsage(ctx, &licenses.LicenseKeyUsage{
KeyID: key.ID,
RepoID: ctx.Repo.Repository.ID,
Domain: ctx.FormString("domain"),
IPAddress: ctx.RemoteAddr(),
UserAgent: ctx.Req.UserAgent(),
VersionFrom: ctx.FormString("version"),
})
// Parse allowed channels from the package.
if pkg.AllowedChannels != "" {
channels := strings.Split(pkg.AllowedChannels, ",")
for i := range channels {
channels[i] = strings.TrimSpace(channels[i])
}
// Also try JSON array format.
if strings.HasPrefix(pkg.AllowedChannels, "[") {
var parsed []string
if err := json.Unmarshal([]byte(pkg.AllowedChannels), &parsed); err == nil {
channels = parsed
}
}
// Normalize shorthand names to full Joomla convention.
for i := range channels {
channels[i] = updateserver.NormalizeChannel(channels[i])
}
return channels, true
}
// Master/internal keys or packages with no channel restriction — all channels.
return nil, true
}
// ServeUpdatesXML generates and serves a Joomla-compatible updates.xml
// from the repository's releases.
func ServeUpdatesXML(ctx *context.Context) {
allowedChannels, ok := validateUpdateKey(ctx)
if !ok {
// Return empty updates XML for invalid keys (Joomla-compatible).
ctx.Resp.Header().Set("Content-Type", "application/xml; charset=utf-8")
ctx.Resp.WriteHeader(http.StatusOK)
_, _ = ctx.Resp.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?><updates></updates>`))
return
}
xmlData, err := updateserver.GenerateJoomlaXML(ctx, ctx.Repo.Repository, allowedChannels...)
if err != nil {
ctx.ServerError("GenerateJoomlaXML", err)
return
}
ctx.Resp.Header().Set("Content-Type", "application/xml; charset=utf-8")
ctx.Resp.WriteHeader(http.StatusOK)
_, _ = ctx.Resp.Write(xmlData)
}
// ServeDolibarrJSON generates and serves a Dolibarr-compatible update feed
// from the repository's releases.
func ServeDolibarrJSON(ctx *context.Context) {
data, err := updateserver.GenerateDolibarrJSON(ctx, ctx.Repo.Repository)
if err != nil {
ctx.ServerError("GenerateDolibarrJSON", err)
return
}
jsonData, err := json.MarshalIndent(data, "", " ")
if err != nil {
ctx.ServerError("json.Marshal", err)
return
}
ctx.Resp.Header().Set("Content-Type", "application/json; charset=utf-8")
ctx.Resp.WriteHeader(http.StatusOK)
_, _ = ctx.Resp.Write(jsonData)
}
+23
View File
@@ -1099,6 +1099,13 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
// at the moment, only editing "owner-level projects" need to "mention", maybe in the future we can relax the permission check
m.Get("/mentions-in-owner", reqUnitAccess(unit.TypeProjects, perm.AccessModeWrite, true), org.GetMentionsInOwner)
m.Group("/licenses", func() {
m.Get("", org.Licenses)
m.Post("/packages", org.LicensesCreatePackage)
m.Post("/keys/generate", org.LicensesGenerateKey)
m.Post("/keys/{id}/revoke", org.LicensesRevokeKey)
})
m.Get("/repositories", org.Repositories)
m.Get("/heatmap", user.DashboardHeatmap)
@@ -1494,6 +1501,22 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
}, optSignIn, context.RepoAssignment, repo.MustBeNotEmpty, reqRepoReleaseReader)
// end "/{username}/{reponame}": repo releases
// "/{username}/{reponame}": update server endpoints
m.Group("/{username}/{reponame}", func() {
m.Get("/updates.xml", repo.ServeUpdatesXML)
m.Get("/updates/dolibarr.json", repo.ServeDolibarrJSON)
}, optSignIn, context.RepoAssignment)
// end "/{username}/{reponame}": update server
// "/{username}/{reponame}": licenses page
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
m.Group("/{username}/{reponame}", func() { // to maintain compatibility with old attachments
m.Get("/attachments/{uuid}", webAuth.AllowBasic, webAuth.AllowOAuth2, repo.GetAttachment)
}, optSignIn, context.RepoAssignment)
+9
View File
@@ -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,14 @@ 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["IsRepoAdmin"] = ctx.Repo.Permission.IsAdmin()
ctx.Data["Title"] = repo.Owner.Name + "/" + repo.Name
ctx.Data["PageTitleCommon"] = repo.Name + " - " + setting.AppName
ctx.Data["Repository"] = repo
+11 -7
View File
@@ -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
+119
View File
@@ -0,0 +1,119 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package updateserver
import (
"context"
"fmt"
"strings"
"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"
)
// DolibarrUpdate represents a single module update entry in Dolibarr format.
type DolibarrUpdate struct {
Name string `json:"name"`
Version string `json:"version"`
Channel string `json:"channel"`
DownloadURL string `json:"url"`
ChangelogURL string `json:"changelog"`
ReleaseURL string `json:"release_url"`
Requires string `json:"requires,omitempty"`
Date string `json:"date"`
SHA256 string `json:"sha256,omitempty"`
}
// DolibarrUpdates holds the full update feed response.
type DolibarrUpdates struct {
Module string `json:"module"`
Updates []DolibarrUpdate `json:"updates"`
}
// GenerateDolibarrJSON builds a Dolibarr-compatible update feed from releases.
func GenerateDolibarrJSON(ctx context.Context, repo *repo_model.Repository) (*DolibarrUpdates, error) {
releases, err := db.Find[repo_model.Release](ctx, repo_model.FindReleasesOptions{
RepoID: repo.ID,
ListOptions: db.ListOptionsAll,
IncludeDrafts: false,
IncludeTags: false,
})
if err != nil {
return nil, fmt.Errorf("FindReleases: %w", err)
}
if err := repo.LoadOwner(ctx); err != nil {
return nil, fmt.Errorf("LoadOwner: %w", err)
}
baseURL := strings.TrimSuffix(setting.AppURL, "/")
repoLink := fmt.Sprintf("%s/%s/%s", baseURL, repo.Owner.Name, repo.Name)
result := &DolibarrUpdates{
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 := licenses.MatchStreamFromTag(rel.TagName, rel.IsPrerelease, streams)
existing, ok := bestByChannel[ch]
if !ok || rel.CreatedUnix > existing.CreatedUnix {
bestByChannel[ch] = rel
}
}
for _, stream := range streams {
ch := stream.Name
rel, ok := bestByChannel[ch]
if !ok {
continue
}
if err := rel.LoadAttributes(ctx); err != nil {
continue
}
var downloadURL string
for _, att := range rel.Attachments {
if strings.HasSuffix(strings.ToLower(att.Name), ".zip") {
downloadURL = fmt.Sprintf("%s/releases/download/%s/%s", repoLink, rel.TagName, att.Name)
break
}
}
if downloadURL == "" {
downloadURL = fmt.Sprintf("%s/archive/%s.zip", repoLink, rel.TagName)
}
version := extractVersion(rel.TagName)
suffix := stream.Suffix
if suffix == "" {
suffix = channelSuffix(ch)
}
if suffix != "" {
version = version + suffix
}
result.Updates = append(result.Updates, DolibarrUpdate{
Name: repo.Name,
Version: version,
Channel: ch,
DownloadURL: downloadURL,
ChangelogURL: fmt.Sprintf("%s/raw/branch/%s/CHANGELOG.md", repoLink, repo.DefaultBranch),
ReleaseURL: fmt.Sprintf("%s/releases/tag/%s", repoLink, rel.TagName),
Date: time.Unix(int64(rel.CreatedUnix), 0).Format("2006-01-02"),
})
}
return result, nil
}
+278
View File
@@ -0,0 +1,278 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package updateserver
import (
"context"
"encoding/xml"
"fmt"
"strings"
"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"
)
// Joomla-compatible updates.xml structures for XML marshaling.
type xmlUpdates struct {
XMLName xml.Name `xml:"updates"`
Updates []xmlUpdate `xml:"update"`
}
type xmlUpdate struct {
Name string `xml:"name"`
Description string `xml:"description"`
Element string `xml:"element"`
Type string `xml:"type"`
Client string `xml:"client"`
Version string `xml:"version"`
CreationDate string `xml:"creationDate"`
InfoURL xmlInfoURL `xml:"infourl"`
Downloads xmlDownloads `xml:"downloads"`
SHA256 string `xml:"sha256,omitempty"`
Tags xmlTags `xml:"tags"`
ChangelogURL string `xml:"changelogurl,omitempty"`
Maintainer string `xml:"maintainer,omitempty"`
MaintainerURL string `xml:"maintainerurl,omitempty"`
TargetPlatform xmlTargetPlat `xml:"targetplatform"`
}
type xmlInfoURL struct {
Title string `xml:"title,attr"`
URL string `xml:",chardata"`
}
type xmlDownloads struct {
DownloadURL []xmlDownloadURL `xml:"downloadurl"`
}
type xmlDownloadURL struct {
Type string `xml:"type,attr"`
Format string `xml:"format,attr"`
URL string `xml:",chardata"`
}
type xmlTags struct {
Tag string `xml:"tag"`
}
type xmlTargetPlat struct {
Name string `xml:"name,attr"`
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 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 ChannelReleaseCandidate
case isPrerelease:
return ChannelReleaseCandidate
default:
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
}
}
// GenerateJoomlaXML builds a Joomla-compatible updates.xml from repository releases.
// It returns the raw XML bytes. The element, maintainer, and target platform
// are derived from the repo name and owner.
// allowedChannels optionally restricts output to specific channels (nil = all).
func GenerateJoomlaXML(ctx context.Context, repo *repo_model.Repository, allowedChannels ...string) ([]byte, error) {
releases, err := db.Find[repo_model.Release](ctx, repo_model.FindReleasesOptions{
RepoID: repo.ID,
ListOptions: db.ListOptionsAll,
IncludeDrafts: false,
IncludeTags: false,
})
if err != nil {
return nil, fmt.Errorf("GetReleasesByRepoID: %w", err)
}
if err := repo.LoadOwner(ctx); err != nil {
return nil, fmt.Errorf("LoadOwner: %w", err)
}
baseURL := setting.AppURL
if strings.HasSuffix(baseURL, "/") {
baseURL = baseURL[:len(baseURL)-1]
}
repoLink := fmt.Sprintf("%s/%s/%s", baseURL, repo.Owner.Name, repo.Name)
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 := licenses.MatchStreamFromTag(rel.TagName, rel.IsPrerelease, streams)
existing, ok := bestByChannel[ch]
if !ok || rel.CreatedUnix > existing.CreatedUnix {
bestByChannel[ch] = rel
}
}
// 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[NormalizeChannel(c)] = true
}
}
var updates xmlUpdates
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
}
rel, ok := bestByChannel[ch]
if !ok {
continue
}
// Load attachments for download URLs.
if err := rel.LoadAttributes(ctx); err != nil {
continue
}
// Find the first .zip attachment as the download URL.
var downloadURL string
for _, att := range rel.Attachments {
if strings.HasSuffix(strings.ToLower(att.Name), ".zip") {
downloadURL = fmt.Sprintf("%s/releases/download/%s/%s", repoLink, rel.TagName, att.Name)
break
}
}
// Fall back to the release tag archive if no zip attachment.
if downloadURL == "" {
downloadURL = fmt.Sprintf("%s/archive/%s.zip", repoLink, rel.TagName)
}
version := extractVersion(rel.TagName)
suffix := stream.Suffix
if suffix == "" {
suffix = channelSuffix(ch) // fallback for Joomla defaults
}
if suffix != "" {
version = version + suffix
}
u := xmlUpdate{
Name: fmt.Sprintf("%s - %s", repo.Owner.Name, repo.Name),
Description: fmt.Sprintf("%s - %s %s build.", repo.Owner.Name, repo.Name, ch),
Element: element,
Type: "component",
Client: "site",
Version: version,
CreationDate: time.Unix(int64(rel.CreatedUnix), 0).Format("2006-01-02"),
InfoURL: xmlInfoURL{
Title: fmt.Sprintf("%s - %s", repo.Owner.Name, repo.Name),
URL: fmt.Sprintf("%s/releases/tag/%s", repoLink, rel.TagName),
},
Downloads: xmlDownloads{
DownloadURL: []xmlDownloadURL{
{Type: "full", Format: "zip", URL: downloadURL},
},
},
Tags: xmlTags{Tag: ch},
ChangelogURL: fmt.Sprintf("%s/raw/branch/%s/CHANGELOG.md", repoLink, repo.DefaultBranch),
Maintainer: repo.Owner.Name,
MaintainerURL: fmt.Sprintf("%s/%s", baseURL, repo.Owner.Name),
TargetPlatform: xmlTargetPlat{
Name: "joomla",
Version: ".*",
},
}
updates.Updates = append(updates.Updates, u)
}
output, err := xml.MarshalIndent(updates, "", " ")
if err != nil {
return nil, fmt.Errorf("xml.MarshalIndent: %w", err)
}
return append([]byte(xml.Header), output...), nil
}
// extractVersion strips common tag prefixes (v, release-, etc.) to get the version.
func extractVersion(tagName string) string {
v := tagName
v = strings.TrimPrefix(v, "v")
v = strings.TrimPrefix(v, "release-")
v = strings.TrimPrefix(v, "release/")
// Strip channel suffixes to get base version.
for _, suffix := range []string{"-dev", "-alpha", "-beta", "-rc", "-development", "-release-candidate"} {
if idx := strings.Index(strings.ToLower(v), suffix); idx > 0 {
v = v[:idx]
break
}
}
return v
}
// channelSuffix returns the version suffix for a channel.
func channelSuffix(channel string) string {
switch channel {
case ChannelDevelopment:
return "-dev"
case ChannelAlpha:
return "-alpha"
case ChannelBeta:
return "-beta"
case ChannelReleaseCandidate:
return "-rc"
default:
return ""
}
}
+149
View File
@@ -0,0 +1,149 @@
{{template "base/head" .}}
<div role="main" aria-label="{{.Title}}" class="page-content organization">
{{template "org/header" .}}
<div class="ui container">
{{if .NewMasterKey}}
<div class="ui info message">
<div class="header">{{ctx.Locale.Tr "repo.licenses.master_key_created"}}</div>
<p>{{ctx.Locale.Tr "repo.licenses.master_key_created_copy"}}</p>
<code class="tw-text-lg tw-select-all">{{.NewMasterKey}}</code>
</div>
{{end}}
{{if .NewKeyCreated}}
<div class="ui success message">
<div class="header">{{ctx.Locale.Tr "repo.licenses.key_created"}}</div>
<p>{{ctx.Locale.Tr "repo.licenses.key_created_copy"}}</p>
<code class="tw-text-lg tw-select-all">{{.NewKeyCreated}}</code>
</div>
{{end}}
<h4 class="ui top attached header tw-flex tw-justify-between tw-items-center">
<span>{{svg "octicon-key" 16}} {{ctx.Locale.Tr "repo.licenses.packages"}}</span>
{{if .IsRepoAdmin}}
<a class="ui small primary button" href="#new-package-form" onclick="this.parentElement.parentElement.nextElementSibling.querySelector('#new-package-form').style.display='block'; return false;">
{{ctx.Locale.Tr "repo.licenses.new_package"}}
</a>
{{end}}
</h4>
<div class="ui attached segment">
{{if .IsRepoAdmin}}
<div id="new-package-form" style="display: none;" class="tw-mb-4">
<form class="ui form" method="post" action="{{$.Org.HomeLink}}/-/licenses/packages">
{{.CsrfTokenHtml}}
<div class="two fields">
<div class="required field">
<label>{{ctx.Locale.Tr "repo.licenses.package_name"}}</label>
<input name="name" required placeholder="Pro Annual">
</div>
<div class="field">
<label>{{ctx.Locale.Tr "repo.licenses.description"}}</label>
<input name="description" placeholder="Annual pro subscription">
</div>
</div>
<div class="three fields">
<div class="field">
<label>{{ctx.Locale.Tr "repo.licenses.duration"}} ({{ctx.Locale.Tr "repo.licenses.days"}})</label>
<input name="duration_days" type="number" value="0" min="0">
</div>
<div class="field">
<label>{{ctx.Locale.Tr "repo.licenses.max_sites"}}</label>
<input name="max_sites" type="number" value="0" min="0">
</div>
<div class="field">
<label>{{ctx.Locale.Tr "repo.licenses.channels"}}</label>
<input name="allowed_channels" placeholder="stable,release-candidate">
</div>
</div>
<button class="ui primary button" type="submit">{{ctx.Locale.Tr "repo.licenses.create_package"}}</button>
</form>
<div class="divider"></div>
</div>
{{end}}
{{if .LicensePackages}}
<table class="ui table">
<thead>
<tr>
<th>{{ctx.Locale.Tr "repo.licenses.package_name"}}</th>
<th>{{ctx.Locale.Tr "repo.licenses.duration"}}</th>
<th>{{ctx.Locale.Tr "repo.licenses.channels"}}</th>
<th>{{ctx.Locale.Tr "repo.licenses.keys_issued"}}</th>
<th>{{ctx.Locale.Tr "repo.licenses.status"}}</th>
{{if .IsRepoAdmin}}<th></th>{{end}}
</tr>
</thead>
<tbody>
{{range .LicensePackages}}
<tr>
<td><strong>{{.Name}}</strong>{{if .Description}}<br><small class="text grey">{{.Description}}</small>{{end}}</td>
<td>{{if eq .DurationDays 0}}{{ctx.Locale.Tr "repo.licenses.lifetime"}}{{else}}{{.DurationDays}} {{ctx.Locale.Tr "repo.licenses.days"}}{{end}}</td>
<td>{{if .AllowedChannels}}<code>{{.AllowedChannels}}</code>{{else}}{{ctx.Locale.Tr "repo.licenses.all_channels"}}{{end}}</td>
<td>{{.KeyCount}}</td>
<td>{{if .IsActive}}<span class="ui green label">{{ctx.Locale.Tr "repo.licenses.active"}}</span>{{else}}<span class="ui grey label">{{ctx.Locale.Tr "repo.licenses.inactive"}}</span>{{end}}</td>
{{if $.IsRepoAdmin}}
<td class="tw-text-right">
<form method="post" action="{{$.Org.HomeLink}}/-/licenses/keys/generate" class="tw-inline">
{{$.CsrfTokenHtml}}
<input type="hidden" name="package_id" value="{{.ID}}">
<button class="ui tiny primary button" type="submit">
{{svg "octicon-plus" 14}} {{ctx.Locale.Tr "repo.licenses.generate_key"}}
</button>
</form>
</td>
{{end}}
</tr>
{{end}}
</tbody>
</table>
{{else}}
<div class="empty-placeholder">
{{svg "octicon-key" 48}}
<h2>{{ctx.Locale.Tr "repo.licenses.none"}}</h2>
<p>{{ctx.Locale.Tr "repo.licenses.none_desc"}}</p>
</div>
{{end}}
</div>
{{if .LicenseKeys}}
<h4 class="ui top attached header tw-mt-4">
{{svg "octicon-lock" 16}} {{ctx.Locale.Tr "repo.licenses.issued_keys"}}
</h4>
<div class="ui attached segment">
<table class="ui table">
<thead>
<tr>
<th>{{ctx.Locale.Tr "repo.licenses.key_prefix"}}</th>
<th>{{ctx.Locale.Tr "repo.licenses.licensee"}}</th>
<th>{{ctx.Locale.Tr "repo.licenses.expires"}}</th>
<th>{{ctx.Locale.Tr "repo.licenses.status"}}</th>
{{if .IsRepoAdmin}}<th></th>{{end}}
</tr>
</thead>
<tbody>
{{range .LicenseKeys}}
<tr>
<td><code>{{.KeyPrefix}}</code>{{if .IsInternal}} <span class="ui tiny orange label">Master</span>{{end}}</td>
<td>{{.LicenseeName}}{{if .LicenseeEmail}} <small>({{.LicenseeEmail}})</small>{{end}}</td>
<td>{{if eq .ExpiresUnix 0}}{{ctx.Locale.Tr "repo.licenses.never"}}{{else}}{{DateUtils.TimeSince .ExpiresUnix}}{{end}}</td>
<td>{{if .IsActive}}<span class="ui green label">{{ctx.Locale.Tr "repo.licenses.active"}}</span>{{else}}<span class="ui grey label">{{ctx.Locale.Tr "repo.licenses.inactive"}}</span>{{end}}</td>
{{if $.IsRepoAdmin}}
<td class="tw-text-right">
<form method="post" action="{{$.Org.HomeLink}}/-/licenses/keys/{{.ID}}/revoke" class="tw-inline">
{{$.CsrfTokenHtml}}
<button class="ui tiny red button" type="submit">
{{svg "octicon-x" 14}}
</button>
</form>
</td>
{{end}}
</tr>
{{end}}
</tbody>
</table>
</div>
{{end}}
</div>
</div>
{{template "base/footer" .}}
+5
View File
@@ -25,6 +25,11 @@
{{svg "octicon-package"}} {{ctx.Locale.Tr "packages.title"}}
</a>
{{end}}
{{if .IsOrganizationMember}}
<a class="{{if .IsLicensesPage}}active {{end}}item" href="{{$.Org.HomeLink}}/-/licenses">
{{svg "octicon-key"}} {{ctx.Locale.Tr "repo.licenses"}}
</a>
{{end}}
{{if and .IsRepoIndexerEnabled .CanReadCode}}
<a class="{{if .IsCodePage}}active {{end}}item" href="{{$.Org.HomeLink}}/-/code">
{{svg "octicon-code"}} {{ctx.Locale.Tr "org.code"}}
+9
View File
@@ -128,6 +128,15 @@
</a>
{{end}}
{{if or .EnableLicenses .IsRepoAdmin}}
<a href="{{.RepoLink}}/licenses" class="{{if .IsLicensesPage}}active {{end}}item">
{{svg "octicon-key"}} {{ctx.Locale.Tr "repo.licenses"}}
{{if .NumLicensePackages}}
<span class="ui small label">{{CountFmt .NumLicensePackages}}</span>
{{end}}
</a>
{{end}}
{{$projectsUnit := .Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypeProjects}}
{{if and (not ctx.Consts.RepoUnitTypeProjects.UnitGlobalDisabled) (.Permission.CanRead ctx.Consts.RepoUnitTypeProjects) ($projectsUnit.ProjectsConfig.IsProjectsAllowed "repo")}}
<a href="{{.RepoLink}}/projects" class="{{if .IsProjectsPage}}active {{end}}item">
+173
View File
@@ -0,0 +1,173 @@
{{template "base/head" .}}
<div role="main" aria-label="{{.Title}}" class="page-content repository">
{{template "repo/header" .}}
<div class="ui container">
{{if .NewMasterKey}}
<div class="ui info message">
<div class="header">{{ctx.Locale.Tr "repo.licenses.master_key_created"}}</div>
<p>{{ctx.Locale.Tr "repo.licenses.master_key_created_copy"}}</p>
<code class="tw-text-lg tw-select-all">{{.NewMasterKey}}</code>
</div>
{{end}}
{{if .NewKeyCreated}}
<div class="ui success message">
<div class="header">{{ctx.Locale.Tr "repo.licenses.key_created"}}</div>
<p>{{ctx.Locale.Tr "repo.licenses.key_created_copy"}}</p>
<code class="tw-text-lg tw-select-all">{{.NewKeyCreated}}</code>
</div>
{{end}}
{{/* License Packages */}}
<h4 class="ui top attached header tw-flex tw-justify-between tw-items-center">
<span>{{svg "octicon-key" 16}} {{ctx.Locale.Tr "repo.licenses.packages"}}</span>
{{if .IsRepoAdmin}}
<a class="ui small primary button" href="#new-package-form" onclick="document.getElementById('new-package-form').style.display=document.getElementById('new-package-form').style.display==='none'?'block':'none'; return false;">
{{ctx.Locale.Tr "repo.licenses.new_package"}}
</a>
{{end}}
</h4>
<div class="ui attached segment">
{{if .IsRepoAdmin}}
<div id="new-package-form" style="display: none;" class="tw-mb-4">
<form class="ui form" method="post" action="{{.RepoLink}}/licenses/packages">
{{.CsrfTokenHtml}}
<div class="two fields">
<div class="required field">
<label>{{ctx.Locale.Tr "repo.licenses.package_name"}}</label>
<input name="name" required placeholder="Pro Annual">
</div>
<div class="field">
<label>{{ctx.Locale.Tr "repo.licenses.description"}}</label>
<input name="description" placeholder="Annual pro subscription">
</div>
</div>
<div class="three fields">
<div class="field">
<label>{{ctx.Locale.Tr "repo.licenses.duration"}} ({{ctx.Locale.Tr "repo.licenses.days"}})</label>
<input name="duration_days" type="number" value="0" min="0" placeholder="0 = lifetime">
</div>
<div class="field">
<label>{{ctx.Locale.Tr "repo.licenses.max_sites"}}</label>
<input name="max_sites" type="number" value="0" min="0" placeholder="0 = unlimited">
</div>
<div class="field">
<label>{{ctx.Locale.Tr "repo.licenses.channels"}}</label>
<input name="allowed_channels" placeholder='stable,release-candidate'>
<p class="help">{{ctx.Locale.Tr "repo.licenses.channels_help"}}</p>
</div>
</div>
<button class="ui primary button" type="submit">{{ctx.Locale.Tr "repo.licenses.create_package"}}</button>
</form>
<div class="divider"></div>
</div>
{{end}}
{{if .LicensePackages}}
<table class="ui table">
<thead>
<tr>
<th>{{ctx.Locale.Tr "repo.licenses.package_name"}}</th>
<th>{{ctx.Locale.Tr "repo.licenses.duration"}}</th>
<th>{{ctx.Locale.Tr "repo.licenses.channels"}}</th>
<th>{{ctx.Locale.Tr "repo.licenses.keys_issued"}}</th>
<th>{{ctx.Locale.Tr "repo.licenses.status"}}</th>
{{if .IsRepoAdmin}}<th></th>{{end}}
</tr>
</thead>
<tbody>
{{range .LicensePackages}}
<tr>
<td><strong>{{.Name}}</strong>{{if .Description}}<br><small class="text grey">{{.Description}}</small>{{end}}</td>
<td>{{if eq .DurationDays 0}}{{ctx.Locale.Tr "repo.licenses.lifetime"}}{{else}}{{.DurationDays}} {{ctx.Locale.Tr "repo.licenses.days"}}{{end}}</td>
<td>{{if .AllowedChannels}}<code>{{.AllowedChannels}}</code>{{else}}{{ctx.Locale.Tr "repo.licenses.all_channels"}}{{end}}</td>
<td>{{.KeyCount}}</td>
<td>{{if .IsActive}}<span class="ui green label">{{ctx.Locale.Tr "repo.licenses.active"}}</span>{{else}}<span class="ui grey label">{{ctx.Locale.Tr "repo.licenses.inactive"}}</span>{{end}}</td>
{{if $.IsRepoAdmin}}
<td class="tw-text-right">
<form method="post" action="{{$.RepoLink}}/licenses/keys/generate" class="tw-inline">
{{$.CsrfTokenHtml}}
<input type="hidden" name="package_id" value="{{.ID}}">
<button class="ui tiny primary button" type="submit" title="{{ctx.Locale.Tr "repo.licenses.generate_key"}}">
{{svg "octicon-plus" 14}} {{ctx.Locale.Tr "repo.licenses.generate_key"}}
</button>
</form>
</td>
{{end}}
</tr>
{{end}}
</tbody>
</table>
{{else}}
<div class="empty-placeholder">
{{svg "octicon-key" 48}}
<h2>{{ctx.Locale.Tr "repo.licenses.none"}}</h2>
<p>{{ctx.Locale.Tr "repo.licenses.none_desc"}}</p>
</div>
{{end}}
</div>
{{/* Issued Keys */}}
{{if .LicenseKeys}}
<h4 class="ui top attached header tw-mt-4">
{{svg "octicon-lock" 16}} {{ctx.Locale.Tr "repo.licenses.issued_keys"}}
</h4>
<div class="ui attached segment">
<table class="ui table">
<thead>
<tr>
<th>{{ctx.Locale.Tr "repo.licenses.key_prefix"}}</th>
<th>{{ctx.Locale.Tr "repo.licenses.licensee"}}</th>
<th>{{ctx.Locale.Tr "repo.licenses.expires"}}</th>
<th>{{ctx.Locale.Tr "repo.licenses.status"}}</th>
{{if .IsRepoAdmin}}<th></th>{{end}}
</tr>
</thead>
<tbody>
{{range .LicenseKeys}}
<tr>
<td><code>{{.KeyPrefix}}</code></td>
<td>{{.LicenseeName}}{{if .LicenseeEmail}} <small>({{.LicenseeEmail}})</small>{{end}}</td>
<td>{{if eq .ExpiresUnix 0}}{{ctx.Locale.Tr "repo.licenses.never"}}{{else}}{{DateUtils.TimeSince .ExpiresUnix}}{{end}}</td>
<td>{{if .IsActive}}<span class="ui green label">{{ctx.Locale.Tr "repo.licenses.active"}}</span>{{else}}<span class="ui grey label">{{ctx.Locale.Tr "repo.licenses.inactive"}}</span>{{end}}</td>
{{if $.IsRepoAdmin}}
<td class="tw-text-right">
<form method="post" action="{{$.RepoLink}}/licenses/keys/{{.ID}}/revoke" class="tw-inline">
{{$.CsrfTokenHtml}}
<button class="ui tiny red button" type="submit" title="{{ctx.Locale.Tr "repo.licenses.revoke"}}">
{{svg "octicon-x" 14}}
</button>
</form>
</td>
{{end}}
</tr>
{{end}}
</tbody>
</table>
</div>
{{end}}
{{/* Update Feed Info */}}
<h4 class="ui top attached header tw-mt-4">
{{svg "octicon-rss" 16}} {{ctx.Locale.Tr "repo.licenses.update_feeds"}}
</h4>
<div class="ui attached segment">
<div class="field">
<label>Joomla updates.xml</label>
<div class="ui action input tw-w-full">
<input type="text" readonly value="{{.Repository.HTMLURL ctx}}/updates.xml" onclick="this.select()">
<button class="ui button" onclick="navigator.clipboard.writeText(this.previousElementSibling.value)">{{svg "octicon-copy" 14}}</button>
</div>
</div>
<div class="field tw-mt-2">
<label>Dolibarr JSON</label>
<div class="ui action input tw-w-full">
<input type="text" readonly value="{{.Repository.HTMLURL ctx}}/updates/dolibarr.json" onclick="this.select()">
<button class="ui button" onclick="navigator.clipboard.writeText(this.previousElementSibling.value)">{{svg "octicon-copy" 14}}</button>
</div>
</div>
</div>
</div>
</div>
{{template "base/footer" .}}
+5
View File
@@ -16,6 +16,11 @@
{{svg "octicon-rss" 16}} {{ctx.Locale.Tr "rss_feed"}}
</a>
{{end}}
{{if not .PageIsTagList}}
<a class="ui small button" href="{{.RepoLink}}/updates.xml" target="_blank">
{{svg "octicon-download" 16}} {{ctx.Locale.Tr "repo.release.update_feed"}}
</a>
{{end}}
{{if and (not .PageIsTagList) .CanCreateRelease}}
<a class="ui small primary button" href="{{$.RepoLink}}/releases/new{{if .PageIsSingleTag}}?tag={{.SingleReleaseTagName}}{{end}}">
{{ctx.Locale.Tr "repo.release.new_release"}}
+25 -1
View File
@@ -330,6 +330,13 @@
<label>{{ctx.Locale.Tr "repo.settings.default_wiki_branch_name"}}</label>
<input name="default_wiki_branch" value="{{.Repository.DefaultWikiBranch}}">
</div>
<div class="inline field">
<label>{{ctx.Locale.Tr "repo.settings.unit_visibility"}}</label>
<select name="wiki_visibility" class="ui dropdown">
<option value="not-set" {{if not (eq (.Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypeWiki).AnonymousAccessMode 1)}}selected{{end}}>{{ctx.Locale.Tr "repo.settings.unit_visibility_private"}}</option>
<option value="anonymous-read" {{if eq (.Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypeWiki).AnonymousAccessMode 1}}selected{{end}}>{{ctx.Locale.Tr "repo.settings.unit_visibility_public"}}</option>
</select>
</div>
</div>
<div class="field">
<div class="ui radio checkbox{{if $isExternalWikiGlobalDisabled}} disabled{{end}}"{{if $isExternalWikiGlobalDisabled}} data-tooltip-content="{{ctx.Locale.Tr "repo.unit_disabled"}}"{{end}}>
@@ -389,6 +396,13 @@
<input name="enable_close_issues_via_commit_in_any_branch" type="checkbox" {{if .Repository.CloseIssuesViaCommitInAnyBranch}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.settings.admin_enable_close_issues_via_commit_in_any_branch"}}</label>
</div>
<div class="inline field tw-mt-2">
<label>{{ctx.Locale.Tr "repo.settings.unit_visibility"}}</label>
<select name="issues_visibility" class="ui dropdown">
<option value="not-set" {{if not (eq (.Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypeIssues).AnonymousAccessMode 1)}}selected{{end}}>{{ctx.Locale.Tr "repo.settings.unit_visibility_private"}}</option>
<option value="anonymous-read" {{if eq (.Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypeIssues).AnonymousAccessMode 1}}selected{{end}}>{{ctx.Locale.Tr "repo.settings.unit_visibility_public"}}</option>
</select>
</div>
</div>
<div class="field">
<div class="ui radio checkbox{{if $isExternalTrackerGlobalDisabled}} disabled{{end}}"{{if $isExternalTrackerGlobalDisabled}} data-tooltip-content="{{ctx.Locale.Tr "repo.unit_disabled"}}"{{end}}>
@@ -487,10 +501,20 @@
<div class="inline field">
<label>{{ctx.Locale.Tr "repo.releases"}}</label>
<div class="ui checkbox{{if $isReleasesGlobalDisabled}} disabled{{end}}"{{if $isReleasesGlobalDisabled}} data-tooltip-content="{{ctx.Locale.Tr "repo.unit_disabled"}}"{{end}}>
<input class="enable-system" name="enable_releases" type="checkbox" {{if $isReleasesEnabled}}checked{{end}}>
<input class="enable-system" name="enable_releases" type="checkbox" data-target="#releases_visibility_box" {{if $isReleasesEnabled}}checked{{end}}>
<label>{{ctx.Locale.Tr "repo.settings.releases_desc"}}</label>
</div>
</div>
<div class="field tw-pl-4{{if not $isReleasesEnabled}} disabled{{end}}" id="releases_visibility_box">
<div class="inline field">
<label>{{ctx.Locale.Tr "repo.settings.unit_visibility"}}</label>
<select name="releases_visibility" class="ui dropdown">
<option value="not-set" {{if not (eq (.Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypeReleases).AnonymousAccessMode 1)}}selected{{end}}>{{ctx.Locale.Tr "repo.settings.unit_visibility_private"}}</option>
<option value="anonymous-read" {{if eq (.Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypeReleases).AnonymousAccessMode 1}}selected{{end}}>{{ctx.Locale.Tr "repo.settings.unit_visibility_public"}}</option>
</select>
<p class="help">{{ctx.Locale.Tr "repo.settings.unit_visibility_releases_help"}}</p>
</div>
</div>
{{$isPackagesEnabled := .Repository.UnitEnabled ctx ctx.Consts.RepoUnitTypePackages}}
{{$isPackagesGlobalDisabled := ctx.Consts.RepoUnitTypePackages.UnitGlobalDisabled}}
+3 -3
View File
@@ -87,13 +87,13 @@
<element>mokogitea</element>
<type>application</type>
<client>site</client>
<version>05.14.00</version>
<version>05.13.00</version>
<creationDate>2026-05-31</creationDate>
<infourl title='MokoGitea'>https://git.mokoconsulting.tech/MokoConsulting/MokoGitea/releases/tag/stable</infourl>
<downloads>
<downloadurl type='full' format='zip'>https://git.mokoconsulting.tech/MokoConsulting/MokoGitea/releases/download/stable/mokogitea-05.14.00.zip</downloadurl>
<downloadurl type='full' format='zip'>https://git.mokoconsulting.tech/MokoConsulting/MokoGitea/releases/download/stable/mokogitea-05.13.00.zip</downloadurl>
</downloads>
<sha256>bec4bf5a1a841f8e72d9826451004db5d8afc70144231dfedc7fb01a6695955c</sha256>
<sha256>b8d32fb99a0fde25ea0860b55ac5f3dfb8a15576863d9ddb80e67c5895722dcd</sha256>
<tags><tag>stable</tag></tags>
<changelogurl>https://git.mokoconsulting.tech/MokoConsulting/MokoGitea/raw/branch/main/CHANGELOG.md</changelogurl>
<maintainer>Moko Consulting</maintainer>