// Gemeinsamer Kern von Popup und Hintergrunddienst: Serveradresse, Anmeldung, API-Aufrufe, Hilfsfunktionen. // Das Anmelde-Token liegt nur in chrome.storage.local dieser Erweiterung (nie in sync, nie im Seitencode). export class ApiError extends Error { constructor(status, code, message) { super(message); this.status = status; this.code = code; } } const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']); /** Bereinigt die Serveradresse wie das Windows-Programm: https ist Pflicht (http nur für lokale Tests), kein Benutzer/Suchtext/Anker. */ export function normalizeServerUrl(input) { let s = String(input || '').trim(); if (!s) throw new Error('Bitte die Server-Adresse eingeben.'); if (!s.includes('://')) s = 'https://' + s; let u; try { u = new URL(s); } catch { throw new Error('Die Server-Adresse ist ungültig.'); } const local = LOCAL_HOSTS.has(u.hostname); if (u.protocol !== 'https:' && !(u.protocol === 'http:' && local)) throw new Error('Die Server-Adresse muss HTTPS verwenden.'); if (u.username || u.password || u.search || u.hash) throw new Error('Die Server-Adresse ist ungültig.'); return u.origin + u.pathname.replace(/\/+$/, ''); } export async function getSession() { const { session } = await chrome.storage.local.get('session'); return session && session.token && session.serverUrl ? session : null; } export const setSession = (session) => chrome.storage.local.set({ session }); export async function clearSession() { await chrome.storage.local.remove(['session', 'lastSeen', 'badgeCount', 'meta']); } export async function getSettings() { const { settings } = await chrome.storage.local.get('settings'); return { notifications: true, ...(settings || {}) }; } export const setSettings = (settings) => chrome.storage.local.set({ settings }); /** Ein Aufruf der Mitarbeiter-API. Token wird wie bei den Apps in zwei Headern gesendet (viele Hoster entfernen „Authorization“). */ export async function apiCall(path, { method = 'GET', query, body, auth = true, serverUrl, signal } = {}) { const session = auth || !serverUrl ? await getSession() : null; const base = serverUrl || session?.serverUrl; if (!base) throw new ApiError(401, 'UNAUTHENTICATED', 'Nicht angemeldet.'); const url = new URL(`${base}/api/staff/v1/${path}`); for (const [k, v] of Object.entries(query || {})) if (v !== undefined && v !== null && v !== '') url.searchParams.set(k, String(v)); const headers = { Accept: 'application/json', 'Cache-Control': 'no-cache' }; if (auth) { if (!session?.token) throw new ApiError(401, 'UNAUTHENTICATED', 'Nicht angemeldet.'); headers.Authorization = `Bearer ${session.token}`; headers['X-DeskHub-Authorization'] = `Bearer ${session.token}`; } const init = { method, headers, redirect: 'error', credentials: 'omit', signal: signal || AbortSignal.timeout(30000) }; if (body !== undefined && method !== 'GET') { headers['Content-Type'] = 'application/json'; init.body = JSON.stringify(body); } let res; try { res = await fetch(url, init); } catch { throw new ApiError(0, 'NETWORK', 'Server nicht erreichbar.'); } const text = await res.text(); let data = null; try { data = text ? JSON.parse(text) : null; } catch { /* kein JSON */ } if (!res.ok && res.status !== 202) { if (res.status === 401 && auth) await clearSession(); throw new ApiError(res.status, data?.error?.code || `HTTP_${res.status}`, data?.error?.message || `Serverfehler (${res.status}).`); } return { status: res.status, data }; } /** Fragt bei Bedarf die Berechtigung für genau diesen Server ab (nur diese eine Adresse, nicht „alle Webseiten“). Muss aus einem Klick heraus aufgerufen werden. */ export async function ensureHostPermission(serverUrl) { const pattern = new URL(serverUrl).origin + '/*'; if (await chrome.permissions.contains({ origins: [pattern] })) return true; return chrome.permissions.request({ origins: [pattern] }); } /** Meldet an. @returns {{requiresTotp:true}|{session:object}} */ export async function login(serverUrlInput, email, password, totpCode) { const serverUrl = normalizeServerUrl(serverUrlInput); if (!(await ensureHostPermission(serverUrl))) throw new Error('Ohne Erlaubnis für diesen Server kann sich die Erweiterung nicht anmelden.'); const { status, data } = await apiCall('login.php', { method: 'POST', auth: false, serverUrl, body: { email, password, totpCode: totpCode || null, deviceName: 'Chrome-Erweiterung' }, }); if (status === 202 || data?.requiresTotp) return { requiresTotp: true }; const session = { serverUrl, token: data.token, expiresAt: data.expiresAt, staff: data.staff }; await setSession(session); return { session }; } /** Meldet ab (Token auf dem Server sperren) und entfernt Daten und Berechtigung dieses Servers. */ export async function logout() { const session = await getSession(); if (session) { try { await apiCall('logout.php', { method: 'POST', body: {} }); } catch { /* Token läuft ohnehin ab */ } try { await chrome.permissions.remove({ origins: [new URL(session.serverUrl).origin + '/*'] }); } catch { /* ignorieren */ } } await clearSession(); } /** Pfad eines Tickets in der Weboberfläche. Nur Ticketnummern im bekannten Format. */ export function ticketPath(number) { const n = String(number || ''); if (!/^[A-Za-z0-9._-]{1,40}$/.test(n)) throw new Error('Ungültige Ticketnummer.'); return `/staff/ticket_view.php?n=${n}`; } /** Öffnet eine Seite der Weboberfläche in einem neuen Tab. Der Server gibt dafür eine einmalige Anmelde-Adresse (und prüft die Rechte). */ export async function openWeb(path) { const session = await getSession(); if (!session) throw new ApiError(401, 'UNAUTHENTICATED', 'Nicht angemeldet.'); const { data } = await apiCall('web_pages.php', { method: 'POST', body: { path } }); const u = new URL(String(data?.url || '')); const base = new URL(session.serverUrl); if ((u.protocol !== 'https:' && !LOCAL_HOSTS.has(u.hostname)) || u.origin !== base.origin || !u.pathname.endsWith('/staff/app_login.php')) { throw new Error('Der Server hat eine unerwartete Adresse geliefert.'); } return chrome.tabs.create({ url: u.href }); } /** Maskiert Text und macht Zeilenumbrüche zu
: Der Server nimmt Ticket-Nachrichten als HTML an und bereinigt sie zusätzlich. */ export function plainToHtml(text) { return String(text ?? '').replace(/&/g, '&').replace(//g, '>').replace(/\r\n|\r|\n/g, '
'); } export const truncate = (s, n) => (String(s).length > n ? String(s).slice(0, n - 1) + '…' : String(s));