mirror of
https://github.com/versity/versitygw.git
synced 2026-01-28 22:12:04 +00:00
Compare commits
26 Commits
v1.1.0
...
test/repor
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4341f6bc3a | ||
|
|
a3e3aa330a | ||
|
|
a1d8161aaa | ||
|
|
2f186a9b9b | ||
|
|
ed2de68733 | ||
|
|
45b6a4a74e | ||
|
|
b576ed87c5 | ||
|
|
0ba5cbe8b9 | ||
|
|
a4d341fc4e | ||
|
|
6c564febb9 | ||
|
|
0c520a30cf | ||
|
|
935e322764 | ||
|
|
f6225aa968 | ||
|
|
1d30567129 | ||
|
|
bfc753b302 | ||
|
|
86e2b02e55 | ||
|
|
2cf8610831 | ||
|
|
8e3e633a24 | ||
|
|
12092cf297 | ||
|
|
75cae81f0a | ||
|
|
68d7924afa | ||
|
|
e37dfa6aaf | ||
|
|
04f8946798 | ||
|
|
43fd18b069 | ||
|
|
eb72d3c6e8 | ||
|
|
43559e646e |
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
12
go.mod
@@ -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
24
go.sum
@@ -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=
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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{},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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/*
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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=$?
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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}"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
46
tests/drivers/put_object/put_object.sh
Normal file
46
tests/drivers/put_object/put_object.sh
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 {
|
||||
|
||||
15
tests/merge_report_tables.sh
Normal file
15
tests/merge_report_tables.sh
Normal 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.
|
||||
201
tests/report.sh
201
tests/report.sh
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
89
tests/test_report.sh
Executable 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
|
||||
}
|
||||
@@ -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
87
tests/test_rest_attributes.sh
Executable 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
|
||||
}
|
||||
@@ -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
60
tests/test_rest_copy_object.sh
Executable 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
97
tests/test_rest_delete_object.sh
Executable file
97
tests/test_rest_delete_object.sh
Executable 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
82
tests/test_rest_get_object.sh
Executable 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
|
||||
}
|
||||
65
tests/test_rest_get_object_legal_hold.sh
Executable file
65
tests/test_rest_get_object_legal_hold.sh
Executable 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
|
||||
}
|
||||
@@ -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
45
tests/test_rest_head_object.sh
Executable 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
110
tests/test_rest_list_objects.sh
Executable 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
87
tests/test_rest_put_object_legal_hold.sh
Executable file
87
tests/test_rest_put_object_legal_hold.sh
Executable 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
73
tests/test_rest_retention.sh
Executable 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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
26
webui/embed.go
Normal 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
|
||||
71
webui/web/assets/LICENSES.txt
Normal file
71
webui/web/assets/LICENSES.txt
Normal 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.
|
||||
35
webui/web/assets/css/fonts.css
Normal file
35
webui/web/assets/css/fonts.css
Normal 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');
|
||||
}
|
||||
83
webui/web/assets/css/tailwind.js
Normal file
83
webui/web/assets/css/tailwind.js
Normal file
File diff suppressed because one or more lines are too long
BIN
webui/web/assets/fonts/roboto-400.woff2
Normal file
BIN
webui/web/assets/fonts/roboto-400.woff2
Normal file
Binary file not shown.
BIN
webui/web/assets/fonts/roboto-500.woff2
Normal file
BIN
webui/web/assets/fonts/roboto-500.woff2
Normal file
Binary file not shown.
BIN
webui/web/assets/fonts/roboto-600.woff2
Normal file
BIN
webui/web/assets/fonts/roboto-600.woff2
Normal file
Binary file not shown.
BIN
webui/web/assets/fonts/roboto-700.woff2
Normal file
BIN
webui/web/assets/fonts/roboto-700.woff2
Normal file
Binary file not shown.
BIN
webui/web/assets/images/Versity-logo-blue-horizontal.png
Normal file
BIN
webui/web/assets/images/Versity-logo-blue-horizontal.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 63 KiB |
BIN
webui/web/assets/images/Versity-logo-white-horizontal.png
Normal file
BIN
webui/web/assets/images/Versity-logo-white-horizontal.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 78 KiB |
1
webui/web/assets/js/crypto-js.min.js
vendored
Normal file
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
705
webui/web/buckets.html
Normal 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
358
webui/web/dashboard.html
Normal 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
4120
webui/web/explorer.html
Normal file
File diff suppressed because it is too large
Load Diff
836
webui/web/index.html
Normal file
836
webui/web/index.html
Normal 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">
|
||||
© 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
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
369
webui/web/js/app.js
Normal 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
621
webui/web/users.html
Normal 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
150
webui/webserver.go
Normal 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()
|
||||
}
|
||||
Reference in New Issue
Block a user