init commit
This commit is contained in:
31
.env
Normal file
31
.env
Normal file
@@ -0,0 +1,31 @@
|
||||
# In all environments, the following files are loaded if they exist,
|
||||
# the latter taking precedence over the former:
|
||||
#
|
||||
# * .env contains default values for the environment variables needed by the app
|
||||
# * .env.local uncommitted file with local overrides
|
||||
# * .env.$APP_ENV committed environment-specific defaults
|
||||
# * .env.$APP_ENV.local uncommitted environment-specific overrides
|
||||
#
|
||||
# Real environment variables win over .env files.
|
||||
#
|
||||
# DO NOT DEFINE PRODUCTION SECRETS IN THIS FILE NOR IN ANY OTHER COMMITTED FILES.
|
||||
# https://symfony.com/doc/current/configuration/secrets.html
|
||||
#
|
||||
# Run "composer dump-env prod" to compile .env files for production use (requires symfony/flex >=1.2).
|
||||
# https://symfony.com/doc/current/best_practices.html#use-environment-variables-for-infrastructure-configuration
|
||||
|
||||
###> symfony/framework-bundle ###
|
||||
APP_ENV=dev
|
||||
APP_SECRET=6aa2ea989e29de27bc42a77db9849b87
|
||||
###< symfony/framework-bundle ###
|
||||
|
||||
###> doctrine/doctrine-bundle ###
|
||||
# Format described at https://www.doctrine-project.org/projects/doctrine-dbal/en/latest/reference/configuration.html#connecting-using-a-url
|
||||
# IMPORTANT: You MUST configure your server version, either here or in config/packages/doctrine.yaml
|
||||
#
|
||||
# DATABASE_URL="sqlite:///%kernel.project_dir%/var/data_%kernel.environment%.db"
|
||||
# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=8.0.32&charset=utf8mb4"
|
||||
# DATABASE_URL="mysql://app:!ChangeMe!@127.0.0.1:3306/app?serverVersion=10.11.2-MariaDB&charset=utf8mb4"
|
||||
# DATABASE_URL="postgresql://app:!ChangeMe!@127.0.0.1:5432/app?serverVersion=16&charset=utf8"
|
||||
DATABASE_URL="mysql://vultradmin:AVNS_jn444_0nHCHAvnZkTFN@vultr-prod-a6de266e-e9c6-477c-abf3-7ec2e7a7bfc8-vultr-prod-3195.vultrdb.com:18140/defaultdb?serverVersion=8.0.32&charset=utf8mb4"
|
||||
###< doctrine/doctrine-bundle ###
|
||||
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
|
||||
###> symfony/framework-bundle ###
|
||||
/.env.local
|
||||
/.env.local.php
|
||||
/.env.*.local
|
||||
/config/secrets/prod/prod.decrypt.private.php
|
||||
/public/bundles/
|
||||
/var/
|
||||
/vendor/
|
||||
###< symfony/framework-bundle ###
|
||||
66
Dockerfile
Normal file
66
Dockerfile
Normal file
@@ -0,0 +1,66 @@
|
||||
# Use FrankenPHP image with full registry path
|
||||
FROM docker.io/dunglas/frankenphp:php8.2
|
||||
|
||||
RUN apt-get update && apt-get install -y cron \
|
||||
supervisor \
|
||||
vim \
|
||||
nano
|
||||
|
||||
# add additional extensions here:
|
||||
RUN install-php-extensions \
|
||||
@composer \
|
||||
pgsql \
|
||||
pdo_pgsql \
|
||||
mysqli \
|
||||
pdo_mysql \
|
||||
bcmath \
|
||||
bz2 \
|
||||
dba \
|
||||
gd \
|
||||
gettext \
|
||||
sockets \
|
||||
sodium \
|
||||
zip \
|
||||
ldap \
|
||||
redis
|
||||
|
||||
# Create and set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Create a dir incase uploads dir needs to work
|
||||
#RUN mkdir uploads
|
||||
|
||||
# Set custom php.ini file
|
||||
#COPY ./php.ini /usr/local/etc/php/php.ini
|
||||
|
||||
# Set the custom Caddyfile:
|
||||
#COPY ./Caddyfile /etc/caddy/Caddyfile
|
||||
#COPY ./Caddyfile /etc/frankenphp/Caddyfile
|
||||
|
||||
# Only now copy the rest of the application
|
||||
COPY . .
|
||||
|
||||
# Install dependencies
|
||||
RUN composer install
|
||||
|
||||
# Final steps that depend on the application code
|
||||
RUN composer dump-autoload --optimize
|
||||
|
||||
# Add cron for vector process
|
||||
#RUN (crontab -l 2>/dev/null; echo "* * * * * /usr/local/bin/php /app/bin/console app:vector-store-process-files") | crontab -
|
||||
#
|
||||
#RUN (crontab -l 2>/dev/null; echo "* * * * * /usr/local/bin/php --version | tee -a /tmp/test.txt") | crontab -
|
||||
#
|
||||
|
||||
# Make sure the process file executable is executeable
|
||||
#RUN chmod +x vector-store-file-processor.sh
|
||||
|
||||
# Add cron to supervisor
|
||||
#COPY ./cron.conf /etc/supervisor/conf.d/
|
||||
|
||||
# Add vector-store-file-processor.conf to supervisor
|
||||
#COPY ./vector-processing-transport.conf /etc/supervisor/conf.d/
|
||||
#COPY ./failed-transport.conf /etc/supervisor/conf.d/
|
||||
|
||||
# Add supervisor to the entry point
|
||||
#RUN sed -i '/set -e/a \\n/usr/bin/supervisord -c /etc/supervisor/supervisord.conf \&\n' /usr/local/bin/docker-php-entrypoint
|
||||
21
bin/console
Executable file
21
bin/console
Executable file
@@ -0,0 +1,21 @@
|
||||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
use App\Kernel;
|
||||
use Symfony\Bundle\FrameworkBundle\Console\Application;
|
||||
|
||||
if (!is_dir(dirname(__DIR__).'/vendor')) {
|
||||
throw new LogicException('Dependencies are missing. Try running "composer install".');
|
||||
}
|
||||
|
||||
if (!is_file(dirname(__DIR__).'/vendor/autoload_runtime.php')) {
|
||||
throw new LogicException('Symfony Runtime is missing. Try running "composer require symfony/runtime".');
|
||||
}
|
||||
|
||||
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
|
||||
|
||||
return function (array $context) {
|
||||
$kernel = new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
|
||||
|
||||
return new Application($kernel);
|
||||
};
|
||||
75
composer.json
Normal file
75
composer.json
Normal file
@@ -0,0 +1,75 @@
|
||||
{
|
||||
"type": "project",
|
||||
"license": "proprietary",
|
||||
"minimum-stability": "stable",
|
||||
"prefer-stable": true,
|
||||
"require": {
|
||||
"php": ">=8.1",
|
||||
"ext-ctype": "*",
|
||||
"ext-iconv": "*",
|
||||
"doctrine/doctrine-bundle": "^2.14",
|
||||
"doctrine/doctrine-migrations-bundle": "^3.4",
|
||||
"doctrine/orm": "^3.3",
|
||||
"symfony/console": "6.4.*",
|
||||
"symfony/dotenv": "6.4.*",
|
||||
"symfony/expression-language": "6.4.*",
|
||||
"symfony/flex": "^2",
|
||||
"symfony/framework-bundle": "6.4.*",
|
||||
"symfony/runtime": "6.4.*",
|
||||
"symfony/twig-bundle": "6.4.*",
|
||||
"symfony/yaml": "6.4.*",
|
||||
"twig/extra-bundle": "^2.12|^3.0",
|
||||
"twig/twig": "^2.12|^3.0"
|
||||
},
|
||||
"config": {
|
||||
"allow-plugins": {
|
||||
"php-http/discovery": true,
|
||||
"symfony/flex": true,
|
||||
"symfony/runtime": true
|
||||
},
|
||||
"sort-packages": true
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"App\\": "src/"
|
||||
}
|
||||
},
|
||||
"autoload-dev": {
|
||||
"psr-4": {
|
||||
"App\\Tests\\": "tests/"
|
||||
}
|
||||
},
|
||||
"replace": {
|
||||
"symfony/polyfill-ctype": "*",
|
||||
"symfony/polyfill-iconv": "*",
|
||||
"symfony/polyfill-php72": "*",
|
||||
"symfony/polyfill-php73": "*",
|
||||
"symfony/polyfill-php74": "*",
|
||||
"symfony/polyfill-php80": "*",
|
||||
"symfony/polyfill-php81": "*"
|
||||
},
|
||||
"scripts": {
|
||||
"auto-scripts": {
|
||||
"cache:clear": "symfony-cmd",
|
||||
"assets:install %PUBLIC_DIR%": "symfony-cmd"
|
||||
},
|
||||
"post-install-cmd": [
|
||||
"@auto-scripts"
|
||||
],
|
||||
"post-update-cmd": [
|
||||
"@auto-scripts"
|
||||
]
|
||||
},
|
||||
"conflict": {
|
||||
"symfony/symfony": "*"
|
||||
},
|
||||
"extra": {
|
||||
"symfony": {
|
||||
"allow-contrib": false,
|
||||
"require": "6.4.*"
|
||||
}
|
||||
},
|
||||
"require-dev": {
|
||||
"symfony/maker-bundle": "^1.63"
|
||||
}
|
||||
}
|
||||
4503
composer.lock
generated
Normal file
4503
composer.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
10
config/bundles.php
Normal file
10
config/bundles.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
Symfony\Bundle\FrameworkBundle\FrameworkBundle::class => ['all' => true],
|
||||
Symfony\Bundle\TwigBundle\TwigBundle::class => ['all' => true],
|
||||
Twig\Extra\TwigExtraBundle\TwigExtraBundle::class => ['all' => true],
|
||||
Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true],
|
||||
Symfony\Bundle\MakerBundle\MakerBundle::class => ['dev' => true],
|
||||
Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle::class => ['all' => true],
|
||||
];
|
||||
19
config/packages/cache.yaml
Normal file
19
config/packages/cache.yaml
Normal file
@@ -0,0 +1,19 @@
|
||||
framework:
|
||||
cache:
|
||||
# Unique name of your app: used to compute stable namespaces for cache keys.
|
||||
#prefix_seed: your_vendor_name/app_name
|
||||
|
||||
# The "app" cache stores to the filesystem by default.
|
||||
# The data in this cache should persist between deploys.
|
||||
# Other options include:
|
||||
|
||||
# Redis
|
||||
#app: cache.adapter.redis
|
||||
#default_redis_provider: redis://localhost
|
||||
|
||||
# APCu (not recommended with heavy random-write workloads as memory fragmentation can cause perf issues)
|
||||
#app: cache.adapter.apcu
|
||||
|
||||
# Namespaced pools use the above "app" backend by default
|
||||
#pools:
|
||||
#my.dedicated.cache: null
|
||||
50
config/packages/doctrine.yaml
Normal file
50
config/packages/doctrine.yaml
Normal file
@@ -0,0 +1,50 @@
|
||||
doctrine:
|
||||
dbal:
|
||||
url: '%env(resolve:DATABASE_URL)%'
|
||||
|
||||
# IMPORTANT: You MUST configure your server version,
|
||||
# either here or in the DATABASE_URL env var (see .env file)
|
||||
#server_version: '16'
|
||||
|
||||
profiling_collect_backtrace: '%kernel.debug%'
|
||||
use_savepoints: true
|
||||
orm:
|
||||
auto_generate_proxy_classes: true
|
||||
enable_lazy_ghost_objects: true
|
||||
report_fields_where_declared: true
|
||||
validate_xml_mapping: true
|
||||
naming_strategy: doctrine.orm.naming_strategy.underscore_number_aware
|
||||
auto_mapping: true
|
||||
mappings:
|
||||
App:
|
||||
type: attribute
|
||||
is_bundle: false
|
||||
dir: '%kernel.project_dir%/src/Entity'
|
||||
prefix: 'App\Entity'
|
||||
alias: App
|
||||
|
||||
when@test:
|
||||
doctrine:
|
||||
dbal:
|
||||
# "TEST_TOKEN" is typically set by ParaTest
|
||||
dbname_suffix: '_test%env(default::TEST_TOKEN)%'
|
||||
|
||||
when@prod:
|
||||
doctrine:
|
||||
orm:
|
||||
auto_generate_proxy_classes: false
|
||||
proxy_dir: '%kernel.build_dir%/doctrine/orm/Proxies'
|
||||
query_cache_driver:
|
||||
type: pool
|
||||
pool: doctrine.system_cache_pool
|
||||
result_cache_driver:
|
||||
type: pool
|
||||
pool: doctrine.result_cache_pool
|
||||
|
||||
framework:
|
||||
cache:
|
||||
pools:
|
||||
doctrine.result_cache_pool:
|
||||
adapter: cache.app
|
||||
doctrine.system_cache_pool:
|
||||
adapter: cache.system
|
||||
6
config/packages/doctrine_migrations.yaml
Normal file
6
config/packages/doctrine_migrations.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
doctrine_migrations:
|
||||
migrations_paths:
|
||||
# namespace is arbitrary but should be different from App\Migrations
|
||||
# as migrations classes should NOT be autoloaded
|
||||
'DoctrineMigrations': '%kernel.project_dir%/migrations'
|
||||
enable_profiler: false
|
||||
24
config/packages/framework.yaml
Normal file
24
config/packages/framework.yaml
Normal file
@@ -0,0 +1,24 @@
|
||||
# see https://symfony.com/doc/current/reference/configuration/framework.html
|
||||
framework:
|
||||
secret: '%env(APP_SECRET)%'
|
||||
annotations: false
|
||||
http_method_override: false
|
||||
handle_all_throwables: true
|
||||
|
||||
# Enables session support. Note that the session will ONLY be started if you read or write from it.
|
||||
# Remove or comment this section to explicitly disable session support.
|
||||
session:
|
||||
handler_id: null
|
||||
cookie_secure: auto
|
||||
cookie_samesite: lax
|
||||
|
||||
#esi: true
|
||||
#fragments: true
|
||||
php_errors:
|
||||
log: true
|
||||
|
||||
when@test:
|
||||
framework:
|
||||
test: true
|
||||
session:
|
||||
storage_factory_id: session.storage.factory.mock_file
|
||||
12
config/packages/routing.yaml
Normal file
12
config/packages/routing.yaml
Normal file
@@ -0,0 +1,12 @@
|
||||
framework:
|
||||
router:
|
||||
utf8: true
|
||||
|
||||
# Configure how to generate URLs in non-HTTP contexts, such as CLI commands.
|
||||
# See https://symfony.com/doc/current/routing.html#generating-urls-in-commands
|
||||
#default_uri: http://localhost
|
||||
|
||||
when@prod:
|
||||
framework:
|
||||
router:
|
||||
strict_requirements: null
|
||||
6
config/packages/twig.yaml
Normal file
6
config/packages/twig.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
twig:
|
||||
file_name_pattern: '*.twig'
|
||||
|
||||
when@test:
|
||||
twig:
|
||||
strict_variables: true
|
||||
5
config/preload.php
Normal file
5
config/preload.php
Normal file
@@ -0,0 +1,5 @@
|
||||
<?php
|
||||
|
||||
if (file_exists(dirname(__DIR__).'/var/cache/prod/App_KernelProdContainer.preload.php')) {
|
||||
require dirname(__DIR__).'/var/cache/prod/App_KernelProdContainer.preload.php';
|
||||
}
|
||||
5
config/routes.yaml
Normal file
5
config/routes.yaml
Normal file
@@ -0,0 +1,5 @@
|
||||
controllers:
|
||||
resource:
|
||||
path: ../src/Controller/
|
||||
namespace: App\Controller
|
||||
type: attribute
|
||||
66
config/routes/console_api.yaml
Normal file
66
config/routes/console_api.yaml
Normal file
@@ -0,0 +1,66 @@
|
||||
# Console API Routes
|
||||
console_api_credentials:
|
||||
path: /api/credentials
|
||||
controller: App\Controller\ConsoleApiController::credentials
|
||||
methods: [GET, POST]
|
||||
|
||||
console_api_credential_detail:
|
||||
path: /api/credentials/{id}
|
||||
controller: App\Controller\ConsoleApiController::credentialDetail
|
||||
methods: [GET, PUT, DELETE]
|
||||
requirements:
|
||||
id: '\d+'
|
||||
|
||||
console_api_buckets:
|
||||
path: /api/buckets
|
||||
controller: App\Controller\ConsoleApiController::buckets
|
||||
methods: [GET, POST]
|
||||
|
||||
console_api_bucket_detail:
|
||||
path: /api/buckets/{name}
|
||||
controller: App\Controller\ConsoleApiController::bucketDetail
|
||||
methods: [GET, DELETE]
|
||||
requirements:
|
||||
name: '[a-z0-9\-\.]+'
|
||||
|
||||
console_api_objects:
|
||||
path: /api/buckets/{bucketName}/objects
|
||||
controller: App\Controller\ConsoleApiController::objects
|
||||
methods: [GET, POST, DELETE]
|
||||
requirements:
|
||||
bucketName: '[a-z0-9\-\.]+'
|
||||
|
||||
console_api_object_detail:
|
||||
path: /api/buckets/{bucketName}/objects/{objectKey}
|
||||
controller: App\Controller\ConsoleApiController::objectDetail
|
||||
methods: [GET, DELETE]
|
||||
requirements:
|
||||
bucketName: '[a-z0-9\-\.]+'
|
||||
objectKey: '.+'
|
||||
|
||||
console_api_multipart_uploads:
|
||||
path: /api/buckets/{bucketName}/multipart-uploads
|
||||
controller: App\Controller\ConsoleApiController::multipartUploads
|
||||
methods: [GET]
|
||||
requirements:
|
||||
bucketName: '[a-z0-9\-\.]+'
|
||||
|
||||
console_api_presigned_urls:
|
||||
path: /api/presigned-urls
|
||||
controller: App\Controller\ConsoleApiController::presignedUrls
|
||||
methods: [GET, POST]
|
||||
|
||||
console_api_stats:
|
||||
path: /api/stats
|
||||
controller: App\Controller\ConsoleApiController::stats
|
||||
methods: [GET]
|
||||
|
||||
# Console Frontend Route
|
||||
console_frontend:
|
||||
path: /console/{route}
|
||||
controller: App\Controller\ConsoleController::index
|
||||
methods: [GET]
|
||||
requirements:
|
||||
route: '.*'
|
||||
defaults:
|
||||
route: ''
|
||||
4
config/routes/docs.yaml
Normal file
4
config/routes/docs.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
docs_show:
|
||||
path: /docs
|
||||
controller: App\Controller\DocsController::show
|
||||
methods: [GET]
|
||||
4
config/routes/framework.yaml
Normal file
4
config/routes/framework.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
when@dev:
|
||||
_errors:
|
||||
resource: '@FrameworkBundle/Resources/config/routing/errors.xml'
|
||||
prefix: /_error
|
||||
121
config/routes/s3_api.yaml
Normal file
121
config/routes/s3_api.yaml
Normal file
@@ -0,0 +1,121 @@
|
||||
# List all buckets
|
||||
s3_list_buckets:
|
||||
path: /s3/
|
||||
controller: App\Controller\S3ApiController::listBuckets
|
||||
methods: [GET]
|
||||
|
||||
# Bucket operations
|
||||
s3_create_bucket:
|
||||
path: /s3/{bucket}
|
||||
controller: App\Controller\S3ApiController::createBucket
|
||||
methods: [PUT]
|
||||
requirements:
|
||||
bucket: '[a-z0-9\-\.]+'
|
||||
|
||||
s3_delete_bucket:
|
||||
path: /s3/{bucket}
|
||||
controller: App\Controller\S3ApiController::deleteBucket
|
||||
methods: [DELETE]
|
||||
requirements:
|
||||
bucket: '[a-z0-9\-\.]+'
|
||||
|
||||
s3_head_bucket:
|
||||
path: /s3/{bucket}
|
||||
controller: App\Controller\S3ApiController::headBucket
|
||||
methods: [HEAD]
|
||||
requirements:
|
||||
bucket: '[a-z0-9\-\.]+'
|
||||
|
||||
s3_list_objects:
|
||||
path: /s3/{bucket}
|
||||
controller: App\Controller\S3ApiController::listObjects
|
||||
methods: [GET]
|
||||
requirements:
|
||||
bucket: '[a-z0-9\-\.]+'
|
||||
|
||||
# Object operations
|
||||
s3_put_object:
|
||||
path: /s3/{bucket}/{key}
|
||||
controller: App\Controller\S3ApiController::putObject
|
||||
methods: [PUT]
|
||||
requirements:
|
||||
bucket: '[a-z0-9\-\.]+'
|
||||
key: '.+'
|
||||
|
||||
s3_get_object:
|
||||
path: /s3/{bucket}/{key}
|
||||
controller: App\Controller\S3ApiController::getObject
|
||||
methods: [GET]
|
||||
requirements:
|
||||
bucket: '[a-z0-9\-\.]+'
|
||||
key: '.+'
|
||||
|
||||
s3_head_object:
|
||||
path: /s3/{bucket}/{key}
|
||||
controller: App\Controller\S3ApiController::headObject
|
||||
methods: [HEAD]
|
||||
requirements:
|
||||
bucket: '[a-z0-9\-\.]+'
|
||||
key: '.+'
|
||||
|
||||
s3_delete_object:
|
||||
path: /s3/{bucket}/{key}
|
||||
controller: App\Controller\S3ApiController::deleteObject
|
||||
methods: [DELETE]
|
||||
requirements:
|
||||
bucket: '[a-z0-9\-\.]+'
|
||||
key: '.+'
|
||||
|
||||
# Multipart upload operations
|
||||
s3_initiate_multipart:
|
||||
path: /s3/{bucket}/{key}
|
||||
controller: App\Controller\S3ApiController::initiateMultipartUpload
|
||||
methods: [POST]
|
||||
condition: "request.query.has('uploads')"
|
||||
requirements:
|
||||
bucket: '[a-z0-9\-\.]+'
|
||||
key: '.+'
|
||||
|
||||
s3_upload_part:
|
||||
path: /s3/{bucket}/{key}
|
||||
controller: App\Controller\S3ApiController::uploadPart
|
||||
methods: [PUT]
|
||||
condition: "request.query.has('partNumber') and request.query.has('uploadId')"
|
||||
requirements:
|
||||
bucket: '[a-z0-9\-\.]+'
|
||||
key: '.+'
|
||||
|
||||
s3_complete_multipart:
|
||||
path: /s3/{bucket}/{key}
|
||||
controller: App\Controller\S3ApiController::completeMultipartUpload
|
||||
methods: [POST]
|
||||
condition: "request.query.has('uploadId') and not request.query.has('uploads')"
|
||||
requirements:
|
||||
bucket: '[a-z0-9\-\.]+'
|
||||
key: '.+'
|
||||
|
||||
s3_abort_multipart:
|
||||
path: /s3/{bucket}/{key}
|
||||
controller: App\Controller\S3ApiController::abortMultipartUpload
|
||||
methods: [DELETE]
|
||||
condition: "request.query.has('uploadId')"
|
||||
requirements:
|
||||
bucket: '[a-z0-9\-\.]+'
|
||||
key: '.+'
|
||||
|
||||
s3_list_parts:
|
||||
path: /s3/{bucket}/{key}
|
||||
controller: App\Controller\S3ApiController::listParts
|
||||
methods: [GET]
|
||||
condition: "request.query.has('uploadId')"
|
||||
requirements:
|
||||
bucket: '[a-z0-9\-\.]+'
|
||||
key: '.+'
|
||||
|
||||
s3_list_multipart_uploads:
|
||||
path: /s3/{bucket}
|
||||
controller: App\Controller\S3ApiController::listMultipartUploads
|
||||
methods: [GET]
|
||||
condition: "request.query.has('uploads')"
|
||||
requirements:
|
||||
bucket: '[a-z0-9\-\.]+'
|
||||
27
config/services.yaml
Normal file
27
config/services.yaml
Normal file
@@ -0,0 +1,27 @@
|
||||
# This file is the entry point to configure your own services.
|
||||
# Files in the packages/ subdirectory configure your dependencies.
|
||||
|
||||
# Put parameters here that don't need to change on each machine where the app is deployed
|
||||
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
|
||||
parameters:
|
||||
s3.storage_path: '%kernel.project_dir%/var/s3storage'
|
||||
|
||||
services:
|
||||
# default configuration for services in *this* file
|
||||
_defaults:
|
||||
autowire: true # Automatically injects dependencies in your services.
|
||||
autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
|
||||
|
||||
# makes classes in src/ available to be used as services
|
||||
# this creates a service per class whose id is the fully-qualified class name
|
||||
App\:
|
||||
resource: '../src/'
|
||||
exclude:
|
||||
- '../src/DependencyInjection/'
|
||||
- '../src/Entity/'
|
||||
- '../src/Kernel.php'
|
||||
# add more service definitions when explicit configuration is needed
|
||||
# please note that last definitions always *replace* previous ones
|
||||
App\Service\S3Service:
|
||||
arguments:
|
||||
$storageBasePath: '%s3.storage_path%'
|
||||
20
docker-compose.yml
Normal file
20
docker-compose.yml
Normal file
@@ -0,0 +1,20 @@
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
ports:
|
||||
- "8000:80"
|
||||
- "8443:443"
|
||||
volumes:
|
||||
#- ./php.ini:/usr/local/etc/php/php.ini
|
||||
- ./public:/app/public
|
||||
- ./src:/app/src
|
||||
- ./templates:/app/templates
|
||||
#- ./Caddyfile:/etc/caddy/Caddyfile
|
||||
environment:
|
||||
- SERVER_NAME=:80
|
||||
- APP_PATH=/app
|
||||
- APP_ENV=dev
|
||||
- APP_SECRET=6aa2ea989e29de27bc42a77db9849b87
|
||||
- DATABASE_URL=mysql://vultradmin:AVNS_jn444_0nHCHAvnZkTFN@vultr-prod-a6de266e-e9c6-477c-abf3-7ec2e7a7bfc8-vultr-prod-3195.vultrdb.com:18140/defaultdb?serverVersion=8.0.32&charset=utf8mb4
|
||||
network_mode: host
|
||||
0
migrations/.gitignore
vendored
Normal file
0
migrations/.gitignore
vendored
Normal file
77
migrations/Version20250605111121.php
Normal file
77
migrations/Version20250605111121.php
Normal file
@@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
/**
|
||||
* Auto-generated Migration: Please modify to your needs!
|
||||
*/
|
||||
final class Version20250605111121 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// this up() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE s3_multipart_uploads (id BIGINT AUTO_INCREMENT NOT NULL, upload_id VARCHAR(64) NOT NULL, object_key VARCHAR(255) NOT NULL, storage_path_prefix VARCHAR(2048) NOT NULL, content_type VARCHAR(255) NOT NULL, metadata JSON DEFAULT NULL, initiated_at DATETIME NOT NULL, expires_at DATETIME DEFAULT NULL, bucket_id INT NOT NULL, initiated_by_id INT NOT NULL, UNIQUE INDEX UNIQ_4FED1A58CCCFBA31 (upload_id), INDEX IDX_4FED1A5884CE584D (bucket_id), INDEX IDX_4FED1A58C4EF1FC7 (initiated_by_id), INDEX idx_upload_id (upload_id), INDEX idx_bucket_key (bucket_id, object_key), INDEX idx_expires (expires_at), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4
|
||||
SQL);
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE s3_objects (id BIGINT AUTO_INCREMENT NOT NULL, object_key VARCHAR(255) NOT NULL, size BIGINT NOT NULL, etag VARCHAR(64) NOT NULL, content_type VARCHAR(255) NOT NULL, storage_path VARCHAR(2048) NOT NULL, metadata JSON DEFAULT NULL, is_multipart TINYINT(1) NOT NULL, part_count INT NOT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL, bucket_id INT NOT NULL, INDEX IDX_4A38581D84CE584D (bucket_id), INDEX idx_etag (etag), INDEX idx_size (size), INDEX idx_created (created_at), UNIQUE INDEX uk_bucket_object (bucket_id, object_key), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4
|
||||
SQL);
|
||||
$this->addSql(<<<'SQL'
|
||||
CREATE TABLE s3_presigned_urls (id BIGINT AUTO_INCREMENT NOT NULL, url_hash VARCHAR(64) NOT NULL, bucket_name VARCHAR(63) NOT NULL, object_key VARCHAR(255) NOT NULL, method VARCHAR(10) NOT NULL, expires_at DATETIME NOT NULL, access_key VARCHAR(32) NOT NULL, created_at DATETIME NOT NULL, UNIQUE INDEX UNIQ_301F0863CFECAB00 (url_hash), INDEX idx_url_hash (url_hash), INDEX idx_expires (expires_at), INDEX idx_bucket_key (bucket_name, object_key), INDEX idx_access_key (access_key), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4
|
||||
SQL);
|
||||
$this->addSql(<<<'SQL'
|
||||
ALTER TABLE s3_multipart_uploads ADD CONSTRAINT FK_4FED1A5884CE584D FOREIGN KEY (bucket_id) REFERENCES s3_buckets (id)
|
||||
SQL);
|
||||
$this->addSql(<<<'SQL'
|
||||
ALTER TABLE s3_multipart_uploads ADD CONSTRAINT FK_4FED1A58C4EF1FC7 FOREIGN KEY (initiated_by_id) REFERENCES s3_credentials (id)
|
||||
SQL);
|
||||
$this->addSql(<<<'SQL'
|
||||
ALTER TABLE s3_objects ADD CONSTRAINT FK_4A38581D84CE584D FOREIGN KEY (bucket_id) REFERENCES s3_buckets (id)
|
||||
SQL);
|
||||
$this->addSql(<<<'SQL'
|
||||
ALTER TABLE s3_buckets ADD CONSTRAINT FK_DC7E620E7E3C61F9 FOREIGN KEY (owner_id) REFERENCES s3_credentials (id)
|
||||
SQL);
|
||||
$this->addSql(<<<'SQL'
|
||||
ALTER TABLE s3_multipart_parts ADD CONSTRAINT FK_39F7DA05CCCFBA31 FOREIGN KEY (upload_id) REFERENCES s3_multipart_uploads (upload_id)
|
||||
SQL);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// this down() migration is auto-generated, please modify it to your needs
|
||||
$this->addSql(<<<'SQL'
|
||||
ALTER TABLE s3_multipart_uploads DROP FOREIGN KEY FK_4FED1A5884CE584D
|
||||
SQL);
|
||||
$this->addSql(<<<'SQL'
|
||||
ALTER TABLE s3_multipart_uploads DROP FOREIGN KEY FK_4FED1A58C4EF1FC7
|
||||
SQL);
|
||||
$this->addSql(<<<'SQL'
|
||||
ALTER TABLE s3_objects DROP FOREIGN KEY FK_4A38581D84CE584D
|
||||
SQL);
|
||||
$this->addSql(<<<'SQL'
|
||||
DROP TABLE s3_multipart_uploads
|
||||
SQL);
|
||||
$this->addSql(<<<'SQL'
|
||||
DROP TABLE s3_objects
|
||||
SQL);
|
||||
$this->addSql(<<<'SQL'
|
||||
DROP TABLE s3_presigned_urls
|
||||
SQL);
|
||||
$this->addSql(<<<'SQL'
|
||||
ALTER TABLE s3_buckets DROP FOREIGN KEY FK_DC7E620E7E3C61F9
|
||||
SQL);
|
||||
$this->addSql(<<<'SQL'
|
||||
ALTER TABLE s3_multipart_parts DROP FOREIGN KEY FK_39F7DA05CCCFBA31
|
||||
SQL);
|
||||
}
|
||||
}
|
||||
9
public/index.php
Normal file
9
public/index.php
Normal file
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
use App\Kernel;
|
||||
|
||||
require_once dirname(__DIR__).'/vendor/autoload_runtime.php';
|
||||
|
||||
return function (array $context) {
|
||||
return new Kernel($context['APP_ENV'], (bool) $context['APP_DEBUG']);
|
||||
};
|
||||
0
src/Controller/.gitignore
vendored
Normal file
0
src/Controller/.gitignore
vendored
Normal file
434
src/Controller/ConsoleApiController.php
Normal file
434
src/Controller/ConsoleApiController.php
Normal file
@@ -0,0 +1,434 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Entity\S3Credential;
|
||||
use App\Entity\S3Bucket;
|
||||
use App\Entity\S3Object;
|
||||
use App\Service\S3Service;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class ConsoleApiController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private S3Service $s3Service,
|
||||
private EntityManagerInterface $entityManager
|
||||
) {}
|
||||
|
||||
// Credentials Management
|
||||
public function credentials(Request $request): JsonResponse
|
||||
{
|
||||
if ($request->getMethod() === 'GET') {
|
||||
$credentials = $this->entityManager->getRepository(S3Credential::class)->findAll();
|
||||
|
||||
return new JsonResponse([
|
||||
'credentials' => array_map(function($cred) {
|
||||
return [
|
||||
'id' => $cred->getId(),
|
||||
'access_key' => $cred->getAccessKey(),
|
||||
'user_name' => $cred->getUserName(),
|
||||
'is_active' => $cred->isActive(),
|
||||
'created_at' => $cred->getCreatedAt()->format('Y-m-d H:i:s'),
|
||||
'bucket_count' => $cred->getBuckets()->count()
|
||||
];
|
||||
}, $credentials)
|
||||
]);
|
||||
}
|
||||
|
||||
if ($request->getMethod() === 'POST') {
|
||||
$data = json_decode($request->getContent(), true);
|
||||
|
||||
$accessKey = $data['access_key'] ?? 'AKIA' . strtoupper(bin2hex(random_bytes(10)));
|
||||
$secretKey = $data['secret_key'] ?? base64_encode(random_bytes(30));
|
||||
$userName = $data['user_name'] ?? null;
|
||||
|
||||
$credential = $this->s3Service->createCredential($accessKey, $secretKey, $userName);
|
||||
|
||||
return new JsonResponse([
|
||||
'id' => $credential->getId(),
|
||||
'access_key' => $credential->getAccessKey(),
|
||||
'secret_key' => $credential->getSecretKey(),
|
||||
'user_name' => $credential->getUserName(),
|
||||
'is_active' => $credential->isActive(),
|
||||
'created_at' => $credential->getCreatedAt()->format('Y-m-d H:i:s')
|
||||
], 201);
|
||||
}
|
||||
|
||||
return new JsonResponse(['error' => 'Method not allowed'], 405);
|
||||
}
|
||||
|
||||
public function credentialDetail(int $id, Request $request): JsonResponse
|
||||
{
|
||||
$credential = $this->entityManager->getRepository(S3Credential::class)->find($id);
|
||||
|
||||
if (!$credential) {
|
||||
return new JsonResponse(['error' => 'Credential not found'], 404);
|
||||
}
|
||||
|
||||
if ($request->getMethod() === 'GET') {
|
||||
return new JsonResponse([
|
||||
'id' => $credential->getId(),
|
||||
'access_key' => $credential->getAccessKey(),
|
||||
'secret_key' => $credential->getSecretKey(),
|
||||
'user_name' => $credential->getUserName(),
|
||||
'is_active' => $credential->isActive(),
|
||||
'created_at' => $credential->getCreatedAt()->format('Y-m-d H:i:s'),
|
||||
'buckets' => array_map(function($bucket) {
|
||||
return [
|
||||
'name' => $bucket->getName(),
|
||||
'region' => $bucket->getRegion(),
|
||||
'created_at' => $bucket->getCreatedAt()->format('Y-m-d H:i:s')
|
||||
];
|
||||
}, $credential->getBuckets()->toArray())
|
||||
]);
|
||||
}
|
||||
|
||||
if ($request->getMethod() === 'PUT') {
|
||||
$data = json_decode($request->getContent(), true);
|
||||
|
||||
if (isset($data['user_name'])) {
|
||||
$credential->setUserName($data['user_name']);
|
||||
}
|
||||
if (isset($data['is_active'])) {
|
||||
$credential->setIsActive($data['is_active']);
|
||||
}
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
return new JsonResponse(['message' => 'Credential updated']);
|
||||
}
|
||||
|
||||
if ($request->getMethod() === 'DELETE') {
|
||||
$this->entityManager->remove($credential);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return new JsonResponse(['message' => 'Credential deleted']);
|
||||
}
|
||||
|
||||
return new JsonResponse(['error' => 'Method not allowed'], 405);
|
||||
}
|
||||
|
||||
// Buckets Management
|
||||
public function buckets(Request $request): JsonResponse
|
||||
{
|
||||
if ($request->getMethod() === 'GET') {
|
||||
$buckets = $this->entityManager->getRepository(S3Bucket::class)->findAll();
|
||||
|
||||
return new JsonResponse([
|
||||
'buckets' => array_map(function($bucket) {
|
||||
$objectCount = $this->entityManager->getRepository(S3Object::class)->count(['bucket' => $bucket]);
|
||||
$totalSize = $this->entityManager->createQueryBuilder()
|
||||
->select('SUM(o.size)')
|
||||
->from(S3Object::class, 'o')
|
||||
->where('o.bucket = :bucket')
|
||||
->setParameter('bucket', $bucket)
|
||||
->getQuery()
|
||||
->getSingleScalarResult() ?: 0;
|
||||
|
||||
return [
|
||||
'name' => $bucket->getName(),
|
||||
'region' => $bucket->getRegion(),
|
||||
'owner' => $bucket->getOwner()->getUserName() ?: $bucket->getOwner()->getAccessKey(),
|
||||
'created_at' => $bucket->getCreatedAt()->format('Y-m-d H:i:s'),
|
||||
'object_count' => $objectCount,
|
||||
'total_size' => $totalSize,
|
||||
'total_size_human' => $this->formatBytes($totalSize)
|
||||
];
|
||||
}, $buckets)
|
||||
]);
|
||||
}
|
||||
|
||||
if ($request->getMethod() === 'POST') {
|
||||
$data = json_decode($request->getContent(), true);
|
||||
|
||||
$bucketName = $data['name'] ?? null;
|
||||
$ownerId = $data['owner_id'] ?? null;
|
||||
$region = $data['region'] ?? 'us-east-1';
|
||||
|
||||
if (!$bucketName || !$ownerId) {
|
||||
return new JsonResponse(['error' => 'Missing bucket name or owner'], 400);
|
||||
}
|
||||
|
||||
$owner = $this->entityManager->getRepository(S3Credential::class)->find($ownerId);
|
||||
if (!$owner) {
|
||||
return new JsonResponse(['error' => 'Owner not found'], 404);
|
||||
}
|
||||
|
||||
try {
|
||||
$bucket = $this->s3Service->createBucket($bucketName, $owner, $region);
|
||||
|
||||
return new JsonResponse([
|
||||
'name' => $bucket->getName(),
|
||||
'region' => $bucket->getRegion(),
|
||||
'owner' => $bucket->getOwner()->getUserName() ?: $bucket->getOwner()->getAccessKey(),
|
||||
'created_at' => $bucket->getCreatedAt()->format('Y-m-d H:i:s')
|
||||
], 201);
|
||||
} catch (\Exception $e) {
|
||||
return new JsonResponse(['error' => $e->getMessage()], 400);
|
||||
}
|
||||
}
|
||||
|
||||
return new JsonResponse(['error' => 'Method not allowed'], 405);
|
||||
}
|
||||
|
||||
public function bucketDetail(string $name, Request $request): JsonResponse
|
||||
{
|
||||
$bucket = $this->s3Service->findBucketByName($name);
|
||||
|
||||
if (!$bucket) {
|
||||
return new JsonResponse(['error' => 'Bucket not found'], 404);
|
||||
}
|
||||
|
||||
if ($request->getMethod() === 'GET') {
|
||||
$objects = $this->s3Service->listObjects($bucket);
|
||||
$totalSize = array_sum(array_map(fn($obj) => $obj->getSize(), $objects));
|
||||
|
||||
return new JsonResponse([
|
||||
'name' => $bucket->getName(),
|
||||
'region' => $bucket->getRegion(),
|
||||
'owner' => $bucket->getOwner()->getUserName() ?: $bucket->getOwner()->getAccessKey(),
|
||||
'created_at' => $bucket->getCreatedAt()->format('Y-m-d H:i:s'),
|
||||
'object_count' => count($objects),
|
||||
'total_size' => $totalSize,
|
||||
'total_size_human' => $this->formatBytes($totalSize),
|
||||
'objects' => array_map(function($obj) {
|
||||
return [
|
||||
'key' => $obj->getObjectKey(),
|
||||
'size' => $obj->getSize(),
|
||||
'size_human' => $this->formatBytes($obj->getSize()),
|
||||
'content_type' => $obj->getContentType(),
|
||||
'etag' => $obj->getEtag(),
|
||||
'is_multipart' => $obj->isMultipart(),
|
||||
'created_at' => $obj->getCreatedAt()->format('Y-m-d H:i:s')
|
||||
];
|
||||
}, $objects)
|
||||
]);
|
||||
}
|
||||
|
||||
if ($request->getMethod() === 'DELETE') {
|
||||
try {
|
||||
$this->s3Service->deleteBucket($bucket);
|
||||
return new JsonResponse(['message' => 'Bucket deleted']);
|
||||
} catch (\Exception $e) {
|
||||
return new JsonResponse(['error' => $e->getMessage()], 400);
|
||||
}
|
||||
}
|
||||
|
||||
return new JsonResponse(['error' => 'Method not allowed'], 405);
|
||||
}
|
||||
|
||||
// Objects Management
|
||||
public function objects(string $bucketName, Request $request): JsonResponse
|
||||
{
|
||||
$bucket = $this->s3Service->findBucketByName($bucketName);
|
||||
|
||||
if (!$bucket) {
|
||||
return new JsonResponse(['error' => 'Bucket not found'], 404);
|
||||
}
|
||||
|
||||
if ($request->getMethod() === 'GET') {
|
||||
$prefix = $request->query->get('prefix', '');
|
||||
$objects = $this->s3Service->listObjects($bucket, $prefix);
|
||||
|
||||
return new JsonResponse([
|
||||
'objects' => array_map(function($obj) {
|
||||
return [
|
||||
'key' => $obj->getObjectKey(),
|
||||
'size' => $obj->getSize(),
|
||||
'size_human' => $this->formatBytes($obj->getSize()),
|
||||
'content_type' => $obj->getContentType(),
|
||||
'etag' => $obj->getEtag(),
|
||||
'is_multipart' => $obj->isMultipart(),
|
||||
'created_at' => $obj->getCreatedAt()->format('Y-m-d H:i:s')
|
||||
];
|
||||
}, $objects)
|
||||
]);
|
||||
}
|
||||
|
||||
if ($request->getMethod() === 'DELETE') {
|
||||
$data = json_decode($request->getContent(), true);
|
||||
$keys = $data['keys'] ?? [];
|
||||
|
||||
$deleted = [];
|
||||
foreach ($keys as $key) {
|
||||
$object = $this->s3Service->findObject($bucket, $key);
|
||||
if ($object) {
|
||||
$this->s3Service->deleteObject($object);
|
||||
$deleted[] = $key;
|
||||
}
|
||||
}
|
||||
|
||||
return new JsonResponse(['deleted' => $deleted]);
|
||||
}
|
||||
|
||||
return new JsonResponse(['error' => 'Method not allowed'], 405);
|
||||
}
|
||||
|
||||
public function objectDetail(string $bucketName, string $objectKey, Request $request): JsonResponse
|
||||
{
|
||||
$bucket = $this->s3Service->findBucketByName($bucketName);
|
||||
|
||||
if (!$bucket) {
|
||||
return new JsonResponse(['error' => 'Bucket not found'], 404);
|
||||
}
|
||||
|
||||
$object = $this->s3Service->findObject($bucket, $objectKey);
|
||||
|
||||
if (!$object) {
|
||||
return new JsonResponse(['error' => 'Object not found'], 404);
|
||||
}
|
||||
|
||||
if ($request->getMethod() === 'GET') {
|
||||
return new JsonResponse([
|
||||
'key' => $object->getObjectKey(),
|
||||
'size' => $object->getSize(),
|
||||
'size_human' => $this->formatBytes($object->getSize()),
|
||||
'content_type' => $object->getContentType(),
|
||||
'etag' => $object->getEtag(),
|
||||
'is_multipart' => $object->isMultipart(),
|
||||
'part_count' => $object->getPartCount(),
|
||||
'metadata' => $object->getMetadata(),
|
||||
'storage_path' => $object->getStoragePath(),
|
||||
'created_at' => $object->getCreatedAt()->format('Y-m-d H:i:s'),
|
||||
'updated_at' => $object->getUpdatedAt()->format('Y-m-d H:i:s')
|
||||
]);
|
||||
}
|
||||
|
||||
if ($request->getMethod() === 'DELETE') {
|
||||
$this->s3Service->deleteObject($object);
|
||||
return new JsonResponse(['message' => 'Object deleted']);
|
||||
}
|
||||
|
||||
return new JsonResponse(['error' => 'Method not allowed'], 405);
|
||||
}
|
||||
|
||||
// Multipart Uploads
|
||||
public function multipartUploads(string $bucketName, Request $request): JsonResponse
|
||||
{
|
||||
$bucket = $this->s3Service->findBucketByName($bucketName);
|
||||
|
||||
if (!$bucket) {
|
||||
return new JsonResponse(['error' => 'Bucket not found'], 404);
|
||||
}
|
||||
|
||||
$uploads = $this->s3Service->listMultipartUploads($bucket);
|
||||
|
||||
return new JsonResponse([
|
||||
'uploads' => array_map(function($upload) {
|
||||
$parts = $this->s3Service->listParts($upload);
|
||||
$totalSize = array_sum(array_map(fn($part) => $part->getSize(), $parts));
|
||||
|
||||
return [
|
||||
'upload_id' => $upload->getUploadId(),
|
||||
'object_key' => $upload->getObjectKey(),
|
||||
'initiated_by' => $upload->getInitiatedBy()->getUserName() ?: $upload->getInitiatedBy()->getAccessKey(),
|
||||
'content_type' => $upload->getContentType(),
|
||||
'initiated_at' => $upload->getInitiatedAt()->format('Y-m-d H:i:s'),
|
||||
'part_count' => count($parts),
|
||||
'total_size' => $totalSize,
|
||||
'total_size_human' => $this->formatBytes($totalSize)
|
||||
];
|
||||
}, $uploads)
|
||||
]);
|
||||
}
|
||||
|
||||
// Presigned URLs
|
||||
public function presignedUrls(Request $request): JsonResponse
|
||||
{
|
||||
if ($request->getMethod() === 'GET') {
|
||||
$urls = $this->entityManager->getRepository(\App\Entity\S3PresignedUrl::class)
|
||||
->createQueryBuilder('p')
|
||||
->where('p.expiresAt > :now')
|
||||
->setParameter('now', new \DateTime())
|
||||
->orderBy('p.createdAt', 'DESC')
|
||||
->setMaxResults(100)
|
||||
->getQuery()
|
||||
->getResult();
|
||||
|
||||
return new JsonResponse([
|
||||
'urls' => array_map(function($url) {
|
||||
return [
|
||||
'bucket_name' => $url->getBucketName(),
|
||||
'object_key' => $url->getObjectKey(),
|
||||
'method' => $url->getMethod(),
|
||||
'access_key' => $url->getAccessKey(),
|
||||
'expires_at' => $url->getExpiresAt()->format('Y-m-d H:i:s'),
|
||||
'created_at' => $url->getCreatedAt()->format('Y-m-d H:i:s')
|
||||
];
|
||||
}, $urls)
|
||||
]);
|
||||
}
|
||||
|
||||
if ($request->getMethod() === 'POST') {
|
||||
$data = json_decode($request->getContent(), true);
|
||||
|
||||
$bucketName = $data['bucket_name'] ?? null;
|
||||
$objectKey = $data['object_key'] ?? null;
|
||||
$method = $data['method'] ?? 'GET';
|
||||
$expiresIn = $data['expires_in'] ?? 3600;
|
||||
$accessKey = $data['access_key'] ?? null;
|
||||
|
||||
if (!$bucketName || !$objectKey || !$accessKey) {
|
||||
return new JsonResponse(['error' => 'Missing required fields'], 400);
|
||||
}
|
||||
|
||||
$credential = $this->s3Service->findCredentialByAccessKey($accessKey);
|
||||
if (!$credential) {
|
||||
return new JsonResponse(['error' => 'Invalid access key'], 404);
|
||||
}
|
||||
|
||||
$url = $this->s3Service->generatePresignedGetUrl($bucketName, $objectKey, $credential, $expiresIn);
|
||||
|
||||
return new JsonResponse(['url' => $url], 201);
|
||||
}
|
||||
|
||||
return new JsonResponse(['error' => 'Method not allowed'], 405);
|
||||
}
|
||||
|
||||
// Statistics
|
||||
public function stats(Request $request): JsonResponse
|
||||
{
|
||||
$credentialCount = $this->entityManager->getRepository(S3Credential::class)->count([]);
|
||||
$bucketCount = $this->entityManager->getRepository(S3Bucket::class)->count([]);
|
||||
$objectCount = $this->entityManager->getRepository(S3Object::class)->count([]);
|
||||
|
||||
$totalSize = $this->entityManager->createQueryBuilder()
|
||||
->select('SUM(o.size)')
|
||||
->from(S3Object::class, 'o')
|
||||
->getQuery()
|
||||
->getSingleScalarResult() ?: 0;
|
||||
|
||||
$multipartUploads = $this->entityManager->getRepository(\App\Entity\S3MultipartUpload::class)->count([]);
|
||||
$activePresignedUrls = $this->entityManager->getRepository(\App\Entity\S3PresignedUrl::class)
|
||||
->createQueryBuilder('p')
|
||||
->select('COUNT(p.id)')
|
||||
->where('p.expiresAt > :now')
|
||||
->setParameter('now', new \DateTime())
|
||||
->getQuery()
|
||||
->getSingleScalarResult();
|
||||
|
||||
return new JsonResponse([
|
||||
'credentials' => $credentialCount,
|
||||
'buckets' => $bucketCount,
|
||||
'objects' => $objectCount,
|
||||
'total_storage' => $totalSize,
|
||||
'total_storage_human' => $this->formatBytes($totalSize),
|
||||
'active_multipart_uploads' => $multipartUploads,
|
||||
'active_presigned_urls' => $activePresignedUrls
|
||||
]);
|
||||
}
|
||||
|
||||
private function formatBytes(int $bytes): string
|
||||
{
|
||||
if ($bytes === 0) return '0 B';
|
||||
|
||||
$units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
$i = floor(log($bytes, 1024));
|
||||
|
||||
return round($bytes / pow(1024, $i), 2) . ' ' . $units[$i];
|
||||
}
|
||||
}
|
||||
14
src/Controller/ConsoleController.php
Normal file
14
src/Controller/ConsoleController.php
Normal file
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
|
||||
class ConsoleController extends AbstractController
|
||||
{
|
||||
public function index(string $route = ''): Response
|
||||
{
|
||||
return $this->render('console/index.html.twig');
|
||||
}
|
||||
}
|
||||
16
src/Controller/DocsController.php
Normal file
16
src/Controller/DocsController.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
namespace App\Controller;
|
||||
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
|
||||
class DocsController extends AbstractController
|
||||
{
|
||||
#[Route('/docs', name: 'docs_show', priority: 0)]
|
||||
public function show(): Response
|
||||
{
|
||||
// the template path is the relative file path from `templates/`
|
||||
return $this->render('openapi/html/index.html', [ ]);
|
||||
}
|
||||
}
|
||||
348
src/Controller/S3ApiController.php
Normal file
348
src/Controller/S3ApiController.php
Normal file
@@ -0,0 +1,348 @@
|
||||
<?php
|
||||
|
||||
namespace App\Controller;
|
||||
|
||||
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
use Symfony\Component\Routing\Annotation\Route;
|
||||
|
||||
class S3ApiController extends AbstractController
|
||||
{
|
||||
// Bucket Operations
|
||||
|
||||
public function listBuckets(Request $request): Response
|
||||
{
|
||||
// TODO: Implement bucket listing logic
|
||||
$buckets = []; // Get from your datastore
|
||||
|
||||
$xml = $this->generateListBucketsXml($buckets);
|
||||
|
||||
return new Response($xml, 200, [
|
||||
'Content-Type' => 'application/xml',
|
||||
'x-amz-request-id' => uniqid(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function createBucket(string $bucket, Request $request): Response
|
||||
{
|
||||
// TODO: Validate bucket name
|
||||
// TODO: Check if bucket already exists
|
||||
// TODO: Create bucket in datastore
|
||||
|
||||
return new Response('', 200, [
|
||||
'Location' => "/$bucket",
|
||||
'x-amz-request-id' => uniqid(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function deleteBucket(string $bucket, Request $request): Response
|
||||
{
|
||||
// TODO: Check if bucket exists
|
||||
// TODO: Check if bucket is empty
|
||||
// TODO: Delete bucket from datastore
|
||||
|
||||
return new Response('', 204, [
|
||||
'x-amz-request-id' => uniqid(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function headBucket(string $bucket, Request $request): Response
|
||||
{
|
||||
// TODO: Check if bucket exists
|
||||
|
||||
return new Response('', 200, [
|
||||
'x-amz-request-id' => uniqid(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function listObjects(string $bucket, Request $request): Response
|
||||
{
|
||||
$prefix = $request->query->get('prefix', '');
|
||||
$marker = $request->query->get('marker', '');
|
||||
$maxKeys = (int) $request->query->get('max-keys', 1000);
|
||||
$delimiter = $request->query->get('delimiter');
|
||||
|
||||
// TODO: Get objects from datastore based on parameters
|
||||
$objects = [];
|
||||
|
||||
$xml = $this->generateListObjectsXml($bucket, $objects, $prefix, $marker, $maxKeys, $delimiter);
|
||||
|
||||
return new Response($xml, 200, [
|
||||
'Content-Type' => 'application/xml',
|
||||
'x-amz-request-id' => uniqid(),
|
||||
]);
|
||||
}
|
||||
|
||||
// Object Operations
|
||||
|
||||
public function putObject(string $bucket, string $key, Request $request): Response
|
||||
{
|
||||
// TODO: Validate bucket exists
|
||||
// TODO: Get request body/stream
|
||||
// TODO: Store object in datastore
|
||||
|
||||
$etag = md5($request->getContent()); // Simplified - use actual content hash
|
||||
|
||||
return new Response('', 200, [
|
||||
'ETag' => "\"$etag\"",
|
||||
'x-amz-request-id' => uniqid(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function getObject(string $bucket, string $key, Request $request): Response
|
||||
{
|
||||
// TODO: Check if object exists
|
||||
// TODO: Handle Range requests
|
||||
// TODO: Get object from datastore
|
||||
|
||||
// For now, return a streamed response
|
||||
$response = new StreamedResponse();
|
||||
$response->setCallback(function () use ($bucket, $key) {
|
||||
// TODO: Stream object content from datastore
|
||||
echo "Object content for $bucket/$key";
|
||||
});
|
||||
|
||||
$response->headers->set('Content-Type', 'application/octet-stream');
|
||||
$response->headers->set('ETag', '"' . md5($bucket . $key) . '"');
|
||||
$response->headers->set('x-amz-request-id', uniqid());
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
public function headObject(string $bucket, string $key, Request $request): Response
|
||||
{
|
||||
// TODO: Check if object exists
|
||||
// TODO: Get object metadata from datastore
|
||||
|
||||
return new Response('', 200, [
|
||||
'Content-Type' => 'application/octet-stream',
|
||||
'Content-Length' => '1024', // TODO: Get actual size
|
||||
'ETag' => '"' . md5($bucket . $key) . '"',
|
||||
'Last-Modified' => gmdate('D, d M Y H:i:s T'),
|
||||
'x-amz-request-id' => uniqid(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function deleteObject(string $bucket, string $key, Request $request): Response
|
||||
{
|
||||
// TODO: Delete object from datastore
|
||||
|
||||
return new Response('', 204, [
|
||||
'x-amz-request-id' => uniqid(),
|
||||
]);
|
||||
}
|
||||
|
||||
// Multipart Upload Operations
|
||||
|
||||
public function initiateMultipartUpload(string $bucket, string $key, Request $request): Response
|
||||
{
|
||||
// TODO: Create multipart upload record in datastore
|
||||
$uploadId = uniqid('upload_');
|
||||
|
||||
$xml = $this->generateInitiateMultipartXml($bucket, $key, $uploadId);
|
||||
|
||||
return new Response($xml, 200, [
|
||||
'Content-Type' => 'application/xml',
|
||||
'x-amz-request-id' => uniqid(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function uploadPart(string $bucket, string $key, Request $request): Response
|
||||
{
|
||||
$partNumber = $request->query->get('partNumber');
|
||||
$uploadId = $request->query->get('uploadId');
|
||||
|
||||
// TODO: Validate multipart upload exists
|
||||
// TODO: Store part in datastore
|
||||
|
||||
$etag = md5($request->getContent());
|
||||
|
||||
return new Response('', 200, [
|
||||
'ETag' => "\"$etag\"",
|
||||
'x-amz-request-id' => uniqid(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function completeMultipartUpload(string $bucket, string $key, Request $request): Response
|
||||
{
|
||||
$uploadId = $request->query->get('uploadId');
|
||||
|
||||
// TODO: Parse request XML for part list
|
||||
// TODO: Combine parts into final object
|
||||
// TODO: Clean up multipart upload record
|
||||
|
||||
$xml = $this->generateCompleteMultipartXml($bucket, $key);
|
||||
|
||||
return new Response($xml, 200, [
|
||||
'Content-Type' => 'application/xml',
|
||||
'x-amz-request-id' => uniqid(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function abortMultipartUpload(string $bucket, string $key, Request $request): Response
|
||||
{
|
||||
$uploadId = $request->query->get('uploadId');
|
||||
|
||||
// TODO: Clean up multipart upload and parts
|
||||
|
||||
return new Response('', 204, [
|
||||
'x-amz-request-id' => uniqid(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function listParts(string $bucket, string $key, Request $request): Response
|
||||
{
|
||||
$uploadId = $request->query->get('uploadId');
|
||||
|
||||
// TODO: Get parts for this multipart upload
|
||||
$parts = [];
|
||||
|
||||
$xml = $this->generateListPartsXml($bucket, $key, $uploadId, $parts);
|
||||
|
||||
return new Response($xml, 200, [
|
||||
'Content-Type' => 'application/xml',
|
||||
'x-amz-request-id' => uniqid(),
|
||||
]);
|
||||
}
|
||||
|
||||
public function listMultipartUploads(string $bucket, Request $request): Response
|
||||
{
|
||||
// TODO: Get active multipart uploads for bucket
|
||||
$uploads = [];
|
||||
|
||||
$xml = $this->generateListMultipartUploadsXml($bucket, $uploads);
|
||||
|
||||
return new Response($xml, 200, [
|
||||
'Content-Type' => 'application/xml',
|
||||
'x-amz-request-id' => uniqid(),
|
||||
]);
|
||||
}
|
||||
|
||||
// XML Response Generators
|
||||
|
||||
private function generateListBucketsXml(array $buckets): string
|
||||
{
|
||||
$xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
|
||||
$xml .= '<ListAllMyBucketsResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">' . "\n";
|
||||
$xml .= '<Buckets>' . "\n";
|
||||
|
||||
foreach ($buckets as $bucket) {
|
||||
$xml .= '<Bucket>' . "\n";
|
||||
$xml .= '<Name>' . htmlspecialchars($bucket['name']) . '</Name>' . "\n";
|
||||
$xml .= '<CreationDate>' . $bucket['created'] . '</CreationDate>' . "\n";
|
||||
$xml .= '</Bucket>' . "\n";
|
||||
}
|
||||
|
||||
$xml .= '</Buckets>' . "\n";
|
||||
$xml .= '</ListAllMyBucketsResult>' . "\n";
|
||||
|
||||
return $xml;
|
||||
}
|
||||
|
||||
private function generateListObjectsXml(string $bucket, array $objects, string $prefix, string $marker, int $maxKeys, ?string $delimiter): string
|
||||
{
|
||||
$xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
|
||||
$xml .= '<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">' . "\n";
|
||||
$xml .= '<Name>' . htmlspecialchars($bucket) . '</Name>' . "\n";
|
||||
$xml .= '<Prefix>' . htmlspecialchars($prefix) . '</Prefix>' . "\n";
|
||||
$xml .= '<Marker>' . htmlspecialchars($marker) . '</Marker>' . "\n";
|
||||
$xml .= '<MaxKeys>' . $maxKeys . '</MaxKeys>' . "\n";
|
||||
$xml .= '<IsTruncated>false</IsTruncated>' . "\n";
|
||||
|
||||
foreach ($objects as $object) {
|
||||
$xml .= '<Contents>' . "\n";
|
||||
$xml .= '<Key>' . htmlspecialchars($object['key']) . '</Key>' . "\n";
|
||||
$xml .= '<LastModified>' . $object['modified'] . '</LastModified>' . "\n";
|
||||
$xml .= '<ETag>"' . $object['etag'] . '"</ETag>' . "\n";
|
||||
$xml .= '<Size>' . $object['size'] . '</Size>' . "\n";
|
||||
$xml .= '<StorageClass>STANDARD</StorageClass>' . "\n";
|
||||
$xml .= '</Contents>' . "\n";
|
||||
}
|
||||
|
||||
$xml .= '</ListBucketResult>' . "\n";
|
||||
|
||||
return $xml;
|
||||
}
|
||||
|
||||
private function generateInitiateMultipartXml(string $bucket, string $key, string $uploadId): string
|
||||
{
|
||||
$xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
|
||||
$xml .= '<InitiateMultipartUploadResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">' . "\n";
|
||||
$xml .= '<Bucket>' . htmlspecialchars($bucket) . '</Bucket>' . "\n";
|
||||
$xml .= '<Key>' . htmlspecialchars($key) . '</Key>' . "\n";
|
||||
$xml .= '<UploadId>' . htmlspecialchars($uploadId) . '</UploadId>' . "\n";
|
||||
$xml .= '</InitiateMultipartUploadResult>' . "\n";
|
||||
|
||||
return $xml;
|
||||
}
|
||||
|
||||
private function generateCompleteMultipartXml(string $bucket, string $key): string
|
||||
{
|
||||
$etag = md5($bucket . $key . time());
|
||||
|
||||
$xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
|
||||
$xml .= '<CompleteMultipartUploadResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">' . "\n";
|
||||
$xml .= '<Location>/' . htmlspecialchars($bucket) . '/' . htmlspecialchars($key) . '</Location>' . "\n";
|
||||
$xml .= '<Bucket>' . htmlspecialchars($bucket) . '</Bucket>' . "\n";
|
||||
$xml .= '<Key>' . htmlspecialchars($key) . '</Key>' . "\n";
|
||||
$xml .= '<ETag>"' . $etag . '"</ETag>' . "\n";
|
||||
$xml .= '</CompleteMultipartUploadResult>' . "\n";
|
||||
|
||||
return $xml;
|
||||
}
|
||||
|
||||
private function generateListPartsXml(string $bucket, string $key, string $uploadId, array $parts): string
|
||||
{
|
||||
$xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
|
||||
$xml .= '<ListPartsResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">' . "\n";
|
||||
$xml .= '<Bucket>' . htmlspecialchars($bucket) . '</Bucket>' . "\n";
|
||||
$xml .= '<Key>' . htmlspecialchars($key) . '</Key>' . "\n";
|
||||
$xml .= '<UploadId>' . htmlspecialchars($uploadId) . '</UploadId>' . "\n";
|
||||
$xml .= '<StorageClass>STANDARD</StorageClass>' . "\n";
|
||||
$xml .= '<PartNumberMarker>0</PartNumberMarker>' . "\n";
|
||||
$xml .= '<NextPartNumberMarker>0</NextPartNumberMarker>' . "\n";
|
||||
$xml .= '<MaxParts>1000</MaxParts>' . "\n";
|
||||
$xml .= '<IsTruncated>false</IsTruncated>' . "\n";
|
||||
|
||||
foreach ($parts as $part) {
|
||||
$xml .= '<Part>' . "\n";
|
||||
$xml .= '<PartNumber>' . $part['number'] . '</PartNumber>' . "\n";
|
||||
$xml .= '<LastModified>' . $part['modified'] . '</LastModified>' . "\n";
|
||||
$xml .= '<ETag>"' . $part['etag'] . '"</ETag>' . "\n";
|
||||
$xml .= '<Size>' . $part['size'] . '</Size>' . "\n";
|
||||
$xml .= '</Part>' . "\n";
|
||||
}
|
||||
|
||||
$xml .= '</ListPartsResult>' . "\n";
|
||||
|
||||
return $xml;
|
||||
}
|
||||
|
||||
private function generateListMultipartUploadsXml(string $bucket, array $uploads): string
|
||||
{
|
||||
$xml = '<?xml version="1.0" encoding="UTF-8"?>' . "\n";
|
||||
$xml .= '<ListMultipartUploadsResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">' . "\n";
|
||||
$xml .= '<Bucket>' . htmlspecialchars($bucket) . '</Bucket>' . "\n";
|
||||
$xml .= '<KeyMarker></KeyMarker>' . "\n";
|
||||
$xml .= '<UploadIdMarker></UploadIdMarker>' . "\n";
|
||||
$xml .= '<NextKeyMarker></NextKeyMarker>' . "\n";
|
||||
$xml .= '<NextUploadIdMarker></NextUploadIdMarker>' . "\n";
|
||||
$xml .= '<MaxUploads>1000</MaxUploads>' . "\n";
|
||||
$xml .= '<IsTruncated>false</IsTruncated>' . "\n";
|
||||
|
||||
foreach ($uploads as $upload) {
|
||||
$xml .= '<Upload>' . "\n";
|
||||
$xml .= '<Key>' . htmlspecialchars($upload['key']) . '</Key>' . "\n";
|
||||
$xml .= '<UploadId>' . htmlspecialchars($upload['upload_id']) . '</UploadId>' . "\n";
|
||||
$xml .= '<Initiated>' . $upload['initiated'] . '</Initiated>' . "\n";
|
||||
$xml .= '<StorageClass>STANDARD</StorageClass>' . "\n";
|
||||
$xml .= '</Upload>' . "\n";
|
||||
}
|
||||
|
||||
$xml .= '</ListMultipartUploadsResult>' . "\n";
|
||||
|
||||
return $xml;
|
||||
}
|
||||
}
|
||||
0
src/Entity/.gitignore
vendored
Normal file
0
src/Entity/.gitignore
vendored
Normal file
57
src/Entity/S3Bucket.php
Normal file
57
src/Entity/S3Bucket.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 's3_buckets')]
|
||||
#[ORM\Index(columns: ['name'], name: 'idx_name')]
|
||||
#[ORM\Index(columns: ['owner_id'], name: 'idx_owner')]
|
||||
class S3Bucket
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private int $id;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 63, unique: true)]
|
||||
private string $name;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: S3Credential::class, inversedBy: 'buckets')]
|
||||
#[ORM\JoinColumn(nullable: false)]
|
||||
private S3Credential $owner;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 32)]
|
||||
private string $region = 'us-east-1';
|
||||
|
||||
#[ORM\Column(type: 'datetime')]
|
||||
private \DateTime $createdAt;
|
||||
|
||||
#[ORM\Column(type: 'datetime')]
|
||||
private \DateTime $updatedAt;
|
||||
|
||||
#[ORM\OneToMany(mappedBy: 'bucket', targetEntity: S3Object::class)]
|
||||
private Collection $objects;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->objects = new ArrayCollection();
|
||||
$this->createdAt = new \DateTime();
|
||||
$this->updatedAt = new \DateTime();
|
||||
}
|
||||
|
||||
public function getId(): int { return $this->id; }
|
||||
public function getName(): string { return $this->name; }
|
||||
public function setName(string $name): self { $this->name = $name; return $this; }
|
||||
public function getOwner(): S3Credential { return $this->owner; }
|
||||
public function setOwner(S3Credential $owner): self { $this->owner = $owner; return $this; }
|
||||
public function getRegion(): string { return $this->region; }
|
||||
public function setRegion(string $region): self { $this->region = $region; return $this; }
|
||||
public function getCreatedAt(): \DateTime { return $this->createdAt; }
|
||||
public function getUpdatedAt(): \DateTime { return $this->updatedAt; }
|
||||
public function setUpdatedAt(\DateTime $updatedAt): self { $this->updatedAt = $updatedAt; return $this; }
|
||||
public function getObjects(): Collection { return $this->objects; }
|
||||
}
|
||||
61
src/Entity/S3Credential.php
Normal file
61
src/Entity/S3Credential.php
Normal file
@@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 's3_credentials')]
|
||||
#[ORM\Index(columns: ['access_key'], name: 'idx_access_key')]
|
||||
#[ORM\Index(columns: ['is_active'], name: 'idx_active')]
|
||||
class S3Credential
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private int $id;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 32, unique: true)]
|
||||
private string $accessKey;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 64)]
|
||||
private string $secretKey;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255, nullable: true)]
|
||||
private ?string $userName = null;
|
||||
|
||||
#[ORM\Column(type: 'boolean')]
|
||||
private bool $isActive = true;
|
||||
|
||||
#[ORM\Column(type: 'datetime')]
|
||||
private \DateTime $createdAt;
|
||||
|
||||
#[ORM\Column(type: 'datetime')]
|
||||
private \DateTime $updatedAt;
|
||||
|
||||
#[ORM\OneToMany(mappedBy: 'owner', targetEntity: S3Bucket::class)]
|
||||
private Collection $buckets;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->buckets = new ArrayCollection();
|
||||
$this->createdAt = new \DateTime();
|
||||
$this->updatedAt = new \DateTime();
|
||||
}
|
||||
|
||||
public function getId(): int { return $this->id; }
|
||||
public function getAccessKey(): string { return $this->accessKey; }
|
||||
public function setAccessKey(string $accessKey): self { $this->accessKey = $accessKey; return $this; }
|
||||
public function getSecretKey(): string { return $this->secretKey; }
|
||||
public function setSecretKey(string $secretKey): self { $this->secretKey = $secretKey; return $this; }
|
||||
public function getUserName(): ?string { return $this->userName; }
|
||||
public function setUserName(?string $userName): self { $this->userName = $userName; return $this; }
|
||||
public function isActive(): bool { return $this->isActive; }
|
||||
public function setIsActive(bool $isActive): self { $this->isActive = $isActive; return $this; }
|
||||
public function getCreatedAt(): \DateTime { return $this->createdAt; }
|
||||
public function getUpdatedAt(): \DateTime { return $this->updatedAt; }
|
||||
public function setUpdatedAt(\DateTime $updatedAt): self { $this->updatedAt = $updatedAt; return $this; }
|
||||
public function getBuckets(): Collection { return $this->buckets; }
|
||||
}
|
||||
57
src/Entity/S3MultipartPart.php
Normal file
57
src/Entity/S3MultipartPart.php
Normal file
@@ -0,0 +1,57 @@
|
||||
<?php
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 's3_multipart_parts')]
|
||||
#[ORM\UniqueConstraint(name: 'uk_upload_part', columns: ['upload_id', 'part_number'])]
|
||||
#[ORM\Index(columns: ['upload_id'], name: 'idx_upload_id')]
|
||||
#[ORM\Index(columns: ['part_number'], name: 'idx_part_number')]
|
||||
class S3MultipartPart
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'bigint')]
|
||||
private int $id;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: S3MultipartUpload::class, inversedBy: 'parts')]
|
||||
#[ORM\JoinColumn(referencedColumnName: 'upload_id')]
|
||||
private S3MultipartUpload $upload;
|
||||
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private int $partNumber;
|
||||
|
||||
#[ORM\Column(type: 'bigint')]
|
||||
private int $size;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 64)]
|
||||
private string $etag;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 2048)]
|
||||
private string $storagePath;
|
||||
|
||||
#[ORM\Column(type: 'datetime')]
|
||||
private \DateTime $uploadedAt;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->uploadedAt = new \DateTime();
|
||||
}
|
||||
|
||||
public function getId(): int { return $this->id; }
|
||||
public function getUpload(): S3MultipartUpload { return $this->upload; }
|
||||
public function setUpload(S3MultipartUpload $upload): self { $this->upload = $upload; return $this; }
|
||||
public function getPartNumber(): int { return $this->partNumber; }
|
||||
public function setPartNumber(int $partNumber): self { $this->partNumber = $partNumber; return $this; }
|
||||
public function getSize(): int { return $this->size; }
|
||||
public function setSize(int $size): self { $this->size = $size; return $this; }
|
||||
public function getEtag(): string { return $this->etag; }
|
||||
public function setEtag(string $etag): self { $this->etag = $etag; return $this; }
|
||||
public function getStoragePath(): string { return $this->storagePath; }
|
||||
public function setStoragePath(string $storagePath): self { $this->storagePath = $storagePath; return $this; }
|
||||
public function getUploadedAt(): \DateTime { return $this->uploadedAt; }
|
||||
}
|
||||
79
src/Entity/S3MultipartUpload.php
Normal file
79
src/Entity/S3MultipartUpload.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 's3_multipart_uploads')]
|
||||
#[ORM\Index(columns: ['upload_id'], name: 'idx_upload_id')]
|
||||
#[ORM\Index(columns: ['bucket_id', 'object_key'], name: 'idx_bucket_key')]
|
||||
#[ORM\Index(columns: ['expires_at'], name: 'idx_expires')]
|
||||
class S3MultipartUpload
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'bigint')]
|
||||
private int $id;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 64, unique: true)]
|
||||
private string $uploadId;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: S3Bucket::class)]
|
||||
#[ORM\JoinColumn(nullable: false)]
|
||||
private S3Bucket $bucket;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255)]
|
||||
private string $objectKey;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: S3Credential::class)]
|
||||
#[ORM\JoinColumn(nullable: false)]
|
||||
private S3Credential $initiatedBy;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 2048)]
|
||||
private string $storagePathPrefix;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255)]
|
||||
private string $contentType = 'application/octet-stream';
|
||||
|
||||
#[ORM\Column(type: 'json', nullable: true)]
|
||||
private ?array $metadata = null;
|
||||
|
||||
#[ORM\Column(type: 'datetime')]
|
||||
private \DateTime $initiatedAt;
|
||||
|
||||
#[ORM\Column(type: 'datetime', nullable: true)]
|
||||
private ?\DateTime $expiresAt = null;
|
||||
|
||||
#[ORM\OneToMany(mappedBy: 'upload', targetEntity: S3MultipartPart::class)]
|
||||
private Collection $parts;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->parts = new ArrayCollection();
|
||||
$this->initiatedAt = new \DateTime();
|
||||
$this->uploadId = uniqid('upload_') . '_' . bin2hex(random_bytes(8));
|
||||
}
|
||||
|
||||
public function getId(): int { return $this->id; }
|
||||
public function getUploadId(): string { return $this->uploadId; }
|
||||
public function setUploadId(string $uploadId): self { $this->uploadId = $uploadId; return $this; }
|
||||
public function getBucket(): S3Bucket { return $this->bucket; }
|
||||
public function setBucket(S3Bucket $bucket): self { $this->bucket = $bucket; return $this; }
|
||||
public function getObjectKey(): string { return $this->objectKey; }
|
||||
public function setObjectKey(string $objectKey): self { $this->objectKey = $objectKey; return $this; }
|
||||
public function getInitiatedBy(): S3Credential { return $this->initiatedBy; }
|
||||
public function setInitiatedBy(S3Credential $initiatedBy): self { $this->initiatedBy = $initiatedBy; return $this; }
|
||||
public function getStoragePathPrefix(): string { return $this->storagePathPrefix; }
|
||||
public function setStoragePathPrefix(string $storagePathPrefix): self { $this->storagePathPrefix = $storagePathPrefix; return $this; }
|
||||
public function getContentType(): string { return $this->contentType; }
|
||||
public function setContentType(string $contentType): self { $this->contentType = $contentType; return $this; }
|
||||
public function getMetadata(): ?array { return $this->metadata; }
|
||||
public function setMetadata(?array $metadata): self { $this->metadata = $metadata; return $this; }
|
||||
public function getInitiatedAt(): \DateTime { return $this->initiatedAt; }
|
||||
public function getExpiresAt(): ?\DateTime { return $this->expiresAt; }
|
||||
public function setExpiresAt(?\DateTime $expiresAt): self { $this->expiresAt = $expiresAt; return $this; }
|
||||
public function getParts(): Collection { return $this->parts; }
|
||||
}
|
||||
85
src/Entity/S3Object.php
Normal file
85
src/Entity/S3Object.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 's3_objects')]
|
||||
#[ORM\UniqueConstraint(name: 'uk_bucket_object', columns: ['bucket_id', 'object_key'])]
|
||||
#[ORM\Index(columns: ['bucket_id', 'object_key'], name: 'idx_bucket_key')]
|
||||
#[ORM\Index(columns: ['etag'], name: 'idx_etag')]
|
||||
#[ORM\Index(columns: ['size'], name: 'idx_size')]
|
||||
#[ORM\Index(columns: ['created_at'], name: 'idx_created')]
|
||||
class S3Object
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'bigint')]
|
||||
private int $id;
|
||||
|
||||
#[ORM\ManyToOne(targetEntity: S3Bucket::class, inversedBy: 'objects')]
|
||||
#[ORM\JoinColumn(nullable: false)]
|
||||
private S3Bucket $bucket;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255)]
|
||||
private string $objectKey;
|
||||
|
||||
#[ORM\Column(type: 'bigint')]
|
||||
private int $size = 0;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 64)]
|
||||
private string $etag;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255)]
|
||||
private string $contentType = 'application/octet-stream';
|
||||
|
||||
#[ORM\Column(type: 'string', length: 2048)]
|
||||
private string $storagePath;
|
||||
|
||||
#[ORM\Column(type: 'json', nullable: true)]
|
||||
private ?array $metadata = null;
|
||||
|
||||
#[ORM\Column(type: 'boolean')]
|
||||
private bool $isMultipart = false;
|
||||
|
||||
#[ORM\Column(type: 'integer')]
|
||||
private int $partCount = 0;
|
||||
|
||||
#[ORM\Column(type: 'datetime')]
|
||||
private \DateTime $createdAt;
|
||||
|
||||
#[ORM\Column(type: 'datetime')]
|
||||
private \DateTime $updatedAt;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->createdAt = new \DateTime();
|
||||
$this->updatedAt = new \DateTime();
|
||||
}
|
||||
|
||||
public function getId(): int { return $this->id; }
|
||||
public function getBucket(): S3Bucket { return $this->bucket; }
|
||||
public function setBucket(S3Bucket $bucket): self { $this->bucket = $bucket; return $this; }
|
||||
public function getObjectKey(): string { return $this->objectKey; }
|
||||
public function setObjectKey(string $objectKey): self { $this->objectKey = $objectKey; return $this; }
|
||||
public function getSize(): int { return $this->size; }
|
||||
public function setSize(int $size): self { $this->size = $size; return $this; }
|
||||
public function getEtag(): string { return $this->etag; }
|
||||
public function setEtag(string $etag): self { $this->etag = $etag; return $this; }
|
||||
public function getContentType(): string { return $this->contentType; }
|
||||
public function setContentType(string $contentType): self { $this->contentType = $contentType; return $this; }
|
||||
public function getStoragePath(): string { return $this->storagePath; }
|
||||
public function setStoragePath(string $storagePath): self { $this->storagePath = $storagePath; return $this; }
|
||||
public function getMetadata(): ?array { return $this->metadata; }
|
||||
public function setMetadata(?array $metadata): self { $this->metadata = $metadata; return $this; }
|
||||
public function isMultipart(): bool { return $this->isMultipart; }
|
||||
public function setIsMultipart(bool $isMultipart): self { $this->isMultipart = $isMultipart; return $this; }
|
||||
public function getPartCount(): int { return $this->partCount; }
|
||||
public function setPartCount(int $partCount): self { $this->partCount = $partCount; return $this; }
|
||||
public function getCreatedAt(): \DateTime { return $this->createdAt; }
|
||||
public function getUpdatedAt(): \DateTime { return $this->updatedAt; }
|
||||
public function setUpdatedAt(\DateTime $updatedAt): self { $this->updatedAt = $updatedAt; return $this; }
|
||||
}
|
||||
67
src/Entity/S3PresignedUrl.php
Normal file
67
src/Entity/S3PresignedUrl.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace App\Entity;
|
||||
|
||||
use Doctrine\ORM\Mapping as ORM;
|
||||
use Doctrine\Common\Collections\ArrayCollection;
|
||||
use Doctrine\Common\Collections\Collection;
|
||||
|
||||
#[ORM\Entity]
|
||||
#[ORM\Table(name: 's3_presigned_urls')]
|
||||
#[ORM\Index(columns: ['url_hash'], name: 'idx_url_hash')]
|
||||
#[ORM\Index(columns: ['expires_at'], name: 'idx_expires')]
|
||||
#[ORM\Index(columns: ['bucket_name', 'object_key'], name: 'idx_bucket_key')]
|
||||
#[ORM\Index(columns: ['access_key'], name: 'idx_access_key')]
|
||||
class S3PresignedUrl
|
||||
{
|
||||
#[ORM\Id]
|
||||
#[ORM\GeneratedValue]
|
||||
#[ORM\Column(type: 'bigint')]
|
||||
private int $id;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 64, unique: true)]
|
||||
private string $urlHash;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 63)]
|
||||
private string $bucketName;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 255)]
|
||||
private string $objectKey;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 10)]
|
||||
private string $method;
|
||||
|
||||
#[ORM\Column(type: 'datetime')]
|
||||
private \DateTime $expiresAt;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 32)]
|
||||
private string $accessKey;
|
||||
|
||||
#[ORM\Column(type: 'datetime')]
|
||||
private \DateTime $createdAt;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->createdAt = new \DateTime();
|
||||
}
|
||||
|
||||
public function getId(): int { return $this->id; }
|
||||
public function getUrlHash(): string { return $this->urlHash; }
|
||||
public function setUrlHash(string $urlHash): self { $this->urlHash = $urlHash; return $this; }
|
||||
public function getBucketName(): string { return $this->bucketName; }
|
||||
public function setBucketName(string $bucketName): self { $this->bucketName = $bucketName; return $this; }
|
||||
public function getObjectKey(): string { return $this->objectKey; }
|
||||
public function setObjectKey(string $objectKey): self { $this->objectKey = $objectKey; return $this; }
|
||||
public function getMethod(): string { return $this->method; }
|
||||
public function setMethod(string $method): self { $this->method = $method; return $this; }
|
||||
public function getExpiresAt(): \DateTime { return $this->expiresAt; }
|
||||
public function setExpiresAt(\DateTime $expiresAt): self { $this->expiresAt = $expiresAt; return $this; }
|
||||
public function getAccessKey(): string { return $this->accessKey; }
|
||||
public function setAccessKey(string $accessKey): self { $this->accessKey = $accessKey; return $this; }
|
||||
public function getCreatedAt(): \DateTime { return $this->createdAt; }
|
||||
|
||||
public function isExpired(): bool
|
||||
{
|
||||
return $this->expiresAt < new \DateTime();
|
||||
}
|
||||
}
|
||||
11
src/Kernel.php
Normal file
11
src/Kernel.php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
namespace App;
|
||||
|
||||
use Symfony\Bundle\FrameworkBundle\Kernel\MicroKernelTrait;
|
||||
use Symfony\Component\HttpKernel\Kernel as BaseKernel;
|
||||
|
||||
class Kernel extends BaseKernel
|
||||
{
|
||||
use MicroKernelTrait;
|
||||
}
|
||||
0
src/Repository/.gitignore
vendored
Normal file
0
src/Repository/.gitignore
vendored
Normal file
430
src/Service/S3Service.php
Normal file
430
src/Service/S3Service.php
Normal file
@@ -0,0 +1,430 @@
|
||||
<?php
|
||||
|
||||
// src/Service/S3Service.php
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\S3Bucket;
|
||||
use App\Entity\S3Credential;
|
||||
use App\Entity\S3Object;
|
||||
use App\Entity\S3MultipartUpload;
|
||||
use App\Entity\S3MultipartPart;
|
||||
use App\Entity\S3PresignedUrl;
|
||||
use Doctrine\ORM\EntityManagerInterface;
|
||||
use Symfony\Component\HttpFoundation\File\UploadedFile;
|
||||
|
||||
class S3Service
|
||||
{
|
||||
public function __construct(
|
||||
private EntityManagerInterface $entityManager,
|
||||
private string $storageBasePath = '/var/s3storage'
|
||||
) {}
|
||||
|
||||
// Credential Management
|
||||
public function findCredentialByAccessKey(string $accessKey): ?S3Credential
|
||||
{
|
||||
return $this->entityManager->getRepository(S3Credential::class)
|
||||
->findOneBy(['accessKey' => $accessKey, 'isActive' => true]);
|
||||
}
|
||||
|
||||
public function createCredential(string $accessKey, string $secretKey, ?string $userName = null): S3Credential
|
||||
{
|
||||
$credential = new S3Credential();
|
||||
$credential->setAccessKey($accessKey)
|
||||
->setSecretKey($secretKey)
|
||||
->setUserName($userName);
|
||||
|
||||
$this->entityManager->persist($credential);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $credential;
|
||||
}
|
||||
|
||||
// Bucket Management
|
||||
public function findBucketByName(string $name): ?S3Bucket
|
||||
{
|
||||
return $this->entityManager->getRepository(S3Bucket::class)
|
||||
->findOneBy(['name' => $name]);
|
||||
}
|
||||
|
||||
public function createBucket(string $name, S3Credential $owner, string $region = 'us-east-1'): S3Bucket
|
||||
{
|
||||
$bucket = new S3Bucket();
|
||||
$bucket->setName($name)
|
||||
->setOwner($owner)
|
||||
->setRegion($region);
|
||||
|
||||
$this->entityManager->persist($bucket);
|
||||
$this->entityManager->flush();
|
||||
|
||||
// Create bucket directory
|
||||
$bucketPath = $this->storageBasePath . '/' . $name;
|
||||
if (!is_dir($bucketPath)) {
|
||||
mkdir($bucketPath, 0755, true);
|
||||
}
|
||||
|
||||
return $bucket;
|
||||
}
|
||||
|
||||
public function deleteBucket(S3Bucket $bucket): bool
|
||||
{
|
||||
// Check if bucket is empty
|
||||
$objectCount = $this->entityManager->getRepository(S3Object::class)
|
||||
->count(['bucket' => $bucket]);
|
||||
|
||||
if ($objectCount > 0) {
|
||||
throw new \Exception('Bucket not empty');
|
||||
}
|
||||
|
||||
$this->entityManager->remove($bucket);
|
||||
$this->entityManager->flush();
|
||||
|
||||
// Remove bucket directory if empty
|
||||
$bucketPath = $this->storageBasePath . '/' . $bucket->getName();
|
||||
if (is_dir($bucketPath) && count(scandir($bucketPath)) === 2) { // Only . and ..
|
||||
rmdir($bucketPath);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function listBuckets(S3Credential $owner): array
|
||||
{
|
||||
return $this->entityManager->getRepository(S3Bucket::class)
|
||||
->findBy(['owner' => $owner], ['createdAt' => 'ASC']);
|
||||
}
|
||||
|
||||
// Object Management
|
||||
public function findObject(S3Bucket $bucket, string $objectKey): ?S3Object
|
||||
{
|
||||
return $this->entityManager->getRepository(S3Object::class)
|
||||
->findOneBy(['bucket' => $bucket, 'objectKey' => $objectKey]);
|
||||
}
|
||||
|
||||
public function putObject(S3Bucket $bucket, string $objectKey, string $content, string $contentType = 'application/octet-stream', array $metadata = []): S3Object
|
||||
{
|
||||
// Generate storage path
|
||||
$storagePath = $this->generateStoragePath($bucket->getName(), $objectKey);
|
||||
$fullPath = $this->storageBasePath . '/' . $storagePath;
|
||||
|
||||
// Ensure directory exists
|
||||
$dir = dirname($fullPath);
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0755, true);
|
||||
}
|
||||
|
||||
// Write file
|
||||
file_put_contents($fullPath, $content);
|
||||
|
||||
// Calculate ETag
|
||||
$etag = md5($content);
|
||||
$size = strlen($content);
|
||||
|
||||
// Create or update object record
|
||||
$object = $this->findObject($bucket, $objectKey);
|
||||
if (!$object) {
|
||||
$object = new S3Object();
|
||||
$object->setBucket($bucket)->setObjectKey($objectKey);
|
||||
}
|
||||
|
||||
$object->setSize($size)
|
||||
->setEtag($etag)
|
||||
->setContentType($contentType)
|
||||
->setStoragePath($storagePath)
|
||||
->setMetadata($metadata)
|
||||
->setUpdatedAt(new \DateTime());
|
||||
|
||||
$this->entityManager->persist($object);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $object;
|
||||
}
|
||||
|
||||
public function getObjectContent(S3Object $object): string
|
||||
{
|
||||
$fullPath = $this->storageBasePath . '/' . $object->getStoragePath();
|
||||
|
||||
if (!file_exists($fullPath)) {
|
||||
throw new \Exception('Object file not found');
|
||||
}
|
||||
|
||||
return file_get_contents($fullPath);
|
||||
}
|
||||
|
||||
public function deleteObject(S3Object $object): bool
|
||||
{
|
||||
$fullPath = $this->storageBasePath . '/' . $object->getStoragePath();
|
||||
|
||||
// Delete file
|
||||
if (file_exists($fullPath)) {
|
||||
unlink($fullPath);
|
||||
}
|
||||
|
||||
// Remove database record
|
||||
$this->entityManager->remove($object);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function listObjects(S3Bucket $bucket, string $prefix = '', string $marker = '', int $maxKeys = 1000, ?string $delimiter = null): array
|
||||
{
|
||||
$qb = $this->entityManager->createQueryBuilder();
|
||||
$qb->select('o')
|
||||
->from(S3Object::class, 'o')
|
||||
->where('o.bucket = :bucket')
|
||||
->setParameter('bucket', $bucket)
|
||||
->orderBy('o.objectKey', 'ASC')
|
||||
->setMaxResults($maxKeys);
|
||||
|
||||
if ($prefix) {
|
||||
$qb->andWhere('o.objectKey LIKE :prefix')
|
||||
->setParameter('prefix', $prefix . '%');
|
||||
}
|
||||
|
||||
if ($marker) {
|
||||
$qb->andWhere('o.objectKey > :marker')
|
||||
->setParameter('marker', $marker);
|
||||
}
|
||||
|
||||
return $qb->getQuery()->getResult();
|
||||
}
|
||||
|
||||
// Multipart Upload Management
|
||||
public function initiateMultipartUpload(S3Bucket $bucket, string $objectKey, S3Credential $initiatedBy, string $contentType = 'application/octet-stream', array $metadata = []): S3MultipartUpload
|
||||
{
|
||||
$upload = new S3MultipartUpload();
|
||||
$upload->setBucket($bucket)
|
||||
->setObjectKey($objectKey)
|
||||
->setInitiatedBy($initiatedBy)
|
||||
->setContentType($contentType)
|
||||
->setMetadata($metadata)
|
||||
->setStoragePathPrefix($this->generateMultipartPrefix($bucket->getName(), $objectKey, $upload->getUploadId()));
|
||||
|
||||
$this->entityManager->persist($upload);
|
||||
$this->entityManager->flush();
|
||||
|
||||
// Create multipart directory
|
||||
$multipartDir = $this->storageBasePath . '/' . $upload->getStoragePathPrefix();
|
||||
if (!is_dir($multipartDir)) {
|
||||
mkdir($multipartDir, 0755, true);
|
||||
}
|
||||
|
||||
return $upload;
|
||||
}
|
||||
|
||||
public function findMultipartUpload(string $uploadId): ?S3MultipartUpload
|
||||
{
|
||||
return $this->entityManager->getRepository(S3MultipartUpload::class)
|
||||
->findOneBy(['uploadId' => $uploadId]);
|
||||
}
|
||||
|
||||
public function uploadPart(S3MultipartUpload $upload, int $partNumber, string $content): S3MultipartPart
|
||||
{
|
||||
// Generate part storage path
|
||||
$partPath = $upload->getStoragePathPrefix() . '/part_' . str_pad($partNumber, 5, '0', STR_PAD_LEFT);
|
||||
$fullPath = $this->storageBasePath . '/' . $partPath;
|
||||
|
||||
// Write part file
|
||||
file_put_contents($fullPath, $content);
|
||||
|
||||
// Calculate part ETag
|
||||
$etag = md5($content);
|
||||
$size = strlen($content);
|
||||
|
||||
// Create or update part record
|
||||
$part = $this->entityManager->getRepository(S3MultipartPart::class)
|
||||
->findOneBy(['upload' => $upload, 'partNumber' => $partNumber]);
|
||||
|
||||
if (!$part) {
|
||||
$part = new S3MultipartPart();
|
||||
$part->setUpload($upload)->setPartNumber($partNumber);
|
||||
}
|
||||
|
||||
$part->setSize($size)
|
||||
->setEtag($etag)
|
||||
->setStoragePath($partPath);
|
||||
|
||||
$this->entityManager->persist($part);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $part;
|
||||
}
|
||||
|
||||
public function completeMultipartUpload(S3MultipartUpload $upload, array $parts): S3Object
|
||||
{
|
||||
// Validate all parts exist
|
||||
$existingParts = $this->entityManager->getRepository(S3MultipartPart::class)
|
||||
->findBy(['upload' => $upload], ['partNumber' => 'ASC']);
|
||||
|
||||
if (count($existingParts) !== count($parts)) {
|
||||
throw new \Exception('Missing parts for multipart upload');
|
||||
}
|
||||
|
||||
// Combine parts into final object
|
||||
$finalPath = $this->generateStoragePath($upload->getBucket()->getName(), $upload->getObjectKey());
|
||||
$fullFinalPath = $this->storageBasePath . '/' . $finalPath;
|
||||
|
||||
// Ensure directory exists
|
||||
$dir = dirname($fullFinalPath);
|
||||
if (!is_dir($dir)) {
|
||||
mkdir($dir, 0755, true);
|
||||
}
|
||||
|
||||
// Combine parts
|
||||
$finalFile = fopen($fullFinalPath, 'wb');
|
||||
$totalSize = 0;
|
||||
$combinedEtag = '';
|
||||
|
||||
foreach ($existingParts as $part) {
|
||||
$partPath = $this->storageBasePath . '/' . $part->getStoragePath();
|
||||
$partContent = file_get_contents($partPath);
|
||||
fwrite($finalFile, $partContent);
|
||||
$totalSize += $part->getSize();
|
||||
$combinedEtag .= $part->getEtag();
|
||||
}
|
||||
fclose($finalFile);
|
||||
|
||||
// Calculate final ETag (MD5 of concatenated part ETags + part count)
|
||||
$finalEtag = md5($combinedEtag) . '-' . count($existingParts);
|
||||
|
||||
// Create final object record
|
||||
$object = new S3Object();
|
||||
$object->setBucket($upload->getBucket())
|
||||
->setObjectKey($upload->getObjectKey())
|
||||
->setSize($totalSize)
|
||||
->setEtag($finalEtag)
|
||||
->setContentType($upload->getContentType())
|
||||
->setStoragePath($finalPath)
|
||||
->setMetadata($upload->getMetadata())
|
||||
->setIsMultipart(true)
|
||||
->setPartCount(count($existingParts));
|
||||
|
||||
$this->entityManager->persist($object);
|
||||
|
||||
// Clean up multipart upload and parts
|
||||
$this->abortMultipartUpload($upload);
|
||||
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $object;
|
||||
}
|
||||
|
||||
public function abortMultipartUpload(S3MultipartUpload $upload): bool
|
||||
{
|
||||
// Delete all part files
|
||||
$parts = $this->entityManager->getRepository(S3MultipartPart::class)
|
||||
->findBy(['upload' => $upload]);
|
||||
|
||||
foreach ($parts as $part) {
|
||||
$partPath = $this->storageBasePath . '/' . $part->getStoragePath();
|
||||
if (file_exists($partPath)) {
|
||||
unlink($partPath);
|
||||
}
|
||||
$this->entityManager->remove($part);
|
||||
}
|
||||
|
||||
// Remove multipart directory
|
||||
$multipartDir = $this->storageBasePath . '/' . $upload->getStoragePathPrefix();
|
||||
if (is_dir($multipartDir)) {
|
||||
rmdir($multipartDir);
|
||||
}
|
||||
|
||||
// Remove upload record
|
||||
$this->entityManager->remove($upload);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function listParts(S3MultipartUpload $upload): array
|
||||
{
|
||||
return $this->entityManager->getRepository(S3MultipartPart::class)
|
||||
->findBy(['upload' => $upload], ['partNumber' => 'ASC']);
|
||||
}
|
||||
|
||||
public function listMultipartUploads(S3Bucket $bucket): array
|
||||
{
|
||||
return $this->entityManager->getRepository(S3MultipartUpload::class)
|
||||
->findBy(['bucket' => $bucket], ['initiatedAt' => 'ASC']);
|
||||
}
|
||||
|
||||
// Presigned URL Management
|
||||
public function createPresignedUrl(string $bucketName, string $objectKey, string $method, \DateTime $expiresAt, string $accessKey): S3PresignedUrl
|
||||
{
|
||||
$presignedUrl = new S3PresignedUrl();
|
||||
$presignedUrl->setBucketName($bucketName)
|
||||
->setObjectKey($objectKey)
|
||||
->setMethod($method)
|
||||
->setExpiresAt($expiresAt)
|
||||
->setAccessKey($accessKey)
|
||||
->setUrlHash(hash('sha256', $bucketName . $objectKey . $method . $expiresAt->getTimestamp() . $accessKey));
|
||||
|
||||
$this->entityManager->persist($presignedUrl);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $presignedUrl;
|
||||
}
|
||||
|
||||
public function findPresignedUrl(string $urlHash): ?S3PresignedUrl
|
||||
{
|
||||
return $this->entityManager->getRepository(S3PresignedUrl::class)
|
||||
->findOneBy(['urlHash' => $urlHash]);
|
||||
}
|
||||
|
||||
public function cleanupExpiredPresignedUrls(): int
|
||||
{
|
||||
$qb = $this->entityManager->createQueryBuilder();
|
||||
$qb->delete(S3PresignedUrl::class, 'p')
|
||||
->where('p.expiresAt < :now')
|
||||
->setParameter('now', new \DateTime());
|
||||
|
||||
return $qb->getQuery()->execute();
|
||||
}
|
||||
|
||||
// Helper Methods
|
||||
private function generateStoragePath(string $bucketName, string $objectKey): string
|
||||
{
|
||||
// Create a nested directory structure based on object key hash to avoid too many files in one directory
|
||||
$hash = hash('sha256', $objectKey);
|
||||
$prefix = substr($hash, 0, 2) . '/' . substr($hash, 2, 2);
|
||||
|
||||
return $bucketName . '/objects/' . $prefix . '/' . $hash;
|
||||
}
|
||||
|
||||
private function generateMultipartPrefix(string $bucketName, string $objectKey, string $uploadId): string
|
||||
{
|
||||
$hash = hash('sha256', $objectKey . $uploadId);
|
||||
$prefix = substr($hash, 0, 2) . '/' . substr($hash, 2, 2);
|
||||
|
||||
return $bucketName . '/multipart/' . $prefix . '/' . $uploadId;
|
||||
}
|
||||
|
||||
// AWS Signature V4 helpers
|
||||
public function generatePresignedGetUrl(string $bucketName, string $objectKey, S3Credential $credential, int $expiresIn = 3600): string
|
||||
{
|
||||
$expiresAt = new \DateTime('+' . $expiresIn . ' seconds');
|
||||
|
||||
// Store in database for tracking
|
||||
$presignedUrl = $this->createPresignedUrl($bucketName, $objectKey, 'GET', $expiresAt, $credential->getAccessKey());
|
||||
|
||||
// Generate the actual presigned URL (simplified - you'll need full AWS Signature V4 implementation)
|
||||
$params = [
|
||||
'X-Amz-Algorithm' => 'AWS4-HMAC-SHA256',
|
||||
'X-Amz-Credential' => $credential->getAccessKey() . '/' . date('Ymd') . '/us-east-1/s3/aws4_request',
|
||||
'X-Amz-Date' => gmdate('Ymd\THis\Z'),
|
||||
'X-Amz-Expires' => $expiresIn,
|
||||
'X-Amz-SignedHeaders' => 'host',
|
||||
'X-Amz-Signature' => $this->calculateSignature($bucketName, $objectKey, $params, $credential->getSecretKey()),
|
||||
'hash' => $presignedUrl->getUrlHash()
|
||||
];
|
||||
|
||||
return '/' . $bucketName . '/' . $objectKey . '?' . http_build_query($params);
|
||||
}
|
||||
|
||||
private function calculateSignature(string $bucketName, string $objectKey, array $params, string $secretKey): string
|
||||
{
|
||||
// Simplified signature calculation - implement full AWS Signature V4 here
|
||||
return hash_hmac('sha256', $bucketName . $objectKey . serialize($params), $secretKey);
|
||||
}
|
||||
}
|
||||
120
symfony.lock
Normal file
120
symfony.lock
Normal file
@@ -0,0 +1,120 @@
|
||||
{
|
||||
"doctrine/deprecations": {
|
||||
"version": "1.1",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "1.0",
|
||||
"ref": "87424683adc81d7dc305eefec1fced883084aab9"
|
||||
}
|
||||
},
|
||||
"doctrine/doctrine-bundle": {
|
||||
"version": "2.14",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "2.10",
|
||||
"ref": "d1778a69711a9b06bb4e202977ca6c4a0d16933d"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/doctrine.yaml",
|
||||
"src/Entity/.gitignore",
|
||||
"src/Repository/.gitignore"
|
||||
]
|
||||
},
|
||||
"doctrine/doctrine-migrations-bundle": {
|
||||
"version": "3.4",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "3.1",
|
||||
"ref": "1d01ec03c6ecbd67c3375c5478c9a423ae5d6a33"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/doctrine_migrations.yaml",
|
||||
"migrations/.gitignore"
|
||||
]
|
||||
},
|
||||
"symfony/console": {
|
||||
"version": "6.4",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "5.3",
|
||||
"ref": "1781ff40d8a17d87cf53f8d4cf0c8346ed2bb461"
|
||||
},
|
||||
"files": [
|
||||
"bin/console"
|
||||
]
|
||||
},
|
||||
"symfony/flex": {
|
||||
"version": "2.7",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "2.4",
|
||||
"ref": "52e9754527a15e2b79d9a610f98185a1fe46622a"
|
||||
},
|
||||
"files": [
|
||||
".env",
|
||||
".env.dev"
|
||||
]
|
||||
},
|
||||
"symfony/framework-bundle": {
|
||||
"version": "6.4",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "6.4",
|
||||
"ref": "32126346f25e1cee607cc4aa6783d46034920554"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/cache.yaml",
|
||||
"config/packages/framework.yaml",
|
||||
"config/preload.php",
|
||||
"config/routes/framework.yaml",
|
||||
"config/services.yaml",
|
||||
"public/index.php",
|
||||
"src/Controller/.gitignore",
|
||||
"src/Kernel.php"
|
||||
]
|
||||
},
|
||||
"symfony/maker-bundle": {
|
||||
"version": "1.63",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "1.0",
|
||||
"ref": "fadbfe33303a76e25cb63401050439aa9b1a9c7f"
|
||||
}
|
||||
},
|
||||
"symfony/routing": {
|
||||
"version": "6.4",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "6.2",
|
||||
"ref": "e0a11b4ccb8c9e70b574ff5ad3dfdcd41dec5aa6"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/routing.yaml",
|
||||
"config/routes.yaml"
|
||||
]
|
||||
},
|
||||
"symfony/twig-bundle": {
|
||||
"version": "6.4",
|
||||
"recipe": {
|
||||
"repo": "github.com/symfony/recipes",
|
||||
"branch": "main",
|
||||
"version": "6.4",
|
||||
"ref": "cab5fd2a13a45c266d45a7d9337e28dee6272877"
|
||||
},
|
||||
"files": [
|
||||
"config/packages/twig.yaml",
|
||||
"templates/base.html.twig"
|
||||
]
|
||||
},
|
||||
"twig/extra-bundle": {
|
||||
"version": "v3.21.0"
|
||||
}
|
||||
}
|
||||
16
templates/base.html.twig
Normal file
16
templates/base.html.twig
Normal file
@@ -0,0 +1,16 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>{% block title %}Welcome!{% endblock %}</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 128 128%22><text y=%221.2em%22 font-size=%2296%22>⚫️</text><text y=%221.3em%22 x=%220.2em%22 font-size=%2276%22 fill=%22%23fff%22>sf</text></svg>">
|
||||
{% block stylesheets %}
|
||||
{% endblock %}
|
||||
|
||||
{% block javascripts %}
|
||||
{% endblock %}
|
||||
</head>
|
||||
<body>
|
||||
{% block body %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
864
templates/console/index.html.twig
Normal file
864
templates/console/index.html.twig
Normal file
@@ -0,0 +1,864 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>vStash - Object Storage Management Console</title>
|
||||
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #f8fafc;
|
||||
color: #1a202c;
|
||||
}
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 250px;
|
||||
background: #2d3748;
|
||||
color: white;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.sidebar h1 {
|
||||
padding: 0 1rem 2rem;
|
||||
font-size: 1.5rem;
|
||||
border-bottom: 1px solid #4a5568;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.nav-item {
|
||||
display: block;
|
||||
padding: 0.75rem 1rem;
|
||||
color: #e2e8f0;
|
||||
text-decoration: none;
|
||||
transition: background 0.2s;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.nav-item:hover, .nav-item.active {
|
||||
background: #4a5568;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nav-item i {
|
||||
width: 20px;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
flex: 1;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
transition: all 0.2s;
|
||||
text-decoration: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #3182ce;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: #2c5282;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background: #e53e3e;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger:hover {
|
||||
background: #c53030;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: #718096;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #4a5568;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
font-weight: 600;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.stats-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: white;
|
||||
padding: 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-number {
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
color: #3182ce;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: #718096;
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.table th,
|
||||
.table td {
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.table th {
|
||||
background: #f7fafc;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.table tbody tr:hover {
|
||||
background: #f7fafc;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background: #c6f6d5;
|
||||
color: #22543d;
|
||||
}
|
||||
|
||||
.badge-danger {
|
||||
background: #fed7d7;
|
||||
color: #742a2a;
|
||||
}
|
||||
|
||||
.modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0,0,0,0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: white;
|
||||
border-radius: 0.5rem;
|
||||
padding: 2rem;
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
max-height: 80vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.form-input,
|
||||
.form-select {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #d2d6dc;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.form-input:focus,
|
||||
.form-select:focus {
|
||||
outline: none;
|
||||
border-color: #3182ce;
|
||||
box-shadow: 0 0 0 3px rgba(49, 130, 206, 0.1);
|
||||
}
|
||||
|
||||
.loading {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #718096;
|
||||
}
|
||||
|
||||
.error {
|
||||
background: #fed7d7;
|
||||
color: #742a2a;
|
||||
padding: 1rem;
|
||||
border-radius: 0.375rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.success {
|
||||
background: #c6f6d5;
|
||||
color: #22543d;
|
||||
padding: 1rem;
|
||||
border-radius: 0.375rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
color: #718096;
|
||||
}
|
||||
|
||||
.empty-state i {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid #d2d6dc;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
animation: fadeIn 0.3s ease-in;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; transform: translateY(10px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
[x-cloak] { display: none !important; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div x-data="consoleApp()" x-init="init()" class="container">
|
||||
<!-- Sidebar -->
|
||||
<nav class="sidebar">
|
||||
<h1><i class="fas fa-cloud"></i> vStash</h1>
|
||||
<a @click="setActiveSection('dashboard')" class="nav-item" :class="{ active: activeSection === 'dashboard' }">
|
||||
<i class="fas fa-chart-bar"></i> Dashboard
|
||||
</a>
|
||||
<a @click="setActiveSection('credentials')" class="nav-item" :class="{ active: activeSection === 'credentials' }">
|
||||
<i class="fas fa-key"></i> Credentials
|
||||
</a>
|
||||
<a @click="setActiveSection('buckets')" class="nav-item" :class="{ active: activeSection === 'buckets' }">
|
||||
<i class="fas fa-folder"></i> Buckets
|
||||
</a>
|
||||
<a @click="setActiveSection('multipart')" class="nav-item" :class="{ active: activeSection === 'multipart' }">
|
||||
<i class="fas fa-upload"></i> Multipart Uploads
|
||||
</a>
|
||||
<a @click="setActiveSection('presigned')" class="nav-item" :class="{ active: activeSection === 'presigned' }">
|
||||
<i class="fas fa-link"></i> Presigned URLs
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="main-content">
|
||||
<!-- Dashboard Section -->
|
||||
<div x-show="activeSection === 'dashboard'" x-cloak class="fade-in">
|
||||
<div class="header">
|
||||
<h2><i class="fas fa-chart-bar"></i> Dashboard</h2>
|
||||
<button @click="loadStats()" class="btn btn-secondary">
|
||||
<i class="fas fa-sync-alt"></i> Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div x-show="loading" class="loading">
|
||||
<i class="fas fa-spinner fa-spin fa-2x"></i>
|
||||
<p>Loading statistics...</p>
|
||||
</div>
|
||||
|
||||
<div x-show="!loading && stats" class="stats-grid">
|
||||
<div class="stat-card">
|
||||
<span class="stat-number" x-text="stats.credentials"></span>
|
||||
<div class="stat-label">Credentials</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-number" x-text="stats.buckets"></span>
|
||||
<div class="stat-label">Buckets</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-number" x-text="stats.objects"></span>
|
||||
<div class="stat-label">Objects</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-number" x-text="stats.total_storage_human"></span>
|
||||
<div class="stat-label">Total Storage</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-number" x-text="stats.active_multipart_uploads"></span>
|
||||
<div class="stat-label">Active Uploads</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-number" x-text="stats.active_presigned_urls"></span>
|
||||
<div class="stat-label">Active URLs</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Credentials Section -->
|
||||
<div x-show="activeSection === 'credentials'" x-cloak class="fade-in">
|
||||
<div class="header">
|
||||
<h2><i class="fas fa-key"></i> Access Credentials</h2>
|
||||
<button @click="showCreateCredentialModal = true" class="btn btn-primary">
|
||||
<i class="fas fa-plus"></i> Create Credential
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div x-show="message" x-text="message" :class="messageType === 'error' ? 'error' : 'success'"></div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div x-show="loading" class="loading">Loading credentials...</div>
|
||||
|
||||
<template x-if="!loading && credentials.length === 0">
|
||||
<div class="empty-state">
|
||||
<i class="fas fa-key"></i>
|
||||
<h3>No credentials found</h3>
|
||||
<p>Create your first access credential to get started.</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="!loading && credentials.length > 0">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Access Key</th>
|
||||
<th>User Name</th>
|
||||
<th>Status</th>
|
||||
<th>Buckets</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-for="credential in credentials" :key="credential.id">
|
||||
<tr>
|
||||
<td>
|
||||
<code x-text="credential.access_key"></code>
|
||||
</td>
|
||||
<td x-text="credential.user_name || '-'"></td>
|
||||
<td>
|
||||
<span class="badge" :class="credential.is_active ? 'badge-success' : 'badge-danger'"
|
||||
x-text="credential.is_active ? 'Active' : 'Inactive'"></span>
|
||||
</td>
|
||||
<td x-text="credential.bucket_count"></td>
|
||||
<td x-text="credential.created_at"></td>
|
||||
<td>
|
||||
<div class="actions">
|
||||
<button @click="viewCredential(credential)" class="btn btn-sm btn-secondary">
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
<button @click="deleteCredential(credential.id)" class="btn btn-sm btn-danger">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Buckets Section -->
|
||||
<div x-show="activeSection === 'buckets'" x-cloak class="fade-in">
|
||||
<div class="header">
|
||||
<h2><i class="fas fa-folder"></i> Storage Buckets</h2>
|
||||
<button @click="showCreateBucketModal = true; loadCredentials()" class="btn btn-primary">
|
||||
<i class="fas fa-plus"></i> Create Bucket
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div x-show="message" x-text="message" :class="messageType === 'error' ? 'error' : 'success'"></div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div x-show="loading" class="loading">Loading buckets...</div>
|
||||
|
||||
<template x-if="!loading && buckets.length === 0">
|
||||
<div class="empty-state">
|
||||
<i class="fas fa-folder"></i>
|
||||
<h3>No buckets found</h3>
|
||||
<p>Create your first bucket to start storing objects.</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="!loading && buckets.length > 0">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Region</th>
|
||||
<th>Owner</th>
|
||||
<th>Objects</th>
|
||||
<th>Size</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-for="bucket in buckets" :key="bucket.name">
|
||||
<tr>
|
||||
<td>
|
||||
<a @click="viewBucket(bucket.name)" class="text-blue-600 cursor-pointer" x-text="bucket.name"></a>
|
||||
</td>
|
||||
<td x-text="bucket.region"></td>
|
||||
<td x-text="bucket.owner"></td>
|
||||
<td x-text="bucket.object_count"></td>
|
||||
<td x-text="bucket.total_size_human"></td>
|
||||
<td x-text="bucket.created_at"></td>
|
||||
<td>
|
||||
<div class="actions">
|
||||
<button @click="viewBucket(bucket.name)" class="btn btn-sm btn-secondary">
|
||||
<i class="fas fa-eye"></i>
|
||||
</button>
|
||||
<button @click="deleteBucket(bucket.name)" class="btn btn-sm btn-danger">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bucket Details Section -->
|
||||
<div x-show="activeSection === 'bucket-detail'" x-cloak class="fade-in">
|
||||
<div class="header">
|
||||
<div>
|
||||
<button @click="setActiveSection('buckets')" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left"></i> Back
|
||||
</button>
|
||||
<h2 style="display: inline-block; margin-left: 1rem;">
|
||||
<i class="fas fa-folder"></i> <span x-text="selectedBucket?.name"></span>
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div x-show="selectedBucket" class="card">
|
||||
<div class="card-header">
|
||||
Objects
|
||||
<div>
|
||||
<input x-model="objectSearch" @input="filterObjects()"
|
||||
placeholder="Search objects..." class="search-input">
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div x-show="loading" class="loading">Loading objects...</div>
|
||||
|
||||
<template x-if="!loading && filteredObjects.length === 0">
|
||||
<div class="empty-state">
|
||||
<i class="fas fa-file"></i>
|
||||
<h3>No objects found</h3>
|
||||
<p>This bucket is empty or no objects match your search.</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="!loading && filteredObjects.length > 0">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Key</th>
|
||||
<th>Size</th>
|
||||
<th>Type</th>
|
||||
<th>Modified</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-for="object in filteredObjects" :key="object.key">
|
||||
<tr>
|
||||
<td x-text="object.key"></td>
|
||||
<td x-text="object.size_human"></td>
|
||||
<td x-text="object.content_type"></td>
|
||||
<td x-text="object.created_at"></td>
|
||||
<td>
|
||||
<button @click="deleteObject(selectedBucket.name, object.key)" class="btn btn-sm btn-danger">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Credential Modal -->
|
||||
<div x-show="showCreateCredentialModal" x-cloak class="modal" @click.self="showCreateCredentialModal = false">
|
||||
<div class="modal-content">
|
||||
<h3 style="margin-bottom: 1rem;">Create New Credential</h3>
|
||||
<form @submit.prevent="createCredential()">
|
||||
<div class="form-group">
|
||||
<label class="form-label">User Name</label>
|
||||
<input x-model="newCredential.user_name" type="text" class="form-input" placeholder="Optional">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Access Key</label>
|
||||
<input x-model="newCredential.access_key" type="text" class="form-input"
|
||||
placeholder="Leave blank to auto-generate">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Secret Key</label>
|
||||
<input x-model="newCredential.secret_key" type="text" class="form-input"
|
||||
placeholder="Leave blank to auto-generate">
|
||||
</div>
|
||||
<div style="display: flex; gap: 1rem; justify-content: flex-end;">
|
||||
<button type="button" @click="showCreateCredentialModal = false" class="btn btn-secondary">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Bucket Modal -->
|
||||
<div x-show="showCreateBucketModal" x-cloak class="modal" @click.self="showCreateBucketModal = false">
|
||||
<div class="modal-content">
|
||||
<h3 style="margin-bottom: 1rem;">Create New Bucket</h3>
|
||||
<form @submit.prevent="createBucket()">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Bucket Name</label>
|
||||
<input x-model="newBucket.name" type="text" class="form-input"
|
||||
placeholder="my-bucket-name" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Owner</label>
|
||||
<select x-model="newBucket.owner_id" class="form-select" required>
|
||||
<option value="">Select owner...</option>
|
||||
<template x-for="credential in credentials" :key="credential.id">
|
||||
<option :value="credential.id" x-text="credential.user_name || credential.access_key"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Region</label>
|
||||
<select x-model="newBucket.region" class="form-select">
|
||||
<option value="us-east-1">us-east-1</option>
|
||||
<option value="us-west-2">us-west-2</option>
|
||||
<option value="eu-west-1">eu-west-1</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="display: flex; gap: 1rem; justify-content: flex-end;">
|
||||
<button type="button" @click="showCreateBucketModal = false" class="btn btn-secondary">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function consoleApp() {
|
||||
return {
|
||||
activeSection: 'dashboard',
|
||||
loading: false,
|
||||
message: '',
|
||||
messageType: 'success',
|
||||
|
||||
// Data
|
||||
stats: null,
|
||||
credentials: [],
|
||||
buckets: [],
|
||||
selectedBucket: null,
|
||||
filteredObjects: [],
|
||||
objectSearch: '',
|
||||
|
||||
// Modals
|
||||
showCreateCredentialModal: false,
|
||||
showCreateBucketModal: false,
|
||||
|
||||
// Forms
|
||||
newCredential: {
|
||||
user_name: '',
|
||||
access_key: '',
|
||||
secret_key: ''
|
||||
},
|
||||
newBucket: {
|
||||
name: '',
|
||||
owner_id: '',
|
||||
region: 'us-east-1'
|
||||
},
|
||||
|
||||
init() {
|
||||
this.loadStats();
|
||||
},
|
||||
|
||||
setActiveSection(section) {
|
||||
this.activeSection = section;
|
||||
this.message = '';
|
||||
|
||||
switch(section) {
|
||||
case 'dashboard':
|
||||
this.loadStats();
|
||||
break;
|
||||
case 'credentials':
|
||||
this.loadCredentials();
|
||||
break;
|
||||
case 'buckets':
|
||||
this.loadBuckets();
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
async apiCall(url, options = {}) {
|
||||
try {
|
||||
const response = await fetch(`/api${url}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
},
|
||||
...options
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
this.showMessage('API Error: ' + error.message, 'error');
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
showMessage(message, type = 'success') {
|
||||
this.message = message;
|
||||
this.messageType = type;
|
||||
setTimeout(() => {
|
||||
this.message = '';
|
||||
}, 5000);
|
||||
},
|
||||
|
||||
async loadStats() {
|
||||
this.loading = true;
|
||||
try {
|
||||
this.stats = await this.apiCall('/stats');
|
||||
} catch (error) {
|
||||
console.error('Failed to load stats:', error);
|
||||
}
|
||||
this.loading = false;
|
||||
},
|
||||
|
||||
async loadCredentials() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const response = await this.apiCall('/credentials');
|
||||
this.credentials = response.credentials;
|
||||
} catch (error) {
|
||||
console.error('Failed to load credentials:', error);
|
||||
}
|
||||
this.loading = false;
|
||||
},
|
||||
|
||||
async loadBuckets() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const response = await this.apiCall('/buckets');
|
||||
this.buckets = response.buckets;
|
||||
} catch (error) {
|
||||
console.error('Failed to load buckets:', error);
|
||||
}
|
||||
this.loading = false;
|
||||
},
|
||||
|
||||
async createCredential() {
|
||||
try {
|
||||
await this.apiCall('/credentials', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(this.newCredential)
|
||||
});
|
||||
|
||||
this.showMessage('Credential created successfully!');
|
||||
this.showCreateCredentialModal = false;
|
||||
this.newCredential = { user_name: '', access_key: '', secret_key: '' };
|
||||
this.loadCredentials();
|
||||
} catch (error) {
|
||||
console.error('Failed to create credential:', error);
|
||||
}
|
||||
},
|
||||
|
||||
async createBucket() {
|
||||
try {
|
||||
await this.apiCall('/buckets', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(this.newBucket)
|
||||
});
|
||||
|
||||
this.showMessage('Bucket created successfully!');
|
||||
this.showCreateBucketModal = false;
|
||||
this.newBucket = { name: '', owner_id: '', region: 'us-east-1' };
|
||||
this.loadBuckets();
|
||||
} catch (error) {
|
||||
console.error('Failed to create bucket:', error);
|
||||
}
|
||||
},
|
||||
|
||||
async deleteCredential(id) {
|
||||
if (!confirm('Are you sure you want to delete this credential?')) return;
|
||||
|
||||
try {
|
||||
await this.apiCall(`/credentials/${id}`, { method: 'DELETE' });
|
||||
this.showMessage('Credential deleted successfully!');
|
||||
this.loadCredentials();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete credential:', error);
|
||||
}
|
||||
},
|
||||
|
||||
async deleteBucket(name) {
|
||||
if (!confirm('Are you sure you want to delete this bucket?')) return;
|
||||
|
||||
try {
|
||||
await this.apiCall(`/buckets/${name}`, { method: 'DELETE' });
|
||||
this.showMessage('Bucket deleted successfully!');
|
||||
this.loadBuckets();
|
||||
} catch (error) {
|
||||
console.error('Failed to delete bucket:', error);
|
||||
}
|
||||
},
|
||||
|
||||
async viewBucket(bucketName) {
|
||||
this.loading = true;
|
||||
try {
|
||||
this.selectedBucket = await this.apiCall(`/buckets/${bucketName}`);
|
||||
this.filteredObjects = this.selectedBucket.objects || [];
|
||||
this.activeSection = 'bucket-detail';
|
||||
} catch (error) {
|
||||
console.error('Failed to load bucket details:', error);
|
||||
}
|
||||
this.loading = false;
|
||||
},
|
||||
|
||||
filterObjects() {
|
||||
if (!this.selectedBucket) return;
|
||||
|
||||
this.filteredObjects = this.selectedBucket.objects.filter(obj =>
|
||||
obj.key.toLowerCase().includes(this.objectSearch.toLowerCase())
|
||||
);
|
||||
},
|
||||
|
||||
async deleteObject(bucketName, objectKey) {
|
||||
if (!confirm('Are you sure you want to delete this object?')) return;
|
||||
|
||||
try {
|
||||
await this.apiCall(`/buckets/${bucketName}/objects/${encodeURIComponent(objectKey)}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
this.showMessage('Object deleted successfully!');
|
||||
this.viewBucket(bucketName);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete object:', error);
|
||||
}
|
||||
},
|
||||
|
||||
async viewCredential(credential) {
|
||||
// Could open a modal with full credential details
|
||||
alert(`Access Key: ${credential.access_key}\nUser: ${credential.user_name || 'N/A'}\nStatus: ${credential.is_active ? 'Active' : 'Inactive'}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
5
templates/openapi/.redocly.yaml
Normal file
5
templates/openapi/.redocly.yaml
Normal file
@@ -0,0 +1,5 @@
|
||||
apis:
|
||||
main:
|
||||
root: ./openapi.yaml
|
||||
rules:
|
||||
no-unused-components: off
|
||||
120
templates/openapi/generate_docs.sh
Executable file
120
templates/openapi/generate_docs.sh
Executable file
@@ -0,0 +1,120 @@
|
||||
#!/bin/bash
|
||||
|
||||
# vStash OpenAPI Documentation Generator
|
||||
# This script compiles the OpenAPI YAML files and generates HTML documentation
|
||||
|
||||
set -e
|
||||
|
||||
# Colors for output
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo -e "${BLUE}🚀 vStash API Documentation Generator${NC}"
|
||||
echo "================================================"
|
||||
|
||||
# Check if we're in the right directory
|
||||
if [ ! -f "templates/openapi/v1.yaml" ]; then
|
||||
echo -e "${RED}❌ Error: templates/openapi/v1.yaml not found${NC}"
|
||||
echo "Please run this script from the project root directory"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd templates/openapi
|
||||
|
||||
# Check for required tools
|
||||
echo -e "${BLUE}🔍 Checking dependencies...${NC}"
|
||||
|
||||
if ! command -v redocly &> /dev/null; then
|
||||
echo -e "${YELLOW}⚠️ redocly not found. Installing...${NC}"
|
||||
npm install -g @redocly/cli@latest
|
||||
fi
|
||||
|
||||
# Try to install modern swagger tools
|
||||
if ! command -v swagger-cli &> /dev/null; then
|
||||
echo -e "${YELLOW}⚠️ swagger-cli not found. Installing modern version...${NC}"
|
||||
npm install -g @apidevtools/swagger-cli
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}✅ Dependencies check complete${NC}"
|
||||
|
||||
# Create output directory
|
||||
mkdir -p html
|
||||
|
||||
# Step 1: Bundle YAML files
|
||||
echo -e "${BLUE}📦 Bundling YAML files...${NC}"
|
||||
|
||||
# Try redocly first (better multi-file support)
|
||||
if redocly bundle v1.yaml --output openapi.yaml; then
|
||||
echo -e "${GREEN}✅ YAML bundling complete (using redocly)${NC}"
|
||||
fi
|
||||
|
||||
# Step 2: Validate the bundled YAML
|
||||
echo -e "${BLUE}🔍 Validating OpenAPI specification...${NC}"
|
||||
redocly lint openapi.yaml
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo -e "${GREEN}✅ OpenAPI validation passed${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}⚠️ OpenAPI validation warnings (proceeding anyway)${NC}"
|
||||
fi
|
||||
|
||||
# Step 3: Generate HTML documentation
|
||||
echo -e "${BLUE}📖 Generating HTML documentation...${NC}"
|
||||
redocly build-docs openapi.yaml --output=html/index.html
|
||||
|
||||
if [ $? -eq 0 ]; then
|
||||
echo -e "${GREEN}✅ HTML documentation generated${NC}"
|
||||
else
|
||||
echo -e "${RED}❌ HTML documentation generation failed${NC}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Step 4: Generate additional formats (optional)
|
||||
echo -e "${BLUE}📄 Generating additional documentation formats...${NC}"
|
||||
|
||||
# Generate a cleaner JSON version
|
||||
if command -v yq &> /dev/null; then
|
||||
yq eval openapi.yaml -o=json > openapi.json
|
||||
echo -e "${GREEN}✅ JSON format generated${NC}"
|
||||
else
|
||||
echo -e "${YELLOW}⚠️ yq not found, skipping JSON generation${NC}"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo -e "${GREEN}🎉 Documentation generation complete!${NC}"
|
||||
echo "================================================"
|
||||
echo -e "${BLUE}📁 Generated files:${NC}"
|
||||
echo " • templates/openapi/openapi.yaml (bundled spec)"
|
||||
echo " • templates/openapi/openapi.json (JSON format)"
|
||||
echo " • templates/openapi/html/index.html (main docs)"
|
||||
echo ""
|
||||
echo -e "${BLUE}🌐 To view the documentation:${NC}"
|
||||
echo " Local: file://$(pwd)/html/index.html"
|
||||
echo " Or serve with: python -m http.server 8080 -d html"
|
||||
echo ""
|
||||
echo -e "${BLUE}📋 Next steps:${NC}"
|
||||
echo " 1. Review the generated documentation"
|
||||
echo " 2. Deploy html/index.html to your web server"
|
||||
echo " 3. Update API endpoints if needed"
|
||||
echo " 4. Share with your team!"
|
||||
echo ""
|
||||
|
||||
# Optional: Open documentation in browser (macOS/Linux)
|
||||
if command -v open &> /dev/null; then
|
||||
read -p "Open documentation in browser? (y/n): " -n 1 -r
|
||||
echo
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
open html/index.html
|
||||
fi
|
||||
elif command -v xdg-open &> /dev/null; then
|
||||
read -p "Open documentation in browser? (y/n): " -n 1 -r
|
||||
echo
|
||||
if [[ $REPLY =~ ^[Yy]$ ]]; then
|
||||
xdg-open html/index.html
|
||||
fi
|
||||
fi
|
||||
|
||||
echo -e "${GREEN}✨ All done!${NC}"
|
||||
847
templates/openapi/html/index.html
Normal file
847
templates/openapi/html/index.html
Normal file
File diff suppressed because one or more lines are too long
1605
templates/openapi/openapi.yaml
Normal file
1605
templates/openapi/openapi.yaml
Normal file
File diff suppressed because it is too large
Load Diff
266
templates/openapi/parameters.yaml
Normal file
266
templates/openapi/parameters.yaml
Normal file
@@ -0,0 +1,266 @@
|
||||
BucketName:
|
||||
name: bucket
|
||||
in: path
|
||||
required: true
|
||||
description: Name of the S3 bucket
|
||||
schema:
|
||||
type: string
|
||||
pattern: '^[a-z0-9\-\.]+$'
|
||||
minLength: 3
|
||||
maxLength: 63
|
||||
example: "my-bucket"
|
||||
|
||||
ObjectKey:
|
||||
name: key
|
||||
in: path
|
||||
required: true
|
||||
description: Object key (file path) within the bucket
|
||||
schema:
|
||||
type: string
|
||||
maxLength: 255
|
||||
example: "folder/file.txt"
|
||||
style: simple
|
||||
explode: false
|
||||
|
||||
CredentialId:
|
||||
name: id
|
||||
in: path
|
||||
required: true
|
||||
description: Unique identifier for the credential
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
example: 1
|
||||
|
||||
Prefix:
|
||||
name: prefix
|
||||
in: query
|
||||
description: Limits the response to keys that begin with the specified prefix
|
||||
schema:
|
||||
type: string
|
||||
maxLength: 255
|
||||
example: "photos/"
|
||||
|
||||
Marker:
|
||||
name: marker
|
||||
in: query
|
||||
description: Specifies the key to start with when listing objects
|
||||
schema:
|
||||
type: string
|
||||
maxLength: 255
|
||||
example: "photos/2023/"
|
||||
|
||||
MaxKeys:
|
||||
name: max-keys
|
||||
in: query
|
||||
description: Maximum number of keys to return
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 1000
|
||||
default: 1000
|
||||
example: 100
|
||||
|
||||
Delimiter:
|
||||
name: delimiter
|
||||
in: query
|
||||
description: Character used to group keys
|
||||
schema:
|
||||
type: string
|
||||
maxLength: 1
|
||||
example: "/"
|
||||
|
||||
PartNumber:
|
||||
name: partNumber
|
||||
in: query
|
||||
description: Part number for multipart upload (1-10,000)
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 10000
|
||||
example: 1
|
||||
|
||||
UploadId:
|
||||
name: uploadId
|
||||
in: query
|
||||
description: Upload ID for multipart upload operations
|
||||
schema:
|
||||
type: string
|
||||
maxLength: 64
|
||||
example: "upload_12345_abcdef"
|
||||
|
||||
VersionId:
|
||||
name: versionId
|
||||
in: query
|
||||
description: Version ID of the object (if versioning is enabled)
|
||||
schema:
|
||||
type: string
|
||||
maxLength: 64
|
||||
example: "version_123"
|
||||
|
||||
ContentMD5:
|
||||
name: Content-MD5
|
||||
in: header
|
||||
description: MD5 digest of the request body
|
||||
schema:
|
||||
type: string
|
||||
pattern: '^[A-Za-z0-9+/]+=*$'
|
||||
example: "n4bQgYhMfWWaL+qgxVrQFaO/TxsrC4Is0V1sFbDwCgg="
|
||||
|
||||
ContentType:
|
||||
name: Content-Type
|
||||
in: header
|
||||
description: MIME type of the object
|
||||
schema:
|
||||
type: string
|
||||
maxLength: 255
|
||||
default: "application/octet-stream"
|
||||
example: "image/jpeg"
|
||||
|
||||
ContentLength:
|
||||
name: Content-Length
|
||||
in: header
|
||||
description: Size of the object in bytes
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 0
|
||||
example: 1024000
|
||||
|
||||
Range:
|
||||
name: Range
|
||||
in: header
|
||||
description: Downloads the specified range bytes of an object
|
||||
schema:
|
||||
type: string
|
||||
pattern: '^bytes=\d+-\d*$'
|
||||
example: "bytes=0-1023"
|
||||
|
||||
IfMatch:
|
||||
name: If-Match
|
||||
in: header
|
||||
description: Return the object only if its ETag matches the specified value
|
||||
schema:
|
||||
type: string
|
||||
example: "\"9bb58f26192e4ba00f01e2e7b136bbd8\""
|
||||
|
||||
IfNoneMatch:
|
||||
name: If-None-Match
|
||||
in: header
|
||||
description: Return the object only if its ETag does not match the specified value
|
||||
schema:
|
||||
type: string
|
||||
example: "\"9bb58f26192e4ba00f01e2e7b136bbd8\""
|
||||
|
||||
IfModifiedSince:
|
||||
name: If-Modified-Since
|
||||
in: header
|
||||
description: Return the object only if it has been modified since the specified time
|
||||
schema:
|
||||
type: string
|
||||
format: date-time
|
||||
example: "Wed, 15 Jan 2023 10:30:00 GMT"
|
||||
|
||||
IfUnmodifiedSince:
|
||||
name: If-Unmodified-Since
|
||||
in: header
|
||||
description: Return the object only if it has not been modified since the specified time
|
||||
schema:
|
||||
type: string
|
||||
format: date-time
|
||||
example: "Wed, 15 Jan 2023 10:30:00 GMT"
|
||||
|
||||
CacheControl:
|
||||
name: Cache-Control
|
||||
in: header
|
||||
description: Specifies caching behavior for the object
|
||||
schema:
|
||||
type: string
|
||||
example: "max-age=3600"
|
||||
|
||||
ContentDisposition:
|
||||
name: Content-Disposition
|
||||
in: header
|
||||
description: Specifies presentational information for the object
|
||||
schema:
|
||||
type: string
|
||||
example: "attachment; filename=\"file.txt\""
|
||||
|
||||
ContentEncoding:
|
||||
name: Content-Encoding
|
||||
in: header
|
||||
description: Specifies what content encodings have been applied to the object
|
||||
schema:
|
||||
type: string
|
||||
example: "gzip"
|
||||
|
||||
ContentLanguage:
|
||||
name: Content-Language
|
||||
in: header
|
||||
description: Language the content is in
|
||||
schema:
|
||||
type: string
|
||||
example: "en-US"
|
||||
|
||||
Expires:
|
||||
name: Expires
|
||||
in: header
|
||||
description: Date and time when the object expires
|
||||
schema:
|
||||
type: string
|
||||
format: date-time
|
||||
example: "Wed, 15 Jan 2024 10:30:00 GMT"
|
||||
|
||||
ServerSideEncryption:
|
||||
name: x-amz-server-side-encryption
|
||||
in: header
|
||||
description: Server-side encryption algorithm to use
|
||||
schema:
|
||||
type: string
|
||||
enum: ["AES256", "aws:kms"]
|
||||
example: "AES256"
|
||||
|
||||
StorageClass:
|
||||
name: x-amz-storage-class
|
||||
in: header
|
||||
description: Storage class for the object
|
||||
schema:
|
||||
type: string
|
||||
enum: ["STANDARD", "REDUCED_REDUNDANCY", "GLACIER", "DEEP_ARCHIVE"]
|
||||
default: "STANDARD"
|
||||
example: "STANDARD"
|
||||
|
||||
Tagging:
|
||||
name: x-amz-tagging
|
||||
in: header
|
||||
description: Object tag set as a query string
|
||||
schema:
|
||||
type: string
|
||||
example: "key1=value1&key2=value2"
|
||||
|
||||
MetadataDirective:
|
||||
name: x-amz-metadata-directive
|
||||
in: header
|
||||
description: Specifies whether metadata is copied from the source object or replaced
|
||||
schema:
|
||||
type: string
|
||||
enum: ["COPY", "REPLACE"]
|
||||
default: "COPY"
|
||||
example: "REPLACE"
|
||||
|
||||
CopySource:
|
||||
name: x-amz-copy-source
|
||||
in: header
|
||||
description: Source bucket and object for copy operations
|
||||
schema:
|
||||
type: string
|
||||
example: "/source-bucket/source-object"
|
||||
|
||||
ACL:
|
||||
name: x-amz-acl
|
||||
in: header
|
||||
description: Canned ACL to apply to the object
|
||||
schema:
|
||||
type: string
|
||||
enum: ["private", "public-read", "public-read-write", "authenticated-read"]
|
||||
default: "private"
|
||||
example: "public-read"
|
||||
582
templates/openapi/paths.yaml
Normal file
582
templates/openapi/paths.yaml
Normal file
@@ -0,0 +1,582 @@
|
||||
# S3 API Endpoints
|
||||
|
||||
# List all buckets
|
||||
/s3/:
|
||||
get:
|
||||
operationId: listBuckets
|
||||
tags:
|
||||
- S3 Buckets
|
||||
summary: List buckets
|
||||
description: Returns a list of all buckets owned by the authenticated sender of the request
|
||||
security:
|
||||
- AWS4-HMAC-SHA256: []
|
||||
responses:
|
||||
'200':
|
||||
description: Success
|
||||
content:
|
||||
application/xml:
|
||||
schema:
|
||||
$ref: './schemas.yaml#/CreatePresignedUrlResponse'#/ListAllMyBucketsResult'
|
||||
'401':
|
||||
description: Unauthorized
|
||||
|
||||
# Bucket operations
|
||||
/s3/{bucket}:
|
||||
parameters:
|
||||
- $ref: './parameters.yaml#/BucketName'
|
||||
|
||||
head:
|
||||
operationId: headBucket
|
||||
tags:
|
||||
- S3 Buckets
|
||||
summary: Check if bucket exists
|
||||
description: Determines if a bucket exists and you have permission to access it
|
||||
security:
|
||||
- AWS4-HMAC-SHA256: []
|
||||
responses:
|
||||
'200':
|
||||
description: Bucket exists and you have access
|
||||
|
||||
|
||||
put:
|
||||
operationId: createBucket
|
||||
tags:
|
||||
- S3 Buckets
|
||||
summary: Create bucket
|
||||
description: Creates a new S3 bucket
|
||||
security:
|
||||
- AWS4-HMAC-SHA256: []
|
||||
responses:
|
||||
'200':
|
||||
description: Bucket created successfully
|
||||
headers:
|
||||
Location:
|
||||
description: Location of the created bucket
|
||||
schema:
|
||||
type: string
|
||||
'409':
|
||||
description: Bucket already exists
|
||||
|
||||
delete:
|
||||
operationId: deleteBucket
|
||||
tags:
|
||||
- S3 Buckets
|
||||
summary: Delete bucket
|
||||
description: Deletes the S3 bucket. The bucket must be empty before it can be deleted
|
||||
security:
|
||||
- AWS4-HMAC-SHA256: []
|
||||
responses:
|
||||
'204':
|
||||
description: Bucket deleted successfully
|
||||
'409':
|
||||
description: Bucket not empty
|
||||
|
||||
get:
|
||||
operationId: listObjects
|
||||
tags:
|
||||
- S3 Buckets
|
||||
summary: List objects in bucket
|
||||
description: Returns some or all of the objects in a bucket
|
||||
security:
|
||||
- AWS4-HMAC-SHA256: []
|
||||
parameters:
|
||||
- $ref: './parameters.yaml#/Prefix'
|
||||
- $ref: './parameters.yaml#/Marker'
|
||||
- $ref: './parameters.yaml#/MaxKeys'
|
||||
- $ref: './parameters.yaml#/Delimiter'
|
||||
- name: uploads
|
||||
in: query
|
||||
description: List multipart uploads when present
|
||||
schema:
|
||||
type: string
|
||||
enum: ['']
|
||||
responses:
|
||||
'200':
|
||||
description: Success
|
||||
content:
|
||||
application/xml:
|
||||
schema:
|
||||
oneOf:
|
||||
- $ref: './schemas.yaml#/ListBucketResult'
|
||||
- $ref: './schemas.yaml#/ListMultipartUploadsResult'
|
||||
|
||||
|
||||
# Object operations
|
||||
/s3/{bucket}/{key}:
|
||||
parameters:
|
||||
- $ref: './parameters.yaml#/BucketName'
|
||||
- $ref: './parameters.yaml#/ObjectKey'
|
||||
|
||||
head:
|
||||
operationId: headObject
|
||||
tags:
|
||||
- S3 Objects
|
||||
summary: Get object metadata
|
||||
description: Retrieves metadata from an object without returning the object itself
|
||||
security:
|
||||
- AWS4-HMAC-SHA256: []
|
||||
responses:
|
||||
'200':
|
||||
description: Success
|
||||
headers:
|
||||
Content-Length:
|
||||
schema:
|
||||
type: integer
|
||||
Content-Type:
|
||||
schema:
|
||||
type: string
|
||||
ETag:
|
||||
schema:
|
||||
type: string
|
||||
Last-Modified:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
get:
|
||||
operationId: getObject
|
||||
tags:
|
||||
- S3 Objects
|
||||
summary: Get object
|
||||
description: Retrieves objects from Amazon S3
|
||||
security:
|
||||
- AWS4-HMAC-SHA256: []
|
||||
parameters:
|
||||
- name: uploadId
|
||||
in: query
|
||||
description: List parts when present with upload ID
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'200':
|
||||
description: Success
|
||||
content:
|
||||
application/octet-stream:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
application/xml:
|
||||
schema:
|
||||
$ref: './schemas.yaml#/ListPartsResult'
|
||||
'404':
|
||||
description: Object not found
|
||||
|
||||
put:
|
||||
operationId: putObject
|
||||
tags:
|
||||
- S3 Objects
|
||||
summary: Put object
|
||||
description: Adds an object to a bucket
|
||||
security:
|
||||
- AWS4-HMAC-SHA256: []
|
||||
parameters:
|
||||
- name: partNumber
|
||||
in: query
|
||||
description: Part number for multipart upload
|
||||
schema:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 10000
|
||||
- name: uploadId
|
||||
in: query
|
||||
description: Upload ID for multipart upload part
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
description: Object data
|
||||
content:
|
||||
application/octet-stream:
|
||||
schema:
|
||||
type: string
|
||||
format: binary
|
||||
responses:
|
||||
'200':
|
||||
description: Success
|
||||
headers:
|
||||
ETag:
|
||||
schema:
|
||||
type: string
|
||||
|
||||
delete:
|
||||
operationId: deleteObject
|
||||
tags:
|
||||
- S3 Objects
|
||||
summary: Delete object
|
||||
description: Removes the null version of an object and inserts a delete marker
|
||||
security:
|
||||
- AWS4-HMAC-SHA256: []
|
||||
parameters:
|
||||
- name: uploadId
|
||||
in: query
|
||||
description: Abort multipart upload when present
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
'204':
|
||||
description: Object deleted successfully
|
||||
|
||||
post:
|
||||
operationId: multipartUpload
|
||||
tags:
|
||||
- S3 Multipart
|
||||
summary: Initiate or complete multipart upload
|
||||
description: Initiates a multipart upload or completes a multipart upload by assembling uploaded parts
|
||||
security:
|
||||
- AWS4-HMAC-SHA256: []
|
||||
parameters:
|
||||
- name: uploads
|
||||
in: query
|
||||
description: Initiate multipart upload when present
|
||||
schema:
|
||||
type: string
|
||||
enum: ['']
|
||||
- name: uploadId
|
||||
in: query
|
||||
description: Complete multipart upload with this upload ID
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
description: Complete multipart upload request
|
||||
content:
|
||||
application/xml:
|
||||
schema:
|
||||
$ref: './schemas.yaml#/CompleteMultipartUpload'
|
||||
responses:
|
||||
'200':
|
||||
description: Success
|
||||
content:
|
||||
application/xml:
|
||||
schema:
|
||||
oneOf:
|
||||
- $ref: './schemas.yaml#/InitiateMultipartUploadResult'
|
||||
- $ref: './schemas.yaml#/CompleteMultipartUploadResult'
|
||||
|
||||
# Management API Endpoints
|
||||
|
||||
/api/stats:
|
||||
get:
|
||||
operationId: getSystemStats
|
||||
tags:
|
||||
- Management - Stats
|
||||
summary: Get system statistics
|
||||
description: Returns overall system statistics including storage usage, object counts, etc.
|
||||
security:
|
||||
- ApiKey: []
|
||||
responses:
|
||||
'200':
|
||||
description: Success
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: './schemas.yaml#/SystemStats'
|
||||
|
||||
/api/credentials:
|
||||
get:
|
||||
operationId: listCredentials
|
||||
tags:
|
||||
- Management - Credentials
|
||||
summary: List credentials
|
||||
description: Returns a list of all access credentials
|
||||
security:
|
||||
- ApiKey: []
|
||||
responses:
|
||||
'200':
|
||||
description: Success
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: './schemas.yaml#/CredentialsList'
|
||||
|
||||
post:
|
||||
operationId: createCredential
|
||||
tags:
|
||||
- Management - Credentials
|
||||
summary: Create credential
|
||||
description: Creates a new access credential
|
||||
security:
|
||||
- ApiKey: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: './schemas.yaml#/CreateCredentialRequest'
|
||||
responses:
|
||||
'201':
|
||||
description: Credential created
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: './schemas.yaml#/Credential'
|
||||
|
||||
/api/credentials/{id}:
|
||||
parameters:
|
||||
- $ref: './parameters.yaml#/CredentialId'
|
||||
|
||||
get:
|
||||
operationId: getCredential
|
||||
tags:
|
||||
- Management - Credentials
|
||||
summary: Get credential details
|
||||
description: Returns detailed information about a specific credential
|
||||
security:
|
||||
- ApiKey: []
|
||||
responses:
|
||||
'200':
|
||||
description: Success
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: './schemas.yaml#/CredentialDetail'
|
||||
|
||||
put:
|
||||
operationId: updateCredential
|
||||
tags:
|
||||
- Management - Credentials
|
||||
summary: Update credential
|
||||
description: Updates an existing credential
|
||||
security:
|
||||
- ApiKey: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: './schemas.yaml#/UpdateCredentialRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Credential updated
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: './schemas.yaml#/ApiResponse'
|
||||
|
||||
delete:
|
||||
operationId: deleteCredential
|
||||
tags:
|
||||
- Management - Credentials
|
||||
summary: Delete credential
|
||||
description: Deletes an access credential
|
||||
security:
|
||||
- ApiKey: []
|
||||
responses:
|
||||
'200':
|
||||
description: Credential deleted
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: './schemas.yaml#/ApiResponse'
|
||||
|
||||
/api/buckets:
|
||||
get:
|
||||
operationId: listBucketsManagement
|
||||
tags:
|
||||
- Management - Buckets
|
||||
summary: List all buckets
|
||||
description: Returns a list of all buckets with detailed information
|
||||
security:
|
||||
- ApiKey: []
|
||||
responses:
|
||||
'200':
|
||||
description: Success
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: './schemas.yaml#/BucketsList'
|
||||
|
||||
post:
|
||||
operationId: createBucketManagement
|
||||
tags:
|
||||
- Management - Buckets
|
||||
summary: Create bucket
|
||||
description: Creates a new bucket via management API
|
||||
security:
|
||||
- ApiKey: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: './schemas.yaml#/CreateBucketRequest'
|
||||
responses:
|
||||
'201':
|
||||
description: Bucket created
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: './schemas.yaml#/BucketDetail'
|
||||
|
||||
/api/buckets/{bucketName}:
|
||||
parameters:
|
||||
- $ref: './parameters.yaml#/BucketName'
|
||||
|
||||
get:
|
||||
operationId: getBucketDetails
|
||||
tags:
|
||||
- Management - Buckets
|
||||
summary: Get bucket details
|
||||
description: Returns detailed information about a specific bucket including objects
|
||||
security:
|
||||
- ApiKey: []
|
||||
responses:
|
||||
'200':
|
||||
description: Success
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: './schemas.yaml#/BucketDetail'
|
||||
|
||||
delete:
|
||||
operationId: deleteBucketManagement
|
||||
tags:
|
||||
- Management - Buckets
|
||||
summary: Delete bucket
|
||||
description: Deletes a bucket via management API
|
||||
security:
|
||||
- ApiKey: []
|
||||
responses:
|
||||
'200':
|
||||
description: Bucket deleted
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: './schemas.yaml#/ApiResponse'
|
||||
|
||||
/api/buckets/{bucketName}/objects:
|
||||
parameters:
|
||||
- $ref: './parameters.yaml#/BucketName'
|
||||
|
||||
get:
|
||||
operationId: listObjectsManagement
|
||||
tags:
|
||||
- Management - Objects
|
||||
summary: List objects in bucket
|
||||
description: Returns a list of objects in the specified bucket
|
||||
security:
|
||||
- ApiKey: []
|
||||
parameters:
|
||||
- $ref: './parameters.yaml#/Prefix'
|
||||
responses:
|
||||
'200':
|
||||
description: Success
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: './schemas.yaml#/ObjectsList'
|
||||
|
||||
delete:
|
||||
operationId: deleteMultipleObjects
|
||||
tags:
|
||||
- Management - Objects
|
||||
summary: Delete multiple objects
|
||||
description: Deletes multiple objects from a bucket
|
||||
security:
|
||||
- ApiKey: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: './schemas.yaml#/DeleteObjectsRequest'
|
||||
responses:
|
||||
'200':
|
||||
description: Objects deleted
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: './schemas.yaml#/DeleteObjectsResponse'
|
||||
|
||||
/api/buckets/{bucketName}/objects/{objectKey}:
|
||||
parameters:
|
||||
- $ref: './parameters.yaml#/BucketName'
|
||||
- $ref: './parameters.yaml#/ObjectKey'
|
||||
|
||||
get:
|
||||
operationId: getObjectDetails
|
||||
tags:
|
||||
- Management - Objects
|
||||
summary: Get object details
|
||||
description: Returns detailed information about a specific object
|
||||
security:
|
||||
- ApiKey: []
|
||||
responses:
|
||||
'200':
|
||||
description: Success
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: './schemas.yaml#/ObjectDetail'
|
||||
|
||||
delete:
|
||||
operationId: deleteObjectManagement
|
||||
tags:
|
||||
- Management - Objects
|
||||
summary: Delete object
|
||||
description: Deletes a specific object
|
||||
security:
|
||||
- ApiKey: []
|
||||
responses:
|
||||
'200':
|
||||
description: Object deleted
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: './schemas.yaml#/ApiResponse'
|
||||
|
||||
/api/buckets/{bucketName}/multipart-uploads:
|
||||
parameters:
|
||||
- $ref: './parameters.yaml#/BucketName'
|
||||
|
||||
get:
|
||||
operationId: listMultipartUploads
|
||||
tags:
|
||||
- Management - Objects
|
||||
summary: List multipart uploads
|
||||
description: Returns active multipart uploads for a bucket
|
||||
security:
|
||||
- ApiKey: []
|
||||
responses:
|
||||
'200':
|
||||
description: Success
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: './schemas.yaml#/MultipartUploadsList'
|
||||
|
||||
/api/presigned-urls:
|
||||
get:
|
||||
operationId: listPresignedUrls
|
||||
tags:
|
||||
- Management - Presigned URLs
|
||||
summary: List presigned URLs
|
||||
description: Returns active presigned URLs
|
||||
security:
|
||||
- ApiKey: []
|
||||
responses:
|
||||
'200':
|
||||
description: Success
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: './schemas.yaml#/PresignedUrlsList'
|
||||
|
||||
post:
|
||||
operationId: createPresignedUrl
|
||||
tags:
|
||||
- Management - Presigned URLs
|
||||
summary: Create presigned URL
|
||||
description: Generates a new presigned URL
|
||||
security:
|
||||
- ApiKey: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: './schemas.yaml#/CreatePresignedUrlRequest'
|
||||
responses:
|
||||
'201':
|
||||
description: Presigned URL created
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: './schemas.yaml#/CreatePresignedUrlResponse'
|
||||
633
templates/openapi/schemas.yaml
Normal file
633
templates/openapi/schemas.yaml
Normal file
@@ -0,0 +1,633 @@
|
||||
# S3 API Schemas
|
||||
|
||||
S3Error:
|
||||
type: object
|
||||
xml:
|
||||
name: Error
|
||||
properties:
|
||||
Code:
|
||||
type: string
|
||||
example: "NoSuchBucket"
|
||||
Message:
|
||||
type: string
|
||||
example: "The specified bucket does not exist"
|
||||
Resource:
|
||||
type: string
|
||||
example: "/mybucket"
|
||||
RequestId:
|
||||
type: string
|
||||
example: "4442587FB7D0A2F9"
|
||||
|
||||
ListAllMyBucketsResult:
|
||||
type: object
|
||||
xml:
|
||||
name: ListAllMyBucketsResult
|
||||
namespace: http://s3.amazonaws.com/doc/2006-03-01/
|
||||
properties:
|
||||
Buckets:
|
||||
type: object
|
||||
properties:
|
||||
Bucket:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/Bucket'
|
||||
|
||||
Bucket:
|
||||
type: object
|
||||
properties:
|
||||
Name:
|
||||
type: string
|
||||
example: "my-bucket"
|
||||
CreationDate:
|
||||
type: string
|
||||
format: date-time
|
||||
example: "2023-01-15T10:30:00.000Z"
|
||||
|
||||
ListBucketResult:
|
||||
type: object
|
||||
xml:
|
||||
name: ListBucketResult
|
||||
namespace: http://s3.amazonaws.com/doc/2006-03-01/
|
||||
properties:
|
||||
Name:
|
||||
type: string
|
||||
example: "my-bucket"
|
||||
Prefix:
|
||||
type: string
|
||||
example: "photos/"
|
||||
Marker:
|
||||
type: string
|
||||
example: "photos/2023/"
|
||||
MaxKeys:
|
||||
type: integer
|
||||
example: 1000
|
||||
IsTruncated:
|
||||
type: boolean
|
||||
example: false
|
||||
Contents:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/ObjectSummary'
|
||||
|
||||
ObjectSummary:
|
||||
type: object
|
||||
properties:
|
||||
Key:
|
||||
type: string
|
||||
example: "photos/2023/vacation.jpg"
|
||||
LastModified:
|
||||
type: string
|
||||
format: date-time
|
||||
example: "2023-01-15T10:30:00.000Z"
|
||||
ETag:
|
||||
type: string
|
||||
example: "\"9bb58f26192e4ba00f01e2e7b136bbd8\""
|
||||
Size:
|
||||
type: integer
|
||||
example: 1024000
|
||||
StorageClass:
|
||||
type: string
|
||||
example: "STANDARD"
|
||||
|
||||
InitiateMultipartUploadResult:
|
||||
type: object
|
||||
xml:
|
||||
name: InitiateMultipartUploadResult
|
||||
namespace: http://s3.amazonaws.com/doc/2006-03-01/
|
||||
properties:
|
||||
Bucket:
|
||||
type: string
|
||||
example: "my-bucket"
|
||||
Key:
|
||||
type: string
|
||||
example: "large-file.zip"
|
||||
UploadId:
|
||||
type: string
|
||||
example: "upload_12345_abcdef"
|
||||
|
||||
CompleteMultipartUpload:
|
||||
type: object
|
||||
xml:
|
||||
name: CompleteMultipartUpload
|
||||
properties:
|
||||
Part:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
PartNumber:
|
||||
type: integer
|
||||
example: 1
|
||||
ETag:
|
||||
type: string
|
||||
example: "\"9bb58f26192e4ba00f01e2e7b136bbd8\""
|
||||
|
||||
CompleteMultipartUploadResult:
|
||||
type: object
|
||||
xml:
|
||||
name: CompleteMultipartUploadResult
|
||||
namespace: http://s3.amazonaws.com/doc/2006-03-01/
|
||||
properties:
|
||||
Location:
|
||||
type: string
|
||||
example: "/my-bucket/large-file.zip"
|
||||
Bucket:
|
||||
type: string
|
||||
example: "my-bucket"
|
||||
Key:
|
||||
type: string
|
||||
example: "large-file.zip"
|
||||
ETag:
|
||||
type: string
|
||||
example: "\"9bb58f26192e4ba00f01e2e7b136bbd8-5\""
|
||||
|
||||
ListPartsResult:
|
||||
type: object
|
||||
xml:
|
||||
name: ListPartsResult
|
||||
namespace: http://s3.amazonaws.com/doc/2006-03-01/
|
||||
properties:
|
||||
Bucket:
|
||||
type: string
|
||||
example: "my-bucket"
|
||||
Key:
|
||||
type: string
|
||||
example: "large-file.zip"
|
||||
UploadId:
|
||||
type: string
|
||||
example: "upload_12345_abcdef"
|
||||
StorageClass:
|
||||
type: string
|
||||
example: "STANDARD"
|
||||
PartNumberMarker:
|
||||
type: integer
|
||||
example: 0
|
||||
NextPartNumberMarker:
|
||||
type: integer
|
||||
example: 5
|
||||
MaxParts:
|
||||
type: integer
|
||||
example: 1000
|
||||
IsTruncated:
|
||||
type: boolean
|
||||
example: false
|
||||
Part:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/Part'
|
||||
|
||||
Part:
|
||||
type: object
|
||||
properties:
|
||||
PartNumber:
|
||||
type: integer
|
||||
example: 1
|
||||
LastModified:
|
||||
type: string
|
||||
format: date-time
|
||||
example: "2023-01-15T10:30:00.000Z"
|
||||
ETag:
|
||||
type: string
|
||||
example: "\"9bb58f26192e4ba00f01e2e7b136bbd8\""
|
||||
Size:
|
||||
type: integer
|
||||
example: 5242880
|
||||
|
||||
ListMultipartUploadsResult:
|
||||
type: object
|
||||
xml:
|
||||
name: ListMultipartUploadsResult
|
||||
namespace: http://s3.amazonaws.com/doc/2006-03-01/
|
||||
properties:
|
||||
Bucket:
|
||||
type: string
|
||||
example: "my-bucket"
|
||||
KeyMarker:
|
||||
type: string
|
||||
example: ""
|
||||
UploadIdMarker:
|
||||
type: string
|
||||
example: ""
|
||||
NextKeyMarker:
|
||||
type: string
|
||||
example: ""
|
||||
NextUploadIdMarker:
|
||||
type: string
|
||||
example: ""
|
||||
MaxUploads:
|
||||
type: integer
|
||||
example: 1000
|
||||
IsTruncated:
|
||||
type: boolean
|
||||
example: false
|
||||
Upload:
|
||||
type: array
|
||||
|
||||
MultipartUpload:
|
||||
type: object
|
||||
properties:
|
||||
Key:
|
||||
type: string
|
||||
example: "large-file.zip"
|
||||
UploadId:
|
||||
type: string
|
||||
example: "upload_12345_abcdef"
|
||||
Initiated:
|
||||
type: string
|
||||
format: date-time
|
||||
example: "2023-01-15T10:30:00.000Z"
|
||||
StorageClass:
|
||||
type: string
|
||||
example: "STANDARD"
|
||||
|
||||
# Management API Schemas
|
||||
|
||||
ApiError:
|
||||
type: object
|
||||
properties:
|
||||
error:
|
||||
type: string
|
||||
example: "Resource not found"
|
||||
code:
|
||||
type: string
|
||||
example: "NOT_FOUND"
|
||||
message:
|
||||
type: string
|
||||
example: "The requested resource could not be found"
|
||||
|
||||
ApiResponse:
|
||||
type: object
|
||||
properties:
|
||||
message:
|
||||
type: string
|
||||
example: "Operation completed successfully"
|
||||
|
||||
SystemStats:
|
||||
type: object
|
||||
properties:
|
||||
credentials:
|
||||
type: integer
|
||||
example: 5
|
||||
description: Total number of access credentials
|
||||
buckets:
|
||||
type: integer
|
||||
example: 12
|
||||
description: Total number of buckets
|
||||
objects:
|
||||
type: integer
|
||||
example: 1250
|
||||
description: Total number of objects
|
||||
total_storage:
|
||||
type: integer
|
||||
example: 1073741824
|
||||
description: Total storage used in bytes
|
||||
total_storage_human:
|
||||
type: string
|
||||
example: "1.0 GB"
|
||||
description: Human-readable storage size
|
||||
active_multipart_uploads:
|
||||
type: integer
|
||||
example: 3
|
||||
description: Number of active multipart uploads
|
||||
active_presigned_urls:
|
||||
type: integer
|
||||
example: 15
|
||||
description: Number of active presigned URLs
|
||||
|
||||
Credential:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
example: 1
|
||||
access_key:
|
||||
type: string
|
||||
example: "AKIAIOSFODNN7EXAMPLE"
|
||||
secret_key:
|
||||
type: string
|
||||
example: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
|
||||
description: Only returned when creating credential
|
||||
user_name:
|
||||
type: string
|
||||
example: "john-doe"
|
||||
nullable: true
|
||||
is_active:
|
||||
type: boolean
|
||||
example: true
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
example: "2023-01-15T10:30:00Z"
|
||||
|
||||
CredentialSummary:
|
||||
type: object
|
||||
properties:
|
||||
id:
|
||||
type: integer
|
||||
example: 1
|
||||
access_key:
|
||||
type: string
|
||||
example: "AKIAIOSFODNN7EXAMPLE"
|
||||
user_name:
|
||||
type: string
|
||||
example: "john-doe"
|
||||
nullable: true
|
||||
is_active:
|
||||
type: boolean
|
||||
example: true
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
example: "2023-01-15T10:30:00Z"
|
||||
bucket_count:
|
||||
type: integer
|
||||
example: 3
|
||||
|
||||
CredentialDetail:
|
||||
allOf:
|
||||
- $ref: '#/CredentialSummary'
|
||||
- type: object
|
||||
properties:
|
||||
buckets:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/BucketSummary'
|
||||
|
||||
CredentialsList:
|
||||
type: object
|
||||
properties:
|
||||
credentials:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/CredentialSummary'
|
||||
|
||||
CreateCredentialRequest:
|
||||
type: object
|
||||
properties:
|
||||
user_name:
|
||||
type: string
|
||||
example: "john-doe"
|
||||
description: Optional user name
|
||||
access_key:
|
||||
type: string
|
||||
example: "AKIAIOSFODNN7EXAMPLE"
|
||||
description: Optional custom access key (auto-generated if not provided)
|
||||
secret_key:
|
||||
type: string
|
||||
example: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
|
||||
description: Optional custom secret key (auto-generated if not provided)
|
||||
|
||||
UpdateCredentialRequest:
|
||||
type: object
|
||||
properties:
|
||||
user_name:
|
||||
type: string
|
||||
example: "john-doe"
|
||||
is_active:
|
||||
type: boolean
|
||||
example: true
|
||||
|
||||
BucketSummary:
|
||||
type: object
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
example: "my-bucket"
|
||||
region:
|
||||
type: string
|
||||
example: "us-east-1"
|
||||
owner:
|
||||
type: string
|
||||
example: "john-doe"
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
example: "2023-01-15T10:30:00Z"
|
||||
object_count:
|
||||
type: integer
|
||||
example: 150
|
||||
total_size:
|
||||
type: integer
|
||||
example: 1073741824
|
||||
total_size_human:
|
||||
type: string
|
||||
example: "1.0 GB"
|
||||
|
||||
BucketDetail:
|
||||
allOf:
|
||||
- $ref: '#/BucketSummary'
|
||||
- type: object
|
||||
properties:
|
||||
objects:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/ObjectInfo'
|
||||
|
||||
BucketsList:
|
||||
type: object
|
||||
properties:
|
||||
buckets:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/BucketSummary'
|
||||
|
||||
CreateBucketRequest:
|
||||
type: object
|
||||
required:
|
||||
- name
|
||||
- owner_id
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
example: "my-new-bucket"
|
||||
pattern: '^[a-z0-9\-\.]+$'
|
||||
owner_id:
|
||||
type: integer
|
||||
example: 1
|
||||
region:
|
||||
type: string
|
||||
example: "us-east-1"
|
||||
default: "us-east-1"
|
||||
|
||||
ObjectInfo:
|
||||
type: object
|
||||
properties:
|
||||
key:
|
||||
type: string
|
||||
example: "photos/vacation.jpg"
|
||||
size:
|
||||
type: integer
|
||||
example: 1024000
|
||||
size_human:
|
||||
type: string
|
||||
example: "1.0 MB"
|
||||
content_type:
|
||||
type: string
|
||||
example: "image/jpeg"
|
||||
etag:
|
||||
type: string
|
||||
example: "9bb58f26192e4ba00f01e2e7b136bbd8"
|
||||
is_multipart:
|
||||
type: boolean
|
||||
example: false
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
example: "2023-01-15T10:30:00Z"
|
||||
|
||||
ObjectDetail:
|
||||
allOf:
|
||||
- $ref: '#/ObjectInfo'
|
||||
- type: object
|
||||
properties:
|
||||
part_count:
|
||||
type: integer
|
||||
example: 0
|
||||
metadata:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
example:
|
||||
user-id: "12345"
|
||||
category: "photos"
|
||||
storage_path:
|
||||
type: string
|
||||
example: "my-bucket/objects/ab/cd/abcdef123456..."
|
||||
updated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
example: "2023-01-15T10:30:00Z"
|
||||
|
||||
ObjectsList:
|
||||
type: object
|
||||
properties:
|
||||
objects:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/ObjectInfo'
|
||||
|
||||
DeleteObjectsRequest:
|
||||
type: object
|
||||
required:
|
||||
- keys
|
||||
properties:
|
||||
keys:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
example: ["file1.txt", "file2.txt", "folder/file3.txt"]
|
||||
|
||||
DeleteObjectsResponse:
|
||||
type: object
|
||||
properties:
|
||||
deleted:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
example: ["file1.txt", "file2.txt"]
|
||||
|
||||
MultipartUploadInfo:
|
||||
type: object
|
||||
properties:
|
||||
upload_id:
|
||||
type: string
|
||||
example: "upload_12345_abcdef"
|
||||
object_key:
|
||||
type: string
|
||||
example: "large-file.zip"
|
||||
initiated_by:
|
||||
type: string
|
||||
example: "john-doe"
|
||||
content_type:
|
||||
type: string
|
||||
example: "application/zip"
|
||||
initiated_at:
|
||||
type: string
|
||||
format: date-time
|
||||
example: "2023-01-15T10:30:00Z"
|
||||
part_count:
|
||||
type: integer
|
||||
example: 5
|
||||
total_size:
|
||||
type: integer
|
||||
example: 26214400
|
||||
total_size_human:
|
||||
type: string
|
||||
example: "25.0 MB"
|
||||
|
||||
MultipartUploadsList:
|
||||
type: object
|
||||
properties:
|
||||
uploads:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/MultipartUploadInfo'
|
||||
|
||||
PresignedUrlInfo:
|
||||
type: object
|
||||
properties:
|
||||
bucket_name:
|
||||
type: string
|
||||
example: "my-bucket"
|
||||
object_key:
|
||||
type: string
|
||||
example: "file.txt"
|
||||
method:
|
||||
type: string
|
||||
enum: [GET, PUT, POST, DELETE]
|
||||
example: "GET"
|
||||
access_key:
|
||||
type: string
|
||||
example: "AKIAIOSFODNN7EXAMPLE"
|
||||
expires_at:
|
||||
type: string
|
||||
format: date-time
|
||||
example: "2023-01-15T11:30:00Z"
|
||||
created_at:
|
||||
type: string
|
||||
format: date-time
|
||||
example: "2023-01-15T10:30:00Z"
|
||||
|
||||
PresignedUrlsList:
|
||||
type: object
|
||||
properties:
|
||||
urls:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/PresignedUrlInfo'
|
||||
|
||||
CreatePresignedUrlRequest:
|
||||
type: object
|
||||
required:
|
||||
- bucket_name
|
||||
- object_key
|
||||
- access_key
|
||||
properties:
|
||||
bucket_name:
|
||||
type: string
|
||||
example: "my-bucket"
|
||||
object_key:
|
||||
type: string
|
||||
example: "file.txt"
|
||||
method:
|
||||
type: string
|
||||
enum: [GET, PUT, POST, DELETE]
|
||||
default: "GET"
|
||||
example: "GET"
|
||||
expires_in:
|
||||
type: integer
|
||||
minimum: 1
|
||||
maximum: 604800
|
||||
default: 3600
|
||||
example: 3600
|
||||
description: Expiration time in seconds (max 7 days)
|
||||
access_key:
|
||||
type: string
|
||||
example: "AKIAIOSFODNN7EXAMPLE"
|
||||
|
||||
CreatePresignedUrlResponse:
|
||||
type: object
|
||||
properties:
|
||||
url:
|
||||
type: string
|
||||
example: "/my-bucket/file.txt?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=AKIAIOSFODNN7EXAMPLE%2F20230115%2Fus-east-1%2Fs3%2Faws4_request&X-Amz-Date=20230115T103000Z&X-Amz-Expires=3600&X-Amz-SignedHeaders=host&X-Amz-Signature=abc123&hash=def456"
|
||||
101
templates/openapi/security.yaml
Normal file
101
templates/openapi/security.yaml
Normal file
@@ -0,0 +1,101 @@
|
||||
AWS4-HMAC-SHA256:
|
||||
type: apiKey
|
||||
description: |
|
||||
AWS Signature Version 4 authentication for S3 API endpoints.
|
||||
|
||||
## Authentication Process
|
||||
1. Create a canonical request
|
||||
2. Create a string to sign
|
||||
3. Calculate the signature using HMAC-SHA256
|
||||
4. Add the authorization header or query parameters
|
||||
|
||||
## Authorization Header Format
|
||||
```
|
||||
Authorization: AWS4-HMAC-SHA256 Credential=<AccessKey>/<Date>/<Region>/s3/aws4_request,SignedHeaders=<SignedHeaders>,Signature=<Signature>
|
||||
```
|
||||
|
||||
## Query String Format (for presigned URLs)
|
||||
```
|
||||
?X-Amz-Algorithm=AWS4-HMAC-SHA256
|
||||
&X-Amz-Credential=<AccessKey>/<Date>/<Region>/s3/aws4_request
|
||||
&X-Amz-Date=<Timestamp>
|
||||
&X-Amz-Expires=<ExpirationTime>
|
||||
&X-Amz-SignedHeaders=<SignedHeaders>
|
||||
&X-Amz-Signature=<Signature>
|
||||
```
|
||||
|
||||
## Required Headers
|
||||
- `Authorization`: The calculated authorization value
|
||||
- `x-amz-date`: Timestamp in ISO 8601 format (YYYYMMDDTHHMMSSZ)
|
||||
- `x-amz-content-sha256`: SHA256 hash of the request payload
|
||||
|
||||
## Example
|
||||
```
|
||||
Authorization: AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20230115/us-east-1/s3/aws4_request,SignedHeaders=host;x-amz-date,Signature=fe5f80f77d5fa3beca038a248ff027d0445342fe2855ddc963176630326f1024
|
||||
x-amz-date: 20230115T103000Z
|
||||
x-amz-content-sha256: e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
|
||||
```
|
||||
name: Authorization
|
||||
in: header
|
||||
|
||||
ApiKey:
|
||||
type: apiKey
|
||||
description: |
|
||||
API key authentication for management console endpoints.
|
||||
|
||||
## Usage
|
||||
Include your API key in the `X-API-Key` header for all management API requests.
|
||||
|
||||
## Example
|
||||
```
|
||||
X-API-Key: your-api-key-here
|
||||
```
|
||||
|
||||
## Obtaining an API Key
|
||||
API keys can be generated through the management console or by contacting your administrator.
|
||||
|
||||
## Permissions
|
||||
API keys have full access to the management API and should be kept secure.
|
||||
name: X-API-Key
|
||||
in: header
|
||||
|
||||
BearerAuth:
|
||||
type: http
|
||||
scheme: bearer
|
||||
bearerFormat: JWT
|
||||
description: |
|
||||
JWT Bearer token authentication (optional alternative for management API).
|
||||
|
||||
## Usage
|
||||
Include the JWT token in the Authorization header:
|
||||
```
|
||||
Authorization: Bearer <jwt-token>
|
||||
```
|
||||
|
||||
## Token Structure
|
||||
The JWT token contains claims about the user's permissions and expires after a set period.
|
||||
|
||||
## Example
|
||||
```
|
||||
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
|
||||
```
|
||||
|
||||
BasicAuth:
|
||||
type: http
|
||||
scheme: basic
|
||||
description: |
|
||||
HTTP Basic authentication (for simple setups).
|
||||
|
||||
## Usage
|
||||
Encode username:password in Base64 and include in Authorization header:
|
||||
```
|
||||
Authorization: Basic <base64-encoded-credentials>
|
||||
```
|
||||
|
||||
## Example
|
||||
```
|
||||
Authorization: Basic dXNlcm5hbWU6cGFzc3dvcmQ=
|
||||
```
|
||||
|
||||
## Note
|
||||
Basic auth should only be used over HTTPS in production environments.
|
||||
117
templates/openapi/v1.yaml
Normal file
117
templates/openapi/v1.yaml
Normal file
@@ -0,0 +1,117 @@
|
||||
openapi: "3.0.0"
|
||||
|
||||
info:
|
||||
version: "1.0.0"
|
||||
title: vStash S3 Compatible API
|
||||
description: |
|
||||
A high-performance S3-compatible object storage API with management console.
|
||||
|
||||
## Features
|
||||
- Full S3 API compatibility for basic operations
|
||||
- Multipart upload support
|
||||
- Presigned URLs
|
||||
- Management console API
|
||||
- Enterprise storage backend
|
||||
|
||||
## Authentication
|
||||
Uses AWS Signature Version 4 for S3 API endpoints and API keys for management endpoints.
|
||||
contact:
|
||||
email: support@vultr.com
|
||||
name: 'vStash Support'
|
||||
url: https://www.vultr.com
|
||||
x-logo:
|
||||
url: 'https://www.vultr.com/dist/img/brand/vultr-logo-onwhite.svg'
|
||||
backgroundColor: '#fafafa'
|
||||
altText: 'vStash - S3 Compatible API and Management'
|
||||
|
||||
servers:
|
||||
- url: https://vstash.vultr.com
|
||||
description: Production server
|
||||
- url: https://vstash-staging.vultr.com
|
||||
description: Staging server
|
||||
- url: http://localhost:8000
|
||||
description: Development server
|
||||
|
||||
tags:
|
||||
- name: S3 Buckets
|
||||
description: S3 bucket operations
|
||||
- name: S3 Objects
|
||||
description: S3 object operations
|
||||
- name: S3 Multipart
|
||||
description: S3 multipart upload operations
|
||||
- name: Management - Stats
|
||||
description: System statistics and monitoring
|
||||
- name: Management - Credentials
|
||||
description: Access key and credential management
|
||||
- name: Management - Buckets
|
||||
description: Bucket management via console
|
||||
- name: Management - Objects
|
||||
description: Object management via console
|
||||
- name: Management - Presigned URLs
|
||||
description: Presigned URL management
|
||||
|
||||
paths:
|
||||
$ref: './paths.yaml'
|
||||
|
||||
components:
|
||||
schemas:
|
||||
$ref: './schemas.yaml'
|
||||
parameters:
|
||||
$ref: './parameters.yaml'
|
||||
securitySchemes:
|
||||
$ref: './security.yaml'
|
||||
responses:
|
||||
'400':
|
||||
description: Bad Request
|
||||
content:
|
||||
application/xml:
|
||||
schema:
|
||||
$ref: '#/components/schemas/S3Error'
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ApiError'
|
||||
'401':
|
||||
description: Unauthorized
|
||||
content:
|
||||
application/xml:
|
||||
schema:
|
||||
$ref: '#/components/schemas/S3Error'
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ApiError'
|
||||
'403':
|
||||
description: Forbidden
|
||||
content:
|
||||
application/xml:
|
||||
schema:
|
||||
$ref: '#/components/schemas/S3Error'
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ApiError'
|
||||
'404':
|
||||
description: Not Found
|
||||
content:
|
||||
application/xml:
|
||||
schema:
|
||||
$ref: '#/components/schemas/S3Error'
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ApiError'
|
||||
'409':
|
||||
description: Conflict
|
||||
content:
|
||||
application/xml:
|
||||
schema:
|
||||
$ref: '#/components/schemas/S3Error'
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ApiError'
|
||||
'500':
|
||||
description: Internal Server Error
|
||||
content:
|
||||
application/xml:
|
||||
schema:
|
||||
$ref: '#/components/schemas/S3Error'
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ApiError'
|
||||
20
templates/openapi/yaml_check_script.sh
Executable file
20
templates/openapi/yaml_check_script.sh
Executable file
@@ -0,0 +1,20 @@
|
||||
#!/bin/bash
|
||||
|
||||
cd templates/openapi
|
||||
|
||||
echo "Checking YAML syntax..."
|
||||
|
||||
echo "Checking v1.yaml..."
|
||||
python3 -c "import yaml; yaml.safe_load(open('v1.yaml'))" && echo "✅ v1.yaml is valid" || echo "❌ v1.yaml has syntax errors"
|
||||
|
||||
echo "Checking paths.yaml..."
|
||||
python3 -c "import yaml; yaml.safe_load(open('paths.yaml'))" && echo "✅ paths.yaml is valid" || echo "❌ paths.yaml has syntax errors"
|
||||
|
||||
echo "Checking schemas.yaml..."
|
||||
python3 -c "import yaml; yaml.safe_load(open('schemas.yaml'))" && echo "✅ schemas.yaml is valid" || echo "❌ schemas.yaml has syntax errors"
|
||||
|
||||
echo "Checking parameters.yaml..."
|
||||
python3 -c "import yaml; yaml.safe_load(open('parameters.yaml'))" && echo "✅ parameters.yaml is valid" || echo "❌ parameters.yaml has syntax errors"
|
||||
|
||||
echo "Checking security.yaml..."
|
||||
python3 -c "import yaml; yaml.safe_load(open('security.yaml'))" && echo "✅ security.yaml is valid" || echo "❌ security.yaml has syntax errors"
|
||||
Reference in New Issue
Block a user