634 |
635 | This program is free software: you can redistribute it and/or modify
636 | it under the terms of the GNU Affero General Public License as published
637 | by the Free Software Foundation, either version 3 of the License, or
638 | (at your option) any later version.
639 |
640 | This program is distributed in the hope that it will be useful,
641 | but WITHOUT ANY WARRANTY; without even the implied warranty of
642 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
643 | GNU Affero General Public License for more details.
644 |
645 | You should have received a copy of the GNU Affero General Public License
646 | along with this program. If not, see .
647 |
648 | Also add information on how to contact you by electronic and paper mail.
649 |
650 | If your software can interact with users remotely through a computer
651 | network, you should also make sure that it provides a way for users to
652 | get its source. For example, if your program is a web application, its
653 | interface could display a "Source" link that leads users to an archive
654 | of the code. There are many ways you could offer source, and different
655 | solutions will be better for different programs; see section 13 for the
656 | specific requirements.
657 |
658 | You should also get your employer (if you work as a programmer) or school,
659 | if any, to sign a "copyright disclaimer" for the program, if necessary.
660 | For more information on this, and how to apply and follow the GNU AGPL, see
661 | .
662 |
--------------------------------------------------------------------------------
/app.js:
--------------------------------------------------------------------------------
1 | require("dotenv").config();
2 |
3 | const {
4 | ComAtprotoSyncSubscribeRepos,
5 | subscribeRepos,
6 | } = require("atproto-firehose");
7 | const mongoose = require("mongoose");
8 |
9 | // The database is divided into two: The one that stores the words of all posts and the one that stores information from BetterBluesky. If everyone goes to the same bank, it causes slowdowns.
10 | const database_words = mongoose.createConnection(process.env.MONGODB_WORDS);
11 | const database = mongoose.createConnection(process.env.MONGODB);
12 |
13 | const express = require("express");
14 |
15 | const app = express();
16 | const cors = require("cors");
17 |
18 | const jwt = require("jsonwebtoken");
19 |
20 | app.use(
21 | cors({
22 | origin: "*",
23 | }),
24 | );
25 |
26 | const WordSchema = database_words.model(
27 | "Word",
28 | new mongoose.Schema({
29 | t: {
30 | //texto
31 | type: String,
32 | required: true,
33 | },
34 | ty: {
35 | //tipo (w = word, h = hashtag)
36 | type: String,
37 | required: true,
38 | },
39 | l: {
40 | //languages
41 | type: String,
42 | default: "",
43 | },
44 | ca: {
45 | //created at
46 | type: Date,
47 | immutable: true,
48 | default: () => new Date(),
49 | },
50 | }),
51 | );
52 |
53 | const PollSchema = database.model(
54 | "Poll",
55 | new mongoose.Schema({
56 | id: {
57 | type: String,
58 | unique: true,
59 | required: true,
60 | },
61 | authorDid: {
62 | type: String,
63 | required: true,
64 | },
65 | title: {
66 | type: String,
67 | required: true,
68 | minLength: 1,
69 | maxLength: 32,
70 | },
71 | options: [
72 | {
73 | text: {
74 | type: String,
75 | minLength: 1,
76 | maxLength: 32,
77 | },
78 | voteCount: {
79 | type: Number,
80 | default: 0,
81 | },
82 | },
83 | ],
84 | createdAt: {
85 | //created at
86 | type: Date,
87 | immutable: true,
88 | default: () => new Date(),
89 | },
90 | }).set("toObject", {
91 | transform: (doc, ret, options) => {
92 | delete ret._id;
93 | delete ret.__v;
94 | return ret;
95 | },
96 | }),
97 | );
98 |
99 | const PollVoteSchema = database.model(
100 | "PollVote",
101 | new mongoose.Schema({
102 | pollId: {
103 | type: String,
104 | required: true,
105 | },
106 | userdid: {
107 | type: String,
108 | required: true,
109 | },
110 | option: {
111 | //position in options array
112 | type: Number,
113 | required: true,
114 | },
115 | createdAt: {
116 | //created at
117 | type: Date,
118 | immutable: true,
119 | default: () => new Date(),
120 | },
121 | }).set("toObject", {
122 | transform: (doc, ret, options) => {
123 | delete ret._id;
124 | delete ret.__v;
125 | return ret;
126 | },
127 | }),
128 | );
129 |
130 | const BookmarkSchema = database.model(
131 | "Bookmark",
132 | new mongoose.Schema({
133 | postaturi: {
134 | type: String,
135 | required: true,
136 | },
137 | userdid: {
138 | type: String,
139 | required: true,
140 | },
141 | postuserdid: {
142 | type: String,
143 | required: true,
144 | },
145 | postid: {
146 | type: String,
147 | required: true,
148 | },
149 | enabled: {
150 | type: Boolean,
151 | default: true,
152 | },
153 | method: {
154 | type: String,
155 | },
156 | createdAt: {
157 | //created at
158 | type: Date,
159 | immutable: true,
160 | default: () => new Date(),
161 | },
162 | }).set("toObject", {
163 | transform: (doc, ret, options) => {
164 | delete ret._id;
165 | delete ret.__v;
166 | return ret;
167 | },
168 | }),
169 | );
170 |
171 | const TokenSchema = database.model(
172 | "Token",
173 | new mongoose.Schema({
174 | //usado para acessar funções de admin
175 | token: {
176 | type: String,
177 | required: true,
178 | },
179 | name: {
180 | type: String, //nome do token
181 | },
182 | permissions: {
183 | type: String,
184 | required: true,
185 | },
186 | createdAt: {
187 | type: Date,
188 | immutable: true,
189 | default: () => new Date(),
190 | },
191 | }),
192 | );
193 |
194 | const UserSchema = database.model(
195 | "User",
196 | new mongoose.Schema({
197 | h: {
198 | //handle
199 | type: String,
200 | required: true,
201 | },
202 | d: {
203 | //did
204 | type: String,
205 | required: true,
206 | },
207 | s: {
208 | //BetterBluesky last SessionID
209 | type: String,
210 | required: true,
211 | },
212 | ss: [
213 | {
214 | //BetterBluesky all SessionID's
215 | type: String,
216 | },
217 | ],
218 | ll: {
219 | //last login
220 | type: Date,
221 | default: () => new Date(),
222 | },
223 | ca: {
224 | //created at
225 | type: Date,
226 | immutable: true,
227 | default: () => new Date(),
228 | },
229 | }),
230 | );
231 |
232 | const SettingsSchema = database.model(
233 | "Setting",
234 | new mongoose.Schema({
235 | blacklist: {
236 | pt: {
237 | trends: {
238 | type: Array,
239 | default: [],
240 | },
241 | words: {
242 | type: Array,
243 | default: [],
244 | },
245 | },
246 | en: {
247 | trends: {
248 | type: Array,
249 | default: [],
250 | },
251 | words: {
252 | type: Array,
253 | default: [],
254 | },
255 | },
256 | ja: {
257 | trends: {
258 | type: Array,
259 | default: [],
260 | },
261 | words: {
262 | type: Array,
263 | default: [],
264 | },
265 | },
266 | es: {
267 | trends: {
268 | type: Array,
269 | default: [],
270 | },
271 | words: {
272 | type: Array,
273 | default: [],
274 | },
275 | },
276 | fr: {
277 | trends: {
278 | type: Array,
279 | default: [],
280 | },
281 | words: {
282 | type: Array,
283 | default: [],
284 | },
285 | },
286 | global: {
287 | trends: {
288 | type: Array,
289 | default: [],
290 | },
291 | words: {
292 | type: Array,
293 | default: [],
294 | },
295 | },
296 | },
297 | pinWord: {
298 | enabled: {
299 | type: Boolean,
300 | default: false,
301 | },
302 | word: {
303 | type: String,
304 | },
305 | count: {
306 | type: Number,
307 | },
308 | message: {
309 | type: String,
310 | },
311 | position: {
312 | type: Number,
313 | },
314 | },
315 | trendsMessages: [
316 | {
317 | //se a palavra está nos trends, adiciona uma mensagem nela
318 | word: {
319 | type: String,
320 | },
321 | message: {
322 | type: String,
323 | },
324 | },
325 | ],
326 | config: {
327 | defaultBookmarksPost: String,
328 | },
329 | }),
330 | );
331 |
332 | const cache = {
333 | _firstUpdated: false,
334 | trending: {
335 | pt: {
336 | head: {
337 | lang: "pt",
338 | time: 0,
339 | length: 0,
340 | },
341 | data: [],
342 | },
343 | en: {
344 | head: {
345 | lang: "en",
346 | time: 0,
347 | length: 0,
348 | },
349 | data: [],
350 | },
351 | ja: {
352 | head: {
353 | lang: "ja",
354 | time: 0,
355 | length: 0,
356 | },
357 | data: [],
358 | },
359 | es: {
360 | head: {
361 | lang: "es",
362 | time: 0,
363 | length: 0,
364 | },
365 | data: [],
366 | },
367 | fr: {
368 | head: {
369 | lang: "fr",
370 | time: 0,
371 | length: 0,
372 | },
373 | data: [],
374 | },
375 | global: {
376 | head: {
377 | lang: "global",
378 | time: 0,
379 | length: 0,
380 | },
381 | data: [],
382 | },
383 | },
384 | stats: {
385 | last30sSessions: new Map(),
386 | last30sSessionsCountStore: 0,
387 | },
388 | settings: {
389 | blacklist: {
390 | pt: {
391 | trends: [],
392 | words: [],
393 | },
394 | en: {
395 | trends: [],
396 | words: [],
397 | },
398 | ja: {
399 | trends: [],
400 | words: [],
401 | },
402 | es: {
403 | trends: [],
404 | words: [],
405 | },
406 | fr: {
407 | trends: [],
408 | words: [],
409 | },
410 | global: {
411 | trends: [],
412 | words: [],
413 | },
414 | },
415 | pinWord: {
416 | enabled: false,
417 | word: "",
418 | count: 0,
419 | position: 0,
420 | },
421 | trendsMessages: [],
422 | config: {},
423 | },
424 | };
425 |
426 | const client = subscribeRepos("wss://bsky.network", { decodeRepoOps: true });
427 |
428 | client.on("message", (message) => {
429 | if (ComAtprotoSyncSubscribeRepos.isCommit(message)) {
430 | message.ops.forEach(async (op) => {
431 | if (!op?.payload) return;
432 | if (op.payload.$type !== "app.bsky.feed.post") return;
433 | if (!op.payload.langs) return;
434 |
435 | const text = op.payload.text.trim();
436 |
437 | if (op.payload.reply) {
438 | if (text === "📍") {
439 | try {
440 | const user = await UserSchema.findOne({ d: message.repo });
441 | if (!user) return;
442 |
443 | const replypostaturi = op.payload.reply.parent.uri;
444 | const replydata = extractPostIdAndDid(replypostaturi);
445 |
446 | if (replydata.error) return;
447 |
448 | const existBookmark = await BookmarkSchema.findOne({
449 | postid: replydata.data.postid,
450 | postuserdid: replydata.data.did,
451 | userdid: user.d,
452 | });
453 | if (existBookmark) {
454 | existBookmark.enabled = true;
455 | return await existBookmark.save();
456 | }
457 |
458 | return BookmarkSchema.create({
459 | postaturi: replypostaturi,
460 | postid: replydata.data.postid,
461 | postuserdid: replydata.data.did,
462 | userdid: user.d,
463 | method: "POST",
464 | });
465 | } catch (e) {
466 | console.log("Error on message bookmark", e);
467 | }
468 | }
469 | }
470 |
471 | if (!cache._firstUpdated) return;
472 | const posthashtags = getHashtags(text);
473 | const postwords = [
474 | ...new Set(
475 | text
476 | .trim()
477 | .split(" ")
478 | .filter(
479 | (word) =>
480 | word.length > 2 && word.length < 64 && !word.startsWith("#"),
481 | ),
482 | ),
483 | ];
484 |
485 | let langBlacklist = cache.settings.blacklist.global;
486 | if (op.payload.langs.length === 1) {
487 | const lang = op.payload.langs[0].replace("en-US", "en");
488 | langBlacklist =
489 | cache.settings.blacklist[lang] || cache.settings.blacklist.global;
490 | }
491 |
492 | for (const hashtag of posthashtags) {
493 | if (hashtag.length > 2) {
494 | if (
495 | langBlacklist.trends
496 | .map((t) => t.toLowerCase())
497 | .includes(hashtag.toLowerCase()) ||
498 | langBlacklist.words.find((w) =>
499 | hashtag.toLowerCase().includes(w.toLowerCase()),
500 | )
501 | ) {
502 | continue;
503 | }
504 | await WordSchema.create({
505 | t: hashtag,
506 | ty: "h",
507 | l: op.payload.langs.join(" "),
508 | });
509 | }
510 | }
511 |
512 | for (const word of postwords) {
513 | if (
514 | langBlacklist.trends
515 | .map((t) => t.toLowerCase())
516 | .includes(word.toLowerCase()) ||
517 | langBlacklist.words.find((w) =>
518 | word.toLowerCase().includes(w.toLowerCase()),
519 | )
520 | ) {
521 | continue;
522 | }
523 | await WordSchema.create({
524 | t: word.toLowerCase(),
525 | ty: "w",
526 | l: op.payload.langs.join(" "),
527 | });
528 | }
529 | });
530 | }
531 | });
532 |
533 | updateCacheSettings();
534 | async function updateCacheSettings() {
535 | const settings =
536 | (await SettingsSchema.findOne({})) || (await SettingsSchema.create({}));
537 | cache.settings.blacklist = settings.blacklist;
538 | cache.settings.pinWord = settings.pinWord;
539 | cache.settings.config = settings.config;
540 | cache.settings.trendsMessages = settings.trendsMessages;
541 | }
542 |
543 | async function updateCacheTrending() {
544 | cache.trending.pt.data = await getTrending(15, 6, ["pt"]);
545 | cache.trending.pt.head.time = Date.now();
546 | cache.trending.pt.head.length = cache.trending.pt.data.length;
547 |
548 | cache.trending.en.data = await getTrending(15, 6, ["en", "en-US"]); //en or en-us
549 | cache.trending.en.head.time = Date.now();
550 | cache.trending.en.head.length = cache.trending.en.data.length;
551 |
552 | cache.trending.ja.data = await getTrending(15, 6, ["ja"]);
553 | cache.trending.ja.head.time = Date.now();
554 | cache.trending.ja.head.length = cache.trending.ja.data.length;
555 |
556 | cache.trending.es.data = await getTrending(15, 6, ["es"]);
557 | cache.trending.es.head.time = Date.now();
558 | cache.trending.es.head.length = cache.trending.es.data.length;
559 |
560 | cache.trending.fr.data = await getTrending(15, 6, ["fr"]);
561 | cache.trending.fr.head.time = Date.now();
562 | cache.trending.fr.head.length = cache.trending.fr.data.length;
563 |
564 | cache.trending.global.data = await getTrending(15, 6, []);
565 | cache.trending.global.head.time = Date.now();
566 | cache.trending.global.head.length = cache.trending.global.data.length;
567 |
568 | console.log(
569 | `=============================== Cache atualizado (${Date.now()}) ===============================`,
570 | );
571 | cache._firstUpdated = true;
572 | }
573 |
574 | setInterval(async () => {
575 | await updateCacheSettings();
576 | updateCacheTrending();
577 | }, 29 * 1000);
578 |
579 | setTimeout(
580 | () => {
581 | deleteOlds(3, 1000 * 60 * 60 * 1);
582 | },
583 | 1000 * 60 * 60 * 1,
584 | );
585 |
586 | //log stats
587 | setInterval(() => {
588 | console.log(`Sessões últimos 30s: ${cache.stats.last30sSessions.size}`);
589 | cache.stats.last30sSessionsCountStore = cache.stats.last30sSessions.size;
590 | cache.stats.last30sSessions = new Map();
591 | }, 1000 * 30);
592 |
593 | async function deleteOlds(hours, loopTimer) {
594 | //apaga as words antes de x horas
595 | console.log(`Apagando documentos de antes de horas: ${hours}`);
596 | const hoursAgo = new Date(Date.now() - hours * 60 * 60 * 1000); // Data e hora de x horas atrás
597 |
598 | const result = await WordSchema.deleteMany({ ca: { $lt: hoursAgo } });
599 |
600 | console.log(
601 | "-----------------------------------------------------------------------",
602 | );
603 | console.log(`Removed before ${hours}h: ${result.deletedCount}`);
604 | console.log(
605 | "-----------------------------------------------------------------------",
606 | );
607 | setTimeout(() => {
608 | deleteOlds(3, 1000 * 60 * 60 * 1);
609 | }, loopTimer);
610 | }
611 |
612 | function getHashtags(texto) {
613 | const regex = /#([\wÀ-ÖØ-öø-ÿ]+)/g;
614 | return texto.match(regex) || [];
615 | }
616 |
617 | app.post("/api/polls", async (req, res) => {
618 | const sessionID = req.query.sessionID;
619 |
620 | if (!sessionID)
621 | return res.status(400).json({ message: "sessionID is required" });
622 | if (typeof sessionID !== "string")
623 | return res.status(400).json({ message: "sessionID must be an string" });
624 |
625 | const polldatastring = req.query.polldata;
626 |
627 | if (!polldatastring)
628 | return res.status(400).json({ message: "polldata is required" });
629 | if (typeof polldatastring !== "string")
630 | return res.status(400).json({ message: "polldata must be an string" });
631 |
632 | let polldata = null;
633 |
634 | try {
635 | polldata = JSON.parse(decodeURIComponent(polldatastring));
636 | } catch (e) {
637 | return res.status(400).json({ message: "polldata must be an json string" });
638 | }
639 |
640 | if (!polldata) return;
641 |
642 | if (!polldata.title || typeof polldata.title !== "string")
643 | return res
644 | .status(400)
645 | .json({ message: "polldata.title must be an string" });
646 | if (polldata.title.length < 1 || polldata.title.length > 32)
647 | return res
648 | .status(400)
649 | .json({ message: "polldata.title must be between 1 and 32 characters" });
650 |
651 | if (!polldata.options || !Array.isArray(polldata.options))
652 | return res
653 | .status(400)
654 | .json({ message: "polldata.options must be an array" });
655 |
656 | if (polldata.options.length < 1 || polldata.options.length > 5)
657 | return res
658 | .status(400)
659 | .json({ message: "polldata.options must be between 1 and 5 itens" });
660 |
661 | if (polldata.options.some((option) => typeof option !== "string"))
662 | return res
663 | .status(400)
664 | .json({ message: "polldata.options.* must be an string" });
665 | if (
666 | polldata.options.some((option) => option.length < 1 || option.length > 32)
667 | )
668 | return res.status(400).json({
669 | message: "polldata.options.* must be between 1 and 32 characters",
670 | });
671 |
672 | try {
673 | const user = await UserSchema.findOne({ ss: sessionID });
674 | if (!user) return res.status(403).json({ message: "Invalid SessionID" });
675 |
676 | const pollid = `${Date.now()}${randomString(3, false)}`;
677 |
678 | const poll = await PollSchema.create({
679 | id: pollid,
680 | authorDid: user.d,
681 | title: polldata.title,
682 | options: polldata.options.map((string) => {
683 | return { text: string };
684 | }),
685 | });
686 |
687 | return res.json(poll.toObject());
688 | } catch (e) {
689 | console.log("poll create", e);
690 | res.status(500).json({ message: "Internal Server Error" });
691 | }
692 | });
693 |
694 | app.get("/api/polls/:pollId", async (req, res) => {
695 | const sessionID = req.query.sessionID;
696 |
697 | if (!sessionID)
698 | return res.status(400).json({ message: "sessionID is required" });
699 | if (typeof sessionID !== "string")
700 | return res.status(400).json({ message: "sessionID must be an string" });
701 |
702 | const pollID = req.params.pollId;
703 |
704 | if (!pollID) return res.status(400).json({ message: "id is required" });
705 | if (typeof pollID !== "string")
706 | return res.status(400).json({ message: "id must be an string" });
707 |
708 | const poll = await PollSchema.findOne({ id: pollID });
709 |
710 | if (!poll) return res.status(404).json({ message: "poll not found" });
711 |
712 | const pollObject = poll.toObject();
713 |
714 | const user = await UserSchema.findOne({ ss: sessionID });
715 |
716 | const userPollVote = user
717 | ? await PollVoteSchema.findOne({ pollId: poll.id, userdid: user.d })
718 | : null;
719 |
720 | pollObject.voted = !!userPollVote;
721 |
722 | pollObject.options.forEach((option, index) => {
723 | option.selected = userPollVote ? userPollVote.option === index : false;
724 | });
725 |
726 | return res.json(pollObject);
727 | });
728 |
729 | app.post("/api/polls/:pollId/votes", async (req, res) => {
730 | const sessionID = req.query.sessionID;
731 |
732 | if (!sessionID)
733 | return res.status(400).json({ message: "sessionID is required" });
734 | if (typeof sessionID !== "string")
735 | return res.status(400).json({ message: "sessionID must be an string" });
736 |
737 | const pollid = req.params.pollId;
738 |
739 | if (!pollid) return res.status(400).json({ message: "pollid is required" });
740 | if (typeof pollid !== "string")
741 | return res.status(400).json({ message: "pollid must be an string" });
742 |
743 | const option = Number(req.query.option);
744 |
745 | if (Number.isNaN(option))
746 | return res.status(400).json({ message: "option must be an number" });
747 |
748 | const user = await UserSchema.findOne({ ss: sessionID });
749 | if (!user) return res.status(403).json({ message: "Invalid SessionID" });
750 |
751 | const poll = await PollSchema.findOne({ id: pollid });
752 | if (!poll) return res.status(404).json({ message: "Poll not found" });
753 |
754 | if (option > poll.options.length)
755 | return res.status(400).json({ message: "Invalid option" });
756 |
757 | const existPollVote = await PollVoteSchema.findOne({
758 | pollId: pollid,
759 | userdid: user.d,
760 | });
761 |
762 | if (existPollVote) return res.json(existPollVote.toObject());
763 |
764 | const pollVote = await PollVoteSchema.create({
765 | pollId: pollid,
766 | userdid: user.d,
767 | option: option,
768 | });
769 |
770 | poll.options[option].voteCount++;
771 | poll.save();
772 |
773 | res.json(pollVote.toObject());
774 | });
775 |
776 | app.post("/api/bookmarks", async (req, res) => {
777 | try {
778 | const sessionID = req.query.sessionID;
779 |
780 | if (!sessionID)
781 | return res.status(400).json({ message: "sessionID is required" });
782 | if (typeof sessionID !== "string")
783 | return res.status(400).json({ message: "sessionID must be an string" });
784 |
785 | const postid = decodeURIComponent(req.query.postid);
786 |
787 | if (!postid) return res.status(400).json({ message: "postid is required" });
788 | if (typeof postid !== "string")
789 | return res.status(400).json({ message: "postid must be an string" });
790 |
791 | const postuserhandle = decodeURIComponent(req.query.postuserhandle);
792 |
793 | if (!postuserhandle)
794 | return res.status(400).json({ message: "postuserhandle is required" });
795 | if (typeof postuserhandle !== "string")
796 | return res
797 | .status(400)
798 | .json({ message: "postuserhandle must be an string" });
799 |
800 | const { did: postuserdid } = await fetch(
801 | `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${postuserhandle}`,
802 | ).then((r) => r.json());
803 | const user = await UserSchema.findOne({ ss: sessionID });
804 | if (!user) return res.status(403).json({ message: "Invalid SessionID" });
805 |
806 | const existBookmark = await BookmarkSchema.findOne({
807 | postid: postid,
808 | postuserdid: postuserdid,
809 | userdid: user.d,
810 | });
811 | if (existBookmark) {
812 | existBookmark.enabled = true;
813 | await existBookmark.save();
814 | return res.json(existBookmark.toObject());
815 | }
816 |
817 | const bookmark = await BookmarkSchema.create({
818 | postaturi: `at://${postuserdid}/app.bsky.feed.post/${postid}`,
819 | postid: postid,
820 | postuserdid: postuserdid,
821 | userdid: user.d,
822 | method: "API",
823 | });
824 | return res.json(bookmark);
825 | } catch (e) {
826 | console.log(e);
827 | res.status(500).json({ message: "Internal Server Error" });
828 | }
829 | });
830 |
831 | app.delete("/api/bookmarks", async (req, res) => {
832 | try {
833 | const sessionID = req.query.sessionID;
834 |
835 | if (!sessionID)
836 | return res.status(400).json({ message: "sessionID is required" });
837 | if (typeof sessionID !== "string")
838 | return res.status(400).json({ message: "sessionID must be an string" });
839 |
840 | const postid = decodeURIComponent(req.query.postid);
841 |
842 | if (!postid) return res.status(400).json({ message: "postid is required" });
843 | if (typeof postid !== "string")
844 | return res.status(400).json({ message: "postid must be an string" });
845 |
846 | const user = await UserSchema.findOne({ ss: sessionID });
847 | if (!user) return res.status(403).json({ message: "Invalid SessionID" });
848 |
849 | await BookmarkSchema.findOneAndUpdate(
850 | { postid: postid, userdid: user.d },
851 | { enabled: false },
852 | );
853 | return res.json({ success: true });
854 | } catch (e) {
855 | console.log(e);
856 | res.status(500).json({ message: "Internal Server Error" });
857 | }
858 | });
859 |
860 | app.get("/api/bookmarks", async (req, res) => {
861 | const sessionID = req.query.sessionID;
862 |
863 | if (!sessionID)
864 | return res.status(400).json({ message: "sessionID is required" });
865 | if (typeof sessionID !== "string")
866 | return res.status(400).json({ message: "sessionID must be an string" });
867 |
868 | const postid = decodeURIComponent(req.query.postid);
869 |
870 | if (!postid) return res.status(400).json({ message: "postid is required" });
871 | if (typeof postid !== "string")
872 | return res.status(400).json({ message: "postid must be an string" });
873 |
874 | const user = await UserSchema.findOne({ ss: sessionID });
875 | if (!user) return res.status(403).json({ message: "Invalid SessionID" });
876 |
877 | const bookmark = await BookmarkSchema.exists({
878 | postid: postid,
879 | userdid: user.d,
880 | enabled: true,
881 | });
882 | res.json({ exists: bookmark });
883 | });
884 |
885 | app.get("/api/users/:userdid/bookmarks", async (req, res) => {
886 | const tokenstring = req.headers.authorization;
887 | if (!tokenstring)
888 | return res.status(401).json({ message: "Token is required" });
889 |
890 | const token = await TokenSchema.findOne({ token: tokenstring });
891 | if (!token) return res.status(401).json({ message: "Unauthorized" });
892 |
893 | if (!token.permissions.includes("*")) {
894 | if (!token.permissions.includes("bookmarks.view"))
895 | //used in feed integration
896 | return res.status(403).json({ message: "Missing permissions" });
897 | }
898 |
899 | const user = await UserSchema.findOne({ d: req.params.userdid });
900 | if (!user) return res.status(404).json({ message: "User not found" });
901 |
902 | const bookmarks = await BookmarkSchema.find({
903 | userdid: user.d,
904 | enabled: true,
905 | });
906 | res.json(
907 | bookmarks.map((bk) => {
908 | return { post: bk.postaturi };
909 | }),
910 | );
911 | });
912 |
913 | let hasSendSomeTrending = false;
914 |
915 | app.get("/api/trends", (req, res) => {
916 | let language = req.query.lang;
917 |
918 | //while the extension is not updated for everyone
919 | // if (!language) return res.status(400).json({ message: "lang query is required (pt, en, ja, es, fr, global, all)" })
920 | if (!language) language = "pt";
921 |
922 | if (!["pt", "en", "ja", "es", "fr", "global", "all"].includes(language))
923 | return res.status(400).json({
924 | message:
925 | "lang query must be 'pt', 'en', 'ja', 'es', 'fr', 'global', 'all'",
926 | });
927 |
928 | if (language === "all") {
929 | res.json(cache.trending);
930 | } else {
931 | res.json(cache.trending[language]);
932 | }
933 |
934 | if (req.query.sessionID)
935 | cache.stats.last30sSessions.set(req.query.sessionID, language);
936 |
937 | //Gambiarra gigante para reinciar o app quando houver o erro misterioso de começar a retornar array vazia nos trends (Me ajude e achar!)
938 | if (cache.trending.pt.data.length > 0) hasSendSomeTrending = true;
939 | if (hasSendSomeTrending && cache.trending.pt.data.length === 0)
940 | process.exit(1);
941 | });
942 |
943 | app.get("/api/blacklist", async (req, res) => {
944 | const tokenstring = req.headers.authorization;
945 | if (!tokenstring)
946 | return res.status(401).json({ message: "Token is required" });
947 |
948 | const token = await TokenSchema.findOne({ token: tokenstring });
949 | if (!token) return res.status(401).json({ message: "Unauthorized" });
950 |
951 | if (!token.permissions.includes("*")) {
952 | if (!token.permissions.includes("blacklist.manage"))
953 | return res.status(403).json({ message: "Missing permissions" });
954 | }
955 |
956 | console.log(`${Date.now()} /api/blacklist`);
957 | const settings = await SettingsSchema.findOne({});
958 |
959 | return res.json(settings.blacklist);
960 | });
961 |
962 | // redundância enquanto a extensão não atualiza para todos
963 |
964 | app.post("/api/users", async (req, res) => {
965 | try {
966 | const sessionID = req.query.sessionID;
967 |
968 | if (!sessionID)
969 | return res.status(400).json({ message: "sessionID is required" });
970 | if (typeof sessionID !== "string")
971 | return res.status(400).json({ message: "sessionID must be an string" });
972 |
973 | const handle = req.query.handle;
974 |
975 | if (!handle) return res.status(400).json({ message: "handle is required" });
976 | if (typeof handle !== "string")
977 | return res.status(400).json({ message: "handle must be an string" });
978 |
979 | const did = req.query.did;
980 |
981 | if (!did) return res.status(400).json({ message: "did is required" });
982 | if (typeof did !== "string")
983 | return res.status(400).json({ message: "did must be an string" });
984 |
985 | const existUser = await UserSchema.findOne({ h: handle, d: did });
986 |
987 | if (existUser) {
988 | existUser.ll = Date.now();
989 | existUser.s = sessionID;
990 | existUser.ss.push(sessionID);
991 | await existUser.save();
992 | return res.json({ message: "updated" });
993 | }
994 |
995 | await UserSchema.create({
996 | //salva os usuários que utilizam a extensão para futuras atualizações
997 | h: handle,
998 | d: did,
999 | s: sessionID,
1000 | ss: [sessionID],
1001 | });
1002 |
1003 | return res.json({ message: "created" });
1004 | } catch (e) {
1005 | console.log(e);
1006 | res.status(500).json({ message: "Internal Server Error" });
1007 | }
1008 | });
1009 |
1010 | app.get("/api/trendsmessages", async (req, res) => {
1011 | console.log(`${Date.now()} /api/trendsmessages`);
1012 | const settings = await SettingsSchema.findOne({});
1013 |
1014 | return res.json(settings.trendsMessages);
1015 | });
1016 |
1017 | app.get("/api/stats", async (req, res) => {
1018 | const userscount = await UserSchema.countDocuments({});
1019 | return res.json({
1020 | last30sonline: cache.stats.last30sSessionsCountStore,
1021 | userscount,
1022 | });
1023 | });
1024 |
1025 | app.put("/api/admin/trendsmessages", async (req, res) => {
1026 | const tokenstring = req.headers.authorization;
1027 | if (!tokenstring)
1028 | return res.status(401).json({ message: "Token is required" });
1029 |
1030 | const token = await TokenSchema.findOne({ token: tokenstring });
1031 | if (!token) return res.status(401).json({ message: "Unauthorized" });
1032 |
1033 | if (!token.permissions.includes("*")) {
1034 | if (!token.permissions.includes("trendsmessages.manage"))
1035 | return res.status(403).json({ message: "Missing permissions" });
1036 | }
1037 | const settings = await SettingsSchema.findOne({});
1038 |
1039 | try {
1040 | const trendsmessages = JSON.parse(
1041 | decodeURIComponent(req.query.trendsmessages),
1042 | );
1043 | settings.trendsMessages = trendsmessages;
1044 | await settings.save();
1045 | return res.json(settings.trendsMessages);
1046 | } catch (e) {
1047 | console.log(e);
1048 | res.status(500).json({ message: "Internal Server Error" });
1049 | }
1050 | });
1051 |
1052 | // xrpc
1053 | app.get("/xrpc/app.bsky.feed.getFeedSkeleton", async (req, res) => {
1054 | console.log("[Feed]", req.query);
1055 | try {
1056 | if (
1057 | req.query.feed ===
1058 | "at://did:plc:xy3lxva6bqrph3avrvhzck7q/app.bsky.feed.generator/bookmarks"
1059 | ) {
1060 | if (!req.headers.authorization)
1061 | return res.json({
1062 | cursor: `${Date.now()}_${randomString(5, false)}`,
1063 | feed: [
1064 | {
1065 | post: cache.settings.config.defaultBookmarksPost,
1066 | },
1067 | ], //No bookmarks post
1068 | });
1069 |
1070 | // const authorization = verifyJWT(req.headers.authorization.replace('Bearer ', '').trim(), process.env.FEED_KEY);
1071 |
1072 | //TEMP
1073 | const authorization = {
1074 | error: false,
1075 | data: JSON.parse(atob(req.headers.authorization.split(".")[1])),
1076 | };
1077 | //---------------------
1078 |
1079 | if (authorization.error)
1080 | return res.json({
1081 | cursor: `${Date.now()}_${randomString(5, false)}`,
1082 | feed: [
1083 | {
1084 | post: cache.settings.config.defaultBookmarksPost,
1085 | },
1086 | ], //No bookmarks post
1087 | });
1088 |
1089 | const user = await UserSchema.findOne({
1090 | d: String(authorization.data.iss),
1091 | });
1092 | if (!user)
1093 | return res.json({
1094 | cursor: `${Date.now()}_${randomString(5, false)}`,
1095 | feed: [
1096 | {
1097 | post: cache.settings.config.defaultBookmarksPost,
1098 | },
1099 | ], //No bookmarks post
1100 | });
1101 |
1102 | const bookmarks = await BookmarkSchema.find({
1103 | userdid: user.d,
1104 | enabled: true,
1105 | });
1106 |
1107 | if (bookmarks.length === 0) {
1108 | return res.json({
1109 | cursor: `${Date.now()}_${randomString(5, false)}`,
1110 | feed: [
1111 | {
1112 | post: cache.settings.config.defaultBookmarksPost,
1113 | },
1114 | ], //No bookmarks post
1115 | });
1116 | }
1117 |
1118 | return res.json({
1119 | cursor: `${Date.now()}_${randomString(5, false)}`,
1120 | feed: bookmarks
1121 | .map((bookmark) => {
1122 | return { post: bookmark.postaturi };
1123 | })
1124 | .reverse(),
1125 | });
1126 | }
1127 |
1128 | return res.status(404).json({ message: "Feed not found" });
1129 | } catch (e) {
1130 | res.status(500).json({ message: "Internal Server Error" });
1131 | }
1132 | });
1133 |
1134 | app.get("/xrpc/app.bsky.feed.describeFeedGenerator", (req, res) => {
1135 | res.json({
1136 | did: "did:web:betterbluesky.nemtudo.me",
1137 | feeds: [
1138 | {
1139 | uri: "at://did:plc:xy3lxva6bqrph3avrvhzck7q/app.bsky.feed/bookmarks",
1140 | title: "Itens Salvos",
1141 | description:
1142 | "Itens salvos da extensão BetterBluesky. Instale: https://nemtudo.me/betterbluesky",
1143 | author: "nemtudo.me",
1144 | },
1145 | ],
1146 | });
1147 | });
1148 |
1149 | app.get("/.well-known/did.json", (req, res) => {
1150 | return res.json({
1151 | "@context": ["https://www.w3.org/ns/did/v1"],
1152 | id: "did:web:betterbluesky.nemtudo.me",
1153 | service: [
1154 | {
1155 | id: "#bsky_fg",
1156 | type: "BskyFeedGenerator",
1157 | serviceEndpoint: "https://betterbluesky.nemtudo.me",
1158 | },
1159 | ],
1160 | });
1161 | });
1162 |
1163 | // static files
1164 | app.get("/privacy-policy", (req, res) => {
1165 | res.sendFile(`${__dirname}/public/privacy-policy.html`);
1166 | });
1167 |
1168 | //general
1169 |
1170 | app.get("*", (req, res) => {
1171 | res.status(404).send({ message: "Route not found" });
1172 | });
1173 |
1174 | app.post("*", (req, res) => {
1175 | res.status(404).send({ message: "Route not found" });
1176 | });
1177 |
1178 | app.listen(process.env.PORT, () => {
1179 | console.log(`Aplicativo iniciado em ${process.env.PORT}`);
1180 | updateCacheSettings();
1181 | // deleteOlds(3)
1182 | });
1183 |
1184 | function verifyJWT(token, key) {
1185 | try {
1186 | const decoded = jwt.verify(token, key, { algorithms: ["ES256K"] });
1187 | return {
1188 | error: false,
1189 | data: decoded,
1190 | };
1191 | } catch (err) {
1192 | return {
1193 | error: true,
1194 | err: err,
1195 | };
1196 | }
1197 | }
1198 |
1199 | async function getTrending(hourlimit, recentlimit, languages) {
1200 | const hourwords = await getTrendingType(
1201 | hourlimit,
1202 | "w",
1203 | 1.5 * 60 * 60 * 1000,
1204 | languages,
1205 | );
1206 | const hourhashtags = await getTrendingType(
1207 | hourlimit,
1208 | "h",
1209 | 1.5 * 60 * 60 * 1000,
1210 | languages,
1211 | );
1212 |
1213 | const recentwords = await getTrendingType(10, "w", 10 * 60 * 1000, languages);
1214 | const recenthashtags = await getTrendingType(
1215 | 10,
1216 | "h",
1217 | 10 * 60 * 1000,
1218 | languages,
1219 | );
1220 |
1221 | const _hourtrends = mergeArray(hourhashtags, hourwords);
1222 | const _recenttrends = mergeArray(recenthashtags, recentwords);
1223 |
1224 | const hourtrends = removeDuplicatedTrends(_hourtrends).slice(0, hourlimit);
1225 | const recenttrends = removeDuplicatedTrends(_recenttrends)
1226 | .filter(
1227 | (rt) =>
1228 | !hourtrends.find((t) => t.text.toLowerCase() === rt.text.toLowerCase()),
1229 | )
1230 | .slice(0, recentlimit);
1231 |
1232 | const trends = removeDuplicatedTrends([...hourtrends, ...recenttrends]);
1233 |
1234 | for (const trend of trends) {
1235 | if (
1236 | cache.settings.trendsMessages.find(
1237 | (t) => t.word.toLowerCase() === trend.text.toLowerCase(),
1238 | )
1239 | ) {
1240 | trend.message = cache.settings.trendsMessages.find(
1241 | (t) => t.word.toLowerCase() === trend.text.toLowerCase(),
1242 | ).message;
1243 | }
1244 | }
1245 |
1246 | if (cache.settings.pinWord.enabled) {
1247 | trends.splice(cache.settings.pinWord.position, 0, {
1248 | text: cache.settings.pinWord.word,
1249 | count: cache.settings.pinWord.count,
1250 | timefilter: 0,
1251 | message: cache.settings.pinWord.message,
1252 | });
1253 | console.log(
1254 | `PINNED WORD: [${cache.settings.pinWord.position}] ${cache.settings.pinWord.word} (${cache.settings.pinWord.count})`,
1255 | );
1256 | }
1257 |
1258 | return trends;
1259 | }
1260 |
1261 | async function getTrendingType(limit, type, time, languages = []) {
1262 | try {
1263 | const hoursAgo = new Date(Date.now() - time); // Data e hora de x horas atrás
1264 |
1265 | const result = await WordSchema.aggregate([
1266 | {
1267 | $match: {
1268 | ca: { $gte: hoursAgo }, // Filtra documentos criados nos últimos x horas
1269 | ty: type, //apenas do tipo
1270 | l: languages.length > 0 ? { $in: languages } : { $exists: true }, //l inclui algum da array languages ou global
1271 | },
1272 | },
1273 | {
1274 | $group: {
1275 | _id: "$t", // Agrupar por palavra
1276 | count: { $sum: 1 }, // Contar o número de ocorrências
1277 | },
1278 | },
1279 | {
1280 | $sort: { count: -1 }, // Ordenar por contagem em ordem decrescente
1281 | },
1282 | {
1283 | $limit: limit + 9, // Limitar o resultado para as palavra mais frequente
1284 | },
1285 | ]);
1286 |
1287 | return result
1288 | .filter((obj) => {
1289 | let langBlacklist = cache.settings.blacklist.global;
1290 | if (languages.length === 1) {
1291 | langBlacklist = cache.settings.blacklist[languages[0]];
1292 | }
1293 | return (
1294 | !langBlacklist.trends
1295 | .map((t) => t.toLowerCase())
1296 | .includes(obj._id.toLowerCase()) &&
1297 | !langBlacklist.words.find((word) =>
1298 | obj._id.toLowerCase().includes(word.toLowerCase()),
1299 | )
1300 | );
1301 | })
1302 | .map((obj) => {
1303 | return { text: obj._id, count: obj.count, timefilter: time };
1304 | })
1305 | .slice(0, limit);
1306 | } catch (e) {
1307 | console.log("getTrending", e);
1308 | }
1309 | }
1310 |
1311 | function removeDuplicatedTrends(trends) {
1312 | const wordMap = new Map();
1313 |
1314 | trends.forEach(({ text, count, timefilter }) => {
1315 | const lowerCaseText = text
1316 | .toLowerCase()
1317 | .normalize("NFD")
1318 | .replace(/\p{Diacritic}/gu, "");
1319 |
1320 | if (wordMap.has(lowerCaseText)) {
1321 | wordMap.set(lowerCaseText, {
1322 | text: wordMap.get(lowerCaseText).text,
1323 | count: wordMap.get(lowerCaseText).count + count,
1324 | timefilter: wordMap.get(lowerCaseText).timefilter,
1325 | });
1326 | } else {
1327 | wordMap.set(lowerCaseText, { text, count, timefilter });
1328 | }
1329 | });
1330 |
1331 | return Array.from(wordMap.values());
1332 | }
1333 |
1334 | function mergeArray(arrayA, arrayB) {
1335 | const result = [];
1336 | const maxLength = Math.max(arrayA.length, arrayB.length);
1337 |
1338 | for (let i = 0; i < maxLength; i++) {
1339 | if (i < arrayA.length) {
1340 | result.push(arrayA[i]);
1341 | }
1342 | if (i < arrayB.length) {
1343 | result.push(arrayB[i]);
1344 | }
1345 | }
1346 |
1347 | return result;
1348 | }
1349 |
1350 | function randomString(length, uppercases = true) {
1351 | let result = "";
1352 | const characters = uppercases
1353 | ? "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"
1354 | : "abcdefghijklmnopqrstuvwxyz0123456789";
1355 | const charactersLength = characters.length;
1356 | let counter = 0;
1357 | while (counter < length) {
1358 | result += characters.charAt(Math.floor(Math.random() * charactersLength));
1359 | counter += 1;
1360 | }
1361 | return result;
1362 | }
1363 |
1364 | function extractPostIdAndDid(uri) {
1365 | const regex = /^at:\/\/did:(plc:[\w\d]+)\/app\.bsky\.feed\.post\/([\w\d]+)/;
1366 | const match = uri.match(regex);
1367 |
1368 | if (match) {
1369 | return {
1370 | error: false,
1371 | data: {
1372 | did: match[1],
1373 | postid: match[2],
1374 | },
1375 | };
1376 | }
1377 | return {
1378 | error: true,
1379 | };
1380 | }
1381 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "blueskytrends",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "app.js",
6 | "scripts": {
7 | "test": "echo \"Error: no test specified\" && exit 1"
8 | },
9 | "keywords": [],
10 | "author": "",
11 | "license": "ISC",
12 | "dependencies": {
13 | "atproto-firehose": "^0.2.2",
14 | "cors": "^2.8.5",
15 | "dotenv": "^16.4.5",
16 | "express": "^4.19.2",
17 | "jsonwebtoken": "^9.0.2",
18 | "mongoose": "^8.6.0"
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/public/privacy-policy.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Privacy Policy
8 |
9 |
10 |
11 | BetterBluesky Privacy Policy
12 | Last updated: 09/15/2024
13 |
14 | Information Collection and Use
15 | We do not collect health, financial and payment information, authentication, personal communications, location,
16 | network
17 | history,
18 | user activity or website content information.
19 | What we collect:
20 |
21 | - Bluesky ID (did), unique and public ID of each user
22 | - Bluesky handle (@domain), unique name that links to your profile.
23 |
24 |
25 | Data Usage
26 |
27 | - Personally Identifiable Information: We collect your Bluesky ID and username, which are used in "bookmarks",
28 | "polls" and statistics.
29 | - Health Information: We do not collect any health information.
30 | - Financial and Payment Information: We do not collect any financial information.
31 | - Authentication Information: We do not collect any authentication information.
32 | - Personal Communications: We do not collect any personal communications.
33 | - Location: We do not collect any location information.
34 | - Web History: We do not collect any web history information.
35 | - User Activity: We do not collect any user activity data.
36 | - Website Content: We do not collect any content from the websites you visit.
37 |
38 |
39 | Data Transfer and Sale
40 | We follow these standards to ensure the protection of your privacy:
41 |
42 | 1. We do not sell or transfer user data to third parties, outside of the approved use cases.
43 | 2. We do not use or transfer user data for purposes that are unrelated to the item's single
44 | purpose.
45 |
46 |
47 | Changes to This Privacy Policy
48 | We may update our Privacy Policy from time to time. Thus, we advise you to review this page
49 | periodically for any changes. We will notify you of any changes by posting the new Privacy
50 | Policy on this page.
51 | Open Source
52 | The extension project is entirely open source. You can view the backend and frontend by clicking on the links
53 | below:
54 |
58 |
59 |
60 |
--------------------------------------------------------------------------------