Files
jouwdriedee/app/Services/MakerWorldService.php
T
2026-10-31 14:23:53 +01:00

526 lines
16 KiB
PHP

<?php
namespace App\Services;
use App\Models\Product;
use App\Models\Setting;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
class MakerWorldService
{
protected string $baseUrl = 'https://api.bambulab.com';
protected string $userAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36';
/**
* Build an HTTP client pre-configured with headers and optional bearer token.
*/
protected function client(): PendingRequest
{
$headers = [
'User-Agent' => $this->userAgent,
'Accept' => 'application/json, text/plain, */*',
];
$token = $this->getAccessToken();
if ($token) {
$headers['Authorization'] = 'Bearer '.$token;
}
return Http::withHeaders($headers)->timeout(15);
}
/**
* Get the stored access token.
*/
public function getAccessToken(): ?string
{
return Setting::get('makerworld_token');
}
/**
* Store the access token.
*/
public function setAccessToken(string $token): array
{
Setting::set('makerworld_token', trim($token));
$profile = $this->getUserProfile();
if ($profile) {
Setting::set('makerworld_user', $profile);
return [
'success' => true,
'user' => $profile,
];
}
return [
'success' => true,
'user' => null,
];
}
/**
* Check if currently authenticated with MakerWorld.
*/
public function isAuthenticated(): bool
{
return ! empty($this->getAccessToken());
}
/**
* Log out from MakerWorld by clearing saved credentials.
*/
public function logout(): void
{
Setting::forget('makerworld_token');
Setting::forget('makerworld_user');
}
/**
* Attempt to log in with MakerWorld account credentials (email and password).
*
* @return array{status: string, message?: string, token?: string, user?: array, account?: string}
*/
public function loginWithCredentials(string $account, string $password): array
{
try {
$response = Http::withHeaders([
'User-Agent' => $this->userAgent,
'Content-Type' => 'application/json',
])->post("{$this->baseUrl}/v1/user-service/user/login", [
'account' => $account,
'password' => $password,
'apiError' => '',
]);
$data = $response->json();
if (! empty($data['accessToken'])) {
$this->setAccessToken($data['accessToken']);
return [
'status' => 'success',
'token' => $data['accessToken'],
'message' => 'Succesvol ingelogd bij MakerWorld!',
];
}
// Check if 2FA code is needed
if (isset($data['loginType']) && $data['loginType'] === 'verifyCode') {
$this->sendEmailVerificationCode($account);
return [
'status' => '2fa_required',
'account' => $account,
'message' => 'Verificatiecode vereist. We hebben een 6-cijferige code naar je e-mail gestuurd.',
];
}
$errorMsg = $data['error'] ?? $data['message'] ?? 'Inloggen mislukt. Controleer je MakerWorld account en wachtwoord.';
return [
'status' => 'error',
'message' => $errorMsg,
];
} catch (\Throwable $e) {
Log::error('MakerWorld login error: '.$e->getMessage());
return [
'status' => 'error',
'message' => 'Verbinding met MakerWorld mislukt: '.$e->getMessage(),
];
}
}
/**
* Request an email verification code for Bambu / MakerWorld login.
*/
public function sendEmailVerificationCode(string $email): bool
{
try {
$response = Http::withHeaders([
'User-Agent' => $this->userAgent,
'Content-Type' => 'application/json',
])->post("{$this->baseUrl}/v1/user-service/user/sendemail/code", [
'email' => $email,
'type' => 'codeLogin',
]);
return $response->successful();
} catch (\Throwable $e) {
Log::error('MakerWorld send code error: '.$e->getMessage());
return false;
}
}
/**
* Complete login with account and email verification code.
*/
public function verifyLoginCode(string $account, string $code): array
{
try {
$response = Http::withHeaders([
'User-Agent' => $this->userAgent,
'Content-Type' => 'application/json',
])->post("{$this->baseUrl}/v1/user-service/user/login", [
'account' => $account,
'code' => trim($code),
]);
$data = $response->json();
if (! empty($data['accessToken'])) {
$this->setAccessToken($data['accessToken']);
return [
'status' => 'success',
'token' => $data['accessToken'],
'message' => 'Verificatie geslaagd! Ingelogd bij MakerWorld.',
];
}
return [
'status' => 'error',
'message' => $data['error'] ?? $data['message'] ?? 'Ongeldige verificatiecode.',
];
} catch (\Throwable $e) {
Log::error('MakerWorld verify code error: '.$e->getMessage());
return [
'status' => 'error',
'message' => 'Verificatie mislukt: '.$e->getMessage(),
];
}
}
/**
* Fetch user preference and profile.
*/
public function getUserProfile(): ?array
{
try {
$response = $this->client()->get("{$this->baseUrl}/v1/design-user-service/my/preference");
if ($response->successful()) {
return $response->json();
}
} catch (\Throwable $e) {
Log::warning('MakerWorld getUserProfile failed: '.$e->getMessage());
}
return Setting::get('makerworld_user');
}
/**
* Fetch user's favorited models from MakerWorld.
*/
public function getFavorites(int $page = 1, int $limit = 24): array
{
if (! $this->isAuthenticated()) {
return [];
}
try {
$response = $this->client()->get("{$this->baseUrl}/v1/design-service/my/design/favoriteslist", [
'page' => $page,
'limit' => $limit,
]);
if ($response->successful()) {
$data = $response->json();
return $data['hits'] ?? $data['designs'] ?? $data['list'] ?? (is_array($data) ? $data : []);
}
} catch (\Throwable $e) {
Log::error('MakerWorld getFavorites error: '.$e->getMessage());
}
return [];
}
/**
* Fetch user's published models from MakerWorld.
*/
public function getPublished(int $page = 1, int $limit = 24): array
{
if (! $this->isAuthenticated()) {
return [];
}
try {
$response = $this->client()->get("{$this->baseUrl}/v1/design-service/my/design/published", [
'page' => $page,
'limit' => $limit,
]);
if ($response->successful()) {
$data = $response->json();
return $data['hits'] ?? $data['designs'] ?? $data['list'] ?? (is_array($data) ? $data : []);
}
} catch (\Throwable $e) {
Log::error('MakerWorld getPublished error: '.$e->getMessage());
}
return [];
}
/**
* Fetch popular showcase/contest models from MakerWorld.
*/
public function getCommunityShowcase(int $limit = 30): array
{
try {
$response = $this->client()->get("{$this->baseUrl}/v1/design-service/contest/list", [
'limit' => 3,
]);
if ($response->successful()) {
$data = $response->json();
$allDesigns = [];
if (! empty($data['hits']) && is_array($data['hits'])) {
foreach ($data['hits'] as $contest) {
if (! empty($contest['designs']) && is_array($contest['designs'])) {
foreach ($contest['designs'] as $design) {
$allDesigns[$design['id']] = $design;
}
}
}
}
return array_slice(array_values($allDesigns), 0, $limit);
}
} catch (\Throwable $e) {
Log::error('MakerWorld getCommunityShowcase error: '.$e->getMessage());
}
return [];
}
/**
* Get detailed design info by ID.
*/
public function getDesignDetails(int|string $designId): ?array
{
try {
$response = $this->client()->get("{$this->baseUrl}/v1/design-service/design/{$designId}");
if ($response->successful()) {
$data = $response->json();
if (! empty($data['id']) || ! empty($data['title'])) {
return $data;
}
}
} catch (\Throwable $e) {
Log::error("MakerWorld getDesignDetails({$designId}) error: ".$e->getMessage());
}
return null;
}
/**
* Get batch of designs by IDs.
*/
public function getDesignsByIds(array $designIds): array
{
if (empty($designIds)) {
return [];
}
try {
$ids = implode(',', array_filter(array_map('intval', $designIds)));
$response = $this->client()->get("{$this->baseUrl}/v1/design-service/design/ids", [
'designIds' => $ids,
]);
if ($response->successful()) {
$data = $response->json();
return $data['designs'] ?? [];
}
} catch (\Throwable $e) {
Log::error('MakerWorld getDesignsByIds error: '.$e->getMessage());
}
return [];
}
/**
* Parse model ID from full MakerWorld URL or raw ID string.
* Examples:
* - https://makerworld.com/nl/models/55866#profileId-60000 -> 55866
* - https://makerworld.com/en/models/12345?keyword=test -> 12345
* - 55866 -> 55866
*/
public function parseModelId(string $input): ?int
{
$input = trim($input);
if (is_numeric($input)) {
return (int) $input;
}
if (preg_match('/models\/(\d+)/i', $input, $matches)) {
return (int) $matches[1];
}
if (preg_match('/design\/(\d+)/i', $input, $matches)) {
return (int) $matches[1];
}
return null;
}
/**
* Transform raw MakerWorld API design details into a clean format for product creation.
*/
public function formatDesignForImport(array $design): array
{
$designId = (int) ($design['id'] ?? 0);
$title = trim($design['title'] ?? 'MakerWorld 3D Model #'.$designId);
// Summary / Description cleanup
$rawSummary = $design['summary'] ?? $design['summaryTranslated'] ?? '';
$cleanDescription = $this->sanitizeHtml($rawSummary);
$shortDescription = Str::limit(strip_tags($rawSummary), 280);
// Cover & Gallery Images
$coverUrl = $design['coverUrl'] ?? $design['cover'] ?? null;
$gallery = [];
if (! empty($design['designExtension']['design_pictures']) && is_array($design['designExtension']['design_pictures'])) {
foreach ($design['designExtension']['design_pictures'] as $pic) {
if (! empty($pic['url']) && $pic['url'] !== $coverUrl) {
$gallery[] = $pic['url'];
}
}
}
if (! empty($design['designExtension']['real_pictures']) && is_array($design['designExtension']['real_pictures'])) {
foreach ($design['designExtension']['real_pictures'] as $pic) {
if (! empty($pic['url']) && $pic['url'] !== $coverUrl && ! in_array($pic['url'], $gallery, true)) {
$gallery[] = $pic['url'];
}
}
}
// Extract print profile stats (first/default instance)
$weightGrams = null;
$printTimeHours = null;
$materials = ['pla', 'petg'];
if (! empty($design['instances']) && is_array($design['instances'])) {
$profile = $design['instances'][0];
if (! empty($profile['weight'])) {
$weightGrams = (int) round($profile['weight']);
}
if (! empty($profile['prediction'])) {
$seconds = (int) $profile['prediction'];
$printTimeHours = round($seconds / 3600, 1);
}
}
// Calculate a smart suggested retail price
// Base starting price of 9.95, + ~0.08 per gram + 1.25 per print hour
$suggestedPrice = 14.95;
if ($weightGrams || $printTimeHours) {
$cost = 5.0;
if ($weightGrams) {
$cost += ($weightGrams * 0.07);
}
if ($printTimeHours) {
$cost += ($printTimeHours * 1.50);
}
$suggestedPrice = round(max(9.95, $cost * 1.6), 2);
}
// Creator and MakerWorld URL
$authorName = $design['designCreator']['name'] ?? $design['designCreator']['handle'] ?? 'MakerWorld Creator';
$makerworldUrl = "https://makerworld.com/nl/models/{$designId}";
$license = $design['license'] ?? 'Standard MakerWorld License';
return [
'makerworld_id' => $designId,
'name' => $title,
'slug' => Str::slug($title),
'short_description' => $shortDescription,
'description' => $cleanDescription,
'base_price' => $suggestedPrice,
'sku' => 'MW-'.$designId,
'is_print_on_demand' => true,
'stock' => 15,
'is_featured' => false,
'is_active' => true,
'weight_grams' => $weightGrams,
'print_time_hours' => $printTimeHours,
'image_url' => $coverUrl,
'makerworld_url' => $makerworldUrl,
'makerworld_author' => $authorName,
'makerworld_license' => $license,
'gallery_images' => array_slice($gallery, 0, 8),
'available_materials' => $materials,
'available_colors' => ['Mat Zwart', 'Sneeuwwit', 'Leisteen Grijs', 'Koningsblauw'],
];
}
/**
* Import or update a product from MakerWorld data.
*/
public function importDesign(
array $designData,
?int $categoryId = null,
?float $customPrice = null,
?string $customName = null,
bool $isActive = true
): Product {
$formatted = $this->formatDesignForImport($designData);
if ($customPrice !== null && $customPrice > 0) {
$formatted['base_price'] = $customPrice;
}
if ($customName !== null && trim($customName) !== '') {
$formatted['name'] = trim($customName);
$formatted['slug'] = Str::slug($formatted['name']);
}
if ($categoryId !== null) {
$formatted['category_id'] = $categoryId;
}
$formatted['is_active'] = $isActive;
// Ensure slug is unique
$baseSlug = $formatted['slug'];
$count = 1;
while (Product::where('slug', $formatted['slug'])->where('makerworld_id', '!=', $formatted['makerworld_id'])->exists()) {
$formatted['slug'] = "{$baseSlug}-{$count}";
$count++;
}
return Product::updateOrCreate(
['makerworld_id' => $formatted['makerworld_id']],
$formatted
);
}
/**
* Basic sanitization of MakerWorld HTML descriptions.
*/
protected function sanitizeHtml(string $html): string
{
$allowed = '<p><br><strong><b><em><i><u><ul><ol><li><h3><h4><h5><blockquote>';
return strip_tags($html, $allowed);
}
}