diff --git a/.gitignore b/.gitignore index 6704566..9000802 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# pointers-specific +peers.json + # Logs logs *.log diff --git a/config/db.json b/config/db.json new file mode 100644 index 0000000..3f3702a --- /dev/null +++ b/config/db.json @@ -0,0 +1,23 @@ +{ + "development": { + "username": "root", + "password": "rootpassword", + "database": "pointers", + "host": "127.0.0.1", + "dialect": "mysql" + }, + "test": { + "username": "root", + "password": null, + "database": "database_test", + "host": "127.0.0.1", + "dialect": "mysql" + }, + "production": { + "username": "root", + "password": null, + "database": "database_production", + "host": "127.0.0.1", + "dialect": "mysql" + } +} diff --git a/config/peers.json b/config/peers.json new file mode 100644 index 0000000..2382734 --- /dev/null +++ b/config/peers.json @@ -0,0 +1,10 @@ +{ + "peers": [ + { + "url": "https://dev.pointers.website" + }, + { + "url": "https://pointers.website" + } + ] +} diff --git a/lib/database.js b/lib/database.js index 8fc0150..29a46a5 100644 --- a/lib/database.js +++ b/lib/database.js @@ -10,7 +10,7 @@ import PointerKey from '../models/PointerKey.js'; dotenv.config(); const sequelize = new Sequelize({ - database: 'pointers', + database: process.env.DB_DATABASE, username: process.env.DB_USER, password: process.env.DB_PASSWORD, host: '127.0.0.1', diff --git a/lib/search.js b/lib/search.js new file mode 100644 index 0000000..bde2c8a --- /dev/null +++ b/lib/search.js @@ -0,0 +1,67 @@ +import { Op } from 'sequelize'; +import sequelize from './database.js'; + +const searchUsers = async (q) => { + if (!q) { + return []; + } + if (q.length < 3) { + return []; + } + const domain = process.env.DOMAIN; + const userNames = await sequelize.models.UserName.findAll({ + where: { + name: { + [Op.substring]: q, + }, + }, + include: { + model: sequelize.models.User, + attributes: ['id', 'hash'], + }, + raw: true, + nest: true, + }); + const users = userNames.reduce((acc, curr) => { + if (!curr.User?.id) { + return acc; + } + if (!acc.find((user) => user.id === curr.User.id)) { + acc.push({ + ...curr.User, + names: [ + { + name: curr.name, + main: Boolean(curr.main), + absolute: `${curr.name}@${domain}`, + }, + ], + }); + } else { + acc.find((user) => user.id === curr.User.id).names.push({ + name: curr.name, + main: curr.main, + absolute: `${curr.name}@${domain}`, + }); + } + return acc; + }, []); + users.forEach((user) => { + user.names.sort((a, b) => { + if (a.main && !b.main) { + return -1; + } else if (!a.main && b.main) { + return 1; + } else { + return 0; + } + }); + // Add the URL to the user's profile + user.url = `https://${process.env.DOMAIN}/u/${user.hash}`; + user.domain = domain; + }); + + return users; +}; + +export { searchUsers }; diff --git a/package.json b/package.json index c7ae68b..cd0f7d3 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "test": "yarn dev & yarn run cypress open" }, "dependencies": { + "axios": "^1.2.3", "bcrypt": "^5.1.0", "cheerio": "^1.0.0-rc.12", "connect-flash": "^0.1.1", diff --git a/public/css/style.css b/public/css/style.css index b6f4c6d..209f529 100644 --- a/public/css/style.css +++ b/public/css/style.css @@ -50,6 +50,8 @@ header#header { } header#header h1 { + font-size: 2rem; + font-weight: 700; margin: 0; color: rgba(255, 255, 255, 1); } @@ -59,6 +61,11 @@ header#header h1 a { text-decoration: none; } +header#header h1 .subheading { + font-size: 1.3rem; + font-weight: 400; +} + header#header #logo { display: flex; align-items: center; @@ -186,6 +193,14 @@ footer#footer a { margin: 0.5rem 0; } +.item aside { + font-style: italic; + white-space: unset; + color: #666; + font-size: 0.9rem; + margin-top: 0.25rem; +} + label { display: block; margin-bottom: 0.5rem; @@ -313,7 +328,7 @@ a.pointer:hover { .pointer .pointer__title { display: flex; align-items: center; - gap: 0.25rem; + gap: 0.25rem; } .pointer .pointer__icon-and-name { @@ -366,4 +381,4 @@ form .edit__icon-field .edit__icon { form .edit__icon-field .edit__no-icon { color: #777; font-weight: 300; -} \ No newline at end of file +} diff --git a/router.js b/router.js index 5369140..04ade45 100644 --- a/router.js +++ b/router.js @@ -30,6 +30,9 @@ import { encryptRsa, generateKeyPair, } from './lib/encryption.js'; +import axios from 'axios'; +import { readFile } from 'fs/promises'; +import { searchUsers } from './lib/search.js'; const api = express.Router(); const frontend = express.Router(); @@ -227,47 +230,29 @@ frontend.get('/search', redirectIfNotLoggedIn, async (req, res) => { error: 'Search query must be at least 3 characters long.', }); } - const userNames = await sequelize.models.UserName.findAll({ - where: { - name: { - [Op.substring]: q, - }, - }, - include: { - model: sequelize.models.User, - attributes: ['id', 'emailAddress', 'bio', 'avatar', 'hash'], - }, - raw: true, - nest: true, - }); - const users = userNames.reduce((acc, curr) => { - if (!curr.User?.id) { - return acc; - } - if (!acc.find((user) => user.id === curr.User.id)) { - acc.push({ - ...curr.User, - names: [{ name: curr.name, main: curr.main }], - }); - } else { - acc.find((user) => user.id === curr.User.id).names.push({ - name: curr.name, - main: curr.main, + const users = await searchUsers(q); + // Add remote results from federated instances ('peers') + const peersConfig = JSON.parse( + await readFile(new URL('./config/peers.json', import.meta.url)), + ); + for (const peer of peersConfig.peers) { + try { + const { data } = await axios.get( + `${peer.url}/api/users?q=${encodeURIComponent(q)}`, + ); + // Add the 'remote' property to each user so we can render them differently + data.forEach((user) => { + user.remote = true; }); - } - return acc; - }, []); - users.forEach((user) => { - user.names.sort((a, b) => { - if (a.main && !b.main) { - return -1; - } else if (!a.main && b.main) { - return 1; - } else { - return 0; + users.push(...data); + } catch (err) { + // If it's a 404, it's because the peer is running an older version + // and doesn't have the search endpoint. We can ignore it. + if (err.response.status !== 404) { + console.error(err); } - }); - }); + } + } res.render('search', { users, q }); }); @@ -842,4 +827,21 @@ api.get( }, ); +// Public search API +api.get('/users', async (req, res) => { + const { q } = req.query; + if (!q) { + return res + .status(400) + .json({ error: 'Please provide a search query.' }); + } + if (q.length < 3) { + return res.status(400).json({ + error: 'Please provide a search query of at least 3 characters.', + }); + } + const users = await searchUsers(q); + return res.json(users); +}); + export { api, frontend, auth }; diff --git a/views/layout.eta b/views/layout.eta index 7d0f87b..ad49c41 100644 --- a/views/layout.eta +++ b/views/layout.eta @@ -10,7 +10,7 @@