clearnet_view.js 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252
  1. const { a, br, div, input, span } = require("../server/node_modules/hyperaxe");
  2. const escapeHtml = (s) => String(s || '')
  3. .replace(/&/g, '&')
  4. .replace(/</g, '&lt;')
  5. .replace(/>/g, '&gt;')
  6. .replace(/"/g, '&quot;')
  7. .replace(/'/g, '&#39;');
  8. const blobIdOf = (v) => {
  9. if (!v) return null;
  10. const s = String(v).trim();
  11. if (!s) return null;
  12. if (s.startsWith('&')) return s;
  13. const m = s.match(/\((&[^)]+\.sha256)\)/);
  14. if (m) return m[1];
  15. return null;
  16. };
  17. const blobUrl = (v) => {
  18. const id = blobIdOf(v);
  19. return id ? `/c/blob/${encodeURIComponent(id)}` : null;
  20. };
  21. const renderReachChip = (isClearnet, i18nObj = {}, href = null) => {
  22. const icon = isClearnet ? '🌐' : '🏝';
  23. const label = isClearnet
  24. ? (i18nObj.shopReachClearnet || 'Clearnet')
  25. : (i18nObj.shopReachOasis || 'Oasis');
  26. const chip = span({ class: `pm-exposition-chip pm-exposition-${isClearnet ? 'whole' : 'mutuals'}` },
  27. span({ class: 'pm-exposition-icon' }, icon),
  28. span({ class: 'pm-exposition-text' }, label)
  29. );
  30. if (href && isClearnet) {
  31. return a({ href, target: '_blank', rel: 'noopener noreferrer', class: 'pm-exposition-chip-link' }, chip);
  32. }
  33. return chip;
  34. };
  35. const INTERNAL_OASIS_PATHS = [
  36. 'author','thread','hashtag','inbox','pm','profile','settings','banking','wallet',
  37. 'jobs','events','projects','shops','audios','videos','images','documents','torrents',
  38. 'tribes','tribe','forum','votes','votations','reports','tasks','maps','chats','pads',
  39. 'calendars','trending','opinions','feed','pixelia','cv','invites','peers','stats',
  40. 'blockexplorer','modules','publish','search','tags','mentions','popular','threads',
  41. 'topics','latest','summaries','multiverse','legacy','cipher','graphos','agenda',
  42. 'favorites','logs','games','parliament','courts','market','ai','public','spread',
  43. 'follow','unfollow','block','like','unlike'
  44. ];
  45. const stripInternalAnchors = (html) => {
  46. if (typeof html !== 'string' || !html) return html;
  47. const list = INTERNAL_OASIS_PATHS.join('|');
  48. const hrefedClosed = new RegExp(`<a\\b[^>]*\\bhref=["']\\/(?:${list})\\/[^"']*["'][^>]*>([\\s\\S]*?)<\\/a>`, 'gi');
  49. const hrefedBare = new RegExp(`<a\\b[^>]*\\bhref=["']\\/(?:${list})["'][^>]*>([\\s\\S]*?)<\\/a>`, 'gi');
  50. return html.replace(hrefedClosed, '$1').replace(hrefedBare, '$1');
  51. };
  52. const renderClearnetSearchForm = ({ authorFeedId = '', query = '', placeholder = 'Search…' }) => {
  53. if (!authorFeedId) return '';
  54. const safeQuery = escapeHtml(query || '');
  55. const safePh = escapeHtml(placeholder);
  56. return `<form class="cn-search" method="GET" action="/c/inhabitant/${encodeURIComponent(authorFeedId)}"><input type="text" name="q" value="${safeQuery}" placeholder="${safePh}" autocomplete="off"/></form>`;
  57. };
  58. const renderClearnetUrlBlock = ({ baseUrl = '', path, i18nObj = {} }) => {
  59. return div({ class: 'shop-clearnet-url' },
  60. a({ href: path, target: '_blank', rel: 'noopener noreferrer', class: 'clearnet-link' }, path)
  61. );
  62. };
  63. const CLEARNET_SEARCH_CSS = `
  64. .cn-search{margin:0}
  65. .cn-search input[type=text]{width:240px;max-width:100%;background:var(--bg-sub);color:var(--fg);border:1px solid var(--border);border-radius:6px;padding:8px 12px;font-size:14px;font-family:inherit}
  66. .cn-search input[type=text]:focus{outline:none;border-color:var(--fg)}
  67. `;
  68. const THEME_PALETTES = {
  69. 'Dark-SNH': {
  70. bg: '#121212', bgElev: '#1C1C1C', bgSub: '#222',
  71. fg: '#FFD700', fgSoft: '#E6C200', fgDim: '#9a8a2e',
  72. border: '#333', accent: '#FFDD44',
  73. font: "system-ui,-apple-system,sans-serif"
  74. },
  75. 'OasisMobile': {
  76. bg: '#121212', bgElev: '#1C1C1C', bgSub: '#222',
  77. fg: '#FFD700', fgSoft: '#E6C200', fgDim: '#9a8a2e',
  78. border: '#333', accent: '#FFDD44',
  79. font: "system-ui,-apple-system,sans-serif"
  80. },
  81. 'Clear-SNH': {
  82. bg: '#F9F9F9', bgElev: '#FFFFFF', bgSub: '#F0F0F0',
  83. fg: '#2C2C2C', fgSoft: '#555555', fgDim: '#888888',
  84. border: '#E0E0E0', accent: '#FF6F00',
  85. font: "'Roboto',sans-serif"
  86. },
  87. 'Matrix-SNH': {
  88. bg: '#000000', bgElev: '#0a0a0a', bgSub: '#050505',
  89. fg: '#00FF00', fgSoft: '#00CC00', fgDim: '#008800',
  90. border: '#00FF00', accent: '#66FF66',
  91. font: "'Courier New',monospace"
  92. },
  93. 'Purple-SNH': {
  94. bg: '#4B0A6D', bgElev: '#39006D', bgSub: '#6A0066',
  95. fg: '#E5E5E5', fgSoft: '#C8C8C8', fgDim: '#9B7CAA',
  96. border: '#9B1C96', accent: '#9B1C96',
  97. font: "'Arial',sans-serif"
  98. }
  99. };
  100. const getCurrentPalette = () => {
  101. try {
  102. const { getConfig } = require('../configs/config-manager.js');
  103. const theme = getConfig()?.themes?.current || 'Dark-SNH';
  104. return THEME_PALETTES[theme] || THEME_PALETTES['Dark-SNH'];
  105. } catch (_) {
  106. return THEME_PALETTES['Dark-SNH'];
  107. }
  108. };
  109. const buildBaseCss = (p) => `
  110. :root{
  111. --bg:${p.bg}; --bg-elev:${p.bgElev}; --bg-sub:${p.bgSub};
  112. --fg:${p.fg}; --fg-soft:${p.fgSoft}; --fg-dim:${p.fgDim};
  113. --border:${p.border}; --border-strong:${p.border};
  114. --accent:${p.accent};
  115. }
  116. *{box-sizing:border-box}
  117. body{background:var(--bg);color:var(--fg);font-family:${p.font};max-width:960px;margin:0 auto;padding:32px 24px;line-height:1.5}
  118. a{color:var(--fg);text-decoration:none}
  119. a:hover{color:var(--accent);text-decoration:underline}
  120. header.cn-header{display:flex;align-items:center;gap:16px;padding-bottom:16px;margin-bottom:24px;border-bottom:1px solid var(--border);flex-wrap:wrap}
  121. .cn-brand-block{flex:0 0 auto}
  122. .cn-brand{font-size:20px;font-weight:700;color:var(--fg);letter-spacing:1px}
  123. .cn-brand-sub{color:var(--fg-dim);font-size:12px;text-transform:uppercase;letter-spacing:2px;margin-top:2px}
  124. .cn-header-extra{flex:1 1 auto;display:flex;justify-content:flex-end;align-items:center;min-width:0}
  125. h2.cn-section{color:var(--fg);font-size:18px;text-transform:uppercase;letter-spacing:2px;margin:32px 0 16px 0;padding-bottom:8px;border-bottom:1px solid var(--border)}
  126. footer.cn-footer{margin-top:48px;padding-top:20px;border-top:1px solid var(--border);font-size:12px;color:var(--fg-dim);text-align:center;letter-spacing:0.5px}
  127. footer.cn-footer a{color:var(--fg-soft)}
  128. footer.cn-footer .cn-footer-logo{width:56px;height:auto;display:block;margin:0 auto 10px auto;border-radius:6px}
  129. `;
  130. const renderClearnetPage = ({ title, ogTitle, ogDescription = '', ogImage = null, extraCss = '', body, headerExtra = '', hubFeedId = null }) => {
  131. const safeTitle = escapeHtml(title || 'Oasis');
  132. const safeOgTitle = escapeHtml(ogTitle || title || 'Oasis');
  133. const safeOgDesc = escapeHtml(ogDescription || '');
  134. const palette = getCurrentPalette();
  135. const baseCss = buildBaseCss(palette);
  136. const brandInner = `<div class="cn-brand">⛱ Oasis HUB</div><div class="cn-brand-sub">Libre · P2P · Federated</div>`;
  137. const brandBlock = hubFeedId
  138. ? `<a class="cn-brand-block cn-brand-link" href="/c/inhabitant/${encodeURIComponent(hubFeedId)}">${brandInner}</a>`
  139. : `<div class="cn-brand-block">${brandInner}</div>`;
  140. return `<!DOCTYPE html>
  141. <html lang="en">
  142. <head>
  143. <meta charset="utf-8"/>
  144. <meta name="viewport" content="width=device-width, initial-scale=1"/>
  145. <title>${safeTitle}</title>
  146. <meta property="og:title" content="${safeOgTitle}"/>
  147. <meta property="og:description" content="${safeOgDesc}"/>
  148. ${ogImage ? `<meta property="og:image" content="${ogImage}"/>` : ''}
  149. <meta name="description" content="${safeOgDesc}"/>
  150. <meta name="robots" content="index, follow"/>
  151. <style>${baseCss}${CLEARNET_SEARCH_CSS}${extraCss}
  152. .cn-brand-link{display:block;text-decoration:none}
  153. .cn-brand-link:hover .cn-brand{color:var(--accent)}
  154. .cn-brand-link:hover{text-decoration:none}
  155. </style>
  156. </head>
  157. <body>
  158. <header class="cn-header">
  159. ${brandBlock}
  160. ${headerExtra ? `<div class="cn-header-extra">${headerExtra}</div>` : ''}
  161. </header>
  162. ${stripInternalAnchors(body)}
  163. <footer class="cn-footer">
  164. <a href="https://code.03c8.net/krakenslab/oasis" target="_blank" rel="noopener"><img class="cn-footer-logo" src="/assets/images/snh-oasis.jpg" alt="Oasis"/></a>
  165. Powered by <a href="https://code.03c8.net/krakenslab/oasis" target="_blank" rel="noopener">Oasis</a>
  166. </footer>
  167. </body>
  168. </html>`;
  169. };
  170. const renderClearnetNotFound = () => {
  171. return renderClearnetPage({
  172. title: 'Oasis',
  173. ogTitle: 'Oasis',
  174. ogDescription: '',
  175. extraCss: `.cn-notfound{color:var(--fg-soft);font-size:16px;max-width:480px;margin:80px auto 40px auto;text-align:center;line-height:1.5}`,
  176. body: `<p class="cn-notfound">The content is not accessible at this moment.</p>`
  177. });
  178. };
  179. const renderClearnetMediaView = ({ kind, item }) => {
  180. const blob = blobUrl(item.url);
  181. const title = escapeHtml(item.title || 'Untitled');
  182. const desc = escapeHtml(item.description || '');
  183. const dateStr = item.createdAt ? escapeHtml(new Date(item.createdAt).toISOString().slice(0, 10)) : '';
  184. const extraCss = `
  185. .cn-media-meta{color:var(--fg-dim);font-size:13px;margin-bottom:16px;display:flex;gap:14px;flex-wrap:wrap;align-items:baseline}
  186. .cn-id-meta{font-family:monospace;font-size:11px;word-break:break-all;color:var(--fg-dim)}
  187. .cn-media-title{color:var(--fg);font-size:26px;font-weight:700;margin:0 0 12px 0}
  188. .cn-media-desc{color:var(--fg-soft);white-space:pre-wrap;line-height:1.6;margin:16px 0}
  189. .cn-media-frame{margin:16px 0}
  190. .cn-media-frame img{max-width:100%;height:auto;border-radius:6px;border:1px solid var(--border);display:block}
  191. .cn-media-frame audio,.cn-media-frame video{width:100%;max-width:100%;display:block;border-radius:6px;background:#000}
  192. .cn-media-frame .cn-media-doc{display:inline-block;background:var(--bg-elev);border:1px solid var(--border);border-radius:6px;padding:10px 18px;color:var(--fg);text-decoration:none}
  193. .cn-media-frame .cn-media-doc:hover{border-color:var(--fg)}
  194. `;
  195. let mediaHtml = '';
  196. if (blob) {
  197. if (kind === 'image') {
  198. mediaHtml = `<img src="${blob}" alt="${title}"/>`;
  199. } else if (kind === 'audio') {
  200. mediaHtml = `<audio controls preload="metadata" src="${blob}"></audio>`;
  201. } else if (kind === 'video') {
  202. mediaHtml = `<video controls preload="metadata" src="${blob}"></video>`;
  203. } else if (kind === 'document' || kind === 'torrent') {
  204. mediaHtml = `<a class="cn-media-doc" href="${blob}" target="_blank" rel="noopener">⇩ ${title}</a>`;
  205. }
  206. }
  207. const body = `
  208. <div class="cn-media-meta">
  209. ${dateStr ? `<span>📅 ${dateStr}</span>` : ''}
  210. </div>
  211. <h1 class="cn-media-title">${title}</h1>
  212. ${mediaHtml ? `<div class="cn-media-frame">${mediaHtml}</div>` : ''}
  213. ${desc ? `<p class="cn-media-desc">${desc}</p>` : ''}
  214. `;
  215. return renderClearnetPage({
  216. title: `${title} — Oasis`,
  217. ogTitle: item.title || 'Oasis',
  218. ogDescription: item.description || '',
  219. ogImage: (kind === 'image') ? blob : null,
  220. extraCss,
  221. body,
  222. hubFeedId: item.author || null
  223. });
  224. };
  225. module.exports = {
  226. escapeHtml,
  227. blobIdOf,
  228. blobUrl,
  229. renderReachChip,
  230. renderClearnetUrlBlock,
  231. renderClearnetSearchForm,
  232. renderClearnetPage,
  233. renderClearnetNotFound,
  234. renderClearnetMediaView
  235. };