Skip to content

Commit 2e35181

Browse files
authored
feat: use azure blob storage for dynamic files (#159)
* feat: use azure blob storage for dynamic files * add wait * docs: move documenatation to README instead of .env * chore: cleanup PREMIUM_AUTHENTICATION key * refactor: remove StorageHelper.php and integrate storage functions directly in index.php
1 parent ab15285 commit 2e35181

34 files changed

+1268
-100
lines changed

.env

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,12 @@ DB_ROOT_PASSWORD=rootpassword
22
DB_USER=vowuser
33
DB_PASSWORD=password
44

5+
# Storage Configuration
6+
# =====================
7+
# See README.md "Storage Configuration" section for detailed setup instructions
8+
STORAGE_TYPE=local
9+
AZURE_STORAGE_CONNECTION_STRING=
10+
511
#Line reporting keys
612
LINE_REPORT_COLLECT=testing
713
LINE_REPORT_MODIFY=testing
@@ -10,7 +16,4 @@ LINE_REPORT_MODIFY=testing
1016
STATISTICS_AGGREGATE=testing
1117

1218
#Discord integration key
13-
DISCORD_INTEGRATION=testing
14-
15-
#Premium authenticator key
16-
PREMIUM_AUTHENTICATION=testing
19+
DISCORD_INTEGRATION=testing

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,5 @@ profiles.php
1111
composer.phar
1212
.devcontainer
1313
/sessions/*
14-
!/sessions/.gitkeep
14+
!/sessions/.gitkeep
15+
*nul

Controllers/Api/ApiKey.php

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,5 @@ enum ApiKey
88
case LINE_REPORT_MODIFY;
99
case STATISTICS_AGGREGATE;
1010
case DISCORD_INTEGRATION;
11-
case PREMIUM_AUTHENTICATION;
1211
}
1312

Controllers/Website/Account/Account.php

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,13 @@
66
use VoicesOfWynn\Controllers\Website\WebpageController;
77
use VoicesOfWynn\Models\Website\AccountDataValidator;
88
use VoicesOfWynn\Models\Website\User;
9+
use VoicesOfWynn\Models\Storage\Storage;
910

1011
class Account extends WebpageController
1112
{
1213

13-
public const PROFILE_AVATAR_DIRECTORY = 'dynamic/avatars/';
14-
public const DISCORD_AVATAR_DIRECTORY = 'dynamic/discord-avatars/';
14+
public const AVATAR_PATH_PREFIX = 'avatars/';
15+
public const DISCORD_AVATAR_PATH_PREFIX = 'discord-avatars/';
1516

1617
/**
1718
* @var User The user object that we're editing
@@ -170,10 +171,11 @@ private function post(array $args): bool
170171
if (empty($validator->errors)) {
171172
if ($_FILES['avatar']['error'] !== UPLOAD_ERR_NO_FILE) {
172173
//Delete old avatars
173-
array_map('unlink', glob(self::PROFILE_AVATAR_DIRECTORY.$this->user->getId().'.*'));
174+
$storage = Storage::get();
175+
$storage->deleteByPrefix(self::AVATAR_PATH_PREFIX . $this->user->getId() . '.');
174176

175177
//Save changes
176-
move_uploaded_file($_FILES['avatar']['tmp_name'], self::PROFILE_AVATAR_DIRECTORY.$avatar);
178+
$storage->upload($_FILES['avatar']['tmp_name'], self::AVATAR_PATH_PREFIX . $avatar);
177179
} else {
178180
$avatar = $this->user->getAvatar();
179181
}

Controllers/Website/Administration/Accounts.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ public function process(array $args): int
7070
self::$data['accounts_accounts'] = $accountManager->getUsers();
7171

7272
self::$cssFiles[] = 'accounts';
73+
self::$jsFiles[] = 'storage-config';
7374
self::$jsFiles[] = 'accounts';
7475
self::$views[] = 'accounts';
7576

Controllers/Website/Contents.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,11 @@ public function process(array $args): int
1717
self::$data['base_keywords'] = 'Minecraft,Wynncraft,Mod,Voice,Contents,Content,Recordings,List,Voting';
1818

1919
$cnm = new ContentManager();
20-
self::$data['contents_quests'] = $cnm->getQuests();
20+
self::$data['contents_quests'] = $cnm->getQuestList();
2121

2222
self::$cssFiles[] = 'contents';
2323
self::$jsFiles[] = 'search_bar';
24+
self::$jsFiles[] = 'storage-config';
2425
self::$jsFiles[] = 'content_modal';
2526
self::$views[] = 'contents';
2627
return true;

Controllers/Website/Npc.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use VoicesOfWynn\Models\Website\RecordingUploader;
99
use VoicesOfWynn\Models\Website\User;
1010
use VoicesOfWynn\Models\Website\Npc as NpcModel;
11+
use VoicesOfWynn\Models\Storage\Storage;
1112

1213
class Npc extends WebpageController
1314
{
@@ -161,7 +162,7 @@ private function delete(array $args): int
161162
array($recordingId, $this->npc->getId()));
162163
if ($result) {
163164
//Delete file
164-
unlink('dynamic/recordings/'.$filename);
165+
Storage::get()->delete('recordings/'.$filename);
165166
}
166167
exit($result);
167168
}

Models/Api/DiscordIntegration/DiscordManager.php

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use VoicesOfWynn\Models\Website\DiscordRole;
88
use VoicesOfWynn\Models\Website\User;
99
use VoicesOfWynn\Models\Website\UserException;
10+
use VoicesOfWynn\Models\Storage\Storage;
1011

1112
class DiscordManager
1213
{
@@ -117,23 +118,35 @@ public function syncUser(int $discordId, string $discordName, ?string $avatarUrl
117118
*/
118119
private function updateDiscordAvatar(int $userId, string $avatarUrl): bool
119120
{
120-
$fh = fopen(Account::DISCORD_AVATAR_DIRECTORY.$userId.'.png', 'w'); //Also clears the current file, if it exists
121-
$ch = curl_init();
122-
123-
curl_setopt($ch, CURLOPT_URL, $avatarUrl);
124-
curl_setopt($ch, CURLOPT_FILE, $fh);
125-
curl_setopt($ch, CURLOPT_HEADER, false);
126-
curl_setopt($ch, CURLOPT_USERAGENT, 'Voices of Wynn +https://www.voicesofwynn.com');
127-
curl_exec($ch);
128-
$error = curl_errno($ch);
129-
130-
curl_close($ch);
131-
fclose($fh);
132-
133-
if ($error === 0) {
134-
return true;
121+
// Download to temp file first
122+
$tempFile = tempnam(sys_get_temp_dir(), 'discord_avatar_');
123+
try {
124+
$fh = fopen($tempFile, 'w');
125+
$ch = curl_init();
126+
127+
curl_setopt($ch, CURLOPT_URL, $avatarUrl);
128+
curl_setopt($ch, CURLOPT_FILE, $fh);
129+
curl_setopt($ch, CURLOPT_HEADER, false);
130+
curl_setopt($ch, CURLOPT_USERAGENT, 'Voices of Wynn +https://www.voicesofwynn.com');
131+
curl_exec($ch);
132+
$error = curl_errno($ch);
133+
134+
curl_close($ch);
135+
fclose($fh);
136+
137+
if ($error === 0) {
138+
// Upload to storage
139+
$storage = Storage::get();
140+
$storage->upload($tempFile, Account::DISCORD_AVATAR_PATH_PREFIX . $userId . '.png', 'image/png');
141+
return true;
142+
}
143+
return false;
144+
} finally {
145+
// Always clean up temp file
146+
if (file_exists($tempFile)) {
147+
unlink($tempFile);
148+
}
135149
}
136-
return false;
137150
}
138151
}
139152

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
<?php
2+
3+
namespace VoicesOfWynn\Models\Storage;
4+
5+
use MicrosoftAzure\Storage\Blob\BlobRestProxy;
6+
use MicrosoftAzure\Storage\Blob\Models\CreateBlockBlobOptions;
7+
use MicrosoftAzure\Storage\Blob\Models\ListBlobsOptions;
8+
use MicrosoftAzure\Storage\Common\Exceptions\ServiceException;
9+
10+
class AzureBlobStorage implements StorageInterface {
11+
private BlobRestProxy $client;
12+
private string $containerName;
13+
private string $baseUrl;
14+
15+
// MIME type mapping
16+
private const MIME_TYPES = [
17+
'ogg' => 'audio/ogg',
18+
'jpg' => 'image/jpeg',
19+
'jpeg' => 'image/jpeg',
20+
'png' => 'image/png',
21+
];
22+
23+
public function __construct(string $connectionString, string $containerName) {
24+
$this->client = BlobRestProxy::createBlobService($connectionString);
25+
$this->containerName = $containerName;
26+
27+
// Extract account name from connection string to build base URL
28+
preg_match('/AccountName=([^;]+)/', $connectionString, $matches);
29+
$accountName = $matches[1] ?? 'vowstorage';
30+
$this->baseUrl = "https://{$accountName}.blob.core.windows.net/{$containerName}/";
31+
}
32+
33+
public function upload(string $sourcePath, string $destinationPath, ?string $contentType = null): bool {
34+
try {
35+
$content = file_get_contents($sourcePath);
36+
if ($content === false) {
37+
throw new StorageException("Cannot read source file", 'upload', $sourcePath);
38+
}
39+
40+
$options = new CreateBlockBlobOptions();
41+
42+
// Set content type
43+
if ($contentType === null) {
44+
$ext = strtolower(pathinfo($destinationPath, PATHINFO_EXTENSION));
45+
$contentType = self::MIME_TYPES[$ext] ?? 'application/octet-stream';
46+
}
47+
$options->setContentType($contentType);
48+
49+
// Set cache control based on content type
50+
if (str_starts_with($contentType, 'image/')) {
51+
$options->setCacheControl('public, max-age=31536000'); // 1 year for images
52+
} else {
53+
$options->setCacheControl('public, max-age=3600'); // 1 hour for audio
54+
}
55+
56+
$this->client->createBlockBlob($this->containerName, $destinationPath, $content, $options);
57+
return true;
58+
} catch (ServiceException $e) {
59+
throw new StorageException("Azure upload failed: " . $e->getMessage(), 'upload', $destinationPath, $e);
60+
}
61+
}
62+
63+
public function delete(string $path): bool {
64+
try {
65+
$this->client->deleteBlob($this->containerName, $path);
66+
return true;
67+
} catch (ServiceException $e) {
68+
if ($e->getCode() === 404) {
69+
return true; // File doesn't exist = success
70+
}
71+
throw new StorageException("Azure delete failed: " . $e->getMessage(), 'delete', $path, $e);
72+
}
73+
}
74+
75+
public function deleteByPrefix(string $prefix): array {
76+
try {
77+
$options = new ListBlobsOptions();
78+
$options->setPrefix($prefix);
79+
80+
$deleted = [];
81+
$result = $this->client->listBlobs($this->containerName, $options);
82+
83+
foreach ($result->getBlobs() as $blob) {
84+
$blobName = $blob->getName();
85+
$this->client->deleteBlob($this->containerName, $blobName);
86+
$deleted[] = $blobName;
87+
}
88+
return $deleted;
89+
} catch (ServiceException $e) {
90+
throw new StorageException("Azure deleteByPrefix failed: " . $e->getMessage(), 'deleteByPrefix', $prefix, $e);
91+
}
92+
}
93+
94+
public function rename(string $oldPath, string $newPath): bool {
95+
try {
96+
// Azure doesn't support rename - must copy then delete
97+
$this->client->copyBlob($this->containerName, $newPath, $this->containerName, $oldPath);
98+
99+
// Wait for the copy to complete before deleting
100+
$this->waitForCopyCompletion($newPath);
101+
102+
$this->client->deleteBlob($this->containerName, $oldPath);
103+
return true;
104+
} catch (ServiceException $e) {
105+
throw new StorageException("Azure rename failed: " . $e->getMessage(), 'rename', $oldPath, $e);
106+
}
107+
}
108+
109+
public function copy(string $sourcePath, string $destinationPath): bool {
110+
try {
111+
$this->client->copyBlob($this->containerName, $destinationPath, $this->containerName, $sourcePath);
112+
return true;
113+
} catch (ServiceException $e) {
114+
throw new StorageException("Azure copy failed: " . $e->getMessage(), 'copy', $sourcePath, $e);
115+
}
116+
}
117+
118+
public function exists(string $path): bool {
119+
try {
120+
$this->client->getBlobMetadata($this->containerName, $path);
121+
return true;
122+
} catch (ServiceException $e) {
123+
if ($e->getCode() === 404) {
124+
return false;
125+
}
126+
throw new StorageException("Azure exists check failed: " . $e->getMessage(), 'exists', $path, $e);
127+
}
128+
}
129+
130+
public function getUrl(string $path, bool $cacheBust = false): string {
131+
// Encode each path segment to handle special characters while preserving directory separators
132+
$segments = explode('/', $path);
133+
$encodedSegments = array_map('rawurlencode', $segments);
134+
$encodedPath = implode('/', $encodedSegments);
135+
136+
$url = $this->baseUrl . $encodedPath;
137+
if ($cacheBust) {
138+
$url .= '?v=' . time();
139+
}
140+
return $url;
141+
}
142+
143+
public function getBaseUrl(): string {
144+
return $this->baseUrl;
145+
}
146+
147+
/**
148+
* Waits for an asynchronous blob copy operation to complete.
149+
* Polls the blob properties until the copy status is 'success' or fails.
150+
*
151+
* @param string $blobPath Path to the destination blob being copied
152+
* @throws StorageException If copy fails or times out
153+
*/
154+
private function waitForCopyCompletion(string $blobPath): void {
155+
$maxAttempts = 60; // Maximum 60 attempts
156+
$sleepSeconds = 1; // Wait 1 second between polls
157+
158+
for ($i = 0; $i < $maxAttempts; $i++) {
159+
try {
160+
$properties = $this->client->getBlobProperties($this->containerName, $blobPath);
161+
$copyStatus = $properties->getProperties()->getCopyState();
162+
163+
if ($copyStatus === null) {
164+
// No copy operation found - might be a same-container instant copy
165+
return;
166+
}
167+
168+
$status = $copyStatus->getStatus();
169+
170+
if ($status === 'success') {
171+
return;
172+
}
173+
174+
if ($status === 'failed' || $status === 'aborted') {
175+
throw new StorageException(
176+
"Blob copy failed with status: {$status}",
177+
'waitForCopyCompletion',
178+
$blobPath
179+
);
180+
}
181+
182+
// Status is 'pending' - wait and retry
183+
sleep($sleepSeconds);
184+
185+
} catch (ServiceException $e) {
186+
throw new StorageException(
187+
"Failed to check copy status: " . $e->getMessage(),
188+
'waitForCopyCompletion',
189+
$blobPath,
190+
$e
191+
);
192+
}
193+
}
194+
195+
throw new StorageException(
196+
"Blob copy timed out after {$maxAttempts} seconds",
197+
'waitForCopyCompletion',
198+
$blobPath
199+
);
200+
}
201+
}

0 commit comments

Comments
 (0)