From 9478aa319fcfb3e2c57f6a836ea5cf1245a8cbc7 Mon Sep 17 00:00:00 2001 From: nfel Date: Thu, 1 Jan 2026 19:06:11 +0330 Subject: [PATCH] main: added presigned url + favicon Signed-off-by: nfel --- .env.example | 7 + app/album/[id]/page.tsx | 38 ++-- app/api/s3/presigned-url/route.ts | 69 ++++++ components/AddAlbumModal.tsx | 362 ++++++++++++++++++++++++++---- components/AlbumCard.tsx | 18 +- components/EditAlbumModal.tsx | 362 ++++++++++++++++++++++++++---- components/MusicPlayer.tsx | 6 +- lib/presigned-url.ts | 167 ++++++++++++++ lib/s3.ts | 50 ++++- public/favicon.ico | Bin 0 -> 5238 bytes 10 files changed, 955 insertions(+), 124 deletions(-) create mode 100644 app/api/s3/presigned-url/route.ts create mode 100644 lib/presigned-url.ts create mode 100644 public/favicon.ico diff --git a/.env.example b/.env.example index 35affa5..996cfda 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,12 @@ # ZarinPal Configuration ZARINPAL_MERCHANT_ID=your-merchant-id-here +# S3 / Object Storage Configuration +AWS_REGION=ir-thr-at1 +AWS_S3_ENDPOINT=https://s3.ir-thr-at1.arvanstorage.ir +AWS_S3_BUCKET=your-bucket-name +AWS_ACCESS_KEY_ID=your-access-key-id +AWS_SECRET_ACCESS_KEY=your-secret-access-key + # Environment NODE_ENV=development diff --git a/app/album/[id]/page.tsx b/app/album/[id]/page.tsx index d60eb36..534f50f 100644 --- a/app/album/[id]/page.tsx +++ b/app/album/[id]/page.tsx @@ -147,11 +147,21 @@ export default function AlbumDetailPage() { transition={{ duration: 0.6 }} className="relative" > -
-
-
- {album.title} -
+
+ {album.coverImage ? ( + {album.title} + ) : ( +
+
+
+ {album.title} +
+
+ )}
{isPurchased && ( @@ -250,29 +260,29 @@ export default function AlbumDetailPage() { initial={{ opacity: 0, x: -20 }} animate={{ opacity: 1, x: 0 }} transition={{ duration: 0.4, delay: index * 0.05 }} - className="flex items-center justify-between p-4 hover:bg-paper-sand border-2 border-transparent hover:border-paper-brown transition-all group cursor-pointer" + className="flex items-center justify-between p-3 md:p-4 hover:bg-paper-sand border-2 border-transparent hover:border-paper-brown transition-all group cursor-pointer" onClick={() => setCurrentAlbum(album)} > -
- +
+ {String(index + 1).padStart(2, '0')} -
-

+
+

{song.title}

-
+
{!isPurchased && ( - + Preview )} - + {song.duration} - +
))} diff --git a/app/api/s3/presigned-url/route.ts b/app/api/s3/presigned-url/route.ts new file mode 100644 index 0000000..9137ded --- /dev/null +++ b/app/api/s3/presigned-url/route.ts @@ -0,0 +1,69 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getPresignedUrl, getMultiplePresignedUrls } from '@/lib/s3'; + +/** + * Generate pre-signed URL(s) for S3 objects + * POST /api/s3/presigned-url + * + * Body: + * - Single key: { "key": "path/to/file.mp3", "expiresIn": 3600 } + * - Multiple keys: { "keys": ["path/file1.mp3", "path/file2.mp3"], "expiresIn": 3600 } + */ +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { key, keys, expiresIn = 3600 } = body; + + // Validate expiration time (max 7 days) + if (expiresIn > 604800) { + return NextResponse.json( + { error: 'expiresIn cannot exceed 7 days (604800 seconds)' }, + { status: 400 } + ); + } + + // Handle single key + if (key) { + if (typeof key !== 'string' || !key.trim()) { + return NextResponse.json( + { error: 'Invalid key provided' }, + { status: 400 } + ); + } + + const url = await getPresignedUrl(key, expiresIn); + return NextResponse.json({ url, expiresIn }, { status: 200 }); + } + + // Handle multiple keys + if (keys) { + if (!Array.isArray(keys) || keys.length === 0) { + return NextResponse.json( + { error: 'keys must be a non-empty array' }, + { status: 400 } + ); + } + + if (keys.length > 100) { + return NextResponse.json( + { error: 'Cannot generate more than 100 URLs at once' }, + { status: 400 } + ); + } + + const urls = await getMultiplePresignedUrls(keys, expiresIn); + return NextResponse.json({ urls, expiresIn }, { status: 200 }); + } + + return NextResponse.json( + { error: 'Either "key" or "keys" must be provided' }, + { status: 400 } + ); + } catch (error: any) { + console.error('Presigned URL generation error:', error); + return NextResponse.json( + { error: error.message || 'Failed to generate presigned URL' }, + { status: 500 } + ); + } +} diff --git a/components/AddAlbumModal.tsx b/components/AddAlbumModal.tsx index 0260562..8d3ea4f 100644 --- a/components/AddAlbumModal.tsx +++ b/components/AddAlbumModal.tsx @@ -25,6 +25,10 @@ export default function AddAlbumModal({ show, onClose, onAdd }: AddAlbumModalPro const [tag, setTag] = useState<'Album' | 'EP' | 'Demo' | 'Deluxe' | 'Single'>('Album'); const [format, setFormat] = useState<'mp3' | 'm4a' | 'flac' | 'wav'>('mp3'); const [bitrate, setBitrate] = useState('320kbps'); + const [uploadMethod, setUploadMethod] = useState<'file' | 'url'>('file'); + const [isDragging, setIsDragging] = useState(false); + const [songUploadMethod, setSongUploadMethod] = useState<{ [key: number]: { preview: 'file' | 'url', full: 'file' | 'url' } }>({}); + const [uploadingSong, setUploadingSong] = useState<{ index: number, field: 'preview' | 'full' } | null>(null); const handleAddSong = () => { setSongs([...songs, { id: '', title: '', duration: '', previewUrl: '', fullUrl: '' }]); @@ -42,10 +46,7 @@ export default function AddAlbumModal({ show, onClose, onAdd }: AddAlbumModalPro setSongs(updatedSongs); }; - const handleCoverImageChange = async (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; - + const uploadFile = async (file: File) => { setCoverImageFile(file); setIsUploading(true); setError(''); @@ -74,6 +75,74 @@ export default function AddAlbumModal({ show, onClose, onAdd }: AddAlbumModalPro } }; + const handleCoverImageChange = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + await uploadFile(file); + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(true); + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + }; + + const handleDrop = async (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + + const file = e.dataTransfer.files[0]; + if (!file) return; + + // Validate file type + const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/gif']; + if (!allowedTypes.includes(file.type)) { + setError('Invalid file type. Only images are allowed.'); + return; + } + + await uploadFile(file); + }; + + const uploadAudioFile = async (file: File, index: number, field: 'preview' | 'full') => { + setUploadingSong({ index, field }); + setError(''); + + try { + const formData = new FormData(); + formData.append('file', file); + formData.append('folder', field === 'preview' ? 'audio/previews' : 'audio/full'); + + const response = await fetch('/api/upload/audio', { + method: 'POST', + body: formData, + }); + + if (!response.ok) { + throw new Error('Upload failed'); + } + + const data = await response.json(); + const updatedSongs = [...songs]; + updatedSongs[index][field === 'preview' ? 'previewUrl' : 'fullUrl'] = data.url; + setSongs(updatedSongs); + } catch (err) { + setError(`Failed to upload ${field} audio file`); + } finally { + setUploadingSong(null); + } + }; + + const handleAudioFileChange = async (e: React.ChangeEvent, index: number, field: 'preview' | 'full') => { + const file = e.target.files?.[0]; + if (!file) return; + await uploadAudioFile(file, index, field); + }; + const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); setError(''); @@ -215,43 +284,114 @@ export default function AddAlbumModal({ show, onClose, onAdd }: AddAlbumModalPro -
-
- handleSongChange(index, 'fullUrl', e.target.value)} - placeholder="Song URL (e.g., https://example.com/song.mp3)" - className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg focus:border-accent-cyan focus:outline-none focus:ring-2 focus:ring-accent-cyan/50 text-white placeholder-gray-500 text-sm" - /> - handleSongChange(index, 'previewUrl', e.target.value)} - placeholder="Preview URL (optional - 30s clip)" - className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg focus:border-accent-cyan focus:outline-none focus:ring-2 focus:ring-accent-cyan/50 text-white placeholder-gray-500 text-sm" - /> + + {/* Full Song Audio */} +
+ +
+ + +
+ {songUploadMethod[index]?.full === 'file' ? ( +
+ {uploadingSong?.index === index && uploadingSong.field === 'full' ? ( +
+
+

Uploading...

+
+ ) : song.fullUrl ? ( +
+ {song.fullUrl.split('/').pop()} + +
+ ) : ( + + )} +
+ ) : ( + handleSongChange(index, 'fullUrl', e.target.value)} + placeholder="https://example.com/song.mp3" + className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg focus:border-accent-cyan focus:outline-none focus:ring-2 focus:ring-accent-cyan/50 text-white placeholder-gray-500 text-sm" + /> + )} +
+ + {/* Preview Audio */} +
+ +
+ + +
+ {songUploadMethod[index]?.preview === 'file' ? ( +
+ {uploadingSong?.index === index && uploadingSong.field === 'preview' ? ( +
+
+

Uploading...

+
+ ) : song.previewUrl ? ( +
+ {song.previewUrl.split('/').pop()} + +
+ ) : ( + + )} +
+ ) : ( + handleSongChange(index, 'previewUrl', e.target.value)} + placeholder="https://example.com/preview.mp3" + className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg focus:border-accent-cyan focus:outline-none focus:ring-2 focus:ring-accent-cyan/50 text-white placeholder-gray-500 text-sm" + /> + )} +
))}
diff --git a/components/AlbumCard.tsx b/components/AlbumCard.tsx index bfeaf2e..40e5925 100644 --- a/components/AlbumCard.tsx +++ b/components/AlbumCard.tsx @@ -47,10 +47,20 @@ export default function AlbumCard({ album, isPurchased, onPlay, onPurchase }: Al className="paper-card-light overflow-hidden group cursor-pointer hover:shadow-paper-lg transition-shadow" > {/* Album Cover */} -
-
- {album.title} -
+
+ {album.coverImage ? ( + {album.title} + ) : ( +
+
+ {album.title} +
+
+ )} {/* Overlay on hover */}
diff --git a/components/EditAlbumModal.tsx b/components/EditAlbumModal.tsx index 944512d..e24f39b 100644 --- a/components/EditAlbumModal.tsx +++ b/components/EditAlbumModal.tsx @@ -26,6 +26,10 @@ export default function EditAlbumModal({ show, album, onClose, onUpdate }: EditA const [tag, setTag] = useState<'Album' | 'EP' | 'Demo' | 'Deluxe' | 'Single'>('Album'); const [format, setFormat] = useState<'mp3' | 'm4a' | 'flac' | 'wav'>('mp3'); const [bitrate, setBitrate] = useState('320kbps'); + const [uploadMethod, setUploadMethod] = useState<'file' | 'url'>('file'); + const [isDragging, setIsDragging] = useState(false); + const [songUploadMethod, setSongUploadMethod] = useState<{ [key: number]: { preview: 'file' | 'url', full: 'file' | 'url' } }>({}); + const [uploadingSong, setUploadingSong] = useState<{ index: number, field: 'preview' | 'full' } | null>(null); // Populate form when album changes useEffect(() => { @@ -59,10 +63,7 @@ export default function EditAlbumModal({ show, album, onClose, onUpdate }: EditA setSongs(updatedSongs); }; - const handleCoverImageChange = async (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (!file) return; - + const uploadFile = async (file: File) => { setCoverImageFile(file); setIsUploading(true); setError(''); @@ -91,6 +92,74 @@ export default function EditAlbumModal({ show, album, onClose, onUpdate }: EditA } }; + const handleCoverImageChange = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + await uploadFile(file); + }; + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(true); + }; + + const handleDragLeave = (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + }; + + const handleDrop = async (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + + const file = e.dataTransfer.files[0]; + if (!file) return; + + // Validate file type + const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/webp', 'image/gif']; + if (!allowedTypes.includes(file.type)) { + setError('Invalid file type. Only images are allowed.'); + return; + } + + await uploadFile(file); + }; + + const uploadAudioFile = async (file: File, index: number, field: 'preview' | 'full') => { + setUploadingSong({ index, field }); + setError(''); + + try { + const formData = new FormData(); + formData.append('file', file); + formData.append('folder', field === 'preview' ? 'audio/previews' : 'audio/full'); + + const response = await fetch('/api/upload/audio', { + method: 'POST', + body: formData, + }); + + if (!response.ok) { + throw new Error('Upload failed'); + } + + const data = await response.json(); + const updatedSongs = [...songs]; + updatedSongs[index][field === 'preview' ? 'previewUrl' : 'fullUrl'] = data.url; + setSongs(updatedSongs); + } catch (err) { + setError(`Failed to upload ${field} audio file`); + } finally { + setUploadingSong(null); + } + }; + + const handleAudioFileChange = async (e: React.ChangeEvent, index: number, field: 'preview' | 'full') => { + const file = e.target.files?.[0]; + if (!file) return; + await uploadAudioFile(file, index, field); + }; + const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); setError(''); @@ -224,43 +293,114 @@ export default function EditAlbumModal({ show, album, onClose, onUpdate }: EditA -
-
- handleSongChange(index, 'fullUrl', e.target.value)} - placeholder="Song URL (e.g., https://example.com/song.mp3)" - className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg focus:border-accent-orange focus:outline-none focus:ring-2 focus:ring-accent-orange/50 text-white placeholder-gray-500 text-sm" - /> - handleSongChange(index, 'previewUrl', e.target.value)} - placeholder="Preview URL (optional - 30s clip)" - className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg focus:border-accent-orange focus:outline-none focus:ring-2 focus:ring-accent-orange/50 text-white placeholder-gray-500 text-sm" - /> + + {/* Full Song Audio */} +
+ +
+ + +
+ {songUploadMethod[index]?.full === 'file' ? ( +
+ {uploadingSong?.index === index && uploadingSong.field === 'full' ? ( +
+
+

Uploading...

+
+ ) : song.fullUrl ? ( +
+ {song.fullUrl.split('/').pop()} + +
+ ) : ( + + )} +
+ ) : ( + handleSongChange(index, 'fullUrl', e.target.value)} + placeholder="https://example.com/song.mp3" + className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg focus:border-accent-orange focus:outline-none focus:ring-2 focus:ring-accent-orange/50 text-white placeholder-gray-500 text-sm" + /> + )} +
+ + {/* Preview Audio */} +
+ +
+ + +
+ {songUploadMethod[index]?.preview === 'file' ? ( +
+ {uploadingSong?.index === index && uploadingSong.field === 'preview' ? ( +
+
+

Uploading...

+
+ ) : song.previewUrl ? ( +
+ {song.previewUrl.split('/').pop()} + +
+ ) : ( + + )} +
+ ) : ( + handleSongChange(index, 'previewUrl', e.target.value)} + placeholder="https://example.com/preview.mp3" + className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg focus:border-accent-orange focus:outline-none focus:ring-2 focus:ring-accent-orange/50 text-white placeholder-gray-500 text-sm" + /> + )} +
))}
diff --git a/components/MusicPlayer.tsx b/components/MusicPlayer.tsx index e6d43da..fce73a9 100644 --- a/components/MusicPlayer.tsx +++ b/components/MusicPlayer.tsx @@ -149,9 +149,9 @@ export default function MusicPlayer({ album, isPurchased, onClose, onPurchase }:
-
- {album.title} -
+ {/*
*/} + {album.title} + {/*
*/}
{/* Preview Badge */} diff --git a/lib/presigned-url.ts b/lib/presigned-url.ts new file mode 100644 index 0000000..55b169a --- /dev/null +++ b/lib/presigned-url.ts @@ -0,0 +1,167 @@ +/** + * Client-side utility for generating pre-signed URLs from S3 keys + */ + +interface PresignedUrlCache { + url: string; + expiresAt: number; +} + +// Cache to avoid regenerating URLs that haven't expired +const urlCache = new Map(); + +/** + * Check if a URL is an S3 key (not a full URL) + */ +export function isS3Key(urlOrKey: string): boolean { + // If it starts with http:// or https://, it's already a URL + if (urlOrKey.startsWith('http://') || urlOrKey.startsWith('https://')) { + return false; + } + // Otherwise, treat it as an S3 key + return true; +} + +/** + * Get a pre-signed URL for an S3 key + * @param key - The S3 object key + * @param expiresIn - Expiration time in seconds (default: 1 hour) + * @param useCache - Whether to use cached URLs (default: true) + * @returns Pre-signed URL or the original key if it's already a URL + */ +export async function getPresignedUrl( + key: string, + expiresIn: number = 3600, + useCache: boolean = true +): Promise { + // If it's already a full URL, return it as-is + if (!isS3Key(key)) { + return key; + } + + // Check cache + if (useCache) { + const cached = urlCache.get(key); + if (cached && cached.expiresAt > Date.now()) { + return cached.url; + } + } + + try { + const response = await fetch('/api/s3/presigned-url', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ key, expiresIn }), + }); + + if (!response.ok) { + throw new Error('Failed to generate presigned URL'); + } + + const data = await response.json(); + + // Cache the URL (expire 5 minutes before actual expiration for safety) + if (useCache) { + urlCache.set(key, { + url: data.url, + expiresAt: Date.now() + (expiresIn - 300) * 1000, + }); + } + + return data.url; + } catch (error) { + console.error('Error generating presigned URL:', error); + // Fallback: return the key itself (might fail, but better than nothing) + return key; + } +} + +/** + * Get pre-signed URLs for multiple S3 keys at once + * @param keys - Array of S3 object keys + * @param expiresIn - Expiration time in seconds (default: 1 hour) + * @returns Map of key to pre-signed URL + */ +export async function getMultiplePresignedUrls( + keys: string[], + expiresIn: number = 3600 +): Promise> { + // Separate keys that need pre-signed URLs from full URLs + const s3Keys: string[] = []; + const result: Record = {}; + + for (const key of keys) { + if (isS3Key(key)) { + // Check cache first + const cached = urlCache.get(key); + if (cached && cached.expiresAt > Date.now()) { + result[key] = cached.url; + } else { + s3Keys.push(key); + } + } else { + // Already a full URL + result[key] = key; + } + } + + // If no keys need pre-signed URLs, return early + if (s3Keys.length === 0) { + return result; + } + + try { + const response = await fetch('/api/s3/presigned-url', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ keys: s3Keys, expiresIn }), + }); + + if (!response.ok) { + throw new Error('Failed to generate presigned URLs'); + } + + const data = await response.json(); + const urls = data.urls as Record; + + // Cache the URLs + const cacheExpiresAt = Date.now() + (expiresIn - 300) * 1000; + for (const [key, url] of Object.entries(urls)) { + urlCache.set(key, { url, expiresAt: cacheExpiresAt }); + result[key] = url; + } + + return result; + } catch (error) { + console.error('Error generating presigned URLs:', error); + // Fallback: return the keys themselves + for (const key of s3Keys) { + result[key] = key; + } + return result; + } +} + +/** + * Clear the URL cache (useful when you want to force refresh) + */ +export function clearUrlCache(): void { + urlCache.clear(); +} + +/** + * Clear expired URLs from cache + */ +export function cleanupUrlCache(): void { + const now = Date.now(); + for (const [key, cached] of urlCache.entries()) { + if (cached.expiresAt <= now) { + urlCache.delete(key); + } + } +} + +// Automatically cleanup cache every 5 minutes +if (typeof window !== 'undefined') { + setInterval(cleanupUrlCache, 5 * 60 * 1000); +} diff --git a/lib/s3.ts b/lib/s3.ts index e0172cf..5f83b34 100644 --- a/lib/s3.ts +++ b/lib/s3.ts @@ -1,4 +1,4 @@ -import { S3Client, PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3'; +import { S3Client, PutObjectCommand, DeleteObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; // Initialize S3 client @@ -17,13 +17,14 @@ export interface UploadFileParams { file: File; key: string; contentType?: string; + makePublic?: boolean; // Set to false for private files } /** * Upload a file to S3 */ export async function uploadFileToS3(params: UploadFileParams): Promise { - const { file, key, contentType } = params; + const { file, key, contentType, makePublic = true } = params; // Convert File to Buffer const arrayBuffer = await file.arrayBuffer(); @@ -33,14 +34,18 @@ export async function uploadFileToS3(params: UploadFileParams): Promise Bucket: BUCKET_NAME, Key: key, Body: buffer, - ACL: "public-read", + ...(makePublic && { ACL: "public-read" }), // Only set ACL if makePublic is true ContentType: contentType || file.type, }); await s3Client.send(command); - // Return the public URL - return `https://${BUCKET_NAME}.s3.ir-thr-at1.arvanstorage.ir/${key}`; + // Return the public URL if public, otherwise return the key + if (makePublic) { + return `https://${BUCKET_NAME}.s3.ir-thr-at1.arvanstorage.ir/${key}`; + } else { + return key; // Return just the key for private files + } } /** @@ -69,6 +74,41 @@ export async function getPresignedUploadUrl(key: string, contentType: string): P return url; } +/** + * Generate a presigned URL for downloading/viewing a file + * @param key - The S3 object key + * @param expiresIn - Expiration time in seconds (default: 1 hour) + * @returns Pre-signed URL + */ +export async function getPresignedUrl(key: string, expiresIn: number = 3600): Promise { + const command = new GetObjectCommand({ + Bucket: BUCKET_NAME, + Key: key, + }); + + const url = await getSignedUrl(s3Client, command, { expiresIn }); + return url; +} + +/** + * Generate multiple presigned URLs for a list of keys + * @param keys - Array of S3 object keys + * @param expiresIn - Expiration time in seconds (default: 1 hour) + * @returns Map of key to pre-signed URL + */ +export async function getMultiplePresignedUrls( + keys: string[], + expiresIn: number = 3600 +): Promise> { + const urlPromises = keys.map(async (key) => { + const url = await getPresignedUrl(key, expiresIn); + return [key, url]; + }); + + const entries = await Promise.all(urlPromises); + return Object.fromEntries(entries); +} + /** * Generate a unique key for file uploads */ diff --git a/public/favicon.ico b/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..635dd3db344b6afe78d05c712a1fb898f3fb8042 GIT binary patch literal 5238 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 OT0gPI@2~!G_P+o@t7BmR literal 0 HcmV?d00001