-- Users (extend existing)
ALTER TABLE users ADD COLUMN avatar_url VARCHAR(500) NULL;
ALTER TABLE users ADD COLUMN bio TEXT NULL;
ALTER TABLE users ADD COLUMN website VARCHAR(255) NULL;
ALTER TABLE users ADD COLUMN location VARCHAR(100) NULL;
ALTER TABLE users ADD COLUMN is_verified BOOLEAN DEFAULT FALSE;
ALTER TABLE users ADD COLUMN follower_count INT DEFAULT 0;
ALTER TABLE users ADD COLUMN following_count INT DEFAULT 0;
ALTER TABLE users ADD COLUMN models_count INT DEFAULT 0;
-- Models Table
CREATE TABLE models (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
uuid VARCHAR(36) UNIQUE NOT NULL,
title VARCHAR(255) NOT NULL,
description TEXT,
slug VARCHAR(255) UNIQUE,
author_id BIGINT NOT NULL,
category_id BIGINT NULL,
-- File Information
file_path VARCHAR(500) NOT NULL,
thumbnail_path VARCHAR(500) NULL,
preview_images JSON NULL, -- Array of preview image URLs
file_size BIGINT NOT NULL, -- bytes
file_format VARCHAR(20) NOT NULL, -- STL, OBJ, PLY, etc.
original_filename VARCHAR(255) NOT NULL,
-- Model Metadata
polygon_count INT NULL,
vertex_count INT NULL,
bounding_box JSON NULL, -- {x, y, z dimensions}
print_settings JSON NULL, -- Print recommendations
software_created VARCHAR(100) NULL,
software_version VARCHAR(50) NULL,
-- Status & Visibility
status ENUM('draft', 'pending_review', 'published', 'rejected', 'archived') DEFAULT 'draft',
visibility ENUM('public', 'unlisted', 'private') DEFAULT 'public',
is_featured BOOLEAN DEFAULT FALSE,
is_downloadable BOOLEAN DEFAULT TRUE,
is_remixable BOOLEAN DEFAULT TRUE,
-- Licensing
license VARCHAR(100) DEFAULT 'Creative Commons - Attribution',
commercial_use BOOLEAN DEFAULT TRUE,
attribution_required BOOLEAN DEFAULT TRUE,
-- SEO & Discovery
search_keywords TEXT NULL,
view_count INT DEFAULT 0,
-- Moderation
moderation_notes TEXT NULL,
moderated_by BIGINT NULL,
moderated_at TIMESTAMP NULL,
-- Timestamps
published_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at TIMESTAMP NULL,
-- Indexes
INDEX idx_models_author (author_id, status, published_at DESC),
INDEX idx_models_category (category_id, status, published_at DESC),
INDEX idx_models_featured (is_featured, status, published_at DESC),
INDEX idx_models_status (status, visibility, published_at DESC),
INDEX idx_models_search (title, description, search_keywords),
FULLTEXT idx_fulltext_search (title, description, search_keywords),
FOREIGN KEY (author_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (category_id) REFERENCES model_categories(id) ON DELETE SET NULL,
FOREIGN KEY (moderated_by) REFERENCES users(id) ON DELETE SET NULL
);
-- Model Categories
CREATE TABLE model_categories (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(100) NOT NULL UNIQUE,
slug VARCHAR(100) NOT NULL UNIQUE,
description TEXT,
parent_id BIGINT NULL,
icon VARCHAR(50) NULL,
color VARCHAR(7) NULL, -- Hex color
sort_order INT DEFAULT 0,
is_active BOOLEAN DEFAULT TRUE,
models_count INT DEFAULT 0,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_categories_parent (parent_id, sort_order),
INDEX idx_categories_active (is_active, sort_order),
INDEX idx_categories_models_count (models_count DESC),
FOREIGN KEY (parent_id) REFERENCES model_categories(id) ON DELETE CASCADE
);
-- Model Tags (Many-to-Many)
CREATE TABLE model_tags (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(50) NOT NULL UNIQUE,
slug VARCHAR(50) NOT NULL UNIQUE,
description TEXT NULL,
usage_count INT DEFAULT 0,
is_trending BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_tags_usage (usage_count DESC),
INDEX idx_tags_trending (is_trending, usage_count DESC),
INDEX idx_tags_name (name)
);
CREATE TABLE model_tag_assignments (
model_id BIGINT NOT NULL,
tag_id BIGINT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (model_id, tag_id),
INDEX idx_tag_assignments_tag (tag_id),
FOREIGN KEY (model_id) REFERENCES models(id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES model_tags(id) ON DELETE CASCADE
);
-- Model Analytics & Stats
CREATE TABLE model_stats (
model_id BIGINT PRIMARY KEY,
views_count INT DEFAULT 0,
unique_views_count INT DEFAULT 0,
downloads_count INT DEFAULT 0,
likes_count INT DEFAULT 0,
comments_count INT DEFAULT 0,
saves_count INT DEFAULT 0,
shares_count INT DEFAULT 0,
remixes_count INT DEFAULT 0,
-- Time-based analytics for trending
views_today INT DEFAULT 0,
views_week INT DEFAULT 0,
views_month INT DEFAULT 0,
downloads_week INT DEFAULT 0,
downloads_month INT DEFAULT 0,
-- Engagement metrics
avg_rating DECIMAL(3,2) DEFAULT 0.00,
rating_count INT DEFAULT 0,
bounce_rate DECIMAL(5,2) DEFAULT 0.00,
last_viewed_at TIMESTAMP NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_stats_trending (views_week DESC, likes_count DESC),
INDEX idx_stats_popular (downloads_count DESC, likes_count DESC),
INDEX idx_stats_rating (avg_rating DESC, rating_count DESC),
FOREIGN KEY (model_id) REFERENCES models(id) ON DELETE CASCADE
);
-- User Collections
CREATE TABLE user_collections (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
name VARCHAR(100) NOT NULL,
description TEXT,
is_public BOOLEAN DEFAULT FALSE,
is_featured BOOLEAN DEFAULT FALSE,
models_count INT DEFAULT 0,
cover_image_url VARCHAR(500) NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_collections_user (user_id, is_public),
INDEX idx_collections_featured (is_featured, models_count DESC),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE TABLE collection_models (
collection_id BIGINT NOT NULL,
model_id BIGINT NOT NULL,
added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
sort_order INT DEFAULT 0,
PRIMARY KEY (collection_id, model_id),
INDEX idx_collection_models_model (model_id),
INDEX idx_collection_models_sort (collection_id, sort_order),
FOREIGN KEY (collection_id) REFERENCES user_collections(id) ON DELETE CASCADE,
FOREIGN KEY (model_id) REFERENCES models(id) ON DELETE CASCADE
);
-- Model Likes
CREATE TABLE model_likes (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
model_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY unique_model_user_like (model_id, user_id),
INDEX idx_model_likes_model (model_id),
INDEX idx_model_likes_user (user_id, created_at DESC),
FOREIGN KEY (model_id) REFERENCES models(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- Model Comments
CREATE TABLE model_comments (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
model_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
parent_comment_id BIGINT NULL,
content TEXT NOT NULL,
is_edited BOOLEAN DEFAULT FALSE,
likes_count INT DEFAULT 0,
replies_count INT DEFAULT 0,
status ENUM('published', 'pending', 'hidden', 'spam') DEFAULT 'published',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted_at TIMESTAMP NULL,
INDEX idx_comments_model (model_id, status, created_at DESC),
INDEX idx_comments_parent (parent_comment_id),
INDEX idx_comments_user (user_id, created_at DESC),
INDEX idx_comments_status (status),
FOREIGN KEY (model_id) REFERENCES models(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (parent_comment_id) REFERENCES model_comments(id) ON DELETE CASCADE
);
-- Comment Likes
CREATE TABLE comment_likes (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
comment_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY unique_comment_user_like (comment_id, user_id),
INDEX idx_comment_likes_comment (comment_id),
INDEX idx_comment_likes_user (user_id),
FOREIGN KEY (comment_id) REFERENCES model_comments(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- User Saves/Bookmarks
CREATE TABLE model_saves (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
model_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY unique_model_user_save (model_id, user_id),
INDEX idx_model_saves_user (user_id, created_at DESC),
INDEX idx_model_saves_model (model_id),
FOREIGN KEY (model_id) REFERENCES models(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- User Follows
CREATE TABLE user_follows (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
follower_id BIGINT NOT NULL,
following_id BIGINT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY unique_follow_relationship (follower_id, following_id),
INDEX idx_follows_follower (follower_id),
INDEX idx_follows_following (following_id),
FOREIGN KEY (follower_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (following_id) REFERENCES users(id) ON DELETE CASCADE
);
-- Model Ratings
CREATE TABLE model_ratings (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
model_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
rating TINYINT NOT NULL CHECK (rating >= 1 AND rating <= 5),
review TEXT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY unique_model_user_rating (model_id, user_id),
INDEX idx_model_ratings_model (model_id, rating),
INDEX idx_model_ratings_user (user_id),
FOREIGN KEY (model_id) REFERENCES models(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- Upload Sessions (for chunked uploads)
CREATE TABLE upload_sessions (
id VARCHAR(36) PRIMARY KEY, -- UUID
user_id BIGINT NOT NULL,
filename VARCHAR(255) NOT NULL,
file_size BIGINT NOT NULL,
mime_type VARCHAR(100) NOT NULL,
chunks_total INT NOT NULL,
chunks_uploaded INT DEFAULT 0,
chunk_size INT NOT NULL,
status ENUM('pending', 'uploading', 'processing', 'completed', 'failed', 'expired') DEFAULT 'pending',
temp_path VARCHAR(500) NULL,
final_path VARCHAR(500) NULL,
metadata JSON NULL,
error_message TEXT NULL,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_upload_sessions_user (user_id, status),
INDEX idx_upload_sessions_expires (expires_at),
INDEX idx_upload_sessions_status (status, created_at),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
-- File Processing Jobs
CREATE TABLE file_processing_jobs (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
upload_session_id VARCHAR(36) NULL,
model_id BIGINT NULL,
job_type ENUM('validation', 'thumbnail', 'preview', 'optimization', 'virus_scan', 'metadata_extraction') NOT NULL,
status ENUM('pending', 'processing', 'completed', 'failed') DEFAULT 'pending',
progress INT DEFAULT 0, -- 0-100
result JSON NULL,
error_message TEXT NULL,
started_at TIMESTAMP NULL,
completed_at TIMESTAMP NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_processing_jobs_session (upload_session_id),
INDEX idx_processing_jobs_model (model_id),
INDEX idx_processing_jobs_status (status, created_at),
INDEX idx_processing_jobs_type (job_type, status),
FOREIGN KEY (upload_session_id) REFERENCES upload_sessions(id) ON DELETE CASCADE,
FOREIGN KEY (model_id) REFERENCES models(id) ON DELETE CASCADE
);
-- Download Tracking
CREATE TABLE model_downloads (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
model_id BIGINT NOT NULL,
user_id BIGINT NULL,
ip_address VARCHAR(45) NOT NULL,
user_agent TEXT NULL,
referrer VARCHAR(500) NULL,
country_code VARCHAR(2) NULL,
download_type ENUM('original', 'stl', 'obj', 'preview') DEFAULT 'original',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
INDEX idx_downloads_model (model_id, created_at),
INDEX idx_downloads_user (user_id, created_at),
INDEX idx_downloads_ip (ip_address, created_at),
INDEX idx_downloads_country (country_code, created_at),
FOREIGN KEY (model_id) REFERENCES models(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
);
storage/
âââ uploads/
â âââ temp/ # Temporary upload chunks
â â âââ {session-id}/
â â â âââ chunk_1
â â â âââ chunk_2
â â â âââ metadata.json
â âââ models/ # Final model files
â â âââ {year}/
â â â âââ {month}/
â â â â âââ {uuid}/
â â â â â âââ original.sldprt
â â â â â âââ optimized.stl
â â â â â âââ web_preview.obj
â â â â â âââ metadata.json
â âââ thumbnails/ # Generated thumbnails
â â âââ {year}/
â â â âââ {month}/
â â â â âââ {uuid}/
â â â â â âââ thumb_200.webp
â â â â â âââ thumb_400.webp
â â â â â âââ thumb_800.webp
â âââ previews/ # Preview images & videos
â âââ {year}/
â â âââ {month}/
â â â âââ {uuid}/
â â â â âââ preview_1.webp
â â â â âââ preview_2.webp
â â â â âââ preview_3.webp
â â â â âââ rotation_video.mp4
// File URL Generation Service
class FileUrlService {
public function getModelUrl(Model $model, string $type = 'original'): string {
$year = $model->created_at->format('Y');
$month = $model->created_at->format('m');
$basePath = "models/{$year}/{$month}/{$model->uuid}";
return match($type) {
'download' => $this->generateSignedUrl($basePath . '/original.' . $model->file_format),
'preview' => $this->getCdnUrl($basePath . '/web_preview.obj'),
'thumbnail' => $this->getCdnUrl("thumbnails/{$year}/{$month}/{$model->uuid}/thumb_400.webp"),
'thumbnail_large' => $this->getCdnUrl("thumbnails/{$year}/{$month}/{$model->uuid}/thumb_800.webp"),
default => $this->getCdnUrl($basePath . '/original.' . $model->file_format)
};
}
public function getPreviewImages(Model $model): array {
$year = $model->created_at->format('Y');
$month = $model->created_at->format('m');
$basePath = "previews/{$year}/{$month}/{$model->uuid}";
$images = [];
for ($i = 1; $i <= 5; $i++) {
$url = $this->getCdnUrl($basePath . "/preview_{$i}.webp");
if ($this->urlExists($url)) {
$images[] = $url;
}
}
return $images;
}
private function getCdnUrl(string $path): string {
return env('CDN_URL') . '/' . $path;
}
private function generateSignedUrl(string $path): string {
return Storage::disk('s3')->temporaryUrl($path, now()->addMinutes(30));
}
}
page (default: 1) - Page numberlimit (default: 24, max: 100) - Items per pagesort (newest|popular|trending|downloads|rating|featured) - Sort ordercategory - Category slug or IDsearch - Full-text search querytags - Comma-separated tag slugsfeatured (true|false) - Featured models onlyauthor - Author ID or usernamelicense - License type filter (cc|mit|proprietary|free)format - File format (stl|obj|solidworks|step|etc)min_rating - Minimum rating (1-5)downloadable (true|false) - Downloadable models onlycreated_after - ISO date for models created aftercreated_before - ISO date for models created before{
"data": [
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"title": "Mechanical Gear Assembly",
"description": "High-precision mechanical gear system for industrial applications...",
"slug": "mechanical-gear-assembly",
"thumbnailUrl": "https://cdn.solidprofessor.com/thumbnails/2025/01/550e8400/thumb_400.webp",
"previewImages": [
"https://cdn.solidprofessor.com/previews/2025/01/550e8400/preview_1.webp",
"https://cdn.solidprofessor.com/previews/2025/01/550e8400/preview_2.webp",
"https://cdn.solidprofessor.com/previews/2025/01/550e8400/preview_3.webp"
],
"author": {
"id": 123,
"name": "Alex Rodriguez",
"username": "alex_cad_pro",
"avatarUrl": "https://cdn.solidprofessor.com/avatars/123.webp",
"isVerified": true,
"badge": "Pro Designer",
"stats": {
"totalModels": 47,
"totalLikes": 2341,
"followers": 1250
}
},
"category": {
"id": 5,
"name": "Mechanical",
"slug": "mechanical",
"icon": "cog",
"color": "#348303"
},
"tags": ["gear", "mechanical", "automotive", "3d-printing", "industrial"],
"stats": {
"views": 1847,
"uniqueViews": 1203,
"downloads": 142,
"likes": 89,
"comments": 18,
"saves": 65,
"rating": 4.7,
"ratingCount": 23
},
"fileInfo": {
"format": "SOLIDWORKS",
"originalFormat": "SLDPRT",
"size": "2.4 MB",
"sizeBytes": 2516582,
"polygonCount": 15420,
"alternativeFormats": ["STL", "OBJ", "STEP"]
},
"interactions": {
"isLiked": false,
"isSaved": true,
"hasDownloaded": false,
"canDownload": true,
"userRating": null
},
"licensing": {
"type": "Creative Commons - Attribution",
"commercialUse": true,
"attributionRequired": true,
"remixAllowed": true
},
"features": {
"isFeatured": false,
"isDownloadable": true,
"isRemixable": true,
"hasPrintSettings": true,
"hasPreviewVideo": true
},
"publishedAt": "2025-01-20T10:00:00Z",
"createdAt": "2025-01-20T10:00:00Z",
"trending": {
"rank": 5,
"category": "weekly"
}
}
],
"meta": {
"currentPage": 1,
"lastPage": 15,
"perPage": 24,
"total": 350,
"from": 1,
"to": 24,
"hasMore": true
},
"filters": {
"categories": [
{
"id": 1,
"name": "Mechanical",
"slug": "mechanical",
"count": 150,
"icon": "cog",
"color": "#348303"
},
{
"id": 2,
"name": "Architectural",
"slug": "architectural",
"count": 75,
"icon": "building",
"color": "#2c5aa0"
}
],
"popularTags": [
{"name": "3d-printing", "count": 200},
{"name": "mechanical", "count": 150},
{"name": "gear", "count": 45},
{"name": "automotive", "count": 67}
],
"formats": [
{"name": "STL", "count": 280},
{"name": "OBJ", "count": 50},
{"name": "SOLIDWORKS", "count": 120},
{"name": "STEP", "count": 85}
],
"licenses": [
{"type": "CC Attribution", "count": 200},
{"type": "CC Share-Alike", "count": 80},
{"type": "MIT", "count": 45},
{"type": "Proprietary", "count": 25}
],
"ratings": {
"5": 45,
"4": 89,
"3": 67,
"2": 23,
"1": 8
}
},
"suggestions": {
"relatedSearches": ["mechanical gears", "gear train", "automotive parts"],
"trendingNow": ["electric vehicle parts", "drone components", "miniature mechanisms"]
}
}
{
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"title": "Mechanical Gear Assembly",
"description": "A comprehensive mechanical gear assembly designed for industrial applications. This model features precision-engineered teeth, optimal load distribution, and compatibility with standard mounting systems.\n\nDesigned with SolidWorks 2024, this assembly includes detailed drawings and manufacturing specifications. Perfect for educational purposes or as a starting point for custom gear systems.",
"slug": "mechanical-gear-assembly",
"author": {
"id": 123,
"name": "Alex Rodriguez",
"username": "alex_cad_pro",
"avatarUrl": "https://cdn.solidprofessor.com/avatars/123.webp",
"isVerified": true,
"badge": "Pro Designer",
"bio": "Mechanical engineer with 10+ years of experience in CAD design and manufacturing. Specialized in precision mechanical components.",
"website": "https://alexrodriguez.design",
"location": "San Francisco, CA",
"joinedDate": "2023-05-15T10:00:00Z",
"stats": {
"totalModels": 47,
"totalLikes": 2341,
"totalDownloads": 15420,
"followers": 1250,
"following": 89,
"avgRating": 4.6
},
"socialLinks": {
"linkedin": "https://linkedin.com/in/alexrodriguez",
"github": "https://github.com/alexcadpro"
},
"isFollowing": false
},
"files": {
"primary": {
"downloadUrl": "/api/v1/models/550e8400/download",
"filename": "gear_assembly_v2.sldprt",
"size": 2516582,
"sizeFormatted": "2.4 MB",
"format": "SOLIDWORKS",
"version": "2024"
},
"alternativeFormats": [
{
"downloadUrl": "/api/v1/models/550e8400/download?format=stl",
"filename": "gear_assembly_v2.stl",
"size": 1024000,
"sizeFormatted": "1.0 MB",
"format": "STL"
},
{
"downloadUrl": "/api/v1/models/550e8400/download?format=obj",
"filename": "gear_assembly_v2.obj",
"size": 856000,
"sizeFormatted": "856 KB",
"format": "OBJ"
},
{
"downloadUrl": "/api/v1/models/550e8400/download?format=step",
"filename": "gear_assembly_v2.step",
"size": 1536000,
"sizeFormatted": "1.5 MB",
"format": "STEP"
}
],
"supportingFiles": [
{
"type": "drawing",
"filename": "technical_drawing.pdf",
"size": 245760,
"downloadUrl": "/api/v1/models/550e8400/files/drawing"
},
{
"type": "assembly_instructions",
"filename": "assembly_guide.pdf",
"size": 512000,
"downloadUrl": "/api/v1/models/550e8400/files/instructions"
}
]
},
"media": {
"thumbnailUrl": "https://cdn.solidprofessor.com/thumbnails/2025/01/550e8400/thumb_800.webp",
"previewImages": [
{
"url": "https://cdn.solidprofessor.com/previews/2025/01/550e8400/preview_1.webp",
"caption": "Isometric view showing overall assembly",
"type": "isometric"
},
{
"url": "https://cdn.solidprofessor.com/previews/2025/01/550e8400/preview_2.webp",
"caption": "Section view revealing internal structure",
"type": "section"
},
{
"url": "https://cdn.solidprofessor.com/previews/2025/01/550e8400/preview_3.webp",
"caption": "Exploded view showing individual components",
"type": "exploded"
},
{
"url": "https://cdn.solidprofessor.com/previews/2025/01/550e8400/preview_4.webp",
"caption": "Detail view of gear teeth profile",
"type": "detail"
}
],
"previewVideo": {
"url": "https://cdn.solidprofessor.com/previews/2025/01/550e8400/rotation.mp4",
"thumbnail": "https://cdn.solidprofessor.com/previews/2025/01/550e8400/video_thumb.webp",
"duration": 15
},
"interactive3D": {
"viewerUrl": "https://viewer.solidprofessor.com/embed/550e8400",
"modelUrl": "https://cdn.solidprofessor.com/models/2025/01/550e8400/web_preview.obj"
}
},
"metadata": {
"category": {
"id": 5,
"name": "Mechanical",
"slug": "mechanical",
"icon": "cog",
"color": "#348303",
"breadcrumb": ["Engineering", "Mechanical", "Gears"]
},
"tags": ["gear", "mechanical", "industrial", "precision", "assembly", "solidworks"],
"specifications": {
"polygonCount": 15420,
"vertexCount": 7710,
"fileSize": 2516582,
"fileSizeFormatted": "2.4 MB",
"dimensions": {
"x": 100.5,
"y": 80.0,
"z": 25.0,
"units": "mm"
},
"boundingBox": {
"min": {"x": -50.25, "y": -40.0, "z": 0},
"max": {"x": 50.25, "y": 40.0, "z": 25.0}
},
"scale": "1:1",
"accuracy": "Âą0.01mm"
},
"technical": {
"software": {
"created": "SolidWorks 2024",
"version": "2024 SP1",
"compatible": ["SolidWorks 2020+", "Fusion 360", "Inventor 2022+"]
},
"materials": {
"recommended": ["Steel AISI 1045", "Aluminum 6061-T6"],
"properties": {
"hardness": "HRC 45-50",
"tensileStrength": "600-700 MPa"
}
},
"manufacturing": {
"processes": ["CNC Machining", "Gear Hobbing", "Heat Treatment"],
"tolerance": "IT7",
"surfaceFinish": "Ra 1.6Ξm"
}
}
},
"licensing": {
"type": "Creative Commons - Attribution",
"code": "CC-BY-4.0",
"description": "You are free to use, modify, and distribute with attribution",
"url": "https://creativecommons.org/licenses/by/4.0/",
"commercialUse": true,
"attributionRequired": true,
"shareAlike": false,
"derivatives": true,
"attribution": "Alex Rodriguez - SolidProfessor Hub"
},
"stats": {
"views": 1847,
"uniqueViews": 1203,
"downloads": 142,
"likes": 89,
"comments": 18,
"saves": 65,
"shares": 12,
"remixes": 3,
"rating": {
"average": 4.7,
"count": 23,
"breakdown": {
"5": 15,
"4": 6,
"3": 2,
"2": 0,
"1": 0
}
}
},
"interactions": {
"isLiked": false,
"isSaved": true,
"hasDownloaded": false,
"canDownload": true,
"canComment": true,
"canRate": true,
"userRating": null,
"canRemix": true,
"canReport": true
},
"printSettings": {
"recommended": {
"layerHeight": "0.2mm",
"infill": "20%",
"supports": true,
"supportType": "Tree supports",
"bedAdhesion": "Brim",
"nozzleTemperature": "210°C",
"bedTemperature": "60°C",
"printSpeed": "50mm/s"
},
"materials": ["PLA", "PETG", "ABS"],
"estimatedPrintTime": "4h 30m",
"estimatedFilament": "45g",
"difficulty": "Intermediate",
"postProcessing": ["Support removal", "Light sanding"]
},
"history": {
"publishedAt": "2025-01-20T10:00:00Z",
"createdAt": "2025-01-20T09:30:00Z",
"updatedAt": "2025-01-22T15:30:00Z",
"versions": [
{
"version": "2.0",
"date": "2025-01-22T15:30:00Z",
"changes": "Improved gear tooth profile, added assembly instructions"
},
{
"version": "1.0",
"date": "2025-01-20T10:00:00Z",
"changes": "Initial release"
}
]
},
"relatedModels": [
{
"id": "another-uuid-here",
"title": "Bearing Housing Assembly",
"thumbnailUrl": "https://cdn.solidprofessor.com/thumbnails/another/thumb_400.webp",
"author": {
"name": "Alex Rodriguez",
"username": "alex_cad_pro"
},
"stats": {
"likes": 67,
"downloads": 45,
"rating": 4.5
},
"similarity": 0.85
}
],
"collections": [
{
"id": 15,
"name": "Mechanical Assemblies",
"owner": {
"name": "Engineering Library",
"username": "eng_library"
},
"isPublic": true,
"modelsCount": 24
}
]
}
}
{
"rating": 5,
"review": "Excellent model with great attention to detail!"
}
{
"filename": "mechanical_gear_assembly.sldprt",
"fileSize": 2516582,
"mimeType": "application/sldprt",
"chunkSize": 1048576,
"fileHash": "sha256:a665a45920422f9d417e4867efdc4fb8a04a1f3fff1fa07e998e86f7f7a27ae3"
}
{
"data": {
"sessionId": "550e8400-e29b-41d4-a716-446655440000",
"chunksTotal": 3,
"chunkSize": 1048576,
"uploadUrls": [
"https://s3.amazonaws.com/bucket/upload/session-id/chunk-1?signature=...",
"https://s3.amazonaws.com/bucket/upload/session-id/chunk-2?signature=...",
"https://s3.amazonaws.com/bucket/upload/session-id/chunk-3?signature=..."
],
"expiresAt": "2025-01-24T12:00:00Z"
}
}
{
"uploadSessionId": "550e8400-e29b-41d4-a716-446655440000",
"title": "Mechanical Gear Assembly",
"description": "A high-precision gear assembly designed for industrial applications...",
"categoryId": 5,
"tags": ["gear", "mechanical", "industrial", "precision"],
"license": "CC-BY-4.0",
"visibility": "public",
"isDownloadable": true,
"isRemixable": true,
"softwareInfo": {
"name": "SolidWorks",
"version": "2024 SP1"
},
"printSettings": {
"layerHeight": "0.2mm",
"infill": "20%",
"supports": true,
"material": "PLA",
"estimatedTime": "4h 30m"
},
"technicalSpecs": {
"dimensions": {
"x": 100.5,
"y": 80.0,
"z": 25.0,
"units": "mm"
},
"materials": ["Steel AISI 1045", "Aluminum 6061-T6"],
"tolerance": "Âą0.01mm"
},
"publishImmediately": true,
"notifyFollowers": true
}
{
"data": {
"id": "550e8400-e29b-41d4-a716-446655440000",
"title": "Mechanical Gear Assembly",
"status": "processing",
"author": {
"id": 123,
"name": "Current User"
},
"processingJobs": [
{
"type": "virus_scan",
"status": "pending",
"estimatedDuration": "30s"
},
{
"type": "validation",
"status": "pending",
"estimatedDuration": "1m"
},
{
"type": "thumbnail",
"status": "pending",
"estimatedDuration": "2m"
},
{
"type": "preview",
"status": "pending",
"estimatedDuration": "3m"
},
{
"type": "metadata_extraction",
"status": "pending",
"estimatedDuration": "1m"
}
],
"estimatedProcessingTime": "5-7 minutes",
"statusUrl": "/api/v1/models/550e8400/processing-status",
"editUrl": "/models/550e8400/edit"
}
}
{
"content": "Amazing work! The detail on the gear teeth is incredible.",
"parentCommentId": null,
"mentions": ["@alex_cad_pro"]
}
{
"reason": "inappropriate_content",
"description": "Contains copyrighted material without permission",
"category": "copyright"
}
// app/Models/Model.php
class Model extends Eloquent implements Searchable
{
use HasFactory, SoftDeletes, Searchable, HasUuids;
protected $fillable = [
'title', 'description', 'author_id', 'category_id', 'status',
'visibility', 'file_path', 'file_size', 'file_format',
'polygon_count', 'license', 'is_featured', 'is_downloadable'
];
protected $casts = [
'preview_images' => 'array',
'bounding_box' => 'array',
'print_settings' => 'array',
'technical_specs' => 'array',
'software_info' => 'array',
'published_at' => 'datetime',
'is_featured' => 'boolean',
'is_downloadable' => 'boolean',
'is_remixable' => 'boolean'
];
protected $hidden = ['file_path'];
// Relationships
public function author() {
return $this->belongsTo(User::class, 'author_id');
}
public function category() {
return $this->belongsTo(ModelCategory::class);
}
public function tags() {
return $this->belongsToMany(ModelTag::class, 'model_tag_assignments');
}
public function comments() {
return $this->hasMany(ModelComment::class)
->whereNull('parent_comment_id')
->where('status', 'published');
}
public function allComments() {
return $this->hasMany(ModelComment::class);
}
public function likes() {
return $this->hasMany(ModelLike::class);
}
public function saves() {
return $this->hasMany(ModelSave::class);
}
public function ratings() {
return $this->hasMany(ModelRating::class);
}
public function downloads() {
return $this->hasMany(ModelDownload::class);
}
public function stats() {
return $this->hasOne(ModelStats::class);
}
public function collections() {
return $this->belongsToMany(UserCollection::class, 'collection_models');
}
// Scopes
public function scopePublished($query) {
return $query->where('status', 'published')
->where('visibility', 'public')
->whereNotNull('published_at');
}
public function scopeFeatured($query) {
return $query->where('is_featured', true);
}
public function scopeByCategory($query, $categoryId) {
return $query->where('category_id', $categoryId);
}
public function scopeByAuthor($query, $authorId) {
return $query->where('author_id', $authorId);
}
public function scopeWithTag($query, $tagName) {
return $query->whereHas('tags', function ($q) use ($tagName) {
$q->where('slug', $tagName);
});
}
public function scopeDownloadable($query) {
return $query->where('is_downloadable', true);
}
public function scopeMinRating($query, $rating) {
return $query->whereHas('stats', function ($q) use ($rating) {
$q->where('avg_rating', '>=', $rating);
});
}
// Helper Methods
public function isLikedBy(?User $user): bool {
if (!$user) return false;
return $this->likes()->where('user_id', $user->id)->exists();
}
public function isSavedBy(?User $user): bool {
if (!$user) return false;
return $this->saves()->where('user_id', $user->id)->exists();
}
public function getRatingBy(?User $user): ?int {
if (!$user) return null;
return $this->ratings()->where('user_id', $user->id)->value('rating');
}
public function hasBeenDownloadedBy(?User $user): bool {
if (!$user) return false;
return $this->downloads()->where('user_id', $user->id)->exists();
}
public function canBeDownloadedBy(?User $user): bool {
if (!$this->is_downloadable) return false;
if ($this->visibility === 'private' && $this->author_id !== $user?->id) return false;
return true;
}
// URL Generation
public function getDownloadUrlAttribute(): string {
return route('api.models.download', $this->uuid);
}
public function getThumbnailUrlAttribute(): string {
return app(FileUrlService::class)->getModelUrl($this, 'thumbnail');
}
public function getPreviewImagesAttribute(): array {
return app(FileUrlService::class)->getPreviewImages($this);
}
// Statistics
public function incrementStat(string $type): void {
$this->stats()->increment($type . '_count');
// Update time-based stats
if (in_array($type, ['views', 'downloads'])) {
$this->stats()->increment($type . '_today');
$this->stats()->increment($type . '_week');
$this->stats()->increment($type . '_month');
}
}
public function updateRatingStats(): void {
$avgRating = $this->ratings()->avg('rating');
$ratingCount = $this->ratings()->count();
$this->stats()->update([
'avg_rating' => round($avgRating, 2),
'rating_count' => $ratingCount
]);
}
// Search Configuration
public function toSearchableArray(): array {
return [
'title' => $this->title,
'description' => $this->description,
'author_name' => $this->author->name,
'category_name' => $this->category->name ?? '',
'tags' => $this->tags->pluck('name')->join(' '),
'file_format' => $this->file_format,
'license' => $this->license,
'published_at' => $this->published_at?->timestamp,
'stats' => [
'downloads' => $this->stats?->downloads_count ?? 0,
'likes' => $this->stats?->likes_count ?? 0,
'rating' => $this->stats?->avg_rating ?? 0,
]
];
}
}
// app/Http/Controllers/Api/ModelController.php
class ModelController extends Controller
{
public function __construct(
private ModelService $modelService,
private FileUrlService $fileUrlService,
private ModelCacheService $cacheService
) {}
public function index(ModelListRequest $request)
{
$filters = $request->getFilters();
$sort = $request->getSortOption();
$perPage = $request->getPerPage();
// Use cache for popular/trending requests
if ($this->shouldUseCache($filters, $sort)) {
$models = $this->cacheService->getModels($filters, $sort, $perPage);
} else {
$models = $this->modelService->getFilteredModels($filters, $sort, $perPage);
}
return ModelListResource::collection($models)
->additional([
'filters' => $this->modelService->getAvailableFilters($request),
'suggestions' => $this->modelService->getSuggestions($request->search)
]);
}
public function show(string $uuid, ModelDetailRequest $request)
{
$includes = $request->getIncludes();
$model = Model::query()
->with($this->getBaseIncludes())
->when(in_array('comments', $includes), function ($q) {
$q->with(['comments.author', 'comments.replies.author']);
})
->when(in_array('related', $includes), function ($q) {
// Load related models will be handled separately
})
->where('uuid', $uuid)
->published()
->firstOrFail();
// Check permissions
$this->authorize('view', $model);
// Track view (async)
TrackModelView::dispatch($model, request()->ip(), auth()->user());
// Get related models if requested
$relatedModels = in_array('related', $includes)
? $this->modelService->getRelatedModels($model)
: collect();
return new ModelDetailResource($model, $relatedModels);
}
public function store(CreateModelRequest $request)
{
$uploadSession = UploadSession::where('id', $request->uploadSessionId)
->where('user_id', auth()->id())
->where('status', 'completed')
->firstOrFail();
DB::transaction(function () use ($request, $uploadSession) {
// Create model
$model = $this->modelService->createFromUpload($uploadSession, $request->validated());
// Queue processing jobs
ProcessUploadedModel::dispatch($model);
// Update user stats
auth()->user()->increment('models_count');
// Notify followers if enabled
if ($request->notifyFollowers) {
NotifyFollowersOfNewModel::dispatch($model);
}
});
return new ModelResource($model);
}
public function download(string $uuid, DownloadModelRequest $request)
{
$model = Model::where('uuid', $uuid)->published()->firstOrFail();
$this->authorize('download', $model);
$format = $request->get('format', 'original');
// Record download
ModelDownload::create([
'model_id' => $model->id,
'user_id' => auth()->id(),
'ip_address' => $request->ip(),
'user_agent' => $request->userAgent(),
'download_type' => $format,
'referrer' => $request->header('referer')
]);
// Update stats
$model->incrementStat('downloads');
// Generate signed download URL
$filePath = $this->fileUrlService->getDownloadPath($model, $format);
$downloadUrl = Storage::disk('s3')->temporaryUrl($filePath, now()->addMinutes(30));
return response()->json([
'downloadUrl' => $downloadUrl,
'filename' => $this->fileUrlService->getDownloadFilename($model, $format),
'expiresAt' => now()->addMinutes(30)->toISOString()
]);
}
public function like(string $uuid)
{
$model = Model::where('uuid', $uuid)->published()->firstOrFail();
$like = ModelLike::firstOrCreate([
'model_id' => $model->id,
'user_id' => auth()->id()
]);
if ($like->wasRecentlyCreated) {
$model->incrementStat('likes');
// Notify author
if ($model->author_id !== auth()->id()) {
NotifyModelLiked::dispatch($model, auth()->user());
}
}
return response()->json([
'liked' => true,
'likesCount' => $model->stats->likes_count
]);
}
public function unlike(string $uuid)
{
$model = Model::where('uuid', $uuid)->published()->firstOrFail();
$deleted = ModelLike::where([
'model_id' => $model->id,
'user_id' => auth()->id()
])->delete();
if ($deleted) {
$model->stats()->decrement('likes_count');
}
return response()->json([
'liked' => false,
'likesCount' => $model->fresh()->stats->likes_count
]);
}
public function rate(string $uuid, RateModelRequest $request)
{
$model = Model::where('uuid', $uuid)->published()->firstOrFail();
ModelRating::updateOrCreate(
[
'model_id' => $model->id,
'user_id' => auth()->id()
],
[
'rating' => $request->rating,
'review' => $request->review
]
);
// Update model rating stats
$model->updateRatingStats();
return response()->json(['success' => true]);
}
private function getBaseIncludes(): array
{
return [
'author:id,name,username,avatar_url,is_verified',
'category:id,name,slug,icon,color',
'tags:id,name,slug',
'stats'
];
}
private function shouldUseCache(array $filters, string $sort): bool
{
// Use cache for popular queries without user-specific filters
return empty($filters['author']) &&
empty($filters['search']) &&
in_array($sort, ['popular', 'trending', 'featured']);
}
}
// app/Jobs/ProcessUploadedModel.php
class ProcessUploadedModel implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $timeout = 600; // 10 minutes
public int $tries = 3;
public function __construct(private Model $model) {}
public function handle(
FileProcessor $fileProcessor,
VirusScanner $virusScanner,
ThumbnailGenerator $thumbnailGenerator,
MetadataExtractor $metadataExtractor
): void {
try {
// Update status
$this->model->update(['status' => 'processing']);
// 1. Virus scan
$this->createProcessingJob('virus_scan', 'processing');
if (!$virusScanner->scan($this->model->file_path)) {
$this->failModel('Security scan failed - potential threat detected');
return;
}
$this->completeProcessingJob('virus_scan');
// 2. File validation
$this->createProcessingJob('validation', 'processing');
$validation = $fileProcessor->validate($this->model->file_path, $this->model->file_format);
if (!$validation['valid']) {
$this->failModel('File validation failed: ' . $validation['error']);
return;
}
$this->completeProcessingJob('validation', $validation);
// 3. Extract metadata
$this->createProcessingJob('metadata_extraction', 'processing');
$metadata = $metadataExtractor->extract($this->model->file_path);
$this->model->update([
'polygon_count' => $metadata['polygonCount'] ?? null,
'vertex_count' => $metadata['vertexCount'] ?? null,
'bounding_box' => $metadata['boundingBox'] ?? null,
'file_size' => Storage::size($this->model->file_path)
]);
$this->completeProcessingJob('metadata_extraction', $metadata);
// 4. Generate thumbnails
$this->createProcessingJob('thumbnail', 'processing');
$thumbnailPaths = $thumbnailGenerator->generate($this->model);
$this->model->update(['thumbnail_path' => $thumbnailPaths['primary']]);
$this->completeProcessingJob('thumbnail', ['paths' => $thumbnailPaths]);
// 5. Generate preview images
$this->createProcessingJob('preview', 'processing');
$previewPaths = $thumbnailGenerator->generatePreviews($this->model);
$this->model->update(['preview_images' => $previewPaths]);
$this->completeProcessingJob('preview', ['paths' => $previewPaths]);
// 6. Generate alternative formats (STL, OBJ if not already)
if (!in_array($this->model->file_format, ['STL', 'OBJ'])) {
GenerateAlternativeFormats::dispatch($this->model);
}
// 7. Create stats record
ModelStats::firstOrCreate(['model_id' => $this->model->id]);
// 8. Update status to published
$this->model->update([
'status' => 'published',
'published_at' => now()
]);
// 9. Index in search
$this->model->searchable();
// 10. Clear related caches
$this->clearCaches();
// 11. Notify author
ModelProcessingCompleted::dispatch($this->model);
} catch (Exception $e) {
$this->failModel('Processing failed: ' . $e->getMessage());
throw $e;
}
}
private function createProcessingJob(string $type, string $status): void
{
FileProcessingJob::create([
'model_id' => $this->model->id,
'job_type' => $type,
'status' => $status,
'started_at' => now()
]);
}
private function completeProcessingJob(string $type, array $result = null): void
{
FileProcessingJob::where('model_id', $this->model->id)
->where('job_type', $type)
->update([
'status' => 'completed',
'progress' => 100,
'result' => $result,
'completed_at' => now()
]);
}
private function failModel(string $reason): void
{
$this->model->update([
'status' => 'rejected',
'moderation_notes' => $reason
]);
// Update all pending jobs to failed
FileProcessingJob::where('model_id', $this->model->id)
->where('status', '!=', 'completed')
->update([
'status' => 'failed',
'error_message' => $reason,
'completed_at' => now()
]);
ModelProcessingFailed::dispatch($this->model, $reason);
}
private function clearCaches(): void
{
$tags = ['models', 'trending', 'popular', 'featured'];
if ($this->model->category_id) {
$tags[] = "category_{$this->model->category_id}";
}
Cache::tags($tags)->flush();
}
}
// app/Jobs/GenerateAlternativeFormats.php
class GenerateAlternativeFormats implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function handle(Model $model, FileConverter $converter): void
{
$basePath = pathinfo($model->file_path, PATHINFO_DIRNAME);
$baseFilename = pathinfo($model->file_path, PATHINFO_FILENAME);
$formats = ['stl', 'obj'];
foreach ($formats as $format) {
if (strtolower($model->file_format) === $format) {
continue; // Skip if already in this format
}
try {
$outputPath = $basePath . '/' . $baseFilename . '.' . $format;
$success = $converter->convert(
$model->file_path,
$outputPath,
$format
);
if ($success) {
// Store format info in model metadata
$alternativeFormats = $model->alternative_formats ?? [];
$alternativeFormats[$format] = [
'path' => $outputPath,
'size' => Storage::size($outputPath),
'generated_at' => now()->toISOString()
];
$model->update(['alternative_formats' => $alternativeFormats]);
}
} catch (Exception $e) {
Log::error('Failed to generate alternative format', [
'model_id' => $model->id,
'format' => $format,
'error' => $e->getMessage()
]);
}
}
}
}
// app/Services/ModelSearchService.php
class ModelSearchService
{
public function __construct(
private ModelCacheService $cacheService
) {}
public function search(string $query, array $filters = [], string $sort = 'relevance'): LengthAwarePaginator
{
// Use cache for popular searches
$cacheKey = $this->generateCacheKey($query, $filters, $sort);
if ($this->shouldUseCache($query, $filters)) {
return $this->cacheService->remember($cacheKey, 900, function () use ($query, $filters, $sort) {
return $this->performSearch($query, $filters, $sort);
});
}
return $this->performSearch($query, $filters, $sort);
}
private function performSearch(string $query, array $filters, string $sort): LengthAwarePaginator
{
$searchQuery = Model::search($query)
->where('status', 'published')
->where('visibility', 'public');
// Apply filters
$this->applyFilters($searchQuery, $filters);
// Apply sorting
$this->applySorting($searchQuery, $sort);
return $searchQuery->paginate(24);
}
private function applyFilters($searchQuery, array $filters): void
{
if (!empty($filters['category'])) {
$searchQuery->where('category_id', $filters['category']);
}
if (!empty($filters['tags'])) {
$searchQuery->whereIn('tags', $filters['tags']);
}
if (!empty($filters['format'])) {
$searchQuery->where('file_format', strtoupper($filters['format']));
}
if (!empty($filters['license'])) {
$searchQuery->where('license', $filters['license']);
}
if (!empty($filters['author'])) {
$searchQuery->where('author_id', $filters['author']);
}
if (!empty($filters['min_rating'])) {
$searchQuery->where('stats.avg_rating', '>=', $filters['min_rating']);
}
if (!empty($filters['created_after'])) {
$searchQuery->where('published_at', '>=', $filters['created_after']);
}
if (!empty($filters['created_before'])) {
$searchQuery->where('published_at', '<=', $filters['created_before']);
}
if (!empty($filters['downloadable'])) {
$searchQuery->where('is_downloadable', true);
}
}
private function applySorting($searchQuery, string $sort): void
{
match($sort) {
'popular' => $searchQuery->orderBy('stats.downloads_count', 'desc'),
'trending' => $searchQuery->orderBy('stats.views_week', 'desc'),
'newest' => $searchQuery->orderBy('published_at', 'desc'),
'oldest' => $searchQuery->orderBy('published_at', 'asc'),
'rating' => $searchQuery->orderBy('stats.avg_rating', 'desc'),
'downloads' => $searchQuery->orderBy('stats.downloads_count', 'desc'),
'likes' => $searchQuery->orderBy('stats.likes_count', 'desc'),
default => null // Use relevance scoring
};
}
public function getSuggestions(string $query, int $limit = 10): array
{
if (strlen($query) < 2) {
return [];
}
return Cache::remember("search_suggestions_{$query}_{$limit}", 3600, function () use ($query, $limit) {
// Get model title suggestions
$modelSuggestions = Model::search($query)
->where('status', 'published')
->take($limit)
->get(['title', 'slug'])
->map(fn($model) => [
'type' => 'model',
'text' => $model->title,
'url' => "/models/{$model->slug}"
]);
// Get tag suggestions
$tagSuggestions = ModelTag::where('name', 'like', "%{$query}%")
->orderBy('usage_count', 'desc')
->take(5)
->get(['name', 'slug'])
->map(fn($tag) => [
'type' => 'tag',
'text' => $tag->name,
'url' => "/models?tags={$tag->slug}"
]);
// Get author suggestions
$authorSuggestions = User::where('name', 'like', "%{$query}%")
->where('models_count', '>', 0)
->orderBy('models_count', 'desc')
->take(3)
->get(['name', 'username'])
->map(fn($user) => [
'type' => 'author',
'text' => $user->name,
'url' => "/users/{$user->username}"
]);
return $modelSuggestions
->concat($tagSuggestions)
->concat($authorSuggestions)
->take($limit)
->values()
->toArray();
});
}
private function shouldUseCache(string $query, array $filters): bool
{
// Cache simple queries without user-specific filters
return strlen($query) <= 20 &&
empty($filters['author']) &&
count($filters) <= 3;
}
private function generateCacheKey(string $query, array $filters, string $sort): string
{
return 'search_' . md5($query . serialize($filters) . $sort);
}
}
// app/Services/ModelCacheService.php
class ModelCacheService
{
private const CACHE_TTL = [
'popular' => 3600, // 1 hour
'trending' => 1800, // 30 minutes
'featured' => 7200, // 2 hours
'categories' => 86400, // 24 hours
'tags' => 3600, // 1 hour
];
public function getPopularModels(int $page = 1, int $limit = 24): LengthAwarePaginator
{
return $this->remember("popular_models_p{$page}_l{$limit}", self::CACHE_TTL['popular'], function () use ($limit) {
return Model::query()
->with(['author', 'category', 'stats'])
->published()
->join('model_stats', 'models.id', '=', 'model_stats.model_id')
->orderByRaw('(downloads_count * 2 + likes_count * 3 + views_count * 0.1) DESC')
->paginate($limit);
});
}
public function getTrendingModels(string $period = 'week', int $limit = 12): Collection
{
return $this->remember("trending_models_{$period}_{$limit}", self::CACHE_TTL['trending'], function () use ($period, $limit) {
$column = match($period) {
'today' => 'views_today',
'week' => 'views_week',
'month' => 'views_month',
default => 'views_week'
};
return Model::query()
->with(['author', 'category', 'stats'])
->published()
->join('model_stats', 'models.id', '=', 'model_stats.model_id')
->where($column, '>', 0)
->orderByRaw("({$column} * 2 + likes_count) DESC")
->limit($limit)
->get();
});
}
public function getFeaturedModels(int $limit = 8): Collection
{
return $this->remember("featured_models_{$limit}", self::CACHE_TTL['featured'], function () use ($limit) {
return Model::query()
->with(['author', 'category', 'stats'])
->featured()
->published()
->orderBy('published_at', 'desc')
->limit($limit)
->get();
});
}
public function getCategories(): Collection
{
return $this->remember('model_categories', self::CACHE_TTL['categories'], function () {
return ModelCategory::query()
->where('is_active', true)
->with('parent')
->orderBy('sort_order')
->orderBy('name')
->get();
});
}
public function getPopularTags(int $limit = 20): Collection
{
return $this->remember("popular_tags_{$limit}", self::CACHE_TTL['tags'], function () use ($limit) {
return ModelTag::query()
->where('usage_count', '>', 0)
->orderBy('usage_count', 'desc')
->limit($limit)
->get(['name', 'slug', 'usage_count']);
});
}
public function clearModelCache(Model $model): void
{
$tags = [
'models',
'popular_models',
'trending_models',
'featured_models',
"category_{$model->category_id}",
"author_{$model->author_id}"
];
Cache::tags($tags)->flush();
// Clear specific model cache
Cache::forget("model_detail_{$model->id}");
}
public function remember(string $key, int $ttl, Closure $callback)
{
return Cache::remember($key, $ttl, $callback);
}
}
// app/Policies/ModelPolicy.php
class ModelPolicy
{
public function viewAny(?User $user): bool
{
return true; // Anyone can browse public models
}
public function view(?User $user, Model $model): bool
{
// Public models can be viewed by anyone
if ($model->visibility === 'public' && $model->status === 'published') {
return true;
}
// Private models only by author or admins
if ($model->visibility === 'private') {
return $user && ($user->id === $model->author_id || $user->hasRole(['admin', 'moderator']));
}
// Draft models only by author or admins
if ($model->status === 'draft') {
return $user && ($user->id === $model->author_id || $user->hasRole(['admin', 'moderator']));
}
// Unlisted models can be viewed by anyone with the link
if ($model->visibility === 'unlisted') {
return $model->status === 'published';
}
return false;
}
public function create(User $user): bool
{
// Check if user has reached upload limits
if ($user->hasRole('free') && $user->models()->count() >= 10) {
return false;
}
if ($user->hasRole('pro') && $user->models()->count() >= 100) {
return false;
}
// Check if user is banned
return !$user->is_banned;
}
public function update(User $user, Model $model): bool
{
return $user->id === $model->author_id ||
$user->hasRole(['admin', 'moderator']);
}
public function delete(User $user, Model $model): bool
{
return $user->id === $model->author_id ||
$user->hasRole(['admin']);
}
public function download(User $user, Model $model): bool
{
// Must be able to view the model first
if (!$this->view($user, $model)) {
return false;
}
// Model must be downloadable
if (!$model->is_downloadable) {
return false;
}
// Check download limits for free users
if ($user->hasRole('free')) {
$todayDownloads = ModelDownload::where('user_id', $user->id)
->whereDate('created_at', today())
->count();
if ($todayDownloads >= 5) {
return false;
}
}
return true;
}
public function comment(User $user, Model $model): bool
{
return $this->view($user, $model) &&
!$user->is_banned &&
!$user->is_muted;
}
public function like(User $user, Model $model): bool
{
return $this->view($user, $model) &&
!$user->is_banned;
}
public function rate(User $user, Model $model): bool
{
return $this->view($user, $model) &&
!$user->is_banned &&
$user->id !== $model->author_id; // Can't rate own models
}
public function report(User $user, Model $model): bool
{
return $this->view($user, $model) &&
!$user->is_banned &&
$user->id !== $model->author_id; // Can't report own models
}
}
// app/Services/FileSecurityService.php
class FileSecurityService
{
private const ALLOWED_MIME_TYPES = [
'stl' => ['application/octet-stream', 'model/stl'],
'obj' => ['application/obj', 'text/plain'],
'ply' => ['application/ply', 'text/plain'],
'sldprt' => ['application/sldprt', 'application/octet-stream'],
'step' => ['application/step', 'model/step'],
'iges' => ['application/iges', 'model/iges'],
'3mf' => ['application/3mf', 'model/3mf']
];
private const MAX_FILE_SIZE = 100 * 1024 * 1024; // 100MB
private const VIRUS_SCAN_TIMEOUT = 30; // seconds
public function validateUpload(UploadedFile $file): array
{
$errors = [];
// Check file size
if ($file->getSize() > self::MAX_FILE_SIZE) {
$errors[] = 'File size exceeds maximum allowed size of 100MB';
}
// Check file extension
$extension = strtolower($file->getClientOriginalExtension());
if (!array_key_exists($extension, self::ALLOWED_MIME_TYPES)) {
$errors[] = 'File type not supported';
}
// Check MIME type
$mimeType = $file->getMimeType();
if (isset(self::ALLOWED_MIME_TYPES[$extension]) &&
!in_array($mimeType, self::ALLOWED_MIME_TYPES[$extension])) {
$errors[] = 'File MIME type does not match extension';
}
// Check for malicious content
if ($this->containsMaliciousContent($file->getPathname())) {
$errors[] = 'File contains potentially malicious content';
}
return [
'valid' => empty($errors),
'errors' => $errors
];
}
public function scanForVirus(string $filePath): bool
{
try {
$scanner = new ClamAVScanner();
$result = $scanner->scan($filePath, self::VIRUS_SCAN_TIMEOUT);
Log::info('Virus scan completed', [
'file' => basename($filePath),
'result' => $result ? 'clean' : 'infected'
]);
return $result;
} catch (Exception $e) {
Log::error('Virus scan failed', [
'file' => basename($filePath),
'error' => $e->getMessage()
]);
// Fail safe - reject if scan fails
return false;
}
}
public function sanitizeFilename(string $filename): string
{
// Remove dangerous characters
$filename = preg_replace('/[^\w\-_\.]/', '_', $filename);
// Prevent double extensions
$filename = preg_replace('/\.{2,}/', '.', $filename);
// Ensure reasonable length
if (strlen($filename) > 255) {
$extension = pathinfo($filename, PATHINFO_EXTENSION);
$basename = substr(pathinfo($filename, PATHINFO_FILENAME), 0, 250);
$filename = $basename . '.' . $extension;
}
return $filename;
}
private function containsMaliciousContent(string $filePath): bool
{
// Read first 2KB to check for malicious patterns
$content = file_get_contents($filePath, false, null, 0, 2048);
$maliciousPatterns = [
// Script injections
'/<\?php/i',
'/<script/i',
'/javascript:/i',
'/data:text\/html/i',
'/eval\s*\(/i',
'/exec\s*\(/i',
'/system\s*\(/i',
'/shell_exec\s*\(/i',
// File inclusions
'/include\s*\(/i',
'/require\s*\(/i',
'/file_get_contents\s*\(/i',
// SQL injections (in text files)
'/union\s+select/i',
'/drop\s+table/i',
'/insert\s+into/i',
// Command injections
'/\|.*?(cat|ls|pwd|whoami)/i',
'/&&.*?(rm|cp|mv)/i',
];
foreach ($maliciousPatterns as $pattern) {
if (preg_match($pattern, $content)) {
Log::warning('Malicious pattern detected in upload', [
'file' => basename($filePath),
'pattern' => $pattern
]);
return true;
}
}
return false;
}
}
// app/Http/Middleware/RateLimitUploads.php
class RateLimitUploads
{
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
if (!$user) {
return response()->json(['error' => 'Authentication required'], 401);
}
// Rate limit based on user role
$limits = match($user->role) {
'free' => ['count' => 3, 'window' => 3600], // 3 per hour
'pro' => ['count' => 10, 'window' => 3600], // 10 per hour
'premium' => ['count' => 25, 'window' => 3600], // 25 per hour
default => ['count' => 1, 'window' => 3600] // 1 per hour
};
$key = 'upload_rate_limit:' . $user->id;
$attempts = Cache::get($key, 0);
if ($attempts >= $limits['count']) {
return response()->json([
'error' => 'Upload rate limit exceeded',
'retry_after' => $limits['window']
], 429);
}
// Increment counter
Cache::put($key, $attempts + 1, $limits['window']);
return $next($request);
}
}
// tests/Feature/ModelUploadTest.php
class ModelUploadTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Storage::fake('private');
Storage::fake('s3');
Queue::fake();
}
public function test_user_can_initialize_upload_session(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)
->postJson('/api/v1/uploads/init', [
'filename' => 'test_model.stl',
'fileSize' => 1048576,
'mimeType' => 'application/octet-stream',
'chunkSize' => 1048576,
'fileHash' => 'sha256:test-hash'
]);
$response->assertStatus(201)
->assertJsonStructure([
'data' => [
'sessionId',
'chunksTotal',
'chunkSize',
'uploadUrls',
'expiresAt'
]
]);
$this->assertDatabaseHas('upload_sessions', [
'user_id' => $user->id,
'filename' => 'test_model.stl',
'file_size' => 1048576
]);
}
public function test_user_can_complete_upload_and_create_model(): void
{
$user = User::factory()->create();
$category = ModelCategory::factory()->create();
// Create completed upload session
$session = UploadSession::factory()->create([
'user_id' => $user->id,
'status' => 'completed',
'file_path' => 'uploads/models/test_file.stl'
]);
$response = $this->actingAs($user)
->postJson('/api/v1/models', [
'uploadSessionId' => $session->id,
'title' => 'Test Model',
'description' => 'A test model description',
'categoryId' => $category->id,
'tags' => ['test', 'sample'],
'license' => 'CC-BY-4.0',
'visibility' => 'public',
'isDownloadable' => true,
'publishImmediately' => true
]);
$response->assertStatus(201)
->assertJsonStructure([
'data' => [
'id',
'title',
'status',
'processingJobs'
]
]);
$this->assertDatabaseHas('models', [
'title' => 'Test Model',
'author_id' => $user->id,
'category_id' => $category->id,
'status' => 'processing'
]);
// Verify processing job was queued
Queue::assertPushed(ProcessUploadedModel::class);
}
public function test_user_cannot_upload_oversized_file(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)
->postJson('/api/v1/uploads/init', [
'filename' => 'huge_model.stl',
'fileSize' => 200 * 1024 * 1024, // 200MB
'mimeType' => 'application/octet-stream',
'chunkSize' => 1048576
]);
$response->assertStatus(422)
->assertJsonValidationErrors(['fileSize']);
}
public function test_user_cannot_upload_invalid_file_type(): void
{
$user = User::factory()->create();
$response = $this->actingAs($user)
->postJson('/api/v1/uploads/init', [
'filename' => 'malicious.exe',
'fileSize' => 1048576,
'mimeType' => 'application/x-executable',
'chunkSize' => 1048576
]);
$response->assertStatus(422)
->assertJsonValidationErrors(['mimeType']);
}
public function test_upload_session_expires(): void
{
$user = User::factory()->create();
$session = UploadSession::factory()->create([
'user_id' => $user->id,
'expires_at' => now()->subHour(),
'status' => 'pending'
]);
$response = $this->actingAs($user)
->postJson("/api/v1/uploads/{$session->id}/complete");
$response->assertStatus(410); // Gone
}
}
// tests/Feature/ModelInteractionTest.php
class ModelInteractionTest extends TestCase
{
use RefreshDatabase;
public function test_user_can_like_model(): void
{
$user = User::factory()->create();
$model = Model::factory()->published()->create();
$response = $this->actingAs($user)
->postJson("/api/v1/models/{$model->uuid}/like");
$response->assertStatus(200)
->assertJson([
'liked' => true,
'likesCount' => 1
]);
$this->assertDatabaseHas('model_likes', [
'model_id' => $model->id,
'user_id' => $user->id
]);
}
public function test_user_can_download_model(): void
{
$user = User::factory()->create();
$model = Model::factory()->published()->downloadable()->create();
Storage::disk('s3')->put($model->file_path, 'fake file content');
$response = $this->actingAs($user)
->postJson("/api/v1/models/{$model->uuid}/download");
$response->assertStatus(200)
->assertJsonStructure([
'downloadUrl',
'filename',
'expiresAt'
]);
$this->assertDatabaseHas('model_downloads', [
'model_id' => $model->id,
'user_id' => $user->id
]);
}
public function test_user_can_rate_model(): void
{
$user = User::factory()->create();
$model = Model::factory()->published()->create();
$response = $this->actingAs($user)
->postJson("/api/v1/models/{$model->uuid}/rate", [
'rating' => 5,
'review' => 'Excellent model!'
]);
$response->assertStatus(200);
$this->assertDatabaseHas('model_ratings', [
'model_id' => $model->id,
'user_id' => $user->id,
'rating' => 5
]);
}
public function test_user_cannot_rate_own_model(): void
{
$user = User::factory()->create();
$model = Model::factory()->published()->create(['author_id' => $user->id]);
$response = $this->actingAs($user)
->postJson("/api/v1/models/{$model->uuid}/rate", [
'rating' => 5
]);
$response->assertStatus(403);
}
}
// tests/Feature/ModelSearchTest.php
class ModelSearchTest extends TestCase
{
use RefreshDatabase;
public function test_can_search_models_by_title(): void
{
Model::factory()->published()->create(['title' => 'Mechanical Gear']);
Model::factory()->published()->create(['title' => 'Electronic Circuit']);
$response = $this->getJson('/api/v1/models?search=mechanical');
$response->assertStatus(200)
->assertJsonCount(1, 'data')
->assertJsonPath('data.0.title', 'Mechanical Gear');
}
public function test_can_filter_models_by_category(): void
{
$category = ModelCategory::factory()->create(['slug' => 'mechanical']);
Model::factory()->published()->create(['category_id' => $category->id]);
Model::factory()->published()->create(['category_id' => null]);
$response = $this->getJson('/api/v1/models?category=mechanical');
$response->assertStatus(200)
->assertJsonCount(1, 'data');
}
public function test_can_sort_models_by_popularity(): void
{
$popular = Model::factory()->published()->create();
$unpopular = Model::factory()->published()->create();
// Create stats to make first model more popular
ModelStats::factory()->create([
'model_id' => $popular->id,
'downloads_count' => 100,
'likes_count' => 50
]);
ModelStats::factory()->create([
'model_id' => $unpopular->id,
'downloads_count' => 1,
'likes_count' => 0
]);
$response = $this->getJson('/api/v1/models?sort=popular');
$response->assertStatus(200)
->assertJsonPath('data.0.id', $popular->uuid);
}
}
# .env.production
APP_NAME="SolidProfessor Platform Hub"
APP_ENV=production
APP_DEBUG=false
APP_URL=https://hub.solidprofessor.com
# Database Configuration
DB_CONNECTION=mysql
DB_HOST=prod-db-cluster.us-east-1.rds.amazonaws.com
DB_PORT=3306
DB_DATABASE=solidprofessor_hub
DB_USERNAME=hub_user
DB_PASSWORD=${DB_PASSWORD}
# Read Replica for Analytics
DB_READ_HOST=prod-db-replica.us-east-1.rds.amazonaws.com
# Redis Configuration
REDIS_HOST=prod-redis-cluster.abc123.cache.amazonaws.com
REDIS_PASSWORD=${REDIS_PASSWORD}
REDIS_PORT=6379
# File Storage
FILESYSTEM_DISK=s3
AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID}
AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY}
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=solidprofessor-hub-models
AWS_URL=https://solidprofessor-hub-models.s3.amazonaws.com
CDN_URL=https://cdn.solidprofessor.com
# Search
SCOUT_DRIVER=elasticsearch
ELASTICSEARCH_HOST=https://search-solidprofessor-abc123.us-east-1.es.amazonaws.com
ELASTICSEARCH_INDEX=models
# Queue Configuration
QUEUE_CONNECTION=redis
QUEUE_PREFIX=hub_prod_
QUEUE_FAILED_DRIVER=database
# Cache Configuration
CACHE_DRIVER=redis
SESSION_DRIVER=redis
SESSION_LIFETIME=120
# File Processing
MAX_UPLOAD_SIZE=104857600 # 100MB
CHUNK_SIZE_LIMIT=10485760 # 10MB
UPLOAD_SESSION_TTL=86400 # 24 hours
VIRUS_SCAN_ENABLED=true
# Rate Limiting
RATE_LIMIT_UPLOADS_FREE=3
RATE_LIMIT_UPLOADS_PRO=10
RATE_LIMIT_DOWNLOADS_FREE=10
RATE_LIMIT_DOWNLOADS_PRO=50
# Email
MAIL_MAILER=ses
MAIL_FROM_ADDRESS=noreply@solidprofessor.com
MAIL_FROM_NAME="SolidProfessor Hub"
# Monitoring
SENTRY_LARAVEL_DSN=${SENTRY_DSN}
LOG_CHANNEL=stack
LOG_LEVEL=info
# Feature Flags
ENABLE_MODEL_REMIXING=true
ENABLE_SOCIAL_FEATURES=true
ENABLE_COLLECTIONS=true
ENABLE_RATINGS=true
# .github/workflows/deploy.yml
name: Deploy to Production
on:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
services:
mysql:
image: mysql:8.0
env:
MYSQL_ROOT_PASSWORD: password
MYSQL_DATABASE: testing
options: --health-cmd="mysqladmin ping" --health-interval=10s
redis:
image: redis:7
options: --health-cmd "redis-cli ping" --health-interval=10s
steps:
- uses: actions/checkout@v3
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: 8.2
extensions: bcmath, ctype, fileinfo, json, mbstring, openssl, pdo, tokenizer, xml
- name: Install dependencies
run: composer install --prefer-dist --no-progress
- name: Copy environment file
run: cp .env.testing .env
- name: Generate application key
run: php artisan key:generate
- name: Run migrations
run: php artisan migrate --force
- name: Run tests
run: php artisan test --parallel
- name: Run static analysis
run: ./vendor/bin/phpstan analyse
deploy:
needs: test
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main'
steps:
- uses: actions/checkout@v3
- name: Deploy to production
uses: appleboy/ssh-action@v0.1.5
with:
host: ${{ secrets.PROD_HOST }}
username: ${{ secrets.PROD_USER }}
key: ${{ secrets.PROD_SSH_KEY }}
script: |
cd /var/www/platform-hub
git pull origin main
composer install --optimize-autoloader --no-dev
php artisan migrate --force
php artisan config:cache
php artisan route:cache
php artisan view:cache
php artisan queue:restart
sudo supervisorctl restart php-fpm
sudo nginx -s reload
# Backup Strategy
- Database: Automated daily backups with 30-day retention
- Files: S3 versioning with cross-region replication
- Configuration: Git-based infrastructure as code
- Recovery Time Objective (RTO): < 4 hours
- Recovery Point Objective (RPO): < 1 hour
# Monitoring Setup
- Application: New Relic APM
- Infrastructure: AWS CloudWatch + DataDog
- Logs: ELK Stack (Elasticsearch, Logstash, Kibana)
- Alerts: PagerDuty integration for critical issues
- Health Checks: Automated endpoint monitoring
ð This comprehensive backend implementation provides a production-ready, scalable platform for 3D model sharing with advanced features, robust security, and enterprise-grade performance optimization.