Commit fa69a6fb authored by nanahira's avatar nanahira

first

parent 7bd7e214
......@@ -13,6 +13,7 @@
"@nestjs/platform-express": "^8.0.0",
"@nestjs/swagger": "^5.0.9",
"@nestjs/typeorm": "^8.0.2",
"axios": "^0.21.1",
"class-transformer": "^0.4.0",
"class-validator": "^0.13.1",
"lodash": "^4.17.21",
......@@ -31,6 +32,7 @@
"@types/express": "^4.17.13",
"@types/jest": "^26.0.24",
"@types/lodash": "^4.14.172",
"@types/multer": "^1.4.7",
"@types/node": "^16.0.0",
"@types/supertest": "^2.0.11",
"@typescript-eslint/eslint-plugin": "^4.28.2",
......@@ -2118,6 +2120,15 @@
"integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==",
"dev": true
},
"node_modules/@types/multer": {
"version": "1.4.7",
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.7.tgz",
"integrity": "sha512-/SNsDidUFCvqqcWDwxv2feww/yqhNeTRL5CVoL3jU4Goc4kKEL10T7Eye65ZqPNi4HRx8sAEX59pV1aEH7drNA==",
"dev": true,
"dependencies": {
"@types/express": "*"
}
},
"node_modules/@types/node": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.6.1.tgz",
......@@ -11959,6 +11970,15 @@
"integrity": "sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==",
"dev": true
},
"@types/multer": {
"version": "1.4.7",
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-1.4.7.tgz",
"integrity": "sha512-/SNsDidUFCvqqcWDwxv2feww/yqhNeTRL5CVoL3jU4Goc4kKEL10T7Eye65ZqPNi4HRx8sAEX59pV1aEH7drNA==",
"dev": true,
"requires": {
"@types/express": "*"
}
},
"@types/node": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-16.6.1.tgz",
......
......@@ -26,6 +26,7 @@
"@nestjs/platform-express": "^8.0.0",
"@nestjs/swagger": "^5.0.9",
"@nestjs/typeorm": "^8.0.2",
"axios": "^0.21.1",
"class-transformer": "^0.4.0",
"class-validator": "^0.13.1",
"lodash": "^4.17.21",
......@@ -44,6 +45,7 @@
"@types/express": "^4.17.13",
"@types/jest": "^26.0.24",
"@types/lodash": "^4.14.172",
"@types/multer": "^1.4.7",
"@types/node": "^16.0.0",
"@types/supertest": "^2.0.11",
"@typescript-eslint/eslint-plugin": "^4.28.2",
......
import { Test, TestingModule } from '@nestjs/testing';
import { AdminController } from './admin.controller';
describe('AdminController', () => {
let controller: AdminController;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [AdminController],
}).compile();
controller = module.get<AdminController>(AdminController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});
import {
Controller,
Post,
UploadedFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { ApiBody, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger';
import { MyCardAdminGuard } from '../my-card-admin.guard';
import { FileInterceptor } from '@nestjs/platform-express';
import { FileUploadDto } from '../dto/FileUpload.dto';
import { AppsJson } from '../utility/apps-json-type';
import { AppService } from '../app.service';
import { BlankReturnMessageDto } from '../dto/ReturnMessage.dto';
@Controller('api/admin')
@ApiTags('admin')
@UseGuards(MyCardAdminGuard)
export class AdminController {
constructor(private readonly appService: AppService) {}
@Post('migrate')
@ApiOperation({ summary: '迁移', description: '上传一个 apps.json 文件' })
@UseInterceptors(FileInterceptor('file'))
@ApiConsumes('multipart/form-data')
@ApiBody({
description: 'apps.json 文件',
type: FileUploadDto,
})
async migrate(
@UploadedFile('file') file: Express.Multer.File,
): Promise<BlankReturnMessageDto> {
const apps: AppsJson.App[] = JSON.parse(file.buffer.toString());
return this.appService.migrateFromAppsJson(apps);
}
}
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
@Controller()
@Controller('api')
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getHello(): string {
return this.appService.getHello();
@Get('apps.json')
getAppsJson() {
return this.appService.getAppsJson();
}
}
......@@ -3,10 +3,11 @@ import { AppController } from './app.controller';
import { AppService } from './app.service';
import { TypeOrmModule } from '@nestjs/typeorm';
import { typeormConfig } from './config';
import { AdminController } from './admin/admin.controller';
@Module({
imports: [TypeOrmModule.forRoot(typeormConfig())],
controllers: [AppController],
controllers: [AppController, AdminController],
providers: [AppService],
})
export class AppModule {}
import { Connection } from 'typeorm';
import { Connection, IsNull, Not } from 'typeorm';
import { InjectConnection } from '@nestjs/typeorm';
import { Injectable, ConsoleLogger } from '@nestjs/common';
import { AppsJson } from './utility/apps-json-type';
import { App } from './entities/App.entity';
import { BlankReturnMessageDto } from './dto/ReturnMessage.dto';
@Injectable()
export class AppService extends ConsoleLogger {
......@@ -11,7 +14,37 @@ export class AppService extends ConsoleLogger {
super('app');
}
getHello(): string {
return 'Hello World!';
async getAppsJson() {
return (
await this.db
.getRepository(App)
.find({ where: { appContent: Not(IsNull()) } })
).map((a) => a.app);
}
async migrateFromAppsJson(apps: AppsJson.App[]) {
const targetApps: App[] = [];
for (const appData of apps) {
if (!appData.id) {
throw new BlankReturnMessageDto(
400,
`App ${appData.name} is invalid.`,
).toException();
}
const checkExistingApp = await this.db
.getRepository(App)
.findOne({ where: { id: appData.id }, relations: ['history'] });
if (checkExistingApp) {
checkExistingApp.updateApp(appData);
targetApps.push(checkExistingApp);
} else {
const app = new App();
app.id = appData.id;
app.app = appData;
targetApps.push(app);
}
}
await this.db.getRepository(App).save(targetApps);
return new BlankReturnMessageDto(201, 'success');
}
}
import { TypeOrmModuleOptions } from '@nestjs/typeorm';
import { User } from './entities/User.entity';
import { App } from './entities/App.entity';
import { AppHistory } from './entities/AppHistory.entity';
export function dbConfig() {
return {
......@@ -13,8 +14,8 @@ export function dbConfig() {
export function typeormConfig(): TypeOrmModuleOptions {
return {
name: 'app',
type: 'mysql',
entities: [User], // entities here
type: 'postgres',
entities: [App, AppHistory], // entities here
synchronize: true,
...dbConfig(),
};
......
import { ApiProperty } from '@nestjs/swagger';
export class FileUploadDto {
@ApiProperty({ type: 'string', format: 'binary' })
file: any;
}
import { Column, Entity, OneToMany, PrimaryColumn } from 'typeorm';
import { TimeBase } from './TimeBase.entity';
import { AppsJson } from '../utility/apps-json-type';
import { MyCardUser } from '../utility/mycard-auth';
import { AppBase } from './AppBase.entity';
import { AppHistory } from './AppHistory.entity';
@Entity()
export class App extends AppBase {
@PrimaryColumn('varchar', { length: 64 })
id: string;
@Column('varchar', { length: 128, nullable: true })
authorsStr: string;
get author(): number[] {
return this.authorsStr
? this.authorsStr.split('|').map((idStr) => parseInt(idStr))
: [];
}
set author(a) {
this.authorsStr = a.join('|');
}
isUserCanEditApp(u: MyCardUser) {
return u.admin || this.author.includes(u.id);
}
@OneToMany(() => AppHistory, (h) => h.parentApp, { cascade: true })
history: AppHistory[];
updateApp(appData: AppsJson.App) {
this.app = appData;
const h = new AppHistory();
h.parentApp = this;
h.time = new Date();
h.app = appData;
this.history.push(h);
}
}
import { TimeBase } from './TimeBase.entity';
import { Column } from 'typeorm';
import { AppsJson } from '../utility/apps-json-type';
import { MyCardUser } from '../utility/mycard-auth';
export class AppBase extends TimeBase {
@Column('text', { nullable: true })
appContent: string;
get app(): AppsJson.App {
return this.appContent ? JSON.parse(this.appContent) : null;
}
set app(a) {
this.appContent = JSON.stringify(a);
}
}
import {
Column,
Entity,
Index,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { AppBase } from './AppBase.entity';
import { App } from './App.entity';
@Entity({ orderBy: { time: 'DESC' } })
export class AppHistory extends AppBase {
@PrimaryGeneratedColumn()
id: number;
@Index()
@Column()
time: Date;
@ManyToOne(() => App, (a) => a.history)
parentApp: App;
}
import { TimeBase } from './TimeBase.entity';
import { Column, Entity, Index, PrimaryColumn } from 'typeorm';
@Entity()
export class User extends TimeBase {
@PrimaryColumn('varchar', { length: 32 })
id: string;
@Index()
@Column('varchar', { length: 32 })
name: string;
}
......@@ -12,8 +12,9 @@ async function bootstrap() {
.setTitle('app')
.setDescription('The app')
.setVersion('1.0')
.addTag('admin', '只有萌卡管理员可以用')
.build();
const document = SwaggerModule.createDocument(app, documentConfig);
SwaggerModule.setup('docs', app, document);
......
import { MyCardAdminGuard } from './my-card-admin.guard';
describe('MyCardAdminGuard', () => {
it('should be defined', () => {
expect(new MyCardAdminGuard()).toBeDefined();
});
});
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Observable } from 'rxjs';
import { getUserFromContext } from './utility/mycard-auth';
import { BlankReturnMessageDto } from './dto/ReturnMessage.dto';
@Injectable()
export class MyCardAdminGuard implements CanActivate {
async canActivate(context: ExecutionContext): Promise<boolean> {
const user = await getUserFromContext(context);
if (!user) {
throw new BlankReturnMessageDto(401, 'Invalid user').toException();
}
if (!user.admin) {
throw new BlankReturnMessageDto(403, 'No permission').toException();
}
return true;
}
}
export namespace AppsJson {
export enum Locale {
zh_CN = 'zh-CN',
en_US = 'en-US',
ja_JP = 'ja-JP',
ko_KR = 'ko-KR',
pt_BR = 'pt-BR',
zh_HK = 'zh-HK',
zh_TW = 'zh-TW',
}
export enum Platform {
Linux = 'linux',
macOS = 'darwin',
Windows = 'win32',
}
export type LocaleWise<T> = Record<Locale, T>;
export type PlatformWise<T> = Record<Platform, T>;
export interface Developer {
name: string;
url: string;
}
export interface Trailer {
type: string;
url: string;
url2?: string;
}
export interface Action {
interpreter?: string;
execute: string;
args: any[];
env: Record<string, string>;
open?: string;
}
export type PlatformAction = Record<string, Action>;
export interface News {
url: string;
image: string;
title: string;
text: string;
updated_at: string;
}
export interface NetworkServer {
id: string;
url: string;
}
export interface Network {
protocol: string;
port: number;
servers: NetworkServer[];
}
export interface Syncable {
sync: boolean;
}
export interface Price {
cny: number;
usd: number;
}
export interface App {
id: string;
key?: string;
name?: LocaleWise<string>;
description?: LocaleWise<string>;
developers?: LocaleWise<Developer[]>;
publishers?: LocaleWise<Developer[]>;
released_at?: string;
category?: string;
tags?: string[];
trailer?: Trailer[];
dependencies?: PlatformWise<string[]>;
references?: PlatformWise<string[]>;
author?: string;
homepage?: string;
locales?: string[];
actions?: PlatformWise<PlatformAction>;
version?: PlatformWise<string>;
news?: LocaleWise<News[]>;
conference?: string;
icon?: string;
cover?: string;
background?: string;
parent?: string;
network?: Network;
updated_at?: string;
files?: Record<string, Syncable>;
data?: any;
price?: Price;
}
}
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { Request } from 'express';
import axios, { AxiosResponse } from 'axios';
export interface MyCardUser {
id: number;
username: string;
name: string;
email: string;
password_hash: string;
salt: string;
active: boolean;
admin: boolean;
avatar: string;
locale: string;
registration_ip_address: string;
ip_address: string;
created_at: string;
updated_at: string;
}
export interface FetchMyCardUserOptions {
mycardAccountsUrl: string;
field: string;
}
const _options = {
mycardAccountsUrl:
process.env.MYCARD_ACCOUNTS_URL || `https://sapi.moecube.com:444/accounts`,
field: 'sso',
};
export async function fetchUserWithToken(token: string) {
let authResult: AxiosResponse<MyCardUser>;
try {
authResult = await axios.get<MyCardUser>(
`${_options.mycardAccountsUrl}/authUser`,
{
responseType: 'json',
validateStatus: (s) => true,
headers: { Authorization: `Bearer ${token}` },
},
);
} catch (e) {
return null;
}
if (authResult.status >= 400) {
return null;
}
return authResult.data;
}
export function getTokenFromRequest(context: ExecutionContext) {
const req = context.switchToHttp().getRequest<Request>();
let token: string;
if (!token && req.headers) {
const authorizationHeader = req.headers['authorization'] as string;
if (authorizationHeader) {
token = authorizationHeader.split(' ').pop();
}
}
if (!token && req.query) {
token = req.query[_options.field] as string;
}
if (!token && req.body) {
token = req.body[_options.field] as string;
}
return token;
}
export function getUserFromContext(context: ExecutionContext) {
const token = getTokenFromRequest(context);
if (!token) {
return null;
}
return fetchUserWithToken(token);
}
export const FetchMyCardUser = createParamDecorator(
async (_, context: ExecutionContext) => {
return getUserFromContext(context);
},
);
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment