Add presigned URL management
This commit is contained in:
26
migrations/Version20250605112000.php
Normal file
26
migrations/Version20250605112000.php
Normal file
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20250605112000 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Add url column to presigned URLs';
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
$this->addSql("ALTER TABLE s3_presigned_urls ADD url VARCHAR(2048) NOT NULL");
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->addSql("ALTER TABLE s3_presigned_urls DROP COLUMN url");
|
||||
}
|
||||
}
|
||||
@@ -389,7 +389,8 @@ class ConsoleApiController extends AbstractController
|
||||
'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')
|
||||
'created_at' => $url->getCreatedAt()->format('Y-m-d H:i:s'),
|
||||
'url' => $url->getUrl()
|
||||
];
|
||||
}, $urls)
|
||||
]);
|
||||
@@ -413,7 +414,7 @@ class ConsoleApiController extends AbstractController
|
||||
return new JsonResponse(['error' => 'Invalid access key'], 404);
|
||||
}
|
||||
|
||||
$url = $this->s3Service->generatePresignedGetUrl($bucketName, $objectKey, $credential, $expiresIn);
|
||||
$url = $this->s3Service->generatePresignedUrl($bucketName, $objectKey, $credential, $method, $expiresIn);
|
||||
|
||||
return new JsonResponse(['url' => $url], 201);
|
||||
}
|
||||
|
||||
@@ -40,6 +40,9 @@ class S3PresignedUrl
|
||||
#[ORM\Column(type: 'datetime')]
|
||||
private \DateTime $createdAt;
|
||||
|
||||
#[ORM\Column(type: 'string', length: 2048)]
|
||||
private string $url;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->createdAt = new \DateTime();
|
||||
@@ -59,6 +62,8 @@ class S3PresignedUrl
|
||||
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 getUrl(): string { return $this->url; }
|
||||
public function setUrl(string $url): self { $this->url = $url; return $this; }
|
||||
|
||||
public function isExpired(): bool
|
||||
{
|
||||
|
||||
@@ -403,31 +403,54 @@ class S3Service
|
||||
}
|
||||
|
||||
// AWS Signature V4 helpers
|
||||
public function generatePresignedGetUrl(string $bucketName, string $objectKey, S3Credential $credential, int $expiresIn = 3600): string
|
||||
public function generatePresignedUrl(string $bucketName, string $objectKey, S3Credential $credential, string $method = 'GET', int $expiresIn = 3600): string
|
||||
{
|
||||
$expiresAt = new \DateTime('+' . $expiresIn . ' seconds');
|
||||
|
||||
// Store in database for tracking
|
||||
$presignedUrl = $this->createPresignedUrl($bucketName, $objectKey, 'GET', $expiresAt, $credential->getAccessKey());
|
||||
// Store entry in DB
|
||||
$presignedUrl = $this->createPresignedUrl($bucketName, $objectKey, $method, $expiresAt, $credential->getAccessKey());
|
||||
|
||||
$amzDate = $presignedUrl->getCreatedAt()->format('Ymd\THis\Z');
|
||||
$shortDate = $presignedUrl->getCreatedAt()->format('Ymd');
|
||||
$scope = $shortDate . '/us-east-1/s3/aws4_request';
|
||||
|
||||
// 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-Credential' => $credential->getAccessKey() . '/' . $scope,
|
||||
'X-Amz-Date' => $amzDate,
|
||||
'X-Amz-Expires' => $expiresIn,
|
||||
'X-Amz-SignedHeaders' => 'host',
|
||||
'X-Amz-Signature' => $this->calculateSignature($bucketName, $objectKey, $params, $credential->getSecretKey()),
|
||||
'hash' => $presignedUrl->getUrlHash()
|
||||
'X-Amz-SignedHeaders' => 'host'
|
||||
];
|
||||
|
||||
return '/' . $bucketName . '/' . $objectKey . '?' . http_build_query($params);
|
||||
$canonicalQuery = http_build_query($params, '', '&', PHP_QUERY_RFC3986);
|
||||
$canonicalRequest = sprintf("%s\n/%s/%s\n%s\nhost:%s\n\nhost\nUNSIGNED-PAYLOAD", $method, $bucketName, $objectKey, $canonicalQuery, 'localhost');
|
||||
|
||||
$stringToSign = "AWS4-HMAC-SHA256\n" . $amzDate . "\n" . $scope . "\n" . hash('sha256', $canonicalRequest);
|
||||
$signingKey = $this->deriveSigningKey($credential->getSecretKey(), $shortDate, 'us-east-1', 's3');
|
||||
$signature = hash_hmac('sha256', $stringToSign, $signingKey);
|
||||
|
||||
$params['X-Amz-Signature'] = $signature;
|
||||
$params['hash'] = $presignedUrl->getUrlHash();
|
||||
|
||||
$url = '/' . $bucketName . '/' . $objectKey . '?' . http_build_query($params, '', '&', PHP_QUERY_RFC3986);
|
||||
$presignedUrl->setUrl($url);
|
||||
$this->entityManager->flush();
|
||||
|
||||
return $url;
|
||||
}
|
||||
|
||||
private function calculateSignature(string $bucketName, string $objectKey, array $params, string $secretKey): string
|
||||
// Deprecated - kept for backward compatibility
|
||||
public function generatePresignedGetUrl(string $bucketName, string $objectKey, S3Credential $credential, int $expiresIn = 3600): string
|
||||
{
|
||||
// Simplified signature calculation - implement full AWS Signature V4 here
|
||||
return hash_hmac('sha256', $bucketName . $objectKey . serialize($params), $secretKey);
|
||||
return $this->generatePresignedUrl($bucketName, $objectKey, $credential, 'GET', $expiresIn);
|
||||
}
|
||||
|
||||
private function deriveSigningKey(string $secretKey, string $date, string $region, string $service)
|
||||
{
|
||||
$kDate = hash_hmac('sha256', $date, 'AWS4' . $secretKey, true);
|
||||
$kRegion = hash_hmac('sha256', $region, $kDate, true);
|
||||
$kService = hash_hmac('sha256', $service, $kRegion, true);
|
||||
return hash_hmac('sha256', 'aws4_request', $kService, true);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -577,6 +577,59 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Presigned URLs Section -->
|
||||
<div x-show="activeSection === 'presigned'" x-cloak class="fade-in">
|
||||
<div class="header">
|
||||
<h2><i class="fas fa-link"></i> Presigned URLs</h2>
|
||||
<button @click="showCreatePresignedModal = true; loadBuckets(); loadCredentials()" class="btn btn-primary">
|
||||
<i class="fas fa-plus"></i> Create URL
|
||||
</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 URLs...</div>
|
||||
|
||||
<template x-if="!loading && presignedUrls.length === 0">
|
||||
<div class="empty-state">
|
||||
<i class="fas fa-link"></i>
|
||||
<h3>No presigned URLs found</h3>
|
||||
<p>Create a presigned URL to share temporary access.</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template x-if="!loading && presignedUrls.length > 0">
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Bucket</th>
|
||||
<th>Object</th>
|
||||
<th>Method</th>
|
||||
<th>Expires</th>
|
||||
<th>URL</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<template x-for="p in presignedUrls" :key="p.url">
|
||||
<tr>
|
||||
<td x-text="p.bucket_name"></td>
|
||||
<td x-text="p.object_key"></td>
|
||||
<td x-text="p.method"></td>
|
||||
<td x-text="p.expires_at"></td>
|
||||
<td>
|
||||
<input class="form-input" :value="p.url" readonly style="font-size: 0.8rem;">
|
||||
</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">
|
||||
@@ -646,6 +699,58 @@
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Presigned URL Modal -->
|
||||
<div x-show="showCreatePresignedModal" x-cloak class="modal" @click.self="showCreatePresignedModal = false">
|
||||
<div class="modal-content">
|
||||
<h3 style="margin-bottom: 1rem;">Create Presigned URL</h3>
|
||||
<form @submit.prevent="createPresigned()">
|
||||
<div class="form-group">
|
||||
<label class="form-label">Bucket Name</label>
|
||||
<select x-model="newPresigned.bucket_name" class="form-select" required>
|
||||
<option value="">Select bucket...</option>
|
||||
<template x-for="bucket in buckets" :key="bucket.name">
|
||||
<option :value="bucket.name" x-text="bucket.name"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Object Key</label>
|
||||
<input x-model="newPresigned.object_key" type="text" class="form-input" placeholder="path/to/file.txt" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Method</label>
|
||||
<select x-model="newPresigned.method" class="form-select">
|
||||
<option value="GET">GET</option>
|
||||
<option value="PUT">PUT</option>
|
||||
<option value="POST">POST</option>
|
||||
<option value="DELETE">DELETE</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Expires In (seconds)</label>
|
||||
<input x-model="newPresigned.expires_in" type="number" class="form-input" min="1" max="604800" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Access Key</label>
|
||||
<select x-model="newPresigned.access_key" class="form-select" required>
|
||||
<option value="">Select credential...</option>
|
||||
<template x-for="credential in credentials" :key="credential.id">
|
||||
<option :value="credential.access_key" x-text="credential.user_name || credential.access_key"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
<div style="display: flex; gap: 1rem; justify-content: flex-end;">
|
||||
<button type="button" @click="showCreatePresignedModal = false" class="btn btn-secondary">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
Create
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -664,10 +769,12 @@
|
||||
selectedBucket: null,
|
||||
filteredObjects: [],
|
||||
objectSearch: '',
|
||||
presignedUrls: [],
|
||||
|
||||
// Modals
|
||||
showCreateCredentialModal: false,
|
||||
showCreateBucketModal: false,
|
||||
showCreatePresignedModal: false,
|
||||
|
||||
// Forms
|
||||
newCredential: {
|
||||
@@ -680,6 +787,13 @@
|
||||
owner_id: '',
|
||||
region: 'us-east-1'
|
||||
},
|
||||
newPresigned: {
|
||||
bucket_name: '',
|
||||
object_key: '',
|
||||
method: 'GET',
|
||||
expires_in: 3600,
|
||||
access_key: ''
|
||||
},
|
||||
|
||||
init() {
|
||||
this.loadStats();
|
||||
@@ -699,6 +813,9 @@
|
||||
case 'buckets':
|
||||
this.loadBuckets();
|
||||
break;
|
||||
case 'presigned':
|
||||
this.loadPresignedUrls();
|
||||
break;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -795,6 +912,39 @@
|
||||
}
|
||||
},
|
||||
|
||||
async loadPresignedUrls() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const response = await this.apiCall('/presigned-urls');
|
||||
this.presignedUrls = response.urls;
|
||||
} catch (error) {
|
||||
console.error('Failed to load presigned URLs:', error);
|
||||
}
|
||||
this.loading = false;
|
||||
},
|
||||
|
||||
async createPresigned() {
|
||||
try {
|
||||
const payload = { ...this.newPresigned };
|
||||
const urlData = await this.apiCall('/presigned-urls', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
this.showMessage('Presigned URL created');
|
||||
this.showCreatePresignedModal = false;
|
||||
this.newPresigned = { bucket_name: '', object_key: '', method: 'GET', expires_in: 3600, access_key: '' };
|
||||
this.presignedUrls.unshift({
|
||||
bucket_name: payload.bucket_name,
|
||||
object_key: payload.object_key,
|
||||
method: payload.method,
|
||||
expires_at: new Date(Date.now() + payload.expires_in * 1000).toISOString().slice(0,19).replace('T',' '),
|
||||
url: urlData.url
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to create presigned URL:', error);
|
||||
}
|
||||
},
|
||||
|
||||
async deleteCredential(id) {
|
||||
if (!confirm('Are you sure you want to delete this credential?')) return;
|
||||
|
||||
|
||||
@@ -1091,6 +1091,9 @@ components:
|
||||
type: string
|
||||
format: date-time
|
||||
example: '2023-01-15T10:30:00Z'
|
||||
url:
|
||||
type: string
|
||||
example: "/my-bucket/file.txt?X-Amz-Algorithm=AWS4-HMAC-SHA256&..."
|
||||
PresignedUrlsList:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -587,6 +587,9 @@ PresignedUrlInfo:
|
||||
type: string
|
||||
format: date-time
|
||||
example: "2023-01-15T10:30:00Z"
|
||||
url:
|
||||
type: string
|
||||
example: "/my-bucket/file.txt?X-Amz-Algorithm=AWS4-HMAC-SHA256&..."
|
||||
|
||||
PresignedUrlsList:
|
||||
type: object
|
||||
|
||||
Reference in New Issue
Block a user