// 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));