Skip to content

Commit

Permalink
Merge pull request #17 from planetary-social/add-metadata
Browse files Browse the repository at this point in the history
Adds agent, ip and updated_at to entries
  • Loading branch information
dcadenas authored Feb 22, 2024
2 parents c597988 + 42f1a4d commit 2fdaf37
Show file tree
Hide file tree
Showing 7 changed files with 199 additions and 61 deletions.
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

0 comments on commit 2fdaf37

Please sign in to comment.