diff --git a/README.md b/README.md index ccde65a..a29398c 100644 --- a/README.md +++ b/README.md @@ -519,6 +519,27 @@ Das Backend akzeptiert nur Bild-Data-URLs und http(s)-Adressen und begrenzt die Länge; die Quelldatei darf bis 25 MB groß sein, weil sie ohnehin komprimiert wird. +### Profilfoto von LinkedIn + +Trägt man beim Anlegen eines Kontakts eine LinkedIn-Adresse ein, holt das CRM +das öffentliche Profilbild und setzt es als Kontaktfoto - beim Verlassen des +Feldes von selbst (einmal je Adresse, nur solange kein Foto gesetzt ist) oder +auf Klick über den Knopf *Foto* daneben. + +Den Abruf macht das Backend (`server/src/linkedinPhoto.js`, Route +`POST /api/leads/linkedin-photo`): aus dem Browser heraus stünde die +Same-Origin-Policy davor. Gelesen wird die Open-Graph-Vorschau der öffentlichen +Profilseite (`og:image`), das Bild kommt anschließend von `media.licdn.com`. +Geprüft wird die Herkunft (nur `https` von `licdn.com` mit `/dms/image/` im +Pfad), die Größe (max. 5 MB) und die Antwortzeit (12 s); die graue +Platzhalter-Silhouette der Profile ohne eigenes Foto wird abgewiesen. Verkleinert +wird danach im Browser über denselben Weg wie ein Upload +(`compressImageSource()`). + +Der Abruf ist bewusst als **Komfort** gebaut, nicht als Zusage: LinkedIn weist +Anfragen aus Rechenzentren häufiger ab als solche aus einem Büronetz. Schlägt er +fehl, steht der Grund unter dem Feld und das Foto wird wie bisher hochgeladen. + Benutzerfotos pflegt jeder für sich unter *Einstellungen → Mein Konto*, Admins für alle in der Nutzerverwaltung. Angezeigt werden sie in der Sidebar, bei Terminteilnehmern und Erstellern, im Benutzer-Detailfenster, in den @@ -544,6 +565,13 @@ Knoten ziehen fixiert ihn (grüner Punkt), Doppelklick löst die Fixierung. Über *Verbindung erstellen* zwei Kontakte anklicken, um eine Kante anzulegen; Esc bricht ab. Die Ansicht respektiert Suche und Statusfilter. +Ein Klick auf einen Knoten hebt ihn samt Nachbarn hervor und blendet den Rest +auf 22 % Deckkraft ab. Ein Klick auf eine **freie Fläche** hebt die Auswahl +wieder auf - der Ausgangszustand der Ansicht. Damit das Verschieben nicht +versehentlich die Auswahl verwirft, gilt eine Geste erst ab 4 px Weg als Ziehen; +Knoten stoppen das `pointerdown` ohnehin, ihre Klicks erreichen den Hintergrund +nicht. + ## Lokale Entwicklung ```bash diff --git a/docs/01-architektur.md b/docs/01-architektur.md index 5e6b028..207b6cb 100644 --- a/docs/01-architektur.md +++ b/docs/01-architektur.md @@ -33,15 +33,21 @@ die Postfachanbindung unter `/api/mail` (siehe ## Ausgehende Verbindungen -Das Backend spricht von sich aus mit drei fremden Diensten. Alle drei sind -optional - fehlen die Zugangsdaten, läuft die Anwendung ohne den jeweiligen -Teil weiter. +Das Backend spricht von sich aus mit vier fremden Diensten. Alle sind +optional - fehlen die Zugangsdaten oder ist das Ziel nicht erreichbar, läuft die +Anwendung ohne den jeweiligen Teil weiter. | Ziel | Wofür | Ausgelöst von | | --- | --- | --- | | Projektbörsen | Scraping der Projektangebote | Scanner-Scheduler | | `api.openai.com` | Bewertung Lebenslauf gegen Projekt | Scanner-Pipeline | | `login.microsoftonline.com`, `graph.microsoft.com` | Postfach `crm@perinco.de` lesen | Mail-Scheduler | +| `linkedin.com`, `media.licdn.com` | Profilfoto zu einem Kontakt holen | Benutzer im Kontaktformular | + +Der LinkedIn-Abruf ist der einzige, den ein Benutzer direkt auslöst: Er liest die +öffentliche Vorschau (`og:image`) einer Profilseite und lädt das Bild vom +LinkedIn-CDN. Keine Anmeldung, kein Zugriff auf geschützte Inhalte - schlägt der +Abruf fehl, bleibt es beim Hochladen von Hand. Die Postfachanbindung schickt **keine** Mailinhalte an Dritte: Kontaktdaten werden auf dem Server aus Graph-Feldern und Signaturen gelesen, ohne diff --git a/docs/05-frontend.md b/docs/05-frontend.md index 383395b..053474d 100644 --- a/docs/05-frontend.md +++ b/docs/05-frontend.md @@ -117,7 +117,7 @@ src/ whisper.tsx Modell, Worker-Lebenszyklus dictation.tsx Aufnahme-Hook audio.ts Aufnahme zu 16-kHz-Mono-PCM - imageCompress.ts Bilder vor dem Upload verkleinern + imageCompress.ts Bilder vor dem Upload verkleinern (auch das LinkedIn-Foto) components/ Sidebar.tsx Menü inkl. aufklappbarer Gruppen (Kontakte, Scanner) Header.tsx Titel, Untertitel, Design-Auswahl, Hell/Dunkel-Schalter diff --git a/docs/07-anleitung-benutzer.md b/docs/07-anleitung-benutzer.md index e30e3df..8f62388 100644 --- a/docs/07-anleitung-benutzer.md +++ b/docs/07-anleitung-benutzer.md @@ -491,6 +491,21 @@ braucht, pflegt sie im Kontakt nach. ![Dialog "Neuer Kontakt" mit allen Feldern](bilder/kontakt-dialog.png) +Zwei Handgriffe nimmt der Dialog beim **Anlegen** ab: + +* **Zuständig** steht schon auf dem angemeldeten Benutzer. Wer für jemand + anderen anlegt, stellt das Feld um; beim späteren Bearbeiten bleibt immer die + gespeicherte Zuständigkeit stehen. +* **Profilfoto von LinkedIn.** Wird eine LinkedIn-Adresse eingetragen und der + Kontakt hat noch kein Foto, holt das CRM beim Verlassen des Feldes das + öffentliche Profilbild und setzt es als Kontaktfoto. Der Knopf **Foto** neben + dem Feld macht dasselbe auf Zuruf - etwa, wenn die Adresse nachgetragen wird. + Ein selbst hochgeladenes Foto wird dabei nie überschrieben. + + Klappt es nicht, steht der Grund unter dem Feld: Das Profil ist nicht + öffentlich, es hat gar kein eigenes Foto, oder LinkedIn hat die Anfrage + abgewiesen. Dann bleibt der gewohnte Weg über *Ändern* am Foto. + Im **Netzwerk** sind Kreise Kontakte (Farbe = Art, Größe = Anzahl Verbindungen) und Quadrate Benutzer, die Kontakte angelegt haben. Grüne Linien verbinden Kontakte untereinander, graue gestrichelte den Ersteller mit seinem @@ -503,6 +518,11 @@ setzt ihn fest (grüner Punkt), Doppelklick löst ihn wieder. Über *Verbindung erstellen* zwei Kontakte anklicken, um eine Verbindung anzulegen; `Esc` bricht ab. Suche und Artfilter wirken auch hier. +Ein Klick auf einen Knoten hebt ihn und seine Nachbarn hervor, alles andere wird +blass. **Ein Klick auf eine freie Stelle nimmt die Hervorhebung wieder zurück** - +das Bild sieht danach aus wie beim Wechsel in die Netzwerkansicht. Verschieben +zählt nicht als Klick: Wer den Hintergrund packt und zieht, behält die Auswahl. + Sobald Kontakte aus dem Postfach übernommen wurden, erscheint neben dem Artfilter ein **Herkunftsfilter**: *Aus Mails übernommen* zeigt nur die aus dem Postfach stammenden, *Selbst angelegt* nur die von Hand erfassten. diff --git a/docs/08-anleitung-entwickler.md b/docs/08-anleitung-entwickler.md index 7cc66db..c6f6de9 100644 --- a/docs/08-anleitung-entwickler.md +++ b/docs/08-anleitung-entwickler.md @@ -53,7 +53,7 @@ ansehen - siehe *Oberfläche prüfen ohne Datenbank* weiter unten. ```bash npm run lint # = tsc --noEmit, das ist die Wahrheit npm run build # muss ohne Fehler durchlaufen -cd server && npm test # Postfachanbindung und Berater-Liste +cd server && npm test # Postfachanbindung, Berater-Liste, Benutzerfelder, LinkedIn-Foto ``` Automatische Tests gibt es für zwei Bereiche, beide ohne Test-Framework, reines diff --git a/docs/09-quellcode.md b/docs/09-quellcode.md index 576c795..e6dd9fd 100644 --- a/docs/09-quellcode.md +++ b/docs/09-quellcode.md @@ -123,6 +123,36 @@ unbekannter Name kommt so nie in die Datenbank. Bewusst getrennt vom Setting-Modell des Scanners (andere Datenbank) und bewusst **ohne Geheimnisse**: Zugangsdaten stehen in `server/.env`. +### `server/src/linkedinPhoto.js` + +Holt das Profilfoto zu einer LinkedIn-Adresse. Bedient wird damit +`POST /api/leads/linkedin-photo`. + +| Export | Zweck | +| --- | --- | +| `normalizeProfileUrl(eingabe)` | macht aus `linkedin.com/in/name` eine `https`-URL, wirft bei fremden Hosts | +| `fetchLinkedInPhoto(eingabe)` | Profilseite lesen, `og:image` ziehen, Bild laden, als Data-URL zurückgeben | +| `PhotoError` | Fehler mit `status`; die Route reicht Status und Text unverändert durch | + +Der Weg in Stichpunkten und die Gründe dafür: + +* **Über das Backend, nicht aus dem Browser.** Ein `fetch` auf linkedin.com aus + der Seite heraus scheitert an der Same-Origin-Policy. +* **Browser-Kennung im Kopf.** Ohne `User-Agent` antwortet LinkedIn oft gar + nicht erst. +* **Nur `og:image`/`twitter:image`**, ersatzweise eine `media.licdn.com`-Adresse + aus dem Quelltext. Es wird nichts angemeldet und nichts Geschütztes gelesen. +* **Herkunft wird geprüft.** Das Bild muss über `https` von `licdn.com` kommen + und `/dms/image/` im Pfad haben - sonst könnte eine manipulierte Seite eine + beliebige Adresse unterschieben. +* **Platzhalter werden abgewiesen.** Profile ohne eigenes Foto liefern eine + graue Silhouette (`ghost` im Pfad); die soll kein Kontaktfoto werden. +* **Grenzen:** 12 s Zeitlimit je Anfrage, höchstens 5 MB Bild. + +Gibt LinkedIn die Seite nicht heraus (Status 999 oder 403 bei zu vielen +Anfragen von einer Adresse), kommt eine Meldung im Klartext zurück - das ist +der Normalfall, mit dem die Oberfläche rechnet, kein Serverfehler. + ### `server/src/mail/` Postfachanbindung, fachlich in [11 Postfachanbindung](11-postfach.md). @@ -212,6 +242,7 @@ Zusagen, auf die sich der Rest verlässt: | `ApiError` | Fehler mit `status` | | `authApi`, `usersApi`, `leadsApi`, `opportunitiesApi`, `eventsApi`, `connectionsApi` | typisierte Endpunkte | | `leadsApi.mailPoolSources` / `.promote` / `.promoteMany` | Vorbereich „Aus Mails": Herkunft lesen, einzeln bzw. gesammelt übernehmen | +| `leadsApi.linkedinPhoto(url)` | Profilfoto zu einer LinkedIn-Adresse als Data-URL; speichert nichts, das erledigt das Formular beim Sichern | Zwei Feinheiten: Der `Content-Type` wird **nicht** gesetzt, wenn der Body `FormData` ist. Und ein Netzwerkfehler wird zu `ApiError(status 0)` mit @@ -278,6 +309,8 @@ Warum genau diese Konstanten festgelegt sind, steht in | `components/acquisition/acquisitionUi.tsx` | `fieldClass` (abgesetzte Felder) und `OptionSelect` - zeigt einen Wert, der nicht mehr in der Liste steht, als solchen; `alwaysOffer` hält den eigenen Namen für *Von* immer wählbar. *Anbieter* nutzt es nicht: Freitext mit ``. Dazu `ConsultantSelect` (mehrere Berater: Auswahlfeld hängt an, Plaketten nehmen weg) und `ConsultantList` für die Anzeige in der Liste | | `components/settings/AcquisitionSettings.tsx` | Reiter *Einstellungen → Akquise*: die vier Auswahllisten pflegen | | `components/leads/leadUi.tsx` | `inputClass`, `labelClass` (Beschriftung als eigene Zeile), `labelTextClass` + `labelRowClass` (Zeile mit Knopf rechts, z. B. Diktat - `min-h-7` haelt sie so hoch wie den Knopf, damit Felder in Nachbarspalten auf einer Linie beginnen), `KindBadge`, `Avatar`, Farben der Kontaktart | +| `components/leads/LeadModal.tsx` | Kontaktformular. Beim Neuanlegen ist der angemeldete Benutzer *Zuständig* (`useAuth()`, Rückfallebene `users[0]`) - beim Bearbeiten bleibt die gespeicherte Zuständigkeit stehen. Neben dem LinkedIn-Feld holt *Foto* das Profilbild; beim Verlassen des Feldes geschieht das einmal je Adresse von selbst, aber nur solange noch kein Bild gesetzt ist (`triedProfiles`) - ein hochgeladenes Foto wird nie überschrieben. Fehler des automatischen Versuchs stehen als Hinweis unter dem Feld, nur der Klick auf *Foto* meldet sich als Toast | +| `components/leads/LeadNetwork.tsx` | Netzwerkdiagramm (d3-force). Ein Klick auf leere Fläche hebt die Auswahl auf, das Diagramm ist danach wieder so hell wie beim Wechsel in die Ansicht; ein Ziehen des Hintergrunds (mehr als 4 px) verschiebt nur und lässt die Auswahl stehen. Knoten stoppen das `pointerdown`, ihre Klicks erreichen den Hintergrund also nicht | | `components/leads/mailPool.ts` | `findMailPoolMatch()`, `diffFields()`, `isConflict()`, `MAIL_POOL_FIELDS` - Dublettenabgleich des Vorbereichs | | `components/leads/MailPoolLeads.tsx` | Ansicht „Aus Mails": Goldmarkierung, Abweichungen, Übernehmen, Sammelübernahme | | `components/leads/MailPoolMergeDialog.tsx` | Feldauswahl beim Ergänzen eines vorhandenen Kontakts | @@ -288,7 +321,7 @@ Warum genau diese Konstanten festgelegt sind, steht in | `components/ui/Modal.tsx` | `Modal` und `ConfirmDialog`; `useDismiss()` schließt per Escape und per Klick in den Hintergrund. Escape trifft nur den obersten Dialog (Stapel `openModals`), der Hintergrundklick muss dort beginnen **und** enden. `ConfirmDialog` kennt zwei Tonlagen: `tone="danger"` (Standard, rot - es geht etwas verloren) und `tone="neutral"` (grün, bloße Rückfrage, z. B. beim Wiederherstellen) | | `components/Toast.tsx` | `useToast().addToast(text, kind)` mit `success`, `error` oder `info` | | `components/ui/DictationField.tsx` | Mikrofon-Knopf, Hinweisbox, Textfeld mit Diktat | -| `lib/imageCompress.ts` | `compressImage()` auf 256 px, WebP → JPEG → PNG | +| `lib/imageCompress.ts` | `compressImage(datei)` auf 256 px, WebP → JPEG → PNG; `compressImageSource(dataUrl)` geht denselben Weg für ein Bild, das nicht aus einer Datei kommt (LinkedIn-Foto) | ### `src/types.ts` diff --git a/server/package.json b/server/package.json index 8d357a2..5041f1c 100644 --- a/server/package.json +++ b/server/package.json @@ -12,7 +12,7 @@ "start": "node src/server.js", "dev": "node --watch src/server.js", "migrate": "node src/migrate.js", - "test": "node test/mail.test.mjs && node test/acquisition.test.mjs && node test/userFields.test.mjs" + "test": "node test/mail.test.mjs && node test/acquisition.test.mjs && node test/userFields.test.mjs && node test/linkedinPhoto.test.mjs" }, "dependencies": { "bcryptjs": "^2.4.3", diff --git a/server/src/linkedinPhoto.js b/server/src/linkedinPhoto.js new file mode 100644 index 0000000..e43306e --- /dev/null +++ b/server/src/linkedinPhoto.js @@ -0,0 +1,191 @@ +/* + * Profilfoto zu einem LinkedIn-Profil besorgen. + * + * LinkedIn liefert oeffentlichen Profilseiten eine Open-Graph-Vorschau mit, + * die das Profilfoto enthaelt (og:image, ausgeliefert ueber media.licdn.com). + * Genau diese Vorschau wird hier gelesen - es findet keine Anmeldung und kein + * Auslesen geschuetzter Inhalte statt. Ist das Profil nicht oeffentlich oder + * blockt LinkedIn die Anfrage, gibt es eine verstaendliche Fehlermeldung + * statt eines Bildes; der Benutzer kann das Foto dann wie gehabt hochladen. + * + * Der Aufruf laeuft bewusst ueber das Backend: aus dem Browser heraus waere er + * an der Same-Origin-Policy gescheitert. + */ + +/** Ohne Browser-Kennung antwortet LinkedIn haeufig gar nicht erst. */ +const BROWSER_HEADERS = { + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) ' + + 'Chrome/140.0.0.0 Safari/537.36', + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'Accept-Language': 'de-DE,de;q=0.9,en;q=0.8', +}; + +/** Nach dieser Zeit wird abgebrochen - die Oberflaeche soll nicht haengen. */ +const TIMEOUT_MS = 12_000; + +/** Groessere Bilder sind kein Profilfoto mehr; der Client verkleinert ohnehin. */ +const MAX_IMAGE_BYTES = 5_000_000; + +/** Bilder liefert LinkedIn ausschliesslich ueber dieses CDN aus. */ +const IMAGE_HOSTS = ['licdn.com']; + +class PhotoError extends Error { + constructor(message, status = 502) { + super(message); + this.status = status; + } +} + +function hostMatches(hostname, domain) { + return hostname === domain || hostname.endsWith(`.${domain}`); +} + +/** + * Eingabe des Benutzers zu einer aufrufbaren Profil-URL machen. Akzeptiert + * auch "linkedin.com/in/vorname" ohne Schema. + */ +export function normalizeProfileUrl(input) { + const raw = String(input ?? '').trim(); + if (!raw) throw new PhotoError('Es ist kein LinkedIn-Profil hinterlegt.', 400); + + let url; + try { + url = new URL(/^https?:\/\//i.test(raw) ? raw : `https://${raw}`); + } catch { + throw new PhotoError('Die LinkedIn-Adresse ist keine gültige URL.', 400); + } + + if (!hostMatches(url.hostname.toLowerCase(), 'linkedin.com')) { + throw new PhotoError('Die Adresse zeigt nicht auf ein LinkedIn-Profil.', 400); + } + + // Tracking-Parameter stoeren nur und aendern die Seite nicht. + url.protocol = 'https:'; + url.search = ''; + url.hash = ''; + return url.toString(); +} + +/** + * Bild-URL aus dem HTML der Profilseite ziehen. og:image ist der + * zuverlaessigste Treffer; als Rueckfallebene wird nach einer Foto-URL des + * LinkedIn-CDN im Seitenquelltext gesucht. + */ +function extractImageUrl(html) { + const metaPattern = /]+>/gi; + for (const tag of html.match(metaPattern) ?? []) { + const isImageTag = /(property|name)\s*=\s*["'](og:image|twitter:image)["']/i.test(tag); + if (!isImageTag) continue; + const content = tag.match(/content\s*=\s*["']([^"']+)["']/i); + if (content?.[1]) return decodeEntities(content[1]); + } + + const direct = html.match(/https:\/\/media[^"'\s\\]*\.licdn\.com\/dms\/image\/[^"'\s\\]+/i); + return direct ? decodeEntities(direct[0]) : null; +} + +/** Im HTML stehen die URLs mit maskierten Und-Zeichen. */ +function decodeEntities(value) { + return value + .replace(/&/g, '&') + .replace(///gi, '/') + .replace(/&/g, '&') + .replace(/"/g, '"'); +} + +async function requestWithTimeout(url, headers) { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), TIMEOUT_MS); + try { + return await fetch(url, { headers, redirect: 'follow', signal: controller.signal }); + } catch (err) { + if (err?.name === 'AbortError') { + throw new PhotoError('LinkedIn hat nicht rechtzeitig geantwortet.', 504); + } + throw new PhotoError('LinkedIn ist gerade nicht erreichbar.', 502); + } finally { + clearTimeout(timer); + } +} + +/** + * Holt das Profilfoto und gibt es als Data-URL zurueck - so wie ein + * hochgeladenes Foto auch gespeichert wird. + */ +export async function fetchLinkedInPhoto(profileInput) { + const profileUrl = normalizeProfileUrl(profileInput); + + const page = await requestWithTimeout(profileUrl, BROWSER_HEADERS); + if (page.status === 404 || page.status === 410) { + throw new PhotoError('Unter dieser Adresse gibt es kein LinkedIn-Profil.', 404); + } + if (!page.ok) { + // 999 ist LinkedIns Kennzeichen fuer "Anfrage abgewiesen". + throw new PhotoError( + 'LinkedIn hat die Anfrage abgewiesen. Bitte das Foto manuell hochladen.', + 502 + ); + } + + const html = await page.text(); + const imageUrl = extractImageUrl(html); + if (!imageUrl) { + throw new PhotoError( + 'Das Profil gibt kein öffentliches Foto preis. Bitte das Foto manuell hochladen.', + 404 + ); + } + + let parsedImage; + try { + parsedImage = new URL(imageUrl); + } catch { + throw new PhotoError('Die gefundene Bildadresse ist unbrauchbar.', 502); + } + const isTrustedHost = + parsedImage.protocol === 'https:' && + IMAGE_HOSTS.some((domain) => hostMatches(parsedImage.hostname.toLowerCase(), domain)); + if (!isTrustedHost) { + throw new PhotoError('Das gefundene Bild stammt nicht von LinkedIn.', 502); + } + + /* + * Profile ohne eigenes Foto bekommen von LinkedIn eine graue Platzhalter- + * Silhouette als Vorschaubild. Die soll nicht als Kontaktfoto landen. + */ + const path = parsedImage.pathname.toLowerCase(); + if (path.includes('ghost') || !path.includes('/dms/image/')) { + throw new PhotoError( + 'Das Profil hat kein eigenes Foto hinterlegt.', + 404 + ); + } + + const image = await requestWithTimeout(parsedImage.toString(), { + 'User-Agent': BROWSER_HEADERS['User-Agent'], + 'Accept': 'image/avif,image/webp,image/png,image/jpeg,*/*;q=0.8', + 'Referer': profileUrl, + }); + if (!image.ok) throw new PhotoError('Das Profilfoto konnte nicht geladen werden.', 502); + + const contentType = (image.headers.get('content-type') || '').split(';')[0].trim().toLowerCase(); + if (!contentType.startsWith('image/')) { + throw new PhotoError('LinkedIn hat statt eines Bildes etwas anderes geliefert.', 502); + } + + const buffer = Buffer.from(await image.arrayBuffer()); + if (buffer.length === 0) throw new PhotoError('Das Profilfoto ist leer.', 502); + if (buffer.length > MAX_IMAGE_BYTES) { + throw new PhotoError('Das Profilfoto ist zu groß.', 502); + } + + return { + avatar: `data:${contentType};base64,${buffer.toString('base64')}`, + bytes: buffer.length, + source_url: parsedImage.toString(), + profile_url: profileUrl, + }; +} + +export { PhotoError, extractImageUrl, decodeEntities }; diff --git a/server/src/routes/leads.js b/server/src/routes/leads.js index 4bd4e45..5839cca 100644 --- a/server/src/routes/leads.js +++ b/server/src/routes/leads.js @@ -1,5 +1,6 @@ import express from 'express'; import { pool } from '../db.js'; +import { fetchLinkedInPhoto } from '../linkedinPhoto.js'; import { requireAuth } from '../middleware/auth.js'; const router = express.Router(); @@ -110,6 +111,24 @@ router.get('/', async (req, res, next) => { } }); +/* + * POST /api/leads/linkedin-photo { linkedin: "https://www.linkedin.com/in/..." } + * + * Liest das oeffentliche Profilfoto und gibt es als Data-URL zurueck. Gespeichert + * wird hier nichts - der Client legt das Bild ins Formular, verkleinert es wie + * ein hochgeladenes Foto und speichert es erst beim Sichern des Kontakts. + * Steht die Route vor '/:id', weil sonst die ID-Routen greifen wuerden. + */ +router.post('/linkedin-photo', async (req, res, next) => { + try { + const result = await fetchLinkedInPhoto(req.body?.linkedin); + res.json(result); + } catch (err) { + if (err?.status) return res.status(err.status).json({ error: err.message }); + next(err); + } +}); + // POST /api/leads router.post('/', async (req, res, next) => { const firstName = emptyToNull(req.body?.first_name); diff --git a/server/test/linkedinPhoto.test.mjs b/server/test/linkedinPhoto.test.mjs new file mode 100644 index 0000000..bf10373 --- /dev/null +++ b/server/test/linkedinPhoto.test.mjs @@ -0,0 +1,82 @@ +/* + * Pruefungen des LinkedIn-Profilfotos: laeuft mit `npm test` im Verzeichnis + * server/. Geprueft wird alles, was ohne Netz geht - die Adresspruefung und das + * Herausloesen der Bildadresse aus dem Seitenquelltext. Der Abruf selbst haengt + * an LinkedIn und taugt nicht als Test: er kann jederzeit abgewiesen werden. + */ +const { normalizeProfileUrl, extractImageUrl } = await import('../src/linkedinPhoto.js'); + +let failures = 0; + +function check(name, condition, detail = '') { + if (condition) { + console.log(` ok ${name}${detail ? ` - ${detail}` : ''}`); + } else { + console.log(` FAIL ${name}${detail ? ` - ${detail}` : ''}`); + failures += 1; + } +} + +function throws(fn) { + try { + fn(); + return null; + } catch (err) { + return err; + } +} + +console.log('\nAdresse pruefen'); +check( + 'Vollstaendige Adresse bleibt', + normalizeProfileUrl('https://www.linkedin.com/in/max') === 'https://www.linkedin.com/in/max' +); +check( + 'Ohne Schema wird https ergaenzt', + normalizeProfileUrl('linkedin.com/in/max') === 'https://linkedin.com/in/max' +); +check( + 'Landeskennung ist erlaubt', + normalizeProfileUrl('https://de.linkedin.com/in/max') === 'https://de.linkedin.com/in/max' +); +check( + 'Nachverfolgung faellt weg', + normalizeProfileUrl('https://www.linkedin.com/in/max?trk=abc#top') === + 'https://www.linkedin.com/in/max' +); +check( + 'http wird zu https', + normalizeProfileUrl('http://www.linkedin.com/in/max') === 'https://www.linkedin.com/in/max' +); + +const fremd = throws(() => normalizeProfileUrl('https://linkedin.com.example.org/in/max')); +check('Aehnlich aussehender Host wird abgewiesen', fremd?.status === 400, fremd?.message); +check('Fremder Host wird abgewiesen', throws(() => normalizeProfileUrl('https://xing.com/x'))?.status === 400); +check('Leere Eingabe wird abgewiesen', throws(() => normalizeProfileUrl(''))?.status === 400); +check('Unsinn wird abgewiesen', throws(() => normalizeProfileUrl('kein ://link'))?.status === 400); + +console.log('\nBildadresse aus dem Quelltext'); +check( + 'og:image wird gefunden', + extractImageUrl('') === + 'https://media.licdn.com/dms/image/v2/abc' +); +check( + 'Maskierte Und-Zeichen werden zurueckgesetzt', + extractImageUrl('') === + 'https://media.licdn.com/dms/image/a?x=1&y=2' +); +check( + 'twitter:image als Ersatz', + extractImageUrl('') === + 'https://media.licdn.com/dms/image/t' +); +check( + 'Ohne Vorschau greift die Suche im Quelltext', + extractImageUrl('var x = "https://media.licdn.com/dms/image/v2/zzz/profile-displayphoto";') === + 'https://media.licdn.com/dms/image/v2/zzz/profile-displayphoto' +); +check('Seite ohne Bild liefert null', extractImageUrl('nichts') === null); + +console.log(failures === 0 ? '\nAlle Pruefungen bestanden.' : `\n${failures} Pruefung(en) fehlgeschlagen.`); +process.exit(failures === 0 ? 0 : 1); diff --git a/src/components/leads/LeadModal.tsx b/src/components/leads/LeadModal.tsx index 4a77ae5..fc5346e 100644 --- a/src/components/leads/LeadModal.tsx +++ b/src/components/leads/LeadModal.tsx @@ -1,8 +1,10 @@ -import React, { useMemo, useState } from 'react'; +import React, { useMemo, useRef, useState } from 'react'; import { Modal } from '../ui/Modal'; import { AvatarPicker } from '../ui/AvatarPicker'; import { Avatar, inputClass, labelClass } from './leadUi'; import { connectionsApi, leadsApi } from '../../lib/api'; +import { useAuth } from '../../lib/auth'; +import { compressImageSource } from '../../lib/imageCompress'; import { useToast } from '../Toast'; import { CONNECTION_TYPES, @@ -92,10 +94,27 @@ export const LeadModal: React.FC = ({ onCreated, }) => { const { addToast } = useToast(); - const [form, setForm] = useState(() => toFormState(lead, users[0]?.id ?? null)); + const { user: currentUser } = useAuth(); + /* + * Beim Neuanlegen ist der angemeldete Benutzer zustaendig - das ist in fast + * allen Faellen richtig und laesst sich im Feld darunter aendern. Beim + * Bearbeiten bleibt die gespeicherte Zustaendigkeit unangetastet. + */ + const [form, setForm] = useState(() => + toFormState(lead, currentUser?.id ?? users[0]?.id ?? null) + ); const [tagInput, setTagInput] = useState(''); const [isSaving, setIsSaving] = useState(false); + // LinkedIn-Profilfoto + const [isFetchingPhoto, setIsFetchingPhoto] = useState(false); + const [photoHint, setPhotoHint] = useState(null); + /* + * Merkt sich die Adressen, zu denen schon automatisch geholt wurde. Ohne das + * wuerde jeder Klick aus dem Feld heraus einen weiteren Versuch ausloesen. + */ + const triedProfiles = useRef>(new Set()); + // Verbindungen dieses Kontakts const [newConnectionId, setNewConnectionId] = useState(''); const [newConnectionType, setNewConnectionType] = useState('Netzwerk'); @@ -134,6 +153,45 @@ export const LeadModal: React.FC = ({ setTagInput(''); }; + /** + * Holt das oeffentliche Profilfoto zur hinterlegten LinkedIn-Adresse und legt + * es als Kontaktfoto ins Formular. Automatisch geschieht das nur einmal je + * Adresse und nur, solange noch kein Foto gesetzt ist - ein bewusst + * hochgeladenes Bild wird nie ueberschrieben. + */ + const importLinkedInPhoto = async (profile: string, automatic: boolean) => { + const url = profile.trim(); + if (!url || isFetchingPhoto) return; + + triedProfiles.current.add(url); + setIsFetchingPhoto(true); + setPhotoHint(null); + try { + const { avatar } = await leadsApi.linkedinPhoto(url); + // Derselbe Weg wie beim Upload: quadratisch zuschneiden und verkleinern. + const compressed = await compressImageSource(avatar); + set('avatar', compressed.dataUrl); + addToast('Profilfoto von LinkedIn übernommen.', 'success'); + } catch (err) { + const message = + err instanceof Error ? err.message : 'Das Profilfoto konnte nicht geholt werden.'; + setPhotoHint(message); + // Beim automatischen Versuch reicht der Hinweis unter dem Feld; nur der + // ausdrueckliche Klick bekommt eine Meldung. + if (!automatic) addToast(message, 'error'); + } finally { + setIsFetchingPhoto(false); + } + }; + + /** Beim Verlassen des Feldes: einmalig versuchen, wenn noch kein Foto da ist. */ + const handleLinkedInBlur = () => { + const url = form.linkedin.trim(); + if (!url || form.avatar || triedProfiles.current.has(url)) return; + if (!url.includes('linkedin.com/')) return; + void importLinkedInPhoto(url, true); + }; + const handleSubmit = async (event: React.FormEvent) => { event.preventDefault(); if (!form.first_name.trim() || !form.last_name.trim()) { @@ -305,13 +363,30 @@ export const LeadModal: React.FC = ({
- set('linkedin', e.target.value)} - className={inputClass} - /> +
+ set('linkedin', e.target.value)} + onBlur={handleLinkedInBlur} + className={inputClass} + /> + +
+ {photoHint &&

{photoHint}

}
diff --git a/src/components/leads/LeadNetwork.tsx b/src/components/leads/LeadNetwork.tsx index ee20a77..deabf7e 100644 --- a/src/components/leads/LeadNetwork.tsx +++ b/src/components/leads/LeadNetwork.tsx @@ -340,7 +340,15 @@ export const LeadNetwork: React.FC = ({ return () => element.removeEventListener('wheel', onWheel); }, []); - const panStateRef = useRef<{ pointerId: number; startX: number; startY: number } | null>(null); + const panStateRef = useRef<{ + pointerId: number; + startX: number; + startY: number; + moved: boolean; + } | null>(null); + + /** Ab dieser Verschiebung gilt die Geste als Ziehen und nicht mehr als Klick. */ + const CLICK_TOLERANCE = 4; const handleBackgroundPointerDown = (event: React.PointerEvent) => { if (event.button !== 0) return; @@ -348,6 +356,7 @@ export const LeadNetwork: React.FC = ({ pointerId: event.pointerId, startX: event.clientX - transform.x, startY: event.clientY - transform.y, + moved: false, }; (event.currentTarget as Element).setPointerCapture(event.pointerId); }; @@ -355,10 +364,33 @@ export const LeadNetwork: React.FC = ({ const handleBackgroundPointerMove = (event: React.PointerEvent) => { const pan = panStateRef.current; if (!pan || pan.pointerId !== event.pointerId) return; - setTransform((prev) => ({ ...prev, x: event.clientX - pan.startX, y: event.clientY - pan.startY })); + const x = event.clientX - pan.startX; + const y = event.clientY - pan.startY; + if (Math.abs(x - transform.x) > CLICK_TOLERANCE || Math.abs(y - transform.y) > CLICK_TOLERANCE) { + pan.moved = true; + } + setTransform((prev) => ({ ...prev, x, y })); }; + /* + * Ein Klick ins Leere hebt die Auswahl auf. Damit sieht das Netz wieder so + * aus wie beim Wechsel auf die Ansicht: nichts ist ausgegraut. Ein Ziehen + * (Verschieben der Ansicht) darf die Auswahl dagegen nicht verwerfen, + * deshalb der Vergleich mit der Toleranz. Knoten stoppen das Ereignis + * bereits beim pointerdown, ihre Klicks landen hier gar nicht erst. + */ const handleBackgroundPointerUp = (event: React.PointerEvent) => { + const pan = panStateRef.current; + if (pan?.pointerId !== event.pointerId) return; + panStateRef.current = null; + if (pan.moved) return; + setSelectedId(null); + setHoveredId(null); + if (linkMode) setLinkSourceId(null); + }; + + /** Verlaesst der Zeiger die Flaeche, endet die Geste ohne Auswirkung auf die Auswahl. */ + const handleBackgroundPointerLeave = (event: React.PointerEvent) => { if (panStateRef.current?.pointerId === event.pointerId) panStateRef.current = null; }; @@ -654,7 +686,7 @@ export const LeadNetwork: React.FC = ({ handleNodePointerMove(event); }} onPointerUp={handleBackgroundPointerUp} - onPointerLeave={handleBackgroundPointerUp} + onPointerLeave={handleBackgroundPointerLeave} className="h-full w-full cursor-grab active:cursor-grabbing" style={{ touchAction: 'none' }} > diff --git a/src/lib/api.ts b/src/lib/api.ts index 3cf2523..1faca93 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -170,6 +170,17 @@ export const leadsApi = { request(`/leads/${id}`, { method: 'PATCH', body: JSON.stringify(input) }), remove: (id: number) => request<{ ok: true }>(`/leads/${id}`, { method: 'DELETE' }), + /** + * Profilfoto zu einer LinkedIn-Adresse holen. Der Server liest das + * oeffentliche Vorschaubild des Profils und liefert es als Data-URL; das + * Speichern uebernimmt danach der normale Kontakt-Aufruf. + */ + linkedinPhoto: (linkedin: string) => + request<{ avatar: string; bytes: number; source_url: string; profile_url: string }>( + '/leads/linkedin-photo', + { method: 'POST', body: JSON.stringify({ linkedin }) } + ), + /** Herkunftsangaben aller Eintraege aus "Aus Mails", nach Lead-ID. */ mailPoolSources: () => request>('/leads/mail-pool/sources'), diff --git a/src/lib/imageCompress.ts b/src/lib/imageCompress.ts index 49f760a..4987533 100644 --- a/src/lib/imageCompress.ts +++ b/src/lib/imageCompress.ts @@ -27,22 +27,25 @@ export interface CompressedImage { type: string; } -function loadImage(file: File): Promise { +function loadImageFromUrl(url: string, revoke: boolean): Promise { return new Promise((resolve, reject) => { - const url = URL.createObjectURL(file); const image = new Image(); image.onload = () => { - URL.revokeObjectURL(url); + if (revoke) URL.revokeObjectURL(url); resolve(image); }; image.onerror = () => { - URL.revokeObjectURL(url); + if (revoke) URL.revokeObjectURL(url); reject(new Error('Das Bild konnte nicht gelesen werden.')); }; image.src = url; }); } +function loadImage(file: File): Promise { + return loadImageFromUrl(URL.createObjectURL(file), true); +} + /** Laenge einer Data-URL in Bytes (Base64 traegt 4 Zeichen pro 3 Bytes). */ function dataUrlBytes(dataUrl: string): number { const base64 = dataUrl.slice(dataUrl.indexOf(',') + 1); @@ -59,7 +62,21 @@ export async function compressImage(file: File): Promise { throw new Error('Die Datei ist zu groß (max. 25 MB).'); } - const image = await loadImage(file); + return renderToSquare(await loadImage(file), file.size); +} + +/** + * Wie compressImage(), nur fuer ein bereits geladenes Bild als Data-URL - etwa + * das Profilfoto, das der Server von LinkedIn geholt hat. Der Weg ueber den + * Canvas ist derselbe, damit ein importiertes Foto exakt so aussieht wie ein + * hochgeladenes. + */ +export async function compressImageSource(dataUrl: string): Promise { + const image = await loadImageFromUrl(dataUrl, false); + return renderToSquare(image, dataUrlBytes(dataUrl)); +} + +function renderToSquare(image: HTMLImageElement, sourceBytes: number): CompressedImage { const { naturalWidth: width, naturalHeight: height } = image; if (!width || !height) throw new Error('Das Bild hat keine lesbaren Abmessungen.'); @@ -83,7 +100,7 @@ export async function compressImage(file: File): Promise { if (dataUrl.startsWith(`data:${candidate.type}`)) { return { dataUrl, - sourceBytes: file.size, + sourceBytes, resultBytes: dataUrlBytes(dataUrl), type: candidate.type, }; @@ -91,7 +108,7 @@ export async function compressImage(file: File): Promise { } const png = canvas.toDataURL('image/png'); - return { dataUrl: png, sourceBytes: file.size, resultBytes: dataUrlBytes(png), type: 'image/png' }; + return { dataUrl: png, sourceBytes, resultBytes: dataUrlBytes(png), type: 'image/png' }; } /** "2,4 MB" bzw. "18 KB" */