Compare commits

..

22 Commits

Author SHA1 Message Date
4acf4971bf fixed the commit detail diff view issue 2026-02-21 13:50:30 +09:00
d4b7554633 updated to use the internal project id and repo id in the file system formatted as %016x 2026-02-21 13:42:41 +09:00
3b1fceb889 converted the id columns to INTEGER PRIMARY KEY(64-bit signed) for sqlite 2026-02-21 11:09:43 +09:00
ac35bcb776 wired util.Logger to http.Server 2026-02-21 10:09:37 +09:00
e86d87da36 added backend/Makefile 2026-02-20 19:34:47 +09:00
be4ed5c497 not using an external rpm command to retrieve rpm info. using a repokit's function 2026-02-20 19:26:18 +09:00
33552ae88f added CODIT_FRONTEND_DIR 2026-02-20 16:54:21 +09:00
a5cbe9eb31 many changed in rpm mirror management.. still long way to go 2026-02-20 14:32:27 +09:00
32730fc2a7 started adding actual rpm mirror pull code 2026-02-17 00:21:51 +09:00
c4e48b6dc3 added initial code to support rpm mirroring without actual pull code 2026-02-16 13:55:23 +09:00
bf80ad9d61 added import of foreign certificates 2026-02-16 11:20:01 +09:00
ad12690d33 service principal enhancement 2026-02-15 19:02:07 +09:00
007987869d primitive service principal and binding to certificate 2026-02-15 17:44:45 +09:00
484e96f407 multiple authentication and policy options for a listener defintion 2026-02-15 17:27:27 +09:00
7a84045f33 added pki management
added listener management
2026-02-15 16:18:53 +09:00
fab83b3e68 showed source info in the user list 2026-02-15 02:06:41 +09:00
babb07fb51 added oidc client support 2026-02-15 02:03:31 +09:00
e46ccc9c6e added code for ldap, self account maintenance, api key management, etc 2026-02-15 01:07:45 +09:00
ac9ac4cbc7 docker image/tag delete is supported 2026-02-06 07:50:07 +09:00
8855bb77fb added primitive docker repo code including the docker service endpoint and simple ui 2026-02-06 07:39:57 +09:00
cc176b4d29 added more checks in manipulating files and directory in a rpm repo 2026-02-05 10:05:40 +09:00
c95953d078 added access logs of api calls 2026-02-05 03:15:47 +09:00
74 changed files with 17760 additions and 600 deletions

21
backend/Makefile Normal file
View File

@@ -0,0 +1,21 @@
CODIT_SERVER_NAME=codit-server
CODIT_SERVER_VERSION=0.5.0
CODIT_SERVER_SRCS = cmd/codit-server/main.go
CODIT_DATA_BROWSER_SRCS = cmd/codit-data-browser/main.go
all: codit-server codit-data-browser
codit-server:
CGO_ENABLED=0 go build -x -ldflags "-X 'main.CODIT_SERVER_NAME=$(NAME)' -X 'main.CODIT_SERVER_VERSION=$(VERSION)'" -o $@ $(CODIT_SERVER_SRCS)
codit-server.debug:
CGO_ENABLED=1 go build -race -x -ldflags "-X 'main.CODIT_SERVER_NAME=$(NAME)' -X 'main.CODIT_SERVER_VERSION=$(VERSION)'" -o $@ $(CODIT_SERVER_SRCS)
codit-data-browser:
CGO_ENABLED=0 go build -x -ldflags "-X 'main.CODIT_SERVER_NAME=$(NAME)' -X 'main.CODIT_SERVER_VERSION=$(VERSION)'" -o $@ $(CODIT_DATA_BROWSER_SRCS)
clean:
go clean -x
rm -rf codit-server codit-server.debug codit-data-browser

View File

@@ -0,0 +1,572 @@
package main
import "database/sql"
import "flag"
import "fmt"
import "os"
import "path/filepath"
import "sort"
import "strconv"
import "strings"
import _ "modernc.org/sqlite"
import "github.com/gdamore/tcell/v2"
import "github.com/rivo/tview"
type projectInfo struct {
ID int64
PublicID string
Slug string
Name string
}
type repoInfo struct {
ID int64
PublicID string
ProjectID int64
Name string
RepoType string
LegacyPath string
}
type entryInfo struct {
Name string
IsDir bool
Hint string
}
type browser struct {
DB *sql.DB
DataDir string
Cwd string
ProjectsByID map[int64]projectInfo
ReposByID map[int64]repoInfo
Entries []entryInfo
App *tview.Application
Root tview.Primitive
Header *tview.TextView
Table *tview.Table
Status *tview.TextView
}
func main() {
var dbPath string
var dataDir string
var err error
var br *browser
flag.StringVar(&dbPath, "db", "./codit-data/codit.db", "sqlite database path")
flag.StringVar(&dataDir, "data", "./codit-data", "codit data directory")
flag.Parse()
br, err = newBrowser(dbPath, dataDir)
if err != nil {
fmt.Fprintf(os.Stderr, "init error: %v\n", err)
os.Exit(1)
}
err = br.run()
if err != nil {
fmt.Fprintf(os.Stderr, "run error: %v\n", err)
os.Exit(1)
}
}
func newBrowser(dbPath string, dataDir string) (*browser, error) {
var err error
var db *sql.DB
var br *browser
db, err = sql.Open("sqlite", "file:"+dbPath+"?_pragma=foreign_keys(1)")
if err != nil {
return nil, err
}
br = &browser{
DB: db,
DataDir: filepath.Clean(dataDir),
Cwd: filepath.Clean(dataDir),
ProjectsByID: map[int64]projectInfo{},
ReposByID: map[int64]repoInfo{},
App: tview.NewApplication(),
Header: tview.NewTextView(),
Table: tview.NewTable(),
Status: tview.NewTextView(),
}
err = br.loadMaps()
if err != nil {
_ = db.Close()
return nil, err
}
err = br.refreshEntries()
if err != nil {
_ = db.Close()
return nil, err
}
br.setupUI()
br.renderAll("Ready. q quit, Enter/Right open, Left/Backspace up, PgUp/PgDn/Home/End, r reload, i info, c check")
return br, nil
}
func (b *browser) run() error {
var err error
b.Root = b.layout()
err = b.App.SetRoot(b.Root, true).EnableMouse(false).Run()
_ = b.DB.Close()
return err
}
func (b *browser) layout() tview.Primitive {
var root *tview.Flex
root = tview.NewFlex().SetDirection(tview.FlexRow)
root.AddItem(b.Header, 1, 0, false)
root.AddItem(b.Table, 0, 1, true)
root.AddItem(b.Status, 1, 0, false)
return root
}
func (b *browser) setupUI() {
var header string
header = "Type Name Hint"
b.Header.SetDynamicColors(true)
b.Header.SetText(header)
b.Table.SetSelectable(true, false)
b.Table.SetFixed(0, 0)
b.Table.SetBorders(false)
b.Table.SetSeparator(' ')
b.Table.SetInputCapture(b.captureKey)
b.Table.SetSelectedStyle(tcell.StyleDefault.Foreground(tcell.ColorBlack).Background(tcell.ColorLightCyan))
}
func (b *browser) renderAll(status string) {
var i int
var e entryInfo
var kind string
var name string
var rel string
var err error
var row int
b.Table.Clear()
for i = 0; i < len(b.Entries); i++ {
e = b.Entries[i]
if e.IsDir {
kind = "DIR "
} else {
kind = "FILE"
}
name = e.Name
row = i
b.Table.SetCell(row, 0, tview.NewTableCell(kind).SetSelectable(false))
b.Table.SetCell(row, 1, tview.NewTableCell(name))
b.Table.SetCell(row, 2, tview.NewTableCell(e.Hint).SetSelectable(false))
}
if len(b.Entries) > 0 {
b.Table.Select(0, 1)
}
rel, err = filepath.Rel(b.DataDir, b.Cwd)
if err != nil {
rel = b.Cwd
}
if rel == "." {
rel = "/"
} else {
rel = "/" + filepath.ToSlash(rel)
}
b.Header.SetText("Path: " + rel + " (entries: " + strconv.Itoa(len(b.Entries)) + ")")
b.Status.SetText(status)
}
func (b *browser) captureKey(event *tcell.EventKey) *tcell.EventKey {
var key tcell.Key
var r rune
var row int
var _, _, _, h = b.Table.GetInnerRect()
var page int
var err error
var info string
var mismatches int
key = event.Key()
r = event.Rune()
if key == tcell.KeyEnter || key == tcell.KeyRight {
err = b.openSelected()
if err != nil {
b.Status.SetText("open failed: " + err.Error())
}
return nil
}
if key == tcell.KeyLeft || key == tcell.KeyBackspace || key == tcell.KeyBackspace2 {
err = b.up()
if err != nil {
b.Status.SetText("up failed: " + err.Error())
}
return nil
}
if key == tcell.KeyHome {
if len(b.Entries) > 0 {
b.Table.Select(0, 1)
}
return nil
}
if key == tcell.KeyEnd {
if len(b.Entries) > 0 {
b.Table.Select(len(b.Entries)-1, 1)
}
return nil
}
if key == tcell.KeyPgUp || key == tcell.KeyPgDn {
row, _ = b.Table.GetSelection()
page = h
if page < 1 {
page = 10
}
if key == tcell.KeyPgUp {
row = row - page
if row < 0 {
row = 0
}
} else {
row = row + page
if row >= len(b.Entries) {
row = len(b.Entries) - 1
}
if row < 0 {
row = 0
}
}
if len(b.Entries) > 0 {
b.Table.Select(row, 1)
}
return nil
}
if r == 'q' || r == 'Q' {
b.App.Stop()
return nil
}
if r == 'r' || r == 'R' {
b.ProjectsByID = map[int64]projectInfo{}
b.ReposByID = map[int64]repoInfo{}
err = b.loadMaps()
if err != nil {
b.Status.SetText("reload failed: " + err.Error())
return nil
}
err = b.refreshEntries()
if err != nil {
b.Status.SetText("reload list failed: " + err.Error())
return nil
}
b.renderAll("Reloaded")
return nil
}
if r == 'i' || r == 'I' {
info = b.selectedInfo()
b.Status.SetText(info)
return nil
}
if r == 'c' || r == 'C' {
mismatches = b.showMismatches()
if mismatches == 0 {
b.Status.SetText("check complete: no missing expected repo directories")
}
return nil
}
return event
}
func (b *browser) openSelected() error {
var row int
var name string
var err error
row, _ = b.Table.GetSelection()
if row < 0 || row >= len(b.Entries) {
return nil
}
if !b.Entries[row].IsDir {
return nil
}
name = b.Entries[row].Name
b.Cwd = filepath.Clean(filepath.Join(b.Cwd, name))
if !pathInside(b.Cwd, b.DataDir) {
return fmt.Errorf("outside data dir")
}
err = b.refreshEntries()
if err != nil {
return err
}
b.renderAll("Opened " + b.Cwd)
return nil
}
func (b *browser) up() error {
var parent string
var err error
parent = filepath.Dir(b.Cwd)
if !pathInside(parent, b.DataDir) {
return nil
}
b.Cwd = parent
err = b.refreshEntries()
if err != nil {
return err
}
b.renderAll("Opened " + b.Cwd)
return nil
}
func (b *browser) selectedInfo() string {
var row int
var e entryInfo
row, _ = b.Table.GetSelection()
if row < 0 || row >= len(b.Entries) {
return "no selection"
}
e = b.Entries[row]
if e.Hint == "" {
return e.Name
}
return e.Name + " | " + e.Hint
}
func (b *browser) refreshEntries() error {
var raw []os.DirEntry
var i int
var item os.DirEntry
var entries []entryInfo
var info entryInfo
var dirs []entryInfo
var files []entryInfo
var err error
raw, err = os.ReadDir(b.Cwd)
if err != nil {
return err
}
entries = make([]entryInfo, 0, len(raw))
for i = 0; i < len(raw); i++ {
item = raw[i]
info = entryInfo{
Name: item.Name(),
IsDir: item.IsDir(),
Hint: b.resolvePathHint(item.Name()),
}
entries = append(entries, info)
}
dirs = make([]entryInfo, 0, len(entries))
files = make([]entryInfo, 0, len(entries))
for i = 0; i < len(entries); i++ {
if entries[i].IsDir {
dirs = append(dirs, entries[i])
} else {
files = append(files, entries[i])
}
}
sort.Slice(dirs, func(i int, j int) bool {
return strings.ToLower(dirs[i].Name) < strings.ToLower(dirs[j].Name)
})
sort.Slice(files, func(i int, j int) bool {
return strings.ToLower(files[i].Name) < strings.ToLower(files[j].Name)
})
b.Entries = make([]entryInfo, 0, len(entries))
b.Entries = append(b.Entries, dirs...)
b.Entries = append(b.Entries, files...)
return nil
}
func (b *browser) loadMaps() error {
var err error
var rows *sql.Rows
var p projectInfo
var r repoInfo
rows, err = b.DB.Query(`SELECT id, public_id, slug, name FROM projects`)
if err != nil {
return err
}
for rows.Next() {
err = rows.Scan(&p.ID, &p.PublicID, &p.Slug, &p.Name)
if err != nil {
rows.Close()
return err
}
b.ProjectsByID[p.ID] = p
}
err = rows.Close()
if err != nil {
return err
}
rows, err = b.DB.Query(`SELECT id, public_id, project_id, name, type, path FROM repos`)
if err != nil {
return err
}
for rows.Next() {
err = rows.Scan(&r.ID, &r.PublicID, &r.ProjectID, &r.Name, &r.RepoType, &r.LegacyPath)
if err != nil {
rows.Close()
return err
}
b.ReposByID[r.ID] = r
}
err = rows.Close()
if err != nil {
return err
}
return nil
}
func (b *browser) resolvePathHint(name string) string {
var rel string
var parts []string
var service string
var projectID int64
var repoID int64
var project projectInfo
var repo repoInfo
var err error
rel, err = filepath.Rel(b.DataDir, filepath.Join(b.Cwd, name))
if err != nil {
return ""
}
parts = strings.Split(filepath.ToSlash(rel), "/")
if len(parts) < 2 {
return ""
}
service = parts[0]
projectID, err = parseStorageIDSegment(parts[1])
if err == nil {
project = b.ProjectsByID[projectID]
if project.ID > 0 && len(parts) == 2 {
return fmt.Sprintf("project: %s (%s)", project.Slug, project.PublicID)
}
}
if len(parts) < 3 {
return ""
}
if service == "git" {
parts[2] = strings.TrimSuffix(parts[2], ".git")
}
repoID, err = parseStorageIDSegment(parts[2])
if err != nil {
return ""
}
repo = b.ReposByID[repoID]
if repo.ID == 0 {
return ""
}
return fmt.Sprintf("repo: %s (%s) type=%s", repo.Name, repo.PublicID, repo.RepoType)
}
func (b *browser) countMismatches() int {
var ids []int64
var repoID int64
var repo repoInfo
var expected string
var err error
var count int
ids = make([]int64, 0, len(b.ReposByID))
for repoID = range b.ReposByID {
ids = append(ids, repoID)
}
sort.Slice(ids, func(i int, j int) bool { return ids[i] < ids[j] })
count = 0
for _, repoID = range ids {
repo = b.ReposByID[repoID]
expected = expectedRepoPath(b.DataDir, repo.RepoType, repo.ProjectID, repo.ID)
_, err = os.Stat(expected)
if err != nil {
count = count + 1
}
}
return count
}
func (b *browser) showMismatches() int {
var lines []string
var text string
var modal *tview.Modal
lines = b.collectMismatchLines()
if len(lines) == 0 {
return 0
}
text = strings.Join(lines, "\n")
modal = tview.NewModal().
SetText(text).
AddButtons([]string{"Close"}).
SetDoneFunc(func(buttonIndex int, buttonLabel string) {
b.App.SetRoot(b.Root, true).SetFocus(b.Table)
})
b.App.SetRoot(modal, true).SetFocus(modal)
return len(lines)
}
func (b *browser) collectMismatchLines() []string {
var ids []int64
var repoID int64
var repo repoInfo
var project projectInfo
var expected string
var lines []string
var err error
ids = make([]int64, 0, len(b.ReposByID))
for repoID = range b.ReposByID {
ids = append(ids, repoID)
}
sort.Slice(ids, func(i int, j int) bool { return ids[i] < ids[j] })
lines = []string{}
for _, repoID = range ids {
repo = b.ReposByID[repoID]
expected = expectedRepoPath(b.DataDir, repo.RepoType, repo.ProjectID, repo.ID)
_, err = os.Stat(expected)
if err == nil {
continue
}
project = b.ProjectsByID[repo.ProjectID]
lines = append(lines, fmt.Sprintf("%s/%s [%s]", project.Slug, repo.Name, repo.RepoType))
lines = append(lines, "expected: "+expected)
lines = append(lines, "current : "+repo.LegacyPath)
lines = append(lines, "")
}
if len(lines) > 0 {
lines = lines[:len(lines)-1]
}
return lines
}
func expectedRepoPath(dataDir string, repoType string, projectID int64, repoID int64) string {
var p string
var r string
p = formatStorageIDSegment(projectID)
r = formatStorageIDSegment(repoID)
if repoType == "git" {
return filepath.Join(dataDir, "git", p, r+".git")
}
if repoType == "rpm" {
return filepath.Join(dataDir, "rpm", p, r)
}
if repoType == "docker" {
return filepath.Join(dataDir, "docker", p, r)
}
return ""
}
func formatStorageIDSegment(id int64) string {
return fmt.Sprintf("%016x", id)
}
func parseStorageIDSegment(s string) (int64, error) {
var parsed int64
var err error
var trimmed string
trimmed = strings.TrimSpace(s)
parsed, err = strconv.ParseInt(trimmed, 16, 64)
if err == nil {
return parsed, nil
}
return strconv.ParseInt(trimmed, 10, 64)
}
func pathInside(path string, root string) bool {
var p string
var r string
var prefix string
p = filepath.Clean(path)
r = filepath.Clean(root)
if p == r {
return true
}
prefix = r + string(os.PathSeparator)
return strings.HasPrefix(p, prefix)
}

File diff suppressed because it is too large Load Diff

View File

@@ -3,9 +3,11 @@ module codit
go 1.25.3
require (
github.com/gdamore/tcell/v2 v2.13.8
github.com/go-git/go-git/v5 v5.12.0
github.com/go-ldap/ldap/v3 v3.4.6
github.com/graphql-go/graphql v0.8.0
github.com/rivo/tview v0.42.0
github.com/sosedoff/gitkit v0.4.0
golang.org/x/crypto v0.47.0
modernc.org/sqlite v1.30.0
@@ -22,6 +24,7 @@ require (
github.com/cyphar/filepath-securejoin v0.2.4 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/gdamore/encoding v1.0.1 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.5 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/go-git/go-billy/v5 v5.5.0 // indirect
@@ -32,19 +35,24 @@ require (
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/kevinburke/ssh_config v1.2.0 // indirect
github.com/klauspost/compress v1.18.3 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/pjbgf/sha1cd v0.3.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sassoftware/go-rpmutils v0.4.1-0.20250318174028-2660c86d578c // indirect
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
github.com/skeema/knownhosts v1.2.2 // indirect
github.com/ulikunitz/xz v0.5.15 // indirect
github.com/xanzy/ssh-agent v0.3.3 // indirect
golang.org/x/mod v0.16.0 // indirect
golang.org/x/mod v0.31.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/tools v0.19.0 // indirect
golang.org/x/term v0.39.0 // indirect
golang.org/x/text v0.33.0 // indirect
golang.org/x/tools v0.40.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
modernc.org/libc v1.50.9 // indirect

View File

@@ -28,6 +28,10 @@ github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a h1:mATvB/9r/3gvcej
github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
github.com/gdamore/tcell/v2 v2.13.8 h1:Mys/Kl5wfC/GcC5Cx4C2BIQH9dbnhnkPgS9/wF3RlfU=
github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=
github.com/gliderlabs/ssh v0.3.7 h1:iV3Bqi942d9huXnzEF2Mt+CY9gLu8DNM4Obd+8bODRE=
github.com/gliderlabs/ssh v0.3.7/go.mod h1:zpHEXBstFnQYtGnB8k8kQLol82umzn/2/snG7alWVD8=
github.com/go-asn1-ber/asn1-ber v1.5.5 h1:MNHlNMBDgEKD4TcKr36vQN68BA00aDfjIt3/bD50WnA=
@@ -70,6 +74,8 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
@@ -84,6 +90,10 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/tview v0.42.0 h1:b/ftp+RxtDsHSaynXTbJb+/n/BxDEi+W3UfF5jILK6c=
github.com/rivo/tview v0.42.0/go.mod h1:cSfIYfhpSGCjp3r/ECJb+GKS7cGJnqV8vfjQPwoXyfY=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
github.com/sassoftware/go-rpmutils v0.4.1-0.20250318174028-2660c86d578c h1:Y+MtXJBE7rpqj0nk6GhSzD/48pXSKNEJPIYhtoSCbjk=
@@ -120,8 +130,8 @@ golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic=
golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
@@ -133,8 +143,8 @@ golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -164,14 +174,15 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw=
golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -0,0 +1,58 @@
package auth
import "strings"
import "testing"
import "time"
import "codit/internal/config"
func TestHashAndComparePassword(t *testing.T) {
var hash string
var err error
hash, err = HashPassword("pw-123")
if err != nil {
t.Fatalf("HashPassword() error: %v", err)
}
err = ComparePassword(hash, "pw-123")
if err != nil {
t.Fatalf("ComparePassword() failed for correct password: %v", err)
}
err = ComparePassword(hash, "wrong")
if err == nil {
t.Fatalf("ComparePassword() must fail for wrong password")
}
}
func TestNewSessionToken(t *testing.T) {
var a string
var b string
var err error
a, err = NewSessionToken()
if err != nil {
t.Fatalf("NewSessionToken() error: %v", err)
}
b, err = NewSessionToken()
if err != nil {
t.Fatalf("NewSessionToken() error for second token: %v", err)
}
if a == b {
t.Fatalf("session tokens must differ")
}
if strings.Contains(a, "=") {
t.Fatalf("token must be raw base64 without padding: %s", a)
}
}
func TestSessionExpiry(t *testing.T) {
var cfg config.Config
var before time.Time
var after time.Time
var exp time.Time
before = time.Now().UTC()
cfg.SessionTTL = config.Duration(2 * time.Hour)
exp = SessionExpiry(cfg)
after = time.Now().UTC()
if exp.Before(before.Add(2*time.Hour-time.Second)) || exp.After(after.Add(2*time.Hour+time.Second)) {
t.Fatalf("unexpected session expiry: %v", exp)
}
}

View File

@@ -1,7 +1,11 @@
package auth
import "context"
import "fmt"
import "net"
import "strings"
import "time"
import "crypto/tls"
import "codit/internal/config"
import "github.com/go-ldap/ldap/v3"
@@ -12,8 +16,15 @@ type LDAPUser struct {
Email string
}
const LDAPOperationTimeout time.Duration = 8 * time.Second
func LDAPAuthenticate(cfg config.Config, username, password string) (LDAPUser, error) {
return LDAPAuthenticateContext(context.Background(), cfg, username, password)
}
func LDAPAuthenticateContext(ctx context.Context, cfg config.Config, username, password string) (LDAPUser, error) {
var conn *ldap.Conn
var cleanup func()
var err error
var filter string
var search *ldap.SearchRequest
@@ -21,15 +32,18 @@ func LDAPAuthenticate(cfg config.Config, username, password string) (LDAPUser, e
var entry *ldap.Entry
var userDN string
var user LDAPUser
conn, err = ldap.DialURL(cfg.LDAPURL)
conn, cleanup, err = ldapConnWithContext(ctx, cfg)
if err != nil {
return LDAPUser{}, err
}
defer conn.Close()
defer cleanup()
if cfg.LDAPBindDN != "" {
err = conn.Bind(cfg.LDAPBindDN, cfg.LDAPBindPassword)
if err != nil {
if ctx.Err() != nil {
return LDAPUser{}, ctx.Err()
}
return LDAPUser{}, err
}
}
@@ -45,6 +59,9 @@ func LDAPAuthenticate(cfg config.Config, username, password string) (LDAPUser, e
res, err = conn.Search(search)
if err != nil {
if ctx.Err() != nil {
return LDAPUser{}, ctx.Err()
}
return LDAPUser{}, err
}
if len(res.Entries) == 0 {
@@ -54,6 +71,9 @@ func LDAPAuthenticate(cfg config.Config, username, password string) (LDAPUser, e
userDN = entry.DN
err = conn.Bind(userDN, password)
if err != nil {
if ctx.Err() != nil {
return LDAPUser{}, ctx.Err()
}
return LDAPUser{}, err
}
@@ -67,3 +87,64 @@ func LDAPAuthenticate(cfg config.Config, username, password string) (LDAPUser, e
}
return user, nil
}
func LDAPTestConnection(cfg config.Config) error {
return LDAPTestConnectionContext(context.Background(), cfg)
}
func LDAPTestConnectionContext(ctx context.Context, cfg config.Config) error {
var conn *ldap.Conn
var cleanup func()
var err error
conn, cleanup, err = ldapConnWithContext(ctx, cfg)
if err != nil {
return err
}
defer cleanup()
if cfg.LDAPBindDN != "" {
err = conn.Bind(cfg.LDAPBindDN, cfg.LDAPBindPassword)
if err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
return err
}
}
return nil
}
func ldapConnWithContext(ctx context.Context, cfg config.Config) (*ldap.Conn, func(), error) {
var opCtx context.Context
var cancel context.CancelFunc
var conn *ldap.Conn
var err error
var dialer *net.Dialer
var tlsConfig *tls.Config
var opts []ldap.DialOpt
var done chan struct{}
opCtx, cancel = context.WithTimeout(ctx, LDAPOperationTimeout)
dialer = &net.Dialer{Timeout: LDAPOperationTimeout}
opts = make([]ldap.DialOpt, 0, 2)
opts = append(opts, ldap.DialWithDialer(dialer))
tlsConfig = &tls.Config{InsecureSkipVerify: cfg.LDAPTLSInsecureSkipVerify}
opts = append(opts, ldap.DialWithTLSConfig(tlsConfig))
conn, err = ldap.DialURL(cfg.LDAPURL, opts...)
if err != nil {
cancel()
return nil, nil, err
}
conn.SetTimeout(LDAPOperationTimeout)
done = make(chan struct{})
go func() {
select {
case <-opCtx.Done():
_ = conn.Close()
case <-done:
}
}()
return conn, func() {
close(done)
cancel()
_ = conn.Close()
}, nil
}

View File

@@ -3,13 +3,17 @@ package config
import "encoding/json"
import "errors"
import "os"
import "path/filepath"
import "strings"
import "time"
import "strconv"
type Config struct {
HTTPAddr string `json:"http_addr"`
HTTPAddrs []string `json:"http_addrs"`
HTTPSAddrs []string `json:"https_addrs"`
PublicBaseURL string `json:"public_base_url"`
DataDir string `json:"data_dir"`
FrontendDir string `json:"frontend_dir"`
DBDriver string `json:"db_driver"`
DBDSN string `json:"db_dsn"`
SessionTTL Duration `json:"session_ttl"`
@@ -19,6 +23,24 @@ type Config struct {
LDAPBindPassword string `json:"ldap_bind_password"`
LDAPUserBaseDN string `json:"ldap_user_base_dn"`
LDAPUserFilter string `json:"ldap_user_filter"`
LDAPTLSInsecureSkipVerify bool `json:"ldap_tls_insecure_skip_verify"`
OIDCClientID string `json:"oidc_client_id"`
OIDCClientSecret string `json:"oidc_client_secret"`
OIDCAuthorizeURL string `json:"oidc_authorize_url"`
OIDCTokenURL string `json:"oidc_token_url"`
OIDCUserInfoURL string `json:"oidc_userinfo_url"`
OIDCRedirectURL string `json:"oidc_redirect_url"`
OIDCScopes string `json:"oidc_scopes"`
OIDCEnabled bool `json:"oidc_enabled"`
OIDCTLSInsecureSkipVerify bool `json:"oidc_tls_insecure_skip_verify"`
TLSServerCertSource string `json:"tls_server_cert_source"`
TLSCertFile string `json:"tls_cert_file"`
TLSKeyFile string `json:"tls_key_file"`
TLSPKIServerCertID string `json:"tls_pki_server_cert_id"`
TLSClientAuth string `json:"tls_client_auth"`
TLSClientCAFile string `json:"tls_client_ca_file"`
TLSPKIClientCAID string `json:"tls_pki_client_ca_id"`
TLSMinVersion string `json:"tls_min_version"`
GitHTTPPrefix string `json:"git_http_prefix"`
RPMHTTPPrefix string `json:"rpm_http_prefix"`
}
@@ -28,13 +50,19 @@ func Load(path string) (Config, error) {
var data []byte
var err error
cfg = Config{
HTTPAddr: ":1080",
HTTPAddrs: []string{":1080"},
HTTPSAddrs: []string{},
DataDir: "./codit-data",
FrontendDir: filepath.Join("..", "frontend", "dist"),
DBDriver: "sqlite",
DBDSN: "file:./codit-data/codit.db?_pragma=foreign_keys(1)",
SessionTTL: Duration(24 * time.Hour),
AuthMode: "db",
LDAPUserFilter: "(uid={username})",
OIDCScopes: "openid profile email",
TLSServerCertSource: "files",
TLSClientAuth: "none",
TLSMinVersion: "1.2",
GitHTTPPrefix: "/git",
RPMHTTPPrefix: "/rpm",
}
@@ -50,6 +78,13 @@ func Load(path string) (Config, error) {
}
override(&cfg)
cfg.AuthMode = strings.ToLower(strings.TrimSpace(cfg.AuthMode))
cfg.TLSServerCertSource = strings.ToLower(strings.TrimSpace(cfg.TLSServerCertSource))
cfg.TLSClientAuth = strings.ToLower(strings.TrimSpace(cfg.TLSClientAuth))
cfg.HTTPAddrs = normalizeHTTPAddrs(cfg.HTTPAddrs)
cfg.HTTPSAddrs = normalizeHTTPAddrs(cfg.HTTPSAddrs)
if len(cfg.HTTPAddrs) == 0 && len(cfg.HTTPSAddrs) == 0 {
return cfg, errors.New("http_addrs or https_addrs is required")
}
if cfg.DBDSN == "" {
return cfg, errors.New("db dsn is required")
}
@@ -58,55 +93,135 @@ func Load(path string) (Config, error) {
func override(cfg *Config) {
var v string
v = os.Getenv("BUN_HTTP_ADDR")
v = os.Getenv("CODIT_HTTP_ADDRS")
if v != "" {
cfg.HTTPAddr = v
cfg.HTTPAddrs = splitCSV(v)
}
v = os.Getenv("BUN_PUBLIC_BASE_URL")
v = os.Getenv("CODIT_HTTPS_ADDRS")
if v != "" {
cfg.HTTPSAddrs = splitCSV(v)
}
v = os.Getenv("CODIT_PUBLIC_BASE_URL")
if v != "" {
cfg.PublicBaseURL = v
}
v = os.Getenv("BUN_DATA_DIR")
v = os.Getenv("CODIT_DATA_DIR")
if v != "" {
cfg.DataDir = v
}
v = os.Getenv("BUN_DB_DRIVER")
v = os.Getenv("CODIT_FRONTEND_DIR")
if v != "" {
cfg.FrontendDir = v
}
v = os.Getenv("CODIT_DB_DRIVER")
if v != "" {
cfg.DBDriver = v
}
v = os.Getenv("BUN_DB_DSN")
v = os.Getenv("CODIT_DB_DSN")
if v != "" {
cfg.DBDSN = v
}
v = os.Getenv("BUN_AUTH_MODE")
v = os.Getenv("CODIT_AUTH_MODE")
if v != "" {
cfg.AuthMode = v
}
v = os.Getenv("BUN_LDAP_URL")
v = os.Getenv("CODIT_LDAP_URL")
if v != "" {
cfg.LDAPURL = v
}
v = os.Getenv("BUN_LDAP_BIND_DN")
v = os.Getenv("CODIT_LDAP_BIND_DN")
if v != "" {
cfg.LDAPBindDN = v
}
v = os.Getenv("BUN_LDAP_BIND_PASSWORD")
v = os.Getenv("CODIT_LDAP_BIND_PASSWORD")
if v != "" {
cfg.LDAPBindPassword = v
}
v = os.Getenv("BUN_LDAP_USER_BASE_DN")
v = os.Getenv("CODIT_LDAP_USER_BASE_DN")
if v != "" {
cfg.LDAPUserBaseDN = v
}
v = os.Getenv("BUN_LDAP_USER_FILTER")
v = os.Getenv("CODIT_LDAP_USER_FILTER")
if v != "" {
cfg.LDAPUserFilter = v
}
v = os.Getenv("BUN_GIT_HTTP_PREFIX")
v = os.Getenv("CODIT_LDAP_TLS_INSECURE_SKIP_VERIFY")
if v != "" {
cfg.LDAPTLSInsecureSkipVerify = parseEnvBool(v)
}
v = os.Getenv("CODIT_OIDC_CLIENT_ID")
if v != "" {
cfg.OIDCClientID = v
}
v = os.Getenv("CODIT_OIDC_CLIENT_SECRET")
if v != "" {
cfg.OIDCClientSecret = v
}
v = os.Getenv("CODIT_OIDC_AUTHORIZE_URL")
if v != "" {
cfg.OIDCAuthorizeURL = v
}
v = os.Getenv("CODIT_OIDC_TOKEN_URL")
if v != "" {
cfg.OIDCTokenURL = v
}
v = os.Getenv("CODIT_OIDC_USERINFO_URL")
if v != "" {
cfg.OIDCUserInfoURL = v
}
v = os.Getenv("CODIT_OIDC_REDIRECT_URL")
if v != "" {
cfg.OIDCRedirectURL = v
}
v = os.Getenv("CODIT_OIDC_SCOPES")
if v != "" {
cfg.OIDCScopes = v
}
v = os.Getenv("CODIT_OIDC_ENABLED")
if v != "" {
cfg.OIDCEnabled = parseEnvBool(v)
}
v = os.Getenv("CODIT_OIDC_TLS_INSECURE_SKIP_VERIFY")
if v != "" {
cfg.OIDCTLSInsecureSkipVerify = parseEnvBool(v)
}
v = os.Getenv("CODIT_TLS_SERVER_CERT_SOURCE")
if v != "" {
cfg.TLSServerCertSource = v
}
v = os.Getenv("CODIT_TLS_CERT_FILE")
if v != "" {
cfg.TLSCertFile = v
}
v = os.Getenv("CODIT_TLS_KEY_FILE")
if v != "" {
cfg.TLSKeyFile = v
}
v = os.Getenv("CODIT_TLS_PKI_SERVER_CERT_ID")
if v != "" {
cfg.TLSPKIServerCertID = v
}
v = os.Getenv("CODIT_TLS_CLIENT_AUTH")
if v != "" {
cfg.TLSClientAuth = v
}
v = os.Getenv("CODIT_TLS_CLIENT_CA_FILE")
if v != "" {
cfg.TLSClientCAFile = v
}
v = os.Getenv("CODIT_TLS_PKI_CLIENT_CA_ID")
if v != "" {
cfg.TLSPKIClientCAID = v
}
v = os.Getenv("CODIT_TLS_MIN_VERSION")
if v != "" {
cfg.TLSMinVersion = v
}
v = os.Getenv("CODIT_GIT_HTTP_PREFIX")
if v != "" {
cfg.GitHTTPPrefix = v
}
v = os.Getenv("BUN_RPM_HTTP_PREFIX")
v = os.Getenv("CODIT_RPM_HTTP_PREFIX")
if v != "" {
cfg.RPMHTTPPrefix = v
}
@@ -139,3 +254,51 @@ func (d *Duration) UnmarshalJSON(b []byte) error {
}
return errors.New("invalid duration format")
}
func parseEnvBool(v string) bool {
var lowered string
var parsed bool
var err error
lowered = strings.ToLower(strings.TrimSpace(v))
if lowered == "true" || lowered == "yes" || lowered == "y" || lowered == "on" {
return true
}
if lowered == "false" || lowered == "no" || lowered == "n" || lowered == "off" {
return false
}
parsed, err = strconv.ParseBool(lowered)
if err == nil {
return parsed
}
return false
}
func splitCSV(v string) []string {
var parts []string
var out []string
var i int
var p string
parts = strings.Split(v, ",")
for i = 0; i < len(parts); i++ {
p = strings.TrimSpace(parts[i])
if p == "" {
continue
}
out = append(out, p)
}
return out
}
func normalizeHTTPAddrs(values []string) []string {
var out []string
var i int
var v string
for i = 0; i < len(values); i++ {
v = strings.TrimSpace(values[i])
if v == "" {
continue
}
out = append(out, v)
}
return out
}

View File

@@ -0,0 +1,73 @@
package config
import "os"
import "path/filepath"
import "testing"
import "time"
func TestLoadDefaults(t *testing.T) {
var cfg Config
var err error
cfg, err = Load("")
if err != nil {
t.Fatalf("Load() error: %v", err)
}
if cfg.DBDriver == "" || cfg.DBDSN == "" {
t.Fatalf("defaults not populated: driver=%q dsn=%q", cfg.DBDriver, cfg.DBDSN)
}
if cfg.GitHTTPPrefix != "/git" {
t.Fatalf("unexpected git prefix default: %s", cfg.GitHTTPPrefix)
}
if cfg.FrontendDir == "" {
t.Fatalf("frontend_dir default missing")
}
}
func TestLoadFromJSONAndEnvOverride(t *testing.T) {
var dir string
var path string
var data string
var err error
var cfg Config
dir = t.TempDir()
path = filepath.Join(dir, "config.json")
data = `{"db_driver":"sqlite","db_dsn":"file:test.db","auth_mode":"HyBrId","git_http_prefix":"/g"}`
err = os.WriteFile(path, []byte(data), 0o644)
if err != nil {
t.Fatalf("write config file: %v", err)
}
t.Setenv("CODIT_DB_DSN", "file:override.db")
t.Setenv("CODIT_FRONTEND_DIR", "/srv/codit/frontend")
cfg, err = Load(path)
if err != nil {
t.Fatalf("Load() error: %v", err)
}
if cfg.DBDSN != "file:override.db" {
t.Fatalf("env override failed: %s", cfg.DBDSN)
}
if cfg.AuthMode != "hybrid" {
t.Fatalf("auth_mode normalization failed: %s", cfg.AuthMode)
}
if cfg.FrontendDir != "/srv/codit/frontend" {
t.Fatalf("frontend_dir env override failed: %s", cfg.FrontendDir)
}
}
func TestDurationUnmarshalJSON(t *testing.T) {
var d Duration
var err error
err = d.UnmarshalJSON([]byte(`"90m"`))
if err != nil {
t.Fatalf("UnmarshalJSON() string duration error: %v", err)
}
if d.Duration() != 90*time.Minute {
t.Fatalf("unexpected duration: %v", d.Duration())
}
err = d.UnmarshalJSON([]byte(`60000000000`))
if err != nil {
t.Fatalf("UnmarshalJSON() numeric duration error: %v", err)
}
if d.Duration() != time.Duration(60000000000) {
t.Fatalf("unexpected numeric duration: %v", d.Duration())
}
}

View File

@@ -14,8 +14,10 @@ type Store struct {
func Open(driver, dsn string) (*Store, error) {
var db *sql.DB
var drv string
var err error
db, err = sql.Open(driverName(driver), dsn)
drv = driverName(driver)
db, err = sql.Open(drv, dsn)
if err != nil {
return nil, err
}
@@ -23,6 +25,13 @@ func Open(driver, dsn string) (*Store, error) {
if err != nil {
return nil, err
}
if drv == "sqlite" {
_, err = db.Exec(`PRAGMA busy_timeout = 10000`)
if err != nil {
return nil, err
}
_, _ = db.Exec(`PRAGMA journal_mode = WAL`)
}
return &Store{DB: db}, nil
}

View File

@@ -0,0 +1,23 @@
package db
import "testing"
func TestDriverNameNormalization(t *testing.T) {
var got string
got = driverName("sqlite3")
if got != "sqlite" {
t.Fatalf("sqlite3 normalize failed: %s", got)
}
got = driverName(" PostgreSQL ")
if got != "postgres" {
t.Fatalf("postgres normalize failed: %s", got)
}
got = driverName("mysql")
if got != "mysql" {
t.Fatalf("mysql normalize failed: %s", got)
}
got = driverName("custom")
if got != "custom" {
t.Fatalf("custom driver should pass through: %s", got)
}
}

354
backend/internal/db/pki.go Normal file
View File

@@ -0,0 +1,354 @@
package db
import "database/sql"
import "strings"
import "time"
import "codit/internal/models"
import "codit/internal/util"
func (s *Store) ListPKICAs() ([]models.PKICA, error) {
var rows *sql.Rows
var err error
var items []models.PKICA
var item models.PKICA
rows, err = s.DB.Query(`SELECT c.public_id, c.name, COALESCE(p.public_id, ''), c.is_root, c.cert_pem, c.key_pem, c.serial_counter, c.status, c.created_at, c.updated_at
FROM pki_cas c
LEFT JOIN pki_cas p ON p.id = c.parent_ca_id
ORDER BY c.name`)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
err = rows.Scan(&item.ID, &item.Name, &item.ParentCAID, &item.IsRoot, &item.CertPEM, &item.KeyPEM, &item.SerialCounter, &item.Status, &item.CreatedAt, &item.UpdatedAt)
if err != nil {
return nil, err
}
items = append(items, item)
}
err = rows.Err()
if err != nil {
return nil, err
}
return items, nil
}
func (s *Store) GetPKICA(id string) (models.PKICA, error) {
var row *sql.Row
var item models.PKICA
var err error
row = s.DB.QueryRow(`SELECT c.public_id, c.name, COALESCE(p.public_id, ''), c.is_root, c.cert_pem, c.key_pem, c.serial_counter, c.status, c.created_at, c.updated_at
FROM pki_cas c
LEFT JOIN pki_cas p ON p.id = c.parent_ca_id
WHERE c.public_id = ?`, id)
err = row.Scan(&item.ID, &item.Name, &item.ParentCAID, &item.IsRoot, &item.CertPEM, &item.KeyPEM, &item.SerialCounter, &item.Status, &item.CreatedAt, &item.UpdatedAt)
if err != nil {
return item, err
}
return item, nil
}
func (s *Store) UpdatePKICAName(id string, name string) error {
var err error
_, err = s.DB.Exec(`UPDATE pki_cas SET name = ?, updated_at = ? WHERE public_id = ?`, name, time.Now().UTC().Unix(), id)
return err
}
func (s *Store) CreatePKICA(item models.PKICA) (models.PKICA, error) {
var id string
var now int64
var err error
if item.ID == "" {
id, err = util.NewID()
if err != nil {
return item, err
}
item.ID = id
}
if item.SerialCounter <= 0 {
item.SerialCounter = 1
}
if item.Status == "" {
item.Status = "active"
}
now = time.Now().UTC().Unix()
item.CreatedAt = now
item.UpdatedAt = now
_, err = s.DB.Exec(`INSERT INTO pki_cas (public_id, name, parent_ca_id, is_root, cert_pem, key_pem, serial_counter, status, created_at, updated_at)
VALUES (?, ?, CASE WHEN ? = '' THEN NULL ELSE (SELECT id FROM pki_cas WHERE public_id = ?) END, ?, ?, ?, ?, ?, ?, ?)`,
item.ID, item.Name, item.ParentCAID, item.ParentCAID, item.IsRoot, item.CertPEM, item.KeyPEM, item.SerialCounter, item.Status, item.CreatedAt, item.UpdatedAt)
if err != nil {
return item, err
}
return item, nil
}
func (s *Store) CountPKICAChildren(id string) (int, error) {
var row *sql.Row
var count int
var err error
row = s.DB.QueryRow(`SELECT COUNT(*)
FROM pki_cas c
JOIN pki_cas p ON p.id = c.parent_ca_id
WHERE p.public_id = ?`, id)
err = row.Scan(&count)
if err != nil {
return 0, err
}
return count, nil
}
func (s *Store) CountPKICertsByCA(id string) (int, error) {
var row *sql.Row
var count int
var err error
row = s.DB.QueryRow(`SELECT COUNT(*)
FROM pki_certs c
JOIN pki_cas ca ON ca.id = c.ca_id
WHERE ca.public_id = ?`, id)
err = row.Scan(&count)
if err != nil {
return 0, err
}
return count, nil
}
func (s *Store) DeletePKICA(id string) error {
var err error
_, err = s.DB.Exec(`DELETE FROM pki_cas WHERE public_id = ?`, id)
return err
}
func (s *Store) DeletePKICASubtree(id string) error {
var tx *sql.Tx
var rows *sql.Rows
var err error
var itemID string
var parentID string
var parentByID map[string]string
var pending []string
var current string
var i int
var j int
var target string
var toDelete []string
var contains bool
parentByID = map[string]string{}
tx, err = s.DB.Begin()
if err != nil {
return err
}
rows, err = tx.Query(`SELECT c.public_id, COALESCE(p.public_id, '')
FROM pki_cas c
LEFT JOIN pki_cas p ON p.id = c.parent_ca_id`)
if err != nil {
_ = tx.Rollback()
return err
}
defer rows.Close()
for rows.Next() {
err = rows.Scan(&itemID, &parentID)
if err != nil {
_ = tx.Rollback()
return err
}
parentByID[itemID] = parentID
}
err = rows.Err()
if err != nil {
_ = tx.Rollback()
return err
}
pending = append(pending, id)
for len(pending) > 0 {
current = pending[0]
pending = pending[1:]
contains = false
for i = 0; i < len(toDelete); i++ {
if toDelete[i] == current {
contains = true
break
}
}
if !contains {
toDelete = append(toDelete, current)
}
for target = range parentByID {
if parentByID[target] == current {
pending = append(pending, target)
}
}
}
for i = len(toDelete) - 1; i >= 0; i-- {
j = i
_ = j
_, err = tx.Exec(`DELETE FROM pki_cas WHERE public_id = ?`, toDelete[i])
if err != nil {
_ = tx.Rollback()
return err
}
}
err = tx.Commit()
if err != nil {
return err
}
return nil
}
func (s *Store) NextPKICASerial(caID string) (int64, error) {
var tx *sql.Tx
var err error
var row *sql.Row
var serial int64
tx, err = s.DB.Begin()
if err != nil {
return 0, err
}
row = tx.QueryRow(`SELECT serial_counter FROM pki_cas WHERE public_id = ?`, caID)
err = row.Scan(&serial)
if err != nil {
_ = tx.Rollback()
return 0, err
}
_, err = tx.Exec(`UPDATE pki_cas SET serial_counter = ?, updated_at = ? WHERE public_id = ?`, serial+1, time.Now().UTC().Unix(), caID)
if err != nil {
_ = tx.Rollback()
return 0, err
}
err = tx.Commit()
if err != nil {
return 0, err
}
return serial, nil
}
func (s *Store) ListPKICerts(caID string) ([]models.PKICert, error) {
var rows *sql.Rows
var err error
var items []models.PKICert
var item models.PKICert
if caID == "" {
rows, err = s.DB.Query(`SELECT c.public_id, COALESCE(ca.public_id, ''), c.serial_hex, c.common_name, c.san_dns, c.san_ips, c.is_ca, c.cert_pem, c.key_pem, c.not_before, c.not_after, c.status, c.revoked_at, c.revocation_reason, c.created_at
FROM pki_certs c
LEFT JOIN pki_cas ca ON ca.id = c.ca_id
ORDER BY c.created_at DESC`)
} else if caID == "standalone" {
rows, err = s.DB.Query(`SELECT c.public_id, COALESCE(ca.public_id, ''), c.serial_hex, c.common_name, c.san_dns, c.san_ips, c.is_ca, c.cert_pem, c.key_pem, c.not_before, c.not_after, c.status, c.revoked_at, c.revocation_reason, c.created_at
FROM pki_certs c
LEFT JOIN pki_cas ca ON ca.id = c.ca_id
WHERE c.ca_id IS NULL
ORDER BY c.created_at DESC`)
} else {
rows, err = s.DB.Query(`SELECT c.public_id, COALESCE(ca.public_id, ''), c.serial_hex, c.common_name, c.san_dns, c.san_ips, c.is_ca, c.cert_pem, c.key_pem, c.not_before, c.not_after, c.status, c.revoked_at, c.revocation_reason, c.created_at
FROM pki_certs c
LEFT JOIN pki_cas ca ON ca.id = c.ca_id
WHERE c.ca_id = (SELECT id FROM pki_cas WHERE public_id = ?)
ORDER BY c.created_at DESC`, caID)
}
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
err = rows.Scan(&item.ID, &item.CAID, &item.SerialHex, &item.CommonName, &item.SANDNS, &item.SANIPs, &item.IsCA, &item.CertPEM, &item.KeyPEM, &item.NotBefore, &item.NotAfter, &item.Status, &item.RevokedAt, &item.RevocationReason, &item.CreatedAt)
if err != nil {
return nil, err
}
items = append(items, item)
}
err = rows.Err()
if err != nil {
return nil, err
}
return items, nil
}
func (s *Store) GetPKICert(id string) (models.PKICert, error) {
var row *sql.Row
var item models.PKICert
var err error
row = s.DB.QueryRow(`SELECT c.public_id, COALESCE(ca.public_id, ''), c.serial_hex, c.common_name, c.san_dns, c.san_ips, c.is_ca, c.cert_pem, c.key_pem, c.not_before, c.not_after, c.status, c.revoked_at, c.revocation_reason, c.created_at
FROM pki_certs c
LEFT JOIN pki_cas ca ON ca.id = c.ca_id
WHERE c.public_id = ?`, id)
err = row.Scan(&item.ID, &item.CAID, &item.SerialHex, &item.CommonName, &item.SANDNS, &item.SANIPs, &item.IsCA, &item.CertPEM, &item.KeyPEM, &item.NotBefore, &item.NotAfter, &item.Status, &item.RevokedAt, &item.RevocationReason, &item.CreatedAt)
if err != nil {
return item, err
}
return item, nil
}
func (s *Store) CreatePKICert(item models.PKICert) (models.PKICert, error) {
var id string
var now int64
var err error
if item.ID == "" {
id, err = util.NewID()
if err != nil {
return item, err
}
item.ID = id
}
if item.Status == "" {
item.Status = "active"
}
now = time.Now().UTC().Unix()
item.CreatedAt = now
item.CAID = strings.TrimSpace(item.CAID)
_, err = s.DB.Exec(`INSERT INTO pki_certs (public_id, ca_id, serial_hex, common_name, san_dns, san_ips, is_ca, cert_pem, key_pem, not_before, not_after, status, revoked_at, revocation_reason, created_at)
VALUES (?, CASE WHEN ? = '' THEN NULL ELSE (SELECT id FROM pki_cas WHERE public_id = ?) END, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
item.ID, item.CAID, item.CAID, item.SerialHex, item.CommonName, item.SANDNS, item.SANIPs, item.IsCA, item.CertPEM, item.KeyPEM, item.NotBefore, item.NotAfter, item.Status, item.RevokedAt, item.RevocationReason, item.CreatedAt)
if err != nil {
return item, err
}
return item, nil
}
func (s *Store) RevokePKICert(id string, reason string) error {
var err error
_, err = s.DB.Exec(`UPDATE pki_certs SET status = 'revoked', revoked_at = ?, revocation_reason = ? WHERE public_id = ?`, time.Now().UTC().Unix(), reason, id)
return err
}
func (s *Store) DeletePKICert(id string) error {
var err error
_, err = s.DB.Exec(`DELETE FROM pki_certs WHERE public_id = ?`, id)
return err
}
func (s *Store) CountTLSServerCertReferences(certID string) (int, int, error) {
var row *sql.Row
var appCount int
var listenerCount int
var err error
row = s.DB.QueryRow(`SELECT COUNT(*) FROM app_settings WHERE key = 'tls.pki_server_cert_id' AND value = ?`, certID)
err = row.Scan(&appCount)
if err != nil {
return 0, 0, err
}
row = s.DB.QueryRow(`SELECT COUNT(*) FROM tls_listeners WHERE tls_pki_server_cert_id = ?`, certID)
err = row.Scan(&listenerCount)
if err != nil {
return 0, 0, err
}
return appCount, listenerCount, nil
}
func (s *Store) CountTLSClientCAReferences(caID string) (int, int, error) {
var row *sql.Row
var appCount int
var listenerCount int
var err error
row = s.DB.QueryRow(`SELECT COUNT(*) FROM app_settings WHERE key = 'tls.pki_client_ca_id' AND value = ?`, caID)
err = row.Scan(&appCount)
if err != nil {
return 0, 0, err
}
row = s.DB.QueryRow(`SELECT COUNT(*) FROM tls_listeners WHERE tls_pki_client_ca_id = ?`, caID)
err = row.Scan(&listenerCount)
if err != nil {
return 0, 0, err
}
return appCount, listenerCount, nil
}

View File

@@ -0,0 +1,216 @@
package db
import "database/sql"
import "strings"
import "time"
import "codit/internal/models"
import "codit/internal/util"
func (s *Store) ListServicePrincipals() ([]models.ServicePrincipal, error) {
var rows *sql.Rows
var items []models.ServicePrincipal
var item models.ServicePrincipal
var err error
rows, err = s.DB.Query(`SELECT public_id, name, description, is_admin, disabled, created_at, updated_at FROM service_principals ORDER BY name`)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
err = rows.Scan(&item.ID, &item.Name, &item.Description, &item.IsAdmin, &item.Disabled, &item.CreatedAt, &item.UpdatedAt)
if err != nil {
return nil, err
}
items = append(items, item)
}
err = rows.Err()
if err != nil {
return nil, err
}
return items, nil
}
func (s *Store) GetServicePrincipal(id string) (models.ServicePrincipal, error) {
var row *sql.Row
var item models.ServicePrincipal
var err error
row = s.DB.QueryRow(`SELECT public_id, name, description, is_admin, disabled, created_at, updated_at FROM service_principals WHERE public_id = ?`, id)
err = row.Scan(&item.ID, &item.Name, &item.Description, &item.IsAdmin, &item.Disabled, &item.CreatedAt, &item.UpdatedAt)
return item, err
}
func (s *Store) CreateServicePrincipal(item models.ServicePrincipal) (models.ServicePrincipal, error) {
var id string
var now int64
var err error
if item.ID == "" {
id, err = util.NewID()
if err != nil {
return item, err
}
item.ID = id
}
now = time.Now().UTC().Unix()
item.CreatedAt = now
item.UpdatedAt = now
_, err = s.DB.Exec(`INSERT INTO service_principals (public_id, name, description, is_admin, disabled, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)`,
item.ID, item.Name, item.Description, item.IsAdmin, item.Disabled, item.CreatedAt, item.UpdatedAt)
if err != nil {
return item, err
}
return item, nil
}
func (s *Store) UpdateServicePrincipal(item models.ServicePrincipal) error {
var now int64
var err error
now = time.Now().UTC().Unix()
item.UpdatedAt = now
_, err = s.DB.Exec(`UPDATE service_principals SET name = ?, description = ?, is_admin = ?, disabled = ?, updated_at = ? WHERE public_id = ?`,
item.Name, item.Description, item.IsAdmin, item.Disabled, item.UpdatedAt, item.ID)
return err
}
func (s *Store) DeleteServicePrincipal(id string) error {
var err error
_, err = s.DB.Exec(`DELETE FROM service_principals WHERE public_id = ?`, id)
return err
}
func (s *Store) ListCertPrincipalBindings() ([]models.CertPrincipalBinding, error) {
var rows *sql.Rows
var items []models.CertPrincipalBinding
var item models.CertPrincipalBinding
var err error
rows, err = s.DB.Query(`SELECT b.fingerprint, p.public_id, b.enabled, b.created_at, b.updated_at
FROM cert_principal_bindings b
JOIN service_principals p ON p.id = b.principal_id
ORDER BY b.fingerprint`)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
err = rows.Scan(&item.Fingerprint, &item.PrincipalID, &item.Enabled, &item.CreatedAt, &item.UpdatedAt)
if err != nil {
return nil, err
}
items = append(items, item)
}
err = rows.Err()
if err != nil {
return nil, err
}
return items, nil
}
func (s *Store) UpsertCertPrincipalBinding(item models.CertPrincipalBinding) (models.CertPrincipalBinding, error) {
var now int64
var err error
now = time.Now().UTC().Unix()
item.Fingerprint = strings.ToLower(strings.TrimSpace(item.Fingerprint))
item.UpdatedAt = now
_, err = s.DB.Exec(`INSERT INTO cert_principal_bindings (fingerprint, principal_id, enabled, created_at, updated_at)
VALUES (?, (SELECT id FROM service_principals WHERE public_id = ?), ?, ?, ?)
ON CONFLICT(fingerprint) DO UPDATE SET principal_id = excluded.principal_id, enabled = excluded.enabled, updated_at = excluded.updated_at`,
item.Fingerprint, item.PrincipalID, item.Enabled, now, now)
if err != nil {
return item, err
}
item.CreatedAt = now
return item, nil
}
func (s *Store) DeleteCertPrincipalBinding(fingerprint string) error {
var err error
fingerprint = strings.ToLower(strings.TrimSpace(fingerprint))
_, err = s.DB.Exec(`DELETE FROM cert_principal_bindings WHERE fingerprint = ?`, fingerprint)
return err
}
func (s *Store) GetPrincipalByCertFingerprint(fingerprint string) (models.ServicePrincipal, bool, error) {
var row *sql.Row
var item models.ServicePrincipal
var enabled bool
var err error
fingerprint = strings.ToLower(strings.TrimSpace(fingerprint))
row = s.DB.QueryRow(`SELECT p.public_id, p.name, p.description, p.is_admin, p.disabled, p.created_at, p.updated_at, b.enabled
FROM cert_principal_bindings b
INNER JOIN service_principals p ON p.id = b.principal_id
WHERE b.fingerprint = ?`, fingerprint)
err = row.Scan(&item.ID, &item.Name, &item.Description, &item.IsAdmin, &item.Disabled, &item.CreatedAt, &item.UpdatedAt, &enabled)
if err != nil {
if err == sql.ErrNoRows {
return item, false, nil
}
return item, false, err
}
if item.Disabled || !enabled {
return item, false, nil
}
return item, true, nil
}
func (s *Store) ListPrincipalProjectRoles(principalID string) ([]models.PrincipalProjectRole, error) {
var rows *sql.Rows
var items []models.PrincipalProjectRole
var item models.PrincipalProjectRole
var err error
rows, err = s.DB.Query(`SELECT sp.public_id, p.public_id, r.role, r.created_at
FROM principal_project_roles r
JOIN service_principals sp ON sp.id = r.principal_id
JOIN projects p ON p.id = r.project_id
WHERE r.principal_id = (SELECT id FROM service_principals WHERE public_id = ?)
ORDER BY p.public_id`, principalID)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
err = rows.Scan(&item.PrincipalID, &item.ProjectID, &item.Role, &item.CreatedAt)
if err != nil {
return nil, err
}
items = append(items, item)
}
err = rows.Err()
if err != nil {
return nil, err
}
return items, nil
}
func (s *Store) UpsertPrincipalProjectRole(item models.PrincipalProjectRole) (models.PrincipalProjectRole, error) {
var now int64
var err error
now = time.Now().UTC().Unix()
item.CreatedAt = now
_, err = s.DB.Exec(`INSERT INTO principal_project_roles (principal_id, project_id, role, created_at)
VALUES ((SELECT id FROM service_principals WHERE public_id = ?), (SELECT id FROM projects WHERE public_id = ?), ?, ?)
ON CONFLICT(principal_id, project_id) DO UPDATE SET role = excluded.role`,
item.PrincipalID, item.ProjectID, item.Role, item.CreatedAt)
if err != nil {
return item, err
}
return item, nil
}
func (s *Store) DeletePrincipalProjectRole(principalID string, projectID string) error {
var err error
_, err = s.DB.Exec(`DELETE FROM principal_project_roles
WHERE principal_id = (SELECT id FROM service_principals WHERE public_id = ?)
AND project_id = (SELECT id FROM projects WHERE public_id = ?)`, principalID, projectID)
return err
}
func (s *Store) GetPrincipalProjectRole(principalID string, projectID string) (string, error) {
var row *sql.Row
var role string
var err error
row = s.DB.QueryRow(`SELECT role FROM principal_project_roles
WHERE principal_id = (SELECT id FROM service_principals WHERE public_id = ?)
AND project_id = (SELECT id FROM projects WHERE public_id = ?)`, principalID, projectID)
err = row.Scan(&role)
return role, err
}

View File

@@ -0,0 +1,397 @@
package db
import "database/sql"
import "strings"
import "time"
import "codit/internal/models"
import "codit/internal/util"
func (s *Store) ListRPMRepoDirs(repoID string) ([]models.RPMRepoDir, error) {
var rows *sql.Rows
var items []models.RPMRepoDir
var item models.RPMRepoDir
var err error
rows, err = s.DB.Query(`SELECT r.public_id, d.path, d.mode, d.allow_delete, d.remote_url, d.connect_host, d.host_header, d.tls_server_name, d.tls_insecure_skip_verify, d.sync_interval_sec, d.sync_enabled, d.dirty, d.next_sync_at, d.sync_running, d.sync_status, d.sync_error, d.sync_step, d.sync_total, d.sync_done, d.sync_failed, d.sync_deleted, d.last_sync_started_at, d.last_sync_finished_at, d.last_sync_success_at, d.last_synced_revision, d.created_at, d.updated_at
FROM rpm_repo_dirs d
JOIN repos r ON r.id = d.repo_id
WHERE r.public_id = ?
ORDER BY LENGTH(d.path), d.path`, repoID)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
err = rows.Scan(&item.RepoID, &item.Path, &item.Mode, &item.AllowDelete, &item.RemoteURL, &item.ConnectHost, &item.HostHeader, &item.TLSServerName, &item.TLSInsecureSkipVerify, &item.SyncIntervalSec, &item.SyncEnabled, &item.Dirty, &item.NextSyncAt, &item.SyncRunning, &item.SyncStatus, &item.SyncError, &item.SyncStep, &item.SyncTotal, &item.SyncDone, &item.SyncFailed, &item.SyncDeleted, &item.LastSyncStartedAt, &item.LastSyncFinishedAt, &item.LastSyncSuccessAt, &item.LastSyncedRevision, &item.CreatedAt, &item.UpdatedAt)
if err != nil {
return nil, err
}
items = append(items, item)
}
err = rows.Err()
if err != nil {
return nil, err
}
return items, nil
}
func (s *Store) UpsertRPMRepoDir(item models.RPMRepoDir) error {
var now int64
var err error
now = time.Now().UTC().Unix()
if item.SyncIntervalSec <= 0 {
item.SyncIntervalSec = 300
}
_, err = s.DB.Exec(`
INSERT INTO rpm_repo_dirs (repo_id, path, mode, allow_delete, remote_url, connect_host, host_header, tls_server_name, tls_insecure_skip_verify, sync_interval_sec, sync_enabled, dirty, next_sync_at, created_at, updated_at)
VALUES ((SELECT id FROM repos WHERE public_id = ?), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(repo_id, path) DO UPDATE SET
mode = excluded.mode,
allow_delete = excluded.allow_delete,
remote_url = excluded.remote_url,
connect_host = excluded.connect_host,
host_header = excluded.host_header,
tls_server_name = excluded.tls_server_name,
tls_insecure_skip_verify = excluded.tls_insecure_skip_verify,
sync_interval_sec = excluded.sync_interval_sec,
sync_enabled = CASE WHEN excluded.mode = 'mirror' THEN excluded.sync_enabled ELSE 1 END,
dirty = CASE WHEN excluded.mode = 'mirror' THEN 1 ELSE rpm_repo_dirs.dirty END,
next_sync_at = CASE WHEN excluded.mode = 'mirror' THEN 0 ELSE rpm_repo_dirs.next_sync_at END,
updated_at = excluded.updated_at
`,
item.RepoID,
item.Path,
item.Mode,
item.AllowDelete,
item.RemoteURL,
item.ConnectHost,
item.HostHeader,
item.TLSServerName,
item.TLSInsecureSkipVerify,
item.SyncIntervalSec,
item.SyncEnabled,
normalizeRPMRepoMode(item.Mode) == "mirror",
int64(0),
now,
now)
return err
}
func (s *Store) GetRPMRepoDir(repoID string, path string) (models.RPMRepoDir, error) {
var row *sql.Row
var item models.RPMRepoDir
var err error
row = s.DB.QueryRow(`SELECT r.public_id, d.path, d.mode, d.allow_delete, d.remote_url, d.connect_host, d.host_header, d.tls_server_name, d.tls_insecure_skip_verify, d.sync_interval_sec, d.sync_enabled, d.dirty, d.next_sync_at, d.sync_running, d.sync_status, d.sync_error, d.sync_step, d.sync_total, d.sync_done, d.sync_failed, d.sync_deleted, d.last_sync_started_at, d.last_sync_finished_at, d.last_sync_success_at, d.last_synced_revision, d.created_at, d.updated_at
FROM rpm_repo_dirs d
JOIN repos r ON r.id = d.repo_id
WHERE r.public_id = ? AND d.path = ?`, repoID, path)
err = row.Scan(&item.RepoID, &item.Path, &item.Mode, &item.AllowDelete, &item.RemoteURL, &item.ConnectHost, &item.HostHeader, &item.TLSServerName, &item.TLSInsecureSkipVerify, &item.SyncIntervalSec, &item.SyncEnabled, &item.Dirty, &item.NextSyncAt, &item.SyncRunning, &item.SyncStatus, &item.SyncError, &item.SyncStep, &item.SyncTotal, &item.SyncDone, &item.SyncFailed, &item.SyncDeleted, &item.LastSyncStartedAt, &item.LastSyncFinishedAt, &item.LastSyncSuccessAt, &item.LastSyncedRevision, &item.CreatedAt, &item.UpdatedAt)
if err != nil {
return item, err
}
return item, nil
}
func (s *Store) ListDueRPMMirrorTasks(now int64, limit int) ([]models.RPMMirrorTask, error) {
var rows *sql.Rows
var out []models.RPMMirrorTask
var item models.RPMMirrorTask
var err error
if limit <= 0 {
limit = 10
}
rows, err = s.DB.Query(`
SELECT r.public_id, r.path, d.path, d.remote_url, d.connect_host, d.host_header, d.tls_server_name, d.tls_insecure_skip_verify, d.sync_interval_sec, d.dirty, d.last_synced_revision
FROM rpm_repo_dirs d
JOIN repos r ON r.id = d.repo_id
WHERE d.mode = 'mirror' AND d.sync_enabled = 1 AND d.sync_running = 0 AND (d.dirty = 1 OR d.next_sync_at <= ? OR d.next_sync_at = 0)
ORDER BY d.next_sync_at, d.updated_at
LIMIT ?`, now, limit)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
err = rows.Scan(&item.RepoID, &item.RepoPath, &item.MirrorPath, &item.RemoteURL, &item.ConnectHost, &item.HostHeader, &item.TLSServerName, &item.TLSInsecureSkipVerify, &item.SyncIntervalSec, &item.Dirty, &item.LastSyncedRevision)
if err != nil {
return nil, err
}
out = append(out, item)
}
err = rows.Err()
if err != nil {
return nil, err
}
return out, nil
}
func (s *Store) TryStartRPMMirrorTask(repoID string, path string, now int64) (bool, error) {
var res sql.Result
var rows int64
var err error
res, err = s.DB.Exec(`UPDATE rpm_repo_dirs SET sync_running = 1, sync_status = 'running', sync_error = '', sync_step = 'start', sync_total = 0, sync_done = 0, sync_failed = 0, sync_deleted = 0, last_sync_started_at = ?, updated_at = ? WHERE repo_id = (SELECT id FROM repos WHERE public_id = ?) AND path = ? AND mode = 'mirror' AND sync_enabled = 1 AND sync_running = 0`, now, now, repoID, path)
if err != nil {
return false, err
}
rows, err = res.RowsAffected()
if err != nil {
return false, err
}
return rows > 0, nil
}
func (s *Store) UpdateRPMMirrorTaskProgress(repoID string, path string, step string, total int64, done int64, failed int64, deleted int64) error {
var now int64
var err error
now = time.Now().UTC().Unix()
_, err = s.DB.Exec(`UPDATE rpm_repo_dirs SET sync_step = ?, sync_total = ?, sync_done = ?, sync_failed = ?, sync_deleted = ?, updated_at = ? WHERE repo_id = (SELECT id FROM repos WHERE public_id = ?) AND path = ?`, step, total, done, failed, deleted, now, repoID, path)
return err
}
func (s *Store) FinishRPMMirrorTask(repoID string, path string, success bool, revision string, errMsg string) error {
var now int64
var status string
var nextSync int64
var interval int64
var row *sql.Row
var err error
row = s.DB.QueryRow(`SELECT sync_interval_sec FROM rpm_repo_dirs WHERE repo_id = (SELECT id FROM repos WHERE public_id = ?) AND path = ?`, repoID, path)
err = row.Scan(&interval)
if err != nil {
return err
}
if interval <= 0 {
interval = 300
}
now = time.Now().UTC().Unix()
nextSync = now + interval
if success {
status = "success"
_, err = s.DB.Exec(`UPDATE rpm_repo_dirs SET sync_running = 0, dirty = 0, next_sync_at = ?, sync_status = ?, sync_error = '', sync_step = 'idle', last_sync_finished_at = ?, last_sync_success_at = ?, last_synced_revision = ?, updated_at = ? WHERE repo_id = (SELECT id FROM repos WHERE public_id = ?) AND path = ?`, nextSync, status, now, now, revision, now, repoID, path)
return err
}
status = "failed"
if errMsg == "" {
errMsg = "mirror sync failed"
}
_, err = s.DB.Exec(`UPDATE rpm_repo_dirs SET sync_running = 0, dirty = 1, next_sync_at = ?, sync_status = ?, sync_error = ?, sync_step = 'idle', last_sync_finished_at = ?, updated_at = ? WHERE repo_id = (SELECT id FROM repos WHERE public_id = ?) AND path = ?`, now+30, status, errMsg, now, now, repoID, path)
return err
}
func (s *Store) MarkRPMMirrorTaskDirty(repoID string, path string) error {
var now int64
var err error
now = time.Now().UTC().Unix()
_, err = s.DB.Exec(`UPDATE rpm_repo_dirs SET dirty = 1, next_sync_at = ?, updated_at = ? WHERE repo_id = (SELECT id FROM repos WHERE public_id = ?) AND path = ?`, now, now, repoID, path)
return err
}
func (s *Store) SetRPMMirrorSyncEnabled(repoID string, path string, enabled bool) error {
var now int64
var err error
now = time.Now().UTC().Unix()
_, err = s.DB.Exec(`UPDATE rpm_repo_dirs SET sync_enabled = ?, dirty = CASE WHEN ? THEN 1 ELSE dirty END, next_sync_at = CASE WHEN ? THEN ? ELSE next_sync_at END, updated_at = ? WHERE repo_id = (SELECT id FROM repos WHERE public_id = ?) AND path = ?`,
enabled, enabled, enabled, now, now, repoID, path)
return err
}
func (s *Store) ResetRunningRPMMirrorTasks() error {
var now int64
var err error
now = time.Now().UTC().Unix()
_, err = s.DB.Exec(`UPDATE rpm_repo_dirs SET sync_running = 0, dirty = 1, next_sync_at = ?, sync_status = 'failed', sync_error = 'aborted by restart', sync_step = 'idle', last_sync_finished_at = ?, updated_at = ? WHERE mode = 'mirror' AND sync_running = 1`, now+5, now, now)
return err
}
func (s *Store) ListRPMMirrorPaths() ([]models.RPMMirrorTask, error) {
var rows *sql.Rows
var out []models.RPMMirrorTask
var item models.RPMMirrorTask
var err error
rows, err = s.DB.Query(`
SELECT r.public_id, r.path, d.path
FROM rpm_repo_dirs d
JOIN repos r ON r.id = d.repo_id
WHERE d.mode = 'mirror'`)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
err = rows.Scan(&item.RepoID, &item.RepoPath, &item.MirrorPath)
if err != nil {
return nil, err
}
out = append(out, item)
}
err = rows.Err()
if err != nil {
return nil, err
}
return out, nil
}
func (s *Store) HasRunningRPMMirrorTask(repoID string) (bool, error) {
var row *sql.Row
var count int64
var err error
row = s.DB.QueryRow(`SELECT COUNT(1) FROM rpm_repo_dirs WHERE repo_id = (SELECT id FROM repos WHERE public_id = ?) AND mode = 'mirror' AND sync_running = 1`, repoID)
err = row.Scan(&count)
if err != nil {
return false, err
}
return count > 0, nil
}
func (s *Store) CreateRPMMirrorRun(repoID string, path string, startedAt int64) (string, error) {
var id string
var err error
id, err = util.NewID()
if err != nil {
return "", err
}
_, err = s.DB.Exec(`INSERT INTO rpm_mirror_runs (public_id, repo_id, path, started_at, status) VALUES (?, (SELECT id FROM repos WHERE public_id = ?), ?, ?, 'running')`, id, repoID, path, startedAt)
if err != nil {
return "", err
}
return id, nil
}
func (s *Store) FinishRPMMirrorRun(id string, finishedAt int64, status string, step string, total int64, done int64, failed int64, deleted int64, revision string, errMsg string) error {
var err error
_, err = s.DB.Exec(`UPDATE rpm_mirror_runs SET finished_at = ?, status = ?, step = ?, total = ?, done = ?, failed = ?, deleted = ?, revision = ?, error = ? WHERE public_id = ?`,
finishedAt, status, step, total, done, failed, deleted, revision, errMsg, id)
return err
}
func (s *Store) ListRPMMirrorRuns(repoID string, path string, limit int) ([]models.RPMMirrorRun, error) {
var rows *sql.Rows
var out []models.RPMMirrorRun
var item models.RPMMirrorRun
var err error
if limit <= 0 {
limit = 20
}
rows, err = s.DB.Query(`SELECT m.public_id, r.public_id, m.path, m.started_at, m.finished_at, m.status, m.step, m.total, m.done, m.failed, m.deleted, m.revision, m.error
FROM rpm_mirror_runs m
JOIN repos r ON r.id = m.repo_id
WHERE r.public_id = ? AND m.path = ?
ORDER BY m.started_at DESC
LIMIT ?`, repoID, path, limit)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
err = rows.Scan(&item.ID, &item.RepoID, &item.Path, &item.StartedAt, &item.FinishedAt, &item.Status, &item.Step, &item.Total, &item.Done, &item.Failed, &item.Deleted, &item.Revision, &item.Error)
if err != nil {
return nil, err
}
out = append(out, item)
}
err = rows.Err()
if err != nil {
return nil, err
}
return out, nil
}
func (s *Store) DeleteRPMMirrorRuns(repoID string, path string) (int64, error) {
var res sql.Result
var count int64
var err error
res, err = s.DB.Exec(`DELETE FROM rpm_mirror_runs WHERE repo_id = (SELECT id FROM repos WHERE public_id = ?) AND path = ?`, repoID, path)
if err != nil {
return 0, err
}
count, err = res.RowsAffected()
if err != nil {
return 0, err
}
return count, nil
}
func (s *Store) CleanupRPMMirrorRunsRetention(repoID string, path string, keepCount int, keepDays int) error {
var cutoff int64
var now int64
var err error
if keepCount <= 0 {
keepCount = 200
}
if keepDays <= 0 {
keepDays = 30
}
now = time.Now().UTC().Unix()
cutoff = now - int64(keepDays*24*60*60)
_, err = s.DB.Exec(`
DELETE FROM rpm_mirror_runs
WHERE repo_id = (SELECT id FROM repos WHERE public_id = ?)
AND path = ?
AND started_at < ?
AND id NOT IN (
SELECT id FROM rpm_mirror_runs
WHERE repo_id = (SELECT id FROM repos WHERE public_id = ?) AND path = ?
ORDER BY started_at DESC
LIMIT ?
)
`, repoID, path, cutoff, repoID, path, keepCount)
return err
}
func normalizeRPMRepoMode(mode string) string {
var v string
v = strings.ToLower(strings.TrimSpace(mode))
if v == "mirror" {
return "mirror"
}
return "local"
}
func (s *Store) DeleteRPMRepoDir(repoID string, path string) error {
var err error
_, err = s.DB.Exec(`DELETE FROM rpm_repo_dirs WHERE repo_id = (SELECT id FROM repos WHERE public_id = ?) AND path = ?`, repoID, path)
return err
}
func (s *Store) DeleteRPMRepoDirSubtree(repoID string, path string) error {
var prefix string
var err error
prefix = path + "/"
_, err = s.DB.Exec(`DELETE FROM rpm_repo_dirs WHERE repo_id = (SELECT id FROM repos WHERE public_id = ?) AND (path = ? OR path LIKE (? || '%'))`, repoID, path, prefix)
return err
}
func (s *Store) MoveRPMRepoDir(repoID string, oldPath string, newPath string) error {
var tx *sql.Tx
var now int64
var oldPrefix string
var newPrefix string
var err error
tx, err = s.DB.Begin()
if err != nil {
return err
}
now = time.Now().UTC().Unix()
oldPrefix = oldPath + "/"
newPrefix = newPath + "/"
_, err = tx.Exec(`DELETE FROM rpm_mirror_runs WHERE repo_id = (SELECT id FROM repos WHERE public_id = ?) AND (path = ? OR path LIKE (? || '%'))`, repoID, oldPath, oldPrefix)
if err != nil {
_ = tx.Rollback()
return err
}
_, err = tx.Exec(`UPDATE rpm_repo_dirs SET path = ?, updated_at = ? WHERE repo_id = (SELECT id FROM repos WHERE public_id = ?) AND path = ?`, newPath, now, repoID, oldPath)
if err != nil {
_ = tx.Rollback()
return err
}
_, err = tx.Exec(`UPDATE rpm_repo_dirs SET path = (? || SUBSTR(path, ?)), updated_at = ? WHERE repo_id = (SELECT id FROM repos WHERE public_id = ?) AND path LIKE (? || '%')`, newPrefix, len(oldPrefix)+1, now, repoID, oldPrefix)
if err != nil {
_ = tx.Rollback()
return err
}
err = tx.Commit()
if err != nil {
return err
}
return nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,94 @@
package db
import "database/sql"
import "strings"
import "time"
import "codit/internal/models"
import "codit/internal/util"
func (s *Store) ListTLSListeners() ([]models.TLSListener, error) {
var rows *sql.Rows
var err error
var items []models.TLSListener
var item models.TLSListener
var httpAddrs string
var httpsAddrs string
var certAllowlist string
rows, err = s.DB.Query(`SELECT l.public_id, l.name, l.enabled, l.http_addrs, l.https_addrs, l.auth_policy, l.apply_policy_api, l.apply_policy_git, l.apply_policy_rpm, l.apply_policy_v2, l.client_cert_allowlist, l.tls_server_cert_source, l.tls_cert_file, l.tls_key_file, l.tls_pki_server_cert_id, l.tls_client_auth, l.tls_client_ca_file, l.tls_pki_client_ca_id, l.tls_min_version, l.created_at, l.updated_at FROM tls_listeners l ORDER BY l.name`)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
err = rows.Scan(&item.ID, &item.Name, &item.Enabled, &httpAddrs, &httpsAddrs, &item.AuthPolicy, &item.ApplyPolicyAPI, &item.ApplyPolicyGit, &item.ApplyPolicyRPM, &item.ApplyPolicyV2, &certAllowlist, &item.TLSServerCertSource, &item.TLSCertFile, &item.TLSKeyFile, &item.TLSPKIServerCertID, &item.TLSClientAuth, &item.TLSClientCAFile, &item.TLSPKIClientCAID, &item.TLSMinVersion, &item.CreatedAt, &item.UpdatedAt)
if err != nil {
return nil, err
}
item.HTTPAddrs = splitCSVValue(httpAddrs)
item.HTTPSAddrs = splitCSVValue(httpsAddrs)
item.ClientCertAllowlist = splitCSVValue(certAllowlist)
items = append(items, item)
}
err = rows.Err()
if err != nil {
return nil, err
}
return items, nil
}
func (s *Store) GetTLSListener(id string) (models.TLSListener, error) {
var row *sql.Row
var item models.TLSListener
var httpAddrs string
var httpsAddrs string
var certAllowlist string
var err error
row = s.DB.QueryRow(`SELECT l.public_id, l.name, l.enabled, l.http_addrs, l.https_addrs, l.auth_policy, l.apply_policy_api, l.apply_policy_git, l.apply_policy_rpm, l.apply_policy_v2, l.client_cert_allowlist, l.tls_server_cert_source, l.tls_cert_file, l.tls_key_file, l.tls_pki_server_cert_id, l.tls_client_auth, l.tls_client_ca_file, l.tls_pki_client_ca_id, l.tls_min_version, l.created_at, l.updated_at FROM tls_listeners l WHERE l.public_id = ?`, id)
err = row.Scan(&item.ID, &item.Name, &item.Enabled, &httpAddrs, &httpsAddrs, &item.AuthPolicy, &item.ApplyPolicyAPI, &item.ApplyPolicyGit, &item.ApplyPolicyRPM, &item.ApplyPolicyV2, &certAllowlist, &item.TLSServerCertSource, &item.TLSCertFile, &item.TLSKeyFile, &item.TLSPKIServerCertID, &item.TLSClientAuth, &item.TLSClientCAFile, &item.TLSPKIClientCAID, &item.TLSMinVersion, &item.CreatedAt, &item.UpdatedAt)
if err != nil {
return item, err
}
item.HTTPAddrs = splitCSVValue(httpAddrs)
item.HTTPSAddrs = splitCSVValue(httpsAddrs)
item.ClientCertAllowlist = splitCSVValue(certAllowlist)
return item, nil
}
func (s *Store) CreateTLSListener(item models.TLSListener) (models.TLSListener, error) {
var id string
var now int64
var err error
if item.ID == "" {
id, err = util.NewID()
if err != nil {
return item, err
}
item.ID = id
}
now = time.Now().UTC().Unix()
item.CreatedAt = now
item.UpdatedAt = now
_, err = s.DB.Exec(`INSERT INTO tls_listeners (public_id, name, enabled, http_addrs, https_addrs, auth_policy, apply_policy_api, apply_policy_git, apply_policy_rpm, apply_policy_v2, client_cert_allowlist, tls_server_cert_source, tls_cert_file, tls_key_file, tls_pki_server_cert_id, tls_client_auth, tls_client_ca_file, tls_pki_client_ca_id, tls_min_version, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
item.ID, item.Name, item.Enabled, strings.Join(item.HTTPAddrs, ","), strings.Join(item.HTTPSAddrs, ","), item.AuthPolicy, item.ApplyPolicyAPI, item.ApplyPolicyGit, item.ApplyPolicyRPM, item.ApplyPolicyV2, strings.Join(item.ClientCertAllowlist, ","), item.TLSServerCertSource, item.TLSCertFile, item.TLSKeyFile, item.TLSPKIServerCertID, item.TLSClientAuth, item.TLSClientCAFile, item.TLSPKIClientCAID, item.TLSMinVersion, item.CreatedAt, item.UpdatedAt)
if err != nil {
return item, err
}
return item, nil
}
func (s *Store) UpdateTLSListener(item models.TLSListener) error {
var err error
var now int64
now = time.Now().UTC().Unix()
item.UpdatedAt = now
_, err = s.DB.Exec(`UPDATE tls_listeners SET name = ?, enabled = ?, http_addrs = ?, https_addrs = ?, auth_policy = ?, apply_policy_api = ?, apply_policy_git = ?, apply_policy_rpm = ?, apply_policy_v2 = ?, client_cert_allowlist = ?, tls_server_cert_source = ?, tls_cert_file = ?, tls_key_file = ?, tls_pki_server_cert_id = ?, tls_client_auth = ?, tls_client_ca_file = ?, tls_pki_client_ca_id = ?, tls_min_version = ?, updated_at = ? WHERE public_id = ?`,
item.Name, item.Enabled, strings.Join(item.HTTPAddrs, ","), strings.Join(item.HTTPSAddrs, ","), item.AuthPolicy, item.ApplyPolicyAPI, item.ApplyPolicyGit, item.ApplyPolicyRPM, item.ApplyPolicyV2, strings.Join(item.ClientCertAllowlist, ","), item.TLSServerCertSource, item.TLSCertFile, item.TLSKeyFile, item.TLSPKIServerCertID, item.TLSClientAuth, item.TLSClientCAFile, item.TLSPKIClientCAID, item.TLSMinVersion, item.UpdatedAt, item.ID)
return err
}
func (s *Store) DeleteTLSListener(id string) error {
var err error
_, err = s.DB.Exec(`DELETE FROM tls_listeners WHERE public_id = ?`, id)
return err
}

View File

@@ -0,0 +1,287 @@
package docker
import "encoding/json"
import "errors"
import "io/fs"
import "os"
import "path/filepath"
import "strings"
type TagInfo struct {
Tag string `json:"tag"`
Digest string `json:"digest"`
Size int64 `json:"size"`
MediaType string `json:"media_type"`
}
type ManifestDetail struct {
Reference string `json:"reference"`
Digest string `json:"digest"`
MediaType string `json:"media_type"`
Size int64 `json:"size"`
Config ImageConfig `json:"config"`
Layers []ociDescriptor `json:"layers"`
}
type ImageConfig struct {
Created string `json:"created"`
Architecture string `json:"architecture"`
OS string `json:"os"`
}
type manifestPayload struct {
SchemaVersion int `json:"schemaVersion"`
MediaType string `json:"mediaType"`
Config ociDescriptor `json:"config"`
Layers []ociDescriptor `json:"layers"`
}
func ListTags(repoPath string) ([]TagInfo, error) {
var tags []string
var err error
var list []TagInfo
var i int
var tag string
var desc ociDescriptor
tags, err = listTags(repoPath)
if err != nil {
return nil, err
}
list = make([]TagInfo, 0, len(tags))
for i = 0; i < len(tags); i++ {
tag = tags[i]
desc, err = resolveTag(repoPath, tag)
if err != nil {
continue
}
list = append(list, TagInfo{
Tag: tag,
Digest: desc.Digest,
Size: desc.Size,
MediaType: desc.MediaType,
})
}
return list, nil
}
func DeleteTag(repoPath string, tag string) error {
var idx ociIndex
var err error
var updated []ociDescriptor
var i int
var desc ociDescriptor
var keep bool
idx, err = loadIndex(repoPath)
if err != nil {
return err
}
updated = make([]ociDescriptor, 0, len(idx.Manifests))
for i = 0; i < len(idx.Manifests); i++ {
desc = idx.Manifests[i]
keep = true
if desc.Annotations != nil && desc.Annotations[ociTagAnnotation] == tag {
keep = false
}
if keep {
updated = append(updated, desc)
}
}
idx.Manifests = updated
return saveIndex(repoPath, idx)
}
func DeleteImage(repoPath string, image string) error {
var imagePath string
imagePath = ImagePath(repoPath, image)
return os.RemoveAll(imagePath)
}
func RenameTag(repoPath string, from string, to string) error {
var idx ociIndex
var err error
var i int
var desc ociDescriptor
var hasFrom bool
var hasTo bool
if from == "" || to == "" {
return errors.New("tag required")
}
if from == to {
return errors.New("tag unchanged")
}
idx, err = loadIndex(repoPath)
if err != nil {
return err
}
for i = 0; i < len(idx.Manifests); i++ {
desc = idx.Manifests[i]
if desc.Annotations == nil {
continue
}
if desc.Annotations[ociTagAnnotation] == to {
hasTo = true
}
}
if hasTo {
return errors.New("tag already exists")
}
for i = 0; i < len(idx.Manifests); i++ {
desc = idx.Manifests[i]
if desc.Annotations == nil {
continue
}
if desc.Annotations[ociTagAnnotation] == from {
desc.Annotations[ociTagAnnotation] = to
idx.Manifests[i] = desc
hasFrom = true
break
}
}
if !hasFrom {
return ErrNotFound
}
return saveIndex(repoPath, idx)
}
func RenameImage(repoPath string, from string, to string) error {
var srcPath string
var destPath string
var err error
var info os.FileInfo
var dir string
if from == to {
return errors.New("image unchanged")
}
if IsReservedImagePath(to) {
return errors.New("invalid image name")
}
srcPath = ImagePath(repoPath, from)
destPath = ImagePath(repoPath, to)
info, err = os.Stat(srcPath)
if err != nil {
if os.IsNotExist(err) {
return ErrNotFound
}
return err
}
if info == nil || !info.IsDir() {
return errors.New("source is not a directory")
}
_, err = os.Stat(destPath)
if err == nil {
return errors.New("target already exists")
}
if err != nil && !os.IsNotExist(err) {
return err
}
dir = filepath.Dir(destPath)
err = os.MkdirAll(dir, 0o755)
if err != nil {
return err
}
return os.Rename(srcPath, destPath)
}
func ListImages(repoPath string) ([]string, error) {
var images []string
var err error
var seen map[string]struct{}
var root string
var ok bool
var dummy struct{}
seen = map[string]struct{}{}
root = filepath.Clean(repoPath)
err = filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error {
var base string
var rel string
var dir string
if walkErr != nil {
return walkErr
}
if entry.IsDir() {
base = entry.Name()
if base == "blobs" || base == "uploads" {
return filepath.SkipDir
}
return nil
}
if entry.Name() != "oci-layout" {
return nil
}
dir = filepath.Dir(path)
rel, err = filepath.Rel(root, dir)
if err != nil {
return err
}
if rel == "." {
rel = ""
}
rel = filepath.ToSlash(rel)
if rel == ".root" {
rel = ""
}
dummy, ok = seen[rel]
_ = dummy
if !ok {
seen[rel] = struct{}{}
images = append(images, rel)
}
return nil
})
if err != nil && !os.IsNotExist(err) {
return nil, err
}
return images, nil
}
func ImagePath(repoPath string, image string) string {
var cleaned string
var normalized string
cleaned = strings.Trim(image, "/")
if cleaned == "" {
return filepath.Join(repoPath, ".root")
}
normalized = strings.ReplaceAll(cleaned, "\\", "/")
if normalized == ".root" {
return filepath.Join(repoPath, ".root")
}
return filepath.Join(repoPath, filepath.FromSlash(cleaned))
}
func GetManifestDetail(repoPath string, reference string) (ManifestDetail, error) {
var detail ManifestDetail
var desc ociDescriptor
var err error
var data []byte
var payload manifestPayload
var configData []byte
var config ImageConfig
desc, err = resolveManifest(repoPath, reference)
if err != nil {
return detail, err
}
data, err = ReadBlob(repoPath, desc.Digest)
if err != nil {
return detail, err
}
err = json.Unmarshal(data, &payload)
if err != nil {
return detail, err
}
if payload.MediaType != "" {
desc.MediaType = payload.MediaType
}
configData, err = ReadBlob(repoPath, payload.Config.Digest)
if err == nil {
_ = json.Unmarshal(configData, &config)
}
detail = ManifestDetail{
Reference: reference,
Digest: desc.Digest,
MediaType: desc.MediaType,
Size: int64(len(data)),
Config: config,
Layers: payload.Layers,
}
return detail, nil
}

View File

@@ -0,0 +1,325 @@
package docker
import "crypto/sha256"
import "encoding/hex"
import "encoding/json"
import "errors"
import "io"
import "os"
import "path/filepath"
import "strings"
var ErrNotFound error = errors.New("not found")
type ociLayout struct {
ImageLayoutVersion string `json:"imageLayoutVersion"`
}
type ociIndex struct {
SchemaVersion int `json:"schemaVersion"`
MediaType string `json:"mediaType,omitempty"`
Manifests []ociDescriptor `json:"manifests"`
}
type ociDescriptor struct {
MediaType string `json:"mediaType"`
Digest string `json:"digest"`
Size int64 `json:"size"`
Annotations map[string]string `json:"annotations,omitempty"`
}
const ociIndexMediaType string = "application/vnd.oci.image.index.v1+json"
const ociLayoutVersion string = "1.0.0"
const ociTagAnnotation string = "org.opencontainers.image.ref.name"
func EnsureLayout(repoPath string) error {
var err error
var layoutPath string
var indexPath string
var blobsDir string
err = os.MkdirAll(repoPath, 0o755)
if err != nil {
return err
}
blobsDir = filepath.Join(repoPath, "blobs", "sha256")
err = os.MkdirAll(blobsDir, 0o755)
if err != nil {
return err
}
layoutPath = filepath.Join(repoPath, "oci-layout")
_, err = os.Stat(layoutPath)
if err != nil {
if !os.IsNotExist(err) {
return err
}
err = writeJSONFile(layoutPath, ociLayout{ImageLayoutVersion: ociLayoutVersion})
if err != nil {
return err
}
}
indexPath = filepath.Join(repoPath, "index.json")
_, err = os.Stat(indexPath)
if err != nil {
if !os.IsNotExist(err) {
return err
}
err = writeJSONFile(indexPath, ociIndex{SchemaVersion: 2, MediaType: ociIndexMediaType, Manifests: []ociDescriptor{}})
if err != nil {
return err
}
}
return nil
}
func BlobPath(repoPath string, digest string) (string, bool) {
var algo string
var hexPart string
var ok bool
algo, hexPart, ok = parseDigest(digest)
if !ok {
return "", false
}
if algo != "sha256" {
return "", false
}
return filepath.Join(repoPath, "blobs", "sha256", hexPart), true
}
func HasBlob(repoPath string, digest string) (bool, error) {
var path string
var ok bool
var err error
path, ok = BlobPath(repoPath, digest)
if !ok {
return false, nil
}
_, err = os.Stat(path)
if err == nil {
return true, nil
}
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
func ReadBlob(repoPath string, digest string) ([]byte, error) {
var path string
var ok bool
var data []byte
var err error
path, ok = BlobPath(repoPath, digest)
if !ok {
return nil, ErrNotFound
}
data, err = os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, ErrNotFound
}
return nil, err
}
return data, nil
}
func WriteBlob(repoPath string, digest string, data []byte) error {
var path string
var ok bool
var err error
var dir string
path, ok = BlobPath(repoPath, digest)
if !ok {
return errors.New("invalid digest")
}
dir = filepath.Dir(path)
err = os.MkdirAll(dir, 0o755)
if err != nil {
return err
}
err = os.WriteFile(path, data, 0o644)
if err != nil {
return err
}
return nil
}
func ComputeDigest(data []byte) string {
var sum [32]byte
sum = sha256.Sum256(data)
return "sha256:" + hex.EncodeToString(sum[:])
}
func parseDigest(digest string) (string, string, bool) {
var parts []string
var algo string
var hexPart string
parts = strings.SplitN(digest, ":", 2)
if len(parts) != 2 {
return "", "", false
}
algo = parts[0]
hexPart = parts[1]
if algo == "" || hexPart == "" {
return "", "", false
}
return algo, hexPart, true
}
func loadIndex(repoPath string) (ociIndex, error) {
var idx ociIndex
var path string
var data []byte
var err error
path = filepath.Join(repoPath, "index.json")
data, err = os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return idx, ErrNotFound
}
return idx, err
}
err = json.Unmarshal(data, &idx)
if err != nil {
return idx, err
}
if idx.Manifests == nil {
idx.Manifests = []ociDescriptor{}
}
return idx, nil
}
func saveIndex(repoPath string, idx ociIndex) error {
var path string
path = filepath.Join(repoPath, "index.json")
return writeJSONFile(path, idx)
}
func updateTag(repoPath string, tag string, desc ociDescriptor) error {
var idx ociIndex
var err error
var updated []ociDescriptor
var i int
var existing ociDescriptor
if desc.Annotations == nil {
desc.Annotations = map[string]string{}
}
desc.Annotations[ociTagAnnotation] = tag
idx, err = loadIndex(repoPath)
if err != nil {
if err == ErrNotFound {
idx = ociIndex{SchemaVersion: 2, MediaType: ociIndexMediaType, Manifests: []ociDescriptor{}}
} else {
return err
}
}
updated = make([]ociDescriptor, 0, len(idx.Manifests))
for i = 0; i < len(idx.Manifests); i++ {
existing = idx.Manifests[i]
if existing.Annotations != nil && existing.Annotations[ociTagAnnotation] == tag {
continue
}
updated = append(updated, existing)
}
updated = append(updated, desc)
idx.Manifests = updated
return saveIndex(repoPath, idx)
}
func resolveTag(repoPath string, tag string) (ociDescriptor, error) {
var idx ociIndex
var err error
var i int
var desc ociDescriptor
idx, err = loadIndex(repoPath)
if err != nil {
return desc, err
}
for i = 0; i < len(idx.Manifests); i++ {
desc = idx.Manifests[i]
if desc.Annotations != nil && desc.Annotations[ociTagAnnotation] == tag {
return desc, nil
}
}
return desc, ErrNotFound
}
func listTags(repoPath string) ([]string, error) {
var idx ociIndex
var err error
var tags []string
var i int
var desc ociDescriptor
var tag string
idx, err = loadIndex(repoPath)
if err != nil {
if err == ErrNotFound {
return []string{}, nil
}
return nil, err
}
tags = []string{}
for i = 0; i < len(idx.Manifests); i++ {
desc = idx.Manifests[i]
if desc.Annotations == nil {
continue
}
tag = desc.Annotations[ociTagAnnotation]
if tag != "" {
tags = append(tags, tag)
}
}
return tags, nil
}
func writeJSONFile(path string, value interface{}) error {
var data []byte
var err error
var temp string
data, err = json.MarshalIndent(value, "", " ")
if err != nil {
return err
}
temp = path + ".tmp"
err = os.WriteFile(temp, data, 0o644)
if err != nil {
return err
}
return os.Rename(temp, path)
}
func computeDigestFromReader(reader io.Reader) (string, int64, error) {
var hash hashWriter
var size int64
var err error
hash = newHashWriter()
size, err = io.Copy(&hash, reader)
if err != nil {
return "", 0, err
}
return hash.Digest(), size, nil
}
type hashWriter struct {
hasher hashState
}
type hashState interface {
Write([]byte) (int, error)
Sum([]byte) []byte
}
func newHashWriter() hashWriter {
var h hashWriter
h.hasher = sha256.New()
return h
}
func (h *hashWriter) Write(p []byte) (int, error) {
return h.hasher.Write(p)
}
func (h *hashWriter) Digest() string {
var sum []byte
sum = h.hasher.Sum(nil)
return "sha256:" + hex.EncodeToString(sum)
}

View File

@@ -0,0 +1,674 @@
package docker
import "encoding/json"
import "errors"
import "fmt"
import "io"
import "net/http"
import "os"
import "path/filepath"
import "strconv"
import "strings"
import "sync"
import "time"
import "codit/internal/db"
import "codit/internal/models"
import "codit/internal/util"
type AuthFunc func(username, password string) (bool, error)
type HTTPServer struct {
store *db.Store
baseDir string
auth AuthFunc
logger *util.Logger
}
func NewHTTPServer(store *db.Store, baseDir string, auth AuthFunc, logger *util.Logger) *HTTPServer {
return &HTTPServer{
store: store,
baseDir: baseDir,
auth: auth,
logger: logger,
}
}
func (s *HTTPServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
var ok bool
var userLabel string
var username string
var password string
var status int
var recorder *statusRecorder
if s.auth != nil {
username, password, ok = r.BasicAuth()
if !ok {
w.Header().Set("WWW-Authenticate", `Basic realm="docker"`)
w.WriteHeader(http.StatusUnauthorized)
return
}
ok, _ = s.auth(username, password)
if !ok {
w.Header().Set("WWW-Authenticate", `Basic realm="docker"`)
w.WriteHeader(http.StatusUnauthorized)
return
}
}
w.Header().Set("Docker-Distribution-Api-Version", "registry/2.0")
recorder = &statusRecorder{ResponseWriter: w, status: http.StatusOK}
s.handle(recorder, r)
status = recorder.status
if s.logger != nil {
userLabel = "-"
if username != "" {
userLabel = username
}
s.logger.Write("docker", util.LOG_INFO, "method=%s path=%s remote=%s user=%s status=%d",
r.Method, r.URL.Path, r.RemoteAddr, userLabel, status)
}
}
func (s *HTTPServer) handle(w http.ResponseWriter, r *http.Request) {
var path string
var action string
var repoName string
var rest string
var repo models.Repo
var project models.Project
var imageName string
var imagePath string
var err error
path = r.URL.Path
if path == "/v2" || path == "/v2/" {
w.WriteHeader(http.StatusOK)
return
}
repoName, action, rest = parseV2Path(path)
if action == "" {
w.WriteHeader(http.StatusNotFound)
return
}
if action == "catalog" {
s.handleCatalog(w, r)
return
}
repo, project, imageName, err = s.resolveRepo(repoName)
_ = project
if err != nil {
w.WriteHeader(http.StatusNotFound)
return
}
imagePath = ImagePath(repo.Path, imageName)
switch action {
case "tags":
s.handleTags(w, r, repo, repoName, imagePath)
case "manifest":
s.handleManifest(w, r, repo, repoName, rest, imagePath)
case "blob":
s.handleBlob(w, r, repo, repoName, rest, imagePath)
case "upload_start":
s.handleUploadStart(w, r, repo, repoName, imagePath)
case "upload":
s.handleUpload(w, r, repo, repoName, rest, imagePath)
default:
w.WriteHeader(http.StatusNotFound)
}
}
func parseV2Path(path string) (string, string, string) {
var p string
var idx int
p = strings.TrimPrefix(path, "/v2/")
if p == "" || p == "/v2" {
return "", "", ""
}
if strings.HasPrefix(p, "_catalog") {
return "", "catalog", ""
}
if strings.HasSuffix(p, "/tags/list") {
p = strings.TrimSuffix(p, "/tags/list")
p = strings.TrimSuffix(p, "/")
if p == "" {
return "", "", ""
}
return p, "tags", ""
}
idx = strings.Index(p, "/manifests/")
if idx >= 0 {
return p[:idx], "manifest", p[idx+len("/manifests/"):]
}
idx = strings.Index(p, "/blobs/uploads/")
if idx >= 0 {
if strings.HasSuffix(p, "/blobs/uploads/") {
return p[:idx], "upload_start", ""
}
return p[:idx], "upload", p[idx+len("/blobs/uploads/"):]
}
if strings.HasSuffix(p, "/blobs/uploads") {
p = strings.TrimSuffix(p, "/blobs/uploads")
p = strings.TrimSuffix(p, "/")
return p, "upload_start", ""
}
idx = strings.Index(p, "/blobs/")
if idx >= 0 {
return p[:idx], "blob", p[idx+len("/blobs/"):]
}
return "", "", ""
}
func (s *HTTPServer) resolveRepo(repoName string) (models.Repo, models.Project, string, error) {
var parts []string
var project models.Project
var repo models.Repo
var projectStorageID int64
var repoStorageID int64
var err error
var slug string
var name string
var image string
repoName = strings.Trim(repoName, "/")
parts = strings.Split(repoName, "/")
if len(parts) < 2 {
return repo, project, "", errors.New("invalid repo name")
}
slug = parts[0]
name = parts[1]
if len(parts) > 2 {
image = strings.Join(parts[2:], "/")
}
if IsReservedImagePath(image) {
return repo, project, "", errors.New("invalid image name")
}
project, err = s.store.GetProjectBySlug(slug)
if err != nil {
return repo, project, "", err
}
repo, err = s.store.GetRepoByProjectNameType(project.ID, name, "docker")
if err != nil {
return repo, project, "", err
}
projectStorageID, repoStorageID, err = s.store.GetRepoStorageIDs(repo.ID)
if err != nil {
return repo, project, "", err
}
repo.Path = filepath.Join(s.baseDir, storageIDSegment(projectStorageID), storageIDSegment(repoStorageID))
return repo, project, image, nil
}
func storageIDSegment(id int64) string {
return fmt.Sprintf("%016x", id)
}
func IsReservedImagePath(image string) bool {
var cleaned string
var parts []string
var i int
var part string
if image == "" {
return false
}
cleaned = strings.Trim(image, "/")
if cleaned == "" {
return false
}
parts = strings.Split(cleaned, "/")
for i = 0; i < len(parts); i++ {
part = parts[i]
if part == ".root" || part == "blobs" || part == "uploads" || part == "oci-layout" || part == "index.json" {
return true
}
}
return false
}
func (s *HTTPServer) handleCatalog(w http.ResponseWriter, r *http.Request) {
var repos []models.Repo
var err error
var names []string
var i int
var repo models.Repo
var project models.Project
var data []byte
var response map[string][]string
var images []string
var j int
var image string
repos, err = s.store.ListAllRepos()
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
names = []string{}
for i = 0; i < len(repos); i++ {
repo = repos[i]
if repo.Type != "docker" {
continue
}
project, err = s.store.GetProject(repo.ProjectID)
if err != nil {
continue
}
images, err = ListImages(repo.Path)
if err != nil {
continue
}
if len(images) == 0 {
names = append(names, project.Slug+"/"+repo.Name)
continue
}
for j = 0; j < len(images); j++ {
image = images[j]
if image == "" {
names = append(names, project.Slug+"/"+repo.Name)
} else {
names = append(names, project.Slug+"/"+repo.Name+"/"+image)
}
}
}
response = map[string][]string{"repositories": names}
data, err = json.Marshal(response)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(data)
}
func (s *HTTPServer) handleTags(w http.ResponseWriter, r *http.Request, repo models.Repo, name string, imagePath string) {
var tags []string
var err error
var response map[string]interface{}
var data []byte
tags, err = listTags(imagePath)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
response = map[string]interface{}{
"name": name,
"tags": tags,
}
data, err = json.Marshal(response)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write(data)
}
func (s *HTTPServer) handleManifest(w http.ResponseWriter, r *http.Request, repo models.Repo, name string, reference string, imagePath string) {
var desc ociDescriptor
var err error
var digest string
var data []byte
var mediaType string
var ok bool
if reference == "" {
w.WriteHeader(http.StatusBadRequest)
return
}
if r.Method == http.MethodPut {
data, err = io.ReadAll(r.Body)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
if r.Header.Get("Content-Type") != "" {
mediaType = r.Header.Get("Content-Type")
} else {
mediaType = detectManifestMediaType(data)
}
digest = ComputeDigest(data)
err = EnsureLayout(imagePath)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
ok, err = HasBlob(imagePath, digest)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
if !ok {
err = WriteBlob(imagePath, digest, data)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
}
desc = ociDescriptor{MediaType: mediaType, Digest: digest, Size: int64(len(data))}
if !isDigestRef(reference) {
err = withRepoLock(imagePath, func() error {
return updateTag(imagePath, reference, desc)
})
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
}
w.Header().Set("Docker-Content-Digest", digest)
w.Header().Set("Location", fmt.Sprintf("/v2/%s/manifests/%s", name, digest))
w.WriteHeader(http.StatusCreated)
return
}
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
desc, err = resolveManifest(imagePath, reference)
if err != nil {
if err == ErrNotFound {
w.WriteHeader(http.StatusNotFound)
return
}
w.WriteHeader(http.StatusInternalServerError)
return
}
data, err = ReadBlob(imagePath, desc.Digest)
if err != nil {
if err == ErrNotFound {
w.WriteHeader(http.StatusNotFound)
return
}
w.WriteHeader(http.StatusInternalServerError)
return
}
if desc.MediaType != "" {
w.Header().Set("Content-Type", desc.MediaType)
}
w.Header().Set("Docker-Content-Digest", desc.Digest)
if r.Method == http.MethodHead {
w.Header().Set("Content-Length", strconv.FormatInt(int64(len(data)), 10))
w.WriteHeader(http.StatusOK)
return
}
_, _ = w.Write(data)
}
func (s *HTTPServer) handleBlob(w http.ResponseWriter, r *http.Request, repo models.Repo, name string, digest string, imagePath string) {
var data []byte
var err error
if digest == "" {
w.WriteHeader(http.StatusBadRequest)
return
}
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
data, err = ReadBlob(imagePath, digest)
if err != nil {
if err == ErrNotFound {
w.WriteHeader(http.StatusNotFound)
return
}
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Header().Set("Docker-Content-Digest", digest)
w.Header().Set("Content-Type", "application/octet-stream")
if r.Method == http.MethodHead {
w.Header().Set("Content-Length", strconv.FormatInt(int64(len(data)), 10))
w.WriteHeader(http.StatusOK)
return
}
_, _ = w.Write(data)
}
func (s *HTTPServer) handleUploadStart(w http.ResponseWriter, r *http.Request, repo models.Repo, name string, imagePath string) {
var id string
var err error
var uploadPath string
var uploadDir string
var created *os.File
if r.Method != http.MethodPost {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
id, err = util.NewID()
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
uploadDir = filepath.Join(imagePath, "uploads")
err = os.MkdirAll(uploadDir, 0o755)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
uploadPath = filepath.Join(uploadDir, id)
_, err = os.Stat(uploadPath)
if err == nil {
w.WriteHeader(http.StatusConflict)
return
}
created, err = os.Create(uploadPath)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
_ = created.Close()
w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/uploads/%s", name, id))
w.Header().Set("Docker-Upload-UUID", id)
w.Header().Set("Range", "0-0")
w.WriteHeader(http.StatusAccepted)
}
func (s *HTTPServer) handleUpload(w http.ResponseWriter, r *http.Request, repo models.Repo, name string, uploadID string, imagePath string) {
var uploadDir string
var uploadPath string
var err error
var file *os.File
var size int64
var digest string
var computed string
var ok bool
var data []byte
var info os.FileInfo
var appendFile *os.File
var appendSize int64
if uploadID == "" {
w.WriteHeader(http.StatusBadRequest)
return
}
uploadDir = filepath.Join(imagePath, "uploads")
uploadPath = filepath.Join(uploadDir, uploadID)
if r.Method == http.MethodDelete {
_ = os.Remove(uploadPath)
w.WriteHeader(http.StatusNoContent)
return
}
if r.Method == http.MethodPatch {
file, err = os.OpenFile(uploadPath, os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
if os.IsNotExist(err) {
w.WriteHeader(http.StatusNotFound)
return
}
w.WriteHeader(http.StatusInternalServerError)
return
}
defer file.Close()
_, err = io.Copy(file, r.Body)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
info, err = file.Stat()
if err == nil {
size = info.Size()
}
w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/uploads/%s", name, uploadID))
w.Header().Set("Docker-Upload-UUID", uploadID)
if size > 0 {
w.Header().Set("Range", fmt.Sprintf("0-%d", size-1))
}
w.WriteHeader(http.StatusAccepted)
return
}
if r.Method == http.MethodPut {
digest = r.URL.Query().Get("digest")
if digest == "" {
w.WriteHeader(http.StatusBadRequest)
return
}
appendFile, err = os.OpenFile(uploadPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
appendSize, err = io.Copy(appendFile, r.Body)
_ = appendSize
_ = appendFile.Close()
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
file, err = os.Open(uploadPath)
if err != nil {
if os.IsNotExist(err) {
w.WriteHeader(http.StatusNotFound)
return
}
w.WriteHeader(http.StatusInternalServerError)
return
}
defer file.Close()
computed, size, err = computeDigestFromReader(file)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
if computed != digest {
w.WriteHeader(http.StatusBadRequest)
return
}
data, err = os.ReadFile(uploadPath)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
err = EnsureLayout(imagePath)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
ok, err = HasBlob(imagePath, digest)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
if !ok {
err = WriteBlob(imagePath, digest, data)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
}
_ = os.Remove(uploadPath)
w.Header().Set("Docker-Content-Digest", digest)
w.Header().Set("Location", fmt.Sprintf("/v2/%s/blobs/%s", name, digest))
w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
w.WriteHeader(http.StatusCreated)
return
}
w.WriteHeader(http.StatusMethodNotAllowed)
}
func resolveManifest(repoPath string, reference string) (ociDescriptor, error) {
var desc ociDescriptor
var err error
var ok bool
var data []byte
if isDigestRef(reference) {
ok, err = HasBlob(repoPath, reference)
if err != nil {
return desc, err
}
if !ok {
return desc, ErrNotFound
}
desc = ociDescriptor{Digest: reference}
data, err = ReadBlob(repoPath, reference)
if err == nil {
desc.Size = int64(len(data))
desc.MediaType = detectManifestMediaType(data)
}
return desc, nil
}
desc, err = resolveTag(repoPath, reference)
if err != nil {
return desc, err
}
return desc, nil
}
func isDigestRef(ref string) bool {
return strings.HasPrefix(ref, "sha256:")
}
func detectManifestMediaType(data []byte) string {
var tmp map[string]interface{}
var value interface{}
var s string
_ = json.Unmarshal(data, &tmp)
if tmp == nil {
return ""
}
value = tmp["mediaType"]
if value == nil {
return ""
}
s, _ = value.(string)
return s
}
type statusRecorder struct {
http.ResponseWriter
status int
}
func (r *statusRecorder) WriteHeader(code int) {
r.status = code
r.ResponseWriter.WriteHeader(code)
}
func (r *statusRecorder) Flush() {
var flusher http.Flusher
var ok bool
flusher, ok = r.ResponseWriter.(http.Flusher)
if ok {
flusher.Flush()
}
}
var repoLocksMu sync.Mutex
var repoLocks map[string]*sync.Mutex = map[string]*sync.Mutex{}
func repoLock(path string) *sync.Mutex {
var lock *sync.Mutex
var ok bool
repoLocksMu.Lock()
lock, ok = repoLocks[path]
if !ok {
lock = &sync.Mutex{}
repoLocks[path] = lock
}
repoLocksMu.Unlock()
return lock
}
func withRepoLock(path string, fn func() error) error {
var lock *sync.Mutex
lock = repoLock(path)
lock.Lock()
defer lock.Unlock()
return fn()
}
func init() {
_ = time.Now()
}

View File

@@ -0,0 +1,35 @@
package docker
import "path/filepath"
import "testing"
func TestParseV2Path(t *testing.T) {
var repo string
var action string
var rest string
repo, action, rest = parseV2Path("/v2/p/r/tags/list")
if repo != "p/r" || action != "tags" || rest != "" {
t.Fatalf("unexpected parse for tags: repo=%s action=%s rest=%s", repo, action, rest)
}
repo, action, rest = parseV2Path("/v2/p/r/manifests/latest")
if repo != "p/r" || action != "manifest" || rest != "latest" {
t.Fatalf("unexpected parse for manifest: repo=%s action=%s rest=%s", repo, action, rest)
}
}
func TestIsReservedImagePath(t *testing.T) {
if !IsReservedImagePath(".root") {
t.Fatalf(".root must be reserved")
}
if IsReservedImagePath("team/app") {
t.Fatalf("normal image path must not be reserved")
}
}
func TestImagePath(t *testing.T) {
var path string
path = ImagePath(filepath.Join("x", "repo"), "")
if path != filepath.Join("x", "repo", ".root") {
t.Fatalf("unexpected root image path: %s", path)
}
}

View File

@@ -94,3 +94,12 @@ func (r *statusRecorder) WriteHeader(code int) {
r.status = code
r.ResponseWriter.WriteHeader(code)
}
func (r *statusRecorder) Flush() {
var flusher http.Flusher
var ok bool
flusher, ok = r.ResponseWriter.(http.Flusher)
if ok {
flusher.Flush()
}
}

View File

@@ -0,0 +1,38 @@
package git
import "net/http"
import "net/http/httptest"
import "path/filepath"
import "testing"
type flushRecorder struct {
*httptest.ResponseRecorder
flushed bool
}
func (f *flushRecorder) Flush() {
f.flushed = true
}
func TestRepoPathFromRequest(t *testing.T) {
var server HTTPServer
var req *http.Request
var got string
server = HTTPServer{baseDir: filepath.Join("data", "git")}
req = httptest.NewRequest(http.MethodGet, "/p/r.git/info/refs", nil)
got = server.repoPathFromRequest(req)
if got != filepath.Join("data", "git", "p", "r.git") {
t.Fatalf("unexpected repo path: %s", got)
}
}
func TestStatusRecorderFlush(t *testing.T) {
var base *flushRecorder
var recorder *statusRecorder
base = &flushRecorder{ResponseRecorder: httptest.NewRecorder()}
recorder = &statusRecorder{ResponseWriter: base, status: http.StatusOK}
recorder.Flush()
if !base.flushed {
t.Fatalf("expected Flush() to be forwarded")
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,434 @@
package handlers
import "context"
import "crypto/rand"
import "crypto/sha1"
import "crypto/tls"
import "database/sql"
import "encoding/base64"
import "encoding/hex"
import "encoding/json"
import "errors"
import "fmt"
import "io"
import "net/http"
import "net/url"
import "strings"
import "time"
import "codit/internal/config"
import "codit/internal/models"
import "codit/internal/util"
type oidcTokenResponse struct {
AccessToken string `json:"access_token"`
IDToken string `json:"id_token"`
}
type oidcUserClaims struct {
Sub string `json:"sub"`
PreferredUsername string `json:"preferred_username"`
Name string `json:"name"`
Email string `json:"email"`
}
type oidcErrorResponse struct {
Error string `json:"error"`
ErrorDescription string `json:"error_description"`
}
func (api *API) OIDCEnabled(w http.ResponseWriter, _ *http.Request, _ map[string]string) {
var settings models.AuthSettings
var err error
var configured bool
settings, err = api.getMergedAuthSettings()
if err != nil {
WriteJSON(w, http.StatusOK, map[string]any{"enabled": false, "configured": false, "auth_mode": "db"})
return
}
configured = api.oidcConfiguredFromSettings(settings)
WriteJSON(w, http.StatusOK, map[string]any{
"enabled": settings.OIDCEnabled,
"configured": configured,
"auth_mode": strings.ToLower(strings.TrimSpace(settings.AuthMode)),
})
}
func (api *API) OIDCLogin(w http.ResponseWriter, r *http.Request, _ map[string]string) {
var settings models.AuthSettings
var cfg config.Config
var state string
var err error
var authURL string
settings, err = api.getMergedAuthSettings()
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load auth settings"})
return
}
cfg, err = api.effectiveAuthConfig()
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load auth settings"})
return
}
if !settings.OIDCEnabled {
WriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "oidc login is disabled"})
return
}
if !api.oidcConfiguredFromSettings(settings) {
WriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "oidc is not configured"})
return
}
state, err = newOIDCState()
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to create state"})
return
}
http.SetCookie(w, &http.Cookie{
Name: "codit_oidc_state",
Value: state,
HttpOnly: true,
Path: "/api/auth/oidc",
Expires: time.Now().UTC().Add(10 * time.Minute),
SameSite: http.SameSiteLaxMode,
})
authURL = api.buildOIDCAuthorizeURL(cfg, state)
if api.Logger != nil {
api.Logger.Write("auth", util.LOG_INFO, "oidc login redirect authorize_url=%s", cfg.OIDCAuthorizeURL)
}
http.Redirect(w, r, authURL, http.StatusFound)
}
func (api *API) OIDCCallback(w http.ResponseWriter, r *http.Request, _ map[string]string) {
var settings models.AuthSettings
var cfg config.Config
var state string
var code string
var cookie *http.Cookie
var err error
var token oidcTokenResponse
var claims oidcUserClaims
var user models.User
settings, err = api.getMergedAuthSettings()
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load auth settings"})
return
}
cfg, err = api.effectiveAuthConfig()
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to load auth settings"})
return
}
if !settings.OIDCEnabled {
WriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "oidc login is disabled"})
return
}
if !api.oidcConfiguredFromSettings(settings) {
WriteJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "oidc is not configured"})
return
}
state = strings.TrimSpace(r.URL.Query().Get("state"))
code = strings.TrimSpace(r.URL.Query().Get("code"))
cookie, err = r.Cookie("codit_oidc_state")
clearOIDCStateCookie(w)
if err != nil || cookie.Value == "" || state == "" || state != cookie.Value {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid oidc state"})
return
}
if code == "" {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "missing authorization code"})
return
}
token, err = api.oidcExchangeCode(r.Context(), cfg, code)
if err != nil {
if api.Logger != nil {
api.Logger.Write("auth", util.LOG_WARN, "oidc token exchange failed err=%v", err)
}
WriteJSON(w, http.StatusUnauthorized, map[string]string{"error": err.Error()})
return
}
claims, err = api.oidcResolveClaims(r.Context(), cfg, token)
if err != nil {
if api.Logger != nil {
api.Logger.Write("auth", util.LOG_WARN, "oidc claims fetch failed err=%v", err)
}
WriteJSON(w, http.StatusUnauthorized, map[string]string{"error": "oidc claims fetch failed"})
return
}
user, err = api.oidcGetOrCreateUser(claims)
if err != nil {
if api.Logger != nil {
api.Logger.Write("auth", util.LOG_WARN, "oidc user mapping failed err=%v", err)
}
WriteJSON(w, http.StatusUnauthorized, map[string]string{"error": "oidc user mapping failed"})
return
}
api.issueSession(w, user)
if api.Logger != nil {
api.Logger.Write("auth", util.LOG_INFO, "oidc login success username=%s", user.Username)
}
http.Redirect(w, r, "/", http.StatusFound)
}
func (api *API) oidcConfiguredFromSettings(settings models.AuthSettings) bool {
if strings.TrimSpace(settings.OIDCClientID) == "" {
return false
}
if strings.TrimSpace(settings.OIDCClientSecret) == "" {
return false
}
if strings.TrimSpace(settings.OIDCAuthorizeURL) == "" {
return false
}
if strings.TrimSpace(settings.OIDCTokenURL) == "" {
return false
}
if strings.TrimSpace(settings.OIDCRedirectURL) == "" {
return false
}
return true
}
func (api *API) buildOIDCAuthorizeURL(cfg config.Config, state string) string {
var values url.Values
var scopes string
var endpoint string
values = url.Values{}
values.Set("response_type", "code")
values.Set("client_id", cfg.OIDCClientID)
values.Set("redirect_uri", cfg.OIDCRedirectURL)
scopes = strings.TrimSpace(cfg.OIDCScopes)
if scopes == "" {
scopes = "openid profile email"
}
values.Set("scope", scopes)
values.Set("state", state)
endpoint = cfg.OIDCAuthorizeURL
if strings.Contains(endpoint, "?") {
return endpoint + "&" + values.Encode()
}
return endpoint + "?" + values.Encode()
}
func (api *API) oidcHTTPClient(cfg config.Config) *http.Client {
var transport *http.Transport
transport = &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: cfg.OIDCTLSInsecureSkipVerify},
}
return &http.Client{Transport: transport, Timeout: 15 * time.Second}
}
func (api *API) oidcExchangeCode(ctx context.Context, cfg config.Config, code string) (oidcTokenResponse, error) {
var client *http.Client
var form url.Values
var req *http.Request
var res *http.Response
var body []byte
var token oidcTokenResponse
var err error
client = api.oidcHTTPClient(cfg)
form = url.Values{}
form.Set("grant_type", "authorization_code")
form.Set("code", code)
form.Set("redirect_uri", cfg.OIDCRedirectURL)
form.Set("client_id", cfg.OIDCClientID)
form.Set("client_secret", cfg.OIDCClientSecret)
req, err = http.NewRequestWithContext(ctx, http.MethodPost, cfg.OIDCTokenURL, strings.NewReader(form.Encode()))
if err != nil {
return token, err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("Accept", "application/json")
res, err = client.Do(req)
if err != nil {
return token, err
}
defer res.Body.Close()
body, err = io.ReadAll(io.LimitReader(res.Body, 1024*1024))
if err != nil {
return token, err
}
if res.StatusCode < 200 || res.StatusCode >= 300 {
return token, fmt.Errorf("oidc token exchange failed: status=%d detail=%s", res.StatusCode, oidcErrorDetail(body))
}
err = json.Unmarshal(body, &token)
if err != nil {
return token, err
}
if strings.TrimSpace(token.AccessToken) == "" && strings.TrimSpace(token.IDToken) == "" {
return token, errors.New("missing oidc token")
}
return token, nil
}
func oidcErrorDetail(body []byte) string {
var payload oidcErrorResponse
var err error
var text string
err = json.Unmarshal(body, &payload)
if err == nil {
if strings.TrimSpace(payload.ErrorDescription) != "" {
return payload.ErrorDescription
}
if strings.TrimSpace(payload.Error) != "" {
return payload.Error
}
}
text = strings.TrimSpace(string(body))
if len(text) > 240 {
text = text[:240]
}
if text == "" {
text = "empty response body"
}
return text
}
func (api *API) oidcResolveClaims(ctx context.Context, cfg config.Config, token oidcTokenResponse) (oidcUserClaims, error) {
var claims oidcUserClaims
var err error
if strings.TrimSpace(cfg.OIDCUserInfoURL) != "" && strings.TrimSpace(token.AccessToken) != "" {
claims, err = api.oidcUserInfo(ctx, cfg, token.AccessToken)
if err == nil {
return claims, nil
}
}
if strings.TrimSpace(token.IDToken) == "" {
return claims, errors.New("missing id token and userinfo unavailable")
}
claims, err = oidcClaimsFromIDToken(token.IDToken)
if err != nil {
return claims, err
}
return claims, nil
}
func (api *API) oidcUserInfo(ctx context.Context, cfg config.Config, accessToken string) (oidcUserClaims, error) {
var client *http.Client
var req *http.Request
var res *http.Response
var body []byte
var claims oidcUserClaims
var err error
client = api.oidcHTTPClient(cfg)
req, err = http.NewRequestWithContext(ctx, http.MethodGet, cfg.OIDCUserInfoURL, nil)
if err != nil {
return claims, err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Accept", "application/json")
res, err = client.Do(req)
if err != nil {
return claims, err
}
defer res.Body.Close()
body, err = io.ReadAll(io.LimitReader(res.Body, 1024*1024))
if err != nil {
return claims, err
}
if res.StatusCode < 200 || res.StatusCode >= 300 {
return claims, fmt.Errorf("userinfo status %d", res.StatusCode)
}
err = json.Unmarshal(body, &claims)
if err != nil {
return claims, err
}
if strings.TrimSpace(claims.Sub) == "" {
return claims, errors.New("userinfo missing sub")
}
return claims, nil
}
func (api *API) oidcGetOrCreateUser(claims oidcUserClaims) (models.User, error) {
var username string
var displayName string
var email string
var user models.User
var hash string
var err error
var created models.User
username = oidcUsernameFromSub(claims.Sub)
user, hash, err = api.Store.GetUserByUsername(username)
_ = hash
if err == nil {
if user.Disabled {
return user, errors.New("user disabled")
}
return user, nil
}
if !errors.Is(err, sql.ErrNoRows) {
return user, err
}
displayName = strings.TrimSpace(claims.Name)
if displayName == "" {
displayName = strings.TrimSpace(claims.PreferredUsername)
}
if displayName == "" {
displayName = username
}
email = strings.TrimSpace(claims.Email)
if email == "" {
email = username + "@oidc.local"
}
user = models.User{
Username: username,
DisplayName: displayName,
Email: email,
AuthSource: "oidc",
}
created, err = api.Store.CreateUser(user, "")
if err != nil {
return created, err
}
return created, nil
}
func oidcClaimsFromIDToken(idToken string) (oidcUserClaims, error) {
var claims oidcUserClaims
var parts []string
var payload []byte
var err error
parts = strings.Split(idToken, ".")
if len(parts) < 2 {
return claims, errors.New("invalid id token")
}
payload, err = base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return claims, err
}
err = json.Unmarshal(payload, &claims)
if err != nil {
return claims, err
}
if strings.TrimSpace(claims.Sub) == "" {
return claims, errors.New("id token missing sub")
}
return claims, nil
}
func oidcUsernameFromSub(sub string) string {
var sum [20]byte
sum = sha1.Sum([]byte(strings.TrimSpace(sub)))
return "oidc-" + hex.EncodeToString(sum[:6])
}
func newOIDCState() (string, error) {
var buf []byte
var err error
buf = make([]byte, 24)
_, err = rand.Read(buf)
if err != nil {
return "", err
}
return hex.EncodeToString(buf), nil
}
func clearOIDCStateCookie(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: "codit_oidc_state",
Value: "",
Path: "/api/auth/oidc",
Expires: time.Unix(0, 0),
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,39 @@
package handlers
import "bytes"
import "encoding/json"
import "net/http"
import "net/http/httptest"
import "testing"
func TestWriteJSON(t *testing.T) {
var recorder *httptest.ResponseRecorder
var payload map[string]string
recorder = httptest.NewRecorder()
payload = map[string]string{"status": "ok"}
WriteJSON(recorder, http.StatusAccepted, payload)
if recorder.Code != http.StatusAccepted {
t.Fatalf("unexpected status: %d", recorder.Code)
}
if recorder.Header().Get("Content-Type") != "application/json" {
t.Fatalf("unexpected content-type: %s", recorder.Header().Get("Content-Type"))
}
}
func TestDecodeJSON(t *testing.T) {
var body []byte
var req *http.Request
var target struct {
Name string `json:"name"`
}
var err error
body, _ = json.Marshal(map[string]string{"name": "bob"})
req = httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))
err = DecodeJSON(req, &target)
if err != nil {
t.Fatalf("DecodeJSON error: %v", err)
}
if target.Name != "bob" {
t.Fatalf("unexpected decoded value: %s", target.Name)
}
}

View File

@@ -0,0 +1,37 @@
package httpx
import "net/http"
import "net/http/httptest"
import "testing"
func TestRouterMatchWithParams(t *testing.T) {
var router *Router
var recorder *httptest.ResponseRecorder
var req *http.Request
router = NewRouter()
router.Handle("GET", "/api/repos/:id", func(w http.ResponseWriter, _ *http.Request, params Params) {
if params["id"] != "abc" {
t.Fatalf("unexpected route param: %s", params["id"])
}
w.WriteHeader(http.StatusNoContent)
})
recorder = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/api/repos/abc", nil)
router.ServeHTTP(recorder, req)
if recorder.Code != http.StatusNoContent {
t.Fatalf("unexpected status: %d", recorder.Code)
}
}
func TestRouterNotFound(t *testing.T) {
var router *Router
var recorder *httptest.ResponseRecorder
var req *http.Request
router = NewRouter()
recorder = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/missing", nil)
router.ServeHTTP(recorder, req)
if recorder.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", recorder.Code)
}
}

View File

@@ -0,0 +1,43 @@
package middleware
import "net/http"
import "time"
import "codit/internal/models"
import "codit/internal/util"
type statusRecorder struct {
http.ResponseWriter
status int
}
func (r *statusRecorder) WriteHeader(code int) {
r.status = code
r.ResponseWriter.WriteHeader(code)
}
func AccessLog(logger *util.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var recorder *statusRecorder
var start time.Time
var duration time.Duration
var userLabel string
var user models.User
var ok bool
if logger == nil {
next.ServeHTTP(w, r)
return
}
recorder = &statusRecorder{ResponseWriter: w, status: http.StatusOK}
start = time.Now()
next.ServeHTTP(recorder, r)
duration = time.Since(start)
userLabel = "-"
user, ok = UserFromContext(r.Context())
if ok && user.Username != "" {
userLabel = user.Username
}
logger.Write("api", util.LOG_INFO, "method=%s path=%s remote=%s user=%s status=%d dur_ms=%d",
r.Method, r.URL.Path, r.RemoteAddr, userLabel, recorder.status, duration.Milliseconds())
})
}

View File

@@ -0,0 +1,41 @@
package middleware
import "bytes"
import "context"
import "net/http"
import "net/http/httptest"
import "strings"
import "testing"
import "codit/internal/models"
import "codit/internal/util"
func TestAccessLogWritesRecord(t *testing.T) {
var buf bytes.Buffer
var logger *util.Logger
var handler http.Handler
var req *http.Request
var recorder *httptest.ResponseRecorder
var user models.User
var ctx context.Context
logger = util.NewLogger("test", &buf, util.LOG_ALL)
defer logger.Close()
handler = AccessLog(logger, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusCreated)
}))
user = models.User{ID: "u1", Username: "alice"}
ctx = context.WithValue(context.Background(), userKey, user)
req = httptest.NewRequest(http.MethodPost, "/api/demo", nil).WithContext(ctx)
recorder = httptest.NewRecorder()
handler.ServeHTTP(recorder, req)
logger.Close()
if recorder.Code != http.StatusCreated {
t.Fatalf("unexpected status: %d", recorder.Code)
}
if !strings.Contains(buf.String(), "path=/api/demo") {
t.Fatalf("missing path in log: %s", buf.String())
}
if !strings.Contains(buf.String(), "user=alice") {
t.Fatalf("missing username in log: %s", buf.String())
}
}

View File

@@ -2,14 +2,17 @@ package middleware
import "context"
import "net/http"
import "strings"
import "time"
import "codit/internal/db"
import "codit/internal/models"
import "codit/internal/util"
type ctxKey string
const userKey ctxKey = "user"
const principalKey ctxKey = "principal"
func WithUser(store *db.Store, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -18,14 +21,40 @@ func WithUser(store *db.Store, next http.Handler) http.Handler {
var user models.User
var expires time.Time
var ctx context.Context
var token string
var hash string
cookie, err = r.Cookie("codit_session")
if err != nil || cookie.Value == "" {
next.ServeHTTP(w, r)
token = apiKeyFromRequest(r)
if token == "" {
next.ServeHTTP(w, r)
return
}
hash = util.HashToken(token)
user, err = store.GetUserByAPIKeyHash(hash)
if err != nil {
next.ServeHTTP(w, r)
return
}
ctx = context.WithValue(r.Context(), userKey, user)
next.ServeHTTP(w, r.WithContext(ctx))
return
}
user, expires, err = store.GetSessionUser(cookie.Value)
if err != nil || time.Now().UTC().After(expires) {
next.ServeHTTP(w, r)
token = apiKeyFromRequest(r)
if token == "" {
next.ServeHTTP(w, r)
return
}
hash = util.HashToken(token)
user, err = store.GetUserByAPIKeyHash(hash)
if err != nil {
next.ServeHTTP(w, r)
return
}
ctx = context.WithValue(r.Context(), userKey, user)
next.ServeHTTP(w, r.WithContext(ctx))
return
}
ctx = context.WithValue(r.Context(), userKey, user)
@@ -40,6 +69,19 @@ func UserFromContext(ctx context.Context) (models.User, bool) {
return user, ok
}
func WithPrincipal(r *http.Request, principal models.ServicePrincipal) *http.Request {
var ctx context.Context
ctx = context.WithValue(r.Context(), principalKey, principal)
return r.WithContext(ctx)
}
func PrincipalFromContext(ctx context.Context) (models.ServicePrincipal, bool) {
var principal models.ServicePrincipal
var ok bool
principal, ok = ctx.Value(principalKey).(models.ServicePrincipal)
return principal, ok
}
func RequireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var ok bool
@@ -64,3 +106,25 @@ func RequireAdmin(next http.Handler) http.Handler {
next.ServeHTTP(w, r)
})
}
func apiKeyFromRequest(r *http.Request) string {
var token string
var auth string
var parts []string
token = r.Header.Get("X-API-Key")
if token != "" {
return token
}
auth = r.Header.Get("Authorization")
if auth == "" {
return ""
}
parts = strings.SplitN(auth, " ", 2)
if len(parts) != 2 {
return ""
}
if strings.ToLower(parts[0]) != "bearer" {
return ""
}
return parts[1]
}

View File

@@ -0,0 +1,69 @@
package middleware
import "context"
import "net/http"
import "net/http/httptest"
import "testing"
import "codit/internal/models"
func TestAPIKeyFromRequest(t *testing.T) {
var req *http.Request
var token string
req = httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("X-API-Key", "abc")
token = apiKeyFromRequest(req)
if token != "abc" {
t.Fatalf("expected header token, got %q", token)
}
req = httptest.NewRequest(http.MethodGet, "/", nil)
req.Header.Set("Authorization", "Bearer xyz")
token = apiKeyFromRequest(req)
if token != "xyz" {
t.Fatalf("expected bearer token, got %q", token)
}
}
func TestRequireAuth(t *testing.T) {
var called bool
var handler http.Handler
var recorder *httptest.ResponseRecorder
var req *http.Request
handler = RequireAuth(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
called = true
w.WriteHeader(http.StatusNoContent)
}))
recorder = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/", nil)
handler.ServeHTTP(recorder, req)
if recorder.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", recorder.Code)
}
if called {
t.Fatalf("protected handler should not be called")
}
}
func TestRequireAdmin(t *testing.T) {
var called bool
var handler http.Handler
var recorder *httptest.ResponseRecorder
var req *http.Request
var user models.User
var ctx context.Context
handler = RequireAdmin(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
called = true
w.WriteHeader(http.StatusNoContent)
}))
user = models.User{ID: "u1", Username: "admin", IsAdmin: true}
ctx = context.WithValue(context.Background(), userKey, user)
recorder = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx)
handler.ServeHTTP(recorder, req)
if recorder.Code != http.StatusNoContent {
t.Fatalf("expected 204, got %d", recorder.Code)
}
if !called {
t.Fatalf("admin handler should be called")
}
}

View File

@@ -6,6 +6,7 @@ type User struct {
DisplayName string `json:"display_name"`
Email string `json:"email"`
IsAdmin bool `json:"is_admin"`
Disabled bool `json:"disabled"`
AuthSource string `json:"auth_source"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
@@ -16,6 +17,7 @@ type Project struct {
Slug string `json:"slug"`
Name string `json:"name"`
Description string `json:"description"`
HomePage string `json:"home_page"`
CreatedBy string `json:"created_by"`
UpdatedBy string `json:"updated_by"`
CreatedByName string `json:"created_by_name"`
@@ -42,6 +44,66 @@ type Repo struct {
IsForeign bool `json:"is_foreign"`
}
type RPMRepoDir struct {
RepoID string `json:"repo_id"`
Path string `json:"path"`
Mode string `json:"mode"`
AllowDelete bool `json:"allow_delete"`
RemoteURL string `json:"remote_url"`
ConnectHost string `json:"connect_host"`
HostHeader string `json:"host_header"`
TLSServerName string `json:"tls_server_name"`
TLSInsecureSkipVerify bool `json:"tls_insecure_skip_verify"`
SyncIntervalSec int64 `json:"sync_interval_sec"`
SyncEnabled bool `json:"sync_enabled"`
Dirty bool `json:"dirty"`
NextSyncAt int64 `json:"next_sync_at"`
SyncRunning bool `json:"sync_running"`
SyncStatus string `json:"sync_status"`
SyncError string `json:"sync_error"`
SyncStep string `json:"sync_step"`
SyncTotal int64 `json:"sync_total"`
SyncDone int64 `json:"sync_done"`
SyncFailed int64 `json:"sync_failed"`
SyncDeleted int64 `json:"sync_deleted"`
LastSyncStartedAt int64 `json:"last_sync_started_at"`
LastSyncFinishedAt int64 `json:"last_sync_finished_at"`
LastSyncSuccessAt int64 `json:"last_sync_success_at"`
LastSyncedRevision string `json:"last_synced_revision"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
type RPMMirrorTask struct {
RepoID string `json:"repo_id"`
RepoPath string `json:"repo_path"`
MirrorPath string `json:"mirror_path"`
RemoteURL string `json:"remote_url"`
ConnectHost string `json:"connect_host"`
HostHeader string `json:"host_header"`
TLSServerName string `json:"tls_server_name"`
TLSInsecureSkipVerify bool `json:"tls_insecure_skip_verify"`
SyncIntervalSec int64 `json:"sync_interval_sec"`
Dirty bool `json:"dirty"`
LastSyncedRevision string `json:"last_synced_revision"`
}
type RPMMirrorRun struct {
ID string `json:"id"`
RepoID string `json:"repo_id"`
Path string `json:"path"`
StartedAt int64 `json:"started_at"`
FinishedAt int64 `json:"finished_at"`
Status string `json:"status"`
Step string `json:"step"`
Total int64 `json:"total"`
Done int64 `json:"done"`
Failed int64 `json:"failed"`
Deleted int64 `json:"deleted"`
Revision string `json:"revision"`
Error string `json:"error"`
}
type Issue struct {
ID string `json:"id"`
ProjectID string `json:"project_id"`
@@ -82,3 +144,140 @@ type Upload struct {
CreatedBy string `json:"created_by"`
CreatedAt int64 `json:"created_at"`
}
type APIKey struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Name string `json:"name"`
Prefix string `json:"prefix"`
CreatedAt int64 `json:"created_at"`
LastUsedAt int64 `json:"last_used_at"`
ExpiresAt int64 `json:"expires_at"`
Disabled bool `json:"disabled"`
}
type AdminAPIKey struct {
ID string `json:"id"`
UserID string `json:"user_id"`
Username string `json:"username"`
DisplayName string `json:"display_name"`
Email string `json:"email"`
Name string `json:"name"`
Prefix string `json:"prefix"`
CreatedAt int64 `json:"created_at"`
LastUsedAt int64 `json:"last_used_at"`
ExpiresAt int64 `json:"expires_at"`
Disabled bool `json:"disabled"`
}
type AuthSettings struct {
AuthMode string `json:"auth_mode"`
OIDCEnabled bool `json:"oidc_enabled"`
LDAPURL string `json:"ldap_url"`
LDAPBindDN string `json:"ldap_bind_dn"`
LDAPBindPassword string `json:"ldap_bind_password"`
LDAPUserBaseDN string `json:"ldap_user_base_dn"`
LDAPUserFilter string `json:"ldap_user_filter"`
LDAPTLSInsecureSkipVerify bool `json:"ldap_tls_insecure_skip_verify"`
OIDCClientID string `json:"oidc_client_id"`
OIDCClientSecret string `json:"oidc_client_secret"`
OIDCAuthorizeURL string `json:"oidc_authorize_url"`
OIDCTokenURL string `json:"oidc_token_url"`
OIDCUserInfoURL string `json:"oidc_userinfo_url"`
OIDCRedirectURL string `json:"oidc_redirect_url"`
OIDCScopes string `json:"oidc_scopes"`
OIDCTLSInsecureSkipVerify bool `json:"oidc_tls_insecure_skip_verify"`
}
type TLSSettings struct {
HTTPAddrs []string `json:"http_addrs"`
HTTPSAddrs []string `json:"https_addrs"`
TLSServerCertSource string `json:"tls_server_cert_source"`
TLSCertFile string `json:"tls_cert_file"`
TLSKeyFile string `json:"tls_key_file"`
TLSPKIServerCertID string `json:"tls_pki_server_cert_id"`
TLSClientAuth string `json:"tls_client_auth"`
TLSClientCAFile string `json:"tls_client_ca_file"`
TLSPKIClientCAID string `json:"tls_pki_client_ca_id"`
TLSMinVersion string `json:"tls_min_version"`
}
type TLSListener struct {
ID string `json:"id"`
Name string `json:"name"`
Enabled bool `json:"enabled"`
HTTPAddrs []string `json:"http_addrs"`
HTTPSAddrs []string `json:"https_addrs"`
AuthPolicy string `json:"auth_policy"`
ApplyPolicyAPI bool `json:"apply_policy_api"`
ApplyPolicyGit bool `json:"apply_policy_git"`
ApplyPolicyRPM bool `json:"apply_policy_rpm"`
ApplyPolicyV2 bool `json:"apply_policy_v2"`
ClientCertAllowlist []string `json:"client_cert_allowlist"`
TLSServerCertSource string `json:"tls_server_cert_source"`
TLSCertFile string `json:"tls_cert_file"`
TLSKeyFile string `json:"tls_key_file"`
TLSPKIServerCertID string `json:"tls_pki_server_cert_id"`
TLSClientAuth string `json:"tls_client_auth"`
TLSClientCAFile string `json:"tls_client_ca_file"`
TLSPKIClientCAID string `json:"tls_pki_client_ca_id"`
TLSMinVersion string `json:"tls_min_version"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
type PKICA struct {
ID string `json:"id"`
Name string `json:"name"`
ParentCAID string `json:"parent_ca_id"`
IsRoot bool `json:"is_root"`
CertPEM string `json:"cert_pem"`
KeyPEM string `json:"key_pem"`
SerialCounter int64 `json:"serial_counter"`
Status string `json:"status"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
type PKICert struct {
ID string `json:"id"`
CAID string `json:"ca_id"`
SerialHex string `json:"serial_hex"`
CommonName string `json:"common_name"`
SANDNS string `json:"san_dns"`
SANIPs string `json:"san_ips"`
IsCA bool `json:"is_ca"`
CertPEM string `json:"cert_pem"`
KeyPEM string `json:"key_pem"`
NotBefore int64 `json:"not_before"`
NotAfter int64 `json:"not_after"`
Status string `json:"status"`
RevokedAt int64 `json:"revoked_at"`
RevocationReason string `json:"revocation_reason"`
CreatedAt int64 `json:"created_at"`
}
type ServicePrincipal struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
IsAdmin bool `json:"is_admin"`
Disabled bool `json:"disabled"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
type CertPrincipalBinding struct {
Fingerprint string `json:"fingerprint"`
PrincipalID string `json:"principal_id"`
Enabled bool `json:"enabled"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
type PrincipalProjectRole struct {
PrincipalID string `json:"principal_id"`
ProjectID string `json:"project_id"`
Role string `json:"role"`
CreatedAt int64 `json:"created_at"`
}

View File

@@ -0,0 +1,75 @@
package models
import "encoding/json"
import "testing"
func TestUserJSONTags(t *testing.T) {
var u User
var data []byte
var err error
var decoded map[string]interface{}
u = User{
ID: "u1",
Username: "alice",
DisplayName: "Alice",
Email: "a@x",
IsAdmin: true,
Disabled: true,
AuthSource: "db",
CreatedAt: 1,
UpdatedAt: 2,
}
data, err = json.Marshal(u)
if err != nil {
t.Fatalf("json marshal error: %v", err)
}
decoded = map[string]interface{}{}
err = json.Unmarshal(data, &decoded)
if err != nil {
t.Fatalf("json unmarshal error: %v", err)
}
if _, ok := decoded["display_name"]; !ok {
t.Fatalf("missing display_name json key")
}
if _, ok := decoded["is_admin"]; !ok {
t.Fatalf("missing is_admin json key")
}
if _, ok := decoded["disabled"]; !ok {
t.Fatalf("missing disabled json key")
}
}
func TestAPIKeyJSONTags(t *testing.T) {
var k APIKey
var data []byte
var err error
var decoded map[string]interface{}
k = APIKey{
ID: "k1",
UserID: "u1",
Name: "n",
Prefix: "pre",
CreatedAt: 10,
LastUsedAt: 11,
ExpiresAt: 12,
Disabled: true,
}
data, err = json.Marshal(k)
if err != nil {
t.Fatalf("json marshal error: %v", err)
}
decoded = map[string]interface{}{}
err = json.Unmarshal(data, &decoded)
if err != nil {
t.Fatalf("json unmarshal error: %v", err)
}
if _, ok := decoded["expires_at"]; !ok {
t.Fatalf("missing expires_at json key")
}
if _, ok := decoded["last_used_at"]; !ok {
t.Fatalf("missing last_used_at json key")
}
if _, ok := decoded["disabled"]; !ok {
t.Fatalf("missing disabled json key")
}
}

View File

@@ -0,0 +1,54 @@
package rpm
import "bytes"
import "net/http"
import "net/http/httptest"
import "os"
import "path/filepath"
import "testing"
import "codit/internal/util"
func TestHTTPServerUnauthorizedWithoutBasicAuth(t *testing.T) {
var dir string
var logger *util.Logger
var server *HTTPServer
var req *http.Request
var recorder *httptest.ResponseRecorder
dir = t.TempDir()
logger = util.NewLogger("test", &bytes.Buffer{}, util.LOG_ALL)
defer logger.Close()
server = NewHTTPServer(dir, func(username, password string) (bool, error) {
return true, nil
}, logger)
req = httptest.NewRequest(http.MethodGet, "/x", nil)
recorder = httptest.NewRecorder()
server.ServeHTTP(recorder, req)
if recorder.Code != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", recorder.Code)
}
}
func TestHTTPServerAuthorized(t *testing.T) {
var dir string
var logger *util.Logger
var server *HTTPServer
var req *http.Request
var recorder *httptest.ResponseRecorder
var path string
dir = t.TempDir()
path = filepath.Join(dir, "a.txt")
_ = os.WriteFile(path, []byte("ok"), 0o644)
logger = util.NewLogger("test", &bytes.Buffer{}, util.LOG_ALL)
defer logger.Close()
server = NewHTTPServer(dir, func(username, password string) (bool, error) {
return username == "u" && password == "p", nil
}, logger)
req = httptest.NewRequest(http.MethodGet, "/a.txt", nil)
req.SetBasicAuth("u", "p")
recorder = httptest.NewRecorder()
server.ServeHTTP(recorder, req)
if recorder.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", recorder.Code)
}
}

View File

@@ -1,8 +1,11 @@
package rpm
import "log"
import "os"
import "path/filepath"
import "strings"
import "sync"
import "time"
import repokit "repokit"
@@ -24,6 +27,18 @@ func NewMetaManager() *MetaManager {
return mgr
}
func (m *MetaManager) IsRunning(dir string) bool {
var state *metaState
var ok bool
m.mutex.Lock()
defer m.mutex.Unlock()
state, ok = m.states[dir]
if !ok || state == nil {
return false
}
return state.inProgress
}
func (m *MetaManager) Schedule(dir string) {
var state *metaState
var ok bool
@@ -46,7 +61,15 @@ func (m *MetaManager) Schedule(dir string) {
func (m *MetaManager) run(dir string) {
var err error
var opts repokit.RpmRepoOptions
var state *metaState
var ok bool
var repodataDir string
var repomdPath string
var entries []os.DirEntry
var repomdInfo os.FileInfo
var statErr error
for {
log.Printf("rpm metadata: job begin dir=%s", dir)
opts = repokit.RpmDefaultRepoOptions()
opts.LockMode = repokit.RpmLockFail
opts.AllowMissingRepomd = true
@@ -57,24 +80,55 @@ func (m *MetaManager) run(dir string) {
if err != nil {
if isLockError(err) {
log.Printf("rpm metadata: lock busy dir=%s err=%v", dir, err)
m.states[dir].pending = true
m.states[dir].inProgress = false
log.Printf("rpm metadata: job end dir=%s result=lock_busy", dir)
state, ok = m.states[dir]
if ok {
state.pending = true
state.inProgress = false
}
m.mutex.Unlock()
time.AfterFunc(2*time.Second, func() {
m.Schedule(dir)
})
return
}
log.Printf("rpm metadata: build failed dir=%s err=%v", dir, err)
m.states[dir].inProgress = false
log.Printf("rpm metadata: job end dir=%s result=failed err=%v", dir, err)
state, ok = m.states[dir]
if ok {
state.inProgress = false
}
m.mutex.Unlock()
return
}
repodataDir = filepath.Join(dir, "repodata")
repomdPath = filepath.Join(repodataDir, "repomd.xml")
entries, err = os.ReadDir(repodataDir)
if err != nil {
log.Printf("rpm metadata: post-check dir=%s repodata_dir=%s read_err=%v", dir, repodataDir, err)
} else {
statErr = nil
repomdInfo = nil
repomdInfo, statErr = os.Stat(repomdPath)
if statErr != nil {
log.Printf("rpm metadata: post-check dir=%s repodata_entries=%d repomd_path=%s repomd_err=%v", dir, len(entries), repomdPath, statErr)
} else {
log.Printf("rpm metadata: post-check dir=%s repodata_entries=%d repomd_path=%s repomd_size=%d", dir, len(entries), repomdPath, repomdInfo.Size())
}
}
log.Printf("rpm metadata: build done dir=%s", dir)
if m.states[dir].pending {
m.states[dir].pending = false
state, ok = m.states[dir]
if ok && state.pending {
log.Printf("rpm metadata: job end dir=%s result=pending_rerun", dir)
state.pending = false
m.mutex.Unlock()
continue
}
m.states[dir].inProgress = false
if ok {
state.inProgress = false
}
m.mutex.Unlock()
log.Printf("rpm metadata: job end dir=%s result=success", dir)
return
}
}

View File

@@ -0,0 +1,908 @@
package rpm
import "compress/gzip"
import "context"
import "crypto/md5"
import "crypto/sha1"
import "crypto/sha256"
import "crypto/sha512"
import "crypto/tls"
import "bytes"
import "encoding/hex"
import "encoding/xml"
import "errors"
import "hash"
import "io"
import "io/fs"
import "net"
import "net/http"
import "net/url"
import "os"
import "path/filepath"
import "strconv"
import "strings"
import "sync"
import "time"
import "codit/internal/db"
import "codit/internal/models"
import "codit/internal/util"
type MirrorManager struct {
store *db.Store
logger *util.Logger
meta *MetaManager
stopCh chan struct{}
cancelMu sync.Mutex
cancelByKey map[string]context.CancelFunc
}
type repomdDoc struct {
Data []repomdData `xml:"data"`
}
type repomdData struct {
Type string `xml:"type,attr"`
Location repomdLocation `xml:"location"`
}
type repomdLocation struct {
Href string `xml:"href,attr"`
}
type primaryDoc struct {
Packages []primaryPackage `xml:"package"`
}
type primaryPackage struct {
Location primaryLocation `xml:"location"`
Checksum primaryChecksum `xml:"checksum"`
Time primaryTime `xml:"time"`
}
type primaryLocation struct {
Href string `xml:"href,attr"`
}
type primaryChecksum struct {
Type string `xml:"type,attr"`
Value string `xml:",chardata"`
}
type mirrorChecksum struct {
Algo string
Value string
BuildTime int64
FileTime int64
}
type primaryTime struct {
File string `xml:"file,attr"`
Build string `xml:"build,attr"`
}
type mirrorHTTPConfig struct {
BaseURL string
ConnectHost string
HostHeader string
TLSServerName string
TLSInsecure bool
DefaultHost string
DefaultServer string
}
func NewMirrorManager(store *db.Store, logger *util.Logger, meta *MetaManager) *MirrorManager {
var m *MirrorManager
m = &MirrorManager{
store: store,
logger: logger,
meta: meta,
stopCh: make(chan struct{}),
cancelByKey: make(map[string]context.CancelFunc),
}
return m
}
func (m *MirrorManager) CancelTask(repoID string, path string) bool {
var key string
var cancel context.CancelFunc
if m == nil {
return false
}
key = mirrorTaskKey(repoID, path)
m.cancelMu.Lock()
cancel = m.cancelByKey[key]
m.cancelMu.Unlock()
if cancel == nil {
return false
}
cancel()
return true
}
func (m *MirrorManager) Start() {
var err error
var tasks []models.RPMMirrorTask
var i int
if m == nil {
return
}
err = m.store.ResetRunningRPMMirrorTasks()
if err != nil && m.logger != nil {
m.logger.Write("rpm-mirror", util.LOG_ERROR, "reset running tasks failed err=%v", err)
}
tasks, err = m.store.ListRPMMirrorPaths()
if err == nil {
for i = 0; i < len(tasks); i++ {
_ = m.store.CleanupRPMMirrorRunsRetention(tasks[i].RepoID, tasks[i].MirrorPath, 200, 30)
}
}
go m.loop()
}
func (m *MirrorManager) Stop() {
if m == nil {
return
}
close(m.stopCh)
}
func (m *MirrorManager) loop() {
var ticker *time.Ticker
ticker = time.NewTicker(10 * time.Second)
defer ticker.Stop()
m.runDue()
for {
select {
case <-m.stopCh:
return
case <-ticker.C:
m.runDue()
}
}
}
func (m *MirrorManager) runDue() {
var tasks []models.RPMMirrorTask
var started bool
var now int64
var i int
var err error
now = time.Now().UTC().Unix()
tasks, err = m.store.ListDueRPMMirrorTasks(now, 8)
if err != nil {
if m.logger != nil {
m.logger.Write("rpm-mirror", util.LOG_ERROR, "list due tasks failed err=%v", err)
}
return
}
for i = 0; i < len(tasks); i++ {
started, err = m.store.TryStartRPMMirrorTask(tasks[i].RepoID, tasks[i].MirrorPath, now)
if err != nil {
if m.logger != nil {
m.logger.Write("rpm-mirror", util.LOG_ERROR, "try start failed repo=%s path=%s err=%v", tasks[i].RepoID, tasks[i].MirrorPath, err)
}
continue
}
if !started {
continue
}
m.syncOne(tasks[i])
}
}
func (m *MirrorManager) syncOne(task models.RPMMirrorTask) {
var localRoot string
var cfg mirrorHTTPConfig
var client *http.Client
var repomdData []byte
var revision string
var primaryHref string
var primaryData []byte
var expected map[string]mirrorChecksum
var duplicateCount int
var runID string
var startedAt int64
var total int64
var done int64
var failed int64
var deleted int64
var changed int64
var err error
var syncCtx context.Context
var syncCancel context.CancelFunc
var canceled bool
var key string
localRoot = filepath.Join(task.RepoPath, filepath.FromSlash(task.MirrorPath))
startedAt = time.Now().UTC().Unix()
syncCtx, syncCancel = context.WithCancel(context.Background())
key = mirrorTaskKey(task.RepoID, task.MirrorPath)
m.cancelMu.Lock()
m.cancelByKey[key] = syncCancel
m.cancelMu.Unlock()
defer func() {
m.cancelMu.Lock()
delete(m.cancelByKey, key)
m.cancelMu.Unlock()
syncCancel()
}()
runID, err = m.store.CreateRPMMirrorRun(task.RepoID, task.MirrorPath, startedAt)
if err != nil {
_ = m.store.FinishRPMMirrorTask(task.RepoID, task.MirrorPath, false, "", err.Error())
return
}
cfg, err = buildMirrorHTTPConfig(task)
if err != nil {
if m.logger != nil {
m.logger.Write("rpm-mirror", util.LOG_ERROR, "sync failed repo=%s path=%s step=start err=%v", task.RepoID, task.MirrorPath, err)
}
_ = m.store.FinishRPMMirrorTask(task.RepoID, task.MirrorPath, false, "", err.Error())
_ = m.store.FinishRPMMirrorRun(runID, time.Now().UTC().Unix(), "failed", "start", 0, 0, 0, 0, "", err.Error())
return
}
client = buildMirrorHTTPClient(cfg)
if m.logger != nil {
m.logger.Write("rpm-mirror", util.LOG_INFO, "sync start repo=%s path=%s remote=%s", task.RepoID, task.MirrorPath, task.RemoteURL)
}
_ = m.store.UpdateRPMMirrorTaskProgress(task.RepoID, task.MirrorPath, "fetch_repodata", 0, 0, 0, 0)
repomdData, err = mirrorFetch(syncCtx, client, cfg, "repodata/repomd.xml")
if err != nil {
if m.logger != nil {
m.logger.Write("rpm-mirror", util.LOG_ERROR, "sync failed repo=%s path=%s step=fetch_repodata err=%v", task.RepoID, task.MirrorPath, err)
}
_ = m.store.FinishRPMMirrorTask(task.RepoID, task.MirrorPath, false, "", err.Error())
_ = m.store.FinishRPMMirrorRun(runID, time.Now().UTC().Unix(), "failed", "fetch_repodata", 0, 0, 0, 0, "", err.Error())
return
}
revision = sha256HexBytes(repomdData)
if !task.Dirty && task.LastSyncedRevision != "" && task.LastSyncedRevision == revision {
if m.meta != nil {
ensureRepodata(task, localRoot, m.meta, m.logger)
}
if m.logger != nil {
m.logger.Write("rpm-mirror", util.LOG_INFO, "sync done repo=%s path=%s status=no_change revision=%s", task.RepoID, task.MirrorPath, revision)
}
_ = m.store.FinishRPMMirrorTask(task.RepoID, task.MirrorPath, true, revision, "")
_ = m.store.FinishRPMMirrorRun(runID, time.Now().UTC().Unix(), "success", "no_change", 0, 0, 0, 0, revision, "")
return
}
primaryHref, err = parseRepomdPrimaryHref(repomdData)
if err != nil {
if m.logger != nil {
m.logger.Write("rpm-mirror", util.LOG_ERROR, "sync failed repo=%s path=%s step=parse_repodata err=%v", task.RepoID, task.MirrorPath, err)
}
_ = m.store.FinishRPMMirrorTask(task.RepoID, task.MirrorPath, false, "", err.Error())
_ = m.store.FinishRPMMirrorRun(runID, time.Now().UTC().Unix(), "failed", "fetch_repodata", 0, 0, 0, 0, "", err.Error())
return
}
_ = m.store.UpdateRPMMirrorTaskProgress(task.RepoID, task.MirrorPath, "fetch_primary", 0, 0, 0, 0)
primaryData, err = mirrorFetch(syncCtx, client, cfg, primaryHref)
if err != nil {
if m.logger != nil {
m.logger.Write("rpm-mirror", util.LOG_ERROR, "sync failed repo=%s path=%s step=fetch_primary err=%v", task.RepoID, task.MirrorPath, err)
}
_ = m.store.FinishRPMMirrorTask(task.RepoID, task.MirrorPath, false, "", err.Error())
_ = m.store.FinishRPMMirrorRun(runID, time.Now().UTC().Unix(), "failed", "fetch_primary", 0, 0, 0, 0, "", err.Error())
return
}
if strings.HasSuffix(strings.ToLower(primaryHref), ".gz") {
primaryData, err = gunzipBytes(primaryData)
if err != nil {
if m.logger != nil {
m.logger.Write("rpm-mirror", util.LOG_ERROR, "sync failed repo=%s path=%s step=decode_primary err=%v", task.RepoID, task.MirrorPath, err)
}
_ = m.store.FinishRPMMirrorTask(task.RepoID, task.MirrorPath, false, "", err.Error())
_ = m.store.FinishRPMMirrorRun(runID, time.Now().UTC().Unix(), "failed", "fetch_primary", 0, 0, 0, 0, "", err.Error())
return
}
}
expected, duplicateCount, err = parsePrimaryPackages(primaryData)
if err != nil {
if m.logger != nil {
m.logger.Write("rpm-mirror", util.LOG_ERROR, "sync failed repo=%s path=%s step=parse_primary err=%v", task.RepoID, task.MirrorPath, err)
}
_ = m.store.FinishRPMMirrorTask(task.RepoID, task.MirrorPath, false, "", err.Error())
_ = m.store.FinishRPMMirrorRun(runID, time.Now().UTC().Unix(), "failed", "fetch_primary", 0, 0, 0, 0, "", err.Error())
return
}
if m.logger != nil {
m.logger.Write("rpm-mirror", util.LOG_INFO, "primary parsed repo=%s path=%s primary_href=%s packages=%d", task.RepoID, task.MirrorPath, primaryHref, len(expected))
if duplicateCount > 0 {
m.logger.Write("rpm-mirror", util.LOG_WARN, "primary has duplicate package paths repo=%s path=%s primary_href=%s duplicates=%d", task.RepoID, task.MirrorPath, primaryHref, duplicateCount)
}
}
total, done, failed, deleted, changed, err = m.applyMirror(syncCtx, task, localRoot, client, cfg, expected)
if err != nil {
canceled = errors.Is(err, context.Canceled)
if m.logger != nil {
if canceled {
m.logger.Write("rpm-mirror", util.LOG_WARN, "sync canceled repo=%s path=%s step=apply total=%d done=%d failed=%d deleted=%d err=%v", task.RepoID, task.MirrorPath, total, done, failed, deleted, err)
} else {
m.logger.Write("rpm-mirror", util.LOG_ERROR, "sync failed repo=%s path=%s step=apply total=%d done=%d failed=%d deleted=%d err=%v", task.RepoID, task.MirrorPath, total, done, failed, deleted, err)
}
}
if canceled {
_ = m.store.FinishRPMMirrorTask(task.RepoID, task.MirrorPath, false, "", "sync canceled by user")
_ = m.store.FinishRPMMirrorRun(runID, time.Now().UTC().Unix(), "failed", "canceled", total, done, failed, deleted, "", "sync canceled by user")
} else {
_ = m.store.FinishRPMMirrorTask(task.RepoID, task.MirrorPath, false, "", err.Error())
_ = m.store.FinishRPMMirrorRun(runID, time.Now().UTC().Unix(), "failed", "apply", total, done, failed, deleted, "", err.Error())
}
return
}
if m.meta != nil && changed > 0 {
if m.logger != nil {
m.logger.Write("rpm-mirror", util.LOG_INFO, "repodata schedule repo=%s path=%s reason=sync_changed changed=%d", task.RepoID, task.MirrorPath, changed)
}
m.meta.Schedule(localRoot)
}
_ = m.store.FinishRPMMirrorTask(task.RepoID, task.MirrorPath, true, revision, "")
_ = m.store.FinishRPMMirrorRun(runID, time.Now().UTC().Unix(), "success", "done", total, done, failed, deleted, revision, "")
_ = m.store.CleanupRPMMirrorRunsRetention(task.RepoID, task.MirrorPath, 200, 30)
if m.logger != nil {
m.logger.Write("rpm-mirror", util.LOG_INFO, "sync done repo=%s path=%s status=success total=%d done=%d failed=%d deleted=%d revision=%s", task.RepoID, task.MirrorPath, total, done, failed, deleted, revision)
}
}
func (m *MirrorManager) applyMirror(ctx context.Context, task models.RPMMirrorTask, localRoot string, client *http.Client, cfg mirrorHTTPConfig, expected map[string]mirrorChecksum) (int64, int64, int64, int64, int64, error) {
var local map[string]bool
var total int64
var done int64
var failed int64
var deleted int64
var changed int64
var path string
var checksum mirrorChecksum
var fullPath string
var localSum string
var needDownload bool
var err error
local, err = listLocalRPMs(localRoot)
if err != nil {
return 0, 0, 0, 0, 0, err
}
total = int64(len(expected))
_ = m.store.UpdateRPMMirrorTaskProgress(task.RepoID, task.MirrorPath, "apply", total, 0, 0, 0)
for path = range local {
select {
case <-ctx.Done():
return total, done, failed, deleted, changed, ctx.Err()
default:
}
if expected[path].Value != "" {
continue
}
if m.logger != nil {
m.logger.Write("rpm-mirror", util.LOG_DEBUG, "delete local stale repo=%s path=%s file=%s", task.RepoID, task.MirrorPath, path)
}
err = os.Remove(filepath.Join(localRoot, filepath.FromSlash(path)))
if err == nil || os.IsNotExist(err) {
deleted = deleted + 1
changed = changed + 1
} else {
if m.logger != nil {
m.logger.Write("rpm-mirror", util.LOG_WARN, "delete local stale failed repo=%s path=%s file=%s err=%v", task.RepoID, task.MirrorPath, path, err)
}
}
}
for path, checksum = range expected {
select {
case <-ctx.Done():
return total, done, failed, deleted, changed, ctx.Err()
default:
}
fullPath = filepath.Join(localRoot, filepath.FromSlash(path))
needDownload = true
_, err = os.Stat(fullPath)
if err == nil {
localSum, err = fileHexByAlgo(fullPath, checksum.Algo)
if err == nil && (checksum.Value == "" || strings.EqualFold(localSum, checksum.Value)) {
needDownload = false
}
}
if needDownload {
if m.logger != nil {
m.logger.Write("rpm-mirror", util.LOG_DEBUG, "download start repo=%s path=%s file=%s checksum_type=%s checksum=%s", task.RepoID, task.MirrorPath, path, checksum.Algo, checksum.Value)
}
err = mirrorDownload(ctx, client, cfg, path, fullPath, checksum.Algo, checksum.Value)
if err != nil {
failed = failed + 1
if m.logger != nil {
m.logger.Write("rpm-mirror", util.LOG_WARN, "download failed repo=%s path=%s file=%s err=%v", task.RepoID, task.MirrorPath, path, err)
}
_ = m.store.UpdateRPMMirrorTaskProgress(task.RepoID, task.MirrorPath, "apply", total, done, failed, deleted)
continue
}
changed = changed + 1
if m.logger != nil {
m.logger.Write("rpm-mirror", util.LOG_DEBUG, "download done repo=%s path=%s file=%s", task.RepoID, task.MirrorPath, path)
}
} else {
if m.logger != nil {
m.logger.Write("rpm-mirror", util.LOG_DEBUG, "download skip repo=%s path=%s file=%s reason=up-to-date", task.RepoID, task.MirrorPath, path)
}
}
done = done + 1
_ = m.store.UpdateRPMMirrorTaskProgress(task.RepoID, task.MirrorPath, "apply", total, done, failed, deleted)
}
if failed > 0 {
return total, done, failed, deleted, changed, errors.New("some mirror files failed to sync")
}
return total, done, failed, deleted, changed, nil
}
func buildMirrorHTTPConfig(task models.RPMMirrorTask) (mirrorHTTPConfig, error) {
var cfg mirrorHTTPConfig
var u *url.URL
var err error
cfg = mirrorHTTPConfig{
BaseURL: strings.TrimRight(strings.TrimSpace(task.RemoteURL), "/"),
ConnectHost: strings.TrimSpace(task.ConnectHost),
HostHeader: strings.TrimSpace(task.HostHeader),
TLSServerName: strings.TrimSpace(task.TLSServerName),
TLSInsecure: task.TLSInsecureSkipVerify,
}
if cfg.BaseURL == "" {
return cfg, errors.New("remote url is empty")
}
u, err = url.Parse(cfg.BaseURL)
if err != nil {
return cfg, err
}
cfg.DefaultHost = u.Host
cfg.DefaultServer = u.Hostname()
if cfg.DefaultHost == "" {
return cfg, errors.New("remote url host is empty")
}
return cfg, nil
}
func buildMirrorHTTPClient(cfg mirrorHTTPConfig) *http.Client {
var transport *http.Transport
transport = &http.Transport{
Proxy: http.ProxyFromEnvironment,
TLSClientConfig: &tls.Config{
InsecureSkipVerify: cfg.TLSInsecure,
ServerName: effectiveServerName(cfg),
},
}
if cfg.ConnectHost != "" {
transport.DialContext = func(ctx context.Context, network string, addr string) (net.Conn, error) {
var d net.Dialer
_ = addr
return d.DialContext(ctx, network, cfg.ConnectHost)
}
}
return &http.Client{
Transport: transport,
Timeout: 60 * time.Second,
}
}
func effectiveHostHeader(cfg mirrorHTTPConfig) string {
if strings.TrimSpace(cfg.HostHeader) != "" {
return strings.TrimSpace(cfg.HostHeader)
}
return cfg.DefaultHost
}
func effectiveServerName(cfg mirrorHTTPConfig) string {
var host string
if strings.TrimSpace(cfg.TLSServerName) != "" {
return strings.TrimSpace(cfg.TLSServerName)
}
host = strings.TrimSpace(cfg.HostHeader)
if host != "" {
if strings.Contains(host, ":") {
return strings.Split(host, ":")[0]
}
return host
}
return cfg.DefaultServer
}
func mirrorFetch(ctx context.Context, client *http.Client, cfg mirrorHTTPConfig, rel string) ([]byte, error) {
var fullURL string
var req *http.Request
var res *http.Response
var body []byte
var err error
fullURL = joinRemoteURL(cfg.BaseURL, rel)
req, err = http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil)
if err != nil {
return nil, err
}
req.Host = effectiveHostHeader(cfg)
res, err = client.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
if res.StatusCode < 200 || res.StatusCode >= 300 {
return nil, errors.New("upstream request failed: " + res.Status)
}
body, err = io.ReadAll(res.Body)
if err != nil {
return nil, err
}
return body, nil
}
func mirrorDownload(ctx context.Context, client *http.Client, cfg mirrorHTTPConfig, rel string, dstPath string, checksumType string, checksum string) error {
var fullURL string
var req *http.Request
var res *http.Response
var tempPath string
var out *os.File
var hash hashWriter
var copied int64
var actualSum string
var contentType string
var finalURL string
var err error
err = os.MkdirAll(filepath.Dir(dstPath), 0o755)
if err != nil {
return err
}
fullURL = joinRemoteURL(cfg.BaseURL, rel)
req, err = http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil)
if err != nil {
return err
}
req.Host = effectiveHostHeader(cfg)
res, err = client.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
if res.StatusCode < 200 || res.StatusCode >= 300 {
return errors.New("upstream request failed: " + res.Status)
}
contentType = strings.TrimSpace(res.Header.Get("Content-Type"))
finalURL = ""
if res.Request != nil && res.Request.URL != nil {
finalURL = res.Request.URL.String()
}
tempPath = dstPath + ".mirror.tmp"
out, err = os.Create(tempPath)
if err != nil {
return err
}
defer out.Close()
hash, err = newHashWriter(checksumType)
if err != nil {
_ = os.Remove(tempPath)
return err
}
copied, err = io.Copy(io.MultiWriter(out, hash), res.Body)
_ = copied
if err != nil {
_ = os.Remove(tempPath)
return err
}
err = out.Close()
if err != nil {
_ = os.Remove(tempPath)
return err
}
if strings.TrimSpace(checksum) != "" {
actualSum = hash.Sum()
if !strings.EqualFold(actualSum, strings.TrimSpace(checksum)) {
_ = os.Remove(tempPath)
return errors.New(
"download checksum mismatch for " + rel +
" type=" + normalizeChecksumAlgo(checksumType) +
" expected=" + strings.TrimSpace(checksum) +
" actual=" + actualSum +
" bytes=" + int64ToString(copied) +
" content_type=" + contentType +
" url=" + finalURL)
}
}
err = os.Rename(tempPath, dstPath)
if err != nil {
_ = os.Remove(tempPath)
return err
}
return nil
}
func joinRemoteURL(base string, rel string) string {
var baseURL *url.URL
var relURL *url.URL
var cleanRel string
var err error
cleanRel = strings.ReplaceAll(rel, "\\", "/")
baseURL, err = url.Parse(strings.TrimSpace(base))
if err != nil || baseURL == nil {
cleanRel = strings.TrimLeft(cleanRel, "/")
return strings.TrimRight(base, "/") + "/" + cleanRel
}
// Treat base as a directory root for repository-relative href resolution.
if !strings.HasSuffix(baseURL.Path, "/") {
baseURL.Path = baseURL.Path + "/"
}
relURL, err = url.Parse(strings.TrimSpace(cleanRel))
if err != nil || relURL == nil {
cleanRel = strings.TrimLeft(cleanRel, "/")
return strings.TrimRight(base, "/") + "/" + cleanRel
}
return baseURL.ResolveReference(relURL).String()
}
func parseRepomdPrimaryHref(data []byte) (string, error) {
var doc repomdDoc
var i int
var href string
var err error
err = xml.Unmarshal(data, &doc)
if err != nil {
return "", err
}
for i = 0; i < len(doc.Data); i++ {
if strings.EqualFold(strings.TrimSpace(doc.Data[i].Type), "primary") {
href = strings.TrimSpace(doc.Data[i].Location.Href)
if href != "" {
return href, nil
}
}
}
return "", errors.New("primary metadata not found in repomd")
}
func parsePrimaryPackages(data []byte) (map[string]mirrorChecksum, int, error) {
var doc primaryDoc
var out map[string]mirrorChecksum
var i int
var path string
var checksum string
var checksumType string
var fileTime int64
var buildTime int64
var existing mirrorChecksum
var ok bool
var duplicates int
var err error
err = xml.Unmarshal(data, &doc)
if err != nil {
return nil, 0, err
}
out = make(map[string]mirrorChecksum)
for i = 0; i < len(doc.Packages); i++ {
path = strings.TrimSpace(doc.Packages[i].Location.Href)
if path == "" {
continue
}
if !strings.HasSuffix(strings.ToLower(path), ".rpm") {
continue
}
checksum = strings.TrimSpace(doc.Packages[i].Checksum.Value)
checksumType = strings.TrimSpace(doc.Packages[i].Checksum.Type)
fileTime = parseTimeAttr(doc.Packages[i].Time.File)
buildTime = parseTimeAttr(doc.Packages[i].Time.Build)
if existing, ok = out[path]; ok {
duplicates = duplicates + 1
if !shouldReplaceDuplicate(existing, buildTime, fileTime, checksum) {
continue
}
}
out[path] = mirrorChecksum{
Algo: normalizeChecksumAlgo(checksumType),
Value: strings.ToLower(checksum),
BuildTime: buildTime,
FileTime: fileTime,
}
}
return out, duplicates, nil
}
func listLocalRPMs(root string) (map[string]bool, error) {
var out map[string]bool
var err error
out = make(map[string]bool)
err = filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error {
var rel string
if walkErr != nil {
return walkErr
}
if d == nil {
return nil
}
if d.IsDir() {
if strings.EqualFold(d.Name(), "repodata") {
return filepath.SkipDir
}
return nil
}
if !strings.HasSuffix(strings.ToLower(d.Name()), ".rpm") {
return nil
}
rel, err = filepath.Rel(root, path)
if err != nil {
return err
}
out[filepath.ToSlash(rel)] = true
return nil
})
if err != nil {
return nil, err
}
return out, nil
}
func sha256HexBytes(data []byte) string {
var sum [32]byte
sum = sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}
func fileHexByAlgo(path string, algo string) (string, error) {
var file *os.File
var hash hashWriter
var copied int64
var err error
file, err = os.Open(path)
if err != nil {
return "", err
}
defer file.Close()
hash, err = newHashWriter(algo)
if err != nil {
return "", err
}
copied, err = io.Copy(hash, file)
_ = copied
if err != nil {
return "", err
}
return hash.Sum(), nil
}
func gunzipBytes(data []byte) ([]byte, error) {
var reader *gzip.Reader
var input *bytes.Reader
var out []byte
var err error
input = bytes.NewReader(data)
reader, err = gzip.NewReader(input)
if err != nil {
return nil, err
}
defer reader.Close()
out, err = io.ReadAll(reader)
if err != nil {
return nil, err
}
return out, nil
}
type hashWriter interface {
io.Writer
Sum() string
}
type shaWriter struct {
h hash.Hash
}
func newHashWriter(algo string) (hashWriter, error) {
var w *shaWriter
var normalized string
var h hash.Hash
normalized = normalizeChecksumAlgo(algo)
switch normalized {
case "", "sha256":
h = sha256.New()
case "sha", "sha1":
h = sha1.New()
case "sha224":
h = sha256.New224()
case "sha384":
h = sha512.New384()
case "sha512":
h = sha512.New()
case "md5":
h = md5.New()
default:
return nil, errors.New("unsupported checksum type: " + normalized)
}
w = &shaWriter{h: h}
return w, nil
}
func (w *shaWriter) Write(p []byte) (int, error) {
return w.h.Write(p)
}
func (w *shaWriter) Sum() string {
var raw []byte
raw = w.h.Sum(nil)
return hex.EncodeToString(raw)
}
func normalizeChecksumAlgo(algo string) string {
var out string
out = strings.ToLower(strings.TrimSpace(algo))
out = strings.ReplaceAll(out, "-", "")
out = strings.ReplaceAll(out, "_", "")
if out == "sha1" {
return "sha1"
}
if out == "sha" {
return "sha"
}
if out == "sha224" {
return "sha224"
}
if out == "sha256" {
return "sha256"
}
if out == "sha384" {
return "sha384"
}
if out == "sha512" {
return "sha512"
}
if out == "md5" {
return "md5"
}
return out
}
func int64ToString(v int64) string {
return strconv.FormatInt(v, 10)
}
func mirrorTaskKey(repoID string, path string) string {
return repoID + "\x00" + path
}
func ensureRepodata(task models.RPMMirrorTask, localRoot string, meta *MetaManager, logger *util.Logger) {
var repomdPath string
var statErr error
repomdPath = filepath.Join(localRoot, "repodata", "repomd.xml")
_, statErr = os.Stat(repomdPath)
if statErr == nil {
return
}
if logger != nil {
logger.Write("rpm-mirror", util.LOG_INFO, "repodata schedule repo=%s path=%s reason=missing repomd=%s", task.RepoID, task.MirrorPath, repomdPath)
}
meta.Schedule(localRoot)
}
func parseTimeAttr(value string) int64 {
var trimmed string
var parsed int64
var err error
trimmed = strings.TrimSpace(value)
if trimmed == "" {
return 0
}
parsed, err = strconv.ParseInt(trimmed, 10, 64)
if err != nil {
return 0
}
return parsed
}
func shouldReplaceDuplicate(existing mirrorChecksum, newBuildTime int64, newFileTime int64, newChecksum string) bool {
var existingChecksum string
if newBuildTime > existing.BuildTime {
return true
}
if newBuildTime < existing.BuildTime {
return false
}
if newFileTime > existing.FileTime {
return true
}
if newFileTime < existing.FileTime {
return false
}
existingChecksum = strings.TrimSpace(existing.Value)
if existingChecksum == "" && strings.TrimSpace(newChecksum) != "" {
return true
}
return false
}

View File

@@ -1,15 +1,11 @@
package rpm
import "bufio"
import "bytes"
import "errors"
import "io/fs"
import "os/exec"
import "path/filepath"
import "sort"
import "strconv"
import "strings"
import "sync"
import repokit "repokit"
type PackageSummary struct {
Filename string `json:"filename"`
@@ -30,35 +26,23 @@ type PackageDetail struct {
Files []string `json:"files"`
Requires []string `json:"requires"`
Provides []string `json:"provides"`
Changelogs []PackageChangeLog `json:"changelogs"`
}
var rpmPath string
var rpmOnce sync.Once
var rpmErr error
func ensureRPM() error {
rpmOnce.Do(func() {
var path string
path, rpmErr = exec.LookPath("rpm")
if rpmErr != nil {
return
}
rpmPath = path
})
return rpmErr
type PackageChangeLog struct {
Author string `json:"author"`
Date int64 `json:"date"`
Text string `json:"text"`
}
func ListPackages(repoPath string) ([]PackageSummary, error) {
var err error
var packages []PackageSummary
var walkErr error
err = ensureRPM()
if err != nil {
return nil, err
}
var err error
walkErr = filepath.WalkDir(repoPath, func(path string, entry fs.DirEntry, entryErr error) error {
var lower string
var rel string
var pkg *repokit.RpmPackage
var summary PackageSummary
if entryErr != nil {
return entryErr
@@ -74,11 +58,11 @@ func ListPackages(repoPath string) ([]PackageSummary, error) {
if err != nil {
return err
}
summary, err = querySummary(path)
pkg, err = repokit.RpmPackageFromRpmBase(path, 0)
if err != nil {
return nil
}
summary.Filename = filepath.ToSlash(rel)
summary = packageSummaryFromRepokit(pkg, filepath.ToSlash(rel))
packages = append(packages, summary)
return nil
})
@@ -86,137 +70,135 @@ func ListPackages(repoPath string) ([]PackageSummary, error) {
return nil, walkErr
}
sort.Slice(packages, func(i int, j int) bool {
if packages[i].Name == packages[j].Name {
return packages[i].Filename < packages[j].Filename
}
return packages[i].Name < packages[j].Name
})
return packages, nil
}
func GetPackageDetail(repoPath string, filename string) (PackageDetail, error) {
var err error
var detail PackageDetail
var fullPath string
var data []string
var fileList []string
var requires []string
var provides []string
var buildTime int64
var size int64
err = ensureRPM()
if err != nil {
return detail, err
}
var pkg *repokit.RpmPackage
var err error
fullPath = filepath.Join(repoPath, filepath.FromSlash(filename))
data, err = queryFields(fullPath, "%{NAME}\n%{VERSION}\n%{RELEASE}\n%{ARCH}\n%{SUMMARY}\n%{DESCRIPTION}\n%{LICENSE}\n%{URL}\n%{BUILDTIME}\n%{SIZE}\n")
pkg, err = repokit.RpmPackageFromRpmBase(fullPath, 256)
if err != nil {
return detail, err
}
if len(data) < 10 {
return detail, errors.New("rpm query returned incomplete metadata")
}
buildTime, _ = strconv.ParseInt(strings.TrimSpace(data[8]), 10, 64)
size, _ = strconv.ParseInt(strings.TrimSpace(data[9]), 10, 64)
fileList, _ = queryList(fullPath)
requires, _ = queryLines(fullPath, "--requires")
provides, _ = queryLines(fullPath, "--provides")
detail = PackageDetail{
PackageSummary: PackageSummary{
Filename: filename,
Name: strings.TrimSpace(data[0]),
Version: strings.TrimSpace(data[1]),
Release: strings.TrimSpace(data[2]),
Arch: strings.TrimSpace(data[3]),
Summary: strings.TrimSpace(data[4]),
},
Description: strings.TrimSpace(data[5]),
License: strings.TrimSpace(data[6]),
URL: strings.TrimSpace(data[7]),
BuildTime: buildTime,
Size: size,
Files: fileList,
Requires: requires,
Provides: provides,
}
detail = packageDetailFromRepokit(pkg, filename)
return detail, nil
}
func querySummary(path string) (PackageSummary, error) {
var fields []string
var err error
func packageSummaryFromRepokit(pkg *repokit.RpmPackage, filename string) PackageSummary {
var summary PackageSummary
fields, err = queryFields(path, "%{NAME}\n%{VERSION}\n%{RELEASE}\n%{ARCH}\n%{SUMMARY}\n")
if err != nil {
return summary, err
summary = PackageSummary{
Filename: filename,
Name: strings.TrimSpace(pkg.Name),
Version: strings.TrimSpace(pkg.Version),
Release: strings.TrimSpace(pkg.Release),
Arch: strings.TrimSpace(pkg.Arch),
Summary: strings.TrimSpace(pkg.Summary),
}
if len(fields) < 5 {
return summary, errors.New("rpm query returned incomplete metadata")
}
summary.Name = strings.TrimSpace(fields[0])
summary.Version = strings.TrimSpace(fields[1])
summary.Release = strings.TrimSpace(fields[2])
summary.Arch = strings.TrimSpace(fields[3])
summary.Summary = strings.TrimSpace(fields[4])
return summary, nil
return summary
}
func queryFields(path string, format string) ([]string, error) {
var output []byte
var err error
var list []string
output, err = runRPM(path, "-qp", "--qf", format)
if err != nil {
return nil, err
}
list = strings.Split(strings.TrimSuffix(string(output), "\n"), "\n")
return list, nil
}
func queryList(path string) ([]string, error) {
var output []byte
var err error
var scanner *bufio.Scanner
func packageDetailFromRepokit(pkg *repokit.RpmPackage, filename string) PackageDetail {
var files []string
output, err = runRPM(path, "-qlp")
if err != nil {
return nil, err
var file repokit.RpmPackageFile
var changelogs []PackageChangeLog
var changelog repokit.RpmChangelogEntry
var detail PackageDetail
files = make([]string, 0, len(pkg.Files))
for _, file = range pkg.Files {
if file.FullPath == "" {
continue
}
files = append(files, file.FullPath)
}
scanner = bufio.NewScanner(bytes.NewReader(output))
for scanner.Scan() {
files = append(files, scanner.Text())
changelogs = make([]PackageChangeLog, 0, len(pkg.Changelogs))
for _, changelog = range pkg.Changelogs {
changelogs = append(changelogs, PackageChangeLog{
Author: strings.TrimSpace(changelog.Author),
Date: changelog.Date,
Text: strings.TrimSpace(changelog.Changelog),
})
}
return files, nil
sort.SliceStable(changelogs, func(i int, j int) bool {
return changelogs[i].Date > changelogs[j].Date
})
sort.Strings(files)
detail = PackageDetail{
PackageSummary: packageSummaryFromRepokit(pkg, filename),
Description: strings.TrimSpace(pkg.Description),
License: strings.TrimSpace(pkg.RpmLicense),
URL: strings.TrimSpace(pkg.Url),
BuildTime: pkg.TimeBuild,
Size: pkg.SizePackage,
Files: files,
Requires: dependencyListToStrings(pkg.Requires),
Provides: dependencyListToStrings(pkg.Provides),
Changelogs: changelogs,
}
return detail
}
func queryLines(path string, flag string) ([]string, error) {
var output []byte
var err error
var scanner *bufio.Scanner
func dependencyListToStrings(deps []repokit.RpmDependency) []string {
var lines []string
output, err = runRPM(path, "-qp", flag)
if err != nil {
return nil, err
var dep repokit.RpmDependency
lines = make([]string, 0, len(deps))
for _, dep = range deps {
lines = append(lines, dependencyToString(dep))
}
scanner = bufio.NewScanner(bytes.NewReader(output))
for scanner.Scan() {
lines = append(lines, scanner.Text())
}
return lines, nil
return lines
}
func runRPM(path string, args ...string) ([]byte, error) {
var err error
var cmd *exec.Cmd
var output []byte
var full []string
err = ensureRPM()
if err != nil {
return nil, err
func dependencyToString(dep repokit.RpmDependency) string {
var op string
var evr string
var line string
op = normalizeDependencyOp(dep.Flags)
evr = dependencyEVR(dep)
line = dep.Name
if op == "" || evr == "" {
return line
}
full = append([]string{}, args...)
full = append(full, path)
cmd = exec.Command(rpmPath, full...)
output, err = cmd.Output()
if err != nil {
return nil, err
}
return output, nil
line = line + " " + op + " " + evr
return line
}
func normalizeDependencyOp(flag string) string {
switch strings.ToUpper(strings.TrimSpace(flag)) {
case "LT":
return "<"
case "GT":
return ">"
case "EQ":
return "="
case "LE":
return "<="
case "GE":
return ">="
default:
return strings.TrimSpace(flag)
}
}
func dependencyEVR(dep repokit.RpmDependency) string {
var value string
var version string
version = strings.TrimSpace(dep.Version)
if version == "" {
return ""
}
value = version
if strings.TrimSpace(dep.Release) != "" {
value = value + "-" + strings.TrimSpace(dep.Release)
}
if strings.TrimSpace(dep.Epoch) != "" && strings.TrimSpace(dep.Epoch) != "0" {
value = strings.TrimSpace(dep.Epoch) + ":" + value
}
return value
}

View File

@@ -8,10 +8,12 @@ import "sort"
import "strings"
type TreeEntry struct {
Name string `json:"name"`
Path string `json:"path"`
Type string `json:"type"`
Size int64 `json:"size"`
Name string `json:"name"`
Path string `json:"path"`
Type string `json:"type"`
Size int64 `json:"size"`
IsRepoDir bool `json:"is_repo_dir"`
RepoMode string `json:"repo_mode"`
}
var ErrPathNotFound = errors.New("path not found")

View File

@@ -0,0 +1,56 @@
package storage
import "io"
import "os"
import "path/filepath"
import "strings"
import "testing"
func TestFileStoreSaveAndOpen(t *testing.T) {
var fs FileStore
var content string
var path string
var n int64
var err error
var file *os.File
var data []byte
fs = FileStore{BaseDir: filepath.Join(t.TempDir(), "uploads")}
content = "hello storage"
path, n, err = fs.Save("a.txt", strings.NewReader(content))
if err != nil {
t.Fatalf("Save() error: %v", err)
}
if n != int64(len(content)) {
t.Fatalf("unexpected bytes copied: got=%d want=%d", n, len(content))
}
file, err = fs.Open(path)
if err != nil {
t.Fatalf("Open() error: %v", err)
}
defer file.Close()
data, err = io.ReadAll(file)
if err != nil {
t.Fatalf("ReadAll() error: %v", err)
}
if string(data) != content {
t.Fatalf("unexpected content: got=%q want=%q", string(data), content)
}
}
func TestFileStoreEnsureCreatesDirectory(t *testing.T) {
var fs FileStore
var err error
var st os.FileInfo
fs = FileStore{BaseDir: filepath.Join(t.TempDir(), "x", "y", "z")}
err = fs.Ensure()
if err != nil {
t.Fatalf("Ensure() error: %v", err)
}
st, err = os.Stat(fs.BaseDir)
if err != nil {
t.Fatalf("stat basedir: %v", err)
}
if !st.IsDir() {
t.Fatalf("base path is not directory")
}
}

View File

@@ -0,0 +1,35 @@
package util
import "regexp"
import "testing"
func TestNewIDFormat(t *testing.T) {
var id string
var err error
var re *regexp.Regexp
re = regexp.MustCompile("^[0-9a-f]{32}$")
id, err = NewID()
if err != nil {
t.Fatalf("NewID() error: %v", err)
}
if !re.MatchString(id) {
t.Fatalf("invalid id format: %s", id)
}
}
func TestNewIDUniqueness(t *testing.T) {
var a string
var b string
var err error
a, err = NewID()
if err != nil {
t.Fatalf("NewID() error for first id: %v", err)
}
b, err = NewID()
if err != nil {
t.Fatalf("NewID() error for second id: %v", err)
}
if a == b {
t.Fatalf("ids must differ: %s", a)
}
}

View File

@@ -280,7 +280,7 @@ func (l* Logger) log_level_to_ansi_code(level LogLevel) string {
}
}
func LogStrToMask(str []string) LogMask {
func LogStrsToMask(str []string) LogMask {
var mask LogMask

View File

@@ -0,0 +1,10 @@
package util
import "crypto/sha256"
import "encoding/hex"
func HashToken(token string) string {
var sum [32]byte
sum = sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}

View File

@@ -0,0 +1,28 @@
package util
import "testing"
func TestHashTokenDeterministic(t *testing.T) {
var input string
var first string
var second string
input = "sample-token"
first = HashToken(input)
second = HashToken(input)
if first != second {
t.Fatalf("hash must be deterministic: %s vs %s", first, second)
}
if len(first) != 64 {
t.Fatalf("hash length must be 64, got %d", len(first))
}
}
func TestHashTokenDifferentInputs(t *testing.T) {
var a string
var b string
a = HashToken("token-a")
b = HashToken("token-b")
if a == b {
t.Fatalf("different tokens must produce different hashes")
}
}

View File

@@ -1,7 +1,8 @@
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_id TEXT NOT NULL UNIQUE,
username TEXT NOT NULL UNIQUE,
display_name TEXT NOT NULL,
email TEXT NOT NULL,
@@ -9,12 +10,13 @@ CREATE TABLE IF NOT EXISTS users (
is_admin INTEGER NOT NULL DEFAULT 0,
auth_source TEXT NOT NULL DEFAULT 'db',
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL
updated_at TIMESTAMP NOT NULL,
disabled INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
user_id INTEGER NOT NULL,
token TEXT NOT NULL UNIQUE,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP NOT NULL,
@@ -22,19 +24,25 @@ CREATE TABLE IF NOT EXISTS sessions (
);
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_id TEXT NOT NULL UNIQUE,
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
description TEXT NOT NULL,
created_by TEXT NOT NULL,
home_page TEXT NOT NULL DEFAULT 'info',
created_by INTEGER NOT NULL,
updated_by INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
created_at_unix INTEGER NOT NULL DEFAULT 0,
updated_at_unix INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (created_by) REFERENCES users(id)
FOREIGN KEY (updated_by) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS project_members (
project_id TEXT NOT NULL,
user_id TEXT NOT NULL,
project_id INTEGER NOT NULL,
user_id INTEGER NOT NULL,
role TEXT NOT NULL,
created_at TIMESTAMP NOT NULL,
PRIMARY KEY (project_id, user_id),
@@ -43,24 +51,27 @@ CREATE TABLE IF NOT EXISTS project_members (
);
CREATE TABLE IF NOT EXISTS repos (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_id TEXT NOT NULL UNIQUE,
project_id INTEGER NOT NULL,
name TEXT NOT NULL,
type TEXT NOT NULL DEFAULT 'git',
path TEXT NOT NULL,
created_by TEXT NOT NULL,
created_by INTEGER NOT NULL,
created_at TIMESTAMP NOT NULL,
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
FOREIGN KEY (created_by) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS issues (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_id TEXT NOT NULL UNIQUE,
project_id INTEGER NOT NULL,
title TEXT NOT NULL,
body TEXT NOT NULL,
status TEXT NOT NULL,
created_by TEXT NOT NULL,
assignee_id TEXT,
created_by INTEGER NOT NULL,
assignee_id INTEGER,
created_at TIMESTAMP NOT NULL,
updated_at TIMESTAMP NOT NULL,
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
@@ -69,22 +80,24 @@ CREATE TABLE IF NOT EXISTS issues (
);
CREATE TABLE IF NOT EXISTS issue_comments (
id TEXT PRIMARY KEY,
issue_id TEXT NOT NULL,
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_id TEXT NOT NULL UNIQUE,
issue_id INTEGER NOT NULL,
body TEXT NOT NULL,
created_by TEXT NOT NULL,
created_by INTEGER NOT NULL,
created_at TIMESTAMP NOT NULL,
FOREIGN KEY (issue_id) REFERENCES issues(id) ON DELETE CASCADE,
FOREIGN KEY (created_by) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS wiki_pages (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_id TEXT NOT NULL UNIQUE,
project_id INTEGER NOT NULL,
title TEXT NOT NULL,
slug TEXT NOT NULL,
body TEXT NOT NULL,
created_by TEXT NOT NULL,
created_by INTEGER NOT NULL,
updated_at TIMESTAMP NOT NULL,
UNIQUE (project_id, slug),
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
@@ -92,20 +105,203 @@ CREATE TABLE IF NOT EXISTS wiki_pages (
);
CREATE TABLE IF NOT EXISTS uploads (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL,
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_id TEXT NOT NULL UNIQUE,
project_id INTEGER NOT NULL,
filename TEXT NOT NULL,
content_type TEXT NOT NULL,
size INTEGER NOT NULL,
storage_path TEXT NOT NULL,
created_by TEXT NOT NULL,
created_by INTEGER NOT NULL,
created_at TIMESTAMP NOT NULL,
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
FOREIGN KEY (created_by) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS project_repos (
project_id INTEGER NOT NULL,
repo_id INTEGER NOT NULL,
created_at TIMESTAMP NOT NULL,
PRIMARY KEY (project_id, repo_id),
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
FOREIGN KEY (repo_id) REFERENCES repos(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_id TEXT NOT NULL UNIQUE,
user_id INTEGER NOT NULL,
name TEXT NOT NULL,
token_hash TEXT NOT NULL,
token_prefix TEXT NOT NULL,
created_at INTEGER NOT NULL,
last_used_at INTEGER NOT NULL DEFAULT 0,
expires_at INTEGER NOT NULL DEFAULT 0,
disabled INTEGER NOT NULL DEFAULT 0,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS pki_cas (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_id TEXT NOT NULL UNIQUE,
name TEXT NOT NULL UNIQUE,
parent_ca_id INTEGER,
is_root INTEGER NOT NULL DEFAULT 0,
cert_pem TEXT NOT NULL,
key_pem TEXT NOT NULL,
serial_counter INTEGER NOT NULL DEFAULT 1,
status TEXT NOT NULL DEFAULT 'active',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY(parent_ca_id) REFERENCES pki_cas(id)
);
CREATE TABLE IF NOT EXISTS pki_certs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_id TEXT NOT NULL UNIQUE,
ca_id INTEGER,
serial_hex TEXT NOT NULL,
common_name TEXT NOT NULL,
san_dns TEXT NOT NULL DEFAULT '',
san_ips TEXT NOT NULL DEFAULT '',
is_ca INTEGER NOT NULL DEFAULT 0,
cert_pem TEXT NOT NULL,
key_pem TEXT NOT NULL,
not_before INTEGER NOT NULL,
not_after INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'active',
revoked_at INTEGER NOT NULL DEFAULT 0,
revocation_reason TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
FOREIGN KEY(ca_id) REFERENCES pki_cas(id) ON DELETE CASCADE,
UNIQUE(ca_id, serial_hex)
);
CREATE TABLE IF NOT EXISTS tls_listeners (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_id TEXT NOT NULL UNIQUE,
name TEXT NOT NULL UNIQUE,
enabled INTEGER NOT NULL DEFAULT 1,
http_addrs TEXT NOT NULL DEFAULT '',
https_addrs TEXT NOT NULL DEFAULT '',
tls_server_cert_source TEXT NOT NULL DEFAULT 'files',
tls_cert_file TEXT NOT NULL DEFAULT '',
tls_key_file TEXT NOT NULL DEFAULT '',
tls_pki_server_cert_id TEXT NOT NULL DEFAULT '',
tls_client_auth TEXT NOT NULL DEFAULT 'none',
tls_client_ca_file TEXT NOT NULL DEFAULT '',
tls_pki_client_ca_id TEXT NOT NULL DEFAULT '',
tls_min_version TEXT NOT NULL DEFAULT '1.2',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
auth_policy TEXT NOT NULL DEFAULT 'default',
apply_policy_api INTEGER NOT NULL DEFAULT 0,
apply_policy_git INTEGER NOT NULL DEFAULT 0,
apply_policy_rpm INTEGER NOT NULL DEFAULT 0,
apply_policy_v2 INTEGER NOT NULL DEFAULT 0,
client_cert_allowlist TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS service_principals (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_id TEXT NOT NULL UNIQUE,
name TEXT NOT NULL UNIQUE,
description TEXT NOT NULL DEFAULT '',
disabled INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
is_admin INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS cert_principal_bindings (
fingerprint TEXT PRIMARY KEY,
principal_id INTEGER NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
FOREIGN KEY (principal_id) REFERENCES service_principals(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS principal_project_roles (
principal_id INTEGER NOT NULL,
project_id INTEGER NOT NULL,
role TEXT NOT NULL,
created_at INTEGER NOT NULL,
PRIMARY KEY (principal_id, project_id),
FOREIGN KEY (principal_id) REFERENCES service_principals(id) ON DELETE CASCADE,
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS rpm_repo_dirs (
repo_id INTEGER NOT NULL,
path TEXT NOT NULL,
mode TEXT NOT NULL DEFAULT 'local',
allow_delete INTEGER NOT NULL DEFAULT 0,
remote_url TEXT NOT NULL DEFAULT '',
connect_host TEXT NOT NULL DEFAULT '',
host_header TEXT NOT NULL DEFAULT '',
tls_server_name TEXT NOT NULL DEFAULT '',
tls_insecure_skip_verify INTEGER NOT NULL DEFAULT 0,
sync_interval_sec INTEGER NOT NULL DEFAULT 300,
sync_enabled INTEGER NOT NULL DEFAULT 1,
dirty INTEGER NOT NULL DEFAULT 1,
next_sync_at INTEGER NOT NULL DEFAULT 0,
sync_running INTEGER NOT NULL DEFAULT 0,
sync_status TEXT NOT NULL DEFAULT 'idle',
sync_error TEXT NOT NULL DEFAULT '',
sync_step TEXT NOT NULL DEFAULT '',
sync_total INTEGER NOT NULL DEFAULT 0,
sync_done INTEGER NOT NULL DEFAULT 0,
sync_failed INTEGER NOT NULL DEFAULT 0,
sync_deleted INTEGER NOT NULL DEFAULT 0,
last_sync_started_at INTEGER NOT NULL DEFAULT 0,
last_sync_finished_at INTEGER NOT NULL DEFAULT 0,
last_sync_success_at INTEGER NOT NULL DEFAULT 0,
last_synced_revision TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
PRIMARY KEY (repo_id, path),
FOREIGN KEY (repo_id) REFERENCES repos(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS rpm_mirror_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
public_id TEXT NOT NULL UNIQUE,
repo_id INTEGER NOT NULL,
path TEXT NOT NULL,
started_at INTEGER NOT NULL,
finished_at INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'running',
step TEXT NOT NULL DEFAULT '',
total INTEGER NOT NULL DEFAULT 0,
done INTEGER NOT NULL DEFAULT 0,
failed INTEGER NOT NULL DEFAULT 0,
deleted INTEGER NOT NULL DEFAULT 0,
revision TEXT NOT NULL DEFAULT '',
error TEXT NOT NULL DEFAULT '',
FOREIGN KEY(repo_id, path) REFERENCES rpm_repo_dirs(repo_id, path) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_users_disabled ON users(disabled);
CREATE INDEX IF NOT EXISTS idx_projects_name ON projects(name);
CREATE INDEX IF NOT EXISTS idx_repos_project ON repos(project_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_repos_project_name_type ON repos(project_id, name, type);
CREATE INDEX IF NOT EXISTS idx_issues_project ON issues(project_id);
CREATE INDEX IF NOT EXISTS idx_wiki_project ON wiki_pages(project_id);
CREATE INDEX IF NOT EXISTS idx_uploads_project ON uploads(project_id);
CREATE UNIQUE INDEX IF NOT EXISTS idx_api_keys_hash ON api_keys(token_hash);
CREATE INDEX IF NOT EXISTS idx_api_keys_user_id ON api_keys(user_id);
CREATE INDEX IF NOT EXISTS idx_api_keys_expires_at ON api_keys(expires_at);
CREATE INDEX IF NOT EXISTS idx_api_keys_disabled ON api_keys(disabled);
CREATE INDEX IF NOT EXISTS idx_project_repos_project ON project_repos(project_id);
CREATE INDEX IF NOT EXISTS idx_project_repos_repo ON project_repos(repo_id);
CREATE INDEX IF NOT EXISTS idx_pki_cas_parent ON pki_cas(parent_ca_id);
CREATE INDEX IF NOT EXISTS idx_pki_certs_ca ON pki_certs(ca_id);
CREATE INDEX IF NOT EXISTS idx_cert_principal_bindings_principal_id ON cert_principal_bindings(principal_id);
CREATE INDEX IF NOT EXISTS idx_principal_project_roles_project_id ON principal_project_roles(project_id);

View File

@@ -1,13 +0,0 @@
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS project_repos (
project_id TEXT NOT NULL,
repo_id TEXT NOT NULL,
created_at TIMESTAMP NOT NULL,
PRIMARY KEY (project_id, repo_id),
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE,
FOREIGN KEY (repo_id) REFERENCES repos(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_project_repos_project ON project_repos(project_id);
CREATE INDEX IF NOT EXISTS idx_project_repos_repo ON project_repos(repo_id);

View File

@@ -1,10 +0,0 @@
ALTER TABLE projects ADD COLUMN updated_by TEXT NOT NULL DEFAULT '';
ALTER TABLE projects ADD COLUMN created_at_unix INTEGER NOT NULL DEFAULT 0;
ALTER TABLE projects ADD COLUMN updated_at_unix INTEGER NOT NULL DEFAULT 0;
UPDATE projects
SET
updated_by = created_by,
created_at_unix = COALESCE(CAST(strftime('%s', created_at) AS INTEGER), 0),
updated_at_unix = COALESCE(CAST(strftime('%s', updated_at) AS INTEGER), 0)
WHERE created_at_unix = 0 OR updated_at_unix = 0 OR updated_by = '';

View File

@@ -1,3 +0,0 @@
PRAGMA foreign_keys = ON;
ALTER TABLE repos ADD COLUMN type TEXT NOT NULL DEFAULT 'git';

View File

@@ -1,3 +0,0 @@
PRAGMA foreign_keys = ON;
CREATE UNIQUE INDEX IF NOT EXISTS idx_repos_project_name_type ON repos(project_id, name, type);

View File

@@ -4,6 +4,7 @@ export interface User {
display_name: string
email: string
is_admin: boolean
disabled?: boolean
}
export interface Project {
@@ -11,6 +12,7 @@ export interface Project {
slug: string
name: string
description: string
home_page?: 'info' | 'repos' | 'issues' | 'wiki' | 'files'
created_by?: string
updated_by?: string
created_by_name?: string
@@ -23,9 +25,10 @@ export interface Repo {
id: string
project_id: string
name: string
type?: 'git' | 'rpm'
type?: 'git' | 'rpm' | 'docker'
clone_url?: string
rpm_url?: string
docker_url?: string
is_foreign?: boolean
owner_project?: string
owner_slug?: string
@@ -62,6 +65,31 @@ export interface RpmPackageDetail extends RpmPackageSummary {
files: string[]
requires: string[]
provides: string[]
changelogs: { author: string; date: number; text: string }[]
}
export interface DockerTagInfo {
tag: string
digest: string
size: number
media_type: string
}
export interface DockerManifestDetail {
reference: string
digest: string
media_type: string
size: number
config: {
created?: string
architecture?: string
os?: string
}
layers: {
mediaType: string
digest: string
size: number
}[]
}
export interface RpmTreeEntry {
@@ -69,6 +97,54 @@ export interface RpmTreeEntry {
path: string
type: 'file' | 'dir'
size: number
is_repo_dir?: boolean
repo_mode?: 'local' | 'mirror' | ''
}
export interface RpmRepoDirConfig {
repo_id: string
path: string
mode: 'local' | 'mirror' | ''
allow_delete: boolean
remote_url: string
connect_host: string
host_header: string
tls_server_name: string
tls_insecure_skip_verify: boolean
sync_interval_sec: number
sync_enabled: boolean
dirty: boolean
next_sync_at: number
sync_running: boolean
sync_status: string
sync_error: string
sync_step: string
sync_total: number
sync_done: number
sync_failed: number
sync_deleted: number
last_sync_started_at: number
last_sync_finished_at: number
last_sync_success_at: number
last_synced_revision: string
created_at: number
updated_at: number
}
export interface RpmMirrorRun {
id: string
repo_id: string
path: string
started_at: number
finished_at: number
status: string
step: string
total: number
done: number
failed: number
deleted: number
revision: string
error: string
}
export interface RepoTypeItem {
@@ -145,6 +221,152 @@ export interface ProjectMember {
role: string
}
export interface APIKey {
id: string
user_id: string
name: string
prefix: string
created_at: number
last_used_at: number
expires_at: number
disabled?: boolean
}
export interface APIKeyWithToken extends APIKey {
token: string
}
export interface AdminAPIKey extends APIKey {
username: string
display_name: string
email: string
}
export interface ServicePrincipal {
id: string
name: string
description: string
is_admin: boolean
disabled: boolean
created_at: number
updated_at: number
}
export interface CertPrincipalBinding {
fingerprint: string
principal_id: string
enabled: boolean
created_at: number
updated_at: number
}
export interface PrincipalProjectRole {
principal_id: string
project_id: string
role: 'viewer' | 'writer' | 'admin'
created_at: number
}
export interface AuthSettings {
auth_mode: 'db' | 'ldap' | 'hybrid'
oidc_enabled: boolean
ldap_url: string
ldap_bind_dn: string
ldap_bind_password: string
ldap_user_base_dn: string
ldap_user_filter: string
ldap_tls_insecure_skip_verify: boolean
oidc_client_id: string
oidc_client_secret: string
oidc_authorize_url: string
oidc_token_url: string
oidc_userinfo_url: string
oidc_redirect_url: string
oidc_scopes: string
oidc_tls_insecure_skip_verify: boolean
}
export interface TLSSettings {
http_addrs: string[]
https_addrs: string[]
tls_server_cert_source: 'pki'
tls_cert_file: string
tls_key_file: string
tls_pki_server_cert_id: string
tls_client_auth: 'none' | 'request' | 'require' | 'verify_if_given' | 'require_and_verify'
tls_client_ca_file: string
tls_pki_client_ca_id: string
tls_min_version: '1.0' | '1.1' | '1.2' | '1.3'
}
export interface TLSListener {
id: string
name: string
enabled: boolean
http_addrs: string[]
https_addrs: string[]
auth_policy: 'default' | 'read_open_write_cert' | 'read_open_write_cert_or_auth' | 'cert_only' | 'read_only_public'
apply_policy_api: boolean
apply_policy_git: boolean
apply_policy_rpm: boolean
apply_policy_v2: boolean
client_cert_allowlist: string[]
tls_server_cert_source: 'pki'
tls_cert_file: string
tls_key_file: string
tls_pki_server_cert_id: string
tls_client_auth: 'none' | 'request' | 'require' | 'verify_if_given' | 'require_and_verify'
tls_client_ca_file: string
tls_pki_client_ca_id: string
tls_min_version: '1.0' | '1.1' | '1.2' | '1.3'
created_at: number
updated_at: number
}
export interface OIDCStatus {
enabled: boolean
configured?: boolean
auth_mode?: string
}
export interface PKICA {
id: string
name: string
parent_ca_id: string
is_root: boolean
status: string
created_at: number
updated_at: number
}
export interface PKICADetail extends PKICA {
cert_pem: string
key_pem: string
serial_counter: number
}
export interface PKICert {
id: string
ca_id: string
serial_hex: string
common_name: string
fingerprint?: string
san_dns: string
san_ips: string
is_ca: boolean
not_before: number
not_after: number
status: string
revoked_at: number
revocation_reason: string
created_at: number
}
export interface PKICertDetail extends PKICert {
cert_pem: string
key_pem: string
}
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const res = await fetch(path, {
credentials: 'include',
@@ -188,6 +410,7 @@ async function requestBinary(path: string, options: RequestInit = {}): Promise<A
}
export const api = {
oidcStatus: () => request<OIDCStatus>('/api/auth/oidc/enabled'),
login: (username: string, password: string) =>
request<User>('/api/login', {
method: 'POST',
@@ -195,6 +418,95 @@ export const api = {
}),
logout: () => request<void>('/api/logout', { method: 'POST' }),
me: () => request<User>('/api/me'),
updateMe: (payload: { display_name?: string; email?: string; password?: string }) =>
request<User>('/api/me', { method: 'PATCH', body: JSON.stringify(payload) }),
listAPIKeys: () => request<APIKey[]>('/api/me/keys'),
createAPIKey: (name: string, expires_at?: number) =>
request<APIKeyWithToken>('/api/me/keys', { method: 'POST', body: JSON.stringify({ name, expires_at: expires_at || 0 }) }),
deleteAPIKey: (id: string) => request<void>(`/api/me/keys/${id}`, { method: 'DELETE' }),
disableAPIKey: (id: string) => request<void>(`/api/me/keys/${id}/disable`, { method: 'POST' }),
enableAPIKey: (id: string) => request<void>(`/api/me/keys/${id}/enable`, { method: 'POST' }),
listAdminAPIKeys: (user_id?: string, q?: string) => {
const params = new URLSearchParams()
if (user_id) params.set('user_id', user_id)
if (q) params.set('q', q)
const qs = params.toString()
return request<AdminAPIKey[]>(`/api/admin/api-keys${qs ? `?${qs}` : ''}`)
},
deleteAdminAPIKey: (id: string) => request<void>(`/api/admin/api-keys/${id}`, { method: 'DELETE' }),
disableAdminAPIKey: (id: string) => request<void>(`/api/admin/api-keys/${id}/disable`, { method: 'POST' }),
enableAdminAPIKey: (id: string) => request<void>(`/api/admin/api-keys/${id}/enable`, { method: 'POST' }),
listServicePrincipals: () => request<ServicePrincipal[]>('/api/admin/service-principals'),
createServicePrincipal: (payload: { name: string; description?: string; is_admin?: boolean; disabled?: boolean }) =>
request<ServicePrincipal>('/api/admin/service-principals', { method: 'POST', body: JSON.stringify(payload) }),
updateServicePrincipal: (id: string, payload: { name: string; description?: string; is_admin: boolean; disabled: boolean }) =>
request<ServicePrincipal>(`/api/admin/service-principals/${id}`, { method: 'PATCH', body: JSON.stringify(payload) }),
deleteServicePrincipal: (id: string) => request<void>(`/api/admin/service-principals/${id}`, { method: 'DELETE' }),
listPrincipalProjectRoles: (principalID: string) => request<PrincipalProjectRole[]>(`/api/admin/service-principals/${principalID}/roles`),
upsertPrincipalProjectRole: (principalID: string, payload: { project_id: string; role: 'viewer' | 'writer' | 'admin' }) =>
request<PrincipalProjectRole>(`/api/admin/service-principals/${principalID}/roles`, { method: 'POST', body: JSON.stringify(payload) }),
deletePrincipalProjectRole: (principalID: string, projectID: string) =>
request<void>(`/api/admin/service-principals/${principalID}/roles/${projectID}`, { method: 'DELETE' }),
listCertPrincipalBindings: () => request<CertPrincipalBinding[]>('/api/admin/cert-principal-bindings'),
upsertCertPrincipalBinding: (payload: { fingerprint: string; principal_id: string; enabled: boolean }) =>
request<CertPrincipalBinding>('/api/admin/cert-principal-bindings', { method: 'POST', body: JSON.stringify(payload) }),
deleteCertPrincipalBinding: (fingerprint: string) =>
request<void>(`/api/admin/cert-principal-bindings/${encodeURIComponent(fingerprint)}`, { method: 'DELETE' }),
getAuthSettings: () => request<AuthSettings>('/api/admin/auth'),
updateAuthSettings: (payload: AuthSettings) =>
request<AuthSettings>('/api/admin/auth', { method: 'PATCH', body: JSON.stringify(payload) }),
testAuthSettings: (
payload: Partial<AuthSettings> & { username?: string; password?: string },
signal?: AbortSignal
) =>
request<{ status: string; user?: string }>('/api/admin/auth/test', {
method: 'POST',
body: JSON.stringify(payload),
signal
}),
getTLSSettings: () => request<TLSSettings>('/api/admin/tls'),
updateTLSSettings: (payload: TLSSettings) =>
request<TLSSettings>('/api/admin/tls', { method: 'PATCH', body: JSON.stringify(payload) }),
listTLSListeners: () => request<TLSListener[]>('/api/admin/tls/listeners'),
getTLSListenerRuntimeStatus: () => request<Record<string, number>>('/api/admin/tls/listeners/runtime'),
createTLSListener: (payload: Omit<TLSListener, 'id' | 'created_at' | 'updated_at'>) =>
request<TLSListener>('/api/admin/tls/listeners', { method: 'POST', body: JSON.stringify(payload) }),
updateTLSListener: (id: string, payload: Omit<TLSListener, 'id' | 'created_at' | 'updated_at'>) =>
request<TLSListener>(`/api/admin/tls/listeners/${id}`, { method: 'PATCH', body: JSON.stringify(payload) }),
deleteTLSListener: (id: string) => request<void>(`/api/admin/tls/listeners/${id}`, { method: 'DELETE' }),
listPKICAs: () => request<PKICA[]>('/api/admin/pki/cas'),
getPKICA: (id: string) => request<PKICADetail>(`/api/admin/pki/cas/${id}`),
updatePKICA: (id: string, payload: { name: string }) =>
request<PKICADetail>(`/api/admin/pki/cas/${id}`, { method: 'PATCH', body: JSON.stringify(payload) }),
downloadPKICABundle: (id: string) => requestBinary(`/api/admin/pki/cas/${id}/bundle`),
createPKIRootCA: (payload: { name: string; common_name: string; days: number; cert_pem?: string; key_pem?: string }) =>
request<PKICA>('/api/admin/pki/cas/root', { method: 'POST', body: JSON.stringify(payload) }),
createPKIIntermediateCA: (payload: { name: string; parent_ca_id: string; common_name: string; days: number; cert_pem?: string; key_pem?: string }) =>
request<PKICA>('/api/admin/pki/cas/intermediate', { method: 'POST', body: JSON.stringify(payload) }),
deletePKICA: (id: string, force?: boolean) =>
request<void>(`/api/admin/pki/cas/${id}${force ? '?force=1' : ''}`, { method: 'DELETE' }),
listPKICerts: (ca_id?: string) => {
const params = new URLSearchParams()
if (ca_id) params.set('ca_id', ca_id)
const qs = params.toString()
return request<PKICert[]>(`/api/admin/pki/certs${qs ? `?${qs}` : ''}`)
},
getPKICert: (id: string) => request<PKICertDetail>(`/api/admin/pki/certs/${id}`),
downloadPKICertBundle: (id: string) => requestBinary(`/api/admin/pki/certs/${id}/bundle`),
issuePKICert: (payload: {
ca_id: string
common_name: string
san_dns: string[]
san_ips: string[]
days: number
is_ca: boolean
}) => request<PKICert>('/api/admin/pki/certs', { method: 'POST', body: JSON.stringify(payload) }),
importPKICert: (payload: { ca_id?: string; cert_pem: string; key_pem: string }) =>
request<PKICert>('/api/admin/pki/certs/import', { method: 'POST', body: JSON.stringify(payload) }),
revokePKICert: (id: string, reason: string) =>
request<{ status: string }>(`/api/admin/pki/certs/${id}/revoke`, { method: 'POST', body: JSON.stringify({ reason }) }),
deletePKICert: (id: string) => request<void>(`/api/admin/pki/certs/${id}`, { method: 'DELETE' }),
getPKICRL: (id: string) => request<{ crl_pem: string }>(`/api/admin/pki/cas/${id}/crl`),
listUsers: () => request<User[]>('/api/users'),
createUser: (payload: { username: string; display_name: string; email: string; password: string; is_admin: boolean }) =>
@@ -202,6 +514,8 @@ export const api = {
updateUser: (id: string, payload: { display_name?: string; email?: string; password?: string; is_admin: boolean }) =>
request<User>(`/api/users/${id}`, { method: 'PATCH', body: JSON.stringify(payload) }),
deleteUser: (id: string) => request<void>(`/api/users/${id}`, { method: 'DELETE' }),
disableUser: (id: string) => request<void>(`/api/users/${id}/disable`, { method: 'POST' }),
enableUser: (id: string) => request<void>(`/api/users/${id}/enable`, { method: 'POST' }),
listProjects: (limit?: number, offset?: number, query?: string) => {
const params = new URLSearchParams()
@@ -219,13 +533,19 @@ export const api = {
return request<Repo[]>(`/api/repos${qs ? `?${qs}` : ''}`)
},
getProject: (id: string) => request<Project>(`/api/projects/${id}`),
createProject: (payload: { slug: string; name: string; description: string }) =>
createProject: (payload: { slug: string; name: string; description: string; home_page?: string }) =>
request<Project>('/api/projects', { method: 'POST', body: JSON.stringify(payload) }),
updateProject: (id: string, payload: { slug?: string; name?: string; description?: string }) =>
updateProject: (id: string, payload: { slug?: string; name?: string; description?: string; home_page?: string }) =>
request<Project>(`/api/projects/${id}`, { method: 'PATCH', body: JSON.stringify(payload) }),
deleteProject: (id: string) => request<void>(`/api/projects/${id}`, { method: 'DELETE' }),
listProjectMembers: (projectId: string) => request<ProjectMember[]>(`/api/projects/${projectId}/members`),
listProjectMemberCandidates: (projectId: string, query?: string) => {
const params = new URLSearchParams()
if (query) params.set('q', query)
const qs = params.toString()
return request<User[]>(`/api/projects/${projectId}/member-candidates${qs ? `?${qs}` : ''}`)
},
addProjectMember: (projectId: string, payload: { user_id: string; role: string }) =>
request<ProjectMember>(`/api/projects/${projectId}/members`, { method: 'POST', body: JSON.stringify(payload) }),
updateProjectMember: (projectId: string, payload: { user_id: string; role: string }) =>
@@ -341,11 +661,87 @@ export const api = {
body: form
})
},
createRpmSubdir: (repoId: string, name: string, type: string, parent?: string) =>
createRpmSubdir: (
repoId: string,
name: string,
type: string,
parent?: string,
mode?: 'local' | 'mirror',
allow_delete?: boolean,
remote_url?: string,
connect_host?: string,
host_header?: string,
tls_server_name?: string,
tls_insecure_skip_verify?: boolean,
sync_interval_sec?: number
) =>
request<{ status: string }>(`/api/repos/${repoId}/rpm/subdirs`, {
method: 'POST',
body: JSON.stringify({ name, type, parent })
body: JSON.stringify({ name, type, parent, mode, allow_delete, remote_url, connect_host, host_header, tls_server_name, tls_insecure_skip_verify, sync_interval_sec })
}),
getRpmSubdir: (repoId: string, path: string) => {
const params = new URLSearchParams()
params.set('path', path)
return request<RpmRepoDirConfig>(`/api/repos/${repoId}/rpm/subdir?${params.toString()}`)
},
updateRpmSubdir: (
repoId: string,
path: string,
name?: string,
mode?: 'local' | 'mirror',
allow_delete?: boolean,
remote_url?: string,
connect_host?: string,
host_header?: string,
tls_server_name?: string,
tls_insecure_skip_verify?: boolean,
sync_interval_sec?: number
) =>
request<{ status: string }>(`/api/repos/${repoId}/rpm/subdir/update`, {
method: 'POST',
body: JSON.stringify({ path, name, mode, allow_delete, remote_url, connect_host, host_header, tls_server_name, tls_insecure_skip_verify, sync_interval_sec })
}),
syncRpmSubdir: (repoId: string, path: string) => {
const params = new URLSearchParams()
params.set('path', path)
return request<{ status: string }>(`/api/repos/${repoId}/rpm/subdir/sync?${params.toString()}`, {
method: 'POST'
})
},
suspendRpmSubdir: (repoId: string, path: string) => {
const params = new URLSearchParams()
params.set('path', path)
return request<{ status: string; sync_enabled: boolean }>(`/api/repos/${repoId}/rpm/subdir/suspend?${params.toString()}`, {
method: 'POST'
})
},
resumeRpmSubdir: (repoId: string, path: string) => {
const params = new URLSearchParams()
params.set('path', path)
return request<{ status: string; sync_enabled: boolean }>(`/api/repos/${repoId}/rpm/subdir/resume?${params.toString()}`, {
method: 'POST'
})
},
rebuildRpmSubdirMetadata: (repoId: string, path: string) => {
const params = new URLSearchParams()
params.set('path', path)
return request<{ status: string }>(`/api/repos/${repoId}/rpm/subdir/rebuild-metadata?${params.toString()}`, {
method: 'POST'
})
},
listRpmMirrorRuns: (repoId: string, path: string, limit?: number) => {
const params = new URLSearchParams()
params.set('path', path)
if (limit && limit > 0) params.set('limit', String(limit))
return request<RpmMirrorRun[]>(`/api/repos/${repoId}/rpm/subdir/runs?${params.toString()}`)
},
clearRpmMirrorRuns: (repoId: string, path: string) => {
const params = new URLSearchParams()
params.set('path', path)
return request<{ status: string; deleted_count: number }>(`/api/repos/${repoId}/rpm/subdir/runs?${params.toString()}`, {
method: 'DELETE'
})
},
deleteRpmSubdir: (repoId: string, path: string) => {
const params = new URLSearchParams()
params.set('path', path)
@@ -370,6 +766,39 @@ export const api = {
params.set('path', path)
return requestBinary(`/api/repos/${repoId}/rpm/file?${params.toString()}`)
},
listDockerImages: (repoId: string) => request<string[]>(`/api/repos/${repoId}/docker/images`),
listDockerTags: (repoId: string, image: string) => {
const params = new URLSearchParams()
if (image) params.set('image', image)
return request<DockerTagInfo[]>(`/api/repos/${repoId}/docker/tags?${params.toString()}`)
},
getDockerManifest: (repoId: string, ref: string, image: string) => {
const params = new URLSearchParams()
params.set('ref', ref)
if (image) params.set('image', image)
return request<DockerManifestDetail>(`/api/repos/${repoId}/docker/manifest?${params.toString()}`)
},
deleteDockerTag: (repoId: string, image: string, tag: string) => {
const params = new URLSearchParams()
if (image) params.set('image', image)
params.set('tag', tag)
return request<{ status: string }>(`/api/repos/${repoId}/docker/tag?${params.toString()}`, { method: 'DELETE' })
},
deleteDockerImage: (repoId: string, image: string) => {
const params = new URLSearchParams()
if (image) params.set('image', image)
return request<{ status: string }>(`/api/repos/${repoId}/docker/image?${params.toString()}`, { method: 'DELETE' })
},
renameDockerTag: (repoId: string, image: string, from: string, to: string) =>
request<{ status: string }>(`/api/repos/${repoId}/docker/tag/rename`, {
method: 'POST',
body: JSON.stringify({ image, from, to })
}),
renameDockerImage: (repoId: string, from: string, to: string) =>
request<{ status: string }>(`/api/repos/${repoId}/docker/image/rename`, {
method: 'POST',
body: JSON.stringify({ from, to })
}),
listIssues: (projectId: string) => request<Issue[]>(`/api/projects/${projectId}/issues`),
createIssue: (projectId: string, payload: { title: string; body: string }) =>

View File

@@ -58,6 +58,11 @@ export default function App() {
fontFamily
},
components: {
MuiDialog: {
defaultProps: {
disableRestoreFocus: true
}
},
MuiPaper: {
styleOverrides: {
root: {

View File

@@ -20,6 +20,13 @@ import DashboardIcon from '@mui/icons-material/Dashboard'
import WorkspacesIcon from '@mui/icons-material/Workspaces'
import StorageIcon from '@mui/icons-material/Storage'
import PeopleIcon from '@mui/icons-material/People'
import KeyIcon from '@mui/icons-material/Key'
import AdminPanelSettingsIcon from '@mui/icons-material/AdminPanelSettings'
import PersonIcon from '@mui/icons-material/Person'
import BadgeIcon from '@mui/icons-material/Badge'
import SecurityIcon from '@mui/icons-material/Security'
import HttpsIcon from '@mui/icons-material/Https'
import VpnKeyIcon from '@mui/icons-material/VpnKey'
import DarkModeIcon from '@mui/icons-material/DarkMode'
import LightModeIcon from '@mui/icons-material/LightMode'
import { ThemeModeContext } from './ThemeModeContext'
@@ -46,11 +53,18 @@ export default function Layout() {
const navItems = useMemo(() => {
const items = [
{ label: 'Dashboard', path: '/', icon: <DashboardIcon fontSize="small" /> },
{ label: 'Account', path: '/account', icon: <PersonIcon fontSize="small" /> },
{ label: 'Projects', path: '/projects', icon: <WorkspacesIcon fontSize="small" /> },
{ label: 'Repositories', path: '/repos', icon: <StorageIcon fontSize="small" /> }
{ label: 'Repositories', path: '/repos', icon: <StorageIcon fontSize="small" /> },
{ label: 'API Keys', path: '/api-keys', icon: <KeyIcon fontSize="small" /> }
]
if (user?.is_admin) {
items.push({ label: 'Admin Users', path: '/admin/users', icon: <PeopleIcon fontSize="small" /> })
items.push({ label: 'Admin API Keys', path: '/admin/api-keys', icon: <AdminPanelSettingsIcon fontSize="small" /> })
items.push({ label: 'Admin PKI', path: '/admin/pki', icon: <SecurityIcon fontSize="small" /> })
items.push({ label: 'Service Principals', path: '/admin/principals', icon: <VpnKeyIcon fontSize="small" /> })
items.push({ label: 'Site Auth', path: '/admin/auth', icon: <BadgeIcon fontSize="small" /> })
items.push({ label: 'Site TLS', path: '/admin/tls', icon: <HttpsIcon fontSize="small" /> })
}
return items
}, [user])

View File

@@ -4,6 +4,7 @@ import DashboardPage from '../pages/DashboardPage'
import LoginPage from '../pages/LoginPage'
import ProjectsPage from '../pages/ProjectsPage'
import GlobalReposPage from '../pages/GlobalReposPage'
import ProjectEntryPage from '../pages/ProjectEntryPage'
import ProjectHomePage from '../pages/ProjectHomePage'
import ReposPage from '../pages/ReposPage'
import RepoDetailPage from '../pages/RepoDetailPage'
@@ -14,6 +15,13 @@ import IssuesPage from '../pages/IssuesPage'
import WikiPage from '../pages/WikiPage'
import FilesPage from '../pages/FilesPage'
import AdminUsersPage from '../pages/AdminUsersPage'
import AdminApiKeysPage from '../pages/AdminApiKeysPage'
import AdminAuthLdapPage from '../pages/AdminAuthLdapPage'
import AdminPKIPage from '../pages/AdminPKIPage'
import AdminTLSSettingsPage from '../pages/AdminTLSSettingsPage'
import AdminServicePrincipalsPage from '../pages/AdminServicePrincipalsPage'
import ApiKeysPage from '../pages/ApiKeysPage'
import AccountPage from '../pages/AccountPage'
import NotFoundPage from '../pages/NotFoundPage'
export const routes: RouteObject[] = [
@@ -25,7 +33,10 @@ export const routes: RouteObject[] = [
{ index: true, element: <DashboardPage /> },
{ path: 'projects', element: <ProjectsPage /> },
{ path: 'repos', element: <GlobalReposPage /> },
{ path: 'projects/:projectId', element: <ProjectHomePage /> },
{ path: 'account', element: <AccountPage /> },
{ path: 'api-keys', element: <ApiKeysPage /> },
{ path: 'projects/:projectId', element: <ProjectEntryPage /> },
{ path: 'projects/:projectId/info', element: <ProjectHomePage /> },
{ path: 'projects/:projectId/repos', element: <ReposPage /> },
{ path: 'projects/:projectId/repos/:repoId', element: <RepoDetailPage /> },
{ path: 'projects/:projectId/repos/:repoId/branches', element: <BranchesPage /> },
@@ -34,7 +45,13 @@ export const routes: RouteObject[] = [
{ path: 'projects/:projectId/issues', element: <IssuesPage /> },
{ path: 'projects/:projectId/wiki', element: <WikiPage /> },
{ path: 'projects/:projectId/files', element: <FilesPage /> },
{ path: 'admin/users', element: <AdminUsersPage /> }
{ path: 'admin/users', element: <AdminUsersPage /> },
{ path: 'admin/api-keys', element: <AdminApiKeysPage /> },
{ path: 'admin/pki', element: <AdminPKIPage /> },
{ path: 'admin/principals', element: <AdminServicePrincipalsPage /> },
{ path: 'admin/auth', element: <AdminAuthLdapPage /> },
{ path: 'admin/tls', element: <AdminTLSSettingsPage /> },
{ path: 'admin/auth/ldap', element: <AdminAuthLdapPage /> }
]
},
{ path: '*', element: <NotFoundPage /> }

View File

@@ -5,6 +5,7 @@ import FolderIcon from '@mui/icons-material/Folder'
import BugReportIcon from '@mui/icons-material/BugReport'
import MenuBookIcon from '@mui/icons-material/MenuBook'
import AttachFileIcon from '@mui/icons-material/AttachFile'
import InfoOutlinedIcon from '@mui/icons-material/InfoOutlined'
import { Link } from 'react-router-dom'
type ProjectNavBarProps = {
@@ -32,6 +33,14 @@ export default function ProjectNavBar(props: ProjectNavBarProps) {
]}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Button
component={Link}
to={`/projects/${projectId}/info`}
startIcon={<InfoOutlinedIcon />}
sx={{ justifyContent: 'flex-start', minWidth: 0, width: 'fit-content' }}
>
Info
</Button>
<Button
component={Link}
to={`/projects/${projectId}/repos`}

View File

@@ -22,6 +22,7 @@ export default function RepoSubNav(props: RepoSubNavProps) {
const isCommits = currentPath.startsWith(`${basePath}/commits`)
const isBranches = currentPath.startsWith(`${basePath}/branches`)
const isRPM = repoType === 'rpm'
const isDocker = repoType === 'docker'
return (
<Paper
@@ -35,9 +36,9 @@ export default function RepoSubNav(props: RepoSubNavProps) {
>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button component={Link} to={codePath} variant={isCode ? 'contained' : 'outlined'} size="small">
{isRPM ? 'Packages' : 'Code'}
{isRPM ? 'Packages' : isDocker ? 'Images' : 'Code'}
</Button>
{!isRPM ? (
{!isRPM && !isDocker ? (
<>
<Button component={Link} to={commitsPath} variant={isCommits ? 'contained' : 'outlined'} size="small">
Commits

View File

@@ -0,0 +1,101 @@
import Alert from '@mui/material/Alert'
import { Box, Button, Paper, TextField, Typography } from '@mui/material'
import { useEffect, useState } from 'react'
import { api, User } from '../api'
export default function AccountPage() {
const [user, setUser] = useState<User | null>(null)
const [displayName, setDisplayName] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [passwordConfirm, setPasswordConfirm] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [saved, setSaved] = useState(false)
useEffect(() => {
api
.me()
.then((me) => {
setUser(me)
setDisplayName(me.display_name || '')
setEmail(me.email || '')
})
.catch(() => {
setUser(null)
})
}, [])
const handleSave = async () => {
setError(null)
setSaved(false)
if (password !== passwordConfirm) {
setError('Password and confirmation must match.')
return
}
setSaving(true)
try {
const updated = await api.updateMe({
display_name: displayName.trim(),
email: email.trim(),
password: password
})
setUser(updated)
setDisplayName(updated.display_name || '')
setEmail(updated.email || '')
setPassword('')
setPasswordConfirm('')
setSaved(true)
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to update account'
setError(message)
} finally {
setSaving(false)
}
}
return (
<Box>
<Typography variant="h5" sx={{ mb: 2 }}>
My Account
</Typography>
<Paper sx={{ p: 2, maxWidth: 560 }}>
{error ? <Alert severity="error" sx={{ mb: 1 }}>{error}</Alert> : null}
{saved ? <Alert severity="success" sx={{ mb: 1 }}>Saved.</Alert> : null}
<Box sx={{ display: 'grid', gap: 1 }}>
<TextField label="Username" value={user?.username || ''} disabled />
<TextField
label="Display Name"
value={displayName}
onChange={(event) => setDisplayName(event.target.value)}
/>
<TextField
label="Email"
value={email}
onChange={(event) => setEmail(event.target.value)}
/>
<TextField
label="New Password (optional)"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
helperText={user?.auth_source === 'db' ? '' : 'Password updates are available for db users only.'}
/>
<TextField
label="Confirm New Password"
type="password"
value={passwordConfirm}
error={passwordConfirm !== '' && password !== passwordConfirm}
helperText={passwordConfirm !== '' && password !== passwordConfirm ? 'Passwords do not match.' : ''}
onChange={(event) => setPasswordConfirm(event.target.value)}
/>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 1 }}>
<Button variant="contained" onClick={handleSave} disabled={saving}>
{saving ? 'Saving...' : 'Save'}
</Button>
</Box>
</Box>
</Paper>
</Box>
)
}

View File

@@ -0,0 +1,315 @@
import DeleteIcon from '@mui/icons-material/Delete'
import BlockIcon from '@mui/icons-material/Block'
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'
import Alert from '@mui/material/Alert'
import {
Box,
Button,
Checkbox,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
IconButton,
List,
ListItemIcon,
ListItem,
ListItemText,
MenuItem,
Paper,
TextField,
Typography
} from '@mui/material'
import { useEffect, useState } from 'react'
import { AdminAPIKey, api, User } from '../api'
function formatUnix(value: number) {
if (!value || value <= 0) {
return 'Never'
}
return new Date(value * 1000).toLocaleString()
}
export default function AdminApiKeysPage() {
const [users, setUsers] = useState<User[]>([])
const [keys, setKeys] = useState<AdminAPIKey[]>([])
const [query, setQuery] = useState('')
const [userID, setUserID] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [deleteTarget, setDeleteTarget] = useState<AdminAPIKey | null>(null)
const [deleteConfirm, setDeleteConfirm] = useState('')
const [deleting, setDeleting] = useState(false)
const [togglingID, setTogglingID] = useState('')
const [selected, setSelected] = useState<string[]>([])
const [bulkOpen, setBulkOpen] = useState(false)
const [bulkConfirm, setBulkConfirm] = useState('')
const [bulkDeleting, setBulkDeleting] = useState(false)
const load = async () => {
setLoading(true)
setError(null)
try {
const list = await api.listAdminAPIKeys(userID || undefined, query.trim() || undefined)
setKeys(Array.isArray(list) ? list : [])
setSelected([])
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to load API keys'
setError(message)
setKeys([])
} finally {
setLoading(false)
}
}
useEffect(() => {
api
.listUsers()
.then((list) => setUsers(Array.isArray(list) ? list : []))
.catch(() => setUsers([]))
}, [])
useEffect(() => {
load()
}, [userID])
const handleSearch = () => {
load()
}
const handleDelete = async () => {
if (!deleteTarget) {
return
}
setDeleting(true)
setError(null)
try {
await api.deleteAdminAPIKey(deleteTarget.id)
setDeleteTarget(null)
setDeleteConfirm('')
await load()
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to revoke API key'
setError(message)
} finally {
setDeleting(false)
}
}
const toggleSelected = (id: string) => {
setSelected((prev) => (prev.includes(id) ? prev.filter((v) => v !== id) : [...prev, id]))
}
const handleSelectAll = (checked: boolean) => {
if (checked) {
setSelected(keys.map((key) => key.id))
return
}
setSelected([])
}
const handleBulkDelete = async () => {
const ids = [...selected]
if (!ids.length) {
return
}
setBulkDeleting(true)
setError(null)
try {
await Promise.all(ids.map((id) => api.deleteAdminAPIKey(id)))
setBulkOpen(false)
setBulkConfirm('')
setSelected([])
await load()
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to revoke selected API keys'
setError(message)
} finally {
setBulkDeleting(false)
}
}
const toggleKeyState = async (key: AdminAPIKey) => {
setTogglingID(key.id)
setError(null)
try {
if (key.disabled) {
await api.enableAdminAPIKey(key.id)
} else {
await api.disableAdminAPIKey(key.id)
}
await load()
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to update API key'
setError(message)
} finally {
setTogglingID('')
}
}
return (
<Box>
<Typography variant="h5" sx={{ mb: 2 }}>
Admin: API Keys
</Typography>
<Paper sx={{ p: 2, mb: 2 }}>
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', flexWrap: 'wrap' }}>
<TextField
size="small"
label="Search"
placeholder="key name, prefix, username, email"
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
handleSearch()
}
}}
sx={{ minWidth: 280 }}
/>
<TextField
size="small"
select
label="User"
value={userID}
onChange={(event) => setUserID(event.target.value)}
sx={{ minWidth: 220 }}
>
<MenuItem value="">All users</MenuItem>
{users.map((user) => (
<MenuItem key={user.id} value={user.id}>
{user.username}
</MenuItem>
))}
</TextField>
<Button variant="outlined" onClick={handleSearch}>
Search
</Button>
<Button
variant="outlined"
color="error"
disabled={!selected.length}
onClick={() => setBulkOpen(true)}
>
Revoke Selected ({selected.length})
</Button>
</Box>
</Paper>
<Paper sx={{ p: 2 }}>
{error ? <Alert severity="error" sx={{ mb: 1 }}>{error}</Alert> : null}
{loading ? (
<Typography variant="body2" color="text.secondary">
Loading API keys...
</Typography>
) : (
<List>
{keys.length ? (
<ListItem divider>
<Checkbox
size="small"
checked={selected.length > 0 && selected.length === keys.length}
indeterminate={selected.length > 0 && selected.length < keys.length}
onChange={(event) => handleSelectAll(event.target.checked)}
/>
<ListItemText primary="Select all" />
</ListItem>
) : null}
{keys.map((key) => (
<ListItem
key={key.id}
divider
secondaryAction={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<IconButton
size="small"
color={key.disabled ? 'success' : 'warning'}
onClick={() => toggleKeyState(key)}
title={key.disabled ? 'Enable key' : 'Disable key'}
disabled={togglingID === key.id}
>
{key.disabled ? <CheckCircleOutlineIcon fontSize="small" /> : <BlockIcon fontSize="small" />}
</IconButton>
<IconButton size="small" color="error" onClick={() => setDeleteTarget(key)}>
<DeleteIcon fontSize="small" />
</IconButton>
</Box>
}
>
<ListItemIcon sx={{ minWidth: 32 }}>
<Checkbox
size="small"
checked={selected.includes(key.id)}
onChange={() => toggleSelected(key.id)}
/>
</ListItemIcon>
<ListItemText
primary={`${key.name}${key.disabled ? ' (disabled)' : ''} (${key.prefix})`}
secondary={`${key.username} | ${key.email} | Created: ${formatUnix(key.created_at)} | Last used: ${formatUnix(key.last_used_at)} | Expires: ${formatUnix(key.expires_at)}`}
/>
</ListItem>
))}
{!keys.length ? (
<Typography variant="body2" color="text.secondary">
No API keys found.
</Typography>
) : null}
</List>
)}
</Paper>
<Dialog open={Boolean(deleteTarget)} onClose={() => setDeleteTarget(null)} maxWidth="xs" fullWidth>
<DialogTitle>Revoke API Key</DialogTitle>
<DialogContent>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Type the API key name to confirm revocation.
</Typography>
<TextField
fullWidth
label="Key name"
value={deleteConfirm}
onChange={(event) => setDeleteConfirm(event.target.value)}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => { setDeleteTarget(null); setDeleteConfirm('') }}>
Cancel
</Button>
<Button
variant="contained"
color="error"
disabled={deleting || !deleteTarget || deleteConfirm !== deleteTarget.name}
onClick={handleDelete}
>
{deleting ? 'Revoking...' : 'Revoke'}
</Button>
</DialogActions>
</Dialog>
<Dialog open={bulkOpen} onClose={() => setBulkOpen(false)} maxWidth="xs" fullWidth>
<DialogTitle>Revoke Selected API Keys</DialogTitle>
<DialogContent>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Type DELETE to revoke {selected.length} selected API key(s).
</Typography>
<TextField
fullWidth
label="Confirmation"
value={bulkConfirm}
onChange={(event) => setBulkConfirm(event.target.value)}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => { setBulkOpen(false); setBulkConfirm('') }}>
Cancel
</Button>
<Button
variant="contained"
color="error"
disabled={bulkDeleting || !selected.length || bulkConfirm !== 'DELETE'}
onClick={handleBulkDelete}
>
{bulkDeleting ? 'Revoking...' : 'Revoke Selected'}
</Button>
</DialogActions>
</Dialog>
</Box>
)
}

View File

@@ -0,0 +1,219 @@
import Alert from '@mui/material/Alert'
import { Box, Button, Checkbox, FormControlLabel, MenuItem, Paper, TextField, Typography } from '@mui/material'
import { useEffect, useRef, useState } from 'react'
import { api, AuthSettings } from '../api'
export default function AdminAuthLdapPage() {
const [settings, setSettings] = useState<AuthSettings>({
auth_mode: 'db',
oidc_enabled: false,
ldap_url: '',
ldap_bind_dn: '',
ldap_bind_password: '',
ldap_user_base_dn: '',
ldap_user_filter: '(uid={username})',
ldap_tls_insecure_skip_verify: false,
oidc_client_id: '',
oidc_client_secret: '',
oidc_authorize_url: '',
oidc_token_url: '',
oidc_userinfo_url: '',
oidc_redirect_url: '',
oidc_scopes: 'openid profile email',
oidc_tls_insecure_skip_verify: false
})
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [testing, setTesting] = useState(false)
const [error, setError] = useState<string | null>(null)
const [saved, setSaved] = useState(false)
const [testResult, setTestResult] = useState<string | null>(null)
const [testUsername, setTestUsername] = useState('')
const [testPassword, setTestPassword] = useState('')
const testControllerRef = useRef<AbortController | null>(null)
useEffect(() => {
setLoading(true)
api
.getAuthSettings()
.then((data) => setSettings(data))
.catch((err) => {
const message = err instanceof Error ? err.message : 'Failed to load authentication settings'
setError(message)
})
.finally(() => setLoading(false))
}, [])
const handleSave = async () => {
setSaving(true)
setError(null)
setSaved(false)
try {
const updated = await api.updateAuthSettings(settings)
setSettings(updated)
setSaved(true)
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to save authentication settings'
setError(message)
} finally {
setSaving(false)
}
}
const handleTest = async () => {
let controller: AbortController
if (testing) {
if (testControllerRef.current) {
testControllerRef.current.abort()
}
return
}
controller = new AbortController()
testControllerRef.current = controller
setTesting(true)
setError(null)
setTestResult(null)
try {
const result = await api.testAuthSettings(
{
...settings,
username: testUsername.trim() || undefined,
password: testPassword || undefined
},
controller.signal
)
setTestResult(result.user ? `Connection ok. User test ok: ${result.user}` : 'Connection ok.')
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') {
setTestResult('LDAP test canceled.')
return
}
const message = err instanceof Error ? err.message : 'LDAP test failed'
setError(message)
} finally {
setTesting(false)
testControllerRef.current = null
}
}
useEffect(() => {
return () => {
if (testControllerRef.current) {
testControllerRef.current.abort()
}
}
}, [])
return (
<Box>
<Typography variant="h5" sx={{ mb: 2 }}>
Admin: Site Authentication
</Typography>
<Paper sx={{ p: 2, maxWidth: 820 }}>
{loading ? (
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Loading...
</Typography>
) : null}
{error ? <Alert severity="error" sx={{ mb: 1 }}>{error}</Alert> : null}
{saved ? <Alert severity="success" sx={{ mb: 1 }}>Saved.</Alert> : null}
{testResult ? <Alert severity="success" sx={{ mb: 1 }}>{testResult}</Alert> : null}
<Box sx={{ display: 'grid', gap: 1 }}>
<TextField
select
label="Auth Mode"
value={settings.auth_mode}
onChange={(event) => setSettings((prev) => ({ ...prev, auth_mode: event.target.value as 'db' | 'ldap' | 'hybrid' }))}
>
<MenuItem value="db">db</MenuItem>
<MenuItem value="ldap">ldap</MenuItem>
<MenuItem value="hybrid">hybrid</MenuItem>
</TextField>
<Typography variant="subtitle2" sx={{ mt: 1 }}>
OIDC
</Typography>
<FormControlLabel
control={<Checkbox checked={settings.oidc_enabled} onChange={(event) => setSettings((prev) => ({ ...prev, oidc_enabled: event.target.checked }))} />}
label="Enable OIDC login"
/>
<TextField label="Client ID" value={settings.oidc_client_id} onChange={(event) => setSettings((prev) => ({ ...prev, oidc_client_id: event.target.value }))} />
<TextField
label="Client Secret"
type="password"
value={settings.oidc_client_secret}
onChange={(event) => setSettings((prev) => ({ ...prev, oidc_client_secret: event.target.value }))}
/>
<TextField
label="Authorize URL"
value={settings.oidc_authorize_url}
onChange={(event) => setSettings((prev) => ({ ...prev, oidc_authorize_url: event.target.value }))}
/>
<TextField
label="Token URL"
value={settings.oidc_token_url}
onChange={(event) => setSettings((prev) => ({ ...prev, oidc_token_url: event.target.value }))}
/>
<TextField
label="UserInfo URL (optional)"
value={settings.oidc_userinfo_url}
onChange={(event) => setSettings((prev) => ({ ...prev, oidc_userinfo_url: event.target.value }))}
/>
<TextField
label="Redirect URL"
value={settings.oidc_redirect_url}
onChange={(event) => setSettings((prev) => ({ ...prev, oidc_redirect_url: event.target.value }))}
/>
<TextField
label="Scopes"
value={settings.oidc_scopes}
onChange={(event) => setSettings((prev) => ({ ...prev, oidc_scopes: event.target.value }))}
helperText="Example: openid profile email"
/>
<FormControlLabel
control={<Checkbox checked={settings.oidc_tls_insecure_skip_verify} onChange={(event) => setSettings((prev) => ({ ...prev, oidc_tls_insecure_skip_verify: event.target.checked }))} />}
label="OIDC TLS insecure skip verify (testing/self-signed only)"
/>
<Typography variant="subtitle2" sx={{ mt: 1 }}>
LDAP
</Typography>
<TextField label="LDAP URL" value={settings.ldap_url} onChange={(event) => setSettings((prev) => ({ ...prev, ldap_url: event.target.value }))} />
<TextField label="Bind DN" value={settings.ldap_bind_dn} onChange={(event) => setSettings((prev) => ({ ...prev, ldap_bind_dn: event.target.value }))} />
<TextField
label="Bind Password"
type="password"
value={settings.ldap_bind_password}
onChange={(event) => setSettings((prev) => ({ ...prev, ldap_bind_password: event.target.value }))}
/>
<TextField
label="User Base DN"
value={settings.ldap_user_base_dn}
onChange={(event) => setSettings((prev) => ({ ...prev, ldap_user_base_dn: event.target.value }))}
/>
<TextField
label="User Filter"
value={settings.ldap_user_filter}
onChange={(event) => setSettings((prev) => ({ ...prev, ldap_user_filter: event.target.value }))}
helperText="Use {username} placeholder."
/>
<FormControlLabel
control={<Checkbox checked={settings.ldap_tls_insecure_skip_verify} onChange={(event) => setSettings((prev) => ({ ...prev, ldap_tls_insecure_skip_verify: event.target.checked }))} />}
label="TLS insecure skip verify (testing/self-signed only)"
/>
<Typography variant="subtitle2" sx={{ mt: 1 }}>
Test (optional user bind)
</Typography>
<TextField label="Test Username" value={testUsername} onChange={(event) => setTestUsername(event.target.value)} />
<TextField label="Test Password" type="password" value={testPassword} onChange={(event) => setTestPassword(event.target.value)} />
<Box sx={{ display: 'flex', justifyContent: 'space-between', mt: 1 }}>
<Button variant="outlined" onClick={handleTest} color={testing ? 'warning' : 'primary'}>
{testing ? 'Cancel Test' : 'Test Connection'}
</Button>
<Button variant="contained" onClick={handleSave} disabled={saving}>
{saving ? 'Saving...' : 'Save'}
</Button>
</Box>
</Box>
</Paper>
</Box>
)
}

View File

@@ -0,0 +1,888 @@
import AddIcon from '@mui/icons-material/Add'
import BlockIcon from '@mui/icons-material/Block'
import DeleteIcon from '@mui/icons-material/Delete'
import DownloadIcon from '@mui/icons-material/Download'
import EditIcon from '@mui/icons-material/Edit'
import VisibilityIcon from '@mui/icons-material/Visibility'
import Alert from '@mui/material/Alert'
import {
Box,
Button,
Checkbox,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControlLabel,
IconButton,
List,
ListItem,
ListItemText,
MenuItem,
Paper,
TextField,
Typography
} from '@mui/material'
import { useEffect, useState } from 'react'
import { api, PKICA, PKICADetail, PKICert, PKICertDetail } from '../api'
function fmt(ts: number): string {
if (!ts || ts <= 0) {
return '-'
}
return new Date(ts * 1000).toLocaleString()
}
function downloadText(filename: string, text: string) {
const blob = new Blob([text], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
function downloadBinary(filename: string, data: ArrayBuffer, contentType: string) {
const blob = new Blob([data], { type: contentType })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
async function readFileText(file: File): Promise<string> {
return file.text()
}
export default function AdminPKIPage() {
const [cas, setCAs] = useState<PKICA[]>([])
const [certs, setCerts] = useState<PKICert[]>([])
const [selectedCA, setSelectedCA] = useState('')
const [error, setError] = useState<string | null>(null)
const [dialogError, setDialogError] = useState<string | null>(null)
const [rootOpen, setRootOpen] = useState(false)
const [interOpen, setInterOpen] = useState(false)
const [issueOpen, setIssueOpen] = useState(false)
const [importOpen, setImportOpen] = useState(false)
const [revokeID, setRevokeID] = useState('')
const [revokeReason, setRevokeReason] = useState('')
const [deleteID, setDeleteID] = useState('')
const [deleteCAID, setDeleteCAID] = useState('')
const [deleteCAName, setDeleteCAName] = useState('')
const [deleteCAConfirm, setDeleteCAConfirm] = useState('')
const [deleteCAForce, setDeleteCAForce] = useState(false)
const [busy, setBusy] = useState(false)
const [viewCA, setViewCA] = useState<PKICADetail | null>(null)
const [viewCert, setViewCert] = useState<PKICertDetail | null>(null)
const [editCAID, setEditCAID] = useState('')
const [editCAName, setEditCAName] = useState('')
const [rootName, setRootName] = useState('')
const [rootCN, setRootCN] = useState('')
const [rootDays, setRootDays] = useState('3650')
const [rootCertPEM, setRootCertPEM] = useState('')
const [rootKeyPEM, setRootKeyPEM] = useState('')
const [interName, setInterName] = useState('')
const [interParent, setInterParent] = useState('')
const [interCN, setInterCN] = useState('')
const [interDays, setInterDays] = useState('1825')
const [interCertPEM, setInterCertPEM] = useState('')
const [interKeyPEM, setInterKeyPEM] = useState('')
const [issueCA, setIssueCA] = useState('')
const [issueCN, setIssueCN] = useState('')
const [issueDNS, setIssueDNS] = useState('')
const [issueIPs, setIssueIPs] = useState('')
const [issueDays, setIssueDays] = useState('365')
const [issueIsCA, setIssueIsCA] = useState(false)
const [importCA, setImportCA] = useState('')
const [importCertPEM, setImportCertPEM] = useState('')
const [importKeyPEM, setImportKeyPEM] = useState('')
const loadTextFile = async (event: React.ChangeEvent<HTMLInputElement>, setter: (value: string) => void) => {
const file = event.target.files && event.target.files[0]
let text: string
if (!file) {
return
}
text = await readFileText(file)
setter(text)
event.target.value = ''
}
const load = async () => {
let listCAs: PKICA[]
let listCerts: PKICert[]
listCAs = await api.listPKICAs()
setCAs(Array.isArray(listCAs) ? listCAs : [])
listCerts = await api.listPKICerts(selectedCA || undefined)
setCerts(Array.isArray(listCerts) ? listCerts : [])
}
useEffect(() => {
load().catch((err) => setError(err instanceof Error ? err.message : 'Failed to load PKI data'))
}, [])
useEffect(() => {
api
.listPKICerts(selectedCA || undefined)
.then((list) => setCerts(Array.isArray(list) ? list : []))
.catch((err) => setError(err instanceof Error ? err.message : 'Failed to load certificates'))
}, [selectedCA])
const createRoot = async () => {
let days: number
if (!rootName.trim()) {
setDialogError('Name is required.')
return
}
days = Number(rootDays) || 3650
setBusy(true)
setError(null)
setDialogError(null)
try {
await api.createPKIRootCA({
name: rootName.trim(),
common_name: rootCN.trim(),
days: days,
cert_pem: rootCertPEM.trim() || undefined,
key_pem: rootKeyPEM.trim() || undefined
})
setRootOpen(false)
await load()
} catch (err) {
setDialogError(err instanceof Error ? err.message : 'Failed to create root CA')
} finally {
setBusy(false)
}
}
const createIntermediate = async () => {
let days: number
if (!interName.trim() || !interParent) {
setDialogError('Name and parent CA are required.')
return
}
days = Number(interDays) || 1825
setBusy(true)
setError(null)
setDialogError(null)
try {
await api.createPKIIntermediateCA({
name: interName.trim(),
parent_ca_id: interParent,
common_name: interCN.trim(),
days: days,
cert_pem: interCertPEM.trim() || undefined,
key_pem: interKeyPEM.trim() || undefined
})
setInterOpen(false)
setInterCertPEM('')
setInterKeyPEM('')
await load()
} catch (err) {
setDialogError(err instanceof Error ? err.message : 'Failed to create intermediate CA')
} finally {
setBusy(false)
}
}
const issueCert = async () => {
let days: number
let dns: string[]
let ips: string[]
if (!issueCA || !issueCN.trim()) {
setDialogError('Issuer CA and common name are required.')
return
}
days = Number(issueDays) || 365
dns = issueDNS
.split(',')
.map((v) => v.trim())
.filter((v) => v)
ips = issueIPs
.split(',')
.map((v) => v.trim())
.filter((v) => v)
setBusy(true)
setError(null)
setDialogError(null)
try {
await api.issuePKICert({
ca_id: issueCA,
common_name: issueCN.trim(),
san_dns: dns,
san_ips: ips,
days: days,
is_ca: issueIsCA
})
setIssueOpen(false)
await load()
} catch (err) {
setDialogError(err instanceof Error ? err.message : 'Failed to issue certificate')
} finally {
setBusy(false)
}
}
const importCert = async () => {
let payload: { ca_id?: string; cert_pem: string; key_pem: string }
if (!importCertPEM.trim() || !importKeyPEM.trim()) {
setDialogError('Certificate PEM and private key PEM are required.')
return
}
payload = { cert_pem: importCertPEM.trim(), key_pem: importKeyPEM.trim() }
if (importCA.trim() != "") {
payload.ca_id = importCA.trim()
}
setBusy(true)
setError(null)
setDialogError(null)
try {
await api.importPKICert(payload)
setImportOpen(false)
setImportCA('')
setImportCertPEM('')
setImportKeyPEM('')
await load()
} catch (err) {
setDialogError(err instanceof Error ? err.message : 'Failed to import certificate')
} finally {
setBusy(false)
}
}
const revokeCert = async () => {
setBusy(true)
setError(null)
setDialogError(null)
try {
await api.revokePKICert(revokeID, revokeReason)
setRevokeID('')
setRevokeReason('')
await load()
} catch (err) {
setDialogError(err instanceof Error ? err.message : 'Failed to revoke certificate')
} finally {
setBusy(false)
}
}
const deleteCert = async () => {
setBusy(true)
setError(null)
setDialogError(null)
try {
await api.deletePKICert(deleteID)
setDeleteID('')
await load()
} catch (err) {
setDialogError(err instanceof Error ? err.message : 'Failed to delete certificate')
} finally {
setBusy(false)
}
}
const deleteCA = async () => {
setBusy(true)
setError(null)
setDialogError(null)
try {
await api.deletePKICA(deleteCAID, deleteCAForce)
setDeleteCAID('')
setDeleteCAName('')
setDeleteCAConfirm('')
setDeleteCAForce(false)
await load()
} catch (err) {
setDialogError(err instanceof Error ? err.message : 'Failed to delete CA')
} finally {
setBusy(false)
}
}
const openCAView = async (id: string) => {
let detail: PKICADetail
setDialogError(null)
detail = await api.getPKICA(id)
setViewCA(detail)
}
const openCAEdit = async (id: string) => {
let detail: PKICADetail
setDialogError(null)
detail = await api.getPKICA(id)
setEditCAID(detail.id)
setEditCAName(detail.name)
}
const saveCAEdit = async () => {
setBusy(true)
setDialogError(null)
try {
await api.updatePKICA(editCAID, { name: editCAName.trim() })
setEditCAID('')
setEditCAName('')
await load()
} catch (err) {
setDialogError(err instanceof Error ? err.message : 'Failed to update CA')
} finally {
setBusy(false)
}
}
const openCertView = async (id: string) => {
let detail: PKICertDetail
setDialogError(null)
detail = await api.getPKICert(id)
setViewCert(detail)
}
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Typography variant="h5">Admin: PKI</Typography>
<Box sx={{ display: 'flex', gap: 1 }}>
<Button variant="outlined" startIcon={<AddIcon />} onClick={() => { setDialogError(null); setRootOpen(true) }}>
New Root CA
</Button>
<Button
variant="outlined"
startIcon={<AddIcon />}
onClick={() => {
setDialogError(null)
setInterCertPEM('')
setInterKeyPEM('')
setInterOpen(true)
}}
>
New Intermediate CA
</Button>
<Button variant="outlined" startIcon={<AddIcon />} onClick={() => { setDialogError(null); setIssueOpen(true) }}>
Issue Certificate
</Button>
<Button
variant="outlined"
startIcon={<AddIcon />}
onClick={() => {
setDialogError(null)
setImportCA('')
setImportCertPEM('')
setImportKeyPEM('')
setImportOpen(true)
}}
>
Import Certificate
</Button>
</Box>
</Box>
{error ? <Alert severity="error" sx={{ mb: 1 }}>{error}</Alert> : null}
<Paper sx={{ p: 2, mb: 2 }}>
<Typography variant="h6" sx={{ mb: 1 }}>Certificate Authorities</Typography>
<List>
{cas.map((ca) => (
<ListItem
key={ca.id}
divider
secondaryAction={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Button
size="small"
onClick={async () => {
const data = await api.getPKICRL(ca.id)
const blob = new Blob([data.crl_pem], { type: 'application/x-pem-file' })
const url = URL.createObjectURL(blob)
window.open(url, '_blank')
}}
>
CRL
</Button>
<IconButton size="small" onClick={() => openCAView(ca.id)} title="View details">
<VisibilityIcon fontSize="small" />
</IconButton>
<IconButton size="small" onClick={() => openCAEdit(ca.id)} title="Edit CA">
<EditIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
color="error"
onClick={() => {
setDialogError(null)
setDeleteCAID(ca.id)
setDeleteCAName(ca.name)
setDeleteCAConfirm('')
setDeleteCAForce(false)
}}
title="Delete CA"
>
<DeleteIcon fontSize="small" />
</IconButton>
</Box>
}
>
<ListItemText
primary={`${ca.name} (${ca.id})`}
secondary={`${ca.is_root ? 'root' : 'intermediate'} · status: ${ca.status} · parent: ${ca.parent_ca_id || '-'} · updated: ${fmt(ca.updated_at)}`}
/>
</ListItem>
))}
</List>
</Paper>
<Paper sx={{ p: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="h6">Issued Certificates</Typography>
<TextField
select
size="small"
label="CA"
value={selectedCA}
onChange={(event) => setSelectedCA(event.target.value)}
sx={{ minWidth: 280 }}
>
<MenuItem value="">(all)</MenuItem>
<MenuItem value="standalone">(standalone)</MenuItem>
{cas.map((ca) => (
<MenuItem key={ca.id} value={ca.id}>{ca.name}</MenuItem>
))}
</TextField>
</Box>
<List>
{certs.map((cert) => (
<ListItem
key={cert.id}
divider
secondaryAction={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<IconButton size="small" onClick={() => openCertView(cert.id)} title="View details">
<VisibilityIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
color="warning"
onClick={() => setRevokeID(cert.id)}
disabled={cert.status === 'revoked'}
title="Revoke"
>
<BlockIcon fontSize="small" />
</IconButton>
<IconButton size="small" color="error" onClick={() => setDeleteID(cert.id)} title="Delete">
<DeleteIcon fontSize="small" />
</IconButton>
</Box>
}
>
<ListItemText
primary={`${cert.common_name} (${cert.id})`}
secondary={`serial: ${cert.serial_hex} · ca: ${cert.ca_id || 'standalone'} · status: ${cert.status} · valid: ${fmt(cert.not_before)} ~ ${fmt(cert.not_after)}${cert.revoked_at ? ` · revoked: ${fmt(cert.revoked_at)}` : ''}`}
/>
</ListItem>
))}
</List>
</Paper>
<Dialog open={rootOpen} onClose={() => setRootOpen(false)} maxWidth="md" fullWidth>
<DialogTitle>
<Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="h6">New Root CA</Typography>
{dialogError ? <Alert severity="error">{dialogError}</Alert> : null}
</Box>
</DialogTitle>
<DialogContent>
<Box sx={{ display: 'grid', gap: 1, mt: 1 }}>
<TextField label="Name" value={rootName} onChange={(event) => setRootName(event.target.value)} />
<TextField label="Common Name" value={rootCN} onChange={(event) => setRootCN(event.target.value)} />
<TextField label="Validity Days" value={rootDays} onChange={(event) => setRootDays(event.target.value)} />
<TextField
label="Import Certificate PEM (optional)"
multiline
minRows={6}
value={rootCertPEM}
onChange={(event) => setRootCertPEM(event.target.value)}
sx={{ '& .MuiInputBase-input': { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace' } }}
/>
<Box>
<Button variant="outlined" component="label" size="small">
Load Certificate File
<input hidden type="file" accept=".pem,.crt,.cer,.txt" onChange={(event) => loadTextFile(event, setRootCertPEM)} />
</Button>
</Box>
<TextField
label="Import Private Key PEM (optional)"
multiline
minRows={6}
value={rootKeyPEM}
onChange={(event) => setRootKeyPEM(event.target.value)}
sx={{ '& .MuiInputBase-input': { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace' } }}
/>
<Box>
<Button variant="outlined" component="label" size="small">
Load Private Key File
<input hidden type="file" accept=".pem,.key,.txt" onChange={(event) => loadTextFile(event, setRootKeyPEM)} />
</Button>
</Box>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setRootOpen(false)}>Cancel</Button>
<Button variant="contained" onClick={createRoot} disabled={busy}>{busy ? 'Saving...' : 'Create'}</Button>
</DialogActions>
</Dialog>
<Dialog open={interOpen} onClose={() => setInterOpen(false)} maxWidth="md" fullWidth>
<DialogTitle>
<Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="h6">New Intermediate CA</Typography>
{dialogError ? <Alert severity="error">{dialogError}</Alert> : null}
</Box>
</DialogTitle>
<DialogContent>
<Box sx={{ display: 'grid', gap: 1, mt: 1 }}>
<TextField label="Name" value={interName} onChange={(event) => setInterName(event.target.value)} />
<TextField
select
label="Parent CA"
value={interParent}
onChange={(event) => setInterParent(event.target.value)}
>
{cas.map((ca) => (
<MenuItem key={ca.id} value={ca.id}>{ca.name}</MenuItem>
))}
</TextField>
<TextField label="Common Name" value={interCN} onChange={(event) => setInterCN(event.target.value)} />
<TextField label="Validity Days" value={interDays} onChange={(event) => setInterDays(event.target.value)} />
<TextField
label="Import Intermediate Certificate PEM (optional)"
multiline
minRows={6}
value={interCertPEM}
onChange={(event) => setInterCertPEM(event.target.value)}
sx={{ '& .MuiInputBase-input': { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace' } }}
/>
<Box>
<Button variant="outlined" component="label" size="small">
Load Intermediate Certificate File
<input hidden type="file" accept=".pem,.crt,.cer,.txt" onChange={(event) => loadTextFile(event, setInterCertPEM)} />
</Button>
</Box>
<TextField
label="Import Intermediate Private Key PEM (optional)"
multiline
minRows={6}
value={interKeyPEM}
onChange={(event) => setInterKeyPEM(event.target.value)}
sx={{ '& .MuiInputBase-input': { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace' } }}
/>
<Box>
<Button variant="outlined" component="label" size="small">
Load Intermediate Private Key File
<input hidden type="file" accept=".pem,.key,.txt" onChange={(event) => loadTextFile(event, setInterKeyPEM)} />
</Button>
</Box>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setInterOpen(false)}>Cancel</Button>
<Button variant="contained" onClick={createIntermediate} disabled={busy}>{busy ? 'Saving...' : 'Create'}</Button>
</DialogActions>
</Dialog>
<Dialog open={issueOpen} onClose={() => setIssueOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>
<Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="h6">Issue Certificate</Typography>
{dialogError ? <Alert severity="error">{dialogError}</Alert> : null}
</Box>
</DialogTitle>
<DialogContent>
<Box sx={{ display: 'grid', gap: 1, mt: 1 }}>
<TextField select label="Issuer CA" value={issueCA} onChange={(event) => setIssueCA(event.target.value)}>
{cas.map((ca) => (
<MenuItem key={ca.id} value={ca.id}>{ca.name}</MenuItem>
))}
</TextField>
<TextField label="Common Name" value={issueCN} onChange={(event) => setIssueCN(event.target.value)} />
<TextField label="SAN DNS (comma-separated)" value={issueDNS} onChange={(event) => setIssueDNS(event.target.value)} />
<TextField label="SAN IPs (comma-separated)" value={issueIPs} onChange={(event) => setIssueIPs(event.target.value)} />
<TextField label="Validity Days" value={issueDays} onChange={(event) => setIssueDays(event.target.value)} />
<FormControlLabel control={<Checkbox checked={issueIsCA} onChange={(event) => setIssueIsCA(event.target.checked)} />} label="Issue as CA certificate" />
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setIssueOpen(false)}>Cancel</Button>
<Button variant="contained" onClick={issueCert} disabled={busy}>{busy ? 'Saving...' : 'Issue'}</Button>
</DialogActions>
</Dialog>
<Dialog open={importOpen} onClose={() => setImportOpen(false)} maxWidth="md" fullWidth>
<DialogTitle>
<Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="h6">Import Certificate</Typography>
{dialogError ? <Alert severity="error">{dialogError}</Alert> : null}
</Box>
</DialogTitle>
<DialogContent>
<Box sx={{ display: 'grid', gap: 1, mt: 1 }}>
<TextField select label="Issuer CA (optional)" value={importCA} onChange={(event) => setImportCA(event.target.value)}>
<MenuItem value="">(none, standalone)</MenuItem>
{cas.map((ca) => (
<MenuItem key={ca.id} value={ca.id}>{ca.name}</MenuItem>
))}
</TextField>
<TextField
label="Certificate PEM"
multiline
minRows={6}
value={importCertPEM}
onChange={(event) => setImportCertPEM(event.target.value)}
sx={{ '& .MuiInputBase-input': { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace' } }}
/>
<Box>
<Button variant="outlined" component="label" size="small">
Load Certificate File
<input hidden type="file" accept=".pem,.crt,.cer,.txt" onChange={(event) => loadTextFile(event, setImportCertPEM)} />
</Button>
</Box>
<TextField
label="Private Key PEM"
multiline
minRows={6}
value={importKeyPEM}
onChange={(event) => setImportKeyPEM(event.target.value)}
sx={{ '& .MuiInputBase-input': { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace' } }}
/>
<Box>
<Button variant="outlined" component="label" size="small">
Load Private Key File
<input hidden type="file" accept=".pem,.key,.txt" onChange={(event) => loadTextFile(event, setImportKeyPEM)} />
</Button>
</Box>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={() => setImportOpen(false)}>Cancel</Button>
<Button variant="contained" onClick={importCert} disabled={busy}>{busy ? 'Saving...' : 'Import'}</Button>
</DialogActions>
</Dialog>
<Dialog open={Boolean(revokeID)} onClose={() => setRevokeID('')} maxWidth="xs" fullWidth>
<DialogTitle>
<Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="h6">Revoke Certificate</Typography>
{dialogError ? <Alert severity="error">{dialogError}</Alert> : null}
</Box>
</DialogTitle>
<DialogContent>
<TextField fullWidth label="Reason (optional)" value={revokeReason} onChange={(event) => setRevokeReason(event.target.value)} />
</DialogContent>
<DialogActions>
<Button onClick={() => setRevokeID('')}>Cancel</Button>
<Button color="warning" variant="contained" onClick={revokeCert} disabled={busy}>{busy ? 'Working...' : 'Revoke'}</Button>
</DialogActions>
</Dialog>
<Dialog open={Boolean(deleteID)} onClose={() => setDeleteID('')} maxWidth="xs" fullWidth>
<DialogTitle>
<Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="h6">Delete Certificate</Typography>
{dialogError ? <Alert severity="error">{dialogError}</Alert> : null}
</Box>
</DialogTitle>
<DialogContent>
<Typography variant="body2" color="text.secondary">Delete certificate permanently?</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteID('')}>Cancel</Button>
<Button color="error" variant="contained" onClick={deleteCert} disabled={busy}>{busy ? 'Working...' : 'Delete'}</Button>
</DialogActions>
</Dialog>
<Dialog open={Boolean(deleteCAID)} onClose={() => setDeleteCAID('')} maxWidth="xs" fullWidth>
<DialogTitle>
<Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="h6">Delete Certificate Authority</Typography>
{dialogError ? <Alert severity="error">{dialogError}</Alert> : null}
</Box>
</DialogTitle>
<DialogContent>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Type the CA name to confirm deletion.
</Typography>
<TextField fullWidth label="CA Name" value={deleteCAConfirm} onChange={(event) => setDeleteCAConfirm(event.target.value)} />
<FormControlLabel
control={<Checkbox checked={deleteCAForce} onChange={(event) => setDeleteCAForce(event.target.checked)} />}
label="Force delete (includes child CAs and issued certs)"
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteCAID('')}>Cancel</Button>
<Button
color="error"
variant="contained"
onClick={deleteCA}
disabled={busy || deleteCAConfirm !== deleteCAName}
>
{busy ? 'Working...' : 'Delete CA'}
</Button>
</DialogActions>
</Dialog>
<Dialog open={Boolean(viewCA)} onClose={() => setViewCA(null)} maxWidth="md" fullWidth>
<DialogTitle>CA Details</DialogTitle>
<DialogContent>
<Box sx={{ display: 'grid', gap: 1, mt: 1 }}>
<TextField label="ID" value={viewCA?.id || ''} InputProps={{ readOnly: true }} />
<TextField label="Name" value={viewCA?.name || ''} InputProps={{ readOnly: true }} />
<TextField label="Parent CA" value={viewCA?.parent_ca_id || '-'} InputProps={{ readOnly: true }} />
<TextField label="Status" value={viewCA?.status || ''} InputProps={{ readOnly: true }} />
<TextField
label="Certificate PEM"
multiline
minRows={8}
value={viewCA?.cert_pem || ''}
InputProps={{ readOnly: true }}
sx={{ '& .MuiInputBase-input': { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace' } }}
/>
<TextField
label="Private Key PEM"
multiline
minRows={8}
value={viewCA?.key_pem || ''}
InputProps={{ readOnly: true }}
sx={{ '& .MuiInputBase-input': { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace' } }}
/>
</Box>
</DialogContent>
<DialogActions>
<Button
startIcon={<DownloadIcon />}
onClick={async () => {
if (!viewCA) return
const data = await api.downloadPKICABundle(viewCA.id)
downloadBinary(`${viewCA.name || viewCA.id}.ca.bundle.zip`, data, 'application/zip')
}}
>
Download Bundle
</Button>
<Button
startIcon={<DownloadIcon />}
onClick={() => {
if (!viewCA) return
downloadText(`${viewCA.name || viewCA.id}.ca.crt.pem`, viewCA.cert_pem || '')
}}
>
Download Cert
</Button>
<Button
startIcon={<DownloadIcon />}
onClick={() => {
if (!viewCA) return
downloadText(`${viewCA.name || viewCA.id}.ca.key.pem`, viewCA.key_pem || '')
}}
>
Download Key
</Button>
<Button onClick={() => setViewCA(null)}>Close</Button>
</DialogActions>
</Dialog>
<Dialog open={Boolean(editCAID)} onClose={() => setEditCAID('')} maxWidth="xs" fullWidth>
<DialogTitle>
<Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="h6">Edit CA</Typography>
{dialogError ? <Alert severity="error">{dialogError}</Alert> : null}
</Box>
</DialogTitle>
<DialogContent>
<TextField
fullWidth
label="Name"
value={editCAName}
onChange={(event) => setEditCAName(event.target.value)}
sx={{ mt: 1 }}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setEditCAID('')}>Cancel</Button>
<Button variant="contained" onClick={saveCAEdit} disabled={busy || !editCAName.trim()}>
{busy ? 'Saving...' : 'Save'}
</Button>
</DialogActions>
</Dialog>
<Dialog open={Boolean(viewCert)} onClose={() => setViewCert(null)} maxWidth="md" fullWidth>
<DialogTitle>Certificate Details</DialogTitle>
<DialogContent>
<Box sx={{ display: 'grid', gap: 1, mt: 1 }}>
<TextField label="ID" value={viewCert?.id || ''} InputProps={{ readOnly: true }} />
<TextField label="Issuer CA ID" value={viewCert?.ca_id || 'standalone'} InputProps={{ readOnly: true }} />
<TextField label="Serial" value={viewCert?.serial_hex || ''} InputProps={{ readOnly: true }} />
<TextField label="Common Name" value={viewCert?.common_name || ''} InputProps={{ readOnly: true }} />
<TextField label="SAN DNS" value={viewCert?.san_dns || ''} InputProps={{ readOnly: true }} />
<TextField label="SAN IPs" value={viewCert?.san_ips || ''} InputProps={{ readOnly: true }} />
<TextField label="Status" value={viewCert?.status || ''} InputProps={{ readOnly: true }} />
<TextField label="Not Before" value={fmt(viewCert?.not_before || 0)} InputProps={{ readOnly: true }} />
<TextField label="Not After" value={fmt(viewCert?.not_after || 0)} InputProps={{ readOnly: true }} />
<TextField
label="Certificate PEM"
multiline
minRows={8}
value={viewCert?.cert_pem || ''}
InputProps={{ readOnly: true }}
sx={{ '& .MuiInputBase-input': { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace' } }}
/>
<TextField
label="Private Key PEM"
multiline
minRows={8}
value={viewCert?.key_pem || ''}
InputProps={{ readOnly: true }}
sx={{ '& .MuiInputBase-input': { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace' } }}
/>
</Box>
</DialogContent>
<DialogActions>
<Button
startIcon={<DownloadIcon />}
onClick={async () => {
if (!viewCert) return
const data = await api.downloadPKICertBundle(viewCert.id)
downloadBinary(`${viewCert.common_name || viewCert.id}.bundle.zip`, data, 'application/zip')
}}
>
Download Bundle
</Button>
<Button
startIcon={<DownloadIcon />}
onClick={() => {
if (!viewCert) return
downloadText(`${viewCert.common_name || viewCert.id}.crt.pem`, viewCert.cert_pem || '')
}}
>
Download Cert
</Button>
<Button
startIcon={<DownloadIcon />}
onClick={() => {
if (!viewCert) return
downloadText(`${viewCert.common_name || viewCert.id}.key.pem`, viewCert.key_pem || '')
}}
>
Download Key
</Button>
<Button onClick={() => setViewCert(null)}>Close</Button>
</DialogActions>
</Dialog>
</Box>
)
}

View File

@@ -0,0 +1,488 @@
import Alert from '@mui/material/Alert'
import {
Box,
Button,
Chip,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
MenuItem,
Paper,
TextField,
Typography
} from '@mui/material'
import { useEffect, useState } from 'react'
import { api, CertPrincipalBinding, PKICert, PrincipalProjectRole, Project, ServicePrincipal } from '../api'
function fmt(ts: number): string {
if (!ts || ts <= 0) return '-'
return new Date(ts * 1000).toLocaleString()
}
export default function AdminServicePrincipalsPage() {
const [principals, setPrincipals] = useState<ServicePrincipal[]>([])
const [bindings, setBindings] = useState<CertPrincipalBinding[]>([])
const [pkiCerts, setPKICerts] = useState<PKICert[]>([])
const [projects, setProjects] = useState<Project[]>([])
const [principalRoles, setPrincipalRoles] = useState<Record<string, PrincipalProjectRole[]>>({})
const [error, setError] = useState<string | null>(null)
const [dialogError, setDialogError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
const [createOpen, setCreateOpen] = useState(false)
const [name, setName] = useState('')
const [description, setDescription] = useState('')
const [bindingOpen, setBindingOpen] = useState(false)
const [bindSource, setBindSource] = useState<'pki' | 'manual'>('pki')
const [bindPKICertID, setBindPKICertID] = useState('')
const [bindFingerprint, setBindFingerprint] = useState('')
const [bindPrincipalID, setBindPrincipalID] = useState('')
const [busy, setBusy] = useState(false)
const [rolePrincipalID, setRolePrincipalID] = useState('')
const [roleProjectID, setRoleProjectID] = useState('')
const [roleValue, setRoleValue] = useState<'viewer' | 'writer' | 'admin'>('writer')
const [rolePrincipalFilter, setRolePrincipalFilter] = useState('')
const [roleProjectFilter, setRoleProjectFilter] = useState('')
const [roleSearch, setRoleSearch] = useState('')
const load = async () => {
setLoading(true)
setError(null)
try {
const p = await api.listServicePrincipals()
const b = await api.listCertPrincipalBindings()
const c = await api.listPKICerts()
const allProjects = await api.listProjects(1000, 0, '')
setPrincipals(Array.isArray(p) ? p : [])
setBindings(Array.isArray(b) ? b : [])
setPKICerts(Array.isArray(c) ? c : [])
setProjects(Array.isArray(allProjects) ? allProjects : [])
const roleMap: Record<string, PrincipalProjectRole[]> = {}
let i: number
let roles: PrincipalProjectRole[]
for (i = 0; i < (Array.isArray(p) ? p.length : 0); i++) {
roles = await api.listPrincipalProjectRoles(p[i].id)
roleMap[p[i].id] = Array.isArray(roles) ? roles : []
}
setPrincipalRoles(roleMap)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load data')
} finally {
setLoading(false)
}
}
useEffect(() => {
load()
}, [])
const createPrincipal = async () => {
if (!name.trim()) {
setDialogError('Name is required.')
return
}
setBusy(true)
setDialogError(null)
try {
await api.createServicePrincipal({ name: name.trim(), description: description.trim(), disabled: false })
setCreateOpen(false)
setName('')
setDescription('')
await load()
} catch (err) {
setDialogError(err instanceof Error ? err.message : 'Failed to create principal')
} finally {
setBusy(false)
}
}
const togglePrincipal = async (item: ServicePrincipal) => {
setError(null)
try {
await api.updateServicePrincipal(item.id, { name: item.name, description: item.description, is_admin: item.is_admin, disabled: !item.disabled })
await load()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update principal')
}
}
const deletePrincipal = async (item: ServicePrincipal) => {
if (!window.confirm(`Delete principal "${item.name}"?`)) return
setError(null)
try {
await api.deleteServicePrincipal(item.id)
await load()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete principal')
}
}
const upsertBinding = async () => {
let fingerprint: string
let cert: PKICert | undefined
fingerprint = ''
if (bindSource === 'pki') {
cert = pkiCerts.find((item) => item.id === bindPKICertID)
fingerprint = (cert?.fingerprint || '').trim().toLowerCase()
} else {
fingerprint = bindFingerprint.trim().toLowerCase()
}
if (!fingerprint || !bindPrincipalID) {
setDialogError('Fingerprint and principal are required.')
return
}
setBusy(true)
setDialogError(null)
try {
await api.upsertCertPrincipalBinding({
fingerprint: fingerprint,
principal_id: bindPrincipalID,
enabled: true
})
setBindingOpen(false)
setBindSource('pki')
setBindPKICertID('')
setBindFingerprint('')
setBindPrincipalID('')
await load()
} catch (err) {
setDialogError(err instanceof Error ? err.message : 'Failed to save binding')
} finally {
setBusy(false)
}
}
const toggleBinding = async (item: CertPrincipalBinding) => {
setError(null)
try {
await api.upsertCertPrincipalBinding({ fingerprint: item.fingerprint, principal_id: item.principal_id, enabled: !item.enabled })
await load()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update binding')
}
}
const deleteBinding = async (item: CertPrincipalBinding) => {
if (!window.confirm(`Delete binding for fingerprint ${item.fingerprint}?`)) return
setError(null)
try {
await api.deleteCertPrincipalBinding(item.fingerprint)
await load()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete binding')
}
}
const principalName = (id: string): string => {
const p = principals.find((item) => item.id === id)
return p ? p.name : id
}
const togglePrincipalAdmin = async (item: ServicePrincipal) => {
setError(null)
try {
await api.updateServicePrincipal(item.id, { name: item.name, description: item.description, is_admin: !item.is_admin, disabled: item.disabled })
await load()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to update principal admin flag')
}
}
const upsertRole = async () => {
if (!rolePrincipalID || !roleProjectID) {
setDialogError('Principal and project are required for role assignment.')
return
}
setBusy(true)
setDialogError(null)
try {
await api.upsertPrincipalProjectRole(rolePrincipalID, { project_id: roleProjectID, role: roleValue })
setRolePrincipalID('')
setRoleProjectID('')
setRoleValue('writer')
await load()
} catch (err) {
setDialogError(err instanceof Error ? err.message : 'Failed to assign role')
} finally {
setBusy(false)
}
}
const deleteRole = async (principalID: string, projectID: string) => {
setError(null)
try {
await api.deletePrincipalProjectRole(principalID, projectID)
await load()
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to delete role assignment')
}
}
const projectName = (id: string): string => {
const p = projects.find((item) => item.id === id)
if (!p) return id
return `${p.name} (${p.slug})`
}
const pkiCertByFingerprint = (fingerprint: string): PKICert | undefined => {
return pkiCerts.find((item) => (item.fingerprint || '').toLowerCase() === (fingerprint || '').toLowerCase())
}
const filteredPrincipals = principals.filter((principal) => {
if (rolePrincipalFilter && principal.id !== rolePrincipalFilter) return false
const roles = principalRoles[principal.id] || []
if (roleProjectFilter && !roles.some((item) => item.project_id === roleProjectFilter)) return false
if (roleSearch.trim()) {
const q = roleSearch.trim().toLowerCase()
const hitPrincipal =
principal.name.toLowerCase().includes(q) ||
principal.id.toLowerCase().includes(q) ||
principal.description.toLowerCase().includes(q)
if (hitPrincipal) return true
return roles.some((item) => {
const project = projects.find((p) => p.id === item.project_id)
const projectText = project ? `${project.name} ${project.slug}`.toLowerCase() : ''
return item.role.toLowerCase().includes(q) || item.project_id.toLowerCase().includes(q) || projectText.includes(q)
})
}
return true
})
return (
<Box>
<Typography variant="h5" sx={{ mb: 2 }}>Admin: Service Principals</Typography>
{error ? <Alert severity="error" sx={{ mb: 1 }}>{error}</Alert> : null}
<Box sx={{ display: 'grid', gap: 1 }}>
<Paper sx={{ p: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="h6">Principals</Typography>
<Button variant="outlined" onClick={() => { setDialogError(null); setCreateOpen(true) }}>New Principal</Button>
</Box>
{loading && principals.length === 0 ? <Typography variant="body2" color="text.secondary">Loading...</Typography> : null}
<Box sx={{ display: 'grid', gap: 1 }}>
{principals.map((item) => (
<Paper key={item.id} variant="outlined" sx={{ p: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="body2">{item.name} ({item.id})</Typography>
<Chip size="small" color={item.disabled ? 'default' : 'success'} label={item.disabled ? 'Disabled' : 'Active'} />
{item.is_admin ? <Chip size="small" color="warning" label="Principal Admin" /> : null}
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Button size="small" color={item.is_admin ? 'warning' : 'primary'} onClick={() => togglePrincipalAdmin(item)}>
{item.is_admin ? 'Unset Admin' : 'Set Admin'}
</Button>
<Button size="small" color={item.disabled ? 'success' : 'warning'} onClick={() => togglePrincipal(item)}>
{item.disabled ? 'Enable' : 'Disable'}
</Button>
<Button size="small" color="error" onClick={() => deletePrincipal(item)}>Delete</Button>
</Box>
</Box>
<Typography variant="caption" color="text.secondary">
{item.description || '(no description)'} · updated: {fmt(item.updated_at)}
</Typography>
</Paper>
))}
</Box>
</Paper>
<Paper sx={{ p: 2 }}>
<Typography variant="h6" sx={{ mb: 0.5 }}>Project Role Assignments</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mb: 1 }}>
Define what each principal can do per project.
</Typography>
{dialogError ? <Alert severity="error" sx={{ mb: 1 }}>{dialogError}</Alert> : null}
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr auto', gap: 1, mb: 1 }}>
<TextField select label="Principal" value={rolePrincipalID} onChange={(event) => setRolePrincipalID(event.target.value)}>
{principals.map((item) => (
<MenuItem key={item.id} value={item.id}>{item.name}</MenuItem>
))}
</TextField>
<TextField select label="Project" value={roleProjectID} onChange={(event) => setRoleProjectID(event.target.value)}>
{projects.map((item) => (
<MenuItem key={item.id} value={item.id}>{item.name} ({item.slug})</MenuItem>
))}
</TextField>
<TextField select label="Role" value={roleValue} onChange={(event) => setRoleValue(event.target.value as 'viewer' | 'writer' | 'admin')}>
<MenuItem value="viewer">viewer</MenuItem>
<MenuItem value="writer">writer</MenuItem>
<MenuItem value="admin">admin</MenuItem>
</TextField>
<Button variant="outlined" onClick={upsertRole} disabled={busy}>Assign</Button>
</Box>
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr 1fr 2fr auto', gap: 1, mb: 1 }}>
<TextField
select
label="Filter Principal"
value={rolePrincipalFilter}
onChange={(event) => setRolePrincipalFilter(event.target.value)}
size="small"
>
<MenuItem value="">All</MenuItem>
{principals.map((item) => (
<MenuItem key={item.id} value={item.id}>{item.name}</MenuItem>
))}
</TextField>
<TextField
select
label="Filter Project"
value={roleProjectFilter}
onChange={(event) => setRoleProjectFilter(event.target.value)}
size="small"
>
<MenuItem value="">All</MenuItem>
{projects.map((item) => (
<MenuItem key={item.id} value={item.id}>{item.name} ({item.slug})</MenuItem>
))}
</TextField>
<TextField
label="Search"
value={roleSearch}
onChange={(event) => setRoleSearch(event.target.value)}
size="small"
placeholder="Principal/project/role..."
/>
<Button
variant="outlined"
size="small"
onClick={() => {
setRolePrincipalFilter('')
setRoleProjectFilter('')
setRoleSearch('')
}}
>
Reset
</Button>
</Box>
<Box sx={{ display: 'grid', gap: 1 }}>
{filteredPrincipals.map((principal) => (
<Paper key={principal.id} variant="outlined" sx={{ p: 1 }}>
<Typography variant="subtitle2">{principal.name}</Typography>
{(principalRoles[principal.id] || []).length === 0 ? (
<Typography variant="caption" color="text.secondary">No project roles</Typography>
) : (
(principalRoles[principal.id] || []).map((role) => (
<Box key={`${role.principal_id}:${role.project_id}`} sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Typography variant="caption" color="text.secondary">{projectName(role.project_id)} · {role.role}</Typography>
<Button size="small" color="error" onClick={() => deleteRole(role.principal_id, role.project_id)}>Remove</Button>
</Box>
))
)}
</Paper>
))}
</Box>
</Paper>
<Paper sx={{ p: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="h6">Cert Fingerprint Bindings</Typography>
<Button variant="outlined" onClick={() => { setDialogError(null); setBindSource('pki'); setBindPKICertID(''); setBindFingerprint(''); setBindPrincipalID(''); setBindingOpen(true) }}>Add Binding</Button>
</Box>
<Box sx={{ display: 'grid', gap: 1 }}>
{bindings.map((item) => (
<Paper key={item.fingerprint} variant="outlined" sx={{ p: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
<Box sx={{ display: 'grid' }}>
{pkiCertByFingerprint(item.fingerprint) ? (
<Typography variant="body2">
{pkiCertByFingerprint(item.fingerprint)?.common_name || pkiCertByFingerprint(item.fingerprint)?.serial_hex} ({pkiCertByFingerprint(item.fingerprint)?.id})
</Typography>
) : (
<Typography variant="body2">{item.fingerprint}</Typography>
)}
<Typography variant="caption" color="text.secondary">principal: {principalName(item.principal_id)} ({item.principal_id})</Typography>
{pkiCertByFingerprint(item.fingerprint) ? (
<Typography variant="caption" color="text.secondary">
source: pki cert · fingerprint: {item.fingerprint}
</Typography>
) : (
<Typography variant="caption" color="text.secondary">source: manual fingerprint</Typography>
)}
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Button size="small" color={item.enabled ? 'warning' : 'success'} onClick={() => toggleBinding(item)}>
{item.enabled ? 'Disable' : 'Enable'}
</Button>
<Button size="small" color="error" onClick={() => deleteBinding(item)}>Delete</Button>
</Box>
</Box>
</Paper>
))}
</Box>
</Paper>
</Box>
<Dialog open={createOpen} onClose={() => setCreateOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>
<Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="h6">New Service Principal</Typography>
{dialogError ? <Alert severity="error">{dialogError}</Alert> : null}
</Box>
</DialogTitle>
<DialogContent sx={{ display: 'grid', gap: 1, pt: '8px !important' }}>
<TextField label="Name" value={name} onChange={(event) => setName(event.target.value)} />
<TextField label="Description" value={description} onChange={(event) => setDescription(event.target.value)} />
</DialogContent>
<DialogActions>
<Button onClick={() => setCreateOpen(false)}>Cancel</Button>
<Button variant="contained" onClick={createPrincipal} disabled={busy}>{busy ? 'Saving...' : 'Create'}</Button>
</DialogActions>
</Dialog>
<Dialog open={bindingOpen} onClose={() => setBindingOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>
<Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="h6">Add Cert Binding</Typography>
{dialogError ? <Alert severity="error">{dialogError}</Alert> : null}
</Box>
</DialogTitle>
<DialogContent sx={{ display: 'grid', gap: 1, pt: '8px !important' }}>
<TextField
select
label="Binding Source"
value={bindSource}
onChange={(event) => setBindSource(event.target.value as 'pki' | 'manual')}
>
<MenuItem value="pki">PKI Certificate</MenuItem>
<MenuItem value="manual">Manual Fingerprint</MenuItem>
</TextField>
{bindSource === 'pki' ? (
<TextField
select
label="PKI Certificate"
value={bindPKICertID}
onChange={(event) => setBindPKICertID(event.target.value)}
>
{pkiCerts.map((item) => (
<MenuItem key={item.id} value={item.id}>
{item.common_name || item.serial_hex} ({item.id.slice(0, 8)})
</MenuItem>
))}
</TextField>
) : null}
{bindSource === 'manual' ? (
<TextField
label="Fingerprint (sha256 hex)"
value={bindFingerprint}
onChange={(event) => setBindFingerprint(event.target.value)}
/>
) : null}
<TextField
select
label="Principal"
value={bindPrincipalID}
onChange={(event) => setBindPrincipalID(event.target.value)}
>
{principals.map((item) => (
<MenuItem key={item.id} value={item.id}>{item.name} ({item.id})</MenuItem>
))}
</TextField>
</DialogContent>
<DialogActions>
<Button onClick={() => setBindingOpen(false)}>Cancel</Button>
<Button variant="contained" onClick={upsertBinding} disabled={busy}>{busy ? 'Saving...' : 'Save'}</Button>
</DialogActions>
</Dialog>
</Box>
)
}

View File

@@ -0,0 +1,712 @@
import Alert from '@mui/material/Alert'
import {
Box,
Button,
Chip,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
MenuItem,
Paper,
TextField,
Typography
} from '@mui/material'
import { useEffect, useMemo, useState } from 'react'
import { api, PKICA, PKICert, ServicePrincipal, TLSListener, TLSSettings } from '../api'
type ListenerForm = Omit<TLSListener, 'id' | 'created_at' | 'updated_at'>
const emptyListener = (): ListenerForm => ({
name: '',
enabled: false,
http_addrs: [],
https_addrs: [],
auth_policy: 'default',
apply_policy_api: true,
apply_policy_git: true,
apply_policy_rpm: true,
apply_policy_v2: true,
client_cert_allowlist: [],
tls_server_cert_source: 'pki',
tls_cert_file: '',
tls_key_file: '',
tls_pki_server_cert_id: '',
tls_client_auth: 'none',
tls_client_ca_file: '',
tls_pki_client_ca_id: '',
tls_min_version: '1.2'
})
export default function AdminTLSSettingsPage() {
const [settings, setSettings] = useState<TLSSettings>({
http_addrs: [':1080'],
https_addrs: [],
tls_server_cert_source: 'pki',
tls_cert_file: '',
tls_key_file: '',
tls_pki_server_cert_id: '',
tls_client_auth: 'none',
tls_client_ca_file: '',
tls_pki_client_ca_id: '',
tls_min_version: '1.2'
})
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [listeners, setListeners] = useState<TLSListener[]>([])
const [listenersLoading, setListenersLoading] = useState(false)
const [dialogOpen, setDialogOpen] = useState(false)
const [editingMain, setEditingMain] = useState(false)
const [editingID, setEditingID] = useState<string | null>(null)
const [listenerForm, setListenerForm] = useState<ListenerForm>(emptyListener())
const [listenerHTTPText, setListenerHTTPText] = useState('')
const [listenerHTTPSText, setListenerHTTPSText] = useState('')
const [listenerCertAllowText, setListenerCertAllowText] = useState('')
const [selectedAllowedCertIDs, setSelectedAllowedCertIDs] = useState<string[]>([])
const [listenerError, setListenerError] = useState<string | null>(null)
const [listenerSaving, setListenerSaving] = useState(false)
const [runtimeCounts, setRuntimeCounts] = useState<Record<string, number>>({})
const [pkiCAs, setPKICAs] = useState<PKICA[]>([])
const [pkiCerts, setPKICerts] = useState<PKICert[]>([])
const [principals, setPrincipals] = useState<ServicePrincipal[]>([])
const [bindPrincipalID, setBindPrincipalID] = useState('')
const [confirmOpen, setConfirmOpen] = useState(false)
const [confirmTitle, setConfirmTitle] = useState('')
const [confirmMessage, setConfirmMessage] = useState('')
const [confirmLabel, setConfirmLabel] = useState('Confirm')
const [confirmColor, setConfirmColor] = useState<'primary' | 'warning' | 'error'>('primary')
const [confirmAction, setConfirmAction] = useState<null | (() => Promise<void>)>(null)
const certIDByFingerprint = useMemo(() => {
const map: Record<string, string> = {}
pkiCerts.forEach((cert) => {
if (cert.fingerprint) {
map[cert.fingerprint.toLowerCase()] = cert.id
}
})
return map
}, [pkiCerts])
const loadMainSettings = async () => {
setLoading(true)
api
.getTLSSettings()
.then((data) => {
setSettings(data)
})
.catch((err) => {
const message = err instanceof Error ? err.message : 'Failed to load TLS settings'
setError(message)
})
.finally(() => setLoading(false))
}
const loadListeners = async () => {
setListenersLoading(true)
api
.listTLSListeners()
.then((data) => setListeners(Array.isArray(data) ? data : []))
.catch((err) => {
const message = err instanceof Error ? err.message : 'Failed to load extra listeners'
setError(message)
})
.finally(() => setListenersLoading(false))
}
const loadRuntimeStatus = async () => {
api
.getTLSListenerRuntimeStatus()
.then((data) => setRuntimeCounts(data || {}))
.catch(() => setRuntimeCounts({}))
}
const loadPKIOptions = async () => {
api
.listPKICAs()
.then((data) => setPKICAs(Array.isArray(data) ? data : []))
.catch(() => setPKICAs([]))
api
.listPKICerts()
.then((data) => setPKICerts(Array.isArray(data) ? data : []))
.catch(() => setPKICerts([]))
api
.listServicePrincipals()
.then((data) => setPrincipals(Array.isArray(data) ? data : []))
.catch(() => setPrincipals([]))
}
useEffect(() => {
loadMainSettings()
loadListeners()
loadRuntimeStatus()
loadPKIOptions()
}, [])
const splitAddrText = (text: string): string[] => {
return text
.split(/\n|,/)
.map((v) => v.trim())
.filter((v) => v !== '')
}
const openCreateDialog = () => {
setEditingMain(false)
setEditingID(null)
setListenerForm(emptyListener())
setListenerHTTPText('')
setListenerHTTPSText('')
setListenerCertAllowText('')
setSelectedAllowedCertIDs([])
setBindPrincipalID('')
setListenerError(null)
setDialogOpen(true)
}
const openMainEditDialog = () => {
setEditingMain(true)
setEditingID(null)
setListenerForm({
name: 'Main Listener',
enabled: true,
http_addrs: settings.http_addrs || [],
https_addrs: settings.https_addrs || [],
auth_policy: 'default',
apply_policy_api: true,
apply_policy_git: true,
apply_policy_rpm: true,
apply_policy_v2: true,
client_cert_allowlist: [],
tls_server_cert_source: 'pki',
tls_cert_file: '',
tls_key_file: '',
tls_pki_server_cert_id: settings.tls_pki_server_cert_id,
tls_client_auth: settings.tls_client_auth,
tls_client_ca_file: '',
tls_pki_client_ca_id: settings.tls_pki_client_ca_id,
tls_min_version: settings.tls_min_version
})
setListenerHTTPText((settings.http_addrs || []).join('\n'))
setListenerHTTPSText((settings.https_addrs || []).join('\n'))
setListenerCertAllowText('')
setSelectedAllowedCertIDs([])
setBindPrincipalID('')
setListenerError(null)
setDialogOpen(true)
}
const openEditDialog = (item: TLSListener) => {
const matchedIDs = (item.client_cert_allowlist || [])
.map((fp) => certIDByFingerprint[(fp || '').toLowerCase()] || '')
.filter((id) => id !== '')
const matchedFPSet = new Set(
matchedIDs
.map((id) => (pkiCerts.find((cert) => cert.id === id)?.fingerprint || '').toLowerCase())
.filter((fp) => fp !== '')
)
const manualOnlyFPs = (item.client_cert_allowlist || [])
.map((fp) => (fp || '').toLowerCase())
.filter((fp) => fp !== '' && !matchedFPSet.has(fp))
setEditingMain(false)
setEditingID(item.id)
setListenerForm({
name: item.name,
enabled: item.enabled,
http_addrs: item.http_addrs || [],
https_addrs: item.https_addrs || [],
auth_policy: item.auth_policy || 'default',
apply_policy_api: item.apply_policy_api,
apply_policy_git: item.apply_policy_git,
apply_policy_rpm: item.apply_policy_rpm,
apply_policy_v2: item.apply_policy_v2,
client_cert_allowlist: item.client_cert_allowlist || [],
tls_server_cert_source: 'pki',
tls_cert_file: '',
tls_key_file: '',
tls_pki_server_cert_id: item.tls_pki_server_cert_id,
tls_client_auth: item.tls_client_auth,
tls_client_ca_file: '',
tls_pki_client_ca_id: item.tls_pki_client_ca_id,
tls_min_version: item.tls_min_version
})
setListenerHTTPText((item.http_addrs || []).join('\n'))
setListenerHTTPSText((item.https_addrs || []).join('\n'))
setListenerCertAllowText(manualOnlyFPs.join('\n'))
setSelectedAllowedCertIDs(matchedIDs)
setBindPrincipalID('')
setListenerError(null)
setDialogOpen(true)
}
const handleSaveDialog = async () => {
const selectedFingerprints = selectedAllowedCertIDs
.map((id) => (pkiCerts.find((cert) => cert.id === id)?.fingerprint || '').toLowerCase())
.filter((fp) => fp !== '')
const mergedAllowlist = Array.from(new Set([...splitAddrText(listenerCertAllowText), ...selectedFingerprints]))
const payload: ListenerForm = {
...listenerForm,
name: listenerForm.name.trim(),
http_addrs: splitAddrText(listenerHTTPText),
https_addrs: splitAddrText(listenerHTTPSText),
client_cert_allowlist: mergedAllowlist
}
if (!editingMain && !payload.name) {
setListenerError('Listener name is required')
return
}
if (payload.http_addrs.length === 0 && payload.https_addrs.length === 0) {
setListenerError('Provide at least one HTTP or HTTPS address')
return
}
if (!editingMain && !payload.apply_policy_api && !payload.apply_policy_git && !payload.apply_policy_rpm && !payload.apply_policy_v2) {
setListenerError('Select at least one scope (API/Git/RPM/V2).')
return
}
if (!editingMain && (payload.auth_policy === 'read_open_write_cert' || payload.auth_policy === 'cert_only') && payload.client_cert_allowlist.length === 0) {
setListenerError('Client certificate fingerprint allowlist is required for this policy.')
return
}
if (payload.https_addrs.length > 0 && !payload.tls_pki_server_cert_id.trim()) {
setListenerError('TLS PKI Server Cert is required when HTTPS addresses are configured.')
return
}
if ((payload.tls_client_auth === 'require_and_verify' || payload.tls_client_auth === 'verify_if_given') && !payload.tls_pki_client_ca_id.trim()) {
setListenerError('TLS PKI Client CA is required for the selected TLS Client Auth mode.')
return
}
setListenerSaving(true)
setListenerError(null)
try {
if (editingMain) {
await api.updateTLSSettings({
http_addrs: payload.http_addrs,
https_addrs: payload.https_addrs,
tls_server_cert_source: 'pki',
tls_cert_file: '',
tls_key_file: '',
tls_pki_server_cert_id: payload.tls_pki_server_cert_id,
tls_client_auth: payload.tls_client_auth,
tls_client_ca_file: '',
tls_pki_client_ca_id: payload.tls_pki_client_ca_id,
tls_min_version: payload.tls_min_version
})
await loadMainSettings()
} else if (editingID) {
await api.updateTLSListener(editingID, payload)
} else {
await api.createTLSListener(payload)
}
setDialogOpen(false)
if (!editingMain) {
await loadListeners()
}
await loadRuntimeStatus()
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to save listener'
setListenerError(message)
} finally {
setListenerSaving(false)
}
}
const bindSelectedCertsToPrincipal = async () => {
let i: number
let cert: PKICert | undefined
if (!bindPrincipalID) {
setListenerError('Choose a service principal for certificate bindings.')
return
}
if (selectedAllowedCertIDs.length === 0) {
setListenerError('Select at least one PKI certificate to bind.')
return
}
setListenerSaving(true)
setListenerError(null)
try {
for (i = 0; i < selectedAllowedCertIDs.length; i++) {
cert = pkiCerts.find((item) => item.id === selectedAllowedCertIDs[i])
if (!cert || !cert.fingerprint) {
continue
}
await api.upsertCertPrincipalBinding({
fingerprint: cert.fingerprint,
principal_id: bindPrincipalID,
enabled: true
})
}
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to bind selected certs to principal'
setListenerError(message)
return
} finally {
setListenerSaving(false)
}
setListenerError(null)
}
const handleToggleListener = async (item: TLSListener) => {
const nextEnabled = !item.enabled
const payload: ListenerForm = {
name: item.name,
enabled: nextEnabled,
http_addrs: item.http_addrs || [],
https_addrs: item.https_addrs || [],
auth_policy: item.auth_policy || 'default',
apply_policy_api: item.apply_policy_api,
apply_policy_git: item.apply_policy_git,
apply_policy_rpm: item.apply_policy_rpm,
apply_policy_v2: item.apply_policy_v2,
client_cert_allowlist: item.client_cert_allowlist || [],
tls_server_cert_source: 'pki',
tls_cert_file: '',
tls_key_file: '',
tls_pki_server_cert_id: item.tls_pki_server_cert_id,
tls_client_auth: item.tls_client_auth,
tls_client_ca_file: '',
tls_pki_client_ca_id: item.tls_pki_client_ca_id,
tls_min_version: item.tls_min_version
}
setConfirmTitle(nextEnabled ? 'Enable Listener' : 'Disable Listener')
setConfirmMessage(`Do you want to ${nextEnabled ? 'enable' : 'disable'} listener "${item.name}"?`)
setConfirmLabel(nextEnabled ? 'Enable' : 'Disable')
setConfirmColor(nextEnabled ? 'primary' : 'warning')
setConfirmAction(() => async () => {
setError(null)
try {
await api.updateTLSListener(item.id, payload)
await loadListeners()
await loadRuntimeStatus()
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to update listener state'
setError(message)
}
})
setConfirmOpen(true)
}
const handleDeleteListener = async (item: TLSListener) => {
setConfirmTitle('Delete Listener')
setConfirmMessage(`Delete listener "${item.name}"?`)
setConfirmLabel('Delete')
setConfirmColor('error')
setConfirmAction(() => async () => {
setError(null)
try {
await api.deleteTLSListener(item.id)
await loadListeners()
await loadRuntimeStatus()
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to delete listener'
setError(message)
}
})
setConfirmOpen(true)
}
const handleConfirm = async () => {
if (!confirmAction) {
setConfirmOpen(false)
return
}
await confirmAction()
setConfirmOpen(false)
setConfirmAction(null)
}
return (
<Box>
<Typography variant="h5" sx={{ mb: 2 }}>
Admin: Site TLS
</Typography>
<Paper sx={{ p: 2, maxWidth: 980 }}>
{error ? <Alert severity="error" sx={{ mb: 1 }}>{error}</Alert> : null}
{(loading || listenersLoading) ? (
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Loading...
</Typography>
) : null}
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Typography variant="h6">Listeners</Typography>
<Button variant="outlined" onClick={openCreateDialog}>
Add Listener
</Button>
</Box>
<Box sx={{ display: 'grid', gap: 1 }}>
<Paper variant="outlined" sx={{ p: 1, display: 'grid', gap: 0.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="subtitle2">Main Listener</Typography>
<Chip size="small" color="info" label="Main" />
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Button size="small" onClick={openMainEditDialog}>Edit</Button>
</Box>
</Box>
<Typography variant="caption" color="text.secondary">
HTTP: {(settings.http_addrs || []).join(', ') || '(none)'}
</Typography>
<Typography variant="caption" color="text.secondary">
HTTPS: {(settings.https_addrs || []).join(', ') || '(none)'}
</Typography>
</Paper>
{!listenersLoading && (listeners || []).length === 0 ? (
<Typography variant="body2" color="text.secondary">
No additional listeners configured.
</Typography>
) : null}
{(listeners || []).map((item) => (
<Paper key={item.id} variant="outlined" sx={{ p: 1, display: 'grid', gap: 0.5 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="subtitle2">{item.name}</Typography>
{item.enabled ? (
(runtimeCounts[item.id] || 0) > 0 ? (
<Chip size="small" color="success" label={`Running (${runtimeCounts[item.id]} endpoint${runtimeCounts[item.id] > 1 ? 's' : ''})`} />
) : (
<Chip size="small" color="warning" label="Enabled (starting...)" />
)
) : (
<Chip size="small" label="Disabled" />
)}
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Button
size="small"
color={item.enabled ? 'warning' : 'success'}
onClick={() => handleToggleListener(item)}
>
{item.enabled ? 'Disable' : 'Enable'}
</Button>
<Button size="small" onClick={() => openEditDialog(item)}>
Edit
</Button>
<Button size="small" color="error" onClick={() => handleDeleteListener(item)}>
Delete
</Button>
</Box>
</Box>
<Typography variant="caption" color="text.secondary">
HTTP: {(item.http_addrs || []).join(', ') || '(none)'}
</Typography>
<Typography variant="caption" color="text.secondary">
HTTPS: {(item.https_addrs || []).join(', ') || '(none)'}
</Typography>
<Typography variant="caption" color="text.secondary">
Policy: {item.auth_policy || 'default'} · Scope: {item.apply_policy_api ? 'API ' : ''}{item.apply_policy_git ? 'Git ' : ''}{item.apply_policy_rpm ? 'RPM ' : ''}{item.apply_policy_v2 ? 'V2' : ''}
</Typography>
</Paper>
))}
</Box>
</Paper>
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="md" fullWidth>
<DialogTitle>
<Box sx={{ display: 'grid', gap: 1 }}>
<Typography variant="h6">{editingMain ? 'Edit Main Listener' : editingID ? 'Edit Listener' : 'Add Listener'}</Typography>
{listenerError ? <Alert severity="error">{listenerError}</Alert> : null}
</Box>
</DialogTitle>
<DialogContent sx={{ display: 'grid', gap: 1, pt: '8px !important' }}>
{!editingMain ? (
<TextField
label="Name"
value={listenerForm.name}
onChange={(event) => setListenerForm((prev) => ({ ...prev, name: event.target.value }))}
/>
) : null}
<TextField
label="HTTP Addresses (one per line)"
multiline
minRows={2}
value={listenerHTTPText}
onChange={(event) => setListenerHTTPText(event.target.value)}
/>
<TextField
label="HTTPS Addresses (one per line)"
multiline
minRows={2}
value={listenerHTTPSText}
onChange={(event) => setListenerHTTPSText(event.target.value)}
/>
{!editingMain ? (
<TextField
select
label="Auth Policy"
value={listenerForm.auth_policy}
onChange={(event) =>
setListenerForm((prev) => ({
...prev,
auth_policy: event.target.value as ListenerForm['auth_policy']
}))
}
>
<MenuItem value="default">default</MenuItem>
<MenuItem value="read_open_write_cert">read_open_write_cert</MenuItem>
<MenuItem value="read_open_write_cert_or_auth">read_open_write_cert_or_auth</MenuItem>
<MenuItem value="cert_only">cert_only</MenuItem>
<MenuItem value="read_only_public">read_only_public</MenuItem>
</TextField>
) : null}
{!editingMain ? (
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0,1fr))', gap: 1 }}>
<Button
variant={listenerForm.apply_policy_api ? 'contained' : 'outlined'}
size="small"
onClick={() => setListenerForm((prev) => ({ ...prev, apply_policy_api: !prev.apply_policy_api }))}
>
API
</Button>
<Button
variant={listenerForm.apply_policy_git ? 'contained' : 'outlined'}
size="small"
onClick={() => setListenerForm((prev) => ({ ...prev, apply_policy_git: !prev.apply_policy_git }))}
>
Git
</Button>
<Button
variant={listenerForm.apply_policy_rpm ? 'contained' : 'outlined'}
size="small"
onClick={() => setListenerForm((prev) => ({ ...prev, apply_policy_rpm: !prev.apply_policy_rpm }))}
>
RPM
</Button>
<Button
variant={listenerForm.apply_policy_v2 ? 'contained' : 'outlined'}
size="small"
onClick={() => setListenerForm((prev) => ({ ...prev, apply_policy_v2: !prev.apply_policy_v2 }))}
>
V2
</Button>
</Box>
) : null}
{!editingMain ? (
<TextField
select
SelectProps={{ multiple: true }}
label="Allowed PKI Client Certificates"
value={selectedAllowedCertIDs}
onChange={(event) => {
const value = event.target.value
setSelectedAllowedCertIDs(typeof value === 'string' ? value.split(',') : (value as string[]))
}}
helperText="Selected certs are added to the fingerprint allowlist automatically."
>
{pkiCerts.map((cert) => (
<MenuItem key={cert.id} value={cert.id}>
{cert.common_name || cert.serial_hex} ({cert.id.slice(0, 8)})
</MenuItem>
))}
</TextField>
) : null}
{!editingMain ? (
<Box sx={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 1, alignItems: 'center' }}>
<TextField
select
label="Bind Selected Certs To Principal"
value={bindPrincipalID}
onChange={(event) => setBindPrincipalID(event.target.value)}
>
{principals
.filter((item) => !item.disabled)
.map((item) => (
<MenuItem key={item.id} value={item.id}>{item.name} ({item.id.slice(0, 8)})</MenuItem>
))}
</TextField>
<Button variant="outlined" onClick={bindSelectedCertsToPrincipal} disabled={listenerSaving}>
Bind
</Button>
</Box>
) : null}
{!editingMain ? (
<TextField
label="Client Cert Fingerprints (SHA256, one per line)"
multiline
minRows={3}
value={listenerCertAllowText}
onChange={(event) => setListenerCertAllowText(event.target.value)}
helperText="Manual fingerprints only. Fingerprints for selected PKI certs are managed by the selector above."
/>
) : null}
<TextField
select
label="TLS PKI Server Cert"
value={listenerForm.tls_pki_server_cert_id}
onChange={(event) =>
setListenerForm((prev) => ({
...prev,
tls_pki_server_cert_id: event.target.value
}))
}
>
<MenuItem value="">(none)</MenuItem>
{pkiCerts.map((cert) => (
<MenuItem key={cert.id} value={cert.id}>
{cert.common_name || cert.serial_hex} ({cert.id.slice(0, 8)})
</MenuItem>
))}
</TextField>
<TextField
select
label="TLS Client Auth"
value={listenerForm.tls_client_auth}
onChange={(event) =>
setListenerForm((prev) => ({
...prev,
tls_client_auth: event.target.value as 'none' | 'request' | 'require' | 'verify_if_given' | 'require_and_verify'
}))
}
>
<MenuItem value="none">none</MenuItem>
<MenuItem value="request">request</MenuItem>
<MenuItem value="require">require</MenuItem>
<MenuItem value="verify_if_given">verify_if_given</MenuItem>
<MenuItem value="require_and_verify">require_and_verify</MenuItem>
</TextField>
<TextField
select
label="TLS PKI Client CA"
value={listenerForm.tls_pki_client_ca_id}
onChange={(event) => setListenerForm((prev) => ({ ...prev, tls_pki_client_ca_id: event.target.value }))}
>
<MenuItem value="">(none)</MenuItem>
{pkiCAs.map((ca) => (
<MenuItem key={ca.id} value={ca.id}>
{ca.name} ({ca.id.slice(0, 8)})
</MenuItem>
))}
</TextField>
<TextField
select
label="TLS Minimum Version"
value={listenerForm.tls_min_version}
onChange={(event) =>
setListenerForm((prev) => ({ ...prev, tls_min_version: event.target.value as '1.0' | '1.1' | '1.2' | '1.3' }))
}
>
<MenuItem value="1.0">1.0</MenuItem>
<MenuItem value="1.1">1.1</MenuItem>
<MenuItem value="1.2">1.2</MenuItem>
<MenuItem value="1.3">1.3</MenuItem>
</TextField>
</DialogContent>
<DialogActions>
<Button onClick={() => setDialogOpen(false)}>Cancel</Button>
<Button variant="contained" onClick={handleSaveDialog} disabled={listenerSaving}>
{listenerSaving ? 'Saving...' : editingMain || editingID ? 'Save' : 'Create'}
</Button>
</DialogActions>
</Dialog>
<Dialog open={confirmOpen} onClose={() => setConfirmOpen(false)} maxWidth="xs" fullWidth>
<DialogTitle>{confirmTitle}</DialogTitle>
<DialogContent>
<Typography variant="body2">{confirmMessage}</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setConfirmOpen(false)}>Cancel</Button>
<Button variant="contained" color={confirmColor} onClick={handleConfirm}>
{confirmLabel}
</Button>
</DialogActions>
</Dialog>
</Box>
)
}

View File

@@ -1,58 +1,340 @@
import { Box, Button, Checkbox, FormControlLabel, List, ListItem, ListItemText, Paper, TextField, Typography } from '@mui/material'
import AddIcon from '@mui/icons-material/Add'
import DeleteIcon from '@mui/icons-material/Delete'
import BlockIcon from '@mui/icons-material/Block'
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'
import EditIcon from '@mui/icons-material/Edit'
import Alert from '@mui/material/Alert'
import {
Box,
Button,
Checkbox,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
FormControlLabel,
IconButton,
List,
ListItem,
ListItemText,
Paper,
TextField,
Typography
} from '@mui/material'
import { useEffect, useState } from 'react'
import { api, User } from '../api'
export default function AdminUsersPage() {
const [users, setUsers] = useState<User[]>([])
const [createOpen, setCreateOpen] = useState(false)
const [createError, setCreateError] = useState<string | null>(null)
const [creating, setCreating] = useState(false)
const [username, setUsername] = useState('')
const [displayName, setDisplayName] = useState('')
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [passwordConfirm, setPasswordConfirm] = useState('')
const [isAdmin, setIsAdmin] = useState(false)
const [error, setError] = useState<string | null>(null)
const [deletingUser, setDeletingUser] = useState<User | null>(null)
const [deleteConfirm, setDeleteConfirm] = useState('')
const [deleting, setDeleting] = useState(false)
const [togglingID, setTogglingID] = useState('')
const [editUser, setEditUser] = useState<User | null>(null)
const [editDisplayName, setEditDisplayName] = useState('')
const [editEmail, setEditEmail] = useState('')
const [editPassword, setEditPassword] = useState('')
const [editPasswordConfirm, setEditPasswordConfirm] = useState('')
const [editIsAdmin, setEditIsAdmin] = useState(false)
const [editError, setEditError] = useState<string | null>(null)
const [savingEdit, setSavingEdit] = useState(false)
useEffect(() => {
api.listUsers().then((list) => setUsers(Array.isArray(list) ? list : []))
}, [])
const handleCreate = async (evt: React.FormEvent<HTMLFormElement>) => {
const reloadUsers = async () => {
const list = await api.listUsers()
setUsers(Array.isArray(list) ? list : [])
}
const resetCreateForm = () => {
setUsername('')
setDisplayName('')
setEmail('')
setPassword('')
setPasswordConfirm('')
setIsAdmin(false)
setCreateError(null)
}
const closeCreateDialog = () => {
setCreateOpen(false)
resetCreateForm()
}
const handleCreate = async (evt: React.FormEvent) => {
evt.preventDefault()
const data = new FormData(evt.currentTarget)
setCreateError(null)
setError(null)
const payload = {
username: String(data.get('username') || ''),
display_name: String(data.get('display_name') || ''),
email: String(data.get('email') || ''),
password: String(data.get('password') || ''),
is_admin: String(data.get('is_admin') || '') === 'on'
username: username.trim(),
display_name: displayName.trim(),
email: email.trim(),
password: password,
is_admin: isAdmin
}
if (password !== passwordConfirm) {
setCreateError('Password and confirmation must match.')
return
}
setCreating(true)
try {
const created = await api.createUser(payload)
setUsers((prev) => [...prev, created])
closeCreateDialog()
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to create user'
setCreateError(message)
} finally {
setCreating(false)
}
}
const toggleUserState = async (user: User) => {
setError(null)
setTogglingID(user.id)
try {
if (user.disabled) {
await api.enableUser(user.id)
} else {
await api.disableUser(user.id)
}
await reloadUsers()
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to update user'
setError(message)
} finally {
setTogglingID('')
}
}
const handleDeleteUser = async () => {
if (!deletingUser) {
return
}
setDeleting(true)
setError(null)
try {
await api.deleteUser(deletingUser.id)
setDeletingUser(null)
setDeleteConfirm('')
await reloadUsers()
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to delete user'
setError(message)
} finally {
setDeleting(false)
}
}
const openEditUser = (user: User) => {
setEditUser(user)
setEditDisplayName(user.display_name || '')
setEditEmail(user.email || '')
setEditPassword('')
setEditPasswordConfirm('')
setEditIsAdmin(Boolean(user.is_admin))
setEditError(null)
}
const closeEditUser = () => {
setEditUser(null)
setEditDisplayName('')
setEditEmail('')
setEditPassword('')
setEditPasswordConfirm('')
setEditIsAdmin(false)
setEditError(null)
}
const handleSaveEdit = async () => {
if (!editUser) {
return
}
setSavingEdit(true)
setEditError(null)
if (editPassword !== editPasswordConfirm) {
setEditError('Password and confirmation must match.')
setSavingEdit(false)
return
}
try {
const updated = await api.updateUser(editUser.id, {
display_name: editDisplayName.trim(),
email: editEmail.trim(),
password: editPassword,
is_admin: editIsAdmin
})
setUsers((prev) => prev.map((user) => (user.id === updated.id ? updated : user)))
closeEditUser()
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to update user'
setEditError(message)
} finally {
setSavingEdit(false)
}
const created = await api.createUser(payload)
setUsers((prev) => [...prev, created])
evt.currentTarget.reset()
}
return (
<Box>
<Typography variant="h5" gutterBottom>
Admin: Users
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Typography variant="h5">Admin: Users</Typography>
<Button variant="outlined" startIcon={<AddIcon />} onClick={() => setCreateOpen(true)}>
New User
</Button>
</Box>
<Paper sx={{ p: 2, mb: 2 }}>
{error ? <Alert severity="error" sx={{ mb: 1 }}>{error}</Alert> : null}
<List>
{users.map((u) => (
<ListItem key={u.id} divider>
<ListItemText primary={u.username} secondary={u.is_admin ? 'admin' : 'user'} />
<ListItem
key={u.id}
divider
secondaryAction={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<IconButton size="small" onClick={() => openEditUser(u)} title="Edit user">
<EditIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
color={u.disabled ? 'success' : 'warning'}
onClick={() => toggleUserState(u)}
title={u.disabled ? 'Enable user' : 'Disable user'}
disabled={togglingID === u.id}
>
{u.disabled ? <CheckCircleOutlineIcon fontSize="small" /> : <BlockIcon fontSize="small" />}
</IconButton>
<IconButton size="small" color="error" onClick={() => setDeletingUser(u)}>
<DeleteIcon fontSize="small" />
</IconButton>
</Box>
}
>
<ListItemText
primary={`${u.display_name || u.username} (${u.username})${u.disabled ? ' (disabled)' : ''}`}
secondary={`${u.is_admin ? 'admin' : 'user'} · source: ${u.auth_source || 'db'}`}
/>
</ListItem>
))}
</List>
</Paper>
<Paper sx={{ p: 2 }}>
<Typography variant="subtitle1" gutterBottom>
Create User
</Typography>
<Box component="form" onSubmit={handleCreate} sx={{ display: 'grid', gap: 1 }}>
<TextField name="username" label="Username" />
<TextField name="display_name" label="Display Name" />
<TextField name="email" label="Email" />
<TextField name="password" label="Password" type="password" />
<FormControlLabel control={<Checkbox name="is_admin" />} label="Admin" />
<Button type="submit" variant="contained">
Create
<Dialog open={createOpen} onClose={closeCreateDialog} maxWidth="sm" fullWidth>
<DialogTitle>New User</DialogTitle>
<DialogContent>
{createError ? <Alert severity="error" sx={{ mb: 1 }}>{createError}</Alert> : null}
<Box component="form" onSubmit={handleCreate} sx={{ display: 'grid', gap: 1, mt: 1 }}>
<TextField name="username" label="Username" value={username} onChange={(event) => setUsername(event.target.value)} />
<TextField
name="display_name"
label="Display Name"
value={displayName}
onChange={(event) => setDisplayName(event.target.value)}
/>
<TextField name="email" label="Email" value={email} onChange={(event) => setEmail(event.target.value)} />
<TextField
name="password"
label="Password"
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
<TextField
name="password_confirm"
label="Confirm Password"
type="password"
value={passwordConfirm}
error={passwordConfirm !== '' && password !== passwordConfirm}
helperText={passwordConfirm !== '' && password !== passwordConfirm ? 'Passwords do not match.' : ''}
onChange={(event) => setPasswordConfirm(event.target.value)}
/>
<FormControlLabel
control={<Checkbox checked={isAdmin} onChange={(event) => setIsAdmin(event.target.checked)} />}
label="Admin"
/>
<DialogActions sx={{ px: 0 }}>
<Button onClick={closeCreateDialog}>Cancel</Button>
<Button type="submit" variant="contained" disabled={creating}>
{creating ? 'Creating...' : 'Create'}
</Button>
</DialogActions>
</Box>
</DialogContent>
</Dialog>
<Dialog open={Boolean(editUser)} onClose={closeEditUser} maxWidth="sm" fullWidth>
<DialogTitle>Edit User</DialogTitle>
<DialogContent>
{editError ? <Alert severity="error" sx={{ mb: 1 }}>{editError}</Alert> : null}
<Box sx={{ display: 'grid', gap: 1, mt: 1 }}>
<TextField label="Username" value={editUser?.username || ''} disabled />
<TextField
label="Display Name"
value={editDisplayName}
onChange={(event) => setEditDisplayName(event.target.value)}
/>
<TextField label="Email" value={editEmail} onChange={(event) => setEditEmail(event.target.value)} />
<TextField
label="New Password (optional)"
type="password"
value={editPassword}
onChange={(event) => setEditPassword(event.target.value)}
/>
<TextField
label="Confirm New Password"
type="password"
value={editPasswordConfirm}
error={editPasswordConfirm !== '' && editPassword !== editPasswordConfirm}
helperText={editPasswordConfirm !== '' && editPassword !== editPasswordConfirm ? 'Passwords do not match.' : ''}
onChange={(event) => setEditPasswordConfirm(event.target.value)}
/>
<FormControlLabel
control={<Checkbox checked={editIsAdmin} onChange={(event) => setEditIsAdmin(event.target.checked)} />}
label="Admin"
/>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={closeEditUser}>Cancel</Button>
<Button variant="contained" onClick={handleSaveEdit} disabled={savingEdit}>
{savingEdit ? 'Saving...' : 'Save'}
</Button>
</Box>
</Paper>
</DialogActions>
</Dialog>
<Dialog open={Boolean(deletingUser)} onClose={() => setDeletingUser(null)} maxWidth="xs" fullWidth>
<DialogTitle>Delete User</DialogTitle>
<DialogContent>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Type the username to confirm deletion.
</Typography>
<TextField
fullWidth
label="Username"
value={deleteConfirm}
onChange={(event) => setDeleteConfirm(event.target.value)}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => { setDeletingUser(null); setDeleteConfirm('') }}>Cancel</Button>
<Button
color="error"
variant="contained"
disabled={deleting || !deletingUser || deleteConfirm !== deletingUser.username}
onClick={handleDeleteUser}
>
{deleting ? 'Deleting...' : 'Delete'}
</Button>
</DialogActions>
</Dialog>
</Box>
)
}

View File

@@ -0,0 +1,312 @@
import AddIcon from '@mui/icons-material/Add'
import ContentCopyIcon from '@mui/icons-material/ContentCopy'
import DeleteIcon from '@mui/icons-material/Delete'
import BlockIcon from '@mui/icons-material/Block'
import CheckCircleOutlineIcon from '@mui/icons-material/CheckCircleOutline'
import Alert from '@mui/material/Alert'
import {
Box,
Button,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
IconButton,
List,
ListItem,
ListItemText,
MenuItem,
Paper,
TextField,
Typography
} from '@mui/material'
import { useEffect, useState } from 'react'
import { api, APIKey } from '../api'
function formatUnix(value: number) {
if (!value || value <= 0) {
return 'Never'
}
return new Date(value * 1000).toLocaleString()
}
export default function ApiKeysPage() {
const [keys, setKeys] = useState<APIKey[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [createOpen, setCreateOpen] = useState(false)
const [createName, setCreateName] = useState('')
const [createExpiryAmount, setCreateExpiryAmount] = useState('')
const [createExpiryUnit, setCreateExpiryUnit] = useState<'days' | 'hours' | 'minutes' | 'seconds'>('days')
const [createError, setCreateError] = useState<string | null>(null)
const [creating, setCreating] = useState(false)
const [newToken, setNewToken] = useState<string | null>(null)
const [tokenCopied, setTokenCopied] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<APIKey | null>(null)
const [deleteConfirm, setDeleteConfirm] = useState('')
const [deleting, setDeleting] = useState(false)
const [togglingID, setTogglingID] = useState('')
const load = async () => {
setError(null)
setLoading(true)
try {
const list = await api.listAPIKeys()
setKeys(Array.isArray(list) ? list : [])
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to load API keys'
setError(message)
} finally {
setLoading(false)
}
}
useEffect(() => {
load()
}, [])
const handleCreate = async () => {
var expiresAtUnix: number
var amount: number
var seconds: number
var nowUnix: number
setCreateError(null)
if (!createName.trim()) {
setCreateError('Name is required.')
return
}
expiresAtUnix = 0
if (createExpiryAmount.trim()) {
amount = Number(createExpiryAmount)
if (!Number.isFinite(amount) || amount <= 0) {
setCreateError('Expiration must be a positive number.')
return
}
seconds = amount
if (createExpiryUnit == 'days') {
seconds = amount * 24 * 60 * 60
} else if (createExpiryUnit == 'hours') {
seconds = amount * 60 * 60
} else if (createExpiryUnit == 'minutes') {
seconds = amount * 60
}
nowUnix = Math.floor(Date.now() / 1000)
expiresAtUnix = nowUnix + Math.floor(seconds)
}
setCreating(true)
try {
const created = await api.createAPIKey(createName.trim(), expiresAtUnix)
setNewToken(created.token)
setTokenCopied(false)
setCreateName('')
setCreateExpiryAmount('')
setCreateExpiryUnit('days')
await load()
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to create API key'
setCreateError(message)
} finally {
setCreating(false)
}
}
const handleCopyToken = async () => {
if (!newToken) {
return
}
try {
await navigator.clipboard.writeText(newToken)
setTokenCopied(true)
} catch {
setTokenCopied(false)
}
}
const handleDelete = async () => {
if (!deleteTarget) {
return
}
setDeleting(true)
setError(null)
try {
await api.deleteAPIKey(deleteTarget.id)
setDeleteTarget(null)
setDeleteConfirm('')
await load()
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to delete API key'
setError(message)
} finally {
setDeleting(false)
}
}
const toggleKeyState = async (key: APIKey) => {
setTogglingID(key.id)
setError(null)
try {
if (key.disabled) {
await api.enableAPIKey(key.id)
} else {
await api.disableAPIKey(key.id)
}
await load()
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to update API key'
setError(message)
} finally {
setTogglingID('')
}
}
const closeCreate = () => {
setCreateOpen(false)
setCreateName('')
setCreateExpiryAmount('')
setCreateExpiryUnit('days')
setCreateError(null)
setNewToken(null)
setTokenCopied(false)
}
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
<Typography variant="h5">API Keys</Typography>
<Button variant="outlined" startIcon={<AddIcon />} onClick={() => setCreateOpen(true)}>
New API Key
</Button>
</Box>
<Paper sx={{ p: 2 }}>
{error ? <Alert severity="error" sx={{ mb: 1 }}>{error}</Alert> : null}
{loading ? (
<Typography variant="body2" color="text.secondary">Loading API keys...</Typography>
) : (
<List>
{keys.map((key) => (
<ListItem
key={key.id}
divider
secondaryAction={
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<IconButton
size="small"
color={key.disabled ? 'success' : 'warning'}
onClick={() => toggleKeyState(key)}
title={key.disabled ? 'Enable key' : 'Disable key'}
disabled={togglingID === key.id}
>
{key.disabled ? <CheckCircleOutlineIcon fontSize="small" /> : <BlockIcon fontSize="small" />}
</IconButton>
<IconButton size="small" color="error" onClick={() => setDeleteTarget(key)}>
<DeleteIcon fontSize="small" />
</IconButton>
</Box>
}
>
<ListItemText
primary={key.disabled ? `${key.name} (disabled)` : key.name}
secondary={`Prefix: ${key.prefix} | Created: ${formatUnix(key.created_at)} | Last used: ${formatUnix(key.last_used_at)} | Expires: ${key.expires_at > 0 ? formatUnix(key.expires_at) : 'Never'}`}
/>
</ListItem>
))}
{!keys.length ? (
<Typography variant="body2" color="text.secondary">No API keys yet.</Typography>
) : null}
</List>
)}
</Paper>
<Dialog open={createOpen} onClose={closeCreate} maxWidth="sm" fullWidth>
<DialogTitle>Create API Key</DialogTitle>
<DialogContent>
{createError ? <Alert severity="error" sx={{ mb: 1 }}>{createError}</Alert> : null}
<TextField
margin="dense"
fullWidth
label="Key name"
value={createName}
onChange={(event) => setCreateName(event.target.value)}
disabled={Boolean(newToken)}
/>
<TextField
margin="dense"
label="Expires in (optional)"
value={createExpiryAmount}
onChange={(event) => setCreateExpiryAmount(event.target.value)}
disabled={Boolean(newToken)}
sx={{ width: 180 }}
/>
<TextField
margin="dense"
select
label="Unit"
value={createExpiryUnit}
onChange={(event) => setCreateExpiryUnit(event.target.value as 'days' | 'hours' | 'minutes' | 'seconds')}
disabled={Boolean(newToken)}
sx={{ width: 160, ml: 1 }}
>
<MenuItem value="days">Days</MenuItem>
<MenuItem value="hours">Hours</MenuItem>
<MenuItem value="minutes">Minutes</MenuItem>
<MenuItem value="seconds">Seconds</MenuItem>
</TextField>
{newToken ? (
<Alert severity="warning" sx={{ mt: 1 }}>
This token is shown only once. Copy and store it now.
</Alert>
) : null}
{newToken ? (
<Paper variant="outlined" sx={{ mt: 1, p: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="body2" sx={{ flex: 1, wordBreak: 'break-all' }}>
{newToken}
</Typography>
<IconButton size="small" onClick={handleCopyToken} title="Copy API key">
<ContentCopyIcon fontSize="small" />
</IconButton>
</Paper>
) : null}
{tokenCopied ? (
<Typography variant="body2" color="success.main" sx={{ mt: 1 }}>
Copied.
</Typography>
) : null}
</DialogContent>
<DialogActions>
<Button onClick={closeCreate}>{newToken ? 'Done' : 'Cancel'}</Button>
{!newToken ? (
<Button onClick={handleCreate} variant="contained" disabled={creating}>
{creating ? 'Creating...' : 'Create'}
</Button>
) : null}
</DialogActions>
</Dialog>
<Dialog open={Boolean(deleteTarget)} onClose={() => setDeleteTarget(null)} maxWidth="xs" fullWidth>
<DialogTitle>Delete API Key</DialogTitle>
<DialogContent>
<Typography variant="body2" color="text.secondary" sx={{ mb: 1 }}>
Type the API key name to confirm deletion.
</Typography>
<TextField
fullWidth
label="Key name"
value={deleteConfirm}
onChange={(event) => setDeleteConfirm(event.target.value)}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => { setDeleteTarget(null); setDeleteConfirm('') }}>Cancel</Button>
<Button
color="error"
variant="contained"
disabled={deleting || !deleteTarget || deleteConfirm !== deleteTarget.name}
onClick={handleDelete}
>
{deleting ? 'Deleting...' : 'Delete'}
</Button>
</DialogActions>
</Dialog>
</Box>
)
}

View File

@@ -64,11 +64,42 @@ export default function CommitDetailPage() {
})
}, [repoId])
const handleFileDiff = async (file: string) => {
if (!repoId || !hash) return
const res = await api.getRepoFileDiff(repoId, hash, file)
const extractFileDiffFromCommitDiff = (raw: string, file: string) => {
const text = (raw || '').replace(/\r\n/g, '\n')
if (!text) return ''
const lines = text.split('\n')
const blocks: string[] = []
let current: string[] = []
let i = 0
let line = ''
for (i = 0; i < lines.length; i += 1) {
line = lines[i]
if (line.startsWith('diff --git ')) {
if (current.length > 0) {
blocks.push(current.join('\n'))
}
current = [line]
} else if (current.length > 0) {
current.push(line)
}
}
if (current.length > 0) {
blocks.push(current.join('\n'))
}
for (i = 0; i < blocks.length; i += 1) {
const block = blocks[i]
const header = block.split('\n', 1)[0] || ''
if (header.includes(` a/${file} `) || header.endsWith(` a/${file}`) || header.includes(` b/${file} `) || header.endsWith(` b/${file}`)) {
return block
}
}
return ''
}
const handleFileDiff = (file: string) => {
const extracted = extractFileDiffFromCommitDiff(diff, file)
setSelectedFile(file)
setFileDiff(res.diff || '')
setFileDiff(extracted)
}
const renderSplitDiff = (raw: string) => {

View File

@@ -9,7 +9,7 @@ export default function GlobalReposPage() {
const [reposError, setReposError] = useState<string | null>(null)
const [reposLoading, setReposLoading] = useState(false)
const [query, setQuery] = useState('')
const [typeFilter, setTypeFilter] = useState<'all' | 'git' | 'rpm'>('all')
const [typeFilter, setTypeFilter] = useState<'all' | 'git' | 'rpm' | 'docker'>('all')
const [page, setPage] = useState(0)
const [pageSize, setPageSize] = useState(20)
const [pageSizeInput, setPageSizeInput] = useState('20')
@@ -82,7 +82,7 @@ export default function GlobalReposPage() {
size="small"
value={typeFilter}
onChange={(event) => {
setTypeFilter(event.target.value as 'all' | 'git' | 'rpm')
setTypeFilter(event.target.value as 'all' | 'git' | 'rpm' | 'docker')
setPage(0)
}}
sx={{ minWidth: 120 }}
@@ -90,6 +90,7 @@ export default function GlobalReposPage() {
<MenuItem value="all">All</MenuItem>
<MenuItem value="git">Git</MenuItem>
<MenuItem value="rpm">RPM</MenuItem>
<MenuItem value="docker">Docker</MenuItem>
</TextField>
<TextField
label="Items"

View File

@@ -1,12 +1,27 @@
import { Box, Button, Paper, TextField, Typography } from '@mui/material'
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { api } from '../api'
export default function LoginPage() {
const [error, setError] = useState<string | null>(null)
const [oidcEnabled, setOIDCEnabled] = useState(false)
const [oidcConfigured, setOIDCConfigured] = useState(true)
const navigate = useNavigate()
useEffect(() => {
api
.oidcStatus()
.then((res) => {
setOIDCEnabled(Boolean(res.enabled))
setOIDCConfigured(res.configured !== false)
})
.catch(() => {
setOIDCEnabled(false)
setOIDCConfigured(false)
})
}, [])
const handleLogin = async (evt: React.FormEvent<HTMLFormElement>) => {
evt.preventDefault()
const data = new FormData(evt.currentTarget)
@@ -36,6 +51,27 @@ export default function LoginPage() {
<Button type="submit" variant="contained" fullWidth sx={{ mt: 2 }}>
Login
</Button>
{oidcEnabled ? (
<>
<Button
type="button"
variant="outlined"
fullWidth
sx={{ mt: 1 }}
onClick={() => {
window.location.assign('/api/auth/oidc/login')
}}
disabled={!oidcConfigured}
>
Login with OIDC
</Button>
{!oidcConfigured ? (
<Typography color="warning.main" variant="body2" sx={{ mt: 1 }}>
OIDC is selected but not fully configured.
</Typography>
) : null}
</>
) : null}
</Box>
</Paper>
)

View File

@@ -0,0 +1,37 @@
import { Box, Typography } from '@mui/material'
import { useEffect } from 'react'
import { useNavigate, useParams } from 'react-router-dom'
import { api } from '../api'
function targetPath(projectId: string, homePage?: string) {
if (homePage === 'repos') return `/projects/${projectId}/repos`
if (homePage === 'issues') return `/projects/${projectId}/issues`
if (homePage === 'wiki') return `/projects/${projectId}/wiki`
if (homePage === 'files') return `/projects/${projectId}/files`
return `/projects/${projectId}/info`
}
export default function ProjectEntryPage() {
const { projectId } = useParams()
const navigate = useNavigate()
useEffect(() => {
if (!projectId) return
api
.getProject(projectId)
.then((project) => {
navigate(targetPath(projectId, project.home_page), { replace: true })
})
.catch(() => {
navigate(`/projects/${projectId}/info`, { replace: true })
})
}, [projectId, navigate])
return (
<Box>
<Typography variant="body2" color="text.secondary">
Opening project...
</Typography>
</Box>
)
}

View File

@@ -1,12 +1,13 @@
import Alert from '@mui/material/Alert'
import { Box, Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle, Paper, Tab, Tabs, TextField, Typography } from '@mui/material'
import { Box, Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle, IconButton, MenuItem, Paper, Tab, Tabs, TextField, ToggleButton, ToggleButtonGroup, Typography } from '@mui/material'
import { useEffect, useState } from 'react'
import { Link, useNavigate, useParams } from 'react-router-dom'
import { api, Project } from '../api'
import { api, Project, ProjectMember, User } from '../api'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import ProjectNavBar from '../components/ProjectNavBar'
import HeaderActionButton from '../components/HeaderActionButton'
import DeleteIcon from '@mui/icons-material/Delete'
export default function ProjectHomePage() {
const { projectId } = useParams()
@@ -15,6 +16,7 @@ export default function ProjectHomePage() {
const [editSlug, setEditSlug] = useState('')
const [editName, setEditName] = useState('')
const [editDescription, setEditDescription] = useState('')
const [editHomePage, setEditHomePage] = useState<'info' | 'repos' | 'issues' | 'wiki' | 'files'>('info')
const [editDescTab, setEditDescTab] = useState<'write' | 'preview'>('write')
const [editError, setEditError] = useState<string | null>(null)
const [savingEdit, setSavingEdit] = useState(false)
@@ -26,6 +28,15 @@ export default function ProjectHomePage() {
const [deleteError, setDeleteError] = useState<string | null>(null)
const [deleteLoading, setDeleteLoading] = useState(false)
const [deleting, setDeleting] = useState(false)
const [members, setMembers] = useState<ProjectMember[]>([])
const [memberUsers, setMemberUsers] = useState<User[]>([])
const [membersError, setMembersError] = useState<string | null>(null)
const [canManageMembers, setCanManageMembers] = useState(false)
const [newMemberUserID, setNewMemberUserID] = useState('')
const [newMemberRole, setNewMemberRole] = useState<'viewer' | 'writer' | 'admin'>('viewer')
const [addingMember, setAddingMember] = useState(false)
const [updatingMemberUserID, setUpdatingMemberUserID] = useState('')
const [removingMemberUserID, setRemovingMemberUserID] = useState('')
const navigate = useNavigate()
useEffect(() => {
@@ -33,11 +44,34 @@ export default function ProjectHomePage() {
api.getProject(projectId).then(setProject)
}, [projectId])
useEffect(() => {
if (!projectId) return
api
.listProjectMembers(projectId)
.then((list) => {
setMembers(Array.isArray(list) ? list : [])
})
.catch((err) => {
const message = err instanceof Error ? err.message : 'Failed to load members'
setMembersError(message)
})
api
.listProjectMemberCandidates(projectId)
.then((list) => {
setMemberUsers(Array.isArray(list) ? list : [])
setCanManageMembers(true)
})
.catch(() => {
setCanManageMembers(false)
})
}, [projectId])
const openEdit = () => {
if (!project) return
setEditSlug(project.slug)
setEditName(project.name)
setEditDescription(project.description || '')
setEditHomePage(project.home_page || 'info')
setEditDescTab('write')
setEditError(null)
setEditOpen(true)
@@ -52,7 +86,8 @@ export default function ProjectHomePage() {
const payload = {
slug: editSlug.trim(),
name: editName.trim(),
description: editDescription.trim()
description: editDescription.trim(),
home_page: editHomePage
}
setEditError(null)
setSavingEdit(true)
@@ -119,6 +154,66 @@ export default function ProjectHomePage() {
}
}
const refreshMembers = async () => {
if (!projectId) return
const list = await api.listProjectMembers(projectId)
setMembers(Array.isArray(list) ? list : [])
}
const handleAddMember = async () => {
if (!projectId || !newMemberUserID) return
setMembersError(null)
setAddingMember(true)
try {
await api.addProjectMember(projectId, { user_id: newMemberUserID, role: newMemberRole })
setNewMemberUserID('')
setNewMemberRole('viewer')
await refreshMembers()
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to add member'
setMembersError(message)
} finally {
setAddingMember(false)
}
}
const handleUpdateMemberRole = async (userID: string, role: string) => {
if (!projectId) return
setMembersError(null)
setUpdatingMemberUserID(userID)
try {
await api.updateProjectMember(projectId, { user_id: userID, role })
await refreshMembers()
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to update member role'
setMembersError(message)
} finally {
setUpdatingMemberUserID('')
}
}
const handleRemoveMember = async (userID: string) => {
if (!projectId) return
setMembersError(null)
setRemovingMemberUserID(userID)
try {
await api.removeProjectMember(projectId, userID)
await refreshMembers()
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to remove member'
setMembersError(message)
} finally {
setRemovingMemberUserID('')
}
}
const memberName = (userID: string) => {
const user = memberUsers.find((item) => item.id === userID)
if (!user) return userID
if (user.display_name) return `${user.display_name} (${user.username})`
return user.username
}
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1, gap: 2, flexWrap: 'wrap' }}>
@@ -201,6 +296,96 @@ export default function ProjectHomePage() {
</Typography>
)}
</Paper>
<Paper sx={{ p: 2, mt: 1 }}>
<Typography variant="subtitle1" sx={{ mb: 1 }}>
Members
</Typography>
{membersError ? <Alert severity="error" sx={{ mb: 1 }}>{membersError}</Alert> : null}
{canManageMembers ? (
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center', mb: 1, flexWrap: 'wrap' }}>
<TextField
select
size="small"
label="User"
value={newMemberUserID}
onChange={(event) => setNewMemberUserID(event.target.value)}
sx={{ minWidth: 260 }}
>
<MenuItem value="">Select user</MenuItem>
{memberUsers
.filter((user) => !members.some((member) => member.user_id === user.id))
.map((user) => (
<MenuItem key={user.id} value={user.id}>
{user.display_name ? `${user.display_name} (${user.username})` : user.username}
</MenuItem>
))}
</TextField>
<ToggleButtonGroup
size="small"
exclusive
value={newMemberRole}
onChange={(_, value) => {
if (value) {
setNewMemberRole(value as 'viewer' | 'writer' | 'admin')
}
}}
>
<ToggleButton value="viewer">Viewer</ToggleButton>
<ToggleButton value="writer">Writer</ToggleButton>
<ToggleButton value="admin">Admin</ToggleButton>
</ToggleButtonGroup>
<Button variant="outlined" onClick={handleAddMember} disabled={!newMemberUserID || addingMember}>
{addingMember ? 'Adding...' : 'Add Member'}
</Button>
</Box>
) : null}
<Box sx={{ display: 'grid', gap: 0.75 }}>
{members.map((member) => (
<Box
key={member.user_id}
sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 1, flexWrap: 'wrap' }}
>
<Typography variant="body2">{memberName(member.user_id)}</Typography>
{canManageMembers ? (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<ToggleButtonGroup
size="small"
exclusive
value={member.role}
onChange={(_, value) => {
if (value && value !== member.role) {
handleUpdateMemberRole(member.user_id, value)
}
}}
disabled={updatingMemberUserID === member.user_id}
>
<ToggleButton value="viewer">Viewer</ToggleButton>
<ToggleButton value="writer">Writer</ToggleButton>
<ToggleButton value="admin">Admin</ToggleButton>
</ToggleButtonGroup>
<IconButton
size="small"
color="error"
onClick={() => handleRemoveMember(member.user_id)}
disabled={removingMemberUserID === member.user_id}
>
<DeleteIcon fontSize="small" />
</IconButton>
</Box>
) : (
<Typography variant="body2" color="text.secondary">
{member.role}
</Typography>
)}
</Box>
))}
{members.length === 0 ? (
<Typography variant="body2" color="text.secondary">
No members.
</Typography>
) : null}
</Box>
</Paper>
<Dialog open={editOpen} onClose={() => setEditOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>Edit Project</DialogTitle>
<DialogContent>
@@ -221,6 +406,20 @@ export default function ProjectHomePage() {
value={editName}
onChange={(event) => setEditName(event.target.value)}
/>
<TextField
margin="dense"
select
label="Default Project Page"
fullWidth
value={editHomePage}
onChange={(event) => setEditHomePage(event.target.value as 'info' | 'repos' | 'issues' | 'wiki' | 'files')}
>
<MenuItem value="info">Info</MenuItem>
<MenuItem value="repos">Repositories</MenuItem>
<MenuItem value="issues">Issues</MenuItem>
<MenuItem value="wiki">Wiki</MenuItem>
<MenuItem value="files">Files</MenuItem>
</TextField>
<Box sx={{ mt: 1 }}>
<Tabs
value={editDescTab}

View File

@@ -2,7 +2,7 @@ import DeleteIcon from '@mui/icons-material/Delete'
import EditIcon from '@mui/icons-material/Edit'
import Alert from '@mui/material/Alert'
import { Autocomplete } from '@mui/material'
import { Box, Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle, IconButton, List, ListItem, ListItemText, Paper, Tab, Tabs, TextField, Typography } from '@mui/material'
import { Box, Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle, IconButton, List, ListItem, ListItemText, MenuItem, Paper, Tab, Tabs, TextField, Typography } from '@mui/material'
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { api, Project, User } from '../api'
@@ -17,12 +17,14 @@ export default function ProjectsPage() {
const [editSlug, setEditSlug] = useState('')
const [editName, setEditName] = useState('')
const [editDescription, setEditDescription] = useState('')
const [editHomePage, setEditHomePage] = useState<'info' | 'repos' | 'issues' | 'wiki' | 'files'>('info')
const [editDescTab, setEditDescTab] = useState<'write' | 'preview'>('write')
const [editError, setEditError] = useState<string | null>(null)
const [createOpen, setCreateOpen] = useState(false)
const [createError, setCreateError] = useState<string | null>(null)
const [creating, setCreating] = useState(false)
const [createDescription, setCreateDescription] = useState('')
const [createHomePage, setCreateHomePage] = useState<'info' | 'repos' | 'issues' | 'wiki' | 'files'>('info')
const [createDescTab, setCreateDescTab] = useState<'write' | 'preview'>('write')
const [savingEdit, setSavingEdit] = useState(false)
const [deleteProject, setDeleteProject] = useState<Project | null>(null)
@@ -74,7 +76,8 @@ export default function ProjectsPage() {
const payload = {
slug: String(data.get('slug') || ''),
name: String(data.get('name') || ''),
description: createDescription
description: createDescription,
home_page: createHomePage
}
if (/\s/.test(payload.slug)) {
setCreateError('Slug cannot contain whitespace.')
@@ -87,6 +90,7 @@ export default function ProjectsPage() {
setProjects((prev) => [...prev, created])
form.reset()
setCreateDescription('')
setCreateHomePage('info')
setCreateDescTab('write')
setCreateOpen(false)
} catch (err) {
@@ -102,6 +106,7 @@ export default function ProjectsPage() {
setEditSlug(project.slug)
setEditName(project.name)
setEditDescription(project.description || '')
setEditHomePage(project.home_page || 'info')
setEditDescTab('write')
setEditError(null)
}
@@ -115,7 +120,8 @@ export default function ProjectsPage() {
const payload = {
slug: editSlug.trim(),
name: editName.trim(),
description: editDescription.trim()
description: editDescription.trim(),
home_page: editHomePage
}
setEditError(null)
setSavingEdit(true)
@@ -126,6 +132,7 @@ export default function ProjectsPage() {
setEditSlug('')
setEditName('')
setEditDescription('')
setEditHomePage('info')
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to update project'
setEditError(message)
@@ -190,6 +197,7 @@ export default function ProjectsPage() {
setCreateOpen(false)
setCreateError(null)
setCreateDescription('')
setCreateHomePage('info')
setCreateDescTab('write')
}
@@ -339,6 +347,20 @@ export default function ProjectsPage() {
value={editName}
onChange={(event) => setEditName(event.target.value)}
/>
<TextField
margin="dense"
select
label="Default Project Page"
fullWidth
value={editHomePage}
onChange={(event) => setEditHomePage(event.target.value as 'info' | 'repos' | 'issues' | 'wiki' | 'files')}
>
<MenuItem value="info">Info</MenuItem>
<MenuItem value="repos">Repositories</MenuItem>
<MenuItem value="issues">Issues</MenuItem>
<MenuItem value="wiki">Wiki</MenuItem>
<MenuItem value="files">Files</MenuItem>
</TextField>
<Box sx={{ mt: 1 }}>
<Tabs
value={editDescTab}
@@ -393,6 +415,18 @@ export default function ProjectsPage() {
helperText={createError && createError.toLowerCase().includes('slug') ? createError : ''}
/>
<TextField name="name" label="Name" />
<TextField
select
label="Default Project Page"
value={createHomePage}
onChange={(event) => setCreateHomePage(event.target.value as 'info' | 'repos' | 'issues' | 'wiki' | 'files')}
>
<MenuItem value="info">Info</MenuItem>
<MenuItem value="repos">Repositories</MenuItem>
<MenuItem value="issues">Issues</MenuItem>
<MenuItem value="wiki">Wiki</MenuItem>
<MenuItem value="files">Files</MenuItem>
</TextField>
<Box sx={{ mt: 1 }}>
<Tabs
value={createDescTab}

View File

@@ -3,6 +3,7 @@ import { useEffect, useState } from 'react'
import { useParams } from 'react-router-dom'
import { api, Repo } from '../api'
import RepoGitDetailPage from './RepoGitDetailPage'
import RepoDockerDetailPage from './RepoDockerDetailPage'
import RepoRpmDetailPage from './RepoRpmDetailPage'
export default function RepoDetailPage() {
@@ -69,5 +70,8 @@ export default function RepoDetailPage() {
if (repo.type === 'rpm') {
return <RepoRpmDetailPage initialRepo={repo} />
}
if (repo.type === 'docker') {
return <RepoDockerDetailPage initialRepo={repo} />
}
return <RepoGitDetailPage initialRepo={repo} />
}

View File

@@ -0,0 +1,580 @@
import {
Alert,
Box,
Button,
Divider,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
IconButton,
List,
ListItem,
ListItemButton,
ListItemText,
Paper,
TextField,
Typography
} from '@mui/material'
import { useEffect, useRef, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import { api, DockerManifestDetail, DockerTagInfo, Project, Repo } from '../api'
import ProjectNavBar from '../components/ProjectNavBar'
import RepoSubNav from '../components/RepoSubNav'
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'
import DriveFileRenameOutlineIcon from '@mui/icons-material/DriveFileRenameOutline'
type RepoDockerDetailPageProps = {
initialRepo?: Repo
}
export default function RepoDockerDetailPage(props: RepoDockerDetailPageProps) {
const { initialRepo } = props
const { projectId, repoId } = useParams()
const [repo, setRepo] = useState<Repo | null>(initialRepo || null)
const [project, setProject] = useState<Project | null>(null)
const [loadError, setLoadError] = useState<string | null>(null)
const [images, setImages] = useState<string[]>([])
const [imagesError, setImagesError] = useState<string | null>(null)
const [selectedImage, setSelectedImage] = useState('')
const [tags, setTags] = useState<DockerTagInfo[]>([])
const [tagsError, setTagsError] = useState<string | null>(null)
const [selectedTag, setSelectedTag] = useState<DockerTagInfo | null>(null)
const [detail, setDetail] = useState<DockerManifestDetail | null>(null)
const [detailError, setDetailError] = useState<string | null>(null)
const [detailLoading, setDetailLoading] = useState(false)
const [deleteTagOpen, setDeleteTagOpen] = useState(false)
const [deleteTagName, setDeleteTagName] = useState('')
const [deleteTagConfirm, setDeleteTagConfirm] = useState('')
const [deleteImageOpen, setDeleteImageOpen] = useState(false)
const [deleteImagePath, setDeleteImagePath] = useState('')
const [deleteImageLabel, setDeleteImageLabel] = useState('')
const [deleteImageConfirm, setDeleteImageConfirm] = useState('')
const [deleteError, setDeleteError] = useState<string | null>(null)
const [deleting, setDeleting] = useState(false)
const [renameTagOpen, setRenameTagOpen] = useState(false)
const [renameTagFrom, setRenameTagFrom] = useState('')
const [renameTagTo, setRenameTagTo] = useState('')
const [renameImageOpen, setRenameImageOpen] = useState(false)
const [renameImageFrom, setRenameImageFrom] = useState('')
const [renameImageFromLabel, setRenameImageFromLabel] = useState('')
const [renameImageTo, setRenameImageTo] = useState('')
const [renameError, setRenameError] = useState<string | null>(null)
const [renaming, setRenaming] = useState(false)
const initRepoRef = useRef<string | null>(null)
const initProjectRef = useRef<string | null>(null)
useEffect(() => {
if (!repoId) return
if (initRepoRef.current === repoId) return
initRepoRef.current = repoId
setLoadError(null)
if (initialRepo && initialRepo.id === repoId) {
setRepo(initialRepo)
return
}
api.getRepo(repoId).then((data) => setRepo(data)).catch((err) => {
const message = err instanceof Error ? err.message : 'Failed to load repository'
setLoadError(message)
setRepo(null)
})
}, [repoId, initialRepo])
useEffect(() => {
if (!projectId) return
if (initProjectRef.current === projectId) return
initProjectRef.current = projectId
api
.getProject(projectId)
.then((data) => setProject(data))
.catch(() => setProject(null))
}, [projectId])
useEffect(() => {
if (!repoId) return
if (!repo || repo.type !== 'docker') {
setImages([])
setImagesError(null)
setSelectedImage('')
return
}
api
.listDockerImages(repoId)
.then((data) => {
const list = Array.isArray(data) ? data : []
setImages(list)
setImagesError(null)
if (list.length && !selectedImage) {
setSelectedImage(list[0])
}
})
.catch((err) => {
const message = err instanceof Error ? err.message : 'Failed to load images'
setImagesError(message)
setImages([])
})
}, [repoId, repo])
useEffect(() => {
if (!repoId) return
if (!repo || repo.type !== 'docker') {
setTags([])
setTagsError(null)
return
}
api
.listDockerTags(repoId, selectedImage)
.then((data) => {
setTags(Array.isArray(data) ? data : [])
setTagsError(null)
})
.catch((err) => {
const message = err instanceof Error ? err.message : 'Failed to load tags'
setTagsError(message)
setTags([])
})
}, [repoId, repo, selectedImage])
const handleTagSelect = (tag: DockerTagInfo) => {
if (!repoId) return
setSelectedTag(tag)
setDetail(null)
setDetailError(null)
setDetailLoading(true)
api
.getDockerManifest(repoId, tag.tag, selectedImage)
.then((data) => {
setDetail(data)
setDetailError(null)
})
.catch((err) => {
const message = err instanceof Error ? err.message : 'Failed to load manifest'
setDetailError(message)
})
.finally(() => {
setDetailLoading(false)
})
}
const refreshImages = () => {
if (!repoId) return
api
.listDockerImages(repoId)
.then((data) => {
const list = Array.isArray(data) ? data : []
setImages(list)
setImagesError(null)
if (list.length && !list.includes(selectedImage)) {
setSelectedImage(list[0])
}
})
.catch((err) => {
const message = err instanceof Error ? err.message : 'Failed to load images'
setImagesError(message)
setImages([])
})
}
const refreshTags = () => {
if (!repoId) return
api
.listDockerTags(repoId, selectedImage)
.then((data) => {
setTags(Array.isArray(data) ? data : [])
setTagsError(null)
})
.catch((err) => {
const message = err instanceof Error ? err.message : 'Failed to load tags'
setTagsError(message)
setTags([])
})
}
const handleDeleteTag = async () => {
if (!repoId || !deleteTagName) return
if (deleteTagConfirm.trim() !== deleteTagName) {
setDeleteError('Type the tag name to confirm deletion.')
return
}
setDeleteError(null)
setDeleting(true)
try {
await api.deleteDockerTag(repoId, selectedImage, deleteTagName)
setDeleteTagOpen(false)
setDeleteTagConfirm('')
setDeleteTagName('')
refreshTags()
setDetail(null)
setSelectedTag(null)
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to delete tag'
setDeleteError(message)
} finally {
setDeleting(false)
}
}
const handleDeleteImage = async () => {
if (!repoId) return
if (deleteImageConfirm.trim() !== deleteImageLabel) {
setDeleteError('Type the image name to confirm deletion.')
return
}
setDeleteError(null)
setDeleting(true)
try {
await api.deleteDockerImage(repoId, deleteImagePath)
setDeleteImageOpen(false)
setDeleteImageConfirm('')
setDeleteImagePath('')
setDeleteImageLabel('')
setSelectedImage('')
setSelectedTag(null)
setDetail(null)
refreshImages()
refreshTags()
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to delete image'
setDeleteError(message)
} finally {
setDeleting(false)
}
}
const handleRenameTag = async () => {
if (!repoId) return
if (!renameTagFrom.trim() || !renameTagTo.trim()) {
setRenameError('Both tag names are required.')
return
}
setRenameError(null)
setRenaming(true)
try {
await api.renameDockerTag(repoId, selectedImage, renameTagFrom.trim(), renameTagTo.trim())
setRenameTagOpen(false)
setRenameTagFrom('')
setRenameTagTo('')
refreshTags()
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to rename tag'
setRenameError(message)
} finally {
setRenaming(false)
}
}
const handleRenameImage = async () => {
if (!repoId) return
setRenameError(null)
setRenaming(true)
try {
await api.renameDockerImage(repoId, renameImageFrom, renameImageTo.trim())
setRenameImageOpen(false)
setRenameImageFrom('')
setRenameImageFromLabel('')
setRenameImageTo('')
setSelectedImage(renameImageTo.trim())
refreshImages()
refreshTags()
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to rename image'
setRenameError(message)
} finally {
setRenaming(false)
}
}
return (
<Box>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flexWrap: 'wrap' }}>
<Button component={Link} to="/projects" size="small">
Projects
</Button>
<Typography variant="body2" color="text.secondary">
/
</Typography>
{project ? (
<>
<Button component={Link} to={`/projects/${projectId}`} size="small">
{project.name}
</Button>
<Typography variant="body2" color="text.secondary">
/
</Typography>
</>
) : null}
<Button component={Link} to={`/projects/${projectId}/repos`} size="small">
Repositories
</Button>
<Typography variant="body2" color="text.secondary">
/
</Typography>
<Typography variant="body2" sx={{ fontWeight: 600 }}>
{repo?.name || 'Repository'}
</Typography>
</Box>
{projectId ? <ProjectNavBar projectId={projectId} sx={{ mb: 0, minWidth: 320 }} /> : null}
</Box>
{projectId && repoId ? <RepoSubNav projectId={projectId} repoId={repoId} repoType={repo?.type} /> : null}
<Divider sx={{ mb: 1 }} />
{loadError ? (
<Typography variant="body2" color="error" sx={{ mb: 1 }}>
{loadError}
</Typography>
) : null}
<Box sx={{ display: 'grid', gridTemplateColumns: '280px minmax(0, 1fr)', gap: 1, mb: 1 }}>
<Paper sx={{ p: 2 }}>
{imagesError ? (
<Alert severity="warning" sx={{ mb: 1 }}>
{imagesError}
</Alert>
) : null}
<List dense>
{images.map((image) => (
<ListItem key={image || 'root'} disablePadding>
<ListItemButton
onClick={() => {
setSelectedImage(image)
setSelectedTag(null)
setDetail(null)
}}
>
<ListItemText
primary={
<Typography variant="body2" sx={{ fontWeight: selectedImage === image ? 600 : 400 }}>
{image || '(root)'}
</Typography>
}
/>
</ListItemButton>
<IconButton
size="small"
onClick={(event) => {
event.stopPropagation()
setDeleteError(null)
setDeleteImagePath(image)
setDeleteImageLabel(image || '(root)')
setDeleteImageConfirm('')
setDeleteImageOpen(true)
}}
aria-label={`Delete image ${image || '(root)'}`}
>
<DeleteOutlineIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
onClick={(event) => {
event.stopPropagation()
setRenameError(null)
setRenameImageFrom(image)
setRenameImageFromLabel(image || '(root)')
setRenameImageTo(image || '')
setRenameImageOpen(true)
}}
aria-label={`Rename image ${image || '(root)'}`}
>
<DriveFileRenameOutlineIcon fontSize="small" />
</IconButton>
</ListItem>
))}
{!images.length && !imagesError ? (
<Typography variant="body2" color="text.secondary" sx={{ px: 1, py: 1 }}>
No images found.
</Typography>
) : null}
</List>
</Paper>
<Paper sx={{ p: 2 }}>
{tagsError ? (
<Alert severity="warning" sx={{ mb: 1 }}>
{tagsError}
</Alert>
) : null}
{selectedImage !== '' || images.includes('') ? (
<Box sx={{ mb: 1 }}>
<Typography variant="body2" color="text.secondary">
Image: {selectedImage || '(root)'}
</Typography>
</Box>
) : null}
<List dense>
{tags.map((tag) => (
<ListItem key={tag.tag} disablePadding>
<ListItemButton onClick={() => handleTagSelect(tag)}>
<ListItemText
primary={
<Typography variant="body2" sx={{ fontWeight: selectedTag?.tag === tag.tag ? 600 : 400 }}>
{tag.tag}
</Typography>
}
secondary={tag.digest ? tag.digest.slice(0, 12) : ''}
/>
</ListItemButton>
<IconButton
size="small"
onClick={() => {
setDeleteError(null)
setDeleteTagName(tag.tag)
setDeleteTagConfirm('')
setDeleteTagOpen(true)
}}
aria-label={`Delete tag ${tag.tag}`}
>
<DeleteOutlineIcon fontSize="small" />
</IconButton>
<IconButton
size="small"
onClick={() => {
setRenameError(null)
setRenameTagFrom(tag.tag)
setRenameTagTo(tag.tag)
setRenameTagOpen(true)
}}
aria-label={`Rename tag ${tag.tag}`}
>
<DriveFileRenameOutlineIcon fontSize="small" />
</IconButton>
</ListItem>
))}
{!tags.length && !tagsError ? (
<Typography variant="body2" color="text.secondary" sx={{ px: 1, py: 1 }}>
No tags found.
</Typography>
) : null}
</List>
{detailLoading ? (
<Typography variant="body2" color="text.secondary">
Loading manifest...
</Typography>
) : null}
{detailError ? (
<Alert severity="warning">{detailError}</Alert>
) : null}
{detail ? (
<Box sx={{ display: 'grid', gap: 0.5 }}>
<Typography variant="body2">Tag: {detail.reference}</Typography>
<Typography variant="body2">Digest: {detail.digest}</Typography>
<Typography variant="body2">Media: {detail.media_type}</Typography>
<Typography variant="body2">Size: {detail.size} bytes</Typography>
<Divider sx={{ my: 0.5 }} />
<Typography variant="body2">OS: {detail.config?.os || 'n/a'}</Typography>
<Typography variant="body2">Architecture: {detail.config?.architecture || 'n/a'}</Typography>
<Typography variant="body2">
Created: {detail.config?.created ? new Date(detail.config.created).toLocaleString() : 'n/a'}
</Typography>
<Divider sx={{ my: 0.5 }} />
<Typography variant="body2">Layers: {detail.layers?.length || 0}</Typography>
<List dense>
{detail.layers?.map((layer) => (
<ListItem key={layer.digest}>
<ListItemText primary={layer.digest} secondary={`${layer.size} bytes`} />
</ListItem>
))}
</List>
</Box>
) : (
<Typography variant="body2" color="text.secondary">
Select a tag to view details.
</Typography>
)}
</Paper>
</Box>
<Dialog open={deleteTagOpen} onClose={() => setDeleteTagOpen(false)} maxWidth="xs" fullWidth>
<DialogTitle>Delete tag</DialogTitle>
<DialogContent>
{deleteError ? <Alert severity="error">{deleteError}</Alert> : null}
<Typography variant="body2" sx={{ mt: 1 }}>
Delete tag "{deleteTagName}"?
</Typography>
<TextField
label="Type tag to confirm"
value={deleteTagConfirm}
onChange={(event) => setDeleteTagConfirm(event.target.value)}
fullWidth
sx={{ mt: 1 }}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteTagOpen(false)}>Cancel</Button>
<Button
onClick={handleDeleteTag}
variant="contained"
color="error"
disabled={deleting || deleteTagConfirm.trim() !== deleteTagName}
>
{deleting ? 'Deleting...' : 'Delete'}
</Button>
</DialogActions>
</Dialog>
<Dialog open={deleteImageOpen} onClose={() => setDeleteImageOpen(false)} maxWidth="xs" fullWidth>
<DialogTitle>Delete image</DialogTitle>
<DialogContent>
{deleteError ? <Alert severity="error">{deleteError}</Alert> : null}
<Typography variant="body2" sx={{ mt: 1 }}>
Delete image "{deleteImageLabel || '(root)'}" and all tags?
</Typography>
<TextField
label="Type image name to confirm"
value={deleteImageConfirm}
onChange={(event) => setDeleteImageConfirm(event.target.value)}
fullWidth
sx={{ mt: 1 }}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteImageOpen(false)}>Cancel</Button>
<Button
onClick={handleDeleteImage}
variant="contained"
color="error"
disabled={deleting || deleteImageConfirm.trim() !== deleteImageLabel}
>
{deleting ? 'Deleting...' : 'Delete'}
</Button>
</DialogActions>
</Dialog>
<Dialog open={renameTagOpen} onClose={() => setRenameTagOpen(false)} maxWidth="xs" fullWidth>
<DialogTitle>Rename tag</DialogTitle>
<DialogContent>
{renameError ? <Alert severity="error">{renameError}</Alert> : null}
<TextField label="From" value={renameTagFrom} fullWidth margin="dense" InputProps={{ readOnly: true }} />
<TextField
label="To"
value={renameTagTo}
onChange={(event) => setRenameTagTo(event.target.value)}
fullWidth
margin="dense"
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setRenameTagOpen(false)}>Cancel</Button>
<Button onClick={handleRenameTag} variant="contained" disabled={renaming}>
{renaming ? 'Renaming...' : 'Rename'}
</Button>
</DialogActions>
</Dialog>
<Dialog open={renameImageOpen} onClose={() => setRenameImageOpen(false)} maxWidth="xs" fullWidth>
<DialogTitle>Rename image</DialogTitle>
<DialogContent>
{renameError ? <Alert severity="error">{renameError}</Alert> : null}
<TextField label="From" value={renameImageFromLabel} fullWidth margin="dense" InputProps={{ readOnly: true }} />
<TextField
label="To"
value={renameImageTo}
onChange={(event) => setRenameImageTo(event.target.value)}
fullWidth
margin="dense"
helperText="Leave blank to move to root."
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setRenameImageOpen(false)}>Cancel</Button>
<Button onClick={handleRenameImage} variant="contained" disabled={renaming}>
{renaming ? 'Renaming...' : 'Rename'}
</Button>
</DialogActions>
</Dialog>
</Box>
)
}

View File

@@ -33,6 +33,7 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
const [defaultBranch, setDefaultBranch] = useState<string>('')
const [tree, setTree] = useState<RepoTreeEntry[]>([])
const [treeError, setTreeError] = useState<string | null>(null)
const [treeReloadTick, setTreeReloadTick] = useState(0)
const [fileQuery, setFileQuery] = useState('')
const [path, setPath] = useState('')
const [pathSegments, setPathSegments] = useState<string[]>([])
@@ -142,7 +143,7 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
if (!ref && branches.length === 0) return
if (!repo) return
if (repo && repo.type && repo.type !== 'git') return
const key = `${repoId}:${ref}:${path}`
const key = `${repoId}:${ref}:${path}:${treeReloadTick}`
if (lastTreeKey.current === key) return
lastTreeKey.current = key
api.listRepoTree(repoId, ref || undefined, path)
@@ -163,7 +164,7 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
setSelectedCommit(null)
}
})
}, [repoId, ref, path, branches])
}, [repoId, ref, path, branches, treeReloadTick])
useEffect(() => {
if (!repoId || !ref) {
@@ -270,6 +271,10 @@ export default function RepoGitDetailPage(props: RepoGitDetailPageProps) {
}
const handleBreadcrumb = (nextPath: string) => {
if (nextPath === path) {
setTreeReloadTick((prev) => prev + 1)
return
}
setPath(nextPath)
if (nextPath === '') {
setPathSegments([])

File diff suppressed because it is too large Load Diff

View File

@@ -463,7 +463,7 @@ export default function ReposPage() {
<Chip label={repo.type || 'git'} size="small" variant="outlined" />
{repo.is_foreign ? <Chip label="foreign" size="small" color="warning" variant="outlined" /> : null}
{repo.is_foreign && repo.owner_slug ? <Chip label={repo.owner_slug} size="small" variant="outlined" /> : null}
{(repo.type === 'rpm' ? repo.rpm_url : repo.clone_url) ? (
{(repo.type === 'rpm' ? repo.rpm_url : repo.type === 'docker' ? repo.docker_url : repo.clone_url) ? (
<Typography
variant="body2"
color="text.secondary"
@@ -476,7 +476,7 @@ export default function ReposPage() {
textOverflow: 'ellipsis'
}}
>
{repo.type === 'rpm' ? repo.rpm_url : repo.clone_url}
{repo.type === 'rpm' ? repo.rpm_url : repo.type === 'docker' ? repo.docker_url : repo.clone_url}
</Typography>
) : null}
</Box>
@@ -530,6 +530,7 @@ export default function ReposPage() {
>
<MenuItem value="git">Git</MenuItem>
<MenuItem value="rpm">RPM</MenuItem>
<MenuItem value="docker">Docker</MenuItem>
</TextField>
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1, mt: 1 }}>
<Button onClick={() => setCreateOpen(false)}>Cancel</Button>

View File

@@ -5,7 +5,7 @@ export default defineConfig({
plugins: [react()],
server: {
proxy: {
'/api': 'http://localhost:1080'
'^/api(?:/|$)': 'http://localhost:1080'
}
}
})