mirror of
https://github.com/zyachel/quetre.git
synced 2025-04-04 05:27:36 +03:00
Compare commits
62 commits
Author | SHA1 | Date | |
---|---|---|---|
|
2796cd8420 | ||
|
3b6ad9691b | ||
|
53c929467a | ||
|
f258719c97 | ||
|
e1601c1ca3 | ||
|
5dd94140a4 | ||
|
d7d99ccdb1 | ||
|
28d1a30fcd | ||
|
47733fa2c3 | ||
|
e22ae77c11 | ||
|
75f899f7fc | ||
|
e58fd34df0 | ||
|
1773b9d916 | ||
|
15c2a0c07d | ||
|
5b6a32c7ba | ||
|
645658b291 | ||
|
6fe64de256 | ||
|
0cd2fc24e3 | ||
|
248e6209dc | ||
|
304fecd927 | ||
|
c5dff2a617 | ||
|
69f464d7f7 | ||
|
1c4acac99f | ||
|
169ceb86ea | ||
|
f49062d44a | ||
|
a0ac36a174 | ||
|
f11d3f2ac6 | ||
|
1073e61530 | ||
|
8e94ecbefc | ||
|
b369bd31cd | ||
|
81eb347c5b | ||
|
339cbbecd4 | ||
|
5410574594 | ||
|
dcd21c4664 | ||
|
da85a6f2f9 | ||
|
a25c5bfa24 | ||
|
ac1007bc61 | ||
|
3889185c4d | ||
|
6fb2ff4c71 | ||
|
7547f54d70 | ||
|
c6c4828422 | ||
|
4cd551438b | ||
|
f77a1b15bf | ||
|
74c64c00d0 | ||
|
2320c6c4d5 | ||
|
fff9f9b1aa | ||
|
1e0b0df79d | ||
|
b409a5cf72 | ||
|
fd7da0cba5 | ||
|
a85e3dd44d | ||
|
1ec97a9eb3 | ||
|
a951cd8456 | ||
|
50592d6afb | ||
|
886c3c92ad | ||
|
972579dbdf | ||
|
17ba1dbf08 | ||
|
b40cc2557d | ||
|
10eea08305 | ||
|
35a0d87c13 | ||
|
b8a0b9fcad | ||
|
0ebaec7065 | ||
|
a6f7294e97 |
32 changed files with 1336 additions and 1401 deletions
47
CHANGELOG.md
47
CHANGELOG.md
|
@ -2,6 +2,53 @@
|
|||
|
||||
All notable changes to this project will be documented in this file. See [commit-and-tag-version](https://github.com/absolute-version/commit-and-tag-version) for commit guidelines.
|
||||
|
||||
## [8.0.0](https://github.com/zyachel/quetre/compare/v7.1.0...v8.0.0) (2024-04-07)
|
||||
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* **search:** any search request will be responded with a 410
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **parse:** don't bail out on encountering weird characters ([f11d3f2](https://github.com/zyachel/quetre/commit/f11d3f2ac641bce64c5a674c0995cf85ffc7e37e))
|
||||
* remove stats from UI that aren't available anymore ([a0ac36a](https://github.com/zyachel/quetre/commit/a0ac36a174849a466c6b63ff65161e4627f1a56b))
|
||||
* **search:** remove broken search route ([f49062d](https://github.com/zyachel/quetre/commit/f49062d44ac04c77c3f064e703f40a8e114b5776))
|
||||
|
||||
## [7.1.0](https://github.com/zyachel/quetre/compare/v7.0.0...v7.1.0) (2023-11-05)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add docker support with redis([#118](https://github.com/zyachel/quetre/issues/118)) ([2320c6c](https://github.com/zyachel/quetre/commit/2320c6c4d5edc77f8c84f3a23fb1b807e7747325))
|
||||
* **answers:** mark related questions that are unanswered ([f77a1b1](https://github.com/zyachel/quetre/commit/f77a1b15bfbbf395f47c107356942fc5d7186a45))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **fetcher:** fix answers route crashing in case there aren't any answers ([c6c4828](https://github.com/zyachel/quetre/commit/c6c4828422f3349cab72b76fd32448f1b5ab9b7e))
|
||||
* fix a typo while modifying url ([7547f54](https://github.com/zyachel/quetre/commit/7547f54d7016a4b9f64b712522c92b8bd6f8ccaf))
|
||||
* **search:** show search query along with results ([4cd5514](https://github.com/zyachel/quetre/commit/4cd551438b288841e41efea5e95cae02f1c27076))
|
||||
|
||||
## [7.0.0](https://github.com/zyachel/quetre/compare/v6.0.0...v7.0.0) (2023-04-23)
|
||||
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* **answers:** old fetcher may or may not work
|
||||
|
||||
fix: https://github.com/zyachel/quetre/issues/101
|
||||
|
||||
### Features
|
||||
|
||||
* **cache:** increase ttl for routes that are cached but being accessed again ([10eea08](https://github.com/zyachel/quetre/commit/10eea0830511d0e914a255a05904ab18265e46e6))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **answers:** fix answers route crashing ([b8a0b9f](https://github.com/zyachel/quetre/commit/b8a0b9fcadd1d3c797b0023ff91b2221b9072298))
|
||||
* fix flash of inaccurate color theme ([35a0d87](https://github.com/zyachel/quetre/commit/35a0d87c133ccda9242d319afa63a2c17a6df973))
|
||||
|
||||
## [6.0.0](https://github.com/zyachel/quetre/compare/v5.6.0...v6.0.0) (2023-03-04)
|
||||
|
||||
|
||||
|
|
11
Dockerfile
Normal file
11
Dockerfile
Normal file
|
@ -0,0 +1,11 @@
|
|||
FROM node:alpine3.17
|
||||
WORKDIR /app
|
||||
|
||||
RUN apk update && apk add git
|
||||
RUN git clone https://github.com/zyachel/quetre .
|
||||
RUN npm i -g pnpm
|
||||
RUN pnpm install
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["pnpm", "start"]
|
76
README.md
76
README.md
|
@ -46,32 +46,29 @@ It enables you to see answers without ads, trackers, and other such bloat.
|
|||
## Instances
|
||||
|
||||
<!-- prettier-ignore -->
|
||||
| Instance | Region | Provider | Notes |
|
||||
| -------- | ------ | -------- | ----- |
|
||||
| 1. Clearnet | | | |
|
||||
| [quetre.iket.me](https://quetre.iket.me) | Canada | OVHCloud | Official instance |
|
||||
| [quora.vern.cc](https://qr.vern.cc) | US | Hetzner | Operated by [~vern](https://vern.cc/) |
|
||||
| [quetre.pussthecat.org](https://quetre.pussthecat.org) | Germany | – | Operated by [PussTheCat.org](https://pussthecat.org/) |
|
||||
| [quetre.tokhmi.xyz](https://quetre.tokhmi.xyz/) | U.S. | Oracle | Operated by [Tokhmi](https://tokhmi.xyz) |
|
||||
| [quetre.projectsegfau.lt](https://quetre.projectsegfau.lt) | Europe | BuyVM | Operated by [Project Segfault](https://projectsegfau.lt) |
|
||||
| [quetre.esmailelbob.xyz](https://quetre.esmailelbob.xyz) | Canada | OVHCloud | Operated by [Esmail EL BoB](https://esmailelbob.xyz/) |
|
||||
| [quetre.odyssey346.dev](https://quetre.odyssey346.dev) | Poland | OVHCloud | Operated by [Odyssey346](https://odyssey346.dev/) |
|
||||
| [quetre.privacydev.net](https://quetre.privacydev.net) | U.S. | BuyVM | Operated by [PrivacyDev](https://privacydev.net/) |
|
||||
| [ask.habedieeh.re](https://ask.habedieeh.re) | Canada | Oracle | Operated by [habedieeh.re](https://www.habedieeh.re) |
|
||||
| [quetre.marcopisco.com](https://quetre.marcopisco.com) | Portugal | Vodafone Portugal (Cloudflare) | Operated by [marcopisco.com](https://www.marcopisco.com) |
|
||||
| [quetre.blackdrgn.nl](https://quetre.blackdrgn.nl) | Germany | Contabo | Operated by [blackdrgn.nl](https://blackdrgn.nl) |
|
||||
| [quetre.pufe.org](https://quetre.pufe.org) | New Zealand | - | Operated by pufe.org |
|
||||
| [quetre.lunar.icu](https://quetre.lunar.icu) | Germany | Cloudflare | Operated by [lunar.icu](https://lunar.icu/) |
|
||||
| [que.wilbvr.me](https://que.wilbvr.me) | Netherlands | Liga Hosting | Operated by [Wilbvr](https://wilbvr.me) |
|
||||
| [quora.femboy.hu](https://quora.femboy.hu) | Hungary | N/A (Self-hosted) | Operated by [hnhx](https://femboy.hu) |
|
||||
| 2. Onion | | | |
|
||||
| [quetre.esmail5pdn24shtvieloeedh7ehz3nrwcdivnfhfcedl7gf4kwddhkqd.onion](http://quetre.esmail5pdn24shtvieloeedh7ehz3nrwcdivnfhfcedl7gf4kwddhkqd.onion) | Canada | OVHCloud | Operated by [Esmail EL BoB](https://esmailelbob.xyz/) |
|
||||
| [qr.vernccvbvyi5qhfzyqengccj7lkove6bjot2xhh5kajhwvidqafczrad.onion](http://qr.vernccvbvyi5qhfzyqengccj7lkove6bjot2xhh5kajhwvidqafczrad.onion/) | US | Hetzner | Operated by [~vern](https://vern.cc) |
|
||||
| [ask.habeehrhadazsw3izbrbilqajalfyqqln54mrja3iwpqxgcuxnus7eid.onion](http://ask.habeehrhadazsw3izbrbilqajalfyqqln54mrja3iwpqxgcuxnus7eid.onion/) | Canada | Oracle | Operated by [habedieeh.re](https://www.habedieeh.re) |
|
||||
| [quetre.g4c3eya4clenolymqbpgwz3q3tawoxw56yhzk4vugqrl6dtu3ejvhjid.onion](http://quetre.g4c3eya4clenolymqbpgwz3q3tawoxw56yhzk4vugqrl6dtu3ejvhjid.onion/) | U.S. | BuyVM | Operated by [PrivacyDev](https://privacydev.net/) |
|
||||
| [quora.cepyxplublbyw2f4axy4pyztfbxmf63lrt2c7uwv6wl4iixz53czload.onion](http://quora.cepyxplublbyw2f4axy4pyztfbxmf63lrt2c7uwv6wl4iixz53czload.onion) | Hungary | N/A (Self-hosted) | Operated by [hnhx](https://femboy.hu) |
|
||||
| 3. I2P | | | |
|
||||
| [qr.vern.i2p/](http://vernnflenvsqccuanaun7yydnmturi4jkyxlyzhn6ultpje66c3q.b32.i2p/) | US | Hetzner | Operated by [~vern](https://vern.cc) |
|
||||
| Instance | Tor | I2P | Region | Provider | Notes |
|
||||
| ----------------------------------------- | ------------------------------------------------------------------------------------ | --------------------------------------------------------------------------- | ------- | -------------------- | --------------------------------------------------------------------------- |
|
||||
| <https://quetre.iket.me/> | No | No | CA | OVHCloud | Official instance |
|
||||
| <https://qr.vern.cc/> | [Yes](http://qr.vernccvbvyi5qhfzyqengccj7lkove6bjot2xhh5kajhwvidqafczrad.onion/) | [Yes](http://vernnflenvsqccuanaun7yydnmturi4jkyxlyzhn6ultpje66c3q.b32.i2p/) | US | Hetzner | Operated by [~vern](https://vern.cc/) |
|
||||
| <https://quetre.pussthecat.org/> | No | No | DE | – | Operated by [PussTheCat.org](https://pussthecat.org/) |
|
||||
| <https://quetre.tokhmi.xyz/> | No | No | US | Oracle | Operated by [Tokhmi](https://tokhmi.xyz/) |
|
||||
| <https://quetre.privacydev.net/> | [Yes](http://quetre.g4c3eya4clenolymqbpgwz3q3tawoxw56yhzk4vugqrl6dtu3ejvhjid.onion/) | No | FR | Clovux | Operated by [PrivacyDev](https://privacydev.net/) |
|
||||
| <https://ask.habedieeh.re/> | [Yes](http://ask.habeehrhadazsw3izbrbilqajalfyqqln54mrja3iwpqxgcuxnus7eid.onion/) | No | CA | Oracle | Operated by [habedieeh.re](https://www.habedieeh.re/) |
|
||||
| <https://quetre.blackdrgn.nl/> | No | No | DE | Contabo | Operated by [blackdrgn.nl](https://blackdrgn.nl/) |
|
||||
| <https://quetre.lunar.icu/> | No | No | DE | Cloudflare | Operated by [lunar.icu](https://lunar.icu/) |
|
||||
| <https://q.opnxng.com/> | No | No | SG | Vultr | Operated by [Opnxng](https://about.opnxng.com/) |
|
||||
| <https://ask.sudovanilla.org/> | No | No | US | N/A (Self-hosted) | Operated by [SudoVanilla](https://sudovanilla.org/) |
|
||||
| <https://quetre.drgns.space/> | No | No | US | N/A (Self-hosted) | Operated with ❤️ from [drgns.space](https://drgns.space/) |
|
||||
| <https://quetre.r4fo.com/> | No | No | NL | Oracle | Operated by [r4fo](https://r4fo.com/) |
|
||||
| <https://quetre.ducks.party/> | No | No | NL | Timeweb | Operated by [ducks.party](https://ducks.party/) |
|
||||
| <https://quetre.nadeko.net/> | [Yes](http://quetre.nadekobxalvyqrhvp3m2atfgdmzp5vcwdmu3wo4htecwjkodancfmgid.onion) | No | CL | Oracle | Operated by [Fijxu](https://nadeko.net) |
|
||||
| <https://quetre.private.coffee/> | [Yes](http://quetre.coffee2m3bjsrrqqycx6ghkxrnejl2q6nl7pjw2j4clchjj6uk5zozad.onion) | No | AT | Alwyzon | Operated by [Private.coffee](https://private.coffee) |
|
||||
| <https://quetre.canine.tools/> | No | No | US | RoyaleHosting | Operated by [canine.tools](https://canine.tools/) |
|
||||
| <https://qt.bloat.cat/> | No | No | DE | Datalix | Operated by [bloat.cat](https://bloat.cat/) |
|
||||
| <https://quetre.gitro.xyz> | No | No | DE | Hetzner | Operated by [Gitro](https://gitro.xyz) |
|
||||
| <https://quetre.jeikobu.net> | No | No | DE | Hetzner (Cloudflare) | Operated by [Shindou Jeikobu](https://jeikobu.net) |
|
||||
|
||||
Instances list in JSON format can be found in [instances.json](instances.json) file.
|
||||
|
||||
---
|
||||
|
||||
|
@ -187,7 +184,7 @@ From [their privacy policy](https://www.quora.com/about/privacy)
|
|||
|
||||
## To-Do
|
||||
|
||||
- [x] add missing routes like topics, profile, and search
|
||||
- [x] add missing routes like topics and profile
|
||||
- [x] use redis
|
||||
- [x] serve images and other assets from Quetre
|
||||
- [x] implement a better installation method
|
||||
|
@ -219,15 +216,15 @@ Quetre will start running at http://localhost:3000.
|
|||
|
||||
### Docker
|
||||
|
||||
There is a [docker image](https://github.com/PussTheCat-org/docker-quetre-quay) made by [@TheFrenchGhosty](https://github.com/TheFrenchGhosty) for [PussTheCat.org](https://pussthecat.org/)'s [instance](https://quetre.pussthecat.org/).
|
||||
There is a [docker image](https://github.com/PussTheCat-org/docker-quetre-quay) made by [@TheFrenchGhosty](https://github.com/TheFrenchGhosty) for [PussTheCat.org](https://pussthecat.org/)'s [instance](https://quetre.pussthecat.org/).
|
||||
If you want a leaner one, you can checkout [@video-prize-ranch](https://codeberg.org/video-prize-ranch)'s [docker image](https://codeberg.org/video-prize-ranch/-/packages/container/quetre/latest).
|
||||
|
||||
---
|
||||
|
||||
## Contributing
|
||||
|
||||
The development may seem slow as I don't have lots of free time. And whenever I do, it gets split between this service and [libremdb](https://github.com/zyachel/libremdb/).
|
||||
If you believe you can help furthering this project in any way(be it maintaining, fixing issues, or adding features), please [get in touch](#contact).
|
||||
The development may seem slow as I don't have lots of free time. And whenever I do, it gets split between this service and [libremdb](https://github.com/zyachel/libremdb/).
|
||||
If you believe you can help furthering this project in any way(be it maintaining, fixing issues, or adding features), please [get in touch](#contact).
|
||||
Regardless, any type of contribution is always welcome.
|
||||
|
||||
## Misc
|
||||
|
@ -236,7 +233,7 @@ Regardless, any type of contribution is always welcome.
|
|||
|
||||
Following extensions can be used to automatically redirect Quora URLs to Quetre:
|
||||
|
||||
- [redirector](https://github.com/einaregilsson/Redirector)
|
||||
- [redirector](https://github.com/einaregilsson/Redirector)
|
||||
You can manually add any redirect.
|
||||
Below is a basic config of Quora to Quetre. Replace `quetre.iket.me` in `Redirect to` to any instance of your choice.
|
||||
|
||||
|
@ -249,19 +246,24 @@ Following extensions can be used to automatically redirect Quora URLs to Quetre:
|
|||
Pattern description: redirects all Quora urls to Quetre
|
||||
```
|
||||
|
||||
This config should output:
|
||||
This config should output:
|
||||
`Example result: https://quetre.iket.me/redirect/https://www.quora.com/What-is-Linux-4?share=1`
|
||||
|
||||
- [LibRedirect](https://github.com/libredirect/libredirect/)
|
||||
- [LibRedirect](https://github.com/libredirect/libredirect/)
|
||||
Redirects many popular services to their alternative front-ends. Has a ton of features and an active community. Quetre is supported by default. So, no need to do anything.
|
||||
|
||||
- [Privacy Redirector](https://github.com/dybdeskarphet/privacy-redirector)
|
||||
- [Privacy Redirector](https://github.com/dybdeskarphet/privacy-redirector)
|
||||
A userscript that redirects popular social media platforms to their privacy respecting frontends.
|
||||
|
||||
- Other addons with similar functionality:
|
||||
|
||||
- [Dynamic Privacy Redirect](https://github.com/PrivacyDevel/DPR-addon)
|
||||
- [Alter](https://github.com/w3bdev1/alter)
|
||||
|
||||
- [Predirect](https://github.com/libreom/predirect), A modern, manifest v3 based extension that requires minimal permissions(even for embeds).
|
||||
|
||||
See [Predirect's Comparision table](https://github.com/libreom/predirect/blob/main/COMPARISON.md) for more.
|
||||
|
||||
### Other alternative front-ends
|
||||
|
||||
- [digitalblossom/alternative-frontends](https://github.com/digitalblossom/alternative-frontends): contains other alternative front-ends.
|
||||
|
@ -312,3 +314,9 @@ Send a message on [\[matrix\]](https://matrix.to/#/@ninal:matrix.org) or go old
|
|||
## License
|
||||
|
||||
Licensed under [GNU AGPLv3](./LICENSE).
|
||||
|
||||
---
|
||||
|
||||
## Disclaimer
|
||||
|
||||
*Quetre does not host any content. All content is from Quora. Quora is a tradmark of Quora Inc.*
|
||||
|
|
|
@ -7,9 +7,8 @@ import catchAsyncErrors from '../utils/catchAsyncErrors.js';
|
|||
import getAnswers from '../fetchers/getAnswers.js';
|
||||
import getTopic from '../fetchers/getTopic.js';
|
||||
import getProfile from '../fetchers/getProfile.js';
|
||||
import getSearch from '../fetchers/getSearch.js';
|
||||
import getOrSetCache from '../utils/getOrSetCache.js';
|
||||
import { answersKey, profileKey, searchKey, topicKey } from '../utils/cacheKeys.js';
|
||||
import { answersKey, profileKey, topicKey } from '../utils/cacheKeys.js';
|
||||
|
||||
////////////////////////////////////////////////////////
|
||||
// EXPORTS
|
||||
|
@ -18,7 +17,7 @@ export const about = (req, res, next) => {
|
|||
res.status(200).json({
|
||||
status: 'success',
|
||||
message: `make a request.
|
||||
available endpoints are: '/slug', '/unanswered/slug', '/topic/slug', '/profile/slug', '/search?q=query', /?q=query.`,
|
||||
available endpoints are: '/slug', '/unanswered/slug', '/topic/slug', '/profile/slug'`,
|
||||
});
|
||||
};
|
||||
|
||||
|
@ -55,21 +54,6 @@ export const profile = catchAsyncErrors(async (req, res, next) => {
|
|||
res.status(200).json({ status: 'success', data });
|
||||
});
|
||||
|
||||
export const search = catchAsyncErrors(async (req, res, next) => {
|
||||
const {
|
||||
urlObj,
|
||||
query: { lang },
|
||||
} = req;
|
||||
|
||||
const searchText = urlObj.searchParams.get('q')?.trim(); // no search to perform if there isn't any query
|
||||
let searchData = null;
|
||||
|
||||
if (searchText)
|
||||
searchData = await getOrSetCache(searchKey(urlObj), getSearch, urlObj.search, lang);
|
||||
|
||||
res.status(200).json({ status: 'success', data: searchData });
|
||||
});
|
||||
|
||||
export const unimplemented = (req, res, next) => {
|
||||
res.status(501).json({
|
||||
status: 'fail',
|
||||
|
@ -77,6 +61,14 @@ export const unimplemented = (req, res, next) => {
|
|||
});
|
||||
};
|
||||
|
||||
export const gone = (req, res, next) => {
|
||||
res.status(501).json({
|
||||
status: 'fail',
|
||||
message: "This route doesn't exist anymore.",
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
export const image = catchAsyncErrors(async (req, res, next) => {
|
||||
const { domain, path } = req.params;
|
||||
if (!domain.endsWith('quoracdn.net')) {
|
||||
|
|
|
@ -7,9 +7,8 @@ import getAnswers from '../fetchers/getAnswers.js';
|
|||
import getTopic from '../fetchers/getTopic.js';
|
||||
import { acceptedLanguages, nonSlugRoutes } from '../utils/constants.js';
|
||||
import getProfile from '../fetchers/getProfile.js';
|
||||
import getSearch from '../fetchers/getSearch.js';
|
||||
import getOrSetCache from '../utils/getOrSetCache.js';
|
||||
import { answersKey, profileKey, searchKey, topicKey } from '../utils/cacheKeys.js';
|
||||
import { answersKey, profileKey, topicKey } from '../utils/cacheKeys.js';
|
||||
|
||||
////////////////////////////////////////////////////////
|
||||
// EXPORTS
|
||||
|
@ -101,28 +100,6 @@ export const profile = catchAsyncErrors(async (req, res, next) => {
|
|||
});
|
||||
});
|
||||
|
||||
export const search = catchAsyncErrors(async (req, res, next) => {
|
||||
const {
|
||||
urlObj,
|
||||
query: { lang },
|
||||
} = req;
|
||||
const searchText = urlObj.searchParams.get('q')?.trim();
|
||||
let searchData = null;
|
||||
|
||||
if (searchText)
|
||||
searchData = await getOrSetCache(searchKey(urlObj), getSearch, urlObj.search, lang);
|
||||
|
||||
res.status(200).render('search', {
|
||||
data: searchData,
|
||||
meta: {
|
||||
title: searchText || 'Search',
|
||||
url: urlObj,
|
||||
imageUrl: `${urlObj.origin}/icon.svg`,
|
||||
description: searchText ? `results for '${searchText}'` : 'search page',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const regex = /^https:\/\/(.{2,})\.quora\.com(\/.*)$/; // local helper constant
|
||||
export const redirect = (req, res, next) => {
|
||||
const url = req.originalUrl.replace('/redirect/', ''); // removing `/redirect/` part.
|
||||
|
@ -158,3 +135,20 @@ export const unimplemented = (req, res, next) => {
|
|||
},
|
||||
});
|
||||
};
|
||||
|
||||
export const gone = (req, res, next) => {
|
||||
const data = {
|
||||
message: "This route doesn't exist anymore.",
|
||||
statusCode: 410,
|
||||
};
|
||||
|
||||
res.status(data.statusCode).render('error', {
|
||||
data,
|
||||
meta: {
|
||||
title: 'Gone',
|
||||
url: req.urlObj,
|
||||
imageUrl: `${req.urlObj.origin}/icon.svg`,
|
||||
description: data.message,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
|
20
docker-compose.yml
Normal file
20
docker-compose.yml
Normal file
|
@ -0,0 +1,20 @@
|
|||
version: '3'
|
||||
|
||||
services:
|
||||
quetre:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- "NODE_ENV=production"
|
||||
- "PORT=3000"
|
||||
- "CACHE_PERIOD=1h"
|
||||
- "REDIS_URL=redis:6379" # optional
|
||||
- "REDIS_TTL=3600"
|
||||
redis:
|
||||
image: docker.io/redis:alpine
|
||||
container_name: redis
|
||||
restart: unless-stopped
|
|
@ -5,6 +5,7 @@
|
|||
import * as cheerio from 'cheerio';
|
||||
import getAxiosInstance from '../utils/getAxiosInstance.js';
|
||||
import AppError from '../utils/AppError.js';
|
||||
import parse from '../utils/parse.js';
|
||||
|
||||
////////////////////////////////////////////////////////
|
||||
// FUNCTION
|
||||
|
@ -31,20 +32,20 @@ const answersFetcher = async (resourceStr, lang) => {
|
|||
if (!matches) return;
|
||||
|
||||
// brittle logic, but works
|
||||
const matchedPart = JSON.parse(JSON.parse(matches[1])).data;
|
||||
const matchedPart = JSON.parse(parse(matches[1])).data;
|
||||
|
||||
// only question block has this word
|
||||
if (typeof matchedPart.question?.viewerHasAnswered !== 'undefined') {
|
||||
rawData.question = matchedPart.question;
|
||||
|
||||
// primary answer block
|
||||
} else if (matchedPart.question?.answers?.edges) {
|
||||
} else if (matchedPart.question?.answers?.edges?.[0].node.answer?.content) {
|
||||
rawData.answers.push(matchedPart.question.answers.edges[0].node.answer);
|
||||
|
||||
// other answer blocks
|
||||
} else if (
|
||||
// eslint-disable-next-line no-underscore-dangle
|
||||
matchedPart.node?.__typename === 'QuestionRelevantAnswerItem2'
|
||||
matchedPart.node?.__typename === 'QuestionAnswerItem2'
|
||||
) {
|
||||
rawData.answers.push(matchedPart.node.answer);
|
||||
|
||||
|
|
|
@ -5,6 +5,7 @@
|
|||
import * as cheerio from 'cheerio';
|
||||
import getAxiosInstance from '../utils/getAxiosInstance.js';
|
||||
import AppError from '../utils/AppError.js';
|
||||
import parse from '../utils/parse.js';
|
||||
|
||||
////////////////////////////////////////////////////////
|
||||
// FUNCTION
|
||||
|
@ -18,19 +19,16 @@ import AppError from '../utils/AppError.js';
|
|||
* await fetcher('topic/Space-Physics'); // will return 'space physics' topic object
|
||||
* await fetcher('profile/Charlie-Cheever'); // will return object containing information about charlie cheever
|
||||
*/
|
||||
const fetcher = async (
|
||||
resourceStr,
|
||||
{ keyword, lang, toEncode = true }
|
||||
) => {
|
||||
const fetcher = async (resourceStr, { keyword, lang, toEncode = true }) => {
|
||||
try {
|
||||
// as url might contain unescaped chars. so, encoding it right away
|
||||
const str = toEncode ? encodeURIComponent(resourceStr) : resourceStr;
|
||||
const axiosInstance = getAxiosInstance(lang);
|
||||
const res = await axiosInstance.get(str);
|
||||
|
||||
|
||||
const $ = cheerio.load(res.data);
|
||||
|
||||
const regex = new RegExp(`"{\\\\"data\\\\":\\{\\\\"${keyword}.*\\}"`); // equivalent to /"\{\\"data\\":\{\\"searchConnection.*\}"/
|
||||
const regex = new RegExp(String.raw`"{\\"data\\":\{\\"${keyword}.*?\}"`);
|
||||
|
||||
let rawData;
|
||||
$('body script').each((i, el) => {
|
||||
|
@ -45,7 +43,7 @@ const fetcher = async (
|
|||
|
||||
if (!rawData) throw new AppError("couldn't retrieve data", 500);
|
||||
|
||||
return JSON.parse(rawData);
|
||||
return parse(rawData);
|
||||
} catch (err) {
|
||||
const statusCode = err.response?.status;
|
||||
if (statusCode === 404) throw new AppError('Not found', 404);
|
||||
|
|
|
@ -184,7 +184,6 @@ const getProfile = async (slug, lang) => {
|
|||
},
|
||||
spaces: {
|
||||
numActiveInSpaces: rawData.numCanContributeTribes,
|
||||
numFollowingSpaces: rawData.numFollowedTribes,
|
||||
spaces: rawData.followingTribesConnection.edges.map(space => ({
|
||||
numItems: space.node.numItemsOfUser,
|
||||
url: quetrefy(space.node.url),
|
||||
|
@ -194,7 +193,6 @@ const getProfile = async (slug, lang) => {
|
|||
})),
|
||||
},
|
||||
topics: {
|
||||
numFollowingTopics: rawData.numFollowedTopics,
|
||||
topics: rawData.expertiseTopicsConnection.edges.map(topic => ({
|
||||
name: topic.node.name,
|
||||
url: quetrefy(topic.node.url),
|
||||
|
|
|
@ -1,168 +0,0 @@
|
|||
////////////////////////////////////////////////////////
|
||||
// IMPORTS
|
||||
////////////////////////////////////////////////////////
|
||||
import AppError from '../utils/AppError.js';
|
||||
import fetcher from './fetcher.js';
|
||||
import { quetrefy } from '../utils/urlModifiers.js';
|
||||
|
||||
////////////////////////////////////////////////////////
|
||||
// HELPER FUNCTIONS
|
||||
////////////////////////////////////////////////////////
|
||||
const topicCleaner = topic => ({
|
||||
type: 'topic',
|
||||
url: quetrefy(topic.url),
|
||||
name: topic.name,
|
||||
numFollowers: topic.numFollowers,
|
||||
image: topic.photoUrl,
|
||||
isSensitive: topic.isSensitive,
|
||||
});
|
||||
const spaceCleaner = space => ({
|
||||
type: 'space',
|
||||
numUsers: space.tribeUserCount,
|
||||
url: quetrefy(space.url),
|
||||
name: space.nameString,
|
||||
description: space.descriptionString,
|
||||
image: space.iconRetinaUrl,
|
||||
isSensitive: space.isSensitive,
|
||||
});
|
||||
const profileCleaner = profile => ({
|
||||
type: 'profile',
|
||||
credential: profile.bestCredential?.translatedString,
|
||||
isAnon: profile.isAnon,
|
||||
name: `${profile.names[0]?.givenName} ${profile.names[0]?.familyName}`,
|
||||
url: quetrefy(profile.profileUrl),
|
||||
image: profile.profileImageUrl,
|
||||
numFollowers: profile.followerCount,
|
||||
isVerified: profile.isVerified,
|
||||
isBusiness: profile.businessStatus,
|
||||
isPlusUser: profile.consumerBundleActive,
|
||||
});
|
||||
const questionCleaner = question => ({
|
||||
type: 'question',
|
||||
text: JSON.parse(question.title).sections,
|
||||
url: quetrefy(question.url),
|
||||
isDeleted: question.isDeleted,
|
||||
numFollowers: question.followerCount,
|
||||
creationTime: question.creationTime,
|
||||
numComments: question.numDisplayComments,
|
||||
isSensitive: question.isSensitive,
|
||||
});
|
||||
const answerCleaner = ({ question, previewAnswer: answer }) => ({
|
||||
type: 'answer',
|
||||
question: {
|
||||
...questionCleaner(question),
|
||||
},
|
||||
...(answer.originalQuestionIfDifferent && {
|
||||
originalQuestion: {
|
||||
text: JSON.parse(answer.originalQuestionIfDifferent.question.title).sections,
|
||||
url: quetrefy(answer.originalQuestionIfDifferent.question.url),
|
||||
qid: answer.originalQuestionIfDifferent.question.qid,
|
||||
},
|
||||
}),
|
||||
isViewable: !!answer.viewerHasAccess,
|
||||
text: JSON.parse(answer.content).sections,
|
||||
creationTime: answer.creationTime,
|
||||
updatedTime: answer.updatedTime,
|
||||
numComments: answer.numDisplayComments,
|
||||
numUpvotes: answer.numUpvotes,
|
||||
numViews: answer.numViews,
|
||||
numShares: answer.numShares,
|
||||
numAnswerRequests: answer.numRequesters,
|
||||
isBusinessAnswer: answer.businessAnswer,
|
||||
url: quetrefy(answer.url),
|
||||
isSensitive: answer.isSensitive,
|
||||
author: {
|
||||
uid: answer.author.uid,
|
||||
isAnon: answer.author.isAnon,
|
||||
image: answer.author.profileImageUrl,
|
||||
isVerified: answer.author.isVerified,
|
||||
isPlusUser: answer.author.consumerBundleActive,
|
||||
url: quetrefy(answer.author.profileUrl),
|
||||
name: `${answer.author.names[0].givenName} ${answer.author.names[0].familyName}`,
|
||||
credential: answer.authorCredential?.translatedString,
|
||||
},
|
||||
});
|
||||
const postCleaner = post => ({
|
||||
type: 'post',
|
||||
pid: post.pid,
|
||||
isViewable: post.viewerHasAccess,
|
||||
url: quetrefy(post.url),
|
||||
title: JSON.parse(post.title).sections,
|
||||
isDeleted: post.isDeleted,
|
||||
isSensitive: post.isSensitive,
|
||||
text: JSON.parse(post.content).sections,
|
||||
creationTime: post.creationTime,
|
||||
updatedTime: post.updatedTime,
|
||||
numComments: post.numDisplayComments,
|
||||
numUpvotes: post.numUpvotes,
|
||||
numViews: post.numViews,
|
||||
numShares: post.numShares,
|
||||
author: {
|
||||
uid: post.author.uid,
|
||||
isAnon: post.author.isAnon,
|
||||
image: post.author.profileImageUrl,
|
||||
isVerified: post.author.isVerified,
|
||||
isPlusUser: post.author.consumerBundleActive,
|
||||
url: quetrefy(post.author.profileUrl),
|
||||
name: `${post.author.names[0].givenName} ${post.author.names[0].familyName}`,
|
||||
credential: post.authorCredential?.translatedString,
|
||||
},
|
||||
...(post.tribeItem && {
|
||||
space: {
|
||||
isSensitive: post.tribeItem.tribe.isSensitive,
|
||||
name: post.tribeItem.tribe.nameString,
|
||||
url: quetrefy(post.tribeItem.tribe.url),
|
||||
image: post.tribeItem.tribe.iconRetinaUrl,
|
||||
description: post.tribeItem.descriptionString,
|
||||
numFollowers: post.tribeItem.tribe.numFollowers,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const resultsCleaner = results => {
|
||||
const cleanedResults = results.map(result => {
|
||||
const resultToClean = result.node;
|
||||
|
||||
if (resultToClean.topic) return topicCleaner(resultToClean.topic);
|
||||
if (resultToClean.tribe) return spaceCleaner(resultToClean.tribe);
|
||||
if (resultToClean.post) return postCleaner(resultToClean.post);
|
||||
if (resultToClean.user) return profileCleaner(resultToClean.user);
|
||||
if (resultToClean.previewAnswer) return answerCleaner(resultToClean);
|
||||
if (resultToClean.question) return questionCleaner(resultToClean.question);
|
||||
|
||||
return {};
|
||||
});
|
||||
|
||||
return cleanedResults;
|
||||
};
|
||||
|
||||
////////////////////////////////////////////////////////
|
||||
// FUNCTION
|
||||
////////////////////////////////////////////////////////
|
||||
const KEYWORD = 'searchConnection';
|
||||
|
||||
const getSearch = async (querySlug, lang) => {
|
||||
const options = { keyword: KEYWORD, lang, toEncode: false };
|
||||
const res = await fetcher(`search/${querySlug}`, options);
|
||||
|
||||
const {
|
||||
data: { [KEYWORD]: rawData },
|
||||
} = JSON.parse(res);
|
||||
|
||||
if (!rawData)
|
||||
throw new AppError(
|
||||
"Search couldn't be done. Recheck the URL, or resend the request if you believe the URL is correct.",
|
||||
404
|
||||
);
|
||||
|
||||
const data = {
|
||||
results: resultsCleaner(rawData.edges),
|
||||
};
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
////////////////////////////////////////////////////////
|
||||
// EXPORTS
|
||||
////////////////////////////////////////////////////////
|
||||
export default getSearch;
|
|
@ -30,8 +30,6 @@ const data = {
|
|||
name: rawData.name,
|
||||
url: quetrefy(rawData.url),
|
||||
image: rawData.photoUrl,
|
||||
aliases: rawData.aliases,
|
||||
numFollowers: rawData.numFollowers,
|
||||
// isLocked: rawData.isLocked,
|
||||
isAdult: rawData.adult,
|
||||
mostViewedAuthors: rawData.mostViewedAuthors.map(author => ({
|
||||
|
|
83
instances.json
Normal file
83
instances.json
Normal file
|
@ -0,0 +1,83 @@
|
|||
[
|
||||
{
|
||||
"clearnet": "https://quetre.iket.me/",
|
||||
"country": "CA"
|
||||
},
|
||||
{
|
||||
"clearnet": "https://qr.vern.cc/",
|
||||
"tor": "http://qr.vernccvbvyi5qhfzyqengccj7lkove6bjot2xhh5kajhwvidqafczrad.onion",
|
||||
"i2p": "http://vernnflenvsqccuanaun7yydnmturi4jkyxlyzhn6ultpje66c3q.b32.i2p",
|
||||
"country": "US"
|
||||
},
|
||||
{
|
||||
"clearnet": "https://quetre.pussthecat.org/",
|
||||
"country": "DE"
|
||||
},
|
||||
{
|
||||
"clearnet": "https://quetre.tokhmi.xyz/",
|
||||
"country": "US"
|
||||
},
|
||||
{
|
||||
"clearnet": "https://quetre.privacydev.net/",
|
||||
"tor": "http://quetre.g4c3eya4clenolymqbpgwz3q3tawoxw56yhzk4vugqrl6dtu3ejvhjid.onion",
|
||||
"country": "FR"
|
||||
},
|
||||
{
|
||||
"clearnet": "https://ask.habedieeh.re/",
|
||||
"tor": "http://ask.habeehrhadazsw3izbrbilqajalfyqqln54mrja3iwpqxgcuxnus7eid.onion",
|
||||
"country": "CA"
|
||||
},
|
||||
{
|
||||
"clearnet": "https://quetre.blackdrgn.nl/",
|
||||
"country": "DE"
|
||||
},
|
||||
{
|
||||
"clearnet": "https://quetre.lunar.icu/",
|
||||
"country": "DE"
|
||||
},
|
||||
{
|
||||
"clearnet": "https://q.opnxng.com/",
|
||||
"country": "SG"
|
||||
},
|
||||
{
|
||||
"clearnet": "https://ask.sudovanilla.org/",
|
||||
"country": "US"
|
||||
},
|
||||
{
|
||||
"clearnet": "https://quetre.drgns.space/",
|
||||
"country": "US"
|
||||
},
|
||||
{
|
||||
"clearnet": "https://quetre.r4fo.com/",
|
||||
"country": "NL"
|
||||
},
|
||||
{
|
||||
"clearnet": "https://quetre.ducks.party/",
|
||||
"country": "NL"
|
||||
},
|
||||
{
|
||||
"clearnet": "https://quetre.nadeko.net/",
|
||||
"tor": "http://quetre.nadekobxalvyqrhvp3m2atfgdmzp5vcwdmu3wo4htecwjkodancfmgid.onion",
|
||||
"country": "CL"
|
||||
},
|
||||
{
|
||||
"clearnet": "https://quetre.private.coffee/",
|
||||
"tor": "http://quetre.coffee2m3bjsrrqqycx6ghkxrnejl2q6nl7pjw2j4clchjj6uk5zozad.onion",
|
||||
"country": "AT"
|
||||
},
|
||||
{
|
||||
"clearnet": "https://quetre.canine.tools/"
|
||||
},
|
||||
{
|
||||
"clearnet": "https://qt.bloat.cat/",
|
||||
"country": "DE"
|
||||
},
|
||||
{
|
||||
"clearnet": "https://quetre.gitro.xyz",
|
||||
"country": "DE"
|
||||
},
|
||||
{
|
||||
"clearnet": "https://quetre.jeikobu.net",
|
||||
"country": "DE"
|
||||
}
|
||||
]
|
28
package.json
28
package.json
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "quetre",
|
||||
"version": "6.0.0",
|
||||
"version": "8.0.0",
|
||||
"description": "a libre front-end for Quora",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
@ -28,26 +28,26 @@
|
|||
},
|
||||
"homepage": "https://github.com/zyachel/quetre#readme",
|
||||
"dependencies": {
|
||||
"axios": "^0.27.2",
|
||||
"axios": "^1.6.8",
|
||||
"cheerio": "1.0.0-rc.12",
|
||||
"compression": "^1.7.4",
|
||||
"dotenv": "^16.0.3",
|
||||
"express": "^4.18.2",
|
||||
"helmet": "^5.1.1",
|
||||
"ioredis": "^5.3.0",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.19.2",
|
||||
"helmet": "^7.1.0",
|
||||
"ioredis": "^5.3.2",
|
||||
"morgan": "^1.10.0",
|
||||
"pug": "^3.0.2",
|
||||
"sass": "^1.57.1"
|
||||
"sass": "^1.74.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/create-config": "^0.3.1",
|
||||
"@types/express": "^4.17.15",
|
||||
"eslint": "^8.31.0",
|
||||
"@eslint/create-config": "^1.0.0",
|
||||
"@types/express": "^4.17.21",
|
||||
"eslint": "^9.0.0",
|
||||
"eslint-config-airbnb-base": "^15.0.0",
|
||||
"eslint-config-prettier": "^8.6.0",
|
||||
"eslint-plugin-import": "^2.26.0",
|
||||
"nodemon": "^2.0.20",
|
||||
"prettier": "^2.8.2"
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-import": "^2.29.1",
|
||||
"nodemon": "^3.1.0",
|
||||
"prettier": "^3.2.5"
|
||||
},
|
||||
"nodemonConfig": {
|
||||
"ignore": [
|
||||
|
|
1828
pnpm-lock.yaml
generated
1828
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
@ -1,38 +1,27 @@
|
|||
////////////////////////////////////////////////////////
|
||||
// CONSTANTS
|
||||
////////////////////////////////////////////////////////
|
||||
const rootEl = document.documentElement;
|
||||
const headerEl = document.querySelector('.header');
|
||||
const [metaThemeEl, tempMetaThemeEl] = document.querySelectorAll(
|
||||
'meta[name="theme-color"]'
|
||||
);
|
||||
const [metaThemeEl, tempMetaThemeEl] = document.querySelectorAll('meta[name="theme-color"]');
|
||||
const btnTheme = document.querySelector('.theme-changer');
|
||||
|
||||
////////////////////////////////////////////////////////
|
||||
// HELPER FUNCTIONS
|
||||
////////////////////////////////////////////////////////
|
||||
// gets theme prefered by browser
|
||||
const browserPrefersDarkTheme = window.matchMedia(
|
||||
'(prefers-color-scheme: dark)'
|
||||
).matches;
|
||||
// gets theme prefered by user(stored in local storage)
|
||||
const userPrefersTheme = localStorage?.getItem('theme');
|
||||
// sets theme to local storage
|
||||
const setTheme = theme => rootEl.setAttribute('theme', theme);
|
||||
const localStorageAccessible = !!typeof Storage;
|
||||
const setMetaTheme = () => {
|
||||
const headerColor = window.getComputedStyle(headerEl).backgroundColor;
|
||||
metaThemeEl.setAttribute('content', headerColor);
|
||||
};
|
||||
|
||||
////////////////////////////////////////////////////////
|
||||
// EVENT LISTENER
|
||||
////////////////////////////////////////////////////////
|
||||
btnTheme.addEventListener('click', () => {
|
||||
const curTheme = rootEl.getAttribute('theme') || 'light';
|
||||
const curTheme = document.documentElement.getAttribute('theme') ?? 'light';
|
||||
const themeToSet = curTheme === 'light' ? 'dark' : 'light';
|
||||
const colorToAdd = window.getComputedStyle(headerEl).backgroundColor;
|
||||
setTheme(themeToSet);
|
||||
// changes the meta theme-color tag to match the header color
|
||||
metaThemeEl.setAttribute('content', colorToAdd);
|
||||
// only setting the value in localStoage if it's actually accessible
|
||||
if (localStorageAccessible) localStorage.setItem('theme', themeToSet);
|
||||
if (isLocalStorageAccessible()) localStorage.setItem('theme', themeToSet);
|
||||
setMetaTheme();
|
||||
});
|
||||
|
||||
////////////////////////////////////////////////////////
|
||||
|
@ -40,13 +29,11 @@ btnTheme.addEventListener('click', () => {
|
|||
////////////////////////////////////////////////////////
|
||||
(() => {
|
||||
// setting this attr on root to not render some css styles
|
||||
rootEl.setAttribute('js-enabled', '');
|
||||
document.documentElement.setAttribute('js-enabled', '');
|
||||
// removing duplicate meta theme color(it's initially for those who haven't enabled js)
|
||||
metaThemeEl.removeAttribute('media');
|
||||
tempMetaThemeEl.remove();
|
||||
// applying theme preferences in case they exist
|
||||
if (userPrefersTheme) setTheme(userPrefersTheme);
|
||||
else if (browserPrefersDarkTheme) setTheme('dark');
|
||||
setMetaTheme();
|
||||
})();
|
||||
|
||||
////////////////////////////////////////////////////////
|
||||
|
|
24
public/js/restore-theme.js
Normal file
24
public/js/restore-theme.js
Normal file
|
@ -0,0 +1,24 @@
|
|||
////////////////////////////////////////////////////////
|
||||
// HELPER FUNCTIONS
|
||||
////////////////////////////////////////////////////////
|
||||
const isLocalStorageAccessible = () => {
|
||||
try {
|
||||
window.localStorage.getItem('test');
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const setTheme = theme => document.documentElement.setAttribute('theme', theme);
|
||||
|
||||
const userPrefersTheme = isLocalStorageAccessible() ? localStorage.getItem('theme') : null;
|
||||
const browserPrefersDarkTheme = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
|
||||
////////////////////////////////////////////////////////
|
||||
// INIT FUNCTION
|
||||
////////////////////////////////////////////////////////
|
||||
(() => {
|
||||
// applying theme preferences in case they exist
|
||||
if (userPrefersTheme) setTheme(userPrefersTheme);
|
||||
else if (browserPrefersDarkTheme) setTheme('dark');
|
||||
})();
|
|
@ -6,13 +6,13 @@ import {
|
|||
topic,
|
||||
image,
|
||||
profile,
|
||||
search,
|
||||
gone,
|
||||
} from '../controllers/apiController.js';
|
||||
|
||||
const apiRouter = express.Router();
|
||||
|
||||
apiRouter.get('/(|search)', search);
|
||||
apiRouter.get('/about', about);
|
||||
apiRouter.get('/search', gone);
|
||||
apiRouter.get('/(|about)', about);
|
||||
apiRouter.get('/image/:domain/:path', image);
|
||||
apiRouter.get('/profile/:name', profile);
|
||||
apiRouter.get('/topic/:slug', topic);
|
||||
|
|
|
@ -6,14 +6,14 @@ import {
|
|||
topic,
|
||||
unimplemented,
|
||||
profile,
|
||||
search,
|
||||
gone,
|
||||
redirect,
|
||||
} from '../controllers/viewController.js';
|
||||
|
||||
const viewRouter = express.Router();
|
||||
|
||||
viewRouter.get('/(|search)', search); // search on / or /search
|
||||
viewRouter.get('/about', about);
|
||||
viewRouter.get('/search', gone);
|
||||
viewRouter.get('/(|about)', about);
|
||||
viewRouter.get('/privacy', privacy);
|
||||
viewRouter.get('/profile/:name', profile);
|
||||
viewRouter.get('/topic/:slug', topic);
|
||||
|
|
|
@ -8,11 +8,6 @@ const formatSlug = (slug, charToRemove) =>
|
|||
////////////////////////////////////////////////////////
|
||||
// EXPORTS
|
||||
////////////////////////////////////////////////////////
|
||||
export const searchKey = urlObj => {
|
||||
const slug = formatSlug(urlObj.search, '?');
|
||||
return `search:${slug}`;
|
||||
};
|
||||
|
||||
export const answersKey = urlObj => {
|
||||
const slug = formatSlug(urlObj.pathname, '/');
|
||||
const lang = getLang(urlObj);
|
||||
|
|
|
@ -4,7 +4,10 @@ const ttl = process.env.REDIS_TTL || 3600;
|
|||
|
||||
const getOrSetCache = async (key, callback, ...callbackArgs) => {
|
||||
const data = await redis.get(key);
|
||||
if (data) return JSON.parse(data);
|
||||
if (data) {
|
||||
await redis.expire(key, ttl);
|
||||
return JSON.parse(data);
|
||||
}
|
||||
|
||||
const dataToCache = await callback(...callbackArgs);
|
||||
await redis.set(key, JSON.stringify(dataToCache), 'EX', ttl);
|
||||
|
|
11
utils/parse.js
Normal file
11
utils/parse.js
Normal file
|
@ -0,0 +1,11 @@
|
|||
const invalidLessThan = /\\x3C/g;
|
||||
const validLessThan = '\\u003C';
|
||||
|
||||
/**
|
||||
* parses and corrects invalid escape sequences
|
||||
* @param {string} data
|
||||
* @returns {Record<PropertyKey, any>}
|
||||
*/
|
||||
const parse = data => JSON.parse(data.replace(invalidLessThan, validLessThan));
|
||||
|
||||
export default parse;
|
|
@ -6,6 +6,7 @@ const redisUrl = process.env.REDIS_URL;
|
|||
const stub = {
|
||||
get: async key => {},
|
||||
set: async (key, value, secondsToken, seconds) => {},
|
||||
expire: async (key, seconds) => {},
|
||||
};
|
||||
|
||||
const redis = redisUrl ? new Redis(redisUrl) : stub;
|
||||
|
|
|
@ -32,6 +32,7 @@ html(lang='en')
|
|||
meta(name='twitter:description', content=meta.description)
|
||||
meta(name='twitter:image', content=meta.imageUrl)
|
||||
//- own script
|
||||
script(src='/js/restore-theme.js')
|
||||
script(src='/js/index.js', defer)
|
||||
|
||||
//- initially setting this var to false. will update in case an answer contains some math expression
|
||||
|
|
|
@ -11,12 +11,9 @@ footer.footer(class=`${meta.title ==='About' ? 'footer__about' : ''}`)
|
|||
ul.footer__nav
|
||||
- if (meta.title !=='About')
|
||||
li.footer__nav-item: a.footer__nav-link.footer__link(href="/about") About
|
||||
li.footer__nav-item: a.footer__nav-link.footer__link(href="/search") Search
|
||||
li.footer__nav-item: a.footer__nav-link.footer__link(href="https://github.com/zyachel/quetre") Source Code
|
||||
li.footer__nav-item: a.footer__nav-link.footer__link(href="/privacy") Privacy
|
||||
li.footer__nav-item: a.footer__nav-link.footer__link(href="#") Back to top
|
||||
|
||||
//- LICENSE
|
||||
p.footer__license Licensed under
|
||||
a.footer__link(href="https://www.gnu.org/licenses/agpl-3.0.html") GNU AGPLv3
|
||||
| .
|
||||
em.footer__license Quetre does not host any content. All content is from Quora. Quora is a tradmark of Quora Inc.
|
|
@ -10,8 +10,6 @@ header.header(class=`${meta.title === 'About' ? 'header__about': ''}`)
|
|||
|
||||
//- BUTTON FOR CHANGING THEME
|
||||
.header__misc
|
||||
a.link.header__search(href="/search", aria-label='search page')
|
||||
svg.icon: use(href='/misc/sprite.svg#icon-search')
|
||||
button.button.theme-changer.header__theme(aria-label='Change Theme')
|
||||
svg.icon.icon__theme.theme-changer__icon.theme-changer__icon--sun: use(href='/misc/sprite.svg#icon-sun')
|
||||
svg.icon.icon__theme.theme-changer__icon.theme-changer__icon--moon: use(href='/misc/sprite.svg#icon-moon')
|
||||
|
|
|
@ -20,7 +20,7 @@ mixin quetrefyUrl(url, text)
|
|||
|
||||
if (match) {
|
||||
const [_, subdomain, rest] = match;
|
||||
if (acceptedLanguages.includes(subdomain)) link = link = `${rest}${rest.includes('?') ? '&' : '?'}lang=${subdomain}`;
|
||||
if (acceptedLanguages.includes(subdomain)) link = `${rest}${rest.includes('?') ? '&' : '?'}lang=${subdomain}`;
|
||||
else if (subdomain === 'www') link = rest;
|
||||
else link = `/space/${subdomain}${rest}`;
|
||||
} else {
|
||||
|
|
|
@ -145,8 +145,7 @@ block content
|
|||
section.profile__spaces.profile-spaces
|
||||
h2.heading.heading__secondary Spaces
|
||||
p.profile-spaces__info
|
||||
span.profile-spaces__item Active in #{data.spaces.numActiveInSpaces},
|
||||
span.profile-spaces__item following #{data.spaces.numFollowingSpaces} spaces
|
||||
span.profile-spaces__item Active in #{data.spaces.numActiveInSpaces} spaces
|
||||
ul.profile-spaces__list
|
||||
each space in data.spaces.spaces
|
||||
li.metadata-primary.profile-spaces__list-item
|
||||
|
@ -160,9 +159,7 @@ block content
|
|||
span sensitive space
|
||||
|
||||
section.profile__topics.profile-topics
|
||||
h2.heading.heading__secondary.profile-topics__heading Topics
|
||||
p.profile-topics__info
|
||||
span.profile-topics__item Following #{data.topics.numFollowingTopics} topics
|
||||
h2.heading.heading__secondary.profile-topics__heading Topics
|
||||
ul.profile-topics__list
|
||||
each topic in data.topics.topics
|
||||
li.metadata-primary.profile-topics__list-item
|
||||
|
|
|
@ -1,89 +0,0 @@
|
|||
//-//////////////////////////////////////////////////////
|
||||
//- INCLUDES/EXTENDS
|
||||
//-//////////////////////////////////////////////////////
|
||||
extends ../base
|
||||
include ../mixins/_formatText
|
||||
include ../mixins/_utils
|
||||
include ../mixins/_answer
|
||||
include ../mixins/_question
|
||||
include ../mixins/_post
|
||||
include ../mixins/_metadata
|
||||
|
||||
|
||||
//-//////////////////////////////////////////////////////
|
||||
//- MAIN CONTENT
|
||||
//-//////////////////////////////////////////////////////
|
||||
block content
|
||||
main#main(class=`main search ${data ? '' :'search--no-results'}`)
|
||||
-
|
||||
const typesArr = [{key: 'question', text: 'Questions'}, {key: 'answer', text: 'Answers'}, {key: 'post', text: 'Posts'}, {key: 'profile', text: 'Profiles'}, {key: 'topic', text: 'Topics'}, {key: 'tribe', text: 'Spaces'}]
|
||||
const timesArr = [{key: 'hour', text: 'Hour'}, {key: 'day', text: 'Day'}, {key: 'week', text: 'Week'}, {key: 'month', text: 'Month'}, {key: 'year', text: 'Year'}]
|
||||
const languagesArr = ['en','es','fr','de','it','jp','id','pt','hi','nl','da','fi','nb','sv','mr','bn','ta','ar','he','gu','kn','ml','te','po'];
|
||||
|
||||
form.search__form.search-form(action="/search", method='get', autocomplete='off', name='search')
|
||||
.search-form__search-container
|
||||
input.search-form__searchbar(type="search", name="q", placeholder='Enter your query...', minlength='3', aria-label='search for anything')
|
||||
button.search-form__button.search-form__button--reset(type="reset", aria-label='clear searchbar and filters'): svg.icon: use(href='/misc/sprite.svg#icon-cross')
|
||||
button.search-form__button.search-form__button--submit(type="submit", aria-label='search'): svg.icon: use(href='/misc/sprite.svg#icon-search')
|
||||
.search-form__filters-container.search-form__filters-container--type
|
||||
p.search-form__filters-heading Filter by Type
|
||||
each item in typesArr
|
||||
.search-form__filters-group
|
||||
input.search-form__radio.visually-hidden(type="radio", name="type", value=item.key, id=`type--${item.key}`)
|
||||
label.search-form__label(for=`type--${item.key}`)= item.text
|
||||
|
||||
.search-form__filters-container.search-form__filters-container--time
|
||||
p.search-form__filters-heading Filter by Time
|
||||
each item in timesArr
|
||||
.search-form__filters-group
|
||||
input.search-form__radio.visually-hidden(type="radio", name='time', value=item.key, id=`time--${item.key}`)
|
||||
label.search-form__label(for=`time--${item.key}`)= item.text
|
||||
|
||||
.search-form__filters-container.search-form__filters-container--lang
|
||||
p.search-form__filters-heading Filter by Language
|
||||
each lang in languagesArr
|
||||
.search-form__filters-group
|
||||
input.search-form__radio.visually-hidden(type="radio", name='lang', value=lang, id=`lang--${lang}`)
|
||||
label.search-form__label(for=`lang--${lang}`)= lang.toUpperCase()
|
||||
|
||||
//- TODO: refactor 'profile', 'topic', and 'space' into resusable mixins.
|
||||
- if (data?.results)
|
||||
section.search__results.search-results
|
||||
h1.heading.heading__primary Results
|
||||
- if (data.results.length === 0)
|
||||
p No results found for the query. Try being less specific and/or removing filters.
|
||||
- else
|
||||
.search-results__container
|
||||
each item in data.results
|
||||
.search-results__item
|
||||
- if (item.type === 'answer')
|
||||
+addAnswer(item, true)
|
||||
- else if (item.type === 'question')
|
||||
+addQuestion(item)
|
||||
- else if (item.type === 'post')
|
||||
+addPost(item)
|
||||
- else if (item.type === 'profile')
|
||||
.metadata-primary
|
||||
p.metadata-primary__heading
|
||||
if item.isAnon
|
||||
span Anonymous
|
||||
else
|
||||
a.link.metadata-primary__link(href=item.url)= item.name
|
||||
if item.isVerified
|
||||
svg.icon.metadata-primary__icon
|
||||
title verified
|
||||
use(href='/misc/sprite.svg#icon-verified')
|
||||
+proxifyImg(item.image)(class='metadata-primary__image', alt='', aria-hidden='true')
|
||||
p.metadata-primary__misc(aria-label=`${item.name}'s credentials`)= item.credential || ''
|
||||
- else if (item.type === 'topic')
|
||||
.metadata-primary
|
||||
a.link.metadata-primary__heading(href=item.url)= item.name
|
||||
+proxifyImg(item.image)(class='metadata-primary__image', aria-hidden='true', alt='')
|
||||
p.metadata-primary__misc
|
||||
+formatNumber(item.numFollowers)
|
||||
| Followers
|
||||
- else if (item.type === 'space')
|
||||
.metadata-primary.profile-spaces__list-item
|
||||
+proxifyImg(item.image)(class='metadata-primary__image', alt='', aria-hidden='true')
|
||||
a.link.metadata-primary__heading(href=item.url)= item.name
|
||||
p.metadata-primary__misc= item.description
|
|
@ -15,12 +15,7 @@ block content
|
|||
.metadata-primary
|
||||
h1.heading.heading__primary.metadata-primary__heading.topic__name= data.name
|
||||
+proxifyImg(data.image)(class='metadata-primary__image', alt='', aria-hidden='true')
|
||||
p.metadata-primary__misc
|
||||
if data.aliases.length
|
||||
span Also known as:
|
||||
span= data.aliases.join(', ')
|
||||
.metadata-secondary
|
||||
+addMetadataSecondary('user','Followers', data.numFollowers)
|
||||
if data.isAdult
|
||||
+addMetadataSecondary('danger', 'Adult Topic', '18+', true)
|
||||
+quorafyUrl(meta.url)(class='link')
|
||||
|
|
|
@ -441,6 +441,11 @@
|
|||
&__question-item {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
&__question-link[href^="/unanswered/"]::before {
|
||||
content: '[Unanswered] ';
|
||||
color: var(--clr-base-text-alt-alpha);
|
||||
}
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////
|
||||
|
@ -531,119 +536,3 @@
|
|||
gap: var(--space-800);
|
||||
}
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////
|
||||
// SEARCH PAGE COMPONENTS
|
||||
////////////////////////////////////////////////////////
|
||||
///
|
||||
.search-form {
|
||||
display: grid;
|
||||
gap: var(--space-200);
|
||||
// justify-items: center;
|
||||
|
||||
&__search-container {
|
||||
display: grid;
|
||||
grid-auto-flow: column;
|
||||
grid-auto-columns: minmax(15rem, auto) 3rem 3rem;
|
||||
grid-auto-rows: 3rem;
|
||||
border: 1px solid var(--clr-base-icon-alt-alpha);
|
||||
border-radius: 100vmax;
|
||||
overflow: hidden;
|
||||
padding: var(--space-100);
|
||||
gap: var(--space-050);
|
||||
|
||||
&:focus-within {
|
||||
background-color: var(--clr-code-bg);
|
||||
color: var(--clr-code-text);
|
||||
}
|
||||
}
|
||||
|
||||
&__searchbar {
|
||||
outline: none;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
font: inherit;
|
||||
caret-color: var(--clr-base-icon);
|
||||
|
||||
// fix for browsers with non-standard properties. yes, webkit and blink suck.
|
||||
-webkit-appearance: none;
|
||||
&::-webkit-search-cancel-button {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__button {
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
border-radius: 100vmax;
|
||||
|
||||
&:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
&--reset {
|
||||
}
|
||||
|
||||
&--submit {
|
||||
}
|
||||
}
|
||||
|
||||
&__filters-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2rem 1rem;
|
||||
}
|
||||
|
||||
&__filters-heading {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
|
||||
&__filters-group {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
&__radio {
|
||||
}
|
||||
|
||||
&__label {
|
||||
cursor: pointer;
|
||||
background: var(--clr-code-bg);
|
||||
color: var(--clr-code-text);
|
||||
padding: 1rem;
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
&__radio:checked + &__label {
|
||||
color: var(--clr-selection-text);
|
||||
background: var(--clr-selection-bg);
|
||||
}
|
||||
|
||||
&__radio:focus + &__label {
|
||||
@include focus-rules;
|
||||
}
|
||||
|
||||
@supports selector(:focus-visible) {
|
||||
&__radio:focus + &__label {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&__radio:focus-visible + &__label {
|
||||
@include focus-rules;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.search-results {
|
||||
--img-dim: var(--fs-600);
|
||||
|
||||
display: grid;
|
||||
gap: var(--space-500);
|
||||
|
||||
&__container {
|
||||
display: grid;
|
||||
gap: var(--space-500);
|
||||
}
|
||||
|
||||
&__item {
|
||||
}
|
||||
}
|
||||
|
|
|
@ -81,8 +81,7 @@
|
|||
gap: var(--space-200);
|
||||
}
|
||||
|
||||
&__theme,
|
||||
&__search {
|
||||
&__theme {
|
||||
height: var(--fs-300);
|
||||
width: var(--fs-300);
|
||||
|
||||
|
@ -92,12 +91,6 @@
|
|||
}
|
||||
}
|
||||
|
||||
// search icon viewBox is larger than content, hence the fix
|
||||
&__search > svg {
|
||||
height: 105%;
|
||||
width: 105%;
|
||||
}
|
||||
|
||||
&__info {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
|
|
|
@ -405,54 +405,3 @@
|
|||
padding-inline: var(--space-200);
|
||||
}
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////
|
||||
// SEARCH
|
||||
////////////////////////////////////////////////////////
|
||||
.search {
|
||||
// justify-self: center;
|
||||
padding: var(--space-500) var(--space-800);
|
||||
|
||||
display: grid;
|
||||
grid-template-columns: 2fr 1.2fr;
|
||||
gap: var(--space-500);
|
||||
align-items: start;
|
||||
|
||||
&--no-results {
|
||||
grid-template-columns: unset;
|
||||
place-content: center;
|
||||
max-width: 100rem;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
&__results {
|
||||
grid-column: 1 / 2;
|
||||
grid-row: 1 / 2;
|
||||
}
|
||||
|
||||
&__form {
|
||||
grid-row: 1 / 2;
|
||||
|
||||
grid-column: -2 / -1;
|
||||
}
|
||||
|
||||
@include respond-to(bp-1200) {
|
||||
padding: var(--space-500);
|
||||
gap: var(--space-500);
|
||||
}
|
||||
@include respond-to(bp-900) {
|
||||
grid-template-columns: auto;
|
||||
grid-template-rows: max-content auto;
|
||||
|
||||
&__results {
|
||||
grid-row: 2 / 3;
|
||||
}
|
||||
|
||||
&__form {
|
||||
grid-row: 1 / 2;
|
||||
}
|
||||
}
|
||||
@include respond-to(bp-550) {
|
||||
padding-inline: var(--space-200);
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue