diff --git a/README.md b/README.md index 67bb404..7a18389 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,10 @@ pnpm test ## Usage +We expect that clients will hit specific endpoints for creation and updates (POST) and deletion (DELETE). + +Although the NIP accepts dots and underscores in names, we only allow a smaller subset without them so that we are more friendly to http redirection. + ### POST Endpoint To securely authenticate POST requests to the `nip05api` endpoint, utilize the [NIP-98](https://github.com/nostr-protocol/nips/blob/master/98.md) HTTP authentication method. This involves creating a signed Nostr event as per NIP 98 specifications, encoding it in base64, and including it in the `Authorization` header. @@ -90,6 +94,10 @@ The GET endpoint implements NIP-05 functionality. No authentication is required curl -H 'Host: nos.social' http://127.0.0.1:3000/.well-known/nostr.json?name=alice ``` +### External Setup + +We configure rate limits and redirects to njump through our [Traefik infra config](https://github.com/planetary-social/ansible-scripts/tree/main/roles/nos_social) + ## Contributing Contributions are welcome! Fork the project, submit pull requests, or report issues. diff --git a/src/app.js b/src/app.js index 3a5d1a5..9578ea6 100644 --- a/src/app.js +++ b/src/app.js @@ -6,8 +6,10 @@ import pinoHTTP from "pino-http"; import promClient from "prom-client"; import promBundle from "express-prom-bundle"; import cors from "cors"; +import NameRecordRepository from "./nameRecordRepository.js"; const redisClient = await getRedisClient(); +const nameRecordRepository = new NameRecordRepository(redisClient); const app = express(); const metricsMiddleware = promBundle({ @@ -30,7 +32,7 @@ app.use( ); app.use((req, res, next) => { - req.redis = redisClient; + req.nameRecordRepo = nameRecordRepository; next(); }); diff --git a/src/middlewares/extractNip05Name.js b/src/middlewares/extractNip05Name.js index 30bef1e..b6af35a 100644 --- a/src/middlewares/extractNip05Name.js +++ b/src/middlewares/extractNip05Name.js @@ -1,6 +1,7 @@ import config from "../../config/index.js"; import asyncHandler from "./asyncHandler.js"; import { AppError } from "../errors.js"; +import { validateName } from "../nameRecord.js"; export default function extractNip05Name(req, res, next) { return asyncHandler("extractNip05Name", async (req, res) => { @@ -27,23 +28,6 @@ function extractName(req) { return name; } -function validateName(name) { - if (name.length < 3) { - throw new AppError( - 422, - `Name '${name}' should have more than 3 characters.` - ); - } - - if (name.startsWith("-")) { - throw new AppError(422, `Name '${name}' should not start with a hyphen.`); - } - - if (name.endsWith("-")) { - throw new AppError(422, `Name '${name}' should not start with a hyphen.`); - } -} - function validateDomain(host) { if (!host.endsWith(config.rootDomain)) { throw new AppError( diff --git a/src/nameRecord.js b/src/nameRecord.js new file mode 100644 index 0000000..c981557 --- /dev/null +++ b/src/nameRecord.js @@ -0,0 +1,44 @@ +import { AppError } from "./errors.js"; +export default class NameRecord { + constructor( + name, + pubkey, + relays = [], + clientIp = "", + userAgent = "", + updated_at + ) { + validateName(name); + + this.name = name; + this.pubkey = pubkey; + this.relays = relays; + this.clientIp = clientIp; + this.userAgent = userAgent; + this.updated_at = updated_at; + } +} + +export function validateName(name) { + if (name.length < 3) { + throw new AppError( + 422, + `Name '${name}' should have more than 3 characters.` + ); + } + + if (name.startsWith("-")) { + throw new AppError(422, `Name '${name}' should not start with a hyphen -.`); + } + + if (name.endsWith("-")) { + throw new AppError(422, `Name '${name}' should not start with a hyphen -.`); + } + + if (name.includes("_")) { + throw new AppError( + 422, + `Name '${name}' should not include an underscore _.` + ); + } +} diff --git a/src/nameRecordRepository.js b/src/nameRecordRepository.js new file mode 100644 index 0000000..661c76d --- /dev/null +++ b/src/nameRecordRepository.js @@ -0,0 +1,90 @@ +import NameRecord from "./nameRecord.js"; +import { AppError } from "./errors.js"; + +const MAX_ENTRIES = 1000; +export default class NameRecordRepository { + constructor(redisClient) { + this.redis = redisClient; + } + + async findByName(name) { + const luaScript = ` + local pubkey = redis.call('GET', 'pubkey:' .. KEYS[1]) + if not pubkey then return nil end + + local relays = redis.call('SMEMBERS', 'relays:' .. pubkey) + local userAgent = redis.call('GET', 'user_agent:' .. pubkey) + local clientIp = redis.call('GET', 'ip:' .. pubkey) + local updatedAt = redis.call('GET', 'updated_at:' .. pubkey) + + return {pubkey, relays, userAgent, clientIp, updatedAt} + `; + + const result = await this.redis.eval(luaScript, 1, name); + if (!result) return null; + + const [pubkey, relays, userAgent, clientIp, updatedAt] = result; + + return new NameRecord(name, pubkey, relays, clientIp, userAgent, updatedAt); + } + + async save(nameRecord) { + const { name, pubkey, relays, clientIp, userAgent } = nameRecord; + const updated_at = new Date().toISOString(); + const timestamp = new Date(updated_at).getTime() / 1000; // Convert to UNIX timestamp + + const currentPubkey = await this.redis.get(`pubkey:${name}`); + if (currentPubkey && currentPubkey !== pubkey) { + throw new AppError( + 409, + "Conflict: pubkey already exists, you can only change associated relays." + ); + } + + const pipeline = this.redis.multi(); + pipeline.set(`pubkey:${name}`, pubkey); + + pipeline.del(`relays:${pubkey}`); + if (relays && relays.length) { + pipeline.sadd(`relays:${pubkey}`, ...relays); + } + if (clientIp) { + pipeline.set(`ip:${pubkey}`, clientIp); + } + if (userAgent) { + pipeline.set(`user_agent:${pubkey}`, userAgent); + } + pipeline.set(`updated_at:${pubkey}`, updated_at); + + pipeline.zadd(`name_record_updates`, timestamp, name); + // Keep the latest maxEntries records by removing older ones + pipeline.zremrangebyrank(`name_record_updates`, 0, -(MAX_ENTRIES + 1)); + + await pipeline.exec(); + } + + async deleteByName(name) { + const pubkey = await this.redis.get(`pubkey:${name}`); + if (!pubkey) return false; + + const pipeline = this.redis.multi(); + pipeline.del(`pubkey:${name}`); + pipeline.del(`relays:${pubkey}`); + pipeline.del(`ip:${pubkey}`); + pipeline.del(`user_agent:${pubkey}`); + pipeline.del(`updated_at:${pubkey}`); + pipeline.zrem(`name_record_updates`, name); + + await pipeline.exec(); + return true; + } + + async findLatest(limit = 10) { + const names = await this.redis.zrevrange("nameRecordUpdates", 0, limit - 1); + const records = await Promise.all( + names.map((name) => this.findByName(name)) + ); + + return records; // These are sorted by updated_at due to the sorted set's ordering + } +} diff --git a/src/routes.js b/src/routes.js index 5a86967..99e9d3b 100644 --- a/src/routes.js +++ b/src/routes.js @@ -7,6 +7,7 @@ import { postNip05, nip05QueryName, nip05ParamsName } from "./schemas.js"; import nip98Auth from "./middlewares/nip98Auth.js"; import config from "../config/index.js"; import { AppError, UNAUTHORIZED_STATUS } from "./errors.js"; +import NameRecord from "./nameRecord.js"; const router = Router(); @@ -15,20 +16,16 @@ router.get( validateSchema(nip05QueryName), extractNip05Name, asyncHandler("getNip05", async (req, res) => { - const name = req.nip05Name; + const nameRecord = await req.nameRecordRepo.findByName(req.nip05Name); - const pubkey = await req.redis.get(`pubkey:${name}`); - if (!pubkey) { - throw new AppError(404, `Name ${name} not found`); + if (!nameRecord) { + throw new AppError(404, `Name ${req.nip05Name} not found`); } - logger.info(`Found pubkey: ${pubkey} for ${name}`); - - const relays = await req.redis.smembers(`relays:${pubkey}`); - - const response = { names: {}, relays: {} }; - response.names[name] = pubkey; - response.relays[pubkey] = relays; + const response = { + names: { [nameRecord.name]: nameRecord.pubkey }, + relays: { [nameRecord.pubkey]: nameRecord.relays }, + }; res.status(200).json(response); }) @@ -43,29 +40,21 @@ router.post( const { data: { pubkey, relays }, } = req.body; - const name = req.nip05Name; - const currentPubkey = await req.redis.get(`pubkey:${name}`); - - if (currentPubkey && currentPubkey !== pubkey) { - return res - .status(409) - .send( - "Conflict: pubkey already exists, you can only change associated relays." - ); - } - - const pipeline = req.redis.multi(); - pipeline.set(`pubkey:${name}`, pubkey); - pipeline.del(`relays:${pubkey}`); - if (relays?.length) { - pipeline.sadd(`relays:${pubkey}`, ...relays); - } - - const result = await pipeline.exec(); - logger.info(`Added ${name} with pubkey ${pubkey}`); + const clientIp = getClientIp(req); + const userAgent = req.headers["user-agent"]; + + const nameRecord = new NameRecord( + name, + pubkey, + relays, + clientIp, + userAgent + ); + await req.nameRecordRepo.save(nameRecord); - res.status(200).json(); + logger.info(`Added/Updated ${name} with pubkey ${pubkey}`); + res.status(200).json({ message: "Name record saved successfully." }); }) ); @@ -76,20 +65,14 @@ router.delete( nip98Auth(validatePubkey), asyncHandler("deleteNip05", async (req, res) => { const name = req.nip05Name; + const deleted = await req.nameRecordRepo.deleteByName(name); - const pubkey = await req.redis.get(`pubkey:${name}`); - if (!pubkey) { + if (!deleted) { throw new AppError(404, "Name not found"); } - const pipeline = req.redis.multi(); - pipeline.del(`relays:${pubkey}`); - pipeline.del(`pubkey:${name}`); - await pipeline.exec(); - - logger.info(`Deleted ${name} with pubkey ${pubkey}`); - - res.status(200).json(); + logger.info(`Deleted ${name}`); + res.status(200).json({ message: "Name record deleted successfully." }); }) ); @@ -120,7 +103,7 @@ if (process.env.NODE_ENV === "test") { */ async function validatePubkey(authEvent, req) { const name = req.nip05Name; - const storedPubkey = await req.redis.get(`pubkey:${name}`); + const storedPubkey = await req.nameRecordRepo.findByName(name).pubkey; const payloadPubkey = req.body?.data?.pubkey; const isServicePubkey = authEvent.pubkey === config.servicePubkey; @@ -154,4 +137,20 @@ async function validatePubkey(authEvent, req) { } } +function getClientIp(req) { + const forwardedIpsStr = req.headers["x-forwarded-for"]; + const realIp = req.headers["x-real-ip"]; + + if (forwardedIpsStr) { + const forwardedIps = forwardedIpsStr.split(","); + return forwardedIps[0]; + } + + if (realIp) { + return realIp; + } + + return req.socket.remoteAddress; +} + export default router; diff --git a/test/app.test.js b/test/app.test.js index 02ae124..ed6698b 100644 --- a/test/app.test.js +++ b/test/app.test.js @@ -144,6 +144,17 @@ describe("Nostr NIP 05 API tests", () => { .expect(422); }); + it("should fail if the name includes an underscore", async () => { + const userData = createUserData({ name: "aa_" }); + + await request(app) + .post("/api/names") + .set("Host", "nos.social") + .set("Authorization", `Nostr ${nip98PostAuthToken}`) + .send(userData) + .expect(422); + }); + it("should fail if the name is not found", async () => { await request(app) .get("/.well-known/nostr.json")