Add contact, upgrade versions, add docs

This commit is contained in:
2024-10-15 17:29:35 -06:00
parent 2fff8e9887
commit d48040bd58
23 changed files with 2299 additions and 2151 deletions

View File

@@ -15,3 +15,5 @@ S3_SECRET_KEY="localminio"
S3_BUCKET="devbucket"
FOCO_LIVE_AIRTABLE_APIKEY=
MAILGUN_SEND_KEY_HOOLI=

3
.gitignore vendored
View File

@@ -37,3 +37,6 @@ lerna-debug.log*
!.vscode/extensions.json
data/
# Ignore Bruno for now
api.us.dev/

View File

@@ -41,7 +41,8 @@
"haversine-ts": "^1.2.0",
"hbs": "^4.2.0",
"ioredis": "^5.3.2",
"minio": "^7.1.3",
"mailgun.js": "^10.2.3",
"minio": "^8.0.1",
"open-graph-scraper": "^6.3.0",
"prom-client": "^15.0.0",
"ramda": "^0.29.0",

View File

@@ -10,7 +10,6 @@ import { DomainrproxyModule } from './domainrproxy/domainrproxy.module';
import configuration from './config/configuration';
import { CacheInterceptor, CacheModule } from '@nestjs/cache-manager';
import { IrcbotModule } from './ircbot/ircbot.module';
import { OgScraperModule } from './ogscraper/ogscraper.module';
import { PrometheusModule } from '@willsoto/nestjs-prometheus';
import { MinioModule } from './minio/minio.module';
import { KvModule } from './kv/kv.module';
@@ -28,6 +27,8 @@ import { RedisModule, RedisModuleOptions } from '@liaoliaots/nestjs-redis';
import { AdsbExchangeModule } from './adsb-exchange/adsb-exchange.module';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { JunkDrawerModule } from './junk-drawer/junk-drawer.module';
import { EmailModule } from './email/email.module';
import { ContactModule } from './contact/contact.module';
@Module({
imports: [
@@ -91,7 +92,6 @@ import { JunkDrawerModule } from './junk-drawer/junk-drawer.module';
UsersModule,
DomainrproxyModule,
IrcbotModule,
OgScraperModule,
MinioModule,
KvModule,
RedirectsModule,
@@ -102,6 +102,8 @@ import { JunkDrawerModule } from './junk-drawer/junk-drawer.module';
JobsModule,
AdsbExchangeModule,
JunkDrawerModule,
EmailModule,
ContactModule,
],
controllers: [AppController],
providers: [
@@ -111,5 +113,6 @@ import { JunkDrawerModule } from './junk-drawer/junk-drawer.module';
useClass: CacheInterceptor,
},
],
exports: [PrometheusModule],
})
export class AppModule {}

View File

@@ -26,6 +26,20 @@ class HashDto {
rounds?: number;
}
class LoginDto {
@ApiProperty({
description: 'The username to authenticate',
default: 'admin',
})
username: string;
@ApiProperty({
description: 'The password to authenticate',
default: 'password',
})
password: string;
}
@ApiTags('auth')
@Controller('auth')
export class AuthController {
@@ -33,6 +47,7 @@ export class AuthController {
@HttpCode(HttpStatus.OK)
@Post('login')
@ApiBody({ type: LoginDto })
signIn(@Body() signInDto: Record<string, any>) {
return this.authService.signIn(signInDto.username, signInDto.password);
}

View File

@@ -44,4 +44,7 @@ export default () => ({
bucketName: process.env.JUNK_DRAWER_BUCKET_NAME ?? 'junk-drawer',
rootPath: process.env.JUNK_DRAWER_ROOT_PATH ?? '',
},
mailgun: {
hooliKey: process.env.MAILGUN_SEND_KEY_HOOLI ?? '',
},
});

View File

@@ -0,0 +1,85 @@
import {
Body,
Controller,
Get,
NotFoundException,
Param,
Post,
UseInterceptors,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiConsumes, ApiParam, ApiQuery, ApiTags } from '@nestjs/swagger';
import { EmailService } from 'src/email/email.service';
import { PowService } from 'src/pow/pow.service';
interface Destination {
email: string;
powChallenge: boolean;
}
type Destinations = {
[key: string]: Destination;
};
const destinations: Destinations = {
focolive: {
// email: 'hello@fortcollinslive.com',
email: 'mailtest@chip.bz',
powChallenge: true,
},
};
@Controller('contact')
@ApiTags('contact')
export class ContactController {
constructor(
private readonly powService: PowService,
private readonly emailService: EmailService,
) {}
@Get(':destination/challenge')
@ApiParam({
name: 'destination',
required: true,
description: 'The destination to send the email to, the contact "class"',
})
async getChallenge(@Param('destination') destination: string) {
const dest = destinations[destination];
if (!dest) {
throw new NotFoundException();
}
if (dest.powChallenge) {
return {
challenge: await this.powService.generateChallenge(),
difficulty: this.powService.getDifficulty(),
};
}
}
@Post(':destination')
@ApiConsumes('multipart/form-data')
@UseInterceptors(FileInterceptor('file'))
async sendEmail(
@Param('destination') destination: string,
@Body('challenge') challenge: string,
@Body('proof') proof: string,
@Body('subject') subject: string,
@Body('text') text: string,
) {
const dest = destinations[destination];
if (!dest) {
throw new NotFoundException();
}
if (dest.powChallenge) {
if (!(await this.powService.verifyChallenge(challenge, proof))) {
throw new NotFoundException();
}
}
const result = await this.emailService.sendEmail(
[dest.email],
subject,
text,
);
this.powService.markChallengeAsComplete(challenge);
return result;
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { ContactController } from './contact.controller';
import { EmailService } from 'src/email/email.service';
import { PowService } from 'src/pow/pow.service';
import { PowModule } from 'src/pow/pow.module';
@Module({
imports: [PowModule],
controllers: [ContactController],
providers: [EmailService],
})
export class ContactModule {}

View File

@@ -9,7 +9,7 @@ import {
} from '@nestjs/swagger';
import { DomainrParsedStatusResult } from './domainrproxy.service';
@ApiTags('Domainr')
@ApiTags('domainr')
@Controller('domainrproxy')
export class DomainrproxyController {
constructor(private readonly proxyService: DomainrproxyService) {}

View File

@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { EmailService } from './email.service';
@Module({
providers: [EmailService],
exports: [EmailService],
})
export class EmailModule {}

View File

@@ -0,0 +1,36 @@
import { Injectable } from '@nestjs/common';
import Mailgun from 'mailgun.js';
import * as formdata from 'form-data';
import { IMailgunClient } from 'mailgun.js/Interfaces';
import { ConfigService } from '@nestjs/config';
@Injectable()
export class EmailService {
private readonly mailgun: IMailgunClient | undefined;
private static readonly emailFromDomain = 'hello.hooli.co';
constructor(private readonly configService: ConfigService) {
const mailgun = new Mailgun(formdata);
const hooliKey = this.configService.get<string>('mailgun.hooliKey');
if (!hooliKey) {
return;
}
this.mailgun = mailgun.client({
username: 'api',
key: hooliKey,
});
}
async sendEmail(to: string[], subject: string, text: string) {
if (!this.mailgun) {
return;
}
return this.mailgun.messages.create(EmailService.emailFromDomain, {
from: `HooliMail <hooli-mail@${EmailService.emailFromDomain}>`,
to,
subject,
text,
html: text,
});
}
}

View File

@@ -10,7 +10,6 @@ import {
} from '@nestjs/common';
import { FileService } from './file.service';
import { Post } from '@nestjs/common';
import { UploadedObjectInfo } from 'minio';
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiConsumes, ApiTags } from '@nestjs/swagger';
import { InjectMetric } from '@willsoto/nestjs-prometheus';

View File

@@ -1,7 +1,8 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Cron } from '@nestjs/schedule';
import { ItemBucketMetadata, UploadedObjectInfo } from 'minio';
import { ItemBucketMetadata } from 'minio';
import { UploadedObjectInfo } from 'minio/dist/main/internal/type';
import { MinioService } from 'src/minio/minio.service';
@Injectable()

View File

@@ -83,7 +83,6 @@ export class JunkDrawerController {
@Body('description') description: string,
@Body('private-ish') privateIsh: boolean,
): Promise<any> {
console.log(privateIsh);
const uniqueSlug = generateUniqueSlug({
random: privateIsh,
});

View File

@@ -1,7 +1,7 @@
import { InjectQueue } from '@nestjs/bull';
import { Injectable, Logger } from '@nestjs/common';
import { Queue } from 'bull';
import { UploadedObjectInfo } from 'minio';
import { UploadedObjectInfo } from 'minio/dist/main/internal/type';
import { MinioService } from 'src/minio/minio.service';
@Injectable()

View File

@@ -2,7 +2,9 @@ import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
import { Inject, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Client, ItemBucketMetadata, UploadedObjectInfo } from 'minio';
import { Client, ItemBucketMetadata } from 'minio';
import { UploadedObjectInfo } from 'minio/dist/main/internal/type';
import { open, readFileSync } from 'fs';
@Injectable()
export class MinioService {
@@ -30,12 +32,8 @@ export class MinioService {
filePath: string,
metadata?: ItemBucketMetadata,
): Promise<UploadedObjectInfo> {
return await this.client.fPutObject(
bucketName,
objectName,
filePath,
metadata,
);
const file = readFileSync(filePath);
return this.uploadBuffer(bucketName, objectName, file, metadata);
}
public async uploadBuffer(
@@ -44,10 +42,11 @@ export class MinioService {
buffer: Buffer,
metadata?: ItemBucketMetadata,
): Promise<UploadedObjectInfo> {
return await this.client.putObject(
return this.client.putObject(
bucketName,
objectName,
buffer,
buffer.length,
metadata,
);
}

View File

@@ -1,34 +0,0 @@
import { Body, Controller, Post } from '@nestjs/common';
import { ApiProperty, ApiTags } from '@nestjs/swagger';
const ogs = require('open-graph-scraper');
import { SuccessResult } from 'open-graph-scraper';
import { OgScraperService } from './ogscraper.service';
import { InjectMetric } from '@willsoto/nestjs-prometheus';
import { Histogram } from 'prom-client';
class ScrapeOgDto {
@ApiProperty({
description: 'URL of the page to fetch Open Graph metadata of',
example:
'https://qz.com/1903322/why-pivot-tables-are-the-spreadsheets-most-powerful-tool',
})
url: string;
}
@Controller('ogscraper')
@ApiTags('open-graph-scraper')
export class OgScraperController {
constructor(
private readonly ogScraperService: OgScraperService,
@InjectMetric('generation_time')
public generationTime: Histogram<string>,
) {}
@Post('')
async scrapeOg(@Body() body: ScrapeOgDto): Promise<SuccessResult> {
const end = this.generationTime.startTimer();
const response = await this.ogScraperService.getOg(body.url);
end();
return response;
}
}

View File

@@ -1,28 +0,0 @@
import { Module } from '@nestjs/common';
import { OgScraperController } from './ogscraper.controller';
import { OgScraperService } from './ogscraper.service';
import {
PrometheusModule,
makeHistogramProvider,
} from '@willsoto/nestjs-prometheus';
@Module({
imports: [
PrometheusModule.register({
customMetricPrefix: 'ogscraper',
defaultMetrics: {
enabled: false,
},
}),
],
controllers: [OgScraperController],
providers: [
OgScraperService,
makeHistogramProvider({
name: 'generation_time',
help: 'Open Graph Scraping response times',
}),
],
exports: [OgScraperService],
})
export class OgScraperModule {}

View File

@@ -1,20 +0,0 @@
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
const ogs = require('open-graph-scraper');
import { SuccessResult } from 'open-graph-scraper';
@Injectable()
export class OgScraperService {
constructor(public readonly configService: ConfigService) {}
async getOg(url: string): Promise<SuccessResult> {
return ogs({
url,
fetchOptions: {
headers: {
'user-agent': this.configService.get<string>('userAgent') || '',
},
},
});
}
}

View File

@@ -1,12 +1,29 @@
import { Body, Controller, Get, Post, Query, Render } from '@nestjs/common';
import {
BadRequestException,
Body,
Controller,
Get,
Post,
Query,
Render,
} from '@nestjs/common';
import { PowService } from './pow.service';
import { ApiBody, ApiQuery, ApiTags } from '@nestjs/swagger';
import { ApiBody, ApiProperty, ApiQuery, ApiTags } from '@nestjs/swagger';
import { ConfigService } from '@nestjs/config';
class SolveDto {
@ApiProperty({
description: 'The challenge to solve',
})
challenge: string;
}
@ApiTags('pow')
@Controller('pow')
export class PowController {
constructor(
private readonly powService: PowService,
private readonly configService: ConfigService,
) {}
@Get('')
@@ -23,8 +40,12 @@ export class PowController {
}
@Post('challenge')
@ApiBody({ schema: { properties: { challenge: { type: 'string' }, proof: { type: 'string' } } } })
async verifyChallenge(@Body() body: { challenge: string, proof: string }) {
@ApiBody({
schema: {
properties: { challenge: { type: 'string' }, proof: { type: 'string' } },
},
})
async verifyChallenge(@Body() body: { challenge: string; proof: string }) {
return this.powService.verifyChallenge(body.challenge, body.proof);
}
@@ -34,4 +55,12 @@ export class PowController {
return this.powService.markChallengeAsComplete(body.challenge);
}
@Post('challenge/solve')
async solveChallenge(@Body() body: SolveDto) {
if (this.configService.get<boolean>('isProduction')) {
throw new BadRequestException('This endpoint is disabled in production');
}
return this.powService.performChallenge(body.challenge);
}
}

View File

@@ -1,44 +1,34 @@
import { Module } from '@nestjs/common';
import { PowService } from './pow.service';
import { CacheModule } from '@nestjs/cache-manager';
import { PowController } from './pow.controller';
import { PrometheusModule, makeGaugeProvider } from '@willsoto/nestjs-prometheus';
import { makeGaugeProvider } from '@willsoto/nestjs-prometheus';
@Module({
imports: [
CacheModule.register({
ttl: 5,
max: 10,
}),
PrometheusModule.register({
customMetricPrefix: 'pow',
defaultMetrics: {
enabled: false,
},
}),
],
providers: [PowService,
imports: [],
providers: [
PowService,
makeGaugeProvider({
name: 'challenges_generated',
name: 'pow_challenges_generated',
help: 'The total number of POW challenges generated',
}),
makeGaugeProvider({
name: 'challenges_completed',
name: 'pow_challenges_completed',
help: 'The total number of POW challenges completed',
}),
makeGaugeProvider({
name: 'successful_verifies',
name: 'pow_successful_verifies',
help: 'The total number of successful POW challenge verifications',
}),
makeGaugeProvider({
name: 'failed_verifies',
name: 'pow_failed_verifies',
help: 'The total number of failed POW challenge verifications',
}),
makeGaugeProvider({
name: 'difficulty',
name: 'pow_difficulty',
help: 'The current POW difficulty',
}),
],
controllers: [PowController]
controllers: [PowController],
exports: [PowService],
})
export class PowModule {}

View File

@@ -4,7 +4,6 @@ import { InjectMetric } from '@willsoto/nestjs-prometheus';
import { Cache } from 'cache-manager';
import { randomBytes, createHash } from 'crypto';
@Injectable()
export class PowService {
private readonly logger = new Logger(PowService.name);
@@ -12,18 +11,18 @@ export class PowService {
constructor(
@Inject(CACHE_MANAGER) private cacheManager: Cache,
@InjectMetric('challenges_generated') private challengesGenerated: any,
@InjectMetric('challenges_completed') private challengesCompleted: any,
@InjectMetric('successful_verifies') private successfulVerifies: any,
@InjectMetric('failed_verifies') private failedVerifies: any,
@InjectMetric('difficulty') private powDifficulty: any,
@InjectMetric('pow_challenges_generated') private challengesGenerated: any,
@InjectMetric('pow_challenges_completed') private challengesCompleted: any,
@InjectMetric('pow_successful_verifies') private successfulVerifies: any,
@InjectMetric('pow_failed_verifies') private failedVerifies: any,
@InjectMetric('pow_difficulty') private powDifficulty: any,
) {
this.powDifficulty.set(this.difficulty);
}
/**
* Generate a proof of work challenge, stored to redis for verification within
* the next 10 seconds.
* the next 60 seconds.
*/
async generateChallenge() {
const challenge = this.generateRandom256BitString();
@@ -38,7 +37,6 @@ export class PowService {
return randomString;
}
hashAndCheck(string: string) {
return this.hashPassesDifficulty(this.hashString(string), this.difficulty);
}
@@ -101,5 +99,4 @@ export class PowService {
getDifficulty() {
return this.difficulty;
}
}

3874
yarn.lock

File diff suppressed because it is too large Load Diff