Compare commits
22 Commits
298d792577
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 4acf4971bf | |||
| d4b7554633 | |||
| 3b1fceb889 | |||
| ac35bcb776 | |||
| e86d87da36 | |||
| be4ed5c497 | |||
| 33552ae88f | |||
| a5cbe9eb31 | |||
| 32730fc2a7 | |||
| c4e48b6dc3 | |||
| bf80ad9d61 | |||
| ad12690d33 | |||
| 007987869d | |||
| 484e96f407 | |||
| 7a84045f33 | |||
| fab83b3e68 | |||
| babb07fb51 | |||
| e46ccc9c6e | |||
| ac9ac4cbc7 | |||
| 8855bb77fb | |||
| cc176b4d29 | |||
| c95953d078 |
21
backend/Makefile
Normal file
21
backend/Makefile
Normal 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
|
||||
572
backend/cmd/codit-data-browser/main.go
Normal file
572
backend/cmd/codit-data-browser/main.go
Normal 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
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
58
backend/internal/auth/auth_test.go
Normal file
58
backend/internal/auth/auth_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
73
backend/internal/config/config_test.go
Normal file
73
backend/internal/config/config_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
23
backend/internal/db/db_test.go
Normal file
23
backend/internal/db/db_test.go
Normal 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
354
backend/internal/db/pki.go
Normal 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
|
||||
}
|
||||
216
backend/internal/db/principals.go
Normal file
216
backend/internal/db/principals.go
Normal 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
|
||||
}
|
||||
397
backend/internal/db/rpm_dirs.go
Normal file
397
backend/internal/db/rpm_dirs.go
Normal 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
94
backend/internal/db/tls_listeners.go
Normal file
94
backend/internal/db/tls_listeners.go
Normal 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
|
||||
}
|
||||
287
backend/internal/docker/browse.go
Normal file
287
backend/internal/docker/browse.go
Normal 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
|
||||
}
|
||||
325
backend/internal/docker/layout.go
Normal file
325
backend/internal/docker/layout.go
Normal 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)
|
||||
}
|
||||
674
backend/internal/docker/registry.go
Normal file
674
backend/internal/docker/registry.go
Normal 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()
|
||||
}
|
||||
35
backend/internal/docker/registry_test.go
Normal file
35
backend/internal/docker/registry_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
38
backend/internal/git/http_test.go
Normal file
38
backend/internal/git/http_test.go
Normal 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
434
backend/internal/handlers/oidc.go
Normal file
434
backend/internal/handlers/oidc.go
Normal 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,
|
||||
})
|
||||
}
|
||||
1056
backend/internal/handlers/pki.go
Normal file
1056
backend/internal/handlers/pki.go
Normal file
File diff suppressed because it is too large
Load Diff
39
backend/internal/handlers/response_test.go
Normal file
39
backend/internal/handlers/response_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
37
backend/internal/http/router_test.go
Normal file
37
backend/internal/http/router_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
43
backend/internal/middleware/access-log.go
Normal file
43
backend/internal/middleware/access-log.go
Normal 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())
|
||||
})
|
||||
}
|
||||
41
backend/internal/middleware/access_log_test.go
Normal file
41
backend/internal/middleware/access_log_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
|
||||
69
backend/internal/middleware/auth_test.go
Normal file
69
backend/internal/middleware/auth_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
75
backend/internal/models/models_json_test.go
Normal file
75
backend/internal/models/models_json_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
54
backend/internal/rpm/http_test.go
Normal file
54
backend/internal/rpm/http_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
908
backend/internal/rpm/mirror.go
Normal file
908
backend/internal/rpm/mirror.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
56
backend/internal/storage/files_test.go
Normal file
56
backend/internal/storage/files_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
35
backend/internal/util/id_test.go
Normal file
35
backend/internal/util/id_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
10
backend/internal/util/token.go
Normal file
10
backend/internal/util/token.go
Normal 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[:])
|
||||
}
|
||||
28
backend/internal/util/token_test.go
Normal file
28
backend/internal/util/token_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
@@ -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 = '';
|
||||
@@ -1,3 +0,0 @@
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
ALTER TABLE repos ADD COLUMN type TEXT NOT NULL DEFAULT 'git';
|
||||
@@ -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);
|
||||
@@ -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 }) =>
|
||||
|
||||
@@ -58,6 +58,11 @@ export default function App() {
|
||||
fontFamily
|
||||
},
|
||||
components: {
|
||||
MuiDialog: {
|
||||
defaultProps: {
|
||||
disableRestoreFocus: true
|
||||
}
|
||||
},
|
||||
MuiPaper: {
|
||||
styleOverrides: {
|
||||
root: {
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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 /> }
|
||||
|
||||
@@ -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`}
|
||||
|
||||
@@ -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
|
||||
|
||||
101
frontend/src/pages/AccountPage.tsx
Normal file
101
frontend/src/pages/AccountPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
315
frontend/src/pages/AdminApiKeysPage.tsx
Normal file
315
frontend/src/pages/AdminApiKeysPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
219
frontend/src/pages/AdminAuthLdapPage.tsx
Normal file
219
frontend/src/pages/AdminAuthLdapPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
888
frontend/src/pages/AdminPKIPage.tsx
Normal file
888
frontend/src/pages/AdminPKIPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
488
frontend/src/pages/AdminServicePrincipalsPage.tsx
Normal file
488
frontend/src/pages/AdminServicePrincipalsPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
712
frontend/src/pages/AdminTLSSettingsPage.tsx
Normal file
712
frontend/src/pages/AdminTLSSettingsPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
312
frontend/src/pages/ApiKeysPage.tsx
Normal file
312
frontend/src/pages/ApiKeysPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
37
frontend/src/pages/ProjectEntryPage.tsx
Normal file
37
frontend/src/pages/ProjectEntryPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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} />
|
||||
}
|
||||
|
||||
580
frontend/src/pages/RepoDockerDetailPage.tsx
Normal file
580
frontend/src/pages/RepoDockerDetailPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -5,7 +5,7 @@ export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': 'http://localhost:1080'
|
||||
'^/api(?:/|$)': 'http://localhost:1080'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user