diff --git a/src/backend/backend.js b/src/backend/backend.js index aebba6d..8c832ff 100644 --- a/src/backend/backend.js +++ b/src/backend/backend.js @@ -52,7 +52,7 @@ const writeAddrMap = (map) => { fs.mkdirSync(path.dirname(ADDR_PATH), { recursiv //parliament model let electionInFlight = null; const ensureTerm = async () => { - const cur = await parliamentModel.getCurrentTerm().catch(() => null); + const cur = await parliamentModel.getPublishedTerm().catch(() => null); if (cur) return cur; if (electionInFlight) return electionInFlight; electionInFlight = parliamentModel.resolveElection().catch(() => null).finally(() => { electionInFlight = null; }); @@ -367,6 +367,10 @@ const refreshInboxCount = async (messagesOpt) => { sharedState.setInboxCount(filtered.filter(isToUser).length); }; +const refreshMentionsCount = async () => { + try { sharedState.setMentionsCount(await mentionsModel.countUnseen()); } catch (_) {} +}; + const PM_CRYPTER_MAX = 4600; const buildInboxMessages = async () => { @@ -394,7 +398,7 @@ const buildInboxMessages = async () => { } return messages; }; -const mediaFavorites = require("./media-favorites.js"); +const contentFavorites = require("./content_favorites.js"); const customStyleFile = path.join(envPaths("oasis", { suffix: "" }).config, "/custom-style.css"); let haveCustomStyle = false; try { fs.readFileSync(customStyleFile, "utf8"); haveCustomStyle = true; } catch (e) { if (e.code !== "ENOENT") { console.log(`Problem loading ${customStyleFile}`); throw e; } } @@ -578,7 +582,8 @@ const { about, blob, friend, meta, post, vote, spreads, lifetime } = models({ cooler, isPublic: config.public, }); -const { handleBlobUpload, serveBlob, FileTooLargeError } = require('../backend/blobHandler.js'); +const { handleBlobUpload, handleBlobUploads, serveBlob, FileTooLargeError } = require('../backend/blobHandler.js'); +const { mergeGallery } = require('../models/media_gallery'); const extractBlobId = (md) => md ? (md.match(/\((&[^)]+)\)/)?.[1] ?? null) : null; const exportmodeModel = require('../models/exportmode_model'); const panicmodeModel = require('../models/panicmode_model'); @@ -637,7 +642,6 @@ const documentsModel = require("../models/documents_model")({ cooler, isPublic: const trendingModel = require('../models/trending_model')({ cooler, isPublic: config.public }); const statsModel = require('../models/stats_model')({ cooler, isPublic: config.public, tribeCrypto, tribesModel }); const padsModel = require('../models/pads_model')({ cooler, cipherModel, tribeCrypto, padCrypto, tribesModel }); - const tagsModel = require('../models/tags_model')({ cooler, isPublic: config.public, padsModel, tribesModel }); const tribesContentModel = require('../models/tribes_content_model')({ cooler, isPublic: config.public, tribeCrypto, tribesModel }); const searchModel = require('../models/search_model')({ cooler, isPublic: config.public, padsModel, tribeCrypto, tribesModel }); @@ -647,15 +651,21 @@ const pixeliaModel = require('../models/pixelia_model')({ cooler, isPublic: conf const melodyModel = require('../models/melody_model')({ cooler }); const marketModel = require('../models/market_model')({ cooler, isPublic: config.public, tribeCrypto }); const forumModel = require('../models/forum_model')({ cooler, isPublic: config.public, tribeCrypto, forumCrypto }); +const blogModel = require('../models/blog_model')({ cooler, isPublic: config.public }); +const dataModel = require('../models/data_model')({ cooler }); +const workflowsModel = require('../models/workflows_model'); +const mentionsModel = require('../models/mentions_model')({ cooler }); const jobsModel = require('../models/jobs_model')({ cooler, isPublic: config.public, tribeCrypto }); +const housingModel = require('../models/housing_model')({ cooler }); const shopsModel = require('../models/shops_model')({ cooler, isPublic: config.public, tribeCrypto }); const chatsModel = require('../models/chats_model')({ cooler, tribeCrypto, chatCrypto, tribesModel }); +const pollsModel = require('../models/polls_model')({ cooler, isPublic: config.public, tribeCrypto, chatsModel }); const projectsModel = require("../models/projects_model")({ cooler, isPublic: config.public }); -const agendaModel = require("../models/agenda_model")({ cooler, isPublic: config.public, calendarsModel, eventsModel, tasksModel, marketModel, jobsModel, projectsModel, industryModel }); +const agendaModel = require("../models/agenda_model")({ cooler, isPublic: config.public, calendarsModel, eventsModel, tasksModel, marketModel, jobsModel, projectsModel, industryModel, housingModel }); const mapsModel = require("../models/maps_model")({ cooler, isPublic: config.public, tribeCrypto, mapCrypto, tribesModel }); const gamesModel = require('../models/games_model')({ cooler }); const bankingModel = require("../models/banking_model")({ services: { cooler }, isPublic: config.public }); -const favoritesModel = require("../models/favorites_model")({ services: { cooler }, audiosModel, bookmarksModel, documentsModel, imagesModel, videosModel, mapsModel, padsModel, chatsModel, calendarsModel, torrentsModel, marketModel, shopsModel }); +const favoritesModel = require("../models/favorites_model")({ services: { cooler }, audiosModel, bookmarksModel, documentsModel, imagesModel, videosModel, mapsModel, padsModel, chatsModel, calendarsModel, torrentsModel, marketModel, shopsModel, eventsModel, tasksModel, reportsModel, votesModel, jobsModel, housingModel, projectsModel, transfersModel, forumModel, blogsModel: blogModel, pollsModel }); const logsModel = require("../models/logs_model")({ cooler }); const parliamentModel = require('../models/parliament_model')({ cooler, services: { tribes: tribesModel, votes: votesModel, inhabitants: inhabitantsModel, banking: bankingModel } }); const fediverseModel = require('../models/fediverse_model')({ isPublic: config.public }); @@ -810,8 +820,40 @@ const applyListFilters = async (items, ctx, opts = {}) => { } out = filtered; } + out = await markFavorites(out, ctx); return out; }; + +const applyTextSearch = (items, q, fields) => { + const needle = String(q || '').trim().toLowerCase(); + if (!needle || !Array.isArray(items)) return items; + return items.filter(it => fields.some(f => { + const v = it && it[f]; + if (Array.isArray(v)) return v.some(x => String(x || '').toLowerCase().includes(needle)); + return String(v == null ? '' : v).toLowerCase().includes(needle); + })); +}; + +const withFavorite = async (item, kind) => { + if (!item || typeof item !== 'object') return item; + try { + const fav = await contentFavorites.getFavoriteSet(kind); + return { ...item, isFavorite: fav.has(String(item.rootId || item.id || item.key)) }; + } catch (_) { return item; } +}; + +const markFavorites = async (items, ctx) => { + if (!Array.isArray(items) || !items.length || !ctx) return items; + const kind = String(ctx.path || '').split('/').filter(Boolean)[0]; + if (!kind || !contentModCheck[kind]) return items; + let fav; + try { fav = await contentFavorites.getFavoriteSet(kind); } catch (_) { return items; } + if (!fav || typeof fav.has !== 'function') return items; + return items.map(it => (it && typeof it === 'object' && it.isFavorite === undefined) + ? { ...it, isFavorite: fav.has(String(it.rootId || it.id || it.key)) } + : it); +}; + const enrichItemLifetime = async (item, opts = {}) => { if (!item) return item; try { @@ -871,30 +913,245 @@ const resolveMapUrl = async (mapUrl) => { } catch (_) { return null; } }; -const mediaResolvers = { +const contentResolvers = { images: id => imagesModel.resolveRootId(id), audios: id => audiosModel.resolveRootId(id), videos: id => videosModel.resolveRootId(id), documents: id => documentsModel.resolveRootId(id), bookmarks: id => bookmarksModel.resolveRootId(id), shops: id => shopsModel.resolveRootId(id), + shopProducts: id => shopsModel.resolveRootId(id), chats: id => chatsModel.resolveRootId(id), maps: id => mapsModel.resolveRootId(id), pads: id => padsModel.resolveRootId(id), calendars: id => calendarsModel.resolveRootId(id), - torrents: id => torrentsModel.resolveRootId(id) + torrents: id => torrentsModel.resolveRootId(id), + events: async id => id, + blogs: async id => id, + logs: async id => id, + polls: async id => pollsModel.resolveRootId(id).catch(() => id), + forum: async id => id, + tasks: async id => id, + reports: async id => id, + votes: async id => id, + jobs: async id => jobsModel.resolveRootId(id).catch(() => id), + housing: async id => housingModel.resolveRootId(id).catch(() => id), + projects: async id => id, + transfers: async id => id, + market: async id => id }; -const mediaModCheck = { images: 'imagesMod', audios: 'audiosMod', videos: 'videosMod', documents: 'documentsMod', bookmarks: 'bookmarksMod', market: 'marketMod', jobs: 'jobsMod', projects: 'projectsMod', shops: 'shopsMod', chats: 'chatsMod', maps: 'mapsMod', pads: 'padsMod', calendars: 'calendarsMod', torrents: 'torrentsMod' }; +const contentModCheck = { images: 'imagesMod', audios: 'audiosMod', videos: 'videosMod', documents: 'documentsMod', bookmarks: 'bookmarksMod', market: 'marketMod', jobs: 'jobsMod', projects: 'projectsMod', shops: 'shopsMod', shopProducts: 'shopsMod', chats: 'chatsMod', maps: 'mapsMod', pads: 'padsMod', calendars: 'calendarsMod', torrents: 'torrentsMod', events: 'eventsMod', forum: 'forumMod', blogs: 'blogsMod', logs: 'logsMod', polls: 'pollsMod', tasks: 'tasksMod', reports: 'reportsMod', votes: 'votesMod', housing: 'housingMod', transfers: 'transfersMod' }; const favAction = async (ctx, kind, action) => { - if (!checkMod(ctx, mediaModCheck[kind])) { ctx.redirect('/modules'); return; } + if (!checkMod(ctx, contentModCheck[kind])) { ctx.redirect('/modules'); return; } try { - const rootId = await mediaResolvers[kind](ctx.params.id); - if (rootId) await mediaFavorites[action + 'Favorite'](kind, rootId); + const rootId = await contentResolvers[kind](ctx.params.id); + if (rootId) await contentFavorites[action + 'Favorite'](kind, rootId); } catch (_) {} ctx.redirect(safeReturnTo(ctx, `/${kind}`, [`/${kind}`])); }; +const voteFormRedirect = (mode, id, err, body = {}) => { + const params = new URLSearchParams(); + params.set('error', err && err.code === 'VOTE_DEADLINE_MIN' ? 'deadline' : 'generic'); + if (body.question) params.set('question', String(body.question).slice(0, 300)); + if (body.deadline) params.set('deadline', String(body.deadline).slice(0, 40)); + if (body.tags) params.set('tags', String(body.tags).slice(0, 300)); + return mode === 'edit' && id + ? `/votes/edit/${encodeURIComponent(id)}?${params.toString()}` + : `/votes?filter=create&${params.toString()}`; +}; + +const voteFormState = (ctx) => ({ + error: typeof ctx.query.error === 'string' ? ctx.query.error : '', + minVoteDays: votesModel.MIN_VOTE_DAYS, + draft: { + question: typeof ctx.query.question === 'string' ? ctx.query.question : '', + deadline: typeof ctx.query.deadline === 'string' ? ctx.query.deadline : '', + tags: typeof ctx.query.tags === 'string' ? ctx.query.tags : '' + } +}); + +const notifyHousingRequesters = async (item, reason) => { + if (!item || String(item.author) !== String(getViewerId())) return; + const requesters = Array.isArray(item.requests) ? item.requests.filter(id => id && id !== item.author) : []; + if (!requesters.length) return; + const label = reason === 'deleted' ? 'has been removed' : 'is no longer available'; + for (const requester of requesters) { + try { + await pmModel.sendMessage([requester], 'HOUSING_UNAVAILABLE', `The place [${item.title || 'a place'}](/housing/${encodeURIComponent(item.id)}) you requested ${label}`); + } catch (_) {} + } +}; + +const readInterval = (b) => { + const single = String((b && b.interval) || '').toLowerCase(); + if (single) { + return { + intervalWeekly: single === 'weekly', + intervalMonthly: single === 'monthly', + intervalYearly: single === 'yearly' + }; + } + return { + intervalWeekly: [].concat(b.intervalWeekly).includes('1'), + intervalMonthly: [].concat(b.intervalMonthly).includes('1'), + intervalYearly: [].concat(b.intervalYearly).includes('1') + }; +}; + +const readGalleryUpload = async (ctx) => { + const b = ctx.request.body || {}; + const uploaded = await handleBlobUploads(ctx, 'images'); + const clip = ctx.request.files?.video ? await handleBlobUpload(ctx, 'video') : null; + const removeIndex = b.removePhoto !== undefined && b.removePhoto !== '' ? parseInt(b.removePhoto, 10) : -1; + const action = String(b.action || ''); + const keep = [].concat(b.keepImages || []).filter(Boolean); + return { + uploaded, + clip, + keep, + removeIndex, + action, + isMediaAction: action === 'addPhoto' || action === 'addVideo' || action === 'removeVideo' || removeIndex >= 0 + }; +}; + +const galleryPatch = (media, current) => { + const patch = {}; + if (media.uploaded.length || media.removeIndex >= 0) patch.images = mergeGallery(current, media.uploaded, media.removeIndex); + if (media.clip) patch.video = media.clip; + else if (media.action === 'removeVideo') patch.video = ''; + return patch; +}; + +const formatFileSize = (n) => { + const x = Number(n) || 0; + if (x < 1024) return x + ' B'; + const u = ['KB', 'MB', 'GB', 'TB']; + let v = x / 1024, i = 0; + while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; } + return v.toFixed(v >= 100 || i === 0 ? 0 : 1) + ' ' + u[i]; +}; + +const PDF_KINDS = { + reports: { mod: 'reportsMod', load: async (id) => ({ item: await reportsModel.getReportById(id) }) }, + votes: { mod: 'votesMod', load: async (id) => ({ item: await votesModel.getVoteById(id) }) }, + events: { + mod: 'eventsMod', + load: async (id, viewerId) => { + const event = await eventsModel.getEventById(id); + if (!event) return null; + const attendees = Array.isArray(event.attendees) ? event.attendees : []; + const isPrivate = String(event.isPublic || '').toUpperCase() === 'PRIVATE'; + if (isPrivate && String(event.organizer) !== String(viewerId) && !attendees.includes(viewerId)) return null; + return { item: event }; + } + }, + tasks: { + mod: 'tasksMod', + load: async (id, viewerId) => { + const task = await tasksModel.getTaskById(id); + if (!task) return null; + const assignees = Array.isArray(task.assignees) ? task.assignees : []; + const isPrivate = String(task.isPublic || '').toUpperCase() === 'PRIVATE'; + if (isPrivate && String(task.author) !== String(viewerId) && !assignees.includes(viewerId)) return null; + return { item: task }; + } + }, + calendars: { + mod: 'calendarsMod', + load: async (id, viewerId) => { + const cal = await calendarsModel.getCalendarById(id); + if (!cal) return null; + if (cal.tribeId) { + const tribe = await tribesModel.getTribeById(cal.tribeId).catch(() => null); + if (!tribe || !tribe.members.includes(viewerId)) return null; + } else { + const participants = Array.isArray(cal.participants) ? cal.participants : []; + const isOpen = String(cal.status || '').toUpperCase() === 'OPEN'; + if (!isOpen && cal.author !== viewerId && !participants.includes(viewerId)) return null; + } + if (String(cal.status || '').toUpperCase() === 'CLOSED' && cal.author !== viewerId) return null; + const dates = await calendarsModel.getDatesForCalendar(cal.rootId); + const notesByDate = {}; + for (const d of dates) notesByDate[d.key] = await calendarsModel.getNotesForDate(cal.rootId, d.key); + return { item: cal, extra: { dates, notesByDate } }; + } + }, + cv: { mod: null, load: async () => ({ item: await cvModel.getCVByUserId() }) } +}; + +const loadPdfDoc = async (ctx, kind, id) => { + const cfg = PDF_KINDS[kind]; + if (!cfg) return null; + if (cfg.mod && !checkMod(ctx, cfg.mod)) return null; + try { + return await cfg.load(id, getViewerId()); + } catch (_) { + return null; + } +}; + +const sendContentPdf = async (ctx, kind, id) => { + const doc = await loadPdfDoc(ctx, kind, id); + if (!doc || !doc.item) { ctx.redirect(`/${kind === 'cv' ? 'cv' : kind}`); return; } + const pdf = buildContentPdf(kind, doc.item, doc.extra || {}, getViewerId()); + ctx.set('Content-Type', 'application/pdf'); + ctx.set('Content-Disposition', `attachment; filename="${pdfFilename(kind, doc.item)}"`); + ctx.body = pdf; +}; + +const sharePdfBuffer = async (ctx, pdf, filename, subject) => { + let pointer; + try { + pointer = await fileshareModel.createShareFromBuffer({ buffer: pdf, filename, mime: 'application/pdf' }); + } catch (_) { + ctx.redirect('/pm?fileerror=failed#fileshare'); + return; + } + const subjectText = String(subject || filename).slice(0, 150); + ctx.body = await pmView('', subjectText, '', false, '', false, null, false, '', { + recipient: '', subject: subjectText, + manifestBlobId: pointer.manifestBlobId, keyHex: pointer.key, + filename: pointer.filename, mime: pointer.mime, size: pointer.size, + sizeLabel: formatFileSize(pointer.size), crypter: false, sharedKey: '' + }); +}; + +const sharePdfAsPm = async (ctx, kind, id) => { + const doc = await loadPdfDoc(ctx, kind, id); + if (!doc || !doc.item) { ctx.redirect(`/${kind}`); return; } + const pdf = buildContentPdf(kind, doc.item, doc.extra || {}, null); + const subject = doc.item.title || doc.item.question || doc.item.name || ''; + await sharePdfBuffer(ctx, pdf, pdfFilename(kind, doc.item), subject); +}; + +const MODULE_HOME_PATHS = new Set([ + 'images', 'audios', 'videos', 'documents', 'bookmarks', 'torrents', 'maps', + 'events', 'tasks', 'reports', 'votes', 'market', 'jobs', 'housing', 'projects', + 'industry', 'shops', 'transfers', 'pads', 'chats', 'calendars', 'forum', + 'tribes', 'feed', 'logs', 'opinions', 'trending', 'agenda' +]); + +const moduleHomeFor = (ctx) => { + if (!ctx || ctx.method !== 'GET') return null; + const segment = String(ctx.path || '').split('/').filter(Boolean)[0]; + return segment && MODULE_HOME_PATHS.has(segment) ? `/${segment}` : null; +}; + +const isMissingContentError = (err) => { + if (!err) return false; + if (err.status === 400 || err.status === 404 || err.name === 'BadRequestError') return true; + const msg = String(err.message || '').toLowerCase(); + return msg.includes('failed to decode') || + msg.includes('not found') || + msg.includes('malformed') || + msg.includes('invalid') || + msg.includes('cannot be decrypted') || + msg.includes('undecodable'); +}; + const commentAction = async (ctx, kind, idParam) => { - const modKey = mediaModCheck[kind]; + const modKey = contentModCheck[kind]; if (modKey && !checkMod(ctx, modKey)) { ctx.redirect('/modules'); return; } const itemId = ctx.params[idParam]; let text = stripDangerousTags((ctx.request.body.text || '').trim()); @@ -908,14 +1165,14 @@ const commentAction = async (ctx, kind, idParam) => { const opinionModels = { images: imagesModel, audios: audiosModel, videos: videosModel, documents: documentsModel, bookmarks: bookmarksModel, torrents: torrentsModel }; const deleteModels = { images: imagesModel, audios: audiosModel, videos: videosModel, documents: documentsModel, bookmarks: bookmarksModel, torrents: torrentsModel }; const opinionAction = async (ctx, kind, idParam) => { - const modKey = mediaModCheck[kind]; + const modKey = contentModCheck[kind]; if (modKey && !checkMod(ctx, modKey)) { ctx.redirect('/modules'); return; } await opinionModels[kind].createOpinion(ctx.params[idParam], ctx.params.category); try { activityModel.invalidateCache(); } catch (_) {} ctx.redirect(safeReturnTo(ctx, `/${kind}`, [`/${kind}`])); }; const deleteAction = async (ctx, kind, deleteFn = 'delete' + kind.charAt(0).toUpperCase() + kind.slice(1, -1) + 'ById') => { - const modKey = mediaModCheck[kind]; + const modKey = contentModCheck[kind]; if (modKey && !checkMod(ctx, modKey)) { ctx.redirect('/modules'); return; } await deleteModels[kind][deleteFn](ctx.params.id); ctx.redirect(safeReturnTo(ctx, `/${kind}?filter=mine`, [`/${kind}`])); @@ -923,7 +1180,7 @@ const deleteAction = async (ctx, kind, deleteFn = 'delete' + kind.charAt(0).toUp const mediaCreateModels = { audios: audiosModel, videos: videosModel }; const mediaCreateAction = async (ctx, kind) => { - const modKey = mediaModCheck[kind]; + const modKey = contentModCheck[kind]; if (modKey && !checkMod(ctx, modKey)) { ctx.redirect('/modules'); return; } const blob = await handleBlobUpload(ctx, kind.slice(0, -1)); const { tags, title, description, mapUrl } = ctx.request.body; @@ -931,7 +1188,7 @@ const mediaCreateAction = async (ctx, kind) => { ctx.redirect(safeReturnTo(ctx, `/${kind}?filter=all`, [`/${kind}`])); }; const mediaUpdateAction = async (ctx, kind) => { - const modKey = mediaModCheck[kind]; + const modKey = contentModCheck[kind]; if (modKey && !checkMod(ctx, modKey)) { ctx.redirect('/modules'); return; } const { tags, title, description, mapUrl } = ctx.request.body; const singular = kind.slice(0, -1); @@ -1358,7 +1615,7 @@ const resolveCommentComponents = async function (ctx) { } return { messages, myFeedId, parentMessage, contentWarning }; }; -const { authorView, previewCommentView, commentView, editProfileView, extendedView, latestView, likesView, threadView, hashtagView, mentionsView, popularView, previewView, privateView, publishCustomView, publishView, previewSubtopicView, subtopicView, imageSearchView, setLanguage, topicsView, summaryView, threadsView, tribeAccessDeniedView, inviteRequiredView, clearnetInhabitantView, clearnetBlogView } = require("../views/main_views"); +const { authorView, previewCommentView, commentView, editProfileView, likesView, threadView, hashtagView, privateView, previewSubtopicView, subtopicView, imageSearchView, setLanguage, tribeAccessDeniedView, inviteRequiredView, clearnetInhabitantView, clearnetBlogView } = require("../views/main_views"); const { activityView } = require("../views/activity_view"); const { cvView, createCVView } = require("../views/cv_view"); const { indexingView } = require("../views/indexing_view"); @@ -1395,15 +1652,20 @@ const { searchView } = require("../views/search_view"); const { transferView, singleTransferView } = require("../views/transfer_view"); const { cipherView } = require("../views/cipher_view"); const { imageView, singleImageView } = require("../views/image_view"); -const { mapsView, singleMapView } = require("../views/maps_view"); +const { mapsView, singleMapView , renderMapInvitePage } = require("../views/maps_view"); const { settingsView } = require("../views/settings_view"); const { fediverseView, fediverseThreadView, fediverseOverviewView, fediversePreviewView } = require("../views/fediverse_view"); const { trendingView } = require("../views/trending_view"); const { marketView, singleMarketView } = require("../views/market_view"); const { aiView } = require("../views/AI_view"); const { forumView, singleForumView, renderForumInvitePage } = require("../views/forum_view"); +const { blogView, singleBlogView } = require("../views/blog_view"); +const { dataView } = require("../views/data_view"); +const { pollsView, singlePollView } = require("../views/polls_view"); +const { mentionsView } = require("../views/mentions_view"); const { renderBlockchainView, renderSingleBlockView } = require("../views/blockchain_view"); const { jobsView, singleJobsView, renderJobForm, clearnetJobView } = require("../views/jobs_view"); +const { housingView, singleHousingView } = require("../views/housing_view"); const { shopsView, singleShopView, singleProductView, editProductView, shopOrdersView, myPurchasesView, clearnetShopView, renderShopInvitePage } = require("../views/shops_view"); const { chatsView, singleChatView, renderChatInvitePage } = require("../views/chats_view"); const { padsView, singlePadView, renderPadInvitePage } = require("../views/pads_view"); @@ -1415,6 +1677,7 @@ const { favoritesView } = require("../views/favorites_view"); const { logsView } = require("../views/logs_view"); const { buildLogsPdf } = require("./logsPdf"); const { buildSmartContractPdf } = require("./smartContractPdf"); +const { buildContentPdf, pdfFilename } = require("./contentPdf"); const { parliamentView } = require("../views/parliament_view"); const { courtsView, courtsCaseView } = require('../views/courts_view'); let sharp; @@ -1507,6 +1770,10 @@ router ctx.body = ainavHomeView({ recentTags }); return; } + if (currentConfig.ux?.current === "chats") { + ctx.redirect("/chats"); + return; + } const homePage = currentConfig.homePage || "activity"; ctx.redirect(`/${homePage}`); }) @@ -1538,15 +1805,8 @@ router try { stats.userEcoinTax = await bankingModel.getUserEcoinTax(getViewerId()); } catch (_) { stats.userEcoinTax = 0; } ctx.body = statsView(stats, filter); }) - .get("/public/popular/:period", async (ctx) => { - if (!checkMod(ctx, 'popularMod')) return ctx.redirect('/modules'); - const i18n = require("../client/assets/translations/i18n"), lang = ctx.cookies.get('language') || getConfig().language || 'en', t = i18n[lang] || i18n['en']; - const messages = sanitizeMessages(await post.popular({ period: ctx.params.period })); - const spreadMap = await spreads.forMessages((messages || []).map(m => m && m.key)).catch(() => new Map()); - ctx.body = await popularView({ messages, prefix: nav(div({ class: "filters" }, ul(['day','week','month','year'].map(p => li(form({ method: "GET", action: `/public/popular/${p}` }, button({ type: "submit", class: "filter-btn" }, t[p]))))))), spreadMap }); - }) .get("/modules", async (ctx) => { - const modules = ['popular', 'topics', 'summaries', 'latest', 'threads', 'multiverse', 'fediverse', 'invites', 'wallet', 'legacy', 'dev', 'cipher', 'bookmarks', 'calendars', 'chats', 'videos', 'docs', 'audios', 'tags', 'images', 'maps', 'trending', 'events', 'tasks', 'market', 'tribes', 'larp', 'votes', 'reports', 'opinions', 'pads', 'transfers', 'feed', 'pixelia', 'melody', 'agenda', 'favorites', 'ai', 'forum', 'games', 'jobs', 'projects', 'industry', 'shops', 'banking', 'parliament', 'courts']; + const modules = ['blogs', 'polls', 'fediverse', 'invites', 'wallet', 'legacy', 'dev', 'cipher', 'bookmarks', 'calendars', 'chats', 'videos', 'docs', 'audios', 'tags', 'images', 'maps', 'trending', 'events', 'tasks', 'market', 'tribes', 'larp', 'votes', 'reports', 'opinions', 'pads', 'transfers', 'feed', 'pixelia', 'melody', 'agenda', 'favorites', 'ai', 'forum', 'games', 'housing', 'jobs', 'projects', 'industry', 'shops', 'banking', 'parliament', 'courts']; const cfg = getConfig().modules; ctx.body = modulesView(modules.reduce((acc, m) => { acc[`${m}Mod`] = cfg[`${m}Mod`]; return acc; }, {})); }) @@ -1742,41 +2002,6 @@ router } ctx.body = renderSingleBlockView(block, filter, userId, search, viewMode, restricted); }) - .get("/public/latest", async (ctx) => { - if (!checkMod(ctx, 'latestMod')) { ctx.redirect('/modules'); return; } - const messages = sanitizeMessages(await post.latest()); - const spreadMap = await spreads.forMessages((messages || []).map(m => m && m.key)).catch(() => new Map()); - ctx.body = await latestView({ messages, spreadMap }); - }) - .get("/public/latest/extended", async (ctx) => { - if (!checkMod(ctx, 'extendedMod')) { ctx.redirect('/modules'); return; } - const messages = sanitizeMessages(await post.latestExtended()); - const spreadMap = await spreads.forMessages((messages || []).map(m => m && m.key)).catch(() => new Map()); - ctx.body = await extendedView({ messages, spreadMap }); - }) - .get("/public/latest/topics", async (ctx) => { - if (!checkMod(ctx, 'topicsMod')) { ctx.redirect('/modules'); return; } - const messages = sanitizeMessages(await post.latestTopics()); - const channels = await post.channels(); - const list = channels.map((c) => { - return li(a({ href: `/hashtag/${c}` }, `#${c}`)); - }); - const prefix = nav(ul(list)); - const spreadMap = await spreads.forMessages((messages || []).map(m => m && m.key)).catch(() => new Map()); - ctx.body = await topicsView({ messages, prefix, spreadMap }); - }) - .get("/public/latest/summaries", async (ctx) => { - if (!checkMod(ctx, 'summariesMod')) { ctx.redirect('/modules'); return; } - const messages = sanitizeMessages(await post.latestSummaries()); - const spreadMap = await spreads.forMessages((messages || []).map(m => m && m.key)).catch(() => new Map()); - ctx.body = await summaryView({ messages, spreadMap }); - }) - .get("/public/latest/threads", async (ctx) => { - if (!checkMod(ctx, 'threadsMod')) { ctx.redirect('/modules'); return; } - const messages = sanitizeMessages(await post.latestThreads()); - const spreadMap = await spreads.forMessages((messages || []).map(m => m && m.key)).catch(() => new Map()); - ctx.body = await threadsView({ messages, spreadMap }); - }) .get('/author/:feed', async (ctx) => { const feedId = decodeURIComponent(ctx.params.feed || ''), gt = Number(ctx.request.query.gt || -1), lt = Number(ctx.request.query.lt || -1); if (lt > 0 && gt > 0 && gt >= lt) throw new Error('Given search range is empty'); @@ -1814,7 +2039,7 @@ router const { bucket: lastActivityBucket } = inhabitantsModel.bucketLastActivity(fullLastTs || null); const profileItems = await fetchProfileItems(feedId, rawPrefs); const profileFilterType = String(ctx.query.type || '').toLowerCase(); - const profileSpreadable = new Set(['post','audio','video','image','document','torrent','bookmark','event','calendar','task','votes','vote','market','shop','shopProduct','project','industry','industryBuild','industryBlueprint','transfer','job','report','chat','chatMessage','pad','padEntry','forum','map']); + const profileSpreadable = new Set(['post','audio','video','image','document','torrent','bookmark','event','calendar','task','votes','vote','market','shop','shopProduct','project','industry','industryBuild','industryBlueprint','transfer','housing','job','report','chat','chatMessage','pad','padEntry','forum','map']); const profileSpreadKeys = (allActions || []).filter(a => a && a.id && typeof a.id === 'string' && a.id.startsWith('%') && /\.sha256$/.test(a.id) && profileSpreadable.has(a.type)).map(a => a.id); const spreadMap = await spreads.forMessages(profileSpreadKeys).catch(() => new Map()); await warmAuthorNames(allActions, sanitizedMsgs, profileItems); @@ -1836,6 +2061,7 @@ router const applySearchPrivacy = (msgs) => msgs.filter(msg => { const c = msg.value?.content; if (!c) return true; + if (c.type === 'pub') return false; if (Array.isArray(c.recps)) return false; if (c.type === 'post' && (c.private === true || c.recps)) return false; if (c.tribeId && anonTribeIds.has(c.tribeId)) return false; @@ -1901,7 +2127,7 @@ router const { filter = 'all', q = '', sort = 'recent' } = ctx.query; const viewerPrefs = await about.visibilityPrefs(getViewerId()).catch(() => null); const items = await imagesModel.listAll({ filter: filter === 'favorites' ? 'all' : filter, q, sort, viewerId: getViewerId() }); - const fav = await mediaFavorites.getFavoriteSet('images'); + const fav = await contentFavorites.getFavoriteSet('images'); let enriched = items.map(x => ({ ...x, isFavorite: fav.has(String(x.rootId || x.key)) })); if (filter === 'favorites') enriched = enriched.filter(x => x.isFavorite); enriched = await applyListFilters(enriched, ctx); @@ -1913,14 +2139,14 @@ router .get("/images/edit/:id", async (ctx) => { if (!checkMod(ctx, 'imagesMod')) { ctx.redirect('/modules'); return; } const img = await imagesModel.getImageById(ctx.params.id, getViewerId()); - const fav = await mediaFavorites.getFavoriteSet('images'); + const fav = await contentFavorites.getFavoriteSet('images'); ctx.body = await imageView([{ ...img, isFavorite: fav.has(String(img.rootId || img.key)) }], 'edit', img.key, { returnTo: ctx.query.returnTo || '' }); }) .get("/images/:imageId", async (ctx) => { if (!checkMod(ctx, 'imagesMod')) { ctx.redirect('/modules'); return; } const { imageId } = ctx.params; const { filter = 'all', q = '', sort = 'recent' } = ctx.query; const img = await imagesModel.getImageById(imageId, getViewerId()); - const fav = await mediaFavorites.getFavoriteSet('images'); + const fav = await contentFavorites.getFavoriteSet('images'); const comments = await getVoteComments(img.key); const imgAuthorPrefs = await about.visibilityPrefs(img.author).catch(() => null); await enrichItemLifetime(img, { key: img.key }); @@ -1931,7 +2157,7 @@ router const { filter = 'all', q = '', lat, lng, zoom, tribeId, title, description, markerLabel, tags, mapType } = ctx.query; const uid = getViewerId(); const items = await mapsModel.listAll({ filter: filter === 'favorites' ? 'all' : filter, q, viewerId: uid }); - const fav = await mediaFavorites.getFavoriteSet('maps'); + const fav = await contentFavorites.getFavoriteSet('maps'); let enriched = items.map(x => ({ ...x, isFavorite: fav.has(String(x.rootId || x.key)) })); if (filter === 'favorites') enriched = enriched.filter(x => x.isFavorite); const myTribeIds = await getUserTribeIds(uid); @@ -1952,7 +2178,7 @@ router try { mapItem = await mapsModel.getMapById(ctx.params.id, getViewerId()); } catch (_) { ctx.redirect('/maps?filter=all'); return; } if (!mapItem) { ctx.redirect('/maps?filter=all'); return; } if (mapItem.author !== getViewerId()) { ctx.redirect(`/maps/${encodeURIComponent(mapItem.key)}`); return; } - const fav = await mediaFavorites.getFavoriteSet('maps'); + const fav = await contentFavorites.getFavoriteSet('maps'); ctx.body = await mapsView([{ ...mapItem, isFavorite: fav.has(String(mapItem.rootId || mapItem.key)) }], 'edit', mapItem.key, { returnTo: ctx.query.returnTo || '' }); }) .get("/maps/:mapId", async (ctx) => { @@ -1968,7 +2194,7 @@ router return; } if (!mapItem) { ctx.redirect('/maps?filter=all'); return; } - const fav = await mediaFavorites.getFavoriteSet('maps'); + const fav = await contentFavorites.getFavoriteSet('maps'); let tribeMembers = []; let parentTribe = null; if (mapItem.tribeId) { @@ -1993,7 +2219,7 @@ router const { filter = 'all', q = '', sort = 'recent' } = ctx.query; const viewerPrefs = await about.visibilityPrefs(getViewerId()).catch(() => null); const items = await audiosModel.listAll({ filter: filter === 'favorites' ? 'all' : filter, q, sort, viewerId: getViewerId() }); - const fav = await mediaFavorites.getFavoriteSet('audios'); + const fav = await contentFavorites.getFavoriteSet('audios'); let enriched = items.map(x => ({ ...x, isFavorite: fav.has(String(x.rootId || x.key)) })); if (filter === 'favorites') enriched = enriched.filter(x => x.isFavorite); enriched = await applyListFilters(enriched, ctx); @@ -2005,14 +2231,14 @@ router .get("/audios/edit/:id", async (ctx) => { if (!checkMod(ctx, 'audiosMod')) { ctx.redirect('/modules'); return; } const audio = await audiosModel.getAudioById(ctx.params.id, getViewerId()); - const fav = await mediaFavorites.getFavoriteSet('audios'); + const fav = await contentFavorites.getFavoriteSet('audios'); ctx.body = await audioView([{ ...audio, isFavorite: fav.has(String(audio.rootId || audio.key)) }], 'edit', audio.key, { returnTo: ctx.query.returnTo || '' }); }) .get("/audios/:audioId", async (ctx) => { if (!checkMod(ctx, 'audiosMod')) { ctx.redirect('/modules'); return; } const { audioId } = ctx.params; const { filter = 'all', q = '', sort = 'recent' } = ctx.query; const audio = await audiosModel.getAudioById(audioId, getViewerId()); - const fav = await mediaFavorites.getFavoriteSet('audios'); + const fav = await contentFavorites.getFavoriteSet('audios'); const comments = await getVoteComments(audio.key); const audioAuthorPrefs = await about.visibilityPrefs(audio.author).catch(() => null); await enrichItemLifetime(audio, { key: audio.key }); @@ -2023,7 +2249,7 @@ router const { filter = 'all', q = '', sort = 'recent', tribeId = '' } = ctx.query; const viewerPrefs = await about.visibilityPrefs(getViewerId()).catch(() => null); const items = await torrentsModel.listAll({ filter: filter === 'favorites' ? 'all' : filter, q, sort, viewerId: getViewerId() }); - const fav = await mediaFavorites.getFavoriteSet('torrents'); + const fav = await contentFavorites.getFavoriteSet('torrents'); let enriched = items.filter(x => !x.tribeId).map(x => ({ ...x, isFavorite: fav.has(String(x.rootId || x.key)) })); if (filter === 'favorites') enriched = enriched.filter(x => x.isFavorite); enriched = await applyListFilters(enriched, ctx); @@ -2034,14 +2260,14 @@ router .get("/torrents/edit/:id", async (ctx) => { if (!checkMod(ctx, 'torrentsMod')) { ctx.redirect('/modules'); return; } const torrent = await torrentsModel.getTorrentById(ctx.params.id, getViewerId()); - const fav = await mediaFavorites.getFavoriteSet('torrents'); + const fav = await contentFavorites.getFavoriteSet('torrents'); ctx.body = await torrentsView([{ ...torrent, isFavorite: fav.has(String(torrent.rootId || torrent.key)) }], 'edit', torrent.key, { returnTo: ctx.query.returnTo || '' }); }) .get("/torrents/:torrentId", async (ctx) => { if (!checkMod(ctx, 'torrentsMod')) { ctx.redirect('/modules'); return; } const { torrentId } = ctx.params; const { filter = 'all', q = '', sort = 'recent' } = ctx.query; const torrent = await torrentsModel.getTorrentById(torrentId, getViewerId()); - const fav = await mediaFavorites.getFavoriteSet('torrents'); + const fav = await contentFavorites.getFavoriteSet('torrents'); const comments = await getVoteComments(torrent.key); const torrentAuthorPrefs = await about.visibilityPrefs(torrent.author).catch(() => null); await enrichItemLifetime(torrent, { key: torrent.key }); @@ -2052,7 +2278,7 @@ router const { filter = 'all', q = '', sort = 'recent' } = ctx.query; const viewerPrefs = await about.visibilityPrefs(getViewerId()).catch(() => null); const items = await videosModel.listAll({ filter: filter === 'favorites' ? 'all' : filter, q, sort, viewerId: getViewerId() }); - const fav = await mediaFavorites.getFavoriteSet('videos'); + const fav = await contentFavorites.getFavoriteSet('videos'); let enriched = items.map(x => ({ ...x, isFavorite: fav.has(String(x.rootId || x.key)) })); if (filter === 'favorites') enriched = enriched.filter(x => x.isFavorite); enriched = await applyListFilters(enriched, ctx); @@ -2064,14 +2290,14 @@ router .get("/videos/edit/:id", async (ctx) => { if (!checkMod(ctx, 'videosMod')) { ctx.redirect('/modules'); return; } const video = await videosModel.getVideoById(ctx.params.id, getViewerId()); - const fav = await mediaFavorites.getFavoriteSet('videos'); + const fav = await contentFavorites.getFavoriteSet('videos'); ctx.body = await videoView([{ ...video, isFavorite: fav.has(String(video.rootId || video.key)) }], 'edit', video.key, { returnTo: ctx.query.returnTo || '' }); }) .get("/videos/:videoId", async (ctx) => { if (!checkMod(ctx, 'videosMod')) { ctx.redirect('/modules'); return; } const { videoId } = ctx.params; const { filter = 'all', q = '', sort = 'recent' } = ctx.query; const video = await videosModel.getVideoById(videoId, getViewerId()); - const fav = await mediaFavorites.getFavoriteSet('videos'); + const fav = await contentFavorites.getFavoriteSet('videos'); const comments = await getVoteComments(video.key); const videoAuthorPrefs = await about.visibilityPrefs(video.author).catch(() => null); await enrichItemLifetime(video, { key: video.key }); @@ -2081,7 +2307,7 @@ router const { filter = 'all', q = '', sort = 'recent' } = ctx.query; const viewerPrefs = await about.visibilityPrefs(getViewerId()).catch(() => null); const items = await documentsModel.listAll({ filter: filter === 'favorites' ? 'all' : filter, q, sort }); - const fav = await mediaFavorites.getFavoriteSet('documents'); + const fav = await contentFavorites.getFavoriteSet('documents'); let enriched = items.map(x => ({ ...x, isFavorite: fav.has(String(x.rootId || x.key)) })); if (filter === 'favorites') enriched = enriched.filter(x => x.isFavorite); enriched = await applyListFilters(enriched, ctx); @@ -2092,13 +2318,13 @@ router }) .get("/documents/edit/:id", async (ctx) => { const doc = await documentsModel.getDocumentById(ctx.params.id); - const fav = await mediaFavorites.getFavoriteSet('documents'); + const fav = await contentFavorites.getFavoriteSet('documents'); ctx.body = await documentView([{ ...doc, isFavorite: fav.has(String(doc.rootId || doc.key)) }], 'edit', doc.key, { returnTo: ctx.query.returnTo || '' }); }) .get("/documents/:documentId", async (ctx) => { const { filter = "all", q = "", sort = "recent" } = ctx.query; const document = await documentsModel.getDocumentById(ctx.params.documentId); - const fav = await mediaFavorites.getFavoriteSet('documents'); + const fav = await contentFavorites.getFavoriteSet('documents'); Object.assign(document, { isFavorite: fav.has(String(document.rootId || document.key)) }); const comments = await getVoteComments(document.rootId || document.key); const docAuthorPrefs = await about.visibilityPrefs(document.author).catch(() => null); @@ -2150,18 +2376,21 @@ router ctx.body = await privateView({ messages }, returnFilter || 'all', decrypted); }) .get('/tags', async ctx => { - const filter = qf(ctx), tags = await tagsModel.listTags(filter); - ctx.body = await tagsView(tags, filter); + const filter = qf(ctx), search = String(ctx.query.search || '').trim(); + const tags = await tagsModel.listTags(filter, search); + ctx.body = await tagsView(tags, filter, search); }) .get('/reports', async ctx => { const filter = qf(ctx); + const q = String(ctx.query.q || '').trim(); let reports = await enrichWithComments(await reportsModel.listAll()); + reports = applyTextSearch(reports, q, ['title', 'description', 'category', 'tags']); reports = await applyListFilters(reports, ctx); try { reports = await lifetime.enrichAndFilter(reports); } catch (_) {} await enrichMsgSize(reports); const spreadMap = await spreads.forMessages((reports || []).map(x => x && (x.id || x.key))); await warmAuthorNames(reports); - ctx.body = await reportView(reports, filter, null, ctx.query.category || '', { spreadMap, prefillTitle: ctx.query.title || '', prefillDescription: ctx.query.description || '' }); + ctx.body = await reportView(reports, filter, null, ctx.query.category || '', { spreadMap, q, prefillTitle: ctx.query.title || '', prefillDescription: ctx.query.description || '' }); }) .get('/reports/edit/:id', async ctx => { const report = await reportsModel.getReportById(ctx.params.id); @@ -2172,23 +2401,36 @@ router const comments = await getVoteComments(reportId); await enrichMsgSize([report]); await enrichItemLifetime(report); - ctx.body = await singleReportView(withCount(report, comments), filter, comments, { spreads: await spreads.forMessage(report.id).catch(() => null) }); + ctx.body = await singleReportView(await withFavorite(withCount(report, comments), 'reports'), filter, comments, { spreads: await spreads.forMessage(report.id).catch(() => null) }); }) .get('/trending', async (ctx) => { - const filter = qf(ctx, 'TOP'); + const filter = qf(ctx, 'ALL'); + const q = String(ctx.query.q || '').trim(); let { filtered = [] } = await trendingModel.listTrending(filter); filtered = await applyListFilters(filtered, ctx); + if (q) { + const needle = q.toLowerCase(); + filtered = filtered.filter(m => { + const c = (m && m.value && m.value.content) || {}; + return [c.title, c.name, c.question, c.description, c.text, c.concept, ...(Array.isArray(c.tags) ? c.tags : [])] + .some(v => String(v || '').toLowerCase().includes(needle)); + }); + } const spreadMap = new Map(); const results = await Promise.all(filtered.map(it => it && it.key ? spreads.forMessage(it.key).catch(() => null) : Promise.resolve(null))); filtered.forEach((it, i) => { if (it && it.key && results[i]) spreadMap.set(it.key, results[i]); }); await warmAuthorNames(filtered); - ctx.body = await trendingView(filtered, filter, trendingModel.categories, spreadMap); + ctx.body = await trendingView(filtered, filter, trendingModel.categories, spreadMap, q); }) .get('/agenda', async (ctx) => { const filter = qf(ctx); + const q = String(ctx.query.q || '').trim(); let data = await agendaModel.listAgenda(filter); - if (Array.isArray(data)) data = await applyListFilters(data, ctx); - ctx.body = await agendaView(data, filter); + if (data && Array.isArray(data.items)) { + const visible = await applyListFilters(data.items, ctx); + data = { ...data, items: applyTextSearch(visible, q, ['title', 'description', 'name', 'concept', 'location', 'tags']) }; + } + ctx.body = await agendaView(data, filter, q); }) .get("/hashtag/:hashtag", async (ctx) => { const { hashtag } = ctx.params; @@ -2210,7 +2452,7 @@ router ctx.body = renderPendingFollows(enriched); return; } - if (['CVs', 'MATCHSKILLS'].includes(filter)) { + if (filter === 'CVs') { Object.assign(query, { location: ctx.query.location || '', language: ctx.query.language || '', @@ -2344,7 +2586,7 @@ router .get('/inhabitant/:id', async (ctx) => { const id = ctx.params.id; const aboutModel = about; - const [aboutMsg, cv, feed, photo, bank, lastTs, visibilityPrefs, carbonGrams, larpHouseKey, deviceSource] = await Promise.all([ + const [aboutMsg, cv, feed, photo, bank, lastTs, visibilityPrefs, carbonGrams, larpHouseKey, deviceSource, relationship] = await Promise.all([ inhabitantsModel.getLatestAboutById(id), inhabitantsModel.getCVByUserId(id), inhabitantsModel.getFeedByUserId(id), @@ -2354,7 +2596,8 @@ router aboutModel.visibilityPrefs(id).catch(() => null), getCarbonGramsForFeed(id).catch(() => 0), larpModel.getUserHouse(id).catch(() => null), - aboutModel.deviceSource(id).catch(() => null) + aboutModel.deviceSource(id).catch(() => null), + friend.getRelationship(id).catch(() => null) ]); const larpHouse = larpHouseKey ? { key: larpHouseKey, ...larpModel.getHouse(larpHouseKey) } : null; const bucketInfo = inhabitantsModel.bucketLastActivity(lastTs || null); @@ -2366,7 +2609,7 @@ router const totalClaimed = bank?.totalClaimed || 0; const oasisVersion = (String(id) === String(currentUserId) ? OASIS_VERSION : null) || await getOasisVersion(id); await warmAuthorNames(feed); - ctx.body = await inhabitantsProfileView({ about: aboutMsg, cv, feed, photo, karmaScore, estimatedUBI, lastClaimedDate, totalClaimed, carbonGrams, larpHouse, lastActivityBucket: bucketInfo.bucket, viewedId: id, visibilityPrefs, deviceSource, stats, oasisVersion }, currentUserId, fediverseModel.hasAccount()); + ctx.body = await inhabitantsProfileView({ about: aboutMsg, cv, feed, photo, relationship, karmaScore, estimatedUBI, lastClaimedDate, totalClaimed, carbonGrams, larpHouse, lastActivityBucket: bucketInfo.bucket, viewedId: id, visibilityPrefs, deviceSource, stats, oasisVersion }, currentUserId, fediverseModel.hasAccount()); }) .get('/parliament', async (ctx) => { if (!checkMod(ctx, 'parliamentMod')) return ctx.redirect('/modules'); @@ -2527,6 +2770,10 @@ router const forums = await listByTribeAllChain(tribe.id, 'forum'); const replies = await listByTribeAllChain(tribe.id, 'forum-reply'); sectionData = [...forums, ...replies]; + } else if (section === 'polls') { + sectionData = checkMod(ctx, 'pollsMod') + ? await pollsModel.listAll('ALL', { tribeId: tribe.id }).catch(() => []) + : []; } else if (section === 'subtribes') { sectionData = await tribesModel.listSubTribes(tribe.id, uid); } else if (mediaSections[section]) { @@ -2551,7 +2798,14 @@ router ]); const tribeChainSet = new Set(tribeChain); const toStandalone = (type, url) => (item) => ({ contentType: type, id: item.rootId || item.key, title: item.title || '', author: item.author, createdAt: item.createdAt, directUrl: url(item) }); + const allTribePolls = checkMod(ctx, 'pollsMod') + ? (await Promise.all([...tribeChainSet].map(tid => pollsModel.listAll('ALL', { tribeId: tid }).catch(() => [])))).flat() + : []; const standaloneItems = [ + ...allTribePolls.filter(p => !p.undecryptable).map(p => ({ + contentType: 'poll', id: p.id, title: p.question, author: p.author, + createdAt: p.createdAt, directUrl: `/tribe/${encodeURIComponent(tribe.id)}?section=polls` + })), ...allPadsRaw.filter(p => tribeChainSet.has(p.tribeId)).map(toStandalone('pad', p => `/pads/${encodeURIComponent(p.rootId)}`)), ...allChatsRaw.filter(c => tribeChainSet.has(c.tribeId)).map(toStandalone('chat', c => `/chats/${encodeURIComponent(c.rootId || c.key)}`)), ...allCalsRaw.filter(c => tribeChainSet.has(c.tribeId)).map(toStandalone('calendar', c => `/calendars/${encodeURIComponent(c.rootId)}`)), @@ -2786,7 +3040,7 @@ router ctx.body = await tribeView(tribe, uid, query, section, sectionData); }) .get('/activity', async ctx => { - const filter = qf(ctx, 'recent'), userId = getViewerId(); + const filter = qf(ctx, 'all'), userId = getViewerId(); const q = String((ctx.query && ctx.query.q) || ''); try { await bankingModel.ensureSelfAddressPublished(); } catch (_) {} try { await bankingModel.getUserEngagementScore(userId); } catch (_) {} @@ -2830,7 +3084,7 @@ router } allActions = await applyListFilters(allActions, ctx); const spreadMap = new Map(); - const SPREADABLE = new Set(['post','audio','video','image','document','torrent','bookmark','event','calendar','task','votes','vote','market','shop','shopProduct','project','industry','industryBuild','industryBlueprint','transfer','job','report','chat','chatMessage','pad','padEntry','forum','map']); + const SPREADABLE = new Set(['post','audio','video','image','document','torrent','bookmark','event','calendar','task','votes','vote','market','shop','shopProduct','project','industry','industryBuild','industryBlueprint','transfer','housing','job','report','chat','chatMessage','pad','padEntry','forum','map']); const targets = (allActions || []).filter(a => a && a.id && typeof a.id === 'string' && a.id.startsWith('%') && /\.sha256$/.test(a.id) && SPREADABLE.has(a.type)); const results = await Promise.all(targets.map(a => spreads.forMessage(a.id).catch(() => null))); targets.forEach((a, i) => { if (results[i]) spreadMap.set(a.id, results[i]); }); @@ -2860,7 +3114,8 @@ router } } } catch (_) {} - ctx.body = activityView(allActions, filter, userId, q, { spreadMap }); + const favIndex = await contentFavorites.getFavoriteIndex().catch(() => new Map()); + ctx.body = activityView(allActions, filter, userId, q, { spreadMap, favIndex, returnTo: `/activity?filter=${encodeURIComponent(filter)}` }); }) .get("/profile", async (ctx) => { const myFeedId = await meta.myFeedId(), gt = Number(ctx.request.query.gt || -1), lt = Number(ctx.request.query.lt || -1); @@ -2897,7 +3152,7 @@ router const baseUrl = resolveExternalBaseUrl(ctx); const profileItems = await fetchProfileItems(myFeedId, rawPrefs); const profileFilterType = String(ctx.query.type || '').toLowerCase(); - const profileSpreadable = new Set(['post','audio','video','image','document','torrent','bookmark','event','calendar','task','votes','vote','market','shop','shopProduct','project','industry','industryBuild','industryBlueprint','transfer','job','report','chat','chatMessage','pad','padEntry','forum','map']); + const profileSpreadable = new Set(['post','audio','video','image','document','torrent','bookmark','event','calendar','task','votes','vote','market','shop','shopProduct','project','industry','industryBuild','industryBlueprint','transfer','housing','job','report','chat','chatMessage','pad','padEntry','forum','map']); const profileSpreadKeys = (allActions || []).filter(a => a && a.id && typeof a.id === 'string' && a.id.startsWith('%') && /\.sha256$/.test(a.id) && profileSpreadable.has(a.type)).map(a => a.id); const spreadMap = await spreads.forMessages(profileSpreadKeys).catch(() => new Map()); ctx.body = await authorView({ feedId: myFeedId, oasisVersion: OASIS_VERSION || await getOasisVersion(myFeedId), messages: sanitizeMessages(messages), firstPost, lastPost, name, description, avatarUrl: getAvatarUrl(image), relationship: { me: true }, ecoAddress, karmaScore: bankData.karmaScore, estimatedUBI: bankData.estimatedUBI || 0, lastClaimedDate: bankData.lastClaimedDate || null, totalClaimed: bankData.totalClaimed || 0, carbonGrams, larpHouse, lastActivityBucket, visibilityPrefs, stats, baseUrl, userActions, allActions, profileItems, profileFilterType, gpgFingerprint, spreadMap, fediverseConfigured: fediverseModel.hasAccount() }); @@ -3071,9 +3326,6 @@ router } catch (e) { console.error('profile/clearnet-toggle:', e.message); } ctx.redirect('/profile'); }) - .get("/publish/custom", async (ctx) => { - ctx.body = await publishCustomView(); - }) .get("/json/:message", async (ctx) => { if (config.public) { throw new Error( @@ -3202,11 +3454,21 @@ router }, }).png().toBuffer(); }; + const avatarFallback = ctx.query.fallback === 'avatar'; + const fallbackImage = async () => { + ctx.set("Content-Type", "image/png"); + ctx.set("Cache-Control", "no-cache"); + if (avatarFallback) { + try { + return fs.readFileSync(path.join(__dirname, "../client/assets/images/default-avatar.png")); + } catch (_) {} + } + return fakeImage(); + }; try { - const buffer = await blob.getResolved({ blobId }); + const buffer = avatarFallback ? await blob.getCached({ blobId }) : await blob.getResolved({ blobId }); if (!buffer) { - ctx.set("Content-Type", "image/png"); - ctx.body = await fakeImage(); + ctx.body = await fallbackImage(); return; } const fileType = await FileType.fromBuffer(buffer); @@ -3221,8 +3483,7 @@ router ctx.body = buffer; } } catch (err) { - ctx.set("Content-Type", "image/png"); - ctx.body = await fakeImage(); + ctx.body = await fallbackImage(); } }) .get("/settings", async (ctx) => { @@ -3454,7 +3715,7 @@ router if (tr && tr.openInviteCode) h.openInviteCode = tr.openInviteCode; } } catch (_) {} - ctx.body = larpListView({ filter, houses, myHouseKey, cycle, governingKey, governingHouse, governingMembers, governingPosts, canPost }); + ctx.body = larpListView({ filter, houses, myHouseKey, cycle, governingKey, governingHouse, governingMembers, governingPosts, canPost, q: String(ctx.query.q || '').trim() }); }) .get("/larp/test", async (ctx) => { if (!checkMod(ctx, 'larpMod')) return ctx.redirect('/modules'); @@ -3575,7 +3836,7 @@ router const text = stripDangerousTags(String(b.text || '')); const myFeedId = await meta.myFeedId(); const myHouseKey = await larpModel.getUserHouse(myFeedId).catch(() => null); - if (myHouseKey !== houseKey || houseKey === 'academia') { ctx.redirect('/larp'); return; } + if (myHouseKey !== houseKey) { ctx.redirect('/larp'); return; } try { await larpModel.publishHousePost({ house: houseKey, text }); } catch (e) { if (isSsbTooLargeError(e)) { sendErrorPage(ctx, require('../views/main_views').i18n.publishTooLong || 'Your post is too long. Please shorten it.', { status: 400 }); return; } @@ -3593,58 +3854,34 @@ router ctx.body = await likesView({ messages, feed, name: await about.name(feed), spreadMap }); }) .get("/mentions", async (ctx) => { - const { messages, myFeedId } = await post.mentionsMe(); - const tribeMentions = []; - try { - const allTribes = await tribesModel.listAll(); - const myTribes = allTribes.filter(t => t.members.includes(myFeedId)); - for (const t of myTribes) { - const items = await tribesContentModel.listByTribe(t.id, null).catch(() => []); - for (const item of items) { - const text = (item.description || '') + ' ' + (item.title || ''); - if (text.includes(myFeedId) || text.includes(myFeedId.slice(1))) { - tribeMentions.push({ - key: item.id, - value: { - author: item.author, - timestamp: Date.parse(item.createdAt) || item._ts || Date.now(), - content: { - type: 'tribe-content', - text: item.description || item.title || '', - tribeId: t.id, - tribeName: t.title, - contentType: item.contentType, - mentions: { _self: [{ link: myFeedId }] } - } - } - }); - } - } - } - } catch (_) {} - const combined = [...(Array.isArray(messages) ? messages : []), ...tribeMentions]; - for (const msg of combined) { - if (!msg.value) continue; - const authorId = msg.value.author; - if (authorId) { - if (!msg.value.meta) msg.value.meta = {}; - if (!msg.value.meta.author) msg.value.meta.author = {}; - if (!msg.value.meta.author.name) { - try { msg.value.meta.author.name = await about.name(authorId); } catch (_) {} - } - } - } - ctx.body = await mentionsView({ messages: combined, myFeedId }); + const filter = String(ctx.query.filter || 'ALL'); + const q = String(ctx.query.q || '').trim(); + const all = await mentionsModel.listMentions('ALL', { q }); + const counts = await mentionsModel.countTypes(all); + const items = filter === 'ALL' ? all : all.filter(x => x.type === filter); + await warmAuthorNames(items); + try { mentionsModel.markSeen(all); } catch (_) {} + sharedState.setMentionsCount(0); + ctx.body = await mentionsView(items, filter, { counts, total: all.length, q }); }) .get('/opinions', async (ctx) => { - const filter = qf(ctx, 'TOP'); + const filter = qf(ctx, 'ALL'); + const q = String(ctx.query.q || '').trim(); let opinions = await opinionsModel.listOpinions(filter); if (Array.isArray(opinions)) opinions = await applyListFilters(opinions, ctx); + if (q && Array.isArray(opinions)) { + const needle = q.toLowerCase(); + opinions = opinions.filter(m => { + const c = (m && m.value && m.value.content) || {}; + return [c.title, c.name, c.question, c.description, c.text, c.concept, ...(Array.isArray(c.tags) ? c.tags : [])] + .some(v => String(v || '').toLowerCase().includes(needle)); + }); + } const spreadMap = new Map(); const list = Array.isArray(opinions) ? opinions : []; const results = await Promise.all(list.map(it => it && it.key ? spreads.forMessage(it.key).catch(() => null) : Promise.resolve(null))); list.forEach((it, i) => { if (it && it.key && results[i]) spreadMap.set(it.key, results[i]); }); - ctx.body = await opinionsView(opinions, filter, spreadMap); + ctx.body = await opinionsView(opinions, filter, spreadMap, q); }) .get("/feed", async (ctx) => { const filter = String(ctx.query.filter || "ALL").toUpperCase(); @@ -3665,8 +3902,9 @@ router const feed = await feedModel.getFeedById(ctx.params.feedId); if (!feed) { ctx.redirect('/feed'); return; } const comments = await feedModel.getComments(ctx.params.feedId).catch(() => []); - ctx.body = singleFeedView(feed, comments); + ctx.body = singleFeedView(feed, comments, { spreads: await spreads.forMessage(feed.key).catch(() => null) }); }) + .get("/multiverse", async (ctx) => { ctx.redirect('/fediverse'); }) .get("/fediverse", async (ctx) => { if (!checkMod(ctx, 'fediverseMod')) { ctx.redirect('/modules'); return; } const account = fediverseModel.getAccount(); @@ -3816,15 +4054,186 @@ router try { await post.publishFediverseHandle(''); } catch (_) {} ctx.redirect('/settings'); }) + .get('/data', async ctx => { + const filter = String(ctx.query.filter || 'ALL').toUpperCase(); + const q = String(ctx.query.q || '').trim(); + const [{ matches, total, hasProfile }, cohesion] = await Promise.all([ + dataModel.listMatches(filter, { q }), + dataModel.cohesion().catch(() => null) + ]); + await warmAuthorNames(matches); + ctx.body = await dataView({ filter, q, matches, total, hasProfile, cohesion }); + }) + .get('/polls', async ctx => { + if (!checkMod(ctx, 'pollsMod')) { ctx.redirect('/modules'); return; } + const filter = String(ctx.query.filter || 'ALL').toUpperCase(); + const q = String(ctx.query.q || '').trim(); + if (filter === 'CREATE') { ctx.body = await pollsView([], 'CREATE', { q }); return; } + if (filter === 'EDIT') { + const poll = await pollsModel.getPollById(String(ctx.query.id || ''), getViewerId()).catch(() => null); + if (!poll || poll.author !== getViewerId()) { ctx.redirect('/polls'); return; } + ctx.body = await pollsView([], 'EDIT', { poll }); + return; + } + const fav = await contentFavorites.getFavoriteSet('polls'); + let polls = await pollsModel.listAll(filter, { q, favorites: [...fav] }); + polls = polls.map(p => ({ ...p, isFavorite: fav.has(String(p.id)) })); + polls = await applyListFilters(polls, ctx); + try { polls = await lifetime.enrichAndFilter(polls, { getKey: (x) => x.id }); } catch (_) {} + const spreadMap = await spreads.forMessages(polls.map(p => p.id)).catch(() => new Map()); + await warmAuthorNames(polls); + ctx.body = await pollsView(polls, filter, { q, spreadMap }); + }) + .get('/polls/:pollId', async ctx => { + if (!checkMod(ctx, 'pollsMod')) { ctx.redirect('/modules'); return; } + const poll = await pollsModel.getPollById(ctx.params.pollId, getViewerId()); + if (poll.chatId) { ctx.redirect(`/chats/${encodeURIComponent(poll.chatId)}`); return; } + const comments = await getVoteComments(poll.id); + const fav = await contentFavorites.getFavoriteSet('polls'); + await enrichItemLifetime(poll, { key: poll.id }); + await warmAuthorNames([poll], comments); + ctx.body = await singlePollView( + { ...poll, isFavorite: fav.has(String(poll.id)) }, + comments, + { + spreads: await spreads.forMessage(poll.id).catch(() => null), + filter: String(ctx.query.filter || 'ALL').toUpperCase(), + q: String(ctx.query.q || '').trim() + } + ); + }) + .post('/polls/create', koaBody(), async ctx => { + if (!checkMod(ctx, 'pollsMod')) { ctx.redirect('/modules'); return; } + const b = ctx.request.body || {}; + try { + await pollsModel.createPoll({ + question: stripDangerousTags(b.question), + options: stripDangerousTags(String(b.options || '')).split('\n'), + anonymous: [].concat(b.anonymous).includes('1'), + multiple: [].concat(b.multiple).includes('1'), + deadline: b.deadline, + tags: b.tags + }); + } catch (err) { sendErrorPage(ctx, err.message || String(err), { status: 400 }); return; } + try { activityModel.invalidateCache(); } catch (_) {} + ctx.redirect('/polls?filter=MINE'); + }) + .post('/polls/update/:id', koaBody(), async ctx => { + if (!checkMod(ctx, 'pollsMod')) { ctx.redirect('/modules'); return; } + const b = ctx.request.body || {}; + try { + await pollsModel.updatePoll(ctx.params.id, { + question: stripDangerousTags(b.question), + options: stripDangerousTags(String(b.options || '')).split('\n'), + anonymous: [].concat(b.anonymous).includes('1'), + multiple: [].concat(b.multiple).includes('1'), + deadline: b.deadline, + tags: b.tags + }); + } catch (err) { sendErrorPage(ctx, err.message || String(err), { status: 400 }); return; } + ctx.redirect(`/polls/${encodeURIComponent(ctx.params.id)}`); + }) + .post('/polls/vote/:id', koaBody(), async ctx => { + if (!checkMod(ctx, 'pollsMod')) { ctx.redirect('/modules'); return; } + const choices = [].concat(ctx.request.body.choices || []).filter(Boolean); + try { await pollsModel.vote(ctx.params.id, choices); } + catch (err) { sendErrorPage(ctx, err.message || String(err), { status: 400 }); return; } + try { activityModel.invalidateCache(); } catch (_) {} + ctx.redirect(safeReturnTo(ctx, `/polls/${encodeURIComponent(ctx.params.id)}`, ['/polls', '/chats', '/tribe'])); + }) + .post('/polls/close/:id', koaBody(), async ctx => { + if (!checkMod(ctx, 'pollsMod')) { ctx.redirect('/modules'); return; } + try { await pollsModel.closePoll(ctx.params.id); } catch (_) {} + ctx.redirect(safeReturnTo(ctx, `/polls/${encodeURIComponent(ctx.params.id)}`, ['/polls', '/chats', '/tribe'])); + }) + .post('/polls/delete/:id', koaBody(), async ctx => { + if (!checkMod(ctx, 'pollsMod')) { ctx.redirect('/modules'); return; } + let poll = null; + try { poll = await pollsModel.getPollById(ctx.params.id, getViewerId()); } catch (_) {} + try { await pollsModel.deletePoll(ctx.params.id); } catch (_) {} + if (poll && poll.chatId) { ctx.redirect(`/chats/${encodeURIComponent(poll.chatId)}`); return; } + ctx.redirect('/polls?filter=MINE'); + }) + .post('/polls/opinions/:pollId/:category', koaBody(), async ctx => { + if (!checkMod(ctx, 'pollsMod')) { ctx.redirect('/modules'); return; } + try { await pollsModel.createOpinion(ctx.params.pollId, ctx.params.category); } catch (_) {} + try { activityModel.invalidateCache(); } catch (_) {} + ctx.redirect(safeReturnTo(ctx, `/polls/${encodeURIComponent(ctx.params.pollId)}`, ['/polls'])); + }) + .post('/polls/:pollId/comments', koaBodyMiddleware, async ctx => commentAction(ctx, 'polls', 'pollId')) + .post('/polls/favorites/add/:id', koaBody(), async ctx => favAction(ctx, 'polls', 'add')) + .post('/polls/favorites/remove/:id', koaBody(), async ctx => favAction(ctx, 'polls', 'remove')) + .get('/blogs', async ctx => { + if (!checkMod(ctx, 'blogsMod')) { ctx.redirect('/modules'); return; } + const filter = String(ctx.query.filter || 'ALL').toUpperCase(); + const q = String(ctx.query.q || '').trim(); + if (filter === 'CREATE') { ctx.body = await blogView([], 'CREATE', { q }); return; } + const fav = await contentFavorites.getFavoriteSet('blogs'); + let blogs = await blogModel.listAll(filter, { q, favorites: [...fav] }); + blogs = await applyListFilters(blogs, ctx); + const spreadMap = await spreads.forMessages((blogs || []).map(b => b && b.id)).catch(() => new Map()); + await warmAuthorNames(blogs); + ctx.body = await blogView(blogs, filter, { q, spreadMap }); + }) + .get('/blogs/:blogId', async ctx => { + if (!checkMod(ctx, 'blogsMod')) { ctx.redirect('/modules'); return; } + let blog = await blogModel.getBlogById(ctx.params.blogId).catch(() => null); + if (!blog) { + const target = await blogModel.blogHrefFor(ctx.params.blogId).catch(() => null); + if (target && !target.startsWith(`/blogs/${encodeURIComponent(ctx.params.blogId)}`)) { ctx.redirect(target); return; } + ctx.redirect('/blogs'); + return; + } + const comments = await getVoteComments(blog.id); + const fav = await contentFavorites.getFavoriteSet('blogs'); + await warmAuthorNames([blog], comments); + ctx.body = await singleBlogView( + { ...blog, isFavorite: fav.has(String(blog.id)) }, + comments, + { spreads: await spreads.forMessage(blog.id).catch(() => null) } + ); + }) + .post('/blogs/create', koaBody({ multipart: true, urlencoded: true, formidable: { multiples: true, maxFileSize: maxSize } }), async ctx => { + if (!checkMod(ctx, 'blogsMod')) { ctx.redirect('/modules'); return; } + const b = ctx.request.body; + let text = stripDangerousTags((b.text || '').toString().trim()); + const subject = stripDangerousTags((b.subject || '').toString().trim()); + const blobMarkdown = await handleBlobUploads(ctx, 'blob', 9); + if (blobMarkdown.length) text += blobMarkdown.join(''); + const allowComments = [].concat(b.allowComments).includes('1'); + let mentions = []; + try { mentions = await extractMentions(text); } catch (_) { mentions = []; } + try { + await blogModel.createBlog({ text, subject, mentions, allowComments }); + } catch (err) { sendErrorPage(ctx, err.message || String(err), { status: 400 }); return; } + try { activityModel.invalidateCache(); } catch (_) {} + ctx.redirect('/blogs?filter=MINE'); + }) + .post('/blogs/opinions/:blogId/:category', koaBody(), async ctx => { + if (!checkMod(ctx, 'blogsMod')) { ctx.redirect('/modules'); return; } + try { await blogModel.createOpinion(ctx.params.blogId, ctx.params.category); } catch (_) {} + try { activityModel.invalidateCache(); } catch (_) {} + ctx.redirect(safeReturnTo(ctx, `/blogs/${encodeURIComponent(ctx.params.blogId)}`, ['/blogs'])); + }) + .post('/blogs/:blogId/comments', koaBodyMiddleware, async ctx => { + if (!checkMod(ctx, 'blogsMod')) { ctx.redirect('/modules'); return; } + const blog = await blogModel.getBlogById(ctx.params.blogId).catch(() => null); + if (!blog || !blog.allowComments) { ctx.redirect(`/blogs/${encodeURIComponent(ctx.params.blogId)}`); return; } + return commentAction(ctx, 'blogs', 'blogId'); + }) + .post('/blogs/favorites/add/:id', koaBody(), async ctx => favAction(ctx, 'blogs', 'add')) + .post('/blogs/favorites/remove/:id', koaBody(), async ctx => favAction(ctx, 'blogs', 'remove')) .get('/forum', async ctx => { if (!checkMod(ctx, 'forumMod')) { ctx.redirect('/modules'); return; } - const filter = qf(ctx, 'hot'); + const filter = qf(ctx, 'all'); + const q = String(ctx.query.q || '').trim(); let forums = await forumModel.listAll(filter); + forums = applyTextSearch(forums, q, ['title', 'text', 'category']); forums = await applyListFilters(forums, ctx); try { forums = await lifetime.enrichAndFilter(forums); } catch (_) {} const spreadMap = await spreads.forMessages((forums || []).map(x => x && (x.key || x.id))); await warmAuthorNames(forums); - ctx.body = await forumView(forums, filter, { spreadMap }); + ctx.body = await forumView(forums, filter, { spreadMap, q }); }) .get('/forum/:forumId', async ctx => { const msg = await forumModel.getMessageById(ctx.params.forumId), isReply = Boolean(msg.root), forumId = isReply ? msg.root : ctx.params.forumId; @@ -3833,7 +4242,7 @@ router try { const oi = await forumModel.getOpenInvite(forumId).catch(() => null); if (oi && forumObj) forumObj.openInviteCode = oi.code; } catch (_) {} const forumMsgs = await forumModel.getMessagesByForumId(forumId); await warmAuthorNames(forumObj, forumMsgs); - ctx.body = await singleForumView(forumObj, forumMsgs, ctx.query.filter, ctx.query.topic || ''); + ctx.body = await singleForumView(await withFavorite(forumObj, 'forum'), forumMsgs, ctx.query.filter, isReply ? ctx.params.forumId : null, { spreads: spreadInfo }); }) .get('/welcome', async (ctx) => { const lang = ctx.cookies.get('language') || getConfig().language || 'en'; @@ -3842,6 +4251,7 @@ router profile: true, federation: checkMod(ctx, 'invitesMod'), larp: checkMod(ctx, 'larpMod'), + ux: true, backup: checkMod(ctx, 'legacyMod'), greeting: checkMod(ctx, 'feedMod') }; @@ -3874,10 +4284,26 @@ router } catch (e) { sendErrorPage(ctx, e.message || String(e), { status: 400 }); return; } ctx.redirect('/welcome'); }) + .post('/welcome/ux', koaBody(), async (ctx) => { + const cfg = getConfig(); + const v = String((ctx.request.body || {}).ux || '').trim().toLowerCase(); + const aiNavEnabled = cfg.modules && cfg.modules.aiNavMod === 'on'; + const chatsEnabled = cfg.modules && cfg.modules.chatsMod === 'on'; + const next = (v === 'ainav' && aiNavEnabled) ? 'ainav' : (v === 'chats' && chatsEnabled) ? 'chats' : 'blocks'; + cfg.ux = { ...(cfg.ux && typeof cfg.ux === 'object' ? cfg.ux : {}), current: next }; + saveConfig(cfg); + try { onboardingModel.markStep('ux'); } catch (_) {} + ctx.redirect('/welcome'); + }) .post('/welcome/dismiss', koaBody(), async (ctx) => { try { onboardingModel.dismiss(); } catch (_) {} safeRefererRedirect(ctx, '/'); }) + .post('/ai/suggestion/dismiss', koaBody(), async (ctx) => { + const current = sharedState.getBestMatch ? sharedState.getBestMatch() : null; + if (current && current.href) sharedState.setDismissedSuggestion(current.href); + safeRefererRedirect(ctx, '/'); + }) .get('/legacy', async (ctx) => { if (!checkMod(ctx, 'legacyMod')) return ctx.redirect('/modules'); try { ctx.body = await legacyView(); } catch (error) { sendErrorPage(ctx, error.message); } @@ -3925,7 +4351,7 @@ router .get('/bookmarks', async (ctx) => { if (!checkMod(ctx, 'bookmarksMod')) return ctx.redirect('/modules'); const filter = qf(ctx), q = ctx.query.q || '', sort = ctx.query.sort || 'recent', viewerId = getViewerId(); - const favs = await mediaFavorites.getFavoriteSet("bookmarks"); + const favs = await contentFavorites.getFavoriteSet("bookmarks"); let bookmarks = (await bookmarksModel.listAll({ viewerId, filter: filter === "favorites" ? "all" : filter, q, sort })).map(b => ({ ...b, isFavorite: favs.has(String(b.rootId || b.id)) })); if (filter === "favorites") bookmarks = bookmarks.filter(b => b.isFavorite); bookmarks = await applyListFilters(bookmarks, ctx); @@ -3936,25 +4362,27 @@ router }) .get("/bookmarks/edit/:id", async (ctx) => { if (!checkMod(ctx, 'bookmarksMod')) return ctx.redirect('/modules'); - const bookmark = await bookmarksModel.getBookmarkById(ctx.params.id, getViewerId()), favs = await mediaFavorites.getFavoriteSet("bookmarks"); + const bookmark = await bookmarksModel.getBookmarkById(ctx.params.id, getViewerId()), favs = await contentFavorites.getFavoriteSet("bookmarks"); ctx.body = await bookmarkView([{ ...bookmark, isFavorite: favs.has(String(bookmark.rootId || bookmark.id)) }], "edit", bookmark.id, { returnTo: ctx.query.returnTo || "" }); }) .get('/bookmarks/:bookmarkId', async (ctx) => { if (!checkMod(ctx, 'bookmarksMod')) return ctx.redirect('/modules'); - const filter = qf(ctx), q = ctx.query.q || '', sort = ctx.query.sort || 'recent', favs = await mediaFavorites.getFavoriteSet("bookmarks"); + const filter = qf(ctx), q = ctx.query.q || '', sort = ctx.query.sort || 'recent', favs = await contentFavorites.getFavoriteSet("bookmarks"); const bookmark = await bookmarksModel.getBookmarkById(ctx.params.bookmarkId), root = bookmark.rootId || bookmark.id, comments = await getVoteComments(root); await enrichItemLifetime(bookmark); ctx.body = await singleBookmarkView({ ...bookmark, commentCount: comments.length, isFavorite: favs.has(String(root)) }, filter, comments, { q, sort, returnTo: safeReturnTo(ctx, `/bookmarks?filter=${encodeURIComponent(filter)}`, ['/bookmarks']), spreads: await spreads.forMessage(bookmark.id) }); }) .get('/tasks', async ctx => { const filter = qf(ctx); + const q = String(ctx.query.q || '').trim(); let tasks = await enrichWithComments(await tasksModel.listAll()); + tasks = applyTextSearch(tasks, q, ['title', 'description', 'location', 'tags']); tasks = await applyListFilters(tasks, ctx); try { tasks = await lifetime.enrichAndFilter(tasks); } catch (_) {} await enrichMsgSize(tasks); const spreadMap = await spreads.forMessages((tasks || []).map(x => x && (x.id || x.key))); await warmAuthorNames(tasks); - ctx.body = await taskView(tasks, filter, null, ctx.query.returnTo, { spreadMap, prefillTitle: ctx.query.title || '', prefillDescription: ctx.query.description || '' }); + ctx.body = await taskView(tasks, filter, null, ctx.query.returnTo, { spreadMap, q, prefillTitle: ctx.query.title || '', prefillDescription: ctx.query.description || '' }); }) .get('/tasks/edit/:id', async ctx => { const id = ctx.params.id; @@ -3966,19 +4394,21 @@ router const comments = await getVoteComments(taskId); await enrichMsgSize([task]); await enrichItemLifetime(task); - ctx.body = await singleTaskView(withCount(task, comments), filter, comments, { spreads: await spreads.forMessage(task.id).catch(() => null) }); + ctx.body = await singleTaskView(await withFavorite(withCount(task, comments), 'tasks'), filter, comments, { spreads: await spreads.forMessage(task.id).catch(() => null) }); }) .get('/events', async (ctx) => { if (!checkMod(ctx, 'eventsMod')) { ctx.redirect('/modules'); return; } const filter = qf(ctx); + const q = String(ctx.query.q || '').trim(); let events = await enrichWithComments(await eventsModel.listAll(null, filter)); + events = applyTextSearch(events, q, ['title', 'description', 'location', 'tags']); events = await applyListFilters(events, ctx); try { events = await lifetime.enrichAndFilter(events); } catch (_) {} await enrichMsgSize(events); const viewerPrefs = await about.visibilityPrefs(getViewerId()).catch(() => null); const spreadMap = await spreads.forMessages((events || []).map(x => x && (x.id || x.key))); await warmAuthorNames(events); - ctx.body = await eventView(events, filter, null, ctx.query.returnTo, { viewerPrefs, spreadMap }); + ctx.body = await eventView(events, filter, null, ctx.query.returnTo, { viewerPrefs, spreadMap, q }); }) .get('/events/edit/:id', async (ctx) => { if (!checkMod(ctx, 'eventsMod')) { ctx.redirect('/modules'); return; } @@ -3999,7 +4429,7 @@ router try { const oi = await eventsModel.getOpenInvite(eventId).catch(() => null); if (oi) event.openInviteCode = oi.code; } catch (_) {} const evAuthorPrefs2 = await about.visibilityPrefs(event.organizer).catch(() => null); await warmAuthorNames(event, comments); - ctx.body = await singleEventView(withCount(event, comments), filter, comments, { mapData, baseUrl: resolveExternalBaseUrl(ctx), authorPrefs: evAuthorPrefs2, linkedCalendarId, spreads: await spreads.forMessage(event.id).catch(() => null) }); + ctx.body = await singleEventView(await withFavorite(withCount(event, comments), 'events'), filter, comments, { mapData, baseUrl: resolveExternalBaseUrl(ctx), authorPrefs: evAuthorPrefs2, linkedCalendarId, spreads: await spreads.forMessage(event.id).catch(() => null) }); }) .get('/c/events/:eventId', async (ctx) => { let event; @@ -4020,26 +4450,28 @@ router }) .get('/votes', async ctx => { const filter = qf(ctx); + const q = String(ctx.query.q || '').trim(); let voteList = await enrichWithComments(await votesModel.listAll(filter)); voteList = await applyListFilters(voteList, ctx); + voteList = applyTextSearch(voteList, q, ['question', 'tags']); try { voteList = await lifetime.enrichAndFilter(voteList, { getAuthor: (x) => x.createdBy }); } catch (_) {} await enrichMsgSize(voteList); const spreadMap = await spreads.forMessages((voteList || []).map(x => x && (x.id || x.key))); await warmAuthorNames(voteList); - ctx.body = await voteView(voteList, filter, null, [], filter, { spreadMap }); + ctx.body = await voteView(voteList, filter, null, [], filter, { spreadMap, q, ...voteFormState(ctx) }); }) .get('/votes/edit/:id', async ctx => { const id = ctx.params.id; const activeFilter = (ctx.query.filter || 'mine'); const voteData = await votesModel.getVoteById(id); - ctx.body = await voteView([voteData], 'edit', id, [], activeFilter); + ctx.body = await voteView([voteData], 'edit', id, [], activeFilter, voteFormState(ctx)); }) .get('/votes/:voteId', async ctx => { const { voteId } = ctx.params, filter = qf(ctx), voteData = await votesModel.getVoteById(voteId); const comments = await getVoteComments(voteId); await enrichMsgSize([voteData]); await enrichItemLifetime(voteData, { author: voteData.createdBy }); - ctx.body = await voteView([withCount(voteData, comments)], 'detail', voteId, comments, filter, { spreads: await spreads.forMessage(voteId).catch(() => null) }); + ctx.body = await voteView([await withFavorite(withCount(voteData, comments), 'votes')], 'detail', voteId, comments, filter, { spreads: await spreads.forMessage(voteId).catch(() => null) }); }) .get("/market", async (ctx) => { if (!checkMod(ctx, 'marketMod')) { ctx.redirect('/modules'); return; } @@ -4086,7 +4518,7 @@ router return `/market${params.length ? `?${params.join("&")}` : ""}` })() await enrichItemLifetime(item, { author: item.seller, createdAt: item.updatedAt || item.createdAt }) - ctx.body = await singleMarketView(withCount(item, comments), filter, comments, { q, minPrice, maxPrice, sort, returnTo, mapData, zoom, spreads: await spreads.forMessage(item.id).catch(() => null) }) + ctx.body = await singleMarketView(await withFavorite(withCount(item, comments), 'market'), filter, comments, { q, minPrice, maxPrice, sort, returnTo, mapData, zoom, spreads: await spreads.forMessage(item.id).catch(() => null) }) }) .get('/jobs', async (ctx) => { if (!checkMod(ctx, 'jobsMod')) { ctx.redirect('/modules'); return; } @@ -4174,7 +4606,63 @@ router } const jobAuthorPrefs2 = await about.visibilityPrefs(job.author).catch(() => null); await enrichItemLifetime(job) - ctx.body = await singleJobsView(withCount(job, comments), filter, comments, { ...params, mapData, candidates, baseUrl: resolveExternalBaseUrl(ctx), authorPrefs: jobAuthorPrefs2, spreads: await spreads.forMessage(job.id).catch(() => null) }) + ctx.body = await singleJobsView(await withFavorite(withCount(job, comments), 'jobs'), filter, comments, { ...params, mapData, candidates, baseUrl: resolveExternalBaseUrl(ctx), authorPrefs: jobAuthorPrefs2, spreads: await spreads.forMessage(job.id).catch(() => null) }) + }) + .get('/housing', async (ctx) => { + if (!checkMod(ctx, 'housingMod')) { ctx.redirect('/modules'); return; } + const filter = String(ctx.query.filter || 'ALL').toUpperCase() + const query = { + search: ctx.query.search || '', + minPrice: ctx.query.minPrice ?? '', + maxPrice: ctx.query.maxPrice ?? '', + place: ctx.query.place || '', + sort: ctx.query.sort || 'recent' + } + query.maxImages = housingModel.MAX_IMAGES + if (filter === 'CREATE') { ctx.body = await housingView([], 'CREATE', query); return } + const viewerId = getViewerId() + let items = await housingModel.listHousing(filter, viewerId, query) + await enrichWithComments(items) + items = await applyListFilters(items, ctx) + if (filter !== 'MINE') { + try { items = await lifetime.enrichAndFilter(items); } catch (_) {} + } + await enrichMsgSize(items) + const spreadMap = await spreads.forMessages((items || []).map(x => x && (x.id || x.key))); + await warmAuthorNames(items); + ctx.body = await housingView(items, filter, { ...query, spreadMap }) + }) + .get('/housing/edit/:id', async (ctx) => { + if (!checkMod(ctx, 'housingMod')) { ctx.redirect('/modules'); return; } + let item; + try { item = await housingModel.getHousingById(ctx.params.id, getViewerId()); } catch (_) { ctx.redirect('/housing?filter=MINE'); return; } + if (!item || String(item.author) !== String(getViewerId())) { ctx.redirect('/housing?filter=MINE'); return; } + ctx.body = await housingView([item], 'EDIT', { maxImages: housingModel.MAX_IMAGES }) + }) + .get('/housing/:housingId', async (ctx) => { + if (!checkMod(ctx, 'housingMod')) { ctx.redirect('/modules'); return; } + let housingId = ctx.params.housingId + if (housingId && housingId.startsWith('%25')) { + try { housingId = decodeURIComponent(housingId); } catch (_) {} + } + const filter = String(ctx.query.filter || 'ALL').toUpperCase() + const params = { + search: ctx.query.search || '', + minPrice: ctx.query.minPrice ?? '', + maxPrice: ctx.query.maxPrice ?? '', + place: ctx.query.place || '', + sort: ctx.query.sort || 'recent', + returnTo: safeReturnTo(ctx, `/housing?filter=${encodeURIComponent(filter)}`, ['/housing']) + } + let item; + try { item = await housingModel.getHousingById(housingId, getViewerId()); } catch (e) { + sendErrorPage(ctx, `Housing not found or invalid id: ${housingId}`, { status: 404 }); return; + } + if (!item) { sendErrorPage(ctx, `Housing not found: ${housingId}`, { status: 404 }); return; } + const [comments, mapData] = await Promise.all([getVoteComments(housingId), resolveMapUrl(item.mapUrl)]) + await enrichMsgSize([item]) + await enrichItemLifetime(item) + ctx.body = await singleHousingView(await withFavorite(withCount(item, comments), 'housing'), filter, comments, { ...params, mapData, spreads: await spreads.forMessage(item.id).catch(() => null) }) }) .get('/c/jobs/:jobId', async (ctx) => { let job; @@ -4210,7 +4698,7 @@ router return; } const items = await shopsModel.listAll({ filter: filter === 'favorites' ? 'all' : filter, q, sort, viewerId: getViewerId() }); - const fav = await mediaFavorites.getFavoriteSet('shops'); + const fav = await contentFavorites.getFavoriteSet('shops'); let enriched = items.map(x => ({ ...x, isFavorite: fav.has(String(x.rootId || x.key)) })); if (filter === 'favorites') enriched = enriched.filter(x => x.isFavorite); enriched = await applyListFilters(enriched, ctx); @@ -4227,7 +4715,7 @@ router if (!checkMod(ctx, 'shopsMod')) { ctx.redirect('/modules'); return; } const shop = await shopsModel.getShopById(ctx.params.id); if (!shop) { ctx.redirect('/shops'); return; } - const fav = await mediaFavorites.getFavoriteSet('shops'); + const fav = await contentFavorites.getFavoriteSet('shops'); ctx.body = await shopsView([{ ...shop, isFavorite: fav.has(String(shop.rootId || shop.key)) }], 'edit', shop, { returnTo: ctx.query.returnTo || '' }); }) .get("/shops/product/edit/:id", async (ctx) => { @@ -4245,7 +4733,8 @@ router const myPurchases = await shopsModel.listMyPurchases().catch(() => []); const productRootId = product.rootId || product.key; const canRate = myPurchases.some(o => o.productId === productRootId && String(o.status || '').toUpperCase() === 'RECEIVED'); - ctx.body = await singleProductView(withCount(product, comments), shop, comments, { shopId: product.shopId, canRate, returnTo: safeReturnTo(ctx, `/shops/${encodeURIComponent(product.shopId)}`, ['/shops']) }); + const productFav = await contentFavorites.getFavoriteSet('shopProducts'); + ctx.body = await singleProductView({ ...withCount(product, comments), isFavorite: productFav.has(String(product.rootId || product.key)) }, shop, comments, { shopId: product.shopId, canRate, returnTo: safeReturnTo(ctx, `/shops/${encodeURIComponent(product.shopId)}`, ['/shops']), spreads: await spreads.forMessage(product.key).catch(() => null) }); }) .get("/c/audios/:id", async (ctx) => { let item; try { item = await audiosModel.getAudioById(ctx.params.id); } catch (_) {} @@ -4538,7 +5027,7 @@ router const shop = await shopsModel.getShopById(ctx.params.shopId); if (!shop) { ctx.redirect('/shops'); return; } if (shop.encrypted && shop.undecryptable && shop.author !== getViewerId()) { ctx.redirect('/invites#invites-shops'); return; } - const fav = await mediaFavorites.getFavoriteSet('shops'); + const fav = await contentFavorites.getFavoriteSet('shops'); const [products, comments, mapData] = await Promise.all([shopsModel.listProducts(shop.rootId || shop.key), getVoteComments(shop.key), resolveMapUrl(shop.mapUrl)]); const baseUrl = resolveExternalBaseUrl(ctx); const authorPrefs = await about.visibilityPrefs(shop.author).catch(() => null); @@ -4583,7 +5072,7 @@ router } const modelFilter = filter === "favorites" ? "all" : filter; const items = await chatsModel.listAll({ filter: modelFilter, q, viewerId }); - const fav = await mediaFavorites.getFavoriteSet('chats'); + const fav = await contentFavorites.getFavoriteSet('chats'); const myTribeIds = await getUserTribeIds(viewerId); const enriched = items.filter(x => !x.tribeId).map(x => ({ ...x, isFavorite: fav.has(String(x.rootId || x.key)) })); let finalList = filter === "favorites" ? enriched.filter(x => x.isFavorite) : enriched; @@ -4591,7 +5080,14 @@ router try { finalList = await lifetime.enrichAndFilter(finalList, { getKey: (x) => x.rootId || x.key }); } catch (_) {} const spreadMap = await spreads.forMessages((finalList || []).map(x => x && (x.key || x.id))); await warmAuthorNames(finalList); - ctx.body = await chatsView(finalList, filter, null, { q, spreadMap }); + const uxChatsMode = getConfig().ux?.current === 'chats'; + if (uxChatsMode && filter === 'all' && !q && finalList.length) { + const actTs = (c) => Math.max(Number(c.lastMsgAt || 0), Date.parse(c.updatedAt || '') || 0, Date.parse(c.createdAt || '') || 0); + const first = finalList.slice().sort((a, b) => actTs(b) - actTs(a))[0]; + ctx.redirect(`/chats/${encodeURIComponent(first.rootId || first.key)}`); + return; + } + ctx.body = await chatsView(finalList, filter, null, { q, spreadMap, workspace: uxChatsMode }); }) .get("/chats/edit/:id", async (ctx) => { if (!checkMod(ctx, 'chatsMod')) { ctx.redirect('/modules'); return; } @@ -4602,7 +5098,7 @@ router .get("/chats/:chatId", async (ctx) => { if (!checkMod(ctx, 'chatsMod')) { ctx.redirect('/modules'); return; } await chatsModel.ingestKeys().catch(() => {}); - const { filter = 'all', q = '', replyTo = '' } = ctx.query; + const { filter = 'all', q = '' } = ctx.query; const uid = getViewerId(); let chat = await chatsModel.getChatById(ctx.params.chatId); if (!chat) { ctx.redirect('/chats'); return; } @@ -4619,11 +5115,23 @@ router const isOpen = String(chat.status || '').toUpperCase() === 'OPEN'; if (!isOpen && chat.author !== uid && !members.includes(uid)) { ctx.redirect('/chats?filter=all'); return; } } - const fav = await mediaFavorites.getFavoriteSet('chats'); + const fav = await contentFavorites.getFavoriteSet('chats'); const messages = await chatsModel.listMessages(chat.rootId || chat.key); const isTribeMember = !!parentTribe; - const chatListForSwitcher = process.env.OASIS_MOBILE === '1' ? await chatsModel.listAll({ viewerId: uid }).catch(() => []) : []; - ctx.body = await singleChatView({ ...chat, isFavorite: fav.has(String(chat.rootId || chat.key)), isTribeMember }, filter, messages, { q, returnTo: safeReturnTo(ctx, `/chats?filter=${encodeURIComponent(filter)}`, ['/chats']), replyTo: typeof replyTo === 'string' ? replyTo : '', chatList: chatListForSwitcher }); + const pollsEnabled = checkMod(ctx, 'pollsMod'); + const chatPolls = pollsEnabled + ? await pollsModel.listAll('ALL', { chatId: chat.rootId || chat.key }).catch(() => []) + : []; + const uxChats = getConfig().ux?.current === 'chats'; + // la rama usa la misma lista para el selector de chats en movil + const forkMobileChats = process.env.OASIS_MOBILE === '1'; + let allChats = []; + if (uxChats || forkMobileChats) { + try { + allChats = (await chatsModel.listAll({ filter: 'all', q: '', viewerId: uid })).filter(x => !x.tribeId); + } catch (_) { allChats = []; } + } + ctx.body = await singleChatView({ ...chat, isFavorite: fav.has(String(chat.rootId || chat.key)), isTribeMember }, filter, messages, { q, chatList: forkMobileChats ? allChats : [], polls: chatPolls, pollsEnabled, returnTo: safeReturnTo(ctx, `/chats?filter=${encodeURIComponent(filter)}`, ['/chats']), spreads: await spreads.forMessage(chat.key).catch(() => null), workspace: uxChats, allChats }); }) .get("/pads", async (ctx) => { if (!checkMod(ctx, 'padsMod')) { ctx.redirect('/modules'); return; } @@ -4640,7 +5148,7 @@ router const q = String(ctx.query.q || "").trim(); const tribeId = ctx.query.tribeId || ""; const pads = await padsModel.listAll({ filter, viewerId: uid }); - const fav = await mediaFavorites.getFavoriteSet('pads'); + const fav = await contentFavorites.getFavoriteSet('pads'); let enriched = pads.filter(p => !p.tribeId).map(p => ({ ...p, isFavorite: fav.has(String(p.rootId)) })); enriched = await applyListFilters(enriched, ctx); try { enriched = await lifetime.enrichAndFilter(enriched, { getKey: (x) => x.rootId || x.key }); } catch (_) {} @@ -4667,7 +5175,7 @@ router const isOpen = String(pad.status || '').toUpperCase() === 'OPEN'; if (!isOpen && pad.author !== uid && !members.includes(uid)) { ctx.redirect('/pads?filter=all'); return; } } - const fav = await mediaFavorites.getFavoriteSet('pads'); + const fav = await contentFavorites.getFavoriteSet('pads'); const entries = await padsModel.getEntries(pad.rootId); const versionKey = ctx.query.version || null; const selectedVersion = versionKey @@ -4693,7 +5201,7 @@ router const tribeId = ctx.query.tribeId || ""; const modelFilter = filter === "favorites" ? "all" : filter; const calendars = await calendarsModel.listAll({ filter: modelFilter, viewerId: uid }); - const fav = await mediaFavorites.getFavoriteSet('calendars'); + const fav = await contentFavorites.getFavoriteSet('calendars'); const myTribeIds = await getUserTribeIds(uid); const enriched = calendars.filter(c => !c.tribeId).map(c => ({ ...c, isFavorite: fav.has(String(c.rootId)) })); let finalList = filter === "favorites" ? enriched.filter(c => c.isFavorite) : enriched; @@ -4728,7 +5236,7 @@ router for (const d of dates) { notesByDate[d.key] = await calendarsModel.getNotesForDate(cal.rootId, d.key); } - const fav = await mediaFavorites.getFavoriteSet('calendars'); + const fav = await contentFavorites.getFavoriteSet('calendars'); const month = String(ctx.query.month || "").trim() || null; const day = String(ctx.query.day || "").trim() || null; await enrichItemLifetime(cal, { key: cal.rootId }); @@ -4747,15 +5255,16 @@ router ctx.body = await projectsView([], "CREATE", null, { viewerPrefs, prefill }) return } + const q = String(ctx.query.q || '').trim() const modelFilter = filter === "BACKERS" ? "ALL" : filter - let projects = await projectsModel.listProjects(modelFilter) + let projects = await projectsModel.listProjects(modelFilter, { q }) await enrichWithComments(projects) projects = await applyListFilters(projects, ctx) try { projects = await lifetime.enrichAndFilter(projects); } catch (_) {} await enrichMsgSize(projects) const spreadMap = await spreads.forMessages((projects || []).map(x => x && (x.id || x.key))); await warmAuthorNames(projects); - ctx.body = await projectsView(projects, filter, null, { viewerPrefs, spreadMap }) + ctx.body = await projectsView(projects, filter, null, { viewerPrefs, spreadMap, q }) }) .get("/projects/edit/:id", async (ctx) => { if (!checkMod(ctx, 'projectsMod')) { ctx.redirect('/modules'); return; } @@ -4772,7 +5281,7 @@ router const [comments, mapData] = await Promise.all([getVoteComments(projectId), resolveMapUrl(project.mapUrl)]) await enrichMsgSize([project]) await enrichItemLifetime(project, { key: project.id || project.key }) - ctx.body = await singleProjectView(withCount(project, comments), filter, comments, { mapData, zoom, baseUrl: resolveExternalBaseUrl(ctx), spreads: await spreads.forMessage(project.id).catch(() => null) }) + ctx.body = await singleProjectView(await withFavorite(withCount(project, comments), 'projects'), filter, comments, { mapData, zoom, baseUrl: resolveExternalBaseUrl(ctx), spreads: await spreads.forMessage(project.id).catch(() => null) }) }) .get("/c/projects/:projectId", async (ctx) => { let project; @@ -4905,7 +5414,7 @@ router await industryModel.joinFacility(ctx.params.id) if (before && before.membershipPolicy === 'vote') { const me = getViewerId() - for (const m of (before.members || [])) { if (m !== me) { try { await pmModel.sendMessage([m], "INDUSTRY_APPLICATION", `A new habitant requested to join "${before.name}" -> /industry/${ctx.params.id}`); } catch (_) {} } } + for (const m of (before.members || [])) { if (m !== me) { try { await pmModel.sendMessage([m], "INDUSTRY_APPLICATION", `A new habitant requested to join [${before.name || 'a facility'}](/industry/${encodeURIComponent(ctx.params.id)})`); } catch (_) {} } } } safeRefererRedirect(ctx, `/industry/${encodeURIComponent(ctx.params.id)}`) } catch (e) { sendErrorPage(ctx, e.message || String(e), { status: 400 }) } @@ -4920,7 +5429,7 @@ router const invitee = ctx.request.body.invitee try { await industryModel.inviteToFacility(ctx.params.id, invitee) - try { const fc = await industryModel.getFacilityById(ctx.params.id); await pmModel.sendMessage([invitee], "INDUSTRY_INVITED", `You have been invited to join "${fc.name}" -> /industry/${ctx.params.id}`); } catch (_) {} + try { const fc = await industryModel.getFacilityById(ctx.params.id); await pmModel.sendMessage([invitee], "INDUSTRY_INVITED", `You have been invited to join [${fc.name || 'a facility'}](/industry/${encodeURIComponent(ctx.params.id)})`); } catch (_) {} safeRefererRedirect(ctx, `/industry/${encodeURIComponent(ctx.params.id)}`) } catch (e) { sendErrorPage(ctx, e.message || String(e), { status: 400 }) } }) @@ -4933,10 +5442,10 @@ router const after = await industryModel.getFacilityById(ctx.params.id).catch(() => null) const me = getViewerId() if (subject === 'admit' && ref && before && after && !before.members.includes(ref) && after.members.includes(ref)) { - try { await pmModel.sendMessage([ref], "INDUSTRY_ADMITTED", `You have been admitted to "${after.name}" -> /industry/${ctx.params.id}`); } catch (_) {} + try { await pmModel.sendMessage([ref], "INDUSTRY_ADMITTED", `You have been admitted to [${after.name || 'a facility'}](/industry/${encodeURIComponent(ctx.params.id)})`); } catch (_) {} } if (subject === 'dissolve' && before && after && before.status !== 'DISSOLVED' && after.status === 'DISSOLVED') { - for (const m of (after.members || [])) { if (m !== me) { try { await pmModel.sendMessage([m], "INDUSTRY_DISSOLVED", `The facility "${after.name}" has been dissolved by collective vote -> /industry/${ctx.params.id}`); } catch (_) {} } } + for (const m of (after.members || [])) { if (m !== me) { try { await pmModel.sendMessage([m], "INDUSTRY_DISSOLVED", `The facility [${after.name || 'a facility'}](/industry/${encodeURIComponent(ctx.params.id)}) has been dissolved by collective vote`); } catch (_) {} } } } safeRefererRedirect(ctx, `/industry/${encodeURIComponent(ctx.params.id)}`) } catch (e) { sendErrorPage(ctx, e.message || String(e), { status: 400 }) } @@ -4997,7 +5506,7 @@ router await industryModel.voteBuild(ctx.params.id, ctx.request.body.choice) const after = await industryModel.getBuild(ctx.params.id).catch(() => null) if (before && after && before.status !== 'APPROVED' && after.status === 'APPROVED' && after.proposer !== getViewerId()) { - try { await pmModel.sendMessage([after.proposer], "INDUSTRY_BUILD_APPROVED", `Your build "${after.title}" has been approved -> /industry/build/${ctx.params.id}`); } catch (_) {} + try { await pmModel.sendMessage([after.proposer], "INDUSTRY_BUILD_APPROVED", `Your build [${after.title || 'a build'}](/industry/build/${encodeURIComponent(ctx.params.id)}) has been approved`); } catch (_) {} } safeRefererRedirect(ctx, `/industry/build/${encodeURIComponent(ctx.params.id)}`) } catch (e) { sendErrorPage(ctx, e.message || String(e), { status: 400 }) } @@ -5022,7 +5531,7 @@ router const res = await industryModel.distributeBuild(ctx.params.id, { outputValue: ctx.request.body.outputValue }) for (const alloc of res.plan.allocations) { if (!alloc || !alloc.to || alloc.to === viewer || !(alloc.amount > 0)) continue - try { await pmModel.sendMessage([alloc.to], "INDUSTRY_DISTRIBUTED", `Build "${res.build.title}" allocated you ${alloc.amount.toFixed(2)} ECO -> /industry/build/${ctx.params.id}`) } catch (_) {} + try { await pmModel.sendMessage([alloc.to], "INDUSTRY_DISTRIBUTED", `Build [${res.build.title || 'a build'}](/industry/build/${encodeURIComponent(ctx.params.id)}) allocated you ${alloc.amount.toFixed(2)} ECO`) } catch (_) {} } safeRefererRedirect(ctx, `/industry/build/${encodeURIComponent(ctx.params.id)}`) } catch (e) { sendErrorPage(ctx, e.message || String(e), { status: 400 }) } @@ -5147,8 +5656,10 @@ router ctx.body = renderEpochView(epoch, allocations); }) .get("/favorites", async (ctx) => { - const filter = qf(ctx), data = await favoritesModel.listAll({ filter }); - ctx.body = await favoritesView(data.items, filter, data.counts); + const filter = qf(ctx), q = String(ctx.query.q || '').trim(); + const data = await favoritesModel.listAll({ filter }); + const items = applyTextSearch(data.items, q, ['title', 'name', 'description', 'category', 'url', 'tags']); + ctx.body = await favoritesView(items, filter, data.counts, q); }) .get("/logs", async (ctx) => { if (!checkMod(ctx, 'logsMod')) { ctx.redirect('/modules'); return; } @@ -5159,13 +5670,6 @@ router ctx.body = logsView([], 'today', mode, { view: 'create', aiModOn }); return; } - if (view === 'edit') { - const id = String(ctx.query.id || ''); - const entry = id ? await logsModel.getLogById(id) : null; - if (!entry) { ctx.redirect('/logs'); return; } - ctx.body = logsView([], 'today', entry.mode, { view: 'edit', aiModOn, entry }); - return; - } const filter = qf(ctx, 'today'); const q = String(ctx.query.q || '').trim().toLowerCase(); const typeQ = String(ctx.query.type || '').trim().toLowerCase(); @@ -5182,7 +5686,8 @@ router }) .get("/logs/view/:id", async (ctx) => { if (!checkMod(ctx, 'logsMod')) { ctx.redirect('/modules'); return; } - const entry = await logsModel.getLogById(ctx.params.id); + const entries = await logsModel.listLogs('always'); + const entry = entries.find(e => e.key === ctx.params.id); if (!entry) { ctx.redirect('/logs'); return; } const aiModOn = logsModel.isAImodOn(); ctx.body = logsView([], 'today', entry.mode, { view: 'detail', aiModOn, entry }); @@ -5197,11 +5702,17 @@ router } catch (_) {} ctx.redirect('/logs'); }) - .post("/logs/edit/:id", koaBody(), async (ctx) => { + .get("/logs/edit/:id", async (ctx) => { + if (!checkMod(ctx, 'logsMod')) { ctx.redirect('/modules'); return; } + const entry = await logsModel.getLogById(ctx.params.id); + if (!entry) { ctx.redirect('/logs'); return; } + ctx.body = logsView([], 'today', entry.mode, { view: 'edit', aiModOn: logsModel.isAImodOn(), entry }); + }) + .post("/logs/update/:id", koaBody(), async (ctx) => { if (!checkMod(ctx, 'logsMod')) { ctx.redirect('/modules'); return; } const b = ctx.request.body || {}; try { await logsModel.updateLog(ctx.params.id, { label: b.label || '', text: b.text || '' }); } catch (_) {} - ctx.redirect('/logs'); + ctx.redirect(`/logs/view/${encodeURIComponent(ctx.params.id)}`); }) .post("/logs/delete/:id", koaBody(), async (ctx) => { if (!checkMod(ctx, 'logsMod')) { ctx.redirect('/modules'); return; } @@ -5235,6 +5746,10 @@ router }) .get("/thread/:message", async (ctx) => { const { message } = ctx.params; + if (checkMod(ctx, 'blogsMod')) { + const target = await blogModel.blogHrefFor(message).catch(() => null); + if (target) { ctx.redirect(target); return; } + } const thread = async (message) => { const messages = await post.fromThread(message); const spreadMap = await spreads.forMessages((messages || []).map(m => m && m.key)).catch(() => new Map()); @@ -5252,7 +5767,7 @@ router ctx.body = await subtopicView({ messages, myFeedId, spreadMap }); }) .get("/publish", async (ctx) => { - ctx.body = await publishView(); + ctx.redirect('/blogs?filter=CREATE'); }) .get("/comment/:message", async (ctx) => { const { messages, myFeedId, parentMessage } = @@ -5359,6 +5874,46 @@ router const tr = await transfersModel.getTransferById(ctx.params.id, getViewerId()); ctx.body = await transferView([tr], 'edit', ctx.params.id, {}); }) + .post('/transfers/:id/share', koaBody(), async ctx => { + if (!checkMod(ctx, 'transfersMod')) { ctx.redirect('/modules'); return; } + const transfer = await transfersModel.getTransferById(ctx.params.id, getViewerId()).catch(() => null); + if (!transfer) { ctx.redirect('/transfers'); return; } + const pdf = buildSmartContractPdf({ transfer, block: null, viewerId: null }); + const slug = String(transfer.concept || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 40); + await sharePdfBuffer(ctx, pdf, `oasis-smart-contract-${slug || 'transfer'}.pdf`, transfer.concept || ''); + }) + .get('/reports/:id/pdf', async ctx => sendContentPdf(ctx, 'reports', ctx.params.id)) + .post('/reports/:id/share', koaBody(), async ctx => sharePdfAsPm(ctx, 'reports', ctx.params.id)) + .get('/votes/:id/pdf', async ctx => sendContentPdf(ctx, 'votes', ctx.params.id)) + .post('/votes/:id/share', koaBody(), async ctx => sharePdfAsPm(ctx, 'votes', ctx.params.id)) + .get('/events/:id/pdf', async ctx => sendContentPdf(ctx, 'events', ctx.params.id)) + .post('/events/:id/share', koaBody(), async ctx => sharePdfAsPm(ctx, 'events', ctx.params.id)) + .get('/tasks/:id/pdf', async ctx => sendContentPdf(ctx, 'tasks', ctx.params.id)) + .post('/tasks/:id/share', koaBody(), async ctx => sharePdfAsPm(ctx, 'tasks', ctx.params.id)) + .get('/calendars/:id/pdf', async ctx => sendContentPdf(ctx, 'calendars', ctx.params.id)) + .post('/calendars/:id/share', koaBody(), async ctx => sharePdfAsPm(ctx, 'calendars', ctx.params.id)) + .get('/cv/pdf', async ctx => sendContentPdf(ctx, 'cv', null)) + .get('/cv/pdf/:id', async ctx => { + const targetId = ctx.params.id; + const viewerId = getViewerId(); + const cv = await cvModel.getCVByUserId(targetId).catch(() => null); + const hidden = cv && String(cv.visibility || '').toUpperCase() === 'HIDDEN'; + if (!cv || (hidden && String(targetId) !== String(viewerId))) { ctx.redirect('/inhabitants?filter=CVs'); return; } + const pdf = buildContentPdf('cv', cv, {}, String(targetId) === String(viewerId) ? viewerId : null); + ctx.set('Content-Type', 'application/pdf'); + ctx.set('Content-Disposition', `attachment; filename="${pdfFilename('cv', cv)}"`); + ctx.body = pdf; + }) + .post('/cv/share', koaBody(), async ctx => sharePdfAsPm(ctx, 'cv', null)) + .post('/cv/share/:id', koaBody(), async ctx => { + const targetId = ctx.params.id; + const viewerId = getViewerId(); + const cv = await cvModel.getCVByUserId(targetId).catch(() => null); + const hidden = cv && String(cv.visibility || '').toUpperCase() === 'HIDDEN'; + if (!cv || (hidden && String(targetId) !== String(viewerId))) { ctx.redirect('/inhabitants?filter=CVs'); return; } + const pdf = buildContentPdf('cv', cv, {}, null); + await sharePdfBuffer(ctx, pdf, pdfFilename('cv', cv), cv.name || targetId); + }) .get('/transfers/contract/:transferId', async ctx => { if (!checkMod(ctx, 'transfersMod')) { ctx.redirect('/modules'); return; } const transfer = await transfersModel.getTransferById(ctx.params.transferId, getViewerId()); @@ -5409,7 +5964,10 @@ router } catch (_) { block = null; } } await enrichItemLifetime(transfer, { author: transfer.from }); - ctx.body = await singleTransferView(transfer, filter, { q: ctx.query.q || '', minAmount: ctx.query.minAmount ?? '', maxAmount: ctx.query.maxAmount ?? '', sort: ctx.query.sort || 'recent', returnTo: safeReturnTo(ctx, `/transfers?filter=${encodeURIComponent(filter)}`, ['/transfers']), block, spreads: await spreads.forMessage(transfer.id).catch(() => null) }); + const comments = await getVoteComments(transfer.id); + const fav = await contentFavorites.getFavoriteSet('transfers'); + await warmAuthorNames([transfer], comments); + ctx.body = await singleTransferView({ ...transfer, isFavorite: fav.has(String(transfer.id)) }, filter, { q: ctx.query.q || '', minAmount: ctx.query.minAmount ?? '', maxAmount: ctx.query.maxAmount ?? '', sort: ctx.query.sort || 'recent', returnTo: safeReturnTo(ctx, `/transfers?filter=${encodeURIComponent(filter)}`, ['/transfers']), block, comments, spreads: await spreads.forMessage(transfer.id).catch(() => null) }); }) .post('/ai', koaBody(), async (ctx) => { const { input } = ctx.request.body; @@ -5597,7 +6155,7 @@ router const raw = String(ctx.request.body?.q || ctx.request.body?.prompt || '').trim(); if (!raw) { ctx.redirect('/'); return; } const ssbRefLib = require('../server/node_modules/ssb-ref'); - const hashtagMatch = raw.match(/^#([0-9A-Za-z_À--]+)/); + const hashtagMatch = raw.match(/^#([\p{L}\p{N}_-]+)/u); if (hashtagMatch) { ctx.redirect('/search?query=' + encodeURIComponent('#' + hashtagMatch[1])); return; @@ -5616,18 +6174,17 @@ router try { const embedder = require('../AI/embedder'); const routesIndex = require('../AI/routes_index'); - if (!embedder.isInstalled()) { - ctx.redirect('/search?query=' + encodeURIComponent(raw)); - return; - } - const vec = await embedder.embed(raw); - if (!vec) { - ctx.redirect('/search?query=' + encodeURIComponent(raw)); - return; - } const isModuleEnabled = (modName) => checkMod(ctx, modName); - const best = await routesIndex.resolveBest(vec, { isModuleEnabled, embed: embedder.embed }); - if (best && best.path) { ctx.redirect(best.path); return; } + if (embedder.isInstalled()) { + const vec = await embedder.embed(raw); + if (vec) { + const best = await routesIndex.resolveBest(vec, { isModuleEnabled, embed: embedder.embed }); + if (best && best.path) { ctx.redirect(best.path); return; } + } + } + const { aiNavResultsView } = require('../views/main_views'); + const results = routesIndex.resolveKeywordTopK({ isModuleEnabled }, raw, 8); + if (Array.isArray(results) && results.length) { ctx.body = await aiNavResultsView({ query: raw, results }); return; } } catch (_) {} ctx.redirect('/search?query=' + encodeURIComponent(raw)); }) @@ -5727,7 +6284,6 @@ router cleanup(); ctx.redirect('/pm?fileerror=mutual#fileshare'); return; } } - const fmtB = (n) => { const x = Number(n) || 0; if (x < 1024) return x + ' B'; const u = ['KB','MB','GB','TB']; let v = x / 1024, i = 0; while (v >= 1024 && i < u.length - 1) { v /= 1024; i++; } return v.toFixed(v >= 100 || i === 0 ? 0 : 1) + ' ' + u[i]; }; if (!file || !file.filepath || !file.size) { cleanup(); ctx.redirect('/pm?fileerror=nofile#fileshare'); return; } if (file.size > FILESHARE_MAX_SIZE) { cleanup(); ctx.redirect('/pm?fileerror=size#fileshare'); return; } let pointer; @@ -5745,7 +6301,7 @@ router recipient, subject: stripDangerousTags(b.subject || ''), manifestBlobId: pointer.manifestBlobId, keyHex: pointer.key, filename: pointer.filename, mime: pointer.mime, size: pointer.size, - sizeLabel: fmtB(pointer.size), crypter: useCrypter, sharedKey + sizeLabel: formatFileSize(pointer.size), crypter: useCrypter, sharedKey }); }) .post('/pm/file', koaBodyFileshare, async ctx => { @@ -5808,22 +6364,14 @@ router const messages = await buildInboxMessages(); const msg = messages.find(m => m && m.key === ctx.params.id); const fileShare = msg && msg.value && msg.value.content && msg.value.content.fileShare; - if (!fileShare) { ctx.redirect('/inbox'); return; } - let pointer = fileShare; - if (fileShare.crypter) { - const key = ctx.query && ctx.query.key; - if (typeof key !== 'string' || !key) { ctx.redirect('/inbox'); return; } - try { - pointer = { ...fileShare, key: cipherModel.decryptData(fileShare.key, key) }; - } catch (_) { ctx.redirect('/inbox?filekey=bad'); return; } - } - if (!(await fileshareModel.ensureAvailable(pointer))) { ctx.redirect('/inbox?filestatus=unavailable'); return; } + if (!fileShare || fileShare.crypter) { ctx.redirect('/inbox'); return; } + if (!(await fileshareModel.ensureAvailable(fileShare))) { ctx.redirect('/inbox?filestatus=unavailable'); return; } const safeName = String(fileShare.filename || 'file').replace(/["\r\n\\]/g, ''); ctx.type = fileShare.mime || 'application/octet-stream'; ctx.set('Content-Disposition', `attachment; filename="${safeName}"`); - const stream = fileshareModel.readShareStream(pointer); + const stream = fileshareModel.readShareStream(fileShare); if (msg.value.author && msg.value.author !== getViewerId()) { - stream.on('end', () => { fileshareModel.removeLocalBlobs(pointer).catch(() => {}); }); + stream.on('end', () => { fileshareModel.removeLocalBlobs(fileShare).catch(() => {}); }); } ctx.body = stream; }) @@ -5868,6 +6416,7 @@ router const applySearchPrivacy = (msgs) => msgs.filter(msg => { const c = msg.value?.content; if (!c) return true; + if (c.type === 'pub') return false; if (Array.isArray(c.recps)) return false; if (c.type === 'post' && (c.private === true || c.recps)) return false; if (c.tribeId && anonTribeIds.has(c.tribeId)) return false; @@ -5955,40 +6504,6 @@ router }); ctx.redirect(`/thread/${encodeURIComponent(parent.key)}`); }) - .post("/publish/preview", koaBody({multipart: true, formidable: { multiples: false, maxFileSize: maxSize }, urlencoded: true }), async (ctx) => { - const cw = stripDangerousTags(ctx.request.body.contentWarning?.toString().trim() || ""); - ctx.body = await previewView({ previewData: await preparePreview(ctx), contentWarning: cw.length > 0 ? cw : undefined }); - }) - .post("/publish", koaBody({ multipart: true, urlencoded: true, formidable: { multiples: false, maxFileSize: maxSize } }), async (ctx) => { - const b = ctx.request.body, text = stripDangerousTags(b.text?.toString().trim() || ""), cw = stripDangerousTags(b.contentWarning?.toString().trim() || ""); - let mentions = []; - try { mentions = JSON.parse(b.mentions || "[]"); } catch { mentions = await extractMentions(text); } - const approxContent = { type: 'post', text, ...(Array.isArray(mentions) && mentions.length ? { mentions } : {}), ...(cw.length > 0 ? { contentWarning: cw } : {}) }; - const tooLongMsg = require('../views/main_views').i18n.publishTooLong || 'Your post is too long. Please shorten it.'; - if (exceedsSsbLimit(approxContent)) { - sendErrorPage(ctx, tooLongMsg, { status: 400 }); - return; - } - try { - await post.root({ text, mentions, contentWarning: cw.length > 0 ? cw : undefined }); - } catch (e) { - if (isSsbTooLargeError(e)) { sendErrorPage(ctx, tooLongMsg, { status: 400 }); return; } - throw e; - } - try { activityModel.invalidateCache(); } catch (_) {} - ctx.redirect("/public/latest"); - }) - .post("/publish/custom", koaBody(), async (ctx) => { - const text = String(ctx.request.body.text); - const obj = JSON.parse(text); - const ALLOWED_TYPES = ['post','about','contact','vote','pub','channel']; - if (!obj.type || !ALLOWED_TYPES.includes(obj.type)) { ctx.throw(400, 'Invalid message type'); return; } - const sanitizeObj = (o) => { for (const k of Object.keys(o)) { if (typeof o[k] === 'string') o[k] = stripDangerousTags(o[k]); else if (o[k] && typeof o[k] === 'object') sanitizeObj(o[k]); } }; - sanitizeObj(obj); - ctx.body = await post.publishCustom(obj); - try { activityModel.invalidateCache(); } catch (_) {} - ctx.redirect(`/public/latest`); - }) .post("/follow/:feed", koaBody(), async (ctx) => { ctx.body = await friend.follow(ctx.params.feed); safeRefererRedirect(ctx, '/inhabitants'); @@ -6008,7 +6523,7 @@ router .post("/spread/:message", koaBody(), async (ctx) => { const { message } = ctx.params; const ref = ctx.request.header.referer; - let target = '/public/latest'; + let target = '/blogs'; try { if (ref) { const u = new URL(ref); @@ -6093,7 +6608,7 @@ router .post("/like/:message", koaBody(), async (ctx) => { const { message } = ctx.params, voteValue = Number(ctx.request.body.voteValue); const ref = ctx.request.header.referer; - let target = '/public/latest'; + let target = '/blogs'; try { if (ref) { const u = new URL(ref); @@ -6118,9 +6633,8 @@ router .post('/forum/:id/message', koaBody(), async ctx => { const { message, parentId } = ctx.request.body; const cleanedMsg = stripDangerousTags(message); - const subtopic = stripDangerousTags(String(ctx.request.body.subtopic || '').trim()).slice(0, 40); const mentions = await extractMentions(cleanedMsg); - await forumModel.addMessageToForum(ctx.params.id, { text: cleanedMsg, author: getViewerId(), timestamp: new Date().toISOString(), mentions: mentions.length > 0 ? mentions : undefined, subtopic: subtopic || undefined }, parentId); + await forumModel.addMessageToForum(ctx.params.id, { text: cleanedMsg, author: getViewerId(), timestamp: new Date().toISOString(), mentions: mentions.length > 0 ? mentions : undefined }, parentId); ctx.redirect(`/forum/${encodeURIComponent(ctx.params.id)}`); }) .post('/forum/:forumId/vote', koaBody(), async ctx => { @@ -6162,6 +6676,19 @@ router ctx.redirect(safeReturnTo(ctx, '/forum', ['/forum'])); } }) + .post('/legacy/export', koaBody(), async (ctx) => { + if (!isLoopbackRequest(ctx)) { ctx.status = 403; ctx.body = ''; return; } + const pw = ctx.request.body.password; + if (!pw || pw.length < 32) return ctx.redirect('/legacy'); + try { + const { filename, data } = await legacyModel.exportData({ password: pw }); + ctx.set('Content-Type', 'application/octet-stream'); + ctx.set('Content-Disposition', `attachment; filename="${filename}"`); + ctx.set('Content-Length', String(data.length)); + ctx.body = data; + try { onboardingModel.markStep('backup'); } catch (_) {} + } catch (error) { sendErrorPage(ctx, error.message); } + }) .post('/legacy/import', koaBody({ multipart: true, formidable: { @@ -6230,13 +6757,13 @@ router .post("/bookmarks/create", koaBody(), async (ctx) => { if (!checkMod(ctx, 'bookmarksMod')) { ctx.redirect('/modules'); return; } const b = ctx.request.body; - await bookmarksModel.createBookmark(stripDangerousTags(b.url), b.tags, stripDangerousTags(b.description), b.category, b.lastVisit); + await bookmarksModel.createBookmark(stripDangerousTags(b.url), b.tags, stripDangerousTags(b.description), b.lastVisit); ctx.redirect(safeReturnTo(ctx, '/bookmarks?filter=all', ['/bookmarks'])); }) .post("/bookmarks/update/:id", koaBody(), async (ctx) => { if (!checkMod(ctx, 'bookmarksMod')) { ctx.redirect('/modules'); return; } const b = ctx.request.body; - await bookmarksModel.updateBookmarkById(ctx.params.id, { url: stripDangerousTags(b.url), tags: b.tags, description: stripDangerousTags(b.description), category: b.category, lastVisit: b.lastVisit }); + await bookmarksModel.updateBookmarkById(ctx.params.id, { url: stripDangerousTags(b.url), tags: b.tags, description: stripDangerousTags(b.description), lastVisit: b.lastVisit }); ctx.redirect(safeReturnTo(ctx, '/bookmarks?filter=mine', ['/bookmarks'])); }) .post("/bookmarks/delete/:id", koaBody(), async ctx => deleteAction(ctx, 'bookmarks')) @@ -6247,13 +6774,13 @@ router .post("/images/create", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { if (!checkMod(ctx, 'imagesMod')) { ctx.redirect('/modules'); return; } const blob = await handleBlobUpload(ctx, 'image'), b = ctx.request.body; - await imagesModel.createImage(blob, b.tags, stripDangerousTags(b.title), stripDangerousTags(b.description), parseBool01(b.meme), stripDangerousTags(b.mapUrl || "")); + await imagesModel.createImage(blob, b.tags, stripDangerousTags(b.title), stripDangerousTags(b.description), stripDangerousTags(b.mapUrl || "")); ctx.redirect(safeReturnTo(ctx, '/images?filter=all', ['/images'])); }) .post("/images/update/:id", koaBody({ multipart: true, formidable: { maxFileSize: maxSize } }), async (ctx) => { if (!checkMod(ctx, 'imagesMod')) { ctx.redirect('/modules'); return; } const b = ctx.request.body, blob = ctx.request.files?.image ? await handleBlobUpload(ctx, 'image') : null; - await imagesModel.updateImageById(ctx.params.id, blob, b.tags, stripDangerousTags(b.title), stripDangerousTags(b.description), parseBool01(b.meme), stripDangerousTags(b.mapUrl || "")); + await imagesModel.updateImageById(ctx.params.id, blob, b.tags, stripDangerousTags(b.title), stripDangerousTags(b.description), stripDangerousTags(b.mapUrl || "")); ctx.redirect(safeReturnTo(ctx, '/images?filter=mine', ['/images'])); }) .post("/images/delete/:id", koaBody(), async ctx => deleteAction(ctx, 'images')) @@ -6301,7 +6828,7 @@ router if (!checkMod(ctx, 'mapsMod')) { ctx.redirect('/modules'); return; } try { const code = await mapsModel.generateInvite(ctx.params.id); - ctx.body = `
Map invite code: ${code}