Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
585 changes: 585 additions & 0 deletions ACTIONS_API.md

Large diffs are not rendered by default.

372 changes: 225 additions & 147 deletions resources/js/hyper.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion resources/js/hyper.js.map

Large diffs are not rendered by default.

36 changes: 36 additions & 0 deletions src/Http/HyperSignal.php
Original file line number Diff line number Diff line change
Expand Up @@ -576,6 +576,42 @@ public function storeMultiple(array $mapping, string $disk = 'public'): array
return $this->getFileStorage()->storeMultiple($mapping, $disk);
}

/**
* Get original filename from Datastar RC6 signal format
*
* Extracts the original filename from RC6 format file signals. Returns null for
* legacy format or when signal doesn't contain file data. Useful for preserving
* original filenames or displaying file names in UI.
*
* RC6 Format: [{name: 'photo.jpg', contents: 'base64...', mime: 'image/jpeg'}]
*
* @param string $signalKey Signal key containing file data
*
* @return string|null Original filename if available, null otherwise
*/
public function getFilename(string $signalKey): ?string
{
return $this->getFileStorage()->getFilename($signalKey);
}

/**
* Get MIME type from Datastar RC6 signal format
*
* Extracts the MIME type from RC6 format file signals without needing to decode
* the base64 data. Returns null when signal doesn't contain file data. Useful
* for validation and content-type determination.
*
* RC6 Format: [{name: 'photo.jpg', contents: 'base64...', mime: 'image/jpeg'}]
*
* @param string $signalKey Signal key containing file data
*
* @return string|null MIME type if available, null otherwise
*/
public function getMimeType(string $signalKey): ?string
{
return $this->getFileStorage()->getMimeType($signalKey);
}

/**
* Static helper for quick access
*
Expand Down
136 changes: 120 additions & 16 deletions src/Services/HyperFileStorage.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,12 @@
* Provides methods for storing base64 data to Laravel filesystem disks with automatic MIME
* type detection, extension resolution, and unique filename generation.
*
* Supports Datastar signal format where file inputs are transmitted as base64 strings or
* arrays containing base64 data. Handles data URL prefixes by stripping metadata headers
* before decoding binary content for storage.
* Supports flexible input formats for maximum compatibility:
* - RC6 Format: [{name: 'photo.jpg', contents: 'base64...', mime: 'image/jpeg'}] (from file inputs)
* - Raw base64: 'base64string...' or ['base64string...'] (manual/custom formats)
*
* RC6 format provides additional metadata (filename, MIME type) accessible via getFilename()
* and getMimeType() methods. Raw base64 format works seamlessly but without metadata.
*
* Files are stored using Laravel Storage facade without creating temporary files on disk,
* enabling efficient memory-based processing for uploaded content. MIME type detection
Expand All @@ -32,23 +35,37 @@ class HyperFileStorage
* binary content, detects MIME type and extension, generates unique filename if not
* provided, and stores file to specified disk and directory path.
*
* Automatically uses the original filename from RC6 metadata if no custom filename
* is provided, preserving the user's original file name. Set $filename to false to
* force auto-generation.
*
* @param string $signalKey Signal key containing base64 file data
* @param string $directory Target directory path within disk (optional)
* @param string $disk Laravel filesystem disk name (default: public)
* @param string|null $filename Custom filename with extension (auto-generated if null)
* @param string|null|false $filename Custom filename with extension (null: use RC6 name, false: force auto-generate)
*
* @throws \InvalidArgumentException When signal key contains no base64 data
*
* @return string Stored file path relative to disk root
*/
public function store(string $signalKey, string $directory = '', string $disk = 'public', ?string $filename = null): string
public function store(string $signalKey, string $directory = '', string $disk = 'public', string|null|false $filename = null): string
{
$base64Data = $this->extractBase64FromSignals($signalKey);

if (empty($base64Data)) {
throw new InvalidArgumentException("No base64 data found for signal: {$signalKey}");
}

// Use RC6 filename if no custom filename provided
if ($filename === null) {
$filename = $this->getFilename($signalKey);
}

// Convert false to null for auto-generation
if ($filename === false) {
$filename = null;
}

return $this->storeBase64Data($base64Data, $directory, $disk, $filename);
}

Expand Down Expand Up @@ -106,38 +123,125 @@ public function storeMultiple(array $mapping, string $disk = 'public'): array
}

/**
* Extract base64 data from signal value handling Datastar format
* Extract base64 data from signal value with flexible format support
*
* Retrieves signal value using signals() helper, handles array format where Datastar
* transmits file inputs as single-element arrays, strips data URL metadata prefix if
* present, and returns clean base64 string ready for decoding.
* Supports both RC6 format and raw base64 strings for maximum flexibility:
* - RC6 Format: [{name: 'photo.jpg', contents: 'base64...', mime: 'image/jpeg'}]
* - Raw base64: 'base64string...' or ['base64string...']
* - Data URI: 'data:image/png;base64,base64string...'
*
* @param string $key Signal key containing file data
*
* @return string Clean base64 string without data URL prefix, empty string if no data
* @return string Clean base64 string, empty string if no data
*/
private function extractBase64FromSignals(string $key): string
{
/** @var \Dancycodes\Hyper\Http\HyperSignal $hyperSignal */
$hyperSignal = signals();
$value = $hyperSignal->get($key);

// Handle raw string (base64 passed directly or data URI)
if (is_string($value)) {
$value = trim($value);

// Strip data URI prefix if present (e.g., "data:image/png;base64,")
if (str_starts_with($value, 'data:')) {
$parts = explode(',', $value, 2);
if (count($parts) === 2) {
$value = $parts[1];
}
}

return $value;
}

// Handle array format
if (is_array($value)) {
if (empty($value)) {
return '';
}
$value = $value[0];

$firstItem = $value[0];

// RC6 format: object with 'contents' key
if (is_array($firstItem) && isset($firstItem['contents'])) {
$contents = $firstItem['contents'];
if (is_string($contents)) {
return trim($contents);
}
}

// Raw format: plain base64 string in array
if (is_string($firstItem)) {
return trim($firstItem);
}
}

if (!is_string($value)) {
return '';
return '';
}

/**
* Extract original filename from Datastar RC6 signal format
*
* Retrieves signal value and extracts the original filename from RC6 metadata.
* Returns null when filename is not available. Useful for preserving original
* filenames during storage operations.
*
* RC6 Format: [{name: 'photo.jpg', contents: 'base64...', mime: 'image/jpeg'}]
*
* @param string $signalKey Signal key containing file data
*
* @return string|null Original filename if available, null otherwise
*/
public function getFilename(string $signalKey): ?string
{
/** @var \Dancycodes\Hyper\Http\HyperSignal $hyperSignal */
$hyperSignal = signals();
$value = $hyperSignal->get($signalKey);

if (!is_array($value) || empty($value)) {
return null;
}

$firstItem = $value[0];

if (is_array($firstItem) && isset($firstItem['name']) && is_string($firstItem['name'])) {
return $firstItem['name'];
}

return null;
}

/**
* Extract MIME type from Datastar RC6 signal format
*
* Retrieves signal value and extracts the MIME type from RC6 metadata.
* Returns null when MIME type is not available. Useful for validation and
* content-type determination without decoding base64 data.
*
* RC6 Format: [{name: 'photo.jpg', contents: 'base64...', mime: 'image/jpeg'}]
*
* @param string $signalKey Signal key containing file data
*
* @return string|null MIME type if available, null otherwise
*/
public function getMimeType(string $signalKey): ?string
{
/** @var \Dancycodes\Hyper\Http\HyperSignal $hyperSignal */
$hyperSignal = signals();
$value = $hyperSignal->get($signalKey);

if (!is_array($value) || empty($value)) {
return null;
}

if (strpos($value, ';base64,') !== false) {
[, $value] = explode(',', $value, 2);
$firstItem = $value[0];

if (is_array($firstItem) && isset($firstItem['mime']) && is_string($firstItem['mime'])) {
return $firstItem['mime'];
}

return trim($value);
return null;
}

/**
Expand Down
61 changes: 44 additions & 17 deletions src/Validation/HyperBase64Validator.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,14 @@
* reactive signals. Implements validation methods for image type verification, file size
* constraints, image dimension requirements, and MIME type checking.
*
* Handles Datastar signal format where file inputs are transmitted as single-element
* arrays, automatically extracting and stripping data URL prefixes before validation.
* All validators delegate empty value handling to required rule for consistent behavior.
* Handles both Datastar RC6 format (objects with {name, contents, mime}) and legacy
* RC5 format (plain base64 strings). File inputs are transmitted as single-element arrays,
* automatically extracting contents from RC6 objects or using plain strings from legacy
* format, and stripping data URL prefixes before validation. All validators delegate empty
* value handling to required rule for consistent behavior.
*
* RC6 Format: [{name: 'photo.jpg', contents: 'base64...', mime: 'image/jpeg'}]
* Legacy Format: ['base64...'] or 'data:image/png;base64,base64...'
*
* Validation rules perform memory-based operations without temporary files, using PHP's
* getimagesizefromstring for image validation and finfo extension for MIME type detection.
Expand Down Expand Up @@ -267,34 +272,56 @@ public function validateB64mimes(string $attribute, mixed $value, array $paramet
}

/**
* Extract base64 value from Datastar signal format or plain string
* Extract base64 value with flexible format support
*
* Handles array format where Datastar transmits file inputs as single-element arrays,
* extracts first element if array provided, returns empty string for non-string values,
* strips data URL metadata prefix if present, and returns trimmed base64 string.
* Supports both RC6 format and raw base64 strings for maximum flexibility:
* - RC6 Format: [{name: 'photo.jpg', contents: 'base64...', mime: 'image/jpeg'}]
* - Raw base64: 'base64string...' or ['base64string...']
*
* @param mixed $value Base64 string or Datastar array format
* @param mixed $value Signal value in various formats
*
* @return string Clean base64 string without data URL prefix
* @return string Clean base64 string
*/
private function extractBase64Value(mixed $value): string
{
// Handle raw string (base64 passed directly or data URI)
if (is_string($value)) {
$value = trim($value);

// Strip data URI prefix if present (e.g., "data:image/png;base64,")
if (str_starts_with($value, 'data:')) {
$parts = explode(',', $value, 2);
if (count($parts) === 2) {
$value = $parts[1];
}
}

return $value;
}

// Handle array format
if (is_array($value)) {
if (empty($value)) {
return '';
}
$value = $value[0];
}

if (!is_string($value)) {
return '';
}
$firstItem = $value[0];

// RC6 format: object with 'contents' key
if (is_array($firstItem) && isset($firstItem['contents'])) {
$contents = $firstItem['contents'];
if (is_string($contents)) {
return trim($contents);
}
}

if (strpos($value, ';base64,') !== false) {
[, $value] = explode(',', $value, 2);
// Raw format: plain base64 string in array
if (is_string($firstItem)) {
return trim($firstItem);
}
}

return trim($value);
return '';
}

/**
Expand Down
Loading