Merge branch 'main' into test/bats_tagging

This commit is contained in:
alice nodelman
2026-04-22 15:00:49 -07:00
committed by GitHub
15 changed files with 460 additions and 50 deletions

View 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

View File

@@ -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: |

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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?

View File

@@ -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" "$@"

View File

@@ -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

View File

@@ -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"

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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
// ============================================

View File

@@ -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();
}
// ============================================

View File

@@ -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();

View File

@@ -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
// ============================================