Files
solidtime/app/Service/Import/Importers/TogglDataImporter.php
2026-07-08 17:19:57 +02:00

250 lines
11 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Service\Import\Importers;
use App\Enums\Role;
use App\Service\TimezoneService;
use Exception;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Override;
use Spatie\TemporaryDirectory\TemporaryDirectory;
use ValueError;
use ZipArchive;
class TogglDataImporter extends DefaultImporter
{
/**
* @throws ImportException
*/
#[Override]
public function importData(string $data, string $timezone): void
{
$temporaryDirectoryZip = null;
$temporaryDirectory = null;
try {
$zip = new ZipArchive;
$temporaryDirectoryZip = TemporaryDirectory::make();
file_put_contents($temporaryDirectoryZip->path('import.zip'), $data);
$res = $zip->open($temporaryDirectoryZip->path('import.zip'), ZipArchive::RDONLY);
if ($res !== true) {
throw new ImportException('Invalid ZIP, error code: '.$res);
}
$temporaryDirectory = TemporaryDirectory::make();
$zip->extractTo($temporaryDirectory->path());
$zip->close();
if (! file_exists($temporaryDirectory->path('clients.json'))) {
throw new ImportException('File "clients.json" missing in ZIP');
}
$clientsFileContent = file_get_contents($temporaryDirectory->path('clients.json'));
if ($clientsFileContent === false) {
throw new ImportException('File "clients.json" can not be opened');
}
$clients = json_decode($clientsFileContent);
if ($clients === null) {
throw new ImportException('File "clients.json" is empty');
}
if (! file_exists($temporaryDirectory->path('projects.json'))) {
throw new ImportException('File "projects.json" missing in ZIP');
}
$projectsFileContent = file_get_contents($temporaryDirectory->path('projects.json'));
if ($projectsFileContent === false) {
throw new ImportException('File "projects.json" can not be opened');
}
$projects = json_decode($projectsFileContent);
if ($projects === null) {
throw new ImportException('File "projects.json" is empty');
}
if (! file_exists($temporaryDirectory->path('tags.json'))) {
throw new ImportException('File "tags.json" missing in ZIP');
}
$tagsFileContent = file_get_contents($temporaryDirectory->path('tags.json'));
if ($tagsFileContent === false) {
throw new ImportException('File "tags.json" can not be opened');
}
$tags = json_decode($tagsFileContent);
if ($tags === null) {
throw new ImportException('File "tags.json" is empty');
}
if (! file_exists($temporaryDirectory->path('workspace_users.json'))) {
throw new ImportException('File "workspace_users.json" missing in ZIP');
}
$workspaceUsersFileContent = file_get_contents($temporaryDirectory->path('workspace_users.json'));
if ($workspaceUsersFileContent === false) {
throw new ImportException('File "workspace_users.json" can not be opened');
}
$workspaceUsers = json_decode($workspaceUsersFileContent);
if ($workspaceUsers === null) {
throw new ImportException('File "workspace_users.json" is empty');
}
foreach ($clients as $client) {
$this->clientImportHelper->getKey([
'name' => $client->name,
'organization_id' => $this->organization->id,
], [
'archived_at' => $client->archived === true ? Carbon::now() : null,
], (string) $client->id);
}
foreach ($tags as $tag) {
$this->tagImportHelper->getKey([
'name' => $tag->name,
'organization_id' => $this->organization->id,
], [], (string) $tag->id);
}
foreach ($workspaceUsers as $workspaceUser) {
$timezone = Str::trim($workspaceUser->timezone);
if ($timezone === '') {
$timezone = 'UTC';
}
if (! app(TimezoneService::class)->isValid($timezone)) {
Log::warning('TogglDateImporter: Invalid timezone', [
'timezone' => $timezone,
]);
$timezone = 'UTC';
}
$userId = $this->userImportHelper->getKey([
'email' => $workspaceUser->email,
], [
'name' => $workspaceUser->name,
'timezone' => $timezone,
'is_placeholder' => true,
], (string) $workspaceUser->uid);
$this->memberImportHelper->getKey([
'user_id' => $userId,
'organization_id' => $this->organization->getKey(),
], [
'role' => Role::Placeholder->value,
], $userId);
}
foreach ($projects as $project) {
$projectExternalId = $this->guardExternalIdentifier($project->id);
$clientId = null;
if ($project->client_id !== null) {
$clientId = $this->clientImportHelper->getKeyByExternalIdentifier((string) $project->client_id);
if ($clientId === null) {
throw new Exception('Client does not exist');
}
}
if (! $this->colorService->isValid($project->color)) {
throw new ImportException('Invalid color');
}
$projectId = $this->projectImportHelper->getKey([
'name' => $project->name,
'client_id' => $clientId,
'organization_id' => $this->organization->getKey(),
], [
'color' => $project->color,
'is_billable' => $project->billable,
'is_public' => ! $project->is_private,
'billable_rate' => $project->rate !== null ? (int) ($project->rate * 100) : null,
], (string) $project->id);
if (! file_exists($temporaryDirectory->path('projects_users/'.$projectExternalId.'.json'))) {
throw new ImportException('File "projects_users/'.$projectExternalId.'.json" missing in ZIP');
}
$projectMembersFileContent = file_get_contents($temporaryDirectory->path('projects_users/'.$projectExternalId.'.json'));
if ($projectMembersFileContent === false) {
throw new ImportException('File "projects_users/'.$projectExternalId.'.json" can not be opened');
}
$projectMembers = json_decode($projectMembersFileContent);
if ($projectMembers === null) {
throw new ImportException('File "projects_users/'.$projectExternalId.'.json" is empty');
}
foreach ($projectMembers as $projectMember) {
$userId = $this->userImportHelper->getKeyByExternalIdentifier((string) $projectMember->user_id);
$this->projectMemberImportHelper->getKey([
'project_id' => $projectId,
'member_id' => $this->memberImportHelper->getKeyByExternalIdentifier($userId),
], [
'user_id' => $userId,
'billable_rate' => $projectMember->rate !== null ? (int) ($projectMember->rate * 100) : null,
]);
}
}
$projectIds = $this->projectImportHelper->getExternalIds();
foreach ($projectIds as $projectIdExternal) {
$projectIdExternal = $this->guardExternalIdentifier($projectIdExternal);
if (! file_exists($temporaryDirectory->path('tasks/'.$projectIdExternal.'.json'))) {
continue;
}
$tasksFileContent = file_get_contents($temporaryDirectory->path('tasks/'.$projectIdExternal.'.json'));
if ($tasksFileContent === false) {
throw new ImportException('File "tasks/'.$projectIdExternal.'.json" can not be opened');
}
$tasks = json_decode($tasksFileContent);
if ($tasks === null) {
throw new ImportException('File "tasks/'.$projectIdExternal.'.json" is empty');
}
foreach ($tasks as $task) {
$projectId = $this->projectImportHelper->getKeyByExternalIdentifier((string) $projectIdExternal);
if ($projectId === null) {
throw new Exception('Project does not exist');
}
$this->taskImportHelper->getKey([
'name' => $task->name,
'project_id' => $projectId,
'organization_id' => $this->organization->getKey(),
], [
'done_at' => $task->active === false ? Carbon::now() : null,
], (string) $task->id);
}
}
} catch (ValueError $exception) {
} catch (ImportException $exception) {
throw $exception;
} catch (Exception $exception) {
report($exception);
throw new ImportException('Unknown error');
} finally {
$temporaryDirectory?->delete();
$temporaryDirectoryZip?->delete();
}
}
/**
* Ensure an externally-sourced identifier can be safely used inside a
* filesystem path. The identifiers originate from the untrusted uploaded
* ZIP, and Spatie's TemporaryDirectory::path() auto-creates any missing
* parent directory of the resolved path, so an unfiltered "../" sequence
* would escape the import sandbox and create/probe arbitrary paths on the
* host (CWE-22). Toggl identifiers are numeric, so restricting them to a
* conservative allow-list rejects traversal without affecting real data.
*
* @throws ImportException
*/
private function guardExternalIdentifier(mixed $id): string
{
if (! is_string($id) && ! is_int($id)) {
throw new ImportException('Invalid identifier in import data');
}
$id = (string) $id;
if (preg_match('/^[A-Za-z0-9_-]+$/', $id) !== 1) {
throw new ImportException('Invalid identifier in import data');
}
return $id;
}
#[Override]
public function getName(): string
{
return __('importer.toggl_data_importer.name');
}
#[Override]
public function getDescription(): string
{
return __('importer.toggl_data_importer.description');
}
}