Add presigned URL management

This commit is contained in:
biondizzle
2025-06-05 10:07:14 -04:00
parent 994b08d44c
commit c5677539e4
7 changed files with 228 additions and 17 deletions

View 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");
}
}

View File

@@ -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);
}

View File

@@ -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
{

View File

@@ -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());
// Generate the actual presigned URL (simplified - you'll need full AWS Signature V4 implementation)
// 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';
$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);
}
/**

View File

@@ -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;

View File

@@ -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:

View File

@@ -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