mirror of
https://github.com/versity/versitygw.git
synced 2026-05-01 09:45:43 +00:00
Merge branch 'main' into test/bats_tagging
This commit is contained in:
31
.github/workflows/functional-sidecar.yml
vendored
Normal file
31
.github/workflows/functional-sidecar.yml
vendored
Normal file
@@ -0,0 +1,31 @@
|
||||
name: functional tests (sidecar)
|
||||
permissions: {}
|
||||
on: pull_request
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: RunTests
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: 'stable'
|
||||
id: go
|
||||
|
||||
- name: Get Dependencies
|
||||
run: |
|
||||
go mod download
|
||||
|
||||
- name: Build and Run
|
||||
run: |
|
||||
make testbin
|
||||
./runtests.sh --sidecar
|
||||
|
||||
- name: Coverage Report
|
||||
run: |
|
||||
go tool covdata percent -i=/tmp/covdata
|
||||
9
.github/workflows/system.yml
vendored
9
.github/workflows/system.yml
vendored
@@ -50,10 +50,11 @@ jobs:
|
||||
sudo apt-get update
|
||||
sudo apt-get install s3cmd
|
||||
|
||||
- name: Install mc
|
||||
run: |
|
||||
curl https://dl.min.io/client/mc/release/linux-amd64/mc --create-dirs -o /usr/local/bin/mc
|
||||
chmod 755 /usr/local/bin/mc
|
||||
# disable mc tests due to dl.min.io instability
|
||||
# - name: Install mc
|
||||
# run: |
|
||||
# curl https://dl.min.io/client/mc/release/linux-amd64/mc --create-dirs -o /usr/local/bin/mc
|
||||
# chmod 755 /usr/local/bin/mc
|
||||
|
||||
- name: Install xml libraries (for rest)
|
||||
run: |
|
||||
|
||||
7
Makefile
7
Makefile
@@ -100,5 +100,10 @@ up-app:
|
||||
# Run the host-style tests in docker containers
|
||||
.PHONY: test-host-style
|
||||
test-host-style:
|
||||
docker compose -f tests/host-style-tests/docker-compose.yml up --build --abort-on-container-exit --exit-code-from test
|
||||
@compose_file=tests/host-style-tests/docker-compose.yml; \
|
||||
COMPOSE_MENU=false docker compose -f "$$compose_file" down -v --remove-orphans >/dev/null 2>&1 || true; \
|
||||
COMPOSE_MENU=false docker compose -f "$$compose_file" up --build --abort-on-container-exit --exit-code-from test; \
|
||||
status=$$?; \
|
||||
COMPOSE_MENU=false docker compose -f "$$compose_file" down -v --remove-orphans; \
|
||||
exit $$status
|
||||
|
||||
|
||||
@@ -90,7 +90,6 @@ Our multi-layered testing strategy includes:
|
||||
- **System Tests** - Protocol-level validation using industry-standard S3 clients:
|
||||
- AWS CLI - Official AWS command-line tools
|
||||
- s3cmd - Popular S3 client
|
||||
- MinIO mc - Modern S3-compatible client
|
||||
- Direct REST API testing with curl for request/response validation
|
||||
- **Security Testing** - Both HTTP and HTTPS configurations tested. Vulnerability scanning with govulncheck. And regular dependency updates with dependabot.
|
||||
- **Compatibility Testing** - Multiple backends, versioning scenarios, static bucket modes, and various authentication methods.
|
||||
|
||||
@@ -145,15 +145,24 @@ func (s SideCar) ListAttributes(bucket, object string) ([]string, error) {
|
||||
}
|
||||
|
||||
// DeleteAttributes removes all attributes for an object or a bucket.
|
||||
// When object is empty the entire bucket sidecar directory is removed,
|
||||
// cleaning up any orphaned object or multipart metadata within it.
|
||||
func (s SideCar) DeleteAttributes(bucket, object string) error {
|
||||
metadir := filepath.Join(s.dir, bucket, object, sidecarmeta)
|
||||
if object == "" {
|
||||
metadir = filepath.Join(s.dir, bucket, sidecarmeta)
|
||||
// Remove the entire bucket sidecar directory so that orphaned
|
||||
// object/multipart metadata does not accumulate after DeleteBucket.
|
||||
bucketDir := filepath.Join(s.dir, bucket)
|
||||
err := os.RemoveAll(bucketDir)
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("failed to remove bucket attributes: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
metadir := filepath.Join(s.dir, bucket, object, sidecarmeta)
|
||||
err := os.RemoveAll(metadir)
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return fmt.Errorf("failed to remove attributes: %v", err)
|
||||
return fmt.Errorf("failed to remove attributes: %w", err)
|
||||
}
|
||||
s.cleanupEmptyDirs(metadir, bucket, object)
|
||||
return nil
|
||||
|
||||
@@ -641,6 +641,13 @@ func (p *Posix) DeleteBucket(ctx context.Context, bucket string) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("remove bucket: %w", err)
|
||||
}
|
||||
// Bucket data is already removed; a metadata cleanup failure orphans only
|
||||
// the sidecar directory, which is not user-visible. Log and continue rather
|
||||
// than returning an error that would mislead callers into thinking the
|
||||
// bucket still exists.
|
||||
if err = p.meta.DeleteAttributes(bucket, ""); err != nil {
|
||||
debuglogger.Logf("failed to delete bucket sidecar attributes (%q): %v", bucket, err)
|
||||
}
|
||||
// Remove the bucket from versioning directory
|
||||
if p.versioningEnabled() {
|
||||
err = os.RemoveAll(filepath.Join(p.versioningDir, bucket))
|
||||
@@ -882,8 +889,12 @@ func (p *Posix) deleteNullVersionIdObject(bucket, key string) error {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return err
|
||||
_ = p.meta.DeleteAttributes(versionPath, "")
|
||||
return nil
|
||||
}
|
||||
|
||||
func isRemovableAttr(attr string) bool {
|
||||
@@ -1042,6 +1053,18 @@ func (p *Posix) ensureNotDeleteMarker(bucket, object, versionId string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// With path-based metadata backends (e.g. sidecar), RetrieveAttribute
|
||||
// returns ErrNoSuchKey whether the sidecar attribute is absent OR the
|
||||
// data file simply doesn't exist — the two cases are indistinguishable
|
||||
// from metadata alone. Verify the data file directly so callers
|
||||
// receive the correct NoSuchVersion / NoSuchKey error.
|
||||
if _, statErr := os.Stat(filepath.Join(bucket, object)); errors.Is(statErr, fs.ErrNotExist) || errors.Is(statErr, syscall.ENOTDIR) {
|
||||
if versionId != "" {
|
||||
return s3err.GetAPIError(s3err.ErrNoSuchVersion)
|
||||
}
|
||||
return s3err.GetAPIError(s3err.ErrNoSuchKey)
|
||||
}
|
||||
|
||||
_, err := p.meta.RetrieveAttribute(nil, bucket, object, deleteMarkerKey)
|
||||
if errors.Is(err, fs.ErrNotExist) || errors.Is(err, syscall.ENOTDIR) {
|
||||
if versionId != "" {
|
||||
@@ -1518,6 +1541,7 @@ func (p *Posix) CreateMultipartUpload(ctx context.Context, mpu s3response.Create
|
||||
// cleanup object if returning error
|
||||
os.RemoveAll(filepath.Join(tmppath, uploadID))
|
||||
os.Remove(tmppath)
|
||||
_ = p.meta.DeleteAttributes(bucket, filepath.Join(objdir, uploadID))
|
||||
return s3response.InitiateMultipartUploadResult{}, err
|
||||
}
|
||||
}
|
||||
@@ -1536,6 +1560,7 @@ func (p *Posix) CreateMultipartUpload(ctx context.Context, mpu s3response.Create
|
||||
// cleanup object if returning error
|
||||
os.RemoveAll(filepath.Join(tmppath, uploadID))
|
||||
os.Remove(tmppath)
|
||||
_ = p.meta.DeleteAttributes(bucket, filepath.Join(objdir, uploadID))
|
||||
return s3response.InitiateMultipartUploadResult{}, err
|
||||
}
|
||||
|
||||
@@ -1549,6 +1574,7 @@ func (p *Posix) CreateMultipartUpload(ctx context.Context, mpu s3response.Create
|
||||
// cleanup object if returning error
|
||||
os.RemoveAll(filepath.Join(tmppath, uploadID))
|
||||
os.Remove(tmppath)
|
||||
_ = p.meta.DeleteAttributes(bucket, filepath.Join(objdir, uploadID))
|
||||
return s3response.InitiateMultipartUploadResult{}, err
|
||||
}
|
||||
}
|
||||
@@ -1564,6 +1590,7 @@ func (p *Posix) CreateMultipartUpload(ctx context.Context, mpu s3response.Create
|
||||
// cleanup object if returning error
|
||||
os.RemoveAll(filepath.Join(tmppath, uploadID))
|
||||
os.Remove(tmppath)
|
||||
_ = p.meta.DeleteAttributes(bucket, filepath.Join(objdir, uploadID))
|
||||
return s3response.InitiateMultipartUploadResult{}, fmt.Errorf("parse object lock retention: %w", err)
|
||||
}
|
||||
err = p.PutObjectRetention(withCtxNoSlot(ctx), bucket, filepath.Join(objdir, uploadID), "", retParsed)
|
||||
@@ -1574,6 +1601,7 @@ func (p *Posix) CreateMultipartUpload(ctx context.Context, mpu s3response.Create
|
||||
// cleanup object if returning error
|
||||
os.RemoveAll(filepath.Join(tmppath, uploadID))
|
||||
os.Remove(tmppath)
|
||||
_ = p.meta.DeleteAttributes(bucket, filepath.Join(objdir, uploadID))
|
||||
return s3response.InitiateMultipartUploadResult{}, err
|
||||
}
|
||||
}
|
||||
@@ -1588,6 +1616,7 @@ func (p *Posix) CreateMultipartUpload(ctx context.Context, mpu s3response.Create
|
||||
// cleanup object if returning error
|
||||
_ = os.RemoveAll(filepath.Join(tmppath, uploadID))
|
||||
_ = os.Remove(tmppath)
|
||||
_ = p.meta.DeleteAttributes(bucket, filepath.Join(objdir, uploadID))
|
||||
return s3response.InitiateMultipartUploadResult{}, fmt.Errorf("store mp checksum algorithm: %w", err)
|
||||
}
|
||||
}
|
||||
@@ -2051,6 +2080,10 @@ func (p *Posix) CompleteMultipartUploadWithCopy(ctx context.Context, input *s3.C
|
||||
if err != nil {
|
||||
return res, "", fmt.Errorf("create object version: %w", err)
|
||||
}
|
||||
// Clean up object-lock attrs that may have leaked from the previous
|
||||
// version's path-based metadata into this (new) version's sidecar.
|
||||
_ = p.meta.DeleteAttribute(bucket, object, objectLegalHoldKey)
|
||||
_ = p.meta.DeleteAttribute(bucket, object, objectRetentionKey)
|
||||
}
|
||||
|
||||
// if the versioning is enabled, generate a new versionID for the object
|
||||
@@ -2488,6 +2521,11 @@ func (p *Posix) AbortMultipartUpload(ctx context.Context, mpu *s3.AbortMultipart
|
||||
}
|
||||
os.Remove(objdir)
|
||||
|
||||
// Clean up sidecar metadata for the aborted upload. With xattr this is
|
||||
// a no-op; with sidecar the metadata directory would otherwise be orphaned.
|
||||
uploadMetaPath := filepath.Join(MetaTmpMultipartDir, fmt.Sprintf("%x", sum), uploadID)
|
||||
_ = p.meta.DeleteAttributes(bucket, uploadMetaPath)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -3556,6 +3594,13 @@ func (p *Posix) PutObjectWithPostFunc(ctx context.Context, po s3response.PutObje
|
||||
if err != nil {
|
||||
return s3response.PutObjectOutput{}, fmt.Errorf("create object version: %w", err)
|
||||
}
|
||||
// With path-based metadata backends (e.g. sidecar), object-lock
|
||||
// attributes written on the previous version persist at this path
|
||||
// after createObjVersion because metadata is not replaced atomically
|
||||
// the way xattrs are on file rename. Delete them so they do not
|
||||
// bleed into the new version.
|
||||
_ = p.meta.DeleteAttribute(*po.Bucket, *po.Key, objectLegalHoldKey)
|
||||
_ = p.meta.DeleteAttribute(*po.Bucket, *po.Key, objectRetentionKey)
|
||||
}
|
||||
}
|
||||
if errors.Is(err, syscall.ENAMETOOLONG) {
|
||||
@@ -3644,6 +3689,11 @@ func (p *Posix) PutObjectWithPostFunc(ctx context.Context, po s3response.PutObje
|
||||
return s3response.PutObjectOutput{}, err
|
||||
}
|
||||
versionID = nullVersionId
|
||||
// Clear any stale versionId sidecar attribute left from a previous
|
||||
// versioned object at this path. With xattr this is implicit (the
|
||||
// new file carries only the attrs set on the tmpfile), but with
|
||||
// path-based metadata the old attr persists until explicitly deleted.
|
||||
_ = p.meta.DeleteAttribute(*po.Bucket, *po.Key, versionIdKey)
|
||||
}
|
||||
|
||||
var sum string
|
||||
@@ -3921,6 +3971,16 @@ func (p *Posix) DeleteObject(ctx context.Context, input *s3.DeleteObjectInput) (
|
||||
return nil, fmt.Errorf("get obj versionId: %w", err)
|
||||
}
|
||||
if errors.Is(err, meta.ErrNoSuchKey) {
|
||||
// With sidecar, ErrNoSuchKey means "attribute absent" regardless of
|
||||
// whether the data file exists. If the file is absent the object
|
||||
// does not exist at all → AWS returns success for DeleteObject.
|
||||
// Also handle ENOTDIR: when a key such as "foo/bar" is requested
|
||||
// but "foo" is a regular file (not a directory), the path cannot
|
||||
// contain any object.
|
||||
_, statErr := os.Stat(filepath.Join(bucket, object))
|
||||
if errors.Is(statErr, fs.ErrNotExist) || errors.Is(statErr, syscall.ENOTDIR) {
|
||||
return &s3.DeleteObjectOutput{VersionId: input.VersionId}, nil
|
||||
}
|
||||
vId = []byte(nullVersionId)
|
||||
}
|
||||
|
||||
@@ -3994,6 +4054,15 @@ func (p *Posix) DeleteObject(ctx context.Context, input *s3.DeleteObjectInput) (
|
||||
return nil, fmt.Errorf("link tmp file: %w", err)
|
||||
}
|
||||
|
||||
// With path-based metadata (sidecar) the live object's attrs are
|
||||
// not replaced atomically. The restored version may not have all
|
||||
// the attrs that the deleted version had (e.g. a null version has
|
||||
// no versionIdKey). Clear attrs that belong to the deleted version
|
||||
// before copying the restored version's attrs so that the restored
|
||||
// version presents a clean state.
|
||||
_ = p.meta.DeleteAttribute(bucket, object, versionIdKey)
|
||||
_ = p.meta.DeleteAttribute(bucket, object, deleteMarkerKey)
|
||||
|
||||
attrs, err := p.meta.ListAttributes(versionPath, srcVersionId)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list object attributes: %w", err)
|
||||
@@ -4016,6 +4085,7 @@ func (p *Posix) DeleteObject(ctx context.Context, input *s3.DeleteObjectInput) (
|
||||
return nil, fmt.Errorf("remove obj version %w", err)
|
||||
}
|
||||
|
||||
_ = p.meta.DeleteAttributes(versionPath, srcVersionId)
|
||||
p.removeParents(filepath.Join(p.versioningDir, bucket), filepath.Join(genObjVersionKey(object), *input.VersionId))
|
||||
|
||||
return &s3.DeleteObjectOutput{
|
||||
@@ -4042,6 +4112,7 @@ func (p *Posix) DeleteObject(ctx context.Context, input *s3.DeleteObjectInput) (
|
||||
return nil, fmt.Errorf("delete object: %w", err)
|
||||
}
|
||||
|
||||
_ = p.meta.DeleteAttributes(versionPath, *input.VersionId)
|
||||
p.removeParents(filepath.Join(p.versioningDir, bucket), filepath.Join(genObjVersionKey(object), *input.VersionId))
|
||||
|
||||
return &s3.DeleteObjectOutput{
|
||||
@@ -5553,6 +5624,19 @@ func (p *Posix) GetObjectTagging(ctx context.Context, bucket, object, versionId
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if versionId == "" {
|
||||
_, err = os.Stat(filepath.Join(bucket, object))
|
||||
if errors.Is(err, fs.ErrNotExist) || errors.Is(err, syscall.ENOTDIR) {
|
||||
return nil, s3err.GetAPIError(s3err.ErrNoSuchKey)
|
||||
}
|
||||
if errors.Is(err, syscall.ENAMETOOLONG) {
|
||||
return nil, s3err.GetAPIError(s3err.ErrKeyTooLong)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stat object: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if versionId != "" {
|
||||
if !p.versioningEnabled() {
|
||||
//TODO: Maybe we need to return our custom error here?
|
||||
@@ -5630,6 +5714,19 @@ func (p *Posix) PutObjectTagging(ctx context.Context, bucket, object, versionId
|
||||
return err
|
||||
}
|
||||
|
||||
if versionId == "" {
|
||||
_, err = os.Stat(filepath.Join(bucket, object))
|
||||
if errors.Is(err, fs.ErrNotExist) || errors.Is(err, syscall.ENOTDIR) {
|
||||
return s3err.GetAPIError(s3err.ErrNoSuchKey)
|
||||
}
|
||||
if errors.Is(err, syscall.ENAMETOOLONG) {
|
||||
return s3err.GetAPIError(s3err.ErrKeyTooLong)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("stat object: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if versionId != "" {
|
||||
if !p.versioningEnabled() {
|
||||
//TODO: Maybe we need to return our custom error here?
|
||||
|
||||
@@ -32,7 +32,15 @@ case "$backend" in
|
||||
;;
|
||||
esac
|
||||
|
||||
set -- "$backend"
|
||||
# Global flags must precede the backend subcommand.
|
||||
if [ -n "${VGW_ARGS:-}" ]; then
|
||||
# shellcheck disable=SC2086
|
||||
set -- ${VGW_ARGS}
|
||||
else
|
||||
set --
|
||||
fi
|
||||
|
||||
set -- "$@" "$backend"
|
||||
|
||||
if [ -n "${VGW_BACKEND_ARG:-}" ]; then
|
||||
set -- "$@" "$VGW_BACKEND_ARG"
|
||||
@@ -43,9 +51,4 @@ if [ -n "${VGW_BACKEND_ARGS:-}" ]; then
|
||||
set -- "$@" ${VGW_BACKEND_ARGS}
|
||||
fi
|
||||
|
||||
if [ -n "${VGW_ARGS:-}" ]; then
|
||||
# shellcheck disable=SC2086
|
||||
set -- "$@" ${VGW_ARGS}
|
||||
fi
|
||||
|
||||
exec "$BIN" "$@"
|
||||
|
||||
26
runtests.sh
26
runtests.sh
@@ -1,5 +1,21 @@
|
||||
#!/bin/bash
|
||||
|
||||
# parse options
|
||||
USE_SIDECAR=false
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--sidecar) USE_SIDECAR=true ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# build sidecar flag for versitygw invocations
|
||||
SIDECAR_FLAG=""
|
||||
if $USE_SIDECAR; then
|
||||
rm -rf /tmp/sidecar
|
||||
mkdir /tmp/sidecar
|
||||
SIDECAR_FLAG="--sidecar /tmp/sidecar"
|
||||
fi
|
||||
|
||||
# make temp dirs
|
||||
rm -rf /tmp/gw
|
||||
mkdir /tmp/gw
|
||||
@@ -26,7 +42,7 @@ openssl req -new -x509 -key key.pem -out cert.pem -days 365 -subj "/C=US/ST=Cali
|
||||
ECHO "Running the sdk test over http"
|
||||
# run server in background not versioning-enabled
|
||||
# port: 7070(default)
|
||||
GOCOVERDIR=/tmp/covdata ./versitygw -a user -s pass --iam-dir /tmp/gw posix /tmp/gw &
|
||||
GOCOVERDIR=/tmp/covdata ./versitygw -a user -s pass --iam-dir /tmp/gw posix $SIDECAR_FLAG /tmp/gw &
|
||||
GW_PID=$!
|
||||
|
||||
sleep 1
|
||||
@@ -63,7 +79,7 @@ ECHO "Running the sdk test over https"
|
||||
|
||||
# run server in background with TLS certificate
|
||||
# port: 7071(default)
|
||||
GOCOVERDIR=/tmp/https.covdata ./versitygw --cert "$PWD/cert.pem" --key "$PWD/key.pem" -p :7071 -a user -s pass --iam-dir /tmp/gw posix /tmp/gw &
|
||||
GOCOVERDIR=/tmp/https.covdata ./versitygw --cert "$PWD/cert.pem" --key "$PWD/key.pem" -p :7071 -a user -s pass --iam-dir /tmp/gw posix $SIDECAR_FLAG /tmp/gw &
|
||||
GW_HTTPS_PID=$!
|
||||
|
||||
sleep 1
|
||||
@@ -99,7 +115,7 @@ kill $GW_HTTPS_PID
|
||||
ECHO "Running the sdk test over http against the versioning-enabled gateway"
|
||||
# run server in background versioning-enabled
|
||||
# port: 7072
|
||||
GOCOVERDIR=/tmp/versioning.covdata ./versitygw -p :7072 -a user -s pass --iam-dir /tmp/gw posix --versioning-dir /tmp/versioningdir /tmp/gw &
|
||||
GOCOVERDIR=/tmp/versioning.covdata ./versitygw -p :7072 -a user -s pass --iam-dir /tmp/gw posix $SIDECAR_FLAG --versioning-dir /tmp/versioningdir /tmp/gw &
|
||||
GW_VS_PID=$!
|
||||
|
||||
# wait a second for server to start up
|
||||
@@ -131,7 +147,7 @@ kill $GW_VS_PID
|
||||
ECHO "Running the sdk test over https against the versioning-enabled gateway"
|
||||
# run server in background versioning-enabled
|
||||
# port: 7073
|
||||
GOCOVERDIR=/tmp/versioning.https.covdata ./versitygw --cert "$PWD/cert.pem" --key "$PWD/key.pem" -p :7073 -a user -s pass --iam-dir /tmp/gw posix --versioning-dir /tmp/versioningdir /tmp/gw &
|
||||
GOCOVERDIR=/tmp/versioning.https.covdata ./versitygw --cert "$PWD/cert.pem" --key "$PWD/key.pem" -p :7073 -a user -s pass --iam-dir /tmp/gw posix $SIDECAR_FLAG --versioning-dir /tmp/versioningdir /tmp/gw &
|
||||
GW_VS_HTTPS_PID=$!
|
||||
|
||||
# wait a second for server to start up
|
||||
@@ -163,7 +179,7 @@ kill $GW_VS_HTTPS_PID
|
||||
ECHO "Running No ACL integration tests"
|
||||
# run server in background versioning-enabled
|
||||
# port: 7073
|
||||
GOCOVERDIR=/tmp/noacl.covdata ./versitygw -p :7074 -a user -s pass -noacl --iam-dir /tmp/gw posix /tmp/gw &
|
||||
GOCOVERDIR=/tmp/noacl.covdata ./versitygw -p :7074 -a user -s pass -noacl --iam-dir /tmp/gw posix $SIDECAR_FLAG /tmp/gw &
|
||||
GW_NO_ACL_PID=$!
|
||||
|
||||
# wait a second for server to start up
|
||||
|
||||
@@ -20,15 +20,36 @@ source ./tests/drivers/params.sh
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
# Tests to exclude from the matrix (matched against file basename without extension)
|
||||
skip_list=(
|
||||
"mc"
|
||||
"mc_file_count"
|
||||
)
|
||||
|
||||
files=()
|
||||
iam_types=()
|
||||
regions=()
|
||||
idx=0
|
||||
|
||||
is_skipped() {
|
||||
local basename="${1##*/}"
|
||||
local name="${basename%.sh}"
|
||||
name="${name#test_}"
|
||||
for skip in "${skip_list[@]}"; do
|
||||
if [[ "$name" == "$skip" ]]; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
check_for_and_load_test_file_and_params() {
|
||||
if ! check_param_count_v2 "file name" 1 $#; then
|
||||
exit 1
|
||||
fi
|
||||
if is_skipped "$1"; then
|
||||
return 0
|
||||
fi
|
||||
if grep -q '@test' "$1"; then
|
||||
if [ $(( idx % 8 )) -eq 0 ]; then
|
||||
iam="s3"
|
||||
|
||||
@@ -37,8 +37,12 @@ func NewTestState(ctx context.Context, conf *S3Conf, parallel bool) *TestState {
|
||||
parallel: parallel,
|
||||
}
|
||||
|
||||
// Start background test processor (only used in parallel mode)
|
||||
go ts.process()
|
||||
// Start background test processor (only used in parallel mode).
|
||||
// Track it in the WaitGroup so Wait() doesn't return until process()
|
||||
// has drained mainCh and all launched goroutines have finished.
|
||||
ts.wg.Go(func() {
|
||||
ts.process()
|
||||
})
|
||||
|
||||
return ts
|
||||
}
|
||||
@@ -99,9 +103,12 @@ func (ct *TestState) process() {
|
||||
// Wait blocks until all queued parallel tests complete, then runs all
|
||||
// synchronous tests. It also ensures proper cleanup of the test channel.
|
||||
func (ct *TestState) Wait() {
|
||||
// Wait for all parallel tests to finish
|
||||
ct.wg.Wait()
|
||||
// Close the channel first so process() drains remaining items and exits.
|
||||
// This must happen before wg.Wait() to avoid a race where wg.Wait()
|
||||
// returns while process() still has buffered tests yet to start.
|
||||
close(ct.mainCh)
|
||||
// Wait for process() goroutine and all test goroutines to finish.
|
||||
ct.wg.Wait()
|
||||
|
||||
// Run all synchronous tests sequentially
|
||||
for _, fn := range ct.syncTests {
|
||||
|
||||
@@ -127,9 +127,10 @@ func teardown(s *S3Conf, bucket string) error {
|
||||
for attempts < maxRetryAttempts {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
|
||||
_, err = s3client.DeleteObject(ctx, &s3.DeleteObjectInput{
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
VersionId: versionId,
|
||||
Bucket: bucket,
|
||||
Key: key,
|
||||
VersionId: versionId,
|
||||
BypassGovernanceRetention: aws.Bool(true),
|
||||
})
|
||||
cancel()
|
||||
if err == nil {
|
||||
|
||||
@@ -221,7 +221,7 @@ under the License.
|
||||
</div>
|
||||
|
||||
<!-- Change Owner Modal -->
|
||||
<div id="owner-modal" class="hidden fixed inset-0 z-50">
|
||||
<div id="owner-modal" class="modal hidden fixed inset-0 z-50">
|
||||
<div class="modal-backdrop absolute inset-0" onclick="closeModal('owner-modal')"></div>
|
||||
<div class="absolute inset-0 flex items-center justify-center p-4">
|
||||
<div class="bg-white rounded-xl shadow-2xl w-full max-w-md relative">
|
||||
@@ -281,7 +281,7 @@ under the License.
|
||||
</div>
|
||||
|
||||
<!-- Create Bucket Modal -->
|
||||
<div id="create-bucket-modal" class="hidden fixed inset-0 z-50">
|
||||
<div id="create-bucket-modal" class="modal hidden fixed inset-0 z-50">
|
||||
<div class="modal-backdrop absolute inset-0" onclick="closeModal('create-bucket-modal')"></div>
|
||||
<div class="absolute inset-0 flex items-center justify-center p-4">
|
||||
<div class="bg-white rounded-xl shadow-2xl w-full max-w-md relative">
|
||||
@@ -353,7 +353,7 @@ under the License.
|
||||
let allBuckets = [];
|
||||
let allUsers = [];
|
||||
let selectedBucket = null;
|
||||
|
||||
|
||||
// ============================================
|
||||
// Custom Dropdown Functions
|
||||
// ============================================
|
||||
|
||||
@@ -285,6 +285,42 @@ under the License.
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!-- Pagination Bar -->
|
||||
<div id="objects-pagination" class="hidden flex items-center justify-between px-4 py-3 border-t border-gray-100">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-charcoal-300" id="pagination-info"></span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm text-charcoal-300">Rows per page:</span>
|
||||
<select id="page-size-select" onchange="changePageSize(parseInt(this.value, 10))" class="text-sm border border-gray-200 rounded-lg px-2 py-1.5 text-charcoal focus:outline-none focus:border-accent bg-white">
|
||||
<option value="10" selected>10</option>
|
||||
<option value="20">20</option>
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
<option value="1000">1000</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<button id="pagination-first" onclick="changePage(1)" class="p-1.5 text-charcoal-300 hover:text-charcoal hover:bg-gray-100 rounded-lg transition-colors disabled:opacity-40 disabled:cursor-not-allowed" title="First page">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 19l-7-7 7-7m8 14l-7-7 7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button id="pagination-prev" onclick="changePage(currentPage - 1)" class="p-1.5 text-charcoal-300 hover:text-charcoal hover:bg-gray-100 rounded-lg transition-colors disabled:opacity-40 disabled:cursor-not-allowed" title="Previous page">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
|
||||
</svg>
|
||||
</button>
|
||||
<span id="pagination-pages" class="text-sm text-charcoal px-2"></span>
|
||||
<button id="pagination-next" onclick="changePage(currentPage + 1)" class="p-1.5 text-charcoal-300 hover:text-charcoal hover:bg-gray-100 rounded-lg transition-colors disabled:opacity-40 disabled:cursor-not-allowed" title="Next page">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -308,7 +344,7 @@ under the License.
|
||||
</div>
|
||||
|
||||
<!-- Object Info Modal -->
|
||||
<div id="info-modal" class="hidden fixed inset-0 z-50">
|
||||
<div id="info-modal" class="modal hidden fixed inset-0 z-50">
|
||||
<div class="modal-backdrop absolute inset-0" onclick="closeModal('info-modal')"></div>
|
||||
<div class="absolute inset-0 flex items-center justify-center p-4">
|
||||
<div class="bg-white rounded-xl shadow-2xl w-full max-w-3xl relative max-h-[90vh] flex flex-col">
|
||||
@@ -408,7 +444,7 @@ under the License.
|
||||
<input type="file" id="file-input" class="hidden" multiple onchange="handleFileSelect(event)">
|
||||
|
||||
<!-- Object Versions Modal -->
|
||||
<div id="versions-modal" class="hidden fixed inset-0 z-50">
|
||||
<div id="versions-modal" class="modal hidden fixed inset-0 z-50">
|
||||
<div class="modal-backdrop absolute inset-0" onclick="closeModal('versions-modal')"></div>
|
||||
<div class="absolute inset-0 flex items-center justify-center p-4">
|
||||
<div class="bg-white rounded-xl shadow-2xl w-full max-w-4xl relative max-h-[90vh] flex flex-col">
|
||||
@@ -442,7 +478,7 @@ under the License.
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div id="delete-modal" class="hidden fixed inset-0 z-50">
|
||||
<div id="delete-modal" class="modal hidden fixed inset-0 z-50">
|
||||
<div class="modal-backdrop absolute inset-0" onclick="closeModal('delete-modal')"></div>
|
||||
<div class="absolute inset-0 flex items-center justify-center p-4">
|
||||
<div class="bg-white rounded-xl shadow-2xl w-full max-w-md relative">
|
||||
@@ -468,7 +504,7 @@ under the License.
|
||||
</div>
|
||||
|
||||
<!-- Create Folder Modal -->
|
||||
<div id="create-folder-modal" class="hidden fixed inset-0 z-50">
|
||||
<div id="create-folder-modal" class="modal hidden fixed inset-0 z-50">
|
||||
<div class="modal-backdrop absolute inset-0" onclick="closeModal('create-folder-modal')"></div>
|
||||
<div class="absolute inset-0 flex items-center justify-center p-4">
|
||||
<div class="bg-white rounded-xl shadow-2xl w-full max-w-md relative">
|
||||
@@ -498,7 +534,7 @@ under the License.
|
||||
</div>
|
||||
|
||||
<!-- Create Bucket Modal -->
|
||||
<div id="create-bucket-modal" class="hidden fixed inset-0 z-50">
|
||||
<div id="create-bucket-modal" class="modal hidden fixed inset-0 z-50">
|
||||
<div class="modal-backdrop absolute inset-0" onclick="closeModal('create-bucket-modal')"></div>
|
||||
<div class="absolute inset-0 flex items-center justify-center p-4">
|
||||
<div class="bg-white rounded-xl shadow-2xl w-full max-w-md relative">
|
||||
@@ -528,7 +564,7 @@ under the License.
|
||||
</div>
|
||||
|
||||
<!-- Delete Bucket Modal -->
|
||||
<div id="delete-bucket-modal" class="hidden fixed inset-0 z-50">
|
||||
<div id="delete-bucket-modal" class="modal hidden fixed inset-0 z-50">
|
||||
<div class="modal-backdrop absolute inset-0" onclick="closeModal('delete-bucket-modal')"></div>
|
||||
<div class="absolute inset-0 flex items-center justify-center p-4">
|
||||
<div class="bg-white rounded-xl shadow-2xl w-full max-w-md relative">
|
||||
@@ -562,7 +598,7 @@ under the License.
|
||||
</div>
|
||||
|
||||
<!-- Bucket Info Modal -->
|
||||
<div id="bucket-info-modal" class="hidden fixed inset-0 z-50">
|
||||
<div id="bucket-info-modal" class="modal hidden fixed inset-0 z-50">
|
||||
<div class="modal-backdrop absolute inset-0" onclick="closeModal('bucket-info-modal')"></div>
|
||||
<div class="absolute inset-0 flex items-center justify-center p-4">
|
||||
<div class="bg-white rounded-xl shadow-2xl w-full max-w-2xl relative max-h-[90vh] overflow-y-auto">
|
||||
@@ -700,7 +736,7 @@ under the License.
|
||||
</div>
|
||||
|
||||
<!-- Versioning Modal -->
|
||||
<div id="versioning-modal" class="hidden fixed inset-0 z-50">
|
||||
<div id="versioning-modal" class="modal hidden fixed inset-0 z-50">
|
||||
<div class="modal-backdrop absolute inset-0" onclick="closeModal('versioning-modal')"></div>
|
||||
<div class="absolute inset-0 flex items-center justify-center p-4">
|
||||
<div class="bg-white rounded-xl shadow-2xl w-full max-w-md relative">
|
||||
@@ -742,7 +778,7 @@ under the License.
|
||||
</div>
|
||||
|
||||
<!-- Object Lock Modal -->
|
||||
<div id="object-lock-modal" class="hidden fixed inset-0 z-50">
|
||||
<div id="object-lock-modal" class="modal hidden fixed inset-0 z-50">
|
||||
<div class="modal-backdrop absolute inset-0" onclick="closeModal('object-lock-modal')"></div>
|
||||
<div class="absolute inset-0 flex items-center justify-center p-4">
|
||||
<div class="bg-white rounded-xl shadow-2xl w-full max-w-md relative">
|
||||
@@ -806,7 +842,7 @@ under the License.
|
||||
</div>
|
||||
|
||||
<!-- Bucket Policy Modal -->
|
||||
<div id="bucket-policy-modal" class="hidden fixed inset-0 z-50">
|
||||
<div id="bucket-policy-modal" class="modal hidden fixed inset-0 z-50">
|
||||
<div class="modal-backdrop absolute inset-0" onclick="closeModal('bucket-policy-modal')"></div>
|
||||
<div class="absolute inset-0 flex items-center justify-center p-4">
|
||||
<div class="bg-white rounded-xl shadow-2xl w-full max-w-5xl relative max-h-[90vh] flex flex-col">
|
||||
@@ -978,7 +1014,7 @@ under the License.
|
||||
</div>
|
||||
|
||||
<!-- Multipart Uploads Modal -->
|
||||
<div id="multipart-uploads-modal" class="hidden fixed inset-0 z-50">
|
||||
<div id="multipart-uploads-modal" class="modal hidden fixed inset-0 z-50">
|
||||
<div class="modal-backdrop absolute inset-0" onclick="closeModal('multipart-uploads-modal')"></div>
|
||||
<div class="absolute inset-0 flex items-center justify-center p-4">
|
||||
<div class="bg-white rounded-xl shadow-2xl w-full max-w-4xl relative max-h-[90vh] flex flex-col">
|
||||
@@ -1085,6 +1121,13 @@ under the License.
|
||||
let currentObjectKey = null; // For info modal
|
||||
let searchTerm = ''; // Search filter
|
||||
|
||||
// Pagination state
|
||||
let currentPage = 1;
|
||||
let pageSize = 10;
|
||||
let continuationTokens = [null]; // continuationTokens[i] = S3 token needed to fetch page i+1
|
||||
let isTruncated = false;
|
||||
let isReloadingFirstPageForSearch = false;
|
||||
|
||||
// Versioning state
|
||||
let showVersions = false;
|
||||
let currentBucketVersioningStatus = null;
|
||||
@@ -1098,6 +1141,9 @@ under the License.
|
||||
if (!requireAuth()) {
|
||||
// Will redirect to login
|
||||
} else {
|
||||
// Clear history state on startup (can happen when reloading with popup open)
|
||||
if (history.state?.modal) history.back();
|
||||
|
||||
initSidebarWithRole();
|
||||
updateUserInfo();
|
||||
init();
|
||||
@@ -1121,11 +1167,19 @@ under the License.
|
||||
currentPrefix += '/';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
currentBucket = "";
|
||||
currentPrefix = "";
|
||||
}
|
||||
|
||||
await loadBuckets();
|
||||
}
|
||||
|
||||
|
||||
// Rerun init() on hash change to allow for back/forward when browsing a bucket.
|
||||
window.addEventListener('hashchange', async () => {
|
||||
await init();
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// Logout
|
||||
// ============================================
|
||||
@@ -1528,15 +1582,23 @@ under the License.
|
||||
// Object Loading
|
||||
// ============================================
|
||||
|
||||
async function loadObjects() {
|
||||
async function loadObjects(continuationToken = null, isPageChange = false) {
|
||||
if (!currentBucket) return;
|
||||
|
||||
showTableLoading('objects-table', 6);
|
||||
clearSelection();
|
||||
if (!isPageChange) {
|
||||
currentPage = 1;
|
||||
continuationTokens = [null];
|
||||
}
|
||||
updateBreadcrumb();
|
||||
|
||||
try {
|
||||
const result = await api.listObjectsV2(currentBucket, currentPrefix);
|
||||
const result = await api.listObjectsV2(currentBucket, currentPrefix, '/', pageSize, continuationToken);
|
||||
isTruncated = result.isTruncated;
|
||||
if (isTruncated && result.continuationToken) {
|
||||
continuationTokens[currentPage] = result.continuationToken;
|
||||
}
|
||||
|
||||
folders = result.commonPrefixes || [];
|
||||
objects = result.contents || [];
|
||||
@@ -1594,11 +1656,29 @@ under the License.
|
||||
}
|
||||
}
|
||||
|
||||
// When paginating, restrict deleted-with-versions objects to the current page's
|
||||
// key range, since listObjectVersions returns all versions (not page-bounded).
|
||||
if (isTruncated || currentPage > 1) {
|
||||
const allPageKeys = [
|
||||
...folders.map(f => f.prefix),
|
||||
...objects.map(o => o.key)
|
||||
].sort();
|
||||
const pageFirstKey = allPageKeys[0];
|
||||
const pageLastKey = allPageKeys[allPageKeys.length - 1];
|
||||
deletedObjectsWithVersions = deletedObjectsWithVersions.filter(obj => {
|
||||
if (currentPage > 1 && pageFirstKey && obj.key < pageFirstKey) return false;
|
||||
if (isTruncated && pageLastKey && obj.key > pageLastKey) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
renderObjects();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Error loading objects:', error);
|
||||
showToast('Error loading objects: ' + error.message, 'error');
|
||||
showEmptyState('objects-table', 6, 'Error loading objects');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1642,6 +1722,7 @@ under the License.
|
||||
|
||||
// Empty folder or no results
|
||||
if (filteredFolders.length === 0 && filteredObjects.length === 0 && filteredDeletedObjects.length === 0) {
|
||||
document.getElementById('objects-pagination').classList.add('hidden');
|
||||
if (searchTerm) {
|
||||
tbody.innerHTML = `
|
||||
<tr>
|
||||
@@ -1677,6 +1758,9 @@ under the License.
|
||||
return;
|
||||
}
|
||||
|
||||
// Count after filtering (objects.filter above may have removed the prefix key)
|
||||
const totalVisible = filteredFolders.length + filteredObjects.length + filteredDeletedObjects.length;
|
||||
|
||||
// Render folders first
|
||||
filteredFolders.forEach(folder => {
|
||||
const name = getFolderName(folder.prefix);
|
||||
@@ -1839,6 +1923,96 @@ under the License.
|
||||
`;
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
|
||||
// Show and update pagination bar
|
||||
updatePagination(totalVisible);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// Pagination
|
||||
// ============================================
|
||||
|
||||
function updatePagination(itemCount) {
|
||||
const bar = document.getElementById('objects-pagination');
|
||||
bar.classList.remove('hidden');
|
||||
|
||||
const startItem = (currentPage - 1) * pageSize + 1;
|
||||
const endItem = (currentPage - 1) * pageSize + itemCount;
|
||||
const searchNote = searchTerm ? ' (filtered)' : '';
|
||||
document.getElementById('pagination-info').textContent =
|
||||
itemCount > 0 ? `${startItem}–${endItem}${isTruncated ? '+' : ''} items${searchNote}` : '';
|
||||
document.getElementById('pagination-pages').textContent =
|
||||
`Page ${currentPage}${isTruncated ? '' : ' (last)'}`;
|
||||
|
||||
const firstBtn = document.getElementById('pagination-first');
|
||||
const prevBtn = document.getElementById('pagination-prev');
|
||||
const nextBtn = document.getElementById('pagination-next');
|
||||
|
||||
const isFirst = currentPage <= 1;
|
||||
const isLast = !isTruncated || !!searchTerm; // disable forward nav while search is active
|
||||
firstBtn.disabled = isFirst;
|
||||
prevBtn.disabled = isFirst;
|
||||
nextBtn.disabled = isLast;
|
||||
|
||||
// Sync the select to the current pageSize
|
||||
const select = document.getElementById('page-size-select');
|
||||
if (select) select.value = String(pageSize);
|
||||
}
|
||||
|
||||
async function changePage(page) {
|
||||
if (page < 1) return false;
|
||||
if (page === currentPage) return false;
|
||||
if (page > currentPage && !isTruncated) return false;
|
||||
const token = continuationTokens[page - 1];
|
||||
if (token === undefined) return false; // token not yet known; can't jump
|
||||
|
||||
const previousPage = currentPage;
|
||||
currentPage = page;
|
||||
const loaded = await loadObjects(token, true);
|
||||
if (!loaded) {
|
||||
currentPage = previousPage;
|
||||
renderObjects();
|
||||
}
|
||||
|
||||
return loaded;
|
||||
}
|
||||
|
||||
async function changePageSize(size) {
|
||||
if (size === pageSize) return false;
|
||||
|
||||
const previousPageSize = pageSize;
|
||||
const previousPage = currentPage;
|
||||
const previousTokens = [...continuationTokens];
|
||||
|
||||
pageSize = size;
|
||||
currentPage = 1;
|
||||
continuationTokens = [null];
|
||||
const loaded = await loadObjects();
|
||||
if (!loaded) {
|
||||
pageSize = previousPageSize;
|
||||
currentPage = previousPage;
|
||||
continuationTokens = previousTokens;
|
||||
renderObjects();
|
||||
}
|
||||
|
||||
return loaded;
|
||||
}
|
||||
|
||||
async function reloadFirstPageForSearch() {
|
||||
if (isReloadingFirstPageForSearch) return;
|
||||
|
||||
const previousTokens = [...continuationTokens];
|
||||
|
||||
isReloadingFirstPageForSearch = true;
|
||||
continuationTokens = [null];
|
||||
try {
|
||||
const loaded = await changePage(1);
|
||||
if (!loaded) {
|
||||
continuationTokens = previousTokens;
|
||||
}
|
||||
} finally {
|
||||
isReloadingFirstPageForSearch = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1920,6 +2094,7 @@ under the License.
|
||||
|
||||
function navigateToFolder(prefix) {
|
||||
currentPrefix = prefix;
|
||||
currentPage = 1;
|
||||
loadObjects();
|
||||
updateUrl();
|
||||
}
|
||||
@@ -3053,6 +3228,12 @@ under the License.
|
||||
} else {
|
||||
clearBtn.classList.add('hidden');
|
||||
}
|
||||
|
||||
if (currentBucket && (currentPage > 1 || isReloadingFirstPageForSearch)) {
|
||||
reloadFirstPageForSearch();
|
||||
return;
|
||||
}
|
||||
|
||||
renderObjects();
|
||||
}, 200));
|
||||
}
|
||||
@@ -3068,7 +3249,15 @@ under the License.
|
||||
const clearBtn = document.getElementById('clear-search');
|
||||
if (searchInput) searchInput.value = '';
|
||||
if (clearBtn) clearBtn.classList.add('hidden');
|
||||
if (currentBucket) renderObjects();
|
||||
|
||||
if (!currentBucket) return;
|
||||
|
||||
if (currentPage > 1 || isReloadingFirstPageForSearch) {
|
||||
reloadFirstPageForSearch();
|
||||
return;
|
||||
}
|
||||
|
||||
renderObjects();
|
||||
}
|
||||
|
||||
// ============================================
|
||||
|
||||
@@ -129,13 +129,26 @@ function openModal(modalId) {
|
||||
// Focus first input
|
||||
const firstInput = modal.querySelector('input:not([readonly]), select');
|
||||
if (firstInput) setTimeout(() => firstInput.focus(), 100);
|
||||
|
||||
// Push to history state so the back button can close the modal
|
||||
history.pushState({ modal: true }, '');
|
||||
}
|
||||
}
|
||||
|
||||
function closeModal(modalId) {
|
||||
let navigatingBack = false
|
||||
|
||||
// Close the currently opened modal, manually popping the modal
|
||||
// history state if the modal was closed manually (default). In the
|
||||
// case where the modal was closed due to navigating back, the state
|
||||
// is already popped and we can skip it.
|
||||
function closeModal(modalId, popState = true) {
|
||||
const modal = document.getElementById(modalId);
|
||||
if (modal) {
|
||||
modal.classList.add('hidden');
|
||||
if (popState && history.state?.modal) {
|
||||
navigatingBack = true;
|
||||
history.back();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -145,6 +158,24 @@ function closeAllModals() {
|
||||
});
|
||||
}
|
||||
|
||||
function closeModalsOnNavigation() {
|
||||
Array.from(document.getElementsByClassName('modal')).forEach((modal) => {
|
||||
if (!modal.classList.contains('hidden')) {
|
||||
closeModal(modal.getAttribute('id'), false);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Catch the back button to close the open modal, if any is open.
|
||||
window.addEventListener('popstate', (e) => {
|
||||
if (navigatingBack) {
|
||||
navigatingBack = false;
|
||||
return;
|
||||
}
|
||||
|
||||
closeModalsOnNavigation();
|
||||
});
|
||||
|
||||
// Close modals on Escape key
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') closeAllModals();
|
||||
|
||||
@@ -209,7 +209,7 @@ under the License.
|
||||
</div>
|
||||
|
||||
<!-- Create/Edit User Modal -->
|
||||
<div id="user-modal" class="hidden fixed inset-0 z-50">
|
||||
<div id="user-modal" class="modal hidden fixed inset-0 z-50">
|
||||
<div class="modal-backdrop absolute inset-0" onclick="closeModal('user-modal')"></div>
|
||||
<div class="absolute inset-0 flex items-center justify-center p-4">
|
||||
<div class="bg-white rounded-xl shadow-2xl w-full max-w-lg relative">
|
||||
@@ -298,7 +298,7 @@ under the License.
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div id="delete-modal" class="hidden fixed inset-0 z-50">
|
||||
<div id="delete-modal" class="modal hidden fixed inset-0 z-50">
|
||||
<div class="modal-backdrop absolute inset-0" onclick="closeModal('delete-modal')"></div>
|
||||
<div class="absolute inset-0 flex items-center justify-center p-4">
|
||||
<div class="bg-white rounded-xl shadow-2xl w-full max-w-md relative">
|
||||
@@ -324,7 +324,7 @@ under the License.
|
||||
<script>
|
||||
let allUsers = [];
|
||||
let userToDelete = null;
|
||||
|
||||
|
||||
// ============================================
|
||||
// Custom Dropdown Functions
|
||||
// ============================================
|
||||
|
||||
Reference in New Issue
Block a user