Compare commits

...

2 Commits

7 changed files with 461 additions and 59 deletions

View File

@@ -1,7 +1,7 @@
package main package main
import "flag" import "flag"
import "io" //import "io"
import "log" import "log"
import "net/http" import "net/http"
import "os" import "os"
@@ -211,11 +211,11 @@ func main() {
log.Fatalf("config error: %v", err) log.Fatalf("config error: %v", err)
} }
log.SetOutput(io.Discard) // log.SetOutput(io.Discard)
//if cfg.APP.LogFile == "" { //if cfg.APP.LogFile == "" {
logger = util.NewLogger("codit", os.Stderr, util.LogStrToMask(nil)) logger = util.NewLogger("codit", os.Stderr, util.LogStrsToMask(nil))
//} else { //} else {
// logger, err = util.AppLoggerToFile("codit", cfg.APP.LogFile, cfg.APP.LogMaxSize, cfg.APP.LogRotate, util.LogStrToMask(cfg.APP.LogMask)) // logger, err = util.NewLoggerToFile("codit", cfg.APP.LogFile, cfg.APP.LogMaxSize, cfg.APP.LogRotate, util.LogStrsToMask(cfg.APP.LogMask))
// if err != nil { // if err != nil {
// log.Fatalf("failed to initialize logger - %s", err.Error()) // log.Fatalf("failed to initialize logger - %s", err.Error())
// } // }
@@ -347,6 +347,7 @@ func main() {
router.Handle("GET", "/api/repos/:id/rpm/packages", api.RepoRPMPackages) router.Handle("GET", "/api/repos/:id/rpm/packages", api.RepoRPMPackages)
router.Handle("GET", "/api/repos/:id/rpm/package", api.RepoRPMPackage) router.Handle("GET", "/api/repos/:id/rpm/package", api.RepoRPMPackage)
router.Handle("POST", "/api/repos/:id/rpm/subdirs", api.RepoRPMCreateSubdir) router.Handle("POST", "/api/repos/:id/rpm/subdirs", api.RepoRPMCreateSubdir)
router.Handle("POST", "/api/repos/:id/rpm/subdir/rename", api.RepoRPMRenameSubdir)
router.Handle("DELETE", "/api/repos/:id/rpm/subdir", api.RepoRPMDeleteSubdir) router.Handle("DELETE", "/api/repos/:id/rpm/subdir", api.RepoRPMDeleteSubdir)
router.Handle("DELETE", "/api/repos/:id/rpm/file", api.RepoRPMDeleteFile) router.Handle("DELETE", "/api/repos/:id/rpm/file", api.RepoRPMDeleteFile)
router.Handle("GET", "/api/repos/:id/rpm/file", api.RepoRPMFile) router.Handle("GET", "/api/repos/:id/rpm/file", api.RepoRPMFile)
@@ -377,10 +378,10 @@ func main() {
rpmIDRewrite = &rpmIDPathRewriteHandler{next: rpmServer, store: store} rpmIDRewrite = &rpmIDPathRewriteHandler{next: rpmServer, store: store}
mux.Handle(cfg.RPMHTTPPrefix+"-id/", http.StripPrefix(cfg.RPMHTTPPrefix+"-id", rpmIDRewrite)) mux.Handle(cfg.RPMHTTPPrefix+"-id/", http.StripPrefix(cfg.RPMHTTPPrefix+"-id", rpmIDRewrite))
mux.Handle("/api/graphql", middleware.WithUser(store, middleware.RequireAuth(graphqlHandler))) mux.Handle("/api/graphql", middleware.WithUser(store, middleware.RequireAuth(graphqlHandler)))
mux.Handle("/api/", middleware.WithUser(store, middleware.RequireAuth(router))) mux.Handle("/api/", middleware.WithUser(store, middleware.AccessLog(logger, middleware.RequireAuth(router))))
mux.Handle("/api/login", middleware.WithUser(store, router)) mux.Handle("/api/login", middleware.WithUser(store, middleware.AccessLog(logger, router)))
mux.Handle("/api/logout", middleware.WithUser(store, router)) mux.Handle("/api/logout", middleware.WithUser(store, middleware.AccessLog(logger, router)))
mux.Handle("/api/health", router) mux.Handle("/api/health", middleware.AccessLog(logger, router))
mux.Handle("/", middleware.WithUser(store, spaHandler(filepath.Join("..", "frontend", "dist")))) mux.Handle("/", middleware.WithUser(store, spaHandler(filepath.Join("..", "frontend", "dist"))))
//log.Printf("codit server listening on %s", cfg.HTTPAddr) //log.Printf("codit server listening on %s", cfg.HTTPAddr)

View File

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

View File

@@ -125,6 +125,11 @@ type repoRPMSubdirRequest struct {
Parent string `json:"parent"` Parent string `json:"parent"`
} }
type repoRPMRenameRequest struct {
Path string `json:"path"`
Name string `json:"name"`
}
type repoRPMUploadResponse struct { type repoRPMUploadResponse struct {
Filename string `json:"filename"` Filename string `json:"filename"`
Size int64 `json:"size"` Size int64 `json:"size"`
@@ -1783,6 +1788,10 @@ func (api *API) RepoRPMCreateSubdir(w http.ResponseWriter, r *http.Request, para
var parentPath string var parentPath string
var fullPath string var fullPath string
var repodataPath string var repodataPath string
var fullRel string
var fullRelLower string
var absParent string
var hasRepoAncestor bool
repo, err = api.Store.GetRepo(params["id"]) repo, err = api.Store.GetRepo(params["id"])
if err != nil { if err != nil {
WriteJSON(w, http.StatusNotFound, map[string]string{"error": "repo not found"}) WriteJSON(w, http.StatusNotFound, map[string]string{"error": "repo not found"})
@@ -1805,6 +1814,10 @@ func (api *API) RepoRPMCreateSubdir(w http.ResponseWriter, r *http.Request, para
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "name required"}) WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "name required"})
return return
} }
if strings.EqualFold(name, "repodata") {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "repodata is reserved"})
return
}
if !isSafeSubdirName(name) { if !isSafeSubdirName(name) {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid subdirectory name"}) WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid subdirectory name"})
return return
@@ -1825,6 +1838,24 @@ func (api *API) RepoRPMCreateSubdir(w http.ResponseWriter, r *http.Request, para
} }
parentPath = filepath.FromSlash(parent) parentPath = filepath.FromSlash(parent)
} }
fullRel = filepath.ToSlash(filepath.Join(parentPath, name))
fullRelLower = strings.ToLower(fullRel)
if fullRelLower == "repodata" || strings.HasPrefix(fullRelLower, "repodata/") || strings.Contains(fullRelLower, "/repodata/") {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "subdirectories under repodata are not allowed"})
return
}
if dirType == "repo" {
absParent = filepath.Join(repo.Path, parentPath)
hasRepoAncestor, err = hasRepodataAncestor(repo.Path, absParent)
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
if hasRepoAncestor {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "repo directories cannot be created under another repo directory"})
return
}
}
fullPath = filepath.Join(repo.Path, parentPath, name) fullPath = filepath.Join(repo.Path, parentPath, name)
err = os.MkdirAll(fullPath, 0o755) err = os.MkdirAll(fullPath, 0o755)
if err != nil { if err != nil {
@@ -1871,6 +1902,10 @@ func (api *API) RepoRPMDeleteSubdir(w http.ResponseWriter, r *http.Request, para
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid path"}) WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid path"})
return return
} }
if isRepodataPath(relPath) {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "repodata cannot be deleted directly"})
return
}
fullPath = filepath.Join(repo.Path, filepath.FromSlash(relPath)) fullPath = filepath.Join(repo.Path, filepath.FromSlash(relPath))
info, err = os.Stat(fullPath) info, err = os.Stat(fullPath)
if err != nil { if err != nil {
@@ -1899,6 +1934,119 @@ func (api *API) RepoRPMDeleteSubdir(w http.ResponseWriter, r *http.Request, para
WriteJSON(w, http.StatusOK, map[string]string{"status": "ok"}) WriteJSON(w, http.StatusOK, map[string]string{"status": "ok"})
} }
func (api *API) RepoRPMRenameSubdir(w http.ResponseWriter, r *http.Request, params map[string]string) {
var repo models.Repo
var err error
var req repoRPMRenameRequest
var relPath string
var newName string
var fullPath string
var info os.FileInfo
var parentRel string
var parentPath string
var newPath string
var repodataPath string
var hasAncestor bool
var absParent string
repo, err = api.Store.GetRepo(params["id"])
if err != nil {
WriteJSON(w, http.StatusNotFound, map[string]string{"error": "repo not found"})
return
}
if !api.requireRepoRole(w, r, repo.ID, "writer") {
return
}
if repo.Type != "rpm" {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "repo is not rpm"})
return
}
err = DecodeJSON(r, &req)
if err != nil {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid json"})
return
}
relPath = strings.TrimSpace(req.Path)
newName = strings.TrimSpace(req.Name)
if relPath == "" || newName == "" {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "path and name required"})
return
}
if strings.EqualFold(newName, "repodata") {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "repodata is reserved"})
return
}
if !isSafeSubdirPath(relPath) {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid path"})
return
}
if !isSafeSubdirName(newName) {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid name"})
return
}
if isRepodataPath(relPath) {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "repodata cannot be renamed"})
return
}
fullPath = filepath.Join(repo.Path, filepath.FromSlash(relPath))
info, err = os.Stat(fullPath)
if err != nil {
if os.IsNotExist(err) {
WriteJSON(w, http.StatusNotFound, map[string]string{"error": "path not found"})
return
}
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
if info == nil || !info.IsDir() {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "path is not a directory"})
return
}
parentRel = filepath.Dir(filepath.FromSlash(relPath))
if parentRel == "." {
parentRel = ""
}
parentPath = filepath.FromSlash(parentRel)
newPath = filepath.Join(repo.Path, parentPath, newName)
if newPath == fullPath {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "name is unchanged"})
return
}
_, err = os.Stat(newPath)
if err == nil {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "target already exists"})
return
}
if err != nil && !os.IsNotExist(err) {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
repodataPath = filepath.Join(fullPath, "repodata")
_, err = os.Stat(repodataPath)
if err == nil {
absParent = filepath.Join(repo.Path, parentPath)
hasAncestor, err = hasRepodataAncestor(repo.Path, absParent)
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
if hasAncestor {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "repo directories cannot be renamed under another repo directory"})
return
}
}
err = os.Rename(fullPath, newPath)
if err != nil {
WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
repodataPath = filepath.Join(filepath.Join(repo.Path, parentPath), "repodata")
_, err = os.Stat(repodataPath)
if err == nil && api.RpmMeta != nil {
api.RpmMeta.Schedule(filepath.Join(repo.Path, parentPath))
}
WriteJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (api *API) RepoRPMDeleteFile(w http.ResponseWriter, r *http.Request, params map[string]string) { func (api *API) RepoRPMDeleteFile(w http.ResponseWriter, r *http.Request, params map[string]string) {
var repo models.Repo var repo models.Repo
var err error var err error
@@ -1929,6 +2077,10 @@ func (api *API) RepoRPMDeleteFile(w http.ResponseWriter, r *http.Request, params
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid path"}) WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid path"})
return return
} }
if isRepodataPath(relPath) {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "repodata files cannot be deleted"})
return
}
lower = strings.ToLower(relPath) lower = strings.ToLower(relPath)
if !strings.HasSuffix(lower, ".rpm") { if !strings.HasSuffix(lower, ".rpm") {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "only rpm files can be deleted"}) WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "only rpm files can be deleted"})
@@ -2067,6 +2219,7 @@ func (api *API) RepoRPMUpload(w http.ResponseWriter, r *http.Request, params map
var err error var err error
var relPath string var relPath string
var dirPath string var dirPath string
var repodataDir string
var file multipart.File var file multipart.File
var header *multipart.FileHeader var header *multipart.FileHeader
var filename string var filename string
@@ -2075,7 +2228,6 @@ func (api *API) RepoRPMUpload(w http.ResponseWriter, r *http.Request, params map
var tempPath string var tempPath string
var out *os.File var out *os.File
var size int64 var size int64
var repodataPath string
var detail rpm.PackageDetail var detail rpm.PackageDetail
var overwriteParam string var overwriteParam string
var overwrite bool var overwrite bool
@@ -2102,15 +2254,18 @@ func (api *API) RepoRPMUpload(w http.ResponseWriter, r *http.Request, params map
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid path"}) WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid path"})
return return
} }
if relPath == "repodata" || strings.HasPrefix(relPath, "repodata/") { if isRepodataPath(relPath) {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "uploads are not allowed in repodata"}) WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "uploads are not allowed in repodata"})
return return
} }
dirPath = filepath.Join(repo.Path, filepath.FromSlash(relPath)) dirPath = filepath.Join(repo.Path, filepath.FromSlash(relPath))
repodataPath = filepath.Join(dirPath, "repodata") repodataDir, err = nearestRepodataDir(repo.Path, dirPath)
_, err = os.Stat(repodataPath)
if err != nil { if err != nil {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "repodata not found in target directory"}) WriteJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
if repodataDir == "" {
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "repodata not found in ancestor directories"})
return return
} }
file, header, err = r.FormFile("file") file, header, err = r.FormFile("file")
@@ -2197,7 +2352,7 @@ func (api *API) RepoRPMUpload(w http.ResponseWriter, r *http.Request, params map
_ = os.RemoveAll(oldTemp) _ = os.RemoveAll(oldTemp)
} }
if api.RpmMeta != nil { if api.RpmMeta != nil {
api.RpmMeta.Schedule(dirPath) api.RpmMeta.Schedule(repodataDir)
} }
WriteJSON(w, http.StatusOK, repoRPMUploadResponse{Filename: filename, Size: size}) WriteJSON(w, http.StatusOK, repoRPMUploadResponse{Filename: filename, Size: size})
} }
@@ -2644,6 +2799,84 @@ func isSafeSubdirPath(path string) bool {
return true return true
} }
func isRepodataPath(path string) bool {
var normalized string
var parts []string
var part string
normalized = strings.Trim(path, "/")
normalized = strings.ReplaceAll(normalized, "\\", "/")
if normalized == "" {
return false
}
parts = strings.Split(normalized, "/")
for _, part = range parts {
if strings.EqualFold(part, "repodata") {
return true
}
}
return false
}
func hasRepodataAncestor(root string, parent string) (bool, error) {
var current string
var relative string
var err error
var repodata string
current = filepath.Clean(parent)
root = filepath.Clean(root)
for {
relative, err = filepath.Rel(root, current)
if err != nil {
return false, err
}
if strings.HasPrefix(relative, "..") {
return false, nil
}
repodata = filepath.Join(current, "repodata")
_, err = os.Stat(repodata)
if err == nil {
return true, nil
}
if err != nil && !os.IsNotExist(err) {
return false, err
}
if current == root {
return false, nil
}
current = filepath.Dir(current)
}
}
func nearestRepodataDir(root string, target string) (string, error) {
var current string
var relative string
var err error
var repodata string
current = filepath.Clean(target)
root = filepath.Clean(root)
for {
relative, err = filepath.Rel(root, current)
if err != nil {
return "", err
}
if strings.HasPrefix(relative, "..") {
return "", nil
}
repodata = filepath.Join(current, "repodata")
_, err = os.Stat(repodata)
if err == nil {
return current, nil
}
if err != nil && !os.IsNotExist(err) {
return "", err
}
if current == root {
return "", nil
}
current = filepath.Dir(current)
}
}
func nameHasWhitespace(name string) bool { func nameHasWhitespace(name string) bool {
return strings.IndexFunc(name, unicode.IsSpace) >= 0 return strings.IndexFunc(name, unicode.IsSpace) >= 0
} }

View File

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

View File

@@ -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 var mask LogMask

View File

@@ -346,6 +346,11 @@ export const api = {
method: 'POST', method: 'POST',
body: JSON.stringify({ name, type, parent }) body: JSON.stringify({ name, type, parent })
}), }),
renameRpmSubdir: (repoId: string, path: string, name: string) =>
request<{ status: string }>(`/api/repos/${repoId}/rpm/subdir/rename`, {
method: 'POST',
body: JSON.stringify({ path, name })
}),
deleteRpmSubdir: (repoId: string, path: string) => { deleteRpmSubdir: (repoId: string, path: string) => {
const params = new URLSearchParams() const params = new URLSearchParams()
params.set('path', path) params.set('path', path)

View File

@@ -27,6 +27,7 @@ import { api, Project, Repo, RpmPackageDetail, RpmPackageSummary, RpmTreeEntry }
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft' import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'
import ChevronRightIcon from '@mui/icons-material/ChevronRight' import ChevronRightIcon from '@mui/icons-material/ChevronRight'
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline' import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'
import DriveFileRenameOutlineIcon from '@mui/icons-material/DriveFileRenameOutline'
import FolderIcon from '@mui/icons-material/Folder' import FolderIcon from '@mui/icons-material/Folder'
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile' import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile'
import HomeOutlinedIcon from '@mui/icons-material/HomeOutlined' import HomeOutlinedIcon from '@mui/icons-material/HomeOutlined'
@@ -70,6 +71,12 @@ export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
const [deleteConfirm, setDeleteConfirm] = useState('') const [deleteConfirm, setDeleteConfirm] = useState('')
const [deleteError, setDeleteError] = useState<string | null>(null) const [deleteError, setDeleteError] = useState<string | null>(null)
const [deleting, setDeleting] = useState(false) const [deleting, setDeleting] = useState(false)
const [renameOpen, setRenameOpen] = useState(false)
const [renamePath, setRenamePath] = useState('')
const [renameName, setRenameName] = useState('')
const [renameNewName, setRenameNewName] = useState('')
const [renameError, setRenameError] = useState<string | null>(null)
const [renaming, setRenaming] = useState(false)
const [rpmPath, setRpmPath] = useState('') const [rpmPath, setRpmPath] = useState('')
const [rpmPathSegments, setRpmPathSegments] = useState<string[]>([]) const [rpmPathSegments, setRpmPathSegments] = useState<string[]>([])
const [rpmTree, setRpmTree] = useState<RpmTreeEntry[]>([]) const [rpmTree, setRpmTree] = useState<RpmTreeEntry[]>([])
@@ -173,8 +180,6 @@ export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
} }
} }
const hasRepodata = rpmTree.some((entry) => entry.type === 'dir' && entry.name.toLowerCase() === 'repodata')
const handleUpload = async () => { const handleUpload = async () => {
if (!repoId) return if (!repoId) return
if (!uploadFiles.length) { if (!uploadFiles.length) {
@@ -283,6 +288,37 @@ export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
} }
} }
const handleRenameSubdir = async () => {
if (!repoId || !renamePath) return
if (!renameNewName.trim()) {
setRenameError('New name is required.')
return
}
setRenameError(null)
setRenaming(true)
try {
await api.renameRpmSubdir(repoId, renamePath, renameNewName.trim())
setRenameOpen(false)
setRenamePath('')
setRenameName('')
setRenameNewName('')
api.listRpmTree(repoId, rpmPath)
.then((list) => {
setRpmTree(Array.isArray(list) ? list : [])
setRpmTreeError(null)
})
.catch((err) => {
const message = err instanceof Error ? err.message : 'Failed to load files'
setRpmTreeError(message)
})
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to rename folder'
setRenameError(message)
} finally {
setRenaming(false)
}
}
const handleRpmBack = () => { const handleRpmBack = () => {
if (!rpmPath) return if (!rpmPath) return
const nextSegments = rpmPathSegments.slice(0, -1) const nextSegments = rpmPathSegments.slice(0, -1)
@@ -346,6 +382,23 @@ export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
return new Response(stream).text() return new Response(stream).text()
} }
const rpmFileLink = () => {
if (!repo || repo.type !== 'rpm' || !repo.rpm_url || !rpmSelectedEntry) return ''
const base = repo.rpm_url.replace(/\/+$/, '')
const path = rpmSelectedEntry.path.replace(/^\/+/, '')
return `${base}/${path}`
}
const rpmFileLabel = () => {
if (!rpmSelected) return ''
return rpmSelected.name
}
const isHttpUrl = (value: string) => {
const trimmed = value.trim().toLowerCase()
return trimmed.startsWith('http://') || trimmed.startsWith('https://')
}
const handleRpmEntry = (entry: RpmTreeEntry) => { const handleRpmEntry = (entry: RpmTreeEntry) => {
if (entry.type === 'dir') { if (entry.type === 'dir') {
setRpmPath(entry.path) setRpmPath(entry.path)
@@ -450,7 +503,6 @@ export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
setUploadFiles([]) setUploadFiles([])
setUploadOpen(true) setUploadOpen(true)
}} }}
disabled={!hasRepodata}
> >
Upload RPM... Upload RPM...
</Button> </Button>
@@ -520,39 +572,59 @@ export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
} }
/> />
</ListItemButton> </ListItemButton>
{entry.type === 'dir' && canWrite ? ( {canWrite ? (
<IconButton <Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, pr: 0.5 }}>
size="small" {entry.type === 'dir' && entry.name.toLowerCase() !== 'repodata' ? (
onClick={(event) => { <IconButton
event.stopPropagation() size="small"
setDeleteError(null) onClick={(event) => {
setDeletePath(entry.path) event.stopPropagation()
setDeleteName(entry.name) setRenameError(null)
setDeleteIsFile(false) setRenamePath(entry.path)
setDeleteConfirm('') setRenameName(entry.name)
setDeleteOpen(true) setRenameNewName(entry.name)
}} setRenameOpen(true)
aria-label={`Delete folder ${entry.name}`} }}
> aria-label={`Rename folder ${entry.name}`}
<DeleteOutlineIcon fontSize="small" /> >
</IconButton> <DriveFileRenameOutlineIcon fontSize="small" />
) : null} </IconButton>
{entry.type !== 'dir' && entry.name.toLowerCase().endsWith('.rpm') && canWrite ? ( ) : null}
<IconButton {entry.type === 'dir' ? (
size="small" <IconButton
onClick={(event) => { size="small"
event.stopPropagation() onClick={(event) => {
setDeleteError(null) event.stopPropagation()
setDeletePath(entry.path) setDeleteError(null)
setDeleteName(entry.name) setDeletePath(entry.path)
setDeleteIsFile(true) setDeleteName(entry.name)
setDeleteConfirm('') setDeleteIsFile(false)
setDeleteOpen(true) setDeleteConfirm('')
}} setDeleteOpen(true)
aria-label={`Delete file ${entry.name}`} }}
> aria-label={`Delete folder ${entry.name}`}
<DeleteOutlineIcon fontSize="small" /> >
</IconButton> <DeleteOutlineIcon fontSize="small" />
</IconButton>
) : null}
{entry.type !== 'dir' && entry.name.toLowerCase().endsWith('.rpm') ? (
<IconButton
size="small"
onClick={(event) => {
event.stopPropagation()
setDeleteError(null)
setDeletePath(entry.path)
setDeleteName(entry.name)
setDeleteIsFile(true)
setDeleteConfirm('')
setDeleteOpen(true)
}}
aria-label={`Delete file ${entry.name}`}
>
<DeleteOutlineIcon fontSize="small" />
</IconButton>
) : null}
</Box>
) : null} ) : null}
</ListItem> </ListItem>
))} ))}
@@ -594,14 +666,36 @@ export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
) : null} ) : null}
{rpmDetail && rpmTab === 'meta' ? ( {rpmDetail && rpmTab === 'meta' ? (
<Box sx={{ mt: 1, display: 'grid', gap: 0.5 }}> <Box sx={{ mt: 1, display: 'grid', gap: 0.5 }}>
<Typography variant="body2">Name: {rpmDetail.name}</Typography> <Typography variant="body2">
Name:{' '}
{rpmFileLink() ? (
<a href={rpmFileLink()} target="_blank" rel="noreferrer">
{rpmDetail.name}
</a>
) : (
rpmDetail.name
)}
</Typography>
<Typography variant="body2"> <Typography variant="body2">
Version: {rpmDetail.version}-{rpmDetail.release} Version: {rpmDetail.version}-{rpmDetail.release}
</Typography> </Typography>
<Typography variant="body2">Arch: {rpmDetail.arch}</Typography> <Typography variant="body2">Arch: {rpmDetail.arch}</Typography>
<Typography variant="body2">Summary: {rpmDetail.summary}</Typography> <Typography variant="body2">Summary: {rpmDetail.summary}</Typography>
<Typography variant="body2">License: {rpmDetail.license || 'n/a'}</Typography> <Typography variant="body2">License: {rpmDetail.license || 'n/a'}</Typography>
<Typography variant="body2">URL: {rpmDetail.url || 'n/a'}</Typography> <Typography variant="body2">
URL:{' '}
{rpmDetail.url ? (
isHttpUrl(rpmDetail.url) ? (
<a href={rpmDetail.url} target="_blank" rel="noreferrer">
{rpmDetail.url}
</a>
) : (
rpmDetail.url
)
) : (
'n/a'
)}
</Typography>
<Typography variant="body2"> <Typography variant="body2">
Build Time: {rpmDetail.build_time ? new Date(rpmDetail.build_time * 1000).toLocaleString() : 'n/a'} Build Time: {rpmDetail.build_time ? new Date(rpmDetail.build_time * 1000).toLocaleString() : 'n/a'}
</Typography> </Typography>
@@ -712,11 +806,6 @@ export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
<DialogTitle>Upload RPM</DialogTitle> <DialogTitle>Upload RPM</DialogTitle>
<DialogContent> <DialogContent>
{uploadError ? <Alert severity="error">{uploadError}</Alert> : null} {uploadError ? <Alert severity="error">{uploadError}</Alert> : null}
{!hasRepodata ? (
<Alert severity="warning" sx={{ mt: 1 }}>
Upload is only allowed in a directory with a repodata folder.
</Alert>
) : null}
<Box sx={{ display: 'grid', gap: 1, mt: 1 }}> <Box sx={{ display: 'grid', gap: 1, mt: 1 }}>
<TextField <TextField
type="file" type="file"
@@ -735,7 +824,7 @@ export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<Button onClick={() => setUploadOpen(false)}>Cancel</Button> <Button onClick={() => setUploadOpen(false)}>Cancel</Button>
<Button onClick={handleUpload} variant="contained" disabled={uploading || !hasRepodata}> <Button onClick={handleUpload} variant="contained" disabled={uploading}>
{uploading ? 'Uploading...' : 'Upload'} {uploading ? 'Uploading...' : 'Upload'}
</Button> </Button>
</DialogActions> </DialogActions>
@@ -769,6 +858,28 @@ export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
</Button> </Button>
</DialogActions> </DialogActions>
</Dialog> </Dialog>
<Dialog open={renameOpen} onClose={() => setRenameOpen(false)} maxWidth="xs" fullWidth>
<DialogTitle>Rename folder</DialogTitle>
<DialogContent>
{renameError ? <Alert severity="error">{renameError}</Alert> : null}
<Typography variant="body2" sx={{ mt: 1 }}>
Current name: {renameName}
</Typography>
<TextField
label="New name"
value={renameNewName}
onChange={(event) => setRenameNewName(event.target.value)}
fullWidth
sx={{ mt: 1 }}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setRenameOpen(false)}>Cancel</Button>
<Button onClick={handleRenameSubdir} variant="contained" disabled={renaming || !renameNewName.trim()}>
{renaming ? 'Renaming...' : 'Rename'}
</Button>
</DialogActions>
</Dialog>
</Box> </Box>
) )
} }