feat(licenses): add Licenses tab, page, and stream config #267

Merged
jmiller merged 1 commits from feat/inline-visibility-settings into dev 2026-05-31 02:04:19 +00:00
8 changed files with 190 additions and 0 deletions
+5
View File
@@ -113,6 +113,11 @@ func ListLicenseKeysByPackage(ctx context.Context, packageID int64) ([]*LicenseK
return keys, db.GetEngine(ctx).Where("package_id = ?", packageID).Find(&keys)
}
// CountKeysByPackage returns the number of keys for a package.
func CountKeysByPackage(ctx context.Context, packageID int64) (int64, error) {
return db.GetEngine(ctx).Where("package_id = ?", packageID).Count(new(LicenseKey))
}
// UpdateLicenseKey updates a license key.
func UpdateLicenseKey(ctx context.Context, key *LicenseKey) error {
_, err := db.GetEngine(ctx).ID(key.ID).AllCols().Update(key)
+16
View File
@@ -8,6 +8,8 @@ import (
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/timeutil"
"xorm.io/builder"
)
func init() {
@@ -55,6 +57,20 @@ func GetLicensePackageByID(ctx context.Context, id int64) (*LicensePackage, erro
return pkg, nil
}
// FindLicensePackageOptions for db.Find/db.Count.
type FindLicensePackageOptions struct {
db.ListOptions
OwnerID int64
}
func (opts FindLicensePackageOptions) ToConds() builder.Cond {
cond := builder.NewCond()
if opts.OwnerID > 0 {
cond = cond.And(builder.Eq{"owner_id": opts.OwnerID})
}
return cond
}
// ListLicensePackages returns all packages for the given owner.
func ListLicensePackages(ctx context.Context, ownerID int64) ([]*LicensePackage, error) {
pkgs := make([]*LicensePackage, 0, 10)
+19
View File
@@ -2613,6 +2613,25 @@
"repo.release.tags": "Tags",
"repo.release.new_release": "New Release",
"repo.release.update_feed": "Update Feed",
"repo.licenses": "Licenses",
"repo.licenses.packages": "License Packages",
"repo.licenses.package_name": "Package",
"repo.licenses.duration": "Duration",
"repo.licenses.channels": "Channels",
"repo.licenses.keys_issued": "Keys",
"repo.licenses.status": "Status",
"repo.licenses.lifetime": "Lifetime",
"repo.licenses.days": "days",
"repo.licenses.all_channels": "All channels",
"repo.licenses.active": "Active",
"repo.licenses.inactive": "Inactive",
"repo.licenses.none": "No License Packages",
"repo.licenses.none_desc": "License packages can be created via the API to gate access to update streams.",
"repo.licenses.issued_keys": "Issued Keys",
"repo.licenses.key_prefix": "Key",
"repo.licenses.licensee": "Licensee",
"repo.licenses.expires": "Expires",
"repo.licenses.never": "Never",
"repo.release.draft": "Draft",
"repo.release.prerelease": "Pre-Release",
"repo.release.stable": "Stable",
+59
View File
@@ -0,0 +1,59 @@
// Copyright 2026 Moko Consulting <hello@mokoconsulting.tech>
// SPDX-License-Identifier: GPL-3.0-or-later
package repo
import (
"net/http"
"time"
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licenses"
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/modules/templates"
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/services/context"
)
const tplLicenses templates.TplName = "repo/licenses"
// LicensePackageDisplay is used in templates.
type LicensePackageDisplay struct {
*licenses.LicensePackage
KeyCount int64
Created time.Time
}
// Licenses shows the license packages and keys for a repo.
func Licenses(ctx *context.Context) {
ctx.Data["Title"] = ctx.Tr("repo.licenses")
ctx.Data["PageIsLicenses"] = true
ctx.Data["IsLicensesPage"] = true
ownerID := ctx.Repo.Repository.OwnerID
pkgs, err := licenses.ListLicensePackages(ctx, ownerID)
if err != nil {
ctx.ServerError("ListLicensePackages", err)
return
}
// Build display list with key counts.
var display []LicensePackageDisplay
for _, pkg := range pkgs {
count, _ := licenses.CountKeysByPackage(ctx, pkg.ID)
display = append(display, LicensePackageDisplay{
LicensePackage: pkg,
KeyCount: count,
Created: time.Unix(int64(pkg.CreatedUnix), 0),
})
}
ctx.Data["LicensePackages"] = display
// List all keys for the owner.
keys, err := licenses.ListLicenseKeys(ctx, ownerID)
if err != nil {
ctx.ServerError("ListLicenseKeys", err)
return
}
ctx.Data["LicenseKeys"] = keys
ctx.HTML(http.StatusOK, tplLicenses)
}
+6
View File
@@ -1501,6 +1501,12 @@ func registerWebRoutes(m *web.Router, webAuth *AuthMiddleware) {
}, optSignIn, context.RepoAssignment)
// end "/{username}/{reponame}": update server
// "/{username}/{reponame}": licenses page
m.Group("/{username}/{reponame}", func() {
m.Get("/licenses", repo.Licenses)
}, reqSignIn, context.RepoAssignment, reqRepoReleaseReader)
// end "/{username}/{reponame}": licenses
m.Group("/{username}/{reponame}", func() { // to maintain compatibility with old attachments
m.Get("/attachments/{uuid}", webAuth.AllowBasic, webAuth.AllowOAuth2, repo.GetAttachment)
}, optSignIn, context.RepoAssignment)
+8
View File
@@ -18,6 +18,7 @@ import (
"git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/db"
git_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/git"
issues_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/issues"
licenses_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/licenses"
access_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/perm/access"
repo_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/repo"
unit_model "git.mokoconsulting.tech/MokoConsulting/MokoGitea/models/unit"
@@ -605,6 +606,13 @@ func repoAssignmentPrepareTemplateData(ctx *Context, data *repoAssignmentPrepare
return
}
// Check if license packages exist for this repo's owner (enables Licenses tab).
numLicensePackages, _ := db.Count[licenses_model.LicensePackage](ctx, licenses_model.FindLicensePackageOptions{
OwnerID: repo.OwnerID,
})
ctx.Data["NumLicensePackages"] = numLicensePackages
ctx.Data["EnableLicenses"] = numLicensePackages > 0
ctx.Data["Title"] = repo.Owner.Name + "/" + repo.Name
ctx.Data["PageTitleCommon"] = repo.Name + " - " + setting.AppName
ctx.Data["Repository"] = repo
+9
View File
@@ -128,6 +128,15 @@
</a>
{{end}}
{{if .EnableLicenses}}
<a href="{{.RepoLink}}/licenses" class="{{if .IsLicensesPage}}active {{end}}item">
{{svg "octicon-key"}} {{ctx.Locale.Tr "repo.licenses"}}
{{if .NumLicensePackages}}
<span class="ui small label">{{CountFmt .NumLicensePackages}}</span>
{{end}}
</a>
{{end}}
{{$projectsUnit := .Repository.MustGetUnit ctx ctx.Consts.RepoUnitTypeProjects}}
{{if and (not ctx.Consts.RepoUnitTypeProjects.UnitGlobalDisabled) (.Permission.CanRead ctx.Consts.RepoUnitTypeProjects) ($projectsUnit.ProjectsConfig.IsProjectsAllowed "repo")}}
<a href="{{.RepoLink}}/projects" class="{{if .IsProjectsPage}}active {{end}}item">
+68
View File
@@ -0,0 +1,68 @@
{{template "base/head" .}}
<div role="main" aria-label="{{.Title}}" class="page-content repository">
{{template "repo/header" .}}
<div class="ui container">
<h4 class="ui top attached header">
{{svg "octicon-key" 16}} {{ctx.Locale.Tr "repo.licenses"}}
</h4>
<div class="ui attached segment">
{{if .LicensePackages}}
<h5>{{ctx.Locale.Tr "repo.licenses.packages"}}</h5>
<table class="ui table">
<thead>
<tr>
<th>{{ctx.Locale.Tr "repo.licenses.package_name"}}</th>
<th>{{ctx.Locale.Tr "repo.licenses.duration"}}</th>
<th>{{ctx.Locale.Tr "repo.licenses.channels"}}</th>
<th>{{ctx.Locale.Tr "repo.licenses.keys_issued"}}</th>
<th>{{ctx.Locale.Tr "repo.licenses.status"}}</th>
</tr>
</thead>
<tbody>
{{range .LicensePackages}}
<tr>
<td><strong>{{.Name}}</strong>{{if .Description}}<br><small class="text grey">{{.Description}}</small>{{end}}</td>
<td>{{if eq .DurationDays 0}}{{ctx.Locale.Tr "repo.licenses.lifetime"}}{{else}}{{.DurationDays}} {{ctx.Locale.Tr "repo.licenses.days"}}{{end}}</td>
<td>{{if .AllowedChannels}}<code>{{.AllowedChannels}}</code>{{else}}{{ctx.Locale.Tr "repo.licenses.all_channels"}}{{end}}</td>
<td>{{.KeyCount}}</td>
<td>{{if .IsActive}}<span class="ui green label">{{ctx.Locale.Tr "repo.licenses.active"}}</span>{{else}}<span class="ui grey label">{{ctx.Locale.Tr "repo.licenses.inactive"}}</span>{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<div class="empty-placeholder">
{{svg "octicon-key" 48}}
<h2>{{ctx.Locale.Tr "repo.licenses.none"}}</h2>
<p>{{ctx.Locale.Tr "repo.licenses.none_desc"}}</p>
</div>
{{end}}
{{if .LicenseKeys}}
<div class="divider"></div>
<h5>{{ctx.Locale.Tr "repo.licenses.issued_keys"}}</h5>
<table class="ui table">
<thead>
<tr>
<th>{{ctx.Locale.Tr "repo.licenses.key_prefix"}}</th>
<th>{{ctx.Locale.Tr "repo.licenses.licensee"}}</th>
<th>{{ctx.Locale.Tr "repo.licenses.expires"}}</th>
<th>{{ctx.Locale.Tr "repo.licenses.status"}}</th>
</tr>
</thead>
<tbody>
{{range .LicenseKeys}}
<tr>
<td><code>{{.KeyPrefix}}</code></td>
<td>{{.LicenseeName}}{{if .LicenseeEmail}} <small>({{.LicenseeEmail}})</small>{{end}}</td>
<td>{{if eq .ExpiresUnix 0}}{{ctx.Locale.Tr "repo.licenses.never"}}{{else}}{{DateTime "short" (.ExpiresUnix.AsTime)}}{{end}}</td>
<td>{{if .IsActive}}<span class="ui green label">{{ctx.Locale.Tr "repo.licenses.active"}}</span>{{else}}<span class="ui grey label">{{ctx.Locale.Tr "repo.licenses.inactive"}}</span>{{end}}</td>
</tr>
{{end}}
</tbody>
</table>
{{end}}
</div>
</div>
</div>
{{template "base/footer" .}}