From 339894733e26f4813c9c61892b7326aaf51e361f Mon Sep 17 00:00:00 2001 From: Raphael Kabo Date: Thu, 26 Jan 2023 10:07:13 +0000 Subject: [PATCH 1/8] Add webfinger discovery --- config/db.json | 23 ++++++++++ config/peers.json | 7 +++ package.json | 1 + router.js | 109 ++++++++++++++++++++++++++++++++++++++++++++++ yarn.lock | 16 ++++++- 5 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 config/db.json create mode 100644 config/peers.json 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..15b568f --- /dev/null +++ b/config/peers.json @@ -0,0 +1,7 @@ +{ + "peers": [ + { + "url": "https://dev.pointers.website" + } + ] +} 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/router.js b/router.js index 5369140..d9dabb8 100644 --- a/router.js +++ b/router.js @@ -30,6 +30,8 @@ import { encryptRsa, generateKeyPair, } from './lib/encryption.js'; +import axios from 'axios'; +import { readFile } from 'fs/promises'; const api = express.Router(); const frontend = express.Router(); @@ -268,6 +270,32 @@ frontend.get('/search', redirectIfNotLoggedIn, async (req, res) => { } }); }); + // Add remote results from federated instances ('peers') + const peersConfig = JSON.parse( + await readFile(new URL('./config/peers.json', import.meta.url)), + ); + console.log(peersConfig); + for (const peer of peersConfig.peers) { + try { + const host = new URL(peer.url).host; + console.log( + `${ + peer.url + }/.well-known/webfinger?resource=acct:${encodeURIComponent( + q, + )}@${host}`, + ); + // const { data } = await axios.get( + // `${ + // peer.url + // }/.well-known/webfinger?resource=acct:${encodeURIComponent( + // q, + // )}@${peer.host}`, + // ); + } catch (err) { + console.error(err); + } + } res.render('search', { users, q }); }); @@ -327,6 +355,87 @@ frontend.get('/u/:hash', async (req, res) => { } }); +// Webfinger-based user discovery +// Because users' usernames are not unique (for instance, two people could both +// be using the username "hunter2"), but searching is based exclusively on +// usernames, we return a definitely non-spec-compliant webfinger response - an +// array of compliant webfinger response objects, one per user with the given username. +// +// Example: +// Query: https://pointers.website/.well-known/webfinger?resource=acct:hunter2@pointers.website +// Response: +// [ +// { +// subject: 'acct:hunter2@pointers.website', +// links: [ +// { +// rel: 'http://webfinger.net/rel/profile-page', +// type: 'text/html', +// href: 'https://pointers.website/u/a1b2c3', +// }, +// ], +// }, +// { +// subject: 'acct:hunter2@pointers.website', +// links: [ +// { +// rel: 'http://webfinger.net/rel/profile-page', +// type: 'text/html', +// href: 'https://pointers.website/u/z9y8x7', +// }, +// ], +// }, +// ]; +frontend.get('/.well-known/webfinger', async (req, res) => { + const { resource } = req.query; + if (!resource) { + return res.status(400).send('Missing resource parameter.'); + } + if (!resource.startsWith('acct:')) { + return res.status(400).send('Invalid resource.'); + } + const [username, domain] = resource.split('acct:')[1].split('@'); + if (domain !== process.env.DOMAIN) { + return res.status(400).send('Invalid domain.'); + } + const users = await sequelize.models.User.findAll({ + where: { + '$UserNames.name$': username, + }, + attributes: ['hash'], + include: [ + { + model: sequelize.models.UserName, + attributes: ['name', 'main'], + }, + ], + }); + if (users.length === 0) { + return res.status(404).send('Account not found.'); + } + for (const user of users) { + user.UserNames = await sequelize.models.UserName.findAll({ + where: { UserId: user.id }, + attributes: ['name', 'main'], + }); + } + // Return the webfinger response + res.json( + users.map((user) => ({ + subject: `acct:${ + user.UserNames.find((n) => n.main === true).name + }@${process.env.DOMAIN}`, + links: [ + { + rel: 'http://webfinger.net/rel/profile-page', + type: 'text/html', + href: `https://${process.env.DOMAIN}/u/${user.hash}`, + }, + ], + })), + ); +}); + api.get('/healthcheck', (req, res) => { res.send('OK'); }); diff --git a/yarn.lock b/yarn.lock index eb3f4b6..0a39472 100644 --- a/yarn.lock +++ b/yarn.lock @@ -897,6 +897,15 @@ at-least-node@^1.0.0: resolved "https://registry.yarnpkg.com/at-least-node/-/at-least-node-1.0.0.tgz#602cd4b46e844ad4effc92a8011a3c46e0238dc2" integrity sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg== +axios@^1.2.3: + version "1.2.3" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.2.3.tgz#31a3d824c0ebf754a004b585e5f04a5f87e6c4ff" + integrity sha512-pdDkMYJeuXLZ6Xj/Q5J3Phpe+jbGdsSzlQaFVkMQzRUL05+6+tetX8TV3p4HrU4kzuO9bt+io/yGQxuyxA/xcw== + dependencies: + follow-redirects "^1.15.0" + form-data "^4.0.0" + proxy-from-env "^1.1.0" + babel-jest@^29.3.1: version "29.3.1" resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-29.3.1.tgz#05c83e0d128cd48c453eea851482a38782249f44" @@ -1961,6 +1970,11 @@ flatted@^3.1.0: resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.7.tgz#609f39207cb614b89d0765b477cb2d437fbf9787" integrity sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ== +follow-redirects@^1.15.0: + version "1.15.2" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.2.tgz#b460864144ba63f2681096f274c4e57026da2c13" + integrity sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA== + form-data@^2.3.3: version "2.5.1" resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.5.1.tgz#f2cbec57b5e59e23716e128fe44d4e5dd23895f4" @@ -3561,7 +3575,7 @@ proxy-agent@^3.0.3: proxy-from-env "^1.0.0" socks-proxy-agent "^4.0.1" -proxy-from-env@^1.0.0: +proxy-from-env@^1.0.0, proxy-from-env@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg== From a33e01a33f01efca25b367389e242c522848b131 Mon Sep 17 00:00:00 2001 From: Raphael Kabo Date: Thu, 26 Jan 2023 10:13:59 +0000 Subject: [PATCH 2/8] fix: database query bug --- router.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/router.js b/router.js index d9dabb8..76bd7b7 100644 --- a/router.js +++ b/router.js @@ -402,7 +402,7 @@ frontend.get('/.well-known/webfinger', async (req, res) => { where: { '$UserNames.name$': username, }, - attributes: ['hash'], + attributes: ['id', 'hash'], include: [ { model: sequelize.models.UserName, From f57cb24144d8066f25dd0c4472c5cb8796ae7db9 Mon Sep 17 00:00:00 2001 From: Raphael Kabo Date: Thu, 26 Jan 2023 10:58:58 +0000 Subject: [PATCH 3/8] Replace webfinger with search API --- config/peers.json | 3 + lib/search.js | 66 ++++++++++++++++++ router.js | 168 +++++++++------------------------------------- views/search.eta | 2 +- 4 files changed, 100 insertions(+), 139 deletions(-) create mode 100644 lib/search.js diff --git a/config/peers.json b/config/peers.json index 15b568f..2382734 100644 --- a/config/peers.json +++ b/config/peers.json @@ -2,6 +2,9 @@ "peers": [ { "url": "https://dev.pointers.website" + }, + { + "url": "https://pointers.website" } ] } diff --git a/lib/search.js b/lib/search.js new file mode 100644 index 0000000..911a376 --- /dev/null +++ b/lib/search.js @@ -0,0 +1,66 @@ +import { Op } from 'sequelize'; +import sequelize from './database.js'; + +const searchUsers = async (q) => { + if (!q) { + return []; + } + if (q.length < 3) { + return []; + } + const host = new URL(process.env.DOMAIN).host; + 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: curr.main, + absolute: `${curr.name}@${host}`, + }, + ], + }); + } else { + acc.find((user) => user.id === curr.User.id).names.push({ + name: curr.name, + main: curr.main, + absolute: `${curr.name}@${host}`, + }); + } + 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 = `${process.env.DOMAIN}/u/${user.hash}`; + }); + + return users; +}; + +export { searchUsers }; diff --git a/router.js b/router.js index 76bd7b7..c0d3826 100644 --- a/router.js +++ b/router.js @@ -32,6 +32,7 @@ import { } 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(); @@ -229,71 +230,26 @@ 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, - }); - } - 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; - } - }); - }); + 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)), ); - console.log(peersConfig); for (const peer of peersConfig.peers) { try { - const host = new URL(peer.url).host; - console.log( - `${ - peer.url - }/.well-known/webfinger?resource=acct:${encodeURIComponent( - q, - )}@${host}`, + const { data } = await axios.get( + `${peer.url}/api/search?q=${encodeURIComponent(q)}`, ); - // const { data } = await axios.get( - // `${ - // peer.url - // }/.well-known/webfinger?resource=acct:${encodeURIComponent( - // q, - // )}@${peer.host}`, - // ); + data.forEach((user) => { + user.peer = peer; + }); + users.push(...data); } catch (err) { - console.error(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 }); @@ -355,87 +311,6 @@ frontend.get('/u/:hash', async (req, res) => { } }); -// Webfinger-based user discovery -// Because users' usernames are not unique (for instance, two people could both -// be using the username "hunter2"), but searching is based exclusively on -// usernames, we return a definitely non-spec-compliant webfinger response - an -// array of compliant webfinger response objects, one per user with the given username. -// -// Example: -// Query: https://pointers.website/.well-known/webfinger?resource=acct:hunter2@pointers.website -// Response: -// [ -// { -// subject: 'acct:hunter2@pointers.website', -// links: [ -// { -// rel: 'http://webfinger.net/rel/profile-page', -// type: 'text/html', -// href: 'https://pointers.website/u/a1b2c3', -// }, -// ], -// }, -// { -// subject: 'acct:hunter2@pointers.website', -// links: [ -// { -// rel: 'http://webfinger.net/rel/profile-page', -// type: 'text/html', -// href: 'https://pointers.website/u/z9y8x7', -// }, -// ], -// }, -// ]; -frontend.get('/.well-known/webfinger', async (req, res) => { - const { resource } = req.query; - if (!resource) { - return res.status(400).send('Missing resource parameter.'); - } - if (!resource.startsWith('acct:')) { - return res.status(400).send('Invalid resource.'); - } - const [username, domain] = resource.split('acct:')[1].split('@'); - if (domain !== process.env.DOMAIN) { - return res.status(400).send('Invalid domain.'); - } - const users = await sequelize.models.User.findAll({ - where: { - '$UserNames.name$': username, - }, - attributes: ['id', 'hash'], - include: [ - { - model: sequelize.models.UserName, - attributes: ['name', 'main'], - }, - ], - }); - if (users.length === 0) { - return res.status(404).send('Account not found.'); - } - for (const user of users) { - user.UserNames = await sequelize.models.UserName.findAll({ - where: { UserId: user.id }, - attributes: ['name', 'main'], - }); - } - // Return the webfinger response - res.json( - users.map((user) => ({ - subject: `acct:${ - user.UserNames.find((n) => n.main === true).name - }@${process.env.DOMAIN}`, - links: [ - { - rel: 'http://webfinger.net/rel/profile-page', - type: 'text/html', - href: `https://${process.env.DOMAIN}/u/${user.hash}`, - }, - ], - })), - ); -}); - api.get('/healthcheck', (req, res) => { res.send('OK'); }); @@ -951,4 +826,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/search.eta b/views/search.eta index 182d990..193abdc 100644 --- a/views/search.eta +++ b/views/search.eta @@ -12,7 +12,7 @@ <% if (it.users.length > 0) { %> <% it.users.forEach((user) => { %> <% }) %> From 3422c5bdbb97004618e3a0a6e8d201d91d835020 Mon Sep 17 00:00:00 2001 From: Raphael Kabo Date: Thu, 26 Jan 2023 11:01:50 +0000 Subject: [PATCH 4/8] fix: search endpoint --- router.js | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/router.js b/router.js index c0d3826..d33826b 100644 --- a/router.js +++ b/router.js @@ -238,11 +238,8 @@ frontend.get('/search', redirectIfNotLoggedIn, async (req, res) => { for (const peer of peersConfig.peers) { try { const { data } = await axios.get( - `${peer.url}/api/search?q=${encodeURIComponent(q)}`, + `${peer.url}/api/users?q=${encodeURIComponent(q)}`, ); - data.forEach((user) => { - user.peer = peer; - }); users.push(...data); } catch (err) { // If it's a 404, it's because the peer is running an older version From 5e8d15f4c041bf054c045c32be6d17ea2483e599 Mon Sep 17 00:00:00 2001 From: Raphael Kabo Date: Fri, 14 Jul 2023 15:05:43 +0100 Subject: [PATCH 5/8] tweaks --- .gitignore | 3 +++ config/peers.json | 6 +++--- lib/search.js | 3 ++- router.js | 4 ++++ 4 files changed, 12 insertions(+), 4 deletions(-) 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/peers.json b/config/peers.json index 2382734..6e53a93 100644 --- a/config/peers.json +++ b/config/peers.json @@ -1,10 +1,10 @@ { - "peers": [ + 'peers': [ { - "url": "https://dev.pointers.website" + 'url': 'https://dev.pointers.website' }, { - "url": "https://pointers.website" + 'url': 'https://pointers.website' } ] } diff --git a/lib/search.js b/lib/search.js index 911a376..99ecaa7 100644 --- a/lib/search.js +++ b/lib/search.js @@ -32,7 +32,7 @@ const searchUsers = async (q) => { names: [ { name: curr.name, - main: curr.main, + main: Boolean(curr.main), absolute: `${curr.name}@${host}`, }, ], @@ -58,6 +58,7 @@ const searchUsers = async (q) => { }); // Add the URL to the user's profile user.url = `${process.env.DOMAIN}/u/${user.hash}`; + user.host = process.env.DOMAIN; }); return users; diff --git a/router.js b/router.js index d33826b..04ade45 100644 --- a/router.js +++ b/router.js @@ -240,6 +240,10 @@ frontend.get('/search', redirectIfNotLoggedIn, async (req, res) => { 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; + }); users.push(...data); } catch (err) { // If it's a 404, it's because the peer is running an older version From 869e7b4e4bab09b0296af6d3db843a1dbfbe9512 Mon Sep 17 00:00:00 2001 From: Raphael Kabo Date: Fri, 14 Jul 2023 15:13:58 +0100 Subject: [PATCH 6/8] fix host in search results --- lib/search.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/search.js b/lib/search.js index 99ecaa7..0e8136a 100644 --- a/lib/search.js +++ b/lib/search.js @@ -58,7 +58,8 @@ const searchUsers = async (q) => { }); // Add the URL to the user's profile user.url = `${process.env.DOMAIN}/u/${user.hash}`; - user.host = process.env.DOMAIN; + // The host is the domain name without the protocol + user.host = host; }); return users; From 959d71dbb6a7fab9b55c95804dbcbad59b2994a7 Mon Sep 17 00:00:00 2001 From: Raphael Kabo Date: Fri, 14 Jul 2023 15:18:19 +0100 Subject: [PATCH 7/8] read database from .env --- lib/database.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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', From 0b7cfee824ebd013a05bcc49832566ce0144a418 Mon Sep 17 00:00:00 2001 From: Raphael Kabo Date: Fri, 11 Aug 2023 11:21:54 +0100 Subject: [PATCH 8/8] Search and federation tweaks --- config/peers.json | 6 +++--- lib/search.js | 11 +++++------ public/css/style.css | 19 +++++++++++++++++-- views/layout.eta | 2 +- views/search.eta | 3 ++- 5 files changed, 28 insertions(+), 13 deletions(-) diff --git a/config/peers.json b/config/peers.json index 6e53a93..2382734 100644 --- a/config/peers.json +++ b/config/peers.json @@ -1,10 +1,10 @@ { - 'peers': [ + "peers": [ { - 'url': 'https://dev.pointers.website' + "url": "https://dev.pointers.website" }, { - 'url': 'https://pointers.website' + "url": "https://pointers.website" } ] } diff --git a/lib/search.js b/lib/search.js index 0e8136a..bde2c8a 100644 --- a/lib/search.js +++ b/lib/search.js @@ -8,7 +8,7 @@ const searchUsers = async (q) => { if (q.length < 3) { return []; } - const host = new URL(process.env.DOMAIN).host; + const domain = process.env.DOMAIN; const userNames = await sequelize.models.UserName.findAll({ where: { name: { @@ -33,7 +33,7 @@ const searchUsers = async (q) => { { name: curr.name, main: Boolean(curr.main), - absolute: `${curr.name}@${host}`, + absolute: `${curr.name}@${domain}`, }, ], }); @@ -41,7 +41,7 @@ const searchUsers = async (q) => { acc.find((user) => user.id === curr.User.id).names.push({ name: curr.name, main: curr.main, - absolute: `${curr.name}@${host}`, + absolute: `${curr.name}@${domain}`, }); } return acc; @@ -57,9 +57,8 @@ const searchUsers = async (q) => { } }); // Add the URL to the user's profile - user.url = `${process.env.DOMAIN}/u/${user.hash}`; - // The host is the domain name without the protocol - user.host = host; + user.url = `https://${process.env.DOMAIN}/u/${user.hash}`; + user.domain = domain; }); return users; 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/views/layout.eta b/views/layout.eta index 7d0f87b..ad49c41 100644 --- a/views/layout.eta +++ b/views/layout.eta @@ -10,7 +10,7 @@