fix(search): remove broken search route

can't do searches now unless logged-in

BREAKING CHANGE: any search request will be responded with a 410
This commit is contained in:
zyachel 2024-04-08 00:33:16 +05:30
parent a0ac36a174
commit f49062d44a
13 changed files with 36 additions and 489 deletions

View file

@ -187,7 +187,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

View file

@ -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, searchText} });
});
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')) {

View file

@ -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, searchText },
meta: {
title: searchText ? `Results for '${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,
},
});
};

View file

@ -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;

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -11,7 +11,6 @@ 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

View file

@ -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')

View file

@ -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.searchData ? '' :'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.searchData?.results)
section.search__results.search-results
h1.heading.heading__primary= `Results for '${data.searchText}'`
- if (data.searchData.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.searchData.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

View file

@ -536,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 {
}
}

View file

@ -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;

View file

@ -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);
}
}