1<!DOCTYPE html> 2<html lang="en"> 3<head> 4 <meta charset="UTF-8"> 5 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 <title>Username Search - OSINT IS NOT A CRIME</title> 7 <meta name="description" content="And OSINT is not a tool, but it helps"> 8 9 <!-- Favicon --> 10 <link rel="icon" type="image/x-icon" href="/static/favicons/favicon.png"> 11 12 <!-- Styles --> 13 <link rel="stylesheet" href="/static/css/iconoir.css"> 14 <link rel="stylesheet" href="/static/css/main.css"> 15 <link rel="stylesheet" href="/static/css/app-layout.css"> 16 <link rel="stylesheet" href="/static/css/dark-theme.css"> 17 <link rel="stylesheet" href="/static/css/theme-minitel.css"> 18 <meta name="theme-color" content="#0b0c0d"> 19 20
20<script> 21 // Posé avant la première peinture : sinon la page s'affiche en sobre puis 22 // saute en Minitel, ce qui est moins un effet rétro qu'un bug. 23 (function () { 24 try { 25 var t = localStorage.getItem('rpuc.theme'); 26 if (t === 'minitel') document.documentElement.setAttribute('data-theme', t); 27 } catch (e) { /* stockage bloqué : on reste en sobre */ } 28 })(); 29 </script>
29 30 31 32<!-- Page-specific styles --> 33<style> 34 .search-container { 35 max-width: 100%; 36 margin: 0 auto; 37 } 38 39 .search-box { 40 display: flex; 41 flex-wrap: wrap; 42 gap: 8px; 43 margin-bottom: 10px; 44 } 45 46 .search-box .search-input { 47 flex: 1 1 260px; 48 /* min-width:auto sur un flex item vaut la largeur intrinsèque de 49 l'input : sans ce 0, la barre débordait sous 400px. */ 50 min-width: 0; 51 max-width: 520px; 52 min-height: 44px; 53 font-size: var(--font-size-md); 54 } 55 56 .search-box .btn-primary { min-height: 44px; padding: 0 20px; } 57 58 /* Sous le formulaire : l'option et le coût, sur une ligne, en retrait. */ 59 .search-meta { 60 display: flex; 61 flex-wrap: wrap; 62 align-items: center; 63 gap: 6px 20px; 64 margin-bottom: 32px; 65 font-size: var(--font-size-xs); 66 color: var(--text-muted); 67 } 68 69 .search-info { color: var(--text-muted); } 70 .search-info a { color: var(--text-secondary); text-decoration: underline; text-underline-offset: 2px; } 71 .search-info a:hover { color: var(--text-primary); } 72 .search-info-sep { margin: 0 4px; color: var(--border-color-strong); } 73 74 .token-warning { 75 background: var(--error-bg); 76 border: 1px solid var(--error-border); 77 border-radius: var(--radius-md); 78 padding: 10px 15px; 79 margin-bottom: 20px; 80 font-size: var(--font-size-sm); 81 display: flex; 82 align-items: center; 83 gap: 10px; 84 color: var(--text-primary); 85 } 86 87 .token-warning a { 88 color: var(--error); 89 font-weight: bold; 90 } 91 92 .no-results { 93 text-align: center; 94 padding: 40px; 95 color: var(--text-muted); 96 } 97 98 #results-container .results-container { 99 height: auto; 100 max-height: none; 101 overflow: visible; 102 } 103 104 /* Avatar hover popover */ 105 .profile-avatar-wrap { 106 position: relative; 107 flex-shrink: 0; 108 margin-right: 12px; 109 } 110 .profile-avatar-wrap .profile-avatar { margin-right: 0; } 111 112 /* Rendered in <body> so the accordion's overflow:hidden can't clip it */ 113 .avatar-popover { 114 position: fixed; 115 top: 0; 116 left: 0; 117 z-index: 10001; 118 display: flex; 119 flex-direction: column; 120 align-items: center; 121 gap: 8px; 122 padding: 8px; 123 background: var(--bg-elevated); 124 border: 1px solid var(--border-color-light); 125 border-radius: var(--radius-lg); 126 box-shadow: 0 12px 40px var(--bg-scrim); 127 opacity: 0; 128 transform: scale(0.9); 129 transform-origin: bottom center; 130 pointer-events: none; 131 visibility: hidden; 132 transition: opacity 0.2s ease, transform 0.2s ease; 133 } 134 .avatar-popover.flip-below { transform-origin: top center; } 135 .avatar-popover.visible { 136 opacity: 1; 137 transform: scale(1); 138 pointer-events: auto; 139 visibility: visible; 140 } 141 .avatar-popover img { 142 width: 160px; height: 160px; 143 object-fit: cover; 144 border-radius: var(--radius-md); 145 } 146 .avatar-popover-actions { 147 display: flex; gap: 4px; 148 } 149 .avatar-action-btn { 150 display: flex; 151 align-items: center; 152 justify-content: center; 153 gap: 4px; 154 padding: 4px 10px; 155 border: none; 156 border-radius: var(--radius-sm); 157 background: var(--tint-medium); 158 color: var(--text-muted); 159 cursor: pointer; 160 font-size: var(--font-size-xs); 161 transition: all 0.15s; 162 } 163 .avatar-action-btn:hover { background: var(--accent); color: var(--text-on-accent); } 164 .avatar-action-btn i { font-size: 0.85rem; } 165 166 .enrich-toggle { 167 display: inline-flex; 168 align-items: center; 169 gap: 7px; 170 cursor: pointer; 171 font-size: var(--font-size-xs); 172 color: var(--text-secondary); 173 } 174 .enrich-toggle input { width: 14px; height: 14px; } 175 .enrich-cost { 176 font-family: var(--font-mono); 177 color: var(--text-muted); 178 } 179 180 /* Cross-site summary */ 181 .search-summary { 182 padding: 18px 20px 12px; 183 margin-bottom: 28px; 184 background: var(--bg-surface); 185 border: 1px solid var(--border-color); 186 border-radius: var(--radius-lg); 187 box-shadow: var(--shadow-sm); 188 } 189 .search-summary-header { 190 display: flex; align-items: baseline; gap: 8px; 191 flex-wrap: wrap; 192 font-size: 0.6875rem; font-weight: 700; 193 text-transform: uppercase; letter-spacing: 0.08em; 194 color: var(--text-secondary); 195 margin-bottom: 16px; 196 } 197 .search-summary-header i { color: var(--accent); font-size: 1rem; align-self: center; } 198 .search-summary-header .summary-sub { 199 margin-left: auto; 200 font-weight: 400; 201 text-transform: none; letter-spacing: 0; 202 font-size: var(--font-size-xs); 203 color: var(--text-muted); 204 } 205 .search-summary-grid { 206 display: grid; 207 grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); 208 gap: 18px 32px; 209 } 210 .summary-facet-label { 211 display: flex; align-items: baseline; gap: 6px; 212 font-size: var(--font-size-xs); 213 font-weight: 500; 214 color: var(--text-secondary); 215 margin-bottom: 8px; 216 } 217 .summary-facet-label .facet-count { 218 color: var(--text-muted); 219 font-family: var(--font-mono); 220 font-size: 0.7rem; 221 } 222 .summary-chips { display: flex; flex-wrap: wrap; gap: 5px; } 223 .summary-chip { 224 display: inline-flex; align-items: baseline; gap: 5px; 225 max-width: 100%; 226 padding: 3px 9px; 227 border: 1px solid var(--border-color-light); 228 border-radius: var(--radius-sm); 229 background: var(--bg-surface-hover); 230 color: var(--text-primary); 231 font-size: var(--font-size-xs); 232 cursor: pointer; 233 transition: border-color 0.15s, background 0.15s; 234 } 235 .summary-chip:hover { 236 background: var(--bg-surface-active); 237 border-color: var(--border-color-strong); 238 } 239 .summary-chip:focus-visible, 240 .summary-chip a:focus-visible { 241 outline: 2px solid var(--accent); 242 outline-offset: 2px; 243 } 244 .summary-chip .chip-text { 245 overflow: hidden; text-overflow: ellipsis; white-space: nowrap; 246 } 247 .summary-chip .chip-n { 248 flex-shrink: 0; 249 color: var(--text-muted); 250 font-family: var(--font-mono); font-size: 0.72rem; 251 } 252 .summary-chip a { color: inherit; text-decoration: none; } 253 .summary-chip.is-overflow { display: none; } 254 .summary-facet.expanded .summary-chip.is-overflow { display: inline-flex; } 255 .summary-facet.expanded .summary-chip-more { display: none; } 256 .summary-chip-more { 257 background: transparent; 258 border-style: dashed; 259 color: var(--text-secondary); 260 } 261 .summary-chip-accent { 262 background: var(--accent-bg); 263 border-color: var(--accent-border); 264 color: var(--text-primary); 265 } 266 .summary-chip-accent:hover { background: var(--accent-bg); border-color: var(--accent); } 267 268 /* Not found accordion */ 269 /* Collapsible section accordion (generic) */ 270 .section-accordion { 271 grid-column: 1 / -1; 272 margin-top: 8px; 273 } 274 .section-accordion-header { 275 display: flex; align-items: center; gap: 8px; 276 padding: 10px 0; 277 margin-bottom: 14px; 278 border-bottom: 1px solid var(--border-color); 279 cursor: pointer; user-select: none; 280 font-size: 0.6875rem; font-weight: 700; 281 text-transform: uppercase; letter-spacing: 0.08em; 282 transition: color 0.15s; 283 color: var(--text-secondary); 284 } 285 .section-accordion-header:hover { color: var(--text-primary); } 286 .section-accordion-header .sa-chevron { color: var(--accent); } 287 .section-accordion-header:focus-visible { 288 outline: 2px solid var(--accent); 289 outline-offset: -2px; 290 } 291 /* Volontairement pas en flex : les noeuds de texte autour du compteur 292 redeviendraient des items et récolteraient le gap du parent. */ 293 .section-accordion-header .sa-title { display: inline; min-width: 0; } 294 .section-accordion-header .sa-title i { vertical-align: -0.1em; margin-right: 4px; } 295 .section-accordion-header .sa-chevron { 296 display: inline-block; width: 12px; height: 12px; 297 flex-shrink: 0; transition: transform 0.25s ease; position: relative; 298 } 299 .section-accordion-header .sa-chevron::before { 300 content: ''; position: absolute; top: 2px; left: 3px; 301 width: 5px; height: 5px; 302 border-right: 2px solid currentColor; border-bottom: 2px solid currentColor; 303 transform: rotate(-45deg); 304 } 305 .section-accordion.open .sa-chevron { transform: rotate(90deg); } 306 .section-accordion-body { 307 max-height: 0; overflow: hidden; 308 transition: max-height 0.35s ease; 309 } 310 .section-accordion.open .section-accordion-body { 311 max-height: 10000px; 312 } 313 .section-accordion-body-inner { 314 padding: 0 0 8px; 315 } 316 317 /* Legacy aliases for not-found */ 318 .not-found-accordion { border: 1px solid var(--border-color); border-radius: var(--radius-md); background: var(--tint-neutral); overflow: hidden; } 319 .not-found-accordion-header { display: flex; align-items: center; gap: 10px; padding: 12px 16px; cursor: pointer; user-select: none; color: var(--text-muted); font-weight: 600; font-size: var(--font-size-md); transition: background 0.15s; } 320 .not-found-accordion-header:hover { background: var(--tint-medium); } 321 .not-found-accordion-header .nf-chevron { display: inline-block; width: 16px; height: 16px; flex-shrink: 0; transition: transform 0.25s ease; position: relative; } 322 .not-found-accordion-header .nf-chevron::before { content: ''; position: absolute; top: 4px; left: 4px; width: 7px; height: 7px; border-right: 2px solid var(--text-muted); border-bottom: 2px solid var(--text-muted); transform: rotate(-45deg); } 323 .not-found-accordion.open .nf-chevron { transform: rotate(90deg); } 324 .not-found-accordion-body { max-height: 0; overflow: hidden; transition: max-height 0.35s ease; } 325 .not-found-accordion.open .not-found-accordion-body { max-height: 5000px; } 326 .not-found-accordion-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 16px; padding: 0 16px 16px; } 327</style> 328 329 330 331</head> 332<body> 333 <div class="app-container"> 334 335 <!-- ===== HEADER ===== --> 336 <header class="app-header"> 337 <button type="button" class="app-nav-toggle" id="app-nav-toggle" 338 aria-label="Open navigation" aria-expanded="false" aria-controls="app-sidebar"> 339 <i class="iconoir-menu"></i> 340 </button> 341 <a href="/app/" class="app-header-logo"> 342 <svg class="app-header-logo-svg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 219.3 106.1"><g><path d="M17.1,40.2c0-3.4.6-6.5,1.7-9.3,1.2-2.8,2.8-5.2,4.8-7.1,2.1-2,4.5-3.5,7.3-4.5,2.8-1,5.9-1.6,9.3-1.6s6.4.5,9.3,1.6c2.8,1,5.3,2.6,7.3,4.5,2.1,2,3.7,4.3,4.8,7.1s1.7,5.9,1.7,9.3-.6,6.5-1.7,9.3-2.8,5.2-4.8,7.1c-2.1,2-4.5,3.5-7.3,4.5-2.8,1-5.9,1.6-9.3,1.6s-6.4-.5-9.3-1.6c-2.8-1-5.3-2.6-7.3-4.5-2.1-2-3.7-4.3-4.8-7.1-1.1-2.8-1.7-5.9-1.7-9.3ZM26.9,40.2c0,2,.3,3.9.9,5.5.6,1.7,1.5,3.1,2.7,4.4,1.2,1.2,2.6,2.2,4.2,2.9,1.6.7,3.5,1,5.5,1s3.8-.3,5.5-1c1.6-.7,3-1.6,4.2-2.9,1.2-1.2,2.1-2.7,2.7-4.4.6-1.7.9-3.5.9-5.5s-.3-3.8-.9-5.5c-.6-1.7-1.5-3.2-2.7-4.4-1.2-1.2-2.6-2.2-4.2-2.9-1.6-.7-3.5-1-5.5-1s-3.8.3-5.5,1c-1.6.7-3,1.6-4.2,2.9-1.2,1.2-2.1,2.7-2.7,4.4-.6,1.7-.9,3.5-.9,5.5Z" fill="currentColor"/><path d="M91,29.2c-.8-1-1.8-1.7-3.1-2.1-1.3-.5-2.6-.7-3.7-.7s-1.4,0-2.1.2c-.7.2-1.4.4-2.1.8-.6.3-1.2.8-1.6,1.4-.4.6-.6,1.3-.6,2.1,0,1.3.5,2.3,1.5,3,1,.7,2.2,1.3,3.7,1.8,1.5.5,3.1,1,4.8,1.5s3.3,1.2,4.8,2.1c1.5.9,2.7,2.1,3.7,3.6,1,1.5,1.5,3.5,1.5,6s-.4,4.5-1.3,6.2c-.9,1.8-2.1,3.2-3.6,4.4-1.5,1.2-3.3,2-5.3,2.6-2,.6-4.1.8-6.3.8s-5.4-.4-7.7-1.3c-2.4-.8-4.6-2.2-6.7-4.1l6.7-7.4c1,1.3,2.2,2.3,3.7,3,1.5.7,3,1.1,4.6,1.1s1.5,0,2.3-.3c.8-.2,1.5-.5,2.1-.8.6-.4,1.1-.8,1.5-1.4.4-.6.6-1.2.6-2,0-1.3-.5-2.3-1.5-3.1-1-.7-2.2-1.4-3.7-1.9-1.5-.5-3.1-1-4.8-1.6-1.7-.5-3.4-1.2-4.8-2.1-1.5-.9-2.7-2.1-3.7-3.5-1-1.5-1.5-3.4-1.5-5.8s.5-4.3,1.4-6.1c.9-1.7,2.1-3.2,3.6-4.4,1.5-1.2,3.3-2,5.2-2.6,2-.6,4-.9,6.1-.9s4.8.3,7,1c2.3.7,4.3,1.8,6.1,3.5l-6.5,7.1Z" fill="currentColor"/><path d="M104.4,18.8h9.4v42.9h-9.4V18.8Z" fill="currentColor"/><path d="M122.4,18.8h12.8l18.2,29.8h.1v-29.8h9.4v42.9h-12.3l-18.7-30.5h-.1v30.5h-9.4V18.8Z" fill="currentColor"/><path d="M180.1,27.1h-12.2v-8.4h33.9v8.4h-12.2v34.5h-9.4V27.1Z" fill="currentColor"/></g><g><path d="M0,0v106.1h219.3V0H0ZM209.3,10v59.2H10V10h199.3ZM14.5,94.2v-17.6h3.1v17.6h-3.1ZM28.6,88c-.4-.3-.9-.6-1.5-.9-.6-.2-1.3-.5-2-.7-.7-.2-1.4-.5-2-.9-.6-.4-1.1-.9-1.5-1.5-.4-.6-.6-1.5-.6-2.5s.2-1.7.6-2.4.9-1.2,1.5-1.7c.6-.4,1.3-.8,2.1-1,.8-.2,1.6-.3,2.4-.3s1.8.1,2.7.4c.9.3,1.7.7,2.4,1.4l-2.3,2.4c-.3-.5-.8-.8-1.3-1.1-.6-.2-1.1-.3-1.8-.3s-.7,0-1.1.1c-.3,0-.6.2-.9.4-.3.2-.5.4-.7.7-.2.3-.2.6-.2,1,0,.6.2,1.1.6,1.4.4.3.9.6,1.5.8.6.2,1.3.5,2,.7.7.2,1.4.5,2,.9.6.4,1.1.9,1.5,1.5.4.6.6,1.5.6,2.5s-.2,1.8-.5,2.5-.8,1.3-1.4,1.8c-.6.5-1.3.8-2.1,1-.8.2-1.6.3-2.5.3s-2.1-.2-3.2-.5-1.9-1-2.6-1.8l2.4-2.3c.4.6.9,1,1.5,1.4.6.3,1.3.5,2,.5s.7,0,1.1-.1.7-.2,1-.4c.3-.2.5-.5.7-.8.2-.3.3-.7.3-1.
3421,0-.6-.2-1.1-.6-1.5ZM43.2,94.2v-17.6h4.2l8.5,13.1h0v-13.1h3.1v17.6h-4l-8.7-13.5h0v13.5h-3.1ZM62,85.5c0-1.4.2-2.7.7-3.8.5-1.2,1.1-2.1,1.9-2.9s1.8-1.4,2.9-1.9c1.1-.4,2.4-.7,3.7-.7,1.4,0,2.6.2,3.8.6s2.1,1,3,1.9,1.5,1.8,1.9,2.9c.5,1.1.7,2.4.7,3.8s-.2,2.6-.7,3.8c-.5,1.1-1.1,2.1-1.9,2.9-.8.8-1.8,1.4-3,1.9-1.1.5-2.4.7-3.8.7-1.4,0-2.6-.2-3.7-.7-1.1-.4-2.1-1.1-2.9-1.9-.8-.8-1.5-1.8-1.9-2.9-.5-1.1-.7-2.4-.7-3.8ZM81.9,79.5v-2.8h13.9v2.8h-5.4v14.8h-3.1v-14.8h-5.4ZM103.3,94.2l7.7-17.6h2.7l7.6,17.6h-3.6l-1.6-4h-7.6l-1.6,4h-3.5ZM133.4,87.9c.3.8.7,1.5,1.2,2.1.5.6,1.2,1,1.9,1.4s1.6.5,2.5.5,1.4-.2,2.1-.5c.7-.3,1.3-.9,1.9-1.6l2.6,1.8c-.8,1.1-1.8,1.9-2.9,2.4-1.1.5-2.4.7-3.7.7s-2.6-.2-3.7-.7c-1.1-.4-2.1-1.1-2.9-1.9-.8-.8-1.5-1.8-1.9-2.9-.5-1.1-.7-2.4-.7-3.8s.2-2.7.7-3.8c.5-1.2,1.1-2.1,1.9-2.9s1.8-1.4,2.9-1.9c1.1-.4,2.4-.7,3.7-.7s2.3.2,3.3.6c1,.4,2,1.1,2.8,2.2l-2.4,1.8c-.6-.7-1.3-1.2-1.9-1.4-.6-.2-1.2-.3-1.8-.3-.9,0-1.7.2-2.5.5s-1.4.8-1.9,1.3c-.5.6-.9,1.2-1.2,2s-.4,1.6-.4,2.5.1,1.8.4,2.6ZM160.9,94.2h-3.8l-4.1-7.5h-2.3v7.5h-3.1v-17.6h6.1c.8,0,1.7,0,2.4.2.8.2,1.5.4,2.1.8.6.4,1.1.9,1.5,1.5.4.6.5,1.5.5,2.5s-.4,2.3-1.1,3.2c-.7.9-1.7,1.4-3,1.6l4.7,7.8ZM163.3,94.2v-17.6h3.1v17.6h-3.1ZM186.2,79.6l-5.3,14.6h-2.2l-5.3-14.6h0v14.6h-3v-17.6h4.8l4.7,12.3,4.8-12.3h4.7v17.6h-3v-14.6h0ZM204.5,83.8v2.8h-8.1v4.8h9v2.8h-12.1v-17.6h11.7v2.8h-8.5v4.3h8.1Z" fill="currentColor"/><polygon points="112.3 80.4 109.5 87.5 115 87.5 112.3 80.4" fill="currentColor"/><path d="M67,90c.5.6,1.2,1,1.9,1.4s1.6.5,2.5.5,1.7-.2,2.5-.5,1.4-.8,1.9-1.4c.5-.6.9-1.3,1.2-2.1s.4-1.7.4-2.6-.1-1.7-.4-2.5-.7-1.4-1.2-2c-.5-.6-1.2-1-1.9-1.3s-1.6-.5-2.5-.5-1.7.2-2.5.5-1.4.8-1.9,1.3c-.5.6-.9,1.2-1.2,2s-.4,1.6-.4,2.5.1,1.8.4,2.6.7,1.5,1.2,2.1Z" fill="currentColor"/><path d="M155.9,79.7c-.3-.2-.7-.3-1.2-.3s-.8,0-1.2,0h-2.7v4.8h2.4c.4,0,.9,0,1.3,0,.4,0,.9-.1,1.2-.3.4-.2.7-.4.9-.7.2-.3.3-.8.3-1.4s-.1-.9-.3-1.2c-.2-.3-.5-.6-.8-.7Z" fill="currentColor"/></g></svg> 343 <span class="app-header-logo-text">OSINT IS NOT A CRIME</span> 344 </a> 345 346 347 <nav class="app-header-nav"> 348 349 350 <a href="/app/" class="app-header-link " title="Home"> 351 <i class="iconoir-home"></i> 352 <span class="app-header-link-label">Home</span> 353 </a> 354 355 356 357 358 359 <a href="/app/news" class="app-header-link " title="News"> 360 <i class="iconoir-journal"></i> 361 <span class="app-header-link-label">News</span> 362 </a> 363 364 365 366 <a href="/app/help" class="app-header-link " title="Help"> 367 <i class="iconoir-chat-bubble-question"></i> 368 <span class="app-header-link-label">Help</span> 369 </a> 370 371 372 </nav> 373 374 <div class="app-header-spacer"></div> 375 376 377 378 <button type="button" class="theme-switch" id="theme-switch" 379 role="switch" aria-checked="false" aria-label="Minitel mode" 380 title="Mode Minitel"> 381 <span class="theme-switch-track"><span class="theme-switch-knob"></span></span> 382 <span class="theme-switch-label">3615</span> 383 </button> 384 385 <div class="app-header-user"> 386 387 <a href="/app/login" class="app-header-icon-link" title="Login"> 388 <i class="iconoir-log-in"></i> 389 </a> 390 <a href="/app/register" class="btn btn-primary btn-sm">Register</a> 391 392 </div> 393 </header> 394 395 <!-- ===== MAIN ===== --> 396 <main class="app-main"> 397 398 <!-- Sidebar â text nav, no icons --> 399 <aside class="app-sidebar" id="app-sidebar"> 400 <nav class="sidebar-nav"> 401 <div class="sidebar-section"> 402 <div class="sidebar-section-label">Tools</div> 403 404 <a href="/app/search" 405 class="sidebar-link active">
406 <span class="sidebar-link-name">Rhino UserSearch</span> 407 408 <span class="sidebar-link-badge">1 tk</span> 409 410 </a> 411 412 <a href="/app/watchlist" 413 class="sidebar-link "> 414 <span class="sidebar-link-name">Watchlist</span> 415 416 <span class="sidebar-link-badge">1 tk</span> 417 418 </a> 419 420 <a href="/app/rhinoscraper" 421 class="sidebar-link "> 422 <span class="sidebar-link-name">RhinoScraper</span> 423 424 <span class="sidebar-link-badge">5 tk</span> 425 426 </a> 427 428 <a href="/app/geoloc" 429 class="sidebar-link "> 430 <span class="sidebar-link-name">Geoloc</span> 431 432 <span class="sidebar-link-badge">10 tk</span> 433 434 </a> 435 436 <a href="/app/exif" 437 class="sidebar-link "> 438 <span class="sidebar-link-name">EXIF</span> 439 440 <span class="sidebar-link-badge free">Free</span> 441 442 </a> 443 444 <a href="/app/opendoc" 445 class="sidebar-link "> 446 <span class="sidebar-link-name">OpenDoc</span> 447 448 <span class="sidebar-link-badge free">Free</span> 449 450 </a> 451 452 </div> 453 <div class="sidebar-section sidebar-section-mobile"> 454 <div class="sidebar-section-label">Menu</div> 455 456 457 <a href="/app/" class="sidebar-link "> 458 <span class="sidebar-link-name">Home</span> 459 </a> 460 461 462 463 464 465 <a href="/app/news" class="sidebar-link "> 466 <span class="sidebar-link-name">News</span> 467 </a> 468 469 470 471 <a href="/app/help" class="sidebar-link "> 472 <span class="sidebar-link-name">Help</span> 473 </a> 474 475 476 </div> 477 </nav> 478 </aside> 479 <div class="app-sidebar-scrim" id="app-sidebar-scrim" hidden></div> 480 481 <!-- Content --> 482 <section class="app-content"> 483 484<div class="app-window"> 485 <div class="app-window-titlebar"> 486 <span class="app-window-title">Username Search</span> 487 </div> 488 <div class="app-window-content"> 489 <div class="search-container"> 490 491 <div class="tab-bar" role="tablist"> 492 <button type="button" class="tab-btn active" role="tab" aria-selected="true" 493 data-tab="main">Search</button> 494 <button type="button" class="tab-btn" role="tab" aria-selected="false" 495 data-tab="saved"> 496 Saved locally <span class="tab-count" data-vault-count></span> 497 </button> 498 </div> 499 500 <div class="tab-panel" data-tab-panel="main" role="tabpanel"> 501 502 <!-- Token Warning (if low balance) --> 503 504 505 <!-- Search Form --> 506 <form id="search-form" class="search-box" onsubmit="return startSearch(event)"> 507 <input type="text" 508 id="username-input" 509 class="app-input search-input" 510 placeholder="Username to look up" 511 required 512 autocomplete="off" 513 minlength="3" 514 maxlength="50"> 515 <button type="submit" class="btn btn-primary" id="search-btn"> 516 Search 517 </button> 518 <button type="button" class="btn btn-ghost" id="load-test-btn" style="display:none" onclick="loadTestJSON()"> 519 <i class="iconoir-upload"></i> Load JSON 520 </button> 521 <input type="file" id="test-json-input" accept=".json" style="display:none"> 522 </form> 523 524 <div class="search-meta"> 525 <label id="enrich-toggle" class="enrich-toggle" title="Find additional mentions, profiles, and email references across the web"> 526 <input type="checkbox" id="enrich-checkbox">
527 <span>Enrich with web traces</span> 528 <span class="enrich-cost">+1 tk</span> 529 </label> 530 <span class="search-info"> 531 1 token per search 532 <span class="search-info-sep">·</span> 533 <a href="/app/sites">221 platforms</a> 534 <span class="search-info-sep">·</span> 535 Balance login required 536 </span> 537 </div> 538 539 <!-- Progress (same structure as index.html) --> 540 <div id="progress-container" class="search-progress"> 541 <div class="search-progress-bar"> 542 <div id="search-progress-bar" class="search-progress-fill"></div> 543 </div> 544 <p id="search-progress-message" class="search-progress-message">Initializing search...</p> 545 </div> 546 547 <!-- Cross-site summary (built once the search completes) --> 548 <div id="search-summary" class="search-summary" style="display:none;"></div> 549 550 <!-- Results --> 551 <div id="results-container" style="display: none;"> 552 <div class="results-header"> 553 <div> 554 <h2 class="results-title" id="results-title">Results</h2> 555 <p class="results-timestamp" id="results-timestamp"></p> 556 </div> 557 <div class="results-header-actions"> 558 <div id="export-buttons"> 559 <button class="btn btn-export" onclick="exportJSON()"><i class="iconoir-code"></i> JSON</button> 560 <button class="btn btn-export" onclick="exportPDF()"><i class="iconoir-folder"></i> PDF</button> 561 <button class="btn btn-export" onclick="exportZeroneurone()" title="Export pour Zeroneurone"><i class="iconoir-brain-warning"></i> Zeroneurone</button> 562 <button class="btn btn-export" id="save-local-btn" onclick="saveSearchLocally()" title="Chiffrer et garder dans ce navigateur uniquement"><i class="iconoir-lock"></i> Save locally</button> 563 564 </div> 565 <div class="selection-bar" id="selection-bar" style="display:none;"> 566 <label><input type="checkbox" id="select-all-cb" checked onchange="toggleSelectAll(this.checked)"> Select all</label> 567 <span class="selection-count" id="selection-count"></span> 568 </div> 569 </div> 570 </div> 571 572 <div class="results-container"> 573 <div id="results-cards" class="results-cards"> 574 <!-- Profile cards will be inserted here --> 575 </div> 576 </div> 577 </div> 578 579 <!-- Dork Enrichment Results --> 580 <div id="dork-results-container" style="display:none;margin-top:15px;"></div> 581 582 <!-- No Results --> 583 <div id="no-results" class="no-results" style="display: none;"> 584 <p>No profiles found for this username.</p> 585 </div> 586 587 </div><!-- /tab main --> 588 589 <div class="tab-panel" data-tab-panel="saved" role="tabpanel" hidden></div> 590 591 </div> 592 </div> 593</div> 594 595 </section> 596 597 </main> 598 599 <!-- ===== FOOTER ===== --> 600 <footer class="app-footer"> 601 <span>© OSINT IS NOT A CRIME 2024</span> 602 603 <span class="sep">·</span> 604 <a href="https://manufacture-osint.fr" target="_blank">provided by Manufacture Française d'Osint</a> 605 606 <span class="sep">·</span> 607 <a href="/page/help" >Help & FAQ</a> 608 609 <span class="sep">·</span> 610 <a href="/page/about" >About</a> 611 612 <span class="sep">·</span> 613 <a href="/page/legal" >Legal</a> 614 615 </footer> 616 617 </div> 618 619 <!-- Global alert modal --> 620 <div id="app-alert-modal" class="app-modal-overlay" onclick="if(event.target===this)appModalClose()"> 621 <div class="app-window"> 622 <div class="app-window-titlebar">
623 <span class="app-window-title" id="app-alert-title">Alert</span> 624 </div> 625 <div class="app-window-content"> 626 <div class="app-alert-icon" id="app-alert-icon"></div> 627 <div class="app-alert-message" id="app-alert-message"></div> 628 <div class="app-alert-actions" id="app-alert-actions"> 629 <button class="btn btn-primary btn-sm" onclick="appModalClose()">OK</button> 630 </div> 631 </div> 632 </div> 633 </div> 634 635
635<script> 636 // Tiroir de navigation mobile : sous 640px la sidebar et la nav d'en-tête 637 // sont masquées, ce bouton est le seul accès aux autres outils. 638 (function() { 639 const toggle = document.getElementById('app-nav-toggle'); 640 const sidebar = document.getElementById('app-sidebar'); 641 const scrim = document.getElementById('app-sidebar-scrim'); 642 if (!toggle || !sidebar || !scrim) return; 643 644 function setOpen(open) { 645 sidebar.classList.toggle('open', open); 646 scrim.hidden = !open; 647 toggle.setAttribute('aria-expanded', String(open)); 648 toggle.setAttribute('aria-label', open ? 'Close navigation' : 'Open navigation'); 649 } 650 toggle.addEventListener('click', () => setOpen(!sidebar.classList.contains('open'))); 651 scrim.addEventListener('click', () => setOpen(false)); 652 sidebar.addEventListener('click', (e) => { if (e.target.closest('a')) setOpen(false); }); 653 document.addEventListener('keydown', (e) => { if (e.key === 'Escape') setOpen(false); }); 654 window.addEventListener('resize', () => { if (window.innerWidth > 640) setOpen(false); }); 655 })(); 656 657 // Bascule de thème. Le seul état persisté est le nom du thème. 658 (function () { 659 const btn = document.getElementById('theme-switch'); 660 if (!btn) return; 661 const root = document.documentElement; 662 663 function apply(theme, persist) { 664 const on = theme === 'minitel'; 665 if (on) root.setAttribute('data-theme', 'minitel'); 666 else root.removeAttribute('data-theme'); 667 btn.setAttribute('aria-checked', String(on)); 668 btn.classList.toggle('is-on', on); 669 if (persist) { 670 try { localStorage.setItem('rpuc.theme', on ? 'minitel' : 'sober'); } 671 catch (e) { /* stockage bloqué : la bascule reste valable pour la session */ } 672 } 673 } 674 675 let current = 'sober'; 676 try { current = localStorage.getItem('rpuc.theme') || 'sober'; } catch (e) { /* ignore */ } 677 apply(current, false); 678 679 btn.addEventListener('click', function () { 680 apply(root.getAttribute('data-theme') === 'minitel' ? 'sober' : 'minitel', true); 681 }); 682 })(); 683 684 const _appAlertIcons = {error:'â',success:'â',warning:'â ',info:'â¹'}; 685 const _appAlertTitles = {error:'Error',success:'Success',warning:'Warning',info:'Information'}; 686 function appModal(message, type) { 687 type = type || 'info'; 688 const modal = document.getElementById('app-alert-modal'); 689 const icon = document.getElementById('app-alert-icon'); 690 const msg = document.getElementById('app-alert-message'); 691 const title = document.getElementById('app-alert-title'); 692 const actions = document.getElementById('app-alert-actions'); 693 title.textContent = _appAlertTitles[type] || 'Alert'; 694 icon.textContent = _appAlertIcons[type] || 'â¹'; 695 icon.className = 'app-alert-icon ' + type; 696 msg.textContent = message; 697 actions.innerHTML = '<button class="btn btn-primary btn-sm" onclick="appModalClose()">OK</button>'; 698 modal.classList.add('active'); 699 } 700 function appConfirm(message, onConfirm, type) { 701 type = type || 'warning'; 702 const modal = document.getElementById('app-alert-modal'); 703 const icon = document.getElementById('app-alert-icon'); 704 const msg = document.getElementById('app-alert-message'); 705 const title = document.getElementById('app-alert-title'); 706 const actions = document.getElementById('app-alert-actions'); 707 title.textContent = _appAlertTitles[type] || 'Confirm'; 708 icon.textContent = _appAlertIcons[type] || 'â '; 709 icon.className = 'app-alert-icon ' + type; 710 msg.textContent = message; 711 actions.innerHTML = '<button class="btn btn-outline btn-sm" onclick="appModalClose()">Cancel</button>' + 712 '<button class="btn btn-primary btn-sm" id="app-confirm-btn">OK</button>'; 713 document.getElementById('app-confirm-btn').onclick = function() { appModalClose(); onConfirm(); }; 714 modal.classList.add('active'); 715 } 716 function appModalClose() { 717 document.getElementById('app-alert-modal').classList.remove('active'); 718 } 719 document.addEventListener('keydown', function(e) { 720 if (e.key === 'Escape') appModalClose(); 721 }); 722 </script>
722 723 724
724<script src="/static/js/socket.io.min.js"></script>
724 725 726<!-- jsPDF for PDF export -->
727<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
vendor: 1 bytes, line 727
727
728<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf-autotable/3.5.31/jspdf.plugin.autotable.min.js"></script>
728 729<!-- Zeroneurone export -->
730<script src="https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js"></script>
vendor: 1 bytes, line 730
730
731<script src="/static/js/zeroneurone-export.js"></script>
731 732<!-- Coffre local chiffré : aucun appel réseau, voir l'en-tête du fichier -->
733<script src="/static/js/local-vault.js"></script>
vendor: 1 bytes, line 733
733
734<script src="/static/js/vault-ui.js"></script>
734 735
736<script> 737 // Browser ID management 738 function generateBrowserId() { 739 const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; 740 let id = ''; 741 for (let i = 0; i < 16; i++) { 742 id += chars.charAt(Math.floor(Math.random() * chars.length)); 743 } 744 return id; 745 } 746 747 function getBrowserId() { 748 const cookies = document.cookie.split(';'); 749 for (let i = 0; i < cookies.length; i++) { 750 const cookie = cookies[i].trim(); 751 if (cookie.startsWith('rpuc_browser_id=')) { 752 return cookie.substring('rpuc_browser_id='.length); 753 } 754 } 755 const newId = generateBrowserId(); 756 const expiry = new Date(); 757 expiry.setDate(expiry.getDate() + 30); 758 document.cookie = `rpuc_browser_id=${newId}; expires=${expiry.toUTCString()}; path=/; SameSite=Lax`; 759 return newId; 760 } 761 762 // Socket.IO connection 763 const socket = io({ 764 reconnection: true, 765 reconnectionAttempts: Infinity, 766 reconnectionDelay: 1000, 767 reconnectionDelayMax: 5000, 768 timeout: 180000, 769 pingTimeout: 60000, 770 pingInterval: 25000 771 }); 772 773 let sessionToken = ''; 774 let isSearching = false; 775 let currentUsername = ''; 776 let searchResults = {}; 777 let resultsCount = 0; 778 // Affichage live : sites déjà rendus + sections créées à la volée 779 let renderedSites = new Set(); 780 let incrementalSections = { multi: null, single: null }; 781 let lastBlockedSites = []; 782 let lastNotFoundSites = []; 783 let lastDorkData = null; 784 785 // Les booléens bruts s'affichaient « false », et les grands nombres se 786 // lisaient mal (142883). La valeur brute reste dans data-full pour la copie. 787 function formatStatValue(value) { 788 if (typeof value === 'boolean') return value ? 'yes' : 'no'; 789 if (value === 'true') return 'yes'; 790 if (value === 'false') return 'no'; 791 if (typeof value === 'number' && Number.isFinite(value) && Math.abs(value) >= 10000) { 792 return value.toLocaleString('en-US').replace(/,/g, '\u202f'); 793 } 794 return value; 795 } 796 797 // HTML attribute escaping for safe data-* attributes 798 function escAttr(str) { 799 return String(str).replace(/&/g,'&').replace(/"/g,'"').replace(/</g,'<').replace(/>/g,'>'); 800 } 801 802 // Liens rel="me" : identités que la personne déclare elle-même sur son profil 803 // (convention IndieWeb). Affichés à part des stats, ce sont des pivots d'enquête. 804 function buildRelMeHtml(...sources) { 805 const links = []; 806 const seen = new Set(); 807 for (const src of sources) { 808 if (!src || !Array.isArray(src.rel_me_links)) continue; 809 for (const url of src.rel_me_links) { 810 const key = String(url).replace(/\/+$/, '').toLowerCase(); 811 if (seen.has(key)) continue; 812 seen.add(key); 813 links.push(String(url)); 814 } 815 } 816 if (!links.length) return ''; 817 // Pas de troncature en JS : le CSS s'en charge (text-overflow), ce qui 818 // s'adapte à la largeur réelle de la carte au lieu de couper à l'aveugle. 819 const items = links.map(url => { 820 const label = url.replace(/^https?:\/\//, '').replace(/\/$/, ''); 821 return `<a href="${escAttr(url)}" target="_blank" rel="noopener noreferrer" class="copyable" title="${escAttr(url)}" data-full="${escAttr(url)}">${escAttr(label)}</a>`; 822 }).join(''); 823 return `<div class="profile-relme"> 824 <div class="profile-relme-label" title='rel="me" â identités déclarées par la personne elle-même'>linked identities <span class="profile-relme-count">${links.length}</span></div> 825 <div class="profile-relme-links">${items}</div> 826 </div>`; 827 } 828 829 socket.on('connect', function() { 830 console.log('Connected to server'); 831 const browserId = getBrowserId(); 832 socket.emit('register_browser', { browser_id: browserId }); 833 }); 834 835 socket.on('session_token', function(data) { 836 sessionToken = data.token; 837 document.cookie = `jwt_token=${sessionToken}; path=/; max-age=3600; SameSite=Strict`; 838 }); 839 840 socket.on('disconnect', function(reason) { 841 console.log('Disconnected:', reason); 842 if (isSearching) { 843 isSearching = false;
844 searchError('Connection lost during search. Please try again.'); 845 } 846 }); 847 848 // Handle token errors 849 socket.on('search_error', function(data) { 850 console.log('Search error:', data); 851 isSearching = false; 852 hideProgress(); 853 document.getElementById('search-btn').disabled = false; 854 855 if (data.login_required) { 856 appConfirm('Login required to search. Cost: ' + data.token_cost + ' token(s). Would you like to login now?', function() { 857 window.location.href = '/app/login?next=' + encodeURIComponent(window.location.pathname); 858 }); 859 } else if (data.insufficient_tokens) { 860 appConfirm('Insufficient tokens. Required: ' + data.required + ', Available: ' + data.balance + '. Would you like to buy more tokens?', function() { 861 window.location.href = '/app/account/tokens'; 862 }); 863 } else { 864 appModal('Error: ' + data.error, 'error'); 865 } 866 }); 867 868 // Handle token balance updates 869 socket.on('token_update', function(data) { 870 console.log('Token update:', data); 871 // Update displayed balance 872 const balanceText = document.querySelector('.search-info'); 873 if (balanceText && data.tokens_balance !== undefined) { 874 balanceText.innerHTML = `1 token per search 875 <span class="search-info-sep">·</span> 876 <a href="/app/sites">221 platforms</a> 877 <span class="search-info-sep">·</span> 878 Balance ${data.tokens_balance}`; 879 } 880 }); 881 882 // Handle search output from terminal_output event 883 socket.on('terminal_output', function(data) { 884 if (!isSearching) return; 885 886 if (data.type === 'text') { 887 const text = cleanAnsiCodes(data.data); 888 889 // Update progress message 890 if (text.includes('Searching') || text.includes('Checking') || 891 text.includes('Processing') || text.includes('Starting') || 892 text.match(/\[\d+\/\d+\]/)) { 893 updateProgressMessage(text); 894 } 895 896 // Update progress bar based on completion messages 897 const progressMatch = text.match(/\[(\d+)\/(\d+)\]/); 898 if (progressMatch) { 899 const current = parseInt(progressMatch[1]); 900 const total = parseInt(progressMatch[2]); 901 const percentage = (current / total) * 100; 902 const progressBar = document.getElementById('search-progress-bar'); 903 if (progressBar) { 904 progressBar.style.width = percentage + '%'; 905 } 906 } 907 } 908 }); 909 910 // Handle a single site hit pushed live during the search 911 socket.on('site_result', function(data) { 912 if (!data || !data.site_name || !data.result) return; 913 renderSiteResult(data.site_name, data.result); 914 }); 915 916 // Handle search completion 917 socket.on('search_complete', function(data) { 918 console.log('Search complete:', data); 919 isSearching = false; 920 921 // Set progress to 100% 922 const progressBar = document.getElementById('search-progress-bar'); 923 if (progressBar) { 924 progressBar.style.width = '100%'; 925 } 926 updateProgressMessage('Search complete!'); 927 document.getElementById('search-btn').disabled = false; 928 929 // Fetch full results from Redis 930 if (data.redis_key || data.task_id) { 931 const taskId = data.redis_key || data.task_id; 932 fetchResultsFromRedis(taskId); 933 // Fetch dork results if available 934 if (data.has_dorks) { 935 fetchDorkResults(taskId); 936 } 937 } else { 938 hideProgress(); 939 if (renderedSites.size === 0) { 940 document.getElementById('no-results').style.display = 'block'; 941 } 942 } 943 }); 944 945 function startSearch(event) { 946 event.preventDefault(); 947 948 if (isSearching) { 949 appModal('A search is already in progress. Please wait.', 'warning'); 950 return false;
951 } 952 953 // Pas de toLowerCase : on préserve la casse pour les sites sensibles 954 // (Wikipedia...). Les sites insensibles la gèrent eux-mêmes. 955 const username = document.getElementById('username-input').value.trim(); 956 if (!username || username.length < 3) { 957 appModal('Username must be at least 3 characters long.', 'error'); 958 return false; 959 } 960 961 // Reset UI 962 currentUsername = username; 963 searchResults = {}; 964 resultsCount = 0; 965 renderedSites = new Set(); 966 incrementalSections = { multi: null, single: null }; 967 showProgress(); 968 document.getElementById('results-container').style.display = 'none'; 969 document.getElementById('no-results').style.display = 'none'; 970 document.getElementById('results-cards').innerHTML = ''; 971 document.getElementById('dork-results-container').style.display = 'none'; 972 document.getElementById('dork-results-container').innerHTML = ''; 973 document.getElementById('selection-bar').style.display = 'none'; 974 document.getElementById('search-summary').style.display = 'none'; 975 document.getElementById('search-summary').innerHTML = ''; 976 summaryReady = false; 977 expandedFacets = new Set(); 978 lastBlockedSites = []; 979 lastNotFoundSites = []; 980 lastDorkData = null; 981 document.getElementById('search-btn').disabled = true; 982 isSearching = true; 983 984 // Update results title 985 document.getElementById('results-title').textContent = `Results for "${username}"`; 986 document.getElementById('results-timestamp').textContent = `Search started at ${new Date().toLocaleTimeString()}`; 987 988 updateProgressMessage('Starting search for ' + username + '...'); 989 990 const enrich = document.getElementById('enrich-checkbox').checked; 991 992 // Send search command via SocketIO 993 socket.emit('command', { 994 command: 'search ' + username, 995 token: sessionToken, 996 browser_id: getBrowserId(), 997 enrich: enrich 998 }); 999 1000 return false; 1001 } 1002 1003 function showProgress() { 1004 const progressContainer = document.getElementById('progress-container'); 1005 if (progressContainer) { 1006 progressContainer.classList.add('active'); 1007 } 1008 const progressBar = document.getElementById('search-progress-bar'); 1009 if (progressBar) { 1010 progressBar.style.width = '0%'; 1011 } 1012 } 1013 1014 function hideProgress() { 1015 const progressContainer = document.getElementById('progress-container'); 1016 if (progressContainer) { 1017 progressContainer.classList.remove('active'); 1018 } 1019 } 1020 1021 function cleanAnsiCodes(text) { 1022 return text.replace(/\x1b\[[0-9;]*m/g, '').trim(); 1023 } 1024 1025 function updateProgressMessage(message) { 1026 const msgElement = document.getElementById('search-progress-message'); 1027 if (msgElement) { 1028 msgElement.textContent = cleanAnsiCodes(message); 1029 } 1030 } 1031 1032 function fetchResultsFromRedis(taskId) { 1033 console.log('Fetching results from Redis:', taskId); 1034 fetch('/api/redis-download/' + taskId) 1035 .then(response => response.json()) 1036 .then(data => { 1037 console.log('Redis results:', data); 1038 hideProgress(); 1039 processResults(data); 1040 }) 1041 .catch(error => { 1042 console.error('Error fetching results:', error); 1043 hideProgress(); 1044 document.getElementById('no-results').style.display = 'block'; 1045 }); 1046 } 1047 1048 // ===== MASONRY ===== 1049 // Une grille CSS aligne le bas de chaque rangée sur la carte la plus haute. 1050 // Avec des fiches allant de zéro à sept stats, ça laisse de grands blancs. 1051 // On donne donc à chaque carte un nombre de pistes de 1px égal à sa hauteur, 1052 // ce qui la fait remonter dans le trou laissé par sa voisine. 1053 const MASONRY_GAP = 16; 1054 let masonryFrame = null; 1055 1056 function layoutMasonry() { 1057 masonryFrame = null; 1058 document.querySelectorAll('.results-cards.masonry-grid').forEach(grid => {
1059 // La classe .masonry n'est posée qu'ici : si ce code ne tourne pas, 1060 // la grille reste une grille normale au lieu de laisser les cartes 1061 // se chevaucher dans des pistes de 1px. 1062 grid.classList.add('masonry'); 1063 const items = Array.from(grid.children); 1064 items.forEach(el => { el.style.gridRowEnd = ''; }); 1065 // Toutes les lectures groupées après toutes les écritures : 1066 // un seul reflow pour la grille entière, pas un par carte. 1067 const heights = items.map(el => el.getBoundingClientRect().height); 1068 items.forEach((el, i) => { 1069 // Pistes de 1px : l'écart entre deux cartes vaut exactement 1070 // MASONRY_GAP, sans l'arrondi d'une piste plus grosse. 1071 el.style.gridRowEnd = 'span ' + (Math.ceil(heights[i]) + MASONRY_GAP); 1072 }); 1073 }); 1074 } 1075 1076 function scheduleMasonry() { 1077 if (masonryFrame) return; 1078 masonryFrame = requestAnimationFrame(layoutMasonry); 1079 } 1080 1081 window.addEventListener('resize', scheduleMasonry); 1082 // Les polices personnalisées changent la hauteur des cartes en arrivant. 1083 if (document.fonts && document.fonts.ready) document.fonts.ready.then(scheduleMasonry); 1084 1085 // Get (or lazily create) the collapsible section for a given kind. 1086 // Multi-profile section is always kept before the single-profile one. 1087 function getOrCreateSection(kind) { 1088 if (incrementalSections[kind]) return incrementalSections[kind]; 1089 1090 const titleBase = kind === 'multi' ? 'USER SEARCH API' : 'SINGLE PROFILE SITES'; 1091 const sec = createCollapsibleSection(`${titleBase} (<span class="sec-live-count">0</span>)`, true); 1092 sec.body.classList.add('results-cards', 'masonry-grid'); 1093 const entry = { 1094 wrapper: sec.wrapper, 1095 body: sec.body, 1096 countSpan: sec.wrapper.querySelector('.sec-live-count'), 1097 count: 0 1098 }; 1099 incrementalSections[kind] = entry; 1100 1101 const container = document.getElementById('results-cards'); 1102 if (kind === 'multi' && incrementalSections.single) { 1103 // Multi always comes first 1104 container.insertBefore(sec.wrapper, incrementalSections.single.wrapper); 1105 } else { 1106 container.appendChild(sec.wrapper); 1107 } 1108 return entry; 1109 } 1110 1111 // Insert a single-profile card into the grid, alphabetically by site name. 1112 function insertSingleCardSorted(sectionBody, siteName, result) { 1113 const tmp = document.createElement('div'); 1114 addSingleProfileSite(tmp, siteName, result); 1115 const card = tmp.firstElementChild; 1116 const key = siteName.toLowerCase(); 1117 let ref = null; 1118 sectionBody.querySelectorAll(':scope > .profile-card').forEach(c => { 1119 if (ref === null && key < (c.dataset.site || '').toLowerCase()) ref = c; 1120 }); 1121 sectionBody.insertBefore(card, ref); 1122 } 1123 1124 // Insert a whole multi-profile group (header + cards), alphabetically by site name. 1125 function insertMultiGroupSorted(sectionBody, siteName, result) { 1126 const tmp = document.createElement('div'); 1127 addMultiProfileSite(tmp, siteName, result); 1128 const nodes = Array.from(tmp.childNodes); 1129 const key = siteName.toLowerCase(); 1130 let ref = null; 1131 sectionBody.querySelectorAll('.results-site-header[data-site]').forEach(h => { 1132 if (ref === null && key < (h.dataset.site || '').toLowerCase()) ref = h; 1133 }); 1134 nodes.forEach(n => sectionBody.insertBefore(n, ref)); 1135 } 1136 1137 // Render a single found site as it arrives (deduped, sorted in place).
1138 function renderSiteResult(siteName, result) { 1139 if (!result || !result.exists) return; 1140 if (renderedSites.has(siteName)) return; 1141 renderedSites.add(siteName); 1142 searchResults[siteName] = result; 1143 1144 const kind = (result.profiles && result.profiles.length > 1) ? 'multi' : 'single'; 1145 const sec = getOrCreateSection(kind); 1146 if (kind === 'multi') { 1147 insertMultiGroupSorted(sec.body, siteName, result); 1148 } else { 1149 insertSingleCardSorted(sec.body, siteName, result); 1150 } 1151 sec.count++; 1152 if (sec.countSpan) sec.countSpan.textContent = sec.count; 1153 1154 resultsCount = renderedSites.size; 1155 document.getElementById('results-container').style.display = 'block'; 1156 document.getElementById('no-results').style.display = 'none'; 1157 showSelectionBar(); 1158 scheduleMasonry(); 1159 } 1160 1161 // Finalize the results view. Found sites are usually already rendered live; 1162 // this fills in any missed ones (fallback) plus the blocked / not-found sections. 1163 function processResults(data) { 1164 const resultsData = data.search_results || data; 1165 const blockedSites = data.blocked_sites || []; 1166 const notFoundSites = data.not_found_sites || []; 1167 lastBlockedSites = blockedSites; 1168 lastNotFoundSites = notFoundSites; 1169 const container = document.getElementById('results-cards'); 1170 1171 // Fallback: render any found site that wasn't pushed live (reconnection, test loadâ¦) 1172 for (const [siteName, result] of Object.entries(resultsData)) { 1173 if (result && result.exists && !renderedSites.has(siteName)) { 1174 renderSiteResult(siteName, result); 1175 } 1176 } 1177 1178 resultsCount = renderedSites.size; 1179 1180 if (resultsCount > 0) { 1181 document.getElementById('results-container').style.display = 'block'; 1182 showSelectionBar(); 1183 1184 // Add blocked sites section 1185 if (blockedSites.length > 0) { 1186 addSpacer(container, 'small'); 1187 const sec = createCollapsibleSection(`<i class="iconoir-shield-alert" style="color:var(--error);"></i> BLOCKED SITES (${blockedSites.length})`, false); 1188 addBlockedSitesContent(sec.body, blockedSites); 1189 container.appendChild(sec.wrapper); 1190 } 1191 1192 // Add not found sites section 1193 if (notFoundSites.length > 0) { 1194 addSpacer(container, 'small'); 1195 const sec = createCollapsibleSection(`<i class="iconoir-xmark-circle" style="color:var(--text-muted);"></i> NOT FOUND (${notFoundSites.length})`, false); 1196 addNotFoundSitesContent(sec.body, notFoundSites); 1197 container.appendChild(sec.wrapper); 1198 } 1199 } else { 1200 document.getElementById('no-results').style.display = 'block'; 1201 } 1202 1203 summaryReady = true; 1204 buildSearchSummary(); 1205 scheduleMasonry(); 1206 } 1207 1208 // ===== CROSS-SITE SUMMARY ===== 1209 // Aggregates the identity signals scattered across every found profile so 1210 // the analyst sees the pivots (real names, username variants, countries, 1211 // emails, declared identities) without opening 30 cards one by one. 1212 1213 const SUMMARY_KEYS = { 1214 names: ['fullname', 'full_name', 'fullName', 'realname', 'display_name', 'name', 'profile_title', 'author', 'artist'], 1215 handles: ['username', 'handle', 'login', 'screenname', 'requested_username', 'pseuds'], 1216 countries: ['country', 'country_code'], 1217 places: ['location', 'city', 'state', 'region', 'business_address'], 1218 socials: ['twitter', 'twitter_username', 'instagram', 'facebook', 'linkedin', 'tiktok', 1219 'youtube', 'bluesky', 'threads', 'github_username', 'website', 'website_url', 'publication'], 1220 dates: ['date_creation', 'member_since', 'member_since_year', 'joined', 'joined_at', 'join_date', 1221 'created', 'created_at', 'created_on', 'created_date', 'creation_date', 'registration', 1222 'registration_date', 'registered', 'registered_at', 'regdate', 'signup_year', 1223 'mapper_since', 'scrobbling_since', 'first_uploaded_on'] 1224 }; 1225 1226 const EMAIL_RE = /[A-Za-z0-9._%+-]+@[A-Za-z0-9-]+\.[A-Za-z0-9.-]{2,}/g; 1227 const SUMMARY_CHIP_LIMIT = 12; 1228 1229 let summaryReady = false; // no panel until the search has finished 1230 let expandedFacets = new Set(); // survives the rebuilds triggered by selection changes 1231
1232 let countryNamer = null; 1233 try { countryNamer = new Intl.DisplayNames(['en'], { type: 'region' }); } catch (e) { /* older browser */ } 1234 1235 function normalizeCountry(value) { 1236 const raw = String(value).trim(); 1237 if (countryNamer && /^[A-Za-z]{2}$/.test(raw)) { 1238 try { 1239 const full = countryNamer.of(raw.toUpperCase()); 1240 if (full && full.toUpperCase() !== raw.toUpperCase()) return full; 1241 } catch (e) { /* not a region code */ } 1242 } 1243 return raw; 1244 } 1245 1246 // A 4-digit year anywhere in the value; good enough across the many date 1247 // formats sites return, and we only ever compare years. 1248 function extractYear(value) { 1249 const m = String(value).match(/\b(19[89]\d|20[0-4]\d)\b/); 1250 if (!m) return null; 1251 const year = parseInt(m[1], 10); 1252 return year <= new Date().getFullYear() ? year : null; 1253 } 1254 1255 // La liste exacte des noms de pays en anglais, dérivée des codes ISO-3166 1256 // que le navigateur connaît déjà . Ãvite d'embarquer une table de 250 lignes. 1257 let countryNameSet = null; 1258 function knownCountryNames() { 1259 if (countryNameSet) return countryNameSet; 1260 countryNameSet = new Set(); 1261 if (!countryNamer) return countryNameSet; 1262 const A = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; 1263 for (const a of A) for (const b of A) { 1264 try { 1265 const name = countryNamer.of(a + b); 1266 if (name && name !== a + b) countryNameSet.add(name.toLowerCase()); 1267 } catch (e) { /* pas un code région */ } 1268 } 1269 return countryNameSet; 1270 } 1271 1272 // Vrai si `short` apparaît dans `long` sur des frontières de mot : 1273 // « lyon » dans « lyon, france » oui, « ana » dans « canada » non. 1274 function containsToken(short, long) { 1275 const esc = short.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); 1276 return new RegExp('(^|[^a-z0-9])' + esc + '([^a-z0-9]|$)').test(long); 1277 } 1278 1279 // « Lyon » et « Lyon, France » sont la même trace : on garde la plus 1280 // précise et on lui recolle les sites de la plus vague. 1281 function mergeContained(bucket) { 1282 const entries = Array.from(bucket.entries()).sort((a, b) => b[0].length - a[0].length); 1283 entries.forEach(([longKey, longEntry], i) => { 1284 if (!bucket.has(longKey)) return; 1285 entries.slice(i + 1).forEach(([shortKey, shortEntry]) => { 1286 if (!bucket.has(shortKey) || shortKey === longKey) return; 1287 if (containsToken(shortKey, longKey)) { 1288 shortEntry.sites.forEach(site => longEntry.sites.add(site)); 1289 bucket.delete(shortKey); 1290 } 1291 }); 1292 }); 1293 } 1294 1295 function summaryAdd(bucket, value, site) { 1296 if (value === null || value === undefined) return; 1297 const label = String(value).trim().replace(/\s+/g, ' '); 1298 if (!label || label.length > 70) return; 1299 if (!/[A-Za-z0-9]/.test(label)) return; 1300 const key = label.toLowerCase(); 1301 let entry = bucket.get(key); 1302 if (!entry) { entry = { label: label, sites: new Set() }; bucket.set(key, entry); } 1303 entry.sites.add(site); 1304 } 1305 1306 function collectSummaryData() { 1307 const acc = { 1308 names: new Map(), handles: new Map(), countries: new Map(), 1309 places: new Map(), emails: new Map(), socials: new Map() 1310 }; 1311 const echo = String(currentUsername || '').trim().toLowerCase(); 1312 let oldest = null; 1313 let siteCount = 0; 1314 let profileCount = 0; 1315 1316 // Same source as the exports: deselecting a card must drop its data 1317 // from the summary too, otherwise the panel contradicts what you keep. 1318 for (const [site, result] of Object.entries(getSelectedExportData())) { 1319 if (!result || !result.exists) continue; 1320 siteCount++; 1321 const profiles = (result.profiles && result.profiles.length) ? result.profiles : [result]; 1322 1323 profiles.forEach(profile => { 1324 if (!profile || typeof profile !== 'object') return; 1325 profileCount++; 1326
1327 SUMMARY_KEYS.names.forEach(k => summaryAdd(acc.names, profile[k], site)); 1328 const first = profile.first_name || profile.firstname || profile.givenName || ''; 1329 const last = profile.last_name || profile.lastname || profile.familyName || ''; 1330 if (first || last) summaryAdd(acc.names, `${first} ${last}`, site); 1331 1332 SUMMARY_KEYS.handles.forEach(k => summaryAdd(acc.handles, profile[k], site)); 1333 SUMMARY_KEYS.places.forEach(k => summaryAdd(acc.places, profile[k], site)); 1334 SUMMARY_KEYS.countries.forEach(k => { 1335 if (profile[k]) summaryAdd(acc.countries, normalizeCountry(profile[k]), site); 1336 }); 1337 SUMMARY_KEYS.socials.forEach(k => summaryAdd(acc.socials, profile[k], site)); 1338 (profile.rel_me_links || []).forEach(url => summaryAdd(acc.socials, url, site)); 1339 (result.rel_me_links || []).forEach(url => summaryAdd(acc.socials, url, site)); 1340 1341 SUMMARY_KEYS.dates.forEach(k => { 1342 if (!profile[k]) return; 1343 const year = extractYear(profile[k]); 1344 if (year && (!oldest || year < oldest.year)) oldest = { year: year, site: site }; 1345 }); 1346 1347 // Emails hide in bios as often as in dedicated fields, so sweep 1348 // every string value rather than a fixed key list. 1349 for (const [key, value] of Object.entries(profile)) { 1350 if (typeof value !== 'string' || key.endsWith('_placeholder_id')) continue; 1351 const found = value.match(EMAIL_RE); 1352 if (found) found.forEach(mail => summaryAdd(acc.emails, mail.toLowerCase(), site)); 1353 } 1354 }); 1355 } 1356 1357 // The searched username is already in the results title; repeating it as 1358 // a "discovery" on 30 sites buries the actual variants. 1359 [acc.names, acc.handles].forEach(bucket => bucket.delete(echo)); 1360 1361 // Un pseudo déjà listé en variante n'est pas un nom : beaucoup de sites 1362 // recopient le handle dans display_name, ce qui polluait « Names ». 1363 acc.handles.forEach((entry, key) => acc.names.delete(key)); 1364 1365 // Un champ « location » qui vaut « France » est un pays, pas un lieu. 1366 const countries = knownCountryNames(); 1367 Array.from(acc.places.entries()).forEach(([key, entry]) => { 1368 if (!countries.has(key)) return; 1369 acc.places.delete(key); 1370 const existing = acc.countries.get(key); 1371 if (existing) entry.sites.forEach(site => existing.sites.add(site)); 1372 else acc.countries.set(key, entry); 1373 }); 1374 mergeContained(acc.places); 1375 1376 return { acc, oldest, siteCount, profileCount }; 1377 } 1378 1379 function summaryChipsHtml(bucket, opts) { 1380 const options = opts || {}; 1381 const entries = Array.from(bucket.values()).sort((a, b) => { 1382 const diff = b.sites.size - a.sites.size; 1383 return diff !== 0 ? diff : a.label.localeCompare(b.label); 1384 }); 1385 1386 const chips = entries.map((entry, i) => { 1387 const overflow = i >= SUMMARY_CHIP_LIMIT ? ' is-overflow' : ''; 1388 const accent = options.accent ? ' summary-chip-accent' : ''; 1389 const sites = Array.from(entry.sites).sort().join(', '); 1390 const count = entry.sites.size > 1 ? `<span class="chip-n">${entry.sites.size}</span>` : ''; 1391 const isUrl = /^https?:\/\//.test(entry.label); 1392 const text = isUrl ? entry.label.replace(/^https?:\/\//, '').replace(/\/$/, '') : entry.label; 1393 const tip = escAttr(entry.label) + ' Found on: ' + escAttr(sites); 1394 const inner = isUrl 1395 ? `<a href="${escAttr(entry.label)}" target="_blank" rel="noopener noreferrer" class="chip-text">${escapeHtml(text)}</a>` 1396 : `<span class="chip-text">${escapeHtml(text)}</span>`; 1397 const focusable = isUrl ? '' : ' tabindex="0" role="button"'; 1398 return `<span class="summary-chip${accent}${overflow}"${focusable} data-copy="${escAttr(entry.label)}" title="${tip}">${inner}${count}</span>`;
1399 }); 1400 1401 const hidden = entries.length - SUMMARY_CHIP_LIMIT; 1402 if (hidden > 0) { 1403 chips.push(`<span class="summary-chip summary-chip-more" tabindex="0" role="button" data-expand="1">+${hidden} more</span>`); 1404 } 1405 return chips.join(''); 1406 } 1407 1408 function summaryFacetHtml(label, bucket, opts) { 1409 if (!bucket.size) return ''; 1410 const expanded = expandedFacets.has(label) ? ' expanded' : ''; 1411 return `<div class="summary-facet${expanded}" data-facet="${escAttr(label)}"> 1412 <div class="summary-facet-label">${label} <span class="facet-count">${bucket.size}</span></div> 1413 <div class="summary-chips">${summaryChipsHtml(bucket, opts)}</div> 1414 </div>`; 1415 } 1416 1417 function buildSearchSummary() { 1418 const panel = document.getElementById('search-summary'); 1419 if (!panel || !summaryReady) return; 1420 1421 const { acc, oldest, siteCount, profileCount } = collectSummaryData(); 1422 const facets = [ 1423 summaryFacetHtml('Names', acc.names, { accent: true }), 1424 summaryFacetHtml('Username variants', acc.handles), 1425 summaryFacetHtml('Countries', acc.countries), 1426 summaryFacetHtml('Locations', acc.places), 1427 summaryFacetHtml('Emails', acc.emails, { accent: true }), 1428 summaryFacetHtml('Declared identities', acc.socials) 1429 ].filter(Boolean).join(''); 1430 1431 if (!facets) { 1432 panel.style.display = 'none'; 1433 panel.innerHTML = ''; 1434 return; 1435 } 1436 1437 const profilePart = profileCount !== siteCount ? ` · ${profileCount} profiles` : ''; 1438 const oldestPart = oldest ? ` · oldest trace ${oldest.year} (${escapeHtml(oldest.site)})` : ''; 1439 panel.innerHTML = ` 1440 <div class="search-summary-header"> 1441 <span><i class="iconoir-fingerprint"></i> Identity summary</span> 1442 <span class="summary-sub">${siteCount} sites${profilePart}${oldestPart}</span> 1443 </div> 1444 <div class="search-summary-grid">${facets}</div>`; 1445 panel.style.display = 'block'; 1446 } 1447 1448 document.addEventListener('keydown', function(e) { 1449 if (e.key !== 'Enter' && e.key !== ' ') return; 1450 const chip = e.target.closest('.summary-chip'); 1451 // Sur un chip-lien, Entrée doit ouvrir le lien : preventDefault ici 1452 // l'aurait remplacé par une copie. 1453 if (!chip || e.target.closest('a')) return; 1454 e.preventDefault(); 1455 chip.click(); 1456 }); 1457 1458 document.addEventListener('click', function(e) { 1459 const more = e.target.closest('.summary-chip-more'); 1460 if (more) { 1461 const facet = more.closest('.summary-facet'); 1462 facet.classList.add('expanded'); 1463 expandedFacets.add(facet.dataset.facet); 1464 return; 1465 } 1466 const chip = e.target.closest('.summary-chip'); 1467 if (!chip || e.target.closest('a') || !navigator.clipboard) return; 1468 navigator.clipboard.writeText(chip.dataset.copy || '').then(() => showToast('Copied')); 1469 }); 1470 1471 function addSectionHeader(container, title) { 1472 const header = document.createElement('h1'); 1473 header.className = 'results-section-header'; 1474 header.textContent = title; 1475 container.appendChild(header); 1476 addSpacer(container, 'small'); 1477 } 1478 1479 // Wrap content in a collapsible accordion section. 1480 // Returns {wrapper, body} â append children to body. 1481 function createCollapsibleSection(title, openByDefault = true) { 1482 const wrapper = document.createElement('div'); 1483 wrapper.className = 'section-accordion' + (openByDefault ? ' open' : ''); 1484 1485 const header = document.createElement('div'); 1486 header.className = 'section-accordion-header'; 1487 header.setAttribute('role', 'button'); 1488 header.setAttribute('tabindex', '0'); 1489 header.setAttribute('aria-expanded', String(openByDefault)); 1490 // Un seul élément flex pour le titre : sinon les bouts de texte nus 1491 // autour du compteur deviennent des items anonymes et récoltent le gap, 1492 // ce qui donnait « SINGLE PROFILE SITES ( 13 ) ». 1493 header.innerHTML = `<span class="sa-chevron"></span><span class="sa-title">${title}</span>`;
1494 const toggle = () => { 1495 const open = wrapper.classList.toggle('open'); 1496 header.setAttribute('aria-expanded', String(open)); 1497 if (open) scheduleMasonry(); 1498 }; 1499 header.onclick = toggle; 1500 header.onkeydown = (e) => { 1501 if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggle(); } 1502 }; 1503 wrapper.appendChild(header); 1504 1505 const bodyOuter = document.createElement('div'); 1506 bodyOuter.className = 'section-accordion-body'; 1507 const bodyInner = document.createElement('div'); 1508 bodyInner.className = 'section-accordion-body-inner'; 1509 bodyOuter.appendChild(bodyInner); 1510 wrapper.appendChild(bodyOuter); 1511 1512 return { wrapper, body: bodyInner }; 1513 } 1514 1515 function addSpacer(container, size) { 1516 const spacer = document.createElement('div'); 1517 spacer.className = 'results-spacer-' + size; 1518 container.appendChild(spacer); 1519 } 1520 1521 // Multi-profile site (like Bluesky with multiple accounts) - IDENTICAL to index.html 1522 function addMultiProfileSite(container, siteName, result) { 1523 // Site header 1524 const siteHeader = document.createElement('h2'); 1525 siteHeader.className = 'results-site-header'; 1526 siteHeader.textContent = siteName; 1527 siteHeader.dataset.site = siteName; 1528 container.appendChild(siteHeader); 1529 1530 const profileCount = document.createElement('span'); 1531 profileCount.className = 'results-site-count'; 1532 profileCount.textContent = `(${result.profiles.length} profiles found)`; 1533 container.appendChild(profileCount); 1534 1535 // Add spacing after header 1536 const spacerAfter = document.createElement('div'); 1537 spacerAfter.className = 'results-spacer-small'; 1538 container.appendChild(spacerAfter); 1539 1540 // Add each profile 1541 result.profiles.forEach((profile, profileIndex) => { 1542 const card = document.createElement('div'); 1543 card.className = 'profile-card'; 1544 card.dataset.site = siteName; 1545 1546 // Build profile info 1547 const displayName = profile.display_name || profile.username || profile.name || ''; 1548 const handle = profile.handle ? `@${profile.handle}` : ''; 1549 const profileUrl = profile.profile_url || profile.url || '#'; 1550 const avatar = profile.avatar || '/static/img/default-avatar.svg'; 1551 const bio = profile.bio || profile.description || ''; 1552 1553 // Build stats - separate URLs from data 1554 let dataStats = ''; 1555 let urlStats = ''; 1556 for (const [key, value] of Object.entries(profile)) { 1557 if (key !== 'avatar' && key !== 'profile_url' && key !== 'url' && key !== 'handle' && 1558 key !== 'display_name' && key !== 'username' && key !== 'name' && key !== 'bio' && 1559 key !== 'description' && value !== '' && value !== null && value !== undefined && 1560 typeof value !== 'object') { 1561 1562 let displayValue = formatStatValue(value); 1563 let displayLabel = key.replace(/_/g, ' '); 1564 if (key === 'country_flag' && value) { 1565 displayValue = `<img src="${escAttr(value)}" class="profile-image-small" alt="Flag">`; 1566 } 1567 1568 const strVal = String(value); 1569 const isUrl = strVal.startsWith('http://') || strVal.startsWith('https://'); 1570 1571 if (isUrl) { 1572 const shortUrl = strVal.replace(/^https?:\/\//, '').split('?')[0]; 1573 const truncated = shortUrl.length > 35 ? shortUrl.substring(0, 32) + '...' : shortUrl; 1574 urlStats += `<div class="profile-stat"> 1575 <div class="profile-stat-value"><a href="${escAttr(strVal)}" target="_blank" class="copyable" data-full="${escAttr(strVal)}">${truncated}</a></div> 1576 <div class="profile-stat-label">${displayLabel}</div> 1577 </div>`; 1578 } else { 1579 dataStats += `<div class="profile-stat"> 1580 <div class="profile-stat-value copyable" data-full="${escAttr(strVal)}">${displayValue}</div> 1581 <div class="profile-stat-label">${displayLabel}</div> 1582 </div>`; 1583 } 1584 } 1585 } 1586 1587 const allStats = dataStats + urlStats; 1588 const shortUrl = profileUrl.replace(/^https?:\/\//, '').split('?')[0]; 1589 const truncatedUrl = shortUrl.length > 45 ? shortUrl.substring(0, 42) + '...' : shortUrl; 1590 1591 const isDefaultAvatar = !profile.avatar; 1592 card.innerHTML = ` 1593 <input type="checkbox" class="card-select" checked data-site="${escAttr(siteName)}" data-profile-index="${profileIndex}" onchange="updateSelectionCount()" aria-label="Include ${escAttr(siteName)} / ${escAttr(displayName)} in exports"> 1594 <div class="profile-info"> 1595 ${isDefaultAvatar ? `<img class="profile-avatar" src="/static/img/default-avatar.svg">` : ` 1596 <div class="profile-avatar-wrap" data-avatar="${escAttr(avatar)}"> 1597 <img class="profile-avatar" src="${escAttr(avatar)}" onerror="this.src='/static/img/default-avatar.svg'"> 1598 </div>`} 1599 <div> 1600 <div class="profile-site">${escAttr(siteName)}</div> 1601 ${displayName ? `<div class="profile-name">${escAttr(displayName)}</div>` : ''} 1602 ${handle ? `<div class="profile-handle">${escAttr(handle)}</div>` : ''} 1603 </div> 1604 </div> 1605 ${bio ? `<div class="profile-bio">${escAttr(bio)}</div>` : ''} 1606 ${allStats ? `<div class="profile-stats">${allStats}</div>` : ''} 1607 ${buildRelMeHtml(profile)}
1607 1608 <div class="profile-actions"> 1609 <a class="profile-url copyable" href="${escAttr(profileUrl)}" target="_blank" data-full="${escAttr(profileUrl)}">${truncatedUrl}</a> 1610 </div> 1611 `; 1612 1613 container.appendChild(card); 1614 }); 1615 1616 // Add spacing after the site's profiles 1617 const spacer = document.createElement('div'); 1618 spacer.className = 'results-spacer-medium'; 1619 container.appendChild(spacer); 1620 } 1621 1622 // Single-profile site - IDENTICAL to index.html 1623 function addSingleProfileSite(container, siteName, result) { 1624 const card = document.createElement('div'); 1625 card.className = 'profile-card'; 1626 card.dataset.site = siteName; 1627 1628 // Get profile data (either from profiles[0] or directly from result) 1629 const profile = (result.profiles && result.profiles.length > 0) ? result.profiles[0] : result; 1630 1631 // Build profile info 1632 const composedName = [profile.first_name || profile.firstname || profile.givenName, 1633 profile.last_name || profile.lastname || profile.familyName] 1634 .filter(Boolean).join(' ').trim(); 1635 const displayName = profile.fullname || profile.full_name || profile.display_name || composedName || 1636 profile.realname || profile.username || profile.name || profile.title || ''; 1637 const handle = profile.handle ? `@${profile.handle}` : ''; 1638 const profileUrl = profile.profile_url || profile.url || result.url || '#'; 1639 const avatar = profile.avatar || result.avatar || '/static/img/default-avatar.svg'; 1640 const bio = profile.bio || profile.description || ''; 1641 1642 // Build stats using CSS classes 1643 let statsHtml = ''; 1644 const excludeKeys = ['exists', 'url', 'profile_url', 'profiles', 'avatar', 'avatar_url', 'profile_image', 1645 'fullname', 'full_name', 'display_name', 'username', 'name', 'handle', 'title', 'bio', 'description', 1646 'summary', 'site_name', 'image', 'locale', 'author', 'canonical_url', 'rel_me_links', 1647 'pgp_key', 'pgp_key_raw', 'pgp_key_full', 'fingerprint', 'pgp_email_placeholder_id']; 1648 1649 // Separate URL fields from data fields 1650 let dataStats = ''; 1651 let urlStats = ''; 1652 for (const [key, value] of Object.entries(profile)) { 1653 if (!excludeKeys.includes(key) && !key.endsWith('_full') && value !== '' && value !== null && value !== undefined && 1654 typeof value !== 'object' && !Array.isArray(value)) { 1655 1656 let displayValue = formatStatValue(value); 1657 let displayLabel = key.replace(/_/g, ' '); 1658 1659 if (key === 'country_flag' && value) { 1660 displayValue = `<img src="${escAttr(value)}" class="profile-image-small" alt="Flag">`; 1661 } else if (key === 'pgp_email') { 1662 displayLabel = 'email (PGP)'; 1663 } 1664 1665 const strVal = String(value); 1666 const isUrl = strVal.startsWith('http://') || strVal.startsWith('https://'); 1667 1668 if (isUrl) { 1669 const shortUrl = strVal.replace(/^https?:\/\//, '').split('?')[0]; 1670 const truncated = shortUrl.length > 35 ? shortUrl.substring(0, 32) + '...' : shortUrl; 1671 urlStats += `<div class="profile-stat"> 1672 <div class="profile-stat-value"><a href="${escAttr(strVal)}" target="_blank" class="copyable" data-full="${escAttr(strVal)}">${truncated}</a></div> 1673 <div class="profile-stat-label">${displayLabel}</div> 1674 </div>`; 1675 } else { 1676 dataStats += `<div class="profile-stat"> 1677 <div class="profile-stat-value copyable" data-full="${escAttr(strVal)}">${displayValue}</div> 1678 <div class="profile-stat-label">${displayLabel}</div> 1679 </div>`; 1680 } 1681 } 1682 } 1683 1684 const allStats = dataStats + urlStats; 1685 const shortUrl = profileUrl.replace(/^https?:\/\//, '').split('?')[0]; 1686 const truncatedUrl = shortUrl.length > 45 ? shortUrl.substring(0, 42) + '...' : shortUrl; 1687 1688 const isDefaultAvatar = !profile.avatar && !result.avatar; 1689 card.innerHTML = ` 1690 <input type="checkbox" class="card-select" checked data-site="${escAttr(siteName)}" onchange="updateSelectionCount()" aria-label="Include ${escAttr(siteName)} in exports"> 1691 <div class="profile-info"> 1692 ${isDefaultAvatar ? `<img class="profile-avatar" src="/static/img/default-avatar.svg">` : ` 1693 <div class="profile-avatar-wrap" data-avatar="${escAttr(avatar)}"> 1694 <img class="profile-avatar" src="${escAttr(avatar)}" onerror="this.src='/static/img/default-avatar.svg'"> 1695 </div>`} 1696 <div> 1697 <div class="profile-site">${escAttr(siteName)}</div> 1698 ${displayName ? `<div class="profile-name">${escAttr(displayName)}</div>` : ''} 1699 ${handle ? `<div class="profile-handle">${escAttr(handle)}</div>` : ''} 1700 </div> 1701 </div> 1702 ${bio ? `<div class="profile-bio">${escAttr(bio)}</div>` : ''} 1703 ${allStats ? `<div class="profile-stats">${allStats}</div>` : ''} 1704 ${buildRelMeHtml(profile, result)} 1705 <div class="profile-actions"> 1706 <a class="profile-url copyable" href="${escAttr(profileUrl)}" target="_blank" data-full="${escAttr(profileUrl)}">${truncatedUrl}</a> 1707 </div> 1708 `; 1709 1710 container.appendChild(card); 1711 } 1712 1713 function addBlockedSitesContent(body, blockedSites) { 1714 const grid = document.createElement('div'); 1715 grid.className = 'results-cards results-cards-flush masonry-grid'; 1716
1717 blockedSites.forEach(site => { 1718 const card = document.createElement('div'); 1719 card.className = 'profile-card blocked-site-card'; 1720 card.innerHTML = ` 1721 <div class="profile-info"> 1722 <div> 1723 <div class="profile-name">${escapeHtml(site.site_name)}</div> 1724 <div class="profile-handle"> 1725 <span class="blocked-status">HTTP ${site.status_code}</span> 1726 </div> 1727 </div> 1728 </div> 1729 <div class="profile-bio">${escapeHtml(site.reason)}</div> 1730 <div class="profile-actions"> 1731 <a class="profile-url" href="${escAttr(site.display_url)}" target="_blank">Verify Manually</a> 1732 </div> 1733 `; 1734 grid.appendChild(card); 1735 }); 1736 body.appendChild(grid); 1737 } 1738 1739 function addNotFoundSitesContent(body, notFoundSites) { 1740 const grid = document.createElement('div'); 1741 grid.className = 'results-cards results-cards-flush masonry-grid'; 1742
1743 notFoundSites.forEach(site => { 1744 const card = document.createElement('div'); 1745 card.className = 'profile-card not-found-site-card'; 1746 card.innerHTML = ` 1747 <div class="profile-info"> 1748 <div> 1749 <div class="profile-name">${escapeHtml(site.site_name)}</div> 1750 <div class="profile-handle"> 1751 <span class="not-found-status">HTTP ${site.status_code}</span> 1752 </div> 1753 </div> 1754 </div> 1755 <div class="profile-actions"> 1756 <a class="profile-url" href="${escAttr(site.display_url)}" target="_blank">Verify Manually</a> 1757 </div> 1758 `; 1759 grid.appendChild(card); 1760 }); 1761 body.appendChild(grid); 1762 } 1763 1764 function searchError(error) { 1765 isSearching = false; 1766 hideProgress(); 1767 document.getElementById('search-btn').disabled = false; 1768 appModal('Search error: ' + error, 'error'); 1769 } 1770 1771 function escapeHtml(text) { 1772 if (!text) return ''; 1773 const div = document.createElement('div'); 1774 div.textContent = text; 1775 return div.innerHTML; 1776 } 1777 1778 function showToast(message) { 1779 const toast = document.createElement('div'); 1780 toast.style.cssText = 'position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:var(--bg-elevated);color:var(--text-primary);padding:8px 16px;border-radius:var(--radius-full);font-size:var(--font-size-sm);z-index:10000;border:1px solid var(--border-color);'; 1781 toast.textContent = message; 1782 document.body.appendChild(toast); 1783 setTimeout(() => toast.remove(), 1500); 1784 } 1785 1786 // Avatar functions 1787 function copyAvatarUrl(url) { 1788 navigator.clipboard.writeText(url).then(() => showToast('Avatar URL copied')); 1789 } 1790 1791 function downloadAvatar(url) { 1792 const a = document.createElement('a'); 1793 a.href = url; 1794 a.download = 'avatar-' + url.split('/').pop().split('?')[0]; 1795 a.target = '_blank'; 1796 a.rel = 'noopener'; 1797 document.body.appendChild(a); 1798 a.click(); 1799 a.remove(); 1800 } 1801 1802 // --- Avatar hover popover (portaled to <body>) ------------------------- 1803 // Cards live inside accordions with overflow:hidden, so an absolutely 1804 // positioned popover gets clipped by the section frame. A single shared 1805 // element in <body>, positioned with fixed coordinates, escapes that. 1806 let avatarPopoverEl = null; 1807 let avatarPopoverWrap = null; 1808 1809 function getAvatarPopover() { 1810 if (avatarPopoverEl) return avatarPopoverEl; 1811 avatarPopoverEl = document.createElement('div'); 1812 avatarPopoverEl.className = 'avatar-popover'; 1813 avatarPopoverEl.innerHTML = ` 1814 <img alt="Avatar" onerror="this.src='/static/img/default-avatar.svg'"> 1815 <div class="avatar-popover-actions"> 1816 <button class="avatar-action-btn" data-action="copy" title="Copy URL"><i class="iconoir-copy"></i> Copy</button> 1817 <button class="avatar-action-btn" data-action="save" title="Download"><i class="iconoir-download"></i> Save</button> 1818 </div>`; 1819 avatarPopoverEl.addEventListener('click', (e) => { 1820 const btn = e.target.closest('.avatar-action-btn'); 1821 if (!btn || !avatarPopoverWrap) return; 1822 const url = avatarPopoverWrap.dataset.avatar; 1823 if (btn.dataset.action === 'copy') copyAvatarUrl(url); 1824 else downloadAvatar(url); 1825 }); 1826 document.body.appendChild(avatarPopoverEl); 1827 return avatarPopoverEl; 1828 } 1829 1830 function showAvatarPopover(wrap) { 1831 const url = wrap.dataset.avatar; 1832 if (!url) return; 1833 const pop = getAvatarPopover(); 1834 avatarPopoverWrap = wrap; 1835 pop.querySelector('img').src = url; 1836 pop.classList.add('visible'); 1837 positionAvatarPopover(); 1838 } 1839 1840 function positionAvatarPopover() { 1841 if (!avatarPopoverEl || !avatarPopoverWrap) return; 1842 const pop = avatarPopoverEl; 1843 const r = avatarPopoverWrap.getBoundingClientRect(); 1844 const pw = pop.offsetWidth; 1845 const ph = pop.offsetHeight; 1846 const margin = 8; 1847 1848 let left = r.left + r.width / 2 - pw / 2; 1849 left = Math.max(margin, Math.min(left, window.innerWidth - pw - margin)); 1850 1851 // Prefer above the avatar; flip below when there isn't room. 1852 let top = r.top - ph; 1853 const flip = top < margin; 1854 if (flip) top = Math.min(r.bottom, window.innerHeight - ph - margin); 1855 1856 pop.classList.toggle('flip-below', flip); 1857 pop.style.left = Math.round(left) + 'px'; 1858 pop.style.top = Math.round(top) + 'px'; 1859 } 1860 1861 function hideAvatarPopover() { 1862 if (!avatarPopoverEl) return; 1863 avatarPopoverEl.classList.remove('visible'); 1864 avatarPopoverWrap = null; 1865 } 1866 1867 document.addEventListener('mouseover', (e) => { 1868 const wrap = e.target.closest('.profile-avatar-wrap'); 1869 if (wrap) { 1870 if (wrap !== avatarPopoverWrap) showAvatarPopover(wrap); 1871 return; 1872 } 1873 if (avatarPopoverEl && e.target.closest('.avatar-popover')) return; 1874 hideAvatarPopover(); 1875 }); 1876 1877 window.addEventListener('scroll', hideAvatarPopover, true); 1878 window.addEventListener('resize', hideAvatarPopover); 1879 1880 // Selection functions 1881 function toggleSelectAll(checked) { 1882 document.querySelectorAll('.card-select').forEach(cb => { 1883 cb.checked = checked; 1884 cb.closest('.profile-card').classList.toggle('card-deselected', !checked); 1885 }); 1886 updateSelectionCount(); 1887 } 1888 1889 function updateSelectionCount() { 1890 const all = document.querySelectorAll('.card-select'); 1891 const checked = document.querySelectorAll('.card-select:checked'); 1892 const countEl = document.getElementById('selection-count'); 1893 const selectAllCb = document.getElementById('select-all-cb'); 1894 1895 if (countEl) countEl.textContent = `${checked.length} / ${all.length} selected`; 1896 if (selectAllCb) selectAllCb.checked = checked.length === all.length; 1897 1898 // Visual feedback 1899 all.forEach(cb => { 1900 cb.closest('.profile-card').classList.toggle('card-deselected', !cb.checked); 1901 }); 1902 1903 buildSearchSummary(); 1904 } 1905 1906 function showSelectionBar() { 1907 const bar = document.getElementById('selection-bar'); 1908 if (bar) bar.style.display = 'flex'; 1909 updateSelectionCount(); 1910 } 1911 1912 // Build export data respecting per-profile selection 1913 function getSelectedExportData() { 1914 const exportData = {}; 1915 // Collect checked checkboxes grouped by site 1916 const checkedBySite = {}; 1917 document.querySelectorAll('.card-select:checked').forEach(cb => { 1918 const site = cb.dataset.site; 1919 if (!checkedBySite[site]) checkedBySite[site] = []; 1920 const idx = cb.dataset.profileIndex; 1921 checkedBySite[site].push(idx !== undefined ? parseInt(idx) : null); 1922 }); 1923 1924 for (const [site, indices] of Object.entries(checkedBySite)) { 1925 const result = searchResults[site]; 1926 if (!result) continue; 1927 1928 // Multi-profile: filter to selected indices only 1929 if (result.profiles && result.profiles.length >
1929 1 && indices[0] !== null) { 1930 const filtered = { ...result, profiles: indices.map(i => result.profiles[i]).filter(Boolean) }; 1931 if (filtered.profiles.length > 0) exportData[site] = filtered; 1932 } else { 1933 exportData[site] = result; 1934 } 1935 } 1936 return exportData; 1937 } 1938 1939 // Export functions 1940 function exportJSON() { 1941 const exportData = getSelectedExportData(); 1942 if (Object.keys(exportData).length === 0) { 1943 appModal('No results selected for export.', 'warning'); 1944 return; 1945 } 1946 1947 const data = JSON.stringify(exportData, null, 2); 1948 const blob = new Blob([data], { type: 'application/json' }); 1949 const url = URL.createObjectURL(blob); 1950 const a = document.createElement('a'); 1951 a.href = url; 1952 a.download = `rpuc_results_${currentUsername}_${Date.now()}.json`; 1953 a.click(); 1954 URL.revokeObjectURL(url); 1955 } 1956 1957 function exportPDF() { 1958 const exportData = getSelectedExportData(); 1959 if (Object.keys(exportData).length === 0) { 1960 appModal('No results selected for export.', 'warning'); 1961 return; 1962 } 1963 1964 const { jsPDF } = window.jspdf; 1965 const doc = new jsPDF(); 1966 1967 // Title 1968 doc.setFontSize(16); 1969 doc.text('RPUC Search Results', 14, 20); 1970 doc.setFontSize(12); 1971 doc.text(`Username: ${currentUsername}`, 14, 30); 1972 doc.text(`Date: ${new Date().toLocaleString()}`, 14, 36); 1973 1974 // Table data 1975 const tableData = []; 1976 for (const [siteName, result] of Object.entries(exportData)) { 1977 if (result.profiles && result.profiles.length > 0) { 1978 result.profiles.forEach(profile => { 1979 tableData.push([ 1980 siteName, 1981 profile.username || profile.handle || currentUsername, 1982 profile.profile_url || profile.url || result.url || '' 1983 ]); 1984 }); 1985 } else { 1986 tableData.push([ 1987 siteName, 1988 result.username || result.handle || currentUsername, 1989 result.profile_url || result.url || '' 1990 ]); 1991 } 1992 } 1993 1994 // Create table 1995 doc.autoTable({ 1996 head: [['Site', 'Username', 'Profile URL']], 1997 body: tableData, 1998 startY: 45, 1999 styles: { fontSize: 10 }, 2000 headStyles: { fillColor: [192, 192, 192], textColor: [0, 0, 0] } 2001 }); 2002 2003 // Save PDF 2004 doc.save(`rpuc_results_${currentUsername}_${Date.now()}.pdf`); 2005 } 2006 2007 async function exportZeroneurone() { 2008 const exportData = getSelectedExportData(); 2009 if (Object.keys(exportData).length === 0) { 2010 appModal('No results selected for export.', 'warning'); 2011 return; 2012 } 2013 const { data, imageMap } = ZeroneuroneExport.fromUserSearch(currentUsername, exportData); 2014 await ZeroneuroneExport.downloadZip(data, `zeroneurone_${currentUsername}_${Date.now()}.json`, imageMap); 2015 } 2016 2017 // ===== COFFRE LOCAL ===== 2018 // Le rendu du panneau, le déverrouillage et l'effacement sont dans 2019 // static/js/vault-ui.js, partagés avec geoloc et rhinoscraper. Ici on ne 2020 // décrit que ce qui est propre à la recherche de pseudos. 2021 VaultUI.mount({ 2022 tool: 'username_search', 2023 emptyHint: 'Run a search, then use <strong>Save locally</strong> above the results.', 2024 row: entry => ({ 2025 title: entry.title, 2026 meta: [ 2027 `${entry.meta.sites || 0} sites`, 2028 entry.meta.blocked ? `${entry.meta.blocked} blocked` : null, 2029 entry.meta.notFound ? `${entry.meta.notFound} not found` : null 2030 ].filter(Boolean).join(' · ') 2031 }), 2032 open: entry => restoreSearch(entry), 2033 // Les cartes mesurées pendant que l'onglet était caché ont une hauteur 2034 // nulle : il faut relancer le calcul au retour. 2035 onTab: name => { if (name === 'main') scheduleMasonry(); } 2036 }); 2037 2038 async function saveSearchLocally() { 2039 if (!resultsCount) {
2040 appModal('Run a search before saving it.', 'warning'); 2041 return; 2042 } 2043 const btn = document.getElementById('save-local-btn'); 2044 if (btn) btn.disabled = true; 2045 try { 2046 await VaultUI.save({ 2047 title: currentUsername, 2048 meta: { 2049 sites: resultsCount, 2050 blocked: lastBlockedSites.length, 2051 notFound: lastNotFoundSites.length 2052 }, 2053 payload: { 2054 search_results: searchResults, 2055 blocked_sites: lastBlockedSites, 2056 not_found_sites: lastNotFoundSites, 2057 dorks: lastDorkData 2058 } 2059 }); 2060 } finally { 2061 if (btn) btn.disabled = false; 2062 } 2063 } 2064 2065 function restoreSearch(entry) { 2066 const data = entry.payload || {}; 2067 currentUsername = entry.title; 2068 searchResults = {}; 2069 resultsCount = 0; 2070 renderedSites = new Set(); 2071 incrementalSections = { multi: null, single: null }; 2072 summaryReady = false; 2073 expandedFacets = new Set(); 2074 lastDorkData = data.dorks || null; 2075 2076 document.getElementById('results-cards').innerHTML = ''; 2077 document.getElementById('results-container').style.display = 'none'; 2078 document.getElementById('no-results').style.display = 'none'; 2079 document.getElementById('search-summary').style.display = 'none'; 2080 document.getElementById('search-summary').innerHTML = ''; 2081 document.getElementById('dork-results-container').style.display = 'none'; 2082 document.getElementById('dork-results-container').innerHTML = ''; 2083 document.getElementById('username-input').value = entry.title || ''; 2084 2085 processResults(data); 2086 document.getElementById('results-title').textContent = `Results for "${entry.title}" (saved locally)`; 2087 document.getElementById('results-timestamp').textContent = 2088 `Saved ${new Date(entry.ts).toLocaleString()} in this browser`; 2089 if (lastDorkData) renderDorkResults(lastDorkData); 2090 window.scrollTo({ top: 0, behavior: 'smooth' }); 2091 } 2092 2093 // ===== TEST MODE ===== 2094 // Add ?test to URL to show "Load JSON" button 2095 if (window.location.search.includes('test')) { 2096 const testBtn = document.getElementById('load-test-btn'); 2097 if (testBtn) testBtn.style.display = 'inline-block'; 2098 } 2099 2100 function loadTestJSON() { 2101 document.getElementById('test-json-input').click(); 2102 } 2103 2104 document.getElementById('test-json-input').addEventListener('change', function(e) { 2105 const file = e.target.files[0]; 2106 if (!file) return; 2107 const reader = new FileReader(); 2108 reader.onload = function(ev) { 2109 try { 2110 const data = JSON.parse(ev.target.result); 2111 // Reset UI 2112 searchResults = {}; 2113 resultsCount = 0; 2114 renderedSites = new Set(); 2115 incrementalSections = { multi: null, single: null }; 2116 const container = document.getElementById('results-cards'); 2117 container.innerHTML = ''; 2118 document.getElementById('results-container').style.display = 'none'; 2119 document.getElementById('no-results').style.display = 'none'; 2120 2121 currentUsername = file.name.replace(/\.json$/i, '').replace(/^rpuc_results_/, '').split('_')[0] || 'test'; 2122 document.getElementById('results-title').textContent = `Results for "${currentUsername}" (test data)`; 2123 document.getElementById('results-timestamp').textContent = `Loaded from ${file.name}`; 2124 2125 processResults({ search_results: data }); 2126 hideProgress(); 2127 } catch (err) { 2128 appModal('Invalid JSON file: ' + err.message, 'error'); 2129 } 2130 }; 2131 reader.readAsText(file); 2132 e.target.value = ''; 2133 }); 2134 2135 // ===== COPY TOOLTIP (hover) ===== 2136 (function() { 2137 let activeTooltip = null; 2138 let hideTimer = null; 2139 2140 function closeTooltip() { 2141 if (activeTooltip) { 2142 activeTooltip.remove(); 2143 activeTooltip = null; 2144 } 2145 } 2146 2147 function showTooltip(target) { 2148 const fullText = target.dataset.full; 2149 if (!fullText) return; 2150 2151 closeTooltip(); 2152 clearTimeout(hideTimer); 2153 2154 const rect = target.getBoundingClientRect(); 2155 const tooltip = document.createElement('div'); 2156 tooltip.className = 'copy-tooltip'; 2157 tooltip.innerHTML = `<span class="copy-tooltip-text">${escAttr(fullText)}</span><span class="copy-tooltip-hint">click to copy</span>`;
2158 2159 document.body.appendChild(tooltip); 2160 2161 let top = rect.bottom + 4; 2162 let left = rect.left; 2163 const ttRect = tooltip.getBoundingClientRect(); 2164 if (left + ttRect.width > window.innerWidth - 12) left = window.innerWidth - ttRect.width - 12; 2165 if (top + ttRect.height > window.innerHeight - 12) top = rect.top - ttRect.height - 4; 2166 tooltip.style.top = top + 'px'; 2167 tooltip.style.left = Math.max(8, left) + 'px'; 2168 2169 tooltip.addEventListener('click', function() { 2170 navigator.clipboard.writeText(fullText).then(() => { 2171 tooltip.querySelector('.copy-tooltip-hint').textContent = 'copied!'; 2172 setTimeout(closeTooltip, 600); 2173 }); 2174 }); 2175 2176 // Keep tooltip open while hovering it 2177 tooltip.addEventListener('mouseenter', () => clearTimeout(hideTimer)); 2178 tooltip.addEventListener('mouseleave', () => { hideTimer = setTimeout(closeTooltip, 200); }); 2179 2180 activeTooltip = tooltip; 2181 } 2182 2183 document.addEventListener('mouseover', function(e) { 2184 const target = e.target.closest('.copyable'); 2185 if (!target || !target.dataset.full) return; 2186 showTooltip(target); 2187 }); 2188 2189 document.addEventListener('mouseout', function(e) { 2190 const target = e.target.closest('.copyable'); 2191 if (!target) return; 2192 hideTimer = setTimeout(closeTooltip, 200); 2193 }); 2194 2195 document.addEventListener('keydown', function(e) { 2196 if (e.key === 'Escape') closeTooltip(); 2197 }); 2198 })(); 2199 2200 // ===== DORK ENRICHMENT ===== 2201 function fetchDorkResults(taskId) { 2202 fetch('/api/dork-results/' + taskId) 2203 .then(r => r.json()) 2204 .then(data => { 2205 if (!data.results || data.results.length === 0) return; 2206 lastDorkData = data; 2207 renderDorkResults(data); 2208 }) 2209 .catch(err => console.error('Failed to fetch dork results:', err)); 2210 } 2211 2212 function highlightUsername(text, username) { 2213 if (!text || !username) return text; 2214 const escaped = username.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); 2215 return text.replace(new RegExp(`(${escaped})`, 'gi'), '<mark class="hl">$1</mark>'); 2216 } 2217 2218 function renderDorkResults(data) { 2219 const wrapper = document.getElementById('dork-results-container'); 2220 wrapper.innerHTML = ''; 2221 wrapper.style.display = 'block'; 2222 2223 const sec = createCollapsibleSection( 2224 `<i class="iconoir-search-engine"></i> ENRICHMENT (${data.total_results})`, 2225 true 2226 ); 2227 2228 const u = currentUsername; 2229 let html = '<div class="dork-list">'; 2230 data.results.forEach(r => { 2231 const title = highlightUsername(escAttr(r.title || 'Untitled'), u); 2232 const snippet = r.snippet ? highlightUsername(escAttr(r.snippet), u) : ''; 2233 html += ` 2234 <a class="dork-hit" href="${escAttr(r.url)}" target="_blank" rel="noopener noreferrer"> 2235 <div class="dork-hit-head"> 2236 <span class="dork-hit-title">${title}</span> 2237 ${r.source_dork ? `<span class="dork-hit-tag">${escAttr(r.source_dork)}</span>` : ''} 2238 </div> 2239 <div class="dork-hit-url">${escAttr(r.domain || r.url)}</div> 2240 ${snippet ? `<div class="dork-hit-snippet">${snippet}</div>` : ''} 2241 </a>`; 2242 }); 2243 html += '</div>'; 2244 sec.body.innerHTML = html; 2245 wrapper.appendChild(sec.wrapper); 2246 } 2247 2248 // ===== WATCHLIST ===== 2249 const WATCHLIST_FREQUENCIES = [ 2250 { value: 'daily', icon: 'iconoir-sun-light', label: 'Daily' }, 2251 { value: 'weekly', icon: 'iconoir-calendar', label: 'Weekly' }, 2252 { value: 'monthly', icon: 'iconoir-calendar-plus', label: 'Monthly' }, 2253 { value: 'manual', icon: 'iconoir-cursor-pointer',label: 'Manual' }, 2254 ]; 2255 2256 let selectedFrequency = 'weekly'; 2257 2258 function buildFrequencyPicker() { 2259 return `<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:8px;" id="watchlist-freq-picker"> 2260 ${WATCHLIST_FREQUENCIES.map(f => `
2261 <button type="button" data-freq="${f.value}" 2262 class="wl-freq-btn" 2263 style="display:flex;flex-direction:column;align-items:center;gap:6px;padding:12px 8px; 2264 border-radius:8px;border:2px solid ${f.value === selectedFrequency ? 'var(--accent)' : 'var(--border-color)'}; 2265 background:${f.value === selectedFrequency ? 'var(--accent-bg)' : 'var(--bg-surface-hover)'}; 2266 color:inherit;cursor:pointer;transition:all 0.15s;" 2267 onclick="selectFrequency('${f.value}')"> 2268 <i class="${f.icon}" style="font-size:1.3rem;${f.value === selectedFrequency ? 'color:var(--accent);' : ''}"></i> 2269 <span style="font-weight:600;font-size:0.9rem;">${f.label}</span> 2270 </button> 2271 `).join('')} 2272 </div>`; 2273 } 2274 2275 function selectFrequency(freq) { 2276 selectedFrequency = freq; 2277 document.querySelectorAll('.wl-freq-btn').forEach(btn => { 2278 const isSelected = btn.dataset.freq === freq; 2279 btn.style.borderColor = isSelected ? 'var(--accent)' : 'var(--border-color)'; 2280 btn.style.background = isSelected ? 'var(--accent-bg)' : 'var(--bg-surface-hover)'; 2281 const icon = btn.querySelector('i'); 2282 if (icon) icon.style.color = isSelected ? 'var(--accent)' : ''; 2283 }); 2284 2285 // Update consent text for manual mode 2286 const costNote = document.getElementById('wl-cost-note'); 2287 if (costNote) { 2288 costNote.textContent = freq === 'manual' 2289 ? 'Each manual check costs 1 token.' 2290 : 'Each automatic re-scan costs 1 token.'; 2291 } 2292 } 2293 2294 function showWatchlistModal() { 2295 if (!currentUsername || resultsCount === 0) { 2296 appModal('Run a search before adding to the watchlist.', 'warning'); 2297 return; 2298 } 2299 2300 selectedFrequency = 'weekly'; 2301 2302 let existing = document.getElementById('watchlist-modal-overlay'); 2303 if (existing) existing.remove(); 2304 2305 const overlay = document.createElement('div'); 2306 overlay.id = 'watchlist-modal-overlay'; 2307 overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:var(--bg-scrim);z-index:10000;display:flex;align-items:center;justify-content:center;'; 2308 overlay.innerHTML = ` 2309 <div style="background:var(--bg-surface);border:1px solid var(--border-color);border-radius:12px;padding:30px;max-width:560px;width:90%;color:var(--text-primary);"> 2310 <h3 style="margin:0 0 20px;display:flex;align-items:center;gap:8px;"> 2311 <i class="iconoir-bell"></i> Add to Watchlist 2312 </h3> 2313 <p>Monitor <strong>"${currentUsername}"</strong> and get notified by email when changes are detected.</p> 2314 2315 <div style="margin:15px 0;"> 2316 <label style="display:block;margin-bottom:10px;font-weight:600;">Check frequency</label> 2317 ${buildFrequencyPicker()} 2318 </div> 2319 2320 <div style="margin:15px 0;"> 2321 <label style="display:flex;align-items:center;gap:10px;cursor:pointer;padding:10px 14px;border:1px solid var(--border-color);border-radius:8px;background:var(--bg-surface-hover);"> 2322 <input type="checkbox" id="watchlist-enrich" ${document.getElementById('enrich-checkbox')?.checked ? 'checked' : ''}> 2323 <span style="font-size:0.9rem;"><i class="iconoir-search-engine"></i> Include enrichment â aggregate mentions, profiles & emails from the web on each scan</span> 2324 <span style="background:var(--accent);color:var(--text-on-accent);padding:1px 6px;border-radius:8px;font-size:0.75rem;">+1 tk/scan</span> 2325 </label> 2326 </div> 2327 2328 <div style="background:var(--warning-bg);border:1px solid var(--warning-border);border-radius:8px;padding:15px;margin:15px 0;"> 2329 <label style="display:flex;align-items:flex-start;gap:10px;cursor:pointer;"> 2330 <input type="checkbox" id="watchlist-consent" style="margin-top:3px;flex-shrink:0;">
2331 <span style="font-size:0.9rem;"> 2332 I agree that search results will be stored <strong>encrypted</strong> (AES-256-GCM, envelope encryption DEK/KEK) in the database for comparison purposes. 2333 Only my account can decrypt this data. <span id="wl-cost-note">Each automatic re-scan costs 1 token.</span> 2334 </span> 2335 </label> 2336 </div> 2337 2338 <div style="display:flex;gap:10px;justify-content:flex-end;margin-top:20px;"> 2339 <button onclick="document.getElementById('watchlist-modal-overlay').remove()" class="btn btn-ghost">Cancel</button> 2340 <button onclick="addToWatchlist()" class="btn btn-default" id="watchlist-submit-btn"> 2341 <i class="iconoir-bell"></i> Watch 2342 </button> 2343 </div> 2344 </div> 2345 `; 2346 document.body.appendChild(overlay); 2347 2348 overlay.addEventListener('click', function(e) { 2349 if (e.target === overlay) overlay.remove(); 2350 }); 2351 } 2352 2353 function addToWatchlist() { 2354 const consent = document.getElementById('watchlist-consent').checked; 2355 if (!consent) { 2356 appModal('You must accept the encrypted storage terms.', 'warning'); 2357 return; 2358 } 2359 2360 const btn = document.getElementById('watchlist-submit-btn'); 2361 btn.disabled = true; 2362 btn.innerHTML = '<i class="iconoir-refresh-double"></i> Adding...'; 2363 2364 fetch('/app/watchlist/add', { 2365 method: 'POST', 2366 headers: { 2367 'Content-Type': 'application/json', 2368 'X-CSRF-Token': 'FiprwQqLQ3TyfHoAK3S7DlOUN78MRF7ZXMrvADR4af0' 2369 }, 2370 body: JSON.stringify({ 2371 username: currentUsername, 2372 frequency: selectedFrequency, 2373 consent_accepted: true, 2374 enrich_dorks: document.getElementById('watchlist-enrich').checked, 2375 results: searchResults 2376 }) 2377 }) 2378 .then(r => r.json()) 2379 .then(data => { 2380 document.getElementById('watchlist-modal-overlay').remove(); 2381 if (data.success) { 2382 appModal(data.message || 'Added to watchlist!', 'success'); 2383 } else { 2384 appModal(data.error || 'Error', 'error'); 2385 } 2386 }) 2387 .catch(err => { 2388 document.getElementById('watchlist-modal-overlay').remove(); 2389 appModal('Network error: ' + err.message, 'error'); 2390 }); 2391 } 2392</script>
2392 2393
2394<script>(function(){function c(){var b=a.contentDocument||(a.contentWindow&&a.contentWindow.document);if(b){var d=b.createElement('script');d.innerHTML="window.__CF$cv$params={r:'a44fb4274fd750d3',t:'MTc5MTA2OTkwMQ=='};var a=document.createElement('script');a.src='/cdn-cgi/challenge-platform/scripts/jsd/main.js';document.getElementsByTagName('head')[0].appendChild(a);";b.getElementsByTagName('head')[0].appendChild(d)}}if(document.body){var a=document.createElement('iframe');a.height=1;a.width=1;a.style.position='absolute';a.style.top=0;a.style.left=0;a.style.border='none';a.style.visibility='hidden';document.body.appendChild(a);if('loading'!==document.readyState)c();else if(window.addEventListener)document.addEventListener('DOMContentLoaded',c);else{var e=document.onreadystatechange||function(){};document.onreadystatechange=function(b){e(b);'loading'!==document.readyState&&(document.onreadystatechange=e,c())}}}})();</script>
2394<script type="module" src="https://static.cloudflareinsights.com/beacon.min.js/v31edd6df95cf4e85bb4c19e7a9bdbcba1788362987495" integrity="sha512-iIg7k2xntmwu6/uSb5tpc/hySgZc4eoL31yB29W6tJFo2akwjPWcEqnCEdJvGexCL0KEQwVYv5BlowfhVz26hg==" data-cf-beacon='{"version":"2024.11.0","token":"637ceef61b4f42548e03fc7b0a0bf6ee","r":1,"spa":2}' crossorigin="anonymous"></script>
2394 2395</body> 2396</html>
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.