Compare commits
2 Commits
298d792577
...
cc176b4d29
| Author | SHA1 | Date | |
|---|---|---|---|
| cc176b4d29 | |||
| c95953d078 |
@@ -1,7 +1,7 @@
|
||||
package main
|
||||
|
||||
import "flag"
|
||||
import "io"
|
||||
//import "io"
|
||||
import "log"
|
||||
import "net/http"
|
||||
import "os"
|
||||
@@ -211,11 +211,11 @@ func main() {
|
||||
log.Fatalf("config error: %v", err)
|
||||
}
|
||||
|
||||
log.SetOutput(io.Discard)
|
||||
// log.SetOutput(io.Discard)
|
||||
//if cfg.APP.LogFile == "" {
|
||||
logger = util.NewLogger("codit", os.Stderr, util.LogStrToMask(nil))
|
||||
logger = util.NewLogger("codit", os.Stderr, util.LogStrsToMask(nil))
|
||||
//} 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 {
|
||||
// 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/package", api.RepoRPMPackage)
|
||||
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/file", api.RepoRPMDeleteFile)
|
||||
router.Handle("GET", "/api/repos/:id/rpm/file", api.RepoRPMFile)
|
||||
@@ -377,10 +378,10 @@ func main() {
|
||||
rpmIDRewrite = &rpmIDPathRewriteHandler{next: rpmServer, store: store}
|
||||
mux.Handle(cfg.RPMHTTPPrefix+"-id/", http.StripPrefix(cfg.RPMHTTPPrefix+"-id", rpmIDRewrite))
|
||||
mux.Handle("/api/graphql", middleware.WithUser(store, middleware.RequireAuth(graphqlHandler)))
|
||||
mux.Handle("/api/", middleware.WithUser(store, middleware.RequireAuth(router)))
|
||||
mux.Handle("/api/login", middleware.WithUser(store, router))
|
||||
mux.Handle("/api/logout", middleware.WithUser(store, router))
|
||||
mux.Handle("/api/health", router)
|
||||
mux.Handle("/api/", middleware.WithUser(store, middleware.AccessLog(logger, middleware.RequireAuth(router))))
|
||||
mux.Handle("/api/login", middleware.WithUser(store, middleware.AccessLog(logger, router)))
|
||||
mux.Handle("/api/logout", middleware.WithUser(store, middleware.AccessLog(logger, router)))
|
||||
mux.Handle("/api/health", middleware.AccessLog(logger, router))
|
||||
mux.Handle("/", middleware.WithUser(store, spaHandler(filepath.Join("..", "frontend", "dist"))))
|
||||
|
||||
//log.Printf("codit server listening on %s", cfg.HTTPAddr)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -125,6 +125,11 @@ type repoRPMSubdirRequest struct {
|
||||
Parent string `json:"parent"`
|
||||
}
|
||||
|
||||
type repoRPMRenameRequest struct {
|
||||
Path string `json:"path"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type repoRPMUploadResponse struct {
|
||||
Filename string `json:"filename"`
|
||||
Size int64 `json:"size"`
|
||||
@@ -1783,6 +1788,10 @@ func (api *API) RepoRPMCreateSubdir(w http.ResponseWriter, r *http.Request, para
|
||||
var parentPath string
|
||||
var fullPath string
|
||||
var repodataPath string
|
||||
var fullRel string
|
||||
var fullRelLower string
|
||||
var absParent string
|
||||
var hasRepoAncestor bool
|
||||
repo, err = api.Store.GetRepo(params["id"])
|
||||
if err != nil {
|
||||
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"})
|
||||
return
|
||||
}
|
||||
if strings.EqualFold(name, "repodata") {
|
||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "repodata is reserved"})
|
||||
return
|
||||
}
|
||||
if !isSafeSubdirName(name) {
|
||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid subdirectory name"})
|
||||
return
|
||||
@@ -1825,6 +1838,24 @@ func (api *API) RepoRPMCreateSubdir(w http.ResponseWriter, r *http.Request, para
|
||||
}
|
||||
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)
|
||||
err = os.MkdirAll(fullPath, 0o755)
|
||||
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"})
|
||||
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))
|
||||
info, err = os.Stat(fullPath)
|
||||
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"})
|
||||
}
|
||||
|
||||
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) {
|
||||
var repo models.Repo
|
||||
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"})
|
||||
return
|
||||
}
|
||||
if isRepodataPath(relPath) {
|
||||
WriteJSON(w, http.StatusBadRequest, map[string]string{"error": "repodata files cannot be deleted"})
|
||||
return
|
||||
}
|
||||
lower = strings.ToLower(relPath)
|
||||
if !strings.HasSuffix(lower, ".rpm") {
|
||||
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 relPath string
|
||||
var dirPath string
|
||||
var repodataDir string
|
||||
var file multipart.File
|
||||
var header *multipart.FileHeader
|
||||
var filename string
|
||||
@@ -2075,7 +2228,6 @@ func (api *API) RepoRPMUpload(w http.ResponseWriter, r *http.Request, params map
|
||||
var tempPath string
|
||||
var out *os.File
|
||||
var size int64
|
||||
var repodataPath string
|
||||
var detail rpm.PackageDetail
|
||||
var overwriteParam string
|
||||
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"})
|
||||
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"})
|
||||
return
|
||||
}
|
||||
dirPath = filepath.Join(repo.Path, filepath.FromSlash(relPath))
|
||||
repodataPath = filepath.Join(dirPath, "repodata")
|
||||
_, err = os.Stat(repodataPath)
|
||||
repodataDir, err = nearestRepodataDir(repo.Path, dirPath)
|
||||
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
|
||||
}
|
||||
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)
|
||||
}
|
||||
if api.RpmMeta != nil {
|
||||
api.RpmMeta.Schedule(dirPath)
|
||||
api.RpmMeta.Schedule(repodataDir)
|
||||
}
|
||||
WriteJSON(w, http.StatusOK, repoRPMUploadResponse{Filename: filename, Size: size})
|
||||
}
|
||||
@@ -2644,6 +2799,84 @@ func isSafeSubdirPath(path string) bool {
|
||||
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 {
|
||||
return strings.IndexFunc(name, unicode.IsSpace) >= 0
|
||||
}
|
||||
|
||||
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())
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -346,6 +346,11 @@ export const api = {
|
||||
method: 'POST',
|
||||
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) => {
|
||||
const params = new URLSearchParams()
|
||||
params.set('path', path)
|
||||
|
||||
@@ -27,6 +27,7 @@ import { api, Project, Repo, RpmPackageDetail, RpmPackageSummary, RpmTreeEntry }
|
||||
import ChevronLeftIcon from '@mui/icons-material/ChevronLeft'
|
||||
import ChevronRightIcon from '@mui/icons-material/ChevronRight'
|
||||
import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'
|
||||
import DriveFileRenameOutlineIcon from '@mui/icons-material/DriveFileRenameOutline'
|
||||
import FolderIcon from '@mui/icons-material/Folder'
|
||||
import InsertDriveFileIcon from '@mui/icons-material/InsertDriveFile'
|
||||
import HomeOutlinedIcon from '@mui/icons-material/HomeOutlined'
|
||||
@@ -70,6 +71,12 @@ export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
|
||||
const [deleteConfirm, setDeleteConfirm] = useState('')
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null)
|
||||
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 [rpmPathSegments, setRpmPathSegments] = useState<string[]>([])
|
||||
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 () => {
|
||||
if (!repoId) return
|
||||
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 = () => {
|
||||
if (!rpmPath) return
|
||||
const nextSegments = rpmPathSegments.slice(0, -1)
|
||||
@@ -346,6 +382,23 @@ export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
|
||||
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) => {
|
||||
if (entry.type === 'dir') {
|
||||
setRpmPath(entry.path)
|
||||
@@ -450,7 +503,6 @@ export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
|
||||
setUploadFiles([])
|
||||
setUploadOpen(true)
|
||||
}}
|
||||
disabled={!hasRepodata}
|
||||
>
|
||||
Upload RPM...
|
||||
</Button>
|
||||
@@ -520,7 +572,25 @@ export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
|
||||
}
|
||||
/>
|
||||
</ListItemButton>
|
||||
{entry.type === 'dir' && canWrite ? (
|
||||
{canWrite ? (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, pr: 0.5 }}>
|
||||
{entry.type === 'dir' && entry.name.toLowerCase() !== 'repodata' ? (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
setRenameError(null)
|
||||
setRenamePath(entry.path)
|
||||
setRenameName(entry.name)
|
||||
setRenameNewName(entry.name)
|
||||
setRenameOpen(true)
|
||||
}}
|
||||
aria-label={`Rename folder ${entry.name}`}
|
||||
>
|
||||
<DriveFileRenameOutlineIcon fontSize="small" />
|
||||
</IconButton>
|
||||
) : null}
|
||||
{entry.type === 'dir' ? (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(event) => {
|
||||
@@ -537,7 +607,7 @@ export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
|
||||
<DeleteOutlineIcon fontSize="small" />
|
||||
</IconButton>
|
||||
) : null}
|
||||
{entry.type !== 'dir' && entry.name.toLowerCase().endsWith('.rpm') && canWrite ? (
|
||||
{entry.type !== 'dir' && entry.name.toLowerCase().endsWith('.rpm') ? (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={(event) => {
|
||||
@@ -554,6 +624,8 @@ export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
|
||||
<DeleteOutlineIcon fontSize="small" />
|
||||
</IconButton>
|
||||
) : null}
|
||||
</Box>
|
||||
) : null}
|
||||
</ListItem>
|
||||
))}
|
||||
{!rpmTree.length && !rpmTreeError ? (
|
||||
@@ -594,14 +666,36 @@ export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
|
||||
) : null}
|
||||
{rpmDetail && rpmTab === 'meta' ? (
|
||||
<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">
|
||||
Version: {rpmDetail.version}-{rpmDetail.release}
|
||||
</Typography>
|
||||
<Typography variant="body2">Arch: {rpmDetail.arch}</Typography>
|
||||
<Typography variant="body2">Summary: {rpmDetail.summary}</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">
|
||||
Build Time: {rpmDetail.build_time ? new Date(rpmDetail.build_time * 1000).toLocaleString() : 'n/a'}
|
||||
</Typography>
|
||||
@@ -712,11 +806,6 @@ export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
|
||||
<DialogTitle>Upload RPM</DialogTitle>
|
||||
<DialogContent>
|
||||
{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 }}>
|
||||
<TextField
|
||||
type="file"
|
||||
@@ -735,7 +824,7 @@ export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setUploadOpen(false)}>Cancel</Button>
|
||||
<Button onClick={handleUpload} variant="contained" disabled={uploading || !hasRepodata}>
|
||||
<Button onClick={handleUpload} variant="contained" disabled={uploading}>
|
||||
{uploading ? 'Uploading...' : 'Upload'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
@@ -769,6 +858,28 @@ export default function RepoRpmDetailPage(props: RepoRpmDetailPageProps) {
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user