omgebouwd naar adonisjs, werkt nog niet echt goed

This commit is contained in:
Daan Meijer 2026-06-03 23:41:09 +02:00
parent dfd3336447
commit 62f1767df3
89 changed files with 9894 additions and 1176 deletions

15
.adonisjs/client/data.d.ts vendored Normal file
View File

@ -0,0 +1,15 @@
/**
* This file is automatically generated.
* DO NOT EDIT manually
*/
/// <reference path="./manifest.d.ts" />
import type { InferData, InferVariants } from '@adonisjs/core/types/transformers'
import type UserTransformer from '#transformers/user_transformer'
export namespace Data {
export type User = InferData<UserTransformer>
export namespace User {
export type Variants = InferVariants<UserTransformer>
}
}

12
.adonisjs/client/manifest.d.ts vendored Normal file
View File

@ -0,0 +1,12 @@
/**
* This file is automatically generated.
* DO NOT EDIT manually
*/
/// <reference path="../../adonisrc.ts" />
/// <reference path="../../config/auth.ts" />
/// <reference path="../../config/hash.ts" />
/// <reference path="../../config/logger.ts" />
/// <reference path="../../config/nntp.ts" />
/// <reference path="../../config/queue.ts" />
/// <reference path="../../config/redis.ts" />

View File

@ -0,0 +1,47 @@
/* eslint-disable prettier/prettier */
import type { AdonisEndpoint } from '@tuyau/core/types'
import type { Registry } from './schema.d.ts'
import type { ApiDefinition } from './tree.d.ts'
const placeholder: any = {}
const routes = {
'auth.new_account.store': {
methods: ["POST"],
pattern: '/api/v1/auth/signup',
tokens: [{"old":"/api/v1/auth/signup","type":0,"val":"api","end":""},{"old":"/api/v1/auth/signup","type":0,"val":"v1","end":""},{"old":"/api/v1/auth/signup","type":0,"val":"auth","end":""},{"old":"/api/v1/auth/signup","type":0,"val":"signup","end":""}],
types: placeholder as Registry['auth.new_account.store']['types'],
},
'auth.access_tokens.store': {
methods: ["POST"],
pattern: '/api/v1/auth/login',
tokens: [{"old":"/api/v1/auth/login","type":0,"val":"api","end":""},{"old":"/api/v1/auth/login","type":0,"val":"v1","end":""},{"old":"/api/v1/auth/login","type":0,"val":"auth","end":""},{"old":"/api/v1/auth/login","type":0,"val":"login","end":""}],
types: placeholder as Registry['auth.access_tokens.store']['types'],
},
'profile.profile.show': {
methods: ["GET","HEAD"],
pattern: '/api/v1/account/profile',
tokens: [{"old":"/api/v1/account/profile","type":0,"val":"api","end":""},{"old":"/api/v1/account/profile","type":0,"val":"v1","end":""},{"old":"/api/v1/account/profile","type":0,"val":"account","end":""},{"old":"/api/v1/account/profile","type":0,"val":"profile","end":""}],
types: placeholder as Registry['profile.profile.show']['types'],
},
'profile.access_tokens.destroy': {
methods: ["POST"],
pattern: '/api/v1/account/logout',
tokens: [{"old":"/api/v1/account/logout","type":0,"val":"api","end":""},{"old":"/api/v1/account/logout","type":0,"val":"v1","end":""},{"old":"/api/v1/account/logout","type":0,"val":"account","end":""},{"old":"/api/v1/account/logout","type":0,"val":"logout","end":""}],
types: placeholder as Registry['profile.access_tokens.destroy']['types'],
},
} as const satisfies Record<string, AdonisEndpoint>
export { routes }
export const registry = {
routes,
$tree: {} as ApiDefinition,
}
declare module '@tuyau/core/types' {
export interface UserRegistry {
routes: typeof routes
$tree: ApiDefinition
}
}

58
.adonisjs/client/registry/schema.d.ts vendored Normal file
View File

@ -0,0 +1,58 @@
/* eslint-disable prettier/prettier */
/// <reference path="../manifest.d.ts" />
import type { ExtractBody, ExtractErrorResponse, ExtractQuery, ExtractQueryForGet, ExtractResponse } from '@tuyau/core/types'
import type { InferInput, SimpleError } from '@vinejs/vine/types'
export type ParamValue = string | number | bigint | boolean
export interface Registry {
'auth.new_account.store': {
methods: ["POST"]
pattern: '/api/v1/auth/signup'
types: {
body: ExtractBody<InferInput<(typeof import('#validators/user').signupValidator)>>
paramsTuple: []
params: {}
query: ExtractQuery<InferInput<(typeof import('#validators/user').signupValidator)>>
response: ExtractResponse<Awaited<ReturnType<import('#controllers/new_account_controller').default['store']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/new_account_controller').default['store']>>> | { status: 422; response: { errors: SimpleError[] } }
}
}
'auth.access_tokens.store': {
methods: ["POST"]
pattern: '/api/v1/auth/login'
types: {
body: ExtractBody<InferInput<(typeof import('#validators/user').loginValidator)>>
paramsTuple: []
params: {}
query: ExtractQuery<InferInput<(typeof import('#validators/user').loginValidator)>>
response: ExtractResponse<Awaited<ReturnType<import('#controllers/access_tokens_controller').default['store']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/access_tokens_controller').default['store']>>> | { status: 422; response: { errors: SimpleError[] } }
}
}
'profile.profile.show': {
methods: ["GET","HEAD"]
pattern: '/api/v1/account/profile'
types: {
body: {}
paramsTuple: []
params: {}
query: {}
response: ExtractResponse<Awaited<ReturnType<import('#controllers/profile_controller').default['show']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/profile_controller').default['show']>>>
}
}
'profile.access_tokens.destroy': {
methods: ["POST"]
pattern: '/api/v1/account/logout'
types: {
body: {}
paramsTuple: []
params: {}
query: {}
response: ExtractResponse<Awaited<ReturnType<import('#controllers/access_tokens_controller').default['destroy']>>>
errorResponse: ExtractErrorResponse<Awaited<ReturnType<import('#controllers/access_tokens_controller').default['destroy']>>>
}
}
}

21
.adonisjs/client/registry/tree.d.ts vendored Normal file
View File

@ -0,0 +1,21 @@
/* eslint-disable prettier/prettier */
import type { routes } from './index.ts'
export interface ApiDefinition {
auth: {
newAccount: {
store: typeof routes['auth.new_account.store']
}
accessTokens: {
store: typeof routes['auth.access_tokens.store']
}
}
profile: {
profile: {
show: typeof routes['profile.profile.show']
}
accessTokens: {
destroy: typeof routes['profile.access_tokens.destroy']
}
}
}

View File

@ -0,0 +1,10 @@
/**
* This file is automatically generated.
* DO NOT EDIT manually
*/
export const controllers = {
AccessTokens: () => import('#controllers/access_tokens_controller'),
NewAccount: () => import('#controllers/new_account_controller'),
Profile: () => import('#controllers/profile_controller'),
}

View File

@ -0,0 +1,6 @@
/**
* This file is automatically generated.
* DO NOT EDIT manually
*/
export const events = {}

View File

@ -0,0 +1,6 @@
/**
* This file is automatically generated.
* DO NOT EDIT manually
*/
export const listeners = {}

26
.adonisjs/server/routes.d.ts vendored Normal file
View File

@ -0,0 +1,26 @@
import '@adonisjs/core/types/http'
type ParamValue = string | number | bigint | boolean
export type ScannedRoutes = {
ALL: {
'auth.new_account.store': { paramsTuple?: []; params?: {} }
'auth.access_tokens.store': { paramsTuple?: []; params?: {} }
'profile.profile.show': { paramsTuple?: []; params?: {} }
'profile.access_tokens.destroy': { paramsTuple?: []; params?: {} }
}
GET: {
'profile.profile.show': { paramsTuple?: []; params?: {} }
}
HEAD: {
'profile.profile.show': { paramsTuple?: []; params?: {} }
}
POST: {
'auth.new_account.store': { paramsTuple?: []; params?: {} }
'auth.access_tokens.store': { paramsTuple?: []; params?: {} }
'profile.access_tokens.destroy': { paramsTuple?: []; params?: {} }
}
}
declare module '@adonisjs/core/types/http' {
export interface RoutesList extends ScannedRoutes {}
}

22
.editorconfig Normal file
View File

@ -0,0 +1,22 @@
# http://editorconfig.org
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.json]
insert_final_newline = unset
[**.min.js]
indent_style = unset
insert_final_newline = unset
[MakeFile]
indent_style = space
[*.md]
trim_trailing_whitespace = false

18
.env.example Normal file
View File

@ -0,0 +1,18 @@
# Node
TZ=UTC
PORT=3333
HOST=localhost
NODE_ENV=development
# App
LOG_LEVEL=info
APP_KEY=
APP_URL=http://${HOST}:${PORT}
# Session
SESSION_DRIVER=cookie
#--------------------------------------------------------------------
# CORS (configure allowed origins for API access)
#--------------------------------------------------------------------
# CORS_ORIGIN=http://localhost:5173,http://localhost:3000

1
.env.test Normal file
View File

@ -0,0 +1 @@
SESSION_DRIVER=memory

127
.gitignore vendored
View File

@ -1,113 +1,26 @@
# Logs # Dependencies and AdonisJS build
logs node_modules
*.log build
npm-debug.log* tmp/*
yarn-debug.log* !tmp/.gitkeep
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html) # Secrets
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-temporary-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# TypeScript v1 declaration files
typings/
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variables file
.env .env
.env.test .env.local
.env.production.local
.env.development.local
# parcel-bundler cache (https://parceljs.org/) # Frontend assets compiled code
.cache public/assets
# Next.js build output # Build tools specific
.next npm-debug.log
yarn-error.log
# Nuxt.js build output # Editors specific
.nuxt .fleet
.idea
.vscode
# Gatsby files # Platform specific
.cache/ .DS_Store
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# IDE files
.idea/
.vscode/
*.swp
database.sqlite
*.par2
*.nzb
files
*.bin

3
.prettierignore Normal file
View File

@ -0,0 +1,3 @@
.adonisjs
node_modules
build

27
ace.js Normal file
View File

@ -0,0 +1,27 @@
/*
|--------------------------------------------------------------------------
| JavaScript entrypoint for running ace commands
|--------------------------------------------------------------------------
|
| DO NOT MODIFY THIS FILE AS IT WILL BE OVERRIDDEN DURING THE BUILD
| PROCESS.
|
| See docs.adonisjs.com/guides/typescript-build-process#creating-production-build
|
| Since, we cannot run TypeScript source code using "node" binary, we need
| a JavaScript entrypoint to run ace commands.
|
| This file registers the "ts-node/esm" hook with the Node.js module system
| and then imports the "bin/console.ts" file.
|
*/
/**
* Register hook to process TypeScript files using @poppinss/ts-exec
*/
import '@poppinss/ts-exec'
/**
* Import ace console entrypoint
*/
await import('./bin/console.js')

116
adonisrc.ts Normal file
View File

@ -0,0 +1,116 @@
import { indexEntities } from '@adonisjs/core'
import { defineConfig } from '@adonisjs/core/app'
import { generateRegistry } from '@tuyau/core/hooks'
export default defineConfig({
/*
|--------------------------------------------------------------------------
| Experimental flags
|--------------------------------------------------------------------------
|
| The following features will be enabled by default in the next major release
| of AdonisJS. You can opt into them today to avoid any breaking changes
| during upgrade.
|
*/
experimental: {},
/*
|--------------------------------------------------------------------------
| Commands
|--------------------------------------------------------------------------
|
| List of ace commands to register from packages. The application commands
| will be scanned automatically from the "./commands" directory.
|
*/
commands: [
() => import('@adonisjs/core/commands'),
() => import('@adonisjs/lucid/commands'),
() => import('@adonisjs/session/commands'),
],
/*
|--------------------------------------------------------------------------
| Service providers
|--------------------------------------------------------------------------
|
| List of service providers to import and register when booting the
| application
|
*/
providers: [
() => import('@adonisjs/core/providers/app_provider'),
() => import('@adonisjs/core/providers/hash_provider'),
{
file: () => import('@adonisjs/core/providers/repl_provider'),
environment: ['repl', 'test'],
},
() => import('@adonisjs/core/providers/vinejs_provider'),
() => import('@adonisjs/session/session_provider'),
() => import('@adonisjs/shield/shield_provider'),
() => import('@adonisjs/lucid/database_provider'),
() => import('@adonisjs/cors/cors_provider'),
() => import('@adonisjs/auth/auth_provider'),
() => import('#providers/api_provider'),
],
/*
|--------------------------------------------------------------------------
| Preloads
|--------------------------------------------------------------------------
|
| List of modules to import before starting the application.
|
*/
preloads: [
() => import('#start/routes'),
() => import('#start/kernel'),
() => import('#start/validator'),
],
/*
|--------------------------------------------------------------------------
| Tests
|--------------------------------------------------------------------------
|
| List of test suites to organize tests by their type. Feel free to remove
| and add additional suites.
|
*/
tests: {
suites: [
{
files: ['tests/unit/**/*.spec.{ts,js}'],
name: 'unit',
timeout: 2000,
},
{
files: ['tests/functional/**/*.spec.{ts,js}'],
name: 'functional',
timeout: 30000,
},
],
forceExit: false,
},
/*
|--------------------------------------------------------------------------
| Metafiles
|--------------------------------------------------------------------------
|
| A collection of files you want to copy to the build folder when creating
| the production build.
|
*/
metaFiles: [],
hooks: {
init: [
indexEntities({
transformers: { enabled: true },
}),
generateRegistry(),
],
},
})

View File

@ -0,0 +1,29 @@
import User from '#models/user'
import { loginValidator } from '#validators/user'
import type { HttpContext } from '@adonisjs/core/http'
import UserTransformer from '#transformers/user_transformer'
export default class AccessTokensController {
async store({ request, serialize }: HttpContext) {
const { email, password } = await request.validateUsing(loginValidator)
const user = await User.verifyCredentials(email, password)
const token = await User.accessTokens.create(user)
return serialize({
user: UserTransformer.transform(user),
token: token.value!.release(),
})
}
async destroy({ auth }: HttpContext) {
const user = auth.getUserOrFail()
if (user.currentAccessToken) {
await User.accessTokens.delete(user, user.currentAccessToken.identifier)
}
return {
message: 'Logged out successfully',
}
}
}

View File

@ -0,0 +1,18 @@
import User from '#models/user'
import { signupValidator } from '#validators/user'
import type { HttpContext } from '@adonisjs/core/http'
import UserTransformer from '#transformers/user_transformer'
export default class NewAccountController {
async store({ request, serialize }: HttpContext) {
const { fullName, email, password } = await request.validateUsing(signupValidator)
const user = await User.create({ fullName, email, password })
const token = await User.accessTokens.create(user)
return serialize({
user: UserTransformer.transform(user),
token: token.value!.release(),
})
}
}

View File

@ -0,0 +1,8 @@
import UserTransformer from '#transformers/user_transformer'
import type { HttpContext } from '@adonisjs/core/http'
export default class ProfileController {
async show({ auth, serialize }: HttpContext) {
return serialize(UserTransformer.transform(auth.getUserOrFail()))
}
}

28
app/exceptions/handler.ts Normal file
View File

@ -0,0 +1,28 @@
import app from '@adonisjs/core/services/app'
import { type HttpContext, ExceptionHandler } from '@adonisjs/core/http'
export default class HttpExceptionHandler extends ExceptionHandler {
/**
* In debug mode, the exception handler will display verbose errors
* with pretty printed stack traces.
*/
protected debug = !app.inProduction
/**
* The method is used for handling errors and returning
* response to the client
*/
async handle(error: unknown, ctx: HttpContext) {
return super.handle(error, ctx)
}
/**
* The method is used to report error to the logging service or
* the a third party error monitoring service.
*
* @note You should not attempt to send a response from this method.
*/
async report(error: unknown, ctx: HttpContext) {
return super.report(error, ctx)
}
}

View File

@ -0,0 +1,20 @@
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
import type { Authenticators } from '@adonisjs/auth/types'
/**
* Auth middleware is used authenticate HTTP requests and deny
* access to unauthenticated users.
*/
export default class AuthMiddleware {
async handle(
ctx: HttpContext,
next: NextFn,
options: {
guards?: (keyof Authenticators)[]
} = {}
) {
await ctx.auth.authenticateUsing(options.guards)
return next()
}
}

View File

@ -0,0 +1,19 @@
import { Logger } from '@adonisjs/core/logger'
import { HttpContext } from '@adonisjs/core/http'
import { type NextFn } from '@adonisjs/core/types/http'
/**
* The container bindings middleware binds classes to their request
* specific value using the container resolver.
*
* - We bind "HttpContext" class to the "ctx" object
* - And bind "Logger" class to the "ctx.logger" object
*/
export default class ContainerBindingsMiddleware {
handle(ctx: HttpContext, next: NextFn) {
ctx.containerResolver.bindValue(HttpContext, ctx)
ctx.containerResolver.bindValue(Logger, ctx.logger)
return next()
}
}

View File

@ -0,0 +1,9 @@
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
export default class ForceJsonResponseMiddleware {
handle(ctx: HttpContext, next: NextFn) {
ctx.request.request.headers.accept = 'application/json'
return next()
}
}

View File

@ -0,0 +1,16 @@
import type { HttpContext } from '@adonisjs/core/http'
import type { NextFn } from '@adonisjs/core/types/http'
/**
* Silent auth middleware can be used as a global middleware to silent check
* if the user is logged-in or not.
*
* The request continues as usual, even when the user is not logged-in.
*/
export default class SilentAuthMiddleware {
async handle(ctx: HttpContext, next: NextFn) {
await ctx.auth.check()
return next()
}
}

4
app/models/base_model.ts Normal file
View File

@ -0,0 +1,4 @@
import { BaseModel as AdonisBaseModel } from '@adonisjs/lucid/orm'
export default class BaseModel extends AdonisBaseModel {
}

32
app/models/file.ts Normal file
View File

@ -0,0 +1,32 @@
import { DateTime } from 'luxon'
import { column } from '@adonisjs/lucid/orm'
import BaseModel from '#models/base_model'
export default class File extends BaseModel {
@column({ isPrimary: true })
declare id: number
@column()
declare filename: string
@column()
declare poster: string
@column()
declare date: number
@column()
declare parts: number
@column({ columnName: 'message_ids' })
declare messageIds: Record<string, any>
@column()
declare groups: string[]
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime
}

23
app/models/group.ts Normal file
View File

@ -0,0 +1,23 @@
import { DateTime } from 'luxon'
import { column } from '@adonisjs/lucid/orm'
import BaseModel from '#models/base_model'
export default class Group extends BaseModel {
@column({ isPrimary: true })
declare id: number
@column()
declare name: string
@column()
declare active: boolean
@column({ columnName: 'last_indexed_id' })
declare lastIndexedId: bigint
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime
}

18
app/models/user.ts Normal file
View File

@ -0,0 +1,18 @@
import { UserSchema } from '#database/schema'
import hash from '@adonisjs/core/services/hash'
import { compose } from '@adonisjs/core/helpers'
import { withAuthFinder } from '@adonisjs/auth/mixins/lucid'
import { type AccessToken, DbAccessTokensProvider } from '@adonisjs/auth/access_tokens'
export default class User extends compose(UserSchema, withAuthFinder(hash)) {
static accessTokens = DbAccessTokensProvider.forModel(User)
declare currentAccessToken?: AccessToken
get initials() {
const [first, last] = this.fullName ? this.fullName.split(' ') : this.email.split('@')
if (first && last) {
return `${first.charAt(0)}${last.charAt(0)}`.toUpperCase()
}
return `${first.slice(0, 2)}`.toUpperCase()
}
}

View File

@ -0,0 +1,72 @@
import nntpConfig from '#config/nntp'
import { NNTP } from "nntp-js";
class NntpService {
private readonly poolSize: number;
private allConnections: Set<any>;
private idleConnections: any[];
private waiters: ((conn: any) => void)[];
private createdCount: number;
constructor(poolSize = 10) {
this.poolSize = poolSize;
this.allConnections = new Set();
this.idleConnections = [];
this.waiters = [];
this.createdCount = 0;
console.log(`NNTP Pool initialized with size ${this.poolSize}`)
}
private async _createConnection() {
// This connection logic is based on the older, working pool implementation.
const conn = new NNTP(nntpConfig.host, nntpConfig.port);
await conn.connect();
if (nntpConfig.user) {
await conn.login(nntpConfig.user, nntpConfig.password?.release());
}
this.allConnections.add(conn);
return conn;
}
public async acquire() {
if (this.idleConnections.length > 0) {
console.log('Reusing existing connection from pool.');
return this.idleConnections.pop();
}
if (this.createdCount < this.poolSize) {
this.createdCount++;
console.log(`Creating new connection (${this.createdCount}/${this.poolSize}).`);
return this._createConnection();
}
console.log(`Pool maxed out at ${this.poolSize}. Waiting for a connection to become available.`);
return new Promise(resolve => this.waiters.push(resolve));
}
public release(conn: any) {
if (this.waiters.length > 0) {
console.log('Releasing connection directly to a waiting task.');
const resolve = this.waiters.shift();
if(resolve) resolve(conn);
} else {
console.log('Returning connection to the idle pool.');
this.idleConnections.push(conn);
}
}
public async shutdown() {
console.log('Shutting down all connections in the pool.');
const shutdownPromises: Promise<any>[] = [];
for (const conn of this.allConnections) {
shutdownPromises.push(conn.quit());
}
await Promise.all(shutdownPromises);
this.allConnections.clear();
this.idleConnections.length = 0;
this.waiters.length = 0;
this.createdCount = 0;
}
}
export default new NntpService();

View File

@ -0,0 +1,30 @@
import { Queue } from 'bullmq'
import queueConfig from '#config/queue'
class QueueService {
public readonly nntpFetchQueue: Queue
public readonly headerQueue: Queue
public readonly fileQueue: Queue
public readonly bodyQueue: Queue
public readonly collectionQueue: Queue
constructor() {
this.nntpFetchQueue = new Queue('nntp-fetch-queue', { connection: queueConfig.connection })
this.headerQueue = new Queue('header-queue', { connection: queueConfig.connection })
this.fileQueue = new Queue('file-queue', { connection: queueConfig.connection })
this.bodyQueue = new Queue('body-queue', { connection: queueConfig.connection })
this.collectionQueue = new Queue('collection-queue', { connection: queueConfig.connection })
}
async closeAll() {
await Promise.all([
this.nntpFetchQueue.close(),
this.headerQueue.close(),
this.fileQueue.close(),
this.bodyQueue.close(),
this.collectionQueue.close(),
])
}
}
export default new QueueService()

View File

@ -0,0 +1,15 @@
import { createRequire } from 'module'
import redisConfig from '#config/redis'
const require = createRequire(import.meta.url)
const IORedis = require('ioredis')
class RedisService {
public readonly client: any
constructor() {
this.client = new IORedis(redisConfig.connections.main)
}
}
export default new RedisService()

19
app/services/YencFile.ts Normal file
View File

@ -0,0 +1,19 @@
import { decode } from 'simple-yenc'
export class YencFile {
private buffer: Buffer | null = null
public processPart(partBuffer: Buffer) {
// This is a simplified implementation.
// simple-yenc's decode function is synchronous and works on a full buffer.
// A more complex implementation would handle multi-part decoding.
this.buffer = decode(partBuffer)
}
public getBuffer(): Buffer {
if (!this.buffer) {
throw new Error('No data has been processed yet.')
}
return this.buffer
}
}

View File

@ -0,0 +1,36 @@
export function parseYencMeta(buffer: Buffer): { header: Record<string, any>; crc32?: string } {
const text = buffer.toString('latin1')
const lines = text.split(/\\r?\\n/)
const header: Record<string, any> = {}
let crc32: string | undefined
for (const line of lines) {
if (line.startsWith('=ybegin')) {
const parts = line.split(' ')
parts.forEach((part) => {
if (part.includes('=')) {
const [key, value] = part.split('=')
if (key === 'name') {
header[key] = value.trim()
} else {
header[key] = parseInt(value, 10)
}
}
})
} else if (line.startsWith('=ypart')) {
const match = /begin=(\d+)/.exec(line)
if (match) {
header.partBegin = parseInt(match[1], 10)
}
} else if (line.startsWith('=yend')) {
const match = /crc32=([a-fA-F0-9]+)/.exec(line)
if (match) {
crc32 = match[1]
}
break // End of yenc data
}
}
return { header, crc32 }
}

View File

@ -0,0 +1,15 @@
import type User from '#models/user'
import { BaseTransformer } from '@adonisjs/core/transformers'
export default class UserTransformer extends BaseTransformer<User> {
toObject() {
return this.pick(this.resource, [
'id',
'fullName',
'email',
'createdAt',
'updatedAt',
'initials',
])
}
}

26
app/validators/user.ts Normal file
View File

@ -0,0 +1,26 @@
import vine from '@vinejs/vine'
/**
* Shared rules for email and password.
*/
const email = () => vine.string().email().maxLength(254)
const password = () => vine.string().minLength(8).maxLength(32)
/**
* Validator to use when performing self-signup
*/
export const signupValidator = vine.create({
fullName: vine.string().nullable(),
email: email().unique({ table: 'users', column: 'email' }),
password: password(),
passwordConfirmation: password().sameAs('password'),
})
/**
* Validator to use before validating user credentials
* during login
*/
export const loginValidator = vine.create({
email: email(),
password: vine.string(),
})

47
bin/console.ts Normal file
View File

@ -0,0 +1,47 @@
/*
|--------------------------------------------------------------------------
| Ace entry point
|--------------------------------------------------------------------------
|
| The "console.ts" file is the entrypoint for booting the AdonisJS
| command-line framework and executing commands.
|
| Commands do not boot the application, unless the currently running command
| has "options.startApp" flag set to true.
|
*/
await import('reflect-metadata')
const { Ignitor, prettyPrintError } = await import('@adonisjs/core')
/**
* URL to the application root. AdonisJS need it to resolve
* paths to file and directories for scaffolding commands
*/
const APP_ROOT = new URL('../', import.meta.url)
/**
* The importer is used to import files in context of the
* application.
*/
const IMPORTER = (filePath: string) => {
if (filePath.startsWith('./') || filePath.startsWith('../')) {
return import(new URL(filePath, APP_ROOT).href)
}
return import(filePath)
}
new Ignitor(APP_ROOT, { importer: IMPORTER })
.tap((app) => {
app.booting(async () => {
await import('#start/env')
})
app.listen('SIGTERM', () => app.terminate())
app.listenIf(app.managedByPm2, 'SIGINT', () => app.terminate())
})
.ace()
.handle(process.argv.splice(2))
.catch((error) => {
process.exitCode = 1
prettyPrintError(error)
})

45
bin/server.ts Normal file
View File

@ -0,0 +1,45 @@
/*
|--------------------------------------------------------------------------
| HTTP server entrypoint
|--------------------------------------------------------------------------
|
| The "server.ts" file is the entrypoint for starting the AdonisJS HTTP
| server. Either you can run this file directly or use the "serve"
| command to run this file and monitor file changes
|
*/
await import('reflect-metadata')
const { Ignitor, prettyPrintError } = await import('@adonisjs/core')
/**
* URL to the application root. AdonisJS need it to resolve
* paths to file and directories for scaffolding commands
*/
const APP_ROOT = new URL('../', import.meta.url)
/**
* The importer is used to import files in context of the
* application.
*/
const IMPORTER = (filePath: string) => {
if (filePath.startsWith('./') || filePath.startsWith('../')) {
return import(new URL(filePath, APP_ROOT).href)
}
return import(filePath)
}
new Ignitor(APP_ROOT, { importer: IMPORTER })
.tap((app) => {
app.booting(async () => {
await import('#start/env')
})
app.listen('SIGTERM', () => app.terminate())
app.listenIf(app.managedByPm2, 'SIGINT', () => app.terminate())
})
.httpServer()
.start()
.catch((error) => {
process.exitCode = 1
prettyPrintError(error)
})

62
bin/test.ts Normal file
View File

@ -0,0 +1,62 @@
/*
|--------------------------------------------------------------------------
| Test runner entrypoint
|--------------------------------------------------------------------------
|
| The "test.ts" file is the entrypoint for running tests using Japa.
|
| Either you can run this file directly or use the "test"
| command to run this file and monitor file changes.
|
*/
process.env.NODE_ENV = 'test'
import 'reflect-metadata'
import { Ignitor, prettyPrintError } from '@adonisjs/core'
import { configure, processCLIArgs, run } from '@japa/runner'
/**
* URL to the application root. AdonisJS need it to resolve
* paths to file and directories for scaffolding commands
*/
const APP_ROOT = new URL('../', import.meta.url)
/**
* The importer is used to import files in context of the
* application.
*/
const IMPORTER = (filePath: string) => {
if (filePath.startsWith('./') || filePath.startsWith('../')) {
return import(new URL(filePath, APP_ROOT).href)
}
return import(filePath)
}
new Ignitor(APP_ROOT, { importer: IMPORTER })
.tap((app) => {
app.booting(async () => {
await import('#start/env')
})
app.listen('SIGTERM', () => app.terminate())
app.listenIf(app.managedByPm2, 'SIGINT', () => app.terminate())
})
.testRunner()
.configure(async (app) => {
const { runnerHooks, ...config } = await import('../tests/bootstrap.js')
processCLIArgs(process.argv.splice(2))
configure({
...app.rcFile.tests,
...config,
...{
setup: runnerHooks.setup,
teardown: runnerHooks.teardown.concat([() => app.terminate()]),
},
})
})
.run(() => run())
.catch((error) => {
process.exitCode = 1
prettyPrintError(error)
})

View File

@ -0,0 +1,85 @@
import { BaseCommand } from '@adonisjs/core/ace'
import { CommandOptions } from '@adonisjs/core/types/ace'
import Group from '#models/group'
import NntpService from '#services/NntpService'
import QueueService from '#services/QueueService'
export default class IndexScheduler extends BaseCommand {
public static commandName = 'index:scheduler'
public static description = 'Periodically checks for new articles and schedules them for fetching.'
public static options: CommandOptions = {
startApp: true,
}
private pool = NntpService
private fetchQueue = QueueService.nntpFetchQueue
public async run() {
this.logger.info('Scheduler started. Awaiting tasks...')
const schedule = async () => {
this.logger.info('Checking for new headers...')
const groups = await Group.query().where('active', true)
if (groups.length === 0) {
this.logger.info('No active groups to index. Add some via `node ace db:seed` or manually.')
return
}
let conn
try {
conn = await this.pool.acquire()
for (const group of groups) {
try {
const groupInfo: any = await conn.group(group.name)
// nntp-js returns article numbers as strings. We must parse them to BigInts.
const firstArticle = BigInt(groupInfo.first)
const lastArticle = BigInt(groupInfo.last)
// lastIndexedId from the database should also be treated as a BigInt.
const lastIndexed = group.lastIndexedId ? BigInt(group.lastIndexedId) : null
const startId = lastIndexed ? lastIndexed + 1n : firstArticle
if (startId > lastArticle) {
this.logger.info(`No new headers for group ${group.name}.`)
continue
}
const BATCH_SIZE = 100000n
const proposedEndId = startId + BATCH_SIZE - 1n
const endId = proposedEndId < lastArticle ? proposedEndId : lastArticle
this.logger.info(`Queueing fetch job for ${group.name}: articles ${startId} to ${endId}`)
await this.fetchQueue.add('fetch-headers', {
groupName: group.name,
startId: startId.toString(),
endId: endId.toString(),
})
group.lastIndexedId = endId
await group.save()
} catch (err: any) {
this.logger.error(`Error processing group ${group.name}: ${err.message}`)
}
}
} catch (err: any) {
this.logger.error(`Error in scheduler main loop: ${err.message}`)
} finally {
if (conn) {
this.pool.release(conn)
}
}
}
// Run once immediately and then on an interval.
schedule()
setInterval(schedule, 60000) // 1 minute
// Keep the command running
await new Promise(() => {})
}
}

74
commands/workers/Body.ts Normal file
View File

@ -0,0 +1,74 @@
import { BaseCommand } from '@adonisjs/core/ace'
import { CommandOptions } from '@adonisjs/core/types/ace'
import { Worker } from 'bullmq'
import fs from 'node:fs/promises'
import path from 'node:path'
import queueConfig from '#config/queue'
import QueueService from '#services/QueueService'
import NntpService from '#services/NntpService'
import { parseYencMeta } from '#services/YencService'
export default class BodyWorker extends BaseCommand {
public static commandName = 'worker:body'
public static description = 'Starts a worker to process article bodies for yEnc metadata.'
public static options: CommandOptions = {
startApp: true,
}
public async run() {
this.logger.info('Starting body worker...')
const pool = NntpService
const headerQueue = QueueService.headerQueue
const worker = new Worker('body-queue', async (job) => {
const { header, group } = job.data
const messageId = header['message-id']
this.logger.debug(`Processing header with unparsable subject: ${header.subject}`)
let conn
try {
conn = await pool.acquire()
const bodyBuffer: Buffer = (await conn.body(messageId)).data
try {
const meta = parseYencMeta(bodyBuffer)
if (meta.header.name) {
const { name, part, total } = meta.header
const newSubject = `"${name}" yEnc (${part}/${total})`
header.subject = newSubject
this.logger.info(`Found yEnc metadata in body. New subject: ${newSubject}`)
await headerQueue.add('process-header', { header, group })
} else {
this.logger.warning(`Could not find yEnc metadata in body for header: ${header.subject}`)
}
} catch (parseError: any) {
this.logger.error(`Failed to parse yEnc data for message ID ${messageId}. Dumping buffer.`)
const debugDir = path.join(this.app.appRoot.pathname, 'debug')
await fs.mkdir(debugDir, { recursive: true })
const timestamp = new Date().toISOString().replace(/:/g, '-')
const dumpFile = path.join(debugDir, `body-error-${timestamp}-${messageId.replace(/[<>]/g, '')}.bin`)
await fs.writeFile(dumpFile, bodyBuffer)
this.logger.error(`Problematic body buffer saved to: ${dumpFile}`)
throw parseError
}
} catch (error: any) {
this.logger.error(`Error in body worker for message ID ${messageId}: ${error.message}`)
throw error
} finally {
if (conn) {
pool.release(conn)
}
}
}, { connection: queueConfig.connection })
worker.on('failed', (job, err) => {
this.logger.error(`Body job ${job?.id} failed: ${err.message}`)
})
this.logger.info('Body worker started and listening for jobs.')
await new Promise(() => {})
}
}

View File

@ -0,0 +1,80 @@
import { BaseCommand } from '@adonisjs/core/ace'
import { CommandOptions } from '@adonisjs/core/types/ace'
import { Worker } from 'bullmq'
import queueConfig from '#config/queue'
import NntpService from '#services/NntpService'
import { YencFile } from '#services/YencFile'
import File from '#models/file'
import { createExtractorFromData } from 'node-unrar-js'
export default class CollectionWorker extends BaseCommand {
public static commandName = 'worker:collection'
public static description = 'Starts a worker to process file collections (e.g., RAR archives).'
public static options: CommandOptions = {
startApp: true,
}
public async run() {
this.logger.info('Starting collection worker...')
const pool = NntpService
const worker = new Worker('collection-queue', async (job) => {
const { fileId } = job.data
this.logger.debug(`Processing file ID ${fileId} for collection.`)
const file = await File.find(fileId)
if (!file) {
this.logger.error(`File with ID ${fileId} not found in the database.`)
return
}
const RAR_REGEX = /\.part0*1\.rar$/
if (RAR_REGEX.test(file.filename)) {
this.logger.info(`File "${file.filename}" is the first part of a RAR set.`)
const firstPart = file.messageIds['1']
if (!firstPart || !firstPart.id) {
this.logger.error(`Could not find message ID for the first part of file "${file.filename}".`)
return
}
let conn
try {
conn = await pool.acquire()
const bodyBuffer = (await conn.body(`<${firstPart.id}>`)).data
const yencFile = new YencFile()
yencFile.processPart(bodyBuffer)
const decodedBuffer = yencFile.getBuffer()
const extractor = await createExtractorFromData({ data: new Uint8Array(decodedBuffer).buffer })
const fileList = extractor.getFileList()
// In a real implementation, we would save this file list.
this.logger.info(`Files in "${file.filename}": ${JSON.stringify(fileList)}`)
} catch (error: any) {
if (error.code === 430) {
this.logger.error(`Article not found for first part of RAR set (Message ID: ${firstPart.id})`)
} else {
this.logger.error(`Error processing RAR file: ${error.message}`)
}
} finally {
if (conn) {
pool.release(conn)
}
}
} else {
this.logger.debug(`File "${file.filename}" is not the first part of a RAR set.`)
}
}, { connection: queueConfig.connection })
worker.on('failed', (job, err) => {
this.logger.error(`Collection job ${job?.id} failed: ${err.message}`)
})
this.logger.info('Collection worker started and listening for jobs.')
await new Promise(() => {})
}
}

69
commands/workers/Fetch.ts Normal file
View File

@ -0,0 +1,69 @@
import { BaseCommand } from '@adonisjs/core/ace'
import { CommandOptions } from '@adonisjs/core/types/ace'
import { Worker } from 'bullmq'
import queueConfig from '#config/queue'
import QueueService from '#services/QueueService'
import NntpService from '#services/NntpService'
export default class FetchWorker extends BaseCommand {
public static commandName = 'worker:fetch'
public static description = 'Starts a worker to fetch headers from the NNTP server.'
public static options: CommandOptions = {
startApp: true,
}
public async run() {
this.logger.info('Starting fetch worker...')
const pool = NntpService
const headerQueue = QueueService.headerQueue
const worker = new Worker('nntp-fetch-queue', async (job) => {
const { groupName, startId, endId } = job.data
this.logger.info(`Processing fetch job for ${groupName}, articles ${startId}-${endId}`)
let conn
try {
conn = await pool.acquire()
await conn.group(groupName)
const overview: any = await conn.xover(startId, endId)
this.logger.info(`Fetched ${overview.overviews.length} headers from ${groupName}.`)
if (overview.overviews.length > 0) {
const jobs = overview.overviews.map(([id, header]: [number, any]) => {
if (!header) {
this.logger.warning(`Header is undefined for job ${job.id}`)
return null
}
return {
name: 'process-header',
data: { header, group: groupName },
opts: { jobId: `${groupName}-${id}` },
}
}).filter(Boolean)
await headerQueue.addBulk(jobs)
this.logger.info(`Added ${jobs.length} header jobs to the queue for ${groupName}.`)
}
} catch (error: any) {
this.logger.error(`Error fetching headers for ${groupName}: ${error.message}`)
throw error
} finally {
if (conn) {
pool.release(conn)
}
}
}, {
connection: queueConfig.connection,
concurrency: 5,
})
worker.on('failed', (job, err) => {
this.logger.error(`Fetch job ${job?.id} failed for group ${job?.data.groupName}: ${err.message}`)
})
this.logger.info('Fetch worker started and listening for jobs.')
await new Promise(() => {}) // Keep command running
}
}

70
commands/workers/File.ts Normal file
View File

@ -0,0 +1,70 @@
import { BaseCommand } from '@adonisjs/core/ace'
import { CommandOptions } from '@adonisjs/core/types/ace'
import { Worker } from 'bullmq'
import queueConfig from '#config/queue'
import QueueService from '#services/QueueService'
import File from '#models/file'
export default class FileWorker extends BaseCommand {
public static commandName = 'worker:file'
public static description = 'Starts a worker to process completed files.'
public static options: CommandOptions = {
startApp: true,
}
public async run() {
this.logger.info('Starting file worker...')
const collectionQueue = QueueService.collectionQueue
const worker = new Worker('file-queue', async (job) => {
const { filename, parts, groups } = job.data
const partCount = Object.keys(parts).length
this.logger.debug(`Processing complete file: "${filename}" with ${partCount} parts.`)
const firstPart = JSON.parse(Object.values(parts)[0] as string)
const poster = firstPart.from
const date = new Date(firstPart.date).getTime()
const messageIds = Object.entries(parts).reduce((acc, [partNumber, partData]) => {
const part = JSON.parse(partData as string)
const messageId = part['message-id']
if (messageId) {
acc[partNumber] = {
id: messageId.replace(/[<>]/g, ''),
size: part[':bytes'],
}
} else {
this.logger.warning(`Message ID not found for part ${partNumber} of file "${filename}"`)
}
return acc
}, {} as Record<string, any>)
if (Object.keys(messageIds).length !== partCount) {
throw new Error(`Could not process all parts for file "${filename}" due to missing message IDs.`)
}
const file = await File.create({
filename,
poster,
date,
parts: partCount,
messageIds,
groups,
})
this.logger.debug(`Saved file "${filename}" to database with ID: ${file.id}`)
await collectionQueue.add('process-collection', { fileId: file.id })
this.logger.debug(`Added file ID ${file.id} to collection queue.`)
}, { connection: queueConfig.connection })
worker.on('failed', (job, err) => {
this.logger.error(`File job ${job?.id} failed: ${err.message}`)
})
this.logger.info('File worker started and listening for jobs.')
await new Promise(() => {})
}
}

View File

@ -0,0 +1,65 @@
import { BaseCommand } from '@adonisjs/core/ace'
import { CommandOptions } from '@adonisjs/core/types/ace'
import { Worker } from 'bullmq'
import queueConfig from '#config/queue'
import QueueService from '#services/QueueService'
import RedisService from '#services/RedisService'
export default class HeaderWorker extends BaseCommand {
public static commandName = 'worker:header'
public static description = 'Starts a worker to process headers.'
public static options: CommandOptions = {
startApp: true,
}
public async run() {
this.logger.info('Starting header worker...')
const redis = RedisService.client
const fileQueue = QueueService.fileQueue
const bodyQueue = QueueService.bodyQueue
const worker = new Worker('header-queue', async (job) => {
const { header, group } = job.data
if (!header || !header.subject) {
this.logger.warning(`Received job with invalid header data. JobID: ${job.id}`)
return
}
const subject = header.subject
const SUBJECT_REGEX = /"(.+)"(?: yEnc)? \((\d+)\/(\d+)\)/
const match = subject.match(SUBJECT_REGEX)
if (match) {
const filename = match[1]
const part = parseInt(match[2], 10)
const total = parseInt(match[3], 10)
const fileKey = `file:${filename}`
await redis.hset(fileKey, part, JSON.stringify(header))
const partCount = await redis.hlen(fileKey)
if (partCount === total) {
const fileParts = await redis.hgetall(fileKey)
await fileQueue.add('process-file', { filename, parts: fileParts, groups: [group] })
await redis.del(fileKey)
this.logger.info(`File "${filename}" is complete and moved to file-queue.`)
} else {
this.logger.info(`Stored part ${part}/${total} for file "${filename}"`)
}
} else {
this.logger.warning(`Could not parse subject: "${subject}". Moving to body-queue.`)
await bodyQueue.add('process-body', { header, group })
}
}, { connection: queueConfig.connection })
worker.on('failed', (job, err) => {
this.logger.error(`Header job ${job?.id} failed: ${err.message}`)
})
this.logger.info('Header worker started and listening for jobs.')
await new Promise(() => {})
}
}

84
config/app.ts Normal file
View File

@ -0,0 +1,84 @@
import env from '#start/env'
import app from '@adonisjs/core/services/app'
import { defineConfig } from '@adonisjs/core/http'
/**
* The app URL can be used in various places where you want to create absolute
* URLs to your application. For example, when sending emails, images should
* use absolute URLs.
*/
export const appUrl = env.get('APP_URL')
/**
* The configuration settings used by the HTTP server
*/
export const http = defineConfig({
/**
* Generate a unique request id for each incoming request.
* Useful to correlate logs and debug a request flow.
*/
generateRequestId: true,
/**
* Allow HTTP method spoofing via the "_method" form/query parameter.
* This lets HTML forms target PUT/PATCH/DELETE routes while still
* submitting with POST.
*/
allowMethodSpoofing: false,
/**
* Enabling async local storage will let you access HTTP context
* from anywhere inside your application.
*/
useAsyncLocalStorage: false,
/**
* Redirect configuration controls the behavior of
* response.redirect().back() and query string forwarding.
*/
redirect: {
/**
* When enabled, all redirects automatically carry over the current
* request's query string parameters to the redirect destination.
* Use withQs(false) to opt out for a specific redirect.
*/
forwardQueryString: true,
},
/**
* Manage cookies configuration. The settings for the session id cookie are
* defined inside the "config/session.ts" file.
*/
cookie: {
/**
* Restrict the cookie to a specific domain.
* Keep empty to use the current host.
*/
domain: '',
/**
* Restrict the cookie to a URL path. '/' means all routes.
*/
path: '/',
/**
* Default lifetime for cookies managed by the HTTP layer.
*/
maxAge: '2h',
/**
* Prevent JavaScript access to the cookie in the browser.
*/
httpOnly: true,
/**
* Send cookies only over HTTPS in production.
*/
secure: app.inProduction,
/**
* Cross-site policy for cookie sending.
*/
sameSite: 'lax',
},
})

50
config/auth.ts Normal file
View File

@ -0,0 +1,50 @@
import { defineConfig } from '@adonisjs/auth'
import { sessionGuard, sessionUserProvider } from '@adonisjs/auth/session'
import { tokensGuard, tokensUserProvider } from '@adonisjs/auth/access_tokens'
import type { InferAuthenticators, InferAuthEvents, Authenticators } from '@adonisjs/auth/types'
const authConfig = defineConfig({
/**
* Default guard used when no guard is explicitly specified.
*/
default: 'api',
guards: {
/**
* Token-based guard for stateless API authentication.
*/
api: tokensGuard({
provider: tokensUserProvider({
tokens: 'accessTokens',
model: () => import('#models/user'),
}),
}),
/**
* Session-based guard for browser authentication.
*/
web: sessionGuard({
/**
* Enable persistent login using remember-me tokens.
*/
useRememberMeTokens: false,
provider: sessionUserProvider({
model: () => import('#models/user'),
}),
}),
},
})
export default authConfig
/**
* Inferring types from the configured auth
* guards.
*/
declare module '@adonisjs/auth/types' {
export interface Authenticators extends InferAuthenticators<typeof authConfig> {}
}
declare module '@adonisjs/core/types' {
interface EventsList extends InferAuthEvents<Authenticators> {}
}

78
config/bodyparser.ts Normal file
View File

@ -0,0 +1,78 @@
import { defineConfig } from '@adonisjs/core/bodyparser'
const bodyParserConfig = defineConfig({
/**
* Parse request bodies for these HTTP methods.
* Keep this aligned with methods that receive payloads in your routes.
*/
allowedMethods: ['POST', 'PUT', 'PATCH', 'DELETE'],
/**
* Config for the "application/x-www-form-urlencoded"
* content-type parser.
*/
form: {
/**
* Normalize empty string values to null.
*/
convertEmptyStringsToNull: true,
/**
* Content types handled by the form parser.
*/
types: ['application/x-www-form-urlencoded'],
},
/**
* Config for the JSON parser.
*/
json: {
/**
* Normalize empty string values to null.
*/
convertEmptyStringsToNull: true,
/**
* Content types handled by the JSON parser.
*/
types: [
'application/json',
'application/json-patch+json',
'application/vnd.api+json',
'application/csp-report',
],
},
/**
* Config for the "multipart/form-data" content-type parser.
* File uploads are handled by the multipart parser.
*/
multipart: {
/**
* Automatically process uploaded files into the system tmp directory.
*/
autoProcess: true,
/**
* Normalize empty string values to null.
*/
convertEmptyStringsToNull: true,
/**
* Routes where multipart processing is handled manually.
*/
processManually: [],
/**
* Maximum accepted payload size for multipart requests.
*/
limit: '20mb',
/**
* Content types handled by the multipart parser.
*/
types: ['multipart/form-data'],
},
})
export default bodyParserConfig

50
config/cors.ts Normal file
View File

@ -0,0 +1,50 @@
import app from '@adonisjs/core/services/app'
import { defineConfig } from '@adonisjs/cors'
/**
* Configuration options to tweak the CORS policy. The following
* options are documented on the official documentation website.
*
* https://docs.adonisjs.com/guides/security/cors
*/
const corsConfig = defineConfig({
/**
* Enable or disable CORS handling globally.
*/
enabled: true,
/**
* In development, allow every origin to simplify local front/backend setup.
* In production, keep an explicit allowlist (empty by default, so no
* cross-origin browser access is allowed until configured).
*/
origin: app.inDev ? true : [],
/**
* HTTP methods accepted for cross-origin requests.
*/
methods: ['GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE'],
/**
* Reflect request headers by default. Use a string array to restrict
* allowed headers.
*/
headers: true,
/**
* Response headers exposed to the browser.
*/
exposeHeaders: [],
/**
* Allow cookies/authorization headers on cross-origin requests.
*/
credentials: true,
/**
* Cache CORS preflight response for N seconds.
*/
maxAge: 90,
})
export default corsConfig

132
config/database.ts Normal file
View File

@ -0,0 +1,132 @@
import app from '@adonisjs/core/services/app'
import { defineConfig } from '@adonisjs/lucid'
import env from '#start/env'
const dbConfig = defineConfig({
/**
* Default connection used for all queries.
*/
connection: env.get('DB_CONNECTION', 'sqlite'),
connections: {
/**
* SQLite connection (default).
*/
sqlite: {
client: 'better-sqlite3',
connection: {
filename: app.tmpPath('db.sqlite3'),
},
/**
* Required by Knex for SQLite defaults.
*/
useNullAsDefault: true,
migrations: {
/**
* Sort migration files naturally by filename.
*/
naturalSort: true,
/**
* Paths containing migration files.
*/
paths: ['database/migrations'],
},
schemaGeneration: {
/**
* Enable schema generation from Lucid models.
*/
enabled: true,
/**
* Custom schema rules file paths.
*/
rulesPaths: ['./database/schema_rules.js'],
},
},
/**
* PostgreSQL connection.
* Install package to switch: npm install pg
*/
pg: {
client: 'pg',
connection: {
host: env.get('DB_HOST'),
port: env.get('DB_PORT'),
user: env.get('DB_USER'),
password: env.get('DB_PASSWORD')?.release(),
database: env.get('DB_DATABASE'),
},
migrations: {
naturalSort: true,
paths: ['database/migrations'],
},
debug: app.inDev,
},
/**
* MySQL / MariaDB connection.
* Install package to switch: npm install mysql2
*/
// mysql: {
// client: 'mysql2',
// connection: {
// host: env.get('DB_HOST'),
// port: env.get('DB_PORT'),
// user: env.get('DB_USER'),
// password: env.get('DB_PASSWORD'),
// database: env.get('DB_DATABASE'),
// },
// migrations: {
// naturalSort: true,
// paths: ['database/migrations'],
// },
// debug: app.inDev,
// },
/**
* Microsoft SQL Server connection.
* Install package to switch: npm install tedious
*/
// mssql: {
// client: 'mssql',
// connection: {
// server: env.get('DB_HOST'),
// port: env.get('DB_PORT'),
// user: env.get('DB_USER'),
// password: env.get('DB_PASSWORD'),
// database: env.get('DB_DATABASE'),
// },
// migrations: {
// naturalSort: true,
// paths: ['database/migrations'],
// },
// debug: app.inDev,
// },
/**
* libSQL (Turso) connection.
* Install package to switch: npm install @libsql/client
*/
// libsql: {
// client: 'libsql',
// connection: {
// url: env.get('LIBSQL_URL'),
// authToken: env.get('LIBSQL_AUTH_TOKEN'),
// },
// useNullAsDefault: true,
// migrations: {
// naturalSort: true,
// paths: ['database/migrations'],
// },
// debug: app.inDev,
// },
},
})
export default dbConfig

34
config/encryption.ts Normal file
View File

@ -0,0 +1,34 @@
import env from '#start/env'
import { defineConfig, drivers } from '@adonisjs/core/encryption'
const encryptionConfig = defineConfig({
/**
* Default encryption driver used by the application.
*/
default: 'gcm',
list: {
gcm: drivers.aes256gcm({
/**
* Keys used for encryption/decryption.
* First key encrypts, all keys are tried for decryption.
*/
keys: [env.get('APP_KEY')],
/**
* Stable identifier for this driver.
*/
id: 'gcm',
}),
},
})
export default encryptionConfig
/**
* Inferring types for the list of encryptors you have configured
* in your application.
*/
declare module '@adonisjs/core/types' {
export interface EncryptorsList extends InferEncryptors<typeof encryptionConfig> {}
}

75
config/hash.ts Normal file
View File

@ -0,0 +1,75 @@
import { defineConfig, drivers } from '@adonisjs/core/hash'
/**
* Hashing configuration.
*
* This starter uses Node.js scrypt under the hood.
* Node.js reference: https://nodejs.org/api/crypto.html#cryptoscryptpassword-salt-keylen-options-callback
*/
const hashConfig = defineConfig({
/**
* Default hasher used by the application.
*/
default: 'scrypt',
list: {
/**
* Scrypt is memory-hard, which makes brute-force attacks more expensive.
*/
scrypt: drivers.scrypt({
/**
* Work factor (Node alias: N / cost).
* Higher values increase security and CPU+memory usage.
*
* Tuning guideline:
* - Start with 16384.
* - Increase gradually (for example 32768) and benchmark login/signup latency.
* - Keep values practical for your slowest production machine.
*
* Node constraint: value must be a power of two greater than 1.
*/
cost: 16384,
/**
* Block size (Node alias: r / blockSize).
* Increases memory and CPU linearly.
*
* Tuning guideline:
* - Keep 8 unless you have a measured reason to change it.
* - Raise only with benchmark data, because memory usage grows quickly.
*/
blockSize: 8,
/**
* Parallelization (Node alias: p / parallelization).
* Controls how many independent computations are performed.
*
* Tuning guideline:
* - Keep 1 for most applications.
* - Increase only after load testing if your infrastructure benefits from it.
*/
parallelization: 1,
/**
* Maximum memory limit in bytes (Node alias: maxmem / maxMemory).
* Hashing throws if the estimated memory usage is above this limit.
* Node documents the check as approximately: 128 * N * r > maxmem.
*
* Tuning guideline:
* - Keep this aligned with your cost/blockSize choices.
* - Increase carefully on memory-constrained environments.
*/
maxMemory: 33554432,
}),
},
})
export default hashConfig
/**
* Inferring types for the list of hashers you have configured
* in your application.
*/
declare module '@adonisjs/core/types' {
export interface HashersList extends InferHashers<typeof hashConfig> {}
}

51
config/logger.ts Normal file
View File

@ -0,0 +1,51 @@
import env from '#start/env'
import app from '@adonisjs/core/services/app'
import { defineConfig, syncDestination, targets } from '@adonisjs/core/logger'
const loggerConfig = defineConfig({
/**
* Default logger name used by ctx.logger and app logger calls.
*/
default: 'app',
loggers: {
app: {
/**
* Toggle this logger on/off.
*/
enabled: true,
/**
* Logger name shown in log records.
*/
name: env.get('APP_NAME'),
/**
* Minimum level to output (trace, debug, info, warn, error, fatal).
*/
level: env.get('LOG_LEVEL'),
/**
* Use sync destination in non-production for immediate flush.
*/
destination: !app.inProduction ? await syncDestination() : undefined,
/**
* Configure where logs are written.
*/
transport: {
targets: [targets.file({ destination: 1 })],
},
},
},
})
export default loggerConfig
/**
* Inferring types for the list of loggers you have configured
* in your application.
*/
declare module '@adonisjs/core/types' {
export interface LoggersList extends InferLoggers<typeof loggerConfig> {}
}

9
config/nntp.ts Normal file
View File

@ -0,0 +1,9 @@
import env from '#start/env'
export default {
host: env.get('NNTP_HOST'),
port: env.get('NNTP_PORT', 119),
user: env.get('NNTP_USER'),
password: env.get('NNTP_PASSWORD'),
secure: env.get('NNTP_SECURE', false),
}

8
config/queue.ts Normal file
View File

@ -0,0 +1,8 @@
import env from '#start/env'
export default {
connection: {
host: env.get('REDIS_HOST', '127.0.0.1'),
port: env.get('REDIS_PORT', 6379),
},
}

12
config/redis.ts Normal file
View File

@ -0,0 +1,12 @@
import env from '#start/env'
export default {
connection: 'main',
connections: {
main: {
host: env.get('REDIS_HOST', '127.0.0.1'),
port: env.get('REDIS_PORT'),
password: env.get('REDIS_PASSWORD')?.release(),
},
},
}

78
config/session.ts Normal file
View File

@ -0,0 +1,78 @@
import env from '#start/env'
import app from '@adonisjs/core/services/app'
import { defineConfig, stores } from '@adonisjs/session'
const sessionConfig = defineConfig({
/**
* Enable or disable session support globally.
*/
enabled: true,
/**
* Cookie name storing the session identifier.
*/
cookieName: 'adonis-session',
/**
* When set to true, the session id cookie will be deleted
* once the user closes the browser.
*/
clearWithBrowser: false,
/**
* Define how long to keep the session data alive without
* any activity.
*/
age: '2h',
/**
* Configuration for session cookie and the
* cookie store.
*/
cookie: {
/**
* Restrict the cookie to a URL path. '/' means all routes.
*/
path: '/',
/**
* Prevent JavaScript access to the cookie in the browser.
*/
httpOnly: true,
/**
* Send cookies only over HTTPS in production.
*/
secure: app.inProduction,
/**
* Cross-site policy for cookie sending.
*/
sameSite: 'lax',
},
/**
* The store to use. Make sure to validate the environment
* variable in order to infer the store name without any
* errors.
*/
store: env.get('SESSION_DRIVER'),
/**
* List of configured stores. Refer documentation to see
* list of available stores and their config.
*/
stores: {
/**
* Store session data inside encrypted cookies.
*/
cookie: stores.cookie(),
/**
* Store session data inside the configured database.
*/
database: stores.database(),
},
})
export default sessionConfig

95
config/shield.ts Normal file
View File

@ -0,0 +1,95 @@
import { defineConfig } from '@adonisjs/shield'
const shieldConfig = defineConfig({
/**
* Configure CSP policies for your app. Refer documentation
* to learn more.
*/
csp: {
/**
* Enable the Content-Security-Policy header.
*/
enabled: false,
/**
* Per-resource CSP directives.
*/
directives: {},
/**
* Report violations without blocking resources.
*/
reportOnly: false,
},
/**
* Configure CSRF protection options. Refer documentation
* to learn more.
*/
csrf: {
/**
* Enable CSRF token verification for state-changing requests.
*/
enabled: false,
/**
* Route patterns to exclude from CSRF checks.
* Useful for external webhooks or API endpoints.
*/
exceptRoutes: [],
/**
* Expose an encrypted XSRF-TOKEN cookie for frontend HTTP clients.
*/
enableXsrfCookie: true,
/**
* HTTP methods protected by CSRF validation.
*/
methods: ['POST', 'PUT', 'PATCH', 'DELETE'],
},
/**
* Control how your website should be embedded inside
* iframes.
*/
xFrame: {
/**
* Enable the X-Frame-Options header.
*/
enabled: true,
/**
* Block all framing attempts. Default value is DENY.
*/
action: 'DENY',
},
/**
* Force browser to always use HTTPS.
*/
hsts: {
/**
* Enable the Strict-Transport-Security header.
*/
enabled: true,
/**
* HSTS policy duration remembered by browsers.
*/
maxAge: '180 days',
},
/**
* Disable browsers from sniffing content types and rely only
* on the response content-type header.
*/
contentTypeSniffing: {
/**
* Enable X-Content-Type-Options: nosniff.
*/
enabled: true,
},
})
export default shieldConfig

View File

@ -0,0 +1,21 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'users'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments('id').notNullable()
table.string('full_name').nullable()
table.string('email', 254).notNullable().unique()
table.string('password').notNullable()
table.timestamp('created_at').notNullable()
table.timestamp('updated_at').nullable()
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}

View File

@ -0,0 +1,31 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'auth_access_tokens'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments('id')
table
.integer('tokenable_id')
.notNullable()
.unsigned()
.references('id')
.inTable('users')
.onDelete('CASCADE')
table.string('type').notNullable()
table.string('name').nullable()
table.string('hash').notNullable()
table.text('abilities').notNullable()
table.timestamp('created_at')
table.timestamp('updated_at')
table.timestamp('last_used_at').nullable()
table.timestamp('expires_at').nullable()
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}

View File

@ -0,0 +1,21 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'groups'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments('id')
table.string('name').notNullable().unique()
table.boolean('active').notNullable().defaultTo(true)
table.bigInteger('last_indexed_id').defaultTo(0)
table.timestamp('created_at', { useTz: true })
table.timestamp('updated_at', { useTz: true })
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}

View File

@ -0,0 +1,24 @@
import { BaseSchema } from '@adonisjs/lucid/schema'
export default class extends BaseSchema {
protected tableName = 'files'
async up() {
this.schema.createTable(this.tableName, (table) => {
table.increments('id').primary()
table.string('filename').notNullable()
table.string('poster').notNullable()
table.bigInteger('date').notNullable()
table.integer('parts').notNullable()
table.jsonb('message_ids').notNullable()
table.jsonb('groups')
table.timestamp('created_at', { useTz: true })
table.timestamp('updated_at', { useTz: true })
})
}
async down() {
this.schema.dropTable(this.tableName)
}
}

90
database/schema.ts Normal file
View File

@ -0,0 +1,90 @@
/**
* This file is automatically generated
* DO NOT EDIT manually
* Run "node ace migration:run" command to re-generate this file
*/
import { BaseModel, column } from '@adonisjs/lucid/orm'
import { DateTime } from 'luxon'
export class AuthAccessTokenSchema extends BaseModel {
static $columns = ['abilities', 'createdAt', 'expiresAt', 'hash', 'id', 'lastUsedAt', 'name', 'tokenableId', 'type', 'updatedAt'] as const
$columns = AuthAccessTokenSchema.$columns
@column()
declare abilities: string
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime | null
@column.dateTime()
declare expiresAt: DateTime | null
@column()
declare hash: string
@column({ isPrimary: true })
declare id: number
@column.dateTime()
declare lastUsedAt: DateTime | null
@column()
declare name: string | null
@column()
declare tokenableId: number
@column()
declare type: string
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime | null
}
export class FileSchema extends BaseModel {
static $columns = ['createdAt', 'date', 'filename', 'groups', 'id', 'messageIds', 'parts', 'poster', 'updatedAt'] as const
$columns = FileSchema.$columns
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime | null
@column()
declare date: bigint | number
@column()
declare filename: string
@column()
declare groups: any | null
@column({ isPrimary: true })
declare id: number
@column()
declare messageIds: any
@column()
declare parts: number
@column()
declare poster: string
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime | null
}
export class GroupSchema extends BaseModel {
static $columns = ['active', 'createdAt', 'id', 'lastIndexedId', 'name', 'updatedAt'] as const
$columns = GroupSchema.$columns
@column()
declare active: boolean
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime | null
@column({ isPrimary: true })
declare id: number
@column()
declare lastIndexedId: bigint | number | null
@column()
declare name: string
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime | null
}
export class UserSchema extends BaseModel {
static $columns = ['createdAt', 'email', 'fullName', 'id', 'password', 'updatedAt'] as const
$columns = UserSchema.$columns
@column.dateTime({ autoCreate: true })
declare createdAt: DateTime
@column()
declare email: string
@column()
declare fullName: string | null
@column({ isPrimary: true })
declare id: number
@column({ serializeAs: null })
declare password: string
@column.dateTime({ autoCreate: true, autoUpdate: true })
declare updatedAt: DateTime | null
}

3
database/schema_rules.ts Normal file
View File

@ -0,0 +1,3 @@
import { type SchemaRules } from '@adonisjs/lucid/types/schema_generator'
export default {} satisfies SchemaRules

View File

@ -0,0 +1,11 @@
import { BaseSeeder } from '@adonisjs/lucid/seeders'
import Group from '#models/group'
export default class extends BaseSeeder {
async run() {
await Group.create({
name: 'alt.binaries.test',
active: true,
})
}
}

2
eslint.config.js Normal file
View File

@ -0,0 +1,2 @@
import { configApp } from '@adonisjs/eslint-config'
export default configApp()

7431
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,30 +1,89 @@
{ {
"name": "usenet-indexer", "name": "temp_adonis_project",
"version": "1.0.0", "version": "0.0.0",
"description": "", "private": true,
"main": "src/bin/start.js",
"type": "module", "type": "module",
"scripts": { "license": "MIT",
"test": "echo \"Error: no test specified\" && exit 1", "exports": {
"start": "node src/bin/start.js", "./data": "./.adonisjs/client/data.d.ts",
"download": "node src/bin/download.js", "./registry": "./.adonisjs/client/registry/index.ts"
"nzb": "node src/bin/nzb.js" },
"scripts": {
"start": "node bin/server.js",
"build": "node ace build",
"dev": "node ace serve --hmr",
"test": "node ace test",
"lint": "eslint .",
"format": "prettier --write .",
"typecheck": "tsc --noEmit"
},
"imports": {
"#controllers/*": "./app/controllers/*.js",
"#exceptions/*": "./app/exceptions/*.js",
"#models/*": "./app/models/*.js",
"#mails/*": "./app/mails/*.js",
"#services/*": "./app/services/*.js",
"#listeners/*": "./app/listeners/*.js",
"#events/*": "./app/events/*.js",
"#generated/*": "./.adonisjs/server/*.js",
"#middleware/*": "./app/middleware/*.js",
"#transformers/*": "./app/transformers/*.js",
"#validators/*": "./app/validators/*.js",
"#providers/*": "./providers/*.js",
"#policies/*": "./app/policies/*.js",
"#abilities/*": "./app/abilities/*.js",
"#database/*": "./database/*.js",
"#tests/*": "./tests/*.js",
"#start/*": "./start/*.js",
"#config/*": "./config/*.js"
},
"devDependencies": {
"@adonisjs/assembler": "^8.4.0",
"@adonisjs/eslint-config": "^3.1.0",
"@adonisjs/prettier-config": "^1.5.0",
"@adonisjs/tsconfig": "^2.0.0",
"@japa/assert": "^4.2.0",
"@japa/plugin-adonisjs": "^5.2.0",
"@japa/runner": "^5.3.0",
"@poppinss/ts-exec": "^1.4.4",
"@types/luxon": "^3.7.1",
"@types/node": "~25.9.1",
"eslint": "^10.4.0",
"hot-hook": "^1.0.0",
"pino-pretty": "^13.1.3",
"prettier": "^3.8.3",
"typescript": "~6.0.3",
"youch": "^4.1.1"
}, },
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": { "dependencies": {
"bullmq": "^5.77.3", "@adonisjs/auth": "^10.1.0",
"dotenv": "^16.3.1", "@adonisjs/core": "^7.3.3",
"ioredis": "^5.3.2", "@adonisjs/cors": "^3.0.0",
"@adonisjs/lucid": "^22.4.2",
"@adonisjs/session": "^8.1.0",
"@adonisjs/shield": "^9.0.0",
"@japa/api-client": "^3.2.1",
"@tuyau/core": "^1.2.2",
"@vinejs/vine": "^4.4.0",
"better-sqlite3": "^12.10.0",
"bullmq": "^5.78.0",
"crc": "^4.3.2",
"ioredis": "5.11.0",
"log4js": "^6.9.1", "log4js": "^6.9.1",
"luxon": "^3.7.2",
"nntp-js": "^1.0.4", "nntp-js": "^1.0.4",
"node-unrar-js": "^2.0.0", "node-unrar-js": "^2.0.2",
"pg": "^8.21.0",
"reflect-metadata": "^0.2.2",
"simple-yenc": "^1.0.4", "simple-yenc": "^1.0.4",
"sqlite": "^5.1.1", "xmlbuilder2": "^4.0.3",
"sqlite3": "^6.0.1", "yencode": "^1.2.4"
"xmlbuilder2": "^3.1.1", },
"yencode": "^1.0.1", "hotHook": {
"crc": "^4.3.2" "boundaries": [
} "./app/controllers/**/*.ts",
"./app/middleware/*.ts"
]
},
"prettier": "@adonisjs/prettier-config"
} }

69
providers/api_provider.ts Normal file
View File

@ -0,0 +1,69 @@
import { HttpContext } from '@adonisjs/core/http'
import { BaseSerializer } from '@adonisjs/core/transformers'
import { type SimplePaginatorMetaKeys } from '@adonisjs/lucid/types/querybuilder'
/**
* Custom serializer for API responses that ensures consistent JSON structure
* across all API endpoints. Wraps response data in a 'data' property and handles
* pagination metadata for Lucid ORM query results.
*/
class ApiSerializer extends BaseSerializer<{
Wrap: 'data'
PaginationMetaData: SimplePaginatorMetaKeys
}> {
/**
* Wraps all serialized data under this key in the response object.
* Example: { data: [...] } instead of returning raw arrays/objects
*/
wrap: 'data' = 'data'
/**
* Validates and defines pagination metadata structure for paginated responses.
* Ensures that pagination info from Lucid queries is properly formatted.
*
* @throws Error if metadata doesn't match Lucid's pagination structure
*/
definePaginationMetaData(metaData: unknown): SimplePaginatorMetaKeys {
if (!this.isLucidPaginatorMetaData(metaData)) {
throw new Error(
'Invalid pagination metadata. Expected metadata to contain Lucid pagination keys'
)
}
return metaData
}
}
/**
* Single instance of ApiSerializer used across the application
*/
const serializer = new ApiSerializer()
const serialize = Object.assign(
function (this: HttpContext, ...[data, resolver]: Parameters<ApiSerializer['serialize']>) {
return serializer.serialize(data, resolver ?? this.containerResolver)
},
{
withoutWrapping(
this: HttpContext,
...[data, resolver]: Parameters<ApiSerializer['serializeWithoutWrapping']>
) {
return serializer.serializeWithoutWrapping(data, resolver ?? this.containerResolver)
},
}
) as ApiSerializer['serialize'] & { withoutWrapping: ApiSerializer['serializeWithoutWrapping'] }
/**
* Adds the serialize method to all HttpContext instances.
* Usage in controllers: return ctx.serialize(data)
* This ensures all API responses follow the same structure with data wrapping.
*/
HttpContext.instanceProperty('serialize', serialize)
/**
* Module augmentation to add the serialize method to HttpContext.
* This allows controllers to use ctx.serialize() for consistent API responses.
*/
declare module '@adonisjs/core/http' {
export interface HttpContext {
serialize: typeof serialize
}
}

View File

@ -1,57 +0,0 @@
import 'dotenv/config';
import { NntpPool } from './lib/NntpPool.js';
import { HeaderWorker } from './workers/HeaderWorker.js';
import { FileWorker } from './workers/FileWorker.js';
import { CollectionWorker } from './workers/CollectionWorker.js';
import { BodyWorker } from './workers/BodyWorker.js';
import log4js from './lib/logger.js';
const logger = log4js.getLogger();
export class Application {
constructor() {
this.pool = new NntpPool();
const collectionWorker = new CollectionWorker();
const fileWorker = new FileWorker(collectionWorker.queue);
const bodyWorker = new BodyWorker(null); // HeaderWorker is not yet initialized
this.headerWorker = new HeaderWorker(fileWorker.queue, bodyWorker.queue);
bodyWorker.headerQueue = this.headerWorker.queue; // Now we can set it
}
async run() {
let conn;
try {
conn = await this.pool.acquire();
logger.info('NNTP connection acquired from pool.');
logger.debug(`Server date: ${await conn.date()}`);
const group = await conn.group('alt.binaries.test');
logger.debug(`Group info: ${JSON.stringify(group)}`);
const overview = await conn.xover(group.first, group.last);
logger.info(`Fetched ${overview.overviews.length} headers.`);
for (const [id, header] of overview.overviews) {
await this.headerWorker.queue.add('process-header', header);
}
if (overview.overviews.length > 0) {
const lastId = overview.overviews[overview.overviews.length - 1][0];
logger.info(`Last header ID queued: ${lastId}`);
}
} catch (error) {
logger.error('Error in main execution:', error);
} finally {
if (conn) {
this.pool.release(conn);
}
}
}
async shutdown() {
logger.info('Gracefully shutting down...');
await this.pool.shutdown();
process.exit(0);
}
}

View File

@ -1,84 +0,0 @@
import 'dotenv/config';
import { getDb } from '../lib/database.js';
import { NntpPool } from '../lib/NntpPool.js';
import { YencFile } from '../lib/YencFile.js';
import fs from 'fs/promises';
import log4js from '../lib/logger.js';
const logger = log4js.getLogger('download');
async function downloadAndProcessPart(pool, partNumber, segment, yencFile) {
let conn;
try {
conn = await pool.acquire();
await conn.group('alt.binaries.test');
logger.debug(`Downloading part ${partNumber} with message ID: ${segment.id}`);
const bodyBuffer = (await conn.body(`<${segment.id}>`)).data;
yencFile.processPart(bodyBuffer);
} catch (error) {
if (error.code === 430) {
logger.error(`Article not found for part ${partNumber} (Message ID: ${segment.id})`);
} else {
throw error;
}
} finally {
if (conn) {
pool.release(conn);
}
}
}
async function downloadFile(fileId, numConnections) {
const pool = new NntpPool(numConnections);
const yencFile = new YencFile();
const db = await getDb();
const file = await db.get('SELECT * FROM files WHERE id = ?', fileId);
if (!file) {
logger.error(`File with ID ${fileId} not found.`);
await pool.shutdown();
return;
}
logger.info(`Downloading file: ${file.filename} with ${numConnections} connections.`);
const messageIds = JSON.parse(file.message_ids);
const sortedParts = Object.entries(messageIds).sort(([a], [b]) => parseInt(a, 10) - parseInt(b, 10));
// Sequentially process the first part to initialize the YencFile
const [firstPartNumber, firstSegment] = sortedParts[0];
await downloadAndProcessPart(pool, firstPartNumber, firstSegment, yencFile);
// Concurrently process the rest of the parts
const remainingParts = sortedParts.slice(1);
const downloadPromises = remainingParts.map(([partNumber, segment]) =>
downloadAndProcessPart(pool, partNumber, segment, yencFile)
);
await Promise.all(downloadPromises);
const completeFile = yencFile.getBuffer();
if (completeFile) {
await fs.writeFile(file.filename, completeFile);
logger.info(`File "${file.filename}" downloaded successfully.`);
} else {
logger.error('Could not assemble the final file.');
}
await pool.shutdown();
}
const args = process.argv.slice(2);
const fileIdArg = args.find(arg => !arg.startsWith('--'));
const connectionsArg = args.find(arg => arg.startsWith('--connections='));
const fileId = fileIdArg ? parseInt(fileIdArg, 10) : null;
const numConnections = connectionsArg ? parseInt(connectionsArg.split('=')[1], 10) : 10;
if (!fileId || isNaN(fileId)) {
logger.error('Please provide a valid file ID as a command-line argument.');
process.exit(1);
}
downloadFile(fileId, numConnections);

View File

@ -1,55 +0,0 @@
import 'dotenv/config';
import { getDb } from '../lib/database.js';
import { create } from 'xmlbuilder2';
import fs from 'fs/promises';
import log4js from '../lib/logger.js';
const logger = log4js.getLogger('nzb');
async function createNzb(fileId) {
const db = await getDb();
const file = await db.get('SELECT * FROM files WHERE id = ?', fileId);
if (!file) {
logger.error(`File with ID ${fileId} not found.`);
return;
}
logger.info(`Creating NZB for file: ${file.filename}`);
const messageIds = JSON.parse(file.message_ids);
const root = create({ version: '1.0', encoding: 'UTF-8' })
.dtd({ pubID: '-//newzBin//DTD NZB 1.1//EN', sysID: 'http://www.newzbin.com/DTD/nzb/nzb-1.1.dtd' })
.ele('nzb', { xmlns: 'http://www.newzbin.com/DTD/2003/nzb' });
const nzbFile = root.ele('file', {
poster: file.poster,
date: file.date,
subject: file.filename,
});
const groups = nzbFile.ele('groups');
// This should be dynamic in a real application
groups.ele('group').txt('alt.binaries.test');
const segments = nzbFile.ele('segments');
for (let i = 1; i <= file.parts; i++) {
const segment = messageIds[i];
segments.ele('segment', { 'bytes': segment.size.toString(), 'number': i.toString() }).txt(segment.id);
}
const xml = root.end({ prettyPrint: true });
const nzbFilename = `${file.filename}.nzb`;
await fs.writeFile(nzbFilename, xml);
logger.info(`NZB file created: ${nzbFilename}`);
}
const fileId = parseInt(process.argv[2], 10);
if (isNaN(fileId)) {
logger.error('Please provide a valid file ID as a command-line argument.');
process.exit(1);
}
createNzb(fileId);

View File

@ -1,6 +0,0 @@
import { Application } from '../Application.js';
const app = new Application();
app.run();
process.on('SIGINT', () => app.shutdown());

View File

@ -1,70 +0,0 @@
import { NNTP } from 'nntp-js';
import log4js from './logger.js';
const logger = log4js.getLogger('pool');
export class NntpPool {
constructor(poolSize = 10) {
this.poolSize = poolSize;
this.allConnections = new Set();
this.idleConnections = [];
this.waiters = [];
this.createdCount = 0;
}
async _createConnection() {
const config = {
host: process.env.NNTP_HOST,
user: process.env.NNTP_USER,
password: process.env.NNTP_PASS,
port: 443,
secure: true,
};
const conn = new NNTP(config.host, 119);
await conn.connect();
await conn.login(config.user, config.password);
this.allConnections.add(conn);
return conn;
}
async acquire() {
if (this.idleConnections.length > 0) {
logger.debug('Reusing existing connection from pool.');
return this.idleConnections.pop();
}
if (this.createdCount < this.poolSize) {
this.createdCount++;
logger.info(`Creating new connection (${this.createdCount}/${this.poolSize}).`);
return this._createConnection();
}
logger.info(`Pool maxed out at ${this.poolSize}. Waiting for a connection to become available.`);
return new Promise(resolve => this.waiters.push(resolve));
}
release(conn) {
if (this.waiters.length > 0) {
logger.debug('Releasing connection directly to a waiting task.');
const resolve = this.waiters.shift();
resolve(conn);
} else {
logger.debug('Returning connection to the idle pool.');
this.idleConnections.push(conn);
}
}
async shutdown() {
logger.info('Shutting down all connections in the pool.');
const shutdownPromises = [];
for (const conn of this.allConnections) {
shutdownPromises.push(conn.quit());
}
await Promise.all(shutdownPromises);
this.allConnections.clear();
this.idleConnections.length = 0;
this.waiters.length = 0;
this.createdCount = 0;
}
}

View File

@ -1,67 +0,0 @@
import yencode from 'yencode';
import { crc32 } from 'crc';
import log4js from './logger.js';
import { parseYencMeta } from './yenc.util.js';
const logger = log4js.getLogger('yenc');
export class YencFile {
constructor() {
this.targetBuffer = null;
this.totalSize = 0;
}
processPart(encodedBuffer) {
const meta = parseYencMeta(encodedBuffer);
// Initialize buffer on the first part that has total size info
if (!this.targetBuffer && meta.header?.size) {
this.totalSize = parseInt(meta.header.size, 10);
if (!this.totalSize) {
throw new Error('Could not determine total file size from yEnc metadata.');
}
this.targetBuffer = Buffer.alloc(this.totalSize);
logger.info(`Allocated buffer of size ${this.totalSize} for file.`);
}
if (!this.targetBuffer) {
throw new Error('Cannot process yEnc part: target buffer not initialized. The first part must contain total file size.');
}
const headerPartMarker = Buffer.from('=ypart');
const headerBeginMarker = Buffer.from('=ybegin');
const footerMarker = Buffer.from('\r\n=yend');
// The content starts after the LAST header line.
const partHeaderIndex = encodedBuffer.indexOf(headerPartMarker);
const beginHeaderIndex = encodedBuffer.indexOf(headerBeginMarker);
const contentHeaderIndex = partHeaderIndex !== -1 ? partHeaderIndex : beginHeaderIndex;
const contentStartIndex = encodedBuffer.indexOf('\r\n', contentHeaderIndex) + 2;
const contentEndIndex = encodedBuffer.lastIndexOf(footerMarker);
const dataToDecode = encodedBuffer.subarray(contentStartIndex, contentEndIndex);
const decoded = yencode.decode(dataToDecode);
const expectedSize = parseInt(meta.footer?.size, 10);
if (decoded.length !== expectedSize) {
throw new Error(`Decoded size (${decoded.length}) does not match expected part size (${expectedSize}).`);
}
logger.debug('Part size check passed.');
const calculatedCrc = crc32(decoded);
const expectedCrc = parseInt(meta.footer?.pcrc32, 16);
if (calculatedCrc !== expectedCrc) {
throw new Error(`CRC32 mismatch: expected ${expectedCrc.toString(16)}, but got ${calculatedCrc.toString(16)}.`);
}
logger.debug('CRC32 check passed.');
const offset = parseInt(meta.part.begin, 10) - 1;
decoded.copy(this.targetBuffer, offset);
logger.info(`Processed part ${meta.header.part}/${meta.header.total || 'N/A'} and wrote to buffer at offset ${offset}.`);
}
getBuffer() {
return this.targetBuffer;
}
}

View File

@ -1,26 +0,0 @@
import { open } from 'sqlite';
import sqlite3 from 'sqlite3';
let db;
export const getDb = async () => {
if (!db) {
db = await open({
filename: './database.sqlite',
driver: sqlite3.Database,
});
await db.exec(`
CREATE TABLE IF NOT EXISTS files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
filename TEXT NOT NULL,
poster TEXT NOT NULL,
date INTEGER NOT NULL,
parts INTEGER NOT NULL,
message_ids TEXT NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
`);
}
return db;
};

View File

@ -1,27 +0,0 @@
import log4js from 'log4js';
import fs from 'fs';
const logsDir = './logs';
if (!fs.existsSync(logsDir)) {
fs.mkdirSync(logsDir);
}
const timestamp = new Date().toISOString().replace(/:/g, '-');
log4js.configure({
appenders: {
console: { type: 'console' },
file: { type: 'file', filename: `${logsDir}/${timestamp}.log` },
},
categories: {
default: { appenders: ['console', 'file'], level: 'debug' },
header: { appenders: ['console', 'file'], level: 'info' },
file: { appenders: ['console', 'file'], level: 'info' },
collection: { appenders: ['console', 'file'], level: 'info' },
body: { appenders: ['console', 'file'], level: 'info' },
pool: { appenders: ['console', 'file'], level: 'info' },
yenc: { appenders: ['console', 'file'], level: 'info' },
},
});
export default log4js;

View File

@ -1,55 +0,0 @@
/**
* Parses a single line of yEnc metadata.
* @param {string} line The metadata line.
* @returns {object} A key-value map of the metadata.
*/
function parseMetaLine(line) {
if (!line) return {};
const meta = {};
line.split(' ').forEach(part => {
const eqIndex = part.indexOf('=');
if (eqIndex !== -1) {
meta[part.slice(0, eqIndex)] = part.slice(eqIndex + 1);
}
});
return meta;
}
/**
* Finds a line in a buffer that starts with a specific marker.
* @param {Buffer} buffer The buffer to search.
* @param {string} marker The marker to find (e.g., '=ybegin').
* @returns {string|null} The found line, or null.
*/
function findLine(buffer, marker) {
const markerBuffer = Buffer.from(marker);
const index = buffer.indexOf(markerBuffer);
if (index === -1) return null;
const lineEndIndex = buffer.indexOf(Buffer.from('\r\n'), index);
return buffer.subarray(index, (lineEndIndex !== -1) ? lineEndIndex : buffer.length).toString();
}
/**
* Extracts and merges metadata from all yEnc headers and footers.
* @param {Buffer} encodedBuffer The yEnc-encoded buffer.
* @returns {object} A single, merged object of all metadata.
*/
export function parseYencMeta(encodedBuffer) {
const beginLine = findLine(encodedBuffer, '=ybegin');
const partLine = findLine(encodedBuffer, '=ypart');
const endLine = findLine(encodedBuffer, '=yend');
if (!endLine || (!beginLine && !partLine)) {
throw new Error('Invalid yEnc data: missing required headers or footers.');
}
// Merge metadata, with more specific lines overwriting general ones.
const meta = {
header: parseMetaLine(beginLine),
part: parseMetaLine(partLine),
footer: parseMetaLine(endLine),
};
return meta;
}

View File

@ -1,71 +0,0 @@
import { Queue, Worker } from 'bullmq';
import log4js from '../lib/logger.js';
import { NntpPool } from '../lib/NntpPool.js';
import { parseYencMeta } from '../lib/yenc.util.js';
import fs from 'fs/promises';
import path from 'path';
const logger = log4js.getLogger('body');
export class BodyWorker {
constructor(headerQueue) {
this.headerQueue = headerQueue;
this.queue = new Queue('body-queue', {
connection: {
host: process.env.REDIS_HOST || 'localhost',
port: process.env.REDIS_PORT || 6379,
},
});
this.pool = new NntpPool();
this.worker = new Worker(this.queue.name, this.process.bind(this), { connection: this.queue.opts.connection });
this.worker.on('failed', (job, err) => {
logger.error(`Body job ${job.id} failed with error: ${err.message}`);
});
}
async process(job) {
const { header } = job.data;
const messageId = header['message-id'];
logger.debug(`Processing header with unparsable subject: ${header.subject}`);
let conn;
try {
conn = await this.pool.acquire();
const bodyBuffer = (await conn.body(messageId)).data;
try {
const meta = parseYencMeta(bodyBuffer);
if (meta.header.name) {
const { name, part, total } = meta.header;
const newSubject = `"${name}" yEnc (${part}/${total})`;
header.subject = newSubject;
logger.info(`Found yEnc metadata in body. New subject: ${newSubject}`);
await this.headerQueue.add('process-header', header);
} else {
logger.warn(`Could not find yEnc metadata in body for header: ${header.subject}`);
}
} catch (parseError) {
if (parseError.message.includes('Invalid yEnc data')) {
logger.error(`Failed to parse yEnc data for message ID ${messageId}. Dumping buffer for inspection.`);
const debugDir = path.join(process.cwd(), 'debug');
await fs.mkdir(debugDir, { recursive: true });
const timestamp = new Date().toISOString().replace(/:/g, '-');
const dumpFile = path.join(debugDir, `body-error-${timestamp}-${messageId.replace(/[<>]/g, '')}.bin`);
await fs.writeFile(dumpFile, bodyBuffer);
logger.error(`Problematic body buffer saved to: ${dumpFile}`);
}
// Re-throw the original parsing error to fail the job
throw parseError;
}
} catch (error) {
logger.error(`Error in body worker for message ID ${messageId}:`, error);
throw error; // Ensure the job fails if any other error occurs
} finally {
if (conn) {
this.pool.release(conn);
}
}
}
}

View File

@ -1,78 +0,0 @@
import { Queue, Worker } from 'bullmq';
import log4js from '../lib/logger.js';
import { getDb } from '../lib/database.js';
import { NntpPool } from '../lib/NntpPool.js';
import { createExtractorFromData } from 'node-unrar-js';
import { YencFile } from '../lib/YencFile.js';
const logger = log4js.getLogger('collection');
export class CollectionWorker {
constructor() {
this.queue = new Queue('collection-queue', {
connection: {
host: process.env.REDIS_HOST || 'localhost',
port: process.env.REDIS_PORT || 6379,
},
});
this.pool = new NntpPool();
this.worker = new Worker(this.queue.name, this.process.bind(this), { connection: this.queue.opts.connection });
this.worker.on('failed', (job, err) => {
logger.error(`Collection job ${job.id} failed with error: ${err.message}`);
});
}
async process(job) {
const { fileId } = job.data;
logger.debug(`Processing file ID ${fileId} for collection.`);
const db = await getDb();
const file = await db.get('SELECT * FROM files WHERE id = ?', fileId);
if (!file) {
logger.error(`File with ID ${fileId} not found in the database.`);
return;
}
const RAR_REGEX = /\.part0*1\.rar$/;
if (RAR_REGEX.test(file.filename)) {
logger.info(`File "${file.filename}" is the first part of a RAR set.`);
const messageIds = JSON.parse(file.message_ids);
const firstPart = messageIds['1'];
if (!firstPart || !firstPart.id) {
logger.error(`Could not find message ID for the first part of file "${file.filename}".`);
return;
}
let conn;
try {
conn = await this.pool.acquire();
await conn.group('alt.binaries.test');
const bodyBuffer = (await conn.body(`<${firstPart.id}>`)).data;
const yencFile = new YencFile();
yencFile.processPart(bodyBuffer);
const decodedBuffer = yencFile.getBuffer();
const extractor = await createExtractorFromData({ data: decodedBuffer });
const fileList = extractor.getFileList();
logger.info(`Files in "${file.filename}":`, fileList);
} catch (error) {
if (error.code === 430) {
logger.error(`Article not found for first part of RAR set (Message ID: ${firstPart.id})`);
} else {
logger.error('Error processing RAR file:', error);
}
} finally {
if (conn) {
this.pool.release(conn);
}
}
} else {
logger.debug(`File "${file.filename}" is not the first part of a RAR set.`);
}
}
}

View File

@ -1,66 +0,0 @@
import { Queue, Worker } from 'bullmq';
import log4js from '../lib/logger.js';
import { getDb } from '../lib/database.js';
const logger = log4js.getLogger('file');
export class FileWorker {
constructor(collectionQueue) {
this.collectionQueue = collectionQueue;
this.queue = new Queue('file-queue', {
connection: {
host: process.env.REDIS_HOST || 'localhost',
port: process.env.REDIS_PORT || 6379,
},
});
this.worker = new Worker(this.queue.name, this.process.bind(this), { connection: this.queue.opts.connection });
this.worker.on('failed', (job, err) => {
logger.error(`File job ${job.id} failed with error: ${err.message}`);
});
}
async process(job) {
const { filename, parts } = job.data;
const partCount = Object.keys(parts).length;
logger.debug(`Processing complete file: "${filename}" with ${partCount} parts.`);
const firstPart = JSON.parse(Object.values(parts)[0]);
const poster = firstPart.from;
const date = new Date(firstPart.date).getTime();
const messageIds = Object.entries(parts).reduce((acc, [partNumber, partData]) => {
const part = JSON.parse(partData);
const messageId = part['message-id'];
if (messageId) {
acc[partNumber] = {
id: messageId.replace(/[<>]/g, ''),
size: part[':bytes'],
};
} else {
logger.warn(`Message ID not found for part ${partNumber} of file "${filename}"`);
}
return acc;
}, {});
if (Object.keys(messageIds).length !== partCount) {
throw new Error(`Could not process all parts for file "${filename}" due to missing message IDs.`);
}
const db = await getDb();
const result = await db.run(
'INSERT INTO files (filename, poster, date, parts, message_ids) VALUES (?, ?, ?, ?, ?)',
filename,
poster,
date,
partCount,
JSON.stringify(messageIds)
);
const fileId = result.lastID;
logger.debug(`Saved file "${filename}" to database with ID: ${fileId}`);
await this.collectionQueue.add('process-collection', { fileId });
logger.debug(`Added file ID ${fileId} to collection queue.`);
}
}

View File

@ -1,54 +0,0 @@
import { Queue, Worker } from 'bullmq';
import Redis from 'ioredis';
import log4js from '../lib/logger.js';
const logger = log4js.getLogger('header');
export class HeaderWorker {
constructor(fileQueue, bodyQueue) {
this.fileQueue = fileQueue;
this.bodyQueue = bodyQueue;
this.queue = new Queue('header-queue', {
connection: {
host: process.env.REDIS_HOST || 'localhost',
port: process.env.REDIS_PORT || 6379,
},
});
this.redis = new Redis(this.queue.opts.connection);
this.worker = new Worker(this.queue.name, this.process.bind(this), { connection: this.queue.opts.connection });
this.worker.on('failed', (job, err) => {
logger.error(`Header job ${job.id} failed with error: ${err.message}`);
});
}
async process(job) {
const header = job.data;
const subject = header.subject;
const SUBJECT_REGEX = /"(.+)"(?: yEnc)? \((\d+)\/(\d+)\)/;
const match = subject.match(SUBJECT_REGEX);
if (match) {
const filename = match[1];
const part = parseInt(match[2], 10);
const total = parseInt(match[3], 10);
const fileKey = `file:${filename}`;
await this.redis.hset(fileKey, part, JSON.stringify(header));
const partCount = await this.redis.hlen(fileKey);
if (partCount === total) {
const fileParts = await this.redis.hgetall(fileKey);
await this.fileQueue.add('process-file', { filename, parts: fileParts });
await this.redis.del(fileKey);
logger.info(`File "${filename}" is complete and moved to file-queue.`);
} else {
logger.info(`Stored part ${part}/${total} for file "${filename}"`);
}
} else {
logger.warn(`Could not parse subject: "${subject}". Moving to body-queue.`);
await this.bodyQueue.add('process-body', { header });
}
}
}

View File

@ -1,21 +0,0 @@
import { decodeYenc } from './yenc.util.js';
import fs from 'fs/promises';
import { Buffer } from 'buffer';
async function runTest() {
const encodedData = await fs.readFile('files/HjVfQlWmHdUrQeQkRiLkTwEj-1779830864932@nyuu.bin');
const correctlyDecodedData = await fs.readFile('files/Dragon.Ball.S01E119.MULTI.BDRip.REMASTERED.1080p.x264.DTS-LILAS.par2-good');
const decodedBuffer = decodeYenc(encodedData);
if (Buffer.compare(decodedBuffer, correctlyDecodedData) === 0) {
console.log('Test passed: Decoded data matches the correctly decoded file.');
} else {
console.error('Test failed: Decoded data does not match the correctly decoded file.');
console.error('Decoded buffer length:', decodedBuffer.length);
console.error('Correct buffer length:', correctlyDecodedData.length);
await fs.writeFile('files/test-decoded-output.bin', decodedBuffer);
}
}
runTest();

47
start/env.ts Normal file
View File

@ -0,0 +1,47 @@
/*
|--------------------------------------------------------------------------
| Environment variables service
|--------------------------------------------------------------------------
|
| The `Env.create` method creates an instance of the Env service. The
| service validates the environment variables and also cast values
| to JavaScript data types.
|
*/
import { Env } from '@adonisjs/core/env'
export default await Env.create(new URL('../', import.meta.url), {
// Node
NODE_ENV: Env.schema.enum(['development', 'production', 'test'] as const),
PORT: Env.schema.number(),
HOST: Env.schema.string({ format: 'host' }),
LOG_LEVEL: Env.schema.string(),
// App
APP_KEY: Env.schema.secret(),
APP_URL: Env.schema.string({ format: 'url', tld: false }),
// Session
SESSION_DRIVER: Env.schema.enum(['cookie', 'memory', 'database'] as const),
// Database
DB_CONNECTION: Env.schema.string(),
DB_HOST: Env.schema.string({ format: 'host' }),
DB_PORT: Env.schema.number(),
DB_USER: Env.schema.string(),
DB_PASSWORD: Env.schema.secret.optional(),
DB_DATABASE: Env.schema.string(),
// Redis
REDIS_HOST: Env.schema.string({ format: 'host' }),
REDIS_PORT: Env.schema.number(),
REDIS_PASSWORD: Env.schema.secret.optional(),
// Usenet (NNTP)
NNTP_HOST: Env.schema.string({ format: 'host' }),
NNTP_PORT: Env.schema.number(),
NNTP_USER: Env.schema.string(),
NNTP_PASSWORD: Env.schema.secret(),
NNTP_SECURE: Env.schema.boolean(),
})

49
start/kernel.ts Normal file
View File

@ -0,0 +1,49 @@
/*
|--------------------------------------------------------------------------
| HTTP kernel file
|--------------------------------------------------------------------------
|
| The HTTP kernel file is used to register the middleware with the server
| or the router.
|
*/
import router from '@adonisjs/core/services/router'
import server from '@adonisjs/core/services/server'
/**
* The error handler is used to convert an exception
* to a HTTP response.
*/
server.errorHandler(() => import('#exceptions/handler'))
/**
* The server middleware stack runs middleware on all the HTTP
* requests, even if there is no route registered for
* the request URL.
*/
server.use([
() => import('#middleware/force_json_response_middleware'),
() => import('#middleware/container_bindings_middleware'),
() => import('@adonisjs/cors/cors_middleware'),
])
/**
* The router middleware stack runs middleware on all the HTTP
* requests with a registered route.
*/
router.use([
() => import('@adonisjs/core/bodyparser_middleware'),
() => import('@adonisjs/session/session_middleware'),
() => import('@adonisjs/shield/shield_middleware'),
() => import('@adonisjs/auth/initialize_auth_middleware'),
() => import('#middleware/silent_auth_middleware'),
])
/**
* Named middleware collection must be explicitly assigned to
* the routes or the routes group.
*/
export const middleware = router.named({
auth: () => import('#middleware/auth_middleware'),
})

37
start/routes.ts Normal file
View File

@ -0,0 +1,37 @@
/*
|--------------------------------------------------------------------------
| Routes file
|--------------------------------------------------------------------------
|
| The routes file is used for defining the HTTP routes.
|
*/
import { middleware } from '#start/kernel'
import router from '@adonisjs/core/services/router'
import { controllers } from '#generated/controllers'
router.get('/', () => {
return { hello: 'world' }
})
router
.group(() => {
router
.group(() => {
router.post('signup', [controllers.NewAccount, 'store'])
router.post('login', [controllers.AccessTokens, 'store'])
})
.prefix('auth')
.as('auth')
router
.group(() => {
router.get('profile', [controllers.Profile, 'show'])
router.post('logout', [controllers.AccessTokens, 'destroy'])
})
.prefix('account')
.as('profile')
.use(middleware.auth())
})
.prefix('/api/v1')

23
start/validator.ts Normal file
View File

@ -0,0 +1,23 @@
/*
|--------------------------------------------------------------------------
| Validator file
|--------------------------------------------------------------------------
|
| The validator file is used for configuring global transforms for VineJS.
| The transform below converts all VineJS date outputs from JavaScript
| Date objects to Luxon DateTime instances, so that validated dates are
| ready to use with Lucid models and other parts of the app that expect
| Luxon DateTime.
|
*/
import { DateTime } from 'luxon'
import { VineDate } from '@vinejs/vine'
declare module '@vinejs/vine/types' {
interface VineGlobalTransforms {
date: DateTime
}
}
VineDate.transform((value) => DateTime.fromJSDate(value))

56
tests/bootstrap.ts Normal file
View File

@ -0,0 +1,56 @@
import { assert } from '@japa/assert'
import { apiClient } from '@japa/api-client'
import app from '@adonisjs/core/services/app'
import type { Config } from '@japa/runner/types'
import { pluginAdonisJS } from '@japa/plugin-adonisjs'
import { dbAssertions } from '@adonisjs/lucid/plugins/db'
import testUtils from '@adonisjs/core/services/test_utils'
import { authApiClient } from '@adonisjs/auth/plugins/api_client'
import { sessionApiClient } from '@adonisjs/session/plugins/api_client'
import type { Registry } from '../.adonisjs/client/registry/schema.d.ts'
/**
* This file is imported by the "bin/test.ts" entrypoint file
*/
declare module '@japa/api-client/types' {
interface RoutesRegistry extends Registry {}
}
/**
* This file is imported by the "bin/test.ts" entrypoint file
*/
/**
* Configure Japa plugins in the plugins array.
* Learn more - https://japa.dev/docs/runner-config#plugins-optional
*/
export const plugins: Config['plugins'] = [
assert(),
pluginAdonisJS(app),
dbAssertions(app),
apiClient(),
sessionApiClient(app),
authApiClient(app),
]
/**
* Configure lifecycle function to run before and after all the
* tests.
*
* The setup functions are executed before all the tests
* The teardown functions are executed after all the tests
*/
export const runnerHooks: Required<Pick<Config, 'setup' | 'teardown'>> = {
setup: [],
teardown: [],
}
/**
* Configure suites by tapping into the test suite instance.
* Learn more - https://japa.dev/docs/test-suites#lifecycle-hooks
*/
export const configureSuite: Config['configureSuite'] = (suite) => {
if (['browser', 'functional', 'e2e'].includes(suite.name)) {
return suite.setup(() => testUtils.httpServer().start())
}
}

9
tsconfig.json Normal file
View File

@ -0,0 +1,9 @@
{
"extends": "@adonisjs/tsconfig/tsconfig.app.json",
"compilerOptions": {
"rootDir": "./",
"jsx": "react",
"outDir": "./build",
"esModuleInterop": true
}
}

1
types/simple-yenc.d.ts vendored Normal file
View File

@ -0,0 +1 @@
declare module 'simple-yenc';