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(),
|
'method' => $url->getMethod(),
|
||||||
'access_key' => $url->getAccessKey(),
|
'access_key' => $url->getAccessKey(),
|
||||||
'expires_at' => $url->getExpiresAt()->format('Y-m-d H:i:s'),
|
'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)
|
}, $urls)
|
||||||
]);
|
]);
|
||||||
@@ -413,7 +414,7 @@ class ConsoleApiController extends AbstractController
|
|||||||
return new JsonResponse(['error' => 'Invalid access key'], 404);
|
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);
|
return new JsonResponse(['url' => $url], 201);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ class S3PresignedUrl
|
|||||||
#[ORM\Column(type: 'datetime')]
|
#[ORM\Column(type: 'datetime')]
|
||||||
private \DateTime $createdAt;
|
private \DateTime $createdAt;
|
||||||
|
|
||||||
|
#[ORM\Column(type: 'string', length: 2048)]
|
||||||
|
private string $url;
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->createdAt = new \DateTime();
|
$this->createdAt = new \DateTime();
|
||||||
@@ -59,6 +62,8 @@ class S3PresignedUrl
|
|||||||
public function getAccessKey(): string { return $this->accessKey; }
|
public function getAccessKey(): string { return $this->accessKey; }
|
||||||
public function setAccessKey(string $accessKey): self { $this->accessKey = $accessKey; return $this; }
|
public function setAccessKey(string $accessKey): self { $this->accessKey = $accessKey; return $this; }
|
||||||
public function getCreatedAt(): \DateTime { return $this->createdAt; }
|
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
|
public function isExpired(): bool
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -403,31 +403,54 @@ class S3Service
|
|||||||
}
|
}
|
||||||
|
|
||||||
// AWS Signature V4 helpers
|
// 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');
|
$expiresAt = new \DateTime('+' . $expiresIn . ' seconds');
|
||||||
|
|
||||||
// Store in database for tracking
|
// Store entry in DB
|
||||||
$presignedUrl = $this->createPresignedUrl($bucketName, $objectKey, 'GET', $expiresAt, $credential->getAccessKey());
|
$presignedUrl = $this->createPresignedUrl($bucketName, $objectKey, $method, $expiresAt, $credential->getAccessKey());
|
||||||
|
|
||||||
// Generate the actual presigned URL (simplified - you'll need full AWS Signature V4 implementation)
|
$amzDate = $presignedUrl->getCreatedAt()->format('Ymd\THis\Z');
|
||||||
|
$shortDate = $presignedUrl->getCreatedAt()->format('Ymd');
|
||||||
|
$scope = $shortDate . '/us-east-1/s3/aws4_request';
|
||||||
|
|
||||||
$params = [
|
$params = [
|
||||||
'X-Amz-Algorithm' => 'AWS4-HMAC-SHA256',
|
'X-Amz-Algorithm' => 'AWS4-HMAC-SHA256',
|
||||||
'X-Amz-Credential' => $credential->getAccessKey() . '/' . date('Ymd') . '/us-east-1/s3/aws4_request',
|
'X-Amz-Credential' => $credential->getAccessKey() . '/' . $scope,
|
||||||
'X-Amz-Date' => gmdate('Ymd\THis\Z'),
|
'X-Amz-Date' => $amzDate,
|
||||||
'X-Amz-Expires' => $expiresIn,
|
'X-Amz-Expires' => $expiresIn,
|
||||||
'X-Amz-SignedHeaders' => 'host',
|
'X-Amz-SignedHeaders' => 'host'
|
||||||
'X-Amz-Signature' => $this->calculateSignature($bucketName, $objectKey, $params, $credential->getSecretKey()),
|
|
||||||
'hash' => $presignedUrl->getUrlHash()
|
|
||||||
];
|
];
|
||||||
|
|
||||||
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 $this->generatePresignedUrl($bucketName, $objectKey, $credential, 'GET', $expiresIn);
|
||||||
return hash_hmac('sha256', $bucketName . $objectKey . serialize($params), $secretKey);
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
</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 -->
|
<!-- Create Credential Modal -->
|
||||||
<div x-show="showCreateCredentialModal" x-cloak class="modal" @click.self="showCreateCredentialModal = false">
|
<div x-show="showCreateCredentialModal" x-cloak class="modal" @click.self="showCreateCredentialModal = false">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
@@ -646,6 +699,58 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -664,10 +769,12 @@
|
|||||||
selectedBucket: null,
|
selectedBucket: null,
|
||||||
filteredObjects: [],
|
filteredObjects: [],
|
||||||
objectSearch: '',
|
objectSearch: '',
|
||||||
|
presignedUrls: [],
|
||||||
|
|
||||||
// Modals
|
// Modals
|
||||||
showCreateCredentialModal: false,
|
showCreateCredentialModal: false,
|
||||||
showCreateBucketModal: false,
|
showCreateBucketModal: false,
|
||||||
|
showCreatePresignedModal: false,
|
||||||
|
|
||||||
// Forms
|
// Forms
|
||||||
newCredential: {
|
newCredential: {
|
||||||
@@ -680,6 +787,13 @@
|
|||||||
owner_id: '',
|
owner_id: '',
|
||||||
region: 'us-east-1'
|
region: 'us-east-1'
|
||||||
},
|
},
|
||||||
|
newPresigned: {
|
||||||
|
bucket_name: '',
|
||||||
|
object_key: '',
|
||||||
|
method: 'GET',
|
||||||
|
expires_in: 3600,
|
||||||
|
access_key: ''
|
||||||
|
},
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
this.loadStats();
|
this.loadStats();
|
||||||
@@ -699,6 +813,9 @@
|
|||||||
case 'buckets':
|
case 'buckets':
|
||||||
this.loadBuckets();
|
this.loadBuckets();
|
||||||
break;
|
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) {
|
async deleteCredential(id) {
|
||||||
if (!confirm('Are you sure you want to delete this credential?')) return;
|
if (!confirm('Are you sure you want to delete this credential?')) return;
|
||||||
|
|
||||||
|
|||||||
@@ -1091,6 +1091,9 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
example: '2023-01-15T10:30:00Z'
|
example: '2023-01-15T10:30:00Z'
|
||||||
|
url:
|
||||||
|
type: string
|
||||||
|
example: "/my-bucket/file.txt?X-Amz-Algorithm=AWS4-HMAC-SHA256&..."
|
||||||
PresignedUrlsList:
|
PresignedUrlsList:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
|||||||
@@ -587,6 +587,9 @@ PresignedUrlInfo:
|
|||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
example: "2023-01-15T10:30:00Z"
|
example: "2023-01-15T10:30:00Z"
|
||||||
|
url:
|
||||||
|
type: string
|
||||||
|
example: "/my-bucket/file.txt?X-Amz-Algorithm=AWS4-HMAC-SHA256&..."
|
||||||
|
|
||||||
PresignedUrlsList:
|
PresignedUrlsList:
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
Reference in New Issue
Block a user