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

feat: nip-05 api #392

Merged
merged 6 commits into from
Oct 4, 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
33 changes: 17 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,21 +17,22 @@ If you'd like to help me test the reliability of this relay implementation, you

🟢 Full implemented 🟡 Partially implemented 🔴 Not implemented

| Feature | Status | Note |
| ------------------------------------------------------------------------------------------------------- | :----: | ---------------------------------------- |
| [NIP-01: Basic protocol flow description](https://github.com/nostr-protocol/nips/blob/master/01.md) | 🟢 | |
| [NIP-02: Contact List and Petnames](https://github.com/nostr-protocol/nips/blob/master/02.md) | 🟢 | |
| [NIP-04: Encrypted Direct Message](https://github.com/nostr-protocol/nips/blob/master/04.md) | 🟢 | |
| [NIP-09: Event Deletion](https://github.com/nostr-protocol/nips/blob/master/09.md) | 🔴 | No real deletion in a distributed system |
| [NIP-11: Relay Information Document](https://github.com/nostr-protocol/nips/blob/master/11.md) | 🟢 | |
| [NIP-13: Proof of Work](https://github.com/nostr-protocol/nips/blob/master/13.md) | 🟢 | |
| [NIP-22: Event created_at Limits](https://github.com/nostr-protocol/nips/blob/master/22.md) | 🟢 | |
| [NIP-26: Delegated Event Signing](https://github.com/nostr-protocol/nips/blob/master/26.md) | 🟢 | |
| [NIP-28: Public Chat](https://github.com/nostr-protocol/nips/blob/master/28.md) | 🟢 | |
| [NIP-40: Expiration Timestamp](https://github.com/nostr-protocol/nips/blob/master/40.md) | 🟢 | |
| [NIP-42: Authentication of clients to relays](https://github.com/nostr-protocol/nips/blob/master/42.md) | 🟢 | |
| [NIP-45: Counting results](https://github.com/nostr-protocol/nips/blob/master/45.md) | 🔴 | |
| [NIP-50: Keywords filter](https://github.com/nostr-protocol/nips/blob/master/50.md) | 🟢 | |
| Feature | Status | Note |
| ------------------------------------------------------------------------------------------------------------------------ | :----: | ---------------------------------------- |
| [NIP-01: Basic protocol flow description](https://github.com/nostr-protocol/nips/blob/master/01.md) | 🟢 | |
| [NIP-02: Follow List](https://github.com/nostr-protocol/nips/blob/master/02.md) | 🟢 | |
| [NIP-04: Encrypted Direct Message](https://github.com/nostr-protocol/nips/blob/master/04.md) | 🟢 | |
| [NIP-05: Mapping Nostr keys to DNS-based internet identifiers](https://github.com/nostr-protocol/nips/blob/master/05.md) | 🟢 | |
| [NIP-09: Event Deletion](https://github.com/nostr-protocol/nips/blob/master/09.md) | 🔴 | No real deletion in a distributed system |
| [NIP-11: Relay Information Document](https://github.com/nostr-protocol/nips/blob/master/11.md) | 🟢 | |
| [NIP-13: Proof of Work](https://github.com/nostr-protocol/nips/blob/master/13.md) | 🟢 | |
| [NIP-22: Event created_at Limits](https://github.com/nostr-protocol/nips/blob/master/22.md) | 🟢 | |
| [NIP-26: Delegated Event Signing](https://github.com/nostr-protocol/nips/blob/master/26.md) | 🟢 | |
| [NIP-28: Public Chat](https://github.com/nostr-protocol/nips/blob/master/28.md) | 🟢 | |
| [NIP-40: Expiration Timestamp](https://github.com/nostr-protocol/nips/blob/master/40.md) | 🟢 | |
| [NIP-42: Authentication of clients to relays](https://github.com/nostr-protocol/nips/blob/master/42.md) | 🟢 | |
| [NIP-45: Counting results](https://github.com/nostr-protocol/nips/blob/master/45.md) | 🔴 | |
| [NIP-50: Keywords filter](https://github.com/nostr-protocol/nips/blob/master/50.md) | 🟢 | |

## Extra Features

Expand All @@ -45,7 +46,7 @@ If you want to enable the WoT feature, you need to set the following environment

### RESTful API

You can see the API documentation at `/api` endpoint.
You can see the API documentation at `/api` endpoint. [Example](https://nostr-relay.app/api)

### TOP verb

Expand Down
16 changes: 16 additions & 0 deletions migrations/1727942395255-create-nip-05-table.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Kysely, sql } from 'kysely';

export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('nip05')
.addColumn('name', 'varchar(20)', (col) => col.primaryKey())
.addColumn('pubkey', 'char(64)', (col) => col.notNull())
.addColumn('create_date', 'timestamp', (col) =>
col.defaultTo(sql`now()`).notNull(),
)
.execute();
}

export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable('nip05').execute();
}
8 changes: 6 additions & 2 deletions src/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { APP_FILTER } from '@nestjs/core';
import { APP_FILTER, APP_GUARD } from '@nestjs/core';
import { ScheduleModule } from '@nestjs/schedule';
import { ThrottlerModule } from '@nestjs/throttler';
import { LoggerModule } from 'nestjs-pino';
import { GlobalExceptionFilter } from './common/filters';
import { ParseNostrAuthorizationGuard } from './common/guards';
import { Config, config } from './config';
import { Nip05Module } from './modules/nip-05/nip-05.module';
import { NostrModule } from './modules/nostr/nostr.module';
Expand Down Expand Up @@ -33,6 +34,9 @@ import { loggerModuleFactory } from './utils';
Nip05Module,
TaskModule,
],
providers: [{ provide: APP_FILTER, useClass: GlobalExceptionFilter }],
providers: [
{ provide: APP_FILTER, useClass: GlobalExceptionFilter },
{ provide: APP_GUARD, useClass: ParseNostrAuthorizationGuard },
],
})
export class AppModule {}
16 changes: 12 additions & 4 deletions src/common/filters/global-exception.filter.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createMock } from '@golevelup/ts-jest';
import { ArgumentsHost, HttpException } from '@nestjs/common';
import { ArgumentsHost, HttpException, HttpStatus } from '@nestjs/common';
import { createOutgoingNoticeMessage } from '@nostr-relay/common';
import { PinoLogger } from 'nestjs-pino';
import { ClientException } from '../exceptions';
Expand Down Expand Up @@ -74,7 +74,11 @@ describe('GlobalExceptionFilter', () => {
globalExceptionFilter.catch(error, host);

expect(mockStatus).toHaveBeenCalledWith(500);
expect(mockSend).toHaveBeenCalledWith('Internal Server Error!');
expect(mockSend).toHaveBeenCalledWith({
message: 'Internal Server Error!',
error: 'Internal Error',
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
});
});

it('should return ISE when catch a 5xx http exception', () => {
Expand All @@ -96,7 +100,11 @@ describe('GlobalExceptionFilter', () => {
globalExceptionFilter.catch(error, host);

expect(mockStatus).toHaveBeenCalledWith(501);
expect(mockSend).toHaveBeenCalledWith('Internal Server Error!');
expect(mockSend).toHaveBeenCalledWith({
message: 'Internal Server Error!',
error: 'Internal Error',
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
});
});

it('should return error msg when catch a 4xx http exception', () => {
Expand All @@ -118,7 +126,7 @@ describe('GlobalExceptionFilter', () => {
globalExceptionFilter.catch(error, host);

expect(mockStatus).toHaveBeenCalledWith(404);
expect(mockSend).toHaveBeenCalledWith('test');
expect(mockSend).toHaveBeenCalledWith(error.getResponse());
});
});

Expand Down
15 changes: 12 additions & 3 deletions src/common/filters/global-exception.filter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
Catch,
ExceptionFilter,
HttpException,
HttpStatus,
NotFoundException,
} from '@nestjs/common';
import { createOutgoingNoticeMessage } from '@nostr-relay/common';
Expand Down Expand Up @@ -47,14 +48,22 @@ export class GlobalExceptionFilter implements ExceptionFilter {

private handleHttpException(error: Error, response: Response) {
if (!(error instanceof HttpException)) {
return response.status(500).send('Internal Server Error!');
return response.status(HttpStatus.INTERNAL_SERVER_ERROR).send({
message: 'Internal Server Error!',
error: 'Internal Error',
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
});
}

const status = error.getStatus();
if (status >= 500 && status < 600) {
return response.status(status).send('Internal Server Error!');
return response.status(status).send({
message: 'Internal Server Error!',
error: 'Internal Error',
statusCode: HttpStatus.INTERNAL_SERVER_ERROR,
});
}

return response.status(status).send(error.message);
return response.status(status).send(error.getResponse());
}
}
40 changes: 40 additions & 0 deletions src/common/guards/admin-only.guard.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { createMock } from '@golevelup/ts-jest';
import { AdminOnlyGuard } from './admin-only.guard';
import { ConfigService } from '@nestjs/config';
import { ExecutionContext } from '@nestjs/common';

describe('AdminOnlyGuard', () => {
let guard: AdminOnlyGuard;

beforeEach(() => {
guard = new AdminOnlyGuard(
createMock<ConfigService>({
get: jest.fn().mockReturnValue({ pubkey: 'admin-pubkey' }),
}),
);
});

it('should return false if adminPubkey is not set', () => {
(guard as any).adminPubkey = undefined;
const context = createMock<ExecutionContext>();
expect(guard.canActivate(context)).toBe(false);
});

it('should return false if pubkey is not equal to adminPubkey', () => {
const context = createMock<ExecutionContext>({
switchToHttp: () => ({
getRequest: () => ({ pubkey: 'not-admin-pubkey' }),
}),
});
expect(guard.canActivate(context)).toBe(false);
});

it('should return true if pubkey is equal to adminPubkey', () => {
const context = createMock<ExecutionContext>({
switchToHttp: () => ({
getRequest: () => ({ pubkey: 'admin-pubkey' }),
}),
});
expect(guard.canActivate(context)).toBe(true);
});
});
22 changes: 22 additions & 0 deletions src/common/guards/admin-only.guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Request } from 'express';
import { Config } from 'src/config';

@Injectable()
export class AdminOnlyGuard implements CanActivate {
private readonly adminPubkey: string | undefined;

constructor(config: ConfigService<Config, true>) {
const relayInfo = config.get('relayInfo', { infer: true });
this.adminPubkey = relayInfo.pubkey;
}

canActivate(context: ExecutionContext): boolean {
if (!this.adminPubkey) {
return false;
}
const request = context.switchToHttp().getRequest<Request>();
return request.pubkey === this.adminPubkey;
}
}
3 changes: 2 additions & 1 deletion src/common/guards/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './ws-throttler.guard';
export * from './admin-only.guard';
export * from './parse-nostr-authorization.guard';
export * from './ws-throttler.guard';
85 changes: 77 additions & 8 deletions src/common/guards/parse-nostr-authorization.guard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,10 @@ describe('ParseNostrAuthorizationGuard', () => {
it('should directly return true if event is invalid', async () => {
const event = createEvent({
kind: 27235,
tags: [['u', 'http://localhost']],
tags: [
['u', 'http://localhost/test'],
['method', 'GET'],
],
created_at: Math.floor(Date.now() / 1000),
});
// modify event id to make it invalid
Expand All @@ -68,7 +71,10 @@ describe('ParseNostrAuthorizationGuard', () => {
it('should directly return true if token is expired', async () => {
const event = createEvent({
kind: 27235,
tags: [['u', 'http://localhost']],
tags: [
['u', 'http://localhost/test'],
['method', 'GET'],
],
created_at: Math.floor(Date.now() / 1000) - 61, // 1 minute ago
});
const token = Buffer.from(JSON.stringify(event)).toString('base64');
Expand All @@ -84,7 +90,10 @@ describe('ParseNostrAuthorizationGuard', () => {
it('should directly return true if event kind is not 27235', async () => {
const event = createEvent({
kind: 1,
tags: [['u', 'http://localhost']],
tags: [
['u', 'http://localhost/test'],
['method', 'GET'],
],
});
const token = Buffer.from(JSON.stringify(event)).toString('base64');
const authorization = 'Nostr ' + token;
Expand All @@ -99,6 +108,7 @@ describe('ParseNostrAuthorizationGuard', () => {
it('should directly return true if u tag is not set', async () => {
const event = createEvent({
kind: 27235,
tags: [['method', 'GET']],
});
const token = Buffer.from(JSON.stringify(event)).toString('base64');
const authorization = 'Nostr ' + token;
Expand All @@ -113,7 +123,10 @@ describe('ParseNostrAuthorizationGuard', () => {
it('should directly return true if u tag value is not the same as hostname', async () => {
const event = createEvent({
kind: 1,
tags: [['u', 'http://test.com']],
tags: [
['u', 'http://test.com/test'],
['method', 'GET'],
],
});
const token = Buffer.from(JSON.stringify(event)).toString('base64');
const authorization = 'Nostr ' + token;
Expand All @@ -125,10 +138,64 @@ describe('ParseNostrAuthorizationGuard', () => {
expect(request.pubkey).toBeUndefined();
});

it('should parse pubkey sucessfully', async () => {
it('should directly return true if method tag value is not the same as request method', async () => {
const event = createEvent({
kind: 27235,
tags: [['u', 'http://localhost']],
tags: [
['u', 'http://localhost/test'],
['method', 'POST'],
],
});
const token = Buffer.from(JSON.stringify(event)).toString('base64');
const authorization = 'Nostr ' + token;
const { request, context } = createMockContext({
authorization,
});

expect(await guard.canActivate(context)).toBe(true);
expect(request.pubkey).toBeUndefined();
});

it('should directly return true if method tag is not set', async () => {
const event = createEvent({
kind: 27235,
tags: [['u', 'http://localhost/test']],
});
const token = Buffer.from(JSON.stringify(event)).toString('base64');
const authorization = 'Nostr ' + token;
const { request, context } = createMockContext({
authorization,
});

expect(await guard.canActivate(context)).toBe(true);
expect(request.pubkey).toBeUndefined();
});

it('should directly return true if url pathname is not the same as request url', async () => {
const event = createEvent({
kind: 27235,
tags: [
['u', 'http://localhost/test2'],
['method', 'GET'],
],
});
const token = Buffer.from(JSON.stringify(event)).toString('base64');
const authorization = 'Nostr ' + token;
const { request, context } = createMockContext({
authorization,
});

expect(await guard.canActivate(context)).toBe(true);
expect(request.pubkey).toBeUndefined();
});

it('should parse pubkey successfully', async () => {
const event = createEvent({
kind: 27235,
tags: [
['u', 'http://localhost/test'],
['method', 'GET'],
],
});
const token = Buffer.from(JSON.stringify(event)).toString('base64');
const authorization = 'Nostr ' + token;
Expand All @@ -142,9 +209,11 @@ describe('ParseNostrAuthorizationGuard', () => {
});

function createMockContext(headers: Record<string, string> = {}) {
const request: { headers: Record<string, string>; pubkey?: string } = {
const request = {
headers,
};
url: '/test',
method: 'GET',
} as any;
const context = createMock<ExecutionContext>({
getClass: jest.fn().mockReturnValue({ name: 'Test' }),
getHandler: jest.fn().mockReturnValue({ name: 'test' }),
Expand Down
16 changes: 15 additions & 1 deletion src/common/guards/parse-nostr-authorization.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,21 @@ export class ParseNostrAuthorizationGuard implements CanActivate {
}

const uTagValue = event.tags.find(([tagName]) => tagName === 'u')?.[1];
if (!uTagValue || new URL(uTagValue).hostname !== this.hostname) {
if (!uTagValue) {
return true;
}
const url = new URL(uTagValue);
if (url.hostname !== this.hostname || url.pathname !== request.url) {
return true;
}

const methodTagValue = event.tags.find(
([tagName]) => tagName === 'method',
)?.[1];
if (
!methodTagValue ||
methodTagValue.toUpperCase() !== request.method.toUpperCase()
) {
return true;
}

Expand Down
Empty file added src/common/pipes/index.ts
Empty file.
Loading
Loading