Browse Source

Oasis release 0.7.2

psy 2 days ago
parent
commit
4c0ed617a5
43 changed files with 1668 additions and 175 deletions
  1. 1 0
      README.md
  2. 14 0
      docs/CHANGELOG.md
  3. 68 19
      src/backend/backend.js
  4. 15 3
      src/backend/blobHandler.js
  5. 1 0
      src/backend/media-favorites.js
  6. 8 1
      src/client/assets/styles/style.css
  7. 50 2
      src/client/assets/translations/oasis_ar.js
  8. 55 7
      src/client/assets/translations/oasis_de.js
  9. 55 7
      src/client/assets/translations/oasis_en.js
  10. 55 7
      src/client/assets/translations/oasis_es.js
  11. 55 7
      src/client/assets/translations/oasis_eu.js
  12. 55 7
      src/client/assets/translations/oasis_fr.js
  13. 50 2
      src/client/assets/translations/oasis_hi.js
  14. 55 7
      src/client/assets/translations/oasis_it.js
  15. 55 7
      src/client/assets/translations/oasis_pt.js
  16. 55 7
      src/client/assets/translations/oasis_ru.js
  17. 50 2
      src/client/assets/translations/oasis_zh.js
  18. 1 1
      src/client/middleware.js
  19. 2 1
      src/configs/config-manager.js
  20. 3 1
      src/configs/media-favorites.json
  21. 2 1
      src/configs/oasis-config.json
  22. 7 7
      src/models/chats_model.js
  23. 7 2
      src/models/favorites_model.js
  24. 29 8
      src/models/jobs_model.js
  25. 4 1
      src/models/search_model.js
  26. 68 46
      src/models/shops_model.js
  27. 1 1
      src/models/stats_model.js
  28. 323 0
      src/models/torrents_model.js
  29. 1 1
      src/server/package-lock.json
  30. 1 1
      src/server/package.json
  31. 18 1
      src/views/activity_view.js
  32. 3 2
      src/views/blockchain_view.js
  33. 4 0
      src/views/favorites_view.js
  34. 3 2
      src/views/forum_view.js
  35. 17 1
      src/views/main_views.js
  36. 2 1
      src/views/maps_view.js
  37. 1 0
      src/views/modules_view.js
  38. 9 0
      src/views/opinions_view.js
  39. 22 1
      src/views/search_view.js
  40. 2 1
      src/views/stats_view.js
  41. 404 0
      src/views/torrents_view.js
  42. 16 1
      src/views/trending_view.js
  43. 21 9
      src/views/tribes_view.js

+ 1 - 0
README.md

@@ -101,6 +101,7 @@ Oasis is TRULY MODULAR. Here's a list of what comes deployed with the "core".
  + Tasks: Module to discover and manage tasks.	
  + Threads: Module to receive conversations grouped by topic or question.
  + Topics: Module to receive discussion categories based on shared interests.	
+ + Torrents: Module to explore and manage torrents.
  + Transfers: Module to discover and manage smart-contracts (transfers).	
  + Trending: Module to explore the most popular content.	
  + Tribes: Module to explore or create tribes (groups).	

+ 14 - 0
docs/CHANGELOG.md

@@ -13,6 +13,20 @@ All notable changes to this project will be documented in this file.
 ### Security
 -->
 
+## v0.7.2 - 2026-04-16
+
+### Added
+
+- Torrents module: create, manage and share torrents (Torrents plugin).
+
+### Changed
+
+- More layers of privacy/encryption applied to sensitive content at different places (Core plugin).
+
+### Fixed
+
+- Chat participants (Chats plugin).
+
 ## v0.7.1 - 2026-04-14
 
 ### Added

File diff suppressed because it is too large
+ 68 - 19
src/backend/backend.js


+ 15 - 3
src/backend/blobHandler.js

@@ -80,7 +80,8 @@ const handleBlobUpload = async function (ctx, fileFieldName) {
     '.flac': 'audio/flac', '.aac': 'audio/aac', '.opus': 'audio/opus',
     '.pdf': 'application/pdf', '.png': 'image/png', '.jpg': 'image/jpeg',
     '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp',
-    '.svg': 'image/svg+xml', '.bmp': 'image/bmp'
+    '.svg': 'image/svg+xml', '.bmp': 'image/bmp',
+    '.torrent': 'application/x-bittorrent'
   };
 
   const blob = { name: blobUpload.originalFilename || blobUpload.name || 'file' };
@@ -101,7 +102,7 @@ const handleBlobUpload = async function (ctx, fileFieldName) {
     blob.mime = 'application/octet-stream';
   }
 
-  if (blob.mime.startsWith('image/')) {
+  if (blob.mime.startsWith('image/') && blob.mime !== 'image/gif') {
     data = await stripImageMetadata(data);
   } else if (blob.mime === 'application/pdf') {
     data = stripPdfMetadata(data);
@@ -120,6 +121,7 @@ const handleBlobUpload = async function (ctx, fileFieldName) {
   if (blob.mime.startsWith("audio/")) return `\n[audio:${blob.name}](${blob.id})`;
   if (blob.mime.startsWith("video/")) return `\n[video:${blob.name}](${blob.id})`;
   if (blob.mime === "application/pdf") return `[pdf:${blob.name}](${blob.id})`;
+  if (blob.mime === "application/x-bittorrent") return `\n[torrent:${blob.name}](${blob.id})`;
 
   return `\n[${blob.name}](${blob.id})`;
 };
@@ -213,8 +215,18 @@ const serveBlob = async function (ctx) {
     if (ft && ft.mime) mime = ft.mime;
   } catch {}
 
+  if (mime === 'application/octet-stream' && buffer.length > 10 && buffer[0] === 0x64) {
+    const head = buffer.slice(0, 128).toString('ascii');
+    if (head.includes('announce') || head.includes('8:announce') || head.includes('4:info')) mime = 'application/x-bittorrent';
+  }
+
+  const isSvg = mime === 'image/svg+xml';
+  const qName = ctx.query.name ? String(ctx.query.name).replace(/["\r\n\\]/g, '').trim() : '';
+  const safeRaw = String(raw).replace(/["\r\n\\]/g, '');
+  const filename = qName || (mime === 'application/x-bittorrent' ? 'download.torrent' : safeRaw);
+  const disposition = isSvg ? 'attachment' : 'inline';
   ctx.type = mime;
-  ctx.set('Content-Disposition', `inline; filename="${raw}"`);
+  ctx.set('Content-Disposition', `${disposition}; filename="${filename}"`);
   ctx.set('Cache-Control', 'public, max-age=31536000, immutable');
 
   const range = ctx.headers.range;

+ 1 - 0
src/backend/media-favorites.js

@@ -13,6 +13,7 @@ const DEFAULT = {
   maps: [],
   pads: [],
   shops: [],
+  torrents: [],
   videos: []
 };
 

+ 8 - 1
src/client/assets/styles/style.css

@@ -3868,6 +3868,7 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
   align-items: center;
   margin-bottom: 16px;
 }
+.tribe-search-form{display:flex;gap:10px;align-items:center}
 
 .tribe-content-filters {
   display: flex;
@@ -5013,9 +5014,15 @@ width:100%; max-width:200px; max-height:300px; object-fit:cover; margin:16px 0;
 .calendar-date-item{background:#333;padding:18px 108px 18px 18px;border-radius:6px;margin-bottom:14px;position:relative;min-height:64px}
 .calendar-date-delete{position:absolute;top:12px;right:12px;margin:0}
 .calendar-date-delete button{padding:4px 10px;font-size:0.8rem}
-.calendar-date-item-header{color:#FFA500;font-weight:bold;margin-bottom:6px}
+.calendar-date-item-header{color:#FFA500;font-weight:bold;margin-bottom:6px;width:max-content}
 .calendar-participants-count{color:#FFA500;font-weight:bold}
 .calendar-day-notes{margin-top:16px}
 .pad-viewer-back{margin-bottom:12px}
 
 .access-denied-msg{margin:12px 0;padding:12px;background:#1a1a1a;border:1px solid #555;border-radius:4px;color:#ccc;font-style:italic}
+
+.torrent-table{width:100%;border-collapse:collapse;margin-top:12px}
+.torrent-table th,.torrent-table td{padding:8px 12px;text-align:left}
+.torrent-table th{background:#1a1a1a;color:#FFA500;font-weight:bold}
+.torrent-download{margin:12px 0}
+.torrent-download .filter-btn{border:none}

+ 50 - 2
src/client/assets/translations/oasis_ar.js

@@ -365,6 +365,7 @@ module.exports = {
     videoLabel: "الفيديوهات",
     audioLabel: "الملفات الصوتية",
     documentLabel: "المستندات",
+    torrentLabel: "التورنت",
     pdfFallbackLabel: "مستند PDF",
     eventLabel: "الأحداث",
     taskLabel: "المهام",
@@ -955,6 +956,7 @@ module.exports = {
     audioButton: "الملفات الصوتية",
     videoButton: "الفيديوهات",
     documentButton: "المستندات",
+    torrentButton: "التورنت",
     author: "بواسطة",
     createdAtLabel: "أُنشئ في",
     totalVotes: "إجمالي الأصوات",
@@ -3029,7 +3031,7 @@ module.exports = {
     calendarMonthNext: "التالي →",
     calendarMonthLabel: "التواريخ",
     calendarsShareUrl: "رابط المشاركة",
-    calendarAllSectionTitle: "جميع التقاويم",
+    calendarAllSectionTitle: "التقاويم",
     calendarRecentSectionTitle: "التقويمات الأحدث",
     calendarFavoritesSectionTitle: "المفضلة",
     calendarMineSectionTitle: "تقويماتك",
@@ -3052,6 +3054,52 @@ module.exports = {
     blockAccessRestricted: "الوصول مقيد",
     tribeContentAccessDenied: "تم رفض الوصول",
     tribeContentAccessDeniedMsg: "هذا المحتوى ينتمي إلى قبيلة. يجب أن تكون عضواً للوصول إليه.",
-    tribeViewTribes: "عرض القبائل"
+    tribeViewTribes: "عرض القبائل",
+    torrentsTitle: "التورنت",
+    torrentsDescription: "استكشف وأدِر التورنت في شبكتك.",
+    torrentAllSectionTitle: "التورنت",
+    torrentMineSectionTitle: "تورنتاتك",
+    torrentRecentSectionTitle: "تورنتات حديثة",
+    torrentTopSectionTitle: "أفضل التورنتات",
+    torrentFavoritesSectionTitle: "المفضلة",
+    torrentFilterAll: "الكل",
+    torrentFilterMine: "خاصتي",
+    torrentFilterRecent: "الحديثة",
+    torrentFilterTop: "الأفضل",
+    torrentFilterFavorites: "المفضلة",
+    torrentCreateSectionTitle: "رفع تورنت",
+    torrentUpdateSectionTitle: "تحديث التورنت",
+    torrentCreateButton: "رفع تورنت",
+    torrentUpdateButton: "تحديث",
+    torrentDeleteButton: "حذف",
+    torrentAddFavoriteButton: "إضافة إلى المفضلة",
+    torrentRemoveFavoriteButton: "إزالة من المفضلة",
+    torrentFileLabel: "اختر ملف تورنت (.torrent)",
+    torrentTitleLabel: "العنوان",
+    torrentTitlePlaceholder: "العنوان",
+    torrentDescriptionLabel: "الوصف",
+    torrentDescriptionPlaceholder: "الوصف",
+    torrentTagsLabel: "الوسوم",
+    torrentTagsPlaceholder: "أدخل الوسوم مفصولة بفواصل",
+    torrentSizeLabel: "الحجم",
+    torrentDownloadButton: "DOWNLOAD IT!",
+    torrentNoFile: "لا يوجد ملف تورنت",
+    noTorrents: "لم يتم العثور على تورنتات.",
+    torrentSearchPlaceholder: "بحث بالعنوان، الوسوم، المؤلف...",
+    torrentSearchButton: "بحث",
+    torrentSortRecent: "الأحدث",
+    torrentSortOldest: "الأقدم",
+    torrentSortTop: "الأكثر تصويتاً",
+    torrentNoMatch: "لا توجد تورنتات مطابقة.",
+    torrentMessageAuthorButton: "مراسلة المؤلف",
+    torrentUpdatedAt: "تم التحديث",
+    statsTorrent: "التورنت",
+    typeTorrent: "التورنت",
+    modulesTorrentsLabel: "التورنت",
+    modulesTorrentsDescription: "وحدة لاكتشاف وإدارة التورنت.",
+    favoritesFilterTorrents: "التورنت",
+    tribeSectionTorrents: "التورنت",
+    tribeCreateTorrent: "رفع تورنت",
+    tribeMediaTypeTorrent: "تورنت"
     }
 };

+ 55 - 7
src/client/assets/translations/oasis_de.js

@@ -362,6 +362,7 @@ module.exports = {
     videoLabel: "VIDEOS",
     audioLabel: "AUDIOS",
     documentLabel: "DOKUMENTE",
+    torrentLabel: "TORRENTS",
     pdfFallbackLabel: "PDF-Dokument",
     eventLabel: "VERANSTALTUNGEN",
     taskLabel: "AUFGABEN",
@@ -951,6 +952,7 @@ module.exports = {
     audioButton: "AUDIOS",
     videoButton: "VIDEOS",
     documentButton: "DOKUMENTE",
+    torrentButton: "TORRENTS",
     author: "Von",
     createdAtLabel: "Erstellt am",
     totalVotes: "Stimmen gesamt",
@@ -1713,12 +1715,12 @@ module.exports = {
     updateTribeTitle: "Stamm aktualisieren",
     tribeSectionOverview: "Übersicht",
     tribeSectionInhabitants: "Bewohner",
-    tribeSectionVotations: "Abstimmungen",
-    tribeSectionEvents: "Termine",
+    tribeSectionVotations: "ABSTIMMUNGEN",
+    tribeSectionEvents: "TERMINE",
     tribeSectionReports: "Berichte",
-    tribeSectionTasks: "Aufgaben",
-    tribeSectionFeed: "Feed",
-    tribeSectionForum: "Forum",
+    tribeSectionTasks: "AUFGABEN",
+    tribeSectionFeed: "FEED",
+    tribeSectionForum: "FORUM",
     tribeSectionMarket: "Markt",
     tribeSectionJobs: "Stellen",
     tribeSectionProjects: "Projekte",
@@ -3025,7 +3027,7 @@ module.exports = {
     calendarMonthNext: "Weiter →",
     calendarMonthLabel: "Daten",
     calendarsShareUrl: "Teilen-URL",
-    calendarAllSectionTitle: "Alle Kalender",
+    calendarAllSectionTitle: "Kalender",
     calendarRecentSectionTitle: "Kürzliche Kalender",
     calendarFavoritesSectionTitle: "Favoriten",
     calendarMineSectionTitle: "Deine Kalender",
@@ -3048,6 +3050,52 @@ module.exports = {
     blockAccessRestricted: "Zugang gesperrt",
     tribeContentAccessDenied: "Zugang Verweigert",
     tribeContentAccessDeniedMsg: "Dieser Inhalt gehört zu einem Stamm. Sie müssen Mitglied sein, um darauf zuzugreifen.",
-    tribeViewTribes: "Stämme Anzeigen"
+    tribeViewTribes: "Stämme Anzeigen",
+    torrentsTitle: "Torrents",
+    torrentsDescription: "Torrents in Ihrem Netzwerk erkunden und verwalten.",
+    torrentAllSectionTitle: "Torrents",
+    torrentMineSectionTitle: "Ihre Torrents",
+    torrentRecentSectionTitle: "Neueste Torrents",
+    torrentTopSectionTitle: "Top Torrents",
+    torrentFavoritesSectionTitle: "Favoriten",
+    torrentFilterAll: "ALLE",
+    torrentFilterMine: "MEINE",
+    torrentFilterRecent: "NEUESTE",
+    torrentFilterTop: "TOP",
+    torrentFilterFavorites: "FAVORITEN",
+    torrentCreateSectionTitle: "Torrent Hochladen",
+    torrentUpdateSectionTitle: "Torrent Aktualisieren",
+    torrentCreateButton: "Torrent Hochladen",
+    torrentUpdateButton: "Aktualisieren",
+    torrentDeleteButton: "Löschen",
+    torrentAddFavoriteButton: "Zu Favoriten Hinzufügen",
+    torrentRemoveFavoriteButton: "Aus Favoriten Entfernen",
+    torrentFileLabel: "Torrent-Datei auswählen (.torrent)",
+    torrentTitleLabel: "Titel",
+    torrentTitlePlaceholder: "Titel",
+    torrentDescriptionLabel: "Beschreibung",
+    torrentDescriptionPlaceholder: "Beschreibung",
+    torrentTagsLabel: "Tags",
+    torrentTagsPlaceholder: "Tags durch Kommas getrennt eingeben",
+    torrentSizeLabel: "Größe",
+    torrentDownloadButton: "DOWNLOAD IT!",
+    torrentNoFile: "Keine Torrent-Datei",
+    noTorrents: "Keine Torrents gefunden.",
+    torrentSearchPlaceholder: "Titel, Tags, Autor suchen...",
+    torrentSearchButton: "Suchen",
+    torrentSortRecent: "Neueste",
+    torrentSortOldest: "Älteste",
+    torrentSortTop: "Meistgewählt",
+    torrentNoMatch: "Keine passenden Torrents.",
+    torrentMessageAuthorButton: "Nachricht an Autor",
+    torrentUpdatedAt: "Aktualisiert",
+    statsTorrent: "Torrents",
+    typeTorrent: "TORRENTS",
+    modulesTorrentsLabel: "Torrents",
+    modulesTorrentsDescription: "Modul zum Entdecken und Verwalten von Torrents.",
+    favoritesFilterTorrents: "TORRENTS",
+    tribeSectionTorrents: "TORRENTS",
+    tribeCreateTorrent: "Torrent Hochladen",
+    tribeMediaTypeTorrent: "Torrent"
     }
 }

+ 55 - 7
src/client/assets/translations/oasis_en.js

@@ -365,6 +365,7 @@ module.exports = {
     videoLabel: "VIDEOS",
     audioLabel: "AUDIOS",
     documentLabel: "DOCUMENTS",
+    torrentLabel: "TORRENTS",
     pdfFallbackLabel: "PDF Document",
     eventLabel: "EVENTS",
     taskLabel: "TASKS",
@@ -956,6 +957,7 @@ module.exports = {
     audioButton: "AUDIOS",
     videoButton: "VIDEOS",
     documentButton: "DOCUMENTS",
+    torrentButton: "TORRENTS",
     author: "By",
     createdAtLabel: "Created At",
     totalVotes: "Total Votes",
@@ -1719,12 +1721,12 @@ module.exports = {
     updateTribeTitle: "Update Tribe",
     tribeSectionOverview: "Overview",
     tribeSectionInhabitants: "Inhabitants",
-    tribeSectionVotations: "Votations",
-    tribeSectionEvents: "Events",
+    tribeSectionVotations: "VOTATIONS",
+    tribeSectionEvents: "EVENTS",
     tribeSectionReports: "Reports",
-    tribeSectionTasks: "Tasks",
-    tribeSectionFeed: "Feed",
-    tribeSectionForum: "Forum",
+    tribeSectionTasks: "TASKS",
+    tribeSectionFeed: "FEED",
+    tribeSectionForum: "FORUM",
     tribeSectionMarket: "Market",
     tribeSectionJobs: "Jobs",
     tribeSectionProjects: "Projects",
@@ -2934,7 +2936,7 @@ module.exports = {
     calendarMonthNext: "Next \u2192",
     calendarMonthLabel: "Dates",
     calendarsShareUrl: "Share URL",
-    calendarAllSectionTitle: "All Calendars",
+    calendarAllSectionTitle: "Calendars",
     calendarRecentSectionTitle: "Recent Calendars",
     calendarFavoritesSectionTitle: "Favorites",
     calendarMineSectionTitle: "Your Calendars",
@@ -3071,7 +3073,53 @@ module.exports = {
     blockAccessRestricted: "Access restricted",
     tribeContentAccessDenied: "Access Denied",
     tribeContentAccessDeniedMsg: "This content belongs to a tribe. You must be a member to access it.",
-    tribeViewTribes: "View Tribes"
+    tribeViewTribes: "View Tribes",
+    torrentsTitle: "Torrents",
+    torrentsDescription: "Explore and manage torrents in your network.",
+    torrentAllSectionTitle: "Torrents",
+    torrentMineSectionTitle: "Your Torrents",
+    torrentRecentSectionTitle: "Recent Torrents",
+    torrentTopSectionTitle: "Top Torrents",
+    torrentFavoritesSectionTitle: "Favorites",
+    torrentFilterAll: "ALL",
+    torrentFilterMine: "MINE",
+    torrentFilterRecent: "RECENT",
+    torrentFilterTop: "TOP",
+    torrentFilterFavorites: "FAVORITES",
+    torrentCreateSectionTitle: "Upload Torrent",
+    torrentUpdateSectionTitle: "Update Torrent",
+    torrentCreateButton: "Upload Torrent",
+    torrentUpdateButton: "Update",
+    torrentDeleteButton: "Delete",
+    torrentAddFavoriteButton: "Add Favorite",
+    torrentRemoveFavoriteButton: "Remove Favorite",
+    torrentFileLabel: "Select torrent file (.torrent)",
+    torrentTitleLabel: "Title",
+    torrentTitlePlaceholder: "Title",
+    torrentDescriptionLabel: "Description",
+    torrentDescriptionPlaceholder: "Description",
+    torrentTagsLabel: "Tags",
+    torrentTagsPlaceholder: "Enter tags separated by commas",
+    torrentSizeLabel: "Size",
+    torrentDownloadButton: "DOWNLOAD IT!",
+    torrentNoFile: "No torrent file",
+    noTorrents: "No torrents found.",
+    torrentSearchPlaceholder: "Search title, tags, author...",
+    torrentSearchButton: "Search",
+    torrentSortRecent: "Most recent",
+    torrentSortOldest: "Oldest",
+    torrentSortTop: "Most voted",
+    torrentNoMatch: "No matching torrents.",
+    torrentMessageAuthorButton: "Message Author",
+    torrentUpdatedAt: "Updated",
+    statsTorrent: "Torrents",
+    typeTorrent: "TORRENTS",
+    modulesTorrentsLabel: "Torrents",
+    modulesTorrentsDescription: "Module to discover and manage torrents.",
+    favoritesFilterTorrents: "TORRENTS",
+    tribeSectionTorrents: "TORRENTS",
+    tribeCreateTorrent: "Upload Torrent",
+    tribeMediaTypeTorrent: "Torrent"
 
     }
 };

+ 55 - 7
src/client/assets/translations/oasis_es.js

@@ -357,6 +357,7 @@ module.exports = {
     videoLabel: "VIDEOS",
     audioLabel: "AUDIOS",
     documentLabel: "DOCUMENTOS",
+    torrentLabel: "TORRENTS",
     pdfFallbackLabel: "Documento PDF",
     eventLabel: "EVENTOS",
     taskLabel: "TAREAS",
@@ -947,6 +948,7 @@ module.exports = {
     audioButton: "AUDIOS",
     videoButton: "VIDEOS",
     documentButton: "DOCUMENTOS",
+    torrentButton: "TORRENTS",
     author: "Por",
     createdAtLabel: "Creado el",
     totalVotes: "Total de Votos",
@@ -1709,12 +1711,12 @@ module.exports = {
     updateTribeTitle: "Actualizar Tribu",
     tribeSectionOverview: "Resumen",
     tribeSectionInhabitants: "Habitantes",
-    tribeSectionVotations: "Votaciones",
-    tribeSectionEvents: "Eventos",
+    tribeSectionVotations: "VOTACIONES",
+    tribeSectionEvents: "EVENTOS",
     tribeSectionReports: "Reportes",
-    tribeSectionTasks: "Tareas",
-    tribeSectionFeed: "Feed",
-    tribeSectionForum: "Foro",
+    tribeSectionTasks: "TAREAS",
+    tribeSectionFeed: "FEED",
+    tribeSectionForum: "FORO",
     tribeSectionMarket: "Mercado",
     tribeSectionJobs: "Empleos",
     tribeSectionProjects: "Proyectos",
@@ -2934,7 +2936,7 @@ module.exports = {
     calendarMonthNext: "Siguiente \u2192",
     calendarMonthLabel: "Fechas",
     calendarsShareUrl: "URL para compartir",
-    calendarAllSectionTitle: "Todos los Calendarios",
+    calendarAllSectionTitle: "Calendarios",
     calendarRecentSectionTitle: "Calendarios Recientes",
     calendarFavoritesSectionTitle: "Favoritos",
     calendarMineSectionTitle: "Tus Calendarios",
@@ -3062,6 +3064,52 @@ module.exports = {
     blockAccessRestricted: "Acceso restringido",
     tribeContentAccessDenied: "Acceso Denegado",
     tribeContentAccessDeniedMsg: "Este contenido pertenece a una tribu. Debes ser miembro para acceder.",
-    tribeViewTribes: "Ver Tribus"
+    tribeViewTribes: "Ver Tribus",
+    torrentsTitle: "Torrents",
+    torrentsDescription: "Explora y gestiona torrents en tu red.",
+    torrentAllSectionTitle: "Torrents",
+    torrentMineSectionTitle: "Tus Torrents",
+    torrentRecentSectionTitle: "Torrents Recientes",
+    torrentTopSectionTitle: "Torrents Destacados",
+    torrentFavoritesSectionTitle: "Favoritos",
+    torrentFilterAll: "TODOS",
+    torrentFilterMine: "MÍOS",
+    torrentFilterRecent: "RECIENTES",
+    torrentFilterTop: "DESTACADOS",
+    torrentFilterFavorites: "FAVORITOS",
+    torrentCreateSectionTitle: "Subir Torrent",
+    torrentUpdateSectionTitle: "Actualizar Torrent",
+    torrentCreateButton: "Subir Torrent",
+    torrentUpdateButton: "Actualizar",
+    torrentDeleteButton: "Eliminar",
+    torrentAddFavoriteButton: "Añadir Favorito",
+    torrentRemoveFavoriteButton: "Quitar Favorito",
+    torrentFileLabel: "Seleccionar archivo torrent (.torrent)",
+    torrentTitleLabel: "Título",
+    torrentTitlePlaceholder: "Título",
+    torrentDescriptionLabel: "Descripción",
+    torrentDescriptionPlaceholder: "Descripción",
+    torrentTagsLabel: "Etiquetas",
+    torrentTagsPlaceholder: "Introduce etiquetas separadas por comas",
+    torrentSizeLabel: "Tamaño",
+    torrentDownloadButton: "DOWNLOAD IT!",
+    torrentNoFile: "Sin archivo torrent",
+    noTorrents: "No se encontraron torrents.",
+    torrentSearchPlaceholder: "Buscar título, etiquetas, autor...",
+    torrentSearchButton: "Buscar",
+    torrentSortRecent: "Más recientes",
+    torrentSortOldest: "Más antiguos",
+    torrentSortTop: "Más votados",
+    torrentNoMatch: "No se encontraron torrents coincidentes.",
+    torrentMessageAuthorButton: "Enviar Mensaje al Autor",
+    torrentUpdatedAt: "Actualizado",
+    statsTorrent: "Torrents",
+    typeTorrent: "TORRENTS",
+    modulesTorrentsLabel: "Torrents",
+    modulesTorrentsDescription: "Módulo para descubrir y gestionar torrents.",
+    favoritesFilterTorrents: "TORRENTS",
+    tribeSectionTorrents: "TORRENTS",
+    tribeCreateTorrent: "Subir Torrent",
+    tribeMediaTypeTorrent: "Torrent"
     }
 };

+ 55 - 7
src/client/assets/translations/oasis_eu.js

@@ -359,6 +359,7 @@ module.exports = {
     videoLabel: "BIDEOAK",
     audioLabel: "AUDIOAK",
     documentLabel: "DOKUMENTUAK",
+    torrentLabel: "TORRENTAK",
     pdfFallbackLabel: "PDF Dokumentua",
     eventLabel: "EKITALDIAK",
     taskLabel: "ATAZAK",
@@ -960,6 +961,7 @@ module.exports = {
     audioButton: "AUDIOAK",
     videoButton: "BIDEOAK",
     documentButton: "DOKUMENTUAK",
+    torrentButton: "TORRENTAK",
     author: "Nork",
     createdAtLabel: "Noiz",
     totalVotes: "Bozkak guztira",
@@ -1676,12 +1678,12 @@ module.exports = {
     updateTribeTitle: "Eguneratu Tribua",
     tribeSectionOverview: "Ikuspegi orokorra",
     tribeSectionInhabitants: "Biztanleak",
-    tribeSectionVotations: "Bozkatzeak",
-    tribeSectionEvents: "Ekitaldiak",
+    tribeSectionVotations: "BOZKATZEAK",
+    tribeSectionEvents: "EKITALDIAK",
     tribeSectionReports: "Txostenak",
-    tribeSectionTasks: "Atazak",
-    tribeSectionFeed: "Jarioa",
-    tribeSectionForum: "Foroa",
+    tribeSectionTasks: "ATAZAK",
+    tribeSectionFeed: "JARIOA",
+    tribeSectionForum: "FOROA",
     tribeSectionMarket: "Merkatua",
     tribeSectionJobs: "Lanak",
     tribeSectionProjects: "Proiektuak",
@@ -2999,7 +3001,7 @@ module.exports = {
     calendarMonthNext: "Hurrengoa →",
     calendarMonthLabel: "Datak",
     calendarsShareUrl: "Partekatzeko URLa",
-    calendarAllSectionTitle: "Egutegi guztiak",
+    calendarAllSectionTitle: "Egutegiak",
     calendarRecentSectionTitle: "Egutegi Berriak",
     calendarFavoritesSectionTitle: "Gogokoak",
     calendarMineSectionTitle: "Zure Egutegiak",
@@ -3022,6 +3024,52 @@ module.exports = {
     blockAccessRestricted: "Sarbidea mugatuta",
     tribeContentAccessDenied: "Sarbidea Ukatua",
     tribeContentAccessDeniedMsg: "Eduki hau tribu batena da. Kide izan behar zara sartzeko.",
-    tribeViewTribes: "Tribuak Ikusi"
+    tribeViewTribes: "Tribuak Ikusi",
+    torrentsTitle: "Torrentak",
+    torrentsDescription: "Esploratu eta kudeatu torrentak zure sarean.",
+    torrentAllSectionTitle: "Torrentak",
+    torrentMineSectionTitle: "Zure Torrentak",
+    torrentRecentSectionTitle: "Azken Torrentak",
+    torrentTopSectionTitle: "Torrent Onenak",
+    torrentFavoritesSectionTitle: "Gogokoak",
+    torrentFilterAll: "DENAK",
+    torrentFilterMine: "NIREAK",
+    torrentFilterRecent: "AZKENAK",
+    torrentFilterTop: "ONENAK",
+    torrentFilterFavorites: "GOGOKOAK",
+    torrentCreateSectionTitle: "Torrenta Igo",
+    torrentUpdateSectionTitle: "Torrenta Eguneratu",
+    torrentCreateButton: "Torrenta Igo",
+    torrentUpdateButton: "Eguneratu",
+    torrentDeleteButton: "Ezabatu",
+    torrentAddFavoriteButton: "Gogokoetara Gehitu",
+    torrentRemoveFavoriteButton: "Gogokoetatik Kendu",
+    torrentFileLabel: "Torrent fitxategia aukeratu (.torrent)",
+    torrentTitleLabel: "Izenburua",
+    torrentTitlePlaceholder: "Izenburua",
+    torrentDescriptionLabel: "Deskribapena",
+    torrentDescriptionPlaceholder: "Deskribapena",
+    torrentTagsLabel: "Etiketak",
+    torrentTagsPlaceholder: "Sartu etiketak komaz bereizita",
+    torrentSizeLabel: "Tamaina",
+    torrentDownloadButton: "DOWNLOAD IT!",
+    torrentNoFile: "Torrent fitxategirik ez",
+    noTorrents: "Ez da torrentik aurkitu.",
+    torrentSearchPlaceholder: "Bilatu izenburua, etiketak, egilea...",
+    torrentSearchButton: "Bilatu",
+    torrentSortRecent: "Berrienak",
+    torrentSortOldest: "Zaharrenak",
+    torrentSortTop: "Bozkatu enak",
+    torrentNoMatch: "Ez dago torrent bat datorrenik.",
+    torrentMessageAuthorButton: "Mezua Bidali Egileari",
+    torrentUpdatedAt: "Eguneratua",
+    statsTorrent: "Torrentak",
+    typeTorrent: "TORRENTAK",
+    modulesTorrentsLabel: "Torrentak",
+    modulesTorrentsDescription: "Torrentak aurkitu eta kudeatzeko modulua.",
+    favoritesFilterTorrents: "TORRENTAK",
+    tribeSectionTorrents: "TORRENTAK",
+    tribeCreateTorrent: "Torrenta Igo",
+    tribeMediaTypeTorrent: "Torrenta"
   }
 };

+ 55 - 7
src/client/assets/translations/oasis_fr.js

@@ -357,6 +357,7 @@ module.exports = {
     videoLabel: "VIDÉOS",
     audioLabel: "AUDIOS",
     documentLabel: "DOCUMENTS",
+    torrentLabel: "TORRENTS",
     pdfFallbackLabel: "Document PDF",
     eventLabel: "ÉVÉNEMENTS",
     taskLabel: "TÂCHES",
@@ -946,6 +947,7 @@ module.exports = {
     audioButton: "AUDIOS",
     videoButton: "VIDÉOS",
     documentButton: "DOCUMENTS",
+    torrentButton: "TORRENTS",
     author: "Par",
     createdAtLabel: "Créé le",
     totalVotes: "Total des votes",
@@ -1701,12 +1703,12 @@ module.exports = {
     updateTribeTitle: "Mettre à jour la tribu",
     tribeSectionOverview: "Aperçu",
     tribeSectionInhabitants: "Habitants",
-    tribeSectionVotations: "Votations",
-    tribeSectionEvents: "Événements",
+    tribeSectionVotations: "VOTATIONS",
+    tribeSectionEvents: "ÉVÉNEMENTS",
     tribeSectionReports: "Rapports",
-    tribeSectionTasks: "Tâches",
-    tribeSectionFeed: "Fil",
-    tribeSectionForum: "Forum",
+    tribeSectionTasks: "TÂCHES",
+    tribeSectionFeed: "FIL",
+    tribeSectionForum: "FORUM",
     tribeSectionMarket: "Marché",
     tribeSectionJobs: "Emplois",
     tribeSectionProjects: "Projets",
@@ -3027,7 +3029,7 @@ module.exports = {
     calendarMonthNext: "Suivant →",
     calendarMonthLabel: "Dates",
     calendarsShareUrl: "URL de partage",
-    calendarAllSectionTitle: "Tous les calendriers",
+    calendarAllSectionTitle: "Calendriers",
     calendarRecentSectionTitle: "Calendriers Récents",
     calendarFavoritesSectionTitle: "Favoris",
     calendarMineSectionTitle: "Vos Calendriers",
@@ -3050,6 +3052,52 @@ module.exports = {
     blockAccessRestricted: "Accès restreint",
     tribeContentAccessDenied: "Accès Refusé",
     tribeContentAccessDeniedMsg: "Ce contenu appartient à une tribu. Vous devez en être membre pour y accéder.",
-    tribeViewTribes: "Voir les Tribus"
+    tribeViewTribes: "Voir les Tribus",
+    torrentsTitle: "Torrents",
+    torrentsDescription: "Explorez et gérez les torrents de votre réseau.",
+    torrentAllSectionTitle: "Torrents",
+    torrentMineSectionTitle: "Vos Torrents",
+    torrentRecentSectionTitle: "Torrents Récents",
+    torrentTopSectionTitle: "Meilleurs Torrents",
+    torrentFavoritesSectionTitle: "Favoris",
+    torrentFilterAll: "TOUS",
+    torrentFilterMine: "MES",
+    torrentFilterRecent: "RÉCENTS",
+    torrentFilterTop: "MEILLEURS",
+    torrentFilterFavorites: "FAVORIS",
+    torrentCreateSectionTitle: "Téléverser un Torrent",
+    torrentUpdateSectionTitle: "Mettre à jour le Torrent",
+    torrentCreateButton: "Téléverser un Torrent",
+    torrentUpdateButton: "Mettre à jour",
+    torrentDeleteButton: "Supprimer",
+    torrentAddFavoriteButton: "Ajouter aux Favoris",
+    torrentRemoveFavoriteButton: "Retirer des Favoris",
+    torrentFileLabel: "Sélectionner un fichier torrent (.torrent)",
+    torrentTitleLabel: "Titre",
+    torrentTitlePlaceholder: "Titre",
+    torrentDescriptionLabel: "Description",
+    torrentDescriptionPlaceholder: "Description",
+    torrentTagsLabel: "Étiquettes",
+    torrentTagsPlaceholder: "Entrez des étiquettes séparées par des virgules",
+    torrentSizeLabel: "Taille",
+    torrentDownloadButton: "DOWNLOAD IT!",
+    torrentNoFile: "Aucun fichier torrent",
+    noTorrents: "Aucun torrent trouvé.",
+    torrentSearchPlaceholder: "Rechercher titre, étiquettes, auteur...",
+    torrentSearchButton: "Rechercher",
+    torrentSortRecent: "Plus récents",
+    torrentSortOldest: "Plus anciens",
+    torrentSortTop: "Plus votés",
+    torrentNoMatch: "Aucun torrent correspondant.",
+    torrentMessageAuthorButton: "Envoyer un Message à l'Auteur",
+    torrentUpdatedAt: "Mis à jour",
+    statsTorrent: "Torrents",
+    typeTorrent: "TORRENTS",
+    modulesTorrentsLabel: "Torrents",
+    modulesTorrentsDescription: "Module pour découvrir et gérer les torrents.",
+    favoritesFilterTorrents: "TORRENTS",
+    tribeSectionTorrents: "TORRENTS",
+    tribeCreateTorrent: "Téléverser un Torrent",
+    tribeMediaTypeTorrent: "Torrent"
     }
 };

+ 50 - 2
src/client/assets/translations/oasis_hi.js

@@ -365,6 +365,7 @@ module.exports = {
     videoLabel: "वीडियो",
     audioLabel: "ऑडियो",
     documentLabel: "दस्तावेज़",
+    torrentLabel: "टॉरेंट",
     pdfFallbackLabel: "PDF दस्तावेज़",
     eventLabel: "कार्यक्रम",
     taskLabel: "कार्य",
@@ -955,6 +956,7 @@ module.exports = {
     audioButton: "ऑडियो",
     videoButton: "वीडियो",
     documentButton: "दस्तावेज़",
+    torrentButton: "टॉरेंट",
     author: "द्वारा",
     createdAtLabel: "बनाया गया",
     totalVotes: "कुल मत",
@@ -3029,7 +3031,7 @@ module.exports = {
     calendarMonthNext: "अगला →",
     calendarMonthLabel: "तारीखें",
     calendarsShareUrl: "शेयर URL",
-    calendarAllSectionTitle: "सभी कैलेंडर",
+    calendarAllSectionTitle: "कैलेंडर",
     calendarRecentSectionTitle: "हाल के कैलेंडर",
     calendarFavoritesSectionTitle: "पसंदीदा",
     calendarMineSectionTitle: "आपके कैलेंडर",
@@ -3052,6 +3054,52 @@ module.exports = {
     blockAccessRestricted: "पहुंच प्रतिबंधित",
     tribeContentAccessDenied: "पहुंच अस्वीकृत",
     tribeContentAccessDeniedMsg: "यह सामग्री एक जनजाति की है। इसे देखने के लिए आपको सदस्य होना चाहिए।",
-    tribeViewTribes: "जनजातियाँ देखें"
+    tribeViewTribes: "जनजातियाँ देखें",
+    torrentsTitle: "टॉरेंट",
+    torrentsDescription: "अपने नेटवर्क में टॉरेंट खोजें और प्रबंधित करें।",
+    torrentAllSectionTitle: "टॉरेंट",
+    torrentMineSectionTitle: "आपके टॉरेंट",
+    torrentRecentSectionTitle: "हालिया टॉरेंट",
+    torrentTopSectionTitle: "शीर्ष टॉरेंट",
+    torrentFavoritesSectionTitle: "पसंदीदा",
+    torrentFilterAll: "सभी",
+    torrentFilterMine: "मेरे",
+    torrentFilterRecent: "हालिया",
+    torrentFilterTop: "शीर्ष",
+    torrentFilterFavorites: "पसंदीदा",
+    torrentCreateSectionTitle: "टॉरेंट अपलोड करें",
+    torrentUpdateSectionTitle: "टॉरेंट अपडेट करें",
+    torrentCreateButton: "टॉरेंट अपलोड करें",
+    torrentUpdateButton: "अपडेट करें",
+    torrentDeleteButton: "हटाएं",
+    torrentAddFavoriteButton: "पसंदीदा में जोड़ें",
+    torrentRemoveFavoriteButton: "पसंदीदा से हटाएं",
+    torrentFileLabel: "टॉरेंट फ़ाइल चुनें (.torrent)",
+    torrentTitleLabel: "शीर्षक",
+    torrentTitlePlaceholder: "शीर्षक",
+    torrentDescriptionLabel: "विवरण",
+    torrentDescriptionPlaceholder: "विवरण",
+    torrentTagsLabel: "टैग",
+    torrentTagsPlaceholder: "अल्पविराम से अलग करके टैग दर्ज करें",
+    torrentSizeLabel: "आकार",
+    torrentDownloadButton: "DOWNLOAD IT!",
+    torrentNoFile: "कोई टॉरेंट फ़ाइल नहीं",
+    noTorrents: "कोई टॉरेंट नहीं मिला।",
+    torrentSearchPlaceholder: "शीर्षक, टैग, लेखक खोजें...",
+    torrentSearchButton: "खोजें",
+    torrentSortRecent: "सबसे हालिया",
+    torrentSortOldest: "सबसे पुराने",
+    torrentSortTop: "सबसे अधिक वोट",
+    torrentNoMatch: "कोई मिलान टॉरेंट नहीं।",
+    torrentMessageAuthorButton: "लेखक को संदेश भेजें",
+    torrentUpdatedAt: "अपडेट किया गया",
+    statsTorrent: "टॉरेंट",
+    typeTorrent: "टॉरेंट",
+    modulesTorrentsLabel: "टॉरेंट",
+    modulesTorrentsDescription: "टॉरेंट खोजने और प्रबंधित करने का मॉड्यूल।",
+    favoritesFilterTorrents: "टॉरेंट",
+    tribeSectionTorrents: "टॉरेंट",
+    tribeCreateTorrent: "टॉरेंट अपलोड करें",
+    tribeMediaTypeTorrent: "टॉरेंट"
     }
 };

+ 55 - 7
src/client/assets/translations/oasis_it.js

@@ -365,6 +365,7 @@ module.exports = {
     videoLabel: "VIDEO",
     audioLabel: "AUDIO",
     documentLabel: "DOCUMENTI",
+    torrentLabel: "TORRENT",
     pdfFallbackLabel: "Documento PDF",
     eventLabel: "EVENTI",
     taskLabel: "COMPITI",
@@ -955,6 +956,7 @@ module.exports = {
     audioButton: "AUDIO",
     videoButton: "VIDEO",
     documentButton: "DOCUMENTI",
+    torrentButton: "TORRENT",
     author: "Di",
     createdAtLabel: "Creato il",
     totalVotes: "Voti totali",
@@ -1714,12 +1716,12 @@ module.exports = {
     updateTribeTitle: "Aggiorna tribù",
     tribeSectionOverview: "Panoramica",
     tribeSectionInhabitants: "Abitanti",
-    tribeSectionVotations: "Votazioni",
-    tribeSectionEvents: "Eventi",
+    tribeSectionVotations: "VOTAZIONI",
+    tribeSectionEvents: "EVENTI",
     tribeSectionReports: "Segnalazioni",
-    tribeSectionTasks: "Compiti",
-    tribeSectionFeed: "Feed",
-    tribeSectionForum: "Forum",
+    tribeSectionTasks: "COMPITI",
+    tribeSectionFeed: "FEED",
+    tribeSectionForum: "FORUM",
     tribeSectionMarket: "Mercato",
     tribeSectionJobs: "Lavori",
     tribeSectionProjects: "Progetti",
@@ -3030,7 +3032,7 @@ module.exports = {
     calendarMonthNext: "Successivo →",
     calendarMonthLabel: "Date",
     calendarsShareUrl: "URL di condivisione",
-    calendarAllSectionTitle: "Tutti i calendari",
+    calendarAllSectionTitle: "Calendari",
     calendarRecentSectionTitle: "Calendari Recenti",
     calendarFavoritesSectionTitle: "Preferiti",
     calendarMineSectionTitle: "I Tuoi Calendari",
@@ -3053,6 +3055,52 @@ module.exports = {
     blockAccessRestricted: "Accesso limitato",
     tribeContentAccessDenied: "Accesso Negato",
     tribeContentAccessDeniedMsg: "Questo contenuto appartiene a una tribù. Devi essere membro per accedervi.",
-    tribeViewTribes: "Visualizza Tribù"
+    tribeViewTribes: "Visualizza Tribù",
+    torrentsTitle: "Torrent",
+    torrentsDescription: "Esplora e gestisci i torrent nella tua rete.",
+    torrentAllSectionTitle: "Torrent",
+    torrentMineSectionTitle: "I Tuoi Torrent",
+    torrentRecentSectionTitle: "Torrent Recenti",
+    torrentTopSectionTitle: "Migliori Torrent",
+    torrentFavoritesSectionTitle: "Preferiti",
+    torrentFilterAll: "TUTTI",
+    torrentFilterMine: "MIEI",
+    torrentFilterRecent: "RECENTI",
+    torrentFilterTop: "MIGLIORI",
+    torrentFilterFavorites: "PREFERITI",
+    torrentCreateSectionTitle: "Carica Torrent",
+    torrentUpdateSectionTitle: "Aggiorna Torrent",
+    torrentCreateButton: "Carica Torrent",
+    torrentUpdateButton: "Aggiorna",
+    torrentDeleteButton: "Elimina",
+    torrentAddFavoriteButton: "Aggiungi ai Preferiti",
+    torrentRemoveFavoriteButton: "Rimuovi dai Preferiti",
+    torrentFileLabel: "Seleziona file torrent (.torrent)",
+    torrentTitleLabel: "Titolo",
+    torrentTitlePlaceholder: "Titolo",
+    torrentDescriptionLabel: "Descrizione",
+    torrentDescriptionPlaceholder: "Descrizione",
+    torrentTagsLabel: "Tag",
+    torrentTagsPlaceholder: "Inserisci tag separati da virgole",
+    torrentSizeLabel: "Dimensione",
+    torrentDownloadButton: "DOWNLOAD IT!",
+    torrentNoFile: "Nessun file torrent",
+    noTorrents: "Nessun torrent trovato.",
+    torrentSearchPlaceholder: "Cerca titolo, tag, autore...",
+    torrentSearchButton: "Cerca",
+    torrentSortRecent: "Più recenti",
+    torrentSortOldest: "Più vecchi",
+    torrentSortTop: "Più votati",
+    torrentNoMatch: "Nessun torrent corrispondente.",
+    torrentMessageAuthorButton: "Invia Messaggio all'Autore",
+    torrentUpdatedAt: "Aggiornato",
+    statsTorrent: "Torrent",
+    typeTorrent: "TORRENT",
+    modulesTorrentsLabel: "Torrent",
+    modulesTorrentsDescription: "Modulo per scoprire e gestire i torrent.",
+    favoritesFilterTorrents: "TORRENT",
+    tribeSectionTorrents: "TORRENT",
+    tribeCreateTorrent: "Carica Torrent",
+    tribeMediaTypeTorrent: "Torrent"
     }
 };

+ 55 - 7
src/client/assets/translations/oasis_pt.js

@@ -365,6 +365,7 @@ module.exports = {
     videoLabel: "VÍDEOS",
     audioLabel: "ÁUDIOS",
     documentLabel: "DOCUMENTOS",
+    torrentLabel: "TORRENTS",
     pdfFallbackLabel: "Documento PDF",
     eventLabel: "EVENTOS",
     taskLabel: "TAREFAS",
@@ -955,6 +956,7 @@ module.exports = {
     audioButton: "ÁUDIOS",
     videoButton: "VÍDEOS",
     documentButton: "DOCUMENTOS",
+    torrentButton: "TORRENTS",
     author: "Por",
     createdAtLabel: "Criado em",
     totalVotes: "Total de votos",
@@ -1714,12 +1716,12 @@ module.exports = {
     updateTribeTitle: "Atualizar tribo",
     tribeSectionOverview: "Visão geral",
     tribeSectionInhabitants: "Habitantes",
-    tribeSectionVotations: "Votações",
-    tribeSectionEvents: "Eventos",
+    tribeSectionVotations: "VOTAÇÕES",
+    tribeSectionEvents: "EVENTOS",
     tribeSectionReports: "Relatórios",
-    tribeSectionTasks: "Tarefas",
-    tribeSectionFeed: "Feed",
-    tribeSectionForum: "Fórum",
+    tribeSectionTasks: "TAREFAS",
+    tribeSectionFeed: "FEED",
+    tribeSectionForum: "FÓRUM",
     tribeSectionMarket: "Mercado",
     tribeSectionJobs: "Empregos",
     tribeSectionProjects: "Projetos",
@@ -3030,7 +3032,7 @@ module.exports = {
     calendarMonthNext: "Seguinte →",
     calendarMonthLabel: "Datas",
     calendarsShareUrl: "URL de partilha",
-    calendarAllSectionTitle: "Todos os calendários",
+    calendarAllSectionTitle: "Calendários",
     calendarRecentSectionTitle: "Calendários Recentes",
     calendarFavoritesSectionTitle: "Favoritos",
     calendarMineSectionTitle: "Seus Calendários",
@@ -3053,6 +3055,52 @@ module.exports = {
     blockAccessRestricted: "Acesso restrito",
     tribeContentAccessDenied: "Acesso Negado",
     tribeContentAccessDeniedMsg: "Este conteúdo pertence a uma tribo. Você deve ser membro para acessá-lo.",
-    tribeViewTribes: "Ver Tribos"
+    tribeViewTribes: "Ver Tribos",
+    torrentsTitle: "Torrents",
+    torrentsDescription: "Explore e gerencie torrents na sua rede.",
+    torrentAllSectionTitle: "Torrents",
+    torrentMineSectionTitle: "Seus Torrents",
+    torrentRecentSectionTitle: "Torrents Recentes",
+    torrentTopSectionTitle: "Melhores Torrents",
+    torrentFavoritesSectionTitle: "Favoritos",
+    torrentFilterAll: "TODOS",
+    torrentFilterMine: "MEUS",
+    torrentFilterRecent: "RECENTES",
+    torrentFilterTop: "MELHORES",
+    torrentFilterFavorites: "FAVORITOS",
+    torrentCreateSectionTitle: "Enviar Torrent",
+    torrentUpdateSectionTitle: "Atualizar Torrent",
+    torrentCreateButton: "Enviar Torrent",
+    torrentUpdateButton: "Atualizar",
+    torrentDeleteButton: "Excluir",
+    torrentAddFavoriteButton: "Adicionar aos Favoritos",
+    torrentRemoveFavoriteButton: "Remover dos Favoritos",
+    torrentFileLabel: "Selecionar arquivo torrent (.torrent)",
+    torrentTitleLabel: "Título",
+    torrentTitlePlaceholder: "Título",
+    torrentDescriptionLabel: "Descrição",
+    torrentDescriptionPlaceholder: "Descrição",
+    torrentTagsLabel: "Tags",
+    torrentTagsPlaceholder: "Insira tags separadas por vírgulas",
+    torrentSizeLabel: "Tamanho",
+    torrentDownloadButton: "DOWNLOAD IT!",
+    torrentNoFile: "Nenhum arquivo torrent",
+    noTorrents: "Nenhum torrent encontrado.",
+    torrentSearchPlaceholder: "Pesquisar título, tags, autor...",
+    torrentSearchButton: "Pesquisar",
+    torrentSortRecent: "Mais recentes",
+    torrentSortOldest: "Mais antigos",
+    torrentSortTop: "Mais votados",
+    torrentNoMatch: "Nenhum torrent correspondente.",
+    torrentMessageAuthorButton: "Enviar Mensagem ao Autor",
+    torrentUpdatedAt: "Atualizado",
+    statsTorrent: "Torrents",
+    typeTorrent: "TORRENTS",
+    modulesTorrentsLabel: "Torrents",
+    modulesTorrentsDescription: "Módulo para descobrir e gerenciar torrents.",
+    favoritesFilterTorrents: "TORRENTS",
+    tribeSectionTorrents: "TORRENTS",
+    tribeCreateTorrent: "Enviar Torrent",
+    tribeMediaTypeTorrent: "Torrent"
     }
 };

+ 55 - 7
src/client/assets/translations/oasis_ru.js

@@ -365,6 +365,7 @@ module.exports = {
     videoLabel: "ВИДЕО",
     audioLabel: "АУДИО",
     documentLabel: "ДОКУМЕНТЫ",
+    torrentLabel: "ТОРРЕНТЫ",
     pdfFallbackLabel: "PDF документ",
     eventLabel: "СОБЫТИЯ",
     taskLabel: "ЗАДАЧИ",
@@ -949,6 +950,7 @@ module.exports = {
     audioButton: "АУДИО",
     videoButton: "ВИДЕО",
     documentButton: "ДОКУМЕНТЫ",
+    torrentButton: "ТОРРЕНТЫ",
     author: "Автор",
     createdAtLabel: "Создано",
     totalVotes: "Всего голосов",
@@ -1702,12 +1704,12 @@ module.exports = {
     updateTribeTitle: "Обновить племя",
     tribeSectionOverview: "Обзор",
     tribeSectionInhabitants: "Жители",
-    tribeSectionVotations: "Голосования",
-    tribeSectionEvents: "События",
+    tribeSectionVotations: "ГОЛОСОВАНИЯ",
+    tribeSectionEvents: "СОБЫТИЯ",
     tribeSectionReports: "Отчёты",
-    tribeSectionTasks: "Задачи",
-    tribeSectionFeed: "Лента",
-    tribeSectionForum: "Форум",
+    tribeSectionTasks: "ЗАДАЧИ",
+    tribeSectionFeed: "ЛЕНТА",
+    tribeSectionForum: "ФОРУМ",
     tribeSectionMarket: "Рынок",
     tribeSectionJobs: "Вакансии",
     tribeSectionProjects: "Проекты",
@@ -2992,7 +2994,7 @@ module.exports = {
     calendarMonthNext: "Вперёд →",
     calendarMonthLabel: "Даты",
     calendarsShareUrl: "Поделиться URL",
-    calendarAllSectionTitle: "Все календари",
+    calendarAllSectionTitle: "Календари",
     calendarRecentSectionTitle: "Недавние календари",
     calendarFavoritesSectionTitle: "Избранное",
     calendarMineSectionTitle: "Ваши Календари",
@@ -3015,6 +3017,52 @@ module.exports = {
     blockAccessRestricted: "Доступ ограничен",
     tribeContentAccessDenied: "Доступ Запрещён",
     tribeContentAccessDeniedMsg: "Этот контент принадлежит племени. Вы должны быть участником, чтобы получить к нему доступ.",
-    tribeViewTribes: "Посмотреть Племена"
+    tribeViewTribes: "Посмотреть Племена",
+    torrentsTitle: "Торренты",
+    torrentsDescription: "Исследуйте и управляйте торрентами в вашей сети.",
+    torrentAllSectionTitle: "Торренты",
+    torrentMineSectionTitle: "Ваши Торренты",
+    torrentRecentSectionTitle: "Недавние Торренты",
+    torrentTopSectionTitle: "Лучшие Торренты",
+    torrentFavoritesSectionTitle: "Избранное",
+    torrentFilterAll: "ВСЕ",
+    torrentFilterMine: "МОИ",
+    torrentFilterRecent: "НЕДАВНИЕ",
+    torrentFilterTop: "ЛУЧШИЕ",
+    torrentFilterFavorites: "ИЗБРАННОЕ",
+    torrentCreateSectionTitle: "Загрузить Торрент",
+    torrentUpdateSectionTitle: "Обновить Торрент",
+    torrentCreateButton: "Загрузить Торрент",
+    torrentUpdateButton: "Обновить",
+    torrentDeleteButton: "Удалить",
+    torrentAddFavoriteButton: "Добавить в Избранное",
+    torrentRemoveFavoriteButton: "Убрать из Избранного",
+    torrentFileLabel: "Выберите торрент-файл (.torrent)",
+    torrentTitleLabel: "Название",
+    torrentTitlePlaceholder: "Название",
+    torrentDescriptionLabel: "Описание",
+    torrentDescriptionPlaceholder: "Описание",
+    torrentTagsLabel: "Теги",
+    torrentTagsPlaceholder: "Введите теги через запятую",
+    torrentSizeLabel: "Размер",
+    torrentDownloadButton: "DOWNLOAD IT!",
+    torrentNoFile: "Нет торрент-файла",
+    noTorrents: "Торренты не найдены.",
+    torrentSearchPlaceholder: "Поиск по названию, тегам, автору...",
+    torrentSearchButton: "Поиск",
+    torrentSortRecent: "Самые новые",
+    torrentSortOldest: "Самые старые",
+    torrentSortTop: "Самые популярные",
+    torrentNoMatch: "Совпадений не найдено.",
+    torrentMessageAuthorButton: "Написать Автору",
+    torrentUpdatedAt: "Обновлено",
+    statsTorrent: "Торренты",
+    typeTorrent: "ТОРРЕНТЫ",
+    modulesTorrentsLabel: "Торренты",
+    modulesTorrentsDescription: "Модуль для поиска и управления торрентами.",
+    favoritesFilterTorrents: "ТОРРЕНТЫ",
+    tribeSectionTorrents: "ТОРРЕНТЫ",
+    tribeCreateTorrent: "Загрузить Торрент",
+    tribeMediaTypeTorrent: "Торрент"
     }
 };

+ 50 - 2
src/client/assets/translations/oasis_zh.js

@@ -366,6 +366,7 @@ module.exports = {
     videoLabel: "视频",
     audioLabel: "音频",
     documentLabel: "文档",
+    torrentLabel: "种子",
     pdfFallbackLabel: "PDF 文档",
     eventLabel: "活动",
     taskLabel: "任务",
@@ -956,6 +957,7 @@ module.exports = {
     audioButton: "音频",
     videoButton: "视频",
     documentButton: "文档",
+    torrentButton: "种子",
     author: "作者",
     createdAtLabel: "创建于",
     totalVotes: "总票数",
@@ -3030,7 +3032,7 @@ module.exports = {
     calendarMonthNext: "下一月 →",
     calendarMonthLabel: "日期",
     calendarsShareUrl: "分享链接",
-    calendarAllSectionTitle: "所有日历",
+    calendarAllSectionTitle: "日历",
     calendarRecentSectionTitle: "最近的日历",
     calendarFavoritesSectionTitle: "收藏",
     calendarMineSectionTitle: "你的日历",
@@ -3053,6 +3055,52 @@ module.exports = {
     blockAccessRestricted: "访问受限",
     tribeContentAccessDenied: "访问被拒绝",
     tribeContentAccessDeniedMsg: "此内容属于一个部落。您必须是成员才能访问它。",
-    tribeViewTribes: "查看部落"
+    tribeViewTribes: "查看部落",
+    torrentsTitle: "种子",
+    torrentsDescription: "浏览和管理网络中的种子。",
+    torrentAllSectionTitle: "种子",
+    torrentMineSectionTitle: "我的种子",
+    torrentRecentSectionTitle: "最近的种子",
+    torrentTopSectionTitle: "热门种子",
+    torrentFavoritesSectionTitle: "收藏",
+    torrentFilterAll: "全部",
+    torrentFilterMine: "我的",
+    torrentFilterRecent: "最近",
+    torrentFilterTop: "热门",
+    torrentFilterFavorites: "收藏",
+    torrentCreateSectionTitle: "上传种子",
+    torrentUpdateSectionTitle: "更新种子",
+    torrentCreateButton: "上传种子",
+    torrentUpdateButton: "更新",
+    torrentDeleteButton: "删除",
+    torrentAddFavoriteButton: "添加到收藏",
+    torrentRemoveFavoriteButton: "从收藏移除",
+    torrentFileLabel: "选择种子文件 (.torrent)",
+    torrentTitleLabel: "标题",
+    torrentTitlePlaceholder: "标题",
+    torrentDescriptionLabel: "描述",
+    torrentDescriptionPlaceholder: "描述",
+    torrentTagsLabel: "标签",
+    torrentTagsPlaceholder: "输入以逗号分隔的标签",
+    torrentSizeLabel: "大小",
+    torrentDownloadButton: "DOWNLOAD IT!",
+    torrentNoFile: "没有种子文件",
+    noTorrents: "未找到种子。",
+    torrentSearchPlaceholder: "搜索标题、标签、作者...",
+    torrentSearchButton: "搜索",
+    torrentSortRecent: "最新",
+    torrentSortOldest: "最旧",
+    torrentSortTop: "最多投票",
+    torrentNoMatch: "没有匹配的种子。",
+    torrentMessageAuthorButton: "给作者发消息",
+    torrentUpdatedAt: "已更新",
+    statsTorrent: "种子",
+    typeTorrent: "种子",
+    modulesTorrentsLabel: "种子",
+    modulesTorrentsDescription: "发现和管理种子的模块。",
+    favoritesFilterTorrents: "种子",
+    tribeSectionTorrents: "种子",
+    tribeCreateTorrent: "上传种子",
+    tribeMediaTypeTorrent: "种子"
     }
 };

+ 1 - 1
src/client/middleware.js

@@ -43,7 +43,7 @@ module.exports = ({ host, port, middleware, allowHost }) => {
     }
     console.error(err);
     if (ctx && isValidRequest(ctx.request)) {
-      err.message = err.stack;
+      err.message = err.message || 'Internal server error';
       err.expose = true;
     }
     return null;

+ 2 - 1
src/configs/config-manager.js

@@ -48,7 +48,8 @@ if (!fs.existsSync(configFilePath)) {
       "parliamentMod": "on",
       "courtsMod": "on",
       "favoritesMod": "on",
-      "mapsMod": "on"
+      "mapsMod": "on",
+      "torrentsMod": "on"
     },
     "wallet": {
       "url": "http://localhost:7474",

+ 3 - 1
src/configs/media-favorites.json

@@ -1,11 +1,13 @@
 {
   "audios": [],
   "bookmarks": [],
+  "calendars": [],
   "chats": [],
   "documents": [],
   "images": [],
   "maps": [],
   "pads": [],
   "shops": [],
+  "torrents": [],
   "videos": []
-}
+}

+ 2 - 1
src/configs/oasis-config.json

@@ -44,7 +44,8 @@
     "courtsMod": "on",
     "favoritesMod": "on",
     "mapsMod": "on",
-    "chatsMod": "on"
+    "chatsMod": "on",
+    "torrentsMod": "on"
   },
   "wallet": {
     "url": "http://localhost:7474",

+ 7 - 7
src/models/chats_model.js

@@ -185,7 +185,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
       })
     },
 
-    async updateChatById(id, data) {
+    async updateChatById(id, data, { skipAuthorCheck = false } = {}) {
       const tipId = await this.resolveCurrentId(id)
       const ssbClient = await openSsb()
       const userId = ssbClient.id
@@ -196,7 +196,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
           const c = item.content
 
           const rawAuthor = c.author || (c.encryptedPayload ? null : undefined)
-          if (rawAuthor && rawAuthor !== userId) return reject(new Error("Not the author"))
+          if (!skipAuthorCheck && rawAuthor && rawAuthor !== userId) return reject(new Error("Not the author"))
 
           const rootId = tipId
           const messages = []
@@ -213,8 +213,8 @@ module.exports = ({ cooler, tribeCrypto }) => {
             category: data.category !== undefined ? safeText(data.category) : chat.category,
             status: data.status !== undefined ? (VALID_STATUS.includes(String(data.status).toUpperCase()) ? String(data.status).toUpperCase() : chat.status) : chat.status,
             tags: data.tags !== undefined ? normalizeTags(data.tags) : chat.tags,
-            members: chat.members,
-            invites: chat.invites,
+            members: data.members !== undefined ? safeArr(data.members) : chat.members,
+            invites: data.invites !== undefined ? safeArr(data.invites) : chat.invites,
             author: chat.author,
             createdAt: chat.createdAt,
             updatedAt: new Date().toISOString()
@@ -404,7 +404,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
         return inv.code !== code
       })
 
-      await this.updateChatById(matchedChat.key, { members, invites, status: matchedChat.status, title: matchedChat.title, description: matchedChat.description, image: matchedChat.image, category: matchedChat.category, tags: matchedChat.tags })
+      await this.updateChatById(matchedChat.key, { members, invites, status: matchedChat.status, title: matchedChat.title, description: matchedChat.description, image: matchedChat.image, category: matchedChat.category, tags: matchedChat.tags }, { skipAuthorCheck: true })
       return matchedChat.key
     },
 
@@ -427,7 +427,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
         }
       }
 
-      await this.updateChatById(chatId, { members, invites: chat.invites, status: chat.status, title: chat.title, description: chat.description, image: chat.image, category: chat.category, tags: chat.tags })
+      await this.updateChatById(chatId, { members, invites: chat.invites, status: chat.status, title: chat.title, description: chat.description, image: chat.image, category: chat.category, tags: chat.tags }, { skipAuthorCheck: true })
       return chat.key
     },
 
@@ -438,7 +438,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
       if (!chat) throw new Error("Chat not found")
       if (chat.author === userId) throw new Error("Author cannot leave their own chat")
       const members = chat.members.filter(m => m !== userId)
-      await this.updateChatById(chatId, { members, invites: chat.invites, status: chat.status, title: chat.title, description: chat.description, image: chat.image, category: chat.category, tags: chat.tags })
+      await this.updateChatById(chatId, { members, invites: chat.invites, status: chat.status, title: chat.title, description: chat.description, image: chat.image, category: chat.category, tags: chat.tags }, { skipAuthorCheck: true })
     },
 
     async sendMessage(chatId, text, image = null) {

+ 7 - 2
src/models/favorites_model.js

@@ -15,7 +15,7 @@ const toTs = (d) => {
   return Number.isFinite(t) ? t : 0;
 };
 
-module.exports = ({ audiosModel, bookmarksModel, documentsModel, imagesModel, videosModel, mapsModel, padsModel, chatsModel, calendarsModel }) => {
+module.exports = ({ audiosModel, bookmarksModel, documentsModel, imagesModel, videosModel, mapsModel, padsModel, chatsModel, calendarsModel, torrentsModel }) => {
   const kindConfig = {
     audios: {
       base: "/audios/",
@@ -52,10 +52,14 @@ module.exports = ({ audiosModel, bookmarksModel, documentsModel, imagesModel, vi
     calendars: {
       base: "/calendars/",
       getById: getFn(calendarsModel, ["getCalendarById", "getById"])
+    },
+    torrents: {
+      base: "/torrents/",
+      getById: getFn(torrentsModel, ["getTorrentById", "getById"])
     }
   };
 
-  const kindOrder = ["audios", "bookmarks", "calendars", "chats", "documents", "images", "maps", "pads", "videos"];
+  const kindOrder = ["audios", "bookmarks", "calendars", "chats", "documents", "images", "maps", "pads", "torrents", "videos"];
 
   const hydrateKind = async (kind, ids) => {
     const cfg = kindConfig[kind];
@@ -115,6 +119,7 @@ module.exports = ({ audiosModel, bookmarksModel, documentsModel, imagesModel, vi
       images: byKind.images.length,
       maps: byKind.maps.length,
       pads: byKind.pads.length,
+      torrents: byKind.torrents.length,
       videos: byKind.videos.length,
       all: flat.length
     };

+ 29 - 8
src/models/jobs_model.js

@@ -45,7 +45,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
       )
     )
 
-  const buildIndex = (messages) => {
+  const buildIndex = (messages, ssbClient) => {
     const tomb = new Set()
     const jobNodes = new Map()
     const parent = new Map()
@@ -110,6 +110,25 @@ module.exports = ({ cooler, tribeCrypto }) => {
       else set.delete(author)
     }
 
+    if (ssbClient) {
+      for (const m of messages) {
+        if (typeof m.value?.content !== 'string') continue
+        try {
+          const dec = ssbClient.private.unbox({ key: m.key, value: m.value, timestamp: m.value?.timestamp || m.timestamp || 0 })
+          if (!dec?.value?.content) continue
+          const c = dec.value.content
+          if (c.type !== 'job_sub' || !c.jobId) continue
+          const author = dec.value.author
+          if (!author) continue
+          const ts = dec.value.timestamp || m.timestamp || 0
+          const jobId = c.jobId
+          const k = `${jobId}::${author}`
+          const prev = jobSubLatest.get(k)
+          if (!prev || ts > prev.ts) jobSubLatest.set(k, { ts, value: !!c.value, author, jobId })
+        } catch {}
+      }
+    }
+
     return { tomb, jobNodes, parent, child, rootOf, tipOf, tipByRoot, subsByJob }
   }
 
@@ -210,7 +229,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
     async resolveCurrentId(jobId) {
       const ssbClient = await openSsb()
       const messages = await readAll(ssbClient)
-      const { tomb, child } = buildIndex(messages)
+      const { tomb, child } = buildIndex(messages, ssbClient)
 
       let cur = jobId
       while (child.has(cur)) cur = child.get(cur)
@@ -221,7 +240,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
     async resolveRootId(jobId) {
       const ssbClient = await openSsb()
       const messages = await readAll(ssbClient)
-      const { tomb, parent, child } = buildIndex(messages)
+      const { tomb, parent, child } = buildIndex(messages, ssbClient)
 
       let tip = jobId
       while (child.has(tip)) tip = child.get(tip)
@@ -235,7 +254,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
     async updateJob(id, jobData) {
       const ssbClient = await openSsb()
       const messages = await readAll(ssbClient)
-      const idx = buildIndex(messages)
+      const idx = buildIndex(messages, ssbClient)
 
       const tipId = await this.resolveCurrentId(id)
       const node = idx.jobNodes.get(tipId)
@@ -366,7 +385,8 @@ module.exports = ({ cooler, tribeCrypto }) => {
         createdAt: new Date().toISOString()
       }
 
-      return new Promise((res, rej) => ssbClient.publish(msg, (e, m) => e ? rej(e) : res(m)))
+      const recps = [me, job.author]
+      return new Promise((res, rej) => ssbClient.private.publish(msg, recps, (e, m) => e ? rej(e) : res(m)))
     },
 
     async unsubscribeFromJob(id, userId) {
@@ -387,7 +407,8 @@ module.exports = ({ cooler, tribeCrypto }) => {
         createdAt: new Date().toISOString()
       }
 
-      return new Promise((res, rej) => ssbClient.publish(msg, (e, m) => e ? rej(e) : res(m)))
+      const recps = [me, job.author]
+      return new Promise((res, rej) => ssbClient.private.publish(msg, recps, (e, m) => e ? rej(e) : res(m)))
     },
 
     async listJobs(filter = "ALL", viewerId = null, query = {}) {
@@ -396,7 +417,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
       const viewer = viewerId || me
 
       const messages = await readAll(ssbClient)
-      const idx = buildIndex(messages)
+      const idx = buildIndex(messages, ssbClient)
 
       const jobs = []
       for (const [rootId, tipId] of idx.tipByRoot.entries()) {
@@ -451,7 +472,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
       void viewerId
 
       const messages = await readAll(ssbClient)
-      const idx = buildIndex(messages)
+      const idx = buildIndex(messages, ssbClient)
 
       let tipId = id
       while (idx.child.has(tipId)) tipId = idx.child.get(tipId)

+ 4 - 1
src/models/search_model.js

@@ -13,7 +13,7 @@ module.exports = ({ cooler, padsModel }) => {
   const searchableTypes = [
     'post', 'about', 'curriculum', 'tribe', 'transfer', 'feed',
     'votes', 'report', 'task', 'event', 'bookmark', 'document',
-    'image', 'audio', 'video', 'market', 'bankWallet', 'bankClaim',
+    'image', 'audio', 'video', 'torrent', 'market', 'bankWallet', 'bankClaim',
     'project', 'job', 'forum', 'vote', 'contact', 'pub', 'map', 'shop', 'shopProduct', 'chat', 'pad'
   ];
 
@@ -39,6 +39,8 @@ module.exports = ({ cooler, padsModel }) => {
         return [content?.url, content?.mimeType, content?.title, content?.description, ...(content?.tags || [])];
       case 'document':
         return [content?.url, content?.title, content?.description, ...(content?.tags || []), content?.key];
+      case 'torrent':
+        return [content?.title, content?.description, ...(content?.tags || []), content?.url];
       case 'market':
         return [content?.item_type, content?.title, content?.description, content?.price, ...(content?.tags || []), content?.status, content?.item_status, content?.deadline, content?.includesShipping, content?.seller, content?.image, content?.auctions_poll, content?.stock];
       case 'bookmark':
@@ -105,6 +107,7 @@ module.exports = ({ cooler, padsModel }) => {
     if (t === 'image') return `image:${c.url || `${author}|${norm(c.title)}|${norm(c.description)}` || msg.key}`;
     if (t === 'audio') return `audio:${c.url || `${author}|${norm(c.title)}|${norm(c.description)}` || msg.key}`;
     if (t === 'video') return `video:${c.url || `${author}|${norm(c.title)}|${norm(c.description)}` || msg.key}`;
+    if (t === 'torrent') return `torrent:${c.url || `${author}|${norm(c.title)}|${norm(c.description)}` || msg.key}`;
     if (t === 'bookmark') return `bookmark:${author}|${c.url || norm(c.description) || msg.key}`;
 
     if (t === 'tribe') {

+ 68 - 46
src/models/shops_model.js

@@ -21,6 +21,14 @@ module.exports = ({ cooler, tribeCrypto }) => {
       pull(ssbClient.createLogStream({ limit: logLimit }), pull.collect((err, msgs) => err ? reject(err) : resolve(msgs)))
     )
 
+  const decryptBuyers = (val, key) => {
+    if (Array.isArray(val)) return val
+    if (typeof val === 'string' && tribeCrypto && key) {
+      try { return JSON.parse(tribeCrypto.decryptWithKey(val, key)) } catch {}
+    }
+    return []
+  }
+
   const buildIndex = (messages) => {
     const tomb = new Set()
     const nodes = new Map()
@@ -91,7 +99,7 @@ module.exports = ({ cooler, tribeCrypto }) => {
       updatedAt: c.updatedAt || null,
       opinions: c.opinions || {},
       opinions_inhabitants: safeArr(c.opinions_inhabitants),
-      buyers: (tribeCrypto && tribeCrypto.getKey(rootId)) || ssb?.id === (c.author || node.author) ? safeArr(c.buyers) : []
+      buyers: decryptBuyers(c.buyers, tribeCrypto ? tribeCrypto.getKey(rootId) : null)
     }
   }
 
@@ -436,65 +444,79 @@ module.exports = ({ cooler, tribeCrypto }) => {
     },
 
     async buyProduct(productId) {
-      const tipId = await this.resolveCurrentId(productId)
       const ssbClient = await openSsb()
       const userId = ssbClient.id
+      const messages = await readAll(ssbClient)
+      const idx = buildIndex(messages)
 
-      return new Promise((resolve, reject) => {
-        ssbClient.get(tipId, (err, item) => {
-          if (err || !item?.content) return reject(new Error("Product not found"))
-          const c = item.content
-          if (c.author === userId) return reject(new Error("Cannot buy your own product"))
-          const stock = Number(c.stock) || 0
-          if (stock <= 0) return reject(new Error("Out of stock"))
+      let tip = productId
+      while (idx.child.has(tip)) tip = idx.child.get(tip)
+      if (idx.tomb.has(tip)) throw new Error("Product not found")
+      const tipId = tip
 
-          const updated = {
-            ...c,
-            stock: stock - 1,
-            buyers: safeArr(c.buyers).concat(userId),
-            updatedAt: new Date().toISOString(),
-            replaces: tipId
-          }
+      let rootId = tipId
+      while (idx.parent.has(rootId)) rootId = idx.parent.get(rootId)
 
-          const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
-          ssbClient.publish(tombstone, (e1) => {
-            if (e1) return reject(e1)
-            ssbClient.publish(updated, (e2, res) => e2 ? reject(e2) : resolve(res))
-          })
-        })
-      })
+      const node = idx.nodes.get(tipId)
+      if (!node) throw new Error("Product not found")
+      const c = node.c
+      if (c.author === userId) throw new Error("Cannot buy your own product")
+      const stock = Number(c.stock) || 0
+      if (stock <= 0) throw new Error("Out of stock")
+
+      const key = tribeCrypto ? tribeCrypto.getKey(rootId) : null
+      const currentBuyers = decryptBuyers(c.buyers, key)
+      const newBuyers = currentBuyers.concat(userId)
+
+      const updated = {
+        ...c,
+        stock: stock - 1,
+        buyers: key ? tribeCrypto.encryptWithKey(JSON.stringify(newBuyers), key) : newBuyers,
+        updatedAt: new Date().toISOString(),
+        replaces: tipId
+      }
+
+      const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
+      await new Promise((res, rej) => ssbClient.publish(tombstone, (e) => e ? rej(e) : res()))
+      return new Promise((res, rej) => ssbClient.publish(updated, (e, m) => e ? rej(e) : res(m)))
     },
 
     async createOpinion(id, category) {
       if (!categories.includes(category)) throw new Error("Invalid category")
       const ssbClient = await openSsb()
       const userId = ssbClient.id
-      const tipId = await this.resolveCurrentId(id)
+      const messages = await readAll(ssbClient)
+      const idx = buildIndex(messages)
 
-      return new Promise((resolve, reject) => {
-        ssbClient.get(tipId, (err, item) => {
-          if (err || !item?.content) return reject(new Error("Not found"))
-          const c = item.content
-          const buyers = safeArr(c.buyers)
-          if (!buyers.includes(userId)) return reject(new Error("Must purchase before rating"))
-          const voters = safeArr(c.opinions_inhabitants)
-          if (voters.includes(userId)) return reject(new Error("Already voted"))
+      let tip = id
+      while (idx.child.has(tip)) tip = idx.child.get(tip)
+      if (idx.tomb.has(tip)) throw new Error("Not found")
+      const tipId = tip
 
-          const updated = {
-            ...c,
-            opinions: { ...(c.opinions || {}), [category]: ((c.opinions || {})[category] || 0) + 1 },
-            opinions_inhabitants: voters.concat(userId),
-            updatedAt: new Date().toISOString(),
-            replaces: tipId
-          }
+      let rootId = tipId
+      while (idx.parent.has(rootId)) rootId = idx.parent.get(rootId)
 
-          const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
-          ssbClient.publish(tombstone, (e1) => {
-            if (e1) return reject(e1)
-            ssbClient.publish(updated, (e2, res) => e2 ? reject(e2) : resolve(res))
-          })
-        })
-      })
+      const node = idx.nodes.get(tipId)
+      if (!node) throw new Error("Not found")
+      const c = node.c
+
+      const key = tribeCrypto ? tribeCrypto.getKey(rootId) : null
+      const buyers = decryptBuyers(c.buyers, key)
+      if (!buyers.includes(userId)) throw new Error("Must purchase before rating")
+      const voters = safeArr(c.opinions_inhabitants)
+      if (voters.includes(userId)) throw new Error("Already voted")
+
+      const updated = {
+        ...c,
+        opinions: { ...(c.opinions || {}), [category]: ((c.opinions || {})[category] || 0) + 1 },
+        opinions_inhabitants: voters.concat(userId),
+        updatedAt: new Date().toISOString(),
+        replaces: tipId
+      }
+
+      const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId }
+      await new Promise((res, rej) => ssbClient.publish(tombstone, (e) => e ? rej(e) : res()))
+      return new Promise((res, rej) => ssbClient.publish(updated, (e, m) => e ? rej(e) : res(m)))
     }
   }
 }

+ 1 - 1
src/models/stats_model.js

@@ -35,7 +35,7 @@ module.exports = ({ cooler }) => {
 
   const types = [
     'bookmark','event','task','votes','report','feed','project',
-    'image','audio','video','document','transfer','post','tribe',
+    'image','torrent','audio','video','document','transfer','post','tribe',
     'market','forum','job','aiExchange','map','shop','shopProduct','chat','chatMessage',
     'pad','padEntry','gameScore','calendar','calendarDate','calendarNote',
     'parliamentCandidature','parliamentTerm','parliamentProposal','parliamentRevocation','parliamentLaw',

+ 323 - 0
src/models/torrents_model.js

@@ -0,0 +1,323 @@
+const pull = require("../server/node_modules/pull-stream");
+const { getConfig } = require("../configs/config-manager.js");
+const categories = require("../backend/opinion_categories");
+
+const logLimit = getConfig().ssbLogStream?.limit || 1000;
+
+const safeArr = (v) => (Array.isArray(v) ? v : []);
+
+const normalizeTags = (raw) => {
+  if (raw === undefined || raw === null) return undefined;
+  if (Array.isArray(raw)) return raw.map((t) => String(t || "").trim()).filter(Boolean);
+  return String(raw).split(",").map((t) => t.trim()).filter(Boolean);
+};
+
+const parseBlobId = (blobMarkdown) => {
+  const s = String(blobMarkdown || "");
+  const match = s.match(/\((&[^)]+\.sha256)\)/);
+  if (match) return match[1];
+  const fallback = s.match(/\(([^)]+)\)/g);
+  return fallback ? fallback[fallback.length - 1].slice(1, -1) : s || null;
+};
+
+const voteSum = (opinions = {}) =>
+  Object.values(opinions || {}).reduce((s, n) => s + (Number(n) || 0), 0);
+
+module.exports = ({ cooler }) => {
+  let ssb;
+
+  const openSsb = async () => {
+    if (!ssb) ssb = await cooler.open();
+    return ssb;
+  };
+
+  const getAllMessages = async (ssbClient) =>
+    new Promise((resolve, reject) => {
+      pull(ssbClient.createLogStream({ limit: logLimit }), pull.collect((err, msgs) => (err ? reject(err) : resolve(msgs))));
+    });
+
+  const getMsg = async (ssbClient, key) =>
+    new Promise((resolve, reject) => {
+      ssbClient.get(key, (err, msg) => (err ? reject(err) : resolve(msg)));
+    });
+
+  const buildIndex = (messages) => {
+    const tomb = new Set();
+    const nodes = new Map();
+    const parent = new Map();
+    const child = new Map();
+
+    for (const m of messages) {
+      const k = m.key;
+      const v = m.value || {};
+      const c = v.content;
+      if (!c) continue;
+
+      if (c.type === "tombstone" && c.target) {
+        tomb.add(c.target);
+        continue;
+      }
+
+      if (c.type !== "torrent") continue;
+
+      const ts = v.timestamp || m.timestamp || 0;
+      nodes.set(k, { key: k, ts, c });
+
+      if (c.replaces) {
+        parent.set(k, c.replaces);
+        child.set(c.replaces, k);
+      }
+    }
+
+    const rootOf = (id) => {
+      let cur = id;
+      while (parent.has(cur)) cur = parent.get(cur);
+      return cur;
+    };
+
+    const tipOf = (id) => {
+      let cur = id;
+      while (child.has(cur)) cur = child.get(cur);
+      return cur;
+    };
+
+    const roots = new Set();
+    for (const id of nodes.keys()) roots.add(rootOf(id));
+
+    const tipByRoot = new Map();
+    for (const r of roots) tipByRoot.set(r, tipOf(r));
+
+    const forward = new Map();
+    for (const [newId, oldId] of parent.entries()) forward.set(oldId, newId);
+
+    return { tomb, nodes, parent, child, rootOf, tipOf, tipByRoot, forward };
+  };
+
+  const buildTorrent = (node, rootId, viewerId) => {
+    const c = node.c || {};
+    const voters = safeArr(c.opinions_inhabitants);
+    return {
+      key: node.key,
+      rootId,
+      url: c.url,
+      createdAt: c.createdAt || new Date(node.ts).toISOString(),
+      updatedAt: c.updatedAt || null,
+      tags: safeArr(c.tags),
+      author: c.author,
+      title: c.title || "",
+      description: c.description || "",
+      size: c.size || 0,
+      opinions: c.opinions || {},
+      opinions_inhabitants: voters,
+      hasVoted: viewerId ? voters.includes(viewerId) : false
+    };
+  };
+
+  return {
+    type: "torrent",
+
+    async resolveCurrentId(id) {
+      const ssbClient = await openSsb();
+      const messages = await getAllMessages(ssbClient);
+      const idx = buildIndex(messages);
+
+      let tip = id;
+      while (idx.forward.has(tip)) tip = idx.forward.get(tip);
+      if (idx.tomb.has(tip)) throw new Error("Torrent not found");
+      return tip;
+    },
+
+    async resolveRootId(id) {
+      const ssbClient = await openSsb();
+      const messages = await getAllMessages(ssbClient);
+      const idx = buildIndex(messages);
+
+      let tip = id;
+      while (idx.forward.has(tip)) tip = idx.forward.get(tip);
+      if (idx.tomb.has(tip)) throw new Error("Torrent not found");
+
+      let root = tip;
+      while (idx.parent.has(root)) root = idx.parent.get(root);
+      return root;
+    },
+
+    async createTorrent(blobMarkdown, tagsRaw, title, description, size) {
+      const ssbClient = await openSsb();
+      const blobId = parseBlobId(blobMarkdown);
+      const tags = normalizeTags(tagsRaw) || [];
+      const now = new Date().toISOString();
+
+      const content = {
+        type: "torrent",
+        url: blobId,
+        createdAt: now,
+        updatedAt: null,
+        author: ssbClient.id,
+        tags,
+        title: title || "",
+        description: description || "",
+        size: Number(size) || 0,
+        opinions: {},
+        opinions_inhabitants: []
+      };
+
+      return new Promise((resolve, reject) => {
+        ssbClient.publish(content, (err, res) => (err ? reject(err) : resolve(res)));
+      });
+    },
+
+    async updateTorrentById(id, blobMarkdown, tagsRaw, title, description) {
+      const ssbClient = await openSsb();
+      const userId = ssbClient.id;
+      const tipId = await this.resolveCurrentId(id);
+      const oldMsg = await getMsg(ssbClient, tipId);
+
+      if (!oldMsg || oldMsg.content?.type !== "torrent") throw new Error("Torrent not found");
+      if (Object.keys(oldMsg.content.opinions || {}).length > 0) throw new Error("Cannot edit torrent after it has received opinions.");
+      if (oldMsg.content.author !== userId) throw new Error("Not the author");
+
+      const tags = tagsRaw !== undefined ? normalizeTags(tagsRaw) || [] : safeArr(oldMsg.content.tags);
+      const blobId = blobMarkdown ? parseBlobId(blobMarkdown) : null;
+      const now = new Date().toISOString();
+
+      const updated = {
+        ...oldMsg.content,
+        replaces: tipId,
+        url: blobId || oldMsg.content.url,
+        tags,
+        title: title !== undefined ? title || "" : oldMsg.content.title || "",
+        description: description !== undefined ? description || "" : oldMsg.content.description || "",
+        createdAt: oldMsg.content.createdAt,
+        updatedAt: now
+      };
+
+      const tombstone = { type: "tombstone", target: tipId, deletedAt: now, author: userId };
+      await new Promise((res, rej) => ssbClient.publish(tombstone, (e) => (e ? rej(e) : res())));
+
+      return new Promise((resolve, reject) => {
+        ssbClient.publish(updated, (err, result) => (err ? reject(err) : resolve(result)));
+      });
+    },
+
+    async deleteTorrentById(id) {
+      const ssbClient = await openSsb();
+      const userId = ssbClient.id;
+      const tipId = await this.resolveCurrentId(id);
+      const msg = await getMsg(ssbClient, tipId);
+
+      if (!msg || msg.content?.type !== "torrent") throw new Error("Torrent not found");
+      if (msg.content.author !== userId) throw new Error("Not the author");
+
+      const tombstone = { type: "tombstone", target: tipId, deletedAt: new Date().toISOString(), author: userId };
+
+      return new Promise((resolve, reject) => {
+        ssbClient.publish(tombstone, (err, res) => (err ? reject(err) : resolve(res)));
+      });
+    },
+
+    async listAll(filterOrOpts = "all", maybeOpts = {}) {
+      const ssbClient = await openSsb();
+
+      const opts = typeof filterOrOpts === "object" ? filterOrOpts : maybeOpts || {};
+      const filter = (typeof filterOrOpts === "string" ? filterOrOpts : opts.filter || "all") || "all";
+      const q = String(opts.q || "").trim().toLowerCase();
+      const sort = String(opts.sort || "recent").trim();
+      const viewerId = opts.viewerId || ssbClient.id;
+
+      const messages = await getAllMessages(ssbClient);
+      const idx = buildIndex(messages);
+
+      const items = [];
+      for (const [rootId, tipId] of idx.tipByRoot.entries()) {
+        if (idx.tomb.has(tipId)) continue;
+        const node = idx.nodes.get(tipId);
+        if (!node) continue;
+        items.push(buildTorrent(node, rootId, viewerId));
+      }
+
+      let list = items;
+      const now = Date.now();
+
+      if (filter === "mine") list = list.filter((a) => String(a.author) === String(viewerId));
+      else if (filter === "recent") list = list.filter((a) => new Date(a.createdAt).getTime() >= now - 86400000);
+      else if (filter === "top") {
+        list = list.slice().sort((a, b) => voteSum(b.opinions) - voteSum(a.opinions) || new Date(b.createdAt) - new Date(a.createdAt));
+      }
+
+      if (q) {
+        list = list.filter((a) => {
+          const title = String(a.title || "").toLowerCase();
+          const desc = String(a.description || "").toLowerCase();
+          const tags = safeArr(a.tags).join(" ").toLowerCase();
+          const author = String(a.author || "").toLowerCase();
+          return title.includes(q) || desc.includes(q) || tags.includes(q) || author.includes(q);
+        });
+      }
+
+      if (sort === "top") {
+        list = list.slice().sort((a, b) => voteSum(b.opinions) - voteSum(a.opinions) || new Date(b.createdAt) - new Date(a.createdAt));
+      } else if (sort === "oldest") {
+        list = list.slice().sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt));
+      } else {
+        list = list.slice().sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
+      }
+
+      return list;
+    },
+
+    async getTorrentById(id, viewerId = null) {
+      const ssbClient = await openSsb();
+      const viewer = viewerId || ssbClient.id;
+      const messages = await getAllMessages(ssbClient);
+      const idx = buildIndex(messages);
+
+      let tip = id;
+      while (idx.forward.has(tip)) tip = idx.forward.get(tip);
+      if (idx.tomb.has(tip)) throw new Error("Torrent not found");
+
+      let root = tip;
+      while (idx.parent.has(root)) root = idx.parent.get(root);
+
+      const node = idx.nodes.get(tip);
+      if (node) return buildTorrent(node, root, viewer);
+
+      const msg = await getMsg(ssbClient, tip);
+      if (!msg || msg.content?.type !== "torrent") throw new Error("Torrent not found");
+      return buildTorrent({ key: tip, ts: msg.timestamp || 0, c: msg.content }, root, viewer);
+    },
+
+    async createOpinion(id, category) {
+      const ssbClient = await openSsb();
+      const userId = ssbClient.id;
+
+      if (!categories.includes(category)) throw new Error("Invalid voting category");
+
+      const tipId = await this.resolveCurrentId(id);
+      const msg = await getMsg(ssbClient, tipId);
+
+      if (!msg || msg.content?.type !== "torrent") throw new Error("Torrent not found");
+
+      const voters = safeArr(msg.content.opinions_inhabitants);
+      if (voters.includes(userId)) throw new Error("Already voted");
+
+      const now = new Date().toISOString();
+      const updated = {
+        ...msg.content,
+        replaces: tipId,
+        opinions: {
+          ...msg.content.opinions,
+          [category]: (msg.content.opinions?.[category] || 0) + 1
+        },
+        opinions_inhabitants: voters.concat(userId),
+        updatedAt: now
+      };
+
+      const tombstone = { type: "tombstone", target: tipId, deletedAt: now, author: userId };
+      await new Promise((res, rej) => ssbClient.publish(tombstone, (e) => (e ? rej(e) : res())));
+
+      return new Promise((resolve, reject) => {
+        ssbClient.publish(updated, (err, result) => (err ? reject(err) : resolve(result)));
+      });
+    }
+  };
+};

+ 1 - 1
src/server/package-lock.json

@@ -1,6 +1,6 @@
 {
   "name": "@krakenslab/oasis",
-  "version": "0.7.1",
+  "version": "0.7.2",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {

+ 1 - 1
src/server/package.json

@@ -1,6 +1,6 @@
 {
   "name": "@krakenslab/oasis",
-  "version": "0.7.1",
+  "version": "0.7.2",
   "description": "Oasis - Social Networking Utopia",
   "repository": {
     "type": "git",

+ 18 - 1
src/views/activity_view.js

@@ -664,6 +664,18 @@ function renderActionCards(actions, userId, allActions) {
       );
     }
 
+    if (type === 'torrent') {
+      const { title } = content;
+      cardBody.push(
+        div({ class: 'card-section' },
+          title ? div({ class: 'card-field' },
+            span({ class: 'card-label' }, (i18n.torrentTitleLabel || 'Title') + ':'),
+            span({ class: 'card-value' }, title)
+          ) : null
+        )
+      );
+    }
+
     if (type === 'document') {
       const { url, title, key } = content;
       if (title && seenDocumentTitles.has(title.trim())) {
@@ -1582,6 +1594,7 @@ function getViewDetailsAction(type, action) {
     case 'image':      return `/images/${id}`;
     case 'audio':      return `/audios/${id}`;
     case 'video':      return `/videos/${id}`;
+    case 'torrent':    return `/torrents/${id}`;
     case 'forum':      return `/forum/${encodeURIComponent(action.content?.key || action.tipId || action.id)}`;
     case 'document':   return `/documents/${id}`;
     case 'bookmark':   return `/bookmarks/${id}`;
@@ -1602,6 +1615,7 @@ function getViewDetailsAction(type, action) {
     case 'report':     return `/reports/${id}`;
     case 'bankWallet': return `/wallet`;
     case 'bankClaim':  return `/banking${action.content?.epochId ? `/epoch/${encodeURIComponent(action.content.epochId)}` : ''}`;
+    case 'gameScore':  return `/games?filter=scoring`;
     default:           return `/activity`;
   }
 }
@@ -1645,7 +1659,8 @@ exports.activityView = (actions, filter, userId, q = '') => {
     { type: 'bookmark',  label: i18n.typeBookmark },
     { type: 'image',     label: i18n.typeImage },
     { type: 'document',  label: i18n.typeDocument },
-    { type: 'video',     label: i18n.typeVideo }
+    { type: 'video',     label: i18n.typeVideo },
+    { type: 'torrent',   label: i18n.typeTorrent }
   ];
 
   let filteredActions;
@@ -1675,6 +1690,8 @@ exports.activityView = (actions, filter, userId, q = '') => {
     filteredActions = actions.filter(action => action.type === 'spread');
   } else if (filter === 'gameScore') {
     filteredActions = actions.filter(action => action.type === 'gameScore');
+  } else if (filter === 'torrent') {
+    filteredActions = actions.filter(action => action.type === 'torrent');
   } else {
     filteredActions = actions.filter(action => (action.type === filter || filter === 'all' || (filter === 'shop' && action.type === 'shopProduct')) && action.type !== 'tombstone');
   }

+ 3 - 2
src/views/blockchain_view.js

@@ -14,14 +14,14 @@ const FILTER_LABELS = {
   aiExchange: i18n.typeAiExchange, parliament: i18n.typeParliament, courts: i18n.typeCourts,
   map: i18n.typeMap, shop: i18n.typeShop, shopProduct: i18n.typeShopProduct || 'Shop Product',
   pad: i18n.typePad || 'PAD', chat: i18n.typeChat || 'CHAT', gameScore: i18n.typeGameScore || 'GAME SCORE',
-  calendar: i18n.typeCalendar || 'CALENDAR'
+  calendar: i18n.typeCalendar || 'CALENDAR', torrent: i18n.typeTorrent
 };
 
 const BASE_FILTERS = ['recent', 'all', 'mine', 'tombstone'];
 const CAT_BLOCK1  = ['votes', 'event', 'task', 'report', 'calendar', 'parliament', 'courts'];
 const CAT_BLOCK2  = ['pub', 'tribe', 'about', 'contact', 'curriculum', 'vote', 'aiExchange'];
 const CAT_BLOCK3  = ['banking', 'job', 'market', 'project', 'transfer', 'feed', 'post', 'pixelia', 'shop', 'gameScore'];
-const CAT_BLOCK4  = ['forum', 'pad', 'chat', 'bookmark', 'image', 'video', 'audio', 'document', 'map'];
+const CAT_BLOCK4  = ['forum', 'pad', 'chat', 'bookmark', 'image', 'video', 'audio', 'document', 'map', 'torrent'];
 
 const SEARCH_FIELDS = ['author','id','from','to'];
 
@@ -123,6 +123,7 @@ const getViewDetailsAction = (type, block) => {
     case 'courtsNomination': return `/courts`;
     case 'courtsNominationVote': return `/courts`;
     case 'map': return `/maps/${encodeURIComponent(block.id)}`;
+    case 'torrent': return `/torrents/${encodeURIComponent(block.id)}`;
     case 'mapMarker': return block.content?.mapId ? `/maps/${encodeURIComponent(block.content.mapId)}` : `/maps`;
     case 'shop': return `/shops/${encodeURIComponent(block.id)}`;
     case 'shopProduct': return `/shops/product/${encodeURIComponent(block.id)}`;

+ 4 - 0
src/views/favorites_view.js

@@ -150,6 +150,10 @@ exports.favoritesView = async (items, filter = "all", counts = {}) => {
           button(
             { type: "submit", name: "filter", value: "videos", class: filter === "videos" ? "filter-btn active" : "filter-btn" },
             `${i18n.favoritesFilterVideos} (${c.videos || 0})`
+          ),
+          button(
+            { type: "submit", name: "filter", value: "torrents", class: filter === "torrents" ? "filter-btn active" : "filter-btn" },
+            `${i18n.favoritesFilterTorrents || "TORRENTS"} (${c.torrents || 0})`
           )
         )
       ),

+ 3 - 2
src/views/forum_view.js

@@ -7,6 +7,7 @@ const { template, i18n } = require('./main_views');
 const { config } = require('../server/SSB_server.js');
 const { renderUrl } = require('../backend/renderUrl');
 const { renderTextWithStyles } = require('../backend/renderTextWithStyles');
+const { sanitizeHtml } = require('../backend/sanitizeHtml');
 
 const userId = config.keys.id;
 const BASE_FILTERS = ['hot','all','mine','recent','top'];
@@ -184,7 +185,7 @@ const renderForumList = (forums, currentFilter) =>
             ),
 	    div({
 	      class: 'forum-body',
-	      innerHTML: renderTextWithStyles(f.text || '')
+	      innerHTML: sanitizeHtml(renderTextWithStyles(f.text || ''))
 	    }),
             div({ class: 'forum-meta' },
               span({ class: 'forum-positive-votes' },
@@ -319,7 +320,7 @@ exports.singleForumView = async (forum, messagesData, currentFilter) => {
           ),
 	  div({
 	    class: 'forum-body',
-	    innerHTML: renderTextWithStyles(forum.text || '')
+	    innerHTML: sanitizeHtml(renderTextWithStyles(forum.text || ''))
 	  }),
           div({ class: 'forum-meta' },
             span({ class: 'votes-count' },

+ 17 - 1
src/views/main_views.js

@@ -326,6 +326,21 @@ const renderImagesLink = () => {
   return "";
 };
 
+const renderTorrentsLink = () => {
+  const torrentsMod = getConfig().modules.torrentsMod === "on";
+  if (torrentsMod) {
+    return [
+      navLink({
+        href: "/torrents",
+        emoji: "ꖅ",
+        text: i18n.torrentsLabel,
+        class: "torrents-link enabled"
+      })
+    ];
+  }
+  return "";
+};
+
 const renderMapsLink = () => {
   const mapsMod = getConfig().modules.mapsMod === "on";
   if (mapsMod) {
@@ -991,6 +1006,7 @@ const template = (titlePrefix, ...elements) => {
                 renderBookmarksLink(),
                 renderDocsLink(),
                 renderImagesLink(),
+                renderTorrentsLink(),
                 renderVideosLink()
               )
             )
@@ -2339,7 +2355,7 @@ exports.privateView = async (messagesInput, filter) => {
             return div(
               { class: 'pm-card normal-pm' },
               headerLine({ sentAt, from: fromResolved, toLinks, subject: subjectRaw }),
-              div({ class: 'message-text', innerHTML: clickableLinks(text) }),
+              div({ class: 'message-text', innerHTML: sanitizeHtml(clickableLinks(text)) }),
               actions({ key: msg.key, replyId: fromResolved, subjectRaw, text })
             )
           }

+ 2 - 1
src/views/maps_view.js

@@ -5,6 +5,7 @@ const moment = require("../server/node_modules/moment");
 const { template, i18n } = require("./main_views");
 const { config } = require("../server/SSB_server.js");
 const { renderMapWithPins, renderZoomedMapWithPins, getViewportBounds, latLngToPx, pxToLatLng, MAP_W, MAP_H, getMaxTileZoom } = require("../maps/map_renderer");
+const { sanitizeHtml } = require('../backend/sanitizeHtml');
 
 const userId = config.keys.id;
 const safeArr = (v) => (Array.isArray(v) ? v : []);
@@ -129,7 +130,7 @@ const renderMap = (markers, clickUrl, mainIdx, opts = {}) => {
       const imgBlobId = pinImages[i] && String(pinImages[i]).startsWith("&") ? pinImages[i] : "";
       const imgHtml = imgBlobId ? `<img src="/blob/${encodeURIComponent(imgBlobId)}" class="map-popup-img" alt="">` : "";
       popupAreasHtml += `<area shape="rect" coords="${x1},${y1},${x2},${y2}" title="${escaped}" alt="${escaped}" href="#${popupId}">`;
-      popupsHtml += `<div id="${popupId}" class="map-popup"><div class="map-popup-box"><a href="#" class="map-popup-close">&#x2715;</a>${imgHtml}<div class="map-popup-label">${withLinks}</div><div class="map-popup-coords">${latStr}, ${lngStr}</div></div></div>`;
+      popupsHtml += `<div id="${popupId}" class="map-popup"><div class="map-popup-box"><a href="#" class="map-popup-close">&#x2715;</a>${imgHtml}<div class="map-popup-label">${sanitizeHtml(withLinks)}</div><div class="map-popup-coords">${latStr}, ${lngStr}</div></div></div>`;
     });
   }
   const mapHtml = useMap ? `<map name="${mapTag}">${popupAreasHtml}${gridAreasHtml}</map>` : "";

+ 1 - 0
src/views/modules_view.js

@@ -40,6 +40,7 @@ const modulesView = () => {
     { name: 'tags', label: i18n.modulesTagsLabel, description: i18n.modulesTagsDescription },
     { name: 'tasks', label: i18n.modulesTasksLabel, description: i18n.modulesTasksDescription },
     { name: 'threads', label: i18n.modulesThreadsLabel, description: i18n.modulesThreadsDescription },
+    { name: 'torrents', label: i18n.modulesTorrentsLabel, description: i18n.modulesTorrentsDescription },
     { name: 'transfers', label: i18n.modulesTransfersLabel, description: i18n.modulesTransfersDescription },
     { name: 'trending', label: i18n.modulesTrendingLabel, description: i18n.modulesTrendingDescription },
     { name: 'tribes', label: i18n.modulesTribesLabel, description: i18n.modulesTribesDescription },

+ 9 - 0
src/views/opinions_view.js

@@ -114,6 +114,15 @@ const renderContentHtml = (content, key) => {
           )
         )
       );
+    case 'torrent':
+      return div({ class: 'opinion-torrent' },
+        div({ class: 'card-section' },
+          form({ method: "GET", action: `/torrents/${encodeURIComponent(key)}` },
+            button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)),
+          br(),
+          content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.torrentTitleLabel || 'Title') + ':'), span({ class: 'card-value' }, content.title)) : ""
+        )
+      );
     case 'document': {
       const t = content.title?.trim();
       if (t && seenDocumentTitles.has(t)) return null;

+ 22 - 1
src/views/search_view.js

@@ -32,7 +32,7 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
 
   const contentTypes = [
     "post", "about", "curriculum", "tribe", "market", "transfer", "feed", "votes",
-    "report", "task", "event", "bookmark", "image", "audio", "video", "document",
+    "report", "task", "event", "bookmark", "image", "audio", "video", "document", "torrent",
     "bankWallet", "bankClaim", "project", "job", "forum", "vote", "contact", "pub", "map", "shop", "shopProduct", "chat", "pad", "all"
   ];
 
@@ -93,6 +93,7 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
       case 'shopProduct': return `/shops/product/${encodeURIComponent(contentId)}`;
       case 'chat': return `/chats/${encodeURIComponent(contentId)}`;
       case 'pad': return `/pads/${encodeURIComponent(contentId)}`;
+      case 'torrent': return `/torrents/${encodeURIComponent(contentId)}`;
       case 'gameScore': return content && content.game ? `/games/${encodeURIComponent(content.game)}` : '/games';
       default: return '#';
     }
@@ -270,6 +271,16 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
             ))
             : null
         );
+      case 'torrent':
+        return div({ class: 'search-torrent' },
+          content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.torrentTitleLabel || 'Title') + ':'), span({ class: 'card-value' }, content.title)) : null,
+          content.description ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.torrentDescriptionLabel || 'Description') + ':'), span({ class: 'card-value' }, content.description)) : null,
+          content.tags && content.tags.length
+            ? div({ class: 'card-tags' }, content.tags.map(tag =>
+              a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`)
+            ))
+            : null
+        );
       case 'market':
         return div({ class: 'search-market' },
           content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.title + ':'), span({ class: 'card-value' }, content.title)) : null,
@@ -515,6 +526,16 @@ const searchView = ({ messages = [], blobs = {}, query = "", type = "", types =
             )
           ) : null
         );
+      case 'torrent':
+        return div({ class: 'search-torrent' },
+          content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.torrentTitleLabel || 'Title') + ':'), span({ class: 'card-value' }, content.title)) : null,
+          content.size ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.torrentSizeLabel || 'Size') + ':'), span({ class: 'card-value' }, String(content.size))) : null,
+          content.tags && content.tags.length
+            ? div({ class: 'card-tags' }, content.tags.map(tag =>
+              a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: 'tag-link' }, `#${tag}`)
+            ))
+            : null
+        );
       case 'map':
         return div({ class: 'search-map' },
           content.title ? div({ class: 'card-field' }, span({ class: 'card-label' }, i18n.title + ':'), span({ class: 'card-value' }, content.title)) : null,

+ 2 - 1
src/views/stats_view.js

@@ -32,7 +32,7 @@ exports.statsView = (stats, filter) => {
   const modes = ['ALL', 'MINE', 'TOMBSTONE'];
   const types = [
     'bookmark', 'event', 'task', 'votes', 'report', 'feed', 'project',
-    'image', 'audio', 'video', 'document', 'transfer', 'post', 'tribe',
+    'image', 'torrent', 'audio', 'video', 'document', 'transfer', 'post', 'tribe',
     'market', 'forum', 'job', 'aiExchange', 'map', 'shop', 'shopProduct',
     'chat', 'chatMessage', 'pad', 'padEntry', 'gameScore', 'calendar', 'calendarDate', 'calendarNote',
     'parliamentCandidature','parliamentTerm','parliamentProposal','parliamentRevocation','parliamentLaw',
@@ -47,6 +47,7 @@ exports.statsView = (stats, filter) => {
     feed: i18n.statsFeed,
     project: i18n.statsProject,
     image: i18n.statsImage,
+    torrent: i18n.statsTorrent,
     audio: i18n.statsAudio,
     video: i18n.statsVideo,
     document: i18n.statsDocument,

+ 404 - 0
src/views/torrents_view.js

@@ -0,0 +1,404 @@
+const {
+  form,
+  button,
+  div,
+  h2,
+  p,
+  section,
+  input,
+  br,
+  a,
+  span,
+  textarea,
+  select,
+  label,
+  option,
+  table,
+  tr,
+  th,
+  td
+} = require("../server/node_modules/hyperaxe");
+
+const { template, i18n } = require("./main_views");
+const moment = require("../server/node_modules/moment");
+const { config } = require("../server/SSB_server.js");
+const { renderUrl } = require("../backend/renderUrl");
+const opinionCategories = require("../backend/opinion_categories");
+
+const userId = config.keys.id;
+
+const safeArr = (v) => (Array.isArray(v) ? v : []);
+const safeText = (v) => String(v || "").trim();
+
+const buildReturnTo = (filter, params = {}) => {
+  const f = safeText(filter || "all");
+  const q = safeText(params.q || "");
+  const sort = safeText(params.sort || "recent");
+  const parts = [`filter=${encodeURIComponent(f)}`];
+  if (q) parts.push(`q=${encodeURIComponent(q)}`);
+  if (sort) parts.push(`sort=${encodeURIComponent(sort)}`);
+  return `/torrents?${parts.join("&")}`;
+};
+
+const renderTags = (tags) => {
+  const list = safeArr(tags).map((t) => String(t || "").trim()).filter(Boolean);
+  return list.length
+    ? div(
+        { class: "card-tags" },
+        list.map((tag) => a({ href: `/search?query=%23${encodeURIComponent(tag)}`, class: "tag-link" }, `#${tag}`))
+      )
+    : null;
+};
+
+const renderTorrentFavoriteToggle = (torrentObj, returnTo = "") =>
+  form(
+    {
+      method: "POST",
+      action: torrentObj.isFavorite
+        ? `/torrents/favorites/remove/${encodeURIComponent(torrentObj.key)}`
+        : `/torrents/favorites/add/${encodeURIComponent(torrentObj.key)}`
+    },
+    returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null,
+    button(
+      { type: "submit", class: "filter-btn" },
+      torrentObj.isFavorite ? i18n.torrentRemoveFavoriteButton : i18n.torrentAddFavoriteButton
+    )
+  );
+
+const renderTorrentOwnerActions = (filter, torrentObj, params = {}) => {
+  const returnTo = buildReturnTo(filter, params);
+  const isAuthor = String(torrentObj.author) === String(userId);
+  const hasOpinions = Object.keys(torrentObj.opinions || {}).length > 0;
+
+  if (!isAuthor) return [];
+
+  const items = [];
+  if (!hasOpinions) {
+    items.push(
+      form(
+        { method: "GET", action: `/torrents/edit/${encodeURIComponent(torrentObj.key)}` },
+        input({ type: "hidden", name: "returnTo", value: returnTo }),
+        button({ class: "update-btn", type: "submit" }, i18n.torrentUpdateButton)
+      )
+    );
+  }
+  items.push(
+    form(
+      { method: "POST", action: `/torrents/delete/${encodeURIComponent(torrentObj.key)}` },
+      input({ type: "hidden", name: "returnTo", value: returnTo }),
+      button({ class: "delete-btn", type: "submit" }, i18n.torrentDeleteButton)
+    )
+  );
+
+  return items;
+};
+
+const renderTorrentCommentsSection = (torrentId, comments = [], returnTo = null) => {
+  const list = safeArr(comments);
+  const commentsCount = list.length;
+
+  return div(
+    { class: "vote-comments-section" },
+    div(
+      { class: "comments-count" },
+      span({ class: "card-label" }, i18n.voteCommentsLabel + ": "),
+      span({ class: "card-value" }, String(commentsCount))
+    ),
+    div(
+      { class: "comment-form-wrapper" },
+      h2({ class: "comment-form-title" }, i18n.voteNewCommentLabel),
+      form(
+        { method: "POST", action: `/torrents/${encodeURIComponent(torrentId)}/comments`, class: "comment-form", enctype: "multipart/form-data" },
+        returnTo ? input({ type: "hidden", name: "returnTo", value: returnTo }) : null,
+        textarea({
+          id: "comment-text",
+          name: "text",
+          rows: 4,
+          class: "comment-textarea",
+          placeholder: i18n.voteNewCommentPlaceholder
+        }),
+        div({ class: "comment-file-upload" }, label(i18n.uploadMedia), input({ type: "file", name: "blob" })),
+        br(),
+        button({ type: "submit", class: "comment-submit-btn" }, i18n.voteNewCommentButton)
+      )
+    ),
+    list.length
+      ? div(
+          { class: "comments-list" },
+          list.map((c) => {
+            const author = c?.value?.author || "";
+            const ts = c?.value?.timestamp || c?.timestamp;
+            const absDate = ts ? moment(ts).format("YYYY/MM/DD HH:mm:ss") : "";
+            const relDate = ts ? moment(ts).fromNow() : "";
+            const userName = author && author.includes("@") ? author.split("@")[1] : author;
+            const content = c?.value?.content || {};
+            const rootId = content.fork || content.root || null;
+            const text = content.text || "";
+
+            return div(
+              { class: "votations-comment-card" },
+              span(
+                { class: "created-at" },
+                span(i18n.createdBy),
+                author ? a({ href: `/author/${encodeURIComponent(author)}` }, `@${userName}`) : span("(unknown)"),
+                absDate ? span(" | ") : "",
+                absDate ? span({ class: "votations-comment-date" }, absDate) : "",
+                relDate ? span({ class: "votations-comment-date" }, " | ", i18n.sendTime) : "",
+                relDate && rootId ? a({ href: `/thread/${encodeURIComponent(rootId)}#${encodeURIComponent(c.key)}` }, relDate) : ""
+              ),
+              p({ class: "votations-comment-text" }, ...renderUrl(text))
+            );
+          })
+        )
+      : p({ class: "votations-no-comments" }, i18n.voteNoCommentsYet)
+  );
+};
+
+const formatSize = (bytes) => {
+  const n = Number(bytes) || 0;
+  if (n === 0) return "—";
+  if (n < 1024) return n + " B";
+  if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KB";
+  return (n / (1024 * 1024)).toFixed(1) + " MB";
+};
+
+const renderTorrentTable = (torrents, filter, params = {}) => {
+  const returnTo = buildReturnTo(filter, params);
+
+  if (!torrents.length) return p(params.q ? i18n.torrentNoMatch : i18n.noTorrents);
+
+  return table(
+    { border: "1", class: "torrent-table" },
+    tr(
+      th(i18n.createdAt || "DATE"),
+      th(i18n.authorLabel || "AUTHOR"),
+      th(i18n.torrentTitleLabel || "TITLE"),
+      th(i18n.torrentSizeLabel || "SIZE"),
+      th(""),
+      th("")
+    ),
+    torrents.map((t) =>
+      tr(
+        td(moment(t.createdAt).format("YYYY/MM/DD HH:mm")),
+        td(a({ href: `/author/${encodeURIComponent(t.author)}`, class: "user-link" }, t.author)),
+        td(t.title || ""),
+        td(formatSize(t.size)),
+        td(
+          form(
+            { method: "GET", action: `/torrents/${encodeURIComponent(t.key)}` },
+            input({ type: "hidden", name: "returnTo", value: returnTo }),
+            input({ type: "hidden", name: "filter", value: filter || "all" }),
+            params.q ? input({ type: "hidden", name: "q", value: params.q }) : null,
+            params.sort ? input({ type: "hidden", name: "sort", value: params.sort }) : null,
+            button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
+          )
+        ),
+        td(
+          t.url && t.url.startsWith("&")
+            ? a({ href: `/blob/${encodeURIComponent(t.url)}`, class: "filter-btn" }, i18n.torrentDownloadButton || "DOWNLOAD IT!")
+            : ""
+        )
+      )
+    )
+  );
+};
+
+const renderTorrentForm = (filter, torrentId, torrentToEdit, params = {}) => {
+  const returnTo = safeText(params.returnTo) || buildReturnTo("all", params);
+  return div(
+    { class: "div-center audio-form" },
+    form(
+      {
+        action: filter === "edit" ? `/torrents/update/${encodeURIComponent(torrentId)}` : "/torrents/create",
+        method: "POST",
+        enctype: "multipart/form-data"
+      },
+      input({ type: "hidden", name: "returnTo", value: returnTo }),
+      span(i18n.torrentFileLabel),
+      br(),
+      input({ type: "file", name: "torrent", accept: ".torrent", required: filter !== "edit" }),
+      br(),
+      br(),
+      span(i18n.torrentTitleLabel),
+      br(),
+      input({ type: "text", name: "title", placeholder: i18n.torrentTitlePlaceholder, value: torrentToEdit?.title || "", required: true }),
+      br(),
+      span(i18n.torrentDescriptionLabel),
+      br(),
+      textarea({ name: "description", placeholder: i18n.torrentDescriptionPlaceholder, rows: "4" }, torrentToEdit?.description || ""),
+      br(),
+      span(i18n.torrentTagsLabel),
+      br(),
+      input({
+        type: "text",
+        name: "tags",
+        placeholder: i18n.torrentTagsPlaceholder,
+        value: safeArr(torrentToEdit?.tags).join(", ")
+      }),
+      br(),
+      br(),
+      button({ type: "submit" }, filter === "edit" ? i18n.torrentUpdateButton : i18n.torrentCreateButton)
+    )
+  );
+};
+
+exports.torrentsView = async (torrents, filter = "all", torrentId = null, params = {}) => {
+  const title =
+    filter === "mine"
+      ? i18n.torrentMineSectionTitle
+      : filter === "create"
+        ? i18n.torrentCreateSectionTitle
+        : filter === "edit"
+          ? i18n.torrentUpdateSectionTitle
+          : filter === "recent"
+            ? i18n.torrentRecentSectionTitle
+            : filter === "top"
+              ? i18n.torrentTopSectionTitle
+              : filter === "favorites"
+                ? i18n.torrentFavoritesSectionTitle
+                : i18n.torrentAllSectionTitle;
+
+  const q = safeText(params.q || "");
+  const sort = safeText(params.sort || "recent");
+
+  const list = safeArr(torrents);
+  const torrentToEdit = torrentId ? list.find((t) => t.key === torrentId) : null;
+
+  return template(
+    title,
+    section(
+      div({ class: "tags-header" }, h2(title), p(i18n.torrentsDescription)),
+      div(
+        { class: "filters" },
+        form(
+          { method: "GET", action: "/torrents", class: "ui-toolbar ui-toolbar--filters" },
+          input({ type: "hidden", name: "q", value: q }),
+          input({ type: "hidden", name: "sort", value: sort }),
+          button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, i18n.torrentFilterAll),
+          button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.torrentFilterMine),
+          button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, i18n.torrentFilterRecent),
+          button({ type: "submit", name: "filter", value: "top", class: filter === "top" ? "filter-btn active" : "filter-btn" }, i18n.torrentFilterTop),
+          button(
+            { type: "submit", name: "filter", value: "favorites", class: filter === "favorites" ? "filter-btn active" : "filter-btn" },
+            i18n.torrentFilterFavorites
+          ),
+          button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.torrentCreateButton)
+        )
+      )
+    ),
+    section(
+      filter === "create" || filter === "edit"
+        ? renderTorrentForm(filter, torrentId, torrentToEdit, { ...params, filter })
+        : section(
+            div(
+              { class: "audios-search" },
+              form(
+                { method: "GET", action: "/torrents", class: "filter-box" },
+                input({ type: "hidden", name: "filter", value: filter }),
+                input({
+                  type: "text",
+                  name: "q",
+                  value: q,
+                  placeholder: i18n.torrentSearchPlaceholder,
+                  class: "filter-box__input"
+                }),
+                div(
+                  { class: "filter-box__controls" },
+                  select(
+                    { name: "sort", class: "filter-box__select" },
+                    option({ value: "recent", selected: sort === "recent" }, i18n.torrentSortRecent),
+                    option({ value: "oldest", selected: sort === "oldest" }, i18n.torrentSortOldest),
+                    option({ value: "top", selected: sort === "top" }, i18n.torrentSortTop)
+                  ),
+                  button({ type: "submit", class: "filter-box__button" }, i18n.torrentSearchButton)
+                )
+              )
+            ),
+            div({ class: "audios-list" }, renderTorrentTable(list, filter, { q, sort }))
+          )
+    )
+  );
+};
+
+exports.singleTorrentView = async (torrentObj, filter = "all", comments = [], params = {}) => {
+  const q = safeText(params.q || "");
+  const sort = safeText(params.sort || "recent");
+  const returnTo = safeText(params.returnTo) || buildReturnTo(filter, { q, sort });
+
+  const title = safeText(torrentObj.title);
+  const ownerActions = renderTorrentOwnerActions(filter, torrentObj, { q, sort });
+
+  const topbar = div(
+    { class: "bookmark-topbar" },
+    div({ class: "bookmark-actions" }, renderTorrentFavoriteToggle(torrentObj, returnTo), ...ownerActions)
+  );
+
+  return template(
+    i18n.torrentsTitle,
+    section(
+      div(
+        { class: "filters" },
+        form(
+          { method: "GET", action: "/torrents", class: "ui-toolbar ui-toolbar--filters" },
+          input({ type: "hidden", name: "q", value: q }),
+          input({ type: "hidden", name: "sort", value: sort }),
+          button({ type: "submit", name: "filter", value: "all", class: filter === "all" ? "filter-btn active" : "filter-btn" }, i18n.torrentFilterAll),
+          button({ type: "submit", name: "filter", value: "mine", class: filter === "mine" ? "filter-btn active" : "filter-btn" }, i18n.torrentFilterMine),
+          button({ type: "submit", name: "filter", value: "recent", class: filter === "recent" ? "filter-btn active" : "filter-btn" }, i18n.torrentFilterRecent),
+          button({ type: "submit", name: "filter", value: "top", class: filter === "top" ? "filter-btn active" : "filter-btn" }, i18n.torrentFilterTop),
+          button(
+            { type: "submit", name: "filter", value: "favorites", class: filter === "favorites" ? "filter-btn active" : "filter-btn" },
+            i18n.torrentFilterFavorites
+          ),
+          button({ type: "submit", name: "filter", value: "create", class: "create-button" }, i18n.torrentCreateButton)
+        )
+      ),
+      div(
+        { class: "bookmark-item card" },
+        topbar,
+        title ? h2(title) : null,
+        safeText(torrentObj.description) ? p(...renderUrl(torrentObj.description)) : null,
+        torrentObj.url && torrentObj.url.startsWith("&")
+          ? div({ class: "torrent-download" },
+              a({ href: `/blob/${encodeURIComponent(torrentObj.url)}?name=${encodeURIComponent((torrentObj.title || 'download').replace(/\.torrent$/i, '') + '.torrent')}` , class: "filter-btn" }, i18n.torrentDownloadButton || "DOWNLOAD IT!")
+            )
+          : p(i18n.torrentNoFile),
+        renderTags(torrentObj.tags),
+        br(),
+        (() => {
+          const createdTs = torrentObj.createdAt ? new Date(torrentObj.createdAt).getTime() : NaN;
+          const updatedTs = torrentObj.updatedAt ? new Date(torrentObj.updatedAt).getTime() : NaN;
+          const showUpdated = Number.isFinite(updatedTs) && (!Number.isFinite(createdTs) || updatedTs !== createdTs);
+
+          return p(
+            { class: "card-footer" },
+            span({ class: "date-link" }, `${moment(torrentObj.createdAt).format("YYYY/MM/DD HH:mm:ss")} ${i18n.performed} `),
+            a({ href: `/author/${encodeURIComponent(torrentObj.author)}`, class: "user-link" }, `${torrentObj.author}`),
+            showUpdated
+              ? span(
+                  { class: "votations-comment-date" },
+                  ` | ${i18n.torrentUpdatedAt}: ${moment(torrentObj.updatedAt).format("YYYY/MM/DD HH:mm:ss")}`
+                )
+              : null
+          );
+        })(),
+        div(
+          { class: "voting-buttons" },
+          opinionCategories.map((category) =>
+            form(
+              { method: "POST", action: `/torrents/opinions/${encodeURIComponent(torrentObj.key)}/${category}` },
+              input({ type: "hidden", name: "returnTo", value: returnTo }),
+              button(
+                { class: "vote-btn" },
+                `${i18n[`vote${category.charAt(0).toUpperCase() + category.slice(1)}`] || category} [${
+                  torrentObj.opinions?.[category] || 0
+                }]`
+              )
+            )
+          )
+        )
+      ),
+      div({ id: "comments" }, renderTorrentCommentsSection(torrentObj.key, comments, returnTo))
+    )
+  );
+};

+ 16 - 1
src/views/trending_view.js

@@ -94,6 +94,21 @@ const renderTrendingCard = (item, votes, categories, seenTitles) => {
           : div({ class: 'card-field' }, p(i18n.videoNoFile))
       )
     );
+  } else if (c.type === 'torrent') {
+    const { url, title, description } = c;
+    contentHtml = div({ class: 'trending-torrent' },
+      div({ class: 'card-section torrent' },
+        form({ method: "GET", action: `/torrents/${encodeURIComponent(item.key)}` },
+          button({ type: "submit", class: "filter-btn" }, i18n.viewDetails)
+        ),
+        br(),
+        title?.trim() ? div({ class: 'card-field' }, span({ class: 'card-label' }, (i18n.torrentTitleLabel || 'Title') + ':'), span({ class: 'card-value' }, title)) : "",
+        description ? [span({ class: 'card-label' }, (i18n.torrentDescriptionLabel || 'Description') + ":"), p(...renderUrl(description))] : null,
+        url && url.startsWith("&")
+          ? div({ class: 'card-field' }, a({ href: `/blob/${encodeURIComponent(url)}`, class: 'filter-btn' }, i18n.torrentDownload || 'Download'))
+          : div({ class: 'card-field' }, p(i18n.torrentNoFile || 'No file'))
+      )
+    );
   } else if (c.type === 'document') {
     const { url, title, description } = c;
     const t = title?.trim();
@@ -219,7 +234,7 @@ exports.trendingView = (items, filter, categories = opinionCategories) => {
   const baseFilters = ['RECENT', 'ALL', 'MINE', 'TOP'];
   const contentFilters = [
     ['votes', 'feed', 'transfer'],
-    ['bookmark', 'image', 'video', 'audio', 'document']
+    ['bookmark', 'image', 'video', 'audio', 'document', 'torrent']
   ];
 
   let filteredItems = items.filter(item => {

+ 21 - 9
src/views/tribes_view.js

@@ -387,7 +387,7 @@ const renderSectionNav = (tribe, section) => {
     { items: firstGroup },
     { items: [{ key: 'votations', label: i18n.tribeSectionVotations }, { key: 'events', label: i18n.tribeSectionEvents }, { key: 'tasks', label: i18n.tribeSectionTasks }] },
     { items: [{ key: 'feed', label: i18n.tribeSectionFeed }, { key: 'forum', label: i18n.tribeSectionForum }] },
-    { items: [{ key: 'images', label: i18n.tribeSectionImages || 'IMAGES' }, { key: 'audios', label: i18n.tribeSectionAudios || 'AUDIOS' }, { key: 'videos', label: i18n.tribeSectionVideos || 'VIDEOS' }, { key: 'documents', label: i18n.tribeSectionDocuments || 'DOCUMENTS' }, { key: 'bookmarks', label: i18n.tribeSectionBookmarks || 'BOOKMARKS' }, { key: 'maps', label: i18n.tribeSectionMaps || 'MAPS' }] },
+    { items: [{ key: 'images', label: i18n.tribeSectionImages || 'IMAGES' }, { key: 'audios', label: i18n.tribeSectionAudios || 'AUDIOS' }, { key: 'videos', label: i18n.tribeSectionVideos || 'VIDEOS' }, { key: 'documents', label: i18n.tribeSectionDocuments || 'DOCUMENTS' }, { key: 'bookmarks', label: i18n.tribeSectionBookmarks || 'BOOKMARKS' }, { key: 'maps', label: i18n.tribeSectionMaps || 'MAPS' }, { key: 'torrents', label: i18n.tribeSectionTorrents || 'TORRENTS' }] },
     { items: [{ key: 'pads', label: i18n.tribeSectionPads || 'PADS' }, { key: 'chats', label: i18n.tribeSectionChats || 'CHATS' }, { key: 'calendars', label: i18n.tribeSectionCalendars || 'CALENDARS' }] },
     { items: [{ key: 'search', label: i18n.tribeSectionSearch }] },
   ];
@@ -482,7 +482,7 @@ const activitySectionForItem = (item) => {
 };
 
 const activityMediaTypeName = (mt) => {
-  const map = { image: i18n.tribeSectionImages, audio: i18n.tribeSectionAudios, video: i18n.tribeSectionVideos, document: i18n.tribeSectionDocuments, bookmark: i18n.tribeSectionBookmarks };
+  const map = { image: i18n.tribeSectionImages, audio: i18n.tribeSectionAudios, video: i18n.tribeSectionVideos, document: i18n.tribeSectionDocuments, bookmark: i18n.tribeSectionBookmarks, torrent: i18n.tribeSectionTorrents };
   return map[mt] || i18n.tribeSectionMedia || 'MEDIA';
 };
 
@@ -1065,10 +1065,10 @@ const renderForumSection = (tribe, items, query) => {
   );
 };
 
-const sectionKeyForMediaType = { image: 'images', audio: 'audios', video: 'videos', document: 'documents', bookmark: 'bookmarks' };
-const acceptForMediaType = { image: 'image/*', audio: 'audio/*', video: 'video/*', document: 'application/pdf,.pdf,.doc,.docx,.txt,.odt', bookmark: null };
+const sectionKeyForMediaType = { image: 'images', audio: 'audios', video: 'videos', document: 'documents', bookmark: 'bookmarks', torrent: 'torrents' };
+const acceptForMediaType = { image: 'image/*', audio: 'audio/*', video: 'video/*', document: 'application/pdf,.pdf,.doc,.docx,.txt,.odt', bookmark: null, torrent: '.torrent' };
 const sectionTitleForMediaType = (mt) => {
-  const map = { image: i18n.tribeSectionImages, audio: i18n.tribeSectionAudios, video: i18n.tribeSectionVideos, document: i18n.tribeSectionDocuments, bookmark: i18n.tribeSectionBookmarks };
+  const map = { image: i18n.tribeSectionImages, audio: i18n.tribeSectionAudios, video: i18n.tribeSectionVideos, document: i18n.tribeSectionDocuments, bookmark: i18n.tribeSectionBookmarks, torrent: i18n.tribeSectionTorrents };
   return map[mt] || mt;
 };
 
@@ -1085,6 +1085,7 @@ const renderTribeMediaTypeSection = (tribe, items, query, mediaType) => {
     video: () => i18n.tribeCreateVideo || 'Create Video',
     document: () => i18n.tribeCreateDocument || 'Create Document',
     bookmark: () => i18n.tribeCreateBookmark || 'Create Bookmark',
+    torrent: () => i18n.tribeCreateTorrent || 'Upload Torrent',
   };
   const mediaBtnLabel = createMediaLabel[mediaType] ? createMediaLabel[mediaType]() : i18n.tribeCreateButton;
 
@@ -1183,6 +1184,16 @@ const renderTribeMediaTypeSection = (tribe, items, query, mediaType) => {
         )
       );
     }
+    if (mediaType === 'torrent') {
+      return div({ class: 'tribe-media-item' },
+        blobUrl ? a({ href: blobUrl, class: 'tribe-action-btn' }, i18n.torrentDownloadButton || 'DOWNLOAD IT!') : p(i18n.tribeMediaEmpty),
+        div({ class: 'tribe-media-item-info' },
+          m.title ? h2(m.title) : null,
+          m.description ? p(...renderUrl(m.description)) : null,
+          ...mediaFooter(m)
+        )
+      );
+    }
     return null;
   };
 
@@ -1253,7 +1264,7 @@ const renderTribeMapsSection = (tribe, maps) => {
     input({ type: 'hidden', name: 'filter', value: 'create' }),
     input({ type: 'hidden', name: 'tribeId', value: tribe.id }),
     button({ type: 'submit', class: 'create-button' }, i18n.mapUploadButton || 'Create Map'));
-  if (items.length === 0) return div({ class: 'tribe-content-list' }, createBtn, p(i18n.noMaps || 'No maps yet'));
+  if (items.length === 0) return div({ class: 'tribe-content-list' }, div({ class: 'tribe-content-header' }, h2(i18n.tribeSectionMaps || 'MAPS'), createBtn), p(i18n.noMaps || 'No maps yet'));
   return div({ class: 'tribe-content-list' },
     div({ class: 'tribe-content-header' }, h2(i18n.tribeSectionMaps || 'MAPS'), createBtn),
     items.map(m =>
@@ -1286,7 +1297,7 @@ const renderTribePadsSection = (tribe, pads) => {
     input({ type: 'hidden', name: 'filter', value: 'create' }),
     input({ type: 'hidden', name: 'tribeId', value: tribe.id }),
     button({ type: 'submit', class: 'create-button' }, i18n.tribePadCreate || 'Create Pad'));
-  if (items.length === 0) return div({ class: 'tribe-content-list' }, createBtn, p(i18n.tribePadsEmpty || 'No pads, yet.'));
+  if (items.length === 0) return div({ class: 'tribe-content-list' }, div({ class: 'tribe-content-header' }, h2(i18n.tribeSectionPads || 'PADS'), createBtn), p(i18n.tribePadsEmpty || 'No pads, yet.'));
   return div({ class: 'tribe-content-list' },
     div({ class: 'tribe-content-header' }, h2(i18n.tribeSectionPads || 'PADS'), createBtn),
     items.map(m =>
@@ -1317,7 +1328,7 @@ const renderTribeChatsSection = (tribe, chats) => {
     input({ type: 'hidden', name: 'filter', value: 'create' }),
     input({ type: 'hidden', name: 'tribeId', value: tribe.id }),
     button({ type: 'submit', class: 'create-button' }, i18n.tribeChatCreate || 'Create Chat'));
-  if (items.length === 0) return div({ class: 'tribe-content-list' }, createBtn, p(i18n.tribeChatsEmpty || 'No chats, yet.'));
+  if (items.length === 0) return div({ class: 'tribe-content-list' }, div({ class: 'tribe-content-header' }, h2(i18n.tribeSectionChats || 'CHATS'), createBtn), p(i18n.tribeChatsEmpty || 'No chats, yet.'));
   return div({ class: 'tribe-content-list' },
     div({ class: 'tribe-content-header' }, h2(i18n.tribeSectionChats || 'CHATS'), createBtn),
     items.map(m =>
@@ -1349,7 +1360,7 @@ const renderTribeCalendarsSection = (tribe, calendars) => {
     input({ type: 'hidden', name: 'filter', value: 'create' }),
     input({ type: 'hidden', name: 'tribeId', value: tribe.id }),
     button({ type: 'submit', class: 'create-button' }, i18n.tribeCalendarCreate || 'Create Calendar'));
-  if (items.length === 0) return div({ class: 'tribe-content-list' }, createBtn, p(i18n.tribeCalendarsEmpty || 'No calendars, yet.'));
+  if (items.length === 0) return div({ class: 'tribe-content-list' }, div({ class: 'tribe-content-header' }, h2(i18n.tribeSectionCalendars || 'CALENDARS'), createBtn), p(i18n.tribeCalendarsEmpty || 'No calendars, yet.'));
   return div({ class: 'tribe-content-list' },
     div({ class: 'tribe-content-header' }, h2(i18n.tribeSectionCalendars || 'CALENDARS'), createBtn),
     items.map(m =>
@@ -1417,6 +1428,7 @@ exports.tribeView = async (tribe, userIdParam, query, section, sectionData) => {
     case 'videos': sectionContent = renderTribeMediaTypeSection(tribe, sectionData, query, 'video'); break;
     case 'documents': sectionContent = renderTribeMediaTypeSection(tribe, sectionData, query, 'document'); break;
     case 'bookmarks': sectionContent = renderTribeMediaTypeSection(tribe, sectionData, query, 'bookmark'); break;
+    case 'torrents': sectionContent = renderTribeMediaTypeSection(tribe, sectionData, query, 'torrent'); break;
     case 'maps': sectionContent = renderTribeMapsSection(tribe, sectionData); break;
     case 'pads': sectionContent = renderTribePadsSection(tribe, sectionData); break;
     case 'chats': sectionContent = renderTribeChatsSection(tribe, sectionData); break;