mirror of
https://github.com/versity/versitygw.git
synced 2026-02-11 12:41:07 +00:00
The PutBucketObjectLockConfiguration now requires Content-MD5 header to match AWS behavior. This broke the GUI from being able to set object lock configuration for a bucket. This fix adds the Content-MD5 header to this request.
2095 lines
66 KiB
JavaScript
2095 lines
66 KiB
JavaScript
// 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 API Client
|
|
* Implements AWS Signature V4 signing for admin and S3 API requests
|
|
* Supports dual-mode: Admin (full UI) and S3-only (Explorer only)
|
|
*/
|
|
|
|
/**
|
|
* CryptoJS wrapper for SHA-256 and HMAC-SHA256 operations
|
|
* Used as fallback when crypto.subtle is unavailable (non-HTTPS contexts)
|
|
* Requires: CryptoJS library (loaded via script tag in HTML)
|
|
*/
|
|
const CryptoJSWrapper = {
|
|
// SHA-256 returning hex string
|
|
sha256(message) {
|
|
if (typeof CryptoJS === 'undefined') {
|
|
throw new Error('CryptoJS library not loaded');
|
|
}
|
|
return CryptoJS.SHA256(message).toString(CryptoJS.enc.Hex);
|
|
},
|
|
|
|
// SHA-256 returning Uint8Array
|
|
sha256Bytes(message) {
|
|
if (typeof CryptoJS === 'undefined') {
|
|
throw new Error('CryptoJS library not loaded');
|
|
}
|
|
const wordArray = CryptoJS.SHA256(message);
|
|
const words = wordArray.words;
|
|
const sigBytes = wordArray.sigBytes;
|
|
const bytes = new Uint8Array(sigBytes);
|
|
|
|
for (let i = 0; i < sigBytes; i++) {
|
|
bytes[i] = (words[i >>> 2] >>> (24 - (i % 4) * 8)) & 0xff;
|
|
}
|
|
return bytes;
|
|
},
|
|
|
|
// HMAC-SHA256 returning Uint8Array
|
|
hmacSha256(key, message) {
|
|
if (typeof CryptoJS === 'undefined') {
|
|
throw new Error('CryptoJS library not loaded');
|
|
}
|
|
|
|
// Convert key to CryptoJS format
|
|
let cryptoKey;
|
|
if (typeof key === 'string') {
|
|
cryptoKey = CryptoJS.enc.Utf8.parse(key);
|
|
} else if (key instanceof Uint8Array) {
|
|
// Convert Uint8Array to WordArray
|
|
const words = [];
|
|
for (let i = 0; i < key.length; i += 4) {
|
|
const word = (key[i] << 24) | (key[i + 1] << 16) | (key[i + 2] << 8) | key[i + 3];
|
|
words.push(word);
|
|
}
|
|
cryptoKey = CryptoJS.lib.WordArray.create(words, key.length);
|
|
} else {
|
|
cryptoKey = key;
|
|
}
|
|
|
|
// Convert message to CryptoJS format
|
|
let cryptoMessage;
|
|
if (typeof message === 'string') {
|
|
cryptoMessage = CryptoJS.enc.Utf8.parse(message);
|
|
} else if (message instanceof Uint8Array) {
|
|
const words = [];
|
|
for (let i = 0; i < message.length; i += 4) {
|
|
const word = (message[i] << 24) | (message[i + 1] << 16) | (message[i + 2] << 8) | message[i + 3];
|
|
words.push(word);
|
|
}
|
|
cryptoMessage = CryptoJS.lib.WordArray.create(words, message.length);
|
|
} else {
|
|
cryptoMessage = message;
|
|
}
|
|
|
|
// Compute HMAC
|
|
const hmac = CryptoJS.HmacSHA256(cryptoMessage, cryptoKey);
|
|
const words = hmac.words;
|
|
const sigBytes = hmac.sigBytes;
|
|
const bytes = new Uint8Array(sigBytes);
|
|
|
|
for (let i = 0; i < sigBytes; i++) {
|
|
bytes[i] = (words[i >>> 2] >>> (24 - (i % 4) * 8)) & 0xff;
|
|
}
|
|
return bytes;
|
|
}
|
|
};
|
|
|
|
// Check if crypto.subtle is available (secure context)
|
|
const hasSubtleCrypto = typeof crypto !== 'undefined' && typeof crypto.subtle !== 'undefined';
|
|
|
|
/**
|
|
* Encode an S3 object key for use in a URL path.
|
|
* Encodes each path segment but preserves slashes as path separators.
|
|
* @param {string} key - The S3 object key
|
|
* @returns {string} - URL-encoded path
|
|
*/
|
|
function encodeS3Key(key) {
|
|
if (!key) return '';
|
|
return key.split('/').map(segment => encodeURIComponent(segment)).join('/');
|
|
}
|
|
|
|
class VersityAPI {
|
|
constructor() {
|
|
this.credentials = null;
|
|
this.adminEndpoint = null; // Admin API endpoint (may be null for S3-only users)
|
|
this.s3Endpoint = null; // S3 API endpoint (always required)
|
|
this.region = 'us-east-1';
|
|
this.addressingStyle = 'path'; // 'path' or 'virtual-host'
|
|
this._isAdmin = false; // Role flag
|
|
}
|
|
|
|
/**
|
|
* Create a SigV4 presigned URL (query-string auth) for S3 requests.
|
|
* This avoids sending non-simple headers (Authorization, X-Amz-Date, etc.)
|
|
* and therefore avoids browser CORS preflight in many deployments.
|
|
*
|
|
* Currently used to ensure ListBuckets (GET /) works when the gateway
|
|
* does not implement OPTIONS /.
|
|
*/
|
|
async presignUrl(method, path, queryParams = {}, expiresSeconds = 60, useAdminEndpoint = false) {
|
|
if (!this.credentials) {
|
|
throw new Error('Not authenticated');
|
|
}
|
|
|
|
const endpoint = this.getEndpoint(useAdminEndpoint);
|
|
const url = new URL(endpoint + path);
|
|
const host = url.host;
|
|
const service = 's3';
|
|
const amzDate = this.getAmzDate();
|
|
const dateStamp = this.getDateStamp();
|
|
|
|
// Base query params
|
|
Object.entries(queryParams || {}).forEach(([key, value]) => {
|
|
if (value !== undefined && value !== null) {
|
|
url.searchParams.set(key, String(value));
|
|
}
|
|
});
|
|
|
|
const algorithm = 'AWS4-HMAC-SHA256';
|
|
const credentialScope = `${dateStamp}/${this.region}/${service}/aws4_request`;
|
|
|
|
url.searchParams.set('X-Amz-Algorithm', algorithm);
|
|
url.searchParams.set('X-Amz-Credential', `${this.credentials.accessKey}/${credentialScope}`);
|
|
url.searchParams.set('X-Amz-Date', amzDate);
|
|
url.searchParams.set('X-Amz-Expires', String(expiresSeconds));
|
|
url.searchParams.set('X-Amz-SignedHeaders', 'host');
|
|
|
|
// Sort query params for canonical request
|
|
const sortedParams = [...url.searchParams.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
|
const canonicalQueryString = sortedParams
|
|
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
|
|
.join('&');
|
|
|
|
const canonicalHeaders = `host:${host}\n`;
|
|
const signedHeaders = 'host';
|
|
const payloadHash = 'UNSIGNED-PAYLOAD';
|
|
|
|
const canonicalRequest = [
|
|
method,
|
|
url.pathname,
|
|
canonicalQueryString,
|
|
canonicalHeaders,
|
|
signedHeaders,
|
|
payloadHash,
|
|
].join('\n');
|
|
|
|
const canonicalRequestHash = await this.sha256(canonicalRequest);
|
|
const stringToSign = [
|
|
algorithm,
|
|
amzDate,
|
|
credentialScope,
|
|
canonicalRequestHash,
|
|
].join('\n');
|
|
|
|
const signingKey = await this.getSigningKey(this.credentials.secretKey, dateStamp, this.region, service);
|
|
const signatureBuffer = await this.hmacSha256(signingKey, stringToSign);
|
|
const signature = this.bufferToHex(signatureBuffer);
|
|
|
|
url.searchParams.set('X-Amz-Signature', signature);
|
|
return url.toString();
|
|
}
|
|
|
|
/**
|
|
* Set credentials for API requests (initial login - assumes same endpoint)
|
|
*/
|
|
setCredentials(endpoint, accessKey, secretKey, region = 'us-east-1') {
|
|
endpoint = endpoint.replace(/\/$/, ''); // Remove trailing slash
|
|
this.adminEndpoint = endpoint;
|
|
this.s3Endpoint = endpoint;
|
|
this.credentials = { accessKey, secretKey };
|
|
this.region = region;
|
|
this._isAdmin = false; // Will be set by detectRole()
|
|
|
|
// Store in sessionStorage for persistence across page loads
|
|
sessionStorage.setItem('vgw_admin_endpoint', this.adminEndpoint);
|
|
sessionStorage.setItem('vgw_s3_endpoint', this.s3Endpoint);
|
|
sessionStorage.setItem('vgw_access_key', accessKey);
|
|
sessionStorage.setItem('vgw_secret_key', secretKey);
|
|
sessionStorage.setItem('vgw_region', region);
|
|
sessionStorage.setItem('vgw_is_admin', 'false');
|
|
}
|
|
|
|
/**
|
|
* Set the S3 endpoint separately (when different from admin)
|
|
*/
|
|
setS3Endpoint(s3Endpoint) {
|
|
this.s3Endpoint = s3Endpoint.replace(/\/$/, '');
|
|
sessionStorage.setItem('vgw_s3_endpoint', this.s3Endpoint);
|
|
}
|
|
|
|
/**
|
|
* Set bucket addressing style ('path' or 'virtual-host')
|
|
*/
|
|
setAddressingStyle(style) {
|
|
this.addressingStyle = style || 'path';
|
|
sessionStorage.setItem('vgw_addressing_style', this.addressingStyle);
|
|
}
|
|
|
|
/**
|
|
* Set admin role flag
|
|
*/
|
|
setAdminRole(isAdmin) {
|
|
this._isAdmin = isAdmin;
|
|
sessionStorage.setItem('vgw_is_admin', isAdmin ? 'true' : 'false');
|
|
}
|
|
|
|
/**
|
|
* Load credentials from sessionStorage
|
|
*/
|
|
loadCredentials() {
|
|
const adminEndpoint = sessionStorage.getItem('vgw_admin_endpoint');
|
|
const s3Endpoint = sessionStorage.getItem('vgw_s3_endpoint');
|
|
const accessKey = sessionStorage.getItem('vgw_access_key');
|
|
const secretKey = sessionStorage.getItem('vgw_secret_key');
|
|
const region = sessionStorage.getItem('vgw_region') || 'us-east-1';
|
|
const addressingStyle = sessionStorage.getItem('vgw_addressing_style') || 'path';
|
|
const isAdmin = sessionStorage.getItem('vgw_is_admin') === 'true';
|
|
|
|
// Support legacy single endpoint storage
|
|
const legacyEndpoint = sessionStorage.getItem('vgw_endpoint');
|
|
|
|
if ((s3Endpoint || legacyEndpoint) && accessKey && secretKey) {
|
|
this.adminEndpoint = adminEndpoint || legacyEndpoint;
|
|
this.s3Endpoint = s3Endpoint || legacyEndpoint;
|
|
this.credentials = { accessKey, secretKey };
|
|
this.region = region;
|
|
this.addressingStyle = addressingStyle;
|
|
this._isAdmin = isAdmin;
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Clear credentials and logout
|
|
*/
|
|
logout() {
|
|
this.credentials = null;
|
|
this.adminEndpoint = null;
|
|
this.s3Endpoint = null;
|
|
this.addressingStyle = 'path';
|
|
this._isAdmin = false;
|
|
this._userType = 'user';
|
|
this._accessibleGateways = [];
|
|
sessionStorage.removeItem('vgw_admin_endpoint');
|
|
sessionStorage.removeItem('vgw_s3_endpoint');
|
|
sessionStorage.removeItem('vgw_endpoint'); // Legacy
|
|
sessionStorage.removeItem('vgw_access_key');
|
|
sessionStorage.removeItem('vgw_secret_key');
|
|
sessionStorage.removeItem('vgw_region');
|
|
sessionStorage.removeItem('vgw_addressing_style');
|
|
sessionStorage.removeItem('vgw_is_admin');
|
|
sessionStorage.removeItem('vgw_user_type');
|
|
sessionStorage.removeItem('vgw_accessible_gateways');
|
|
}
|
|
|
|
// ============================================
|
|
// User Context Methods (ROOT user detection)
|
|
// ============================================
|
|
|
|
/**
|
|
* Detect ROOT user and get accessible gateways
|
|
* Calls /api/detect-root endpoint to check if credentials match ROOT config
|
|
* @param {string} accessKey - Access key to check
|
|
* @param {string} secretKey - Secret key to check
|
|
* @returns {Object} - { userType: 'root'|'user', matchingGateways: [...] }
|
|
*/
|
|
// detectRootUser removed - single gateway mode
|
|
|
|
/**
|
|
* Store user type and accessible gateways in session
|
|
* @param {string} userType - 'root' | 'admin' | 'user'
|
|
* @param {Array} accessibleGateways - List of gateways this user can access
|
|
*/
|
|
setUserContext(userType, accessibleGateways) {
|
|
this._userType = userType;
|
|
sessionStorage.setItem('vgw_user_type', userType);
|
|
}
|
|
|
|
/**
|
|
* Load user context from session storage
|
|
* Called after loadCredentials() to restore user type and gateways
|
|
*/
|
|
loadUserContext() {
|
|
this._userType = sessionStorage.getItem('vgw_user_type') || 'user';
|
|
}
|
|
|
|
/**
|
|
* Check if current user is ROOT user (has ROOT credentials matching gateway configs)
|
|
* @returns {boolean}
|
|
*/
|
|
// isRootUser removed
|
|
|
|
/**
|
|
* Get list of gateways accessible to current user
|
|
* Only populated for ROOT users
|
|
* @returns {Array} - Array of { name, port, endpoint, region, status }
|
|
*/
|
|
// getAccessibleGateways removed
|
|
|
|
/**
|
|
* Check if authenticated
|
|
*/
|
|
isAuthenticated() {
|
|
return this.credentials !== null && this.s3Endpoint !== null;
|
|
}
|
|
|
|
/**
|
|
* Check if user has admin privileges
|
|
*/
|
|
isAdmin() {
|
|
return this._isAdmin;
|
|
}
|
|
|
|
/**
|
|
* Get current credentials info (without secret)
|
|
*/
|
|
getCredentialsInfo() {
|
|
if (!this.credentials) return null;
|
|
return {
|
|
adminEndpoint: this.adminEndpoint,
|
|
s3Endpoint: this.s3Endpoint,
|
|
endpoint: this.s3Endpoint, // Legacy compatibility
|
|
accessKey: this.credentials.accessKey,
|
|
region: this.region,
|
|
isAdmin: this._isAdmin
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get the appropriate endpoint for a request type
|
|
*/
|
|
getEndpoint(useAdmin = false) {
|
|
if (useAdmin && this.adminEndpoint) {
|
|
return this.adminEndpoint;
|
|
}
|
|
return this.s3Endpoint;
|
|
}
|
|
|
|
/**
|
|
* Build URL for S3 requests with appropriate addressing style
|
|
* @param {string} path - Request path (e.g., '/bucket/key')
|
|
* @param {boolean} useAdmin - Whether to use admin endpoint
|
|
* @returns {string} - Full URL with endpoint and path
|
|
*/
|
|
buildRequestUrl(path, useAdmin = false) {
|
|
const endpoint = this.getEndpoint(useAdmin);
|
|
|
|
// Admin API and non-S3 requests always use path style
|
|
if (useAdmin) {
|
|
return endpoint + path;
|
|
}
|
|
|
|
// For S3 API requests, check addressing style
|
|
if (this.addressingStyle === 'virtual-host') {
|
|
// Extract bucket name from path (format: /bucket/key or /bucket)
|
|
const pathMatch = path.match(/^\/([^\/]+)(\/.*)?$/);
|
|
if (pathMatch) {
|
|
const bucketName = pathMatch[1];
|
|
const keyPath = pathMatch[2] || '/';
|
|
|
|
// Parse the endpoint URL
|
|
const endpointUrl = new URL(endpoint);
|
|
|
|
// Create virtual host URL: bucket.host/key
|
|
const virtualHost = `${bucketName}.${endpointUrl.host}`;
|
|
return `${endpointUrl.protocol}//${virtualHost}${keyPath}`;
|
|
}
|
|
}
|
|
|
|
// Default to path style
|
|
return endpoint + path;
|
|
}
|
|
|
|
// ============================================
|
|
// AWS Signature V4 Implementation
|
|
// ============================================
|
|
|
|
/**
|
|
* Convert ArrayBuffer to hex string
|
|
*/
|
|
bufferToHex(buffer) {
|
|
return Array.from(new Uint8Array(buffer))
|
|
.map(b => b.toString(16).padStart(2, '0'))
|
|
.join('');
|
|
}
|
|
|
|
/**
|
|
* SHA-256 hash (uses crypto.subtle in HTTPS, CryptoJS in HTTP)
|
|
*/
|
|
async sha256(message) {
|
|
if (hasSubtleCrypto) {
|
|
const encoder = new TextEncoder();
|
|
const data = encoder.encode(message);
|
|
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
|
return this.bufferToHex(hashBuffer);
|
|
} else {
|
|
return CryptoJSWrapper.sha256(message);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* SHA-256 hash returning base64 (for x-amz-checksum-sha256 header)
|
|
*/
|
|
async sha256Base64(message) {
|
|
if (hasSubtleCrypto) {
|
|
const encoder = new TextEncoder();
|
|
const data = encoder.encode(message);
|
|
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
|
const bytes = new Uint8Array(hashBuffer);
|
|
let binary = '';
|
|
for (let i = 0; i < bytes.byteLength; i++) {
|
|
binary += String.fromCharCode(bytes[i]);
|
|
}
|
|
return btoa(binary);
|
|
} else {
|
|
// Use CryptoJS - get bytes and convert to base64
|
|
const bytes = CryptoJSWrapper.sha256Bytes(message);
|
|
let binary = '';
|
|
for (let i = 0; i < bytes.byteLength; i++) {
|
|
binary += String.fromCharCode(bytes[i]);
|
|
}
|
|
return btoa(binary);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* MD5 hash returning base64 (for Content-MD5 header)
|
|
*/
|
|
md5Base64(message) {
|
|
// MD5 is not available in Web Crypto API (not secure), so use CryptoJS
|
|
const hash = CryptoJS.MD5(message);
|
|
const bytes = [];
|
|
for (let i = 0; i < hash.sigBytes; i++) {
|
|
bytes.push((hash.words[i >>> 2] >>> (24 - (i % 4) * 8)) & 0xff);
|
|
}
|
|
let binary = '';
|
|
for (let i = 0; i < bytes.length; i++) {
|
|
binary += String.fromCharCode(bytes[i]);
|
|
}
|
|
return btoa(binary);
|
|
}
|
|
|
|
/**
|
|
* HMAC-SHA256 (uses crypto.subtle in HTTPS, CryptoJS in HTTP)
|
|
*/
|
|
async hmacSha256(key, message) {
|
|
if (hasSubtleCrypto) {
|
|
const encoder = new TextEncoder();
|
|
const keyData = typeof key === 'string' ? encoder.encode(key) : key;
|
|
const messageData = encoder.encode(message);
|
|
|
|
const cryptoKey = await crypto.subtle.importKey(
|
|
'raw',
|
|
keyData,
|
|
{ name: 'HMAC', hash: 'SHA-256' },
|
|
false,
|
|
['sign']
|
|
);
|
|
|
|
const signature = await crypto.subtle.sign('HMAC', cryptoKey, messageData);
|
|
return new Uint8Array(signature);
|
|
} else {
|
|
return CryptoJSWrapper.hmacSha256(key, message);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get signing key for AWS Signature V4
|
|
*/
|
|
async getSigningKey(secretKey, dateStamp, region, service) {
|
|
const encoder = new TextEncoder();
|
|
const kDate = await this.hmacSha256(encoder.encode('AWS4' + secretKey), dateStamp);
|
|
const kRegion = await this.hmacSha256(kDate, region);
|
|
const kService = await this.hmacSha256(kRegion, service);
|
|
const kSigning = await this.hmacSha256(kService, 'aws4_request');
|
|
return kSigning;
|
|
}
|
|
|
|
/**
|
|
* Format date for AWS signing
|
|
*/
|
|
getAmzDate() {
|
|
const now = new Date();
|
|
return now.toISOString().replace(/[:-]|\.\d{3}/g, '');
|
|
}
|
|
|
|
/**
|
|
* Get date stamp (YYYYMMDD)
|
|
*/
|
|
getDateStamp() {
|
|
return this.getAmzDate().slice(0, 8);
|
|
}
|
|
|
|
/**
|
|
* Sign a request using AWS Signature V4
|
|
* @param {string} method - HTTP method
|
|
* @param {string} path - Request path
|
|
* @param {Object} queryParams - Query parameters
|
|
* @param {string} body - Request body
|
|
* @param {boolean} useAdminEndpoint - Use admin endpoint instead of S3
|
|
*/
|
|
async signRequest(method, path, queryParams = {}, body = '', useAdminEndpoint = false, contentType = 'application/xml') {
|
|
if (!this.credentials) {
|
|
throw new Error('Not authenticated');
|
|
}
|
|
|
|
const fullUrl = this.buildRequestUrl(path, useAdminEndpoint);
|
|
const url = new URL(fullUrl);
|
|
const host = url.host;
|
|
const service = 's3';
|
|
const amzDate = this.getAmzDate();
|
|
const dateStamp = this.getDateStamp();
|
|
|
|
// Add query parameters
|
|
Object.entries(queryParams).forEach(([key, value]) => {
|
|
if (value !== undefined && value !== null) {
|
|
url.searchParams.set(key, value);
|
|
}
|
|
});
|
|
|
|
// Sort query parameters
|
|
const sortedParams = [...url.searchParams.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
|
const canonicalQueryString = sortedParams.map(([k, v]) =>
|
|
`${encodeURIComponent(k)}=${encodeURIComponent(v)}`
|
|
).join('&');
|
|
|
|
// Hash the payload
|
|
const payloadHash = await this.sha256(body);
|
|
|
|
// Create canonical headers - only include content-type for methods with body
|
|
const headers = {
|
|
'host': host,
|
|
'x-amz-content-sha256': payloadHash,
|
|
'x-amz-date': amzDate,
|
|
};
|
|
|
|
// Add Content-Type only for methods that have a body.
|
|
// IMPORTANT: if the actual request sends a different Content-Type than the one
|
|
// we sign, the gateway will return SignatureDoesNotMatch.
|
|
const hasBody = method === 'PUT' || method === 'POST' || method === 'PATCH';
|
|
if (hasBody && contentType) {
|
|
headers['content-type'] = contentType;
|
|
}
|
|
|
|
const signedHeadersList = Object.keys(headers).sort();
|
|
const signedHeaders = signedHeadersList.join(';');
|
|
const canonicalHeaders = signedHeadersList.map(h => `${h}:${headers[h]}\n`).join('');
|
|
|
|
// Create canonical request
|
|
const canonicalRequest = [
|
|
method,
|
|
url.pathname,
|
|
canonicalQueryString,
|
|
canonicalHeaders,
|
|
signedHeaders,
|
|
payloadHash
|
|
].join('\n');
|
|
|
|
// Create string to sign
|
|
const algorithm = 'AWS4-HMAC-SHA256';
|
|
const credentialScope = `${dateStamp}/${this.region}/${service}/aws4_request`;
|
|
const canonicalRequestHash = await this.sha256(canonicalRequest);
|
|
const stringToSign = [
|
|
algorithm,
|
|
amzDate,
|
|
credentialScope,
|
|
canonicalRequestHash
|
|
].join('\n');
|
|
|
|
// Calculate signature
|
|
const signingKey = await this.getSigningKey(this.credentials.secretKey, dateStamp, this.region, service);
|
|
const signatureBuffer = await this.hmacSha256(signingKey, stringToSign);
|
|
const signature = this.bufferToHex(signatureBuffer);
|
|
|
|
// Create authorization header
|
|
const authorization = `${algorithm} Credential=${this.credentials.accessKey}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
|
|
|
|
// Build response headers - only include Content-Type if it was signed
|
|
const responseHeaders = {
|
|
'Authorization': authorization,
|
|
'X-Amz-Date': amzDate,
|
|
'X-Amz-Content-Sha256': payloadHash,
|
|
};
|
|
|
|
if (hasBody && contentType) {
|
|
responseHeaders['Content-Type'] = contentType;
|
|
}
|
|
|
|
return {
|
|
url: url.toString(),
|
|
headers: responseHeaders
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Sign a request with x-amz-checksum-sha256 header (for S3 Multi-Object Delete)
|
|
* @param {string} method - HTTP method
|
|
* @param {string} path - Request path
|
|
* @param {Object} queryParams - Query parameters
|
|
* @param {string} body - Request body
|
|
* @param {string} checksumBase64 - Base64-encoded SHA256 checksum of body
|
|
*/
|
|
async signRequestWithChecksum(method, path, queryParams = {}, body = '', checksumBase64) {
|
|
if (!this.credentials) {
|
|
throw new Error('Not authenticated');
|
|
}
|
|
|
|
const fullUrl = this.buildRequestUrl(path, false);
|
|
const url = new URL(fullUrl);
|
|
const host = url.host;
|
|
const service = 's3';
|
|
const amzDate = this.getAmzDate();
|
|
const dateStamp = this.getDateStamp();
|
|
|
|
// Add query parameters
|
|
Object.entries(queryParams).forEach(([key, value]) => {
|
|
if (value !== undefined && value !== null) {
|
|
url.searchParams.set(key, value);
|
|
}
|
|
});
|
|
|
|
// Sort query parameters
|
|
const sortedParams = [...url.searchParams.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
|
const canonicalQueryString = sortedParams.map(([k, v]) =>
|
|
`${encodeURIComponent(k)}=${encodeURIComponent(v)}`
|
|
).join('&');
|
|
|
|
// Hash the payload
|
|
const payloadHash = await this.sha256(body);
|
|
|
|
// Create canonical headers - include checksum and content-type (this is always POST with body)
|
|
const headers = {
|
|
'content-type': 'application/xml',
|
|
'host': host,
|
|
'x-amz-checksum-sha256': checksumBase64,
|
|
'x-amz-content-sha256': payloadHash,
|
|
'x-amz-date': amzDate,
|
|
};
|
|
|
|
const signedHeadersList = Object.keys(headers).sort();
|
|
const signedHeaders = signedHeadersList.join(';');
|
|
const canonicalHeaders = signedHeadersList.map(h => `${h}:${headers[h]}\n`).join('');
|
|
|
|
// Create canonical request
|
|
const canonicalRequest = [
|
|
method,
|
|
url.pathname,
|
|
canonicalQueryString,
|
|
canonicalHeaders,
|
|
signedHeaders,
|
|
payloadHash
|
|
].join('\n');
|
|
|
|
// Create string to sign
|
|
const algorithm = 'AWS4-HMAC-SHA256';
|
|
const credentialScope = `${dateStamp}/${this.region}/${service}/aws4_request`;
|
|
const canonicalRequestHash = await this.sha256(canonicalRequest);
|
|
const stringToSign = [
|
|
algorithm,
|
|
amzDate,
|
|
credentialScope,
|
|
canonicalRequestHash
|
|
].join('\n');
|
|
|
|
// Calculate signature
|
|
const signingKey = await this.getSigningKey(this.credentials.secretKey, dateStamp, this.region, service);
|
|
const signatureBuffer = await this.hmacSha256(signingKey, stringToSign);
|
|
const signature = this.bufferToHex(signatureBuffer);
|
|
|
|
// Create authorization header
|
|
const authorization = `${algorithm} Credential=${this.credentials.accessKey}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
|
|
|
|
return {
|
|
url: url.toString(),
|
|
headers: {
|
|
'Authorization': authorization,
|
|
'X-Amz-Date': amzDate,
|
|
'X-Amz-Content-Sha256': payloadHash,
|
|
'X-Amz-Checksum-Sha256': checksumBase64,
|
|
'Content-Type': 'application/xml',
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Make a signed API request
|
|
* @param {string} method - HTTP method
|
|
* @param {string} path - Request path
|
|
* @param {Object} queryParams - Query parameters
|
|
* @param {string} body - Request body
|
|
* @param {boolean} useAdminEndpoint - Use admin endpoint instead of S3
|
|
* @param {string} contentType - Content type for the request
|
|
* @param {Object} additionalHeaders - Additional headers to include
|
|
*/
|
|
async request(method, path, queryParams = {}, body = '', useAdminEndpoint = false, contentType = 'application/xml', additionalHeaders = {}) {
|
|
if (!this.credentials) {
|
|
throw new Error('Not authenticated');
|
|
}
|
|
|
|
// Always sign and send directly to the configured endpoint.
|
|
// CORS must be configured on the S3 endpoint.
|
|
const signed = await this.signRequest(method, path, queryParams, body, useAdminEndpoint, contentType);
|
|
const fetchUrl = signed.url;
|
|
const headers = { ...signed.headers, ...additionalHeaders };
|
|
|
|
let response;
|
|
try {
|
|
response = await fetch(fetchUrl, {
|
|
method,
|
|
headers,
|
|
body: body || undefined,
|
|
});
|
|
} catch (e) {
|
|
// Browsers surface CORS blocks as a generic TypeError.
|
|
if (e instanceof TypeError) {
|
|
throw new Error(`CORS blocked by gateway. Allow origin ${window.location.origin} and headers Authorization, X-Amz-Date, X-Amz-Content-Sha256, Content-Type.`);
|
|
}
|
|
throw e;
|
|
}
|
|
|
|
const responseText = await response.text();
|
|
|
|
if (!response.ok) {
|
|
// Try to parse error from XML
|
|
let errorMessage = `HTTP ${response.status}: ${response.statusText}`;
|
|
try {
|
|
const parser = new DOMParser();
|
|
const xmlDoc = parser.parseFromString(responseText, 'text/xml');
|
|
const code = xmlDoc.querySelector('Code')?.textContent;
|
|
const message = xmlDoc.querySelector('Message')?.textContent;
|
|
if (code) errorMessage = `${code}: ${message || 'Unknown error'}`;
|
|
} catch (e) {
|
|
// Ignore parsing errors
|
|
}
|
|
throw new Error(errorMessage);
|
|
}
|
|
|
|
return responseText;
|
|
}
|
|
|
|
/**
|
|
* Build fetch parameters for a request
|
|
* When proxy is available: uses server-side signing (credentials sent to vgwmgr, not S3 gateway)
|
|
* When no proxy: uses browser-side signing
|
|
* @param {string} method - HTTP method
|
|
* @param {string} path - Request path
|
|
* @param {Object} queryParams - Query parameters
|
|
* @param {string} body - Request body
|
|
* @param {boolean} useAdminEndpoint - Use admin endpoint instead of S3
|
|
* @param {string} contentType - Optional content type override
|
|
* @returns {Object} - { url, headers } for fetch
|
|
*/
|
|
async buildFetchParams(method, path, queryParams = {}, body = '', useAdminEndpoint = false, contentType = 'application/xml') {
|
|
if (!this.credentials) {
|
|
throw new Error('Not authenticated');
|
|
}
|
|
|
|
// Build URL with query params
|
|
const endpoint = useAdminEndpoint ? this.adminEndpoint : this.s3Endpoint;
|
|
const url = new URL(endpoint + path);
|
|
Object.entries(queryParams).forEach(([key, value]) => {
|
|
if (value !== undefined && value !== null) {
|
|
url.searchParams.set(key, value);
|
|
}
|
|
});
|
|
|
|
const signed = await this.signRequest(method, path, queryParams, body, useAdminEndpoint, contentType);
|
|
return {
|
|
url: signed.url,
|
|
headers: signed.headers,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Detect user role by trying admin API first, then S3 API
|
|
* Returns: 'admin' | 's3' | 'none'
|
|
*/
|
|
async detectRole() {
|
|
// Validate S3 credentials first.
|
|
// This avoids blocking non-admin users on an expected admin-API failure.
|
|
try {
|
|
await this.listBucketsS3();
|
|
this.setAdminRole(false);
|
|
} catch (s3Error) {
|
|
// If the gateway is reachable but the browser blocks the response due to CORS,
|
|
// surface that as an error so the UI can show a useful message.
|
|
if (s3Error && typeof s3Error.message === 'string' && s3Error.message.includes('CORS blocked')) {
|
|
throw s3Error;
|
|
}
|
|
return 'none';
|
|
}
|
|
|
|
// S3 works, now test admin API access.
|
|
try {
|
|
const users = await this.listUsers();
|
|
this.setAdminRole(true);
|
|
return 'admin';
|
|
} catch (adminError) {
|
|
return 's3';
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// Admin API Methods
|
|
// ============================================
|
|
|
|
/**
|
|
* Parse XML list response to array
|
|
*/
|
|
parseXmlList(xmlString, itemTag) {
|
|
if (!xmlString.trim()) return [];
|
|
|
|
const parser = new DOMParser();
|
|
const xmlDoc = parser.parseFromString(xmlString, 'text/xml');
|
|
|
|
// Check for parsing errors
|
|
const parseError = xmlDoc.querySelector('parsererror');
|
|
if (parseError) {
|
|
console.error('XML Parse Error:', parseError.textContent);
|
|
return [];
|
|
}
|
|
|
|
const items = xmlDoc.querySelectorAll(itemTag);
|
|
return Array.from(items).map(item => {
|
|
const obj = {};
|
|
Array.from(item.children).forEach(child => {
|
|
obj[child.tagName.toLowerCase()] = child.textContent;
|
|
});
|
|
return obj;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* List all users (Admin API)
|
|
*/
|
|
async listUsers() {
|
|
const response = await this.request('PATCH', '/list-users', {}, '', true);
|
|
// VersityGW returns XML with <Accounts> tags
|
|
return this.parseXmlList(response, 'Accounts');
|
|
}
|
|
|
|
/**
|
|
* Create a new user (Admin API)
|
|
*/
|
|
async createUser(access, secret, role, userID = 0, groupID = 0, projectID = 0) {
|
|
const body = `<?xml version="1.0" encoding="UTF-8"?>
|
|
<Account>
|
|
<Access>${this.escapeXml(access)}</Access>
|
|
<Secret>${this.escapeXml(secret)}</Secret>
|
|
<Role>${this.escapeXml(role)}</Role>
|
|
<UserID>${userID}</UserID>
|
|
<GroupID>${groupID}</GroupID>
|
|
<ProjectID>${projectID}</ProjectID>
|
|
</Account>`;
|
|
|
|
await this.request('PATCH', '/create-user', {}, body, true);
|
|
}
|
|
|
|
/**
|
|
* Update an existing user (Admin API)
|
|
*/
|
|
async updateUser(access, updates) {
|
|
let body = '<?xml version="1.0" encoding="UTF-8"?>\n<MutableProps>';
|
|
|
|
if (updates.secret !== undefined) {
|
|
body += `\n <Secret>${this.escapeXml(updates.secret)}</Secret>`;
|
|
}
|
|
if (updates.role !== undefined) {
|
|
body += `\n <Role>${this.escapeXml(updates.role)}</Role>`;
|
|
}
|
|
if (updates.userID !== undefined) {
|
|
body += `\n <UserID>${updates.userID}</UserID>`;
|
|
}
|
|
if (updates.groupID !== undefined) {
|
|
body += `\n <GroupID>${updates.groupID}</GroupID>`;
|
|
}
|
|
if (updates.projectID !== undefined) {
|
|
body += `\n <ProjectID>${updates.projectID}</ProjectID>`;
|
|
}
|
|
|
|
body += '\n</MutableProps>';
|
|
|
|
await this.request('PATCH', '/update-user', { access }, body, true);
|
|
}
|
|
|
|
/**
|
|
* Delete a user (Admin API)
|
|
*/
|
|
async deleteUser(access) {
|
|
await this.request('PATCH', '/delete-user', { access }, '', true);
|
|
}
|
|
|
|
/**
|
|
* List all buckets (Admin API - returns all buckets with owner info)
|
|
*/
|
|
async listBuckets() {
|
|
const response = await this.request('PATCH', '/list-buckets', {}, '', true);
|
|
// VersityGW returns XML - check for Buckets or Bucket tags
|
|
let buckets = this.parseXmlList(response, 'Buckets');
|
|
if (buckets.length === 0) {
|
|
buckets = this.parseXmlList(response, 'Bucket');
|
|
}
|
|
return buckets;
|
|
}
|
|
|
|
/**
|
|
* Change bucket owner (Admin API)
|
|
*/
|
|
async changeBucketOwner(bucket, owner) {
|
|
await this.request('PATCH', '/change-bucket-owner', { bucket, owner }, '', true);
|
|
}
|
|
|
|
// ============================================
|
|
// S3 Standard API Methods
|
|
// ============================================
|
|
|
|
/**
|
|
* List buckets via standard S3 API (for non-admin users)
|
|
* Returns buckets the user has access to
|
|
*/
|
|
async listBucketsS3() {
|
|
// Use presigned URL to avoid triggering a browser preflight for GET /.
|
|
const presignedUrl = await this.presignUrl('GET', '/', {}, 60, false);
|
|
let httpResponse;
|
|
try {
|
|
httpResponse = await fetch(presignedUrl, { method: 'GET' });
|
|
} catch (e) {
|
|
if (e instanceof TypeError) {
|
|
throw new Error(`CORS blocked by gateway. Allow origin ${window.location.origin} for S3 responses (GET / and bucket/object operations).`);
|
|
}
|
|
throw e;
|
|
}
|
|
const response = await httpResponse.text();
|
|
|
|
if (!httpResponse.ok) {
|
|
// Try to parse error from XML
|
|
let errorMessage = `HTTP ${httpResponse.status}: ${httpResponse.statusText}`;
|
|
try {
|
|
const parser = new DOMParser();
|
|
const xmlDoc = parser.parseFromString(response, 'text/xml');
|
|
const code = xmlDoc.querySelector('Code')?.textContent;
|
|
const message = xmlDoc.querySelector('Message')?.textContent;
|
|
if (code) errorMessage = `${code}: ${message || 'Unknown error'}`;
|
|
} catch (e) {
|
|
// Ignore parsing errors
|
|
}
|
|
throw new Error(errorMessage);
|
|
}
|
|
|
|
const parser = new DOMParser();
|
|
const doc = parser.parseFromString(response, 'text/xml');
|
|
|
|
const buckets = [];
|
|
doc.querySelectorAll('Bucket').forEach(bucket => {
|
|
buckets.push({
|
|
name: bucket.querySelector('Name')?.textContent || '',
|
|
creationdate: bucket.querySelector('CreationDate')?.textContent || ''
|
|
});
|
|
});
|
|
|
|
return buckets;
|
|
}
|
|
|
|
/**
|
|
* Create a new bucket with owner and settings (Admin API)
|
|
* @param {string} bucketName - The name of the bucket to create
|
|
* @param {string} owner - The access key ID of the bucket owner
|
|
* @param {boolean} enableVersioning - Whether to enable versioning
|
|
* @param {boolean} enableObjectLock - Whether to enable object lock
|
|
*/
|
|
async createBucketWithOwner(bucketName, owner, enableVersioning = false, enableObjectLock = false) {
|
|
if (!owner) {
|
|
throw new Error('Owner access key ID is required');
|
|
}
|
|
|
|
// Build the request with custom headers for the admin API
|
|
const headers = {
|
|
'x-vgw-owner': owner,
|
|
};
|
|
|
|
// Add object lock header if enabled
|
|
if (enableObjectLock) {
|
|
headers['x-amz-bucket-object-lock-enabled'] = 'true';
|
|
}
|
|
|
|
// Create the bucket using the admin API endpoint
|
|
const response = await this.request(
|
|
'PATCH',
|
|
`/${bucketName}/create`,
|
|
{},
|
|
'',
|
|
true, // useAdminEndpoint
|
|
'application/xml',
|
|
headers
|
|
);
|
|
|
|
// If versioning is enabled (but not object lock, as object lock enables versioning automatically)
|
|
// we need to call PutBucketVersioning after bucket creation
|
|
if (enableVersioning && !enableObjectLock) {
|
|
try {
|
|
await this.putBucketVersioning(bucketName, 'Enabled');
|
|
} catch (error) {
|
|
console.warn('Failed to enable versioning after bucket creation:', error);
|
|
// Don't throw - bucket was created successfully
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Create a new bucket with bucket name(s3api)
|
|
* @param {string} bucketName - The name of the bucket to create
|
|
*/
|
|
async createBucket(bucketName) {
|
|
if (!bucketName) {
|
|
throw new Error('Bucket name is required');
|
|
}
|
|
|
|
await this.request(
|
|
'PUT',
|
|
`/${bucketName}`,
|
|
{},
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Delete a bucket (S3 DeleteBucket)
|
|
*/
|
|
async deleteBucket(bucketName) {
|
|
const fetchParams = await this.buildFetchParams('DELETE', `/${bucketName}`, {}, '');
|
|
const response = await fetch(fetchParams.url, {
|
|
method: 'DELETE',
|
|
headers: fetchParams.headers,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const text = await response.text();
|
|
const messageMatch = text.match(/<Message>([^<]+)<\/Message>/);
|
|
throw new Error(messageMatch ? messageMatch[1] : `Failed to delete bucket (${response.status})`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* List objects in a bucket (S3 ListObjectsV2)
|
|
*/
|
|
async listObjectsV2(bucket, prefix = '', delimiter = '/', maxKeys = 1000, continuationToken = null) {
|
|
const params = {
|
|
'list-type': '2',
|
|
'prefix': prefix,
|
|
'delimiter': delimiter,
|
|
'max-keys': maxKeys.toString()
|
|
};
|
|
|
|
if (continuationToken) {
|
|
params['continuation-token'] = continuationToken;
|
|
}
|
|
|
|
const response = await this.request('GET', `/${bucket}`, params);
|
|
return this.parseListObjectsV2Response(response);
|
|
}
|
|
|
|
/**
|
|
* Parse ListObjectsV2 XML response
|
|
*/
|
|
parseListObjectsV2Response(xmlString) {
|
|
const parser = new DOMParser();
|
|
const doc = parser.parseFromString(xmlString, 'text/xml');
|
|
|
|
const result = {
|
|
name: doc.querySelector('Name')?.textContent || '',
|
|
prefix: doc.querySelector('Prefix')?.textContent || '',
|
|
delimiter: doc.querySelector('Delimiter')?.textContent || '',
|
|
isTruncated: doc.querySelector('IsTruncated')?.textContent === 'true',
|
|
continuationToken: doc.querySelector('NextContinuationToken')?.textContent || null,
|
|
contents: [],
|
|
commonPrefixes: []
|
|
};
|
|
|
|
// Parse Contents (files)
|
|
doc.querySelectorAll('Contents').forEach(item => {
|
|
result.contents.push({
|
|
key: item.querySelector('Key')?.textContent || '',
|
|
lastModified: item.querySelector('LastModified')?.textContent || '',
|
|
size: parseInt(item.querySelector('Size')?.textContent || '0', 10),
|
|
storageClass: item.querySelector('StorageClass')?.textContent || 'STANDARD',
|
|
etag: item.querySelector('ETag')?.textContent || ''
|
|
});
|
|
});
|
|
|
|
// Parse CommonPrefixes (folders)
|
|
doc.querySelectorAll('CommonPrefixes').forEach(item => {
|
|
result.commonPrefixes.push({
|
|
prefix: item.querySelector('Prefix')?.textContent || ''
|
|
});
|
|
});
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Get object metadata (HeadObject)
|
|
*/
|
|
async headObject(bucket, key) {
|
|
const fetchParams = await this.buildFetchParams('HEAD', `/${bucket}/${encodeS3Key(key)}`);
|
|
|
|
const response = await fetch(fetchParams.url, {
|
|
method: 'HEAD',
|
|
headers: fetchParams.headers,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
}
|
|
|
|
return {
|
|
contentLength: parseInt(response.headers.get('Content-Length') || '0', 10),
|
|
contentType: response.headers.get('Content-Type') || 'application/octet-stream',
|
|
lastModified: response.headers.get('Last-Modified') || '',
|
|
etag: response.headers.get('ETag') || '',
|
|
storageClass: response.headers.get('x-amz-storage-class') || 'STANDARD',
|
|
metadata: this.extractMetadataHeaders(response.headers)
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Extract x-amz-meta-* headers as metadata object
|
|
*/
|
|
extractMetadataHeaders(headers) {
|
|
const metadata = {};
|
|
headers.forEach((value, key) => {
|
|
if (key.toLowerCase().startsWith('x-amz-meta-')) {
|
|
const metaKey = key.substring(11); // Remove 'x-amz-meta-' prefix
|
|
metadata[metaKey] = value;
|
|
}
|
|
});
|
|
return metadata;
|
|
}
|
|
|
|
/**
|
|
* Download an object (GetObject)
|
|
* Returns a Blob for browser download
|
|
*/
|
|
async getObject(bucket, key) {
|
|
const fetchParams = await this.buildFetchParams('GET', `/${bucket}/${encodeS3Key(key)}`);
|
|
|
|
const response = await fetch(fetchParams.url, {
|
|
method: 'GET',
|
|
headers: fetchParams.headers,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
}
|
|
|
|
return {
|
|
blob: await response.blob(),
|
|
contentType: response.headers.get('Content-Type') || 'application/octet-stream',
|
|
contentLength: parseInt(response.headers.get('Content-Length') || '0', 10)
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Upload an object (PutObject) - for small files < 5MB
|
|
*/
|
|
async putObject(bucket, key, file, contentType = null) {
|
|
const finalContentType = contentType || file.type || 'application/octet-stream';
|
|
const path = `/${bucket}/${encodeS3Key(key)}`;
|
|
|
|
// Browser-side signing with payload hash
|
|
const arrayBuffer = await file.arrayBuffer();
|
|
const payloadHash = await this.sha256ArrayBuffer(arrayBuffer);
|
|
const signed = await this.signRequestWithPayloadHash('PUT', path, {}, payloadHash, finalContentType);
|
|
|
|
const response = await fetch(signed.url, {
|
|
method: 'PUT',
|
|
headers: signed.headers,
|
|
body: file,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
throw new Error(`Upload failed: HTTP ${response.status} - ${errorText}`);
|
|
}
|
|
|
|
return { etag: response.headers.get('ETag') || '' };
|
|
}
|
|
|
|
/**
|
|
* SHA-256 hash of ArrayBuffer
|
|
*/
|
|
async sha256ArrayBuffer(buffer) {
|
|
if (hasSubtleCrypto) {
|
|
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer);
|
|
return this.bufferToHex(hashBuffer);
|
|
} else {
|
|
// Convert ArrayBuffer to CryptoJS WordArray for proper binary hashing
|
|
const bytes = new Uint8Array(buffer);
|
|
const words = [];
|
|
for (let i = 0; i < bytes.length; i += 4) {
|
|
const word = (bytes[i] << 24) | ((bytes[i + 1] || 0) << 16) | ((bytes[i + 2] || 0) << 8) | (bytes[i + 3] || 0);
|
|
words.push(word);
|
|
}
|
|
const wordArray = CryptoJS.lib.WordArray.create(words, bytes.length);
|
|
return CryptoJS.SHA256(wordArray).toString(CryptoJS.enc.Hex);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sign request with pre-computed payload hash (for binary uploads)
|
|
*/
|
|
async signRequestWithPayloadHash(method, path, queryParams = {}, payloadHash, contentType) {
|
|
if (!this.credentials) {
|
|
throw new Error('Not authenticated');
|
|
}
|
|
|
|
const fullUrl = this.buildRequestUrl(path, false);
|
|
const url = new URL(fullUrl);
|
|
const host = url.host;
|
|
const service = 's3';
|
|
const amzDate = this.getAmzDate();
|
|
const dateStamp = this.getDateStamp();
|
|
|
|
// Add query parameters
|
|
Object.entries(queryParams).forEach(([key, value]) => {
|
|
if (value !== undefined && value !== null) {
|
|
url.searchParams.set(key, value);
|
|
}
|
|
});
|
|
|
|
// Sort query parameters
|
|
const sortedParams = [...url.searchParams.entries()].sort((a, b) => a[0].localeCompare(b[0]));
|
|
const canonicalQueryString = sortedParams.map(([k, v]) =>
|
|
`${encodeURIComponent(k)}=${encodeURIComponent(v)}`
|
|
).join('&');
|
|
|
|
// Create canonical headers
|
|
const headers = {
|
|
'content-type': contentType,
|
|
'host': host,
|
|
'x-amz-content-sha256': payloadHash,
|
|
'x-amz-date': amzDate,
|
|
};
|
|
|
|
const signedHeadersList = Object.keys(headers).sort();
|
|
const signedHeaders = signedHeadersList.join(';');
|
|
const canonicalHeaders = signedHeadersList.map(h => `${h}:${headers[h]}\n`).join('');
|
|
|
|
// Create canonical request
|
|
const canonicalRequest = [
|
|
method,
|
|
url.pathname,
|
|
canonicalQueryString,
|
|
canonicalHeaders,
|
|
signedHeaders,
|
|
payloadHash
|
|
].join('\n');
|
|
|
|
// Create string to sign
|
|
const algorithm = 'AWS4-HMAC-SHA256';
|
|
const credentialScope = `${dateStamp}/${this.region}/${service}/aws4_request`;
|
|
const canonicalRequestHash = await this.sha256(canonicalRequest);
|
|
const stringToSign = [
|
|
algorithm,
|
|
amzDate,
|
|
credentialScope,
|
|
canonicalRequestHash
|
|
].join('\n');
|
|
|
|
// Calculate signature
|
|
const signingKey = await this.getSigningKey(this.credentials.secretKey, dateStamp, this.region, service);
|
|
const signatureBuffer = await this.hmacSha256(signingKey, stringToSign);
|
|
const signature = this.bufferToHex(signatureBuffer);
|
|
|
|
// Create authorization header
|
|
const authorization = `${algorithm} Credential=${this.credentials.accessKey}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
|
|
|
|
return {
|
|
url: url.toString(),
|
|
headers: {
|
|
'Authorization': authorization,
|
|
'X-Amz-Date': amzDate,
|
|
'X-Amz-Content-Sha256': payloadHash,
|
|
'Content-Type': contentType,
|
|
}
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Delete an object
|
|
*/
|
|
async deleteObject(bucket, key) {
|
|
await this.request('DELETE', `/${bucket}/${encodeS3Key(key)}`);
|
|
}
|
|
|
|
/**
|
|
* Delete multiple objects (batch delete)
|
|
* S3 Multi-Object Delete requires x-amz-checksum-* or Content-MD5 header
|
|
*/
|
|
async deleteObjects(bucket, keys) {
|
|
let body = '<?xml version="1.0" encoding="UTF-8"?>\n<Delete>';
|
|
body += '\n <Quiet>true</Quiet>';
|
|
|
|
keys.forEach(key => {
|
|
body += `\n <Object>\n <Key>${this.escapeXml(key)}</Key>\n </Object>`;
|
|
});
|
|
|
|
body += '\n</Delete>';
|
|
|
|
const checksum = await this.sha256Base64(body);
|
|
const signed = await this.signRequestWithChecksum('POST', `/${bucket}`, { delete: '' }, body, checksum);
|
|
|
|
const response = await fetch(signed.url, {
|
|
method: 'POST',
|
|
headers: signed.headers,
|
|
body: body,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
throw new Error(`Delete failed: ${errorText}`);
|
|
}
|
|
|
|
return await response.text();
|
|
}
|
|
|
|
/**
|
|
* Create a folder (empty object with trailing slash)
|
|
*/
|
|
async createFolder(bucket, prefix) {
|
|
// Ensure prefix ends with /
|
|
const folderKey = prefix.endsWith('/') ? prefix : prefix + '/';
|
|
|
|
const fetchParams = await this.buildFetchParams('PUT', `/${bucket}/${encodeURIComponent(folderKey)}`, {}, '');
|
|
|
|
const response = await fetch(fetchParams.url, {
|
|
method: 'PUT',
|
|
headers: fetchParams.headers,
|
|
body: '',
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
throw new Error(`Create folder failed: ${errorText}`);
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// Multipart Upload Methods
|
|
// ============================================
|
|
|
|
/**
|
|
* Initiate a multipart upload
|
|
* Returns uploadId needed for subsequent parts
|
|
*/
|
|
async createMultipartUpload(bucket, key, contentType = 'application/octet-stream') {
|
|
const fetchParams = await this.buildFetchParams('POST', `/${bucket}/${encodeS3Key(key)}`, { uploads: '' }, '', false, contentType);
|
|
|
|
const response = await fetch(fetchParams.url, {
|
|
method: 'POST',
|
|
headers: fetchParams.headers,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
throw new Error(`Failed to initiate multipart upload: ${errorText}`);
|
|
}
|
|
|
|
const xmlText = await response.text();
|
|
const parser = new DOMParser();
|
|
const doc = parser.parseFromString(xmlText, 'text/xml');
|
|
const uploadId = doc.querySelector('UploadId')?.textContent;
|
|
|
|
if (!uploadId) {
|
|
throw new Error('No UploadId returned from server');
|
|
}
|
|
|
|
return uploadId;
|
|
}
|
|
|
|
/**
|
|
* Upload a single part of a multipart upload
|
|
* Returns ETag needed for CompleteMultipartUpload
|
|
*/
|
|
async uploadPart(bucket, key, uploadId, partNumber, data) {
|
|
const arrayBuffer = data instanceof ArrayBuffer ? data : await data.arrayBuffer();
|
|
const path = `/${bucket}/${encodeS3Key(key)}`;
|
|
const queryParams = {
|
|
uploadId: uploadId,
|
|
partNumber: partNumber.toString()
|
|
};
|
|
|
|
const payloadHash = await this.sha256ArrayBuffer(arrayBuffer);
|
|
const signed = await this.signRequestWithPayloadHash(
|
|
'PUT',
|
|
path,
|
|
queryParams,
|
|
payloadHash,
|
|
'application/octet-stream'
|
|
);
|
|
|
|
const response = await fetch(signed.url, {
|
|
method: 'PUT',
|
|
headers: signed.headers,
|
|
body: arrayBuffer,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
throw new Error(`Failed to upload part ${partNumber}: ${errorText}`);
|
|
}
|
|
|
|
// Try multiple ways to get ETag (CORS may not expose all headers)
|
|
let etag = response.headers.get('ETag')
|
|
|| response.headers.get('etag')
|
|
|| response.headers.get('x-amz-etag');
|
|
|
|
// If still no ETag, try to read from response body (some proxies put it there)
|
|
if (!etag) {
|
|
const text = await response.clone().text();
|
|
const match = text.match(/<ETag>([^<]+)<\/ETag>/i);
|
|
if (match) {
|
|
etag = match[1];
|
|
}
|
|
}
|
|
|
|
if (!etag) {
|
|
// Log all available headers for debugging
|
|
console.warn('Available headers:', [...response.headers.entries()]);
|
|
throw new Error(`No ETag returned for part ${partNumber}. The server may need to expose ETag in CORS headers (Access-Control-Expose-Headers: ETag).`);
|
|
}
|
|
|
|
return etag;
|
|
}
|
|
|
|
/**
|
|
* Complete a multipart upload
|
|
* parts should be an array of { partNumber, etag }
|
|
*/
|
|
async completeMultipartUpload(bucket, key, uploadId, parts) {
|
|
let body = '<?xml version="1.0" encoding="UTF-8"?>\n<CompleteMultipartUpload>';
|
|
|
|
// Sort parts by partNumber
|
|
parts.sort((a, b) => a.partNumber - b.partNumber);
|
|
|
|
parts.forEach(part => {
|
|
body += `\n <Part>`;
|
|
body += `\n <PartNumber>${part.partNumber}</PartNumber>`;
|
|
body += `\n <ETag>${this.escapeXml(part.etag)}</ETag>`;
|
|
body += `\n </Part>`;
|
|
});
|
|
|
|
body += '\n</CompleteMultipartUpload>';
|
|
|
|
const fetchParams = await this.buildFetchParams('POST', `/${bucket}/${encodeS3Key(key)}`, { uploadId }, body);
|
|
|
|
const response = await fetch(fetchParams.url, {
|
|
method: 'POST',
|
|
headers: fetchParams.headers,
|
|
body: body,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
throw new Error(`Failed to complete multipart upload: ${errorText}`);
|
|
}
|
|
|
|
return await response.text();
|
|
}
|
|
|
|
/**
|
|
* Abort a multipart upload
|
|
*/
|
|
async abortMultipartUpload(bucket, key, uploadId) {
|
|
const fetchParams = await this.buildFetchParams('DELETE', `/${bucket}/${encodeS3Key(key)}`, { uploadId }, '');
|
|
|
|
const response = await fetch(fetchParams.url, {
|
|
method: 'DELETE',
|
|
headers: fetchParams.headers,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
throw new Error(`Failed to abort multipart upload: ${errorText}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* List multipart uploads in a bucket
|
|
* @param {string} bucket - Bucket name
|
|
* @returns {Promise<Array>} - Array of multipart upload objects
|
|
*/
|
|
async listMultipartUploads(bucket) {
|
|
const response = await this.request('GET', `/${bucket}`, { uploads: '' });
|
|
const parser = new DOMParser();
|
|
const doc = parser.parseFromString(response, 'text/xml');
|
|
|
|
const uploads = [];
|
|
doc.querySelectorAll('Upload').forEach(upload => {
|
|
uploads.push({
|
|
key: upload.querySelector('Key')?.textContent || '',
|
|
uploadId: upload.querySelector('UploadId')?.textContent || '',
|
|
initiated: upload.querySelector('Initiated')?.textContent || '',
|
|
initiator: upload.querySelector('Initiator DisplayName')?.textContent || '',
|
|
owner: upload.querySelector('Owner DisplayName')?.textContent || ''
|
|
});
|
|
});
|
|
|
|
return uploads;
|
|
}
|
|
|
|
// ============================================
|
|
// Bucket Versioning Methods
|
|
// ============================================
|
|
|
|
/**
|
|
* Get versioning status for a bucket
|
|
* Returns: { status: 'Enabled' | 'Suspended' | '' }
|
|
*/
|
|
async getBucketVersioning(bucket) {
|
|
const response = await this.request('GET', `/${bucket}`, { versioning: '' });
|
|
const parser = new DOMParser();
|
|
const doc = parser.parseFromString(response, 'text/xml');
|
|
return {
|
|
status: doc.querySelector('Status')?.textContent || ''
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Set versioning status for a bucket
|
|
* @param {string} bucket - Bucket name
|
|
* @param {string} status - 'Enabled' or 'Suspended'
|
|
*/
|
|
async putBucketVersioning(bucket, status) {
|
|
const body = `<?xml version="1.0" encoding="UTF-8"?>
|
|
<VersioningConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
|
<Status>${this.escapeXml(status)}</Status>
|
|
</VersioningConfiguration>`;
|
|
await this.request('PUT', `/${bucket}`, { versioning: '' }, body);
|
|
}
|
|
|
|
/**
|
|
* Get object lock configuration for a bucket
|
|
* Returns: { enabled: boolean, mode: string, days: number, years: number }
|
|
*/
|
|
async getBucketObjectLockConfiguration(bucket) {
|
|
try {
|
|
const response = await this.request('GET', `/${bucket}`, { 'object-lock': '' });
|
|
const parser = new DOMParser();
|
|
const doc = parser.parseFromString(response, 'text/xml');
|
|
|
|
const enabled = doc.querySelector('ObjectLockEnabled')?.textContent === 'Enabled';
|
|
const rule = doc.querySelector('Rule');
|
|
let mode = '';
|
|
let days = null;
|
|
let years = null;
|
|
|
|
if (rule) {
|
|
mode = rule.querySelector('Mode')?.textContent || '';
|
|
days = rule.querySelector('Days')?.textContent;
|
|
years = rule.querySelector('Years')?.textContent;
|
|
}
|
|
|
|
return {
|
|
enabled,
|
|
mode,
|
|
days: days ? parseInt(days) : null,
|
|
years: years ? parseInt(years) : null
|
|
};
|
|
} catch (error) {
|
|
// If object lock is not configured or not supported, return disabled status
|
|
if (error.message.includes('ObjectLockConfigurationNotFoundError') ||
|
|
error.message.includes('405') ||
|
|
error.message.includes('501')) {
|
|
return { enabled: false, mode: '', days: null, years: null };
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Enable or update object lock configuration for a bucket
|
|
* @param {string} bucket - Bucket name
|
|
* @param {Object} config - { mode: 'GOVERNANCE'|'COMPLIANCE', days: number, years: number }
|
|
*/
|
|
async putBucketObjectLockConfiguration(bucket, config) {
|
|
let ruleXml = '';
|
|
if (config.mode && (config.days || config.years)) {
|
|
const retention = config.days ? `<Days>${config.days}</Days>` : `<Years>${config.years}</Years>`;
|
|
ruleXml = `
|
|
<Rule>
|
|
<DefaultRetention>
|
|
<Mode>${this.escapeXml(config.mode)}</Mode>
|
|
${retention}
|
|
</DefaultRetention>
|
|
</Rule>`;
|
|
}
|
|
|
|
const body = `<?xml version="1.0" encoding="UTF-8"?>
|
|
<ObjectLockConfiguration xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
|
<ObjectLockEnabled>Enabled</ObjectLockEnabled>${ruleXml}
|
|
</ObjectLockConfiguration>`;
|
|
|
|
// Calculate Content-MD5 header
|
|
const contentMd5 = this.md5Base64(body);
|
|
|
|
await this.request('PUT', `/${bucket}`, { 'object-lock': '' }, body, false, 'application/xml', {
|
|
'Content-MD5': contentMd5
|
|
});
|
|
}
|
|
|
|
/**
|
|
* List all versions of objects in a bucket
|
|
*/
|
|
async listObjectVersions(bucket, prefix = '', delimiter = '/', maxKeys = 1000, keyMarker = null, versionIdMarker = null) {
|
|
const params = {
|
|
versions: '',
|
|
prefix: prefix,
|
|
delimiter: delimiter,
|
|
'max-keys': maxKeys.toString()
|
|
};
|
|
|
|
if (keyMarker) params['key-marker'] = keyMarker;
|
|
if (versionIdMarker) params['version-id-marker'] = versionIdMarker;
|
|
|
|
const response = await this.request('GET', `/${bucket}`, params);
|
|
return this.parseListObjectVersionsResponse(response);
|
|
}
|
|
|
|
/**
|
|
* Parse ListObjectVersions XML response
|
|
*/
|
|
parseListObjectVersionsResponse(xmlString) {
|
|
const parser = new DOMParser();
|
|
const doc = parser.parseFromString(xmlString, 'text/xml');
|
|
|
|
const result = {
|
|
name: doc.querySelector('Name')?.textContent || '',
|
|
prefix: doc.querySelector('Prefix')?.textContent || '',
|
|
isTruncated: doc.querySelector('IsTruncated')?.textContent === 'true',
|
|
nextKeyMarker: doc.querySelector('NextKeyMarker')?.textContent || null,
|
|
nextVersionIdMarker: doc.querySelector('NextVersionIdMarker')?.textContent || null,
|
|
versions: [],
|
|
deleteMarkers: [],
|
|
commonPrefixes: []
|
|
};
|
|
|
|
// Parse object versions
|
|
doc.querySelectorAll('Version').forEach(v => {
|
|
result.versions.push({
|
|
key: v.querySelector('Key')?.textContent || '',
|
|
versionId: v.querySelector('VersionId')?.textContent || '',
|
|
isLatest: v.querySelector('IsLatest')?.textContent === 'true',
|
|
lastModified: v.querySelector('LastModified')?.textContent || '',
|
|
size: parseInt(v.querySelector('Size')?.textContent || '0', 10),
|
|
storageClass: v.querySelector('StorageClass')?.textContent || 'STANDARD',
|
|
etag: v.querySelector('ETag')?.textContent || ''
|
|
});
|
|
});
|
|
|
|
// Parse delete markers
|
|
doc.querySelectorAll('DeleteMarker').forEach(dm => {
|
|
result.deleteMarkers.push({
|
|
key: dm.querySelector('Key')?.textContent || '',
|
|
versionId: dm.querySelector('VersionId')?.textContent || '',
|
|
isLatest: dm.querySelector('IsLatest')?.textContent === 'true',
|
|
lastModified: dm.querySelector('LastModified')?.textContent || '',
|
|
isDeleteMarker: true
|
|
});
|
|
});
|
|
|
|
// Parse common prefixes (folders)
|
|
doc.querySelectorAll('CommonPrefixes').forEach(cp => {
|
|
result.commonPrefixes.push({
|
|
prefix: cp.querySelector('Prefix')?.textContent || ''
|
|
});
|
|
});
|
|
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Delete a specific version of an object
|
|
*/
|
|
async deleteObjectVersion(bucket, key, versionId) {
|
|
await this.request('DELETE', `/${bucket}/${encodeS3Key(key)}`, { versionId });
|
|
}
|
|
|
|
/**
|
|
* Download a specific version of an object
|
|
*/
|
|
async getObjectVersion(bucket, key, versionId) {
|
|
const fetchParams = await this.buildFetchParams('GET', `/${bucket}/${encodeS3Key(key)}`, { versionId });
|
|
|
|
const response = await fetch(fetchParams.url, {
|
|
method: 'GET',
|
|
headers: fetchParams.headers,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
}
|
|
|
|
return {
|
|
blob: await response.blob(),
|
|
contentType: response.headers.get('Content-Type') || 'application/octet-stream'
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Restore an old version by copying it to create a new current version
|
|
*/
|
|
async restoreObjectVersion(bucket, key, versionId) {
|
|
const copySource = `/${bucket}/${encodeS3Key(key)}?versionId=${versionId}`;
|
|
|
|
const fetchParams = this.buildFetchParams('PUT', `/${bucket}/${encodeS3Key(key)}`, {}, '');
|
|
fetchParams.headers['x-amz-copy-source'] = copySource;
|
|
|
|
const response = await fetch(fetchParams.url, {
|
|
method: 'PUT',
|
|
headers: fetchParams.headers,
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
throw new Error(`Restore failed: ${errorText}`);
|
|
}
|
|
}
|
|
|
|
// ============================================
|
|
// Bucket Tagging Methods
|
|
// ============================================
|
|
|
|
/**
|
|
* Get bucket tags
|
|
* Returns: Array of { key, value } objects
|
|
*/
|
|
async getBucketTagging(bucket) {
|
|
try {
|
|
const response = await this.request('GET', `/${bucket}`, { tagging: '' });
|
|
const parser = new DOMParser();
|
|
const doc = parser.parseFromString(response, 'text/xml');
|
|
|
|
const tags = [];
|
|
doc.querySelectorAll('Tag').forEach(tagElement => {
|
|
const key = tagElement.querySelector('Key')?.textContent || '';
|
|
const value = tagElement.querySelector('Value')?.textContent || '';
|
|
if (key) {
|
|
tags.push({ key, value });
|
|
}
|
|
});
|
|
|
|
return tags;
|
|
} catch (error) {
|
|
// If tagging is not configured, return empty array
|
|
if (error.message.includes('404') || error.message.includes('NoSuchTagSet')) {
|
|
return [];
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set bucket tags
|
|
* @param {string} bucket - Bucket name
|
|
* @param {Array<{key: string, value: string}>} tags - Array of tag objects
|
|
*/
|
|
async putBucketTagging(bucket, tags) {
|
|
if (!tags || tags.length === 0) {
|
|
// Delete all tags if empty
|
|
return await this.deleteBucketTagging(bucket);
|
|
}
|
|
|
|
const tagsXml = tags.map(tag =>
|
|
` <Tag>\n <Key>${this.escapeXml(tag.key)}</Key>\n <Value>${this.escapeXml(tag.value)}</Value>\n </Tag>`
|
|
).join('\n');
|
|
|
|
const body = `<?xml version="1.0" encoding="UTF-8"?>
|
|
<Tagging xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
|
<TagSet>
|
|
${tagsXml}
|
|
</TagSet>
|
|
</Tagging>`;
|
|
|
|
await this.request('PUT', `/${bucket}`, { tagging: '' }, body);
|
|
}
|
|
|
|
/**
|
|
* Delete all bucket tags
|
|
* @param {string} bucket - Bucket name
|
|
*/
|
|
async deleteBucketTagging(bucket) {
|
|
await this.request('DELETE', `/${bucket}`, { tagging: '' });
|
|
}
|
|
|
|
/**
|
|
* Get bucket policy
|
|
* @param {string} bucket - Bucket name
|
|
* @returns {Promise<Object>} - Policy document as JSON object
|
|
*/
|
|
async getBucketPolicy(bucket) {
|
|
const response = await this.request('GET', `/${bucket}`, { policy: '' });
|
|
try {
|
|
return JSON.parse(response);
|
|
} catch (error) {
|
|
throw new Error('Failed to parse bucket policy: ' + error.message);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Put bucket policy
|
|
* @param {string} bucket - Bucket name
|
|
* @param {Object} policy - Policy document as JSON object
|
|
*/
|
|
async putBucketPolicy(bucket, policy) {
|
|
const policyJson = JSON.stringify(policy);
|
|
await this.request('PUT', `/${bucket}`, { policy: '' }, policyJson, {
|
|
'Content-Type': 'application/json'
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Delete bucket policy
|
|
* @param {string} bucket - Bucket name
|
|
*/
|
|
async deleteBucketPolicy(bucket) {
|
|
await this.request('DELETE', `/${bucket}`, { policy: '' });
|
|
}
|
|
|
|
/**
|
|
* Get object tags
|
|
* @param {string} bucket - Bucket name
|
|
* @param {string} key - Object key
|
|
* @returns {Promise<Array<{key: string, value: string}>>} - Array of tag objects
|
|
*/
|
|
async getObjectTagging(bucket, key) {
|
|
try {
|
|
const response = await this.request('GET', `/${bucket}/${encodeS3Key(key)}`, { tagging: '' });
|
|
const parser = new DOMParser();
|
|
const doc = parser.parseFromString(response, 'text/xml');
|
|
|
|
const tags = [];
|
|
doc.querySelectorAll('Tag').forEach(tagElement => {
|
|
const tagKey = tagElement.querySelector('Key')?.textContent || '';
|
|
const tagValue = tagElement.querySelector('Value')?.textContent || '';
|
|
if (tagKey) {
|
|
tags.push({ key: tagKey, value: tagValue });
|
|
}
|
|
});
|
|
|
|
return tags;
|
|
} catch (error) {
|
|
// If tagging is not configured, return empty array
|
|
if (error.message.includes('404') || error.message.includes('NoSuchTagSet')) {
|
|
return [];
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Set object tags
|
|
* @param {string} bucket - Bucket name
|
|
* @param {string} key - Object key
|
|
* @param {Array<{key: string, value: string}>} tags - Array of tag objects
|
|
*/
|
|
async putObjectTagging(bucket, key, tags) {
|
|
if (!tags || tags.length === 0) {
|
|
// Delete all tags if empty
|
|
return await this.deleteObjectTagging(bucket, key);
|
|
}
|
|
|
|
const tagsXml = tags.map(tag =>
|
|
` <Tag>\n <Key>${this.escapeXml(tag.key)}</Key>\n <Value>${this.escapeXml(tag.value)}</Value>\n </Tag>`
|
|
).join('\n');
|
|
|
|
const body = `<?xml version="1.0" encoding="UTF-8"?>
|
|
<Tagging xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
|
<TagSet>
|
|
${tagsXml}
|
|
</TagSet>
|
|
</Tagging>`;
|
|
|
|
await this.request('PUT', `/${bucket}/${encodeS3Key(key)}`, { tagging: '' }, body);
|
|
}
|
|
|
|
/**
|
|
* Delete all object tags
|
|
* @param {string} bucket - Bucket name
|
|
* @param {string} key - Object key
|
|
*/
|
|
async deleteObjectTagging(bucket, key) {
|
|
await this.request('DELETE', `/${bucket}/${encodeS3Key(key)}`, { tagging: '' });
|
|
}
|
|
|
|
/**
|
|
* Update object metadata using CopyObject with REPLACE directive
|
|
* @param {string} bucket - Bucket name
|
|
* @param {string} key - Object key
|
|
* @param {Object} metadata - Metadata key-value pairs (will be prefixed with x-amz-meta-)
|
|
* @param {string} contentType - Optional content type override
|
|
*/
|
|
async putObjectMetadata(bucket, key, metadata, contentType = null) {
|
|
if (!this.credentials) {
|
|
throw new Error('Not authenticated');
|
|
}
|
|
|
|
const method = 'PUT';
|
|
const path = `/${bucket}/${encodeS3Key(key)}`;
|
|
const endpoint = this.getEndpoint(false);
|
|
const url = new URL(endpoint + path);
|
|
const host = url.host;
|
|
const service = 's3';
|
|
const amzDate = this.getAmzDate();
|
|
const dateStamp = this.getDateStamp();
|
|
|
|
// CopyObject has no body
|
|
const body = '';
|
|
const payloadHash = await this.sha256(body);
|
|
|
|
// Build all headers that need to be signed
|
|
// IMPORTANT: x-amz-copy-source must be URL-encoded
|
|
const copySource = `/${bucket}/${encodeS3Key(key)}`;
|
|
|
|
const headers = {
|
|
'host': host,
|
|
'x-amz-content-sha256': payloadHash,
|
|
'x-amz-copy-source': copySource,
|
|
'x-amz-date': amzDate,
|
|
'x-amz-metadata-directive': 'REPLACE'
|
|
};
|
|
|
|
// Add metadata headers with x-amz-meta- prefix
|
|
if (metadata) {
|
|
for (const [metaKey, metaValue] of Object.entries(metadata)) {
|
|
headers[`x-amz-meta-${metaKey}`] = metaValue;
|
|
}
|
|
}
|
|
|
|
// Add content type if specified
|
|
if (contentType) {
|
|
headers['content-type'] = contentType;
|
|
}
|
|
|
|
// Sort headers for canonical request (case-insensitive)
|
|
const signedHeadersList = Object.keys(headers).map(h => h.toLowerCase()).sort();
|
|
const signedHeaders = signedHeadersList.join(';');
|
|
|
|
// Create canonical headers (must be lowercase)
|
|
const canonicalHeaders = signedHeadersList.map(h => {
|
|
const originalKey = Object.keys(headers).find(k => k.toLowerCase() === h);
|
|
return `${h}:${headers[originalKey]}\n`;
|
|
}).join('');
|
|
|
|
// Create canonical request (no query params for CopyObject)
|
|
const canonicalRequest = [
|
|
method,
|
|
url.pathname,
|
|
'', // empty query string
|
|
canonicalHeaders,
|
|
signedHeaders,
|
|
payloadHash
|
|
].join('\n');
|
|
|
|
// Create string to sign
|
|
const algorithm = 'AWS4-HMAC-SHA256';
|
|
const credentialScope = `${dateStamp}/${this.region}/${service}/aws4_request`;
|
|
const canonicalRequestHash = await this.sha256(canonicalRequest);
|
|
const stringToSign = [
|
|
algorithm,
|
|
amzDate,
|
|
credentialScope,
|
|
canonicalRequestHash
|
|
].join('\n');
|
|
|
|
// Calculate signature
|
|
const signingKey = await this.getSigningKey(this.credentials.secretKey, dateStamp, this.region, service);
|
|
const signatureBuffer = await this.hmacSha256(signingKey, stringToSign);
|
|
const signature = this.bufferToHex(signatureBuffer);
|
|
|
|
// Create authorization header
|
|
const authorization = `${algorithm} Credential=${this.credentials.accessKey}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`;
|
|
|
|
// Build final request headers
|
|
const requestHeaders = {
|
|
'Authorization': authorization,
|
|
'X-Amz-Date': amzDate,
|
|
'X-Amz-Content-Sha256': payloadHash,
|
|
'x-amz-copy-source': copySource,
|
|
'x-amz-metadata-directive': 'REPLACE'
|
|
};
|
|
|
|
// Add metadata headers
|
|
if (metadata) {
|
|
for (const [metaKey, metaValue] of Object.entries(metadata)) {
|
|
requestHeaders[`x-amz-meta-${metaKey}`] = metaValue;
|
|
}
|
|
}
|
|
|
|
// Add content type if specified
|
|
if (contentType) {
|
|
requestHeaders['Content-Type'] = contentType;
|
|
}
|
|
|
|
const response = await fetch(url.toString(), {
|
|
method: method,
|
|
headers: requestHeaders
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const text = await response.text();
|
|
throw new Error(`HTTP ${response.status}: ${text || response.statusText}`);
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Escape XML special characters
|
|
*/
|
|
escapeXml(str) {
|
|
if (!str) return '';
|
|
return str
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
/**
|
|
* Generate an AWS-style access key
|
|
* Format: AKIA + 16 base32-like characters (excluding 0, 1, 8, 9)
|
|
*/
|
|
generateAccessKey(prefix = 'AKIA') {
|
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
|
let result = prefix;
|
|
const randomValues = new Uint8Array(16);
|
|
crypto.getRandomValues(randomValues);
|
|
for (let i = 0; i < 16; i++) {
|
|
result += chars[randomValues[i] % chars.length];
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Generate an AWS-style secret key (40 characters)
|
|
*/
|
|
generateSecretKey(length = 40) {
|
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=';
|
|
let result = '';
|
|
const randomValues = new Uint8Array(length);
|
|
crypto.getRandomValues(randomValues);
|
|
for (let i = 0; i < length; i++) {
|
|
result += chars[randomValues[i] % chars.length];
|
|
}
|
|
return result;
|
|
}
|
|
}
|
|
|
|
// Create global API instance
|
|
const api = new VersityAPI();
|