Browse Source

add basic wanikani auth

main
parent
commit
646c7aed1f
8 changed files with 2490 additions and 0 deletions
  1. +43
    -0
      package.json
  2. +160
    -0
      src/api/index.ts
  3. +51
    -0
      src/index.ts
  4. +30
    -0
      src/logger.ts
  5. +23
    -0
      src/shared.ts
  6. +299
    -0
      src/util/search.ts
  7. +100
    -0
      tsconfig.json
  8. +1784
    -0
      yarn.lock

+ 43
- 0
package.json View File

@ -0,0 +1,43 @@
{
"name": "@jaquiz/server",
"version": "0.1.0",
"main": "lib/index.js",
"private": true,
"repository": "git@git.polv.cc:jaquiz/server.git",
"author": "Pacharapol Withayasakpunt <polv@polv.cc>",
"license": "MIT",
"scripts": {
"ts": "ts-node",
"build": "rm -r lib && tsc --rootDir src --outDir lib"
},
"dependencies": {
"@databases/pg": "^5.1.1",
"axios": "^0.23.0",
"connect-pg-simple": "^7.0.0",
"dayjs": "^1.10.7",
"fastify": "^3.22.0",
"fastify-cookie": "^5.3.1",
"fastify-rate-limit": "^5.6.2",
"fastify-session": "^5.2.1",
"fastify-static": "^4.4.2",
"fastify-swagger": "^4.12.4",
"jsonschema-definer": "^1.3.2",
"short-uuid": "^4.2.0"
},
"devDependencies": {
"@types/better-sqlite3": "^7.4.0",
"@types/connect-pg-simple": "^4.2.4",
"@types/node": "^16.10.9",
"@types/pino": "^6.3.11",
"better-sqlite3": "^7.4.3",
"import-sort-parser-typescript": "^6.0.0",
"ts-node": "^10.3.0",
"typescript": "^4.4.4"
},
"importSort": {
".js, .ts": {
"parser": "typescript",
"style": "module"
}
}
}

+ 160
- 0
src/api/index.ts View File

@ -0,0 +1,160 @@
import qs from 'querystring'
import sql from '@databases/sql'
import axios from 'axios'
import ConnectPG from 'connect-pg-simple'
import fastify, { FastifyPluginAsync } from 'fastify'
import fCookie from 'fastify-cookie'
import rateLimit from 'fastify-rate-limit'
import fSession from 'fastify-session'
import fastifySwagger from 'fastify-swagger'
import { db, isDev } from '../shared'
const apiRouter: FastifyPluginAsync = async (f) => {
f.register(fCookie)
f.register(fSession, {
secret: process.env['SECRET']!,
// @ts-ignore
store: new (ConnectPG(fSession))(
process.env['DATABASE_URL']
? {
conString: process.env['DATABASE_URL'],
}
: {
conObject: {
user: process.env['POSTGRES_USER'],
password: process.env['POSTGRES_PASSWORD'],
database: process.env['POSTGRES_DB'],
host: process.env['POSTGRES_HOST'],
},
}
),
})
f.register(rateLimit, {
max: 10,
timeWindow: '1 second',
allowList: (req) => {
if (req.routerPath === '/api/quiz/getSrsLevel') {
return true
}
return false
},
})
f.register(fastifySwagger, {
openapi: {
security: [
{
BearerAuth: [],
},
],
components: {
securitySchemes: {
BearerAuth: {
type: 'http',
scheme: 'bearer',
},
},
},
},
exposeRoute: isDev,
routePrefix: '/doc',
})
f.addHook('preHandler', async (req) => {
const { body, log } = req
if (body && typeof body === 'object' && body.constructor === Object) {
log.info({ body }, 'parsed body')
}
})
f.addHook('preHandler', async (req) => {
if (['/api/doc', '/api/settings'].some((s) => req.url.startsWith(s))) {
return
}
if (req.session['userId']) {
return
}
const [, apiKey] =
/^Bearer (.+)$/.exec(req.headers.authorization || '') || []
if (!apiKey) {
throw { statusCode: 401, message: 'no apiKey in header' }
}
const userData = await axios
.create({
baseURL: 'https://api.wanikani.com/v2/',
headers: {
Authorization: `Bearer ${apiKey}`,
},
validateStatus: function () {
return true
},
})
.get('/user')
.then(
(r) =>
r.data as {
data: {
id: string
username: string
level: number
subscription: {
active: boolean
type: string
}
}
}
)
const userId = userData.data.id
await db.query(sql`
INSERT INTO "user" ("id", "level")
VALUES (${userId}, ${userData.data.level})
ON CONFLICT ("id") DO UPDATE SET
"level" = EXCLUDED."level"
`)
req.session['userId'] = userId
})
}
export default apiRouter
async function main() {
const app = fastify({
logger: {
prettyPrint: isDev,
serializers: {
req(req) {
if (!req.url) {
return { method: req.method }
}
const [url = '', q] = req.url.split(/\?(.+)$/)
const query = q ? qs.parse(q) : undefined
return { method: req.method, url, query }
},
},
},
})
app.register(apiRouter, {
prefix: '/api',
})
return app.listen(process.env['PORT']!, '0.0.0.0')
}
if (require.main === module) {
main()
}

+ 51
- 0
src/index.ts View File

@ -0,0 +1,51 @@
import path from 'path'
import qs from 'querystring'
import fastify from 'fastify'
import fastifyStatic from 'fastify-static'
import apiRouter from './api'
import { gCloudLogger } from './logger'
import { isDev } from './shared'
async function main() {
const port = parseInt(process.env['PORT']!) || 35594
process.env['PORT'] = port.toString()
const app = fastify({
logger: gCloudLogger({
prettyPrint: isDev,
serializers: {
req(req) {
if (!req.url) {
return { method: req.method }
}
const [url, q] = req.url.split(/\?(.+)$/)
const query = q ? qs.parse(q) : undefined
return { method: req.method, url, query }
},
},
}),
})
app.register(apiRouter, {
prefix: '/api',
})
app.register(fastifyStatic, {
root: path.join(__dirname, '../public'),
redirect: true,
})
app.setNotFoundHandler((_, reply) => {
reply.redirect(200, '/')
})
await app.listen(port, '0.0.0.0')
}
if (require.main === module) {
main()
}

+ 30
- 0
src/logger.ts View File

@ -0,0 +1,30 @@
import pino from 'pino'
const SeverityLookup = {
default: 'DEFAULT',
silly: 'DEFAULT',
verbose: 'DEBUG',
debug: 'DEBUG',
http: 'notice',
info: 'INFO',
warn: 'WARNING',
error: 'ERROR',
}
const defaultPinoConfig: pino.LoggerOptions = {
messageKey: 'message',
formatters: {
level(label, number) {
return {
severity: SeverityLookup[label as keyof typeof SeverityLookup],
level: number,
}
},
},
}
export const gCloudLogger = (pinoConfigOverrides: pino.LoggerOptions = {}) =>
pino({
...defaultPinoConfig,
...pinoConfigOverrides,
})

+ 23
- 0
src/shared.ts View File

@ -0,0 +1,23 @@
import createConnectionPool, { ConnectionPool } from '@databases/pg'
export const isDev = process.env['NODE_ENV'] === 'development'
export let db: ConnectionPool
// @ts-ignore
if (!db) {
db = createConnectionPool({
...(process.env['DATABASE_URL']
? {
connectionString: process.env['DATABASE_URL'],
}
: {
user: process.env['POSTGRES_USER']!,
password: process.env['POSTGRES_PASSWORD']!,
database: process.env['POSTGRES_DB']!,
host: process.env['POSTGRES_HOST']!,
port: parseInt(process.env['POSTGRES_PORT']!),
}),
bigIntMode: 'number',
})
}

+ 299
- 0
src/util/search.ts View File

@ -0,0 +1,299 @@
import { SQLQuery, sql } from '@databases/pg'
import dayjs from 'dayjs'
export class QSplit {
constructor(
private opts: {
default: (v: string) => SQLQuery | null
fields: {
[name: string]: {
[op: string]: (v: string) => SQLQuery
}
}
}
) {}
parse(q: string) {
const $and: SQLQuery[] = []
const $or: SQLQuery[] = []
const $not: SQLQuery[] = []
const ops = new Set(
Object.values(this.opts.fields).flatMap((def) => Object.keys(def))
)
for (let kv of this.doSplit(q, ' ')) {
let $current = $and
if (kv[0] === '-') {
$current = $not
kv = kv.substr(1)
} else if (kv[0] === '?') {
$current = $or
kv = kv.substr(1)
}
for (const op of ops) {
const segs = this.doSplit(kv, op)
if (segs.length === 1) {
const cond = this.opts.default(segs[0]!)
if (cond) {
$current.push(cond)
}
} else if (segs.length === 2) {
const fnMap = this.opts.fields[segs[0]!]
if (!fnMap) continue
const fn = fnMap[op]
if (!fn) continue
$current.push(fn(segs[1]!))
}
}
}
let cond: SQLQuery | null = null
if ($not.length) {
$and.push(sql`NOT ((${sql.join($not, ') AND (')}))`)
}
if ($and.length) {
$or.push(sql`(${sql.join($and, ') AND (')})`)
}
if ($or.length) {
cond = sql`((${sql.join($or, ') OR (')}))`
}
return cond
}
/**
* ```js
* > this.doSplit('')
* []
* > this.doSplit('a:b "c:d e:f"')
* ['a:b', 'c:d e:f']
* > this.doSplit('a "b c" "d e"')
* ['a', 'b c', 'd e']
* ```
*/
private doSplit(ss: string, splitter: string) {
const brackets = [
['"', '"'],
["'", "'"],
] as const
const keepBraces = false
const bracketStack = {
data: [] as string[],
push(c: string) {
this.data.push(c)
},
pop() {
return this.data.pop()
},
peek() {
return this.data.length > 0
? this.data[this.data.length - 1]
: undefined
},
}
const tokenStack = {
data: [] as string[],
currentChars: [] as string[],
addChar(c: string) {
this.currentChars.push(c)
},
flush() {
const d = this.currentChars.join('')
if (d) {
this.data.push(d)
}
this.currentChars = []
},
}
let prev = ''
ss.split('').map((c) => {
if (prev === '\\') {
tokenStack.addChar(c)
} else {
let canAddChar = true
for (const [op, cl] of brackets) {
if (c === cl) {
if (bracketStack.peek() === op) {
bracketStack.pop()
canAddChar = false
break
}
}
if (c === op) {
bracketStack.push(c)
canAddChar = false
break
}
}
if (c === splitter && !bracketStack.peek()) {
tokenStack.flush()
} else {
if (keepBraces || canAddChar) {
tokenStack.addChar(c)
}
}
}
prev = c
})
tokenStack.flush()
return tokenStack.data.map((s) => s.trim()).filter((s) => s)
}
}
export const qParseNum: (
k: SQLQuery
) => Record<string, (v: string) => SQLQuery> = (k) => ({
':': (v) => {
switch (v.toLocaleLowerCase()) {
case 'null':
return sql`${k} IS NULL`
case 'any':
return sql`${k} IS NOT NULL`
}
const m = /^([[\(])(\d+),(\d+)([\])])$/.exec(v)
if (m) {
let gt = sql`>`
let lt = sql`<`
if (m[1] === '[') gt = sql`>=`
if (m[4] === ']') gt = sql`<=`
return sql`${k} ${gt} ${parseInt(m[2])} OR ${k} ${lt} ${parseInt(m[3])}`
}
return sql`${k} = ${parseInt(v)}`
},
'>': (v) => {
return v[0] === '='
? sql`${k} >= ${parseInt(v.substr(1))}`
: sql`${k} > ${parseInt(v)}`
},
'<': (v) => {
return v[0] === '='
? sql`${k} <= ${parseInt(v.substr(1))}`
: sql`${k} < ${parseInt(v)}`
},
})
const reDur = /^([+-]?\d+(?:\.\d+)?)([A-Z]+)$/i
const toDate = (s: string) => {
const m = reDur.exec(s)
let d = dayjs(s)
if (m) {
d = dayjs().add(parseFloat(m[1]!), m[2] as any)
if (d.isValid()) {
return d.toDate()
}
}
if (d.isValid()) {
return d.toDate()
}
return null
}
const toBetween = (s: string) => {
const m = reDur.exec(s)
if (m && toDate(s) instanceof Date) {
return [
dayjs()
.add(parseFloat(m[1]!) - 0.5, m[2] as any)
.toDate(),
dayjs()
.add(parseFloat(m[1]!) + 0.5, m[2] as any)
.toDate(),
]
}
return []
}
export const qParseDate: (
k: SQLQuery
) => Record<string, (v: string) => SQLQuery> = (k) => ({
':': (v) => {
let b: Date[] = []
switch (v.toLocaleLowerCase()) {
case 'null':
return sql`${k} IS NULL`
case 'any':
return sql`${k} IS NOT NULL`
case 'now':
b = toBetween('-0.5d')
}
const reBetween =
/^([[\(])([+-]?\d+(?:\.\d+)?[A-Z]+),([+-]?\d+(?:\.\d+)?[A-Z]+)([\])])$/i
const m = reBetween.exec(v)
if (m) {
let gt = sql`>`
let lt = sql`<`
if (m[1] === '[') gt = sql`>=`
if (m[4] === ']') gt = sql`<=`
return sql`${k} ${gt} ${toDate(m[2]!)} OR ${k} ${lt} ${toDate(m[3]!)}`
}
if (reDur.test(v)) {
b = toBetween(v)
}
if (b.length === 2) {
return sql`${k} > ${b[0]} AND ${k} > ${b[1]}`
}
return sql`FALSE`
},
'>': (v) => sql`${k} > ${toDate(v)}`,
'<': (v) => sql`${k} < ${toDate(v)}`,
})
export const makeQuiz = new QSplit({
default: () => null,
fields: {
srsLevel: qParseNum(sql`quiz."srsLevel"`),
nextReview: qParseDate(sql`quiz."nextReview"`),
lastRight: qParseDate(sql`quiz."lastRight"`),
lastWrong: qParseDate(sql`quiz."lastWrong"`),
maxRight: qParseNum(sql`quiz."maxRight"`),
maxWrong: qParseNum(sql`quiz."maxWrong"`),
rightStreak: qParseNum(sql`quiz."rightStreak"`),
wrongStreak: qParseNum(sql`quiz."wrongStreak"`),
},
})
export const makeTag = new QSplit({
default: () => null,
fields: {
tag: { ':': (v) => sql`${v} = ANY("entry"."tag")` },
type: {
':': (v) => sql`"entry"."type" = ${v.replace(/hanzi/gi, 'character')}`,
},
},
})
export const makeLevel = new QSplit({
default: () => null,
fields: {
level: qParseNum(sql`"entry"."level"`),
hLevel: qParseNum(sql`"entry"."hLevel"`),
},
})

+ 100
- 0
tsconfig.json View File

@ -0,0 +1,100 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig.json to read more about this file */
/* Projects */
// "incremental": true, /* Enable incremental compilation */
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
// "tsBuildInfoFile": "./", /* Specify the folder for .tsbuildinfo incremental compilation files. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "es2019", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h' */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using `jsx: react-jsx*`.` */
// "reactNamespace": "", /* Specify the object invoked for `createElement`. This only applies when targeting `react` JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
/* Modules */
"module": "commonjs", /* Specify what module code is generated. */
// "rootDir": "./", /* Specify the root folder within your source files. */
// "moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like `./node_modules/@types`. */
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "resolveJsonModule": true, /* Enable importing .json files */
// "noResolve": true, /* Disallow `import`s, `require`s or `<reference>`s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the `checkJS` option to get errors from these files. */
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from `node_modules`. Only applicable with `allowJs`. */
/* Emit */
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
"sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If `declaration` is true, also designates a file that bundles all .d.ts output. */
// "outDir": "./", /* Specify an output folder for all emitted files. */
// "removeComments": true, /* Disable emitting comments. */
// "noEmit": true, /* Disable emitting files from a compilation. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
// "newLine": "crlf", /* Set the newline character for emitting files. */
// "stripInternal": true, /* Disable emitting declarations that have `@internal` in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like `__extends` in compiled output. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
// "preserveConstEnums": true, /* Disable erasing `const enum` declarations in generated code. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
/* Interop Constraints */
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables `allowSyntheticDefaultImports` for type compatibility. */
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
/* Type Checking */
"strict": true, /* Enable all strict type-checking options. */
"noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied `any` type.. */
"strictNullChecks": true, /* When type checking, take into account `null` and `undefined`. */
"strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
"strictBindCallApply": true, /* Check that the arguments for `bind`, `call`, and `apply` methods match the original function. */
"strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
"noImplicitThis": true, /* Enable error reporting when `this` is given the type `any`. */
"useUnknownInCatchVariables": true, /* Type catch clause variables as 'unknown' instead of 'any'. */
"alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
"noUnusedLocals": true, /* Enable error reporting when a local variables aren't read. */
"noUnusedParameters": true, /* Raise an error when a function parameter isn't read */
"exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
"noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
"noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
"noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */
"noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
"noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
}
}

+ 1784
- 0
yarn.lock
File diff suppressed because it is too large
View File


Loading…
Cancel
Save