Compare commits

...

56 Commits

Author SHA1 Message Date
Jonathan Miller 2dc745c5fa feat(wiki): print view, ZIP export, and folder access control (#674, #675)
Universal: Auto Version Bump / Version Bump (push) Successful in 18s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 34s
Deploy MokoGitea / deploy (push) Failing after 4m10s
Print view: clean rendering without navigation chrome for printing.
ZIP export: download entire wiki as ZIP archive of markdown files.
Folder ACL: _access.yml per-folder write protection with role checks.
Resolve merge conflicts between #674 and #675 implementations.
2026-06-22 08:51:58 -05:00
Jonathan Miller 9dda78da7c feat(wiki): print view, ZIP export, and folder access control (#674, #675)
Universal: Auto Version Bump / Version Bump (push) Successful in 12s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 27s
Print view: clean page rendering without navigation chrome for printing.
ZIP export: download entire wiki as ZIP archive of markdown files.
Folder ACL: _access.yml files control per-folder write permissions.
Both accessible from wiki pages dropdown menu.
2026-06-22 08:47:36 -05:00
jmiller 6ceef765eb feat(wiki): per-folder access control via _access.yml (#674)
Deploy MokoGitea / deploy (push) Failing after 4m8s
2026-06-22 01:12:47 +00:00
jmiller 23d3528676 feat(wiki): per-folder access control via _access.yml (#674)
Deploy MokoGitea / deploy (push) Failing after 5m28s
2026-06-22 01:08:35 +00:00
jmiller 249b639c70 feat(wiki): per-folder access control via _access.yml (#674)
Universal: Auto Version Bump / Version Bump (push) Successful in 15s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 49s
2026-06-22 01:05:43 +00:00
jmiller 5c9db551dc feat(wiki): per-folder access control via _access.yml (#674) — template
Universal: Auto Version Bump / Version Bump (push) Successful in 11s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 41s
2026-06-22 00:54:07 +00:00
jmiller 408f2329b3 chore: sync auto-release.yml from Template-Generic [skip ci] 2026-06-22 00:35:06 +00:00
Jonathan Miller 827025bd17 feat(wiki): enhanced ToC — collapsible, inline, sticky, frontmatter control (#673)
Universal: Auto Version Bump / Version Bump (push) Successful in 13s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 56s
Deploy MokoGitea / deploy (push) Failing after 4m4s
ToC can be controlled via frontmatter: toc=false disables, toc=inline
shows at top of content instead of sidebar. Sidebar ToC is now
collapsible via <details> and sticky on scroll. Inline ToC also
uses collapsible <details> with "Contents" header.
2026-06-21 19:33:57 -05:00
Jonathan Miller 98da1644be feat(wiki): template transclusion — reusable content blocks (#671)
Universal: Auto Version Bump / Version Bump (push) Successful in 14s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 53s
Deploy MokoGitea / deploy (push) Failing after 3m57s
Add {{template:Name|key=val}} syntax for embedding reusable content.
Templates stored as _Template/Name.md with {{{key}}} parameter
substitution. Recursive with depth limit of 5. _Template folder
hidden from sidebar tree.
2026-06-21 19:05:12 -05:00
Jonathan Miller db596575a0 feat(wiki): page categories via YAML frontmatter (#668)
Universal: Auto Version Bump / Version Bump (push) Successful in 12s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 54s
Deploy MokoGitea / deploy (push) Failing after 5m44s
Add categories support using frontmatter: categories: [arch, api, ref]
Categories render as clickable tags at the bottom of wiki pages.
Category index page lists all pages tagged with a given category.
Frontmatter is stripped before markdown rendering.
2026-06-21 18:32:07 -05:00
Jonathan Miller 3c56dc8814 feat(wiki): revision diff — view changes for any wiki commit (#667)
Universal: Auto Version Bump / Version Bump (push) Successful in 14s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 1m9s
Deploy MokoGitea / deploy (push) Failing after 5m31s
Compare a wiki commit against its parent showing added/removed lines
with color-coded diff view. Accessible via diff icon button in wiki
page header and from revision history. Shows commit metadata
(author, message, timestamp) alongside the diff.
2026-06-21 18:23:09 -05:00
Jonathan Miller dce712fabd Merge remote-tracking branch 'origin/main' into dev
Deploy MokoGitea / deploy (push) Failing after 6m21s
2026-06-21 18:17:44 -05:00
Jonathan Miller 78b0ce9650 fix: org metadata API respects visibility for unauthenticated requests (#690)
Universal: Auto Version Bump / Version Bump (push) Successful in 18s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 1m28s
Add checkOrgVisibility() guard to issue-statuses, issue-priorities,
and issue-types endpoints. Public orgs remain accessible to everyone.
Private/limited orgs return 404 for unauthenticated callers.
2026-06-21 18:17:00 -05:00
jmiller 500a5be6d7 Merge pull request 'Release: Wiki redirects, code review fixes, bug fixes' (#689) from dev into main
Deploy MokoGitea / deploy (push) Failing after 7m34s
2026-06-21 23:14:07 +00:00
Jonathan Miller 95a747b1d5 docs: update changelog with post-#682 fixes and #672
Universal: Auto Version Bump / Version Bump (push) Successful in 14s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 1m1s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Failing after 1m27s
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Failing after 12s
Generic: Repo Health / Access control (pull_request) Successful in 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: PR Check / Secret Scan (pull_request) Successful in 2m43s
PR RC Release / Build RC Release (pull_request) Failing after 2m5s
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 7s
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
2026-06-21 18:13:28 -05:00
Jonathan Miller bb7e99ad40 fix: backlinks template pluralization
Universal: Auto Version Bump / Version Bump (push) Successful in 16s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 1m6s
Deploy MokoGitea / deploy (push) Failing after 6m34s
2026-06-21 18:10:16 -05:00
Jonathan Miller 6c6b7c888e feat(wiki): page rename with automatic redirects (#672)
Universal: Auto Version Bump / Version Bump (push) Successful in 16s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 1m25s
Deploy MokoGitea / deploy (push) Successful in 5m57s
When a wiki page is renamed, automatically create a redirect file at
the old path with YAML frontmatter (redirect: new/path). The redirect
is detected in renderViewPage() before markdown rendering, issuing an
HTTP redirect with a flash message "Redirected from OldPage".
2026-06-21 18:00:56 -05:00
Jonathan Miller 2a1692d599 fix: resolve code review issues in wiki and status features
Universal: Auto Version Bump / Version Bump (push) Successful in 10s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 36s
Deploy MokoGitea / deploy (push) Successful in 7m31s
- collectWikiPages: store hyphen-normalized names for flexible lookup
- Backlinks: use wiki_service.GitPathToWebPath for subdirectory URLs
- issue_statuses.tmpl: fix garbled em-dash bytes (0x97 → hyphen)
- backlinks.tmpl: fix pluralization text
2026-06-21 17:57:46 -05:00
Jonathan Miller 6984ac108f Merge remote-tracking branch 'origin/main' into dev
Deploy MokoGitea / deploy (push) Successful in 4m47s
2026-06-21 17:39:36 -05:00
Jonathan Miller 3fdbe94830 fix: use commit.MessageTitle() instead of commit.Message() in wiki recent changes
Universal: Auto Version Bump / Version Bump (push) Successful in 11s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 35s
The Commit type embeds CommitMessage which provides MessageTitle(),
MessageUTF8(), and MessageBody() — not Message().
2026-06-21 17:38:43 -05:00
jmiller e937dd8d8b Merge pull request 'Release: Wiki system, licensing, issue statuses, metadata API' (#682) from dev into main
Deploy MokoGitea / deploy (push) Failing after 4m39s
2026-06-21 22:26:33 +00:00
Jonathan Miller e7b70f54ed docs: add #670 to changelog
Universal: PR Check / Branch Policy (pull_request) Successful in 4s
Universal: PR Check / Validate PR (pull_request) Failing after 21s
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Universal: PR Check / Secret Scan (pull_request) Successful in 1m50s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 6s
PR RC Release / Build RC Release (pull_request) Failing after 1m52s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Failing after 1m11s
Universal: Auto Version Bump / Version Bump (push) Successful in 11s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 32s
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
2026-06-21 17:24:17 -05:00
Jonathan Miller b161561571 Merge remote-tracking branch 'origin/main' into dev
Deploy MokoGitea / deploy (push) Failing after 5m8s
2026-06-21 17:21:01 -05:00
Jonathan Miller b981cf72e3 feat(wiki): recent changes page — cross-page edit activity (#670)
Universal: Auto Version Bump / Version Bump (push) Successful in 13s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 1m4s
Shows all recent wiki edits with page name, author, edit summary,
and timestamp. Paginated. Accessible via "Recent changes" in the
wiki pages dropdown menu.
2026-06-21 17:20:12 -05:00
jmiller 9964c7e16c chore: sync auto-release.yml from Template-Generic [skip ci] 2026-06-21 22:02:50 +00:00
Jonathan Miller ff27e77c37 docs: update changelog with session 2 changes
Universal: Auto Version Bump / Version Bump (push) Successful in 12s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 1m6s
2026-06-21 16:54:20 -05:00
Jonathan Miller 04ce7dc896 feat(wiki): internal wikilinks with [[Page Name]] syntax (#666)
Deploy MokoGitea / deploy (push) Successful in 4m51s
Universal: Auto Version Bump / Version Bump (push) Successful in 10s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 40s
Add Wikipedia-style [[Page Name]] and [[Page|Display Text]] syntax.
Existing pages render as normal links; non-existent pages render as
red "new page" links. Supports [[folder/Page]], [[Page#Section]],
and [[#Anchor]] patterns.
2026-06-21 15:42:50 -05:00
Jonathan Miller f87f904a21 fix: remove Version field from metadata settings template
Version is no longer stored in RepoMetadata struct — it comes from
the metadata API. The template reference caused a 500 error.
2026-06-21 15:42:46 -05:00
Jonathan Miller fc72d8e90a feat(wiki): add backlinks — "What links here" for wiki pages (#669)
Deploy MokoGitea / deploy (push) Successful in 3m27s
Scan all wiki pages for references to the current page and display
them on a dedicated backlinks page. Uses content search across
markdown files (no database needed). Adds cross-reference button
to wiki page header.
2026-06-21 11:45:20 -05:00
Jonathan Miller 71d52e432e feat: enforce required baseline issue statuses (#681)
Deploy MokoGitea / deploy (push) Successful in 5m8s
Add IsRequired field to IssueStatusDef. Open and Closed statuses are
seeded as required and cannot be deleted. Delete attempts return an
error flash in the web UI and ErrStatusRequired in the model layer.
API response now includes is_required field.
2026-06-21 11:29:49 -05:00
jmiller 172303b61f chore: sync pre-release.yml from Template-Generic [skip ci] 2026-06-21 16:05:39 +00:00
Jonathan Miller bfb4b53da3 feat: add folder-based tree sidebar to org wiki (#680)
Deploy MokoGitea / deploy (push) Successful in 3m31s
Replace flat page list with hierarchical folder tree in org wiki sidebar.
_Sidebar.md takes precedence when present; otherwise auto-generates
collapsible folder menus up to 2 levels deep.
2026-06-21 11:00:18 -05:00
Jonathan Miller 9149fa100c feat: make metadata/manifest GET endpoint publicly accessible (#676)
Deploy MokoGitea / deploy (push) Successful in 3m30s
Remove reqRepoReader auth requirement from GET /repos/{owner}/{repo}/metadata
and /manifest endpoints. PUT (update) still requires token + admin.
2026-06-21 10:20:56 -05:00
jmiller 6a2c80a8f3 chore: sync composer-publish.yml from Template-Generic [skip ci] 2026-06-21 06:35:03 +00:00
jmiller 28ee70a946 Merge pull request 'fix: resolve all compile errors in licensing endpoints' (#662) from dev into main
Deploy MokoGitea / deploy (push) Successful in 4m4s
2026-06-21 04:10:04 +00:00
Jonathan Miller 6d194f9bdf fix: sort imports in manage.go (stdlib before module imports)
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Failing after 8s
Universal: Auto Version Bump / Version Bump (push) Successful in 14s
PR RC Release / Build RC Release (pull_request) Failing after 51s
Universal: PR Check / Secret Scan (pull_request) Successful in 1m8s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 1m0s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Failing after 44s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 1m44s
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
2026-06-20 23:03:44 -05:00
Jonathan Miller 403db405cb fix: resolve all compile errors in licensing endpoints
Universal: PR Check / Branch Policy (pull_request) Successful in 2s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 1s
Universal: PR Check / Validate PR (pull_request) Failing after 8s
Universal: Build & Release / Promote to RC (pull_request) Failing after 14s
Universal: Build & Release / Build & Release Pipeline (pull_request) Has been skipped
Universal: Auto Version Bump / Version Bump (push) Successful in 14s
Universal: PR Check / Secret Scan (pull_request) Successful in 1m4s
PR RC Release / Build RC Release (pull_request) Failing after 1m5s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 54s
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
- ctx.BindJSON → json.NewDecoder(ctx.Req.Body).Decode
- ctx.Orm() → db.GetEngine(ctx)
- ctx.NotFound() → ctx.APIErrorNotFound()
- GetAttachmentsByReleaseID → db.GetEngine query
2026-06-20 22:57:19 -05:00
jmiller 39e4eb6ec8 Merge pull request 'fix: use ctx.APIError in licensing endpoints (build fix)' (#661) from dev into main
Deploy MokoGitea / deploy (push) Failing after 3m45s
2026-06-21 02:49:52 +00:00
Jonathan Miller 79cc30e9a8 fix: use ctx.APIError instead of ctx.Error in licensing endpoints
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Generic: Repo Health / Access control (pull_request) Successful in 2s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Universal: PR Check / Validate PR (pull_request) Failing after 10s
PR RC Release / Build RC Release (pull_request) Failing after 1m13s
Universal: PR Check / Secret Scan (pull_request) Successful in 1m19s
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Universal: Auto Version Bump / Version Bump (push) Successful in 13s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 2m0s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 40s
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Failing after 1m1s
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
APIContext doesn't have an Error method — use APIError(status, msg)
which is the correct 2-arg pattern for Gitea API error responses.
2026-06-20 21:47:02 -05:00
jmiller 78ad2c999b Merge pull request 'feat: licensing API phase 2 — validation, signed downloads, management, tier admin' (#660) from dev into main
Deploy MokoGitea / deploy (push) Failing after 4m18s
2026-06-21 02:37:26 +00:00
Jonathan Miller e3949077b0 Merge remote-tracking branch 'origin/main' into dev
Universal: PR Check / Branch Policy (pull_request) Successful in 1s
Generic: Repo Health / Site Health (pull_request) Has been skipped
Generic: Repo Health / Access control (pull_request) Successful in 2s
Universal: PR Check / Validate PR (pull_request) Failing after 8s
Universal: Auto Version Bump / Version Bump (push) Successful in 14s
Universal: PR Check / Secret Scan (pull_request) Successful in 43s
PR RC Release / Build RC Release (pull_request) Failing after 41s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 42s
Branch Cleanup / Delete merged branch (pull_request) Has been skipped
RC Revert / Rename rc/ back to dev/ (pull_request) Has been skipped
Universal: Build & Release / Promote to RC (pull_request) Has been skipped
Universal: Build & Release / Build & Release Pipeline (pull_request) Failing after 44s
Universal: Workflow Sync Trigger / Sync workflows to live repos (pull_request) Failing after 1m35s
Universal: PR Check / Build RC Package (pull_request) Has been cancelled
Universal: PR Check / Report Issues (pull_request) Has been cancelled
Generic: Repo Health / Scripts governance (pull_request) Has been cancelled
Generic: Repo Health / Repository health (pull_request) Has been cancelled
Generic: Repo Health / Report Issues (pull_request) Has been cancelled
# Conflicts:
#	.mokogitea/manifest.xml
#	.mokogitea/workflows/issue-branch.yml
#	CHANGELOG.md
2026-06-20 21:35:55 -05:00
jmiller e469b4a857 chore: sync workflow-sync-trigger.yml from Template-Generic [skip ci] 2026-06-21 01:28:46 +00:00
jmiller acae63f727 chore: sync rc-revert.yml from Template-Generic [skip ci] 2026-06-21 01:28:38 +00:00
jmiller e71ab8415f chore: sync pre-release.yml from Template-Generic [skip ci] 2026-06-21 01:28:29 +00:00
jmiller 03ce66a4f4 chore: sync pr-check.yml from Template-Generic [skip ci] 2026-06-21 01:28:22 +00:00
jmiller deafaeca65 chore: sync issue-branch.yml from Template-Generic [skip ci] 2026-06-21 01:28:16 +00:00
jmiller 5e74c22609 chore: sync auto-release.yml from Template-Generic [skip ci] 2026-06-21 01:28:10 +00:00
gitea-actions[bot] 03f881c746 chore(version): pre-release bump to 06.18.12-dev [skip ci] 2026-06-21 01:15:16 +00:00
Jonathan Miller 3a405033ae feat: add product tier admin UI with CRUD and license counts (#627)
Universal: Auto Version Bump / Version Bump (push) Successful in 5s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 1m35s
Admin page at /-/admin/license-tiers for managing product tiers:
- Tier list with key, name, repos, max domains, license count, sort order
- Create new tier form with repo input
- Delete tier (blocked if active licenses exist)
- Nav item added to admin sidebar
2026-06-20 20:14:24 -05:00
gitea-actions[bot] 034795951f chore(version): pre-release bump to 06.18.11-dev [skip ci] 2026-06-21 01:04:49 +00:00
Jonathan Miller 1d1b867df5 feat: add license management API — admin CRUD, user self-service, tier management (#624)
Universal: Auto Version Bump / Version Bump (push) Successful in 5s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 1m22s
Admin: POST/GET/PATCH/DELETE /api/v1/licensing/licenses (reqSiteAdmin)
User: GET /api/v1/licensing/my/licenses, manage domains (reqToken)
Tiers: GET/POST/PATCH/DELETE /api/v1/licensing/tiers (reqSiteAdmin)

Includes pagination, entitlement/activation detail in GET, tier change
triggers entitlement rebuild, delete-tier blocked if active licenses exist.
2026-06-20 20:04:15 -05:00
gitea-actions[bot] 63b599f62c chore(version): pre-release bump to 06.18.10-dev [skip ci] 2026-06-21 01:00:07 +00:00
Jonathan Miller 5bd449017c feat: add signed download endpoint with ed25519 tokens (#622)
Universal: Auto Version Bump / Version Bump (push) Successful in 6s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 1m20s
GET /api/v1/licensing/download/{product}/{version}.zip?token=XXX&expires=YYY&dlid=ZZZ

ed25519 keypair auto-generated on first use, stored in Gitea data dir.
Update XML endpoint now generates signed URLs with 5-minute TTL.
Download verifies signature + expiry + DLID + entitlement before serving
the release ZIP attachment. Downloads logged to audit trail.
2026-06-20 19:59:37 -05:00
gitea-actions[bot] fe3de3fbff chore(version): pre-release bump to 06.18.09-dev [skip ci] 2026-06-21 00:52:30 +00:00
Jonathan Miller 3e909df6d4 feat: add license validation API — public validate + authenticated status (#623)
Universal: Auto Version Bump / Version Bump (push) Successful in 6s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 1m16s
GET /api/v1/licensing/validate?dlid=XXX&product=YYY&domain=ZZZ (public)
GET /api/v1/licensing/{dlid}/status (authenticated, reqToken)

Public endpoint returns valid/invalid with reason codes for Joomla plugin
and external integration use. Authenticated endpoint returns full license
detail with entitlement list and domain usage for admin dashboards.
2026-06-20 19:51:50 -05:00
gitea-actions[bot] 30bb5e33e2 chore(version): pre-release bump to 06.18.08-dev [skip ci] 2026-06-20 22:20:41 +00:00
34 changed files with 3027 additions and 139 deletions
+68 -5
View File
@@ -10,9 +10,9 @@
# VERSION: 05.00.00 # VERSION: 05.00.00
# BRIEF: Universal build & release detects platform from manifest.xml # BRIEF: Universal build & release detects platform from manifest.xml
# #
# +========================================================================+ # +=======================================================================+
# | UNIVERSAL BUILD & RELEASE PIPELINE | # | UNIVERSAL BUILD & RELEASE PIPELINE |
# +========================================================================+ # +=======================================================================+
# | | # | |
# | Reads manifest.xml (joomla|dolibarr|generic) to branch logic. | # | Reads manifest.xml (joomla|dolibarr|generic) to branch logic. |
# | | # | |
@@ -21,7 +21,7 @@
# | dolibarr: mod*.class.php, update.txt, dev version reset | # | dolibarr: mod*.class.php, update.txt, dev version reset |
# | generic: README-only, no update stream | # | generic: README-only, no update stream |
# | | # | |
# +========================================================================+ # +=======================================================================+
name: "Universal: Build & Release" name: "Universal: Build & Release"
@@ -30,6 +30,15 @@ on:
types: [opened, closed] types: [opened, closed]
branches: branches:
- main - main
paths-ignore:
- '.mokogitea/workflows/**'
- '*.md'
- 'wiki/**'
- '.editorconfig'
- '.gitignore'
- '.gitattributes'
- '.gitmessage'
- 'LICENSE'
workflow_dispatch: workflow_dispatch:
inputs: inputs:
action: action:
@@ -51,7 +60,7 @@ permissions:
contents: write contents: write
jobs: jobs:
# ── PR Opened → Rename branch to RC and build RC release ───────────────────── # ── PR Opened → Rename branch to RC and build RC release ─────────────────────────
promote-rc: promote-rc:
name: Promote to RC name: Promote to RC
runs-on: release runs-on: release
@@ -149,7 +158,7 @@ jobs:
echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY echo "## Promoted to Release Candidate" >> $GITHUB_STEP_SUMMARY
echo "Branch renamed to rc, minor bump, RC release built" >> $GITHUB_STEP_SUMMARY echo "Branch renamed to rc, minor bump, RC release built" >> $GITHUB_STEP_SUMMARY
# ── Merged PR → Build & Release (or promote RC to stable) ──────────────────── # ── Merged PR → Build & Release (or promote RC to stable) ─────────────────────────
release: release:
name: Build & Release Pipeline name: Build & Release Pipeline
runs-on: release runs-on: release
@@ -205,6 +214,12 @@ jobs:
echo MOKO_CLI=/tmp/mokocli/cli >> $GITHUB_ENV echo MOKO_CLI=/tmp/mokocli/cli >> $GITHUB_ENV
fi fi
- name: "Detect platform"
id: platform
run: |
php ${MOKO_CLI}/platform_detect.php --path . --github-output 2>/dev/null || true
php ${MOKO_CLI}/manifest_read.php --path . --github-output 2>/dev/null || true
- name: "Determine version bump level" - name: "Determine version bump level"
id: bump id: bump
run: | run: |
@@ -228,6 +243,54 @@ jobs:
--path . --stability stable ${BUMP_FLAG} --branch main \ --path . --stability stable ${BUMP_FLAG} --branch main \
--token "${{ secrets.MOKOGITEA_TOKEN }}" --token "${{ secrets.MOKOGITEA_TOKEN }}"
- name: "Read published version"
id: version
run: |
VERSION=$(php ${MOKO_CLI}/version_read.php --path . 2>/dev/null || echo "")
VERSION=$(echo "$VERSION" | sed 's/-\(dev\|alpha\|beta\|rc\)$//')
[ -z "$VERSION" ] && VERSION="00.00.00" && echo "skip=true" >> "$GITHUB_OUTPUT"
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
PLATFORM="${{ steps.platform.outputs.platform }}"
if [[ "$PLATFORM" == joomla* ]]; then
echo "tag=stable" >> "$GITHUB_OUTPUT"
echo "release_tag=stable" >> "$GITHUB_OUTPUT"
else
echo "tag=v${VERSION}" >> "$GITHUB_OUTPUT"
echo "release_tag=v${VERSION}" >> "$GITHUB_OUTPUT"
fi
echo "branch=main" >> "$GITHUB_OUTPUT"
echo "Published version: ${VERSION}"
- name: "Create semver tag for non-Joomla repos"
id: semver
if: |
steps.version.outputs.skip != 'true' &&
!startsWith(steps.platform.outputs.platform, 'joomla')
run: |
VERSION="${{ steps.version.outputs.version }}"
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
TOKEN="${{ secrets.MOKOGITEA_TOKEN }}"
SEMVER_TAG="v${VERSION}"
echo "Creating semver tag: ${SEMVER_TAG}"
# Create the git tag via API
HTTP_CODE=$(curl -sf -o /dev/null -w "%{http_code}" \
-X POST -H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
"${API_BASE}/tags" \
-d "{\"tag_name\":\"${SEMVER_TAG}\",\"target\":\"main\",\"message\":\"Release ${VERSION}\"}" 2>/dev/null || echo "000")
if [ "$HTTP_CODE" = "201" ] || [ "$HTTP_CODE" = "200" ]; then
echo "Created semver tag: ${SEMVER_TAG}"
elif [ "$HTTP_CODE" = "409" ]; then
echo "Semver tag ${SEMVER_TAG} already exists (skipped)"
else
echo "::warning::Failed to create semver tag ${SEMVER_TAG} (HTTP ${HTTP_CODE})"
fi
echo "semver_tag=${SEMVER_TAG}" >> "$GITHUB_OUTPUT"
- name: Update release notes and promote changelog - name: Update release notes and promote changelog
run: | run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
+76
View File
@@ -0,0 +1,76 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
# SPDX-License-Identifier: GPL-3.0-or-later
name: "Publish to Composer"
on:
push:
tags:
- 'v*'
- '[0-9]*.[0-9]*.[0-9]*'
release:
types: [published]
workflow_dispatch:
env:
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
jobs:
publish:
name: Publish Package
runs-on: ubuntu-latest
if: >-
!contains(github.event.head_commit.message, '[skip ci]') &&
!contains(github.event.head_commit.message, '[skip publish]')
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup PHP
run: |
if ! command -v php &> /dev/null; then
sudo apt-get update -qq
sudo apt-get install -y -qq php-cli php-mbstring php-xml php-zip php-curl composer >/dev/null 2>&1
fi
- name: Install dependencies
run: composer install --no-dev --no-interaction --prefer-dist --quiet
- name: Determine version
id: version
run: |
VERSION=$(php -r "echo json_decode(file_get_contents('composer.json'))->version;")
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
echo "Package version: ${VERSION}"
# Gitea Composer Registry — auto-publishes from tags
# The tag push itself registers the package at:
# https://git.mokoconsulting.tech/api/packages/MokoConsulting/composer
- name: Verify Gitea registry
run: |
echo "Gitea Composer registry auto-publishes from tags."
echo "Package available at: ${GITEA_URL}/api/packages/MokoConsulting/composer"
echo "Install: composer require mokoconsulting/mokocli"
# Packagist — notify of new version
- name: Notify Packagist
if: secrets.PACKAGIST_TOKEN != ''
run: |
VERSION="${{ steps.version.outputs.version }}"
echo "Notifying Packagist of version ${VERSION}..."
curl -sf -X POST \
-H "Content-Type: application/json" \
-d '{"repository":{"url":"https://git.mokoconsulting.tech/MokoConsulting/mokocli"}}' \
"https://packagist.org/api/update-package?username=mokoconsulting&apiToken=${{ secrets.PACKAGIST_TOKEN }}" \
&& echo "Packagist notified" \
|| echo "::warning::Packagist notification failed (package may not be registered yet)"
- name: Summary
run: |
VERSION="${{ steps.version.outputs.version }}"
echo "## Composer Package Published" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "| Registry | Status |" >> $GITHUB_STEP_SUMMARY
echo "|----------|--------|" >> $GITHUB_STEP_SUMMARY
echo "| Gitea | \`composer require mokoconsulting/mokocli:${VERSION}\` |" >> $GITHUB_STEP_SUMMARY
echo "| Packagist | \`composer require mokoconsulting/mokocli\` |" >> $GITHUB_STEP_SUMMARY
+2 -2
View File
@@ -4,8 +4,8 @@
# #
# FILE INFORMATION # FILE INFORMATION
# DEFGROUP: Gitea.Workflow # DEFGROUP: Gitea.Workflow
# INGROUP: moko-platform.Automation # INGROUP: mokocli.Automation
# VERSION: 06.20.00 # VERSION: 01.00.00
# BRIEF: Auto-create feature branch when an issue is opened # BRIEF: Auto-create feature branch when an issue is opened
name: "Universal: Issue Branch" name: "Universal: Issue Branch"
+45
View File
@@ -487,3 +487,48 @@ jobs:
echo "Source: ${FILE_COUNT} files" echo "Source: ${FILE_COUNT} files"
[ "$FILE_COUNT" -gt 0 ] || { echo "::error::Source directory is empty"; exit 1; } [ "$FILE_COUNT" -gt 0 ] || { echo "::error::Source directory is empty"; exit 1; }
# ── Pre-Release RC Build ─────────────────────────────────────────────────
pre-release:
name: Build RC Package
runs-on: ubuntu-latest
needs: [branch-policy, validate]
steps:
- name: Trigger RC pre-release
env:
GA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
REPO: ${{ github.repository }}
BRANCH: ${{ github.head_ref }}
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
run: |
curl -s -X POST "${GITEA_URL}/api/v1/repos/${REPO}/actions/workflows/pre-release.yml/dispatches" -H "Authorization: token ${GITEA_TOKEN}" -H "Content-Type: application/json" -d "{\"ref\":\"${BRANCH}\",\"inputs\":{\"stability\":\"release-candidate\"}}"
echo "### Pre-Release" >> $GITHUB_STEP_SUMMARY
echo "Triggered RC build on branch \`${BRANCH}\`" >> $GITHUB_STEP_SUMMARY
# ── Issue Reporter ──────────────────────────────────────────────────────
report-issues:
name: Report Issues
runs-on: ubuntu-latest
needs: [branch-policy, validate]
if: >-
always() &&
needs.validate.result == 'failure'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
sparse-checkout: automation/ci-issue-reporter.sh
sparse-checkout-cone-mode: false
- name: "File issue for PR validation failure"
env:
GITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
GITEA_URL: ${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}
run: |
chmod +x automation/ci-issue-reporter.sh
./automation/ci-issue-reporter.sh \
--gate "PR Validation" \
--workflow "PR Check" \
--severity error \
--details "PR validation failed (syntax, manifest, changelog, or source checks). See the CI run for the specific check that failed."
+18 -4
View File
@@ -49,10 +49,8 @@ jobs:
name: "Build Pre-Release (${{ inputs.stability || github.ref_name }})" name: "Build Pre-Release (${{ inputs.stability || github.ref_name }})"
runs-on: release runs-on: release
if: >- if: >-
(github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_dispatch' ||
github.event_name == 'push') && github.event_name == 'push'
!contains(github.event.head_commit.message, '[skip ci]') &&
!contains(github.event.head_commit.message, '[skip bump]')
steps: steps:
- name: Checkout - name: Checkout
@@ -90,8 +88,20 @@ jobs:
php ${MOKO_CLI}/platform_detect.php --path . --github-output 2>/dev/null || true php ${MOKO_CLI}/platform_detect.php --path . --github-output 2>/dev/null || true
php ${MOKO_CLI}/manifest_read.php --path . --github-output php ${MOKO_CLI}/manifest_read.php --path . --github-output
- name: Check platform eligibility (Joomla only)
id: eligibility
run: |
PLATFORM="${{ steps.platform.outputs.platform }}"
if [[ "$PLATFORM" == joomla* ]] || [[ "$PLATFORM" == "joomla" ]]; then
echo "proceed=true" >> "$GITHUB_OUTPUT"
else
echo "proceed=false" >> "$GITHUB_OUTPUT"
echo "::notice::Platform '$PLATFORM' — non-Joomla, skipping pre-release auto-bump"
fi
- name: Resolve metadata and bump version - name: Resolve metadata and bump version
id: meta id: meta
if: steps.eligibility.outputs.proceed == 'true'
run: | run: |
# Auto-detect stability from branch name on push, or use input on dispatch # Auto-detect stability from branch name on push, or use input on dispatch
if [ "${{ github.event_name }}" = "push" ]; then if [ "${{ github.event_name }}" = "push" ]; then
@@ -168,6 +178,7 @@ jobs:
- name: Create release - name: Create release
id: release id: release
if: steps.eligibility.outputs.proceed == 'true'
run: | run: |
TAG="${{ steps.meta.outputs.tag }}" TAG="${{ steps.meta.outputs.tag }}"
VERSION="${{ steps.meta.outputs.version }}" VERSION="${{ steps.meta.outputs.version }}"
@@ -178,6 +189,7 @@ jobs:
--repo "${GITEA_REPO}" --branch "${{ github.ref_name }}" --prerelease --repo "${GITEA_REPO}" --branch "${{ github.ref_name }}" --prerelease
- name: Update release notes from CHANGELOG.md - name: Update release notes from CHANGELOG.md
if: steps.eligibility.outputs.proceed == 'true'
run: | run: |
TAG="${{ steps.meta.outputs.tag }}" TAG="${{ steps.meta.outputs.tag }}"
VERSION="${{ steps.meta.outputs.version }}" VERSION="${{ steps.meta.outputs.version }}"
@@ -214,6 +226,7 @@ jobs:
- name: Build package and upload - name: Build package and upload
id: package id: package
if: steps.eligibility.outputs.proceed == 'true'
run: | run: |
VERSION="${{ steps.meta.outputs.version }}" VERSION="${{ steps.meta.outputs.version }}"
TAG="${{ steps.meta.outputs.tag }}" TAG="${{ steps.meta.outputs.tag }}"
@@ -227,6 +240,7 @@ jobs:
# No need to build, commit, or sync updates.xml from workflows # No need to build, commit, or sync updates.xml from workflows
- name: "Delete lesser pre-release channels (cascade)" - name: "Delete lesser pre-release channels (cascade)"
if: steps.eligibility.outputs.proceed == 'true'
continue-on-error: true continue-on-error: true
run: | run: |
API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}" API_BASE="${GITEA_URL}/api/v1/repos/${GITEA_ORG}/${GITEA_REPO}"
+2 -2
View File
@@ -4,8 +4,8 @@
# #
# FILE INFORMATION # FILE INFORMATION
# DEFGROUP: Gitea.Workflow # DEFGROUP: Gitea.Workflow
# INGROUP: MokoPlatform.Universal # INGROUP: mokocli.Universal
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokoplatform # REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /.mokogitea/workflows/rc-revert.yml # PATH: /.mokogitea/workflows/rc-revert.yml
# VERSION: 09.23.00 # VERSION: 09.23.00
# BRIEF: Rename rc/ branch back to dev/ when PR is closed without merge # BRIEF: Rename rc/ branch back to dev/ when PR is closed without merge
@@ -0,0 +1,73 @@
# Copyright (C) 2026 Moko Consulting <hello@mokoconsulting.tech>
#
# SPDX-License-Identifier: GPL-3.0-or-later
#
# FILE INFORMATION
# DEFGROUP: Gitea.Workflow
# INGROUP: mokocli.Universal
# REPO: https://git.mokoconsulting.tech/MokoConsulting/mokocli
# PATH: /.mokogitea/workflows/workflow-sync-trigger.yml
# VERSION: 01.01.00
# BRIEF: Trigger workflow sync to live repos when a PR is merged to main
name: "Universal: Workflow Sync Trigger"
on:
pull_request:
types: [closed]
branches:
- main
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
sync:
name: Sync workflows to live repos
runs-on: ubuntu-latest
if: >-
github.event.pull_request.merged == true &&
!contains(github.event.pull_request.title, '[skip sync]')
steps:
- name: Determine platform from repo name
id: platform
run: |
REPO="${{ github.event.repository.name }}"
case "$REPO" in
Template-Joomla) PLATFORM="joomla" ;;
Template-Dolibarr) PLATFORM="dolibarr" ;;
Template-Go) PLATFORM="go" ;;
Template-MCP) PLATFORM="mcp" ;;
Template-Generic) PLATFORM="" ;;
*) PLATFORM="" ;;
esac
echo "platform=$PLATFORM" >> "$GITHUB_OUTPUT"
echo "Platform: ${PLATFORM:-all}"
- name: Clone mokocli
env:
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
run: |
GITEA_URL="${{ vars.GITEA_URL || 'https://git.mokoconsulting.tech' }}"
git clone --depth 1 "${GITEA_URL}/MokoConsulting/mokocli.git" /tmp/mokocli
- name: Install dependencies
run: |
cd /tmp/mokocli
composer install --no-dev --no-interaction --quiet 2>/dev/null || true
- name: Run workflow sync
env:
MOKOGITEA_TOKEN: ${{ secrets.MOKOGITEA_TOKEN }}
run: |
ARGS="--token ${MOKOGITEA_TOKEN}"
ARGS="${ARGS} --org ${{ vars.GITEA_ORG || github.repository_owner }}"
ARGS="${ARGS} --phase repos"
PLATFORM="${{ steps.platform.outputs.platform }}"
if [ -n "$PLATFORM" ]; then
ARGS="${ARGS} --platform-filter ${PLATFORM}"
fi
php /tmp/mokocli/cli/workflow_sync.php ${ARGS}
+19
View File
@@ -10,6 +10,25 @@
- 13 seeded product tiers from base to enterprise - 13 seeded product tiers from base to enterprise
- DLID-gated update XML endpoint: GET /api/v1/licensing/updates/{product}.xml - DLID-gated update XML endpoint: GET /api/v1/licensing/updates/{product}.xml
- Profile repo fallback chain: .mokogitea > .profile > .github - Profile repo fallback chain: .mokogitea > .profile > .github
- Metadata/manifest GET endpoint publicly accessible without auth (#676)
- Org wiki: folder-based collapsible tree sidebar, _Sidebar.md overrides (#680)
- Wiki backlinks: "What links here" page showing all pages referencing current page (#669)
- Wiki wikilinks: [[Page Name]] and [[Page|Display Text]] syntax with red links for missing pages (#666)
- Required baseline issue statuses: Open and Closed are indestructible (is_required flag) (#681)
- Issue status API response includes is_required field
- Wiki recent changes page: cross-page edit activity with pagination (#670)
- Wiki page rename with automatic redirects via YAML frontmatter (#672)
### Fixed
- Metadata settings template 500 error: removed reference to deleted Version field
- Wiki recent changes: use commit.MessageTitle() instead of commit.Message()
- Wiki backlinks: proper URL encoding for subdirectory pages
- Wiki wikilinks: page existence lookup normalizes spaces and hyphens
- Issue statuses template: garbled em-dash character replaced
### Changed
- Issue status seed defaults: Open, In Progress, Waiting, In Review, Closed, Won't Fix
- Pre-release workflow: auto-bump skipped for non-Joomla repos (platform check)
## [06.19.00] --- 2026-06-20 ## [06.19.00] --- 2026-06-20
+32 -6
View File
@@ -22,6 +22,7 @@ type IssueStatusDef struct {
Color string `xorm:"VARCHAR(7)"` // hex color, e.g. "#e11d48" Color string `xorm:"VARCHAR(7)"` // hex color, e.g. "#e11d48"
Description string `xorm:"TEXT"` Description string `xorm:"TEXT"`
ClosesIssue bool `xorm:"NOT NULL DEFAULT false 'closes_issue'"` ClosesIssue bool `xorm:"NOT NULL DEFAULT false 'closes_issue'"`
IsRequired bool `xorm:"NOT NULL DEFAULT false 'is_required'"` // cannot be deleted
SortOrder int `xorm:"NOT NULL DEFAULT 0 'sort_order'"` SortOrder int `xorm:"NOT NULL DEFAULT 0 'sort_order'"`
IsActive bool `xorm:"NOT NULL DEFAULT true 'is_active'"` IsActive bool `xorm:"NOT NULL DEFAULT true 'is_active'"`
CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED 'created_unix'"` CreatedUnix timeutil.TimeStamp `xorm:"INDEX CREATED 'created_unix'"`
@@ -56,14 +57,15 @@ func GetIssueStatusDefsByOrg(ctx context.Context, orgID int64) ([]*IssueStatusDe
} }
// seedDefaultIssueStatuses creates the standard status presets for an org. // seedDefaultIssueStatuses creates the standard status presets for an org.
// Open and Closed are required (is_required=true) and cannot be deleted.
func seedDefaultIssueStatuses(ctx context.Context, orgID int64) error { func seedDefaultIssueStatuses(ctx context.Context, orgID int64) error {
defaults := []*IssueStatusDef{ defaults := []*IssueStatusDef{
{OrgID: orgID, Name: "In Progress", Color: "#2563eb", Description: "Work is actively being done", SortOrder: 1, IsActive: true}, {OrgID: orgID, Name: "Open", Color: "#2563eb", Description: "New or active issue", ClosesIssue: false, IsRequired: true, SortOrder: 0, IsActive: true},
{OrgID: orgID, Name: "Needs Info", Color: "#f59e0b", Description: "Waiting for more information", SortOrder: 2, IsActive: true}, {OrgID: orgID, Name: "In Progress", Color: "#7c3aed", Description: "Work is actively being done", SortOrder: 1, IsActive: true},
{OrgID: orgID, Name: "Blocked", Color: "#dc2626", Description: "Cannot proceed due to dependency", SortOrder: 3, IsActive: true}, {OrgID: orgID, Name: "Waiting", Color: "#f59e0b", Description: "Blocked or waiting for input", SortOrder: 2, IsActive: true},
{OrgID: orgID, Name: "Resolved", Color: "#16a34a", Description: "Fix implemented and verified", ClosesIssue: true, SortOrder: 4, IsActive: true}, {OrgID: orgID, Name: "In Review", Color: "#0891b2", Description: "PR submitted, awaiting review", SortOrder: 3, IsActive: true},
{OrgID: orgID, Name: "Closed", Color: "#16a34a", Description: "Completed or resolved", ClosesIssue: true, IsRequired: true, SortOrder: 4, IsActive: true},
{OrgID: orgID, Name: "Won't Fix", Color: "#6b7280", Description: "Decided not to address", ClosesIssue: true, SortOrder: 5, IsActive: true}, {OrgID: orgID, Name: "Won't Fix", Color: "#6b7280", Description: "Decided not to address", ClosesIssue: true, SortOrder: 5, IsActive: true},
{OrgID: orgID, Name: "Duplicate", Color: "#8b5cf6", Description: "Already tracked elsewhere", ClosesIssue: true, SortOrder: 6, IsActive: true},
} }
for _, d := range defaults { for _, d := range defaults {
if _, err := db.GetEngine(ctx).Insert(d); err != nil { if _, err := db.GetEngine(ctx).Insert(d); err != nil {
@@ -111,13 +113,37 @@ func UpdateIssueStatusDef(ctx context.Context, def *IssueStatusDef) error {
return err return err
} }
// ErrStatusRequired is returned when trying to delete a required status.
type ErrStatusRequired struct {
ID int64
Name string
}
func (e ErrStatusRequired) Error() string {
return "status is required and cannot be deleted"
}
// IsErrStatusRequired checks if an error is ErrStatusRequired.
func IsErrStatusRequired(err error) bool {
_, ok := err.(ErrStatusRequired)
return ok
}
// DeleteIssueStatusDef deletes a status definition and clears references on issues. // DeleteIssueStatusDef deletes a status definition and clears references on issues.
// Returns ErrStatusRequired if the status is marked as required.
func DeleteIssueStatusDef(ctx context.Context, id int64) error { func DeleteIssueStatusDef(ctx context.Context, id int64) error {
def, err := GetIssueStatusDefByID(ctx, id)
if err != nil {
return err
}
if def.IsRequired {
return ErrStatusRequired{ID: def.ID, Name: def.Name}
}
// Clear status_id on all issues that reference this definition // Clear status_id on all issues that reference this definition
if _, err := db.GetEngine(ctx).Exec("UPDATE issue SET status_id = 0 WHERE status_id = ?", id); err != nil { if _, err := db.GetEngine(ctx).Exec("UPDATE issue SET status_id = 0 WHERE status_id = ?", id); err != nil {
return err return err
} }
_, err := db.GetEngine(ctx).ID(id).Delete(new(IssueStatusDef)) _, err = db.GetEngine(ctx).ID(id).Delete(new(IssueStatusDef))
return err return err
} }
+1
View File
@@ -165,6 +165,7 @@ type IssueStatusDef struct {
Color string `json:"color"` Color string `json:"color"`
Description string `json:"description"` Description string `json:"description"`
ClosesIssue bool `json:"closes_issue"` ClosesIssue bool `json:"closes_issue"`
IsRequired bool `json:"is_required"`
SortOrder int `json:"sort_order"` SortOrder int `json:"sort_order"`
} }
+35 -7
View File
@@ -1480,12 +1480,10 @@ func Routes() *web.Router {
Delete(reqToken(), repo.DeleteTopic) Delete(reqToken(), repo.DeleteTopic)
}, reqAdmin()) }, reqAdmin())
}, reqAnyRepoReader()) }, reqAnyRepoReader())
m.Combo("/metadata", reqRepoReader(unit.TypeCode)). m.Get("/metadata", repo.GetRepoMetadata)
Get(repo.GetRepoMetadata). m.Put("/metadata", reqToken(), reqAdmin(), repo.UpdateRepoMetadata)
Put(reqToken(), reqAdmin(), repo.UpdateRepoMetadata) m.Get("/manifest", repo.GetRepoMetadata) // backward compat
m.Combo("/manifest", reqRepoReader(unit.TypeCode)). // backward compat m.Put("/manifest", reqToken(), reqAdmin(), repo.UpdateRepoMetadata)
Get(repo.GetRepoMetadata).
Put(reqToken(), reqAdmin(), repo.UpdateRepoMetadata)
// MokoGitea badge engine // MokoGitea badge engine
m.Get("/badge/{type}.svg", repo.GetRepoBadge) m.Get("/badge/{type}.svg", repo.GetRepoBadge)
m.Get("/issue_templates", reqRepoReader(unit.TypeCode), context.ReferencesGitRepo(), repo.GetIssueTemplates) m.Get("/issue_templates", reqRepoReader(unit.TypeCode), context.ReferencesGitRepo(), repo.GetIssueTemplates)
@@ -1860,9 +1858,39 @@ func Routes() *web.Router {
m.Get("/search", repo.TopicSearch) m.Get("/search", repo.TopicSearch)
}, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository)) }, tokenRequiresScopes(auth_model.AccessTokenScopeCategoryRepository))
// Licensing endpoints — DLID-gated, no token required // Licensing endpoints
m.Group("/licensing", func() { m.Group("/licensing", func() {
// Public (no auth)
m.Get("/updates/{product}", licensing.ServeUpdates) m.Get("/updates/{product}", licensing.ServeUpdates)
m.Get("/validate", licensing.Validate)
m.Get("/download/{product}/{version}", licensing.ServeDownload)
// User self-service (authenticated)
m.Group("/my", func() {
m.Get("/licenses", licensing.MyLicenses)
m.Get("/licenses/{id}/domains", licensing.MyLicenseDomains)
m.Delete("/licenses/{id}/domains/{domain}", licensing.MyDeactivateDomain)
}, reqToken())
// Admin license management
m.Group("/licenses", func() {
m.Get("", licensing.ListLicenses)
m.Post("", licensing.CreateLicense)
m.Get("/{id}", licensing.GetLicense)
m.Patch("/{id}", licensing.UpdateLicense)
m.Delete("/{id}", licensing.DeleteLicense)
}, reqToken(), reqSiteAdmin())
// Admin tier management
m.Group("/tiers", func() {
m.Get("", licensing.ListTiers)
m.Post("", licensing.CreateTier)
m.Patch("/{id}", licensing.UpdateTier)
m.Delete("/{id}", licensing.DeleteTier)
}, reqToken(), reqSiteAdmin())
// Authenticated license detail
m.Get("/{dlid}/status", reqToken(), licensing.Status)
}) })
}, sudo()) }, sudo())
+154
View File
@@ -0,0 +1,154 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package licensing
import (
"fmt"
"io"
"net/http"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
licensing_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licensing"
repo_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/storage"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
licensing_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/licensing"
)
// ServeDownload handles GET /api/v1/licensing/download/{product}/{version}.zip?token=XXX&expires=YYY&dlid=ZZZ
func ServeDownload(ctx *context.APIContext) {
product := ctx.PathParam("product")
versionFile := ctx.PathParam("version")
token := ctx.FormString("token")
expiresStr := ctx.FormString("expires")
dlid := ctx.FormString("dlid")
version, ok := licensing_service.ParseDownloadParams(versionFile)
if !ok {
ctx.APIError(http.StatusBadRequest, "invalid version format")
return
}
expires, ok := licensing_service.ParseExpires(expiresStr)
if !ok || token == "" || dlid == "" {
ctx.APIError(http.StatusForbidden, "missing or invalid download parameters")
return
}
// Verify signed token
if !licensing_service.VerifyDownloadToken(token, product, version, dlid, expires) {
ctx.APIError(http.StatusForbidden, "invalid or expired download token")
return
}
// Verify DLID is still valid
license, err := licensing_model.GetLicenseByDLID(ctx, dlid)
if err != nil || license == nil || !license.IsActive() {
ctx.APIError(http.StatusForbidden, "license invalid or expired")
return
}
// Verify entitlement
has, _ := licensing_model.HasEntitlement(ctx, license.ID, product)
if !has {
ctx.APIError(http.StatusForbidden, "no entitlement for product")
return
}
// Resolve repo from entitlement
ents, err := licensing_model.GetEntitlementsByLicense(ctx, license.ID)
if err != nil {
ctx.APIError(http.StatusInternalServerError, "failed to get entitlements")
return
}
var repoOwner, repoName string
for _, ent := range ents {
if ent.ProductCode == product {
repoOwner = ent.RepoOwner
repoName = ent.RepoName
break
}
}
if repoName == "" {
ctx.APIError(http.StatusNotFound, "product repo not found")
return
}
// Find repo
repo, err := repo_model.GetRepositoryByOwnerAndName(ctx, repoOwner, repoName)
if err != nil || repo == nil {
ctx.APIError(http.StatusNotFound, "repository not found")
return
}
// Find the release with matching version
releases, err := db.Find[repo_model.Release](ctx, repo_model.FindReleasesOptions{
RepoID: repo.ID,
ListOptions: db.ListOptionsAll,
IncludeDrafts: false,
IncludeTags: false,
})
if err != nil {
ctx.APIError(http.StatusInternalServerError, "failed to list releases")
return
}
var targetRelease *repo_model.Release
for _, rel := range releases {
relVersion := extractVersion(rel.TagName)
if relVersion == version {
targetRelease = rel
break
}
if rel.Title != "" && extractVersion(rel.Title) == version {
targetRelease = rel
break
}
}
if targetRelease == nil {
ctx.APIError(http.StatusNotFound, fmt.Sprintf("release version %s not found", version))
return
}
// Find ZIP attachment
var attachments []*repo_model.Attachment
err = db.GetEngine(ctx).Where("release_id = ?", targetRelease.ID).Find(&attachments)
if err != nil {
ctx.APIError(http.StatusInternalServerError, "failed to get attachments")
return
}
var zipAttachment *repo_model.Attachment
for _, att := range attachments {
if att.Name != "" && len(att.Name) > 4 && att.Name[len(att.Name)-4:] == ".zip" {
zipAttachment = att
break
}
}
if zipAttachment == nil {
ctx.APIError(http.StatusNotFound, "no zip attachment found for release")
return
}
// Log the download
licensing_model.LogLicenseAudit(ctx, license.ID, "download",
product, fmt.Sprintf("%s/%s", version, zipAttachment.Name))
// Serve the file
fr, err := storage.Attachments.Open(zipAttachment.RelativePath())
if err != nil {
ctx.APIError(http.StatusInternalServerError, "failed to open attachment")
return
}
defer fr.Close()
ctx.Resp.Header().Set("Content-Type", "application/zip")
ctx.Resp.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", zipAttachment.Name))
ctx.Resp.WriteHeader(http.StatusOK)
if _, err := io.Copy(ctx.Resp, fr); err != nil {
log.Error("ServeDownload: io.Copy: %v", err)
}
}
+479
View File
@@ -0,0 +1,479 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package licensing
import (
"encoding/json"
"net/http"
"strconv"
"time"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
licensing_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licensing"
mojo_json "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/json"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
)
// ── Admin: License CRUD ─────────────────────────────────────────────────
type createLicenseRequest struct {
UserID int64 `json:"user_id" binding:"Required"`
Tier string `json:"tier" binding:"Required"`
MaxDomains int `json:"max_domains"`
ExpiresMonths int `json:"expires_months"`
Notes string `json:"notes"`
}
// CreateLicense handles POST /api/v1/licensing/licenses
func CreateLicense(ctx *context.APIContext) {
var req createLicenseRequest
if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil {
ctx.APIError(http.StatusBadRequest, "invalid request body")
return
}
// Resolve max_domains from tier if not specified
maxDomains := req.MaxDomains
if maxDomains == 0 {
tier, _ := licensing_model.GetProductTierByKey(ctx, req.Tier)
if tier != nil {
maxDomains = tier.MaxDomains
}
if maxDomains == 0 {
maxDomains = 1
}
}
var expiresAt timeutil.TimeStamp
if req.ExpiresMonths > 0 {
expiresAt = timeutil.TimeStamp(time.Now().AddDate(0, req.ExpiresMonths, 0).Unix())
}
license, err := licensing_model.CreateLicense(ctx, req.UserID, req.Tier, maxDomains, expiresAt)
if err != nil {
ctx.APIError(http.StatusInternalServerError, "failed to create license")
return
}
if req.Notes != "" {
license.Notes = req.Notes
// TODO: update notes field
}
// Build entitlements from tier
if err := licensing_model.RebuildEntitlements(ctx, license.ID, req.Tier); err != nil {
log.Error("CreateLicense: RebuildEntitlements: %v", err)
}
ctx.JSON(http.StatusCreated, licenseToJSON(ctx, license))
}
// ListLicenses handles GET /api/v1/licensing/licenses
func ListLicenses(ctx *context.APIContext) {
page := ctx.FormInt("page")
if page <= 0 {
page = 1
}
limit := ctx.FormInt("limit")
if limit <= 0 || limit > 50 {
limit = 20
}
// For now, get all licenses (pagination via offset)
// TODO: add proper pagination to the model layer
var licenses []*licensing_model.License
err := db.GetEngine(ctx).Limit(limit, (page-1)*limit).Find(&licenses)
if err != nil {
ctx.APIError(http.StatusInternalServerError, "failed to list licenses")
return
}
results := make([]map[string]any, 0, len(licenses))
for _, l := range licenses {
results = append(results, licenseToJSON(ctx, l))
}
ctx.JSON(http.StatusOK, results)
}
// GetLicense handles GET /api/v1/licensing/licenses/{id}
func GetLicense(ctx *context.APIContext) {
id, err := strconv.ParseInt(ctx.PathParam("id"), 10, 64)
if err != nil {
ctx.APIError(http.StatusBadRequest, "invalid license ID")
return
}
license, err := licensing_model.GetLicenseByID(ctx, id)
if err != nil || license == nil {
ctx.APIErrorNotFound()
return
}
result := licenseToJSON(ctx, license)
// Include entitlements
ents, _ := licensing_model.GetEntitlementsByLicense(ctx, license.ID)
entList := make([]map[string]any, 0, len(ents))
for _, e := range ents {
entList = append(entList, map[string]any{
"product_code": e.ProductCode,
"repo_owner": e.RepoOwner,
"repo_name": e.RepoName,
"is_custom": e.IsCustom,
})
}
result["entitlements"] = entList
// Include activations
acts, _ := licensing_model.GetActivationsByLicense(ctx, license.ID)
actList := make([]map[string]any, 0, len(acts))
for _, a := range acts {
actList = append(actList, map[string]any{
"domain": a.Domain,
"ip_address": a.IPAddress,
"joomla_ver": a.JoomlaVer,
"activated_at": formatTime(a.ActivatedAt),
"last_seen_at": formatTime(a.LastSeenAt),
})
}
result["activations"] = actList
ctx.JSON(http.StatusOK, result)
}
type updateLicenseRequest struct {
Tier *string `json:"tier"`
Status *string `json:"status"`
MaxDomains *int `json:"max_domains"`
ExpiresAt *string `json:"expires_at"`
Notes *string `json:"notes"`
}
// UpdateLicense handles PATCH /api/v1/licensing/licenses/{id}
func UpdateLicense(ctx *context.APIContext) {
id, err := strconv.ParseInt(ctx.PathParam("id"), 10, 64)
if err != nil {
ctx.APIError(http.StatusBadRequest, "invalid license ID")
return
}
license, err := licensing_model.GetLicenseByID(ctx, id)
if err != nil || license == nil {
ctx.APIErrorNotFound()
return
}
var req updateLicenseRequest
if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil {
ctx.APIError(http.StatusBadRequest, "invalid request body")
return
}
if req.Tier != nil && *req.Tier != license.Tier {
if err := licensing_model.UpdateLicenseTier(ctx, id, *req.Tier); err != nil {
ctx.APIError(http.StatusInternalServerError, "failed to update tier")
return
}
license.Tier = *req.Tier
}
if req.Status != nil && *req.Status != license.Status {
if err := licensing_model.SetLicenseStatus(ctx, id, *req.Status); err != nil {
ctx.APIError(http.StatusInternalServerError, "failed to update status")
return
}
license.Status = *req.Status
}
// Update simple fields directly
cols := make([]string, 0)
if req.MaxDomains != nil {
license.MaxDomains = *req.MaxDomains
cols = append(cols, "max_domains")
}
if req.Notes != nil {
license.Notes = *req.Notes
cols = append(cols, "notes")
}
if req.ExpiresAt != nil {
t, err := time.Parse(time.RFC3339, *req.ExpiresAt)
if err == nil {
license.ExpiresAt = timeutil.TimeStamp(t.Unix())
cols = append(cols, "expires_at")
}
}
if len(cols) > 0 {
cols = append(cols, "updated_at")
db.GetEngine(ctx).ID(id).Cols(cols...).Update(license)
}
ctx.JSON(http.StatusOK, licenseToJSON(ctx, license))
}
// DeleteLicense handles DELETE /api/v1/licensing/licenses/{id}
func DeleteLicense(ctx *context.APIContext) {
id, err := strconv.ParseInt(ctx.PathParam("id"), 10, 64)
if err != nil {
ctx.APIError(http.StatusBadRequest, "invalid license ID")
return
}
if err := licensing_model.RevokeLicense(ctx, id); err != nil {
ctx.APIError(http.StatusInternalServerError, "failed to revoke license")
return
}
ctx.Status(http.StatusNoContent)
}
// ── User: Self-service ──────────────────────────────────────────────────
// MyLicenses handles GET /api/v1/licensing/my/licenses
func MyLicenses(ctx *context.APIContext) {
licenses, err := licensing_model.GetLicensesByUser(ctx, ctx.Doer.ID)
if err != nil {
ctx.APIError(http.StatusInternalServerError, "failed to list licenses")
return
}
results := make([]map[string]any, 0, len(licenses))
for _, l := range licenses {
results = append(results, licenseToJSON(ctx, l))
}
ctx.JSON(http.StatusOK, results)
}
// MyLicenseDomains handles GET /api/v1/licensing/my/licenses/{id}/domains
func MyLicenseDomains(ctx *context.APIContext) {
id, err := strconv.ParseInt(ctx.PathParam("id"), 10, 64)
if err != nil {
ctx.APIError(http.StatusBadRequest, "invalid license ID")
return
}
license, err := licensing_model.GetLicenseByID(ctx, id)
if err != nil || license == nil || license.UserID != ctx.Doer.ID {
ctx.APIErrorNotFound()
return
}
acts, err := licensing_model.GetActivationsByLicense(ctx, id)
if err != nil {
ctx.APIError(http.StatusInternalServerError, "failed to list domains")
return
}
results := make([]map[string]any, 0, len(acts))
for _, a := range acts {
results = append(results, map[string]any{
"domain": a.Domain,
"activated_at": formatTime(a.ActivatedAt),
"last_seen_at": formatTime(a.LastSeenAt),
})
}
ctx.JSON(http.StatusOK, results)
}
// MyDeactivateDomain handles DELETE /api/v1/licensing/my/licenses/{id}/domains/{domain}
func MyDeactivateDomain(ctx *context.APIContext) {
id, err := strconv.ParseInt(ctx.PathParam("id"), 10, 64)
if err != nil {
ctx.APIError(http.StatusBadRequest, "invalid license ID")
return
}
license, err := licensing_model.GetLicenseByID(ctx, id)
if err != nil || license == nil || license.UserID != ctx.Doer.ID {
ctx.APIErrorNotFound()
return
}
domain := ctx.PathParam("domain")
if err := licensing_model.DeactivateDomain(ctx, id, domain); err != nil {
ctx.APIError(http.StatusInternalServerError, "failed to deactivate domain")
return
}
ctx.Status(http.StatusNoContent)
}
// ── Admin: Product Tier CRUD ────────────────────────────────────────────
// ListTiers handles GET /api/v1/licensing/tiers
func ListTiers(ctx *context.APIContext) {
tiers, err := licensing_model.GetAllProductTiers(ctx)
if err != nil {
ctx.APIError(http.StatusInternalServerError, "failed to list tiers")
return
}
results := make([]map[string]any, 0, len(tiers))
for _, t := range tiers {
results = append(results, tierToJSON(t))
}
ctx.JSON(http.StatusOK, results)
}
type createTierRequest struct {
TierKey string `json:"tier_key" binding:"Required"`
TierName string `json:"tier_name" binding:"Required"`
Repos []string `json:"repos"`
MaxDomains int `json:"max_domains"`
SortOrder int `json:"sort_order"`
}
// CreateTier handles POST /api/v1/licensing/tiers
func CreateTier(ctx *context.APIContext) {
var req createTierRequest
if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil {
ctx.APIError(http.StatusBadRequest, "invalid request body")
return
}
reposJSON, _ := mojo_json.Marshal(req.Repos)
tier := &licensing_model.ProductTier{
TierKey: req.TierKey,
TierName: req.TierName,
Repos: string(reposJSON),
MaxDomains: req.MaxDomains,
SortOrder: req.SortOrder,
}
_, err := db.GetEngine(ctx).Insert(tier)
if err != nil {
ctx.APIError(http.StatusInternalServerError, "failed to create tier")
return
}
ctx.JSON(http.StatusCreated, tierToJSON(tier))
}
type updateTierRequest struct {
TierName *string `json:"tier_name"`
Repos []string `json:"repos"`
MaxDomains *int `json:"max_domains"`
SortOrder *int `json:"sort_order"`
}
// UpdateTier handles PATCH /api/v1/licensing/tiers/{id}
func UpdateTier(ctx *context.APIContext) {
id, err := strconv.ParseInt(ctx.PathParam("id"), 10, 64)
if err != nil {
ctx.APIError(http.StatusBadRequest, "invalid tier ID")
return
}
tier := new(licensing_model.ProductTier)
has, err := db.GetEngine(ctx).ID(id).Get(tier)
if err != nil || !has {
ctx.APIErrorNotFound()
return
}
var req updateTierRequest
if err := json.NewDecoder(ctx.Req.Body).Decode(&req); err != nil {
ctx.APIError(http.StatusBadRequest, "invalid request body")
return
}
cols := make([]string, 0)
if req.TierName != nil {
tier.TierName = *req.TierName
cols = append(cols, "tier_name")
}
if req.Repos != nil {
reposJSON, _ := mojo_json.Marshal(req.Repos)
tier.Repos = string(reposJSON)
cols = append(cols, "repos")
}
if req.MaxDomains != nil {
tier.MaxDomains = *req.MaxDomains
cols = append(cols, "max_domains")
}
if req.SortOrder != nil {
tier.SortOrder = *req.SortOrder
cols = append(cols, "sort_order")
}
if len(cols) > 0 {
db.GetEngine(ctx).ID(id).Cols(cols...).Update(tier)
}
ctx.JSON(http.StatusOK, tierToJSON(tier))
}
// DeleteTier handles DELETE /api/v1/licensing/tiers/{id}
func DeleteTier(ctx *context.APIContext) {
id, err := strconv.ParseInt(ctx.PathParam("id"), 10, 64)
if err != nil {
ctx.APIError(http.StatusBadRequest, "invalid tier ID")
return
}
// Check if any licenses use this tier
tier := new(licensing_model.ProductTier)
has, _ := db.GetEngine(ctx).ID(id).Get(tier)
if !has {
ctx.APIErrorNotFound()
return
}
count, _ := db.GetEngine(ctx).Where("tier = ?", tier.TierKey).Count(new(licensing_model.License))
if count > 0 {
ctx.APIError(http.StatusConflict, "cannot delete tier with active licenses")
return
}
db.GetEngine(ctx).ID(id).Delete(new(licensing_model.ProductTier))
ctx.Status(http.StatusNoContent)
}
// ── Helpers ─────────────────────────────────────────────────────────────
func licenseToJSON(ctx *context.APIContext, l *licensing_model.License) map[string]any {
tierName := l.Tier
tier, _ := licensing_model.GetProductTierByKey(ctx, l.Tier)
if tier != nil {
tierName = tier.TierName
}
domainCount, _ := licensing_model.CountActivations(ctx, l.ID)
result := map[string]any{
"id": l.ID,
"user_id": l.UserID,
"dlid": l.DLID,
"tier": l.Tier,
"tier_name": tierName,
"max_domains": l.MaxDomains,
"domains_used": domainCount,
"status": l.Status,
"notes": l.Notes,
"created_at": formatTime(l.CreatedAt),
"updated_at": formatTime(l.UpdatedAt),
}
if l.ExpiresAt > 0 {
result["expires_at"] = formatTime(l.ExpiresAt)
}
return result
}
func tierToJSON(t *licensing_model.ProductTier) map[string]any {
return map[string]any{
"id": t.ID,
"tier_key": t.TierKey,
"tier_name": t.TierName,
"repos": t.RepoList(),
"max_domains": t.MaxDomains,
"sort_order": t.SortOrder,
}
}
func formatTime(ts timeutil.TimeStamp) string {
if ts == 0 {
return ""
}
return time.Unix(int64(ts), 0).UTC().Format(time.RFC3339)
}
+5 -3
View File
@@ -15,6 +15,7 @@ import (
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log" "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/setting" "code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/setting"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context" "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
licensing_service "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/licensing"
) )
// Joomla update XML structures. // Joomla update XML structures.
@@ -186,10 +187,11 @@ func ServeUpdates(ctx *context.APIContext) {
displayName = manifest.DerivedDisplayName() displayName = manifest.DerivedDisplayName()
} }
// Build download URL // Build signed download URL
baseURL := setting.AppURL baseURL := setting.AppURL
downloadURL := fmt.Sprintf("%sapi/v1/licensing/download/%s/%s.zip?dlid=%s", token, expires := licensing_service.SignDownloadToken(productCode, version, dlid)
baseURL, productCode, version, dlid) downloadURL := fmt.Sprintf("%sapi/v1/licensing/download/%s/%s.zip?dlid=%s&token=%s&expires=%d",
baseURL, productCode, version, dlid, token, expires)
updates := xmlUpdates{ updates := xmlUpdates{
Updates: []xmlUpdate{ Updates: []xmlUpdate{
+194
View File
@@ -0,0 +1,194 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package licensing
import (
"net/http"
"time"
licensing_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licensing"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
)
// validateResponse is the public validation result.
type validateResponse struct {
Valid bool `json:"valid"`
Tier string `json:"tier,omitempty"`
TierName string `json:"tier_name,omitempty"`
Status string `json:"status,omitempty"`
Reason string `json:"reason,omitempty"`
DomainsUsed int `json:"domains_used,omitempty"`
DomainsMax int `json:"domains_max,omitempty"`
ExpiresAt string `json:"expires_at,omitempty"`
}
// statusResponse is the full license detail for authenticated callers.
type statusResponse struct {
Valid bool `json:"valid"`
DLID string `json:"dlid"`
Tier string `json:"tier"`
TierName string `json:"tier_name"`
Status string `json:"status"`
Products []string `json:"products"`
DomainsUsed int `json:"domains_used"`
DomainsMax int `json:"domains_max"`
ExpiresAt string `json:"expires_at,omitempty"`
CreatedAt string `json:"created_at"`
}
// Validate handles GET /api/v1/licensing/validate?dlid=XXX&product=YYY&domain=ZZZ
// Public endpoint — no auth required. Returns minimal valid/invalid with reason.
func Validate(ctx *context.APIContext) {
dlid := ctx.FormString("dlid")
product := ctx.FormString("product")
domain := ctx.FormString("domain")
if dlid == "" {
ctx.JSON(http.StatusOK, validateResponse{Valid: false, Reason: "missing_dlid"})
return
}
if !licensing_model.ValidateDLIDFormat(dlid) {
ctx.JSON(http.StatusOK, validateResponse{Valid: false, Reason: "invalid_dlid"})
return
}
license, err := licensing_model.GetLicenseByDLID(ctx, dlid)
if err != nil {
log.Error("Validate: GetLicenseByDLID: %v", err)
ctx.JSON(http.StatusOK, validateResponse{Valid: false, Reason: "internal_error"})
return
}
if license == nil {
ctx.JSON(http.StatusOK, validateResponse{Valid: false, Reason: "invalid_dlid"})
return
}
if license.Status == "revoked" {
ctx.JSON(http.StatusOK, validateResponse{Valid: false, Reason: "revoked"})
return
}
if license.Status == "suspended" {
ctx.JSON(http.StatusOK, validateResponse{Valid: false, Reason: "suspended"})
return
}
if license.IsExpired() {
ctx.JSON(http.StatusOK, validateResponse{Valid: false, Reason: "expired"})
return
}
// Check product entitlement if product is specified
if product != "" {
has, err := licensing_model.HasEntitlement(ctx, license.ID, product)
if err != nil {
log.Error("Validate: HasEntitlement: %v", err)
}
if !has {
ctx.JSON(http.StatusOK, validateResponse{Valid: false, Reason: "no_entitlement"})
return
}
}
// Check domain limit if domain is specified
if domain != "" {
domainCount, _ := licensing_model.CountActivations(ctx, license.ID)
if license.MaxDomains > 0 && domainCount >= int64(license.MaxDomains) {
// Check if this domain is already activated
acts, _ := licensing_model.GetActivationsByLicense(ctx, license.ID)
found := false
for _, a := range acts {
if a.Domain == domain {
found = true
break
}
}
if !found {
ctx.JSON(http.StatusOK, validateResponse{Valid: false, Reason: "domain_limit"})
return
}
}
}
// Look up tier name
tierName := license.Tier
tier, _ := licensing_model.GetProductTierByKey(ctx, license.Tier)
if tier != nil {
tierName = tier.TierName
}
domainCount, _ := licensing_model.CountActivations(ctx, license.ID)
resp := validateResponse{
Valid: true,
Tier: license.Tier,
TierName: tierName,
Status: license.Status,
DomainsUsed: int(domainCount),
DomainsMax: license.MaxDomains,
}
if license.ExpiresAt > 0 {
resp.ExpiresAt = time.Unix(int64(license.ExpiresAt), 0).UTC().Format(time.RFC3339)
}
ctx.JSON(http.StatusOK, resp)
}
// Status handles GET /api/v1/licensing/{dlid}/status
// Authenticated endpoint — returns full license detail with entitlement list.
func Status(ctx *context.APIContext) {
dlid := ctx.PathParam("dlid")
if dlid == "" || !licensing_model.ValidateDLIDFormat(dlid) {
ctx.JSON(http.StatusBadRequest, map[string]string{"error": "invalid DLID format"})
return
}
license, err := licensing_model.GetLicenseByDLID(ctx, dlid)
if err != nil {
log.Error("Status: GetLicenseByDLID: %v", err)
ctx.JSON(http.StatusInternalServerError, map[string]string{"error": "internal error"})
return
}
if license == nil {
ctx.JSON(http.StatusNotFound, map[string]string{"error": "license not found"})
return
}
// Get entitlements
ents, err := licensing_model.GetEntitlementsByLicense(ctx, license.ID)
if err != nil {
log.Error("Status: GetEntitlementsByLicense: %v", err)
}
products := make([]string, 0, len(ents))
for _, e := range ents {
products = append(products, e.ProductCode)
}
// Get tier name
tierName := license.Tier
tier, _ := licensing_model.GetProductTierByKey(ctx, license.Tier)
if tier != nil {
tierName = tier.TierName
}
domainCount, _ := licensing_model.CountActivations(ctx, license.ID)
resp := statusResponse{
Valid: license.IsActive(),
DLID: license.DLID,
Tier: license.Tier,
TierName: tierName,
Status: license.Status,
Products: products,
DomainsUsed: int(domainCount),
DomainsMax: license.MaxDomains,
CreatedAt: time.Unix(int64(license.CreatedAt), 0).UTC().Format(time.RFC3339),
}
if license.ExpiresAt > 0 {
resp.ExpiresAt = time.Unix(int64(license.ExpiresAt), 0).UTC().Format(time.RFC3339)
}
ctx.JSON(http.StatusOK, resp)
}
+26
View File
@@ -11,6 +11,19 @@ import (
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context" "code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
) )
// checkOrgVisibility returns true if the current user can view org metadata.
// Public orgs are visible to everyone. Private/limited orgs require authentication.
func checkOrgVisibility(ctx *context.APIContext) bool {
if ctx.Org.Organization.Visibility == api.VisibleTypePublic {
return true
}
if ctx.Doer == nil {
ctx.APIErrorNotFound()
return false
}
return true
}
// ListIssueStatuses returns active issue status definitions for an org. // ListIssueStatuses returns active issue status definitions for an org.
func ListIssueStatuses(ctx *context.APIContext) { func ListIssueStatuses(ctx *context.APIContext) {
// swagger:operation GET /orgs/{org}/issue-statuses organization orgListIssueStatuses // swagger:operation GET /orgs/{org}/issue-statuses organization orgListIssueStatuses
@@ -34,6 +47,10 @@ func ListIssueStatuses(ctx *context.APIContext) {
// "404": // "404":
// "$ref": "#/responses/notFound" // "$ref": "#/responses/notFound"
if !checkOrgVisibility(ctx) {
return
}
defs, err := issues_model.GetIssueStatusDefsByOrg(ctx, ctx.Org.Organization.ID) defs, err := issues_model.GetIssueStatusDefsByOrg(ctx, ctx.Org.Organization.ID)
if err != nil { if err != nil {
ctx.APIErrorInternal(err) ctx.APIErrorInternal(err)
@@ -47,6 +64,7 @@ func ListIssueStatuses(ctx *context.APIContext) {
Color: d.Color, Color: d.Color,
Description: d.Description, Description: d.Description,
ClosesIssue: d.ClosesIssue, ClosesIssue: d.ClosesIssue,
IsRequired: d.IsRequired,
SortOrder: d.SortOrder, SortOrder: d.SortOrder,
}) })
} }
@@ -76,6 +94,10 @@ func ListIssuePriorities(ctx *context.APIContext) {
// "404": // "404":
// "$ref": "#/responses/notFound" // "$ref": "#/responses/notFound"
if !checkOrgVisibility(ctx) {
return
}
defs, err := issues_model.GetIssuePriorityDefsByOrg(ctx, ctx.Org.Organization.ID) defs, err := issues_model.GetIssuePriorityDefsByOrg(ctx, ctx.Org.Organization.ID)
if err != nil { if err != nil {
ctx.APIErrorInternal(err) ctx.APIErrorInternal(err)
@@ -118,6 +140,10 @@ func ListIssueTypes(ctx *context.APIContext) {
// "404": // "404":
// "$ref": "#/responses/notFound" // "$ref": "#/responses/notFound"
if !checkOrgVisibility(ctx) {
return
}
defs, err := issues_model.GetIssueTypeDefsByOrg(ctx, ctx.Org.Organization.ID) defs, err := issues_model.GetIssueTypeDefsByOrg(ctx, ctx.Org.Organization.ID)
if err != nil { if err != nil {
ctx.APIErrorInternal(err) ctx.APIErrorInternal(err)
+128
View File
@@ -0,0 +1,128 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package admin
import (
"net/http"
"strconv"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
licensing_model "code.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licensing"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/json"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/templates"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
)
const tplLicenseTiers templates.TplName = "admin/license_tiers"
// LicenseTiers shows the product tier management page.
func LicenseTiers(ctx *context.Context) {
ctx.Data["Title"] = "Product Tiers"
ctx.Data["PageIsAdminLicenseTiers"] = true
tiers, err := licensing_model.GetAllProductTiers(ctx)
if err != nil {
ctx.ServerError("GetAllProductTiers", err)
return
}
type tierView struct {
*licensing_model.ProductTier
Repos []string
LicenseCount int64
}
views := make([]tierView, 0, len(tiers))
for _, t := range tiers {
count, _ := db.GetEngine(ctx).Where("tier = ?", t.TierKey).Count(new(licensing_model.License))
views = append(views, tierView{
ProductTier: t,
Repos: t.RepoList(),
LicenseCount: count,
})
}
ctx.Data["Tiers"] = views
ctx.HTML(http.StatusOK, tplLicenseTiers)
}
// LicenseTierCreate handles POST to create a new tier.
func LicenseTierCreate(ctx *context.Context) {
tierKey := ctx.FormString("tier_key")
tierName := ctx.FormString("tier_name")
repos := ctx.FormStrings("repos")
maxDomains, _ := strconv.Atoi(ctx.FormString("max_domains"))
sortOrder, _ := strconv.Atoi(ctx.FormString("sort_order"))
if tierKey == "" || tierName == "" {
ctx.Flash.Error("Tier key and name are required")
ctx.Redirect("/admin/license-tiers")
return
}
reposJSON, _ := json.Marshal(repos)
tier := &licensing_model.ProductTier{
TierKey: tierKey,
TierName: tierName,
Repos: string(reposJSON),
MaxDomains: maxDomains,
SortOrder: sortOrder,
}
if _, err := db.GetEngine(ctx).Insert(tier); err != nil {
ctx.Flash.Error("Failed to create tier: " + err.Error())
} else {
ctx.Flash.Success("Tier '" + tierName + "' created")
}
ctx.Redirect("/admin/license-tiers")
}
// LicenseTierUpdate handles POST to update a tier.
func LicenseTierUpdate(ctx *context.Context) {
id, _ := strconv.ParseInt(ctx.PathParam("id"), 10, 64)
tier := new(licensing_model.ProductTier)
has, _ := db.GetEngine(ctx).ID(id).Get(tier)
if !has {
ctx.NotFound(nil)
return
}
tier.TierName = ctx.FormString("tier_name")
repos := ctx.FormStrings("repos")
reposJSON, _ := json.Marshal(repos)
tier.Repos = string(reposJSON)
tier.MaxDomains, _ = strconv.Atoi(ctx.FormString("max_domains"))
tier.SortOrder, _ = strconv.Atoi(ctx.FormString("sort_order"))
if _, err := db.GetEngine(ctx).ID(id).Cols("tier_name", "repos", "max_domains", "sort_order").Update(tier); err != nil {
ctx.Flash.Error("Failed to update tier: " + err.Error())
} else {
ctx.Flash.Success("Tier '" + tier.TierName + "' updated")
}
ctx.Redirect("/admin/license-tiers")
}
// LicenseTierDelete handles POST to delete a tier.
func LicenseTierDelete(ctx *context.Context) {
id, _ := strconv.ParseInt(ctx.FormString("id"), 10, 64)
tier := new(licensing_model.ProductTier)
has, _ := db.GetEngine(ctx).ID(id).Get(tier)
if !has {
ctx.NotFound(nil)
return
}
count, _ := db.GetEngine(ctx).Where("tier = ?", tier.TierKey).Count(new(licensing_model.License))
if count > 0 {
ctx.Flash.Error("Cannot delete tier with active licenses. Reassign licenses first.")
ctx.Redirect("/admin/license-tiers")
return
}
db.GetEngine(ctx).ID(id).Delete(new(licensing_model.ProductTier))
ctx.Flash.Success("Tier '" + tier.TierName + "' deleted")
ctx.Redirect("/admin/license-tiers")
}
+5
View File
@@ -103,6 +103,11 @@ func SettingsIssueStatusesDeletePost(ctx *context.Context) {
} }
if err := issues_model.DeleteIssueStatusDef(ctx, id); err != nil { if err := issues_model.DeleteIssueStatusDef(ctx, id); err != nil {
if issues_model.IsErrStatusRequired(err) {
ctx.Flash.Error("Cannot delete required status: " + def.Name)
ctx.Redirect(ctx.Org.OrgLink + "/settings/issue-statuses")
return
}
ctx.ServerError("DeleteIssueStatusDef", err) ctx.ServerError("DeleteIssueStatusDef", err)
return return
} }
+73 -25
View File
@@ -29,6 +29,14 @@ type OrgWikiPage struct {
SubURL string SubURL string
} }
// OrgWikiTreeNode represents a node in the org wiki folder tree for sidebar navigation.
type OrgWikiTreeNode struct {
Name string
SubURL string
IsDir bool
Children []*OrgWikiTreeNode
}
// Wiki renders the org wiki tab. // Wiki renders the org wiki tab.
func Wiki(ctx *context.Context) { func Wiki(ctx *context.Context) {
org := ctx.Org.Organization org := ctx.Org.Organization
@@ -71,31 +79,9 @@ func Wiki(ctx *context.Context) {
} }
ctx.Data["WikiRepoLink"] = wikiRepo.Link() ctx.Data["WikiRepoLink"] = wikiRepo.Link()
// Build page list from repo root. // Build folder tree for sidebar navigation.
entries, err := commit.ListEntries() wikiTree := buildOrgWikiTree(commit)
if err != nil { ctx.Data["WikiTree"] = wikiTree
ctx.ServerError("ListEntries", err)
return
}
pages := make([]OrgWikiPage, 0, len(entries))
for _, entry := range entries {
if !entry.IsRegular() {
continue
}
name := entry.Name()
if !isMarkdownFile(name) {
continue
}
displayName := strings.TrimSuffix(name, path.Ext(name))
if strings.EqualFold(displayName, "_sidebar") || strings.EqualFold(displayName, "_footer") {
continue
}
pages = append(pages, OrgWikiPage{
Name: displayName,
SubURL: displayName,
})
}
ctx.Data["Pages"] = pages
// Determine which page to render. // Determine which page to render.
pageName := ctx.PathParamRaw("*") pageName := ctx.PathParamRaw("*")
@@ -157,6 +143,68 @@ func Wiki(ctx *context.Context) {
ctx.HTML(http.StatusOK, tplOrgWiki) ctx.HTML(http.StatusOK, tplOrgWiki)
} }
// buildOrgWikiTree builds a hierarchical folder tree from the org wiki git repo.
// Shows up to 2 levels deep (folders and their immediate children).
func buildOrgWikiTree(commit *git.Commit) []*OrgWikiTreeNode {
if commit == nil {
return nil
}
entries, err := commit.ListEntries()
if err != nil {
return nil
}
var topLevel []*OrgWikiTreeNode
for _, entry := range entries {
name := entry.Name()
if entry.IsDir() {
node := &OrgWikiTreeNode{
Name: name,
SubURL: name,
IsDir: true,
}
// List children of this directory (1 level deep).
subTree := entry.Tree()
if subTree != nil {
children, _ := subTree.ListEntries()
for _, child := range children {
childName := child.Name()
if child.IsDir() {
node.Children = append(node.Children, &OrgWikiTreeNode{
Name: childName,
SubURL: name + "/" + childName,
IsDir: true,
})
} else if isMarkdownFile(childName) {
displayName := strings.TrimSuffix(childName, path.Ext(childName))
if strings.EqualFold(displayName, "_sidebar") || strings.EqualFold(displayName, "_footer") {
continue
}
node.Children = append(node.Children, &OrgWikiTreeNode{
Name: displayName,
SubURL: name + "/" + displayName,
IsDir: false,
})
}
}
}
topLevel = append(topLevel, node)
} else if isMarkdownFile(name) {
displayName := strings.TrimSuffix(name, path.Ext(name))
if strings.EqualFold(displayName, "_sidebar") || strings.EqualFold(displayName, "_footer") {
continue
}
topLevel = append(topLevel, &OrgWikiTreeNode{
Name: displayName,
SubURL: displayName,
IsDir: false,
})
}
}
return topLevel
}
// findOrgWikiCommit locates the profile repo's wiki and returns its HEAD commit. // findOrgWikiCommit locates the profile repo's wiki and returns its HEAD commit.
// The org wiki lives in the .wiki.git sidecar of the profile repo (e.g. .mokogitea.wiki.git). // The org wiki lives in the .wiki.git sidecar of the profile repo (e.g. .mokogitea.wiki.git).
// Tries fallback repo names (.profile, .github) if the primary doesn't exist. // Tries fallback repo names (.profile, .github) if the primary doesn't exist.
+988 -51
View File
File diff suppressed because it is too large Load Diff
+6
View File
@@ -842,6 +842,12 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
m.Post("/cleanup", admin.CleanupExpiredData) m.Post("/cleanup", admin.CleanupExpiredData)
}, packagesEnabled) }, packagesEnabled)
m.Group("/license-tiers", func() {
m.Get("", admin.LicenseTiers)
m.Post("", admin.LicenseTierCreate)
m.Post("/{id}/delete", admin.LicenseTierDelete)
})
m.Group("/hooks", func() { m.Group("/hooks", func() {
m.Get("", admin.DefaultOrSystemWebhooks) m.Get("", admin.DefaultOrSystemWebhooks)
m.Post("/delete", admin.DeleteDefaultOrSystemWebhook) m.Post("/delete", admin.DeleteDefaultOrSystemWebhook)
+132
View File
@@ -0,0 +1,132 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package licensing
import (
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/log"
"code.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/setting"
)
const (
keyFileName = "licensing_ed25519.key"
downloadTTL = 5 * time.Minute
tokenSeparator = "|"
)
var (
privateKey ed25519.PrivateKey
publicKey ed25519.PublicKey
keyOnce sync.Once
)
// initKeys loads or generates the ed25519 keypair used for signing download tokens.
func initKeys() {
keyOnce.Do(func() {
keyPath := filepath.Join(setting.AppDataPath, keyFileName)
data, err := os.ReadFile(keyPath)
if err == nil && len(data) == ed25519.SeedSize {
privateKey = ed25519.NewKeyFromSeed(data)
publicKey = privateKey.Public().(ed25519.PublicKey)
log.Info("Licensing: loaded ed25519 key from %s", keyPath)
return
}
// Generate new keypair
seed := make([]byte, ed25519.SeedSize)
if _, err := rand.Read(seed); err != nil {
log.Error("Licensing: failed to generate ed25519 seed: %v", err)
return
}
privateKey = ed25519.NewKeyFromSeed(seed)
publicKey = privateKey.Public().(ed25519.PublicKey)
if err := os.WriteFile(keyPath, seed, 0600); err != nil {
log.Error("Licensing: failed to save ed25519 key to %s: %v", keyPath, err)
} else {
log.Info("Licensing: generated new ed25519 key at %s", keyPath)
}
})
}
// SignDownloadToken creates a signed, time-limited download token.
// The message format is: product|version|dlid|expires
func SignDownloadToken(product, version, dlid string) (token string, expires int64) {
initKeys()
if privateKey == nil {
return "", 0
}
expires = time.Now().Add(downloadTTL).Unix()
message := fmt.Sprintf("%s%s%s%s%s%s%d",
product, tokenSeparator,
version, tokenSeparator,
dlid, tokenSeparator,
expires)
sig := ed25519.Sign(privateKey, []byte(message))
token = base64.RawURLEncoding.EncodeToString(sig)
return token, expires
}
// VerifyDownloadToken validates a signed download token.
// Returns the parsed product, version, dlid, and any error.
func VerifyDownloadToken(token string, product, version, dlid string, expires int64) bool {
initKeys()
if publicKey == nil {
return false
}
// Check expiry
if time.Now().Unix() > expires {
return false
}
// Reconstruct message
message := fmt.Sprintf("%s%s%s%s%s%s%d",
product, tokenSeparator,
version, tokenSeparator,
dlid, tokenSeparator,
expires)
sig, err := base64.RawURLEncoding.DecodeString(token)
if err != nil {
return false
}
return ed25519.Verify(publicKey, []byte(message), sig)
}
// ParseDownloadParams extracts product and version from the URL path segment.
// Expects format: "{version}.zip" with product as a separate path param.
func ParseDownloadParams(versionFile string) (version string, ok bool) {
if !strings.HasSuffix(versionFile, ".zip") {
return "", false
}
version = strings.TrimSuffix(versionFile, ".zip")
if version == "" {
return "", false
}
return version, true
}
// ParseExpires converts the expires query parameter to int64.
func ParseExpires(s string) (int64, bool) {
v, err := strconv.ParseInt(s, 10, 64)
if err != nil {
return 0, false
}
return v, true
}
+101
View File
@@ -0,0 +1,101 @@
{{template "admin/layout_head" (dict "ctxData" . "pageClass" "admin")}}
<div class="admin-setting-content">
<h4 class="ui top attached header">
Product Tiers
<div class="ui right">
<button class="ui primary tiny button" id="btn-new-tier">New Tier</button>
</div>
</h4>
<div class="ui attached segment">
{{if .Tiers}}
<table class="ui very basic striped table">
<thead>
<tr>
<th>Key</th>
<th>Name</th>
<th>Repos</th>
<th>Max Domains</th>
<th>Licenses</th>
<th>Order</th>
<th></th>
</tr>
</thead>
<tbody>
{{range .Tiers}}
<tr>
<td><code>{{.TierKey}}</code></td>
<td>{{.TierName}}</td>
<td>
{{range .Repos}}
<span class="ui label">{{.}}</span>
{{end}}
</td>
<td>{{if eq .MaxDomains 0}}Unlimited{{else}}{{.MaxDomains}}{{end}}</td>
<td>{{.LicenseCount}}</td>
<td>{{.SortOrder}}</td>
<td class="right aligned">
<form method="post" action="{{AppSubUrl}}/-/admin/license-tiers/{{.ID}}/delete" style="display:inline">
{{$.CsrfTokenHtml}}
<input type="hidden" name="id" value="{{.ID}}">
<button class="ui tiny red button{{if gt .LicenseCount 0}} disabled{{end}}" type="submit"
{{if gt .LicenseCount 0}}title="Cannot delete tier with active licenses"{{end}}>
Delete
</button>
</form>
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p>No product tiers defined. Create one to get started.</p>
{{end}}
</div>
<!-- New Tier Form (hidden by default) -->
<div id="new-tier-form" class="ui attached segment" style="display:none">
<h5>Create New Tier</h5>
<form method="post" action="{{AppSubUrl}}/-/admin/license-tiers" class="ui form">
{{.CsrfTokenHtml}}
<div class="two fields">
<div class="field">
<label>Tier Key</label>
<input type="text" name="tier_key" placeholder="e.g. pos, suite, enterprise" required>
</div>
<div class="field">
<label>Tier Name</label>
<input type="text" name="tier_name" placeholder="e.g. MokoSuite POS" required>
</div>
</div>
<div class="two fields">
<div class="field">
<label>Max Domains (0 = unlimited)</label>
<input type="number" name="max_domains" value="3" min="0">
</div>
<div class="field">
<label>Sort Order</label>
<input type="number" name="sort_order" value="50" min="0">
</div>
</div>
<div class="field">
<label>Repos (comma-separated)</label>
<input type="text" name="repos" placeholder="MokoSuite,MokoSuiteCRM,MokoSuiteERP">
<p class="help">Enter repo names separated by commas</p>
</div>
<button class="ui primary button" type="submit">Create Tier</button>
<button class="ui button" type="button" id="btn-cancel-tier">Cancel</button>
</form>
</div>
</div>
<script>
document.getElementById('btn-new-tier').addEventListener('click', function() {
document.getElementById('new-tier-form').style.display = '';
this.style.display = 'none';
});
document.getElementById('btn-cancel-tier').addEventListener('click', function() {
document.getElementById('new-tier-form').style.display = 'none';
document.getElementById('btn-new-tier').style.display = '';
});
</script>
{{template "admin/layout_footer" .}}
+3
View File
@@ -87,6 +87,9 @@
<a class="{{if .PageIsAdminBranding}}active {{end}}item" href="{{AppSubUrl}}/-/admin/branding"> <a class="{{if .PageIsAdminBranding}}active {{end}}item" href="{{AppSubUrl}}/-/admin/branding">
{{svg "octicon-paintbrush" 16}} Branding {{svg "octicon-paintbrush" 16}} Branding
</a> </a>
<a class="{{if .PageIsAdminLicenseTiers}}active {{end}}item" href="{{AppSubUrl}}/-/admin/license-tiers">
{{svg "octicon-key" 16}} License Tiers
</a>
<details class="item toggleable-item" {{if or .PageIsAdminConfig}}open{{end}}> <details class="item toggleable-item" {{if or .PageIsAdminConfig}}open{{end}}>
<summary>{{svg "octicon-gear" 16}} {{ctx.Locale.Tr "admin.config"}}</summary> <summary>{{svg "octicon-gear" 16}} {{ctx.Locale.Tr "admin.config"}}</summary>
<div class="menu"> <div class="menu">
@@ -28,6 +28,7 @@
</td> </td>
<td> <td>
<strong>{{.Name}}</strong> <strong>{{.Name}}</strong>
{{if .IsRequired}}<span class="ui mini blue label" title="Required status - cannot be deleted">{{svg "octicon-lock" 10}} required</span>{{end}}
{{if not .IsActive}}<span class="ui mini grey label">{{ctx.Locale.Tr "org.settings.issue_status_inactive"}}</span>{{end}} {{if not .IsActive}}<span class="ui mini grey label">{{ctx.Locale.Tr "org.settings.issue_status_inactive"}}</span>{{end}}
{{if .Description}}<br><small class="text grey">{{.Description}}</small>{{end}} {{if .Description}}<br><small class="text grey">{{.Description}}</small>{{end}}
</td> </td>
@@ -40,10 +41,14 @@
</td> </td>
<td>{{.SortOrder}}</td> <td>{{.SortOrder}}</td>
<td class="tw-text-right"> <td class="tw-text-right">
{{if .IsRequired}}
<span class="ui tiny icon button disabled" title="Required - cannot be deleted">{{svg "octicon-lock" 14}}</span>
{{else}}
<form method="post" action="{{$.OrgLink}}/settings/issue-statuses/{{.ID}}/delete" class="tw-inline"> <form method="post" action="{{$.OrgLink}}/settings/issue-statuses/{{.ID}}/delete" class="tw-inline">
{{$.CsrfTokenHtml}} {{$.CsrfTokenHtml}}
<button class="ui tiny red icon button" type="submit" title="{{ctx.Locale.Tr "remove"}}">{{svg "octicon-trash" 14}}</button> <button class="ui tiny red icon button" type="submit" title="{{ctx.Locale.Tr "remove"}}">{{svg "octicon-trash" 14}}</button>
</form> </form>
{{end}}
</td> </td>
</tr> </tr>
{{end}} {{end}}
+34 -9
View File
@@ -11,7 +11,7 @@
This organization doesn't have a wiki yet. This organization doesn't have a wiki yet.
</div> </div>
<p class="tw-text-center"> <p class="tw-text-center">
Enable the wiki on the <code>.profile</code> (public) or <code>.profile-private</code> (members-only) Enable the wiki on the <code>.mokogitea</code> (public) or <code>.mokogitea-private</code> (members-only)
repository to get started. repository to get started.
</p> </p>
</div> </div>
@@ -47,34 +47,59 @@
<p>The page "{{.CurrentPage}}" does not exist in this wiki.</p> <p>The page "{{.CurrentPage}}" does not exist in this wiki.</p>
</div> </div>
</div> </div>
{{if .Pages}} {{if .WikiTree}}
<h4>Available pages:</h4> <h4>Available pages:</h4>
<ul> <ul>
{{range .Pages}} {{range .WikiTree}}
{{if .IsDir}}
{{range .Children}}
<li><a href="{{$.Org.HomeLink}}/-/wiki/{{.SubURL}}">{{.Name}}</a></li> <li><a href="{{$.Org.HomeLink}}/-/wiki/{{.SubURL}}">{{.Name}}</a></li>
{{end}} {{end}}
{{else}}
<li><a href="{{$.Org.HomeLink}}/-/wiki/{{.SubURL}}">{{.Name}}</a></li>
{{end}}
{{end}}
</ul> </ul>
{{end}} {{end}}
</div> </div>
{{else}} {{else}}
<div class="wiki-content-parts"> <div class="wiki-content-parts">
<div class="render-content markup wiki-content-main {{if or .WikiSidebarHTML .Pages}}with-sidebar{{end}}"> <div class="render-content markup wiki-content-main {{if or .WikiSidebarHTML .WikiTree}}with-sidebar{{end}}">
{{.WikiContent}} {{.WikiContent}}
</div> </div>
{{if or .WikiSidebarHTML .Pages}} {{if or .WikiSidebarHTML .WikiTree}}
<div class="render-content markup wiki-content-sidebar"> <div class="render-content markup wiki-content-sidebar">
{{if .WikiSidebarHTML}} {{if .WikiSidebarHTML}}
{{.WikiSidebarHTML}} {{.WikiSidebarHTML}}
<div class="ui divider"></div> {{else if .WikiTree}}
{{end}}
{{if .Pages}}
<strong>{{svg "octicon-list-unordered" 14}} Pages</strong> <strong>{{svg "octicon-list-unordered" 14}} Pages</strong>
<ul class="wiki-tree-list"> <ul class="wiki-tree-list">
{{range .Pages}} {{range .WikiTree}}
<li> <li>
{{if .IsDir}}
<details open>
<summary>{{svg "octicon-file-directory" 14}} <strong>{{.Name}}</strong></summary>
{{if .Children}}
<ul>
{{range .Children}}
<li>
{{if .IsDir}}
{{svg "octicon-file-directory" 14}}
<a href="{{$.Org.HomeLink}}/-/wiki/{{.SubURL}}"><strong>{{.Name}}</strong></a>
{{else}}
{{svg "octicon-file" 14}} {{svg "octicon-file" 14}}
<a href="{{$.Org.HomeLink}}/-/wiki/{{.SubURL}}" {{if eq $.CurrentPage .Name}}class="active"{{end}}>{{.Name}}</a> <a href="{{$.Org.HomeLink}}/-/wiki/{{.SubURL}}" {{if eq $.CurrentPage .Name}}class="active"{{end}}>{{.Name}}</a>
{{end}}
</li>
{{end}}
</ul>
{{end}}
</details>
{{else}}
{{svg "octicon-file" 14}}
<a href="{{$.Org.HomeLink}}/-/wiki/{{.SubURL}}" {{if eq $.CurrentPage .Name}}class="active"{{end}}>{{.Name}}</a>
{{end}}
</li> </li>
{{end}} {{end}}
</ul> </ul>
+1 -5
View File
@@ -21,11 +21,7 @@
<input name="org" value="{{.Manifest.Org}}" placeholder="Organization"> <input name="org" value="{{.Manifest.Org}}" placeholder="Organization">
</div> </div>
</div> </div>
<div class="four fields"> <div class="three fields">
<div class="field">
<label>Version</label>
<input name="version" value="{{.Manifest.Version}}" placeholder="e.g. 06.00.00">
</div>
<div class="field"> <div class="field">
<label>Version Prefix</label> <label>Version Prefix</label>
<input name="version_prefix" value="{{.Manifest.VersionPrefix}}" placeholder="e.g. v1.26.1-moko."> <input name="version_prefix" value="{{.Manifest.VersionPrefix}}" placeholder="e.g. v1.26.1-moko.">
+42
View File
@@ -0,0 +1,42 @@
{{template "base/head" .}}
<div role="main" aria-label="{{.Title}}" class="page-content repository wiki">
{{template "repo/header" .}}
<div class="ui container">
<div class="repo-button-row">
<div class="tw-flex tw-items-center tw-gap-2">
<a class="ui small button" href="{{.RepoLink}}/wiki/{{.PageURL}}">
{{svg "octicon-arrow-left" 14}} Back to {{.title}}
</a>
</div>
</div>
<h2>{{svg "octicon-cross-reference" 20}} What links here: {{.title}}</h2>
{{if .Backlinks}}
<div class="ui relaxed divided list">
{{range .Backlinks}}
<div class="item">
<div class="content">
<a class="header" href="{{$.RepoLink}}/wiki/{{.PageURL}}">{{.PageName}}</a>
{{if .Context}}
<div class="description">
<code class="tw-text-sm">{{.Context}}</code>
</div>
{{end}}
</div>
</div>
{{end}}
</div>
<p class="tw-mt-4 text grey">{{.BacklinkCount}} {{if eq .BacklinkCount 1}}page{{else}}pages{{end}} linking here.</p>
{{else}}
<div class="ui placeholder segment">
<div class="ui icon header">
{{svg "octicon-unlink" 48}}
<br>
No pages link to "{{.title}}"
</div>
</div>
{{end}}
</div>
</div>
{{template "base/footer" .}}
+36
View File
@@ -0,0 +1,36 @@
{{template "base/head" .}}
<div role="main" aria-label="{{.Title}}" class="page-content repository wiki">
{{template "repo/header" .}}
<div class="ui container">
<div class="repo-button-row tw-flex tw-items-center tw-gap-2 tw-mb-4">
<div class="tw-flex-1">
<a class="ui small button" href="{{.RepoLink}}/wiki/">
{{svg "octicon-arrow-left" 14}} Back to wiki
</a>
</div>
</div>
<h2>{{svg "octicon-tag" 20}} Category: {{.CategoryName}}</h2>
{{if .CategoryPages}}
<div class="ui relaxed divided list">
{{range .CategoryPages}}
<div class="item">
{{svg "octicon-file" 14}}
<a href="{{$.RepoLink}}/wiki/{{.SubURL}}">{{.Name}}</a>
</div>
{{end}}
</div>
<p class="tw-mt-4 text grey">{{.CategoryCount}} {{if eq .CategoryCount 1}}page{{else}}pages{{end}} in this category.</p>
{{else}}
<div class="ui placeholder segment">
<div class="ui icon header">
{{svg "octicon-tag" 48}}
<br>
No pages in category "{{.CategoryName}}"
</div>
</div>
{{end}}
</div>
</div>
{{template "base/footer" .}}
+51
View File
@@ -0,0 +1,51 @@
{{template "base/head" .}}
<div role="main" aria-label="{{.Title}}" class="page-content repository wiki">
{{template "repo/header" .}}
<div class="ui container">
<div class="repo-button-row tw-flex tw-items-center tw-gap-2 tw-mb-4">
<div class="tw-flex-1">
<a href="{{.RepoLink}}/wiki/{{.PageURL}}">{{svg "octicon-arrow-left" 14}} {{.title}}</a>
&middot;
<a href="{{.RepoLink}}/wiki/{{.PageURL}}?action=_revision">Revision history</a>
</div>
</div>
<div class="ui segment">
<h3>{{svg "octicon-diff" 20}} Changes in <code>{{.CommitID}}</code></h3>
<p>
<strong>{{.CommitAuthor}}</strong> &mdash; {{.CommitMessage}}
<br>
<small class="text grey">{{DateUtils.TimeSince .CommitWhen}}</small>
</p>
{{if .IsNewPage}}
<div class="ui info message">New page created</div>
{{else if .IsDeletedPage}}
<div class="ui warning message">Page deleted</div>
{{else if not .HasDiff}}
<div class="ui info message">No content changes in this revision</div>
{{end}}
{{if .HasDiff}}
<div class="diff-file-box" style="overflow-x: auto;">
<table class="chroma" style="width: 100%; border-collapse: collapse; font-family: monospace; font-size: 13px;">
{{range .DiffLines}}
<tr class="{{if eq .Type "add"}}diff-line-add{{else if eq .Type "del"}}diff-line-del{{else}}diff-line-context{{end}}">
<td style="width: 40px; text-align: right; padding: 0 8px; color: #999; user-select: none; {{if eq .Type "add"}}background: #e6ffec;{{else if eq .Type "del"}}background: #ffebe9;{{else}}background: #f6f8fa;{{end}}">
{{if .OldNum}}{{.OldNum}}{{end}}
</td>
<td style="width: 40px; text-align: right; padding: 0 8px; color: #999; user-select: none; {{if eq .Type "add"}}background: #e6ffec;{{else if eq .Type "del"}}background: #ffebe9;{{else}}background: #f6f8fa;{{end}}">
{{if .NewNum}}{{.NewNum}}{{end}}
</td>
<td style="padding: 0 8px; white-space: pre-wrap; word-break: break-all; {{if eq .Type "add"}}background: #e6ffec;{{else if eq .Type "del"}}background: #ffebe9;{{else}}background: #fff;{{end}}">
{{if eq .Type "add"}}+{{else if eq .Type "del"}}-{{else}} {{end}} {{.Content}}
</td>
</tr>
{{end}}
</table>
</div>
{{end}}
</div>
</div>
</div>
{{template "base/footer" .}}
+40
View File
@@ -0,0 +1,40 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>{{.Title}}</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 40px 20px;
color: #333;
line-height: 1.6;
}
h1 { font-size: 2em; border-bottom: 1px solid #eee; padding-bottom: 0.3em; }
h2 { font-size: 1.5em; border-bottom: 1px solid #eee; padding-bottom: 0.3em; }
code { background: #f6f8fa; padding: 2px 6px; border-radius: 3px; font-size: 85%; }
pre { background: #f6f8fa; padding: 16px; border-radius: 6px; overflow-x: auto; }
pre code { background: none; padding: 0; }
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #ddd; padding: 8px 12px; text-align: left; }
th { background: #f6f8fa; }
img { max-width: 100%; }
blockquote { border-left: 4px solid #ddd; margin: 0; padding: 0 16px; color: #666; }
a { color: #0366d6; }
@media print {
body { padding: 0; }
a { color: inherit; text-decoration: none; }
}
</style>
</head>
<body>
<h1>{{.Title}}</h1>
{{.WikiContentHTML}}
<hr>
<p style="font-size: 12px; color: #999;">
Printed from wiki &middot; {{.Title}}
</p>
</body>
</html>
+72
View File
@@ -0,0 +1,72 @@
{{template "base/head" .}}
<div role="main" aria-label="{{.Title}}" class="page-content repository wiki">
{{template "repo/header" .}}
<div class="ui container">
<div class="repo-button-row tw-flex tw-items-center tw-gap-2 tw-mb-4">
<div class="tw-flex-1">
<h2>{{svg "octicon-history" 20}} Recent changes</h2>
</div>
<a class="ui small button" href="{{.RepoLink}}/wiki/">
{{svg "octicon-arrow-left" 14}} Back to wiki
</a>
</div>
{{if .RecentChanges}}
<table class="ui compact table">
<thead>
<tr>
<th>Page</th>
<th>Author</th>
<th>Edit summary</th>
<th>When</th>
<th>Commit</th>
</tr>
</thead>
<tbody>
{{range .RecentChanges}}
<tr>
<td>
{{if .PageURL}}
{{svg "octicon-file" 14}}
<a href="{{$.RepoLink}}/wiki/{{.PageURL}}">{{.PageName}}</a>
{{else if .PageName}}
{{svg "octicon-file" 14}} {{.PageName}}
{{else}}
<span class="text grey">—</span>
{{end}}
</td>
<td>{{.Author}}</td>
<td class="gt-ellipsis" style="max-width: 400px;">{{.Message}}</td>
<td>{{DateUtils.TimeSince .When}}</td>
<td><code class="tw-text-xs">{{.SHA}}</code></td>
</tr>
{{end}}
</tbody>
</table>
<div class="tw-flex tw-justify-between tw-mt-4">
{{if .HasPrevPage}}
<a class="ui small button" href="{{.RepoLink}}/wiki/?action=_recent&page={{Eval .CurrentPage "-" 1}}">
{{svg "octicon-chevron-left" 14}} Newer
</a>
{{else}}
<span></span>
{{end}}
{{if .HasNextPage}}
<a class="ui small button" href="{{.RepoLink}}/wiki/?action=_recent&page={{Eval .CurrentPage "+" 1}}">
Older {{svg "octicon-chevron-right" 14}}
</a>
{{end}}
</div>
{{else}}
<div class="ui placeholder segment">
<div class="ui icon header">
{{svg "octicon-history" 48}}
<br>
No recent changes
</div>
</div>
{{end}}
</div>
</div>
{{template "base/footer" .}}
+31 -1
View File
@@ -20,6 +20,9 @@
</div> </div>
<div class="scrolling menu"> <div class="scrolling menu">
<a class="item muted" href="{{.RepoLink}}/wiki/?action=_pages">{{ctx.Locale.Tr "repo.wiki.pages"}}</a> <a class="item muted" href="{{.RepoLink}}/wiki/?action=_pages">{{ctx.Locale.Tr "repo.wiki.pages"}}</a>
<a class="item muted" href="{{.RepoLink}}/wiki/?action=_recent">{{svg "octicon-history" 14}} Recent changes</a>
t <a class="item muted" href="{{.RepoLink}}/wiki/{{.PageURL}}?action=_print" target="_blank">{{svg "octicon-browser" 14}} Print view</a>
<a class="item muted" href="{{.RepoLink}}/wiki/?action=_export&format=zip">{{svg "octicon-download" 14}} Export wiki (ZIP)</a>
<div class="divider"></div> <div class="divider"></div>
{{range .Pages}} {{range .Pages}}
<a class="item {{if eq $.Title .Name}}selected{{end}}" href="{{$.RepoLink}}/wiki/{{.SubURL}}">{{.Name}}</a> <a class="item {{if eq $.Title .Name}}selected{{end}}" href="{{$.RepoLink}}/wiki/{{.SubURL}}">{{.Name}}</a>
@@ -34,6 +37,8 @@
<div class="flex-text-block tw-flex-wrap tw-justify-end"> <div class="flex-text-block tw-flex-wrap tw-justify-end">
<div class="flex-text-block tw-flex-1 tw-min-w-[300px]"> <div class="flex-text-block tw-flex-1 tw-min-w-[300px]">
<a class="ui basic button tw-px-3 tw-gap-3" title="{{ctx.Locale.Tr "repo.wiki.file_revision"}}" href="{{.RepoLink}}/wiki/{{.PageURL}}?action=_revision" >{{if .CommitCount}}<span>{{.CommitCount}}</span> {{end}}{{svg "octicon-history"}}</a> <a class="ui basic button tw-px-3 tw-gap-3" title="{{ctx.Locale.Tr "repo.wiki.file_revision"}}" href="{{.RepoLink}}/wiki/{{.PageURL}}?action=_revision" >{{if .CommitCount}}<span>{{.CommitCount}}</span> {{end}}{{svg "octicon-history"}}</a>
<a class="ui basic button tw-px-3" title="What links here" href="{{.RepoLink}}/wiki/{{.PageURL}}?action=_backlinks">{{svg "octicon-cross-reference"}}</a>
{{if .LastCommitID}}<a class="ui basic button tw-px-3" title="View last change" href="{{.RepoLink}}/wiki/{{.PageURL}}?action=_diff&commit={{.LastCommitID}}">{{svg "octicon-diff"}}</a>{{end}}
<div class="tw-flex-1 gt-ellipsis"> <div class="tw-flex-1 gt-ellipsis">
{{$title}} {{$title}}
<div class="ui sub header gt-ellipsis"> <div class="ui sub header gt-ellipsis">
@@ -47,7 +52,7 @@
<a class="ui small button unescape-button tw-hidden" data-unicode-content-selector=".wiki-content-parts">{{ctx.Locale.Tr "repo.unescape_control_characters"}}</a> <a class="ui small button unescape-button tw-hidden" data-unicode-content-selector=".wiki-content-parts">{{ctx.Locale.Tr "repo.unescape_control_characters"}}</a>
<a class="ui small button escape-button" data-unicode-content-selector=".wiki-content-parts">{{ctx.Locale.Tr "repo.escape_control_characters"}}</a> <a class="ui small button escape-button" data-unicode-content-selector=".wiki-content-parts">{{ctx.Locale.Tr "repo.escape_control_characters"}}</a>
{{end}} {{end}}
{{if and .CanWriteWiki (not .Repository.IsMirror)}} {{if and .CanWriteWiki (not .Repository.IsMirror) (not .WikiFolderProtected)}}
<a class="ui small button" href="{{.RepoLink}}/wiki/{{.PageURL}}?action=_edit">{{ctx.Locale.Tr "repo.wiki.edit_page_button"}}</a> <a class="ui small button" href="{{.RepoLink}}/wiki/{{.PageURL}}?action=_edit">{{ctx.Locale.Tr "repo.wiki.edit_page_button"}}</a>
<a class="ui small primary button" href="{{.RepoLink}}/wiki?action=_new">{{ctx.Locale.Tr "repo.wiki.new_page_button"}}</a> <a class="ui small primary button" href="{{.RepoLink}}/wiki?action=_new">{{ctx.Locale.Tr "repo.wiki.new_page_button"}}</a>
<a class="ui small red button link-action" href data-modal-confirm="#repo-wiki-delete-page-modal" data-url="{{.RepoLink}}/wiki/{{.PageURL}}?action=_delete">{{ctx.Locale.Tr "repo.wiki.delete_page_button"}}</a> <a class="ui small red button link-action" href data-modal-confirm="#repo-wiki-delete-page-modal" data-url="{{.RepoLink}}/wiki/{{.PageURL}}?action=_delete">{{ctx.Locale.Tr "repo.wiki.delete_page_button"}}</a>
@@ -69,6 +74,12 @@
{{end}} {{end}}
{{end}} {{end}}
{{if .WikiFolderProtected}}
<div class="ui warning message">
<p>{{svg "octicon-lock" 14}} This page is in a protected folder. Only users with the required role can edit it.</p>
</div>
{{end}}
{{if .FormatWarning}} {{if .FormatWarning}}
<div class="ui negative message"> <div class="ui negative message">
<p>{{.FormatWarning}}</p> <p>{{.FormatWarning}}</p>
@@ -103,13 +114,30 @@
<div class="wiki-content-parts"> <div class="wiki-content-parts">
{{if .WikiSidebarTocHTML}} {{if .WikiSidebarTocHTML}}
<div class="render-content markup wiki-content-sidebar wiki-content-toc"> <div class="render-content markup wiki-content-sidebar wiki-content-toc">
<details open>
<summary><strong>{{svg "octicon-list-unordered" 14}} Contents</strong></summary>
{{.WikiSidebarTocHTML}} {{.WikiSidebarTocHTML}}
</details>
</div> </div>
{{end}} {{end}}
<div class="render-content markup wiki-content-main {{if or .WikiSidebarTocHTML .WikiSidebarHTML .WikiTree}}with-sidebar{{end}}"> <div class="render-content markup wiki-content-main {{if or .WikiSidebarTocHTML .WikiSidebarHTML .WikiTree}}with-sidebar{{end}}">
{{template "repo/unicode_escape_prompt" dict "EscapeStatus" .EscapeStatus}} {{template "repo/unicode_escape_prompt" dict "EscapeStatus" .EscapeStatus}}
{{if .WikiInlineTocHTML}}
<details open class="wiki-toc-inline tw-mb-4">
<summary><strong>{{svg "octicon-list-unordered" 14}} Contents</strong></summary>
{{.WikiInlineTocHTML}}
</details>
{{end}}
{{.WikiContentHTML}} {{.WikiContentHTML}}
{{if .WikiCategories}}
<div class="tw-mt-4 tw-pt-2" style="border-top: 1px solid var(--color-secondary);">
{{svg "octicon-tag" 14}} Categories:
{{range .WikiCategories}}
<a class="ui small label" href="{{$.RepoLink}}/wiki/?action=_category&name={{.}}">{{.}}</a>
{{end}}
</div>
{{end}}
</div> </div>
{{if .WikiTree}} {{if .WikiTree}}
@@ -121,6 +149,7 @@
{{if .IsDir}} {{if .IsDir}}
{{svg "octicon-file-directory" 14}} {{svg "octicon-file-directory" 14}}
<a href="{{$.RepoLink}}/wiki/{{.SubURL}}"><strong>{{.Name}}</strong></a> <a href="{{$.RepoLink}}/wiki/{{.SubURL}}"><strong>{{.Name}}</strong></a>
{{if .Protected}}{{svg "octicon-lock" 12}}{{end}}
{{if .Children}} {{if .Children}}
<ul> <ul>
{{range .Children}} {{range .Children}}
@@ -128,6 +157,7 @@
{{if .IsDir}} {{if .IsDir}}
{{svg "octicon-file-directory" 14}} {{svg "octicon-file-directory" 14}}
<a href="{{$.RepoLink}}/wiki/{{.SubURL}}"><strong>{{.Name}}</strong></a> <a href="{{$.RepoLink}}/wiki/{{.SubURL}}"><strong>{{.Name}}</strong></a>
{{if .Protected}}{{svg "octicon-lock" 12}}{{end}}
{{else}} {{else}}
{{svg "octicon-file" 14}} {{svg "octicon-file" 14}}
<a href="{{$.RepoLink}}/wiki/{{.SubURL}}" {{if eq $.PageURL .SubURL}}class="active"{{end}}>{{.Name}}</a> <a href="{{$.RepoLink}}/wiki/{{.SubURL}}" {{if eq $.PageURL .SubURL}}class="active"{{end}}>{{.Name}}</a>
+31
View File
@@ -86,3 +86,34 @@
max-width: unset; max-width: unset;
} }
} }
/* Wikilinks: red links for non-existent pages */
.wiki .wiki-link-new {
color: var(--color-red);
}
.wiki .wiki-link-new:hover {
color: var(--color-red);
text-decoration: underline;
}
/* Wiki inline ToC */
.wiki .wiki-toc-inline {
border: 1px solid var(--color-secondary);
border-radius: 4px;
padding: 8px 16px;
background: var(--color-box-body);
}
.wiki .wiki-toc-inline summary {
cursor: pointer;
user-select: none;
}
/* Sticky sidebar ToC */
.wiki .wiki-content-toc {
position: sticky;
top: 16px;
max-height: calc(100vh - 100px);
overflow-y: auto;
}