'Kirjaudu sisään']); exit; } } function requireAdmin() { requireAuth(); if (!isCompanyAdmin()) { http_response_code(403); echo json_encode(['error' => 'Vain ylläpitäjä voi tehdä tämän']); exit; } } function requireSuperAdmin() { requireAuth(); if (($_SESSION['role'] ?? '') !== 'superadmin') { http_response_code(403); echo json_encode(['error' => 'Vain pääkäyttäjä voi tehdä tämän']); exit; } } function isSuperAdmin(): bool { return ($_SESSION['role'] ?? '') === 'superadmin'; } function isCompanyAdmin(): bool { if (($_SESSION['role'] ?? '') === 'superadmin') return true; return ($_SESSION['company_role'] ?? '') === 'admin'; } function currentUser(): string { return $_SESSION['username'] ?? 'tuntematon'; } function generateId(): string { return bin2hex(random_bytes(8)); } function generateToken(): string { return bin2hex(random_bytes(32)); } // ==================== MULTI-COMPANY ==================== function getCompanyDir(?string $companyId = null): string { $id = $companyId ?? ($_SESSION['company_id'] ?? ''); if (empty($id) || !preg_match('/^[a-z0-9-]+$/', $id)) { http_response_code(400); echo json_encode(['error' => 'Yritystä ei ole valittu']); exit; } $dir = DATA_DIR . '/companies/' . $id; if (!file_exists($dir)) mkdir($dir, 0755, true); return $dir; } function requireCompany(): string { $companyId = $_SESSION['company_id'] ?? ''; if (empty($companyId)) { http_response_code(400); echo json_encode(['error' => 'Valitse ensin yritys']); exit; } $userCompanies = $_SESSION['companies'] ?? []; $isSuperadmin = ($_SESSION['role'] ?? '') === 'superadmin'; if (!$isSuperadmin && !in_array($companyId, $userCompanies)) { http_response_code(403); echo json_encode(['error' => 'Ei oikeutta tähän yritykseen']); exit; } // IP-rajoitus: tarkista vasta kun yrityksen dataa käytetään (superadmin ohittaa) if (($_SESSION['role'] ?? '') !== 'superadmin') { $allCompanies = dbLoadCompanies(); foreach ($allCompanies as $comp) { if ($comp['id'] === $companyId) { if (!isIpAllowed(getClientIp(), $comp['allowed_ips'] ?? '')) { http_response_code(403); echo json_encode(['error' => 'IP-osoitteesi ei ole sallittu tälle yritykselle.']); exit; } break; } } } return $companyId; } // Kuten requireCompany(), mutta sallii company_id:n overriden GET-parametrista // Käytetään tiketti-endpointeissa jotta toisen yrityksen tikettejä voi avata function requireCompanyOrParam(): string { $paramCompany = $_GET['company_id'] ?? ''; if (!empty($paramCompany)) { $userCompanies = $_SESSION['companies'] ?? []; if (!in_array($paramCompany, $userCompanies)) { http_response_code(403); echo json_encode(['error' => 'Ei oikeutta tähän yritykseen']); exit; } $_SESSION['company_id'] = $paramCompany; } return requireCompany(); } function companyFile(string $filename): string { return getCompanyDir() . '/' . $filename; } function getClientIp(): string { $xff = $_SERVER['HTTP_X_FORWARDED_FOR'] ?? ''; if ($xff) { // X-Forwarded-For voi sisältää useita IP:itä: "client, proxy1, proxy2" — otetaan ensimmäinen $parts = explode(',', $xff); $ip = trim($parts[0]); if (filter_var($ip, FILTER_VALIDATE_IP)) return $ip; } return $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'; } /** * Tarkista onko IP sallittujen listalla. * Tyhjä lista = ei rajoitusta (kaikki sallittu). * Tukee IPv4 ja IPv6, yksittäisiä osoitteita ja CIDR-alueita. */ function isIpAllowed(string $ip, string $allowedIps): bool { $allowedIps = trim($allowedIps); if ($allowedIps === '' || strtolower($allowedIps) === 'kaikki') return true; $entries = preg_split('/[\s,]+/', $allowedIps, -1, PREG_SPLIT_NO_EMPTY); // Normalisoi IP: IPv4-mapped IPv6 (::ffff:1.2.3.4) → myös IPv4 muotoon $ipBin = @inet_pton($ip); if ($ipBin === false) return false; // Jos IP on IPv4-mapped IPv6 (::ffff:x.x.x.x), kokeile myös puhtaana IPv4:nä $ipv4Equivalent = null; if (strlen($ipBin) === 16 && substr($ipBin, 0, 12) === "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff") { $ipv4Equivalent = inet_ntop(substr($ipBin, 12)); } // Jos IP on puhdas IPv4, kokeile myös mapped-muodossa $ipv6MappedBin = null; if (strlen($ipBin) === 4) { $ipv6MappedBin = "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff" . $ipBin; } foreach ($entries as $entry) { $entry = trim($entry); if ($entry === '') continue; if (strpos($entry, '/') !== false) { // CIDR-alue (IPv4 tai IPv6) [$subnet, $bits] = explode('/', $entry, 2); $bits = (int)$bits; $subnetBin = @inet_pton($subnet); if ($subnetBin === false) continue; $maxBits = strlen($subnetBin) * 8; if ($bits < 0 || $bits > $maxBits) continue; // Rakenna bittimask $mask = str_repeat("\xff", intdiv($bits, 8)); if ($bits % 8) $mask .= chr(0xff << (8 - ($bits % 8))); $mask = str_pad($mask, strlen($subnetBin), "\x00"); // Tarkista suoraan if (strlen($ipBin) === strlen($subnetBin) && ($ipBin & $mask) === ($subnetBin & $mask)) return true; // Tarkista IPv4-equivalent if ($ipv4Equivalent) { $ipv4Bin = @inet_pton($ipv4Equivalent); if ($ipv4Bin && strlen($ipv4Bin) === strlen($subnetBin) && ($ipv4Bin & $mask) === ($subnetBin & $mask)) return true; } // Tarkista IPv6-mapped if ($ipv6MappedBin && strlen($ipv6MappedBin) === strlen($subnetBin) && ($ipv6MappedBin & $mask) === ($subnetBin & $mask)) return true; } else { // Yksittäinen IP (IPv4 tai IPv6) $entryBin = @inet_pton($entry); if ($entryBin === false) continue; if ($ipBin === $entryBin) return true; // Tarkista IPv4-equivalent if ($ipv4Equivalent) { $ipv4Bin = @inet_pton($ipv4Equivalent); if ($ipv4Bin && $ipv4Bin === $entryBin) return true; } // Tarkista IPv6-mapped if ($ipv6MappedBin && $ipv6MappedBin === $entryBin) return true; } } return false; } function normalizeAddress(string $addr): string { $addr = strtolower(trim($addr)); $addr = preg_replace('/\s+/', ' ', $addr); return $addr; } // ==================== EMAIL ==================== function sendMail(string $to, string $subject, string $htmlBody): bool { $headers = "MIME-Version: 1.0\r\n"; $headers .= "Content-Type: text/html; charset=UTF-8\r\n"; $headers .= "From: " . MAIL_FROM_NAME . " <" . MAIL_FROM . ">\r\n"; $headers .= "Reply-To: " . MAIL_FROM . "\r\n"; return mail($to, $subject, $htmlBody, $headers, '-f ' . MAIL_FROM); } // ==================== ZAMMAD CLIENT ==================== class ZammadClient { private string $url; private string $token; public function __construct(string $url, string $token) { $url = rtrim($url, '/'); // Lisää https:// jos protokolla puuttuu if (!preg_match('#^https?://#i', $url)) { $url = 'https://' . $url; } $this->url = $url; $this->token = $token; } private function request(string $method, string $endpoint, ?array $data = null): array { $url = $this->url . '/api/v1/' . ltrim($endpoint, '/'); $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 30, CURLOPT_HTTPHEADER => [ 'Authorization: Token token=' . $this->token, 'Content-Type: application/json', ], ]); if ($method === 'POST') { curl_setopt($ch, CURLOPT_POST, true); if ($data) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data)); } elseif ($method === 'PUT') { curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT'); if ($data) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data)); } $response = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($httpCode >= 400) { $err = json_decode($response, true); throw new \RuntimeException('Zammad API error (' . $httpCode . '): ' . ($err['error'] ?? $response)); } return json_decode($response, true) ?: []; } /** Hae tikettejä (search API palauttaa kaikki joihin on oikeus) */ public function getTickets(array $groupIds = [], int $page = 1, int $perPage = 100, ?string $updatedSince = null): array { if (!empty($groupIds)) { $parts = array_map(fn($id) => 'group_id:' . $id, $groupIds); $query = '(' . implode(' OR ', $parts) . ')'; } else { $query = '*'; } if ($updatedSince) { $query .= ' AND updated_at:>=' . $updatedSince; } return $this->request('GET', 'tickets/search?query=' . urlencode($query) . '&per_page=' . $perPage . '&page=' . $page . '&expand=true'); } /** Hae yksittäinen tiketti */ public function getTicket(int $id): array { return $this->request('GET', 'tickets/' . $id . '?expand=true'); } /** Hae tiketin artikkelit */ public function getArticles(int $ticketId): array { return $this->request('GET', 'ticket_articles/by_ticket/' . $ticketId . '?expand=true'); } /** Lähetä vastaus tikettiin */ public function createArticle(int $ticketId, string $body, string $to = '', string $subject = '', string $type = 'email'): array { $data = [ 'ticket_id' => $ticketId, 'body' => $body, 'content_type' => 'text/html', 'type' => $type, 'internal' => false, 'sender' => 'Agent', ]; if ($to) $data['to'] = $to; if ($subject) $data['subject'] = $subject; return $this->request('POST', 'ticket_articles', $data); } /** Päivitä tiketin tila */ public function updateTicket(int $ticketId, array $fields): array { return $this->request('PUT', 'tickets/' . $ticketId, $fields); } /** Hae ryhmät */ public function getGroups(): array { return $this->request('GET', 'groups?expand=true'); } /** Testaa yhteys */ public function testConnection(): array { $user = $this->request('GET', 'users/me'); $groups = $this->getGroups(); return ['user' => $user['login'] ?? '?', 'groups' => count($groups), 'ok' => true]; } } // ==================== IMAP CLIENT (socket-pohjainen, ei vaadi php-imap) ==================== class ImapClient { private $socket = null; private int $tagCounter = 0; public string $lastError = ''; public function connect(array $config): bool { $host = $config['imap_host'] ?? ''; $port = intval($config['imap_port'] ?? 993); $user = $config['imap_user'] ?? ''; $pass = $config['imap_password'] ?? ''; $encryption = $config['imap_encryption'] ?? 'ssl'; if (empty($host) || empty($user) || empty($pass)) { $this->lastError = 'IMAP-asetukset puuttuvat'; return false; } $prefix = ($encryption === 'ssl') ? 'ssl://' : 'tcp://'; $context = stream_context_create([ 'ssl' => ['verify_peer' => false, 'verify_peer_name' => false] ]); $this->socket = @stream_socket_client( $prefix . $host . ':' . $port, $errno, $errstr, 15, STREAM_CLIENT_CONNECT, $context ); if (!$this->socket) { $this->lastError = "Yhteys epäonnistui: {$errstr} ({$errno})"; return false; } stream_set_timeout($this->socket, 30); // Read greeting $greeting = $this->readLine(); if (!$greeting || strpos($greeting, '* OK') === false) { $this->lastError = 'Palvelin ei vastannut oikein: ' . $greeting; $this->disconnect(); return false; } // STARTTLS if needed if ($encryption === 'tls') { $resp = $this->command('STARTTLS'); if (!$this->isOk($resp)) { $this->lastError = 'STARTTLS epäonnistui'; $this->disconnect(); return false; } if (!stream_socket_enable_crypto($this->socket, true, STREAM_CRYPTO_METHOD_TLS_CLIENT)) { $this->lastError = 'TLS-neuvottelu epäonnistui'; $this->disconnect(); return false; } } // Login $resp = $this->command('LOGIN "' . $this->escape($user) . '" "' . $this->escape($pass) . '"'); if (!$this->isOk($resp)) { $this->lastError = 'Kirjautuminen epäonnistui: väärä tunnus tai salasana'; $this->disconnect(); return false; } // Select INBOX $resp = $this->command('SELECT INBOX'); if (!$this->isOk($resp)) { $this->lastError = 'INBOX:n avaus epäonnistui'; $this->disconnect(); return false; } return true; } public function fetchMessages(int $limit = 50): array { if (!$this->socket) return []; // Get message count from STATUS $resp = $this->command('STATUS INBOX (MESSAGES)'); $totalMessages = 0; foreach ($resp as $line) { if (preg_match('/MESSAGES\s+(\d+)/i', $line, $m)) { $totalMessages = intval($m[1]); } } if ($totalMessages === 0) return []; $start = max(1, $totalMessages - $limit + 1); $range = $start . ':' . $totalMessages; // Fetch headers for range $resp = $this->command("FETCH {$range} (BODY.PEEK[HEADER.FIELDS (FROM SUBJECT DATE MESSAGE-ID IN-REPLY-TO REFERENCES)] BODY.PEEK[TEXT] FLAGS)"); $messages = []; $current = null; $headerBuf = ''; $bodyBuf = ''; $readingHeader = false; $readingBody = false; $headerBytesLeft = 0; $bodyBytesLeft = 0; // Simpler approach: fetch one-by-one for reliability $messages = []; for ($i = $totalMessages; $i >= $start; $i--) { $msg = $this->fetchSingleMessage($i); if ($msg) $messages[] = $msg; } return $messages; } private function fetchSingleMessage(int $num): ?array { // Fetch headers $resp = $this->command("FETCH {$num} BODY.PEEK[HEADER]"); $headerRaw = $this->extractLiteral($resp); if (!$headerRaw) return null; $headers = $this->parseHeaders($headerRaw); $subject = $this->decodeMimeHeader($headers['subject'] ?? ''); $fromRaw = $headers['from'] ?? ''; $fromParsed = $this->parseFrom($fromRaw); $messageId = trim($headers['message-id'] ?? ''); $inReplyTo = trim($headers['in-reply-to'] ?? ''); $references = trim($headers['references'] ?? ''); $dateStr = $headers['date'] ?? ''; $date = $dateStr ? @date('Y-m-d H:i:s', strtotime($dateStr)) : date('Y-m-d H:i:s'); if (!$date) $date = date('Y-m-d H:i:s'); // Parse To $toRaw = $this->decodeMimeHeader($headers['to'] ?? ''); $toEmails = $this->parseCcAddresses($toRaw); // Parse CC $ccRaw = $this->decodeMimeHeader($headers['cc'] ?? ''); $ccEmails = $this->parseCcAddresses($ccRaw); // Fetch body (text part) $body = $this->fetchBody($num); return [ 'subject' => $subject, 'from_email' => $fromParsed['email'], 'from_name' => $this->decodeMimeHeader($fromParsed['name']), 'to' => $toEmails, 'message_id' => $messageId, 'in_reply_to' => $inReplyTo, 'references' => $references, 'date' => $date, 'body' => $body, 'cc' => $ccEmails, ]; } private function fetchBody(int $num): string { // Hae BODYSTRUCTURE rakenteen selvittämiseksi $resp = $this->command("FETCH {$num} BODYSTRUCTURE"); $struct = implode(' ', $resp); error_log("IMAP BODYSTRUCTURE msg#{$num}: " . substr($struct, 0, 500)); // Etsi text/plain osa ja sen koodaus BODYSTRUCTURE:sta // Joustava regex: param=(list|NIL), body-id=(NIL|"str"), body-desc=(NIL|"str"), encoding="str" $pParam = '(?:\([^)]*\)|NIL)'; $pNStr = '(?:NIL|"[^"]*")'; $plainRx = '/"TEXT"\s+"PLAIN"\s+' . $pParam . '\s+' . $pNStr . '\s+' . $pNStr . '\s+"([^"]+)"/i'; $htmlRx = '/"TEXT"\s+"HTML"\s+' . $pParam . '\s+' . $pNStr . '\s+' . $pNStr . '\s+"([^"]+)"/i'; $plainEncoding = ''; $htmlEncoding = ''; if (preg_match($plainRx, $struct, $em)) $plainEncoding = strtoupper($em[1]); if (preg_match($htmlRx, $struct, $em)) $htmlEncoding = strtoupper($em[1]); // Charset text/plain -osasta $charset = 'utf-8'; if (preg_match('/"TEXT"\s+"PLAIN"\s+\([^)]*"CHARSET"\s+"([^"]+)"/i', $struct, $cm)) { $charset = strtolower($cm[1]); } elseif (preg_match('/charset[="\s]+([^\s;"\\)]+)/i', $struct, $cm)) { $charset = strtolower(trim($cm[1], '"')); } // Päättele oikea section-numero BODYSTRUCTURE:n rakenteesta // Yksiosainen: BODYSTRUCTURE ("TEXT" "PLAIN" ...) → BODY[TEXT] // Multipart: BODYSTRUCTURE (("TEXT" "PLAIN" ...) ...) → BODY[1] // Sisäkkäinen: BODYSTRUCTURE ((("TEXT" "PLAIN" ...) ...) ...) → BODY[1.1] $sections = []; $plainPos = stripos($struct, '"TEXT" "PLAIN"'); if ($plainPos !== false) { $bsPos = stripos($struct, 'BODYSTRUCTURE'); $after = ($bsPos !== false) ? substr($struct, $bsPos + 13) : $struct; $plainInAfter = stripos($after, '"TEXT" "PLAIN"'); if ($plainInAfter !== false) { $beforePlain = substr($after, 0, $plainInAfter); $depth = substr_count($beforePlain, '(') - substr_count($beforePlain, ')'); if ($depth <= 1) { $sections[] = 'TEXT'; // yksiosainen viesti } elseif ($depth === 2) { $sections[] = '1'; // suora lapsi multipartissa } elseif ($depth >= 3) { $sections[] = '1.1'; // sisäkkäinen multipart } } } // Lisää fallbackit foreach (['1', '1.1', 'TEXT'] as $fb) { if (!in_array($fb, $sections)) $sections[] = $fb; } error_log("IMAP sections to try for msg#{$num}: " . implode(', ', $sections) . " | plainEnc={$plainEncoding} htmlEnc={$htmlEncoding} charset={$charset}"); // Kokeile osioita järjestyksessä $body = ''; $usedSection = ''; foreach ($sections as $sec) { $resp = $this->command("FETCH {$num} BODY.PEEK[{$sec}]"); $data = $this->extractLiteral($resp); if ($data && strlen(trim($data)) > 0) { $body = $data; $usedSection = $sec; break; } } if (!$body) return ''; // Päättele käytettävä koodaus $encoding = $plainEncoding; // Jos BODYSTRUCTURE ei löytänyt text/plain koodausta, kokeile raakaa hakua if (!$encoding) { if (preg_match('/"(BASE64|QUOTED-PRINTABLE|7BIT|8BIT)"/i', $struct, $em)) { $encoding = strtoupper($em[1]); } } error_log("IMAP body msg#{$num}: section={$usedSection} encoding={$encoding} bodyLen=" . strlen($body) . " first100=" . substr($body, 0, 100)); // Dekoodaa sisältö if ($encoding === 'BASE64') { $decoded = @base64_decode($body); if ($decoded !== false) $body = $decoded; } elseif ($encoding === 'QUOTED-PRINTABLE') { $body = quoted_printable_decode($body); } // Jos koodausta ei tunnistettu, yritä automaattinen tunnistus if (!$encoding || $encoding === '7BIT' || $encoding === '8BIT') { $trimmed = trim($body); // Tarkista näyttääkö base64:ltä if (preg_match('/^[A-Za-z0-9+\/=\s]+$/', $trimmed) && strlen($trimmed) > 50) { $decoded = @base64_decode($trimmed, true); if ($decoded !== false && strlen($decoded) > 0 && preg_match('/[\x20-\x7E\xC0-\xFF]/', $decoded)) { $body = $decoded; } } // Tarkista näyttääkö quoted-printable:lta (sisältää =XX koodeja) elseif (preg_match('/=[0-9A-Fa-f]{2}/', $body) && substr_count($body, '=') > 3) { $decoded = quoted_printable_decode($body); if (strlen($decoded) < strlen($body)) { $body = $decoded; } } } // Jos body sisältää multipart-rajoja (haettiin väärä osio), yritä parsia plain text if (preg_match('/^--[^\r\n]+\r?\n/m', $body) && preg_match('/Content-Type:/i', $body)) { error_log("IMAP msg#{$num}: body contains MIME boundaries, trying to extract text/plain"); $extracted = $this->extractPlainFromMultipart($body); if ($extracted) $body = $extracted; } // Riisu HTML jos sisältö on HTML:ää if (preg_match('/]+)>/', $from, $m)) { return ['name' => trim($m[1], ' "'), 'email' => trim($m[2])]; } if (preg_match('/^([^\s@]+@[^\s@]+)/', $from, $m)) { return ['name' => '', 'email' => $m[1]]; } return ['name' => '', 'email' => $from]; } private function parseCcAddresses(string $cc): string { $cc = trim($cc); if (!$cc) return ''; // Parse "Name , Name2 " or "email1, email2" $emails = []; // Split on comma, but be careful with quoted strings $parts = preg_split('/,\s*(?=(?:[^"]*"[^"]*")*[^"]*$)/', $cc); foreach ($parts as $part) { $part = trim($part); if (!$part) continue; $parsed = $this->parseFrom($part); if ($parsed['email']) $emails[] = $parsed['email']; } return implode(', ', $emails); } private function decodeMimeHeader(string $str): string { if (strpos($str, '=?') === false) return trim($str); $decoded = ''; $parts = preg_split('/(=\?[^\?]+\?[BbQq]\?[^\?]*\?=)/', $str, -1, PREG_SPLIT_DELIM_CAPTURE); foreach ($parts as $part) { if (preg_match('/^=\?([^\?]+)\?([BbQq])\?([^\?]*)\?=$/', $part, $m)) { $charset = $m[1]; $encoding = strtoupper($m[2]); $text = $m[3]; if ($encoding === 'B') { $text = base64_decode($text); } elseif ($encoding === 'Q') { $text = quoted_printable_decode(str_replace('_', ' ', $text)); } if (strtolower($charset) !== 'utf-8') { $converted = @iconv($charset, 'UTF-8//IGNORE', $text); if ($converted !== false) $text = $converted; } $decoded .= $text; } else { // Remove whitespace between encoded words if (trim($part) === '') continue; $decoded .= $part; } } return trim($decoded); } private function command(string $cmd): array { $tag = 'A' . (++$this->tagCounter); $this->writeLine("{$tag} {$cmd}"); $response = []; while (true) { $line = $this->readLine(); if ($line === false || $line === null) break; $response[] = $line; // Check for literal {N} — read N bytes if (preg_match('/\{(\d+)\}$/', $line, $m)) { $bytes = intval($m[1]); $data = $this->readBytes($bytes); $response[] = $data; // Read the closing line after literal $closingLine = $this->readLine(); if ($closingLine !== false && $closingLine !== null) { $response[] = $closingLine; } continue; } // Tagged response = done if (strpos($line, $tag . ' ') === 0) break; } return $response; } private function extractLiteral(array $resp): string { $result = ''; for ($i = 0; $i < count($resp); $i++) { if (preg_match('/\{(\d+)\}$/', $resp[$i], $m)) { // Next element should be the literal data if (isset($resp[$i + 1])) { $result .= $resp[$i + 1]; } } } return $result; } private function isOk(array $resp): bool { foreach ($resp as $line) { if (preg_match('/^A\d+\s+OK/i', $line)) return true; if (preg_match('/^A\d+\s+(NO|BAD)/i', $line)) return false; } return false; } private function escape(string $str): string { return str_replace(['\\', '"'], ['\\\\', '\\"'], $str); } private function writeLine(string $line): void { if (!$this->socket) return; fwrite($this->socket, $line . "\r\n"); } private function readLine(): ?string { if (!$this->socket) return null; $line = fgets($this->socket, 8192); if ($line === false) return null; return rtrim($line, "\r\n"); } private function readBytes(int $n): string { if (!$this->socket) return ''; $data = ''; $remaining = $n; while ($remaining > 0) { $chunk = fread($this->socket, min($remaining, 8192)); if ($chunk === false || $chunk === '') break; $data .= $chunk; $remaining -= strlen($chunk); } return $data; } public function disconnect(): void { if ($this->socket) { try { $this->command('LOGOUT'); } catch (\Throwable $e) {} @fclose($this->socket); $this->socket = null; } } } // ==================== TICKETS HELPER ==================== function sendTelegramAlert(string $companyId, array $ticket): void { $config = dbLoadConfig(); $botToken = $config['telegram_bot_token'] ?? ''; $chatId = $config['telegram_chat_id'] ?? ''; if (!$botToken || !$chatId) return; $text = "🚨 *URGENT TIKETTI*\n\n"; $text .= "📋 *" . ($ticket['subject'] ?? '(Ei aihetta)') . "*\n"; $text .= "👤 " . ($ticket['from_name'] ?? $ticket['from_email'] ?? 'Tuntematon') . "\n"; $text .= "📧 " . ($ticket['from_email'] ?? '') . "\n"; $text .= "🏢 " . $companyId . "\n"; $text .= "🕐 " . date('d.m.Y H:i'); $url = "https://api.telegram.org/bot{$botToken}/sendMessage"; $data = [ 'chat_id' => $chatId, 'text' => $text, 'parse_mode' => 'Markdown', ]; $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => json_encode($data), CURLOPT_HTTPHEADER => ['Content-Type: application/json'], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 5, ]); curl_exec($ch); curl_close($ch); } /** * Generoi oletusallekirjoitukset käyttäjälle niille mailboxeille joille ei ole omaa. * Palauttaa yhdistetyn allekirjoitukset-arrayn (omat + generoitut oletukset). */ function buildSignaturesWithDefaults(array $user, array $userCompanyIds): array { $sigs = $user['signatures'] ?? []; $allCompanies = dbLoadCompanies(); foreach ($allCompanies as $comp) { if (!in_array($comp['id'], $userCompanyIds)) continue; $mailboxes = dbLoadMailboxes($comp['id']); foreach ($mailboxes as $mb) { if (!empty($sigs[$mb['id']])) continue; // käyttäjällä on jo oma allekirjoitus // Generoi oletus: Etunimi \n Yritys \n sähköposti $etunimi = trim(explode(' ', $user['nimi'] ?? '')[0]); $yritys = ($comp['nimi'] ?? '') . ' Oy'; $email = $mb['smtp_from_email'] ?? $mb['imap_user'] ?? ''; $parts = array_filter([$etunimi, $yritys, $email]); if (!empty($parts)) { $sigs[$mb['id']] = implode("\n", $parts); } } } return $sigs; } function sendTicketMail(string $to, string $subject, string $body, string $inReplyTo = '', string $references = '', ?array $mailbox = null, string $cc = ''): bool { $fromEmail = $mailbox['smtp_from_email'] ?? $mailbox['imap_user'] ?? MAIL_FROM; $fromName = $mailbox['smtp_from_name'] ?? $mailbox['nimi'] ?? 'Asiakaspalvelu'; // Jos mailboxilla on SMTP-asetukset, käytä SMTP:tä $smtpHost = $mailbox['smtp_host'] ?? ''; error_log("MAIL DEBUG: to={$to} smtpHost={$smtpHost} from={$fromEmail} mailbox_keys=" . implode(',', array_keys($mailbox ?? []))); if ($smtpHost !== '') { return sendViaSMTP($to, $subject, $body, $fromEmail, $fromName, $inReplyTo, $references, $mailbox, $cc); } // Fallback: PHP mail() $headers = "MIME-Version: 1.0\r\n"; $headers .= "Content-Type: text/plain; charset=UTF-8\r\n"; $headers .= "From: {$fromName} <{$fromEmail}>\r\n"; $headers .= "Reply-To: {$fromEmail}\r\n"; if ($cc) { $headers .= "Cc: {$cc}\r\n"; } if ($inReplyTo) { $headers .= "In-Reply-To: {$inReplyTo}\r\n"; $headers .= "References: " . ($references ? $references . ' ' : '') . $inReplyTo . "\r\n"; } return mail($to, $subject, $body, $headers, '-f ' . $fromEmail); } /** @var string|null Viimeisin SMTP-virhe (palautetaan frontendille) */ $GLOBALS['smtp_last_error'] = null; function smtpReadResponse($fp): string { $full = ''; while ($line = @fgets($fp, 512)) { $full .= $line; // Viimeinen rivi: koodi + välilyönti (ei -) if (isset($line[3]) && $line[3] !== '-') break; // Timeout / EOF if ($line === false) break; } return $full; } function smtpCommand($fp, string $cmd): string { fwrite($fp, $cmd . "\r\n"); return smtpReadResponse($fp); } function smtpCode(string $resp): string { return substr(trim($resp), 0, 3); } function sendViaSMTP(string $to, string $subject, string $body, string $fromEmail, string $fromName, string $inReplyTo, string $references, array $mailbox, string $cc): bool { $host = $mailbox['smtp_host']; $port = (int)($mailbox['smtp_port'] ?? 587); // Fallback-ketju käyttäjälle: smtp_user → imap_user → smtp_from_email $user = $mailbox['smtp_user'] ?? ''; if ($user === '') $user = $mailbox['imap_user'] ?? ''; if ($user === '') $user = $fromEmail; // Fallback salasanalle: smtp_password → imap_password $pass = $mailbox['smtp_password'] ?? ''; if ($pass === '') $pass = $mailbox['imap_password'] ?? ''; $encryption = $mailbox['smtp_encryption'] ?? 'tls'; $log = []; // Debug-loki $fail = function(string $step, string $detail) use (&$fp, &$log) { $log[] = "FAIL @ {$step}: {$detail}"; $msg = "SMTP {$step}: {$detail} | log: " . implode(' → ', $log); error_log($msg); $GLOBALS['smtp_last_error'] = "SMTP {$step}: {$detail}"; if (isset($fp) && is_resource($fp)) fclose($fp); return false; }; // 1. Yhteys $timeout = 15; $errno = 0; $errstr = ''; $connStr = ($encryption === 'ssl' ? "ssl" : "tcp") . "://{$host}:{$port}"; $log[] = "connect {$connStr}"; $ctx = stream_context_create(['ssl' => [ 'verify_peer' => false, 'verify_peer_name' => false, 'allow_self_signed' => true, ]]); $fp = @stream_socket_client($connStr, $errno, $errstr, $timeout, STREAM_CLIENT_CONNECT, $ctx); if (!$fp) return $fail('connect', "{$errstr} ({$errno})"); stream_set_timeout($fp, $timeout); // 2. Banner $resp = smtpReadResponse($fp); $log[] = "banner:" . smtpCode($resp); if (smtpCode($resp) !== '220') return $fail('banner', trim($resp)); // 3. EHLO $ehlo = smtpCommand($fp, "EHLO " . gethostname()); $log[] = "ehlo:" . smtpCode($ehlo); if (smtpCode($ehlo) !== '250') return $fail('EHLO', trim($ehlo)); // 4. STARTTLS if ($encryption === 'tls') { $resp = smtpCommand($fp, "STARTTLS"); $log[] = "starttls:" . smtpCode($resp); if (smtpCode($resp) !== '220') return $fail('STARTTLS', trim($resp)); $crypto = @stream_socket_enable_crypto($fp, true, STREAM_CRYPTO_METHOD_TLSv1_2_CLIENT | STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT); if (!$crypto) return $fail('TLS', 'negotiation failed'); $log[] = "tls:ok"; // EHLO opnieuw na TLS $ehlo = smtpCommand($fp, "EHLO " . gethostname()); $log[] = "ehlo2:" . smtpCode($ehlo); } // 5. AUTH — probeer eerst PLAIN, dan LOGIN if ($user !== '') { $authOk = false; // AUTH PLAIN $cred = base64_encode("\0{$user}\0{$pass}"); $resp = smtpCommand($fp, "AUTH PLAIN {$cred}"); $log[] = "auth_plain:" . smtpCode($resp); if (smtpCode($resp) === '235') { $authOk = true; } else { // AUTH LOGIN fallback $resp = smtpCommand($fp, "AUTH LOGIN"); $log[] = "auth_login:" . smtpCode($resp); if (smtpCode($resp) === '334') { $resp = smtpCommand($fp, base64_encode($user)); $log[] = "auth_user:" . smtpCode($resp); if (smtpCode($resp) === '334') { $resp = smtpCommand($fp, base64_encode($pass)); $log[] = "auth_pass:" . smtpCode($resp); if (smtpCode($resp) === '235') { $authOk = true; } } } } if (!$authOk) { $passHint = strlen($pass) > 0 ? strlen($pass) . ' chars' : 'EMPTY'; return $fail('AUTH', trim($resp) . " (user={$user}, pass={$passHint})"); } $log[] = "auth:ok"; } // 6. MAIL FROM $resp = smtpCommand($fp, "MAIL FROM:<{$fromEmail}>"); $log[] = "from:" . smtpCode($resp); if (smtpCode($resp) !== '250') return $fail('MAIL FROM', trim($resp)); // 7. RCPT TO $allRecipients = array_filter(array_map('trim', explode(',', $to))); if ($cc) $allRecipients = array_merge($allRecipients, array_filter(array_map('trim', explode(',', $cc)))); foreach ($allRecipients as $rcpt) { $resp = smtpCommand($fp, "RCPT TO:<{$rcpt}>"); $log[] = "rcpt:" . smtpCode($resp); if (!in_array(smtpCode($resp), ['250', '251'])) return $fail('RCPT TO', trim($resp) . " ({$rcpt})"); } // 8. DATA $resp = smtpCommand($fp, "DATA"); $log[] = "data:" . smtpCode($resp); if (smtpCode($resp) !== '354') return $fail('DATA', trim($resp)); // 9. Viesti $messageId = '<' . uniqid('msg_', true) . '@' . (explode('@', $fromEmail)[1] ?? 'localhost') . '>'; $msg = "From: {$fromName} <{$fromEmail}>\r\n"; $msg .= "To: {$to}\r\n"; if ($cc) $msg .= "Cc: {$cc}\r\n"; $msg .= "Subject: =?UTF-8?B?" . base64_encode($subject) . "?=\r\n"; $msg .= "Message-ID: {$messageId}\r\n"; if ($inReplyTo) { $msg .= "In-Reply-To: {$inReplyTo}\r\n"; $msg .= "References: " . ($references ? $references . ' ' : '') . $inReplyTo . "\r\n"; } $msg .= "MIME-Version: 1.0\r\n"; $msg .= "Content-Type: text/plain; charset=UTF-8\r\n"; $msg .= "Content-Transfer-Encoding: base64\r\n"; $msg .= "Date: " . date('r') . "\r\n"; $msg .= "\r\n"; $msg .= chunk_split(base64_encode($body)); // Lopeta piste omalla rivillä fwrite($fp, $msg . "\r\n.\r\n"); $resp = smtpReadResponse($fp); $log[] = "send:" . smtpCode($resp); if (smtpCode($resp) !== '250') return $fail('send', trim($resp)); // QUIT fwrite($fp, "QUIT\r\n"); fclose($fp); error_log("SMTP OK: " . implode(' → ', $log)); return true; } function parseLiittymat(array $input): array { $liittymat = []; foreach (($input['liittymat'] ?? []) as $l) { $liittymat[] = [ 'asennusosoite' => trim($l['asennusosoite'] ?? ''), 'postinumero' => trim($l['postinumero'] ?? ''), 'kaupunki' => trim($l['kaupunki'] ?? ''), 'liittymanopeus' => trim($l['liittymanopeus'] ?? ''), 'hinta' => floatval($l['hinta'] ?? 0), 'sopimuskausi' => trim($l['sopimuskausi'] ?? ''), 'alkupvm' => trim($l['alkupvm'] ?? ''), 'vlan' => trim($l['vlan'] ?? ''), 'laite' => trim($l['laite'] ?? ''), 'portti' => trim($l['portti'] ?? ''), 'ip' => trim($l['ip'] ?? ''), ]; } if (empty($liittymat)) { $liittymat[] = ['asennusosoite' => '', 'postinumero' => '', 'kaupunki' => '', 'liittymanopeus' => '', 'hinta' => 0, 'sopimuskausi' => '', 'alkupvm' => '', 'vlan' => '', 'laite' => '', 'portti' => '', 'ip' => '']; } return $liittymat; } // ==================== ROUTES ==================== switch ($action) { // ---------- SAATAVUUS (julkinen API) ---------- case 'saatavuus': $providedKey = $_GET['key'] ?? ($_SERVER['HTTP_X_API_KEY'] ?? ''); if (empty($providedKey)) { http_response_code(403); echo json_encode(['error' => 'API-avain puuttuu']); break; } // Etsi yritys jonka API-avain täsmää $matchedCompany = dbGetCompanyByApiKey($providedKey); if (!$matchedCompany) { http_response_code(403); echo json_encode(['error' => 'Virheellinen API-avain']); break; } // CORS - yrityskohtaiset originit $allowedOrigins = dbGetCompanyCorsOrigins($matchedCompany['id']); $origin = $_SERVER['HTTP_ORIGIN'] ?? ''; if (in_array($origin, $allowedOrigins)) { header("Access-Control-Allow-Origin: $origin"); header('Access-Control-Allow-Methods: GET, OPTIONS'); header('Access-Control-Allow-Headers: Content-Type, X-Api-Key'); } if ($method === 'OPTIONS') { http_response_code(204); break; } // Parametrit $queryOsoite = normalizeAddress($_GET['osoite'] ?? ''); $queryPostinumero = trim($_GET['postinumero'] ?? ''); $queryKaupunki = strtolower(trim($_GET['kaupunki'] ?? '')); if (empty($queryOsoite) || empty($queryPostinumero) || empty($queryKaupunki)) { http_response_code(400); echo json_encode(['error' => 'Anna osoite, postinumero ja kaupunki']); break; } // Hae VAIN tämän yrityksen asiakkaista $customers = dbLoadCustomers($matchedCompany['id']); $found = false; foreach ($customers as $c) { foreach ($c['liittymat'] ?? [] as $l) { $addr = normalizeAddress($l['asennusosoite'] ?? ''); $zip = trim($l['postinumero'] ?? ''); $city = strtolower(trim($l['kaupunki'] ?? '')); if ($zip === $queryPostinumero && $city === $queryKaupunki) { if (!empty($addr) && !empty($queryOsoite)) { if (strpos($addr, $queryOsoite) !== false || strpos($queryOsoite, $addr) !== false) { $found = true; break 2; } } } } } echo json_encode(['saatavilla' => $found]); break; // ---------- CONFIG (admin, yrityskohtainen) ---------- case 'config': requireAdmin(); $companyId = requireCompany(); $globalConf = dbLoadConfig(); echo json_encode([ 'api_key' => dbGetCompanyApiKey($companyId), 'cors_origins' => dbGetCompanyCorsOrigins($companyId), 'telegram_bot_token' => $globalConf['telegram_bot_token'] ?? '', 'telegram_chat_id' => $globalConf['telegram_chat_id'] ?? '', ]); break; case 'config_update': requireAdmin(); $companyId = requireCompany(); if ($method !== 'POST') break; $input = json_decode(file_get_contents('php://input'), true); if (isset($input['api_key'])) { dbSetCompanyApiKey($companyId, trim($input['api_key'])); } if (isset($input['cors_origins'])) { $origins = array_filter(array_map('trim', explode("\n", $input['cors_origins']))); dbSetCompanyCorsOrigins($companyId, array_values($origins)); } // Telegram-asetukset (globaalit, tallennetaan config-tauluun) if (isset($input['telegram_bot_token'])) { dbSaveConfig(['telegram_bot_token' => trim($input['telegram_bot_token'])]); } if (isset($input['telegram_chat_id'])) { dbSaveConfig(['telegram_chat_id' => trim($input['telegram_chat_id'])]); } dbAddLog($companyId, currentUser(), 'config_update', '', '', 'Päivitti asetuksia'); echo json_encode([ 'api_key' => dbGetCompanyApiKey($companyId), 'cors_origins' => dbGetCompanyCorsOrigins($companyId), ]); break; case 'telegram_test': requireAdmin(); if ($method !== 'POST') break; $config = dbLoadConfig(); $botToken = $config['telegram_bot_token'] ?? ''; $chatId = $config['telegram_chat_id'] ?? ''; if (!$botToken || !$chatId) { http_response_code(400); echo json_encode(['error' => 'Telegram Bot Token ja Chat ID vaaditaan']); break; } $url = "https://api.telegram.org/bot{$botToken}/sendMessage"; $data = ['chat_id' => $chatId, 'text' => '✅ Noxus HUB Telegram-hälytys toimii!', 'parse_mode' => 'Markdown']; $ch = curl_init($url); curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => json_encode($data), CURLOPT_HTTPHEADER => ['Content-Type: application/json'], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 5]); $resp = curl_exec($ch); $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); if ($httpCode === 200) { echo json_encode(['success' => true]); } else { http_response_code(400); echo json_encode(['error' => 'Telegram virhe: ' . $resp]); } break; case 'generate_api_key': requireAdmin(); $companyId = requireCompany(); if ($method !== 'POST') break; $newApiKey = bin2hex(random_bytes(16)); dbSetCompanyApiKey($companyId, $newApiKey); dbAddLog($companyId, currentUser(), 'config_update', '', '', 'Generoi uuden API-avaimen'); echo json_encode([ 'api_key' => $newApiKey, 'cors_origins' => dbGetCompanyCorsOrigins($companyId), ]); break; // ---------- CAPTCHA ---------- case 'captcha': $a = rand(1, 20); $b = rand(1, 20); $_SESSION['captcha_answer'] = $a + $b; echo json_encode(['question' => "$a + $b = ?"]); break; // ---------- BRANDING (julkinen) ---------- case 'branding': $host = strtolower(explode(':', $_SERVER['HTTP_HOST'] ?? '')[0]); echo json_encode(dbGetBranding($host)); break; case 'company_logo': $companyId = $_GET['company_id'] ?? ''; if (empty($companyId) || !preg_match('/^[a-z0-9-]+$/', $companyId)) { http_response_code(400); echo json_encode(['error' => 'Virheellinen company_id']); break; } $companies = dbLoadCompanies(); $logoFile = ''; foreach ($companies as $comp) { if ($comp['id'] === $companyId) { $logoFile = $comp['logo_file'] ?? ''; break; } } if (empty($logoFile)) { http_response_code(404); echo json_encode(['error' => 'Logoa ei löydy']); break; } $logoPath = DATA_DIR . '/companies/' . $companyId . '/' . $logoFile; if (!file_exists($logoPath)) { http_response_code(404); echo json_encode(['error' => 'Logotiedostoa ei löydy']); break; } $ext = strtolower(pathinfo($logoFile, PATHINFO_EXTENSION)); $mimeTypes = ['png' => 'image/png', 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg', 'svg' => 'image/svg+xml', 'webp' => 'image/webp']; $mime = $mimeTypes[$ext] ?? 'application/octet-stream'; header('Content-Type: ' . $mime); header('Cache-Control: public, max-age=3600'); readfile($logoPath); exit; case 'company_logo_upload': requireAdmin(); if ($method !== 'POST') break; $companyId = $_POST['company_id'] ?? ''; if (empty($companyId) || !preg_match('/^[a-z0-9-]+$/', $companyId)) { http_response_code(400); echo json_encode(['error' => 'Virheellinen company_id']); break; } if (!isset($_FILES['logo']) || $_FILES['logo']['error'] !== UPLOAD_ERR_OK) { http_response_code(400); echo json_encode(['error' => 'Logotiedosto puuttuu tai virhe uploadissa']); break; } $file = $_FILES['logo']; // Validoi koko (max 2MB) if ($file['size'] > 2 * 1024 * 1024) { http_response_code(400); echo json_encode(['error' => 'Logo on liian suuri (max 2MB)']); break; } // Validoi tyyppi (tiedostopäätteen + mahdollisen finfo:n perusteella) $allowedExtensions = ['png', 'jpg', 'jpeg', 'svg', 'webp']; $origExt = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)); if (!in_array($origExt, $allowedExtensions)) { http_response_code(400); echo json_encode(['error' => 'Sallitut tiedostotyypit: PNG, JPG, SVG, WebP']); break; } // Käytä finfo:a jos saatavilla, muuten luota tiedostopäätteeseen if (function_exists('finfo_open')) { $finfo = finfo_open(FILEINFO_MIME_TYPE); $detectedType = finfo_file($finfo, $file['tmp_name']); finfo_close($finfo); $allowedMimes = ['image/png', 'image/jpeg', 'image/svg+xml', 'image/webp']; if (!in_array($detectedType, $allowedMimes)) { http_response_code(400); echo json_encode(['error' => 'Sallitut tiedostotyypit: PNG, JPG, SVG, WebP']); break; } } $extNormalize = ['jpeg' => 'jpg']; $ext = $extNormalize[$origExt] ?? $origExt; $newFilename = 'logo.' . $ext; $compDir = DATA_DIR . '/companies/' . $companyId; // Luo kansio tarvittaessa (data on nyt MySQL:ssä, kansio vain logoille) if (!file_exists($compDir)) { mkdir($compDir, 0755, true); } // Poista vanha logo ja päivitä kantaan $companies = dbLoadCompanies(); $found = false; foreach ($companies as $comp) { if ($comp['id'] === $companyId) { $oldLogo = $comp['logo_file'] ?? ''; if ($oldLogo && $oldLogo !== $newFilename && file_exists($compDir . '/' . $oldLogo)) { unlink($compDir . '/' . $oldLogo); } $comp['logo_file'] = $newFilename; dbSaveCompany($comp); $found = true; break; } } if (!$found) { http_response_code(404); echo json_encode(['error' => 'Yritystä ei löydy']); break; } move_uploaded_file($file['tmp_name'], $compDir . '/' . $newFilename); echo json_encode([ 'success' => true, 'logo_file' => $newFilename, 'logo_url' => "api.php?action=company_logo&company_id=" . urlencode($companyId), ]); break; // ---------- AUTH ---------- case 'login': if ($method !== 'POST') break; $ip = getClientIp(); if (!dbCheckRateLimit($ip)) { http_response_code(429); echo json_encode(['error' => 'Liian monta kirjautumisyritystä. Yritä uudelleen 15 minuutin kuluttua.']); break; } $input = json_decode(file_get_contents('php://input'), true); // Captcha-tarkistus $captchaAnswer = intval($input['captcha'] ?? 0); if (!isset($_SESSION['captcha_answer']) || $captchaAnswer !== $_SESSION['captcha_answer']) { dbRecordLoginAttempt($ip); http_response_code(400); echo json_encode(['error' => 'Virheellinen captcha-vastaus']); unset($_SESSION['captcha_answer']); break; } unset($_SESSION['captcha_answer']); $username = trim($input['username'] ?? ''); $password = $input['password'] ?? ''; $u = dbGetUserByUsername($username); if ($u && password_verify($password, $u['password_hash'])) { $userCompanies = $u['companies'] ?? []; // Domain-pohjainen kirjautumisrajoitus $host = strtolower(explode(':', $_SERVER['HTTP_HOST'] ?? '')[0]); $domainCompany = dbGetCompanyByDomain($host); $domainCompanyId = $domainCompany ? $domainCompany['id'] : ''; // Jos domain kuuluu tietylle yritykselle, vain sen yrityksen käyttäjät + adminit pääsevät sisään if ($domainCompanyId && $u['role'] !== 'superadmin' && !in_array($domainCompanyId, $userCompanies)) { dbRecordLoginAttempt($ip); http_response_code(403); echo json_encode(['error' => 'Sinulla ei ole oikeutta kirjautua tälle sivustolle.']); break; } $allCompanies = dbLoadCompanies(); // IP-rajoitus: selvitä mihin yrityksiin on pääsy (superadmin ohittaa) $allowedCompanyIds = $userCompanies; if ($u['role'] !== 'superadmin') { $allowedCompanyIds = []; foreach ($allCompanies as $comp) { if (in_array($comp['id'], $userCompanies) && isIpAllowed($ip, $comp['allowed_ips'] ?? '')) { $allowedCompanyIds[] = $comp['id']; } } // Jos kaikki yritykset IP-estetty → estä kirjautuminen if (empty($allowedCompanyIds) && !empty($userCompanies)) { dbRecordLoginAttempt($ip); http_response_code(403); echo json_encode(['error' => 'IP-osoitteesi (' . $ip . ') ei ole sallittu. Ota yhteyttä ylläpitoon.']); break; } } // false = säilytä vanha sessio hetken (estää race condition kun frontend lataa heti dataa) session_regenerate_id(false); $_SESSION['user_id'] = $u['id']; $_SESSION['username'] = $u['username']; $_SESSION['nimi'] = $u['nimi']; $_SESSION['role'] = $u['role']; // Superadmin saa pääsyn kaikkiin yrityksiin if ($u['role'] === 'superadmin') { $allIds = array_map(fn($c) => $c['id'], $allCompanies); $_SESSION['companies'] = $allIds; } else { $_SESSION['companies'] = $userCompanies; } $_SESSION['company_roles'] = $u['company_roles'] ?? []; // Valitse aktiivinen yritys: domain-match > ensimmäinen sallittu if ($domainCompanyId && in_array($domainCompanyId, $allowedCompanyIds)) { $_SESSION['company_id'] = $domainCompanyId; } elseif (!empty($allowedCompanyIds)) { $_SESSION['company_id'] = $allowedCompanyIds[0]; } else { $_SESSION['company_id'] = !empty($userCompanies) ? $userCompanies[0] : ''; } // Aseta aktiivisen yrityksen rooli $_SESSION['company_role'] = $_SESSION['company_roles'][$_SESSION['company_id']] ?? 'user'; // Hae yritysten nimet + IP-status $companyList = []; foreach ($allCompanies as $comp) { // Superadmin näkee kaikki yritykset if ($u['role'] === 'superadmin' || in_array($comp['id'], $userCompanies)) { $entry = ['id' => $comp['id'], 'nimi' => $comp['nimi']]; // Merkitse IP-estetyt yritykset (superadmin ohittaa) if ($u['role'] !== 'superadmin' && !isIpAllowed($ip, $comp['allowed_ips'] ?? '')) { $entry['ip_blocked'] = true; } $companyList[] = $entry; } } echo json_encode([ 'success' => true, 'username' => $u['username'], 'nimi' => $u['nimi'], 'role' => $u['role'], 'company_role' => $_SESSION['company_role'], 'companies' => $companyList, 'company_id' => $_SESSION['company_id'], 'signatures' => buildSignaturesWithDefaults($u, $u['companies'] ?? []), ]); } else { dbRecordLoginAttempt($ip); http_response_code(401); echo json_encode(['error' => 'Väärä käyttäjätunnus tai salasana']); } break; case 'logout': session_destroy(); echo json_encode(['success' => true]); break; case 'check_auth': if (isset($_SESSION['user_id'])) { // Synkronoi aina tuoreet yritysoikeudet tietokannasta sessioon $u = dbGetUser($_SESSION['user_id']); if ($u) { // Superadmin saa pääsyn kaikkiin yrityksiin if (($u['role'] ?? '') === 'superadmin') { $allComps = dbLoadCompanies(); $_SESSION['companies'] = array_map(fn($c) => $c['id'], $allComps); } else { $_SESSION['companies'] = $u['companies'] ?? []; } $_SESSION['company_roles'] = $u['company_roles'] ?? []; // Varmista aktiivinen yritys on sallittu if (!in_array($_SESSION['company_id'] ?? '', $_SESSION['companies'])) { $_SESSION['company_id'] = !empty($_SESSION['companies']) ? $_SESSION['companies'][0] : ''; } // Päivitä aktiivisen yrityksen rooli $_SESSION['company_role'] = $_SESSION['company_roles'][$_SESSION['company_id'] ?? ''] ?? 'user'; } // Hae yritysten nimet ja IP-status $userCompanyIds = $_SESSION['companies'] ?? []; $allCompanies = dbLoadCompanies(); $ip = getClientIp(); $isSuperAdmin = ($_SESSION['role'] ?? '') === 'superadmin'; $companyList = []; $firstAllowedId = null; foreach ($allCompanies as $comp) { // Superadmin näkee kaikki yritykset if ($isSuperAdmin || in_array($comp['id'], $userCompanyIds)) { $entry = ['id' => $comp['id'], 'nimi' => $comp['nimi']]; if (!$isSuperAdmin && !isIpAllowed($ip, $comp['allowed_ips'] ?? '')) { $entry['ip_blocked'] = true; } else { if (!$firstAllowedId) $firstAllowedId = $comp['id']; } $companyList[] = $entry; } } // Jos aktiivinen yritys on IP-estetty → vaihda sallittuun if (!$isSuperAdmin) { $activeBlocked = false; $activeId = $_SESSION['company_id'] ?? ''; foreach ($companyList as $c) { if ($c['id'] === $activeId && !empty($c['ip_blocked'])) { $activeBlocked = true; break; } } if ($activeBlocked) { if ($firstAllowedId) { $_SESSION['company_id'] = $firstAllowedId; $_SESSION['company_role'] = $_SESSION['company_roles'][$firstAllowedId] ?? 'user'; } else { // Kaikki yritykset IP-estetty → kirjaa ulos session_destroy(); echo json_encode(['authenticated' => false, 'reason' => 'ip_blocked']); break; } } } // Hae allekirjoitukset (oletus generoituna jos omaa ei ole) $userSignatures = $u ? buildSignaturesWithDefaults($u, $u['companies'] ?? []) : []; // Brändäystiedot aktiivisen yrityksen mukaan (ei domain-pohjainen) $activeCompanyId = $_SESSION['company_id'] ?? ''; $branding = null; $enabledModules = []; foreach ($allCompanies as $comp) { if ($comp['id'] === $activeCompanyId) { $logoUrl = !empty($comp['logo_file']) ? "api.php?action=company_logo&company_id=" . urlencode($comp['id']) : ''; $branding = [ 'found' => true, 'company_id' => $comp['id'], 'nimi' => $comp['nimi'], 'primary_color' => $comp['primary_color'] ?? '#0f3460', 'subtitle' => $comp['subtitle'] ?? '', 'logo_url' => $logoUrl, ]; $enabledModules = $comp['enabled_modules'] ?? []; break; } } if (!$branding) { $branding = ['found' => false, 'nimi' => 'Noxus HUB', 'primary_color' => '#0f3460', 'subtitle' => 'Hallintapaneeli', 'logo_url' => '']; } // Tarkista onko yrityksellä integraatioita päällä $hasIntegrations = false; if ($activeCompanyId) { $integrations = dbLoadIntegrations($activeCompanyId); foreach ($integrations as $integ) { if (!empty($integ['enabled'])) { $hasIntegrations = true; break; } } } echo json_encode([ 'authenticated' => true, 'user_id' => $_SESSION['user_id'], 'username' => $_SESSION['username'], 'nimi' => $_SESSION['nimi'], 'email' => $u['email'] ?? '', 'role' => $_SESSION['role'], 'company_role' => $_SESSION['company_role'] ?? 'user', 'companies' => $companyList, 'company_id' => $_SESSION['company_id'] ?? '', 'signatures' => $userSignatures, 'branding' => $branding, 'enabled_modules' => $enabledModules, 'hidden_mailboxes' => $u ? ($u['hidden_mailboxes'] ?? []) : [], 'has_integrations' => $hasIntegrations, ]); } else { echo json_encode(['authenticated' => false]); } break; // ---------- PASSWORD RESET ---------- case 'password_reset_request': if ($method !== 'POST') break; $ip = getClientIp(); if (!dbCheckRateLimit($ip)) { http_response_code(429); echo json_encode(['error' => 'Liian monta yritystä. Yritä uudelleen myöhemmin.']); break; } dbRecordLoginAttempt($ip); $input = json_decode(file_get_contents('php://input'), true); $username = trim($input['username'] ?? ''); $user = dbGetUserByUsername($username); // Palauta aina sama viesti (ei paljasta onko tunnus olemassa) if ($user && !empty($user['email'])) { $token = generateToken(); dbSaveToken($user['id'], $token); $resetUrl = SITE_URL . '/?reset=' . $token; $html = '
'; $html .= '

Noxus HUB

'; $html .= '

Hei ' . htmlspecialchars($user['nimi'] ?: $user['username']) . ',

'; $html .= '

Sait tämän viestin koska salasanan palautusta pyydettiin tilillesi.

'; $html .= '

Vaihda salasana

'; $html .= '

Linkki on voimassa 1 tunnin. Jos et pyytänyt salasanan vaihtoa, voit jättää tämän viestin huomiotta.

'; $html .= '
'; sendMail($user['email'], 'Salasanan palautus - Noxus HUB', $html); } echo json_encode(['success' => true, 'message' => 'Jos käyttäjätunnus löytyy ja sillä on sähköposti, palautuslinkki lähetetään.']); break; case 'password_reset': if ($method !== 'POST') break; $input = json_decode(file_get_contents('php://input'), true); $token = $input['token'] ?? ''; $newPassword = $input['password'] ?? ''; if (strlen($newPassword) < 4) { http_response_code(400); echo json_encode(['error' => 'Salasanan pitää olla vähintään 4 merkkiä']); break; } $userId = dbValidateToken($token); if (!$userId) { http_response_code(400); echo json_encode(['error' => 'Palautuslinkki on vanhentunut tai virheellinen']); break; } $user = dbGetUser($userId); if ($user) { $user['password_hash'] = password_hash($newPassword, PASSWORD_DEFAULT); dbSaveUser($user); } dbRemoveToken($token); echo json_encode(['success' => true, 'message' => 'Salasana vaihdettu onnistuneesti']); break; case 'validate_reset_token': $token = $_GET['token'] ?? ''; $userId = dbValidateToken($token); echo json_encode(['valid' => $userId !== null]); break; // ---------- USERS ---------- case 'users': requireAdmin(); $users = dbLoadUsers(); // Admin näkee vain oman yrityksensä käyttäjät, superadmin näkee kaikki $role = $_SESSION['role'] ?? ''; $companyId = $_SESSION['company_id'] ?? ''; if ($role !== 'superadmin') { $users = array_filter($users, function($u) use ($companyId) { return in_array($companyId, $u['companies'] ?? []); }); } $safe = array_map(function($u) { unset($u['password_hash']); return $u; }, $users); echo json_encode(array_values($safe)); break; case 'user_create': requireAdmin(); if ($method !== 'POST') break; $input = json_decode(file_get_contents('php://input'), true); $username = trim($input['username'] ?? ''); $password = $input['password'] ?? ''; $nimi = trim($input['nimi'] ?? ''); $email = trim($input['email'] ?? ''); $isSA = ($_SESSION['role'] ?? '') === 'superadmin'; // Globaali rooli: user tai superadmin (admin on nyt yrityskohtainen) $validRoles = $isSA ? ['superadmin', 'user'] : ['user']; $role = in_array($input['role'] ?? '', $validRoles) ? $input['role'] : 'user'; if (empty($username) || empty($password)) { http_response_code(400); echo json_encode(['error' => 'Käyttäjätunnus ja salasana vaaditaan']); break; } if (strlen($password) < 4) { http_response_code(400); echo json_encode(['error' => 'Salasanan pitää olla vähintään 4 merkkiä']); break; } $existingUser = dbGetUserByUsername($username); if ($existingUser) { http_response_code(400); echo json_encode(['error' => 'Käyttäjätunnus on jo käytössä']); break; } $companies = $input['companies'] ?? []; // Admin voi lisätä käyttäjiä vain omaan yritykseensä if (!$isSA) { $myCompanyId = $_SESSION['company_id'] ?? ''; $companies = [$myCompanyId]; } // Validoi yritys-IDt $allCompanies = dbLoadCompanies(); $validIds = array_column($allCompanies, 'id'); $companies = array_values(array_filter($companies, fn($c) => in_array($c, $validIds))); $signatures = []; if (isset($input['signatures']) && is_array($input['signatures'])) { foreach ($input['signatures'] as $mbId => $sig) { $signatures[(string)$mbId] = (string)$sig; } } // Yrityskohtaiset roolit $companyRoles = []; if ($isSA && isset($input['company_roles']) && is_array($input['company_roles'])) { foreach ($input['company_roles'] as $cid => $crole) { if (in_array($cid, $companies) && in_array($crole, ['admin', 'user'])) { $companyRoles[$cid] = $crole; } } } elseif (!$isSA) { // Admin luo käyttäjiä vain omaan yritykseensä -> oletusrooli user $myCompanyId = $_SESSION['company_id'] ?? ''; $companyRoles[$myCompanyId] = 'user'; } $newUser = [ 'id' => generateId(), 'username' => $username, 'password_hash' => password_hash($password, PASSWORD_DEFAULT), 'nimi' => $nimi ?: $username, 'email' => $email, 'role' => $role, 'companies' => $companies, 'company_roles' => $companyRoles, 'signatures' => $signatures, 'luotu' => date('Y-m-d H:i:s'), ]; dbSaveUser($newUser); $companyId = $_SESSION['company_id'] ?? ''; dbAddLog($companyId, currentUser(), 'user_create', '', '', "Lisäsi käyttäjän: {$username} ({$role})"); unset($newUser['password_hash']); echo json_encode($newUser); break; case 'user_update': requireAdmin(); if ($method !== 'POST') break; $input = json_decode(file_get_contents('php://input'), true); $id = $input['id'] ?? ''; $u = dbGetUser($id); if (!$u) { http_response_code(404); echo json_encode(['error' => 'Käyttäjää ei löydy']); break; } $isSA = ($_SESSION['role'] ?? '') === 'superadmin'; $myCompanyId = $_SESSION['company_id'] ?? ''; // Admin voi muokata vain oman yrityksensä käyttäjiä if (!$isSA && !in_array($myCompanyId, $u['companies'] ?? [])) { http_response_code(403); echo json_encode(['error' => 'Ei oikeutta muokata tätä käyttäjää']); break; } if (isset($input['nimi'])) $u['nimi'] = trim($input['nimi']); if (isset($input['email'])) $u['email'] = trim($input['email']); if (isset($input['role'])) { // Globaali rooli: user tai superadmin (admin on nyt yrityskohtainen) $validRoles = $isSA ? ['superadmin', 'user'] : ['user']; // Admin ei voi muuttaa superadminia if (!$isSA && ($u['role'] === 'superadmin')) { // Älä muuta roolia } else { $u['role'] = in_array($input['role'], $validRoles) ? $input['role'] : 'user'; } } if (isset($input['companies'])) { $allCompanies = dbLoadCompanies(); $validIds = array_column($allCompanies, 'id'); $u['companies'] = array_values(array_filter($input['companies'], fn($c) => in_array($c, $validIds))); } // Yrityskohtaiset roolit if (isset($input['company_roles']) && is_array($input['company_roles'])) { $companyRoles = $u['company_roles'] ?? []; foreach ($input['company_roles'] as $cid => $crole) { if (in_array($cid, $u['companies'] ?? []) && in_array($crole, ['admin', 'user'])) { $companyRoles[$cid] = $crole; } } $u['company_roles'] = $companyRoles; } if (!empty($input['password'])) { $u['password_hash'] = password_hash($input['password'], PASSWORD_DEFAULT); } if (isset($input['signatures']) && is_array($input['signatures'])) { $sigs = []; foreach ($input['signatures'] as $mbId => $sig) { $sigs[(string)$mbId] = (string)$sig; } $u['signatures'] = $sigs; } dbSaveUser($u); $companyId = $_SESSION['company_id'] ?? ''; dbAddLog($companyId, currentUser(), 'user_update', '', '', "Muokkasi käyttäjää: {$u['username']}"); // Päivitä sessio jos muokattiin kirjautunutta käyttäjää if ($u['id'] === $_SESSION['user_id']) { $_SESSION['companies'] = $u['companies'] ?? []; $_SESSION['company_roles'] = $u['company_roles'] ?? []; if (!empty($u['companies']) && !in_array($_SESSION['company_id'] ?? '', $u['companies'])) { $_SESSION['company_id'] = $u['companies'][0]; } if (empty($u['companies'])) { $_SESSION['company_id'] = ''; } // Päivitä aktiivisen yrityksen rooli $_SESSION['company_role'] = ($_SESSION['company_roles'][$_SESSION['company_id'] ?? '']) ?? 'user'; } $safe = $u; unset($safe['password_hash']); echo json_encode($safe); break; case 'user_delete': requireAdmin(); if ($method !== 'POST') break; $input = json_decode(file_get_contents('php://input'), true); $id = $input['id'] ?? ''; if ($id === $_SESSION['user_id']) { http_response_code(400); echo json_encode(['error' => 'Et voi poistaa itseäsi']); break; } $deleted = dbGetUser($id); $isSA = ($_SESSION['role'] ?? '') === 'superadmin'; $myCompanyId = $_SESSION['company_id'] ?? ''; // Admin ei voi poistaa superadmineja eikä toisen yrityksen käyttäjiä if (!$isSA) { if ($deleted && ($deleted['role'] === 'superadmin' || !in_array($myCompanyId, $deleted['companies'] ?? []))) { http_response_code(403); echo json_encode(['error' => 'Ei oikeutta poistaa tätä käyttäjää']); break; } } $companyId = $_SESSION['company_id'] ?? ''; if ($isSA) { // Superadmin poistaa käyttäjän kokonaan dbDeleteUser($id); if ($deleted) dbAddLog($companyId, currentUser(), 'user_delete', '', '', "Poisti käyttäjän kokonaan: {$deleted['username']}"); } else { // Admin poistaa käyttäjän vain nykyisestä yrityksestä dbRemoveUserFromCompany($id, $companyId); if ($deleted) dbAddLog($companyId, currentUser(), 'user_delete', '', '', "Poisti käyttäjän yrityksestä: {$deleted['username']}"); // Jos käyttäjällä ei ole enää yhtään yritystä, poista kokonaan $remaining = dbGetUserCompanies($id); if (empty($remaining)) { dbDeleteUser($id); } } echo json_encode(['success' => true]); break; // ---------- PROFILE (oma profiili) ---------- case 'profile_update': requireAuth(); if ($method !== 'POST') break; $input = json_decode(file_get_contents('php://input'), true); $userId = $_SESSION['user_id']; $u = dbGetUser($userId); if (!$u) { http_response_code(404); echo json_encode(['error' => 'Käyttäjää ei löydy']); break; } if (isset($input['nimi'])) $u['nimi'] = trim($input['nimi']); if (isset($input['email'])) $u['email'] = trim($input['email']); if (!empty($input['password'])) { if (strlen($input['password']) < 4) { http_response_code(400); echo json_encode(['error' => 'Salasanan pitää olla vähintään 4 merkkiä']); break; } $u['password_hash'] = password_hash($input['password'], PASSWORD_DEFAULT); } if (isset($input['signatures']) && is_array($input['signatures'])) { $sigs = []; foreach ($input['signatures'] as $mbId => $sig) { $sigs[(string)$mbId] = (string)$sig; } $u['signatures'] = $sigs; } if (isset($input['hidden_mailboxes']) && is_array($input['hidden_mailboxes'])) { $u['hidden_mailboxes'] = array_map('strval', $input['hidden_mailboxes']); } dbSaveUser($u); // Päivitä session nimi $_SESSION['nimi'] = $u['nimi']; $safe = $u; unset($safe['password_hash']); echo json_encode($safe); break; // ---------- CHANGELOG ---------- case 'changelog': requireAuth(); $companyId = requireCompany(); $limit = intval($_GET['limit'] ?? 100); echo json_encode(dbLoadChangelog($companyId, $limit)); break; // ---------- CUSTOMERS ---------- case 'customers': requireAuth(); $companyId = requireCompany(); if ($method === 'GET') { echo json_encode(dbLoadCustomers($companyId)); } break; case 'customer': requireAuth(); $companyId = requireCompany(); if ($method === 'POST') { $input = json_decode(file_get_contents('php://input'), true); $customer = [ 'id' => generateId(), 'yritys' => trim($input['yritys'] ?? ''), 'yhteyshenkilö' => trim($input['yhteyshenkilö'] ?? ''), 'puhelin' => trim($input['puhelin'] ?? ''), 'sahkoposti' => trim($input['sahkoposti'] ?? ''), 'laskutusosoite' => trim($input['laskutusosoite'] ?? ''), 'laskutuspostinumero' => trim($input['laskutuspostinumero'] ?? ''), 'laskutuskaupunki' => trim($input['laskutuskaupunki'] ?? ''), 'laskutussahkoposti' => trim($input['laskutussahkoposti'] ?? ''), 'elaskuosoite' => trim($input['elaskuosoite'] ?? ''), 'elaskuvalittaja' => trim($input['elaskuvalittaja'] ?? ''), 'ytunnus' => trim($input['ytunnus'] ?? ''), 'lisatiedot' => trim($input['lisatiedot'] ?? ''), 'priority_emails' => trim($input['priority_emails'] ?? ''), 'liittymat' => parseLiittymat($input), 'luotu' => date('Y-m-d H:i:s'), ]; if (empty($customer['yritys'])) { http_response_code(400); echo json_encode(['error' => 'Yrityksen nimi vaaditaan']); break; } dbSaveCustomer($companyId, $customer); dbAddLog($companyId, currentUser(), 'customer_create', $customer['id'], $customer['yritys'], 'Lisäsi asiakkaan'); echo json_encode($customer); } break; case 'customer_update': requireAuth(); $companyId = requireCompany(); if ($method !== 'POST') break; $input = json_decode(file_get_contents('php://input'), true); $id = $input['id'] ?? ''; $customers = dbLoadCustomers($companyId); $found = false; foreach ($customers as $c) { if ($c['id'] === $id) { $changes = []; $fields = ['yritys','yhteyshenkilö','puhelin','sahkoposti','laskutusosoite','laskutuspostinumero','laskutuskaupunki','laskutussahkoposti','elaskuosoite','elaskuvalittaja','ytunnus','lisatiedot','priority_emails']; foreach ($fields as $f) { if (isset($input[$f])) { $old = $c[$f] ?? ''; $new = trim($input[$f]); if ($old !== $new) $changes[] = $f; $c[$f] = $new; } } if (isset($input['liittymat'])) { $c['liittymat'] = parseLiittymat($input); $changes[] = 'liittymat'; } $c['muokattu'] = date('Y-m-d H:i:s'); $c['muokkaaja'] = currentUser(); $found = true; dbSaveCustomer($companyId, $c); dbAddLog($companyId, currentUser(), 'customer_update', $c['id'], $c['yritys'], 'Muokkasi: ' . implode(', ', $changes)); echo json_encode($c); break; } } if (!$found) { http_response_code(404); echo json_encode(['error' => 'Asiakasta ei löydy']); } break; case 'customer_delete': requireAuth(); $companyId = requireCompany(); if ($method !== 'POST') break; $input = json_decode(file_get_contents('php://input'), true); $id = $input['id'] ?? ''; $customers = dbLoadCustomers($companyId); $archived = null; foreach ($customers as $c) { if ($c['id'] === $id) { $c['arkistoitu'] = date('Y-m-d H:i:s'); $c['arkistoija'] = currentUser(); $archived = $c; break; } } if ($archived) { dbArchiveCustomer($companyId, $archived); dbDeleteCustomer($id); dbAddLog($companyId, currentUser(), 'customer_archive', $archived['id'], $archived['yritys'], 'Arkistoi asiakkaan'); } echo json_encode(['success' => true]); break; // ---------- SIJAINNIT (SITES) — YHDISTETTY LAITETILOIHIN ---------- // sites-endpoint palauttaa nyt laitetilat (taaksepäin yhteensopivuus) case 'sites': requireAuth(); $companyId = requireCompany(); echo json_encode(dbLoadLaitetilat($companyId)); break; // ---------- LAITTEET (DEVICES) ---------- case 'devices': requireAuth(); $companyId = requireCompany(); echo json_encode(dbLoadDevices($companyId)); break; case 'device_save': requireAuth(); $companyId = requireCompany(); if ($method !== 'POST') break; $input = json_decode(file_get_contents('php://input'), true); $isNew = empty($input['id']); $device = [ 'id' => $input['id'] ?? generateId(), 'nimi' => trim($input['nimi'] ?? ''), 'hallintaosoite' => trim($input['hallintaosoite'] ?? ''), 'serial' => trim($input['serial'] ?? ''), 'site_id' => null, 'laitetila_id' => $input['laitetila_id'] ?? null, 'funktio' => trim($input['funktio'] ?? ''), 'tyyppi' => trim($input['tyyppi'] ?? ''), 'malli' => trim($input['malli'] ?? ''), 'ping_check' => !empty($input['ping_check']), 'lisatiedot' => trim($input['lisatiedot'] ?? ''), 'luotu' => $isNew ? date('Y-m-d H:i:s') : ($input['luotu'] ?? date('Y-m-d H:i:s')), 'muokattu' => $isNew ? null : date('Y-m-d H:i:s'), 'muokkaaja' => $isNew ? '' : currentUser(), ]; if (empty($device['nimi'])) { http_response_code(400); echo json_encode(['error' => 'Laitteen nimi vaaditaan']); break; } dbSaveDevice($companyId, $device); // Auto-IPAM: varaa hallintaosoite laitteelle IPAM:iin $mgmtIp = $device['hallintaosoite']; if ($mgmtIp) { // Normalisoi: poista mahdollinen /32 suffix $cleanIp = explode('/', $mgmtIp)[0]; // Etsi löytyykö jo IPAM:ista $ipamEntries = dbLoadIpam($companyId); $existing = null; foreach ($ipamEntries as $ie) { $ieClean = explode('/', $ie['verkko'])[0]; if ($ieClean === $cleanIp && $ie['tyyppi'] === 'ip') { $existing = $ie; break; } } if ($existing) { // Päivitä olemassa oleva: merkitse varatuksi laitteelle $existing['tila'] = 'varattu'; $existing['nimi'] = $device['nimi']; $existing['site_id'] = $device['laitetila_id']; $existing['muokattu'] = date('Y-m-d H:i:s'); $existing['muokkaaja'] = currentUser(); dbSaveIpam($companyId, $existing); } else { // Luo uusi IPAM-merkintä $newIpam = [ 'id' => generateId(), 'tyyppi' => 'ip', 'nimi' => $device['nimi'], 'verkko' => $cleanIp, 'vlan_id' => null, 'site_id' => $device['laitetila_id'], 'tila' => 'varattu', 'asiakas' => '', 'lisatiedot' => 'Automaattinen varaus laitteelta: ' . $device['nimi'], 'luotu' => date('Y-m-d H:i:s'), 'muokattu' => null, 'muokkaaja' => currentUser(), ]; dbSaveIpam($companyId, $newIpam); } } dbAddLog($companyId, currentUser(), $isNew ? 'device_create' : 'device_update', $device['id'], $device['nimi'], ($isNew ? 'Lisäsi' : 'Muokkasi') . ' laitteen'); echo json_encode($device); break; case 'device_delete': requireAuth(); $companyId = requireCompany(); if ($method !== 'POST') break; $input = json_decode(file_get_contents('php://input'), true); $id = $input['id'] ?? ''; $devices = dbLoadDevices($companyId); $deviceName = ''; foreach ($devices as $d) { if ($d['id'] === $id) { $deviceName = $d['nimi']; break; } } dbDeleteDevice($id); dbAddLog($companyId, currentUser(), 'device_delete', $id, $deviceName, 'Poisti laitteen'); echo json_encode(['success' => true]); break; // ---------- IPAM ---------- case 'ipam': requireAuth(); $companyId = requireCompany(); echo json_encode(dbLoadIpam($companyId)); break; case 'ipam_save': requireAuth(); $companyId = requireCompany(); if ($method !== 'POST') break; $input = json_decode(file_get_contents('php://input'), true); $entry = [ 'id' => $input['id'] ?? generateId(), 'tyyppi' => $input['tyyppi'] ?? 'ip', 'nimi' => trim($input['nimi'] ?? ''), 'verkko' => trim($input['verkko'] ?? ''), 'vlan_id' => $input['vlan_id'] ?? null, 'site_id' => $input['site_id'] ?? null, 'tila' => $input['tila'] ?? 'vapaa', 'asiakas' => trim($input['asiakas'] ?? ''), 'lisatiedot' => trim($input['lisatiedot'] ?? ''), 'luotu' => $input['luotu'] ?? date('Y-m-d H:i:s'), 'muokattu' => date('Y-m-d H:i:s'), 'muokkaaja' => currentUser(), ]; // Duplikaatti-tarkistus $skipDuplicateCheck = !empty($input['force']); if (!$skipDuplicateCheck) { $existingAll = dbLoadIpam($companyId); // Verkko/IP duplikaatti — estä kokonaan if ($entry['verkko'] !== '' && $entry['tyyppi'] !== 'vlan') { foreach ($existingAll as $ex) { if ($ex['verkko'] === $entry['verkko'] && $ex['id'] !== $entry['id'] && $ex['tyyppi'] !== 'vlan') { http_response_code(400); echo json_encode(['error' => 'IP-osoite tai verkko "' . $entry['verkko'] . '" on jo olemassa (' . ($ex['nimi'] ?: 'nimetön') . ')']); exit; } } } // VLAN duplikaatti — varoitus (409 = confirm) if ($entry['tyyppi'] === 'vlan' && !empty($entry['vlan_id'])) { $vlanNum = (int)$entry['vlan_id']; foreach ($existingAll as $ex) { if ($ex['tyyppi'] === 'vlan' && (int)$ex['vlan_id'] === $vlanNum && $ex['id'] !== $entry['id']) { http_response_code(409); echo json_encode(['warning' => 'VLAN ' . $vlanNum . ' on jo olemassa (' . ($ex['nimi'] ?: 'nimetön') . '). Lisätäänkö silti?']); exit; } } } } dbSaveIpam($companyId, $entry); // Auto-VLAN: jos subnet/ip:llä on vlan_id, luo VLAN automaattisesti jos ei vielä ole if ($entry['tyyppi'] !== 'vlan' && !empty($entry['vlan_id'])) { $vlanNum = (int)$entry['vlan_id']; $existingIpam = dbLoadIpam($companyId); $vlanExists = false; foreach ($existingIpam as $ie) { if ($ie['tyyppi'] === 'vlan' && (int)$ie['vlan_id'] === $vlanNum) { $vlanExists = true; break; } } if (!$vlanExists) { $newVlan = [ 'id' => generateId(), 'tyyppi' => 'vlan', 'nimi' => trim($entry['nimi']) ?: ('VLAN ' . $vlanNum), 'verkko' => '', 'vlan_id' => $vlanNum, 'site_id' => $entry['site_id'], 'tila' => 'varattu', 'asiakas' => '', 'lisatiedot' => 'Luotu automaattisesti', 'luotu' => date('Y-m-d H:i:s'), 'muokattu' => null, 'muokkaaja' => currentUser(), ]; dbSaveIpam($companyId, $newVlan); } } $action_label = isset($input['id']) && !empty($input['id']) ? 'ipam_update' : 'ipam_create'; $desc = ($entry['tyyppi'] === 'vlan' ? 'VLAN ' . ($entry['vlan_id'] ?? '') : $entry['verkko']) . ' ' . $entry['nimi']; dbAddLog($companyId, currentUser(), $action_label, $entry['id'], $desc, $action_label === 'ipam_create' ? 'Lisäsi IPAM-merkinnän' : 'Muokkasi IPAM-merkintää'); echo json_encode($entry); break; case 'ipam_delete': requireAuth(); $companyId = requireCompany(); if ($method !== 'POST') break; $input = json_decode(file_get_contents('php://input'), true); $id = $input['id'] ?? ''; $all = dbLoadIpam($companyId); $entryName = ''; foreach ($all as $e) { if ($e['id'] === $id) { $entryName = ($e['tyyppi'] === 'vlan' ? 'VLAN ' . $e['vlan_id'] : $e['verkko']) . ' ' . $e['nimi']; break; } } dbDeleteIpam($id); dbAddLog($companyId, currentUser(), 'ipam_delete', $id, $entryName, 'Poisti IPAM-merkinnän'); echo json_encode(['success' => true]); break; // ---------- OHJEET (GUIDES) ---------- case 'guides': requireAuth(); $companyId = requireCompany(); echo json_encode(dbLoadGuides($companyId)); break; case 'guide': requireAuth(); requireCompany(); $id = $_GET['id'] ?? ''; $guide = dbLoadGuide($id); if (!$guide) { http_response_code(404); echo json_encode(['error' => 'Ohjetta ei löydy']); exit; } echo json_encode($guide); break; case 'guide_save': requireAuth(); requireAdmin(); $companyId = requireCompany(); if ($method !== 'POST') break; $input = json_decode(file_get_contents('php://input'), true); $isNew = empty($input['id']); $guide = [ 'id' => $input['id'] ?? generateId(), 'category_id' => $input['category_id'] ?? null, 'title' => trim($input['title'] ?? ''), 'content' => $input['content'] ?? '', 'tags' => trim($input['tags'] ?? ''), 'author' => $isNew ? currentUser() : ($input['author'] ?? currentUser()), 'pinned' => !empty($input['pinned']), 'luotu' => $isNew ? date('Y-m-d H:i:s') : ($input['luotu'] ?? date('Y-m-d H:i:s')), 'muokattu' => $isNew ? null : date('Y-m-d H:i:s'), 'muokkaaja' => $isNew ? '' : currentUser(), ]; if (empty($guide['title'])) { http_response_code(400); echo json_encode(['error' => 'Otsikko vaaditaan']); exit; } dbSaveGuide($companyId, $guide); dbAddLog($companyId, currentUser(), $isNew ? 'guide_create' : 'guide_update', $guide['id'], $guide['title'], ($isNew ? 'Loi' : 'Muokkasi') . ' ohjeen'); echo json_encode($guide); break; case 'guide_delete': requireAuth(); requireAdmin(); $companyId = requireCompany(); if ($method !== 'POST') break; $input = json_decode(file_get_contents('php://input'), true); $id = $input['id'] ?? ''; $guide = dbLoadGuide($id); dbDeleteGuide($id); dbAddLog($companyId, currentUser(), 'guide_delete', $id, $guide ? $guide['title'] : '', 'Poisti ohjeen'); echo json_encode(['success' => true]); break; case 'guide_categories': requireAuth(); $companyId = requireCompany(); echo json_encode(dbLoadGuideCategories($companyId)); break; case 'guide_category_save': requireAuth(); requireAdmin(); $companyId = requireCompany(); if ($method !== 'POST') break; $input = json_decode(file_get_contents('php://input'), true); $cat = [ 'id' => $input['id'] ?? generateId(), 'nimi' => trim($input['nimi'] ?? ''), 'sort_order' => (int)($input['sort_order'] ?? 0), ]; if (empty($cat['nimi'])) { http_response_code(400); echo json_encode(['error' => 'Kategorian nimi vaaditaan']); exit; } dbSaveGuideCategory($companyId, $cat); echo json_encode($cat); break; case 'guide_category_delete': requireAuth(); requireAdmin(); $companyId = requireCompany(); if ($method !== 'POST') break; $input = json_decode(file_get_contents('php://input'), true); dbDeleteGuideCategory($input['id'] ?? ''); echo json_encode(['success' => true]); break; case 'guide_image_upload': requireAuth(); requireAdmin(); $companyId = requireCompany(); if ($method !== 'POST') break; if (empty($_FILES['image'])) { http_response_code(400); echo json_encode(['error' => 'Kuva puuttuu']); break; } $file = $_FILES['image']; if ($file['error'] !== UPLOAD_ERR_OK) { http_response_code(400); echo json_encode(['error' => 'Kuvan lähetys epäonnistui']); break; } if ($file['size'] > 5 * 1024 * 1024) { http_response_code(400); echo json_encode(['error' => 'Kuva on liian suuri (max 5 MB)']); break; } $allowedExt = ['png', 'jpg', 'jpeg', 'gif', 'webp']; $ext = strtolower(pathinfo($file['name'], PATHINFO_EXTENSION)); if (!in_array($ext, $allowedExt)) { http_response_code(400); echo json_encode(['error' => 'Sallitut tiedostotyypit: PNG, JPG, GIF, WebP']); break; } $imgDir = getCompanyDir($companyId) . '/guide_images'; if (!file_exists($imgDir)) mkdir($imgDir, 0755, true); $filename = uniqid() . '.' . ($ext === 'jpeg' ? 'jpg' : $ext); if (move_uploaded_file($file['tmp_name'], $imgDir . '/' . $filename)) { $url = 'api.php?action=guide_image&file=' . urlencode($filename); echo json_encode(['success' => true, 'url' => $url, 'filename' => $filename]); } else { http_response_code(500); echo json_encode(['error' => 'Tallennusvirhe']); } break; case 'guide_image': requireAuth(); $companyId = requireCompany(); $filename = basename($_GET['file'] ?? ''); if (!$filename || !preg_match('/^[a-f0-9]+\.(png|jpg|gif|webp)$/', $filename)) { http_response_code(400); echo 'Virheellinen tiedostonimi'; break; } $path = getCompanyDir($companyId) . '/guide_images/' . $filename; if (!file_exists($path)) { http_response_code(404); echo 'Kuvaa ei löydy'; break; } $mimes = ['png' => 'image/png', 'jpg' => 'image/jpeg', 'gif' => 'image/gif', 'webp' => 'image/webp']; $ext = pathinfo($filename, PATHINFO_EXTENSION); header('Content-Type: ' . ($mimes[$ext] ?? 'application/octet-stream')); header('Cache-Control: public, max-age=86400'); readfile($path); exit; // ---------- TEHTÄVÄT (TODOS) ---------- case 'todos': requireAuth(); $companyId = requireCompany(); echo json_encode(dbLoadTodos($companyId)); break; case 'todo_detail': requireAuth(); $companyId = requireCompany(); $id = $_GET['id'] ?? ''; $todo = dbLoadTodo($id); if (!$todo || $todo['company_id'] !== $companyId) { http_response_code(404); echo json_encode(['error' => 'Tehtävää ei löydy']); break; } unset($todo['company_id']); echo json_encode($todo); break; case 'todo_save': requireAuth(); $companyId = requireCompany(); if ($method !== 'POST') break; $input = json_decode(file_get_contents('php://input'), true); $type = $input['type'] ?? 'task'; $isNew = empty($input['id']); // Task: vain admin. Feature request: kaikki voivat luoda, mutta muokata vain omia (tai admin) if ($type === 'task') { requireAdmin(); } elseif (!$isNew) { $existing = dbLoadTodo($input['id']); if ($existing && $existing['created_by'] !== currentUser() && !isCompanyAdmin()) { http_response_code(403); echo json_encode(['error' => 'Voit muokata vain omia ehdotuksiasi']); break; } } $todo = [ 'id' => $input['id'] ?? generateId(), 'type' => $type, 'title' => trim($input['title'] ?? ''), 'description' => $input['description'] ?? '', 'status' => $input['status'] ?? ($type === 'task' ? 'avoin' : 'ehdotettu'), 'priority' => $input['priority'] ?? 'normaali', 'category' => $input['category'] ?? '', 'assigned_to' => $input['assigned_to'] ?? '', 'created_by' => $isNew ? currentUser() : ($input['created_by'] ?? currentUser()), 'deadline' => $input['deadline'] ?? null, 'luotu' => $isNew ? date('Y-m-d H:i:s') : ($input['luotu'] ?? date('Y-m-d H:i:s')), 'muokkaaja' => currentUser(), ]; if (empty($todo['title'])) { http_response_code(400); echo json_encode(['error' => 'Otsikko on pakollinen']); break; } dbSaveTodo($companyId, $todo); dbAddLog($companyId, currentUser(), $isNew ? 'todo_create' : 'todo_update', $todo['id'], $todo['title'], ''); echo json_encode($todo); break; case 'todo_delete': requireAuth(); requireAdmin(); $companyId = requireCompany(); if ($method !== 'POST') break; $input = json_decode(file_get_contents('php://input'), true); $id = $input['id'] ?? ''; $todo = dbLoadTodo($id); if ($todo) { dbDeleteTodo($id); dbAddLog($companyId, currentUser(), 'todo_delete', $id, $todo['title'] ?? '', ''); } echo json_encode(['success' => true]); break; case 'todo_status': requireAuth(); $companyId = requireCompany(); if ($method !== 'POST') { echo json_encode(['error' => 'POST required']); break; } try { $input = json_decode(file_get_contents('php://input'), true); $id = $input['id'] ?? ''; $status = $input['status'] ?? ''; if (!$id || !$status) { echo json_encode(['error' => 'id ja status vaaditaan']); break; } // Tarkista oikeudet kevyellä querylla (ei dbLoadTodo joka voi kaatua) $rows = _dbFetchAll("SELECT type, company_id FROM todos WHERE id = ?", [$id]); if (empty($rows) || $rows[0]['company_id'] !== $companyId) { http_response_code(404); echo json_encode(['error' => 'Tehtävää ei löytynyt']); break; } $type = $rows[0]['type']; if ($type === 'feature_request' && !isCompanyAdmin()) { http_response_code(403); echo json_encode(['error' => 'Vain admin voi muuttaa ehdotuksen statusta']); break; } if ($type === 'task' && !isCompanyAdmin()) { http_response_code(403); echo json_encode(['error' => 'Vain admin voi muuttaa tehtävän statusta']); break; } _dbExecute("UPDATE todos SET status = ?, muokattu = NOW(), muokkaaja = ? WHERE id = ?", [$status, currentUser(), $id]); echo json_encode(['success' => true]); } catch (\Throwable $e) { http_response_code(500); echo json_encode(['error' => 'Virhe: ' . $e->getMessage()]); } break; case 'todo_assign': requireAuth(); requireAdmin(); $companyId = requireCompany(); if ($method !== 'POST') { echo json_encode(['error' => 'POST required']); break; } try { $input = json_decode(file_get_contents('php://input'), true); $id = $input['id'] ?? ''; $assignedTo = $input['assigned_to'] ?? ''; $rows = _dbFetchAll("SELECT company_id FROM todos WHERE id = ?", [$id]); if (empty($rows) || $rows[0]['company_id'] !== $companyId) { http_response_code(404); echo json_encode(['error' => 'Tehtävää ei löytynyt']); break; } _dbExecute("UPDATE todos SET assigned_to = ?, muokattu = NOW(), muokkaaja = ? WHERE id = ?", [$assignedTo, currentUser(), $id]); echo json_encode(['success' => true]); } catch (\Throwable $e) { http_response_code(500); echo json_encode(['error' => 'Virhe: ' . $e->getMessage()]); } break; case 'todo_comment': requireAuth(); $companyId = requireCompany(); if ($method !== 'POST') break; $input = json_decode(file_get_contents('php://input'), true); $todoId = $input['todo_id'] ?? ''; $body = trim($input['body'] ?? ''); if (empty($body)) { http_response_code(400); echo json_encode(['error' => 'Kommentti ei voi olla tyhjä']); break; } $comment = [ 'id' => generateId(), 'author' => currentUser(), 'body' => $body, 'luotu' => date('Y-m-d H:i:s'), ]; dbAddTodoComment($todoId, $comment); echo json_encode($comment); break; case 'todo_comment_delete': requireAuth(); $companyId = requireCompany(); if ($method !== 'POST') break; $input = json_decode(file_get_contents('php://input'), true); $commentId = $input['id'] ?? ''; // Tarkista onko oma kommentti tai admin $rows = _dbFetchAll("SELECT author FROM todo_comments WHERE id = ?", [$commentId]); if (!empty($rows) && ($rows[0]['author'] === currentUser() || isCompanyAdmin())) { dbDeleteTodoComment($commentId); echo json_encode(['success' => true]); } else { http_response_code(403); echo json_encode(['error' => 'Ei oikeutta']); } break; case 'todo_time_add': requireAuth(); $companyId = requireCompany(); if ($method !== 'POST') break; $input = json_decode(file_get_contents('php://input'), true); $todoId = $input['todo_id'] ?? ''; $hours = floatval($input['hours'] ?? 0); if ($hours <= 0) { http_response_code(400); echo json_encode(['error' => 'Tunnit pitää olla > 0']); break; } $entry = [ 'id' => generateId(), 'user' => currentUser(), 'hours' => $hours, 'description' => trim($input['description'] ?? ''), 'work_date' => $input['work_date'] ?? date('Y-m-d'), 'luotu' => date('Y-m-d H:i:s'), ]; dbAddTodoTimeEntry($todoId, $entry); echo json_encode($entry); break; case 'todo_time_delete': requireAuth(); $companyId = requireCompany(); if ($method !== 'POST') break; $input = json_decode(file_get_contents('php://input'), true); $entryId = $input['id'] ?? ''; $rows = _dbFetchAll("SELECT user FROM todo_time_entries WHERE id = ?", [$entryId]); if (!empty($rows) && ($rows[0]['user'] === currentUser() || isCompanyAdmin())) { dbDeleteTodoTimeEntry($entryId); echo json_encode(['success' => true]); } else { http_response_code(403); echo json_encode(['error' => 'Ei oikeutta']); } break; case 'todo_subtask_add': requireAuth(); $companyId = requireCompany(); if ($method !== 'POST') { echo json_encode(['error' => 'POST required']); break; } try { $input = json_decode(file_get_contents('php://input'), true); $todoId = $input['todo_id'] ?? ''; $title = trim($input['title'] ?? ''); if (!$todoId || !$title) { echo json_encode(['error' => 'todo_id ja title vaaditaan']); break; } $rows = _dbFetchAll("SELECT company_id FROM todos WHERE id = ?", [$todoId]); if (empty($rows) || $rows[0]['company_id'] !== $companyId) { http_response_code(404); echo json_encode(['error' => 'Tehtävää ei löytynyt']); break; } $id = generateId(); dbAddTodoSubtask($todoId, ['id' => $id, 'title' => $title, 'created_by' => currentUser()]); echo json_encode(['success' => true, 'id' => $id]); } catch (\Throwable $e) { http_response_code(500); echo json_encode(['error' => 'Virhe: ' . $e->getMessage()]); } break; case 'todo_subtask_toggle': requireAuth(); requireCompany(); if ($method !== 'POST') { echo json_encode(['error' => 'POST required']); break; } try { $input = json_decode(file_get_contents('php://input'), true); $subtaskId = $input['id'] ?? ''; if (!$subtaskId) { echo json_encode(['error' => 'id vaaditaan']); break; } $completed = dbToggleTodoSubtask($subtaskId); echo json_encode(['success' => true, 'completed' => $completed]); } catch (\Throwable $e) { http_response_code(500); echo json_encode(['error' => 'Virhe: ' . $e->getMessage()]); } break; case 'todo_subtask_delete': requireAuth(); requireCompany(); if ($method !== 'POST') { echo json_encode(['error' => 'POST required']); break; } try { $input = json_decode(file_get_contents('php://input'), true); $subtaskId = $input['id'] ?? ''; if (!$subtaskId) { echo json_encode(['error' => 'id vaaditaan']); break; } $rows = _dbFetchAll("SELECT created_by FROM todo_subtasks WHERE id = ?", [$subtaskId]); if (!empty($rows) && ($rows[0]['created_by'] === currentUser() || isCompanyAdmin())) { dbDeleteTodoSubtask($subtaskId); echo json_encode(['success' => true]); } else { http_response_code(403); echo json_encode(['error' => 'Ei oikeutta']); } } catch (\Throwable $e) { http_response_code(500); echo json_encode(['error' => 'Virhe: ' . $e->getMessage()]); } break; // ---------- ARCHIVE ---------- case 'archived_customers': requireAuth(); $companyId = requireCompany(); echo json_encode(dbLoadArchive($companyId)); break; case 'customer_restore': requireAuth(); $companyId = requireCompany(); if ($method !== 'POST') break; $input = json_decode(file_get_contents('php://input'), true); $id = $input['id'] ?? ''; $restored = dbRestoreArchive($id); if ($restored) { unset($restored['arkistoitu'], $restored['arkistoija'], $restored['archived_at']); dbSaveCustomer($companyId, $restored); dbAddLog($companyId, currentUser(), 'customer_restore', $restored['id'], $restored['yritys'] ?? '', 'Palautti asiakkaan arkistosta'); } echo json_encode(['success' => true]); break; case 'customer_permanent_delete': requireAdmin(); $companyId = requireCompany(); if ($method !== 'POST') break; $input = json_decode(file_get_contents('php://input'), true); $id = $input['id'] ?? ''; // Hae arkistoidun tiedot ennen poistoa $archive = dbLoadArchive($companyId); $deleted = null; foreach ($archive as $c) { if ($c['id'] === $id) { $deleted = $c; break; } } dbDeleteArchive($id); $filesDir = getCompanyDir() . '/files/' . $id; if (is_dir($filesDir)) { array_map('unlink', glob($filesDir . '/*')); rmdir($filesDir); } if ($deleted) dbAddLog($companyId, currentUser(), 'customer_permanent_delete', $id, $deleted['yritys'] ?? '', 'Poisti pysyvästi'); echo json_encode(['success' => true]); break; // ---------- LEADS ---------- case 'leads': requireAuth(); $companyId = requireCompany(); echo json_encode(dbLoadLeads($companyId)); break; case 'lead_create': requireAuth(); $companyId = requireCompany(); if ($method !== 'POST') break; $input = json_decode(file_get_contents('php://input'), true); $lead = [ 'id' => generateId(), 'yritys' => trim($input['yritys'] ?? ''), 'yhteyshenkilo' => trim($input['yhteyshenkilo'] ?? ''), 'puhelin' => trim($input['puhelin'] ?? ''), 'sahkoposti' => trim($input['sahkoposti'] ?? ''), 'osoite' => trim($input['osoite'] ?? ''), 'kaupunki' => trim($input['kaupunki'] ?? ''), 'tila' => trim($input['tila'] ?? 'uusi'), 'muistiinpanot' => trim($input['muistiinpanot'] ?? ''), 'luotu' => date('Y-m-d H:i:s'), 'luoja' => currentUser(), ]; if (empty($lead['yritys'])) { http_response_code(400); echo json_encode(['error' => 'Yrityksen nimi vaaditaan']); break; } dbSaveLead($companyId, $lead); dbAddLog($companyId, currentUser(), 'lead_create', $lead['id'], $lead['yritys'], 'Lisäsi liidin'); echo json_encode($lead); break; case 'lead_update': requireAuth(); $companyId = requireCompany(); if ($method !== 'POST') break; $input = json_decode(file_get_contents('php://input'), true); $id = $input['id'] ?? ''; $leads = dbLoadLeads($companyId); $found = false; foreach ($leads as $l) { if ($l['id'] === $id) { $fields = ['yritys','yhteyshenkilo','puhelin','sahkoposti','osoite','kaupunki','tila','muistiinpanot']; foreach ($fields as $f) { if (isset($input[$f])) $l[$f] = trim($input[$f]); } $l['muokattu'] = date('Y-m-d H:i:s'); $l['muokkaaja'] = currentUser(); $found = true; dbSaveLead($companyId, $l); dbAddLog($companyId, currentUser(), 'lead_update', $l['id'], $l['yritys'], 'Muokkasi liidiä'); echo json_encode($l); break; } } if (!$found) { http_response_code(404); echo json_encode(['error' => 'Liidiä ei löydy']); } break; case 'lead_delete': requireAuth(); $companyId = requireCompany(); if ($method !== 'POST') break; $input = json_decode(file_get_contents('php://input'), true); $id = $input['id'] ?? ''; $leads = dbLoadLeads($companyId); $deleted = null; foreach ($leads as $l) { if ($l['id'] === $id) { $deleted = $l; break; } } dbDeleteLead($id); if ($deleted) dbAddLog($companyId, currentUser(), 'lead_delete', $id, $deleted['yritys'] ?? '', 'Poisti liidin'); echo json_encode(['success' => true]); break; case 'lead_to_customer': requireAuth(); $companyId = requireCompany(); if ($method !== 'POST') break; $input = json_decode(file_get_contents('php://input'), true); $id = $input['id'] ?? ''; $leads = dbLoadLeads($companyId); $lead = null; foreach ($leads as $l) { if ($l['id'] === $id) { $lead = $l; break; } } if (!$lead) { http_response_code(404); echo json_encode(['error' => 'Liidiä ei löydy']); break; } // Luo asiakas liidistä $customer = [ 'id' => generateId(), 'yritys' => $lead['yritys'], 'yhteyshenkilö' => $lead['yhteyshenkilo'] ?? '', 'puhelin' => $lead['puhelin'] ?? '', 'sahkoposti' => $lead['sahkoposti'] ?? '', 'laskutusosoite' => '', 'laskutuspostinumero' => '', 'laskutuskaupunki' => '', 'laskutussahkoposti' => '', 'elaskuosoite' => '', 'elaskuvalittaja' => '', 'ytunnus' => '', 'lisatiedot' => $lead['muistiinpanot'] ?? '', 'liittymat' => [['asennusosoite' => $lead['osoite'] ?? '', 'postinumero' => '', 'kaupunki' => $lead['kaupunki'] ?? '', 'liittymanopeus' => '', 'hinta' => 0, 'sopimuskausi' => '', 'alkupvm' => '', 'vlan' => '', 'laite' => '', 'portti' => '', 'ip' => '']], 'luotu' => date('Y-m-d H:i:s'), ]; dbSaveCustomer($companyId, $customer); // Poista liidi dbDeleteLead($id); dbAddLog($companyId, currentUser(), 'lead_to_customer', $customer['id'], $customer['yritys'], 'Muutti liidin asiakkaaksi'); echo json_encode($customer); break; // ---------- FILES ---------- case 'file_upload': requireAuth(); requireCompany(); if ($method !== 'POST') break; $customerId = $_POST['customer_id'] ?? ''; if (!$customerId || !preg_match('/^[a-f0-9]+$/', $customerId)) { http_response_code(400); echo json_encode(['error' => 'Virheellinen asiakas-ID']); break; } if (empty($_FILES['file'])) { http_response_code(400); echo json_encode(['error' => 'Tiedosto puuttuu']); break; } $file = $_FILES['file']; if ($file['error'] !== UPLOAD_ERR_OK) { http_response_code(400); echo json_encode(['error' => 'Tiedoston lähetys epäonnistui']); break; } if ($file['size'] > 20 * 1024 * 1024) { http_response_code(400); echo json_encode(['error' => 'Tiedosto on liian suuri (max 20 MB)']); break; } $uploadDir = getCompanyDir() . '/files/' . $customerId; if (!file_exists($uploadDir)) mkdir($uploadDir, 0755, true); $safeName = preg_replace('/[^a-zA-Z0-9._-]/', '_', basename($file['name'])); $dest = $uploadDir . '/' . $safeName; if (file_exists($dest)) { $ext = pathinfo($safeName, PATHINFO_EXTENSION); $base = pathinfo($safeName, PATHINFO_FILENAME); $safeName = $base . '_' . date('His') . ($ext ? '.' . $ext : ''); $dest = $uploadDir . '/' . $safeName; } if (move_uploaded_file($file['tmp_name'], $dest)) { echo json_encode(['success' => true, 'filename' => $safeName, 'size' => $file['size']]); } else { http_response_code(500); echo json_encode(['error' => 'Tallennusvirhe']); } break; case 'file_list': requireAuth(); requireCompany(); $customerId = $_GET['customer_id'] ?? ''; if (!$customerId || !preg_match('/^[a-f0-9]+$/', $customerId)) { echo json_encode([]); break; } $dir = getCompanyDir() . '/files/' . $customerId; $files = []; if (is_dir($dir)) { foreach (scandir($dir) as $f) { if ($f === '.' || $f === '..') continue; $path = $dir . '/' . $f; $files[] = ['filename' => $f, 'size' => filesize($path), 'modified' => date('Y-m-d H:i', filemtime($path))]; } } usort($files, fn($a, $b) => strcmp($b['modified'], $a['modified'])); echo json_encode($files); break; case 'file_download': requireAuth(); requireCompany(); $customerId = $_GET['customer_id'] ?? ''; $filename = $_GET['filename'] ?? ''; if (!$customerId || !preg_match('/^[a-f0-9]+$/', $customerId) || !$filename) { http_response_code(400); echo json_encode(['error' => 'Virheelliset parametrit']); break; } $safeName = basename($filename); $path = getCompanyDir() . '/files/' . $customerId . '/' . $safeName; if (!file_exists($path)) { http_response_code(404); echo json_encode(['error' => 'Tiedostoa ei löydy']); break; } header('Content-Type: application/octet-stream'); header('Content-Disposition: attachment; filename="' . $safeName . '"'); header('Content-Length: ' . filesize($path)); readfile($path); exit; case 'file_delete': requireAuth(); requireCompany(); if ($method !== 'POST') break; $input = json_decode(file_get_contents('php://input'), true); $customerId = $input['customer_id'] ?? ''; $filename = $input['filename'] ?? ''; if (!$customerId || !preg_match('/^[a-f0-9]+$/', $customerId) || !$filename) { http_response_code(400); echo json_encode(['error' => 'Virheelliset parametrit']); break; } $safeName = basename($filename); $path = getCompanyDir() . '/files/' . $customerId . '/' . $safeName; if (file_exists($path)) unlink($path); echo json_encode(['success' => true]); break; // ---------- TICKETS ---------- case 'tickets': requireAuth(); $allCompaniesMode = !empty($_GET['all']); $userCompanyIds = $_SESSION['companies'] ?? []; // Kerää yritykset joista haetaan $companiesToQuery = []; if ($allCompaniesMode && count($userCompanyIds) > 1) { $allComps = dbLoadCompanies(); foreach ($allComps as $c) { if (in_array($c['id'], $userCompanyIds)) { $companiesToQuery[] = $c; } } } else { requireCompany(); $companiesToQuery[] = ['id' => $_SESSION['company_id'], 'nimi' => '']; } $list = []; foreach ($companiesToQuery as $comp) { $tickets = dbLoadTickets($comp['id']); // Auto-close tarkistus $now = date('Y-m-d H:i:s'); foreach ($tickets as &$tc) { if (!empty($tc['auto_close_at']) && $tc['auto_close_at'] <= $now && !in_array($tc['status'], ['suljettu'])) { $tc['status'] = 'suljettu'; $tc['updated'] = $now; dbSaveTicket($comp['id'], $tc); } } unset($tc); // Resolve mailbox names for this company $mailboxes = dbLoadMailboxes($comp['id']); $mailboxNames = []; foreach ($mailboxes as $mb) { $mailboxNames[$mb['id']] = $mb['nimi']; } foreach ($tickets as $t) { $msgCount = count($t['messages'] ?? []); $lastMsg = $msgCount > 0 ? $t['messages'][$msgCount - 1] : null; $list[] = [ 'id' => $t['id'], 'subject' => $t['subject'], 'from_email' => $t['from_email'], 'from_name' => $t['from_name'], 'status' => $t['status'], 'type' => $t['type'] ?? 'muu', 'assigned_to' => $t['assigned_to'] ?? '', 'customer_id' => $t['customer_id'] ?? '', 'customer_name' => $t['customer_name'] ?? '', 'tags' => $t['tags'] ?? [], 'priority' => $t['priority'] ?? 'normaali', 'auto_close_at' => $t['auto_close_at'] ?? '', 'mailbox_id' => $t['mailbox_id'] ?? '', 'mailbox_name' => $mailboxNames[$t['mailbox_id'] ?? ''] ?? '', 'company_id' => $comp['id'], 'company_name' => $comp['nimi'] ?? '', 'created' => $t['created'], 'updated' => $t['updated'], 'message_count' => $msgCount, 'last_message_type' => $lastMsg ? ($lastMsg['type'] ?? '') : '', 'last_message_time' => $lastMsg ? ($lastMsg['timestamp'] ?? '') : '', ]; } } echo json_encode($list); break; case 'ticket_detail': requireAuth(); $companyId = requireCompanyOrParam(); $id = $_GET['id'] ?? ''; $tickets = dbLoadTickets($companyId); $ticket = null; foreach ($tickets as $t) { if ($t['id'] === $id) { $ticket = $t; break; } } if (!$ticket) { http_response_code(404); echo json_encode(['error' => 'Tikettiä ei löydy']); break; } // Zammad-tiketti: hae artikkelit on-demand jos ei vielä haettu if (!empty($ticket['zammad_ticket_id']) && empty($ticket['messages'])) { try { $integ = dbGetIntegration($companyId, 'zammad'); if ($integ && $integ['enabled']) { $z = new ZammadClient($integ['config']['url'], $integ['config']['token']); $articles = $z->getArticles((int)$ticket['zammad_ticket_id']); $toEmail = ''; foreach ($articles as $art) { if (($art['internal'] ?? false)) continue; // Tallenna ensimmäisen saapuneen viestin to-osoite if (!$toEmail && ($art['sender'] ?? '') === 'Customer' && !empty($art['to'])) { $toEmail = $art['to']; } $artId = (int)$art['id']; $existingMsg = dbGetMessageByZammadArticleId($ticket['id'], $artId); if ($existingMsg) continue; $msgId = substr(uniqid(), -8) . bin2hex(random_bytes(2)); $msgType = ($art['sender'] ?? '') === 'Customer' ? 'incoming' : 'outgoing'; $body = $art['body'] ?? ''; if (($art['content_type'] ?? '') === 'text/html') { $body = strip_tags($body, '