feat: slowly began implementing redis search (needs data sanitization!!!)

This commit is contained in:
Kakious 2024-10-15 15:38:29 -04:00
parent 178d922f24
commit 7cd7c4d1a0
11 changed files with 207 additions and 26 deletions

View file

@ -15,6 +15,7 @@ export class AuthMiddleware implements NestMiddleware {
const { session, user } = await this.validateSession(req, res); const { session, user } = await this.validateSession(req, res);
// set the session and user data in the CLS // set the session and user data in the CLS
delete user.password;
this.clsService.set('authType', 'session'); this.clsService.set('authType', 'session');
this.clsService.set('user', user); this.clsService.set('user', user);
this.clsService.set('session', session); this.clsService.set('session', session);

View file

@ -567,7 +567,7 @@ export const createOidcAdapter: (db: DataSource, redis: RedisService, baseUrl: s
*/ */
async genericFind(id: string): Promise<AdapterPayload | undefined> { async genericFind(id: string): Promise<AdapterPayload | undefined> {
const key = this.key(id); const key = this.key(id);
const data = await redis.jsonGet(key); const data = await redis.jsonGet<AdapterPayload>(key);
if (!data) { if (!data) {
return undefined; return undefined;

View file

@ -44,9 +44,9 @@ async function bootstrap() {
.setDescription('An OpenSource Identity Provider written by Waterwolf') .setDescription('An OpenSource Identity Provider written by Waterwolf')
.setVersion('1.0') .setVersion('1.0')
.addTag('Authentication', 'Initial login and registration') .addTag('Authentication', 'Initial login and registration')
.addTag('Client')
.addTag('Organization')
.addTag('User') .addTag('User')
.addTag('Organization')
.addTag('Client')
.build(); .build();
const document = SwaggerModule.createDocument(app, config); const document = SwaggerModule.createDocument(app, config);

View file

@ -2,9 +2,10 @@ import { Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { RedisService } from './service/redis.service'; import { RedisService } from './service/redis.service';
import { SearchService } from './service/search.service';
@Module({ @Module({
providers: [RedisService, ConfigService], providers: [RedisService, SearchService, ConfigService],
exports: [RedisService], exports: [RedisService, SearchService],
}) })
export class RedisModule {} export class RedisModule {}

View file

@ -174,7 +174,7 @@ export class RedisService implements OnApplicationShutdown {
* @param key Key for the value to get * @param key Key for the value to get
* @returns Promise<object | null> * @returns Promise<object | null>
*/ */
public async jsonGet(key: string): Promise<object | null> { public async jsonGet<T = string>(key: string): Promise<T | null> {
const value = (await this._ioredis.call('JSON.GET', key)) as string | null; const value = (await this._ioredis.call('JSON.GET', key)) as string | null;
if (!value) { if (!value) {
return null; return null;
@ -193,11 +193,11 @@ export class RedisService implements OnApplicationShutdown {
* @param value Value to set * @param value Value to set
* @returns Promise<void> * @returns Promise<void>
*/ */
public async jsonSet(key: string, value: string | object): Promise<void> { public async jsonSet(key: string, value: string | object, path = '$'): Promise<void> {
if (typeof value === 'object') { if (typeof value === 'object') {
value = JSON.stringify(value); value = JSON.stringify(value);
} }
await this._ioredis.set(key, value); await this._ioredis.call('JSON.SET', key, path, value);
} }
/** /**

View file

@ -0,0 +1,108 @@
import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common';
import { RedisService } from './redis.service';
import { userCacheKey } from '../../user/user.constant';
@Injectable()
export class SearchService implements OnApplicationBootstrap {
private readonly logger = new Logger(SearchService.name);
constructor(private readonly redisService: RedisService) {}
async onApplicationBootstrap() {
this.logger.debug('Checking Index in Redis Serch');
if (!(await this.checkIndexExists('idx:users'))) {
this.logger.log('Created users redis index');
this.createIndex(
'idx:users',
userCacheKey + ':',
'$.id',
'AS',
'id',
'TEXT',
'WEIGHT',
'5',
'$.email',
'AS',
'email',
'TEXT',
'$.username',
'AS',
'username',
'TEXT',
'$.displayName',
'AS',
'displayName',
'TEXT',
);
}
}
public async createIndex(indexName: string, keyPrefix: string, ...schema: string[]) {
this.redisService.ioredis.call(
'FT.CREATE',
indexName,
'ON',
'JSON',
'PREFIX',
'1',
keyPrefix,
'SCHEMA',
...schema,
);
}
/**
* Checks if an Index Exists
* @param indexName The index name to check if it exists
* @returns boolean
*/
public async checkIndexExists(indexName: string): Promise<boolean> {
try {
await this.redisService.ioredis.call('FT.INFO', indexName);
return true;
} catch (e) {
if (e.message === 'Unknown Index name') return false;
throw e;
}
}
public async search(index: string, searchQuery: string, field?: string) {
let query: string = '';
searchQuery = searchQuery.replace(/[.@\\]/g, '\\$&');
if (field) {
query = `@${field}:(${searchQuery})`;
}
query = searchQuery;
this.logger.debug(`Searching index ${index} for ${query}`);
const redisSearch = (await this.redisService.ioredis.call('FT.SEARCH', index, query)) as any;
if (redisSearch[0] === 0) {
return [];
}
delete redisSearch[2][0];
const redisSearchResults = redisSearch[2]
.filter((result) => result !== null)
.map((result) => {
// try to parse the result, if fails, skip
try {
return JSON.parse(result);
} catch (e) {
this.logger.error('Failed to parse search result', e);
return null;
}
});
return {
totalResults: redisSearch[0],
results: redisSearchResults,
};
}
}

View file

@ -1,11 +1,23 @@
import { Controller, Delete, Get, Param, Patch, Post, Put } from '@nestjs/common'; import { Controller, Delete, Get, Param, Patch, Post, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger'; import { ApiParam, ApiTags } from '@nestjs/swagger';
import { UserService } from '../service/user.service'; import { UserService } from '../service/user.service';
import { RedisService } from '../../redis/service/redis.service';
import { SearchService } from '../../redis/service/search.service';
@Controller('user') @Controller('user')
@ApiTags('User') @ApiTags('User')
export class UserController { export class UserController {
constructor(private readonly userService: UserService) {} constructor(
private readonly userService: UserService,
private readonly redisService: RedisService,
private readonly searchService: SearchService,
) {}
@Get('/search')
@ApiParam({ name: 'field', required: false })
public async search(@Query('query') query: string, @Query('field') field?: string) {
return await this.searchService.search('idx:users', query, field);
}
@Get() @Get()
// Admin: Paginated list of users. // Admin: Paginated list of users.
@ -14,11 +26,15 @@ export class UserController {
} }
@Get(':id') @Get(':id')
// Authenticated : Has to be publicly seen due to org inviting. Filter amount of data unless your an admin.
// Allow self reflection to get all user data. @me
public async getUser(@Param('id') id: string): Promise<any> { public async getUser(@Param('id') id: string): Promise<any> {
return await this.userService.getUserById(id); return await this.userService.getUserById(id);
} }
@Delete(':id') @Delete(':id')
// Admin: Marks a user for deletion
// Allow self reflection to allow user to delete their own profile.
public async deleteUser(@Param('id') id: string): Promise<any> { public async deleteUser(@Param('id') id: string): Promise<any> {
return await this.userService.deleteUser(id); return await this.userService.deleteUser(id);
} }

View file

@ -18,7 +18,6 @@ import {
INVALID_CREDENTIALS_ERROR, INVALID_CREDENTIALS_ERROR,
USER_NOT_FOUND_ERROR, USER_NOT_FOUND_ERROR,
userCacheKeyGenerate, userCacheKeyGenerate,
userCacheTTL,
} from '../user.constant'; } from '../user.constant';
import { ClsService } from 'nestjs-cls'; import { ClsService } from 'nestjs-cls';
@ -45,7 +44,7 @@ export class UserService {
return this.clsService.get('user'); return this.clsService.get('user');
} }
} }
const cachedUser = await this.redisService.get<User>(userCacheKeyGenerate(id)); const cachedUser = await this.redisService.jsonGet<User>(userCacheKeyGenerate(id));
if (cachedUser && relations.length === 0) { if (cachedUser && relations.length === 0) {
return cachedUser; return cachedUser;
} }
@ -77,7 +76,7 @@ export class UserService {
} }
if (relations.length === 0) { if (relations.length === 0) {
await this.redisService.set(userCacheKeyGenerate(id), user, userCacheTTL); await this.redisService.jsonSet(userCacheKeyGenerate(id), user);
} }
return user; return user;

View file

@ -4,10 +4,11 @@ import { TypeOrmModule } from '@nestjs/typeorm';
import { RedisModule } from '../redis/redis.module'; import { RedisModule } from '../redis/redis.module';
import { UserService } from './service/user.service'; import { UserService } from './service/user.service';
import { DATABASE_ENTITIES } from '../database/database.entities'; import { DATABASE_ENTITIES } from '../database/database.entities';
import { UserController } from './controller/user.controller';
@Module({ @Module({
imports: [TypeOrmModule.forFeature(DATABASE_ENTITIES), RedisModule], imports: [TypeOrmModule.forFeature(DATABASE_ENTITIES), RedisModule],
controllers: [], controllers: [UserController],
providers: [UserService], providers: [UserService],
exports: [UserService], exports: [UserService],
}) })

View file

@ -3,6 +3,7 @@ import { ApiExcludeController, ApiExcludeEndpoint } from '@nestjs/swagger';
import { LoginGuard } from '../../auth/guard/login.guard'; import { LoginGuard } from '../../auth/guard/login.guard';
import { User } from '../../auth/decorators/user.decorator'; import { User } from '../../auth/decorators/user.decorator';
import { User as UserObject } from '../../database/models/user.model';
@ApiExcludeController() @ApiExcludeController()
@Controller() @Controller()
@ -51,9 +52,16 @@ export class ViewController {
}; };
} }
@Get('auth/auth-test') @Get('home')
@Render('home/index')
@ApiExcludeEndpoint() @ApiExcludeEndpoint()
public async getAuthTest(@User() user: any): Promise<any> { public async getHomeView(@User() user: UserObject): Promise<any> {
return user; return {
user: {
name: user.displayName ?? user.username,
avatar: user.avatar,
email: user.email,
},
};
} }
} }

View file

@ -1,10 +1,57 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html lang="en">
<head>
<meta charset="utf-8" /> <head>
<title>App</title> <meta charset="UTF-8">
</head> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<body> <title>Profile Management</title>
{{ message }} <link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
</body> </head>
<body class="bg-gray-100">
<div class="max-w-3xl mx-auto mt-10 p-6 bg-white rounded-lg shadow-lg">
<h2 class="text-2xl font-bold text-gray-800 mb-4">Profile Management</h2>
<form action="/profile/update" method="POST" class="space-y-6">
<div class="flex items-center space-x-4">
<img src="{{user.avatar}}" alt="User Avatar" class="h-16 w-16 rounded-full">
<div>
<label class="block text-sm font-medium text-gray-700">Change Profile Picture</label>
<input type="file" name="avatar"
class="block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-indigo-50 file:text-indigo-700 hover:file:bg-indigo-100 mt-1">
</div>
</div>
<div>
<label for="name" class="block text-sm font-medium text-gray-700">Name</label>
<input type="text" name="name" id="name" value="{{user.name}}"
class="mt-1 block w-full px-3 py-2 bg-white border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
</div>
<div>
<label for="email" class="block text-sm font-medium text-gray-700">Email</label>
<input type="email" name="email" id="email" value="{{user.email}}"
class="mt-1 block w-full px-3 py-2 bg-white border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">
</div>
<div>
<label for="bio" class="block text-sm font-medium text-gray-700">Bio</label>
<textarea name="bio" id="bio" rows="3"
class="mt-1 block w-full px-3 py-2 bg-white border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm">{{user.bio}}</textarea>
</div>
<dev></dev>
<div>
<button type="submit"
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
Save Changes
</button>
</div>
</form>
</div>
</body>
</html> </html>