Files
rspade_system/app/RSpade/Core/Files/CLAUDE.md
root 949cc17b0d Fix preset thumbnail route conflict, improve docs
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-28 23:10:17 +00:00

6.9 KiB
Executable File

File Attachments System

Overview

The RSpade file attachment system provides secure, session-based file uploads with automatic thumbnail generation and polymorphic model associations.

Upload Flow

Security Model: Files upload UNATTACHED → validate → assign via API

  1. User uploads file to /_upload endpoint
  2. File saved with session_id, no model association
  3. Returns unique key to frontend
  4. Frontend calls API endpoint with key
  5. Backend validates ownership and assigns to model

Security Implementation

Session-based validation prevents cross-user file assignment:

  • Files get session_id on upload
  • can_user_assign_this_file() validates:
    • File not already assigned
    • Same site_id as user's session
    • Same session_id (prevents cross-user assignment)
  • User-provided fileable_* params ignored during upload

Attachment API

File_Attachment_Model Methods

// Find attachment by key
$attachment = File_Attachment_Model::find_by_key($key);

// Validate user can assign
if ($attachment->can_user_assign_this_file()) {
    // Single file attachment (replaces existing)
    $attachment->attach_to($user, 'profile_photo');

    // Multiple file attachment (adds to collection)
    $attachment->add_to($project, 'documents');
}

// Remove assignment
$attachment->detach();

// Check assignment status
if ($attachment->is_attached()) {
    // File is assigned to a model
}

Model Helper Methods

All models extending Rsx_Model_Abstract have attachment helpers:

// Get single attachment
$photo = $user->get_attachment('profile_photo');

// Get multiple attachments
$docs = $project->get_attachments('documents');

// Count attachments
$count = $project->count_attachments('documents');

// Check if has attachments
if ($user->has_attachment('profile_photo')) {
    // User has profile photo
}

Display URLs

// Thumbnail with specific dimensions
$photo->get_thumbnail_url('cover', 128, 128);

// Full file URL
$photo->get_url();

// Force download URL
$photo->get_download_url();

// Get file metadata
$size = $photo->file_size;
$mime = $photo->mime_type;
$name = $photo->original_filename;

Endpoints

  • /_upload - File upload endpoint
  • /_download/:key - Force download file
  • /_thumbnail/:key/:type/:w/:h - Generated thumbnail
  • /_file/:key - Direct file access

Thumbnail System

Thumbnails are generated on-demand and cached.

Dynamic thumbnail types (for get_thumbnail_url()):

  • 'cover' - Crops image to fill exact dimensions
  • 'fit' - Scales image to fit within dimensions, maintaining aspect ratio

Retina support: Thumbnails are automatically generated at 2x the requested dimensions. Request your CSS display size - a 300x200 request generates a 600x400 image.

Dynamic thumbnails:

  • Limited to max_dynamic_size (default 800px base, 1600px actual with 2x)
  • Cached for performance
  • Automatic cleanup via scheduled task

Preset thumbnails (for get_thumbnail_url_preset()):

Presets must be defined in config before use:

// rsx/resource/config/rsx.php
'thumbnails' => [
    'presets' => [
        'profile' => [
            'type' => 'cover',    // 'cover' or 'fit'
            'width' => 150,       // base width (CSS display size)
            'height' => 150,      // base height
        ],
        'card_image' => [
            'type' => 'cover',
            'width' => 400,
            'height' => 250,
        ],
        'document_preview' => [
            'type' => 'fit',
            'width' => 800,
            'height' => 600,
        ],
    ],
],

Usage:

$photo->get_thumbnail_url_preset('profile')
// Generates: /_thumbnail/preset/{key}/profile

Best practice: Create a preset for every fixed-size thumbnail in your application (profile photos, card images, list thumbnails, etc.). Presets are preserved permanently. Only use dynamic thumbnails when the size is determined at runtime or varies based on context (e.g., responsive galleries, user-resizable containers).

Controller Implementation Pattern

#[Ajax_Endpoint]
public static function save_with_photo(Request $request, array $params = [])
{
    // Validate required fields
    if (empty($params['name'])) {
        return response_form_error('Validation failed', [
            'name' => 'Name is required'
        ]);
    }

    // Save model
    $user = new User_Model();
    $user->name = $params['name'];
    $user->save();

    // Attach photo if provided
    if (!empty($params['photo_key'])) {
        $photo = File_Attachment_Model::find_by_key($params['photo_key']);

        if (!$photo || !$photo->can_user_assign_this_file()) {
            return response_form_error('Invalid file', [
                'photo' => 'File not found or access denied'
            ]);
        }

        $photo->attach_to($user, 'profile_photo');
    }

    return ['user_id' => $user->id];
}

Frontend Upload Component

// Using Rsx_File_Upload component
<Rsx_File_Upload
    $id="photo_upload"
    $accept="image/*"
    $max_size="5242880"
/>

// Get uploaded file key
const key = this.$id('photo_upload').component().get_file_key();

// Submit with form
const data = {
    name: this.$id('name').val(),
    photo_key: key
};
await Controller.save_with_photo(data);

Database Schema

file_attachments
├── id (bigint)
├── key (varchar 64, unique)
├── site_id (bigint)
├── session_id (bigint)
├── fileable_type (varchar 255, nullable)
├── fileable_id (bigint, nullable)
├── fileable_key (varchar 255, nullable)
├── storage_path (varchar 500)
├── original_filename (varchar 500)
├── mime_type (varchar 255)
├── file_size (bigint)
├── metadata (json)
└── timestamps

Configuration

In /system/config/rsx.php:

'attachments' => [
    'upload_dir' => storage_path('rsx-attachments'),
    'max_upload_size' => 10 * 1024 * 1024, // 10MB
    'allowed_extensions' => ['jpg', 'jpeg', 'png', 'gif', 'pdf', 'doc', 'docx'],
],

'thumbnails' => [
    'presets' => [
        'cover' => ['width' => 800, 'height' => 600],
        'square' => ['width' => 300, 'height' => 300],
    ],
    'max_dynamic_size' => 2000,
    'quotas' => [
        'preset_max_bytes' => 500 * 1024 * 1024, // 500MB
        'dynamic_max_bytes' => 100 * 1024 * 1024, // 100MB
    ],
],

Security Considerations

  1. Never trust client-provided fileable_ params* during upload
  2. Always validate ownership before assignment
  3. Use polymorphic associations for flexibility
  4. Implement access control in download endpoints
  5. Sanitize filenames to prevent directory traversal
  6. Validate MIME types server-side
  7. Set appropriate upload size limits
  8. Use scheduled cleanup for orphaned files

Scheduled Cleanup

Orphaned files (uploaded but never assigned) are cleaned automatically:

  • Files older than 24 hours without assignment
  • Runs daily via scheduled task
  • Preserves actively used files

See also: php artisan rsx:man file_upload