Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adds agent, ip and updated_at to entries #17

Merged
merged 5 commits into from
Feb 22, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.

Expand Down
4 changes: 3 additions & 1 deletion src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -30,7 +32,7 @@ app.use(
);

app.use((req, res, next) => {
req.redis = redisClient;
req.nameRecordRepo = nameRecordRepository;
next();
});

Expand Down
18 changes: 1 addition & 17 deletions src/middlewares/extractNip05Name.js
Original file line number Diff line number Diff line change
@@ -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) => {
Expand All @@ -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(
Expand Down
44 changes: 44 additions & 0 deletions src/nameRecord.js
Original file line number Diff line number Diff line change
@@ -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 _.`
);
}
}
90 changes: 90 additions & 0 deletions src/nameRecordRepository.js
Original file line number Diff line number Diff line change
@@ -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
}
}
85 changes: 42 additions & 43 deletions src/routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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);
})
Expand All @@ -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." });
})
);

Expand All @@ -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." });
})
);

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
11 changes: 11 additions & 0 deletions test/app.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down