Compare commits

...

26 Commits

Author SHA1 Message Date
Luke McCrone
4341f6bc3a test: safety 2026-01-28 16:30:27 -03:00
Luke McCrone
a3e3aa330a test: add more testing 2026-01-26 16:13:38 -03:00
Luke McCrone
a1d8161aaa test: temp 2026-01-26 13:29:41 -03:00
Luke McCrone
2f186a9b9b test: temp 2026-01-23 19:11:54 -03:00
Luke McCrone
ed2de68733 test: PutObject object lock tests and updates, delete bucket test 2026-01-23 13:06:15 -03:00
Ben McClelland
45b6a4a74e Merge pull request #1779 from versity/sis/admin-flags
feat: makes root creds usable for admin subcommand with lower precendence
2026-01-22 14:44:43 -08:00
Ben McClelland
b576ed87c5 Merge pull request #1777 from eest/support-tls-cert-reload
Reload TLS certificates on SIGHUP
2026-01-22 14:43:36 -08:00
Ben McClelland
0ba5cbe8b9 Merge pull request #1762 from versity/test/reorganization
Test: break up, speed up REST tests, openssl command logging
2026-01-22 13:20:36 -08:00
Luke McCrone
a4d341fc4e test: speed up/reorganize REST tests, openssl logging 2026-01-22 15:54:47 -03:00
niksis02
6c564febb9 feat: makes root creds usable for admin subcommand with lower precendence
Closes #1760

The admin subcommand accepts the `--access` and `--secret` flags as admin user credentials. This change makes these flags optional and allows the root user credentials to be used as admin credentials with lower precedence when the admin flags are omitted.

Both invocation styles are now valid:

```bash
versitygw admin --access user --secret pass -er http://127.0.0.1:7070 list-buckets
```

and

```bash
versitygw --access user --secret pass admin -er http://127.0.0.1:7070 list-buckets
```

Additionally, if `ROOT_ACCESS_KEY` and `ROOT_SECRET_KEY` are already set, the following also works:

```bash
versitygw admin -er http://127.0.0.1:7070 list-buckets
```
2026-01-22 21:14:19 +04:00
Patrik Lundin
0c520a30cf Reload TLS certificates on SIGHUP
* Add utils.CertStorage for holding cert data that can be updated
  at runtime.
* Add utils.NewTLSListener() to have a central place to control e.g. TLS
  MinVersion across different servers.
* Add WithTLS() to webserver code so it looks more like the other
  servers.

Fixes #1299
2026-01-22 14:12:07 +01:00
Ben McClelland
935e322764 Merge pull request #1772 from versity/sis/block-createbucket-readonly-mode
fix: fixes some write operations blocking in read-only mode
2026-01-21 19:12:52 -08:00
niksis02
f6225aa968 fix: fixes some write operations blocking in read-only mode
Fixes #1765
Fixes #1771

This PR addresses two issues:

1. CreateBucket was previously allowed when the gateway was running in read-only mode. It is now correctly blocked.
2. Write operations were permitted on public buckets in read-only mode because the public access checks in `auth.VerifyAccess` were evaluated before the read-only check. The read-only check now takes precedence, and all write operations on public buckets are blocked.
2026-01-21 13:51:46 +04:00
Ben McClelland
1d30567129 Merge pull request #1770 from versity/sis/s3-actions-on-delete-markers
fix: fixes delete markers access for some actions
2026-01-20 10:12:32 -08:00
Luke McCrone
bfc753b302 test: test fix 2026-01-20 11:29:41 -03:00
niksis02
86e2b02e55 fix: fixes delete markers access for some actions
Fixes #1766
Fixes #1750

This PR focuses on two bug fixes:

First, it blocks access to delete `DeleteMarkers` for the following operations by returning a `MethodNotAllowed` error: `PutObjectTagging`, `GetObjectTagging`, `DeleteObjectTagging`, `PutObjectLegalHold`, `GetObjectLegalHold`, `PutObjectRetention`, and `GetObjectRetention`.

Second, it removes the access check that previously prevented deleting a delete marker locked by a bucket default retention rule. A delete marker should always be allowed to be deleted.
2026-01-20 16:24:46 +04:00
Ben McClelland
2cf8610831 Merge pull request #1768 from versity/sis/streaming-test-failure 2026-01-19 15:57:51 -08:00
Ben McClelland
8e3e633a24 Merge pull request #1763 from versity/ben/webgui 2026-01-19 15:57:09 -08:00
niksis02
12092cf297 fix: fixes the SignedStreamingPayloadTrailer_success test failure
In the `SignedStreamingPayloadTrailer_success` integration test, the signing date was not using UTC. This caused an incorrect string-to-sign and signature calculation, because the timestamp used for `x-amz-date` (which is always UTC) differed from the timestamp used for streaming payload signature generation. The test now uses UTC, resolving the issue that occurred when the local time zone differed from UTC, specifically in terms of the `yyyymmdd` date component.
2026-01-20 02:32:54 +04:00
Ben McClelland
75cae81f0a Merge pull request #1769 from versity/dependabot/go_modules/dev-dependencies-40b2bad3af
chore(deps): bump the dev-dependencies group with 6 updates
2026-01-19 14:24:21 -08:00
Ben McClelland
68d7924afa feat: add web-based UI for S3 object management and admin operations
Implements a web interface for VersityGW with role-based access:
- Object explorer for all users to browse, upload, and download S3 objects
- Admin dashboard showing system overview and gateway status
- Admin-only user management for IAM user administration
- Admin-only bucket management for creating and configuring S3 buckets
- User authentication with automatic role-based page access

The web UI is disabled by default and only enabled with the --webui or
VGW_WEBUI_PORT env options that specify the listening address/port for
the web UI server. This preserves previous version behavior to not enable
any new ports/services unless opted in.

Login to the web UI login page with accesskey/secretkey credentials as
either user or admin account. UI functionality will auto detect login
role.

Regular users have access to the object explorer for managing files within
their accessible buckets. Admins additionally have access to user and bucket
management interfaces. The web UI is served on a separate port from the S3
server and integrates with existing S3 and Admin API endpoints.

All requests to the S3 and Admin services are signed by the browser and sent
directly to the S3/Admin service handlers. The login credentials are never
sent over the network for security purposes. This requires the S3/Admin
service to configure CORS Access-Control-Allow-Origin headers for these
requests.
2026-01-19 14:22:12 -08:00
dependabot[bot]
e37dfa6aaf chore(deps): bump the dev-dependencies group with 6 updates
Bumps the dev-dependencies group with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [github.com/Azure/azure-sdk-for-go/sdk/azcore](https://github.com/Azure/azure-sdk-for-go) | `1.20.0` | `1.21.0` |
| [github.com/Azure/azure-sdk-for-go/sdk/storage/azblob](https://github.com/Azure/azure-sdk-for-go) | `1.6.3` | `1.6.4` |
| [github.com/segmentio/kafka-go](https://github.com/segmentio/kafka-go) | `0.4.49` | `0.4.50` |
| [github.com/clipperhouse/uax29/v2](https://github.com/clipperhouse/uax29) | `2.3.0` | `2.3.1` |
| [github.com/pierrec/lz4/v4](https://github.com/pierrec/lz4) | `4.1.23` | `4.1.25` |
| [github.com/klauspost/compress](https://github.com/klauspost/compress) | `1.18.2` | `1.18.3` |


Updates `github.com/Azure/azure-sdk-for-go/sdk/azcore` from 1.20.0 to 1.21.0
- [Release notes](https://github.com/Azure/azure-sdk-for-go/releases)
- [Commits](https://github.com/Azure/azure-sdk-for-go/compare/sdk/azcore/v1.20.0...sdk/azcore/v1.21.0)

Updates `github.com/Azure/azure-sdk-for-go/sdk/storage/azblob` from 1.6.3 to 1.6.4
- [Release notes](https://github.com/Azure/azure-sdk-for-go/releases)
- [Commits](https://github.com/Azure/azure-sdk-for-go/compare/sdk/storage/azblob/v1.6.3...sdk/storage/azblob/v1.6.4)

Updates `github.com/segmentio/kafka-go` from 0.4.49 to 0.4.50
- [Release notes](https://github.com/segmentio/kafka-go/releases)
- [Commits](https://github.com/segmentio/kafka-go/compare/v0.4.49...v0.4.50)

Updates `github.com/clipperhouse/uax29/v2` from 2.3.0 to 2.3.1
- [Release notes](https://github.com/clipperhouse/uax29/releases)
- [Commits](https://github.com/clipperhouse/uax29/compare/v2.3.0...v2.3.1)

Updates `github.com/pierrec/lz4/v4` from 4.1.23 to 4.1.25
- [Release notes](https://github.com/pierrec/lz4/releases)
- [Commits](https://github.com/pierrec/lz4/compare/v4.1.23...v4.1.25)

Updates `github.com/klauspost/compress` from 1.18.2 to 1.18.3
- [Release notes](https://github.com/klauspost/compress/releases)
- [Commits](https://github.com/klauspost/compress/compare/v1.18.2...v1.18.3)

---
updated-dependencies:
- dependency-name: github.com/Azure/azure-sdk-for-go/sdk/azcore
  dependency-version: 1.21.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: dev-dependencies
- dependency-name: github.com/Azure/azure-sdk-for-go/sdk/storage/azblob
  dependency-version: 1.6.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: github.com/segmentio/kafka-go
  dependency-version: 0.4.50
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: github.com/clipperhouse/uax29/v2
  dependency-version: 2.3.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: github.com/pierrec/lz4/v4
  dependency-version: 4.1.25
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: github.com/klauspost/compress
  dependency-version: 1.18.3
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-19 22:10:03 +00:00
Ben McClelland
04f8946798 Merge pull request #1764 from versity/ben/admin-debug 2026-01-19 11:15:12 -08:00
Ben McClelland
43fd18b069 fix: admin server debug always enabled when --admin-port option enabled
We had debug output incorrectly always enabled when running the
admin API on a separate port. This fixes the debug output to only
be enabled when --debug option selected.
2026-01-16 23:54:06 -08:00
Ben McClelland
eb72d3c6e8 Merge pull request #1759 from versity/sis/deleteobject-versionid-internal-error
fix: fixes non-existing object deletion with versionId
2026-01-16 11:26:20 -08:00
niksis02
43559e646e fix: fixes non-existing object deletion with versionId
Fixes #1757
Fixes #1758

When attempting to delete a non-existing object in a versioning-enabled bucket while specifying a `versionId`, VersityGW previously returned an internal error if the object had a parent file object, and an `InvalidArgument` error if the object did not exist. This PR fixes both behaviors and now returns a successful response that includes the `versionId`.
2026-01-16 15:00:47 +04:00
71 changed files with 12012 additions and 844 deletions

View File

@@ -82,15 +82,15 @@ type AccessOptions struct {
}
func VerifyAccess(ctx context.Context, be backend.Backend, opts AccessOptions) error {
// Skip the access check for public bucket requests
if opts.IsPublicRequest {
return nil
}
if opts.Readonly {
if opts.AclPermission == PermissionWrite || opts.AclPermission == PermissionWriteAcp {
return s3err.GetAPIError(s3err.ErrAccessDenied)
}
}
// Skip the access check for public bucket requests
if opts.IsPublicRequest {
return nil
}
if opts.IsRoot {
return nil
}

View File

@@ -278,6 +278,11 @@ func CheckObjectAccess(ctx context.Context, bucket, userAccess string, objects [
if errors.Is(err, s3err.GetAPIError(s3err.ErrNoSuchKey)) {
continue
}
// the object is a delete marker, if a `MethodNotAllowed` error is returned
// no object lock check is needed
if errors.Is(err, s3err.GetAPIError(s3err.ErrMethodNotAllowed)) {
continue
}
if errors.Is(err, s3err.GetAPIError(s3err.ErrNoSuchObjectLockConfiguration)) {
checkRetention = false
}

View File

@@ -871,6 +871,30 @@ func getBoolPtr(b bool) *bool {
return &b
}
// ensureNotDeleteMarker return a `MethodNotAllowd` error
// if the provided object(version) is a delete marker
func (p *Posix) ensureNotDeleteMarker(bucket, object, versionId string) error {
if !p.versioningEnabled() {
return nil
}
_, err := p.meta.RetrieveAttribute(nil, bucket, object, deleteMarkerKey)
if errors.Is(err, fs.ErrNotExist) || errors.Is(err, syscall.ENOTDIR) {
if versionId != "" {
return s3err.GetAPIError(s3err.ErrNoSuchVersion)
}
return s3err.GetAPIError(s3err.ErrNoSuchKey)
}
if errors.Is(err, meta.ErrNoSuchKey) {
return nil
}
if err != nil {
return fmt.Errorf("get delete marker attr: %w", err)
}
return s3err.GetAPIError(s3err.ErrMethodNotAllowed)
}
// Check if the given object is a delete marker
func (p *Posix) isObjDeleteMarker(bucket, object string) (bool, error) {
_, err := p.meta.RetrieveAttribute(nil, bucket, object, deleteMarkerKey)
@@ -3407,7 +3431,13 @@ func (p *Posix) DeleteObject(ctx context.Context, input *s3.DeleteObjectInput) (
versionPath := p.genObjVersionPath(bucket, object)
vId, err := p.meta.RetrieveAttribute(nil, bucket, object, versionIdKey)
if err != nil && !errors.Is(err, meta.ErrNoSuchKey) && !errors.Is(err, fs.ErrNotExist) {
if errors.Is(err, fs.ErrNotExist) || errors.Is(err, syscall.ENOTDIR) {
// AWS returns success if the object does not exist
return &s3.DeleteObjectOutput{
VersionId: input.VersionId,
}, nil
}
if err != nil && !errors.Is(err, meta.ErrNoSuchKey) {
return nil, fmt.Errorf("get obj versionId: %w", err)
}
if errors.Is(err, meta.ErrNoSuchKey) {
@@ -4884,6 +4914,11 @@ func (p *Posix) GetObjectTagging(_ context.Context, bucket, object, versionId st
}
}
err = p.ensureNotDeleteMarker(bucket, object, versionId)
if err != nil {
return nil, err
}
return p.getAttrTags(bucket, object, versionId)
}
@@ -4946,6 +4981,11 @@ func (p *Posix) PutObjectTagging(_ context.Context, bucket, object, versionId st
}
}
err = p.ensureNotDeleteMarker(bucket, object, versionId)
if err != nil {
return err
}
if tags == nil {
err = p.meta.DeleteAttribute(bucket, object, tagHdr)
if errors.Is(err, fs.ErrNotExist) || errors.Is(err, syscall.ENOTDIR) {
@@ -5237,6 +5277,11 @@ func (p *Posix) PutObjectLegalHold(_ context.Context, bucket, object, versionId
}
}
err = p.ensureNotDeleteMarker(bucket, object, versionId)
if err != nil {
return err
}
err = p.meta.StoreAttribute(nil, bucket, object, objectLegalHoldKey, statusData)
if errors.Is(err, fs.ErrNotExist) || errors.Is(err, syscall.ENOTDIR) {
if versionId != "" {
@@ -5283,6 +5328,11 @@ func (p *Posix) GetObjectLegalHold(_ context.Context, bucket, object, versionId
}
}
err = p.ensureNotDeleteMarker(bucket, object, versionId)
if err != nil {
return nil, err
}
data, err := p.meta.RetrieveAttribute(nil, bucket, object, objectLegalHoldKey)
if errors.Is(err, fs.ErrNotExist) || errors.Is(err, syscall.ENOTDIR) {
if versionId != "" {
@@ -5334,6 +5384,11 @@ func (p *Posix) PutObjectRetention(_ context.Context, bucket, object, versionId
}
}
err = p.ensureNotDeleteMarker(bucket, object, versionId)
if err != nil {
return err
}
err = p.meta.StoreAttribute(nil, bucket, object, objectRetentionKey, retention)
if err != nil {
return fmt.Errorf("set object lock config: %w", err)
@@ -5374,6 +5429,11 @@ func (p *Posix) GetObjectRetention(_ context.Context, bucket, object, versionId
}
}
err = p.ensureNotDeleteMarker(bucket, object, versionId)
if err != nil {
return nil, err
}
data, err := p.meta.RetrieveAttribute(nil, bucket, object, objectRetentionKey)
if errors.Is(err, fs.ErrNotExist) || errors.Is(err, syscall.ENOTDIR) {
if versionId != "" {

View File

@@ -241,7 +241,6 @@ func adminCommand() *cli.Command {
Usage: "admin access key id",
EnvVars: []string{"ADMIN_ACCESS_KEY_ID", "ADMIN_ACCESS_KEY"},
Aliases: []string{"a"},
Required: true,
Destination: &adminAccess,
},
&cli.StringFlag{
@@ -249,7 +248,6 @@ func adminCommand() *cli.Command {
Usage: "admin secret access key",
EnvVars: []string{"ADMIN_SECRET_ACCESS_KEY", "ADMIN_SECRET_KEY"},
Aliases: []string{"s"},
Required: true,
Destination: &adminSecret,
},
&cli.StringFlag{
@@ -279,6 +277,32 @@ func adminCommand() *cli.Command {
}
}
// getAdminCreds returns the effective admin access key ID and secret key.
// If admin-specific credentials are not provided, it falls back to the
// root user credentials. Both resulting values must be non-empty;
// otherwise, an error is returned.
func getAdminCreds() (string, string, error) {
access := adminAccess
secret := adminSecret
// Fallbacks to root user credentials
if access == "" {
access = rootUserAccess
}
if secret == "" {
secret = rootUserSecret
}
if access == "" {
return "", "", errors.New("subcommand admin access key id is not set")
}
if secret == "" {
return "", "", errors.New("subcommand admin secret access key is not set")
}
return access, secret, nil
}
func initHTTPClient() *http.Client {
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: allowInsecure},
@@ -287,6 +311,10 @@ func initHTTPClient() *http.Client {
}
func createUser(ctx *cli.Context) error {
adminAccess, adminSecret, err := getAdminCreds()
if err != nil {
return err
}
access, secret, role := ctx.String("access"), ctx.String("secret"), ctx.String("role")
userID, groupID, projectID := ctx.Int("user-id"), ctx.Int("group-id"), ctx.Int("project-id")
if access == "" || secret == "" {
@@ -348,6 +376,10 @@ func createUser(ctx *cli.Context) error {
}
func deleteUser(ctx *cli.Context) error {
adminAccess, adminSecret, err := getAdminCreds()
if err != nil {
return err
}
access := ctx.String("access")
if access == "" {
return fmt.Errorf("invalid input parameter for the user access key")
@@ -391,6 +423,11 @@ func deleteUser(ctx *cli.Context) error {
}
func updateUser(ctx *cli.Context) error {
adminAccess, adminSecret, err := getAdminCreds()
if err != nil {
return err
}
access, secret, userId, groupId, projectID, role :=
ctx.String("access"),
ctx.String("secret"),
@@ -462,6 +499,11 @@ func updateUser(ctx *cli.Context) error {
}
func listUsers(ctx *cli.Context) error {
adminAccess, adminSecret, err := getAdminCreds()
if err != nil {
return err
}
req, err := http.NewRequest(http.MethodPatch, fmt.Sprintf("%v/list-users", adminEndpoint), nil)
if err != nil {
return fmt.Errorf("failed to send the request: %w", err)
@@ -680,6 +722,11 @@ func parseTag(input string) (types.Tag, error) {
}
func createBucket(ctx *cli.Context) error {
adminAccess, adminSecret, err := getAdminCreds()
if err != nil {
return err
}
bucket, owner := ctx.String("bucket"), ctx.String("owner")
payload, err := parseCreateBucketPayload(ctx.String("create-bucket-configuration"))
@@ -768,6 +815,11 @@ func printAcctTable(accs []auth.Account) {
}
func changeBucketOwner(ctx *cli.Context) error {
adminAccess, adminSecret, err := getAdminCreds()
if err != nil {
return err
}
bucket, owner := ctx.String("bucket"), ctx.String("owner")
req, err := http.NewRequest(http.MethodPatch, fmt.Sprintf("%v/change-bucket-owner/?bucket=%v&owner=%v", adminEndpoint, bucket, owner), nil)
if err != nil {
@@ -819,6 +871,11 @@ func printBuckets(buckets []s3response.Bucket) {
}
func listBuckets(ctx *cli.Context) error {
adminAccess, adminSecret, err := getAdminCreds()
if err != nil {
return err
}
req, err := http.NewRequest(http.MethodPatch, fmt.Sprintf("%v/list-buckets", adminEndpoint), nil)
if err != nil {
return fmt.Errorf("failed to send the request: %w", err)

View File

@@ -16,13 +16,13 @@ package main
import (
"context"
"crypto/tls"
"fmt"
"log"
"net"
"net/http"
_ "net/http/pprof"
"os"
"strconv"
"strings"
"github.com/urfave/cli/v2"
@@ -35,6 +35,7 @@ import (
"github.com/versity/versitygw/s3api/utils"
"github.com/versity/versitygw/s3event"
"github.com/versity/versitygw/s3log"
"github.com/versity/versitygw/webui"
)
var (
@@ -90,6 +91,9 @@ var (
ipaUser, ipaPassword string
ipaInsecure bool
iamDebug bool
webuiAddr string
webuiCertFile, webuiKeyFile string
webuiNoTLS bool
)
var (
@@ -167,6 +171,30 @@ func initFlags() []cli.Flag {
Destination: &port,
Aliases: []string{"p"},
},
&cli.StringFlag{
Name: "webui",
Usage: "enable WebUI server on the specified listen address (e.g. ':7071', '127.0.0.1:7071', 'localhost:7071'; disabled when omitted)",
EnvVars: []string{"VGW_WEBUI_PORT"},
Destination: &webuiAddr,
},
&cli.StringFlag{
Name: "webui-cert",
Usage: "TLS cert file for WebUI (defaults to --cert value when WebUI is enabled)",
EnvVars: []string{"VGW_WEBUI_CERT"},
Destination: &webuiCertFile,
},
&cli.StringFlag{
Name: "webui-key",
Usage: "TLS key file for WebUI (defaults to --key value when WebUI is enabled)",
EnvVars: []string{"VGW_WEBUI_KEY"},
Destination: &webuiKeyFile,
},
&cli.BoolFlag{
Name: "webui-no-tls",
Usage: "disable TLS for WebUI even if TLS is configured for the gateway",
EnvVars: []string{"VGW_WEBUI_NO_TLS"},
Destination: &webuiNoTLS,
},
&cli.StringFlag{
Name: "access",
Usage: "root user access key",
@@ -645,6 +673,42 @@ func runGateway(ctx context.Context, be backend.Backend) error {
return fmt.Errorf("root user access and secret key must be provided")
}
webuiAddr = strings.TrimSpace(webuiAddr)
if webuiAddr != "" && isAllDigits(webuiAddr) {
webuiAddr = ":" + webuiAddr
}
// WebUI runs in a browser and typically talks to the gateway/admin APIs cross-origin
// (different port). If no bucket CORS configuration exists, those API responses need
// a default Access-Control-Allow-Origin to be usable from the WebUI.
if webuiAddr != "" && strings.TrimSpace(corsAllowOrigin) == "" {
// A single Access-Control-Allow-Origin value cannot cover multiple specific
// origins. Default to '*' for usability and print a warning so operators can
// lock it down explicitly.
corsAllowOrigin = "*"
webuiScheme := "http"
if !webuiNoTLS && (strings.TrimSpace(webuiCertFile) != "" || strings.TrimSpace(certFile) != "") {
webuiScheme = "https"
}
// Suggest a more secure explicit origin based on the actual WebUI listening interfaces.
// (Browsers require an exact origin match; this is typically one chosen hostname/IP.)
var suggestion string
ips, ipsErr := getMatchingIPs(webuiAddr)
_, webPrt, prtErr := net.SplitHostPort(webuiAddr)
if ipsErr == nil && prtErr == nil && len(ips) > 0 {
origins := make([]string, 0, len(ips))
for _, ip := range ips {
origins = append(origins, fmt.Sprintf("%s://%s:%s", webuiScheme, ip, webPrt))
}
suggestion = fmt.Sprintf("consider setting it to one of: %s (or your public hostname)", strings.Join(origins, ", "))
} else {
suggestion = fmt.Sprintf("consider setting it to %s://<host>:<port>", webuiScheme)
}
fmt.Fprintf(os.Stderr, "WARNING: --webui is enabled but --cors-allow-origin is not set; defaulting to '*'; %s\n", suggestion)
}
utils.SetBucketNameValidationStrict(!disableStrictBucketNames)
if pprof != "" {
@@ -668,11 +732,12 @@ func runGateway(ctx context.Context, be backend.Backend) error {
return fmt.Errorf("TLS cert specified without key file")
}
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
cs := utils.NewCertStorage()
err := cs.SetCertificate(certFile, keyFile)
if err != nil {
return fmt.Errorf("tls: load certs: %v", err)
}
opts = append(opts, s3api.WithTLS(cert))
opts = append(opts, s3api.WithTLS(cs))
}
if admPort == "" {
opts = append(opts, s3api.WithAdminServer())
@@ -808,11 +873,12 @@ func runGateway(ctx context.Context, be backend.Backend) error {
return fmt.Errorf("TLS cert specified without key file")
}
cert, err := tls.LoadX509KeyPair(admCertFile, admKeyFile)
cs := utils.NewCertStorage()
err = cs.SetCertificate(admCertFile, admKeyFile)
if err != nil {
return fmt.Errorf("tls: load certs: %v", err)
}
opts = append(opts, s3api.WithAdminSrvTLS(cert))
opts = append(opts, s3api.WithAdminSrvTLS(cs))
}
if quiet {
opts = append(opts, s3api.WithAdminQuiet())
@@ -824,15 +890,102 @@ func runGateway(ctx context.Context, be backend.Backend) error {
admSrv = s3api.NewAdminServer(be, middlewares.RootUserConfig{Access: rootUserAccess, Secret: rootUserSecret}, admPort, region, iam, loggers.AdminLogger, srv.Router.Ctrl, opts...)
}
if !quiet {
printBanner(port, admPort, certFile != "", admCertFile != "")
var webSrv *webui.Server
webuiSSLEnabled := false
webTLSCert := ""
webTLSKey := ""
if webuiAddr != "" {
_, webPrt, err := net.SplitHostPort(webuiAddr)
if err != nil {
return fmt.Errorf("webui listen address must be in the form ':port' or 'host:port': %w", err)
}
webPortNum, err := strconv.Atoi(webPrt)
if err != nil {
return fmt.Errorf("webui port must be a number: %w", err)
}
if webPortNum < 0 || webPortNum > 65535 {
return fmt.Errorf("webui port must be between 0 and 65535")
}
var webOpts []webui.Option
if !webuiNoTLS {
// WebUI can either use explicitly provided TLS files or reuse the
// gateway's TLS files by default.
webTLSCert = webuiCertFile
webTLSKey = webuiKeyFile
if webTLSCert == "" && webTLSKey == "" {
webTLSCert = certFile
webTLSKey = keyFile
}
if webTLSCert != "" || webTLSKey != "" {
if webTLSCert == "" {
return fmt.Errorf("webui TLS key specified without cert file")
}
if webTLSKey == "" {
return fmt.Errorf("webui TLS cert specified without key file")
}
webuiSSLEnabled = true
cs := utils.NewCertStorage()
err := cs.SetCertificate(webTLSCert, webTLSKey)
if err != nil {
return fmt.Errorf("tls: load certs: %v", err)
}
webOpts = append(webOpts, webui.WithTLS(cs))
}
}
sslEnabled := certFile != ""
admSSLEnabled := sslEnabled
if admPort != "" {
admSSLEnabled = admCertFile != ""
}
gateways, err := buildServiceURLs(port, sslEnabled)
if err != nil {
return fmt.Errorf("webui: build gateway URLs: %w", err)
}
adminGateways := gateways
if admPort != "" {
adminGateways, err = buildServiceURLs(admPort, admSSLEnabled)
if err != nil {
return fmt.Errorf("webui: build admin gateway URLs: %w", err)
}
}
if quiet {
webOpts = append(webOpts, webui.WithQuiet())
}
webSrv = webui.NewServer(&webui.ServerConfig{
ListenAddr: webuiAddr,
Gateways: gateways,
AdminGateways: adminGateways,
Region: region,
}, webOpts...)
}
c := make(chan error, 2)
if !quiet {
printBanner(port, admPort, certFile != "", admCertFile != "", webuiAddr, webuiSSLEnabled)
}
servers := 1
if admPort != "" {
servers++
}
if webSrv != nil {
servers++
}
c := make(chan error, servers)
go func() { c <- srv.Serve() }()
if admPort != "" {
go func() { c <- admSrv.Serve() }()
}
if webSrv != nil {
go func() { c <- webSrv.Serve() }()
}
// for/select blocks until shutdown
Loop:
@@ -857,6 +1010,30 @@ Loop:
break Loop
}
}
if certFile != "" && keyFile != "" {
err = srv.CertStorage.SetCertificate(certFile, keyFile)
if err != nil {
debuglogger.InernalError(fmt.Errorf("srv cert reload failed: %w", err))
} else {
fmt.Printf("srv cert reloaded (cert: %s, key: %s)\n", certFile, keyFile)
}
}
if admPort != "" && admCertFile != "" && admKeyFile != "" {
err = admSrv.CertStorage.SetCertificate(admCertFile, admKeyFile)
if err != nil {
debuglogger.InernalError(fmt.Errorf("admSrv cert reload failed: %w", err))
} else {
fmt.Printf("admSrv cert reloaded (cert: %s, key: %s)\n", admCertFile, admKeyFile)
}
}
if webSrv != nil && webTLSCert != "" && webTLSKey != "" {
err := webSrv.CertStorage.SetCertificate(webTLSCert, webTLSKey)
if err != nil {
debuglogger.InernalError(fmt.Errorf("webSrv cert reload failed: %w", err))
} else {
fmt.Printf("webSrv cert reloaded (cert: %s, key: %s)\n", webTLSCert, webTLSKey)
}
}
}
}
saveErr := err
@@ -875,6 +1052,13 @@ Loop:
}
}
if webSrv != nil {
err := webSrv.Shutdown()
if err != nil {
fmt.Fprintf(os.Stderr, "shutdown webui server: %v\n", err)
}
}
be.Shutdown()
err = iam.Shutdown()
@@ -909,7 +1093,7 @@ Loop:
return saveErr
}
func printBanner(port, admPort string, ssl, admSsl bool) {
func printBanner(port, admPort string, ssl, admSsl bool, webuiAddr string, webuiSsl bool) {
interfaces, err := getMatchingIPs(port)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to match local IP addresses: %v\n", err)
@@ -991,6 +1175,30 @@ func printBanner(port, admPort string, ssl, admSsl bool) {
}
}
if strings.TrimSpace(webuiAddr) != "" {
webInterfaces, err := getMatchingIPs(webuiAddr)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to match webui port local IP addresses: %v\n", err)
return
}
_, webPrt, err := net.SplitHostPort(webuiAddr)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to parse webui port: %v\n", err)
return
}
lines = append(lines,
centerText(""),
leftText("WebUI listening on:"),
)
for _, ip := range webInterfaces {
url := fmt.Sprintf("http://%s:%s", ip, webPrt)
if webuiSsl {
url = fmt.Sprintf("https://%s:%s", ip, webPrt)
}
lines = append(lines, leftText(" "+url))
}
}
// Print the top border
fmt.Println("┌" + strings.Repeat("─", columnWidth-2) + "┐")
@@ -1066,6 +1274,42 @@ func getMatchingIPs(spec string) ([]string, error) {
return result, nil
}
func buildServiceURLs(spec string, ssl bool) ([]string, error) {
interfaces, err := getMatchingIPs(spec)
if err != nil {
return nil, err
}
_, prt, err := net.SplitHostPort(spec)
if err != nil {
return nil, fmt.Errorf("parse address/port: %w", err)
}
if len(interfaces) == 0 {
interfaces = []string{"localhost"}
}
scheme := "http"
if ssl {
scheme = "https"
}
urls := make([]string, 0, len(interfaces))
for _, ip := range interfaces {
urls = append(urls, fmt.Sprintf("%s://%s:%s", scheme, ip, prt))
}
return urls, nil
}
func isAllDigits(s string) bool {
if s == "" {
return false
}
for _, r := range s {
if r < '0' || r > '9' {
return false
}
}
return true
}
const columnWidth = 70
func centerText(text string) string {

View File

@@ -201,6 +201,42 @@ ROOT_SECRET_ACCESS_KEY=
# to generate a default rules file "event_config.json" in the current directory.
#VGW_EVENT_FILTER=
###########
# Web GUI #
###########
# The VGW_WEBUI_PORT option enables the Web GUI server on the specified
# listening address. The Web GUI provides a browser-based interface for managing
# users, buckets and objects. The format can be either ':port' to listen on all
# interfaces (e.g., ':7071') or 'host:port' to listen on a specific interface
# (e.g., '127.0.0.1:7071' or 'localhost:7071'). When omitted, the Web GUI is
# disabled.
#VGW_WEBUI_PORT=
# The VGW_WEBUI_CERT and VGW_WEBUI_KEY options specify the TLS certificate and
# private key for the Web GUI server. If these are not specified and TLS is
# configured for the gateway (VGW_CERT and VGW_KEY), the Web GUI will use the
# same certificates as the gateway. If neither are specified, the Web GUI will
# run without TLS (HTTP only). These options allow the Web GUI to use different
# certificates than the main S3 gateway.
#VGW_WEBUI_CERT=
#VGW_WEBUI_KEY=
# The VGW_WEBUI_NO_TLS option disables TLS for the Web GUI even if TLS
# certificates are configured for the gateway. Set to true to force the Web GUI
# to use HTTP instead of HTTPS. This can be useful when running the Web GUI
# behind a reverse proxy that handles TLS termination.
#VGW_WEBUI_NO_TLS=false
# The VGW_CORS_ALLOW_ORIGIN option sets the default CORS (Cross-Origin Resource
# Sharing) Access-Control-Allow-Origin header value. This header is applied to
# responses when no bucket-specific CORS configuration exists, and for all admin
# API responses. When the Web GUI is enabled and this option is not set, it
# defaults to '*' (allow all origins) for usability. For production environments,
# it is recommended to set this to a specific origin (e.g.,
# 'https://webui.example.com') to improve security.
#VGW_CORS_ALLOW_ORIGIN=
#######################
# Debug / Diagnostics #
#######################

12
go.mod
View File

@@ -5,9 +5,9 @@ go 1.24.0
toolchain go1.24.1
require (
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.3
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4
github.com/DataDog/datadog-go/v5 v5.8.2
github.com/aws/aws-sdk-go-v2 v1.41.1
github.com/aws/aws-sdk-go-v2/service/s3 v1.95.1
@@ -23,7 +23,7 @@ require (
github.com/oklog/ulid/v2 v2.1.1
github.com/pkg/xattr v0.4.12
github.com/rabbitmq/amqp091-go v1.10.0
github.com/segmentio/kafka-go v0.4.49
github.com/segmentio/kafka-go v0.4.50
github.com/smira/go-statsd v1.3.4
github.com/stretchr/testify v1.11.1
github.com/urfave/cli/v2 v2.27.7
@@ -45,7 +45,7 @@ require (
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect
github.com/clipperhouse/stringish v0.1.1 // indirect
github.com/clipperhouse/uax29/v2 v2.3.0 // indirect
github.com/clipperhouse/uax29/v2 v2.3.1 // indirect
github.com/go-asn1-ber/asn1-ber v1.5.8-0.20250403174932-29230038a667 // indirect
github.com/golang-jwt/jwt/v5 v5.3.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
@@ -57,7 +57,7 @@ require (
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/nats-io/nkeys v0.4.12 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
github.com/pierrec/lz4/v4 v4.1.23 // indirect
github.com/pierrec/lz4/v4 v4.1.25 // indirect
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/ryanuber/go-glob v1.0.0 // indirect
@@ -82,7 +82,7 @@ require (
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 // indirect
github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect
github.com/klauspost/compress v1.18.2 // indirect
github.com/klauspost/compress v1.18.3 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.19 // indirect

24
go.sum
View File

@@ -1,5 +1,5 @@
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0 h1:JXg2dwJUmPB9JmtVmdEB16APJ7jurfbY5jnfXpJoRMc=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.20.0/go.mod h1:YD5h/ldMsG0XiIw7PdyNhLxaM317eFh5yNLccNfGdyw=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0/go.mod h1:t76Ruy8AHvUAC8GfMWJMa0ElSbuIcO03NLpynfbgsPA=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0=
github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY=
@@ -8,8 +8,8 @@ github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDo
github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1 h1:/Zt+cDPnpC3OVDm/JKLOs7M2DKmLRIIp3XIx9pHHiig=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.8.1/go.mod h1:Ng3urmn6dYe8gnbCMoHHVl5APYz2txho3koEkV2o2HA=
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.3 h1:ZJJNFaQ86GVKQ9ehwqyAFE6pIfyicpuJ8IkVaPBc6/4=
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.3/go.mod h1:URuDvhmATVKqHBH9/0nOiNKk0+YcwfQ3WkK5PqHKxc8=
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4 h1:jWQK1GI+LeGGUKBADtcH2rRqPxYB1Ljwms5gFA2LqrM=
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4/go.mod h1:8mwH4klAm9DUgR2EEHyEEAQlRDvLPyg5fQry3y+cDew=
github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A=
github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM=
@@ -67,8 +67,8 @@ github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/clipperhouse/uax29/v2 v2.3.1 h1:RjM8gnVbFbgI67SBekIC7ihFpyXwRPYWXn9BZActHbw=
github.com/clipperhouse/uax29/v2 v2.3.1/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo=
github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -117,8 +117,8 @@ github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZ
github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc=
github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU=
github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k=
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw=
github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
@@ -146,8 +146,8 @@ github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OS
github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
github.com/pierrec/lz4/v4 v4.1.23 h1:oJE7T90aYBGtFNrI8+KbETnPymobAhzRrR8Mu8n1yfU=
github.com/pierrec/lz4/v4 v4.1.23/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0=
github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ=
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@@ -163,8 +163,8 @@ github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk=
github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc=
github.com/segmentio/kafka-go v0.4.49 h1:GJiNX1d/g+kG6ljyJEoi9++PUMdXGAxb7JGPiDCuNmk=
github.com/segmentio/kafka-go v0.4.49/go.mod h1:Y1gn60kzLEEaW28YshXyk2+VCUKbJ3Qr6DrnT3i4+9E=
github.com/segmentio/kafka-go v0.4.50 h1:mcyC3tT5WeyWzrFbd6O374t+hmcu1NKt2Pu1L3QaXmc=
github.com/segmentio/kafka-go v0.4.50/go.mod h1:Y1gn60kzLEEaW28YshXyk2+VCUKbJ3Qr6DrnT3i4+9E=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/smira/go-statsd v1.3.4 h1:kBYWcLSGT+qC6JVbvfz48kX7mQys32fjDOPrfmsSx2c=
github.com/smira/go-statsd v1.3.4/go.mod h1:RjdsESPgDODtg1VpVVf9MJrEW2Hw0wtRNbmB1CAhu6A=

View File

@@ -15,15 +15,15 @@
package s3api
import (
"crypto/tls"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/logger"
"github.com/gofiber/fiber/v2/middleware/recover"
"github.com/versity/versitygw/auth"
"github.com/versity/versitygw/backend"
"github.com/versity/versitygw/debuglogger"
"github.com/versity/versitygw/s3api/controllers"
"github.com/versity/versitygw/s3api/middlewares"
"github.com/versity/versitygw/s3api/utils"
"github.com/versity/versitygw/s3log"
)
@@ -32,7 +32,7 @@ type S3AdminServer struct {
backend backend.Backend
router *S3AdminRouter
port string
cert *tls.Certificate
CertStorage *utils.CertStorage
quiet bool
debug bool
corsAllowOrigin string
@@ -70,11 +70,15 @@ func NewAdminServer(be backend.Backend, root middlewares.RootUserConfig, port, r
// Logging middlewares
if !server.quiet {
app.Use(logger.New(logger.Config{
Format: "${time} | ${status} | ${latency} | ${ip} | ${method} | ${path} | ${error} | ${queryParams}\n",
Format: "${time} | adm | ${status} | ${latency} | ${ip} | ${method} | ${path} | ${error} | ${queryParams}\n",
}))
}
app.Use(controllers.WrapMiddleware(middlewares.DecodeURL, l, nil))
app.Use(middlewares.DebugLogger())
// initialize the debug logger in debug mode
if debuglogger.IsDebugEnabled() {
app.Use(middlewares.DebugLogger())
}
server.router.Init(app, be, iam, l, root, region, server.debug, server.corsAllowOrigin)
@@ -83,8 +87,8 @@ func NewAdminServer(be backend.Backend, root middlewares.RootUserConfig, port, r
type AdminOpt func(s *S3AdminServer)
func WithAdminSrvTLS(cert tls.Certificate) AdminOpt {
return func(s *S3AdminServer) { s.cert = &cert }
func WithAdminSrvTLS(cs *utils.CertStorage) AdminOpt {
return func(s *S3AdminServer) { s.CertStorage = cs }
}
// WithQuiet silences default logging output
@@ -104,8 +108,13 @@ func WithAdminCORSAllowOrigin(origin string) AdminOpt {
}
func (sa *S3AdminServer) Serve() (err error) {
if sa.cert != nil {
return sa.app.ListenTLSWithCertificate(sa.port, *sa.cert)
if sa.CertStorage != nil {
ln, err := utils.NewTLSListener(sa.app.Config().Network, sa.port, sa.CertStorage.GetCertificate)
if err != nil {
return err
}
return sa.app.Listener(ln)
}
return sa.app.Listen(sa.port)
}

View File

@@ -492,6 +492,12 @@ func (c S3ApiController) CreateBucket(ctx *fiber.Ctx) (*Response, error) {
ctx.Get("X-Amz-Object-Ownership", string(types.ObjectOwnershipBucketOwnerEnforced)),
)
if c.readonly {
return &Response{
MetaOpts: &MetaOptions{},
}, s3err.GetAPIError(s3err.ErrAccessDenied)
}
creator := utils.ContextKeyAccount.Get(ctx).(auth.Account)
if !utils.ContextKeyBucketOwner.IsSet(ctx) {
utils.ContextKeyBucketOwner.Set(ctx, creator)

View File

@@ -15,7 +15,6 @@
package s3api
import (
"crypto/tls"
"errors"
"net/http"
"strings"
@@ -45,7 +44,7 @@ type S3ApiServer struct {
app *fiber.App
backend backend.Backend
port string
cert *tls.Certificate
CertStorage *utils.CertStorage
quiet bool
readonly bool
keepAlive bool
@@ -97,7 +96,7 @@ func New(
// Logging middlewares
if !server.quiet {
app.Use(logger.New(logger.Config{
Format: "${time} | ${status} | ${latency} | ${ip} | ${method} | ${path} | ${error} | ${queryParams}\n",
Format: "${time} | vgw | ${status} | ${latency} | ${ip} | ${method} | ${path} | ${error} | ${queryParams}\n",
}))
}
// Set up health endpoint if specified
@@ -133,8 +132,8 @@ func New(
type Option func(*S3ApiServer)
// WithTLS sets TLS Credentials
func WithTLS(cert tls.Certificate) Option {
return func(s *S3ApiServer) { s.cert = &cert }
func WithTLS(cs *utils.CertStorage) Option {
return func(s *S3ApiServer) { s.CertStorage = cs }
}
// WithAdminServer runs admin endpoints with the gateway in the same network
@@ -173,8 +172,13 @@ func WithCORSAllowOrigin(origin string) Option {
}
func (sa *S3ApiServer) Serve() (err error) {
if sa.cert != nil {
return sa.app.ListenTLSWithCertificate(sa.port, *sa.cert)
if sa.CertStorage != nil {
ln, err := utils.NewTLSListener(sa.app.Config().Network, sa.port, sa.CertStorage.GetCertificate)
if err != nil {
return err
}
return sa.app.Listener(ln)
}
return sa.app.Listen(sa.port)
}

View File

@@ -15,11 +15,11 @@
package s3api
import (
"crypto/tls"
"testing"
"github.com/gofiber/fiber/v2"
"github.com/versity/versitygw/backend"
"github.com/versity/versitygw/s3api/utils"
)
func TestS3ApiServer_Serve(t *testing.T) {
@@ -42,11 +42,11 @@ func TestS3ApiServer_Serve(t *testing.T) {
name: "Serve-invalid-address-with-certificate",
wantErr: true,
sa: &S3ApiServer{
app: fiber.New(),
backend: backend.BackendUnsupported{},
port: "Invalid address",
Router: &S3ApiRouter{},
cert: &tls.Certificate{},
app: fiber.New(),
backend: backend.BackendUnsupported{},
port: "Invalid address",
Router: &S3ApiRouter{},
CertStorage: &utils.CertStorage{},
},
},
}

View File

@@ -16,11 +16,13 @@ package utils
import (
"bytes"
"crypto/tls"
"encoding/base64"
"encoding/xml"
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"regexp"
@@ -910,3 +912,39 @@ func GenerateObjectLocation(ctx *fiber.Ctx, virtualDomain, bucket, object string
obj,
)
}
type CertStorage struct {
cert atomic.Pointer[tls.Certificate]
}
func NewCertStorage() *CertStorage {
return &CertStorage{}
}
func (cs *CertStorage) GetCertificate(_ *tls.ClientHelloInfo) (*tls.Certificate, error) {
return cs.cert.Load(), nil
}
func (cs *CertStorage) SetCertificate(certFile string, keyFile string) error {
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err != nil {
return fmt.Errorf("unable to set certificate: %w", err)
}
cs.cert.Store(&cert)
return nil
}
func NewTLSListener(network string, address string, getCertificateFunc func(*tls.ClientHelloInfo) (*tls.Certificate, error)) (net.Listener, error) {
config := &tls.Config{
MinVersion: tls.VersionTLS12,
GetCertificate: getCertificateFunc,
}
ln, err := net.Listen(network, address)
if err != nil {
return nil, err
}
return tls.NewListener(ln, config), nil
}

View File

@@ -25,6 +25,7 @@ RUN apt-get update && \
python3-pip \
python3-venv \
xxd \
uuid-runtime \
ca-certificates && \
update-ca-certificates && \
rm -rf /var/lib/apt/lists/*

View File

@@ -179,7 +179,7 @@ A single instance can be run with `docker-compose -f docker-compose-bats.yml up
**CREATE_STATIC_USERS_IF_NONEXISTENT**: setup_user_v2, if **AUTOCREATE_USERS** is set to **false**, generate non-existing users if they don't exist, but don't delete them, as with user autogeneration
**DIRECT_POST_COMMAND_DELAY**: in direct mode, time to wait before sending new commands to try to prevent propagation delay issues
**DIRECT_POST_COMMAND_DELAY**: in v1 direct mode, time to wait before sending new commands to try to prevent propagation delay issues
**SKIP_ACL_TESTING**: avoid ACL tests for systems which do not use ACLs
@@ -187,6 +187,8 @@ A single instance can be run with `docker-compose -f docker-compose-bats.yml up
**SKIP_USERS_TESTS**: skip versitygw-specific users tests, set to **false** to test against other S3 gateways
**MAX_OPENSSL_COMMAND_LOG_BYTES**: number of OpenSSL command bytes to display in command log, can prevent the display of too many chars in the case of large payload commands, -1 means display whole command
## REST Scripts
REST scripts are included for calls to S3's REST API in the `./tests/rest_scripts/` folder. To call a script, the following parameters are needed:

View File

@@ -15,6 +15,7 @@
# under the License.
source ./tests/logger.sh
source ./tests/report.sh
send_command() {
if [ $# -eq 0 ]; then
@@ -27,6 +28,7 @@ send_command() {
fi
# shellcheck disable=SC2154
echo "${masked_args[*]}" >> "$COMMAND_LOG"
record_command_v2 "${masked_args[*]}"
fi
local command_result=0
"$@" || command_result=$?

View File

@@ -89,13 +89,9 @@ get_bucket_location_rest() {
if ! check_param_count_v2 "bucket, callback" 2 $#; then
return 1
fi
if ! send_rest_go_command "200" "-bucketName" "$1" "-method" "GET" "-query" "location=" "-awsRegion" "$AWS_REGION"; then
if ! send_rest_go_command_callback "200" "$2" "-bucketName" "$1" "-method" "GET" "-query" "location=" "-awsRegion" "$AWS_REGION"; then
log 2 "error sending rest go command"
return 1
fi
if [ "$2" != "" ] && ! "$2" "$TEST_FILE_FOLDER/result.txt"; then
log 2 "callback error"
return 1
fi
return 0
}

View File

@@ -48,4 +48,15 @@ list_object_versions_rest() {
fi
versions=$(cat "$TEST_FILE_FOLDER/object_versions.txt")
return 0
}
list_object_versions_rest_v2() {
if ! check_param_count_v2 "bucket name, callback" 2 $#; then
return 1
fi
if ! send_rest_go_command_callback "200" "$2" "-bucketName" "$1" "-query" "versions="; then
log 2 "error sending REST list object versions command"
return 1
fi
return 0
}

View File

@@ -14,9 +14,10 @@
# specific language governing permissions and limitations
# under the License.
source ./tests/util/util_rest.sh
put_object_tagging() {
if [ $# -ne 5 ]; then
log 2 "'put-object-tagging' command missing command type, bucket, object name, file, key, and/or value"
if ! check_param_count_v2 "command type, bucket, object key, tag key, value" 5 $#; then
return 1
fi
local error

View File

@@ -150,3 +150,30 @@ attempt_to_delete_version_after_retention_policy() {
return 1
fi
}
delete_delete_marker() {
if ! check_param_count_v2 "data file" 1 $#; then
return 1
fi
if ! parse_version_or_delete_marker_id "$1" "DeleteMarker" "true"; then
echo "error parsing delete marker ID"
return 1
fi
log 5 "version or marker ID: $version_or_marker_id"
if ! delete_object_version_rest "$bucket_name" "$object_key" "$version_or_marker_id"; then
log 2 "error deleting delete marker"
return 1
fi
}
delete_delete_marker_without_object_lock() {
if ! check_param_count_v2 "bucket name, key" 2 $#; then
return 1
fi
bucket_name="$1"
object_key="$2"
if ! list_object_versions_rest_v2 "$1" "delete_delete_marker"; then
return 1
fi
return 0
}

View File

@@ -151,3 +151,11 @@ chunked_upload_trailer_success() {
fi
return 0
}
get_file_name() {
if ! uuid=$(uuidgen 2>&1); then
log 2 "error getting UUID: $uuid"
return 1
fi
echo "test-file-${uuid}"
}

View File

@@ -51,6 +51,7 @@ parse_bucket_location() {
if ! check_param_count_v2 "file" 1 $#; then
return 1
fi
log 5 "file: $1"
log 5 "data: $(cat "$1")"
if ! location_constraint=$(get_element_text "$1" "LocationConstraint" 2>&1); then
log 2 "error getting location constraint: $location_constraint"

View File

@@ -14,6 +14,8 @@
# specific language governing permissions and limitations
# under the License.
source ./tests/util/util_list_parts.sh
upload_and_check_attributes() {
if ! check_param_count_v2 "bucket, test file, file size" 3 $#; then
return 1

View File

@@ -14,27 +14,39 @@
# specific language governing permissions and limitations
# under the License.
parse_version_id() {
if ! check_param_count_v2 "data file, IsLatest val" 2 $#; then
parse_version_or_delete_marker_id() {
if ! check_param_count_v2 "data file, 'Version' or 'DeleteMarker', IsLatest val" 3 $#; then
return 1
fi
log 5 "data: $(cat "$1")"
version_string="//*[local-name()=\"Version\"][*[local-name()=\"IsLatest\" and text()=\"$2\"]]"
version_string="//*[local-name()=\"$2\"][*[local-name()=\"IsLatest\" and text()=\"$3\"]]"
log 5 "match string: $version_string"
if ! get_xml_data "$1" "$1.xml"; then
log 2 "error getting XML data"
return 1
fi
if ! version=$(xmllint --xpath "$version_string" "$1.xml" 2>&1); then
log 2 "error getting result: $version"
if ! version_or_marker=$(xmllint --xpath "$version_string" "$1.xml" 2>&1); then
log 2 "error getting result: $version_or_marker"
return 1
fi
log 5 "latest: $2, version: $version"
if ! version_id=$(xmllint --xpath "//*[local-name()=\"VersionId\"]/text()" <(echo "$version" | head -n 1) 2>&1); then
log 2 "error getting version ID: $version_id"
log 5 "latest: $3, version or marker: $version_or_marker"
if ! version_or_marker_id=$(xmllint --xpath "//*[local-name()=\"VersionId\"]/text()" <(echo "$version_or_marker" | head -n 1) 2>&1); then
log 2 "error getting version ID: $version_or_marker_id"
return 1
fi
log 5 "version ID: $version_id"
log 5 "version or marker ID: $version_or_marker_id"
return 0
}
parse_version_id() {
if ! check_param_count_v2 "data file, IsLatest val" 2 $#; then
return 1
fi
if ! parse_version_or_delete_marker_id "$1" "Version" "$2"; then
echo "error parsing version ID"
return 1
fi
version_id=$version_or_marker_id
return 0
}

View File

@@ -16,6 +16,32 @@
source ./tests/drivers/xml.sh
write_openssl_command_to_command_log() {
if ! check_param_count_v2 "command file" 1 $#; then
return 1
fi
max_chars=1024
if [ -n "$MAX_OPENSSL_COMMAND_LOG_BYTES" ]; then
max_chars="$MAX_OPENSSL_COMMAND_LOG_BYTES"
fi
if ! file_size=$(get_file_size "$1"); then
return 1
fi
if [ "$max_chars" -eq -1 ] || [ "$file_size" -lt "$max_chars" ]; then
log_data=$(perl -pe 's/\x00/<NULL>/g' "$1" | perl -pe 's/\r/<CR>/g')
else
log_data=$(head -c "$max_chars" "$1" | perl -pe 's/\x00/<NULL>/g' | perl -pe 's/\r/<CR>/g')
log_data+="<TRUNC>"
fi
while IFS=$' ' read -r -a line_words; do
if ! mask_arg_array "${line_words[@]}"; then
return 1
fi
# shellcheck disable=SC2154
echo "${masked_args[*]}" >> "$COMMAND_LOG"
done <<< "$log_data"
}
send_via_openssl() {
if ! check_param_count_v2 "command file" 1 $#; then
return 1
@@ -25,6 +51,9 @@ send_via_openssl() {
host+=":443"
fi
log 5 "connecting to $host"
if [ -n "$COMMAND_LOG" ]; then
write_openssl_command_to_command_log "$1"
fi
if ! result=$(openssl s_client -connect "$host" -ign_eof < "$1" 2>&1); then
log 2 "error sending openssl command: $result"
return 1

View File

@@ -0,0 +1,46 @@
#!/usr/bin/env bash
# Copyright 2026 Versity Software
# This file is licensed under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http:#www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
legal_hold_retention_setup() {
if ! check_param_count_v2 "bucket name, username, password, test file" 4 $#; then
return 1
fi
if ! setup_user "$2" "$3" "user"; then
log 2 "error setting up user '$2'"
return 1
fi
if ! create_test_file "$4"; then
log 2 "error creating test file '$4'"
return 1
fi
if ! setup_bucket_object_lock_enabled_v2 "$1"; then
log 2 "error creating bucket with object lock enabled"
return 1
fi
if ! change_bucket_owner "$AWS_ACCESS_KEY_ID" "$AWS_SECRET_ACCESS_KEY" "$1" "$2"; then
log 2 "error changing bucket owner"
return 1
fi
if ! put_object_with_user "s3api" "$TEST_FILE_FOLDER/$4" "$1" "$4" "$2" "$3"; then
log 2 "error putting object with user '$2'"
return 1
fi
return 0
}

View File

@@ -391,3 +391,42 @@ setup_bucket_versioning_file_two_versions() {
fi
return 0
}
attempt_put_object_with_specific_acl() {
if ! check_param_count_v2 "acl header" 1 $#; then
return 1
fi
if ! bucket_name=$(get_bucket_name "$BUCKET_ONE_NAME" 2>&1); then
log 2 "error getting bucket name: $bucket_name"
return 1
fi
test_file="test_file"
if ! setup_bucket_and_file_v2 "$bucket_name" "$test_file"; then
log 2 "error setting up bucket and file"
return 1
fi
if ! put_bucket_ownership_controls_rest "$bucket_name" "BucketOwnerPreferred"; then
log 2 "error changing bucket ownership controls"
return 1
fi
if [ "$DIRECT" == "true" ]; then
if ! allow_public_access "$bucket_name"; then
log 2 "error allowing public access"
return 1
fi
id="id=$ACL_AWS_CANONICAL_ID"
else
id="$AWS_ACCESS_KEY_ID"
fi
if ! send_rest_go_command_expect_error "501" "NotImplemented" "not implemented" "-method" "PUT" "-payloadFile" "$TEST_FILE_FOLDER/$test_file" "-bucketName" "$bucket_name" \
"-objectKey" "$test_file" "-signedParams" "$1:$id"; then
log 2 "error sending put object command with header '$1' and checking response"
return 1
fi
return 0
}

View File

@@ -147,7 +147,11 @@ send_rest_command_expect_success_callback() {
if ! check_param_count_v2 "env vars, script, response code, callback fn" 4 $#; then
return 1
fi
output_file="$TEST_FILE_FOLDER/output.txt"
if ! uuid=$(uuidgen 2>&1); then
log 2 "error generating uuid: $uuid"
return 1
fi
output_file="$TEST_FILE_FOLDER/output-${uuid}.txt"
local env_array=("env" "COMMAND_LOG=$COMMAND_LOG" "OUTPUT_FILE=$output_file")
if [ "$1" != "" ]; then
IFS=' ' read -r -a env_vars <<< "$1"
@@ -160,10 +164,10 @@ send_rest_command_expect_success_callback() {
fi
response_code="$(echo "$result" | tail -n 1)"
if [ "$response_code" != "$3" ]; then
log 2 "expected '$3', was '$response_code' ($(cat "$TEST_FILE_FOLDER/output.txt"))"
log 2 "expected '$3', was '$response_code' ($(cat "$output_file"))"
return 1
fi
if [ "$4" != "" ] && ! "$4" "$TEST_FILE_FOLDER/output.txt"; then
if [ "$4" != "" ] && ! "$4" "$output_file"; then
log 2 "callback error"
return 1
fi
@@ -265,8 +269,12 @@ send_rest_go_command_callback() {
log 2 "expected curl response '$1', was '$status_code'"
return 1
fi
echo -n "$result" > "$TEST_FILE_FOLDER/result.txt"
if [ "$2" != "" ] && ! "$2" "$TEST_FILE_FOLDER/result.txt"; then
if ! uuid=$(uuidgen 2>&1); then
log 2 "error generating uuid"
return 1
fi
echo -n "$result" > "$TEST_FILE_FOLDER/result-${uuid}.txt"
if [ "$2" != "" ] && ! "$2" "$TEST_FILE_FOLDER/result-${uuid}.txt"; then
log 2 "error in callback"
return 1
fi
@@ -317,3 +325,26 @@ send_rest_go_command_expect_error_with_arg_name_value() {
fi
return 0
}
check_specific_argument_name_and_value() {
if ! check_param_count_v2 "data file" 1 $#; then
return 1
fi
if ! check_error_parameter "$1" "$argument_name" "$argument_value"; then
log 2 "error checking 'ArgumentName' parameter"
return 1
fi
}
send_rest_go_command_expect_error_with_specific_arg_name_value() {
if ! check_param_count_gt "response code, error code, message, arg name, arg value, params" 5 $#; then
return 1
fi
argument_name=$4
argument_value=$5
if ! send_rest_go_command_expect_error_callback "$1" "$2" "$3" "check_specific_argument_name_and_value" "${@:6}"; then
log 2 "error checking error response values"
return 1
fi
return 0
}

View File

@@ -1024,6 +1024,7 @@ func TestVersioning(ts *TestState) {
// object tagging actions
ts.Run(Versioning_PutObjectTagging_invalid_versionId)
ts.Run(Versioning_PutObjectTagging_non_existing_object_version)
ts.Run(Versioning_PutGetDeleteObjectTagging_delete_marker)
ts.Run(Versioning_GetObjectTagging_invalid_versionId)
ts.Run(Versioning_GetObjectTagging_non_existing_object_version)
ts.Run(Versioning_DeleteObjectTagging_invalid_versionId)
@@ -1040,6 +1041,7 @@ func TestVersioning(ts *TestState) {
ts.Run(Versioning_DeleteObject_delete_a_delete_marker)
ts.Run(Versioning_Delete_null_versionId_object)
ts.Run(Versioning_DeleteObject_nested_dir_object)
ts.Run(Versioning_DeleteObject_non_existing_objects)
ts.Run(Versioning_DeleteObject_suspended)
ts.Run(Versioning_DeleteObjects_success)
ts.Run(Versioning_DeleteObjects_delete_deleteMarkers)
@@ -1067,12 +1069,14 @@ func TestVersioning(ts *TestState) {
ts.Run(Versioning_PutObjectRetention_non_existing_object_version)
ts.Run(Versioning_GetObjectRetention_invalid_versionId)
ts.Run(Versioning_GetObjectRetention_non_existing_object_version)
ts.Run(Versioning_Put_GetObjectRetention_delete_marker)
ts.Run(Versioning_Put_GetObjectRetention_success)
// Object-Lock Legal hold
ts.Run(Versioning_PutObjectLegalHold_invalid_versionId)
ts.Run(Versioning_PutObjectLegalHold_non_existing_object_version)
ts.Run(Versioning_GetObjectLegalHold_invalid_versionId)
ts.Run(Versioning_GetObjectLegalHold_non_existing_object_version)
ts.Run(Versioning_PutGetObjectLegalHold_delete_marker)
ts.Run(Versioning_Put_GetObjectLegalHold_success)
// WORM protection
ts.Run(Versioning_WORM_obj_version_locked_with_legal_hold)
@@ -1084,6 +1088,7 @@ func TestVersioning(ts *TestState) {
ts.Run(Versioning_WORM_PutObject_overwrite_locked_object)
ts.Run(Versioning_WORM_CopyObject_overwrite_locked_object)
ts.Run(Versioning_WORM_CompleteMultipartUpload_overwrite_locked_object)
ts.Run(Versioning_WORM_remove_delete_marker_under_bucket_default_retention)
// Concurrent requests
// Versioninig_concurrent_upload_object
ts.Run(Versioning_AccessControl_GetObjectVersion)
@@ -1735,6 +1740,7 @@ func GetIntTests() IntTests {
"Versioning_GetObject_null_versionId_obj": Versioning_GetObject_null_versionId_obj,
"Versioning_PutObjectTagging_invalid_versionId": Versioning_PutObjectTagging_invalid_versionId,
"Versioning_PutObjectTagging_non_existing_object_version": Versioning_PutObjectTagging_non_existing_object_version,
"Versioning_PutGetDeleteObjectTagging_delete_marker": Versioning_PutGetDeleteObjectTagging_delete_marker,
"Versioning_GetObjectTagging_invalid_versionId": Versioning_GetObjectTagging_invalid_versionId,
"Versioning_GetObjectTagging_non_existing_object_version": Versioning_GetObjectTagging_non_existing_object_version,
"Versioning_DeleteObjectTagging_invalid_versionId": Versioning_DeleteObjectTagging_invalid_versionId,
@@ -1749,6 +1755,7 @@ func GetIntTests() IntTests {
"Versioning_DeleteObject_delete_a_delete_marker": Versioning_DeleteObject_delete_a_delete_marker,
"Versioning_Delete_null_versionId_object": Versioning_Delete_null_versionId_object,
"Versioning_DeleteObject_nested_dir_object": Versioning_DeleteObject_nested_dir_object,
"Versioning_DeleteObject_non_existing_objects": Versioning_DeleteObject_non_existing_objects,
"Versioning_DeleteObject_suspended": Versioning_DeleteObject_suspended,
"Versioning_DeleteObjects_success": Versioning_DeleteObjects_success,
"Versioning_DeleteObjects_delete_deleteMarkers": Versioning_DeleteObjects_delete_deleteMarkers,
@@ -1772,11 +1779,13 @@ func GetIntTests() IntTests {
"Versioning_PutObjectRetention_non_existing_object_version": Versioning_PutObjectRetention_non_existing_object_version,
"Versioning_GetObjectRetention_invalid_versionId": Versioning_GetObjectRetention_invalid_versionId,
"Versioning_GetObjectRetention_non_existing_object_version": Versioning_GetObjectRetention_non_existing_object_version,
"Versioning_Put_GetObjectRetention_delete_marker": Versioning_Put_GetObjectRetention_delete_marker,
"Versioning_Put_GetObjectRetention_success": Versioning_Put_GetObjectRetention_success,
"Versioning_PutObjectLegalHold_invalid_versionId": Versioning_PutObjectLegalHold_invalid_versionId,
"Versioning_PutObjectLegalHold_non_existing_object_version": Versioning_PutObjectLegalHold_non_existing_object_version,
"Versioning_GetObjectLegalHold_invalid_versionId": Versioning_GetObjectLegalHold_invalid_versionId,
"Versioning_GetObjectLegalHold_non_existing_object_version": Versioning_GetObjectLegalHold_non_existing_object_version,
"Versioning_PutGetObjectLegalHold_delete_marker": Versioning_PutGetObjectLegalHold_delete_marker,
"Versioning_Put_GetObjectLegalHold_success": Versioning_Put_GetObjectLegalHold_success,
"Versioning_WORM_obj_version_locked_with_legal_hold": Versioning_WORM_obj_version_locked_with_legal_hold,
"Versioning_WORM_obj_version_locked_with_governance_retention": Versioning_WORM_obj_version_locked_with_governance_retention,
@@ -1787,6 +1796,7 @@ func GetIntTests() IntTests {
"Versioning_WORM_PutObject_overwrite_locked_object": Versioning_WORM_PutObject_overwrite_locked_object,
"Versioning_WORM_CopyObject_overwrite_locked_object": Versioning_WORM_CopyObject_overwrite_locked_object,
"Versioning_WORM_CompleteMultipartUpload_overwrite_locked_object": Versioning_WORM_CompleteMultipartUpload_overwrite_locked_object,
"Versioning_WORM_remove_delete_marker_under_bucket_default_retention": Versioning_WORM_remove_delete_marker_under_bucket_default_retention,
"Versioning_AccessControl_GetObjectVersion": Versioning_AccessControl_GetObjectVersion,
"Versioning_AccessControl_HeadObjectVersion": Versioning_AccessControl_HeadObjectVersion,
"Versioning_AccessControl_object_tagging_policy": Versioning_AccessControl_object_tagging_policy,

View File

@@ -118,17 +118,25 @@ func teardown(s *S3Conf, bucket string) error {
s3client := s.GetClient()
deleteObject := func(bucket, key, versionId *string) error {
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
_, err := s3client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: bucket,
Key: key,
VersionId: versionId,
})
cancel()
if err != nil {
return fmt.Errorf("failed to delete object %v: %w", *key, err)
var attempts int
var err error
for attempts < maxRetryAttempts {
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
_, err = s3client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: bucket,
Key: key,
VersionId: versionId,
})
cancel()
if err == nil {
return nil
}
attempts++
time.Sleep(time.Second)
}
return nil
return fmt.Errorf("delete object %s: %w", *key, err)
}
if s.versioningEnabled {
@@ -1915,6 +1923,7 @@ func cleanupLockedObjects(client *s3.Client, bucket string, objs []objToDelete)
if errors.Is(err, s3err.GetAPIError(s3err.ErrNoSuchKey)) {
return nil
}
if err != nil {
return err
}
@@ -1922,29 +1931,8 @@ func cleanupLockedObjects(client *s3.Client, bucket string, objs []objToDelete)
// Wait until retention lock expires before attempting delete
time.Sleep(lockWaitTime)
// Attempt deletion with retries
attempts := 0
for attempts != maxRetryAttempts {
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
_, err = client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: &bucket,
Key: &obj.key,
VersionId: getPtr(obj.versionId),
})
cancel()
if err != nil {
// Retry after a short delay if delete fails
time.Sleep(time.Second)
attempts++
continue
}
// Success, no more retries needed
return nil
}
// Return last error if all retries failed
return err
return nil
})
}
@@ -2221,7 +2209,7 @@ func testSignedStreamingObjectPut(s *S3Conf, bucket, object string, payload []by
}
signer := v4.NewSigner()
signingTime := time.Now()
signingTime := time.Now().UTC()
// sign the request
err = signer.SignHTTP(ctx, aws.Credentials{AccessKeyID: s.awsID, SecretAccessKey: s.awsSecret}, req, sha256Header, "s3", s.awsRegion, signingTime)
@@ -2458,7 +2446,7 @@ func getAWS4StreamingTrailer(
yearMonthDay := signingTime.Format("20060102")
// ISO8601 basic format: yyyyMMdd'T'HHmmss'Z'
currentDateTime := signingTime.UTC().Format("20060102T150405Z")
currentDateTime := signingTime.Format("20060102T150405Z")
// <date>/<region>/<service>/aws4_request
serviceString := fmt.Sprintf(

View File

@@ -1335,6 +1335,65 @@ func Versioning_DeleteObject_nested_dir_object(s *S3Conf) error {
}, withLock())
}
func Versioning_DeleteObject_non_existing_objects(s *S3Conf) error {
testName := "Versioning_DeleteObject_non_existing_objects"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
out, err := putObjectWithData(2, &s3.PutObjectInput{
Bucket: &bucket,
Key: getPtr("foo"),
}, s3client)
if err != nil {
return err
}
versionId := getString(out.res.VersionId)
for _, test := range []struct {
key string
versionId string
}{
{"foo/bar", "01KF2YVN948NAZ4JJR4X1AAVRA"},
{"foo/bar/baz", "01KF2YVN948NAZ4JJR4X1AAVRA"},
{"hello", "01KF2YVN948NAZ4JJR4X1AAVRA"},
{"hello/world", "01KF2YVN948NAZ4JJR4X1AAVRA"},
{"foo/bar/baz/quxx", versionId},
{"foo", versionId},
} {
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
res, err := s3client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: &bucket,
Key: &test.key,
VersionId: &test.versionId,
})
cancel()
if err != nil {
return err
}
if getString(res.VersionId) != test.versionId {
return fmt.Errorf("expected the versionId to be %s, instead got %s", test.versionId, getString(res.VersionId))
}
}
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
resp, err := s3client.ListObjectVersions(ctx, &s3.ListObjectVersionsInput{
Bucket: &bucket,
})
cancel()
if err != nil {
return err
}
if len(resp.Versions) != 0 {
return fmt.Errorf("expected empty object versions, instead got %v", resp.Versions)
}
if len(resp.DeleteMarkers) != 0 {
return fmt.Errorf("expected empty delete markers list, insead got %v", resp.DeleteMarkers)
}
return nil
}, withLock())
}
func Versioning_DeleteObject_suspended(s *S3Conf) error {
testName := "Versioning_DeleteObject_suspended"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
@@ -2117,6 +2176,57 @@ func Versioning_GetObjectRetention_non_existing_object_version(s *S3Conf) error
}, withLock(), withVersioning(types.BucketVersioningStatusEnabled))
}
func Versioning_Put_GetObjectRetention_delete_marker(s *S3Conf) error {
testName := "Versioning_Put_GetObjectRetention_delete_marker"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
obj := "my-object"
_, err := putObjectWithData(10, &s3.PutObjectInput{
Bucket: &bucket,
Key: &obj,
}, s3client)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
out, err := s3client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: &bucket,
Key: &obj,
})
cancel()
if err != nil {
return err
}
// PutObjectRetention
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
_, err = s3client.PutObjectRetention(ctx, &s3.PutObjectRetentionInput{
Bucket: &bucket,
Key: &obj,
VersionId: out.VersionId,
Retention: &types.ObjectLockRetention{
Mode: types.ObjectLockRetentionModeCompliance,
RetainUntilDate: getPtr(time.Now().AddDate(1, 0, 0)),
},
})
cancel()
if err := checkApiErr(err, s3err.GetAPIError(s3err.ErrMethodNotAllowed)); err != nil {
return err
}
// GetObjectRetention
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
_, err = s3client.GetObjectRetention(ctx, &s3.GetObjectRetentionInput{
Bucket: &bucket,
Key: &obj,
VersionId: out.VersionId,
})
cancel()
return checkApiErr(err, s3err.GetAPIError(s3err.ErrMethodNotAllowed))
}, withLock())
}
func Versioning_Put_GetObjectRetention_success(s *S3Conf) error {
testName := "Versioning_Put_GetObjectRetention_success"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
@@ -2257,6 +2367,56 @@ func Versioning_GetObjectLegalHold_non_existing_object_version(s *S3Conf) error
}, withLock(), withVersioning(types.BucketVersioningStatusEnabled))
}
func Versioning_PutGetObjectLegalHold_delete_marker(s *S3Conf) error {
testName := "Versioning_PutGetObjectLegalHold_delete_marker"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
obj := "my-object"
_, err := putObjectWithData(10, &s3.PutObjectInput{
Bucket: &bucket,
Key: &obj,
}, s3client)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
out, err := s3client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: &bucket,
Key: &obj,
})
cancel()
if err != nil {
return err
}
// PutObjectLegalHold
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
_, err = s3client.PutObjectLegalHold(ctx, &s3.PutObjectLegalHoldInput{
Bucket: &bucket,
Key: &obj,
VersionId: out.VersionId,
LegalHold: &types.ObjectLockLegalHold{
Status: types.ObjectLockLegalHoldStatusOn,
},
})
cancel()
if err := checkApiErr(err, s3err.GetAPIError(s3err.ErrMethodNotAllowed)); err != nil {
return err
}
// GetObjectLegalHold
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
_, err = s3client.GetObjectLegalHold(ctx, &s3.GetObjectLegalHoldInput{
Bucket: &bucket,
Key: &obj,
VersionId: out.VersionId,
})
cancel()
return checkApiErr(err, s3err.GetAPIError(s3err.ErrMethodNotAllowed))
}, withLock())
}
func Versioning_Put_GetObjectLegalHold_success(s *S3Conf) error {
testName := "Versioning_Put_GetObjectLegalHold_success"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
@@ -2898,6 +3058,85 @@ func Versioning_WORM_CompleteMultipartUpload_overwrite_locked_object(s *S3Conf)
}, withLock())
}
func Versioning_WORM_remove_delete_marker_under_bucket_default_retention(s *S3Conf) error {
testName := "Versioning_WORM_remove_delete_marker_under_bucket_default_retention"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
_, err := s3client.PutObjectLockConfiguration(ctx, &s3.PutObjectLockConfigurationInput{
Bucket: &bucket,
ObjectLockConfiguration: &types.ObjectLockConfiguration{
ObjectLockEnabled: types.ObjectLockEnabledEnabled,
Rule: &types.ObjectLockRule{
DefaultRetention: &types.DefaultRetention{
Mode: types.ObjectLockRetentionModeGovernance,
Days: getPtr(int32(5)),
},
},
},
})
cancel()
if err != nil {
return err
}
obj := "my-object"
versions, err := createObjVersions(s3client, bucket, obj, 3)
if err != nil {
return err
}
// Create a delete marker
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
out, err := s3client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: &bucket,
Key: &obj,
})
cancel()
if err != nil {
return err
}
// Delete the delete marker
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
_, err = s3client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: &bucket,
Key: &obj,
VersionId: out.VersionId,
})
cancel()
if err != nil {
return err
}
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
resp, err := s3client.ListObjectVersions(ctx, &s3.ListObjectVersionsInput{
Bucket: &bucket,
})
cancel()
if err != nil {
return err
}
if !compareVersions(versions, resp.Versions) {
return fmt.Errorf("expected the object vresions to be %v, instead got %v", versions, resp.Versions)
}
if len(resp.DeleteMarkers) != 0 {
return fmt.Errorf("expected empty delete markers list, instead got %v", resp.DeleteMarkers)
}
//
lockedVersions := make([]objToDelete, 0, len(versions))
for _, v := range versions {
lockedVersions = append(lockedVersions, objToDelete{
key: obj,
versionId: getString(v.VersionId),
isCompliance: false,
})
}
return cleanupLockedObjects(s3client, bucket, lockedVersions)
}, withLock())
}
func Versioning_AccessControl_GetObjectVersion(s *S3Conf) error {
testName := "Versioning_AccessControl_GetObjectVersion"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
@@ -3457,6 +3696,68 @@ func Versioning_PutObjectTagging_non_existing_object_version(s *S3Conf) error {
}, withVersioning(types.BucketVersioningStatusEnabled))
}
func Versioning_PutGetDeleteObjectTagging_delete_marker(s *S3Conf) error {
testName := "Versioning_PutGetDeleteObjectTagging_delete_marker"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {
obj := "my-object"
_, err := putObjectWithData(10, &s3.PutObjectInput{
Bucket: &bucket,
Key: &obj,
}, s3client)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), shortTimeout)
out, err := s3client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: &bucket,
Key: &obj,
})
cancel()
if err != nil {
return err
}
// PutObjectTagging
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
_, err = s3client.PutObjectTagging(ctx, &s3.PutObjectTaggingInput{
Bucket: &bucket,
Key: &obj,
VersionId: out.VersionId,
Tagging: &types.Tagging{
TagSet: []types.Tag{{Key: getPtr("key"), Value: getPtr("value")}},
},
})
cancel()
if err := checkApiErr(err, s3err.GetAPIError(s3err.ErrMethodNotAllowed)); err != nil {
return err
}
// GetObjectTagging
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
_, err = s3client.GetObjectTagging(ctx, &s3.GetObjectTaggingInput{
Bucket: &bucket,
Key: &obj,
VersionId: out.VersionId,
})
cancel()
if err := checkApiErr(err, s3err.GetAPIError(s3err.ErrMethodNotAllowed)); err != nil {
return err
}
// DeleteObjectTagging
ctx, cancel = context.WithTimeout(context.Background(), shortTimeout)
_, err = s3client.DeleteObjectTagging(ctx, &s3.DeleteObjectTaggingInput{
Bucket: &bucket,
Key: &obj,
VersionId: out.VersionId,
})
cancel()
return checkApiErr(err, s3err.GetAPIError(s3err.ErrMethodNotAllowed))
}, withLock())
}
func Versioning_PutObjectTagging_invalid_versionId(s *S3Conf) error {
testName := "Versioning_PutObjectTagging_invalid_versionId"
return actionHandler(s, testName, func(s3client *s3.Client, bucket string) error {

View File

@@ -0,0 +1,15 @@
#!/usr/bin/env bash
# Copyright 2026 Versity Software
# This file is licensed under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http:#www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.

View File

@@ -14,6 +14,8 @@
# specific language governing permissions and limitations
# under the License.
source ./tests/drivers/params.sh
check_and_create_database() {
# Define SQL commands to create a table
SQL_CREATE_TABLE="CREATE TABLE IF NOT EXISTS entries (
@@ -107,4 +109,201 @@ EOF
sqlite3 "$COVERAGE_DB" "DROP TABLE entries;"
log 5 "Database '$COVERAGE_DB' and table 'entries' created successfully."
}
}
get_curl_method() {
if ! check_param_count_v2 "command string" 1 $#; then
return 1
fi
if [[ "$1" =~ (^|[[:space:]])-([^-[:space:]]*)I([^-[:space:]]*) ]]; then
method="HEAD"
elif [[ "$1" =~ (^|[[:space:]])-X[[:space:]]*([^[:space:]]+) ]]; then
method="${BASH_REMATCH[2]}"
else
method="GET"
fi
echo "$method"
}
parse_path_and_get_route() {
}
get_route() {
if ! check_param_count_v2 "string" 1 $#; then
return 1
fi
url="$(echo "$1" | grep -oE 'https?://[^" ]+' | head -n 1)"
local path
# Only accept http/https URLs with a path
if [ -z "$url" ]; then
echo "UNKNOWN"
return 0
fi
# Strip protocol + host + port
path="$(echo "$url" | sed -E 's|https?://[^/]+||')"
# Normalize: remove leading/trailing slashes
path="${path#/}"
path="${path%/}"
# Split path on '/'
IFS='/' read -r -a parts <<< "$path"
if [[ -z "$path" ]]; then
echo "MAIN"
elif [[ "${#parts[@]}" -eq 1 ]]; then
echo "BUCKET"
else
echo "FILE"
fi
return 0
}
get_query() {
if ! check_param_count_v2 "string" 1 $#; then
return 1
fi
url="$(echo "$1" | grep -oE 'https?://[^" ]+' | head -n 1)"
# Must look like a URL
if [ -z "$url" ]; then
echo ""
return 0
fi
# Extract query string (everything after '?')
local query
query="${url#*\?}"
# No query present
if [[ "$query" == "$url" ]]; then
echo ""
return 0
fi
# Remove fragment if present
query="${query%%#*}"
keys=()
while [[ $query ]]; do
key="${query%%=*}" # Extract key
keys+=("$key")
# If no more keys
if [[ "$query" != *"&"* ]]; then
break
fi
query="${query#*&}" # Remove extracted part from query
done
echo "${keys[*]}"
}
parse_curl_rest_command() {
if ! check_param_count_v2 "command string" 1 $#; then
return 1
fi
if ! method=$(get_curl_method "$1" 2>&1); then
echo "error retrieving method: $method"
return 1
fi
if ! route=$(get_route "$1" 2>&1); then
echo "error retrieving route: $route"
return 1
fi
if ! query=$(get_query "$1" 2>&1); then
echo "error retrieving query: $query"
return 1
fi
echo "$method $route $query"
return 0
}
get_openssl_method_route_queries() {
if ! check_param_count_v2 "command file" 1 $#; then
return 1
fi
top_line=$(head -n 1 "$1")
method=$(awk 'NR==1{print $1}' "$1")
route=$(get_route "$top_line")
query=$(get_query "$top_line")
echo "$method $route $query"
return 0
}
parse_openssl_command() {
if ! check_param_count_v2 "command file" 1 $#; then
return 1
fi
}
get_client_type() {
if [[ "$1" == *" curl "* ]] || [[ "$1" == "curl "* ]]; then
echo "CURL"
return 0
elif [[ "$1" == *" s3api "* ]] || [[ "$1" == "s3api "* ]]; then
echo "S3API"
return 0
fi
echo "UNKNOWN"
}
parse_command_info() {
if ! check_param_count_v2 "command string" 1 $#; then
return 1
fi
if [[ "$1" == *"curl "* ]]; then
if ! command_info=$(parse_curl_rest_command "$1" 2>&1); then
echo "error parsing rest command: $command_info"
return 1
fi
else
command_info="NONE"
fi
}
check_and_create_database_v2() {
# Define SQL commands to create a table
SQL_CREATE_TABLE="CREATE TABLE IF NOT EXISTS entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
command TEXT UNIQUE NOT NULL,
count INTEGER DEFAULT 1
);"
# Execute the SQL commands to create the database and table
sqlite3 "coverage.db" <<EOF
$SQL_CREATE_TABLE
.exit
EOF
}
record_command_v2() {
#if [ -z "$COVERAGE_DB" ]; then
# log 5 "no coverage db set, not recording"
# return 0
#fi
if ! check_param_count_v2 "command string" 1 $#; then
return 1
fi
if ! db_result=$(check_and_create_database_v2 2>&1); then
log 2 "error creating database: $db_result"
return 1
fi
if ! parse_command_info "$1"; then
return 1
fi
if [ "$command_info" == "NONE" ]; then
return 0
fi
echo "$command_info" >> "commandInfo.txt"
cat "commandInfo.txt" | sort | uniq > "commandInfo.txt.tmp"
mv "commandInfo.txt.tmp" "commandInfo.txt"
if ! error=$(sqlite3 "coverage.db" "INSERT INTO entries (command, count) VALUES(\"$command_info\", 1) ON CONFLICT(command) DO UPDATE SET count = count + 1" 2>&1); then
log 2 "error in sqlite statement: $error"
fi
}

View File

@@ -40,7 +40,13 @@ check_secrets_line() {
log 2 "$password_env secrets parameter missing"
return 1
fi
if ! user_exists "${!username_env}" && ! create_user_versitygw "${!username_env}" "${!password_env}" "$role"; then
local user_exists_code=0
user_exists "${!username_env}" || user_exists_code=$?
if [ $user_exists_code -eq 2 ]; then
log 2 "error checking for user existence"
return 1
fi
if [ $user_exists_code -eq 1 ] && ! create_user_versitygw "${!username_env}" "${!password_env}" "$role"; then
log 2 "error creating user"
return 1
fi

89
tests/test_report.sh Executable file
View File

@@ -0,0 +1,89 @@
#!/usr/bin/env bats
# Copyright 2024 Versity Software
# This file is licensed under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http:#www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
load ./bats-support/load
load ./bats-assert/load
source ./tests/logger.sh
source ./tests/report.sh
@test "reporting - parse curl method" {
tests=(" -Iks" "" " -X PUT" " -X DELETE")
expected_results=("HEAD" "GET" "PUT" "DELETE")
for ((i=0; i<${#tests[@]}; i++)); do
echo "test: ${tests[$i]}, expected result: ${expected_results[$i]}"
run get_curl_method "${tests[$i]}"
assert_output "${expected_results[$i]}"
done
}
@test "reporting - parse curl route" {
tests=("http://localhost:7070/bucket_name" "http://localhost:7070/bucket_name/file_name" "http://localhost:7070/" "")
expected_results=("BUCKET" "FILE" "MAIN" "UNKNOWN")
for ((i=0; i<${#tests[@]}; i++)); do
echo "test: ${tests[$i]}, expected result: ${expected_results[$i]}"
run get_curl_route "${tests[$i]}"
assert_output "${expected_results[$i]}"
done
}
@test "reporting - get query" {
tests=("https://localhost:7070/?query1=" "https://localhost/bucket?another=" "https://1.2.3.4/" "http://localhost/bucket/file?third")
expected_results=("query1" "another" "" "third")
for ((i=0; i<${#tests[@]}; i++)); do
echo "test: ${tests[$i]}, expected result: ${expected_results[$i]}"
run get_query "${tests[$i]}"
assert_output "${expected_results[$i]}"
done
}
@test "reporting - get client type" {
tests=("curl -iks https://localhost:7070/versity-gwtest-bucket-one-1-20260127113351?location= -H Authorization: AWS4-HMAC-SHA256 Credential=AKIA6****/20260127/us-east-1/s3/aws4_request,SignedHeaders=host;x-amz-content-sha256;x-amz-date,Signature=68c0b96180a5791be8a10335c10d302d31d358c4bc6028aec94faf502f3a185e -H host: localhost:7070 -H x-amz-content-sha256: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 -H x-amz-date: 20260127T143355Z" \
"aws --no-verify-ssl s3api create-bucket --bucket versity-gwtest-bucket-one-1-20260127113351 --object-lock-enabled-for-bucket" "")
expected_results=("CURL" "S3API" "UNKNOWN")
for ((i=0; i<${#tests[@]}; i++)); do
run get_client_type "${tests[$i]}"
assert_output "${expected_results[$i]}"
done
}
@test "reporting - parse curl rest command" {
tests=("curl -iks https://localhost:7070/versity-gwtest-bucket-one-1-20260127113351?location= -H Authorization: AWS4-HMAC-SHA256 Credential=AKIA6****/20260127/us-east-1/s3/aws4_request,SignedHeaders=host;x-amz-content-sha256;x-amz-date,Signature=68c0b96180a5791be8a10335c10d302d31d358c4bc6028aec94faf502f3a185e -H host: localhost:7070 -H x-amz-content-sha256: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 -H x-amz-date: 20260127T143355Z")
expected_command=("GET BUCKET location")
for ((i=0; i<${#tests[@]}; i++)); do
run parse_curl_rest_command "${tests[$i]}"
assert_output "${expected_command[$i]}"
done
}
@test "openssl - get method, route, and queries" {
tests=("GET / HTTP/1.1
Authorization: AWS4-HMAC-SHA256 Credential=AKIAQJVWFRZQNI6LF3W7/20250911/us-east-1/s3/aws4_request,SignedHeaders=x-amz-content-sha256;x-amz-date,Signature=86ffbe2317caddcac569b25aa9b8e8db4a613a639b2a402cf4a9dc0e975ba997
x-amz-content-sha256:UNSIGNED-PAYLOAD")
expected_output=("GET MAIN ")
for ((i=0; i<${#tests[@]}; i++)); do
file_name="$TMPDIR/openssl-$(uuidgen)"
echo "${tests[$i]}" > "$file_name"
run get_openssl_method_route_queries "$file_name"
assert_output "${expected_output[$i]}"
done
}

View File

@@ -1,504 +0,0 @@
#!/usr/bin/env bats
# Copyright 2024 Versity Software
# This file is licensed under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http:#www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
load ./bats-support/load
load ./bats-assert/load
source ./tests/commands/create_multipart_upload.sh
source ./tests/commands/delete_object_tagging.sh
source ./tests/commands/get_bucket_versioning.sh
source ./tests/commands/get_object.sh
source ./tests/commands/get_object_lock_configuration.sh
source ./tests/commands/get_object_retention.sh
source ./tests/commands/list_buckets.sh
source ./tests/commands/list_object_versions.sh
source ./tests/commands/put_bucket_versioning.sh
source ./tests/commands/put_object.sh
source ./tests/commands/put_object_retention.sh
source ./tests/commands/put_object_tagging.sh
source ./tests/drivers/create_bucket/create_bucket_rest.sh
source ./tests/drivers/copy_object/copy_object_rest.sh
source ./tests/drivers/get_object_attributes/get_object_attributes_rest.sh
source ./tests/drivers/get_object_lock_config/get_object_lock_config_rest.sh
source ./tests/drivers/get_object_tagging/get_object_tagging.sh
source ./tests/drivers/get_bucket_ownership_controls/get_bucket_ownership_controls_rest.sh
source ./tests/drivers/head_object/head_object_rest.sh
source ./tests/drivers/list_objects/list_objects_rest.sh
source ./tests/drivers/file.sh
source ./tests/drivers/xml.sh
source ./tests/logger.sh
source ./tests/setup.sh
source ./tests/util/util_delete_object.sh
source ./tests/util/util_legal_hold.sh
source ./tests/util/util_list_buckets.sh
source ./tests/util/util_list_objects.sh
source ./tests/util/util_list_parts.sh
source ./tests/util/util_lock_config.sh
source ./tests/util/util_multipart_before_completion.sh
source ./tests/util/util_object.sh
source ./tests/util/util_policy.sh
source ./tests/util/util_public_access_block.sh
source ./tests/util/util_rest.sh
source ./tests/util/util_time.sh
source ./tests/util/util_versioning.sh
export RUN_USERS=true
test_file="test_file"
@test "test_rest_list_objects" {
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
bucket_name="$output"
run setup_bucket_and_file_v2 "$bucket_name" "$test_file"
assert_success
run put_object "rest" "$TEST_FILE_FOLDER/$test_file" "$bucket_name" "$test_file"
assert_success
run list_check_objects_rest "$bucket_name"
assert_success
}
@test "test_rest_delete_object" {
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
bucket_name="$output"
run setup_bucket_and_file_v2 "$bucket_name" "$test_file"
assert_success
run put_object "rest" "$TEST_FILE_FOLDER/$test_file" "$bucket_name" "$test_file"
assert_success
run download_and_compare_file "$TEST_FILE_FOLDER/$test_file" "$bucket_name" "$test_file" "$TEST_FILE_FOLDER/$test_file-copy"
assert_success
run delete_object "rest" "$bucket_name" "$test_file"
assert_success
run get_object "rest" "$bucket_name" "$test_file" "$TEST_FILE_FOLDER/$test_file-copy"
assert_failure
}
@test "test_rest_tagging" {
test_key="TestKey"
test_value="TestValue"
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
bucket_name="$output"
run setup_bucket_and_file_v2 "$bucket_name" "$test_file"
assert_success
run put_object "rest" "$TEST_FILE_FOLDER/$test_file" "$bucket_name" "$test_file"
assert_success
run put_object_tagging "rest" "$bucket_name" "$test_file" "$test_key" "$test_value"
assert_success
run check_verify_object_tags "rest" "$bucket_name" "$test_file" "$test_key" "$test_value"
assert_success
run delete_object_tagging "rest" "$bucket_name" "$test_file"
assert_success
run verify_no_object_tags "rest" "$bucket_name" "$test_file"
assert_success
}
@test "test_rest_retention" {
test_key="TestKey"
test_value="TestValue"
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
bucket_name="$output"
run setup_bucket_object_lock_enabled_v2 "$bucket_name"
assert_success
run create_test_files "$test_file"
assert_success
run put_object "rest" "$TEST_FILE_FOLDER/$test_file" "$bucket_name" "$test_file"
assert_success
if ! five_seconds_later=$(get_time_seconds_in_future 5 "%z"); then
log 2 "error getting future time"
return 1
fi
log 5 "later: $five_seconds_later"
run put_object_retention_rest "$bucket_name" "$test_file" "GOVERNANCE" "$five_seconds_later"
assert_success
}
@test "REST - legal hold, get without config" {
if [ "$RECREATE_BUCKETS" == "false" ]; then
skip "test requires object lock not to be enabled"
fi
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
bucket_name="$output"
run setup_bucket_and_file_v2 "$bucket_name" "$test_file"
assert_success
run put_object "rest" "$TEST_FILE_FOLDER/$test_file" "$bucket_name" "$test_file"
assert_success
run check_legal_hold_without_lock_enabled "$bucket_name" "$test_file" "InvalidRequest"
assert_success
}
@test "REST - legal hold, object lock enabled w/o specific object lock set" {
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
bucket_name="$output"
run setup_bucket_object_lock_enabled_v2 "$bucket_name"
assert_success
run create_test_file "$test_file"
assert_success
run put_object "rest" "$TEST_FILE_FOLDER/$test_file" "$bucket_name" "$test_file"
assert_success
run check_legal_hold_without_lock_enabled "$bucket_name" "$test_file" "NoSuchObjectLockConfiguration"
assert_success
}
@test "REST - get object attributes" {
if [ "$DIRECT" != "true" ]; then
skip "https://github.com/versity/versitygw/issues/1001"
fi
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
bucket_name="$output"
run setup_bucket_and_large_file_v2 "$bucket_name" "$test_file"
assert_success
# shellcheck disable=SC2034
file_size=$(stat -c %s "$TEST_FILE_FOLDER/$test_file" 2>/dev/null || stat -f %z "$TEST_FILE_FOLDER/$test_file" 2>/dev/null)
run split_file "$TEST_FILE_FOLDER/$test_file" 4
assert_success
run upload_and_check_attributes "$bucket_name" "$test_file" "$file_size"
assert_success
}
@test "REST - attributes - invalid param" {
if [ "$DIRECT" != "true" ]; then
skip "https://github.com/versity/versitygw/issues/1001"
fi
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
bucket_name="$output"
run setup_bucket_and_file_v2 "$bucket_name" "$test_file"
assert_success
run put_object "rest" "$TEST_FILE_FOLDER/$test_file" "$bucket_name" "$test_file"
assert_success
run check_attributes_invalid_param "$bucket_name" "$test_file"
assert_success
}
@test "REST - attributes - checksum" {
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
bucket_name="$output"
run setup_bucket_and_file_v2 "$bucket_name" "$test_file"
assert_success
run add_and_check_checksum "$TEST_FILE_FOLDER/$test_file" "$bucket_name" "$test_file"
assert_success
}
@test "REST - list objects v2 - invalid continuation token" {
if [ "$DIRECT" != "true" ]; then
skip "https://github.com/versity/versitygw/issues/993"
fi
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
bucket_name="$output"
test_file_two="test_file_2"
test_file_three="test_file_3"
run setup_bucket_and_files_v2 "$bucket_name" "$test_file" "$test_file_two" "$test_file_three"
assert_success
run put_object "rest" "$TEST_FILE_FOLDER/$test_file" "$bucket_name" "$test_file"
assert_success
run put_object "rest" "$TEST_FILE_FOLDER/$test_file_two" "$bucket_name" "$test_file_two"
assert_success
run put_object "rest" "$TEST_FILE_FOLDER/$test_file_three" "$bucket_name" "$test_file_three"
assert_success
run list_objects_check_params_get_token "$bucket_name" "$test_file" "$test_file_two" "TRUE"
assert_success
continuation_token=$output
# interestingly, AWS appears to accept continuation tokens that are a few characters off, so have to remove three chars
run list_objects_check_continuation_error "$bucket_name" "${continuation_token:0:${#continuation_token}-3}"
assert_success
}
@test "REST - list objects v1 - no NextMarker without delimiter" {
if [ "$DIRECT" != "true" ]; then
skip "https://github.com/versity/versitygw/issues/999"
fi
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
bucket_name="$output"
test_file_two="test_file_2"
run setup_bucket_and_files_v2 "$bucket_name" "$test_file" "$test_file_two"
assert_success
run put_object "rest" "$TEST_FILE_FOLDER/$test_file" "$bucket_name" "$test_file"
assert_success
run put_object "rest" "$TEST_FILE_FOLDER/$test_file_two" "$bucket_name" "$test_file_two"
assert_success
run list_objects_v1_check_nextmarker_empty "$bucket_name"
assert_success
}
@test "REST - head object" {
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
bucket_name="$output"
run setup_bucket_and_file_v2 "$bucket_name" "$test_file"
assert_success
run put_object "rest" "$TEST_FILE_FOLDER/$test_file" "$bucket_name" "$test_file"
assert_success
run get_etag_rest "$bucket_name" "$test_file"
assert_success
expected_etag=$output
run get_etag_attribute_rest "$bucket_name" "$test_file" "$expected_etag"
assert_success
}
@test "REST - delete objects - no content-md5 header" {
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
bucket_name="$output"
run setup_bucket_v2 "$bucket_name"
assert_success
run delete_objects_no_content_md5_header "$bucket_name"
assert_success
}
@test "REST - delete objects command" {
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
bucket_name="$output"
test_file_two="test_file_two"
run setup_bucket_and_files_v2 "$bucket_name" "$test_file" "$test_file_two"
assert_success
run put_object "rest" "$TEST_FILE_FOLDER/$test_file" "$bucket_name" "$test_file"
assert_success
run put_object "rest" "$TEST_FILE_FOLDER/$test_file_two" "$bucket_name" "$test_file_two"
assert_success
run verify_object_exists "$bucket_name" "$test_file"
assert_success
run verify_object_exists "$bucket_name" "$test_file_two"
assert_success
run delete_objects_verify_success "$bucket_name" "$test_file" "$test_file_two"
assert_success
run verify_object_not_found "$bucket_name" "$test_file"
assert_success
run verify_object_not_found "$bucket_name" "$test_file_two"
assert_success
}
@test "REST - PutObjectRetention - w/o request body" {
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
bucket_name="$output"
run setup_bucket_object_lock_enabled_v2 "$bucket_name"
assert_success
run create_test_file "$test_file"
assert_success
run put_object "rest" "$TEST_FILE_FOLDER/$test_file" "$bucket_name" "$test_file"
assert_success
run retention_rest_without_request_body "$bucket_name" "$test_file"
assert_success
}
@test "REST - PutObjectLegalHold - missing content-md5" {
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
bucket_name="$output"
run setup_bucket_object_lock_enabled_v2 "$bucket_name"
assert_success
run create_test_file "$test_file"
assert_success
run put_object "rest" "$TEST_FILE_FOLDER/$test_file" "$bucket_name" "$test_file"
assert_success
run check_legal_hold_without_content_md5 "$bucket_name" "$test_file"
assert_success
}
@test "REST - PutObjectLegalHold w/o payload" {
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
bucket_name="$output"
run setup_bucket_object_lock_enabled_v2 "$bucket_name"
assert_success
run create_test_file "$test_file"
assert_success
run put_object "rest" "$TEST_FILE_FOLDER/$test_file" "$bucket_name" "$test_file"
assert_success
run check_legal_hold_without_payload "$bucket_name" "$test_file"
assert_success
}
@test "REST - PutObjectLegalHold - success" {
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
bucket_name="$output"
run setup_bucket_object_lock_enabled_v2 "$bucket_name"
assert_success
run create_test_file "$test_file"
assert_success
run put_object "rest" "$TEST_FILE_FOLDER/$test_file" "$bucket_name" "$test_file"
assert_success
run rest_check_legal_hold "$bucket_name" "$test_file"
assert_success
}
@test "REST - copy object w/invalid copy source" {
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
bucket_name="$output"
run setup_bucket_and_file_v2 "$bucket_name" "$test_file"
assert_success
run put_object "rest" "$TEST_FILE_FOLDER/$test_file" "$bucket_name" "$test_file"
assert_success
run copy_object_invalid_copy_source "$bucket_name"
assert_success
}
@test "REST - copy object w/copy source and payload" {
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
bucket_name="$output"
run setup_bucket_and_file_v2 "$bucket_name" "$test_file"
assert_success
run put_object "rest" "$TEST_FILE_FOLDER/$test_file" "$bucket_name" "$test_file"
assert_success
run copy_object_copy_source_and_payload "$bucket_name" "$test_file" "$TEST_FILE_FOLDER/$test_file"
assert_success
}
@test "REST - range download and compare" {
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
bucket_name="$output"
run setup_bucket_and_large_file_v2 "$bucket_name" "$test_file"
assert_success
run put_object "rest" "$TEST_FILE_FOLDER/$test_file" "$bucket_name" "$test_file"
assert_success
run download_and_compare_file "$TEST_FILE_FOLDER/$test_file" "$bucket_name" "$test_file" "$TEST_FILE_FOLDER/$test_file-copy" 2000000
assert_success
}
@test "REST - put, get object, encoded name" {
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
bucket_name="$output"
file_name=" \"<>\\^\`{}|+&?%"
run setup_bucket_and_file_v2 "$bucket_name" "$file_name"
assert_success
run put_object_rest_special_chars "$TEST_FILE_FOLDER/$file_name" "$bucket_name" "$file_name/$file_name"
assert_success
run list_check_single_object "$bucket_name" "$file_name/$file_name"
assert_success
run get_object_rest_special_chars "$bucket_name" "$file_name/$file_name" "$TEST_FILE_FOLDER/${file_name}-copy"
assert_success
run compare_files "$TEST_FILE_FOLDER/$file_name" "$TEST_FILE_FOLDER/${file_name}-copy"
assert_success
run delete_object_rest "$bucket_name" "$file_name/$file_name"
assert_success
}
@test "REST - GetObject w/STREAMING-AWS4-HMAC-SHA256-PAYLOAD type" {
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
bucket_name="$output"
run setup_bucket_and_file_v2 "$bucket_name" "$test_file"
assert_success
run get_object_rest_with_invalid_streaming_type "$bucket_name" "$test_file"
assert_success
}

87
tests/test_rest_attributes.sh Executable file
View File

@@ -0,0 +1,87 @@
#!/usr/bin/env bats
# Copyright 2026 Versity Software
# This file is licensed under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http:#www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
load ./bats-support/load
load ./bats-assert/load
source ./tests/setup.sh
source ./tests/drivers/create_bucket/create_bucket_rest.sh
source ./tests/drivers/get_object_attributes/get_object_attributes_rest.sh
@test "REST - get object attributes" {
if [ "$DIRECT" != "true" ]; then
skip "https://github.com/versity/versitygw/issues/1001"
fi
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
bucket_name="$output"
run get_file_name
assert_success
test_file="$output"
run setup_bucket_and_large_file_v2 "$bucket_name" "$test_file"
assert_success
run get_file_size "$TEST_FILE_FOLDER/$test_file"
assert_success
file_size=${output}
run split_file "$TEST_FILE_FOLDER/$test_file" 4
assert_success
run upload_and_check_attributes "$bucket_name" "$test_file" "$file_size"
assert_success
}
@test "REST - attributes - invalid param" {
if [ "$DIRECT" != "true" ]; then
skip "https://github.com/versity/versitygw/issues/1001"
fi
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
bucket_name="$output"
run get_file_name
assert_success
test_file="$output"
run setup_bucket_and_file_v2 "$bucket_name" "$test_file"
assert_success
run put_object "rest" "$TEST_FILE_FOLDER/$test_file" "$bucket_name" "$test_file"
assert_success
run check_attributes_invalid_param "$bucket_name" "$test_file"
assert_success
}
@test "REST - attributes - checksum" {
run get_file_name
assert_success
test_file="$output"
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
bucket_name="$output"
run setup_bucket_and_file_v2 "$bucket_name" "$test_file"
assert_success
run add_and_check_checksum "$TEST_FILE_FOLDER/$test_file" "$bucket_name" "$test_file"
assert_success
}

View File

@@ -30,7 +30,10 @@ source ./tests/util/util_file.sh
assert_success
bucket_name="$output"
test_file="test-file"
run get_file_name
assert_success
test_file="$output"
run setup_bucket_and_file_v2 "$bucket_name" "$test_file"
assert_success
@@ -46,7 +49,10 @@ source ./tests/util/util_file.sh
run setup_bucket_v2 "$bucket_name"
assert_success
test_file="test-file"
run get_file_name
assert_success
test_file="$output"
run create_test_file "$test_file" 8192
assert_success
@@ -62,7 +68,10 @@ source ./tests/util/util_file.sh
run setup_bucket_v2 "$bucket_name"
assert_success
test_file="test-file"
run get_file_name
assert_success
test_file="$output"
run create_test_file "$test_file" 0
assert_success
@@ -78,7 +87,10 @@ source ./tests/util/util_file.sh
run setup_bucket_v2 "$bucket_name"
assert_success
test_file="test-file"
run get_file_name
assert_success
test_file="$output"
run create_file_single_char "$test_file" 8192 'a'
assert_success
@@ -97,7 +109,10 @@ source ./tests/util/util_file.sh
run setup_bucket_v2 "$bucket_name"
assert_success
test_file="test-file"
run get_file_name
assert_success
test_file="$output"
run create_file_single_char "$test_file" 8192 '\0'
assert_success
@@ -116,7 +131,10 @@ source ./tests/util/util_file.sh
run setup_bucket_v2 "$bucket_name"
assert_success
test_file="test-file"
run get_file_name
assert_success
test_file="$output"
run create_test_file "$test_file" 10000
assert_success
@@ -135,7 +153,10 @@ source ./tests/util/util_file.sh
run setup_bucket_v2 "$bucket_name"
assert_success
test_file="test-file"
run get_file_name
assert_success
test_file="$output"
run create_test_file "$test_file" 0
assert_success
@@ -176,7 +197,10 @@ source ./tests/util/util_file.sh
assert_success
bucket_name="$output"
test_file="test-file"
run get_file_name
assert_success
test_file="$output"
run setup_bucket_and_file "$bucket_name" "$test_file"
assert_success
@@ -242,7 +266,10 @@ source ./tests/util/util_file.sh
run setup_bucket "$bucket_name"
assert_success
test_file="test-file"
run get_file_name
assert_success
test_file="$output"
run create_test_file "$test_file" 200000
assert_success
@@ -252,3 +279,192 @@ source ./tests/util/util_file.sh
run download_and_compare_file "$TEST_FILE_FOLDER/$test_file" "$bucket_name" "$test_file" "$TEST_FILE_FOLDER/$test_file-copy"
assert_success
}
@test "REST - PutObject - STREAMING-UNSIGNED-PAYLOAD-TRAILER, x-amz-trailer of crc32, trailer missing" {
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
bucket_name="$output"
run setup_bucket_v2 "$bucket_name"
assert_success
run send_openssl_go_command_expect_error "400" "MalformedTrailerError" "The request contained trailing data that was not well-formed" \
"-client" "openssl" "-commandType" "putObject" "-bucketName" "$bucket_name" "-payload" "abcdefg" \
"-omitPayloadTrailer" "-checksumType" "crc32" \
"-payloadType" "STREAMING-UNSIGNED-PAYLOAD-TRAILER" "-chunkSize" "8192" "-objectKey" "key" "-signedParams" "x-amz-trailer:x-amz-checksum-crc32"
assert_success
}
@test "REST - PutObject - STREAMING-UNSIGNED-PAYLOAD-TRAILER - 200 header returns correct checksum type" {
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
bucket_name="$output"
run get_file_name
assert_success
test_file="$output"
run setup_bucket_and_file_v2 "$bucket_name" "$test_file"
assert_success
run bash -c "sha256sum $TEST_FILE_FOLDER/$test_file | awk '{print $1}' | xxd -r -p | base64"
assert_success
checksum=${output}
run send_openssl_go_command_check_header "200" "x-amz-checksum-sha256" "$checksum" \
"-client" "openssl" "-commandType" "putObject" "-bucketName" "$bucket_name" "-payloadFile" "$TEST_FILE_FOLDER/$test_file" "-checksumType" "sha256" \
"-payloadType" "STREAMING-UNSIGNED-PAYLOAD-TRAILER" "-chunkSize" "8192" "-objectKey" "key" "-signedParams" "x-amz-trailer:x-amz-checksum-sha256"
assert_success
}
@test "REST - PutObject - STREAMING-UNSIGNED-PAYLOAD-TRAILER - success (sha1)" {
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
bucket_name="$output"
run setup_bucket_v2 "$bucket_name"
assert_success
run get_file_name
assert_success
test_file="$output"
run create_test_file "$test_file" 10000
assert_success
run bash -c "sha1sum $TEST_FILE_FOLDER/$test_file | awk '{print $1}' | xxd -r -p | base64"
assert_success
checksum=${output}
run send_openssl_go_command_check_header "200" "x-amz-checksum-sha1" "$checksum" \
"-client" "openssl" "-commandType" "putObject" "-bucketName" "$bucket_name" "-payloadFile" "$TEST_FILE_FOLDER/$test_file" "-checksumType" "sha1" \
"-payloadType" "STREAMING-UNSIGNED-PAYLOAD-TRAILER" "-chunkSize" "8192" "-objectKey" "key" "-signedParams" "x-amz-trailer:x-amz-checksum-sha1"
assert_success
}
@test "REST - PutObject - STREAMING-UNSIGNED-PAYLOAD-TRAILER - success (crc32)" {
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
bucket_name="$output"
run setup_bucket_v2 "$bucket_name"
assert_success
run get_file_name
assert_success
test_file="$output"
run create_test_file "$test_file" 10000
assert_success
run bash -c "gzip -c -1 $TEST_FILE_FOLDER/$test_file | tail -c8 | od -t x4 -N 4 -A n | awk '{print $1}' | xxd -r -p | base64"
assert_success
checksum=${output}
run send_openssl_go_command_check_header "200" "x-amz-checksum-crc32" "$checksum" \
"-client" "openssl" "-commandType" "putObject" "-bucketName" "$bucket_name" "-payloadFile" "$TEST_FILE_FOLDER/$test_file" "-checksumType" "crc32" \
"-payloadType" "STREAMING-UNSIGNED-PAYLOAD-TRAILER" "-chunkSize" "8192" "-objectKey" "key" "-signedParams" "x-amz-trailer:x-amz-checksum-crc32"
assert_success
}
@test "REST - PutObject - STREAMING-UNSIGNED-PAYLOAD-TRAILER - success (crc32c)" {
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
bucket_name="$output"
run setup_bucket_v2 "$bucket_name"
assert_success
run get_file_name
assert_success
test_file="$output"
run create_test_file "$test_file" 10000
assert_success
run bash -c "DATA_FILE=$TEST_FILE_FOLDER/$test_file CHECKSUM_TYPE=crc32c ./tests/rest_scripts/calculate_checksum.sh"
assert_success
checksum=$output
run send_openssl_go_command_check_header "200" "x-amz-checksum-crc32c" "$checksum" \
"-client" "openssl" "-commandType" "putObject" "-bucketName" "$bucket_name" "-payloadFile" "$TEST_FILE_FOLDER/$test_file" "-checksumType" "crc32c" \
"-payloadType" "STREAMING-UNSIGNED-PAYLOAD-TRAILER" "-chunkSize" "8192" "-objectKey" "key" "-checksumType" "crc32c" "-signedParams" "x-amz-trailer:x-amz-checksum-crc32c"
assert_success
}
@test "REST - PutObject - STREAMING-UNSIGNED-PAYLOAD-TRAILER - success (crc64nvme)" {
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
bucket_name="$output"
run setup_bucket_v2 "$bucket_name"
assert_success
run get_file_name
assert_success
test_file="$output"
run create_test_file "$test_file" 10000
assert_success
run bash -c "DATA_FILE=$TEST_FILE_FOLDER/$test_file CHECKSUM_TYPE=crc64nvme ./tests/rest_scripts/calculate_checksum.sh"
assert_success
checksum=$output
run send_openssl_go_command_check_header "200" "x-amz-checksum-crc64nvme" "$checksum" \
"-client" "openssl" "-commandType" "putObject" "-bucketName" "$bucket_name" "-payloadFile" "$TEST_FILE_FOLDER/$test_file" "-checksumType" "crc64nvme" \
"-payloadType" "STREAMING-UNSIGNED-PAYLOAD-TRAILER" "-chunkSize" "8192" "-objectKey" "key" "-signedParams" "x-amz-trailer:x-amz-checksum-crc64nvme"
assert_success
}
@test "REST - PutObject - STREAMING-AWS4-HMAC-SHA256-PAYLOAD - missing content length" {
if [ "$DIRECT" != "true" ]; then
skip "https://github.com/versity/versitygw/issues/1623"
fi
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
bucket_name="$output"
run setup_bucket_v2 "$bucket_name"
assert_success
run send_openssl_go_command_chunked_no_content_length "$bucket_name" "key"
assert_success
}
@test "REST - PutObject - STREAMING-UNSIGNED-PAYLOAD-TRAILER, x-amz-trailer of crc32, trailer key missing" {
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
bucket_name="$output"
run setup_bucket_v2 "$bucket_name"
assert_success
run send_openssl_go_command_expect_error "400" "MalformedTrailerError" "The request contained trailing data that was not well-formed" \
"-client" "openssl" "-commandType" "putObject" "-bucketName" "$bucket_name" "-objectKey" "key" "-payload" "abcdefg" "-checksumType" "crc32c" \
"-omitPayloadTrailerKey" \
"-payloadType" "STREAMING-UNSIGNED-PAYLOAD-TRAILER" "-chunkSize" "8192" "-objectKey" "key" "-signedParams" "x-amz-trailer:x-amz-checksum-crc32"
assert_success
}
@test "REST - PutObject - STREAMING-UNSIGNED-PAYLOAD-TRAILER - default crc64nvme" {
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
bucket_name="$output"
run setup_bucket_v2 "$bucket_name"
assert_success
run get_file_name
assert_success
test_file="$output"
run create_test_file "$test_file" 1024
assert_success
run send_openssl_go_command "200" "-bucketName" "$bucket_name" "-objectKey" "$test_file" "-commandType" "putObject" \
"-payloadFile" "$TEST_FILE_FOLDER/$test_file" "-omitPayloadTrailer" \
"-debug" "-logFile" "tagging.log" "-checksumType" "crc64nvme" \
"-payloadType" "STREAMING-UNSIGNED-PAYLOAD-TRAILER" "-chunkSize" "8192"
assert_success
}

60
tests/test_rest_copy_object.sh Executable file
View File

@@ -0,0 +1,60 @@
#!/usr/bin/env bats
# Copyright 2026 Versity Software
# This file is licensed under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http:#www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
load ./bats-support/load
load ./bats-assert/load
source ./tests/drivers/copy_object/copy_object_rest.sh
source ./tests/drivers/create_bucket/create_bucket_rest.sh
source ./tests/setup.sh
@test "REST - copy object w/invalid copy source" {
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
bucket_name="$output"
run get_file_name
assert_success
test_file=$output
run setup_bucket_and_file_v2 "$bucket_name" "$test_file"
assert_success
run put_object "rest" "$TEST_FILE_FOLDER/$test_file" "$bucket_name" "$test_file"
assert_success
run copy_object_invalid_copy_source "$bucket_name"
assert_success
}
@test "REST - copy object w/copy source and payload" {
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
bucket_name="$output"
run get_file_name
assert_success
test_file=$output
run setup_bucket_and_file_v2 "$bucket_name" "$test_file"
assert_success
run put_object "rest" "$TEST_FILE_FOLDER/$test_file" "$bucket_name" "$test_file"
assert_success
run copy_object_copy_source_and_payload "$bucket_name" "$test_file" "$TEST_FILE_FOLDER/$test_file"
assert_success
}

View File

@@ -145,3 +145,26 @@ source ./tests/drivers/put_object/put_object_rest.sh
run send_rest_go_command "204" "-method" "DELETE" "-bucketName" "$bucket_name" "-signedParams" "x-amz-expected-bucket-owner:$AWS_USER_ID"
assert_success
}
@test "REST - DeleteBucket - BucketNotEmpty error contains bucket that is not empty" {
if [ "$DIRECT" != "true" ]; then
skip "https://github.com/versity/versitygw/issues/1780"
fi
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
bucket_name="$output"
run get_file_name
assert_success
test_file="$output"
run setup_bucket_and_file_v2 "$bucket_name" "$test_file"
assert_success
run put_object_rest "$TEST_FILE_FOLDER/$test_file" "$bucket_name" "$test_file"
assert_success
run send_rest_go_command_expect_error_with_specific_arg_name_value "409" "BucketNotEmpty" "not empty" "BucketName" "$bucket_name" \
"-bucketName" "$bucket_name" "-method" "DELETE"
assert_success
}

View File

@@ -0,0 +1,97 @@
#!/usr/bin/env bats
# Copyright 2026 Versity Software
# This file is licensed under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http:#www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
load ./bats-support/load
load ./bats-assert/load
source ./tests/drivers/create_bucket/create_bucket_rest.sh
source ./tests/util/util_delete_object.sh
source ./tests/setup.sh
@test "test_rest_delete_object" {
run get_file_name
assert_success
test_file="$output"
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
bucket_name="$output"
run setup_bucket_and_file_v2 "$bucket_name" "$test_file"
assert_success
run put_object "rest" "$TEST_FILE_FOLDER/$test_file" "$bucket_name" "$test_file"
assert_success
run download_and_compare_file "$TEST_FILE_FOLDER/$test_file" "$bucket_name" "$test_file" "$TEST_FILE_FOLDER/$test_file-copy"
assert_success
run delete_object "rest" "$bucket_name" "$test_file"
assert_success
run get_object "rest" "$bucket_name" "$test_file" "$TEST_FILE_FOLDER/$test_file-copy"
assert_failure
}
@test "REST - delete objects - no content-md5 header" {
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
bucket_name="$output"
run setup_bucket_v2 "$bucket_name"
assert_success
run delete_objects_no_content_md5_header "$bucket_name"
assert_success
}
@test "REST - delete objects command" {
run get_file_name
assert_success
test_file="$output"
run get_file_name
assert_success
test_file_two="$output"
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
bucket_name="$output"
run setup_bucket_and_files_v2 "$bucket_name" "$test_file" "$test_file_two"
assert_success
run put_object "rest" "$TEST_FILE_FOLDER/$test_file" "$bucket_name" "$test_file"
assert_success
run put_object "rest" "$TEST_FILE_FOLDER/$test_file_two" "$bucket_name" "$test_file_two"
assert_success
run verify_object_exists "$bucket_name" "$test_file"
assert_success
run verify_object_exists "$bucket_name" "$test_file_two"
assert_success
run delete_objects_verify_success "$bucket_name" "$test_file" "$test_file_two"
assert_success
run verify_object_not_found "$bucket_name" "$test_file"
assert_success
run verify_object_not_found "$bucket_name" "$test_file_two"
assert_success
}

82
tests/test_rest_get_object.sh Executable file
View File

@@ -0,0 +1,82 @@
#!/usr/bin/env bats
# Copyright 2026 Versity Software
# This file is licensed under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http:#www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
load ./bats-support/load
load ./bats-assert/load
source ./tests/drivers/create_bucket/create_bucket_rest.sh
source ./tests/setup.sh
@test "REST - range download and compare" {
run get_file_name
assert_success
test_file="$output"
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
bucket_name="$output"
run setup_bucket_and_large_file_v2 "$bucket_name" "$test_file"
assert_success
run put_object "rest" "$TEST_FILE_FOLDER/$test_file" "$bucket_name" "$test_file"
assert_success
download_chunk_size="2000000"
run download_and_compare_file "$TEST_FILE_FOLDER/$test_file" "$bucket_name" "$test_file" "$TEST_FILE_FOLDER/$test_file-copy" "$download_chunk_size"
assert_success
}
@test "REST - put, get object, encoded name" {
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
bucket_name="$output"
file_name=" \"<>\\^\`{}|+&?%"
run setup_bucket_and_file_v2 "$bucket_name" "$file_name"
assert_success
run put_object_rest_special_chars "$TEST_FILE_FOLDER/$file_name" "$bucket_name" "$file_name/$file_name"
assert_success
run list_check_single_object "$bucket_name" "$file_name/$file_name"
assert_success
run get_object_rest_special_chars "$bucket_name" "$file_name/$file_name" "$TEST_FILE_FOLDER/${file_name}-copy"
assert_success
run compare_files "$TEST_FILE_FOLDER/$file_name" "$TEST_FILE_FOLDER/${file_name}-copy"
assert_success
run delete_object_rest "$bucket_name" "$file_name/$file_name"
assert_success
}
@test "REST - GetObject w/STREAMING-AWS4-HMAC-SHA256-PAYLOAD type" {
run get_file_name
assert_success
test_file="$output"
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
bucket_name="$output"
run setup_bucket_and_file_v2 "$bucket_name" "$test_file"
assert_success
run get_object_rest_with_invalid_streaming_type "$bucket_name" "$test_file"
assert_success
}

View File

@@ -0,0 +1,65 @@
#!/usr/bin/env bats
# Copyright 2026 Versity Software
# This file is licensed under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http:#www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
load ./bats-support/load
load ./bats-assert/load
source ./tests/setup.sh
source ./tests/drivers/create_bucket/create_bucket_rest.sh
@test "REST - legal hold, get without config" {
if [ "$RECREATE_BUCKETS" == "false" ]; then
skip "test requires object lock not to be enabled"
fi
run get_file_name
assert_success
test_file="$output"
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
bucket_name="$output"
run setup_bucket_and_file_v2 "$bucket_name" "$test_file"
assert_success
run put_object "rest" "$TEST_FILE_FOLDER/$test_file" "$bucket_name" "$test_file"
assert_success
run check_legal_hold_without_lock_enabled "$bucket_name" "$test_file" "InvalidRequest"
assert_success
}
@test "REST - legal hold, object lock enabled w/o specific object lock set" {
run get_file_name
assert_success
test_file="$output"
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
bucket_name="$output"
run setup_bucket_object_lock_enabled_v2 "$bucket_name"
assert_success
run create_test_file "$test_file"
assert_success
run put_object "rest" "$TEST_FILE_FOLDER/$test_file" "$bucket_name" "$test_file"
assert_success
run check_legal_hold_without_lock_enabled "$bucket_name" "$test_file" "NoSuchObjectLockConfiguration"
assert_success
}

View File

@@ -18,12 +18,17 @@ load ./bats-support/load
load ./bats-assert/load
source ./tests/setup.sh
source ./tests/commands/delete_object_tagging.sh
source ./tests/commands/put_object_tagging.sh
source ./tests/drivers/create_bucket/create_bucket_rest.sh
source ./tests/drivers/get_object_tagging/get_object_tagging.sh
source ./tests/drivers/get_object_tagging/get_object_tagging_rest.sh
source ./tests/drivers/put_object/put_object_rest.sh
@test "REST - GetObjectTagging - no tags" {
test_file="test_file"
run get_file_name
assert_success
test_file="$output"
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
@@ -37,7 +42,9 @@ source ./tests/drivers/put_object/put_object_rest.sh
}
@test "REST - GetObjectTagging - older version returns version ID" {
test_file="test_file"
run get_file_name
assert_success
test_file="$output"
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
@@ -54,7 +61,9 @@ source ./tests/drivers/put_object/put_object_rest.sh
if [ "$DIRECT" != "true" ]; then
skip "https://github.com/versity/versitygw/issues/1698"
fi
test_file="test_file"
run get_file_name
assert_success
test_file="$output"
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
@@ -71,4 +80,35 @@ source ./tests/drivers/put_object/put_object_rest.sh
run get_object_tagging_invalid_version_id "$bucket_name" "$test_file"
assert_success
}
}
@test "test_rest_tagging" {
test_key="TestKey"
test_value="TestValue"
run get_file_name
assert_success
test_file="$output"
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
bucket_name="$output"
run setup_bucket_and_file_v2 "$bucket_name" "$test_file"
assert_success
run put_object "rest" "$TEST_FILE_FOLDER/$test_file" "$bucket_name" "$test_file"
assert_success
run put_object_tagging "rest" "$bucket_name" "$test_file" "$test_key" "$test_value"
assert_success
run check_verify_object_tags "rest" "$bucket_name" "$test_file" "$test_key" "$test_value"
assert_success
run delete_object_tagging "rest" "$bucket_name" "$test_file"
assert_success
run verify_no_object_tags "rest" "$bucket_name" "$test_file"
assert_success
}

45
tests/test_rest_head_object.sh Executable file
View File

@@ -0,0 +1,45 @@
#!/usr/bin/env bats
# Copyright 2026 Versity Software
# This file is licensed under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http:#www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
load ./bats-support/load
load ./bats-assert/load
source ./tests/setup.sh
source ./tests/drivers/create_bucket/create_bucket_rest.sh
source ./tests/drivers/get_object_attributes/get_object_attributes_rest.sh
@test "REST - head object" {
run get_file_name
assert_success
test_file="$output"
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
bucket_name="$output"
run setup_bucket_and_file_v2 "$bucket_name" "$test_file"
assert_success
run put_object "rest" "$TEST_FILE_FOLDER/$test_file" "$bucket_name" "$test_file"
assert_success
run get_etag_rest "$bucket_name" "$test_file"
assert_success
expected_etag=$output
run get_etag_attribute_rest "$bucket_name" "$test_file" "$expected_etag"
assert_success
}

110
tests/test_rest_list_objects.sh Executable file
View File

@@ -0,0 +1,110 @@
#!/usr/bin/env bats
# Copyright 2026 Versity Software
# This file is licensed under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http:#www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
load ./bats-support/load
load ./bats-assert/load
source ./tests/setup.sh
source ./tests/drivers/create_bucket/create_bucket_rest.sh
@test "test_rest_list_objects" {
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
bucket_name="$output"
run get_file_name
assert_success
test_file="$output"
run setup_bucket_and_file_v2 "$bucket_name" "$test_file"
assert_success
run put_object "rest" "$TEST_FILE_FOLDER/$test_file" "$bucket_name" "$test_file"
assert_success
run list_check_objects_rest "$bucket_name"
assert_success
}
@test "REST - list objects v2 - invalid continuation token" {
if [ "$DIRECT" != "true" ]; then
skip "https://github.com/versity/versitygw/issues/993"
fi
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
bucket_name="$output"
run get_file_name
assert_success
test_file="$output"
run get_file_name
assert_success
test_file_two="$output"
run get_file_name
assert_success
test_file_three="$output"
run setup_bucket_and_files_v2 "$bucket_name" "$test_file" "$test_file_two" "$test_file_three"
assert_success
run put_object "rest" "$TEST_FILE_FOLDER/$test_file" "$bucket_name" "$test_file"
assert_success
run put_object "rest" "$TEST_FILE_FOLDER/$test_file_two" "$bucket_name" "$test_file_two"
assert_success
run put_object "rest" "$TEST_FILE_FOLDER/$test_file_three" "$bucket_name" "$test_file_three"
assert_success
run list_objects_check_params_get_token "$bucket_name" "$test_file" "$test_file_two" "TRUE"
assert_success
continuation_token=$output
# interestingly, AWS appears to accept continuation tokens that are a few characters off, so have to remove three chars
run list_objects_check_continuation_error "$bucket_name" "${continuation_token:0:${#continuation_token}-3}"
assert_success
}
@test "REST - list objects v1 - no NextMarker without delimiter" {
if [ "$DIRECT" != "true" ]; then
skip "https://github.com/versity/versitygw/issues/999"
fi
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
bucket_name="$output"
run get_file_name
assert_success
test_file="$output"
run get_file_name
assert_success
test_file_two="$output"
run setup_bucket_and_files_v2 "$bucket_name" "$test_file" "$test_file_two"
assert_success
run put_object "rest" "$TEST_FILE_FOLDER/$test_file" "$bucket_name" "$test_file"
assert_success
run put_object "rest" "$TEST_FILE_FOLDER/$test_file_two" "$bucket_name" "$test_file_two"
assert_success
run list_objects_v1_check_nextmarker_empty "$bucket_name"
assert_success
}

View File

@@ -247,164 +247,6 @@ export RUN_USERS=true
assert_success
}
@test "REST - PutObject - STREAMING-UNSIGNED-PAYLOAD-TRAILER, x-amz-trailer of crc32, trailer missing" {
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
bucket_name="$output"
run setup_bucket_v2 "$bucket_name"
assert_success
run send_openssl_go_command_expect_error "400" "MalformedTrailerError" "The request contained trailing data that was not well-formed" \
"-client" "openssl" "-commandType" "putObject" "-bucketName" "$bucket_name" "-payload" "abcdefg" \
"-omitPayloadTrailer" "-checksumType" "crc32" \
"-payloadType" "STREAMING-UNSIGNED-PAYLOAD-TRAILER" "-chunkSize" "8192" "-objectKey" "key" "-signedParams" "x-amz-trailer:x-amz-checksum-crc32"
assert_success
}
@test "REST - PutObject - STREAMING-UNSIGNED-PAYLOAD-TRAILER - 200 header returns correct checksum type" {
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
bucket_name="$output"
run setup_bucket_and_file_v2 "$bucket_name" "$test_file"
assert_success
checksum="$(sha256sum "$TEST_FILE_FOLDER/$test_file" | awk '{print $1}' | xxd -r -p | base64)"
run send_openssl_go_command_check_header "200" "x-amz-checksum-sha256" "$checksum" \
"-client" "openssl" "-commandType" "putObject" "-bucketName" "$bucket_name" "-payloadFile" "$TEST_FILE_FOLDER/$test_file" "-checksumType" "sha256" \
"-payloadType" "STREAMING-UNSIGNED-PAYLOAD-TRAILER" "-chunkSize" "8192" "-objectKey" "key" "-signedParams" "x-amz-trailer:x-amz-checksum-sha256"
assert_success
}
@test "REST - PutObject - STREAMING-UNSIGNED-PAYLOAD-TRAILER - success (sha1)" {
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
bucket_name="$output"
run setup_bucket_v2 "$bucket_name"
assert_success
run create_test_file "$test_file" 10000
assert_success
checksum="$(sha1sum "$TEST_FILE_FOLDER/$test_file" | awk '{print $1}' | xxd -r -p | base64)"
run send_openssl_go_command_check_header "200" "x-amz-checksum-sha1" "$checksum" \
"-client" "openssl" "-commandType" "putObject" "-bucketName" "$bucket_name" "-payloadFile" "$TEST_FILE_FOLDER/$test_file" "-checksumType" "sha1" \
"-payloadType" "STREAMING-UNSIGNED-PAYLOAD-TRAILER" "-chunkSize" "8192" "-objectKey" "key" "-signedParams" "x-amz-trailer:x-amz-checksum-sha1"
assert_success
}
@test "REST - PutObject - STREAMING-UNSIGNED-PAYLOAD-TRAILER - success (crc32)" {
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
bucket_name="$output"
run setup_bucket_v2 "$bucket_name"
assert_success
run create_test_file "$test_file" 10000
assert_success
checksum="$(gzip -c -1 "$TEST_FILE_FOLDER/$test_file" | tail -c8 | od -t x4 -N 4 -A n | awk '{print $1}' | xxd -r -p | base64)"
run send_openssl_go_command_check_header "200" "x-amz-checksum-crc32" "$checksum" \
"-client" "openssl" "-commandType" "putObject" "-bucketName" "$bucket_name" "-payloadFile" "$TEST_FILE_FOLDER/$test_file" "-checksumType" "crc32" \
"-payloadType" "STREAMING-UNSIGNED-PAYLOAD-TRAILER" "-chunkSize" "8192" "-objectKey" "key" "-signedParams" "x-amz-trailer:x-amz-checksum-crc32"
assert_success
}
@test "REST - PutObject - STREAMING-UNSIGNED-PAYLOAD-TRAILER - success (crc32c)" {
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
bucket_name="$output"
run setup_bucket_v2 "$bucket_name"
assert_success
run create_test_file "$test_file" 10000
assert_success
if ! checksum=$(DATA_FILE="$TEST_FILE_FOLDER/$test_file" CHECKSUM_TYPE="crc32c" ./tests/rest_scripts/calculate_checksum.sh 2>&1); then
log 2 "error calculating checksum: $checksum"
return 1
fi
run send_openssl_go_command_check_header "200" "x-amz-checksum-crc32c" "$checksum" \
"-client" "openssl" "-commandType" "putObject" "-bucketName" "$bucket_name" "-payloadFile" "$TEST_FILE_FOLDER/$test_file" "-checksumType" "crc32c" \
"-payloadType" "STREAMING-UNSIGNED-PAYLOAD-TRAILER" "-chunkSize" "8192" "-objectKey" "key" "-checksumType" "crc32c" "-signedParams" "x-amz-trailer:x-amz-checksum-crc32c"
assert_success
}
@test "REST - PutObject - STREAMING-UNSIGNED-PAYLOAD-TRAILER - success (crc64nvme)" {
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
bucket_name="$output"
run setup_bucket_v2 "$bucket_name"
assert_success
run create_test_file "$test_file" 10000
assert_success
if ! checksum=$(DATA_FILE="$TEST_FILE_FOLDER/$test_file" CHECKSUM_TYPE="crc64nvme" ./tests/rest_scripts/calculate_checksum.sh 2>&1); then
log 2 "error calculating checksum: $checksum"
return 1
fi
run send_openssl_go_command_check_header "200" "x-amz-checksum-crc64nvme" "$checksum" \
"-client" "openssl" "-commandType" "putObject" "-bucketName" "$bucket_name" "-payloadFile" "$TEST_FILE_FOLDER/$test_file" "-checksumType" "crc64nvme" \
"-payloadType" "STREAMING-UNSIGNED-PAYLOAD-TRAILER" "-chunkSize" "8192" "-objectKey" "key" "-signedParams" "x-amz-trailer:x-amz-checksum-crc64nvme"
assert_success
}
@test "REST - PutObject - STREAMING-AWS4-HMAC-SHA256-PAYLOAD - missing content length" {
if [ "$DIRECT" != "true" ]; then
skip "https://github.com/versity/versitygw/issues/1623"
fi
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
bucket_name="$output"
run setup_bucket_v2 "$bucket_name"
assert_success
run send_openssl_go_command_chunked_no_content_length "$bucket_name" "key"
assert_success
}
@test "REST - PutObject - STREAMING-UNSIGNED-PAYLOAD-TRAILER, x-amz-trailer of crc32, trailer key missing" {
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
bucket_name="$output"
run setup_bucket_v2 "$bucket_name"
assert_success
run send_openssl_go_command_expect_error "400" "MalformedTrailerError" "The request contained trailing data that was not well-formed" \
"-client" "openssl" "-commandType" "putObject" "-bucketName" "$bucket_name" "-objectKey" "key" "-payload" "abcdefg" "-checksumType" "crc32c" \
"-omitPayloadTrailerKey" \
"-payloadType" "STREAMING-UNSIGNED-PAYLOAD-TRAILER" "-chunkSize" "8192" "-objectKey" "key" "-signedParams" "x-amz-trailer:x-amz-checksum-crc32"
assert_success
}
@test "REST - PutObject - STREAMING-UNSIGNED-PAYLOAD-TRAILER - default crc64nvme" {
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
bucket_name="$output"
run setup_bucket_and_file_v2 "$bucket_name" "$test_file"
assert_success
run send_openssl_go_command "200" "-bucketName" "$bucket_name" "-objectKey" "$test_file" "-commandType" "putObject" \
"-payloadFile" "$TEST_FILE_FOLDER/$test_file" "-omitPayloadTrailer" \
"-debug" "-logFile" "tagging.log" "-checksumType" "crc64nvme" \
"-payloadType" "STREAMING-UNSIGNED-PAYLOAD-TRAILER" "-chunkSize" "8192"
assert_success
}
@test "REST - PutObject - invalid x-amz-request-payer" {
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
@@ -548,3 +390,126 @@ export RUN_USERS=true
run put_object_with_lock_mode_and_delete_latest_version "$TEST_FILE_FOLDER/$test_file" "$bucket_name" "$test_file" "$later_date"
assert_success
}
@test "PutObject - x-amz-acl - not implemented" {
if [ "$DIRECT" != "true" ]; then
skip "https://github.com/versity/versitygw/issues/1767"
fi
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
bucket_name="$output"
run get_file_name
assert_success
test_file="$output"
run setup_bucket_and_file_v2 "$bucket_name" "$test_file"
assert_success
run put_bucket_ownership_controls_rest "$bucket_name" "BucketOwnerPreferred"
assert_success
if [ "$DIRECT" == "true" ]; then
run allow_public_access "$bucket_name"
assert_success
fi
run send_rest_go_command_expect_error "501" "NotImplemented" "not implemented" "-method" "PUT" "-payloadFile" "$TEST_FILE_FOLDER/$test_file" "-bucketName" "$bucket_name" \
"-objectKey" "$test_file" "-signedParams" "x-amz-acl:public-read"
assert_success
}
@test "PutObject - x-amz-grant-full-control - not implemented" {
if [ "$DIRECT" != "true" ]; then
skip "https://github.com/versity/versitygw/issues/1773"
fi
run attempt_put_object_with_specific_acl "x-amz-grant-full-control"
assert_success
}
@test "PutObject - x-amz-grant-read - not implemented" {
if [ "$DIRECT" != "true" ]; then
skip "https://github.com/versity/versitygw/issues/1773"
fi
run attempt_put_object_with_specific_acl "x-amz-grant-read"
assert_success
}
@test "PutObject - x-amz-grant-read-acp - not implemented" {
if [ "$DIRECT" != "true" ]; then
skip "https://github.com/versity/versitygw/issues/1773"
fi
run attempt_put_object_with_specific_acl "x-amz-grant-read-acp"
assert_success
}
@test "PutObject - x-amz-grant-write-acp - not implemented" {
if [ "$DIRECT" != "true" ]; then
skip "https://github.com/versity/versitygw/issues/1773"
fi
run attempt_put_object_with_specific_acl "x-amz-grant-write-acp"
assert_success
}
@test "PutObject - x-amz-object-lock-legal-hold - invalid value" {
if [ "$DIRECT" != "true" ]; then
skip "https://github.com/versity/versitygw/issues/1775"
fi
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
bucket_name="$output"
run get_file_name
assert_success
test_file="$output"
run setup_bucket_and_file_v2 "$bucket_name" "$test_file"
assert_success
local legal_hold_value="wrong"
run send_rest_go_command_expect_error_with_arg_name_value "400" "InvalidArgument" "Legal Hold must be either of" \
"x-amz-object-lock-legal-hold" "$legal_hold_value" "-method" "PUT" "-payloadFile" "$TEST_FILE_FOLDER/$test_file" \
"-bucketName" "$bucket_name" "-objectKey" "$test_file" "-signedParams" "x-amz-object-lock-legal-hold:$legal_hold_value"
assert_success
}
@test "PutObject - x-amz-object-lock-legal-hold - no Content-MD5" {
if [ "$DIRECT" != "true" ]; then
skip "https://github.com/versity/versitygw/issues/1776"
fi
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
bucket_name="$output"
test_file="test_file_1234567890"
run setup_bucket_object_lock_enabled_v2 "$bucket_name"
assert_success
run create_test_file "$test_file"
assert_success
run send_rest_go_command_expect_error "400" "InvalidRequest" "Content-MD5" "-method" "PUT" "-payloadFile" "$TEST_FILE_FOLDER/$test_file" "-bucketName" "$bucket_name" \
"-objectKey" "$test_file" "-signedParams" "x-amz-object-lock-legal-hold:ON"
assert_success
}
@test "PutObject - x-amz-object-lock-legal-hold - success" {
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
bucket_name="$output"
test_file="test_file_$(uuidgen)"
run setup_bucket_object_lock_enabled_v2 "$bucket_name"
assert_success
run create_test_file "$test_file"
assert_success
run send_rest_go_command "200" "-method" "PUT" "-payloadFile" "$TEST_FILE_FOLDER/$test_file" "-bucketName" "$bucket_name" \
"-objectKey" "$test_file" "-signedParams" "x-amz-object-lock-legal-hold:ON" "-contentMD5"
assert_success
run rest_check_legal_hold "$bucket_name" "$test_file"
assert_success
}

View File

@@ -0,0 +1,87 @@
#!/usr/bin/env bats
# Copyright 2026 Versity Software
# This file is licensed under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http:#www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
load ./bats-support/load
load ./bats-assert/load
source ./tests/setup.sh
source ./tests/drivers/create_bucket/create_bucket_rest.sh
@test "REST - PutObjectLegalHold - missing content-md5" {
run get_file_name
assert_success
test_file="$output"
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
bucket_name="$output"
run setup_bucket_object_lock_enabled_v2 "$bucket_name"
assert_success
run create_test_file "$test_file"
assert_success
run put_object "rest" "$TEST_FILE_FOLDER/$test_file" "$bucket_name" "$test_file"
assert_success
run check_legal_hold_without_content_md5 "$bucket_name" "$test_file"
assert_success
}
@test "REST - PutObjectLegalHold w/o payload" {
run get_file_name
assert_success
test_file="$output"
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
bucket_name="$output"
run setup_bucket_object_lock_enabled_v2 "$bucket_name"
assert_success
run create_test_file "$test_file"
assert_success
run put_object "rest" "$TEST_FILE_FOLDER/$test_file" "$bucket_name" "$test_file"
assert_success
run check_legal_hold_without_payload "$bucket_name" "$test_file"
assert_success
}
@test "REST - PutObjectLegalHold - success" {
run get_file_name
assert_success
test_file="$output"
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
bucket_name="$output"
run setup_bucket_object_lock_enabled_v2 "$bucket_name"
assert_success
run create_test_file "$test_file"
assert_success
run put_object "rest" "$TEST_FILE_FOLDER/$test_file" "$bucket_name" "$test_file"
assert_success
run rest_check_legal_hold "$bucket_name" "$test_file"
assert_success
}

73
tests/test_rest_retention.sh Executable file
View File

@@ -0,0 +1,73 @@
#!/usr/bin/env bats
# Copyright 2026 Versity Software
# This file is licensed under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http:#www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
load ./bats-support/load
load ./bats-assert/load
source ./tests/setup.sh
source ./tests/commands/put_object_retention.sh
source ./tests/drivers/create_bucket/create_bucket_rest.sh
source ./tests/util/util_time.sh
@test "test_rest_retention" {
run get_file_name
assert_success
test_file="$output"
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
bucket_name="$output"
run setup_bucket_object_lock_enabled_v2 "$bucket_name"
assert_success
run create_test_files "$test_file"
assert_success
run put_object "rest" "$TEST_FILE_FOLDER/$test_file" "$bucket_name" "$test_file"
assert_success
run get_time_seconds_in_future 5 "%z"
assert_success
five_seconds_later=${output}
log 5 "later: $five_seconds_later"
run put_object_retention_rest "$bucket_name" "$test_file" "GOVERNANCE" "$five_seconds_later"
assert_success
}
@test "REST - PutObjectRetention - w/o request body" {
run get_file_name
assert_success
test_file="$output"
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
bucket_name="$output"
run setup_bucket_object_lock_enabled_v2 "$bucket_name"
assert_success
run create_test_file "$test_file"
assert_success
run put_object "rest" "$TEST_FILE_FOLDER/$test_file" "$bucket_name" "$test_file"
assert_success
run retention_rest_without_request_body "$bucket_name" "$test_file"
assert_success
}

View File

@@ -18,10 +18,6 @@ load ./bats-support/load
load ./bats-assert/load
source ./tests/setup.sh
source ./tests/util/util_file.sh
source ./tests/util/util_lock_config.sh
source ./tests/util/util_object.sh
source ./tests/test_s3api_root_inner.sh
source ./tests/test_common.sh
source ./tests/test_common_acl.sh
source ./tests/commands/copy_object.sh
@@ -50,6 +46,11 @@ source ./tests/drivers/copy_object/copy_object_rest.sh
source ./tests/drivers/get_object_tagging/get_object_tagging.sh
source ./tests/drivers/list_buckets/list_buckets_rest.sh
source ./tests/drivers/put_bucket_ownership_controls/put_bucket_ownership_controls_rest.sh
source ./tests/drivers/file.sh
source ./tests/util/util_file.sh
source ./tests/util/util_lock_config.sh
source ./tests/util/util_object.sh
source ./tests/test_s3api_root_inner.sh
export RUN_USERS=true

View File

@@ -17,6 +17,7 @@
source ./tests/commands/delete_objects.sh
source ./tests/commands/list_objects_v2.sh
source ./tests/commands/list_parts.sh
source ./tests/drivers/put_object/put_object.sh
source ./tests/util/util_get_bucket_acl.sh
source ./tests/util/util_get_object_attributes.sh
source ./tests/util/util_get_object_retention.sh
@@ -108,33 +109,42 @@ test_get_put_object_legal_hold_s3api_root() {
if [ "$SKIP_USERS_TESTS" == "true" ]; then
skip "skipping versitygw-specific users tests"
fi
bucket_file="bucket_file"
run get_file_name
assert_success
# shellcheck disable=SC2154
bucket_file="$output"
username=$USERNAME_ONE
password=$PASSWORD_ONE
run legal_hold_retention_setup "$username" "$password" "$bucket_file"
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
bucket_name="$output"
run legal_hold_retention_setup "$bucket_name" "$username" "$password" "$bucket_file"
assert_success
run get_check_object_lock_config_enabled "$BUCKET_ONE_NAME"
run get_check_object_lock_config_enabled "$bucket_name"
assert_success
run put_object_legal_hold "s3api" "$BUCKET_ONE_NAME" "$bucket_file" "ON"
run put_object_legal_hold "s3api" "$bucket_name" "$bucket_file" "ON"
assert_success
run get_and_check_legal_hold "s3api" "$BUCKET_ONE_NAME" "$bucket_file" "ON"
run get_and_check_legal_hold "s3api" "$bucket_name" "$bucket_file" "ON"
assert_success
echo "fdkljafajkfs" > "$TEST_FILE_FOLDER/$bucket_file"
run put_object_with_user "s3api" "$TEST_FILE_FOLDER/$bucket_file" "$BUCKET_ONE_NAME" "$bucket_file" "$username" "$password"
run put_object_with_user "s3api" "$TEST_FILE_FOLDER/$bucket_file" "$bucket_name" "$bucket_file" "$username" "$password"
assert_success
run delete_object_with_user "s3api" "$BUCKET_ONE_NAME" "$bucket_file" "$username" "$password"
run delete_object_with_user "s3api" "$bucket_name" "$bucket_file" "$username" "$password"
assert_success
run put_object_legal_hold "s3api" "$BUCKET_ONE_NAME" "$bucket_file" "OFF"
assert_success
run put_object_legal_hold "s3api" "$bucket_name" "$bucket_file" "OFF"
assert_failure
assert_output -p "MethodNotAllowed"
run delete_object_with_user "s3api" "$BUCKET_ONE_NAME" "$bucket_file" "$username" "$password"
run delete_delete_marker_without_object_lock "$bucket_name" "$bucket_file"
assert_success
}
@@ -142,14 +152,21 @@ test_get_put_object_retention_s3api_root() {
if [ "$SKIP_USERS_TESTS" == "true" ]; then
skip "skipping versitygw-specific users tests"
fi
bucket_file="bucket_file"
run get_file_name
assert_success
bucket_file="$output"
username=$USERNAME_ONE
secret_key=$PASSWORD_ONE
run legal_hold_retention_setup "$username" "$secret_key" "$bucket_file"
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
bucket_name="$output"
run legal_hold_retention_setup "$bucket_name" "$username" "$secret_key" "$bucket_file"
assert_success
run get_check_object_lock_config_enabled "$BUCKET_ONE_NAME"
run get_check_object_lock_config_enabled "$bucket_name"
assert_success
if [[ "$OSTYPE" == "darwin"* ]]; then
@@ -159,17 +176,17 @@ test_get_put_object_retention_s3api_root() {
fi
log 5 "retention date: $retention_date"
run put_object_retention "$BUCKET_ONE_NAME" "$bucket_file" "GOVERNANCE" "$retention_date"
run put_object_retention "$bucket_name" "$bucket_file" "GOVERNANCE" "$retention_date"
assert_success
run get_check_object_retention "$BUCKET_ONE_NAME" "$bucket_file" "$retention_date"
run get_check_object_retention "$bucket_name" "$bucket_file" "$retention_date"
assert_success
echo "fdkljafajkfs" > "$TEST_FILE_FOLDER/$bucket_file"
run put_object_with_user "s3api" "$TEST_FILE_FOLDER/$bucket_file" "$BUCKET_ONE_NAME" "$bucket_file" "$username" "$secret_key"
run put_object_with_user "s3api" "$TEST_FILE_FOLDER/$bucket_file" "$bucket_name" "$bucket_file" "$username" "$secret_key"
assert_success
run delete_object_with_user "s3api" "$BUCKET_ONE_NAME" "$bucket_file" "$username" "$secret_key"
run delete_object_with_user "s3api" "$bucket_name" "$bucket_file" "$username" "$secret_key"
assert_success
}
@@ -177,15 +194,22 @@ test_retention_bypass_s3api_root() {
if [ "$SKIP_USERS_TESTS" == "true" ]; then
skip "skipping versitygw-specific users tests"
fi
bucket_file="bucket_file"
run get_file_name
assert_success
bucket_file="$output"
run get_bucket_name "$BUCKET_ONE_NAME"
assert_success
bucket_name="$output"
username=$USERNAME_ONE
secret_key=$PASSWORD_ONE
policy_file="policy_file"
run legal_hold_retention_setup "$username" "$secret_key" "$bucket_file"
run legal_hold_retention_setup "$bucket_name" "$username" "$secret_key" "$bucket_file"
assert_success
run get_check_object_lock_config_enabled "$BUCKET_ONE_NAME"
run get_check_object_lock_config_enabled "$bucket_name"
assert_success
if [[ "$OSTYPE" == "darwin"* ]]; then
@@ -195,45 +219,20 @@ test_retention_bypass_s3api_root() {
fi
log 5 "retention date: $retention_date"
run put_object_retention "$BUCKET_ONE_NAME" "$bucket_file" "GOVERNANCE" "$retention_date"
run put_object_retention "$bucket_name" "$bucket_file" "GOVERNANCE" "$retention_date"
assert_success
run delete_object_with_user "s3api" "$BUCKET_ONE_NAME" "$bucket_file"
run delete_object_with_user "s3api" "$bucket_name" "$bucket_file"
assert_failure 1
run setup_policy_with_single_statement "$TEST_FILE_FOLDER/$policy_file" "2012-10-17" "Allow" "$username" \
"[\"s3:BypassGovernanceRetention\",\"s3:DeleteObject\"]" "arn:aws:s3:::$BUCKET_ONE_NAME/*"
"[\"s3:BypassGovernanceRetention\",\"s3:DeleteObject\"]" "arn:aws:s3:::$bucket_name/*"
assert_success
run put_bucket_policy "s3api" "$BUCKET_ONE_NAME" "$TEST_FILE_FOLDER/$policy_file"
run put_bucket_policy "s3api" "$bucket_name" "$TEST_FILE_FOLDER/$policy_file"
assert_success
run delete_object_bypass_retention "$BUCKET_ONE_NAME" "$bucket_file" "$username" "$secret_key"
assert_success
}
legal_hold_retention_setup() {
assert [ $# -eq 3 ]
run bucket_cleanup_if_bucket_exists "$BUCKET_ONE_NAME"
assert_success
run setup_user "$1" "$2" "user"
assert_success
run create_test_file "$3"
assert_success
#create_bucket "s3api" "$BUCKET_ONE_NAME" || fail "error creating bucket"
if [[ $RECREATE_BUCKETS == "true" ]]; then
run create_bucket_object_lock_enabled "$BUCKET_ONE_NAME"
assert_success
fi
run change_bucket_owner "$AWS_ACCESS_KEY_ID" "$AWS_SECRET_ACCESS_KEY" "$BUCKET_ONE_NAME" "$1"
assert_success
run put_object_with_user "s3api" "$TEST_FILE_FOLDER/$3" "$BUCKET_ONE_NAME" "$3" "$1" "$2"
run delete_object_bypass_retention "$bucket_name" "$bucket_file" "$username" "$secret_key"
assert_success
}

View File

@@ -20,7 +20,7 @@ get_check_object_retention() {
return 1
fi
# shellcheck disable=SC2154
if ! get_object_retention "$BUCKET_ONE_NAME" "$bucket_file"; then
if ! get_object_retention "$1" "$bucket_file"; then
log 2 "failed to get object retention"
return 1
fi

26
webui/embed.go Normal file
View File

@@ -0,0 +1,26 @@
// Copyright 2026 Versity Software
// This file is licensed under the Apache License, Version 2.0
// (the "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
package webui
import "embed"
// webFiles embeds the admin GUI static files from web/.
// The "all:" prefix recursively includes all files and subdirectories.
//
//go:embed all:web
var webFiles embed.FS
// webFS is an alias for webFiles for consistency with server.go
var webFS = webFiles

View File

@@ -0,0 +1,71 @@
================================================================================
TAILWIND CSS
================================================================================
MIT License
Copyright (c) Tailwind Labs, Inc.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
================================================================================
ROBOTO FONT
================================================================================
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
Copyright 2011 Google Inc. All Rights Reserved.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
================================================================================
CRYPTO-JS
================================================================================
MIT License
Copyright (c) 2009-2013 Jeff Mott
Copyright (c) 2013-2016 Evan Vosberg
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,35 @@
/* Roboto Font - Regular (400) */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('../fonts/roboto-400.woff2') format('woff2');
}
/* Roboto Font - Medium (500) */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 500;
font-display: swap;
src: url('../fonts/roboto-500.woff2') format('woff2');
}
/* Roboto Font - Semi-Bold (600) */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('../fonts/roboto-600.woff2') format('woff2');
}
/* Roboto Font - Bold (700) */
@font-face {
font-family: 'Roboto';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('../fonts/roboto-700.woff2') format('woff2');
}

File diff suppressed because one or more lines are too long

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

1
webui/web/assets/js/crypto-js.min.js vendored Normal file

File diff suppressed because one or more lines are too long

705
webui/web/buckets.html Normal file
View File

@@ -0,0 +1,705 @@
<!--
Copyright 2026 Versity Software
This file is licensed under the Apache License, Version 2.0
(the "License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VersityGW Admin - Buckets</title>
<script src="assets/js/crypto-js.min.js"></script>
<script src="assets/css/tailwind.js"></script>
<link rel="stylesheet" href="assets/css/fonts.css">
<script>
tailwind.config = {
theme: {
extend: {
colors: {
primary: { DEFAULT: '#002A7A', 50: '#E6EBF4', 500: '#002A7A', 600: '#002468' },
accent: { DEFAULT: '#0076CD', 50: '#E6F3FA', 500: '#0076CD', 600: '#0065AF' },
charcoal: { DEFAULT: '#191B2A', 300: '#757884', 400: '#565968' },
surface: { DEFAULT: '#F3F8FC' }
},
fontFamily: { sans: ['Roboto', 'system-ui', 'sans-serif'] },
}
}
}
</script>
<style>
body { font-family: 'Roboto', system-ui, sans-serif; }
.nav-item { transition: all 0.15s ease; }
.nav-item:hover { background: rgba(255,255,255,0.1); }
.nav-item.active { background: rgba(0, 118, 205, 0.2); border-left: 4px solid #0076CD; }
.modal-backdrop { background: rgba(0,0,0,0.5); backdrop-filter: blur(4px); }
/* Custom dropdown styles */
.custom-dropdown {
display: none;
position: absolute;
z-index: 10;
width: 100%;
margin-top: 4px;
background: white;
border: 2px solid #e5e7eb;
border-radius: 0.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
max-height: 12rem;
overflow: auto;
}
.custom-dropdown.show {
display: block;
}
.custom-dropdown-item {
padding: 0.75rem 1rem;
cursor: pointer;
color: #191B2A;
transition: background-color 0.15s;
}
.custom-dropdown-item:hover {
background-color: #f9fafb;
}
.custom-dropdown-item.selected {
background-color: rgba(0, 118, 205, 0.1);
color: #0076CD;
}
/* Dropup variant - opens upward */
.custom-dropdown.dropup {
bottom: 100%;
top: auto;
margin-top: 0;
margin-bottom: 4px;
}
</style>
</head>
<body class="min-h-screen bg-surface">
<script src="js/api.js"></script>
<script src="js/app.js"></script>
<div class="flex h-screen overflow-hidden">
<!-- Sidebar -->
<aside class="w-60 bg-charcoal flex flex-col flex-shrink-0">
<div class="h-16 flex items-center px-6 border-b border-white/10">
<a href="https://www.versity.com" target="_blank" rel="noopener noreferrer">
<img src="assets/images/Versity-logo-white-horizontal.png" alt="Versity" class="h-10 hover:opacity-80 transition-opacity">
</a>
</div>
<nav class="flex-1 py-4">
<div class="px-6 pt-2 pb-2 text-[11px] font-semibold tracking-wider text-white/40 uppercase" data-admin-only>
Admin
</div>
<a href="dashboard.html" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-admin-only>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/>
</svg>
<span class="font-medium">Dashboard</span>
</a>
<a href="users.html" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-admin-only>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/>
</svg>
<span class="font-medium">Users</span>
</a>
<a href="buckets.html" class="nav-item active flex items-center gap-3 px-6 py-3 text-white" data-admin-only>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"/>
</svg>
<span class="font-medium">Buckets</span>
</a>
<div class="mx-6 my-2 border-t border-white/10" data-admin-only></div>
<a href="explorer.html" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/>
</svg>
<span class="font-medium">Explorer</span>
</a>
<div class="mx-6 my-2 border-t border-white/10"></div>
<div class="px-6 pt-2 pb-2 text-[11px] font-semibold tracking-wider text-white/40 uppercase">
Resources
</div>
<a href="https://github.com/versity/versitygw/wiki" target="_blank" rel="noopener noreferrer" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>
</svg>
<span class="font-medium">Documentation</span>
</a>
<a href="https://github.com/versity/versitygw/issues" target="_blank" rel="noopener noreferrer" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-admin-only>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
<span class="font-medium">Bug Reports</span>
</a>
<a href="https://github.com/versity/versitygw/releases" target="_blank" rel="noopener noreferrer" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-admin-only>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/>
</svg>
<span class="font-medium">Releases</span>
</a>
<a href="https://github.com/versity/versitygw" target="_blank" rel="noopener noreferrer" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"/>
</svg>
<span class="font-medium">GitHub</span>
</a>
</nav>
<div class="p-4 border-t border-white/10">
<div id="user-info" class="flex items-center gap-3 mb-3"></div>
<button onclick="api.logout(); window.location.href='index.html';" class="w-full flex items-center gap-2 px-3 py-2 text-white/70 hover:text-white hover:bg-white/10 rounded-lg transition-colors text-sm">
<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="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/>
</svg>
Sign Out
</button>
</div>
</aside>
<!-- Main Content -->
<div class="flex-1 flex flex-col overflow-hidden">
<header class="h-16 bg-white border-b border-gray-200 flex items-center justify-between px-8 flex-shrink-0">
<h1 class="text-xl font-semibold text-charcoal">VersityGW Buckets</h1>
<button onclick="loadBuckets()" class="p-2 text-charcoal-300 hover:text-charcoal hover:bg-gray-100 rounded-lg transition-colors" title="Refresh">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
</button>
</header>
<main class="flex-1 overflow-auto p-8">
<div class="max-w-7xl mx-auto">
<!-- Page Header -->
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-semibold text-charcoal">Buckets</h1>
<p class="text-charcoal-300 mt-1">View and manage bucket ownership</p>
</div>
<button onclick="openCreateBucketDialog()" class="inline-flex items-center gap-2 px-4 py-2.5 bg-accent hover:bg-accent-600 text-white font-medium rounded-lg transition-colors">
<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="M12 4v16m8-8H4"/>
</svg>
Create Bucket
</button>
</div>
<!-- Info Banner -->
<div class="bg-accent-50 border border-accent/20 rounded-xl p-4 mb-6">
<div class="flex items-start gap-3">
<svg class="w-5 h-5 text-accent flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<p class="text-sm text-charcoal">
<span class="font-medium">Note:</span> Create buckets, view existing buckets, and transfer ownership between users.
</p>
</div>
</div>
<!-- Search -->
<div class="bg-white rounded-xl p-4 shadow-sm border border-gray-100 mb-6">
<div class="flex flex-wrap items-center gap-4">
<div class="relative flex-1 min-w-64">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-charcoal-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
<input
type="text"
id="search-input"
placeholder="Search buckets..."
class="w-full pl-10 pr-4 py-2.5 border border-gray-200 rounded-lg text-charcoal placeholder:text-charcoal-300 focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-all"
oninput="filterBuckets()"
>
</div>
<div class="relative" id="owner-filter-container">
<input
type="text"
id="owner-filter-display"
readonly
value="All Owners"
onclick="toggleDropdown('owner-filter')"
class="bg-white border border-gray-200 rounded-lg px-4 py-2.5 pr-10 text-charcoal cursor-pointer focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-all"
>
<input type="hidden" id="owner-filter" value="">
<svg class="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-charcoal-300 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
<div id="owner-filter-dropdown" class="custom-dropdown">
<div class="custom-dropdown-item selected" data-value="" onclick="selectOwnerFilter('')">All Owners</div>
<!-- Populated dynamically -->
</div>
</div>
</div>
</div>
<!-- Buckets Table -->
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full">
<colgroup>
<col style="width: 50%;">
<col style="width: 30%;">
<col style="width: 20%;">
</colgroup>
<thead class="bg-gray-50 border-b border-gray-100">
<tr>
<th class="text-left py-4 px-6 text-sm font-semibold text-charcoal">Bucket Name</th>
<th class="text-left py-4 px-6 text-sm font-semibold text-charcoal">Owner</th>
<th class="text-right py-4 px-6 text-sm font-semibold text-charcoal">Actions</th>
</tr>
</thead>
<tbody id="buckets-table-body">
<!-- Populated by JS -->
</tbody>
</table>
</div>
</div>
</div>
</main>
</div>
</div>
<!-- Change Owner Modal -->
<div id="owner-modal" class="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">
<div class="flex items-center justify-between p-6 border-b border-gray-100">
<h2 class="text-xl font-semibold text-charcoal">Change Bucket Owner</h2>
<button onclick="closeModal('owner-modal')" class="p-2 text-charcoal-300 hover:text-charcoal hover:bg-gray-100 rounded-lg transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<form class="p-6 space-y-5">
<div>
<label class="block text-sm font-medium text-charcoal mb-2">Bucket</label>
<input type="text" id="modal-bucket" readonly class="w-full px-4 py-2.5 border-2 border-gray-200 rounded-lg text-charcoal bg-gray-50 font-mono">
</div>
<div>
<label class="block text-sm font-medium text-charcoal mb-2">Current Owner</label>
<input type="text" id="modal-current-owner" readonly class="w-full px-4 py-2.5 border-2 border-gray-200 rounded-lg text-charcoal-400 bg-gray-50 font-mono">
</div>
<div>
<label class="block text-sm font-medium text-charcoal mb-2">New Owner <span class="text-red-500">*</span></label>
<div class="relative" id="new-owner-container">
<input
type="text"
id="modal-new-owner-display"
readonly
value="Select a user..."
onclick="toggleDropdown('new-owner')"
class="w-full px-4 py-2.5 pr-10 border-2 border-gray-200 rounded-lg text-charcoal bg-white cursor-pointer focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-all"
>
<input type="hidden" id="modal-new-owner" value="">
<svg class="absolute right-4 top-1/2 -translate-y-1/2 w-4 h-4 text-charcoal-300 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
<div id="new-owner-dropdown" class="custom-dropdown">
<div class="custom-dropdown-item" data-value="" onclick="selectNewOwner('')">Select a user...</div>
<!-- Populated dynamically -->
</div>
</div>
</div>
<div class="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<div class="flex items-start gap-3">
<svg class="w-5 h-5 text-yellow-600 flex-shrink-0 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
<p class="text-sm text-yellow-800">Transferring ownership will give the new owner full control over this bucket and its contents.</p>
</div>
</div>
</form>
<div class="flex items-center justify-end gap-3 p-6 border-t border-gray-100">
<button onclick="closeModal('owner-modal')" class="px-4 py-2.5 border border-gray-200 rounded-lg text-charcoal font-medium hover:bg-gray-50 transition-colors">Cancel</button>
<button id="transfer-btn" onclick="transferOwnership()" class="px-4 py-2.5 bg-primary hover:bg-primary-600 text-white font-medium rounded-lg transition-colors">Transfer Ownership</button>
</div>
</div>
</div>
</div>
<!-- Create Bucket Modal -->
<div id="create-bucket-modal" class="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">
<div class="flex items-center justify-between p-6 border-b border-gray-100">
<h2 class="text-xl font-semibold text-charcoal">Create Bucket</h2>
<button onclick="closeModal('create-bucket-modal')" class="p-2 text-charcoal-300 hover:text-charcoal hover:bg-gray-100 rounded-lg transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<form class="p-6 space-y-5">
<div>
<label class="block text-sm font-medium text-charcoal mb-2">Bucket Name <span class="text-red-500">*</span></label>
<input type="text" id="new-bucket-name" class="w-full px-4 py-2.5 border-2 border-gray-200 rounded-lg text-charcoal focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-all" placeholder="my-bucket-name">
<p class="text-xs text-charcoal-300 mt-2">Bucket names must be lowercase, 3-63 characters, and can contain letters, numbers, and hyphens.</p>
</div>
<div>
<label class="block text-sm font-medium text-charcoal mb-2">Owner <span class="text-red-500">*</span></label>
<div class="relative" id="bucket-owner-container">
<input
type="text"
id="bucket-owner-display"
readonly
value="Select owner..."
onclick="toggleDropdown('bucket-owner')"
class="w-full px-4 py-2.5 pr-10 border-2 border-gray-200 rounded-lg text-charcoal bg-white cursor-pointer focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-all"
>
<input type="hidden" id="bucket-owner" value="">
<svg class="absolute right-4 top-1/2 -translate-y-1/2 w-4 h-4 text-charcoal-300 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
<div id="bucket-owner-dropdown" class="custom-dropdown">
<div class="custom-dropdown-item" data-value="" onclick="selectBucketOwner('', '')">Select owner...</div>
<!-- Populated dynamically -->
</div>
</div>
</div>
<div class="space-y-3">
<label class="flex items-start gap-3 cursor-pointer group">
<input type="checkbox" id="enable-versioning" class="mt-1 w-4 h-4 text-accent border-2 border-gray-200 rounded focus:ring-2 focus:ring-accent/20 transition-all">
<div>
<span class="block text-sm font-medium text-charcoal group-hover:text-accent transition-colors">Enable Versioning</span>
<span class="block text-xs text-charcoal-300 mt-0.5">Keep multiple versions of objects in the bucket</span>
</div>
</label>
<label class="flex items-start gap-3 cursor-pointer group">
<input type="checkbox" id="enable-object-lock" class="mt-1 w-4 h-4 text-accent border-2 border-gray-200 rounded focus:ring-2 focus:ring-accent/20 transition-all">
<div>
<span class="block text-sm font-medium text-charcoal group-hover:text-accent transition-colors">Enable Object Lock</span>
<span class="block text-xs text-charcoal-300 mt-0.5">Prevent object deletion for compliance (enables versioning)</span>
</div>
</label>
</div>
</form>
<div class="flex items-center justify-end gap-3 p-6 border-t border-gray-100">
<button onclick="closeModal('create-bucket-modal')" class="px-4 py-2.5 border border-gray-200 rounded-lg text-charcoal font-medium hover:bg-gray-50 transition-colors">
Cancel
</button>
<button id="create-bucket-btn" onclick="createBucket()" class="px-4 py-2.5 bg-primary hover:bg-primary-600 text-white font-medium rounded-lg transition-colors">
Create
</button>
</div>
</div>
</div>
</div>
<script>
let allBuckets = [];
let allUsers = [];
let selectedBucket = null;
// ============================================
// Custom Dropdown Functions
// ============================================
// Toggle any dropdown
function toggleDropdown(name) {
const dropdown = document.getElementById(name + '-dropdown');
const allDropdowns = document.querySelectorAll('.custom-dropdown');
// Close all other dropdowns
allDropdowns.forEach(d => {
if (d.id !== name + '-dropdown') d.classList.remove('show');
});
dropdown.classList.toggle('show');
}
// Close all dropdowns when clicking outside
document.addEventListener('click', (e) => {
const containers = ['owner-filter-container', 'new-owner-container', 'bucket-owner-container'];
if (!containers.some(id => e.target.closest('#' + id))) {
document.querySelectorAll('.custom-dropdown').forEach(d => d.classList.remove('show'));
}
});
// Owner filter dropdown
function selectOwnerFilter(value) {
const display = document.getElementById('owner-filter-display');
const hidden = document.getElementById('owner-filter');
const dropdown = document.getElementById('owner-filter-dropdown');
display.value = value || 'All Owners';
hidden.value = value;
dropdown.querySelectorAll('.custom-dropdown-item').forEach(item => {
item.classList.toggle('selected', item.dataset.value === value);
});
dropdown.classList.remove('show');
filterBuckets();
}
// Populate owner filter dropdown
function populateOwnerFilterDropdown(owners) {
const dropdown = document.getElementById('owner-filter-dropdown');
dropdown.innerHTML = '<div class="custom-dropdown-item selected" data-value="" onclick="selectOwnerFilter(\'\')">All Owners</div>';
owners.forEach(owner => {
dropdown.innerHTML += `<div class="custom-dropdown-item" data-value="${escapeHtml(owner)}" onclick="selectOwnerFilter('${escapeHtml(owner)}')">${escapeHtml(owner)}</div>`;
});
}
// New owner dropdown (for modal)
function selectNewOwner(value, displayText) {
const display = document.getElementById('modal-new-owner-display');
const hidden = document.getElementById('modal-new-owner');
const dropdown = document.getElementById('new-owner-dropdown');
display.value = displayText || value || 'Select a user...';
hidden.value = value;
dropdown.querySelectorAll('.custom-dropdown-item').forEach(item => {
item.classList.toggle('selected', item.dataset.value === value);
});
dropdown.classList.remove('show');
}
// Bucket owner dropdown (for create bucket modal)
function selectBucketOwner(value, displayText) {
const display = document.getElementById('bucket-owner-display');
const hidden = document.getElementById('bucket-owner');
const dropdown = document.getElementById('bucket-owner-dropdown');
display.value = displayText || value || 'Select owner...';
hidden.value = value;
dropdown.querySelectorAll('.custom-dropdown-item').forEach(item => {
item.classList.toggle('selected', item.dataset.value === value);
});
dropdown.classList.remove('show');
}
// Populate new owner dropdown
function populateNewOwnerDropdown(users, currentOwner) {
const dropdown = document.getElementById('new-owner-dropdown');
dropdown.innerHTML = '<div class="custom-dropdown-item" data-value="" onclick="selectNewOwner(\'\')">Select a user...</div>';
users.forEach(user => {
if (user.access !== currentOwner) {
const roleLabel = user.role ? ` (${user.role.charAt(0).toUpperCase() + user.role.slice(1)})` : '';
const displayText = `${user.access}${roleLabel}`;
dropdown.innerHTML += `<div class="custom-dropdown-item" data-value="${escapeHtml(user.access)}" onclick="selectNewOwner('${escapeHtml(user.access)}', '${escapeHtml(displayText)}')">${escapeHtml(displayText)}</div>`;
}
});
}
// Populate bucket owner dropdown (for create bucket modal)
function populateBucketOwnerDropdown(users) {
const dropdown = document.getElementById('bucket-owner-dropdown');
dropdown.innerHTML = '<div class="custom-dropdown-item" data-value="" onclick="selectBucketOwner(\'\', \'\'">Select owner...</div>';
users.forEach(user => {
const roleLabel = user.role ? ` (${user.role.charAt(0).toUpperCase() + user.role.slice(1)})` : '';
const displayText = `${user.access}${roleLabel}`;
dropdown.innerHTML += `<div class="custom-dropdown-item" data-value="${escapeHtml(user.access)}" onclick="selectBucketOwner('${escapeHtml(user.access)}', '${escapeHtml(displayText)}')">${escapeHtml(displayText)}</div>`;
});
}
if (!requireAdmin()) {
// Redirected
} else {
initSidebarWithRole();
updateUserInfo();
loadData();
}
async function loadData() {
try {
// Load both users and buckets
allUsers = await api.listUsers();
await loadBuckets();
// Populate owner filter dropdown
const uniqueOwners = [...new Set(allBuckets.map(b => b.owner).filter(Boolean))];
populateOwnerFilterDropdown(uniqueOwners);
filterBuckets();
} catch (error) {
console.error('Error loading data:', error);
showToast('Error loading data: ' + error.message, 'error');
}
}
async function loadBuckets() {
showTableLoading('buckets-table-body', 4);
try {
allBuckets = await api.listBuckets();
filterBuckets();
} catch (error) {
console.error('Error loading buckets:', error);
showToast('Error loading buckets: ' + error.message, 'error');
showEmptyState('buckets-table-body', 4, 'Error loading buckets');
}
}
function filterBuckets() {
const searchTerm = document.getElementById('search-input').value.toLowerCase();
const ownerFilter = document.getElementById('owner-filter').value;
let filtered = allBuckets;
if (searchTerm) {
filtered = filtered.filter(b => b.name && b.name.toLowerCase().includes(searchTerm));
}
if (ownerFilter) {
filtered = filtered.filter(b => b.owner === ownerFilter);
}
renderBuckets(filtered);
}
function renderBuckets(buckets) {
const tbody = document.getElementById('buckets-table-body');
tbody.innerHTML = '';
if (buckets.length === 0) {
showEmptyState('buckets-table-body', 4, 'No buckets found');
return;
}
buckets.forEach(bucket => {
const explorerHref = `explorer.html#${encodeURIComponent(bucket.name)}`;
const row = document.createElement('tr');
row.className = 'border-b border-gray-50 hover:bg-gray-50 transition-colors';
row.innerHTML = `
<td class="py-4 px-6">
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-accent-50 rounded-lg flex items-center justify-center">
<svg class="w-5 h-5 text-accent" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"/>
</svg>
</div>
<a href="${explorerHref}" class="font-mono text-sm text-accent hover:underline">${escapeHtml(bucket.name)}</a>
</div>
</td>
<td class="py-4 px-6">
<span class="font-mono text-sm text-charcoal-400">${escapeHtml(bucket.owner || 'Unknown')}</span>
</td>
<td class="py-4 px-6 text-right">
<div class="flex items-center justify-end gap-2">
<button onclick="openChangeOwnerModal('${escapeHtml(bucket.name)}', '${escapeHtml(bucket.owner || '')}')" class="inline-flex items-center gap-2 px-3 py-1.5 text-sm text-charcoal-400 hover:text-charcoal hover:bg-gray-100 rounded-lg transition-colors">
<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="M8 7h12m0 0l-4-4m4 4l-4 4m0 6H4m0 0l4 4m-4-4l4-4"/>
</svg>
Owner
</button>
</div>
</td>
`;
tbody.appendChild(row);
});
}
function openChangeOwnerModal(bucket, currentOwner) {
selectedBucket = bucket;
document.getElementById('modal-bucket').value = bucket;
document.getElementById('modal-current-owner').value = currentOwner || 'Unknown';
// Reset and populate new owner dropdown
document.getElementById('modal-new-owner-display').value = 'Select a user...';
document.getElementById('modal-new-owner').value = '';
populateNewOwnerDropdown(allUsers, currentOwner);
openModal('owner-modal');
}
async function transferOwnership() {
const newOwner = document.getElementById('modal-new-owner').value;
if (!newOwner) {
showToast('Please select a new owner', 'error');
return;
}
const btn = document.getElementById('transfer-btn');
setLoading(btn, true);
try {
await api.changeBucketOwner(selectedBucket, newOwner);
showToast('Bucket ownership transferred successfully', 'success');
closeModal('owner-modal');
loadBuckets();
} catch (error) {
console.error('Error transferring ownership:', error);
showToast('Error: ' + error.message, 'error');
} finally {
setLoading(btn, false);
}
}
// ============================================
// Create Bucket
// ============================================
function openCreateBucketDialog() {
document.getElementById('new-bucket-name').value = '';
document.getElementById('bucket-owner-display').value = 'Select owner...';
document.getElementById('bucket-owner').value = '';
document.getElementById('enable-versioning').checked = false;
document.getElementById('enable-object-lock').checked = false;
populateBucketOwnerDropdown(allUsers);
openModal('create-bucket-modal');
}
async function createBucket() {
const bucketName = document.getElementById('new-bucket-name').value.trim().toLowerCase();
const owner = document.getElementById('bucket-owner').value;
const enableVersioning = document.getElementById('enable-versioning').checked;
const enableObjectLock = document.getElementById('enable-object-lock').checked;
if (!bucketName) {
showToast('Please enter a bucket name', 'warning');
return;
}
if (!owner) {
showToast('Please select an owner', 'warning');
return;
}
// Basic bucket name validation
if (bucketName.length < 3 || bucketName.length > 63) {
showToast('Bucket name must be between 3 and 63 characters', 'warning');
return;
}
if (!/^[a-z0-9][a-z0-9.-]*[a-z0-9]$/.test(bucketName) && bucketName.length > 2) {
showToast('Bucket name must start and end with a letter or number', 'warning');
return;
}
if (/[^a-z0-9.-]/.test(bucketName)) {
showToast('Bucket name can only contain lowercase letters, numbers, hyphens, and periods', 'warning');
return;
}
const btn = document.getElementById('create-bucket-btn');
setLoading(btn, true);
try {
await api.createBucketWithOwner(bucketName, owner, enableVersioning, enableObjectLock);
showToast(`Bucket "${bucketName}" created successfully`, 'success');
closeModal('create-bucket-modal');
// Reload buckets list
await loadBuckets();
} catch (error) {
console.error('Create bucket error:', error);
showToast(error.message || 'Failed to create bucket', 'error');
} finally {
setLoading(btn, false);
}
}
</script>
</body>
</html>

358
webui/web/dashboard.html Normal file
View File

@@ -0,0 +1,358 @@
<!--
Copyright 2026 Versity Software
This file is licensed under the Apache License, Version 2.0
(the "License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VersityGW Admin - Dashboard</title>
<script src="assets/js/crypto-js.min.js"></script>
<script src="assets/css/tailwind.js"></script>
<link rel="stylesheet" href="assets/css/fonts.css">
<script>
tailwind.config = {
theme: {
extend: {
colors: {
primary: { DEFAULT: '#002A7A', 50: '#E6EBF4', 500: '#002A7A', 600: '#002468' },
accent: { DEFAULT: '#0076CD', 50: '#E6F3FA', 500: '#0076CD', 600: '#0065AF' },
charcoal: { DEFAULT: '#191B2A', 300: '#757884', 400: '#565968' },
surface: { DEFAULT: '#F3F8FC' }
},
fontFamily: { sans: ['Roboto', 'system-ui', 'sans-serif'] },
}
}
}
</script>
<style>
body { font-family: 'Roboto', system-ui, sans-serif; }
.nav-item { transition: all 0.15s ease; }
.nav-item:hover { background: rgba(255,255,255,0.1); }
.nav-item.active { background: rgba(0, 118, 205, 0.2); border-left: 4px solid #0076CD; }
</style>
</head>
<body class="min-h-screen bg-surface">
<script src="js/api.js"></script>
<script src="js/app.js"></script>
<div class="flex h-screen overflow-hidden">
<!-- Sidebar -->
<aside class="w-60 bg-charcoal flex flex-col flex-shrink-0">
<div class="h-16 flex items-center px-6 border-b border-white/10">
<a href="https://www.versity.com" target="_blank" rel="noopener noreferrer">
<img src="assets/images/Versity-logo-white-horizontal.png" alt="Versity" class="h-10 hover:opacity-80 transition-opacity">
</a>
</div>
<nav class="flex-1 py-4">
<div class="px-6 pt-2 pb-2 text-[11px] font-semibold tracking-wider text-white/40 uppercase" data-admin-only>
Admin
</div>
<a href="dashboard.html" class="nav-item active flex items-center gap-3 px-6 py-3 text-white" data-admin-only>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/>
</svg>
<span class="font-medium">Dashboard</span>
</a>
<a href="users.html" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-admin-only>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/>
</svg>
<span class="font-medium">Users</span>
</a>
<a href="buckets.html" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-admin-only>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"/>
</svg>
<span class="font-medium">Buckets</span>
</a>
<div class="mx-6 my-2 border-t border-white/10" data-admin-only></div>
<a href="explorer.html" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/>
</svg>
<span class="font-medium">Explorer</span>
</a>
<div class="mx-6 my-2 border-t border-white/10"></div>
<div class="px-6 pt-2 pb-2 text-[11px] font-semibold tracking-wider text-white/40 uppercase">
Resources
</div>
<a href="https://github.com/versity/versitygw/wiki" target="_blank" rel="noopener noreferrer" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>
</svg>
<span class="font-medium">Documentation</span>
</a>
<a href="https://github.com/versity/versitygw/issues" target="_blank" rel="noopener noreferrer" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-admin-only>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
<span class="font-medium">Bug Reports</span>
</a>
<a href="https://github.com/versity/versitygw/releases" target="_blank" rel="noopener noreferrer" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-admin-only>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/>
</svg>
<span class="font-medium">Releases</span>
</a>
<a href="https://github.com/versity/versitygw" target="_blank" rel="noopener noreferrer" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"/>
</svg>
<span class="font-medium">GitHub</span>
</a>
</nav>
<div class="p-4 border-t border-white/10">
<div id="user-info" class="flex items-center gap-3 mb-3">
<!-- Populated by JS -->
</div>
<button onclick="api.logout(); window.location.href='index.html';" class="w-full flex items-center gap-2 px-3 py-2 text-white/70 hover:text-white hover:bg-white/10 rounded-lg transition-colors text-sm">
<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="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/>
</svg>
Sign Out
</button>
</div>
</aside>
<!-- Main Content -->
<div class="flex-1 flex flex-col overflow-hidden">
<header class="h-16 bg-white border-b border-gray-200 flex items-center justify-between px-8 flex-shrink-0">
<h1 class="text-xl font-semibold text-charcoal">VersityGW Dashboard</h1>
<div class="flex items-center gap-4">
<button onclick="loadDashboard()" class="p-2 text-charcoal-300 hover:text-charcoal hover:bg-gray-100 rounded-lg transition-colors" title="Refresh">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
</button>
</div>
</header>
<main class="flex-1 overflow-auto p-8">
<div class="max-w-7xl mx-auto">
<!-- Metric Cards -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<!-- Total Users -->
<div class="bg-white rounded-xl p-6 shadow-sm border border-gray-100">
<div class="flex items-center justify-between">
<div>
<p class="text-charcoal-300 text-sm font-medium">Total Users</p>
<p id="user-count" class="text-3xl font-bold text-charcoal mt-2">-</p>
</div>
<div class="w-14 h-14 bg-primary-50 rounded-xl flex items-center justify-center">
<svg class="w-7 h-7 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/>
</svg>
</div>
</div>
</div>
<!-- Total Buckets -->
<div class="bg-white rounded-xl p-6 shadow-sm border border-gray-100">
<div class="flex items-center justify-between">
<div>
<p class="text-charcoal-300 text-sm font-medium">Total Buckets</p>
<p id="bucket-count" class="text-3xl font-bold text-charcoal mt-2">-</p>
</div>
<div class="w-14 h-14 bg-accent-50 rounded-xl flex items-center justify-center">
<svg class="w-7 h-7 text-accent" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"/>
</svg>
</div>
</div>
</div>
<!-- System Status -->
<div class="bg-white rounded-xl p-6 shadow-sm border border-gray-100">
<div class="flex items-center justify-between">
<div>
<p class="text-charcoal-300 text-sm font-medium">System Status</p>
<div id="system-status" class="flex items-center gap-2 mt-2">
<span class="w-3 h-3 bg-green-500 rounded-full animate-pulse"></span>
<p class="text-xl font-bold text-green-600">Connected</p>
</div>
</div>
<div class="w-14 h-14 bg-green-50 rounded-xl flex items-center justify-center">
<svg class="w-7 h-7 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
</div>
</div>
</div>
</div>
<!-- Two Column Layout -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Quick Actions -->
<div class="bg-white rounded-xl p-6 shadow-sm border border-gray-100">
<h3 class="text-lg font-semibold text-charcoal mb-4">Quick Actions</h3>
<div class="space-y-3">
<a href="users.html" class="flex items-center gap-4 p-4 bg-surface rounded-lg hover:bg-gray-100 transition-colors group">
<div class="w-10 h-10 bg-primary-50 rounded-lg flex items-center justify-center group-hover:bg-primary-100 transition-colors">
<svg class="w-5 h-5 text-primary" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"/>
</svg>
</div>
<div>
<p class="font-medium text-charcoal">Manage Users</p>
<p class="text-sm text-charcoal-300">Create, edit, and delete user accounts</p>
</div>
<svg class="w-5 h-5 text-charcoal-300 ml-auto" 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>
</a>
<a href="buckets.html" class="flex items-center gap-4 p-4 bg-surface rounded-lg hover:bg-gray-100 transition-colors group">
<div class="w-10 h-10 bg-accent-50 rounded-lg flex items-center justify-center group-hover:bg-accent-100 transition-colors">
<svg class="w-5 h-5 text-accent" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"/>
</svg>
</div>
<div>
<p class="font-medium text-charcoal">Manage Buckets</p>
<p class="text-sm text-charcoal-300">View and manage bucket ownership</p>
</div>
<svg class="w-5 h-5 text-charcoal-300 ml-auto" 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>
</a>
</div>
</div>
<!-- Connection Info -->
<div class="bg-white rounded-xl p-6 shadow-sm border border-gray-100">
<h3 class="text-lg font-semibold text-charcoal mb-4">Connection Info</h3>
<div class="space-y-4">
<div class="flex items-center justify-between py-3 border-b border-gray-100">
<span class="text-charcoal-300">Endpoint</span>
<span id="endpoint-display" class="text-charcoal font-mono text-sm">-</span>
</div>
<div class="flex items-center justify-between py-3 border-b border-gray-100">
<span class="text-charcoal-300">Region</span>
<span id="region-display" class="text-charcoal text-sm">-</span>
</div>
<div class="flex items-center justify-between py-3 border-b border-gray-100">
<span class="text-charcoal-300">Access Key</span>
<span id="access-key-display" class="text-charcoal font-mono text-sm">-</span>
</div>
<div class="flex items-center justify-between py-3">
<span class="text-charcoal-300">Status</span>
<span class="flex items-center gap-2">
<span class="w-2 h-2 bg-green-500 rounded-full"></span>
<span class="text-green-600 text-sm font-medium">Authenticated</span>
</span>
</div>
</div>
</div>
</div>
<!-- Recent Users Table -->
<div class="mt-6 bg-white rounded-xl p-6 shadow-sm border border-gray-100">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-charcoal">Recent Users</h3>
<a href="users.html" class="text-accent hover:text-accent-600 text-sm font-medium">View all</a>
</div>
<div class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="border-b border-gray-100">
<th class="text-left py-3 px-4 text-sm font-medium text-charcoal-300">Access Key</th>
<th class="text-left py-3 px-4 text-sm font-medium text-charcoal-300">Role</th>
<th class="text-left py-3 px-4 text-sm font-medium text-charcoal-300">Project ID</th>
<th class="text-left py-3 px-4 text-sm font-medium text-charcoal-300">User ID</th>
<th class="text-left py-3 px-4 text-sm font-medium text-charcoal-300">Group ID</th>
</tr>
</thead>
<tbody id="recent-users">
<!-- Populated by JS -->
</tbody>
</table>
</div>
</div>
</div>
</main>
</div>
</div>
<script>
// Auth guard - require admin role
if (!requireAdmin()) {
// Will redirect to login or explorer
} else {
initSidebarWithRole();
updateUserInfo();
loadDashboard();
}
async function loadDashboard() {
const info = api.getCredentialsInfo();
// Display connection info
document.getElementById('endpoint-display').textContent = info.endpoint || '-';
document.getElementById('region-display').textContent = info.region || '-';
document.getElementById('access-key-display').textContent = info.accessKey || '-';
try {
// Load users
const users = await api.listUsers();
document.getElementById('user-count').textContent = users.length;
// Load buckets
const buckets = await api.listBuckets();
document.getElementById('bucket-count').textContent = buckets.length;
// Display recent users (max 5)
const recentUsers = users.slice(0, 5);
const tbody = document.getElementById('recent-users');
tbody.innerHTML = '';
if (recentUsers.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="5" class="py-8 text-center text-charcoal-300">No users found</td>
</tr>
`;
} else {
recentUsers.forEach(user => {
const row = document.createElement('tr');
row.className = 'border-b border-gray-50 hover:bg-gray-50 transition-colors';
row.innerHTML = `
<td class="py-3 px-4 font-mono text-sm text-charcoal">${escapeHtml(user.access)}</td>
<td class="py-3 px-4">${formatRole(user.role)}</td>
<td class="py-3 px-4 text-sm text-charcoal">${user.projectid || '-'}</td>
<td class="py-3 px-4 text-sm text-charcoal">${user.userid || '0'}</td>
<td class="py-3 px-4 text-sm text-charcoal">${user.groupid || '0'}</td>
`;
tbody.appendChild(row);
});
}
// Update status
document.getElementById('system-status').innerHTML = `
<span class="w-3 h-3 bg-green-500 rounded-full animate-pulse"></span>
<p class="text-xl font-bold text-green-600">Connected</p>
`;
} catch (error) {
console.error('Error loading dashboard:', error);
showToast('Error loading dashboard data: ' + error.message, 'error');
document.getElementById('system-status').innerHTML = `
<span class="w-3 h-3 bg-red-500 rounded-full"></span>
<p class="text-xl font-bold text-red-600">Error</p>
`;
}
}
</script>
</body>
</html>

4120
webui/web/explorer.html Normal file

File diff suppressed because it is too large Load Diff

836
webui/web/index.html Normal file
View File

@@ -0,0 +1,836 @@
<!--
Copyright 2026 Versity Software
This file is licensed under the Apache License, Version 2.0
(the "License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VersityGW Admin - Login</title>
<script src="assets/js/crypto-js.min.js"></script>
<script src="assets/css/tailwind.js"></script>
<link rel="stylesheet" href="assets/css/fonts.css">
<script>
tailwind.config = {
theme: {
extend: {
colors: {
primary: {
DEFAULT: '#002A7A',
50: '#E6EBF4',
100: '#B3C2E0',
200: '#809ACC',
300: '#4D71B8',
400: '#264DA3',
500: '#002A7A',
600: '#002468',
700: '#001D56',
},
accent: {
DEFAULT: '#0076CD',
50: '#E6F3FA',
100: '#B3DCF2',
500: '#0076CD',
600: '#0065AF',
},
charcoal: {
DEFAULT: '#191B2A',
300: '#757884',
400: '#565968',
},
surface: {
DEFAULT: '#F3F8FC',
}
},
fontFamily: {
sans: ['Roboto', 'system-ui', 'sans-serif'],
},
}
}
}
</script>
<style>
body { font-family: 'Roboto', system-ui, sans-serif; }
.input-icon {
position: absolute;
left: 14px;
top: 50%;
transform: translateY(-50%);
color: #808080;
}
.input-with-icon {
padding-left: 44px;
}
.password-toggle {
position: absolute;
right: 14px;
top: 50%;
transform: translateY(-50%);
color: #808080;
cursor: pointer;
background: none;
border: none;
padding: 4px;
}
.password-toggle:hover {
color: #002A7A;
}
.advanced-toggle {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
padding: 0.75rem 0;
margin: 0.5rem 0;
background: none;
border: none;
font: inherit;
color: inherit;
text-align: left;
width: 100%;
}
.advanced-toggle:hover {
opacity: 0.8;
}
.advanced-toggle-carat {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.25rem;
height: 1.25rem;
flex-shrink: 0;
transition: transform 0.3s ease;
}
.advanced-toggle.expanded .advanced-toggle-carat {
transform: rotate(90deg);
}
.advanced-toggle-label {
font-weight: 500;
color: #565968;
cursor: pointer;
}
.advanced-options {
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease-out;
}
.advanced-options.show {
max-height: 500px;
}
/* Custom dropdown styles */
.custom-dropdown {
display: none;
position: absolute;
z-index: 10;
width: 100%;
margin-top: 4px;
background: white;
border: 2px solid #e5e7eb;
border-radius: 0.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
max-height: 12rem;
overflow: auto;
}
.custom-dropdown.show {
display: block;
}
.custom-dropdown-item {
padding: 0.75rem 1rem;
cursor: pointer;
color: #191B2A;
transition: background-color 0.15s;
}
.custom-dropdown-item:hover {
background-color: #f9fafb;
}
.custom-dropdown-item.selected {
background-color: rgba(0, 118, 205, 0.1);
color: #0076CD;
}
/* Toggle Switch Styles */
.toggle-switch {
position: relative;
display: inline-block;
width: 60px;
height: 28px;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.toggle-slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #e5e7eb;
transition: 0.3s;
border-radius: 28px;
}
.toggle-slider:before {
position: absolute;
content: "";
height: 20px;
width: 20px;
left: 4px;
bottom: 4px;
background-color: white;
transition: 0.3s;
border-radius: 50%;
}
input:checked + .toggle-slider {
background-color: #0076CD;
}
input:checked + .toggle-slider:before {
transform: translateX(32px);
}
.toggle-label {
font-size: 0.875rem;
color: #565968;
font-weight: 500;
}
</style>
</head>
<body class="min-h-screen bg-gradient-to-br from-surface to-white flex items-center justify-center p-4">
<script src="js/api.js"></script>
<script src="js/app.js"></script>
<div class="w-full max-w-md">
<!-- Login Card -->
<div class="bg-white rounded-xl shadow-lg p-8">
<!-- Logo inside card -->
<div class="flex flex-col items-center mb-6">
<img src="assets/images/Versity-logo-blue-horizontal.png" alt="Versity" class="h-12">
<span class="text-charcoal font-semibold text-lg mt-2">S3 Gateway</span>
</div>
<!-- Error Alert -->
<div id="error-alert" class="hidden mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
<div class="flex items-center gap-3">
<svg class="w-5 h-5 text-red-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
</svg>
<p id="error-message" class="text-sm text-red-700">Invalid credentials.</p>
</div>
</div>
<form id="login-form" action="#" method="post" class="space-y-5">
<!-- Access Key -->
<div>
<label class="block text-sm font-medium text-charcoal-400 mb-2">Access Key</label>
<input
type="text"
id="access-key"
name="username"
required
placeholder="Enter your access key"
autocomplete="username"
class="w-full px-4 py-3 border-2 border-gray-200 rounded-lg text-charcoal placeholder:text-gray-400 focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-all"
>
</div>
<!-- Secret Key -->
<div>
<label class="block text-sm font-medium text-charcoal-400 mb-2">Secret Key</label>
<div class="relative">
<input
type="password"
id="secret-key"
name="password"
required
placeholder="Enter your secret key"
autocomplete="current-password"
class="w-full px-4 py-3 border-2 border-gray-200 rounded-lg text-charcoal placeholder:text-gray-400 focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-all pr-12"
>
<button type="button" onclick="togglePassword()" class="password-toggle">
<svg id="eye-icon" class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
</svg>
<svg id="eye-off-icon" class="w-5 h-5 hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"/>
</svg>
</button>
</div>
</div>
<!-- Remember Access Key -->
<div class="flex items-center gap-2">
<input type="checkbox" id="remember-access-key" class="w-4 h-4 text-primary border-gray-300 rounded focus:ring-primary">
<label for="remember-access-key" class="text-sm text-charcoal-400">Remember Access Key</label>
</div>
<!-- Advanced Options Toggle -->
<button type="button" id="advanced-options-toggle" class="advanced-toggle" onclick="toggleAdvancedOptions()">
<svg class="advanced-toggle-carat w-5 h-5 text-charcoal-300" 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>
<span class="advanced-toggle-label">Advanced Options</span>
</button>
<!-- Advanced Options Section -->
<div id="advanced-options-section" class="advanced-options space-y-5">
<!-- S3 Endpoint URL -->
<div>
<label class="block text-sm font-medium text-charcoal-400 mb-2">S3 API Endpoint</label>
<div class="relative" id="endpoint-container">
<input
type="url"
id="endpoint-select"
required
placeholder="http://localhost:7070"
autocomplete="off"
class="w-full px-4 py-3 pr-10 border-2 border-gray-200 rounded-lg text-charcoal placeholder:text-gray-400 focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-all"
>
<button type="button" onclick="toggleDropdown('endpoint')" class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
<div id="endpoint-dropdown" class="custom-dropdown">
<!-- Populated dynamically -->
</div>
</div>
</div>
<!-- Admin Endpoint URL -->
<div>
<label class="block text-sm font-medium text-charcoal-400 mb-2">Admin API Endpoint</label>
<div class="relative" id="admin-endpoint-container">
<input
type="url"
id="admin-endpoint-select"
required
placeholder="http://localhost:7070"
autocomplete="off"
class="w-full px-4 py-3 pr-10 border-2 border-gray-200 rounded-lg text-charcoal placeholder:text-gray-400 focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-all"
>
<button type="button" onclick="toggleDropdown('admin-endpoint')" class="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
</button>
<div id="admin-endpoint-dropdown" class="custom-dropdown">
<!-- Populated dynamically -->
</div>
</div>
</div>
<!-- Region Selector -->
<div>
<label class="block text-sm font-medium text-charcoal-400 mb-2">Region</label>
<div class="relative" id="region-container">
<input
type="text"
id="region-display"
readonly
value="us-east-1"
onclick="toggleDropdown('region')"
class="w-full px-4 py-3 pr-10 border-2 border-gray-200 rounded-lg text-charcoal bg-white cursor-pointer focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-all"
>
<input type="hidden" id="region" value="us-east-1">
<svg class="absolute right-3 top-1/2 -translate-y-1/2 w-5 h-5 text-gray-400 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
<div id="region-dropdown" class="custom-dropdown">
<div class="custom-dropdown-item selected" data-value="us-east-1" onclick="selectRegion('us-east-1')">us-east-1</div>
<div class="custom-dropdown-item" data-value="us-west-2" onclick="selectRegion('us-west-2')">us-west-2</div>
<div class="custom-dropdown-item" data-value="eu-west-1" onclick="selectRegion('eu-west-1')">eu-west-1</div>
<div class="custom-dropdown-item" data-value="ap-southeast-1" onclick="selectRegion('ap-southeast-1')">ap-southeast-1</div>
</div>
</div>
</div>
<!-- Bucket Addressing Style -->
<div>
<label class="block text-sm font-medium text-charcoal-400 mb-2">Bucket Addressing Style</label>
<div class="flex items-center justify-between">
<span class="toggle-label">Path Style</span>
<label class="toggle-switch">
<input type="checkbox" id="addressing-style-toggle" onchange="toggleAddressingStyle()">
<span class="toggle-slider"></span>
</label>
<span class="toggle-label">Virtual Host</span>
</div>
<input type="hidden" id="addressing-style" value="path">
</div>
</div>
<!-- Submit Button -->
<button
type="submit"
id="submit-btn"
class="w-full bg-primary hover:bg-primary-600 active:bg-primary-700 text-white font-medium py-3 px-4 rounded-lg transition-all duration-150 shadow-sm hover:shadow-md focus:outline-none focus:ring-2 focus:ring-primary/50 focus:ring-offset-2"
>
Sign In
</button>
</form>
</div>
<!-- Footer -->
<p class="text-center text-charcoal-300 text-sm mt-6">
&copy; 2025 Versity Software Inc.
</p>
</div>
<script>
// Redirect if already authenticated
redirectIfAuthenticated();
// ============================================
// Advanced Options Toggle
// ============================================
function toggleAdvancedOptions() {
const toggle = document.getElementById('advanced-options-toggle');
const section = document.getElementById('advanced-options-section');
toggle.classList.toggle('expanded');
section.classList.toggle('show');
}
// ============================================
// Configured Gateways (from vgwmgr CLI)
// ============================================
let configuredGateways = [];
let configuredAdminGateways = [];
let configuredDefaultRegion = null;
function normalizeEndpoint(value) {
return String(value || '').trim();
}
function normalizeRegion(value) {
const s = String(value || '').trim();
return s || null;
}
function uniqNonEmpty(values) {
const out = [];
const seen = new Set();
(values || []).forEach(v => {
const s = normalizeEndpoint(v);
if (!s) return;
const key = s.toLowerCase();
if (seen.has(key)) return;
seen.add(key);
out.push(s);
});
return out;
}
async function loadConfiguredGateways() {
try {
const res = await fetch('/api/gateways', { cache: 'no-store' });
if (!res.ok) return { gateways: [], adminGateways: [], defaultRegion: null };
const data = await res.json();
if (!data || !Array.isArray(data.gateways)) return { gateways: [], adminGateways: [], defaultRegion: null };
return {
gateways: data.gateways,
adminGateways: data.adminGateways || data.gateways || [],
defaultRegion: normalizeRegion(typeof data.defaultRegion === 'string' ? data.defaultRegion : null),
};
} catch (e) {
return { gateways: [], adminGateways: [], defaultRegion: null };
}
}
async function initConfiguredGateways() {
const cfg = await loadConfiguredGateways();
configuredGateways = uniqNonEmpty(cfg.gateways);
configuredAdminGateways = uniqNonEmpty(cfg.adminGateways);
configuredDefaultRegion = cfg.defaultRegion;
// Apply default region from server only if user hasn't changed it yet
if (configuredDefaultRegion) {
const hidden = document.getElementById('region');
const display = document.getElementById('region-display');
const looksUntouched =
hidden && display &&
hidden.value === 'us-east-1' &&
display.value === 'us-east-1';
if (looksUntouched) {
setRegion(configuredDefaultRegion);
}
}
// Default the endpoint input to the first configured gateway (if user hasn't typed one)
const endpointInput = document.getElementById('endpoint-select');
if (configuredGateways.length > 0 && endpointInput && !endpointInput.value.trim()) {
endpointInput.value = configuredGateways[0];
onEndpointInput(configuredGateways[0], { skipRegion: true });
}
// Default the admin-endpoint input to the first configured admin gateway (if user hasn't typed one)
const adminEndpointInput = document.getElementById('admin-endpoint-select');
if (configuredAdminGateways.length > 0 && adminEndpointInput && !adminEndpointInput.value.trim()) {
adminEndpointInput.value = configuredAdminGateways[0];
onAdminEndpointInput(configuredAdminGateways[0]);
}
}
// ============================================
// Recent Gateways (localStorage)
// ============================================
const RECENT_GATEWAYS_KEY = 'vgw_recent_gateways';
const MAX_RECENT_GATEWAYS = 5;
// Load recent gateways from localStorage
function loadRecentGateways() {
const stored = localStorage.getItem(RECENT_GATEWAYS_KEY);
if (!stored) return [];
try {
return JSON.parse(stored);
} catch (e) {
return [];
}
}
// Save gateway to recent list (call on successful login)
function saveRecentGateway(endpoint, region, accessKey, rememberKey) {
let gateways = loadRecentGateways();
// Remove existing entry for this endpoint
gateways = gateways.filter(g => g.endpoint !== endpoint);
// Add new entry at the beginning
gateways.unshift({
endpoint,
region,
accessKey: rememberKey ? accessKey : null,
lastUsed: Date.now()
});
// Keep only last 5
gateways = gateways.slice(0, MAX_RECENT_GATEWAYS);
localStorage.setItem(RECENT_GATEWAYS_KEY, JSON.stringify(gateways));
}
// ============================================
// Custom Dropdown Functions
// ============================================
// Toggle any dropdown
function toggleDropdown(name) {
const dropdown = document.getElementById(name + '-dropdown');
const allDropdowns = document.querySelectorAll('.custom-dropdown');
// Close all other dropdowns
allDropdowns.forEach(d => {
if (d.id !== name + '-dropdown') d.classList.remove('show');
});
dropdown.classList.toggle('show');
// If opening endpoint dropdown, populate it
if (name === 'endpoint' && dropdown.classList.contains('show')) {
populateEndpointDropdown();
}
// If opening admin-endpoint dropdown, populate it
if (name === 'admin-endpoint' && dropdown.classList.contains('show')) {
populateAdminEndpointDropdown();
}
}
// Close all dropdowns when clicking outside
document.addEventListener('click', (e) => {
if (!e.target.closest('#endpoint-container') && !e.target.closest('#admin-endpoint-container') && !e.target.closest('#region-container')) {
document.querySelectorAll('.custom-dropdown').forEach(d => d.classList.remove('show'));
}
});
// Populate endpoint dropdown with recent gateways
function populateEndpointDropdown() {
const dropdown = document.getElementById('endpoint-dropdown');
const recent = loadRecentGateways();
// Build a combined list: configured gateways first, then recents not already listed
const configured = uniqNonEmpty(configuredGateways);
const recentEndpoints = uniqNonEmpty(recent.map(r => r.endpoint));
const configuredSet = new Set(configured.map(e => e.toLowerCase()));
const combined = configured.concat(recentEndpoints.filter(e => !configuredSet.has(e.toLowerCase())));
dropdown.innerHTML = '';
if (combined.length === 0) {
dropdown.innerHTML = '<div class="px-4 py-3 text-gray-400 text-sm italic">No gateways configured</div>';
return;
}
combined.forEach(endpoint => {
const item = document.createElement('div');
item.className = 'custom-dropdown-item';
item.textContent = endpoint;
item.addEventListener('click', () => selectEndpoint(endpoint));
dropdown.appendChild(item);
});
}
// Populate admin-endpoint dropdown (with configured admin gateways)
function populateAdminEndpointDropdown() {
const dropdown = document.getElementById('admin-endpoint-dropdown');
// Build a combined list: configured admin gateways first, then all configured gateways as fallback
const configured = uniqNonEmpty(configuredAdminGateways.length > 0 ? configuredAdminGateways : configuredGateways);
dropdown.innerHTML = '';
if (configured.length === 0) {
dropdown.innerHTML = '<div class="px-4 py-3 text-gray-400 text-sm italic">No admin gateways configured</div>';
return;
}
configured.forEach(endpoint => {
const item = document.createElement('div');
item.className = 'custom-dropdown-item';
item.textContent = endpoint;
item.addEventListener('click', () => selectAdminEndpoint(endpoint));
dropdown.appendChild(item);
});
}
// Select an endpoint from dropdown
function selectEndpoint(endpoint) {
document.getElementById('endpoint-select').value = endpoint;
document.getElementById('endpoint-dropdown').classList.remove('show');
onEndpointInput(endpoint);
}
// Select an admin endpoint from dropdown
function selectAdminEndpoint(endpoint) {
document.getElementById('admin-endpoint-select').value = endpoint;
document.getElementById('admin-endpoint-dropdown').classList.remove('show');
onAdminEndpointInput(endpoint);
}
// Select a region from dropdown
function selectRegion(value) {
const display = document.getElementById('region-display');
const hidden = document.getElementById('region');
const dropdown = document.getElementById('region-dropdown');
// Update selected state
dropdown.querySelectorAll('.custom-dropdown-item').forEach(item => {
item.classList.toggle('selected', item.dataset.value === value);
});
display.value = value;
hidden.value = value;
dropdown.classList.remove('show');
}
// Toggle addressing style between path and virtual-host
function toggleAddressingStyle() {
const toggle = document.getElementById('addressing-style-toggle');
const hidden = document.getElementById('addressing-style');
// When toggle is checked, use virtual-host; unchecked is path
hidden.value = toggle.checked ? 'virtual-host' : 'path';
}
// Auto-fill access key, region and checkbox when endpoint is selected
function onEndpointInput(endpoint, opts = {}) {
const normalized = normalizeEndpoint(endpoint);
const gateways = loadRecentGateways();
const match = gateways.find(g => g.endpoint === normalized);
if (match) {
// Auto-fill access key if remembered
if (match.accessKey) {
document.getElementById('access-key').value = match.accessKey;
// Check the "Remember Access Key" checkbox since it was previously remembered
document.getElementById('remember-access-key').checked = true;
} else {
// Access key not remembered - uncheck the checkbox
document.getElementById('remember-access-key').checked = false;
}
// Auto-fill region
if (!opts.skipRegion && match.region) {
setRegion(match.region);
}
}
}
// Handle admin endpoint input (placeholder for future admin-specific logic)
function onAdminEndpointInput(endpoint) {
// For now, just acknowledge the change. Can be extended with admin-specific logic.
}
// Keep behavior consistent if user types an endpoint manually
document.getElementById('endpoint-select').addEventListener('input', (e) => {
onEndpointInput(e.target.value);
});
document.getElementById('admin-endpoint-select').addEventListener('input', (e) => {
onAdminEndpointInput(e.target.value);
});
// Helper to set region (works with custom dropdown)
function setRegion(region) {
const dropdown = document.getElementById('region-dropdown');
const normalized = normalizeRegion(region);
if (!normalized) return;
const existingItem = dropdown.querySelector(`.custom-dropdown-item[data-value="${CSS.escape(normalized)}"]`);
if (!existingItem) {
const item = document.createElement('div');
item.className = 'custom-dropdown-item';
item.dataset.value = normalized;
item.textContent = normalized;
item.addEventListener('click', () => selectRegion(normalized));
// Insert at the top of the list so the default is visible
dropdown.insertBefore(item, dropdown.firstChild);
}
selectRegion(normalized);
}
// Load configured gateways ASAP (needs setRegion defined)
initConfiguredGateways();
function getSelectedRegion() {
return document.getElementById('region').value;
}
function togglePassword() {
const input = document.getElementById('secret-key');
const eyeIcon = document.getElementById('eye-icon');
const eyeOffIcon = document.getElementById('eye-off-icon');
if (input.type === 'password') {
input.type = 'text';
eyeIcon.classList.add('hidden');
eyeOffIcon.classList.remove('hidden');
} else {
input.type = 'password';
eyeIcon.classList.remove('hidden');
eyeOffIcon.classList.add('hidden');
}
}
function showError(message) {
const alert = document.getElementById('error-alert');
const msgEl = document.getElementById('error-message');
msgEl.textContent = message;
alert.classList.remove('hidden');
}
function hideError() {
document.getElementById('error-alert').classList.add('hidden');
}
document.getElementById('login-form').addEventListener('submit', async (e) => {
e.preventDefault();
hideError();
const s3Endpoint = document.getElementById('endpoint-select').value.trim();
const adminEndpoint = document.getElementById('admin-endpoint-select').value.trim();
const accessKey = document.getElementById('access-key').value.trim();
const secretKey = document.getElementById('secret-key').value;
const region = getSelectedRegion();
const addressingStyle = document.getElementById('addressing-style').value;
// Validate inputs
if (!s3Endpoint) {
showError('Please enter an S3 API endpoint.');
return;
}
if (!adminEndpoint) {
showError('Please enter an Admin API endpoint.');
return;
}
if (!accessKey || !secretKey) {
showError('Please enter both access key and secret key.');
return;
}
// Validate that virtual host style is not used with IP addresses
if (addressingStyle === 'virtual-host') {
try {
const url = new URL(s3Endpoint);
const hostname = url.hostname;
// Check for IPv4 (e.g., 192.168.1.1) or IPv6 (e.g., [::1] or 2001:db8::1)
const isIPv4 = /^(\d{1,3}\.){3}\d{1,3}$/.test(hostname);
const isIPv6 = hostname.includes(':') || hostname.startsWith('[');
if (isIPv4 || isIPv6) {
showError('Virtual Host addressing style cannot be used with IP addresses. Please use a domain name or switch to Path Style.');
return;
}
} catch (err) {
// If URL parsing fails, let it continue and fail later with a more specific error
}
}
const submitBtn = document.getElementById('submit-btn');
setLoading(submitBtn, true);
try {
// Set credentials with admin endpoint, then configure s3 endpoint separately
api.setCredentials(adminEndpoint, accessKey, secretKey, region);
api.setS3Endpoint(s3Endpoint);
api.setAddressingStyle(addressingStyle);
const role = await api.detectRole();
if (role === 'none') {
api.logout();
showError('Invalid credentials or no access. Please check your access key and secret key.');
return;
}
// Store user type based on role
// Admin role means they have Admin API access
let userType = role === 'admin' ? 'admin' : 'user';
api.setUserContext(userType, []);
// Save gateway to recent list
const rememberKey = document.getElementById('remember-access-key').checked;
saveRecentGateway(s3Endpoint, region, accessKey, rememberKey);
// Navigate based on role
if (role === 'admin') {
// Admin user - redirect to dashboard
window.location.href = 'dashboard.html';
} else {
// Regular user with S3 access - redirect to explorer
window.location.href = 'explorer.html';
}
} catch (error) {
api.logout();
console.error('Login error:', error);
if (error.message.includes('CORS blocked')) {
showError(error.message);
} else if (error.message.includes('Failed to fetch') || error.message.includes('NetworkError')) {
showError('Unable to connect to the gateway. Please check the endpoint URL and ensure the server is running.');
} else if (error.message.includes('SignatureDoesNotMatch')) {
showError('Invalid credentials. Please check your access key and secret key.');
} else {
showError(error.message || 'An error occurred. Please try again.');
}
} finally {
setLoading(submitBtn, false);
}
});
</script>
</body>
</html>

2072
webui/web/js/api.js Normal file

File diff suppressed because it is too large Load Diff

369
webui/web/js/app.js Normal file
View File

@@ -0,0 +1,369 @@
// Copyright 2026 Versity Software
// This file is licensed under the Apache License, Version 2.0
// (the "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
/**
* VersityGW Admin - Application Utilities
*/
// ============================================
// Navigation & Auth Guards
// ============================================
/**
* Check if user is authenticated, redirect to login if not
* Also loads user context (user type and accessible gateways)
*/
function requireAuth() {
if (!api.loadCredentials()) {
window.location.href = 'index.html';
return false;
}
api.loadUserContext();
return true;
}
/**
* Require admin role, redirect non-admins to explorer
* Call this on admin-only pages (dashboard, users, buckets, settings)
* Also loads user context (user type and accessible gateways)
*/
function requireAdmin() {
if (!api.loadCredentials()) {
window.location.href = 'index.html';
return false;
}
api.loadUserContext();
if (!api.isAdmin()) {
window.location.href = 'explorer.html';
return false;
}
return true;
}
/**
* Redirect to appropriate page if already authenticated
* Admin users go to dashboard, regular users go to explorer
*/
function redirectIfAuthenticated() {
if (api.loadCredentials()) {
if (api.isAdmin()) {
window.location.href = 'dashboard.html';
} else {
window.location.href = 'explorer.html';
}
return true;
}
return false;
}
// ============================================
// Toast Notifications
// ============================================
let toastContainer = null;
function initToasts() {
if (!toastContainer) {
toastContainer = document.createElement('div');
toastContainer.id = 'toast-container';
toastContainer.className = 'fixed top-4 right-4 z-50 flex flex-col gap-2';
document.body.appendChild(toastContainer);
}
}
function showToast(message, type = 'info') {
initToasts();
const toast = document.createElement('div');
const bgColors = {
success: 'bg-green-50 border-green-500 text-green-800',
error: 'bg-red-50 border-red-500 text-red-800',
warning: 'bg-yellow-50 border-yellow-500 text-yellow-800',
info: 'bg-blue-50 border-blue-500 text-blue-800'
};
const icons = {
success: `<svg class="w-5 h-5 text-green-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>`,
error: `<svg class="w-5 h-5 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>`,
warning: `<svg class="w-5 h-5 text-yellow-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg>`,
info: `<svg class="w-5 h-5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/></svg>`
};
toast.className = `flex items-center gap-3 px-4 py-3 rounded-lg border-l-4 shadow-lg max-w-sm animate-slide-in ${bgColors[type]}`;
toast.innerHTML = `
${icons[type]}
<p class="text-sm font-medium flex-1">${escapeHtml(message)}</p>
<button onclick="this.parentElement.remove()" class="text-gray-400 hover:text-gray-600">
<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="M6 18L18 6M6 6l12 12"/></svg>
</button>
`;
toastContainer.appendChild(toast);
// Auto-remove after 5 seconds
setTimeout(() => {
toast.classList.add('animate-fade-out');
setTimeout(() => toast.remove(), 300);
}, 5000);
}
// ============================================
// Modal Utilities
// ============================================
function openModal(modalId) {
const modal = document.getElementById(modalId);
if (modal) {
modal.classList.remove('hidden');
// Focus first input
const firstInput = modal.querySelector('input:not([readonly]), select');
if (firstInput) setTimeout(() => firstInput.focus(), 100);
}
}
function closeModal(modalId) {
const modal = document.getElementById(modalId);
if (modal) {
modal.classList.add('hidden');
}
}
function closeAllModals() {
document.querySelectorAll('[id$="-modal"]').forEach(modal => {
modal.classList.add('hidden');
});
}
// Close modals on Escape key
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeAllModals();
});
// ============================================
// Loading States
// ============================================
function setLoading(element, loading) {
if (loading) {
element.disabled = true;
element.dataset.originalText = element.innerHTML;
element.innerHTML = `
<svg class="animate-spin h-5 w-5 mx-auto" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
`;
} else {
element.disabled = false;
if (element.dataset.originalText) {
element.innerHTML = element.dataset.originalText;
}
}
}
function showTableLoading(tableBodyId, columns) {
const tbody = document.getElementById(tableBodyId);
if (!tbody) return;
tbody.innerHTML = '';
for (let i = 0; i < 5; i++) {
const row = document.createElement('tr');
row.className = 'border-b border-gray-50';
for (let j = 0; j < columns; j++) {
row.innerHTML += `
<td class="py-4 px-6">
<div class="h-4 bg-gray-200 rounded animate-pulse" style="width: ${60 + Math.random() * 40}%"></div>
</td>
`;
}
tbody.appendChild(row);
}
}
function showEmptyState(tableBodyId, columns, message = 'No data found') {
const tbody = document.getElementById(tableBodyId);
if (!tbody) return;
tbody.innerHTML = `
<tr>
<td colspan="${columns}" class="py-12 px-6 text-center">
<svg class="w-12 h-12 text-gray-300 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"/>
</svg>
<p class="text-gray-500">${escapeHtml(message)}</p>
</td>
</tr>
`;
}
// ============================================
// Utility Functions
// ============================================
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function formatRole(role) {
const roleConfig = {
admin: { label: 'Admin', class: 'bg-primary-50 text-primary' },
user: { label: 'User', class: 'bg-gray-100 text-charcoal' },
userplus: { label: 'User+', class: 'bg-accent-50 text-accent' }
};
const config = roleConfig[role] || roleConfig.user;
return `<span class="px-2.5 py-1 ${config.class} text-xs font-medium rounded-md">${config.label}</span>`;
}
function debounce(func, wait) {
let timeout;
return function executedFunction(...args) {
const later = () => {
clearTimeout(timeout);
func(...args);
};
clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
// ============================================
// Sidebar Active State
// ============================================
function initSidebar() {
const currentPage = window.location.pathname.split('/').pop() || 'index.html';
document.querySelectorAll('.nav-item').forEach(item => {
const href = item.getAttribute('href');
if (href === currentPage) {
item.classList.add('active');
item.classList.remove('text-white/70');
item.classList.add('text-white');
} else {
item.classList.remove('active');
}
});
}
// ============================================
// Update User Info in Sidebar
// ============================================
function updateUserInfo() {
const info = api.getCredentialsInfo();
if (!info) return;
const accessKeyShort = info.accessKey.length > 12
? info.accessKey.substring(0, 12) + '...'
: info.accessKey;
const roleLabel = info.isAdmin ? 'Admin' : 'User';
const userInfoEl = document.getElementById('user-info');
if (userInfoEl) {
userInfoEl.innerHTML = `
<div class="flex-1 min-w-0">
<p class="text-white text-sm font-medium truncate">${escapeHtml(accessKeyShort)}</p>
<p class="text-white/50 text-xs">${roleLabel}</p>
</div>
`;
}
}
/**
* Initialize sidebar with role-based navigation
* Hides admin-only nav items for non-admin users
*/
function initSidebarWithRole() {
initSidebar();
// Hide admin-only nav items for non-admin users
if (!api.isAdmin()) {
document.querySelectorAll('[data-admin-only]').forEach(item => {
item.style.display = 'none';
});
}
}
// ============================================
// Confirm Dialog
// ============================================
function confirm(message, onConfirm, onCancel) {
const modal = document.createElement('div');
modal.className = 'fixed inset-0 z-50';
modal.innerHTML = `
<div class="modal-backdrop absolute inset-0" style="background: rgba(0,0,0,0.5); backdrop-filter: blur(4px);"></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">
<div class="p-6">
<div class="w-12 h-12 bg-yellow-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg class="w-6 h-6 text-yellow-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
</div>
<h3 class="text-lg font-semibold text-charcoal text-center mb-2">Confirm Action</h3>
<p class="text-charcoal-300 text-center mb-6">${escapeHtml(message)}</p>
<div class="flex items-center justify-center gap-3">
<button id="confirm-cancel" class="px-4 py-2.5 border border-gray-200 rounded-lg text-charcoal font-medium hover:bg-gray-50 transition-colors">
Cancel
</button>
<button id="confirm-ok" class="px-4 py-2.5 bg-primary hover:bg-primary-600 text-white font-medium rounded-lg transition-colors">
Confirm
</button>
</div>
</div>
</div>
</div>
`;
document.body.appendChild(modal);
modal.querySelector('#confirm-cancel').addEventListener('click', () => {
modal.remove();
if (onCancel) onCancel();
});
modal.querySelector('#confirm-ok').addEventListener('click', () => {
modal.remove();
if (onConfirm) onConfirm();
});
modal.querySelector('.modal-backdrop').addEventListener('click', () => {
modal.remove();
if (onCancel) onCancel();
});
}
// ============================================
// CSS Animations (inject once)
// ============================================
const styleEl = document.createElement('style');
styleEl.textContent = `
@keyframes slide-in {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes fade-out {
from { opacity: 1; }
to { opacity: 0; }
}
.animate-slide-in { animation: slide-in 0.3s ease-out; }
.animate-fade-out { animation: fade-out 0.3s ease-out; }
`;
document.head.appendChild(styleEl);

621
webui/web/users.html Normal file
View File

@@ -0,0 +1,621 @@
<!--
Copyright 2026 Versity Software
This file is licensed under the Apache License, Version 2.0
(the "License"); you may not use this file except in compliance
with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing,
software distributed under the License is distributed on an
"AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
KIND, either express or implied. See the License for the
specific language governing permissions and limitations
under the License.
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>VersityGW Admin - Users</title>
<script src="assets/js/crypto-js.min.js"></script>
<script src="assets/css/tailwind.js"></script>
<link rel="stylesheet" href="assets/css/fonts.css">
<script>
tailwind.config = {
theme: {
extend: {
colors: {
primary: { DEFAULT: '#002A7A', 50: '#E6EBF4', 500: '#002A7A', 600: '#002468' },
accent: { DEFAULT: '#0076CD', 50: '#E6F3FA', 500: '#0076CD', 600: '#0065AF' },
charcoal: { DEFAULT: '#191B2A', 300: '#757884', 400: '#565968' },
surface: { DEFAULT: '#F3F8FC' }
},
fontFamily: { sans: ['Roboto', 'system-ui', 'sans-serif'] },
}
}
}
</script>
<style>
body { font-family: 'Roboto', system-ui, sans-serif; }
.nav-item { transition: all 0.15s ease; }
.nav-item:hover { background: rgba(255,255,255,0.1); }
.nav-item.active { background: rgba(0, 118, 205, 0.2); border-left: 4px solid #0076CD; }
.modal-backdrop { background: rgba(0,0,0,0.5); backdrop-filter: blur(4px); }
/* Custom dropdown styles */
.custom-dropdown {
display: none;
position: absolute;
z-index: 10;
width: 100%;
margin-top: 4px;
background: white;
border: 2px solid #e5e7eb;
border-radius: 0.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
max-height: 12rem;
overflow: auto;
}
.custom-dropdown.show {
display: block;
}
.custom-dropdown-item {
padding: 0.75rem 1rem;
cursor: pointer;
color: #191B2A;
transition: background-color 0.15s;
}
.custom-dropdown-item:hover {
background-color: #f9fafb;
}
.custom-dropdown-item.selected {
background-color: rgba(0, 118, 205, 0.1);
color: #0076CD;
}
</style>
</head>
<body class="min-h-screen bg-surface">
<script src="js/api.js"></script>
<script src="js/app.js"></script>
<div class="flex h-screen overflow-hidden">
<!-- Sidebar -->
<aside class="w-60 bg-charcoal flex flex-col flex-shrink-0">
<div class="h-16 flex items-center px-6 border-b border-white/10">
<a href="https://www.versity.com" target="_blank" rel="noopener noreferrer">
<img src="assets/images/Versity-logo-white-horizontal.png" alt="Versity" class="h-10 hover:opacity-80 transition-opacity">
</a>
</div>
<nav class="flex-1 py-4">
<div class="px-6 pt-2 pb-2 text-[11px] font-semibold tracking-wider text-white/40 uppercase" data-admin-only>
Admin
</div>
<a href="dashboard.html" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-admin-only>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2V6zM14 6a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2V6zM4 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2H6a2 2 0 01-2-2v-2zM14 16a2 2 0 012-2h2a2 2 0 012 2v2a2 2 0 01-2 2h-2a2 2 0 01-2-2v-2z"/>
</svg>
<span class="font-medium">Dashboard</span>
</a>
<a href="users.html" class="nav-item active flex items-center gap-3 px-6 py-3 text-white" data-admin-only>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z"/>
</svg>
<span class="font-medium">Users</span>
</a>
<a href="buckets.html" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-admin-only>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4"/>
</svg>
<span class="font-medium">Buckets</span>
</a>
<div class="mx-6 my-2 border-t border-white/10" data-admin-only></div>
<a href="explorer.html" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"/>
</svg>
<span class="font-medium">Explorer</span>
</a>
<div class="mx-6 my-2 border-t border-white/10"></div>
<div class="px-6 pt-2 pb-2 text-[11px] font-semibold tracking-wider text-white/40 uppercase">
Resources
</div>
<a href="https://github.com/versity/versitygw/wiki" target="_blank" rel="noopener noreferrer" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253"/>
</svg>
<span class="font-medium">Documentation</span>
</a>
<a href="https://github.com/versity/versitygw/issues" target="_blank" rel="noopener noreferrer" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-admin-only>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
<span class="font-medium">Bug Reports</span>
</a>
<a href="https://github.com/versity/versitygw/releases" target="_blank" rel="noopener noreferrer" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white" data-admin-only>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"/>
</svg>
<span class="font-medium">Releases</span>
</a>
<a href="https://github.com/versity/versitygw" target="_blank" rel="noopener noreferrer" class="nav-item flex items-center gap-3 px-6 py-3 text-white/70 hover:text-white">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4"/>
</svg>
<span class="font-medium">GitHub</span>
</a>
</nav>
<div class="p-4 border-t border-white/10">
<div id="user-info" class="flex items-center gap-3 mb-3"></div>
<button onclick="api.logout(); window.location.href='index.html';" class="w-full flex items-center gap-2 px-3 py-2 text-white/70 hover:text-white hover:bg-white/10 rounded-lg transition-colors text-sm">
<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="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"/>
</svg>
Sign Out
</button>
</div>
</aside>
<!-- Main Content -->
<div class="flex-1 flex flex-col overflow-hidden">
<header class="h-16 bg-white border-b border-gray-200 flex items-center justify-between px-8 flex-shrink-0">
<h1 class="text-xl font-semibold text-charcoal">VersityGW Users</h1>
<button onclick="loadUsers()" class="p-2 text-charcoal-300 hover:text-charcoal hover:bg-gray-100 rounded-lg transition-colors" title="Refresh">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
</svg>
</button>
</header>
<main class="flex-1 overflow-auto p-8">
<div class="max-w-7xl mx-auto">
<!-- Page Header -->
<div class="flex items-center justify-between mb-6">
<div>
<h1 class="text-2xl font-semibold text-charcoal">Users</h1>
<p class="text-charcoal-300 mt-1">Manage gateway user accounts</p>
</div>
<button onclick="openCreateModal()" class="flex items-center gap-2 bg-primary hover:bg-primary-600 text-white font-medium py-2.5 px-4 rounded-lg transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/>
</svg>
Create User
</button>
</div>
<!-- Filters & Search -->
<div class="bg-white rounded-xl p-4 shadow-sm border border-gray-100 mb-6">
<div class="flex flex-wrap items-center gap-4">
<div class="relative flex-1 min-w-64">
<svg class="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-charcoal-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/>
</svg>
<input
type="text"
id="search-input"
placeholder="Search by access key..."
class="w-full pl-10 pr-4 py-2.5 border border-gray-200 rounded-lg text-charcoal placeholder:text-charcoal-300 focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-all"
oninput="filterUsers()"
>
</div>
<div class="relative" id="role-filter-container">
<input
type="text"
id="role-filter-display"
readonly
value="All Roles"
onclick="toggleDropdown('role-filter')"
class="bg-white border border-gray-200 rounded-lg px-4 py-2.5 pr-10 text-charcoal cursor-pointer focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-all"
>
<input type="hidden" id="role-filter" value="">
<svg class="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-charcoal-300 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
<div id="role-filter-dropdown" class="custom-dropdown">
<div class="custom-dropdown-item selected" data-value="" onclick="selectRoleFilter('')">All Roles</div>
<div class="custom-dropdown-item" data-value="admin" onclick="selectRoleFilter('admin')">Admin</div>
<div class="custom-dropdown-item" data-value="user" onclick="selectRoleFilter('user')">User</div>
<div class="custom-dropdown-item" data-value="userplus" onclick="selectRoleFilter('userplus')">User+</div>
</div>
</div>
</div>
</div>
<!-- Users Table -->
<div class="bg-white rounded-xl shadow-sm border border-gray-100 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full">
<thead class="bg-gray-50 border-b border-gray-100">
<tr>
<th class="text-left py-4 px-6 text-sm font-semibold text-charcoal">Access Key</th>
<th class="text-left py-4 px-6 text-sm font-semibold text-charcoal">Role</th>
<th class="text-left py-4 px-6 text-sm font-semibold text-charcoal">User ID</th>
<th class="text-left py-4 px-6 text-sm font-semibold text-charcoal">Group ID</th>
<th class="text-left py-4 px-6 text-sm font-semibold text-charcoal">Project ID</th>
<th class="text-right py-4 px-6 text-sm font-semibold text-charcoal">Actions</th>
</tr>
</thead>
<tbody id="users-table-body">
<!-- Populated by JS -->
</tbody>
</table>
</div>
</div>
</div>
</main>
</div>
</div>
<!-- Create/Edit User Modal -->
<div id="user-modal" class="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">
<div class="flex items-center justify-between p-6 border-b border-gray-100">
<h2 id="modal-title" class="text-xl font-semibold text-charcoal">Create New User</h2>
<button onclick="closeModal('user-modal')" class="p-2 text-charcoal-300 hover:text-charcoal hover:bg-gray-100 rounded-lg transition-colors">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<form id="user-form" class="p-6 space-y-5">
<input type="hidden" id="edit-mode" value="create">
<div>
<label class="block text-sm font-medium text-charcoal mb-2">Access Key <span class="text-red-500">*</span></label>
<div class="flex gap-2">
<input type="text" id="form-access" required placeholder="e.g., AKIAXXXXXXXXXX" class="flex-1 px-4 py-2.5 border-2 border-gray-200 rounded-lg text-charcoal font-mono text-sm placeholder:text-charcoal-300 placeholder:font-sans focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-all">
<button type="button" id="generate-access-btn" onclick="generateAccessKey()" class="px-4 py-2.5 bg-gray-100 hover:bg-gray-200 text-charcoal font-medium rounded-lg transition-colors text-sm">Generate</button>
</div>
</div>
<div>
<label class="block text-sm font-medium text-charcoal mb-2">Secret Key <span class="text-red-500">*</span></label>
<div class="flex gap-2">
<input type="text" id="form-secret" required placeholder="Click Generate or enter manually" class="flex-1 px-4 py-2.5 border-2 border-gray-200 rounded-lg text-charcoal font-mono text-sm placeholder:text-charcoal-300 placeholder:font-sans focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-all">
<button type="button" onclick="generateSecret()" class="px-4 py-2.5 bg-gray-100 hover:bg-gray-200 text-charcoal font-medium rounded-lg transition-colors text-sm">Generate</button>
</div>
</div>
<div>
<label class="block text-sm font-medium text-charcoal mb-2">Role <span class="text-red-500">*</span></label>
<div class="relative" id="form-role-container">
<input
type="text"
id="form-role-display"
readonly
value="Select a role..."
onclick="toggleDropdown('form-role')"
class="w-full px-4 py-2.5 pr-10 border-2 border-gray-200 rounded-lg text-charcoal bg-white cursor-pointer focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-all"
>
<input type="hidden" id="form-role" value="">
<svg class="absolute right-4 top-1/2 -translate-y-1/2 w-4 h-4 text-charcoal-300 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"/>
</svg>
<div id="form-role-dropdown" class="custom-dropdown">
<div class="custom-dropdown-item" data-value="" onclick="selectFormRole('')">Select a role...</div>
<div class="custom-dropdown-item" data-value="admin" onclick="selectFormRole('admin')">Admin - Full administrative access</div>
<div class="custom-dropdown-item" data-value="user" onclick="selectFormRole('user')">User - Standard user access</div>
<div class="custom-dropdown-item" data-value="userplus" onclick="selectFormRole('userplus')">User+ - Enhanced user permissions</div>
</div>
</div>
</div>
<details class="group">
<summary class="flex items-center gap-2 cursor-pointer text-sm font-medium text-charcoal-400 hover:text-charcoal transition-colors list-none">
<svg class="w-4 h-4 transition-transform group-open:rotate-90" 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>
Advanced Options
</summary>
<div class="mt-4 space-y-4 pl-6">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-charcoal mb-2">User ID</label>
<input type="number" id="form-userid" value="0" min="0" class="w-full px-4 py-2.5 border-2 border-gray-200 rounded-lg text-charcoal focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-all">
</div>
<div>
<label class="block text-sm font-medium text-charcoal mb-2">Group ID</label>
<input type="number" id="form-groupid" value="0" min="0" class="w-full px-4 py-2.5 border-2 border-gray-200 rounded-lg text-charcoal focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-all">
</div>
</div>
<div>
<label class="block text-sm font-medium text-charcoal mb-2">Project ID</label>
<input type="number" id="form-projectid" value="0" min="0" class="w-full px-4 py-2.5 border-2 border-gray-200 rounded-lg text-charcoal focus:outline-none focus:border-accent focus:ring-2 focus:ring-accent/20 transition-all">
</div>
</div>
</details>
</form>
<div class="flex items-center justify-end gap-3 p-6 border-t border-gray-100">
<button onclick="closeModal('user-modal')" class="px-4 py-2.5 border border-gray-200 rounded-lg text-charcoal font-medium hover:bg-gray-50 transition-colors">Cancel</button>
<button id="submit-btn" onclick="submitUserForm()" class="px-4 py-2.5 bg-primary hover:bg-primary-600 text-white font-medium rounded-lg transition-colors">Create User</button>
</div>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div id="delete-modal" class="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">
<div class="p-6">
<div class="w-12 h-12 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-4">
<svg class="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/>
</svg>
</div>
<h3 class="text-lg font-semibold text-charcoal text-center mb-2">Delete User</h3>
<p class="text-charcoal-300 text-center mb-6">
Are you sure you want to delete <span id="delete-user-name" class="font-mono text-charcoal"></span>? This action cannot be undone.
</p>
<div class="flex items-center justify-center gap-3">
<button onclick="closeModal('delete-modal')" class="px-4 py-2.5 border border-gray-200 rounded-lg text-charcoal font-medium hover:bg-gray-50 transition-colors">Cancel</button>
<button id="confirm-delete-btn" onclick="confirmDelete()" class="px-4 py-2.5 bg-red-600 hover:bg-red-700 text-white font-medium rounded-lg transition-colors">Delete User</button>
</div>
</div>
</div>
</div>
</div>
<script>
let allUsers = [];
let userToDelete = null;
// ============================================
// Custom Dropdown Functions
// ============================================
// Toggle any dropdown
function toggleDropdown(name) {
const dropdown = document.getElementById(name + '-dropdown');
const allDropdowns = document.querySelectorAll('.custom-dropdown');
// Close all other dropdowns
allDropdowns.forEach(d => {
if (d.id !== name + '-dropdown') d.classList.remove('show');
});
dropdown.classList.toggle('show');
}
// Close all dropdowns when clicking outside
document.addEventListener('click', (e) => {
const containers = ['role-filter-container', 'form-role-container'];
if (!containers.some(id => e.target.closest('#' + id))) {
document.querySelectorAll('.custom-dropdown').forEach(d => d.classList.remove('show'));
}
});
// Role filter dropdown
function selectRoleFilter(value) {
const display = document.getElementById('role-filter-display');
const hidden = document.getElementById('role-filter');
const dropdown = document.getElementById('role-filter-dropdown');
const labels = { '': 'All Roles', 'admin': 'Admin', 'user': 'User', 'userplus': 'User+' };
display.value = labels[value] || value;
hidden.value = value;
dropdown.querySelectorAll('.custom-dropdown-item').forEach(item => {
item.classList.toggle('selected', item.dataset.value === value);
});
dropdown.classList.remove('show');
filterUsers();
}
// Form role dropdown (for modal)
function selectFormRole(value) {
const display = document.getElementById('form-role-display');
const hidden = document.getElementById('form-role');
const dropdown = document.getElementById('form-role-dropdown');
const labels = {
'': 'Select a role...',
'admin': 'Admin - Full administrative access',
'user': 'User - Standard user access',
'userplus': 'User+ - Enhanced user permissions'
};
display.value = labels[value] || value;
hidden.value = value;
dropdown.querySelectorAll('.custom-dropdown-item').forEach(item => {
item.classList.toggle('selected', item.dataset.value === value);
});
dropdown.classList.remove('show');
}
if (!requireAdmin()) {
// Redirected
} else {
initSidebarWithRole();
updateUserInfo();
loadUsers();
}
async function loadUsers() {
showTableLoading('users-table-body', 6);
try {
allUsers = await api.listUsers();
filterUsers();
} catch (error) {
console.error('Error loading users:', error);
showToast('Error loading users: ' + error.message, 'error');
showEmptyState('users-table-body', 6, 'Error loading users');
}
}
function filterUsers() {
const searchTerm = document.getElementById('search-input').value.toLowerCase();
const roleFilter = document.getElementById('role-filter').value;
let filtered = allUsers;
if (searchTerm) {
filtered = filtered.filter(u => u.access && u.access.toLowerCase().includes(searchTerm));
}
if (roleFilter) {
filtered = filtered.filter(u => u.role === roleFilter);
}
renderUsers(filtered);
}
function renderUsers(users) {
const tbody = document.getElementById('users-table-body');
tbody.innerHTML = '';
if (users.length === 0) {
showEmptyState('users-table-body', 6, 'No users found');
return;
}
users.forEach(user => {
const row = document.createElement('tr');
row.className = 'border-b border-gray-50 hover:bg-gray-50 transition-colors';
row.innerHTML = `
<td class="py-4 px-6"><span class="font-mono text-sm text-charcoal">${escapeHtml(user.access)}</span></td>
<td class="py-4 px-6">${formatRole(user.role)}</td>
<td class="py-4 px-6 text-sm text-charcoal">${user.userid || '0'}</td>
<td class="py-4 px-6 text-sm text-charcoal">${user.groupid || '0'}</td>
<td class="py-4 px-6 text-sm text-charcoal">${user.projectid || '0'}</td>
<td class="py-4 px-6 text-right">
<div class="flex items-center justify-end gap-2">
<button onclick="openEditModal('${escapeHtml(user.access)}')" class="p-2 text-charcoal-300 hover:text-accent hover:bg-accent-50 rounded-lg transition-colors" title="Edit">
<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 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"/>
</svg>
</button>
<button onclick="openDeleteModal('${escapeHtml(user.access)}')" class="p-2 text-charcoal-300 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors" title="Delete">
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"/>
</svg>
</button>
</div>
</td>
`;
tbody.appendChild(row);
});
}
function openCreateModal() {
document.getElementById('edit-mode').value = 'create';
document.getElementById('modal-title').textContent = 'Create New User';
document.getElementById('submit-btn').textContent = 'Create User';
document.getElementById('form-access').value = '';
document.getElementById('form-access').readOnly = false;
document.getElementById('form-access').classList.remove('bg-gray-50');
document.getElementById('generate-access-btn').classList.remove('hidden');
document.getElementById('form-secret').value = '';
document.getElementById('form-secret').required = true;
document.getElementById('form-secret').placeholder = 'Click Generate or enter manually';
selectFormRole('');
document.getElementById('form-userid').value = '0';
document.getElementById('form-groupid').value = '0';
document.getElementById('form-projectid').value = '0';
openModal('user-modal');
}
function openEditModal(accessKey) {
const user = allUsers.find(u => u.access === accessKey);
if (!user) return;
document.getElementById('edit-mode').value = 'edit';
document.getElementById('modal-title').textContent = 'Edit User';
document.getElementById('submit-btn').textContent = 'Save Changes';
document.getElementById('form-access').value = user.access;
document.getElementById('form-access').readOnly = true;
document.getElementById('form-access').classList.add('bg-gray-50');
document.getElementById('generate-access-btn').classList.add('hidden');
document.getElementById('form-secret').value = '';
document.getElementById('form-secret').required = false;
document.getElementById('form-secret').placeholder = 'Leave blank to keep current';
selectFormRole(user.role || 'user');
document.getElementById('form-userid').value = user.userid || '0';
document.getElementById('form-groupid').value = user.groupid || '0';
document.getElementById('form-projectid').value = user.projectid || '0';
openModal('user-modal');
}
function openDeleteModal(accessKey) {
userToDelete = accessKey;
document.getElementById('delete-user-name').textContent = accessKey;
openModal('delete-modal');
}
function generateAccessKey() {
document.getElementById('form-access').value = api.generateAccessKey();
}
function generateSecret() {
document.getElementById('form-secret').value = api.generateSecretKey();
}
async function submitUserForm() {
const mode = document.getElementById('edit-mode').value;
const access = document.getElementById('form-access').value.trim();
const secret = document.getElementById('form-secret').value;
const role = document.getElementById('form-role').value;
const userid = parseInt(document.getElementById('form-userid').value) || 0;
const groupid = parseInt(document.getElementById('form-groupid').value) || 0;
const projectid = parseInt(document.getElementById('form-projectid').value) || 0;
if (!access || !role) {
showToast('Please fill in all required fields', 'error');
return;
}
if (mode === 'create' && !secret) {
showToast('Secret key is required for new users', 'error');
return;
}
const btn = document.getElementById('submit-btn');
setLoading(btn, true);
try {
if (mode === 'create') {
await api.createUser(access, secret, role, userid, groupid, projectid);
showToast('User created successfully', 'success');
} else {
const updates = { role, userID: userid, groupID: groupid, projectID: projectid };
if (secret) updates.secret = secret;
await api.updateUser(access, updates);
showToast('User updated successfully', 'success');
}
closeModal('user-modal');
loadUsers();
} catch (error) {
console.error('Error saving user:', error);
showToast('Error: ' + error.message, 'error');
} finally {
setLoading(btn, false);
}
}
async function confirmDelete() {
if (!userToDelete) return;
const btn = document.getElementById('confirm-delete-btn');
setLoading(btn, true);
try {
await api.deleteUser(userToDelete);
showToast('User deleted successfully', 'success');
closeModal('delete-modal');
userToDelete = null;
loadUsers();
} catch (error) {
console.error('Error deleting user:', error);
showToast('Error: ' + error.message, 'error');
} finally {
setLoading(btn, false);
}
}
</script>
</body>
</html>

150
webui/webserver.go Normal file
View File

@@ -0,0 +1,150 @@
// Copyright 2026 Versity Software
// This file is licensed under the Apache License, Version 2.0
// (the "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
package webui
import (
"fmt"
"net/http"
"strings"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/filesystem"
"github.com/gofiber/fiber/v2/middleware/logger"
"github.com/gofiber/fiber/v2/middleware/recover"
"github.com/versity/versitygw/s3api/utils"
)
// ServerConfig holds the server configuration
type ServerConfig struct {
ListenAddr string
Gateways []string // S3 API gateways
AdminGateways []string // Admin API gateways (defaults to Gateways if empty)
Region string
CORSOrigin string
}
// Server is the main GUI server
type Server struct {
app *fiber.App
CertStorage *utils.CertStorage
config *ServerConfig
quiet bool
}
// Option sets various options for NewServer()
type Option func(*Server)
// WithQuiet silences default logging output.
func WithQuiet() Option {
return func(s *Server) { s.quiet = true }
}
// WithTLS sets TLS Credentials
func WithTLS(cs *utils.CertStorage) Option {
return func(s *Server) { s.CertStorage = cs }
}
// NewServer creates a new GUI server instance
func NewServer(cfg *ServerConfig, opts ...Option) *Server {
app := fiber.New(fiber.Config{
AppName: "versitygw",
ServerHeader: "VERSITYGW",
DisableStartupMessage: true,
})
server := &Server{
app: app,
config: cfg,
}
for _, opt := range opts {
opt(server)
}
server.setupMiddleware()
server.setupRoutes()
fmt.Printf("initializing web dashboard on %s\n", cfg.ListenAddr)
return server
}
// setupMiddleware configures middleware
func (s *Server) setupMiddleware() {
// Panic recovery
s.app.Use(recover.New())
// Request logging
if !s.quiet {
s.app.Use(logger.New(logger.Config{
Format: "${time} | web | ${status} | ${latency} | ${ip} | ${method} | ${path}\n",
}))
}
}
// setupRoutes configures all routes
func (s *Server) setupRoutes() {
// API endpoint to get configured gateways
s.app.Get("/api/gateways", s.handleGetGateways)
// Serve embedded static files from web/
s.app.Use("/", filesystem.New(filesystem.Config{
Root: http.FS(webFS),
PathPrefix: "web",
Index: "index.html",
NotFoundFile: "index.html", // SPA fallback
Browse: false,
}))
}
// handleGetGateways returns the configured gateway URLs (both S3 and Admin)
func (s *Server) handleGetGateways(c *fiber.Ctx) error {
adminGateways := s.config.AdminGateways
if len(adminGateways) == 0 {
// Fallback to S3 gateways if admin gateways not configured
adminGateways = s.config.Gateways
}
return c.JSON(fiber.Map{
"gateways": s.config.Gateways,
"adminGateways": adminGateways,
"defaultRegion": s.config.Region,
})
}
// Serve starts the server
func (s *Server) Serve() error {
addr := strings.TrimSpace(s.config.ListenAddr)
if addr == "" {
return fmt.Errorf("webui: listen address is required")
}
// Check if TLS is configured
if s.CertStorage != nil {
ln, err := utils.NewTLSListener(s.app.Config().Network, addr, s.CertStorage.GetCertificate)
if err != nil {
return err
}
return s.app.Listener(ln)
}
return s.app.Listen(addr)
}
// Shutdown gracefully shuts down the server
func (s *Server) Shutdown() error {
return s.app.Shutdown()
}