Compare commits

...

60 Commits

Author SHA1 Message Date
aba378ab44 Add additional properties
All checks were successful
Gitea Actions Demo / build (push) Successful in 2m9s
2025-06-17 18:45:31 -06:00
5a7bad327f Add initial baby-names service 2025-06-17 17:17:54 -06:00
a4c75cfbd2 Update claude summary prompt 2025-05-29 10:45:27 -06:00
ef01d3d475 Update to Claude Opus 4 2025-05-22 16:13:01 -06:00
79a0b436f1 Add markdown response to claude prompt 2025-05-15 13:47:47 -06:00
1b3e52d9cf Return 200 on claude summary 2025-04-25 10:57:01 -06:00
089a7e7975 Add claude module 2025-04-25 10:45:29 -06:00
04e4342097 Remove excess line in script output 2025-04-24 13:48:51 -06:00
1dfed5cee8 Allow larger body sizes 2025-04-24 13:41:00 -06:00
84efbd295c Set response headers 2025-04-24 13:25:38 -06:00
149668a673 Add assembly ai conversion 2025-04-24 13:17:41 -06:00
76f093eeda Add names api 2025-01-13 21:56:53 -07:00
368f2b100f Hoard lichess stats 2024-12-05 16:39:05 -07:00
3ada3433cc New chess.com username 2024-12-05 16:30:48 -07:00
6115d2c76c Remove chess stat hoarding 2024-12-03 08:09:42 -07:00
fa502a45c6 Service Junk Drawer at /attic 2024-11-08 15:03:06 -07:00
d6390d365c Add hoarding 2024-11-01 16:34:32 -06:00
d9dfda60d8 Use inmemory cache 2024-10-30 14:40:29 -06:00
c631566ab0 Consume headers for IP 2024-10-30 10:38:32 -06:00
6418bff35c Add headers endpoint 2024-10-30 10:32:44 -06:00
4bc81484d3 Use nestjs ip decorator 2024-10-30 10:29:43 -06:00
8ff7ca4f10 Render IP 2024-10-30 10:23:24 -06:00
3df355f045 Add junk-drawer history by IP 2024-10-30 10:21:38 -06:00
c854a1540a Put junk drawer in same bucket 2024-10-28 16:20:17 -06:00
83541aa80d Move to backblaze 2024-10-28 16:14:16 -06:00
d48040bd58 Add contact, upgrade versions, add docs 2024-10-15 17:29:35 -06:00
2fff8e9887 Improve upload style 2024-10-01 18:23:27 -06:00
9090a07aa5 Adjust junk drawer styles 2024-10-01 18:18:39 -06:00
9394e1cc5a Add ability to make junk-drawer private-ish 2024-10-01 17:04:08 -06:00
12290c7095 Add JunkDrawer JSON endpoint 2024-10-01 16:59:27 -06:00
e021be16a3 Add junk drawer 2024-10-01 16:57:19 -06:00
7fae07b3c3 Add ADSB Exchange 2024-09-10 21:02:00 -06:00
76897bf48c Remove dinosaur wet module 2024-09-01 12:16:07 -06:00
9ed66e65e9 Add metrics to fly.toml 2024-09-01 12:00:56 -06:00
5ee0d57629 Remove default metrics 2024-09-01 12:00:11 -06:00
5bb93ba362 Update fly.toml to keep one machine running 2024-09-01 11:51:21 -06:00
9898a2389f Clarify IRC enable 2024-09-01 11:33:33 -06:00
758cf386b4 Migrate to fly.io, add Genre type 2024-09-01 11:28:37 -06:00
c25a5230d9 Add proxy with file rewrite 2024-06-26 16:51:03 -06:00
e0f7c29244 Return ID as part of foco-live events and venues 2024-05-18 21:54:31 -06:00
a8ba435ec3 Add venues 2024-05-14 18:41:50 -06:00
13096102bc Up local data cache 2024-05-03 16:53:29 -06:00
c7befa4d1c Add job score calculation 2024-05-03 09:43:47 -06:00
860ea64d8d Add auto refresh to leaderboard 2024-04-26 09:00:24 -06:00
605b2c1cfe Switch to split than aperture 2024-04-25 17:16:05 -06:00
e3682dfae6 Improvoe focolive, add job views 2024-04-25 08:08:23 -06:00
4e07eee0b9 Add helpers to shopify items 2024-04-19 16:02:09 -06:00
aa1277fafd Add initial jobs implementation 2024-04-11 22:28:56 -06:00
73c91a7c63 Add coffee caching 2024-04-05 16:52:11 -06:00
e07f34137d Add fococoffee endpoints 2024-04-05 16:43:44 -06:00
6084054590 Add pow metrics 2024-04-04 17:43:27 -06:00
2eed36bdd7 Add ability to mark PoW complete 2024-04-02 16:46:01 -06:00
8b25ba39b8 Add PoW module 2024-04-02 16:45:09 -06:00
27954f6a8c Add event caching, filtering, and metrics 2024-04-02 15:56:17 -06:00
20caec034f Add FocoLive events route 2024-03-25 22:30:57 -06:00
7bdb45da45 Do not render upload.txt 2023-12-15 21:06:16 -07:00
7316671457 Add upload path that just returns new URL 2023-12-15 16:48:15 -07:00
ccd4dbe694 Move upload form 2023-12-11 23:07:57 -07:00
fbbfae4ab2 Add file upload 2023-12-11 23:05:15 -07:00
6b2fd89ab2 Add local users 2023-12-11 18:27:33 -07:00
86 changed files with 5571 additions and 2284 deletions

View File

@@ -33,3 +33,6 @@ lerna-debug.log*
!.vscode/tasks.json !.vscode/tasks.json
!.vscode/launch.json !.vscode/launch.json
!.vscode/extensions.json !.vscode/extensions.json
# fly.io
fly.toml

View File

@@ -1,7 +1,8 @@
JWT_SECRET=#jBhALXeYXC3$#e9 NODE_ENV=development
JWT_SECRET=jBhALXeYXC3$
RAPID_API_KEY= RAPID_API_KEY=
IRC_SERVER=irc.libera.chat IRC_SERVER=
IRC_CHANNEL="##usdev-dev" IRC_CHANNEL=
REDIS_HOST=localhost REDIS_HOST=localhost
REDIS_PASS=password REDIS_PASS=password
@@ -12,3 +13,7 @@ S3_USE_SSL=false
S3_ACCESS_KEY="localminio" S3_ACCESS_KEY="localminio"
S3_SECRET_KEY="localminio" S3_SECRET_KEY="localminio"
S3_BUCKET="devbucket" S3_BUCKET="devbucket"
FOCO_LIVE_AIRTABLE_APIKEY=
MAILGUN_SEND_KEY_HOOLI=

18
.github/workflows/fly-deploy.yml vendored Normal file
View File

@@ -0,0 +1,18 @@
# See https://fly.io/docs/app-guides/continuous-deployment-with-github-actions/
name: Fly Deploy
on:
push:
branches:
- main
jobs:
deploy:
name: Deploy app
runs-on: ubuntu-latest
concurrency: deploy-group # optional: ensure only one action runs at a time
steps:
- uses: actions/checkout@v4
- uses: superfly/flyctl-actions/setup-flyctl@master
- run: flyctl deploy --remote-only
env:
FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }}

3
.gitignore vendored
View File

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

View File

@@ -16,13 +16,22 @@ cp .env.example .env
yarn yarn
# Start local services # Start local services
docker compose up -d docker compose up -d
# Copy default data into local minio
cp -r default/* data/minio/devbucket
# Start Application # Start Application
yarn start:dev yarn start:dev
# Log in with development user (admin) and dev password (password)
curl --request POST \
--url http://localhost:3000/auth/login \
--header 'Content-Type: application/json' \
--data '{
"username":"admin",
"password":"password"
}'
``` ```
Visit http://localhost:3000/api The Login command will return a JSON object with `access_token`. You can then
head over to http://localhost:3000/api, click the "Authorize" button, and enter
the `access_token`. This will then be applied to all of the endpoints that
require auth on the API page.
## Configuration ## Configuration

View File

@@ -21,8 +21,6 @@ services:
- db_net - db_net
ports: ports:
- 6379:6379 - 6379:6379
command: >
--requirepass ${REDIS_PASS}
env_file: env_file:
- .env - .env
networks: networks:

44
fly.toml Normal file
View File

@@ -0,0 +1,44 @@
# fly.toml app configuration file generated for us-api on 2024-09-01T10:29:03-06:00
#
# See https://fly.io/docs/reference/configuration/ for information about how to use this file.
#
app = 'us-api'
primary_region = 'den'
[build]
[http_service]
internal_port = 3000
force_https = true
auto_stop_machines = 'stop'
auto_start_machines = true
min_machines_running = 1
processes = ['app']
[[vm]]
size = 'shared-cpu-2x'
# [mounts]
# source = "usapi_data"
# destination = "/data"
# initial_size = "20gb"
# auto_extend_size_increment = "10gb"
# auto_extend_size_threshold = 80
# auto_extend_size_limit = "200gb"
[env]
DATA_ROOT_PATH = "/data"
FILE_DEFAULT_TTL = "600"
S3_ENDPOINT="s3.us-west-002.backblazeb2.com"
S3_PORT="443"
S3_USE_SSL="true"
S3_BUCKET="api-us-dev"
REDIS_DB="0"
NODE_ENV="production"
JUNK_DRAWER_BUCKET_NAME="api-us-dev"
JUNK_DRAWER_ROOT_PATH="junk-drawer"
[metrics]
port = 3000
path = "/metrics"

View File

@@ -20,6 +20,8 @@
"test:e2e": "jest --config ./test/jest-e2e.json" "test:e2e": "jest --config ./test/jest-e2e.json"
}, },
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.40.0",
"@liaoliaots/nestjs-redis": "^9.0.5",
"@nestjs/bull": "^10.0.1", "@nestjs/bull": "^10.0.1",
"@nestjs/cache-manager": "^2.1.1", "@nestjs/cache-manager": "^2.1.1",
"@nestjs/common": "^10.0.0", "@nestjs/common": "^10.0.0",
@@ -30,13 +32,19 @@
"@nestjs/schedule": "^4.0.0", "@nestjs/schedule": "^4.0.0",
"@nestjs/swagger": "^7.1.11", "@nestjs/swagger": "^7.1.11",
"@willsoto/nestjs-prometheus": "^6.0.0", "@willsoto/nestjs-prometheus": "^6.0.0",
"airtable": "^0.12.2",
"axios": "^1.5.0", "axios": "^1.5.0",
"bcrypt": "^5.1.1", "bcrypt": "^5.1.1",
"bull": "^4.11.5", "bull": "^4.11.5",
"cache-manager": "^5.3.1", "cache-manager": "^5.3.1",
"cache-manager-redis-yet": "^4.1.2", "cache-manager-redis-yet": "^4.1.2",
"cookie-parser": "^1.4.7",
"fp-ts": "^2.16.3",
"haversine-ts": "^1.2.0",
"hbs": "^4.2.0", "hbs": "^4.2.0",
"minio": "^7.1.3", "ioredis": "^5.3.2",
"mailgun.js": "^10.2.3",
"minio": "^8.0.1",
"open-graph-scraper": "^6.3.0", "open-graph-scraper": "^6.3.0",
"prom-client": "^15.0.0", "prom-client": "^15.0.0",
"ramda": "^0.29.0", "ramda": "^0.29.0",
@@ -44,6 +52,7 @@
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.1.13",
"rxjs": "^7.8.1", "rxjs": "^7.8.1",
"slate-irc": "^0.9.3", "slate-irc": "^0.9.3",
"uuid": "^10.0.0",
"xml": "^1.0.1" "xml": "^1.0.1"
}, },
"devDependencies": { "devDependencies": {
@@ -52,6 +61,7 @@
"@nestjs/testing": "^10.0.0", "@nestjs/testing": "^10.0.0",
"@types/axios": "^0.14.0", "@types/axios": "^0.14.0",
"@types/bcrypt": "^5.0.0", "@types/bcrypt": "^5.0.0",
"@types/cookie-parser": "^1.4.9",
"@types/express": "^4.17.17", "@types/express": "^4.17.17",
"@types/jest": "^29.5.2", "@types/jest": "^29.5.2",
"@types/multer": "^1.4.10", "@types/multer": "^1.4.10",
@@ -59,6 +69,7 @@
"@types/ramda": "^0.29.3", "@types/ramda": "^0.29.3",
"@types/slate-irc": "^0.0.29", "@types/slate-irc": "^0.0.29",
"@types/supertest": "^2.0.12", "@types/supertest": "^2.0.12",
"@types/uuid": "^10.0.0",
"@types/xml": "^1.0.9", "@types/xml": "^1.0.9",
"@typescript-eslint/eslint-plugin": "^6.0.0", "@typescript-eslint/eslint-plugin": "^6.0.0",
"@typescript-eslint/parser": "^6.0.0", "@typescript-eslint/parser": "^6.0.0",

View File

@@ -0,0 +1,116 @@
import {
BadRequestException,
Controller,
Get,
Logger,
Param,
UseInterceptors,
} from '@nestjs/common';
import { ExtendedAircraft, LiteAircraft } from './types';
import { AdsbExchangeService, ValidRadius } from './adsb-exchange.service';
import { CacheInterceptor, CacheTTL } from '@nestjs/cache-manager';
import { ApiParam, ApiTags } from '@nestjs/swagger';
@Controller('adsb-exchange')
@UseInterceptors(CacheInterceptor)
@CacheTTL(60)
@ApiTags('adsb-exchange')
export class AdsbExchangeController {
private readonly logger: Logger = new Logger(AdsbExchangeController.name);
constructor(private readonly adsbExchangeService: AdsbExchangeService) {}
@Get('radius/lat/:lat/long/:long/dist/:dist')
@ApiParam({
name: 'lat',
type: Number,
example: 40.6043,
required: true,
description: 'Latitude of the center point',
})
@ApiParam({
name: 'long',
type: Number,
example: -105.01919,
required: true,
description: 'Longitude of the center point, note that West is negative',
})
@ApiParam({
name: 'dist',
type: Number,
example: 10,
required: true,
description:
'Radius in nautical miles. Must be one of 1, 5, 10, 25, 50, 100, 250',
})
async adsbExchangeAircraftWithinRadius(
@Param('lat') lat: number,
@Param('long') long: number,
@Param('dist') dist: number,
): Promise<ExtendedAircraft[]> {
dist = Number(dist);
lat = Number(lat);
long = Number(long);
this.logger.verbose(
`Requesting aircraft within ${dist} nautical miles of ${lat}, ${long}`,
);
if (!this.adsbExchangeService.isValidRadius(dist)) {
throw new BadRequestException(`Invalid radius: ${dist}`);
}
return this.adsbExchangeService.extendAircraft(
await this.adsbExchangeService.adsbExchangeAircraftWithinRadius(dist, {
lat,
long,
}),
{ lat, long },
);
}
@Get('radius/lat/:lat/long/:long/dist/:dist/lite')
@ApiParam({
name: 'lat',
type: Number,
example: 40.6043,
required: true,
description: 'Latitude of the center point',
})
@ApiParam({
name: 'long',
type: Number,
example: -105.01919,
required: true,
description: 'Longitude of the center point, note that West is negative',
})
@ApiParam({
name: 'dist',
type: Number,
example: 10,
required: true,
description:
'Radius in nautical miles. Must be one of 1, 5, 10, 25, 50, 100, 250',
})
async adsbExchangeAircraftWithinRadiusLite(
@Param('lat') lat: number,
@Param('long') long: number,
@Param('dist') dist: number,
): Promise<LiteAircraft[]> {
dist = Number(dist);
lat = Number(lat);
long = Number(long);
this.logger.verbose(
`Requesting aircraft within ${dist} nautical miles of ${lat}, ${long}`,
);
if (!this.adsbExchangeService.isValidRadius(dist)) {
throw new BadRequestException(`Invalid radius: ${dist}`);
}
const aircraft = await this.adsbExchangeService.extendAircraft(
await this.adsbExchangeService.adsbExchangeAircraftWithinRadius(dist, {
lat,
long,
}),
{ lat, long },
);
return this.adsbExchangeService
.liteAircraft(aircraft)
.filter((aircraft) => aircraft.registration !== undefined);
}
}

View File

@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { AdsbExchangeService } from './adsb-exchange.service';
import { AdsbExchangeController } from './adsb-exchange.controller';
import { CacheModule } from '@nestjs/cache-manager';
@Module({
imports: [CacheModule.register()],
providers: [AdsbExchangeService],
controllers: [AdsbExchangeController],
})
export class AdsbExchangeModule {}

View File

@@ -0,0 +1,81 @@
import { Injectable, Logger } from '@nestjs/common';
import { AdsbExchange, Bearing, ExtendedAircraft } from './types';
import { DDPoint, Haversine, UnitOfDistance } from 'haversine-ts';
import axios from 'axios';
import { ConfigService } from '@nestjs/config';
const ValidRadii = [1, 5, 10, 25, 50, 100, 250] as const;
export type ValidRadius = (typeof ValidRadii)[number];
@Injectable()
export class AdsbExchangeService {
private readonly logger: Logger = new Logger(AdsbExchangeService.name);
constructor(private readonly configService: ConfigService) {}
isValidRadius(radius: number): radius is ValidRadius {
return ValidRadii.includes(radius as ValidRadius);
}
bearingToAircraft(
aircraft: AdsbExchange.Aircraft,
location: AdsbExchange.Location,
): Bearing {
const locationPoint = new DDPoint(location.lat, location.long);
const aircraftPoint = new DDPoint(aircraft.lat, aircraft.lon);
const haversine = new Haversine(UnitOfDistance.Mile);
const bearing = haversine.getBearing(locationPoint, aircraftPoint);
return {
bearing: bearing.start,
distance: haversine.getDistance(locationPoint, aircraftPoint),
};
}
async adsbExchangeAircraftWithinRadius(
radius: ValidRadius,
location: AdsbExchange.Location,
): Promise<AdsbExchange.Aircraft[]> {
this.logger.verbose(
`Requesting aircraft within ${radius} nautical miles of ${location.lat}, ${location.long}`,
);
const options = {
method: 'GET',
url: `https://adsbexchange-com1.p.rapidapi.com/v2/lat/${location.lat}/lon/${location.long}/dist/${radius}/`,
headers: {
'x-rapidapi-key': this.configService.get<string>('rapidApiKey'),
'x-rapidapi-host': 'adsbexchange-com1.p.rapidapi.com',
},
};
try {
const response =
await axios.request<AdsbExchange.AdsbExchangeRadiusResponse>(options);
return response.data.ac;
} catch (error) {
this.logger.error(error);
return [];
}
}
extendAircraft(
aircraft: AdsbExchange.Aircraft[],
location: AdsbExchange.Location,
): ExtendedAircraft[] {
return aircraft.map((a) => ({
...a,
bearing: this.bearingToAircraft(a, location),
}));
}
liteAircraft = (aircraft: ExtendedAircraft[]) =>
aircraft.map((a) => ({
flight: a.flight,
registration: a.r,
type: a.t,
altitude: a.alt_baro,
speed: a.gs,
track: a.track,
bearing: a.bearing,
}));
}

189
src/adsb-exchange/types.ts Normal file
View File

@@ -0,0 +1,189 @@
export namespace AdsbExchange {
export const AircraftMessageTypes = [
'adsb_icao',
'adsb_icao_nt',
'adsr_icao',
'tisb_icao',
'adsc',
'mlat',
'other',
'mode_s',
'adsb_other',
'adsr_other',
'tisb_other',
'tisb_trackfile',
] as const;
export type AircraftMessageType = (typeof AircraftMessageTypes)[number];
export interface Location {
lat: number;
long: number;
}
export interface Aircraft {
/**
* The 24-bit ICAO identifier of the aircraft
*/
hex: string;
/**
* Type of underlying message/best source of data
*/
type: AircraftMessageType | string;
/**
* Flight callsign or aircraft registration as 8 characters
*/
flight: string;
/**
* Aircraft registration pulled from database
*/
r: string;
/**
* Aircraft type pulled from database
*/
t: string;
/**
* Barometric altitude in feet or "ground"
*/
alt_baro: number | 'ground';
/**
* Geometric (GNSS/INS) altitude in feet
*/
alt_geom: number;
/**
* Groundspeed in knots
*/
gs: number;
/**
* True track over ground in degrees
*/
track: number;
/**
* Rate of change of geometric altitude in feet per minute
*/
geom_rate: number;
/**
* Mode A squawk code encoded as 4 octal digits
*/
squawk: string;
/**
* Emergency status
*/
emergency: string;
/**
* Emitter category to identify particular aircraft
* or vehicle classes. https://www.adsbexchange.com/emitter-category-ads-b-do-260b-2-2-3-2-5-2/
*/
category: string;
/**
* Aircraft location in decimal degrees
*/
lat: number;
/**
* Aircraft location in decimal degrees
*/
lon: number;
/**
* Navigation integrity category
*/
nic: number;
/**
* Radius of containment in meters
*/
rc: number;
/**
* How long ago in seconds the aircraft was last seen
*/
seen_pos: number;
/**
* ADS-B version
*/
version: number;
/**
* Navigation integrity category for barometric altitude
*/
nic_baro: number;
/**
* Navigation accuracy for position
*/
nac_p: number;
/**
* Navigation accuracy for velocity
*/
nac_v: number;
/**
* Source integrity level
*/
sil: number;
/**
* Interpretation of SIL
*/
sil_type: 'unknown' | 'perhour' | 'persample';
/**
* Geometric vertical accuracy
*/
gva: number;
/**
* System design assurance level
*/
sda: number;
/**
* Flight status alert bit
*/
alert: number;
/**
* Flight status special position indicator
*/
spi: number;
mlat: [];
tisb: [];
/**
* Total number of mode S messages received
* for this aircraft
*/
messages: number;
/**
* How long ago in seconds a message was last received
* for this aircraft
*/
seen: number;
/**
* Recent average signal strength in dbFS
*/
rssi: number;
}
export interface AdsbExchangeRadiusResponse {
ac: Aircraft[];
msg: string;
now: number;
total: number;
ctime: number;
ptime: number;
}
}
export interface Bearing {
/**
* Bearing in degrees
*/
bearing: number;
/**
* Distance in miles
*/
distance: number;
}
export interface LiteAircraft {
flight?: string;
registration?: string;
type: string;
altitude: number | 'ground';
speed: number;
track: number;
bearing: Bearing;
}
export type ExtendedAircraft = AdsbExchange.Aircraft & {
bearing: Bearing;
};

View File

@@ -16,7 +16,7 @@ describe('AppController', () => {
describe('root', () => { describe('root', () => {
it('should return "Hello World!"', () => { it('should return "Hello World!"', () => {
expect(appController.getHello()).toBe('Hello World!'); expect(appController.getHello()).toBe("Hello World! This is Chip's generalized API for fetching information and things. You can contact me on mastodon @chip@talking.dev.");
}); });
}); });
}); });

View File

@@ -1,4 +1,4 @@
import { Controller, Get } from '@nestjs/common'; import { Controller, Get, Req } from '@nestjs/common';
import { AppService } from './app.service'; import { AppService } from './app.service';
@Controller() @Controller()
@@ -9,4 +9,9 @@ export class AppController {
getHello(): string { getHello(): string {
return this.appService.getHello(); return this.appService.getHello();
} }
@Get('headers')
getHeaders(@Req() req: Request) {
return req.headers;
}
} }

View File

@@ -8,10 +8,8 @@ import { UsersModule } from './users/users.module';
import { ConfigModule, ConfigService } from '@nestjs/config'; import { ConfigModule, ConfigService } from '@nestjs/config';
import { DomainrproxyModule } from './domainrproxy/domainrproxy.module'; import { DomainrproxyModule } from './domainrproxy/domainrproxy.module';
import configuration from './config/configuration'; import configuration from './config/configuration';
import { CacheModule, CacheStoreFactory } from '@nestjs/cache-manager'; import { CacheInterceptor, CacheModule } from '@nestjs/cache-manager';
import { IrcbotModule } from './ircbot/ircbot.module'; import { IrcbotModule } from './ircbot/ircbot.module';
import { DinosaurwetModule } from './dinosaurwet/dinosaurwet.module';
import { OgScraperModule } from './ogscraper/ogscraper.module';
import { PrometheusModule } from '@willsoto/nestjs-prometheus'; import { PrometheusModule } from '@willsoto/nestjs-prometheus';
import { MinioModule } from './minio/minio.module'; import { MinioModule } from './minio/minio.module';
import { KvModule } from './kv/kv.module'; import { KvModule } from './kv/kv.module';
@@ -20,6 +18,22 @@ import { ScheduleModule } from '@nestjs/schedule';
import { RedisClientOptions } from 'redis'; import { RedisClientOptions } from 'redis';
import { redisStore } from 'cache-manager-redis-yet'; import { redisStore } from 'cache-manager-redis-yet';
import { RedirectsModule } from './redirects/redirects.module'; import { RedirectsModule } from './redirects/redirects.module';
import { FileModule } from './file/file.module';
import { FocoLiveModule } from './foco-live/foco-live.module';
import { PowModule } from './pow/pow.module';
import { FocoCoffeeModule } from './fococoffee/fococoffee.module';
import { JobsModule } from './jobs/jobs.module';
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';
import { HoardingModule } from './hoarding/hoarding.module';
import { NamesModule } from './names/names.module';
import { AssemblyAiModule } from './assembly-ai/assembly-ai.module';
import { ClaudeModule } from './claude/claude.module';
import { BabyNamesModule } from './baby-names/baby-names.module';
@Module({ @Module({
imports: [ imports: [
@@ -28,54 +42,87 @@ import { RedirectsModule } from './redirects/redirects.module';
isGlobal: true, isGlobal: true,
load: [configuration], load: [configuration],
}), }),
CacheModule.registerAsync<RedisClientOptions>({ RedisModule.forRootAsync({
isGlobal: true,
inject: [ConfigService],
imports: [ConfigModule], imports: [ConfigModule],
useFactory: async (configService: ConfigService) => ({ inject: [ConfigService],
store: redisStore, useFactory: async (
socket: { configService: ConfigService,
host: configService.get<string>('redis.host'), ): Promise<RedisModuleOptions> => {
port: configService.get<number>('redis.port'), return {
}, config: {
database: configService.get<number>('redis.db'), url: configService.get<string>('redis.url'),
password: configService.get<string>('redis.password'), db: configService.get<number>('redis.db'),
}), family: 6,
},
};
},
}), }),
// CacheModule.registerAsync<RedisClientOptions>({
// isGlobal: true,
// inject: [ConfigService],
// imports: [ConfigModule],
// useFactory: async (configService: ConfigService) => ({
// store: redisStore,
// url: configService.get<string>('redis.url'),
// family: 6,
// database: configService.get<number>('redis.db'),
// }),
// }),
CacheModule.register({ isGlobal: true }),
BullModule.forRootAsync({ BullModule.forRootAsync({
imports: [ConfigModule], imports: [ConfigModule],
inject: [ConfigService], inject: [ConfigService],
useFactory: async (configService: ConfigService) => { useFactory: async (configService: ConfigService) => {
const config = { const config = {
redis: { redis: {
password: configService.get<string>('redis.password'), url: configService.get<string>('redis.url'),
host: configService.get<string>('redis.host'),
port: configService.get<number>('redis.port'),
db: configService.get<number>('redis.db'), db: configService.get<number>('redis.db'),
family: 6,
}, },
}; };
return config; return config;
}, },
}), }),
PrometheusModule.register({ PrometheusModule.register({
defaultMetrics: {
enabled: false,
},
defaultLabels: { defaultLabels: {
app: 'us.dev api', app: 'us.dev api',
}, },
}), }),
CacheModule.register({ isGlobal: true }),
ParkioModule, ParkioModule,
IswordModule, IswordModule,
AuthModule, AuthModule,
UsersModule, UsersModule,
DomainrproxyModule, DomainrproxyModule,
IrcbotModule, IrcbotModule,
DinosaurwetModule,
OgScraperModule,
MinioModule, MinioModule,
KvModule, KvModule,
RedirectsModule, RedirectsModule,
FileModule,
FocoLiveModule,
PowModule,
FocoCoffeeModule,
JobsModule,
AdsbExchangeModule,
JunkDrawerModule,
EmailModule,
ContactModule,
HoardingModule,
NamesModule,
AssemblyAiModule,
ClaudeModule,
BabyNamesModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [AppService], providers: [
AppService,
{
provide: APP_INTERCEPTOR,
useClass: CacheInterceptor,
},
],
exports: [PrometheusModule],
}) })
export class AppModule { } export class AppModule {}

View File

@@ -0,0 +1,46 @@
import { Body, Controller, Post, Res } from '@nestjs/common';
import { ApiBody, ApiConsumes, ApiResponse } from '@nestjs/swagger';
import { Response } from 'express';
interface Utterance {
speaker: string;
text: string;
start: number;
end: number;
confidence: number;
words: Array<{
start: number;
end: number;
text: string;
confidence: number;
speaker: string;
}>;
}
@Controller('assembly-ai')
export class AssemblyAiController {
@Post('utterance-to-script')
@ApiResponse({
status: 200,
description: 'Converts utterances to a script format',
type: String,
})
@ApiBody({
description: 'Array of utterances or an object containing utterances',
type: String,
})
async utteranceToScript(
@Body() body: { utterances: Utterance[] } | Utterance[],
@Res() res: Response,
) {
const utterances = Array.isArray(body) ? body : body.utterances;
return res.header('Content-Type', 'text/plain; charset=utf-8').send(
utterances
.map(
(utterance) => `Speaker ${utterance.speaker}:
${utterance.text}`,
)
.join('\n\n'),
);
}
}

View File

@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { AssemblyAiController } from './assembly-ai.controller';
@Module({
controllers: [AssemblyAiController]
})
export class AssemblyAiModule {}

View File

@@ -10,7 +10,35 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
import { AuthGuard } from './auth.guard'; import { AuthGuard } from './auth.guard';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiBody, ApiProperty, ApiTags } from '@nestjs/swagger';
class HashDto {
@ApiProperty({
description: 'The password to hash',
default: 'password',
})
password: string;
@ApiProperty({
description: 'The number of bcrypt hashing rounds',
default: 10,
})
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') @ApiTags('auth')
@Controller('auth') @Controller('auth')
@@ -19,14 +47,17 @@ export class AuthController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('login') @Post('login')
@ApiBody({ type: LoginDto })
signIn(@Body() signInDto: Record<string, any>) { signIn(@Body() signInDto: Record<string, any>) {
return this.authService.signIn(signInDto.username, signInDto.password); return this.authService.signIn(signInDto.username, signInDto.password);
} }
@UseGuards(AuthGuard) @UseGuards(AuthGuard)
@Post('hash') @Post('hash')
hash(@Body() hashDto: { pass: string; rounds?: number }) { @ApiBody({ type: HashDto })
return this.authService.hash(hashDto.pass, hashDto.rounds); @ApiBearerAuth()
hash(@Body() hashDto: HashDto) {
return this.authService.hash(hashDto.password, hashDto.rounds);
} }
@UseGuards(AuthGuard) @UseGuards(AuthGuard)

View File

@@ -0,0 +1,92 @@
import {
Body,
Controller,
Get,
Post,
Redirect,
Render,
Req,
Res,
} from '@nestjs/common';
import { ApiConsumes, ApiTags } from '@nestjs/swagger';
import { BabyNamesService } from './baby-names.service';
import { Request, Response } from 'express';
@Controller('baby-names')
@ApiTags('baby-names')
export class BabyNamesController {
constructor(private readonly babyNamesService: BabyNamesService) {}
@Get()
@Render('baby-names/index')
async index(@Req() request: Request) {
const key = request.cookies['baby-names-key'] || null;
return { previousKey: key };
}
@Get('form')
@Render('baby-names/form')
async form(@Req() request: Request) {
const { key } = request.query;
if (!key) {
throw new Error("Missing 'key' field");
}
const currentIndex = await this.babyNamesService.getCurrentNumber(
key as string,
);
const name = this.babyNamesService.nameList[currentIndex];
const synonyms = this.babyNamesService.getSynonyms(name);
return {
key: key,
name,
index: currentIndex,
message: request.query.message || null,
synonyms: synonyms.join(', '),
};
}
@Post()
@ApiConsumes('multipart/form-data')
@Redirect('/baby-names', 302)
async submit(
@Body()
body: {
key: string;
name: string;
nameindex: string;
opinion: string;
pronunciation: string;
spelling: string;
comment: string;
},
@Res({ passthrough: true }) res: Response,
) {
const { key, name, opinion, nameindex, pronunciation, spelling, comment } =
body;
if (!key) {
throw new Error("Missing 'key' field");
}
await this.babyNamesService.addUserScore(key, name, {
opinion: parseInt(opinion, 10),
pronunciation: parseInt(pronunciation, 10),
spelling: parseInt(spelling, 10),
comment: comment || '',
});
await this.babyNamesService.writeUserNumber(
key,
parseInt(nameindex, 10) + 1,
);
res.cookie('baby-names-key', key, { maxAge: 30 * 24 * 60 * 60 * 1000 }); // 30 days
return {
url: `/baby-names/form?key=${key}&message=Logged ${name} as ${opinion}`,
};
}
@Get('data/names.json')
async getNamesData() {
return {
names: this.babyNamesService.nameList,
nameCount: this.babyNamesService.nameCountMap,
};
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { BabyNamesController } from './baby-names.controller';
import { KvService } from 'src/kv/kv.service';
import { MinioService } from 'src/minio/minio.service';
import { BullModule } from '@nestjs/bull';
import { BabyNamesService } from './baby-names.service';
@Module({
providers: [KvService, MinioService, BabyNamesService],
imports: [],
controllers: [BabyNamesController],
})
export class BabyNamesModule {}

View File

@@ -0,0 +1,133 @@
import { Injectable } from '@nestjs/common';
import axios from 'axios';
import { KvService } from 'src/kv/kv.service';
import { MinioService } from 'src/minio/minio.service';
export interface Name {
name: string;
year: number;
gender: string;
count: number;
}
export interface NameMap {
[key: string]: Name[];
}
export type NameList = string[];
export interface NameCountMap {
[key: string]: number;
}
interface NameSynonyms {
name: string;
gender: string;
synonyms: string[];
}
export interface NameSynonymsMap {
[key: string]: NameSynonyms;
}
interface UserScoreMap {
[key: string]: object;
}
@Injectable()
export class BabyNamesService {
public nameList: NameList = [];
public nameCountMap: NameCountMap = {};
public nameSynonymsMap: NameSynonymsMap = {};
public userScoreMap: UserScoreMap = {};
constructor(private readonly minioService: MinioService) {
this.refreshNames();
}
private async refreshNames() {
this.nameCountMap = (
await axios.get('https://cache.sh/baby-name-data/namecount.json')
).data;
this.nameList = (
await axios.get('https://cache.sh/baby-name-data/namecount-list.json')
).data;
this.nameSynonymsMap = (
await axios.get('https://cache.sh/baby-name-data/btn_synonyms.json')
).data;
this.nameSynonymsMap = Object.keys(this.nameSynonymsMap).reduce(
(acc, key) => {
acc[key.toLowerCase()] = this.nameSynonymsMap[key];
return acc;
},
{} as NameSynonymsMap,
);
}
public async getCurrentNumber(userKey: string): Promise<number> {
const currentKey = await this.minioService
.getBuffer(
this.minioService.defaultBucketName,
`baby-names/${userKey}-current`,
)
.then((buffer) => buffer.toString())
.catch(() => null);
if (currentKey === null) {
await this.writeUserNumber(userKey, 0);
}
const currentNumber = parseInt(currentKey || '0', 10);
return currentNumber + 1;
}
public async writeUserNumber(userKey: string, number: number): Promise<void> {
await this.minioService.uploadBuffer(
this.minioService.defaultBucketName,
`baby-names/${userKey}-current`,
Buffer.from(number.toString()),
);
}
public async getUserScores(userKey: string): Promise<UserScoreMap> {
const scoresKey = await this.minioService
.getBuffer(
this.minioService.defaultBucketName,
`baby-names/${userKey}-scores`,
)
.then((buffer) => buffer.toString())
.catch(() => null);
if (scoresKey === null) {
return {};
}
return JSON.parse(scoresKey);
}
public async saveUserScores(
userKey: string,
scores: UserScoreMap,
): Promise<void> {
await this.minioService.uploadBuffer(
this.minioService.defaultBucketName,
`baby-names/${userKey}-scores`,
Buffer.from(JSON.stringify(scores)),
);
}
public async addUserScore(
userKey: string,
name: string,
score: object,
): Promise<void> {
const scores = await this.getUserScores(userKey);
scores[name] = score;
await this.saveUserScores(userKey, scores);
}
public getSynonyms(name: string): string[] {
const entry = this.nameSynonymsMap[name.toLowerCase()];
if (entry) {
return entry.synonyms;
}
return [];
}
}

View File

@@ -0,0 +1,66 @@
import Anthropic from '@anthropic-ai/sdk';
import { Body, Controller, Logger, Post, Res, Headers } from '@nestjs/common';
import { ApiBody, ApiConsumes, ApiResponse } from '@nestjs/swagger';
import { Response } from 'express';
@Controller('claude')
export class ClaudeController {
private readonly logger = new Logger(ClaudeController.name);
private readonly claude: Anthropic;
constructor() {
this.claude = new Anthropic({
apiKey: process.env.ANTHROPIC_API_KEY,
});
}
@Post('script-to-meeting-summary')
@ApiResponse({
status: 200,
description: 'Converts a script to a meeting summary',
type: String,
})
@ApiBody({
description: 'Meeting transcript in script format',
type: String,
})
@ApiConsumes('text/plain')
async scriptToMeetingSummary(
@Body() body: string,
@Res() res: Response,
@Headers('x-api-key') apiKey: string,
) {
if (apiKey !== process.env.CLAUDE_ENDPOINT_KEY) {
this.logger.warn('Unauthorized access attempt');
return res.status(401).send('Unauthorized');
}
const instructionPrefix = `You are being contacted by an API which is making this call.
Do not respond with anything other than the text that should be returned to the user.
The meeting transcript follows the line separating these instructions which is a line of = symbols.
Return a two paragraph or less summary, bullet points of the topics, and bullet points of any action items.
Your response MUST be in markdown format.
Lists should be formatted with '- ' for bullet points.
==========
`;
this.logger.log(`Received script to summarize: ${body.length} characters`);
const response = await this.claude.messages.create({
model: 'claude-opus-4-20250514',
messages: [
{
role: 'user',
content: `${instructionPrefix}${body}`,
},
],
max_tokens: 1000,
});
return res
.header('Content-Type', 'text/plain; charset=utf-8')
.status(200)
.send((response.content[0] as any).text);
}
}

View File

@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { ClaudeController } from './claude.controller';
@Module({
controllers: [ClaudeController]
})
export class ClaudeModule {}

View File

@@ -1,24 +1,58 @@
require('dotenv').config();
export default () => ({ export default () => ({
port: parseInt(process.env.PORT ?? '', 10) || 3000, port: parseInt(process.env.PORT ?? '', 10) || 3000,
isProduction: process.env.NODE_ENV === 'production',
// UserAgent should be added to calls made to third party apis // UserAgent should be added to calls made to third party apis
userAgent: 'api.us.dev/@chip@talking.dev', userAgent: 'api.us.dev/@chip@talking.dev',
rapidApiKey: process.env.RAPID_API_KEY || '', rapidApiKey: process.env.RAPID_API_KEY || '',
irc: { irc: {
enabled: process.env.IRC_SERVER !== undefined, enabled:
process.env.IRC_SERVER === '' || process.env.IRC_SERVER === undefined
? false
: true,
server: process.env.IRC_SERVER, server: process.env.IRC_SERVER,
tls: process.env.IRC_TLS === 'true', tls: process.env.IRC_TLS === 'true',
port: parseInt(process.env.IRC_PORT ?? '6697'), port: parseInt(process.env.IRC_PORT ?? '6697'),
channel: process.env.IRC_CHANNEL ?? '#usdev', channel: process.env.IRC_CHANNEL ?? '#usdev',
password: process.env.IRC_PASSWORD ?? '', password: process.env.IRC_PASSWORD ?? '',
nick: nick:
process.env.IRC_NICK ?? process.env.NODE_ENV === 'production' (process.env.IRC_NICK ?? process.env.NODE_ENV === 'production')
? 'us-bot' ? 'us-bot'
: 'us-dev', : 'us-dev',
}, },
redis: { redis: {
host: process.env.REDIS_HOST ?? 'redis-master', url: process.env.REDIS_URL ?? '',
port: parseInt(process.env.REDIS_PORT ?? '6379'),
password: process.env.REDIS_PASS ?? '',
db: parseInt(process.env.REDIS_DB ?? '1'), db: parseInt(process.env.REDIS_DB ?? '1'),
}, },
file: {
bucketName:
process.env.FILE_BUCKET_NAME ??
process.env.S3_BUCKET ??
'api.us.dev-files',
// default file ttl in seconds
defaultTtl: parseInt(
process.env.FILE_DEFAULT_TTL ?? (30 * 24 * 60 * 60).toString(),
),
},
s3: {
bucketName: process.env.S3_BUCKET ?? 'api.us.dev',
},
focoLive: {
airtable: {
apiKey: process.env.FOCO_LIVE_AIRTABLE_APIKEY ?? '',
},
},
junkDrawer: {
bucketName: process.env.JUNK_DRAWER_BUCKET_NAME ?? 'junk-drawer',
rootPath: process.env.JUNK_DRAWER_ROOT_PATH ?? '',
},
mailgun: {
hooliKey: process.env.MAILGUN_SEND_KEY_HOOLI ?? '',
},
thirdPartyServices: {
lichess: {
token: process.env.SVC_LICHESS_TOKEN ?? '',
},
},
}); });

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

@@ -1,188 +0,0 @@
import { Controller, Get, Res } from '@nestjs/common';
import { Response } from 'express';
import { identity, prop } from 'ramda';
import * as xml from 'xml';
import { InjectMetric } from '@willsoto/nestjs-prometheus';
import { Gauge, Histogram } from 'prom-client';
import { ApiTags } from '@nestjs/swagger';
const episodeFiles: [number, string][] = [
[27544345, '01-Into-the-Big-Wet.mp3'],
[30724178, '02-Ocean-Rumours.mp3'],
[30752181, '03-Step-Two.mp3'],
[28793207, '04-Red-Ladder-Revelations.mp3'],
[29684714, '05-What-to-do-with-a-Problem-Like-Daveda.mp3'],
[29387545, '06-Hair-Bussy.mp3'],
[30789798, '07-Monkey-Monkey-Cock-a-Roach.mp3'],
[29449403, '08-SS-Jungle-and-Blood.mp3'],
[29554728, '09-A-Bad-Day-in-Sogland.mp3'],
[31728952, '10-A-Loathed-Trip-Down-Memory-Lane.mp3'],
[29711881, '11-Dog-Egg-Consumption.mp3'],
[31212772, '12-Meg-and-Bobs-Wedding-Bussy.mp3'],
[30951548, '13-100-Genuine-Pussy-Hound.mp3'],
[33766503, '14-One-Perfect-Answer.mp3'],
[29643754, '15-Succulent-Pig-Excitement.mp3'],
[29797145, '16-Welcome-to-Jackson-Island.mp3'],
[37926451, '17-Infinite-Meat.mp3'],
[33863888, '18-The-Jackson-Triumvirate.mp3'],
[30256482, '19-A-Lucky-Day-to-be-a-Jackson.mp3'],
[40947042, '20-The-Joels-Triumphant.mp3'],
];
const startDate = new Date('2023-10-16');
type Episode = {
filename: string;
length: number;
url: string;
title: string;
description: string;
fakeReleaseDate: Date;
};
const dinosaurImage =
'https://imaginary.apps.hooli.co/enlarge?height=1400&width=1400&url=https://www.sanspantsradio.com/wp-content/uploads/2023/03/Dino-Wet-600x600.jpg';
const episodes: Episode[] = episodeFiles.map((file, i) => {
const [length, filename] = file;
return {
filename,
length,
url: `https://s3.apps.hnl.byoi.net/dinosaurpark/DinosaurWet/${filename}`,
title: filename.replace('.mp3', '').replace(/-/g, ' ').trim(),
description:
'A bootleg-ish of Dinosar Wet, making the downloaded files more like and RSS feed',
fakeReleaseDate: new Date(
new Date(startDate).setDate(startDate.getDate() + i * 7),
),
};
});
const buildFeeds = (
title: string,
episodes: Episode[],
pubDateFn: (index: number) => Date = () => startDate,
) => {
const episodeItems = episodes
.filter((episode: Episode, index: number) => pubDateFn(index) <= new Date())
.map(({ title, url, length, fakeReleaseDate }, index) => ({
item: [
{ title },
{ guid: url },
{ pubDate: pubDateFn(index).toUTCString() },
{
'itunes:image': {
_attr: {
href: dinosaurImage,
},
},
},
{ 'itunes:episode': index + 1 },
{
enclosure: [
{
_attr: {
url,
type: 'audio/mpeg',
length,
},
},
],
},
],
}));
const feedObject = {
rss: [
{
_attr: {
version: '2.0',
'xmlns:atom': 'http://www.w3.org/2005/Atom',
'xmlns:itunes': 'http://www.itunes.com/dtds/podcast-1.0.dtd',
'xmlns:content': 'http://purl.org/rss/1.0/modules/content/',
'xmlns:podcast': 'https://podcastindex.org/namespace/1.0',
},
},
{
channel: [
{
'atom:link': {
_attr: {
href: 'https://api.us.dev/dinosaurwet',
rel: 'self',
type: 'application/rss+xml',
},
},
},
{
title,
},
{
link: 'https://sanspantsradio.com',
},
{
'itunes:image': {
_attr: {
href: dinosaurImage,
},
},
},
{
description:
'A recreation of Dinosaur Wet as an RSS feed for listeners-with-licenses enjoyment.',
},
{ language: 'en-US' },
...episodeItems,
],
},
],
};
const feed = '<?xml version="1.0" encoding="UTF-8"?>' + xml(feedObject);
return { feed, episodeItems };
};
@ApiTags('dinosaurwet')
@Controller('dinosaurwet')
export class DinosaurwetController {
constructor(
@InjectMetric('weekly_count') public weeklyCount: Gauge<string>,
@InjectMetric('daily_count') public dailyCount: Gauge<string>,
@InjectMetric('rss_query_count') public queryCount: Gauge<string>,
) {}
@Get('')
getAllAtOnce(@Res() response: Response) {
this.queryCount.inc();
response.header('Content-Type', 'application/xml');
const feed = buildFeeds('Dinosaur Wet - All At Once', episodes);
return response.send(feed);
}
@Get('daily')
getDaily(@Res() response: Response) {
this.queryCount.inc();
response.header('Content-Type', 'application/xml');
const feed = buildFeeds(
'Dinosaur Wet - Daily',
episodes,
(index: number) =>
new Date(new Date(startDate).setDate(startDate.getDate() + index)),
);
this.dailyCount.set(feed.episodeItems.length);
return response.send(feed.feed);
}
@Get('weekly')
getWeekly(@Res() response: Response) {
this.queryCount.inc();
response.header('Content-Type', 'application/xml');
const feed = buildFeeds(
'Dinosaur Wet - Weekly',
episodes,
(index: number) =>
new Date(new Date(startDate).setDate(startDate.getDate() + index * 7)),
);
this.weeklyCount.set(feed.episodeItems.length);
return response.send(feed.feed);
}
}

View File

@@ -1,33 +0,0 @@
import { Module } from '@nestjs/common';
import { DinosaurwetController } from './dinosaurwet.controller';
import {
PrometheusModule,
makeGaugeProvider,
} from '@willsoto/nestjs-prometheus';
@Module({
imports: [
PrometheusModule.register({
customMetricPrefix: 'dinosaurwet',
defaultMetrics: {
enabled: false,
},
}),
],
controllers: [DinosaurwetController],
providers: [
makeGaugeProvider({
name: 'daily_count',
help: 'The current daily Dinosaur Wet episode count',
}),
makeGaugeProvider({
name: 'weekly_count',
help: 'The current weekly Dinosaur Wet episode count',
}),
makeGaugeProvider({
name: 'rss_query_count',
help: 'Total RSS endpoint queries',
}),
],
})
export class DinosaurwetModule {}

View File

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

@@ -0,0 +1,89 @@
import {
Controller,
Get,
Param,
Redirect,
Render,
Res,
UploadedFile,
UseInterceptors,
} from '@nestjs/common';
import { FileService } from './file.service';
import { Post } from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { ApiConsumes, ApiTags } from '@nestjs/swagger';
import { InjectMetric } from '@willsoto/nestjs-prometheus';
import { Response } from 'express';
@Controller('file')
@ApiTags('file')
export class FileController {
constructor(private readonly fileService: FileService) {}
@Get()
@Render('file/upload')
generateUploadForm() {
return {};
}
@Get(':key/direct')
@Redirect('', 302)
async getFile(@Param('key') key: string) {
const url = await this.fileService.generatePresignedUrl(key);
return { url };
}
@Get(':key')
async getFileWithProxy(@Param('key') key: string, @Res() res: Response) {
if (!(await this.fileService.fileExists(key))) {
return res.status(404).send('File not found or expired');
}
const buffer = await this.fileService.getFileFromS3(key);
const metadata = await this.fileService.getFileMetadataFromS3(key);
const { originalfilename, 'content-type': contentType } = metadata.metaData;
return res
.header('Content-Type', contentType)
.header(
'Content-Disposition',
`attachment; filename="${originalfilename ?? key}"`,
)
.send(buffer);
}
@Post('upload')
@UseInterceptors(FileInterceptor('file'))
@ApiConsumes('multipart/form-data')
@Render('file/upload-result')
async handleFileUpload(
@UploadedFile() file: Express.Multer.File,
): Promise<any> {
const upload = await this.fileService.handleFileUpload(file);
return {
...upload,
expireTime: new Date(upload.expireAt * 1000).toLocaleString(),
};
}
@Post('upload.txt')
@UseInterceptors(FileInterceptor('file'))
@ApiConsumes('multipart/form-data')
async handleFileSilentUpload(
@UploadedFile() file: Express.Multer.File,
): Promise<string> {
const upload = await this.fileService.handleFileUpload(file);
return `https://api.us.dev/file/${upload.key}`;
}
@Post('upload.json')
@UseInterceptors(FileInterceptor('file'))
@ApiConsumes('multipart/form-data')
async handleFileUploadJson(
@UploadedFile() file: Express.Multer.File,
): Promise<any> {
const upload = await this.fileService.handleFileUpload(file);
return {
...upload,
expireTime: new Date(upload.expireAt * 1000).toLocaleString(),
};
}
}

10
src/file/file.module.ts Normal file
View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { FileService } from './file.service';
import { FileController } from './file.controller';
import { MinioService } from 'src/minio/minio.service';
@Module({
providers: [FileService, MinioService],
controllers: [FileController]
})
export class FileModule { }

124
src/file/file.service.ts Normal file
View File

@@ -0,0 +1,124 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Cron } from '@nestjs/schedule';
import { ItemBucketMetadata } from 'minio';
import { UploadedObjectInfo } from 'minio/dist/main/internal/type';
import { MinioService } from 'src/minio/minio.service';
@Injectable()
export class FileService {
private readonly logger = new Logger(FileService.name);
private readonly bucketName;
private readonly filePrefix = 'file';
private expirationTime: number;
constructor(
private readonly minioService: MinioService,
private readonly configService: ConfigService,
) {
this.expirationTime = this.configService.get(
'file.defaultTtl',
30 * 24 * 60 * 60,
);
this.bucketName = this.configService.get(
'file.bucketName',
'api.us.dev-files',
);
try {
this.minioService.listBucketObjects(this.bucketName);
} catch (e) {
this.logger.log(`Error with bucket ${this.bucketName}: ${e.message}`);
}
this.deleteExpiredFiles();
}
private generateRandomKey(): string {
return Math.random().toString(36).substring(2);
}
private pathFromKey(key: string): string {
return [this.filePrefix, key].join('/');
}
async handleFileUpload(file: Express.Multer.File): Promise<{
uploadResult: UploadedObjectInfo;
expireAt: number;
key: string;
originalFilename: string;
}> {
const expireAt = Date.now() / 1000 + this.expirationTime;
const key = this.generateRandomKey();
const uploadResult = await this.minioService.uploadBuffer(
this.bucketName,
this.pathFromKey(key),
file.buffer,
{
expireAt,
originalFilename: file.originalname,
'content-type': file.mimetype,
},
);
return {
uploadResult,
key,
expireAt,
originalFilename: file.originalname,
};
}
async getFileFromS3(key: string): Promise<Buffer> {
return this.minioService.getBuffer(this.bucketName, this.pathFromKey(key));
}
async getFileMetadataFromS3(key: string): Promise<ItemBucketMetadata> {
return this.minioService.getObjectMetadata(
this.bucketName,
this.pathFromKey(key),
);
}
async fileExists(key: string): Promise<boolean> {
return this.minioService.objectExists(
this.bucketName,
this.pathFromKey(key),
);
}
@Cron('0 0 * * *')
private async deleteExpiredFiles(): Promise<void> {
this.logger.debug('Running cron job to delete expired files');
const now = Date.now() / 1000;
const objectNames = await this.minioService.listBucketObjects(
this.bucketName,
this.filePrefix,
true,
);
for (const objectName of objectNames) {
this.logger.debug(`Checking object ${objectName}`);
const objectInfo = await this.minioService.getObjectMetadata(
this.bucketName,
objectName,
);
if (objectInfo.metaData.expireat < now) {
this.logger.debug(`Deleting object ${objectName}`);
await this.minioService.deleteObject(this.bucketName, objectName);
}
}
}
async generatePresignedUrl(key: string): Promise<string> {
const objectPath = [this.filePrefix, key].join('/');
const metadata = await this.minioService.getObjectMetadata(
this.bucketName,
objectPath,
);
if (metadata.expireAt < Date.now() / 1000) {
throw new Error('Object has expired');
}
return await this.minioService.generatePresignedUrl(
this.bucketName,
objectPath,
10,
);
}
}

View File

@@ -0,0 +1,32 @@
import { Controller, Get, Logger, Query } from '@nestjs/common';
import { FocoLiveService } from './foco-live.service';
import { ApiQuery, ApiTags } from '@nestjs/swagger';
import { InjectMetric } from '@willsoto/nestjs-prometheus';
import { Gauge } from 'prom-client';
@ApiTags('foco-live')
@Controller('foco-live')
export class FocoLiveController {
private readonly logger = new Logger(FocoLiveController.name);
constructor(
private readonly focoLiveService: FocoLiveService,
@InjectMetric('query_count') public queryCount: Gauge<string>,
) { }
@Get('events')
@ApiQuery({ name: 'venue', required: false })
@ApiQuery({ name: 'before', required: false, description: "Filter events that are before the provided date, any date format" })
@ApiQuery({ name: 'after', required: false, description: "Filter events that are after the provided date, any date format" })
async getEvents(@Query('venue') venue?: string, @Query('before') before?: string, @Query('after') after?: string) {
this.queryCount.inc();
this.logger.verbose(`GET /foco-live/events?venue=${venue}&before=${before}&after=${after}`);
return this.focoLiveService.getEvents({ venue, before: before ? new Date(before) : undefined, after: after ? new Date(after) : undefined });
}
@Get('venues')
async getVenues() {
this.queryCount.inc();
return this.focoLiveService.getAllVenuesCached();
}
}

View File

@@ -0,0 +1,33 @@
import { Module } from '@nestjs/common';
import { FocoLiveController } from './foco-live.controller';
import { FocoLiveService } from './foco-live.service';
import { PrometheusModule, makeGaugeProvider } from '@willsoto/nestjs-prometheus';
import { CacheModule } from '@nestjs/cache-manager';
@Module({
imports: [
CacheModule.register(),
PrometheusModule.register({
customMetricPrefix: 'foco_live',
defaultMetrics: {
enabled: false,
},
})
],
controllers: [FocoLiveController],
providers: [FocoLiveService,
makeGaugeProvider({
name: 'event_count',
help: 'The current number of events in the Foco Live database at last query time',
}),
makeGaugeProvider({
name: 'event_cache_misses',
help: 'The total number of cache misses for the Foco Live events',
}),
makeGaugeProvider({
name: 'query_count',
help: 'The total number of queries to the Foco Live API',
}),
]
})
export class FocoLiveModule { }

View File

@@ -0,0 +1,152 @@
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Inject, Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Cron } from '@nestjs/schedule';
import { InjectMetric } from '@willsoto/nestjs-prometheus';
import * as Airtable from 'airtable';
import { AirtableBase } from 'airtable/lib/airtable_base';
import { all } from 'axios';
import { Cache } from 'cache-manager';
import { Gauge } from 'prom-client';
import { filter, pipe } from 'ramda';
const tables = {
venues: 'tblRi4wDorKqNJJbs',
events: 'tbl4RZ75QF5WefE7L',
};
const compareDates = (a: any, b: any) =>
new Date(a.Date).getTime() - new Date(b.Date).getTime();
const beforeFilter = (before?: Date) => (a: Event) =>
before ? new Date(a.Date) <= before : true;
const afterFilter = (after?: Date) => (a: Event) =>
after ? new Date(a.Date) >= after : true;
export interface Event {
Date: string;
'Music Start Time': string;
'Bar or Venue Name': string;
'Band/DJ/Musician Name': string;
'Cost Select': string;
Cost: string;
'Date Select': string;
'"Specials" at Venue': string;
Genre?: string;
id: string;
}
export interface Venue {
id: string;
'Bar or Venue Name': string;
'Street Address': string;
City: string;
'Zip Code': number;
State: string;
'Phone Number': string;
Website: string;
'Has Calendar Of Events': string;
'Facebook Page': string;
Instagram: string;
'Twitter Account': string;
}
const cacheKeys = {
allEvents: 'foco_live_events',
allVenues: 'foco_live_venues',
};
@Injectable()
export class FocoLiveService {
private readonly airtableBase: AirtableBase;
private readonly logger = new Logger(FocoLiveService.name);
constructor(
private readonly config: ConfigService,
@InjectMetric('event_count') public eventCount: Gauge<string>,
@InjectMetric('event_cache_misses') public cacheMisses: Gauge<string>,
@Inject(CACHE_MANAGER) private cacheManager: Cache,
) {
this.airtableBase = new Airtable({
apiKey: config.get('focoLive.airtable.apiKey'),
}).base('app1SjPrn5qrhr59J');
}
async getAllEvents(): Promise<Event[]> {
return (
(
await this.airtableBase('Events')
.select({
view: 'Grid view',
})
.all()
).map((record) => ({
id: record.id,
...record.fields,
})) as any as Event[]
)
.sort(compareDates)
.reverse();
}
async getAllEventsCached(): Promise<Event[]> {
let events: Event[] | null | undefined = await this.cacheManager.get(
cacheKeys.allEvents,
);
if (!events) {
events = await this.getAllEvents();
this.cacheMisses.inc();
this.cacheManager.set(cacheKeys.allEvents, events, 10 * 60 * 1000);
}
this.eventCount.set(events.length);
return events;
}
async getAllVenues(): Promise<Venue[]> {
return (
(
await this.airtableBase('Venues')
.select({
view: 'Grid view',
})
.all()
).map((record) => ({
id: record.id,
...record.fields,
})) as any as Venue[]
)
.sort(compareDates)
.reverse();
}
async getAllVenuesCached(): Promise<Venue[]> {
return (
(await this.cacheManager.get(cacheKeys.allVenues)) ||
(await this.getAllVenues())
);
}
@Cron('0 */5 * * * *')
async refreshEvents() {
this.logger.verbose('Refreshing events cache.');
await this.getAllEventsCached();
}
async getEvents(
options: { venue?: string; before?: Date; after?: Date } = {},
) {
const events = await this.getAllEventsCached();
const results = pipe(
filter(
(a: Event) =>
a['Bar or Venue Name'] === (options.venue ?? a['Bar or Venue Name']),
),
filter(beforeFilter(options.before)),
filter(afterFilter(options.after)),
)(events);
this.logger.verbose(
`Returning ${results.length} events, ${events.length} total events in database.`,
);
return results;
}
}

View File

@@ -0,0 +1,20 @@
import { Injectable } from '@nestjs/common';
import { ShopifyProduct, ShopifyProductWithHelpers, addShopifyHelperProperties, parseShopifyProduct } from './shopifyUtils';
import axios from 'axios';
import { map, pipe } from 'ramda';
@Injectable()
export class BindleService {
public readonly shopifyUrl = 'https://bindlecoffee.com/products.json'
public async fetchProducts(): Promise<ShopifyProductWithHelpers[]> {
const response = await axios.get(this.shopifyUrl)
if (response.status !== 200) {
throw new Error('Failed to fetch products')
}
return pipe(
map(parseShopifyProduct),
map(addShopifyHelperProperties({ shopBaseUrl: 'https://bindlecoffee.com' }))
)(response.data.products as ShopifyProduct<string>[])
}
}

View File

@@ -0,0 +1,50 @@
import { Controller, Get, UseInterceptors } from '@nestjs/common';
import { HarbingerService } from './harbinger.service';
import { FocoCoffeeService } from './fococoffee.service';
import { LimaService } from './lima.service';
import { BindleService } from './bindle.service';
import { ApiResponse, ApiTags } from '@nestjs/swagger';
import { CacheInterceptor } from '@nestjs/cache-manager';
@Controller('fococoffee')
@ApiTags('fococoffee')
@UseInterceptors(CacheInterceptor)
export class FocoCoffeeController {
constructor(
private readonly focoCoffeeService: FocoCoffeeService,
private readonly harbingerService: HarbingerService,
private readonly limaService: LimaService,
private readonly bindleService: BindleService
) { }
@Get('harbinger/products')
@ApiResponse({ status: 200, description: 'Returns the list of Harbinger products' })
async getHarbingerProducts() {
return this.harbingerService.fetchProducts()
}
@Get('lima/products')
@ApiResponse({ status: 200, description: 'Returns the list of Lima products' })
async getLimaProducts() {
return this.limaService.fetchProducts()
}
@Get('bindle/products')
@ApiResponse({ status: 200, description: 'Returns the list of Bindle products' })
async getBindleProducts() {
return this.bindleService.fetchProducts()
}
@Get('beans')
@ApiResponse({ status: 200, description: 'Returns the list of coffee bean products from all suppliers' })
async getBeans() {
return this.focoCoffeeService.getBeans()
}
@Get('products')
@ApiResponse({ status: 200, description: 'Returns the list of all products from all suppliers' })
async getAllProducts() {
return this.focoCoffeeService.getAllProducts()
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { FocoCoffeeController } from './fococoffee.controller';
import { FocoCoffeeService } from './fococoffee.service';
import { HarbingerService } from './harbinger.service';
import { LimaService } from './lima.service';
import { BindleService } from './bindle.service';
@Module({
imports: [],
controllers: [FocoCoffeeController],
providers: [FocoCoffeeService, HarbingerService, LimaService, BindleService]
})
export class FocoCoffeeModule { }

View File

@@ -0,0 +1,39 @@
import { Injectable } from '@nestjs/common';
import { LimaService } from './lima.service';
import { HarbingerService } from './harbinger.service';
import { BindleService } from './bindle.service';
@Injectable()
export class FocoCoffeeService {
constructor(
private readonly limaService: LimaService,
private readonly harbingerService: HarbingerService,
private readonly bindleService: BindleService
) { }
public async getAllProducts() {
const limaProducts = await this.limaService.fetchProducts()
const harbingerProducts = await this.harbingerService.fetchProducts()
const bindleProducts = await this.bindleService.fetchProducts()
return {
lima: limaProducts,
harbinger: harbingerProducts,
bindle: bindleProducts
}
}
public async getBeans() {
const limaBeans = await this.limaService.fetchProducts()
const harbingerBeans = await this.harbingerService.fetchProducts()
const bindleBeans = await this.bindleService.fetchProducts()
return {
lima: limaBeans
.filter(p => p.tags.includes("Coffee"))
.filter(p => !p.title.toLocaleLowerCase().includes("subscription"))
.filter(p => p.handle !== "lima-sample-pack")
,
harbinger: harbingerBeans.filter(p => p.product_type === "Packaged Coffee"),
bindle: bindleBeans.filter(p => p.product_type === "Coffee, Roasted")
}
}
}

View File

@@ -0,0 +1,20 @@
import { Injectable } from '@nestjs/common';
import { ShopifyProduct, ShopifyProductWithHelpers, addShopifyHelperProperties, parseShopifyProduct } from './shopifyUtils';
import axios from 'axios';
import { map, pipe } from 'ramda';
@Injectable()
export class HarbingerService {
public readonly shopifyUrl = 'https://harbingercoffee.com/products.json'
public async fetchProducts(): Promise<ShopifyProductWithHelpers[]> {
const response = await axios.get(this.shopifyUrl)
if (response.status !== 200) {
throw new Error('Failed to fetch products')
}
return pipe(
map(parseShopifyProduct),
map(addShopifyHelperProperties({ shopBaseUrl: 'https://harbingercoffee.com' }))
)(response.data.products as ShopifyProduct<string>[])
}
}

View File

@@ -0,0 +1,20 @@
import { Injectable } from '@nestjs/common';
import { ShopifyProduct, ShopifyProductWithHelpers, addShopifyHelperProperties, parseShopifyProduct } from './shopifyUtils';
import axios from 'axios';
import { map, pipe } from 'ramda';
@Injectable()
export class LimaService {
public readonly shopifyUrl = 'https://www.limacoffeeroasters.com/products.json'
public async fetchProducts(): Promise<ShopifyProductWithHelpers[]> {
const response = await axios.get(this.shopifyUrl)
if (response.status !== 200) {
throw new Error('Failed to fetch products')
}
return pipe(
map(parseShopifyProduct),
map(addShopifyHelperProperties({ shopBaseUrl: 'https://www.limacoffeeroasters.com' }))
)(response.data.products as ShopifyProduct<string>[])
}
}

View File

@@ -0,0 +1,96 @@
import { pipe } from "ramda";
export interface ShopifyVariant<T> {
id: number;
title: string;
price: string;
sku: string;
position: number;
option1?: string;
option2?: string;
option3?: string;
taxable: boolean;
grams: number;
available: boolean;
created_at: T;
updated_at: T;
}
export interface ShopifyImage<T> {
id: number;
created_at: T;
position: number;
updated_at: T;
product_id: number;
variant_ids: number[];
src: string;
width: number;
height: number;
}
export interface ShopifyOption {
name: string;
position: number;
values: string[];
}
export interface ShopifyProduct<T> {
id: number;
title: string;
handle: string;
body_html: string;
published_at: T;
created_at: T;
vendor: string;
product_type: string;
updated_at: T;
tags: string[];
variants: ShopifyVariant<T>[];
images: ShopifyImage<T>[];
options: ShopifyOption[];
}
export interface ShopifyProductWithHelpers extends Omit<ShopifyProduct<Date>, "images"> {
productUrl: string;
images: ({
imaginaryUrl: string;
} | ShopifyImage<Date>)[]
}
export interface helperData {
shopBaseUrl: string,
}
export const addShopifyHelperProperties = (data: helperData) => (productIn: ShopifyProduct<Date>): ShopifyProductWithHelpers => {
return {
productUrl: `${data.shopBaseUrl}/products/${productIn.handle}`,
...productIn,
images: productIn.images.map((image) => ({
...image,
imaginaryUrl: `https://imaginary.hooli.co/resize?width=250&height=250&url=${image.src}`
})),
}
}
export const parseShopifyProductDates = (productIn: ShopifyProduct<string | Date>): ShopifyProduct<Date> => {
return {
...productIn,
published_at: new Date(productIn.published_at),
created_at: new Date(productIn.created_at),
updated_at: new Date(productIn.updated_at),
variants: productIn.variants.map((variant) => ({
...variant,
created_at: new Date(variant.created_at),
updated_at: new Date(variant.updated_at),
})),
images: productIn.images.map((image) => ({
...image,
created_at: new Date(image.created_at),
updated_at: new Date(image.updated_at),
})),
};
}
export const parseShopifyProduct = pipe(parseShopifyProductDates);

View File

@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { HoardingService } from './hoarding.service';
import { MinioService } from 'src/minio/minio.service';
@Module({
providers: [HoardingService, MinioService],
})
export class HoardingModule {}

View File

@@ -0,0 +1,84 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Cron } from '@nestjs/schedule';
import axios from 'axios';
import { MinioService } from 'src/minio/minio.service';
@Injectable()
export class HoardingService {
private readonly logger: Logger = new Logger(HoardingService.name);
constructor(
public readonly minioService: MinioService,
private readonly configService: ConfigService,
) {
this.hoardChessStats();
}
@Cron('0 0 1 * * *')
async hoardChessStats() {
try {
await this.hoardUserChessStats('sentientcrouton');
} catch (e) {
this.logger.error(e);
}
try {
await this.hoardUserChessStats('archy_type');
} catch (e) {
this.logger.error(e);
}
try {
await this.hoardLichessStats('sentientcrouton');
} catch (e) {
this.logger.error(e);
}
try {
await this.hoardLichessStats('archy_type');
} catch (e) {
this.logger.error(e);
}
}
async hoardLichessStats(user: string) {
if (!this.configService.get('thirdPartyServices.lichess.token')) {
this.logger.error('No lichess token found');
return;
}
const token = this.configService.get('thirdPartyServices.lichess.token');
this.logger.log(`Hoarding lichess stats for ${user}`);
const lichessState = (id: string) => `https://lichess.org/api/user/${id}`;
const stats = await axios.get(lichessState(user), {
headers: {
Authorization: `Bearer ${token}`,
},
});
await this.minioService.uploadBuffer(
this.minioService.defaultBucketName,
`hoarding/lichess-stats/${user}/${new Date().getTime()}-${user}.json`,
Buffer.from(
JSON.stringify({
time: new Date().getTime(),
data: stats.data,
}),
),
);
}
async hoardUserChessStats(user: string) {
this.logger.log(`Hoarding chess.com stats for ${user}`);
const chessState = (id: string) =>
`https://api.chess.com/pub/player/${id}/stats`;
const stats = await axios.get(chessState(user));
const time = new Date().getTime();
await this.minioService.uploadBuffer(
this.minioService.defaultBucketName,
`hoarding/chess.com-stats/${user}/${time}-${user}.json`,
Buffer.from(
JSON.stringify({
time,
data: stats.data,
}),
),
);
}
}

View File

@@ -17,7 +17,10 @@ export class IrcbotService {
public readonly configService: ConfigService, public readonly configService: ConfigService,
public readonly domainrProxy: DomainrproxyService, public readonly domainrProxy: DomainrproxyService,
) { ) {
if (!this.configService.get<boolean>('irc.enabled')) return; if (!this.configService.get<boolean>('irc.enabled')) {
this.logger.verbose('IRC disabled, not connecting');
return;
};
const nick = this.configService.get<string>('irc.nick') || 'us-dev'; const nick = this.configService.get<string>('irc.nick') || 'us-dev';
const ircPassword = this.configService.get<string>('irc.password'); const ircPassword = this.configService.get<string>('irc.password');
this.socket = connect({ this.socket = connect({
@@ -68,7 +71,7 @@ export class IrcbotService {
this.client.send(channel, `Dunno what ${command} means`); this.client.send(channel, `Dunno what ${command} means`);
return; return;
} }
} catch {} } catch { }
}); });
} }
} }

190
src/jobs/jobs.controller.ts Normal file
View File

@@ -0,0 +1,190 @@
import { Body, Controller, Get, Header, Headers, Param, Post, Render, Req, UploadedFile, UseInterceptors } from '@nestjs/common';
import { JobMetadata, JobsService } from './jobs.service';
import { ApiBearerAuth, ApiBody, ApiConsumes, ApiParam, ApiProperty, ApiTags } from '@nestjs/swagger';
import { FileInterceptor } from '@nestjs/platform-express';
class ClaimCompleteDto {
@ApiProperty({
description: 'Identity of the completer',
example: 'my-identity'
})
completer: string;
@ApiProperty({
description: 'Identity of the item to complete',
example: 'my-item'
})
item: string;
@ApiProperty({
description: 'Data to store with the completion',
example: { foo: 'bar' }
})
data: any;
}
@Controller('jobs')
@ApiTags('jobs')
export class JobsController {
constructor(
private readonly jobsService: JobsService,
) { }
@Get(':jobName/stats.json')
@ApiParam({ name: 'jobName', required: true })
async getStats(@Param('jobName') jobName: string) {
return {
todoCount: await this.jobsService.getTodoItemCount(jobName),
claimedCount: await this.jobsService.getClaimedItemCount(jobName),
doneCount: await this.jobsService.getDoneItemCount(jobName),
doneScore: await this.jobsService.getDoneItemScoresTotal(jobName),
};
}
@Get(':jobName/stats')
@ApiParam({ name: 'jobName', required: true })
@Render('jobs/stats')
async getStatsPage(@Param('jobName') jobName: string) {
return {
jobName,
todoCount: await this.jobsService.getTodoItemCount(jobName),
claimedCount: await this.jobsService.getClaimedItemCount(jobName),
doneCount: await this.jobsService.getDoneItemCount(jobName),
doneScore: await this.jobsService.getDoneItemScoresTotal(jobName),
};
}
@Get(':jobName/todo.json')
@ApiParam({ name: 'jobName', required: true })
async getTodoItems(@Param('jobName') jobName: string) {
return this.jobsService.getTodoItems(jobName);
}
@Get(':jobName/done.json')
@ApiParam({ name: 'jobName', required: true })
async getDone(@Param('jobName') jobName: string) {
return this.jobsService.getDoneItems(jobName);
}
@Get(':jobName/claimed.json')
@ApiParam({ name: 'jobName', required: true })
async getClaimed(@Param('jobName') jobName: string) {
return this.jobsService.getClaimedItems(jobName);
}
@Get(':jobName/leaderboard.json')
@ApiParam({ name: 'jobName', required: true })
async getLeaderboard(@Param('jobName') jobName: string) {
return this.jobsService.getLeaderboard(jobName);
}
@Get(':jobName/leaderboard')
@ApiParam({ name: 'jobName', required: true })
@Render('jobs/leaderboard')
async getLeaderboardPage(@Param('jobName') jobName: string) {
const leaderboard = await this.jobsService.getLeaderboard(jobName);
type LeaderboardItem = { name: string, count: number }
return {
jobName,
claims: Object.keys(leaderboard.claimCounts).map(claimer => ({ name: claimer, count: leaderboard.claimCounts[claimer] })).sort((a: LeaderboardItem, b: LeaderboardItem) => b.count - a.count),
completes: Object.keys(leaderboard.completeCounts).map(completer => ({ name: completer, count: leaderboard.completeCounts[completer] })).sort((a: LeaderboardItem, b: LeaderboardItem) => b.count - a.count),
}
}
@Post(':jobName/register')
@ApiParam({ name: 'jobName', required: true })
@ApiBody({
description: "Job metadata", examples:
{
"example1": {
value: {
name: 'my-job',
description: 'My job description',
tags: ['tag1', 'tag2'],
createdBy: 'my-identity',
createdAt: '2021-01-01T00:00:00Z',
claimSecret: 'my-secret'
}
}
}
})
@ApiConsumes('application/json')
async claimJobItemJson(@Param('jobName') jobName: string, @Body() body: JobMetadata) {
return this.jobsService.registerJob(jobName, body);
}
@Get(':jobName.json')
@ApiParam({ name: 'jobName', required: true })
async getJobMetadata(@Param('jobName') jobName: string) {
const metadata = await this.jobsService.getPublicJobMetadata(jobName);
if (!metadata) {
return {
error: 'Job not registered',
};
}
return metadata;
}
@Post(':jobName/clear-todo')
@ApiParam({ name: 'jobName', required: true })
@ApiBearerAuth('x-claim-key')
async clearTodoItems(@Headers('x-claim-key') claimKey: string, @Param('jobName') jobName: string) {
return this.jobsService.clearTodoItems(jobName, claimKey);
}
@Get(':jobName/add')
@ApiParam({ name: 'jobName', required: true })
@Render('jobs/add')
async addItemsPage(@Param('jobName') jobName: string) {
return {
jobName,
};
}
@Post(':jobName/add')
@ApiParam({ name: 'jobName', required: true })
@ApiConsumes('text/plain')
@ApiBody({ type: String, description: "Items to add, one per line separated by newline characters" })
async addItemsToJob(@Param('jobName') jobName: string, @Body() items: string) {
return this.jobsService.addItemsToJob(jobName, items.split('\n'));
}
@Post(':jobName/addFile')
@UseInterceptors(FileInterceptor('file'))
@ApiParam({ name: 'jobName', required: true })
@ApiConsumes('multipart/form-data')
@ApiBody({ description: "File containing items to add, one per line separated by newline characters" })
async addItemsToJobFromFile(@Param('jobName') jobName: string, @UploadedFile() file: Express.Multer.File) {
return this.jobsService.addItemsToJob(
jobName,
file.buffer.toString().split('\n')
);
}
@Post(':jobName/claim')
@ApiParam({ name: 'jobName', required: true })
@ApiConsumes('text/plain')
@ApiBody({ type: String, description: "Claimer identity string" })
async claimJobItem(@Param('jobName') jobName: string, @Body() claimer: string) {
const item = await this.jobsService.claimJobItem(jobName, claimer);
return {
jobName,
claimer,
item
}
}
@Post(':jobName/complete')
@ApiParam({ name: 'jobName', required: true })
async completeJobItem(@Param('jobName') jobName: string, @Body() body: ClaimCompleteDto) {
return this.jobsService.completeJobItem(jobName, body.item, body.completer, body.data);
}
@Post(':jobName/reset-claimed')
@ApiParam({ name: 'jobName', required: true })
async resetClaimed(@Param('jobName') jobName: string) {
return this.jobsService.resetClaimedItems(jobName);
}
}

10
src/jobs/jobs.module.ts Normal file
View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { JobsService } from './jobs.service';
import { JobsController } from './jobs.controller';
import { MinioService } from 'src/minio/minio.service';
@Module({
providers: [JobsService, MinioService],
controllers: [JobsController]
})
export class JobsModule { }

241
src/jobs/jobs.service.ts Normal file
View File

@@ -0,0 +1,241 @@
import { Inject, Injectable } from '@nestjs/common';
import { MinioService } from 'src/minio/minio.service';
import Redis from 'ioredis'
import { InjectRedis } from '@liaoliaots/nestjs-redis';
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager';
import { aperture, splitEvery } from 'ramda';
export interface JobMetadata {
name: string;
description: string;
tags: string[];
createdBy: string;
createdAt: string;
claimSecret: string;
}
export const privateMetadataKeys = ['claimSecret'];
export type PublicJobMetadata = Omit<JobMetadata, typeof privateMetadataKeys[number]>;
type Leaderboard = {
completeCounts: { [claimer: string]: number },
claimCounts: { [claimer: string]: number }
doneScores: { [claimer: string]: number }
}
@Injectable()
export class JobsService {
constructor(
private readonly minioService: MinioService,
@InjectRedis() private readonly redis: Redis,
@Inject(CACHE_MANAGER) private readonly cacheManager: Cache,
) { }
public static cleanJobMetadata(metadata: JobMetadata): PublicJobMetadata {
return Object.fromEntries(Object.entries(metadata).filter(([key]) => !privateMetadataKeys.includes(key))) as PublicJobMetadata;
}
private jobNameBuilder(jobName: string) {
return `job:${jobName}`;
}
private todoListNameBuilder(jobName: string) {
return `todo:${jobName}`;
}
private doneListNameBuilder(jobName: string) {
return `done:${jobName}`;
}
private claimedListNameBuilder(jobName: string) {
return `claimed:${jobName}`;
}
private completeCountNameBuilder(jobName: string, claimer: string) {
return `complete:${jobName}:${claimer}`;
}
private claimerCountNameBuilder(jobName: string, claimer: string) {
return `claim:${jobName}:${claimer}`;
}
private claimerCountWildcardBuilder(jobName: string) {
return `claim:${jobName}:*`;
}
private async getCompleteCounts(jobName: string): Promise<{ [claimer: string]: number }> {
const keys = await this.redis.keys(`complete:${jobName}:*`);
const counts = await Promise.all(keys.map(async key => {
const count = await this.redis.get(key);
if (!count) {
return null;
}
return { claimer: key.split(':')[2], count: parseInt(count) };
}));
return counts.reduce((acc: any, val: any) => {
if (!val) {
return acc;
}
acc[val.claimer] = val.count;
return acc;
}, {})
}
private async getClaimCounts(jobName: string): Promise<{ [claimer: string]: number }> {
const keys = await this.redis.keys(`claim:${jobName}:*`);
const counts = await Promise.all(keys.map(async key => {
const count = await this.redis.get(key);
if (!count) {
return null;
}
return { claimer: key.split(':')[2], count: parseInt(count) };
}));
return counts.reduce((acc: any, val: any) => {
if (!val) {
return acc;
}
acc[val.claimer] = val.count;
return acc;
}, {});
}
async getLeaderboard(jobName: string): Promise<Leaderboard> {
const cachedLeaderboard = await this.cacheManager.get<Leaderboard>(`leaderboard:${jobName}`);
if (cachedLeaderboard) {
return cachedLeaderboard;
}
const completeCounts = await this.getCompleteCounts(jobName);
const claimCounts = await this.getClaimCounts(jobName);
const doneScores = await this.getDoneItemScores(jobName);
this.cacheManager.set(`leaderboard:${jobName}`, { completeCounts, claimCounts, doneScores }, 200);
return { completeCounts, claimCounts, doneScores };
}
async addItemsToJob(jobName: string, items: string[]) {
const splitSize = 500
for (const itemSubset of (items.length > splitSize ? splitEvery(splitSize, items) : [items])) {
await this.redis.rpush(this.todoListNameBuilder(jobName), ...itemSubset);
}
}
async claimJobItem(jobName: string, claimer: string): Promise<string | null> {
const jobItem = await this.redis.brpoplpush(this.todoListNameBuilder(jobName), this.claimedListNameBuilder(jobName), 10);
if (!jobItem) {
return null;
}
await this.redis.incr(this.claimerCountNameBuilder(jobName, claimer));
return jobItem;
}
async completeJobItem(jobName: string, jobItem: string, completer: string, data: any) {
const claimRemoveResult = await this.redis.lrem(this.claimedListNameBuilder(jobName), 1, jobItem);
if (claimRemoveResult === 0) {
return false;
}
await this.redis.rpush(this.doneListNameBuilder(jobName), JSON.stringify({ item: jobItem, client: completer, data }));
await this.redis.decr(this.claimerCountNameBuilder(jobName, completer));
await this.redis.incr(this.completeCountNameBuilder(jobName, completer));
return true
}
async getTodoItems(jobName: string) {
return this.redis.lrange(this.todoListNameBuilder(jobName), 0, -1);
}
async getTodoItemCount(jobName: string) {
return this.redis.llen(this.todoListNameBuilder(jobName));
}
async getClaimedItems(jobName: string) {
return this.redis.lrange(this.claimedListNameBuilder(jobName), 0, -1);
}
async getClaimedItemCount(jobName: string) {
return this.redis.llen(this.claimedListNameBuilder(jobName));
}
async getDoneItems(jobName: string) {
return this.redis.lrange(this.doneListNameBuilder(jobName), 0, -1);
}
async getDoneItemCount(jobName: string) {
return this.redis.llen(this.doneListNameBuilder(jobName));
}
async getDoneItemScoresTotal(jobName: string) {
const doneItems = await this.getDoneItems(jobName);
const scores = doneItems.map(item => JSON.parse(item).data?.score || 1);
return scores.reduce((acc: number, val: number) => acc + val, 0);
}
// Sum the "score" property of each item in the done list and
// accumulate based on claimer
async getDoneItemScores(jobName: string) {
const doneItems = await this.getDoneItems(jobName);
const scores = doneItems.map(item => JSON.parse(item).data?.score || 1);
const claimers = doneItems.map(item => JSON.parse(item).client);
const scoreMap = claimers.reduce((acc: any, val: any, idx: number) => {
acc[val] = (acc[val] || 0) + scores[idx];
return acc;
}, {});
return scoreMap;
}
async getJobs() {
return this.redis.keys('job:*');
}
async isJobRegistered(jobName: string) {
return this.redis.exists(this.jobNameBuilder(jobName));
}
async registerJob(jobName: string, metadata: JobMetadata) {
if (await this.isJobRegistered(jobName)) {
return false;
}
await this.redis.set(this.jobNameBuilder(jobName), JSON.stringify(metadata));
return true
}
async getJobMetadata(jobName: string): Promise<JobMetadata | null> {
const result = await this.redis.get(this.jobNameBuilder(jobName))
if (!result) {
return null;
}
return JSON.parse(result)
}
async getPublicJobMetadata(jobName: string): Promise<PublicJobMetadata | null> {
const metadata = await this.getJobMetadata(jobName);
if (!metadata) {
return null;
}
return JobsService.cleanJobMetadata(metadata);
}
async clearClaimerCounts(jobName: string) {
const keys = await this.redis.keys(this.claimerCountWildcardBuilder(jobName));
await Promise.all(keys.map(key => this.redis.del(key)));
}
async resetClaimedItems(jobName: string) {
const claimedItems = await this.getClaimedItems(jobName);
for (const claimedItem of claimedItems) {
await this.redis.rpoplpush(this.claimedListNameBuilder(jobName), this.todoListNameBuilder(jobName));
}
await this.clearClaimerCounts(jobName);
}
async clearTodoItems(jobName: string, claimKey: string) {
const metadata = await this.getJobMetadata(jobName);
if (metadata === null || metadata.claimSecret !== claimKey) {
return false;
}
await this.redis.del(this.todoListNameBuilder(jobName));
}
}

View File

@@ -0,0 +1,123 @@
import {
Body,
Controller,
Get,
Param,
Post,
Redirect,
Render,
Req,
Res,
UploadedFiles,
UseInterceptors,
} from '@nestjs/common';
import { JunkDrawerService } from './junk-drawer.service';
import { ApiConsumes, ApiTags } from '@nestjs/swagger';
import { FilesInterceptor } from '@nestjs/platform-express';
import { generateUniqueSlug } from 'src/utils/slug';
import { JunkDrawerMetadata } from './types';
import { Request, Response } from 'express';
import { ipFromRequest } from 'src/utils/ip';
@Controller(['junk-drawer', 'attic'])
@ApiTags('attic')
export class JunkDrawerController {
constructor(private readonly junkDrawerService: JunkDrawerService) {}
@Get('')
@Render('junk-drawer/upload')
async generateUploadForm(@Req() req: Request) {
let items: string[] = [];
const ip = ipFromRequest(req);
if (ip) {
items = await this.junkDrawerService.getItemsForIp(ip);
}
return { items, ip };
}
@Get(':slug.json')
async junkDrawerJson(
@Param('slug') slug: string,
): Promise<JunkDrawerMetadata | undefined> {
return this.junkDrawerService.getJunkDrawerMetadata(slug);
}
@Get(':slug')
@Render('junk-drawer/view')
async viewJunkDrawer(@Param('slug') slug: string): Promise<any> {
const metadata = await this.junkDrawerService.getJunkDrawerMetadata(slug);
if (!metadata) {
return { error: 'File not found or expired' };
}
metadata.items = metadata.items.map((item) => ({
...item,
isImage: item.mimetype.startsWith('image/'),
}));
return { ...metadata };
}
@Get(':slug/:filename')
async downloadJunkDrawerItem(
@Param('slug') slug: string,
@Param('filename') filename: string,
@Res() res: Response,
) {
const metadata = await this.junkDrawerService.getJunkDrawerMetadata(slug);
if (!metadata) {
return res.status(404).send('File not found or expired');
}
const item = metadata.items.find((item) => item.filename === filename);
if (!item) {
return res.status(404).send('File not found or expired');
}
const buffer = await this.junkDrawerService.getJunkDrawerItem(
slug,
filename,
);
return res
.header('Content-Type', item.mimetype)
.header('Content-Disposition', `filename="${item.filename}"`)
.send(buffer);
}
@Post('upload')
@Redirect('', 302)
@ApiConsumes('multipart/form-data')
@UseInterceptors(FilesInterceptor('files'))
async handleFileUpload(
@UploadedFiles() files: Express.Multer.File[],
@Req() request: Request,
@Body('description') description: string,
@Body('private-ish') privateIsh: boolean,
@Body('remember') remember: boolean,
): Promise<any> {
const uniqueSlug = generateUniqueSlug({
random: privateIsh,
});
const ip = ipFromRequest(request);
const metadata: JunkDrawerMetadata = {
slug: uniqueSlug,
version: '1.0.0',
lastModified: new Date(),
description,
items: files.map((file) => ({
filename: file.originalname,
size: file.size,
lastModified: new Date(),
mimetype: file.mimetype,
})),
};
for (const file of files) {
await this.junkDrawerService.storeJunkDrawerItem(
uniqueSlug,
file.originalname,
file.buffer,
);
}
await this.junkDrawerService.storeJunkDrawerMetadata(metadata);
if (remember && ip && !privateIsh) {
await this.junkDrawerService.recordItemForIp(ip, uniqueSlug);
}
return { url: `/attic/${uniqueSlug}` };
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { JunkDrawerService } from './junk-drawer.service';
import { JunkDrawerController } from './junk-drawer.controller';
import { MinioService } from 'src/minio/minio.service';
@Module({
providers: [JunkDrawerService, MinioService],
controllers: [JunkDrawerController],
})
export class JunkDrawerModule {}

View File

@@ -0,0 +1,135 @@
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { MinioService } from 'src/minio/minio.service';
import { JunkDrawerMetadata } from './types';
import { generateUniqueSlug } from 'src/utils/slug';
@Injectable()
export class JunkDrawerService {
private readonly logger: Logger = new Logger(JunkDrawerService.name);
constructor(
private readonly minioService: MinioService,
private readonly configService: ConfigService,
) {}
private pathForFile(filename: string): string {
return [this.configService.get<string>('junkDrawer.rootPath'), filename]
.join('/')
.replace(/\.\.\//g, '');
}
private junkDrawerBucketName(): string {
return this.configService.get<string>(
'junkDrawer.bucketName',
'junk-drawer',
);
}
public generateSlug(fileName: string): string {
return (
generateUniqueSlug() +
fileName
.replace(/[^a-z0-9]/gi, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
);
}
public async getJunkDrawerMetadata(
slug: string,
): Promise<JunkDrawerMetadata | undefined> {
const metadata = await this.minioService.getBuffer(
this.junkDrawerBucketName(),
this.pathForFile(`${slug}/metadata.json`),
);
if (metadata) {
return JSON.parse(metadata.toString());
}
}
public async storeJunkDrawerMetadata(
metadata: JunkDrawerMetadata,
): Promise<JunkDrawerMetadata | undefined> {
const uploadResult = await this.minioService.uploadBuffer(
this.junkDrawerBucketName(),
this.pathForFile(`${metadata.slug}/metadata.json`),
Buffer.from(JSON.stringify(metadata)),
);
if (uploadResult) {
return metadata;
}
}
public async storeJunkDrawerItem(
slug: string,
filename: string,
buffer: Buffer,
): Promise<void> {
const uploadResult = this.minioService.uploadBuffer(
this.junkDrawerBucketName(),
this.pathForFile(`${slug}/${filename}`),
buffer,
);
}
public async storeJunkDrawerCollection(
metadata: JunkDrawerMetadata,
files: { filename: string; buffer: Buffer }[],
): Promise<void> {
await this.storeJunkDrawerMetadata(metadata);
await Promise.all(
files.map((file) =>
this.storeJunkDrawerItem(metadata.slug, file.filename, file.buffer),
),
);
}
public async getJunkDrawerItem(
slug: string,
filename: string,
): Promise<Buffer | undefined> {
return this.minioService.getBuffer(
this.junkDrawerBucketName(),
this.pathForFile(`${slug}/${filename}`),
);
}
public async recordItemForIp(ip: string, itemId: string) {
let ipList = Buffer.from(JSON.stringify({}));
try {
ipList = await this.minioService.getBuffer(
this.junkDrawerBucketName(),
this.pathForFile(`ip-list.json`),
);
} catch (error) {
this.logger.warn('IP list is empty');
}
let ipListObject = ipList ? JSON.parse(ipList.toString()) : {};
if (!ipListObject[ip]) {
ipListObject[ip] = {
lastUpload: new Date(),
itemIds: [],
};
}
ipListObject[ip].itemIds.push(itemId);
await this.minioService.uploadBuffer(
this.junkDrawerBucketName(),
this.pathForFile(`ip-list.json`),
Buffer.from(JSON.stringify(ipListObject)),
);
}
public async getItemsForIp(ip: string): Promise<string[]> {
let ipList = Buffer.from(JSON.stringify({}));
try {
ipList = await this.minioService.getBuffer(
this.junkDrawerBucketName(),
this.pathForFile(`ip-list.json`),
);
} catch (error) {
this.logger.warn('IP list is empty');
}
let ipListObject = ipList ? JSON.parse(ipList.toString()) : {};
return ipListObject[ip]?.itemIds || [];
}
}

20
src/junk-drawer/types.ts Normal file
View File

@@ -0,0 +1,20 @@
export interface JunkDrawerMetadata {
slug: string;
version: string;
lastModified: Date;
description: string;
items: JunkDrawerItem[];
}
export interface JunkDrawerItem {
filename: string;
size: number;
lastModified: Date;
mimetype: string;
}
export interface JunkDrawerIpList {
ip: string;
lastUpload: Date;
itemIds: string[];
}

View File

@@ -12,16 +12,17 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { KvService } from './kv.service'; import { KvService } from './kv.service';
import { Request } from 'express'; import { Request } from 'express';
import { ApiTags } from '@nestjs/swagger'; import { ApiQuery, ApiTags } from '@nestjs/swagger';
import exp from 'constants';
import { FileInterceptor } from '@nestjs/platform-express'; import { FileInterceptor } from '@nestjs/platform-express';
@Controller('kv') @Controller('kv')
@ApiTags('kv') @ApiTags('kv')
export class KvController { export class KvController {
constructor(private readonly kvService: KvService) {} constructor(private readonly kvService: KvService) { }
@Get(':namespace/:key/metadata') @Get(':namespace/:key/metadata')
@ApiQuery({ name: 'namespace', required: true })
@ApiQuery({ name: 'key', required: true })
async getMetadata( async getMetadata(
@Param('namespace') namespace: string, @Param('namespace') namespace: string,
@Param('key') key: string, @Param('key') key: string,

View File

@@ -6,11 +6,7 @@ import { BullModule } from '@nestjs/bull';
@Module({ @Module({
providers: [KvService, MinioService], providers: [KvService, MinioService],
imports: [ imports: [],
BullModule.registerQueue({
name: 'kv',
}),
],
controllers: [KvController], controllers: [KvController],
}) })
export class KvModule {} export class KvModule {}

View File

@@ -1,7 +1,5 @@
import { InjectQueue } from '@nestjs/bull';
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { Queue } from 'bull'; import { UploadedObjectInfo } from 'minio/dist/main/internal/type';
import { UploadedObjectInfo } from 'minio';
import { MinioService } from 'src/minio/minio.service'; import { MinioService } from 'src/minio/minio.service';
@Injectable() @Injectable()
@@ -12,10 +10,7 @@ export class KvService {
private readonly kvMetadataPath = `${this.kvPrefix}/${this.kvMetadataFileName}`; private readonly kvMetadataPath = `${this.kvPrefix}/${this.kvMetadataFileName}`;
private readonly logger: Logger = new Logger(KvService.name); private readonly logger: Logger = new Logger(KvService.name);
constructor( constructor(private readonly minioService: MinioService) {}
private readonly minioService: MinioService,
@InjectQueue('kv') private kvProcessingQueue: Queue,
) {}
public generateFilePath(namespace: string, key: string): string { public generateFilePath(namespace: string, key: string): string {
return `${this.kvPrefix}/${namespace}/${key}`; return `${this.kvPrefix}/${namespace}/${key}`;
@@ -123,7 +118,6 @@ export class KvService {
}; };
await this.setMetadataFile(Buffer.from(JSON.stringify(metadata))); await this.setMetadataFile(Buffer.from(JSON.stringify(metadata)));
this.logger.verbose(`Claimed namespace ${namespace}`); this.logger.verbose(`Claimed namespace ${namespace}`);
this.kvProcessingQueue.add('namespaceModeration', metadata[namespace]);
return metadata[namespace]; return metadata[namespace];
} }

View File

@@ -3,6 +3,7 @@ import { AppModule } from './app.module';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { NestExpressApplication } from '@nestjs/platform-express'; import { NestExpressApplication } from '@nestjs/platform-express';
import { join } from 'path'; import { join } from 'path';
import * as cookieParser from 'cookie-parser';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule); const app = await NestFactory.create<NestExpressApplication>(AppModule);
@@ -18,7 +19,9 @@ async function bootstrap() {
const document = SwaggerModule.createDocument(app, config); const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api', app, document); SwaggerModule.setup('api', app, document);
app.enableCors({ origin: '*' }); app.enableCors({ origin: '*' });
app.useBodyParser('text'); app.useBodyParser('json', { limit: '50mb' });
app.useBodyParser('text', { limit: '50mb' });
app.use(cookieParser());
app.disable('x-powered-by'); app.disable('x-powered-by');
await app.listen(3000); await app.listen(3000);
} }

View File

@@ -1,7 +1,8 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { MinioService } from './minio.service'; import { MinioService } from './minio.service';
import { CacheModule } from '@nestjs/cache-manager';
@Module({ @Module({
providers: [MinioService] providers: [MinioService],
}) })
export class MinioModule {} export class MinioModule {}

View File

@@ -2,7 +2,9 @@ import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Cache } from 'cache-manager'; import { Cache } from 'cache-manager';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; 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() @Injectable()
export class MinioService { export class MinioService {
@@ -19,6 +21,7 @@ export class MinioService {
useSSL: this.configService.get<string>('S3_USE_SSL', 'true') === 'true', useSSL: this.configService.get<string>('S3_USE_SSL', 'true') === 'true',
accessKey: this.configService.get<string>('S3_ACCESS_KEY', ''), accessKey: this.configService.get<string>('S3_ACCESS_KEY', ''),
secretKey: this.configService.get<string>('S3_SECRET_KEY', ''), secretKey: this.configService.get<string>('S3_SECRET_KEY', ''),
region: this.configService.get<string>('S3_REGION', 'auto'),
}); });
this.defaultBucketName = this.configService.get<string>('S3_BUCKET', ''); this.defaultBucketName = this.configService.get<string>('S3_BUCKET', '');
} }
@@ -29,12 +32,8 @@ export class MinioService {
filePath: string, filePath: string,
metadata?: ItemBucketMetadata, metadata?: ItemBucketMetadata,
): Promise<UploadedObjectInfo> { ): Promise<UploadedObjectInfo> {
return await this.client.fPutObject( const file = readFileSync(filePath);
bucketName, return this.uploadBuffer(bucketName, objectName, file, metadata);
objectName,
filePath,
metadata,
);
} }
public async uploadBuffer( public async uploadBuffer(
@@ -43,10 +42,11 @@ export class MinioService {
buffer: Buffer, buffer: Buffer,
metadata?: ItemBucketMetadata, metadata?: ItemBucketMetadata,
): Promise<UploadedObjectInfo> { ): Promise<UploadedObjectInfo> {
return await this.client.putObject( return this.client.putObject(
bucketName, bucketName,
objectName, objectName,
buffer, buffer,
buffer.length,
metadata, metadata,
); );
} }
@@ -89,8 +89,13 @@ export class MinioService {
public async listBucketObjects( public async listBucketObjects(
bucketName: string, bucketName: string,
prefix?: string, prefix?: string,
recursive: boolean = false,
): Promise<string[]> { ): Promise<string[]> {
const objectStream = await this.client.listObjects(bucketName, prefix); const objectStream = await this.client.listObjectsV2(
bucketName,
prefix,
recursive,
);
const objects = await new Promise<ItemBucketMetadata[]>( const objects = await new Promise<ItemBucketMetadata[]>(
(resolve, reject) => { (resolve, reject) => {
const objects: ItemBucketMetadata[] = []; const objects: ItemBucketMetadata[] = [];
@@ -109,6 +114,10 @@ export class MinioService {
await this.client.removeObject(bucketName, objectName); await this.client.removeObject(bucketName, objectName);
} }
public async makeBucket(bucketName: string): Promise<void> {
return this.client.makeBucket(bucketName);
}
/** /**
* *
* @param bucketName * @param bucketName
@@ -123,4 +132,16 @@ export class MinioService {
): Promise<string> { ): Promise<string> {
return await this.client.presignedGetObject(bucketName, objectName, expiry); return await this.client.presignedGetObject(bucketName, objectName, expiry);
} }
public async objectExists(
bucketName: string,
objectName: string,
): Promise<boolean> {
try {
await this.client.statObject(bucketName, objectName);
return true;
} catch (e) {
return false;
}
}
} }

View File

@@ -0,0 +1,26 @@
import { Controller, Get, Param } from '@nestjs/common';
import { NamesService } from './names.service';
@Controller('names')
export class NamesController {
constructor(private readonly namesService: NamesService) {}
@Get()
async getNameList() {
return await this.namesService.getNameList();
}
@Get(':name')
async getNameInformation(@Param('name') name: string) {
return {
popularity: await this.namesService.getSsaNameData(name),
otherSites: {
behindTheName: `https://www.behindthename.com/name/${name.toLocaleLowerCase()}`,
babynames: `https://www.babynames.com/name/${name.toLocaleLowerCase()}`,
},
behindTheName: {
synonyms: await this.namesService.getBtnSynonyms(name),
},
};
}
}

10
src/names/names.module.ts Normal file
View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { NamesService } from './names.service';
import { NamesController } from './names.controller';
import { MinioService } from 'src/minio/minio.service';
@Module({
providers: [MinioService, NamesService],
controllers: [NamesController],
})
export class NamesModule {}

View File

@@ -0,0 +1,41 @@
import { Injectable } from '@nestjs/common';
import { MinioService } from 'src/minio/minio.service';
@Injectable()
export class NamesService {
constructor(private readonly minioService: MinioService) {}
async getNameList(): Promise<string[]> {
return JSON.parse(
(
await this.minioService.getCachedBuffer(
'cdn-source',
'baby-name-data/list.json',
)
).toString(),
);
}
async getSsaNameData(name: string): Promise<any> {
return JSON.parse(
(
await this.minioService.getCachedBuffer(
'cdn-source',
`baby-name-data/individual/${name}.json`,
)
).toString(),
);
}
async getBtnSynonyms(name: string): Promise<string[]> {
const synonymData = JSON.parse(
(
await this.minioService.getCachedBuffer(
'cdn-source',
`baby-name-data/btn_synonyms.json`,
)
).toString(),
);
return synonymData[name]?.synonyms || [];
}
}

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

@@ -2,9 +2,10 @@ import { Module } from '@nestjs/common';
import { ParkioService } from './parkio.service'; import { ParkioService } from './parkio.service';
import { ParkioController } from './parkio.controller'; import { ParkioController } from './parkio.controller';
import { IswordService } from 'src/isword/isword.service'; import { IswordService } from 'src/isword/isword.service';
import { CacheModule } from '@nestjs/cache-manager';
@Module({ @Module({
providers: [ParkioService, IswordService], providers: [ParkioService, IswordService],
controllers: [ParkioController] controllers: [ParkioController],
}) })
export class ParkioModule {} export class ParkioModule {}

66
src/pow/pow.controller.ts Normal file
View File

@@ -0,0 +1,66 @@
import {
BadRequestException,
Body,
Controller,
Get,
Post,
Query,
Render,
} from '@nestjs/common';
import { PowService } from './pow.service';
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('')
@Render('pow/index')
async index() {
return {
difficulty: this.powService.getDifficulty(),
};
}
@Get('challenge')
async generateChallenge() {
return this.powService.generateChallenge();
}
@Post('challenge')
@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);
}
@Post('challenge/complete')
@ApiBody({ schema: { properties: { challenge: { type: 'string' } } } })
async markChallengeAsComplete(@Body() body: { challenge: string }) {
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);
}
}

34
src/pow/pow.module.ts Normal file
View File

@@ -0,0 +1,34 @@
import { Module } from '@nestjs/common';
import { PowService } from './pow.service';
import { PowController } from './pow.controller';
import { makeGaugeProvider } from '@willsoto/nestjs-prometheus';
@Module({
imports: [],
providers: [
PowService,
makeGaugeProvider({
name: 'pow_challenges_generated',
help: 'The total number of POW challenges generated',
}),
makeGaugeProvider({
name: 'pow_challenges_completed',
help: 'The total number of POW challenges completed',
}),
makeGaugeProvider({
name: 'pow_successful_verifies',
help: 'The total number of successful POW challenge verifications',
}),
makeGaugeProvider({
name: 'pow_failed_verifies',
help: 'The total number of failed POW challenge verifications',
}),
makeGaugeProvider({
name: 'pow_difficulty',
help: 'The current POW difficulty',
}),
],
controllers: [PowController],
exports: [PowService],
})
export class PowModule {}

102
src/pow/pow.service.ts Normal file
View File

@@ -0,0 +1,102 @@
import { CACHE_MANAGER } from '@nestjs/cache-manager';
import { Inject, Injectable, Logger } from '@nestjs/common';
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);
private difficulty = 5;
constructor(
@Inject(CACHE_MANAGER) private cacheManager: Cache,
@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 60 seconds.
*/
async generateChallenge() {
const challenge = this.generateRandom256BitString();
this.logger.verbose(`Generated challenge: ${challenge}`);
this.challengesGenerated.inc();
await this.cacheManager.set(challenge, true, 60 * 1000);
return challenge;
}
generateRandom256BitString() {
const randomString = randomBytes(32).toString('hex');
return randomString;
}
hashAndCheck(string: string) {
return this.hashPassesDifficulty(this.hashString(string), this.difficulty);
}
hashPassesDifficulty(hash: string, difficulty: number) {
return hash.startsWith('0'.repeat(difficulty));
}
/**
* Verify that the proof of work submitted has a leading number of
* zeroes equal to the challenge length and the challenge exists.
*/
async verifyChallenge(challenge: string, proof: string): Promise<boolean> {
const expected = await this.cacheManager.get<boolean>(challenge);
const success = expected ? this.hashAndCheck(proof + challenge) : false;
if (success) {
this.successfulVerifies.inc();
} else {
this.failedVerifies.inc();
}
return success;
}
async markChallengeAsComplete(challenge: string) {
this.challengesCompleted.inc();
await this.cacheManager.del(challenge);
}
/**
* Perform a proof of work challenge to find a proof that hashes to a value
*/
async performChallenge(challenge: string) {
let proof = this.generateRandom256BitString();
let hash = this.hashString(proof + challenge);
while (!this.hashPassesDifficulty(hash, this.difficulty)) {
proof = this.generateRandom256BitString();
hash = this.hashString(proof + challenge);
}
return { proof, hash };
}
/**
* sha512 the provided string and return the result.
*/
hashString(input: string) {
return createHash('sha512').update(input).digest('hex');
}
/**
* Set the difficulty of the proof of work challenge.
*/
setDifficulty(difficulty: number) {
this.difficulty = difficulty;
this.powDifficulty.set(this.difficulty);
}
/**
* Get the current difficulty of the proof of work challenge.
*/
getDifficulty() {
return this.difficulty;
}
}

View File

@@ -1,7 +1,8 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { RedirectsController } from './redirects.controller'; import { RedirectsController } from './redirects.controller';
import { RequiredReadingController } from './required-reading.controller';
@Module({ @Module({
controllers: [RedirectsController] controllers: [RedirectsController, RequiredReadingController]
}) })
export class RedirectsModule {} export class RedirectsModule { }

1
src/users/users.json Normal file
View File

@@ -0,0 +1 @@
[]

View File

@@ -2,7 +2,6 @@ import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { Cron } from '@nestjs/schedule'; import { Cron } from '@nestjs/schedule';
import { MinioService } from 'src/minio/minio.service'; import { MinioService } from 'src/minio/minio.service';
require('dotenv').config();
// This should be a real class/interface representing a user entity // This should be a real class/interface representing a user entity
export type User = { export type User = {
@@ -11,6 +10,15 @@ export type User = {
password: string; password: string;
}; };
const developmentUsers: User[] = [
{
userId: 1,
username: 'admin',
// "password"
password: '$2b$10$c3d3JacaYw3KZ9qy4HniMeum5MXSj1VOOz8EWL5K23ZTL5aPnMNhS',
}
]
@Injectable() @Injectable()
export class UsersService { export class UsersService {
private users: User[] = []; private users: User[] = [];
@@ -28,6 +36,11 @@ export class UsersService {
@Cron('* * * * *') @Cron('* * * * *')
async refreshUsers(): Promise<void> { async refreshUsers(): Promise<void> {
this.logger.verbose('Refreshing users'); this.logger.verbose('Refreshing users');
if (this.configService.get<boolean>('isProduction', false) === false) {
this.logger.verbose('Development environment, using development users');
this.users = developmentUsers;
return;
}
const buffer = await this.minioService.getBuffer(this.bucket, 'users.json'); const buffer = await this.minioService.getBuffer(this.bucket, 'users.json');
this.users = JSON.parse(buffer.toString()); this.users = JSON.parse(buffer.toString());
if (this.users === undefined) { if (this.users === undefined) {

9
src/utils/ip.ts Normal file
View File

@@ -0,0 +1,9 @@
import { Request } from 'express';
export const ipFromRequest = (req: Request): string | undefined => {
const flyIp = req.headers['fly-client-ip'] as string | undefined;
const forwardedFor = (req.headers['x-forwarded-for'] as string)?.split(
',',
)[0];
return flyIp || forwardedFor || req.ip;
};

45
src/utils/slug.ts Normal file
View File

@@ -0,0 +1,45 @@
import { v4 } from 'uuid';
interface SlugOptions {
year: boolean;
month: boolean;
day: boolean;
hour: boolean;
minute: boolean;
second: boolean;
random: boolean;
separator: string;
}
const defaultSlugOptions: SlugOptions = {
year: true,
month: true,
day: true,
hour: true,
minute: true,
second: false,
random: false,
separator: '',
};
export const generateUniqueSlug = (
options: Partial<SlugOptions> = {},
): string => {
const { year, month, day, hour, minute, second, random, separator } = {
...defaultSlugOptions,
...options,
};
const date = new Date();
const parts = [
year ? date.getFullYear() : '',
month ? String(date.getMonth() + 1).padStart(2, '0') : '',
day ? String(date.getDate()).padStart(2, '0') : '',
hour ? String(date.getHours()).padStart(2, '0') : '',
minute ? String(date.getMinutes()).padStart(2, '0') : '',
second ? String(date.getSeconds()).padStart(2, '0') : '',
random ? '-' + v4() : '',
].filter(Boolean);
return parts.join(separator);
};

129
views/baby-names/form.hbs Normal file
View File

@@ -0,0 +1,129 @@
<form action='/baby-names' method='post'>
{{#if message}}
<div>
<i>{{message}}</i>
</div>
{{/if}}
<div>
<label for='key'>Your key:</label>
<input type='text' name='key' value='{{key}}' required />
</div>
<div>
<h1>{{name}} <span style='color:gray'>{{lastName}}</span></h1>
</div>
<div>
<h3>{{synonyms}}</h3>
</div>
<input type='hidden' name='name' value='{{name}}' />
<input type='hidden' name='nameindex' value='{{index}}' />
<div
style='display: flex; flex-direction: column; gap: 1em; margin-bottom: 1em;'
>
{{! Opinion }}
<div>
<label for='opinion'>How do you feel about this name?</label>
<div style='width: 300px;'>
<input
type='range'
id='opinion'
name='opinion'
min='-2'
max='2'
step='1'
list='opinion-ticks'
style='width: 100%;'
required
/>
<datalist id='opinion-ticks'>
<option value='-2' label='Hate it'></option>
<option value='-1' label='Dislike it'></option>
<option value='0' label='Neutral'></option>
<option value='1' label='Like it'></option>
<option value='2' label='Love it'></option>
</datalist>
<div
style='display: flex; justify-content: space-between; font-size: 0.9em; margin-top: 0.2em;'
>
<span>Hate it</span>
<span>Dislike it</span>
<span>Neutral</span>
<span>Like it</span>
<span>Love it</span>
</div>
</div>
</div>
{{! Pronunciation }}
<div>
<label for='pronunciation'>Is this pronounceable?</label>
<div style='width: 300px;'>
<input
type='range'
id='pronunciation'
name='pronunciation'
min='-2'
max='2'
step='1'
list='pronunciation-ticks'
style='width: 100%;'
required
/>
<datalist id='pronunciation-ticks'>
<option value='-2' label='Very Hard'></option>
<option value='0' label='Normal'></option>
<option value='2' label='Very Easy'></option>
</datalist>
<div
style='display: flex; justify-content: space-between; font-size: 0.9em; margin-top: 0.2em;'
>
<span>Very Hard</span>
<span></span>
<span>Normal</span>
<span></span>
<span>Very Easy</span>
</div>
</div>
</div>
{{! Spelling }}
<div>
<label for='spelling'>How easy is this to spell?</label>
<div style='width: 300px;'>
<input
type='range'
id='spelling'
name='spelling'
min='-2'
max='2'
step='1'
list='spelling-ticks'
style='width: 100%;'
required
/>
<datalist id='spelling-ticks'>
<option value='-2' label='Very Hard'></option>
<option value='0' label='Normal'></option>
<option value='2' label='Very Easy'></option>
</datalist>
<div
style='display: flex; justify-content: space-between; font-size: 0.9em; margin-top: 0.2em;'
>
<span>Very Hard</span>
<span></span>
<span>Normal</span>
<span></span>
<span>Very Easy</span>
</div>
</div>
</div>
<div>
</div>
<div>
<textarea
name='comment'
rows='4'
cols='50'
placeholder='Any additional comments?'
></textarea>
</div>
</div>
<button type='submit'>Submit Opinion</button>
</form>

View File

@@ -0,0 +1,13 @@
<form action='/baby-names/form' method='get'>
<input type='text' name='key' placeholder='Enter your key' required />
<button type='submit'>Log In</button>
</form>
{{#if previousKey}}
<i>or</i>
<form action='/baby-names/form' method='get'>
<input type='hidden' name='key' value='{{previousKey}}' />
<button type='submit'>Continue as {{previousKey}}</button>
</form>
{{/if}}

View File

@@ -0,0 +1,8 @@
<div>
<h1>Upload Result</h1>
<p>File uploaded successfully.</p>
<p>File name: {{originalFilename}}</p>
<p>Right-click copy: <a href='/file/{{key}}'>{{key}}</a></p>
<p>Expires at {{expireTime}}</p>
<p><a href='/file'>Upload another file</a></p>
</div>

9
views/file/upload.hbs Normal file
View File

@@ -0,0 +1,9 @@
<form method='post' action='/file/upload' enctype='multipart/form-data'>
<div>
<label for='file'>Choose file to upload</label>
<input type='file' id='file' name='file' multiple />
</div>
<div>
<button>Submit</button>
</div>
</form>

14
views/jobs/add.hbs Normal file
View File

@@ -0,0 +1,14 @@
<form
method='post'
action='/jobs/{{jobName}}/addFile'
enctype='multipart/form-data'
>
<p>File will be split on newline characters</p>
<div>
<label for='file'>Choose file to upload</label>
<input type='file' id='file' name='file' />
</div>
<div>
<button>Submit</button>
</div>
</form>

View File

@@ -0,0 +1,23 @@
<div>
<div>Job: {{jobName}}</div>
<div style='display: flex; flex-direction: row;'>
<div>
<h2>Complete</h2>
<ol>
{{#each completes as |item|}}
<li>{{name}}: {{count}}</li>
{{/each}}
</ol>
</div>
<div>
<h2>Claims</h2>
<ol>
{{#each claims as |item|}}
<li>{{name}}: {{count}}</li>
{{/each}}
</ol>
</div>
</div>
</div>
<i>This page will auto-refresh every 10 seconds.</i>
<meta http-equiv='refresh' content='10' />

15
views/jobs/stats.hbs Normal file
View File

@@ -0,0 +1,15 @@
<div>
<div>Job: {{jobName}}</div>
<div style='display: flex; flex-direction: row;'>
<div>ToDo: </div>
<div>{{todoCount}}</div>
</div>
<div style='display: flex; flex-direction: row;'>
<div>Claimed: </div>
<div>{{claimedCount}}</div>
</div>
<div style='display: flex; flex-direction: row;'>
<div>Done: </div>
<div>{{doneCount}}</div>
</div>
</div>

View File

@@ -0,0 +1,59 @@
<html>
<head>
<meta charset='utf-8' />
<title>Chip's Attic</title>
<script src='https://cdn.tailwindcss.com'></script>
<meta name='viewport' content='width=device-width, initial-scale=1' />
</head>
<body>
<form
method='post'
action='/attic/upload'
enctype='multipart/form-data'
>
<div>
<label for='files'>Choose files to upload</label>
<input type='file' id='files' name='files' multiple />
</div>
<div>
<label
class='cursor-help'
title='Append a random-ish string to the ID to make it harder to find via enumeration'
for='private-ish'
>Private-ish</label>
<input type='checkbox' id='private-ish' name='private-ish' />
</div>
<div>
<label for='remember'>Remember for my IP</label>
<input type='checkbox' id='remember' name='remember' />
</div>
<div class='flex flex-col max-w-96'>
<label for='description'>Description:</label>
<textarea
type='text'
id='description'
name='description'
rows='4'
autofocus
class='border-2 rounded-md border-slate-800 border-solid p-1'
></textarea>
</div>
<div>
<button class='border-2 rounded-md border-slate-800 p-1'>Submit</button>
</div>
</form>
<div>
<h2>Your Items ({{ip}})</h2>
<div>
{{#each items}}
<div>
<a class="text-blue-500 underline" href='/junk-drawer/{{.}}'>{{.}}</a>
</div>
{{/each}}
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,39 @@
<html>
</html>
<head>
<meta charset='utf-8' />
<title>Chip's Attic</title>
<script src='https://cdn.tailwindcss.com'></script>
</head>
<body>
<div>
<h1 class="text-xl">Attic Item</h1>
<p>The attic item was uploaded {{lastModified}}</p>
<h2>Description</h2>
<p class="bg-slate-100 px-2 py-4">{{description}}</p>
<p>Right-click copy: <a class="text-blue-500 underline" href='/attic/{{slug}}'>{{slug}}</a></p>
{{#if items}}
<p class="text-lg">Files:</p>
<div class="list-disc list-inside ml-1">
{{#each items}}
<div>
<a target="_blank" class="text-emerald-600" href='/attic/{{../slug}}/{{filename}}'>
<p>
{{filename}}
</p>
{{#if isImage}}
<img height="150" width="150" src='/attic/{{../slug}}/{{filename}}' alt='{{filename}}' />
{{/if}}
</a>
</div>
{{/each}}
</div>
{{/if}}
<p><a class="text-blue-500 underline" href='/attic'>Upload another file</a></p>
</div>
</body>
</html>

83
views/pow/index.hbs Normal file
View File

@@ -0,0 +1,83 @@
<html>
<head>
<title>Proof Of Work API</title>
</head>
<body>
<h1>PoW API</h1>
<p>
This is a simple API that allows you to generate a proof of work
challenges and verify the proof of work.
</p>
<p>
The API has two endpoints:
<ul>
<li>GET
<a href='/pow/challenge'>/pow/challenge</a>
- This endpoint generates a new proof of work challenge.</li>
<li>POST
<a href='/pow/challenge'>/pow/challenge</a>
- This endpoint verifies a proof of work challenge.</li>
</ul>
</p>
<p>
The proof of work challenge is to find a proof which, when concatenated
and hashed (via SHA 512) with a challenge string that starts with a
certain number of zeros. The number of zeros is determined by the
difficulty level. The difficulty level is a number between 1 and 10. The
higher the difficulty level, the more zeros the string must start with.
</p>
<p>
The current difficulty is
{{difficulty}}.
</p>
<p>
The example code for performing a proof of work challenge is:
<pre>
const crypto = require('crypto');
const axios = require('axios');
async function getChallenge() {
const response = await axios.get('https://api.us.dev/pow/challenge');
return response.data;
}
async function submitSolution(challenge, proof) {
const response = await axios.post('https://api.us.dev/pow/challenge', {
challenge,
proof
});
return response.data;
}
function hashProof(challenge, proof) {
return crypto.createHash('sha512').update(challenge + proof).digest('hex');
}
function verifyChallenge(hash: string, difficulty: number) {
return hash.startsWith('0'.repeat(difficulty));
}
async function proofOfWork() {
const challenge = await getChallenge();
let proof = 0;
let hash = '';
do {
proof++;
hash = hashProof(challenge, proof);
} while (!verifyChallenge(hash, 5));
return await submitSolution(challenge, proof);
}
proofOfWork().then(console.log).catch(console.error);
</pre>
</p>
<p>
If you're a developer you should use the GET /pow/challenge endpoint to
get the challenge, then pass the challenge to your client, then have the
client find a solution to the challenge and then submit their proof as
part of their later action. Your service should then verify the solution
using the POST /pow/challenge endpoint (or your own local implementation)
to verify the solution.
</p>
</body>
</html>

4009
yarn.lock

File diff suppressed because it is too large Load Diff