init2
This commit is contained in:
parent
fd4c2ff0b0
commit
c8c0f3e209
11
.babelrc
Normal file
11
.babelrc
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"presets": [
|
||||||
|
"@babel/preset-env",
|
||||||
|
"@babel/preset-typescript",
|
||||||
|
"@babel/preset-react"
|
||||||
|
],
|
||||||
|
"plugins": [
|
||||||
|
"@babel/proposal-class-properties",
|
||||||
|
"@babel/proposal-object-rest-spread"
|
||||||
|
]
|
||||||
|
}
|
13
index.html
Normal file
13
index.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ko">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>react-sample</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<link rel="stylesheet" href="./dist/css/style.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script src="dist/js/bundle.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
5
knexfile.js
Normal file
5
knexfile.js
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
require('ts-node').register();
|
||||||
|
const {Knex} = require('./src/config');
|
||||||
|
// Update with your config settings.
|
||||||
|
|
||||||
|
module.exports = Knex.config;
|
52
migrations/initial.ts
Normal file
52
migrations/initial.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import Knex from 'knex';
|
||||||
|
|
||||||
|
export async function up(knex:Knex) {
|
||||||
|
await knex.schema.createTable("users",(b)=>{
|
||||||
|
b.string("username").primary().comment("user's login id");
|
||||||
|
b.string("password_hash",64).notNullable();
|
||||||
|
b.string("password_salt",64).notNullable();
|
||||||
|
});
|
||||||
|
await knex.schema.createTable("contents",(b)=>{
|
||||||
|
b.increments("id").primary();
|
||||||
|
b.string("title").notNullable();
|
||||||
|
b.string("content_type",16).notNullable();
|
||||||
|
b.string("basepath",256).notNullable().comment("directory path for resource");
|
||||||
|
b.string("filename",256).notNullable().comment("filename");
|
||||||
|
b.string("thumbnail").nullable();
|
||||||
|
b.json("additional").nullable();
|
||||||
|
b.timestamps();
|
||||||
|
b.index("content_type","content_type_index");
|
||||||
|
});
|
||||||
|
await knex.schema.createTable("tags", (b)=>{
|
||||||
|
b.string("name").primary();
|
||||||
|
b.text("description");
|
||||||
|
});
|
||||||
|
await knex.schema.createTable("content_tag_relation",(b)=>{
|
||||||
|
b.integer("content_id").unsigned().notNullable();
|
||||||
|
b.string("tag_name").notNullable();
|
||||||
|
b.foreign("content_id").references("contents.id");
|
||||||
|
b.foreign("tag_name").references("tags.name");
|
||||||
|
b.primary(["content_id","tag_name"]);
|
||||||
|
});
|
||||||
|
await knex.schema.createTable("permissions",b=>{
|
||||||
|
b.integer('username').unsigned().notNullable();
|
||||||
|
b.string("name").notNullable();
|
||||||
|
b.primary(["username","name"]);
|
||||||
|
b.foreign('username').references('users.username');
|
||||||
|
});
|
||||||
|
//create admin account.
|
||||||
|
await knex.insert({
|
||||||
|
username:"admin",
|
||||||
|
password_hash:"unchecked",
|
||||||
|
password_salt:"unchecked"
|
||||||
|
}).into('users');
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function down(knex:Knex) {
|
||||||
|
//throw new Error('Downward migrations are not supported. Restore from backup.');
|
||||||
|
await knex.schema.dropTable("users");
|
||||||
|
await knex.schema.dropTable("contents");
|
||||||
|
await knex.schema.dropTable("tags");
|
||||||
|
await knex.schema.dropTable("content_tag_relation");
|
||||||
|
await knex.schema.dropTable("permissions");
|
||||||
|
};
|
54
package.json
Normal file
54
package.json
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
{
|
||||||
|
"name": "followed",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "server.ts",
|
||||||
|
"scripts": {
|
||||||
|
"test": "mocha",
|
||||||
|
"build:dev": "webpack --mode development",
|
||||||
|
"build:prod": "webpack --mode production",
|
||||||
|
"build:watch": "webpack --mode development -w",
|
||||||
|
"start": "ts-node src/server.ts",
|
||||||
|
"check-types": "tsc"
|
||||||
|
},
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"@material-ui/core": "^4.11.2",
|
||||||
|
"@material-ui/icons": "^4.11.2",
|
||||||
|
"knex": "^0.21.14",
|
||||||
|
"koa": "^2.13.0",
|
||||||
|
"koa-bodyparser": "^4.3.0",
|
||||||
|
"koa-router": "^10.0.0",
|
||||||
|
"natural-orderby": "^2.0.3",
|
||||||
|
"node-stream-zip": "^1.12.0",
|
||||||
|
"react": "^17.0.1",
|
||||||
|
"react-dom": "^17.0.1",
|
||||||
|
"sqlite3": "^5.0.0",
|
||||||
|
"ts-node": "^9.1.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@babel/core": "^7.12.10",
|
||||||
|
"@babel/plugin-proposal-class-properties": "^7.12.1",
|
||||||
|
"@babel/plugin-proposal-object-rest-spread": "^7.12.1",
|
||||||
|
"@babel/preset-env": "^7.12.11",
|
||||||
|
"@babel/preset-react": "^7.12.10",
|
||||||
|
"@babel/preset-typescript": "^7.12.7",
|
||||||
|
"@types/knex": "^0.16.1",
|
||||||
|
"@types/koa": "^2.11.6",
|
||||||
|
"@types/koa-bodyparser": "^4.3.0",
|
||||||
|
"@types/koa-router": "^7.4.1",
|
||||||
|
"@types/node": "^14.14.16",
|
||||||
|
"@types/react": "^17.0.0",
|
||||||
|
"@types/react-dom": "^17.0.0",
|
||||||
|
"babel-core": "^6.26.3",
|
||||||
|
"babel-loader": "^8.2.2",
|
||||||
|
"css-loader": "^5.0.1",
|
||||||
|
"mini-css-extract-plugin": "^1.3.3",
|
||||||
|
"style-loader": "^2.0.0",
|
||||||
|
"typescript": "^4.1.3",
|
||||||
|
"webpack": "^5.11.0",
|
||||||
|
"webpack-cli": "^4.2.0",
|
||||||
|
"webpack-dev-server": "^3.11.0"
|
||||||
|
}
|
||||||
|
}
|
3
settings.json
Normal file
3
settings.json
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"path":["data"]
|
||||||
|
}
|
9
src/client/css/style.css
Normal file
9
src/client/css/style.css
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-family: sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
11
src/client/js/app.tsx
Normal file
11
src/client/js/app.tsx
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import hello from './hello'
|
||||||
|
import React from 'react';
|
||||||
|
import ReactDom from 'react-dom';
|
||||||
|
import {Headline} from './test';
|
||||||
|
import style from '../css/style.css';
|
||||||
|
hello();
|
||||||
|
|
||||||
|
ReactDom.render(
|
||||||
|
<Headline />,
|
||||||
|
document.getElementById("root")
|
||||||
|
)
|
4
src/client/js/hello.ts
Normal file
4
src/client/js/hello.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export default function (){
|
||||||
|
console.log("hello");
|
||||||
|
console.log("???");
|
||||||
|
};
|
88
src/client/js/test.tsx
Normal file
88
src/client/js/test.tsx
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import ReactDom from 'react-dom';
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
import Drawer from '@material-ui/core/Drawer';
|
||||||
|
import { Button, Divider, IconButton, List, ListItem } from '@material-ui/core';
|
||||||
|
import { makeStyles, Theme, useTheme } from '@material-ui/core/styles';
|
||||||
|
import {ChevronLeft, ChevronRight} from '@material-ui/icons';
|
||||||
|
|
||||||
|
const drawerWidth = 240;
|
||||||
|
|
||||||
|
const useStyles = makeStyles((theme: Theme) => ({
|
||||||
|
root: {
|
||||||
|
display: 'flex'
|
||||||
|
},
|
||||||
|
appBar: {
|
||||||
|
transition: theme.transitions.create(['width', 'margin'], {
|
||||||
|
easing: theme.transitions.easing.sharp,
|
||||||
|
duration: theme.transitions.duration.leavingScreen
|
||||||
|
})
|
||||||
|
},
|
||||||
|
appBarShift: {
|
||||||
|
width: `calc(100% - ${drawerWidth}px)`,
|
||||||
|
marginLeft: drawerWidth,
|
||||||
|
transition: theme.transitions.create(['margin', 'width'], {
|
||||||
|
easing: theme.transitions.easing.easeOut,
|
||||||
|
duration: theme.transitions.duration.enteringScreen,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
drawer: {
|
||||||
|
width: drawerWidth,
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
|
drawerPaper: {
|
||||||
|
width: drawerWidth,
|
||||||
|
},
|
||||||
|
drawerHeader: {
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: theme.spacing(0, 1),
|
||||||
|
// necessary for content to be below app bar
|
||||||
|
...theme.mixins.toolbar,
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flexGrow: 1,
|
||||||
|
padding: theme.spacing(3),
|
||||||
|
transition: theme.transitions.create('margin', {
|
||||||
|
easing: theme.transitions.easing.sharp,
|
||||||
|
duration: theme.transitions.duration.leavingScreen,
|
||||||
|
}),
|
||||||
|
marginLeft: -drawerWidth,
|
||||||
|
},
|
||||||
|
contentShift: {
|
||||||
|
transition: theme.transitions.create('margin', {
|
||||||
|
easing: theme.transitions.easing.easeOut,
|
||||||
|
duration: theme.transitions.duration.enteringScreen,
|
||||||
|
}),
|
||||||
|
marginLeft: 0,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const Headline = () => {
|
||||||
|
const [v, setv] = useState(false);
|
||||||
|
const classes = useStyles();
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
return (<div className={classes.root}>
|
||||||
|
<Drawer variant='persistent' anchor='left' open={v} className={classes.drawer} classes={{paper:classes.drawerPaper}}>
|
||||||
|
<div className={classes.drawerHeader}>
|
||||||
|
<IconButton onClick={()=>setv(false)}>
|
||||||
|
{theme.direction === "ltr" ? <ChevronLeft /> : <ChevronRight />}
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
<Divider />
|
||||||
|
<List>
|
||||||
|
<ListItem>
|
||||||
|
|
||||||
|
<div>NO</div>
|
||||||
|
</ListItem>
|
||||||
|
</List>
|
||||||
|
</Drawer>
|
||||||
|
<main className={([classes.content, v ? classes.contentShift: ""].join(" ").trim())}>
|
||||||
|
<h1>aaa{`a${v} ${classes.content}`}aaa</h1>
|
||||||
|
<Button onClick={() => setv(!v)}>open</Button>
|
||||||
|
</main>
|
||||||
|
</div>);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Headline;
|
14
src/client/js/util.ts
Normal file
14
src/client/js/util.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
|
||||||
|
type Representable = string|number|boolean;
|
||||||
|
|
||||||
|
type ToQueryStringA = {
|
||||||
|
[name:string]:Representable|Representable[]
|
||||||
|
};
|
||||||
|
|
||||||
|
export const toQueryString = (obj:ToQueryStringA)=> {
|
||||||
|
return Object.entries(obj).map(e =>
|
||||||
|
e[1] instanceof Array
|
||||||
|
? e[1].map(f=>`${e[0]}[]=${encodeURIComponent(f)}`).join('&')
|
||||||
|
: `${e[0]}=${encodeURIComponent(e[1])}`)
|
||||||
|
.join('&');
|
||||||
|
}
|
16
src/config.ts
Normal file
16
src/config.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
export namespace Knex {
|
||||||
|
export const config = {
|
||||||
|
development: {
|
||||||
|
client: 'sqlite3',
|
||||||
|
connection: {
|
||||||
|
filename: './devdb.sqlite3'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
production: {
|
||||||
|
client: 'sqlite3',
|
||||||
|
connection: {
|
||||||
|
database: './db.sqlite3',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
28
src/database.ts
Normal file
28
src/database.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { existsSync } from 'fs';
|
||||||
|
import Knex from 'knex';
|
||||||
|
|
||||||
|
export async function connectDB(){
|
||||||
|
const config = require('./../knexfile');
|
||||||
|
const env = process.env.NODE_ENV || 'development';
|
||||||
|
const knex = Knex(config[env]);
|
||||||
|
let tries = 0;
|
||||||
|
for(;;){
|
||||||
|
try{
|
||||||
|
console.log("try to connect db");
|
||||||
|
await knex.raw('select 1 + 1;');
|
||||||
|
console.log("connect success");
|
||||||
|
}
|
||||||
|
catch(err){
|
||||||
|
if(tries < 3){
|
||||||
|
tries++;
|
||||||
|
console.error(`connection fail ${err} retry...`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
else{
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return knex;
|
||||||
|
}
|
143
src/db/contents.ts
Normal file
143
src/db/contents.ts
Normal file
@ -0,0 +1,143 @@
|
|||||||
|
import { Content, ContentContent, ContentAccessor, QueryListOption } from '../model/contents';
|
||||||
|
import Knex from 'knex';
|
||||||
|
import {createKnexTagController} from './tag';
|
||||||
|
import { TagAccessor } from '../model/tag';
|
||||||
|
|
||||||
|
type DBTagContentRelation = {
|
||||||
|
content_id:number,
|
||||||
|
tag_name:string
|
||||||
|
}
|
||||||
|
|
||||||
|
class KnexContentsAccessor implements ContentAccessor{
|
||||||
|
knex : Knex;
|
||||||
|
tagController: TagAccessor;
|
||||||
|
constructor(knex : Knex){
|
||||||
|
this.knex = knex;
|
||||||
|
this.tagController = createKnexTagController(knex);
|
||||||
|
}
|
||||||
|
async add(c: ContentContent){
|
||||||
|
const {tags,additional, ...rest} = c;
|
||||||
|
const id_lst = await this.knex.insert({
|
||||||
|
additional:JSON.stringify(additional),
|
||||||
|
...rest
|
||||||
|
}).into('contents');
|
||||||
|
const id = id_lst[0];
|
||||||
|
for (const it of tags) {
|
||||||
|
this.tagController.addTag({name:it});
|
||||||
|
}
|
||||||
|
if(tags.length > 0){
|
||||||
|
await this.knex.insert<DBTagContentRelation>(
|
||||||
|
tags.map(x=>({content_id:id,tag_name:x}))
|
||||||
|
).into("content_tag_relation");
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
};
|
||||||
|
async del(id:number) {
|
||||||
|
if (await this.findById(id) !== undefined){
|
||||||
|
await this.knex.delete().from("contents").where({id:id});
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
async findById(id:number,tagload?:boolean){
|
||||||
|
const s:Content[] = await this.knex.select("*").from("contents").where({id:id});
|
||||||
|
if(s.length === 0) return undefined;
|
||||||
|
const first = s[0];
|
||||||
|
first.additional = JSON.parse((first.additional as unknown) as string)
|
||||||
|
first['tags'] = [];
|
||||||
|
if(tagload === true){
|
||||||
|
const tags : DBTagContentRelation[] = await this.knex.select("*")
|
||||||
|
.from("content_tag_relation").where({content_id:first.id});
|
||||||
|
first.tags = tags.map(x=>x.tag_name);
|
||||||
|
}
|
||||||
|
return first;
|
||||||
|
};
|
||||||
|
async findList(option?:QueryListOption){
|
||||||
|
option = option || {};
|
||||||
|
const allow_tag = option.allow_tag || [];
|
||||||
|
const eager_loading = typeof option.eager_loading === "undefined" || option.eager_loading;
|
||||||
|
const limit = option.limit || 20;
|
||||||
|
const use_offset = option.use_offset || false;
|
||||||
|
const offset = option.offset || 0;
|
||||||
|
const word = option.word;
|
||||||
|
const cursor = option.cursor;
|
||||||
|
|
||||||
|
const buildquery = ()=>{
|
||||||
|
let query = this.knex.select("*");
|
||||||
|
if(allow_tag.length > 0){
|
||||||
|
query = query.from("content_tag_relation").innerJoin("contents","content_tag_relation.content_id","contents.id");
|
||||||
|
for(const tag of allow_tag){
|
||||||
|
query = query.where({tag_name:tag});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else{
|
||||||
|
query = query.from("contents");
|
||||||
|
}
|
||||||
|
if(word !== undefined){
|
||||||
|
query = query.where('title','like',`%${word}%`);
|
||||||
|
}
|
||||||
|
if(use_offset){
|
||||||
|
query = query.offset(offset);
|
||||||
|
}
|
||||||
|
else{
|
||||||
|
if(cursor !== undefined){
|
||||||
|
query = query.where('id','<',cursor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
query = query.limit(limit);
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
let query = buildquery();
|
||||||
|
//console.log(query.toSQL());
|
||||||
|
let result:Content[] = await query;
|
||||||
|
for(let i of result){
|
||||||
|
i.additional = JSON.parse((i.additional as unknown) as string);
|
||||||
|
}
|
||||||
|
if(eager_loading){
|
||||||
|
let idmap: {[index:number]:Content} = {};
|
||||||
|
for(const r of result){
|
||||||
|
idmap[r.id] = r;
|
||||||
|
r.tags = [];
|
||||||
|
}
|
||||||
|
let subquery = buildquery();
|
||||||
|
let tagresult:{id:number,tag_name:string}[] = await this.knex.select("id","content_tag_relation.tag_name").from(subquery)
|
||||||
|
.innerJoin("content_tag_relation","content_tag_relation.content_id","id");
|
||||||
|
for(const {id,tag_name} of tagresult){
|
||||||
|
idmap[id].tags.push(tag_name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
async findListByBasePath(path:string):Promise<Content[]>{
|
||||||
|
let results:Content[] = await this.knex.select("*").from("contents").where({basepath:path});
|
||||||
|
results.forEach(e => {
|
||||||
|
e.additional = JSON.parse((e.additional as unknown) as string);
|
||||||
|
});
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
async update(c:Partial<Content> & { id:number }){
|
||||||
|
const {id,tags,...rest} = c;
|
||||||
|
if (await this.findById(id) !== undefined){
|
||||||
|
await this.knex.update(rest).where({id: id}).from("contents");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
async addTag(c: Content,tag_name:string){
|
||||||
|
if (c.tags.includes(tag_name)) return false;
|
||||||
|
this.tagController.addTag({name:tag_name});
|
||||||
|
await this.knex.insert<DBTagContentRelation>({tag_name: tag_name, content_id: c.id})
|
||||||
|
.into("content_tag_relation");
|
||||||
|
c.tags.push(tag_name);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
async delTag(c: Content,tag_name:string){
|
||||||
|
if (c.tags.includes(tag_name)) return false;
|
||||||
|
await this.knex.delete().where({tag_name: tag_name,content_id: c.id}).from("content_tag_relation");
|
||||||
|
c.tags.push(tag_name);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export const createKnexContentsAccessor = (knex:Knex): ContentAccessor=>{
|
||||||
|
return new KnexContentsAccessor(knex);
|
||||||
|
}
|
3
src/db/mod.ts
Normal file
3
src/db/mod.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from './contents';
|
||||||
|
export * from './tag';
|
||||||
|
export * from './user';
|
50
src/db/tag.ts
Normal file
50
src/db/tag.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import {Tag, TagAccessor} from '../model/tag';
|
||||||
|
import Knex from 'knex';
|
||||||
|
|
||||||
|
type DBTags = {
|
||||||
|
name: string,
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
|
class KnexTagAccessor implements TagAccessor{
|
||||||
|
knex:Knex
|
||||||
|
constructor(knex:Knex){
|
||||||
|
this.knex = knex;
|
||||||
|
}
|
||||||
|
async getTagAllList(onlyname?:boolean){
|
||||||
|
onlyname = onlyname || false;
|
||||||
|
const t:DBTags[] = await this.knex.select(onlyname ? "*" : "name").from("tags")
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
async getTagByName(name:string){
|
||||||
|
const t:DBTags[] = await this.knex.select('*').from("tags").where({name: name});
|
||||||
|
if(t.length === 0) return undefined;
|
||||||
|
return t[0];
|
||||||
|
}
|
||||||
|
async addTag(tag: Tag){
|
||||||
|
if(await this.getTagByName(tag.name) === undefined){
|
||||||
|
await this.knex.insert<DBTags>({
|
||||||
|
name:tag.name,
|
||||||
|
description:tag.description === undefined ? "" : tag.description
|
||||||
|
}).into("tags");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
async delTag(name:string){
|
||||||
|
if(await this.getTagByName(name) !== undefined){
|
||||||
|
await this.knex.delete().where({name:name}).from("tags");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
async updateTag(name:string,desc:string){
|
||||||
|
if(await this.getTagByName(name) !== undefined){
|
||||||
|
await this.knex.update({description:desc}).where({name:name}).from("tags");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
export const createKnexTagController = (knex:Knex):TagAccessor=>{
|
||||||
|
return new KnexTagAccessor(knex);
|
||||||
|
}
|
82
src/db/user.ts
Normal file
82
src/db/user.ts
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import Knex from 'knex';
|
||||||
|
import {IUser,UserCreateInput, UserAccessor, Password} from '../model/user';
|
||||||
|
|
||||||
|
type PermissionTable={
|
||||||
|
username:string,
|
||||||
|
name:string
|
||||||
|
};
|
||||||
|
type DBUser = {
|
||||||
|
username : string,
|
||||||
|
password_hash: string,
|
||||||
|
password_salt: string
|
||||||
|
}
|
||||||
|
class KnexUser implements IUser{
|
||||||
|
private knex: Knex;
|
||||||
|
readonly username: string;
|
||||||
|
readonly password: Password;
|
||||||
|
|
||||||
|
constructor(username: string, pw: Password,knex:Knex){
|
||||||
|
this.username = username;
|
||||||
|
this.password = pw;
|
||||||
|
this.knex = knex;
|
||||||
|
}
|
||||||
|
async reset_password(password: string){
|
||||||
|
this.password.set_password(password);
|
||||||
|
await this.knex.from("users")
|
||||||
|
.where({username:this.username})
|
||||||
|
.update({password_hash:this.password.hash,password_salt:this.password.salt});
|
||||||
|
}
|
||||||
|
async get_permissions(){
|
||||||
|
let b = (await this.knex.select('*').from("permissions")
|
||||||
|
.where({username : this.username})) as PermissionTable[];
|
||||||
|
return b.map(x=>x.name);
|
||||||
|
}
|
||||||
|
async add(name: string) {
|
||||||
|
if(!(await this.get_permissions()).includes(name)){
|
||||||
|
const r = await this.knex.insert({
|
||||||
|
username: this.username,
|
||||||
|
name: name
|
||||||
|
}).into("permissions");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
async remove(name: string) {
|
||||||
|
const r = await this.knex
|
||||||
|
.from("permissions")
|
||||||
|
.where({
|
||||||
|
username:this.username, name:name
|
||||||
|
}).delete();
|
||||||
|
return r !== 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createKnexUserController = (knex: Knex):UserAccessor=>{
|
||||||
|
const createUserKnex = async (input:UserCreateInput)=>{
|
||||||
|
if(undefined !== (await findUserKenx(input.username))){
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
const user = new KnexUser(input.username,new Password(input.password),knex);
|
||||||
|
await knex.insert<DBUser>({
|
||||||
|
username: user.username,
|
||||||
|
password_hash: user.password.hash,
|
||||||
|
password_salt: user.password.salt}).into("users");
|
||||||
|
return user;
|
||||||
|
};
|
||||||
|
const findUserKenx = async (id:string)=>{
|
||||||
|
let user:DBUser[] = await knex.select("*").from("users").where({username:id});
|
||||||
|
if(user.length == 0) return undefined;
|
||||||
|
const first = user[0];
|
||||||
|
return new KnexUser(first.username,
|
||||||
|
new Password({hash: first.password_hash, salt: first.password_salt}), knex);
|
||||||
|
}
|
||||||
|
const delUserKnex = async (id:string) => {
|
||||||
|
let r = await knex.delete().from("users").where({username:id});
|
||||||
|
return r===0;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
createUser: createUserKnex,
|
||||||
|
findUser: findUserKenx,
|
||||||
|
delUser: delUserKnex,
|
||||||
|
};
|
||||||
|
}
|
69
src/diff.ts
Normal file
69
src/diff.ts
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import { watch } from 'fs';
|
||||||
|
import { promises } from 'fs';
|
||||||
|
|
||||||
|
const readdir = promises.readdir;
|
||||||
|
|
||||||
|
type FileDiff = {
|
||||||
|
name: string,
|
||||||
|
desc: string
|
||||||
|
};
|
||||||
|
|
||||||
|
export class Watcher{
|
||||||
|
private _path:string;
|
||||||
|
private _added: FileDiff[];
|
||||||
|
private _deleted: FileDiff[];
|
||||||
|
constructor(path:string){
|
||||||
|
this._path = path;
|
||||||
|
this._added =[];
|
||||||
|
this._deleted =[]
|
||||||
|
}
|
||||||
|
public get added() : FileDiff[] {
|
||||||
|
return this._added;
|
||||||
|
}
|
||||||
|
/*public set added(diff : FileDiff[]) {
|
||||||
|
this._added = diff;
|
||||||
|
}*/
|
||||||
|
public get deleted(): FileDiff[]{
|
||||||
|
return this._deleted;
|
||||||
|
}
|
||||||
|
/*public set deleted(diff : FileDiff[]){
|
||||||
|
this._deleted = diff;
|
||||||
|
}*/
|
||||||
|
public get path(){
|
||||||
|
return this._path;
|
||||||
|
}
|
||||||
|
|
||||||
|
async setup(initial_filenames:string[]){
|
||||||
|
const cur = (await readdir(this._path,{
|
||||||
|
encoding:"utf8",
|
||||||
|
withFileTypes: true,
|
||||||
|
})).filter(x=>x.isFile).map(x=>x.name);
|
||||||
|
let added = cur.filter(x => !initial_filenames.includes(x));
|
||||||
|
let deleted = initial_filenames.filter(x=>!cur.includes(x));
|
||||||
|
this._added = added.map(x=>{return {name:x,desc:""}});
|
||||||
|
this._deleted = deleted.map(x=>{return {name:x,desc:""}});
|
||||||
|
watch(this._path,{persistent: true, recursive:false},async (eventType,filename)=>{
|
||||||
|
if(eventType === "rename"){
|
||||||
|
const cur = (await readdir(this._path,{
|
||||||
|
encoding:"utf8",
|
||||||
|
withFileTypes: true,
|
||||||
|
})).filter(x=>x.isFile).map(x=>x.name);
|
||||||
|
if(cur.includes(filename)){
|
||||||
|
this._added.push({name:filename,desc:""});
|
||||||
|
}
|
||||||
|
else{
|
||||||
|
if(this._added.map(x=>x.name).includes(filename)){
|
||||||
|
this._added = this._added.filter(x=> x.name !== filename);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
this._deleted.push({name:filename,desc:""});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class DiffWatcher{
|
||||||
|
Watchers: {[basepath:string]:Watcher} = {};
|
||||||
|
}
|
112
src/model/contents.ts
Normal file
112
src/model/contents.ts
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
import {TagAccessor} from './tag';
|
||||||
|
import {check_type} from './../util/type_check'
|
||||||
|
|
||||||
|
|
||||||
|
export interface ContentContent{
|
||||||
|
title : string,
|
||||||
|
content_type : string,
|
||||||
|
basepath : string,
|
||||||
|
filename : string,
|
||||||
|
thumbnail? : string,
|
||||||
|
additional : object,
|
||||||
|
tags : string[],//eager loading
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MetaContentContent = {
|
||||||
|
title : "string",
|
||||||
|
content_type : "string",
|
||||||
|
basepath : "string",
|
||||||
|
filename : "string",
|
||||||
|
additional : "object",
|
||||||
|
tags : "string[]",
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isContentContent = (c : any):c is ContentContent =>{
|
||||||
|
return check_type<ContentContent>(c,MetaContentContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Content extends ContentContent{
|
||||||
|
readonly id: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isContent = (c: any):c is Content =>{
|
||||||
|
if('id' in c && typeof c['id'] === "number"){
|
||||||
|
const {id, ...rest} = c;
|
||||||
|
return isContentContent(rest);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueryListOption{
|
||||||
|
/**
|
||||||
|
* search word
|
||||||
|
*/
|
||||||
|
word?:string,
|
||||||
|
allow_tag?:string[],
|
||||||
|
/**
|
||||||
|
* limit of list
|
||||||
|
* @default 20
|
||||||
|
*/
|
||||||
|
limit?:number,
|
||||||
|
/**
|
||||||
|
* use offset if true, otherwise
|
||||||
|
* @default false
|
||||||
|
*/
|
||||||
|
use_offset?:boolean,
|
||||||
|
/**
|
||||||
|
* cursor of contents
|
||||||
|
*/
|
||||||
|
cursor?:number,
|
||||||
|
/**
|
||||||
|
* offset of contents
|
||||||
|
*/
|
||||||
|
offset?:number,
|
||||||
|
/**
|
||||||
|
* tag eager loading
|
||||||
|
* @default true
|
||||||
|
*/
|
||||||
|
eager_loading?:boolean,
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
content_type?:string,
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ContentAccessor{
|
||||||
|
/**
|
||||||
|
* find list by option
|
||||||
|
* @returns content list
|
||||||
|
*/
|
||||||
|
findList: (option?:QueryListOption)=>Promise<Content[]>,
|
||||||
|
/**
|
||||||
|
* @returns content if exist, otherwise undefined
|
||||||
|
*/
|
||||||
|
findById: (id:number,tagload?:boolean)=> Promise<Content| undefined>,
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
findListByBasePath:(basepath: string)=>Promise<Content[]>;
|
||||||
|
/**
|
||||||
|
* update content except tag.
|
||||||
|
*/
|
||||||
|
update:(c:Partial<Content> & { id:number })=>Promise<boolean>;
|
||||||
|
/**
|
||||||
|
* add content
|
||||||
|
*/
|
||||||
|
add:(c:ContentContent)=>Promise<number>;
|
||||||
|
/**
|
||||||
|
* delete content
|
||||||
|
* @returns if it exists, return true.
|
||||||
|
*/
|
||||||
|
del:(id:number)=>Promise<boolean>;
|
||||||
|
/**
|
||||||
|
* @param c Valid Content
|
||||||
|
* @param tagname tag name to add
|
||||||
|
* @returns if success, return true
|
||||||
|
*/
|
||||||
|
addTag:(c:Content,tag_name:string)=>Promise<boolean>;
|
||||||
|
/**
|
||||||
|
* @returns if success, return true
|
||||||
|
*/
|
||||||
|
delTag:(c:Content,tag_name:string)=>Promise<boolean>;
|
||||||
|
};
|
3
src/model/mod.ts
Normal file
3
src/model/mod.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export * from './contents';
|
||||||
|
export * from './tag';
|
||||||
|
export * from './user';
|
12
src/model/tag.ts
Normal file
12
src/model/tag.ts
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
export interface Tag{
|
||||||
|
readonly name: string,
|
||||||
|
description?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TagAccessor{
|
||||||
|
getTagAllList: (onlyname?:boolean)=> Promise<Tag[]>
|
||||||
|
getTagByName: (name:string)=>Promise<Tag|undefined>,
|
||||||
|
addTag: (tag:Tag)=>Promise<boolean>,
|
||||||
|
delTag: (name:string) => Promise<boolean>,
|
||||||
|
updateTag: (name:string,tag:string) => Promise<boolean>
|
||||||
|
}
|
80
src/model/user.ts
Normal file
80
src/model/user.ts
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import { createHmac, randomBytes } from 'crypto';
|
||||||
|
|
||||||
|
function hashForPassword(salt: string,password:string){
|
||||||
|
return createHmac('sha256', salt).update(password).digest('hex')
|
||||||
|
}
|
||||||
|
function createPasswordHashAndSalt(password: string):{salt:string,hash:string}{
|
||||||
|
const secret = randomBytes(32).toString('hex');
|
||||||
|
return {
|
||||||
|
salt: secret,
|
||||||
|
hash: hashForPassword(secret,password)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Password{
|
||||||
|
private _salt:string;
|
||||||
|
private _hash:string;
|
||||||
|
constructor(pw : string|{salt:string,hash:string}){
|
||||||
|
const {salt,hash} = typeof pw === "string" ? createPasswordHashAndSalt(pw) : pw;
|
||||||
|
this._hash = hash;
|
||||||
|
this._salt = salt;
|
||||||
|
}
|
||||||
|
set_password(password: string){
|
||||||
|
const {salt,hash} = createPasswordHashAndSalt(password);
|
||||||
|
this._hash = hash;
|
||||||
|
this._salt = salt;
|
||||||
|
}
|
||||||
|
check_password(password: string):boolean{
|
||||||
|
return this._hash === hashForPassword(this._salt,password);
|
||||||
|
}
|
||||||
|
get salt(){return this._salt;}
|
||||||
|
get hash(){return this._hash;}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserCreateInput{
|
||||||
|
username: string,
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IUser{
|
||||||
|
readonly username : string;
|
||||||
|
readonly password : Password;
|
||||||
|
/**
|
||||||
|
* return user's permission list.
|
||||||
|
*/
|
||||||
|
get_permissions():Promise<string[]>;
|
||||||
|
/**
|
||||||
|
* add permission
|
||||||
|
* @param name permission name to add
|
||||||
|
* @returns if `name` doesn't exist, return true
|
||||||
|
*/
|
||||||
|
add(name :string):Promise<boolean>;
|
||||||
|
/**
|
||||||
|
* remove permission
|
||||||
|
* @param name permission name to remove
|
||||||
|
* @returns if `name` exist, return true
|
||||||
|
*/
|
||||||
|
remove(name :string):Promise<boolean>;
|
||||||
|
/**
|
||||||
|
* reset password.
|
||||||
|
* @param password password to set
|
||||||
|
*/
|
||||||
|
reset_password(password: string):Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface UserAccessor{
|
||||||
|
/**
|
||||||
|
* create user
|
||||||
|
* @returns if user exist, return undefined
|
||||||
|
*/
|
||||||
|
createUser: (input :UserCreateInput)=> Promise<IUser|undefined>,
|
||||||
|
/**
|
||||||
|
* find user
|
||||||
|
*/
|
||||||
|
findUser: (username: string)=> Promise<IUser|undefined>,
|
||||||
|
/**
|
||||||
|
* remove user
|
||||||
|
* @returns if user exist, true
|
||||||
|
*/
|
||||||
|
delUser: (username: string)=>Promise<boolean>
|
||||||
|
};
|
99
src/render.ts
Normal file
99
src/render.ts
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
import {Context} from 'koa';
|
||||||
|
import {promises, createReadStream} from "fs";
|
||||||
|
import {createReadStreamFromZip, entriesByNaturalOrder, readZip} from "./util/ziputil";
|
||||||
|
|
||||||
|
function since_last_modified(ctx: Context, last_modified: Date): boolean{
|
||||||
|
const con = ctx.get("If-Modified-Since");
|
||||||
|
if(con === "") return false;
|
||||||
|
const mdate = new Date(con);
|
||||||
|
if(last_modified > mdate) return false;
|
||||||
|
ctx.status = 304;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderZipImage(ctx: Context,path : string, page:number){
|
||||||
|
const image_ext = ['gif', 'png', 'jpeg', 'bmp', 'webp', 'jpg'];
|
||||||
|
let zip = await readZip(path);
|
||||||
|
const entries = entriesByNaturalOrder(zip).filter(x=>{
|
||||||
|
const ext = x.name.split('.').pop();
|
||||||
|
return ext !== undefined && image_ext.includes(ext);
|
||||||
|
});
|
||||||
|
if(0 <= page && page < entries.length){
|
||||||
|
const entry = entries[page];
|
||||||
|
const last_modified = new Date(entry.time);
|
||||||
|
if(since_last_modified(ctx,last_modified)){
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const read_stream = (await createReadStreamFromZip(zip,entry));
|
||||||
|
read_stream.on('close',()=>zip.close());
|
||||||
|
ctx.body = read_stream;
|
||||||
|
ctx.response.length = entry.size;
|
||||||
|
console.log(`${entry.name}'s ${page}:${entry.size}`);
|
||||||
|
ctx.response.type = entry.name.split(".").pop() as string;
|
||||||
|
ctx.status = 200;
|
||||||
|
ctx.set('Date', new Date().toUTCString());
|
||||||
|
ctx.set("Last-Modified",last_modified.toUTCString());
|
||||||
|
}
|
||||||
|
else{
|
||||||
|
ctx.status = 404;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
export async function renderImage(ctx: Context,path : string){
|
||||||
|
const ext = path.trim().split('.').pop();
|
||||||
|
if(ext === undefined) {
|
||||||
|
ctx.status = 404;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ctx.response.type = ext;
|
||||||
|
ctx.body = createReadStream(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function renderVideo(ctx: Context,path : string){
|
||||||
|
const ext = path.trim().split('.').pop();
|
||||||
|
if(ext === undefined) {
|
||||||
|
//ctx.status = 404;
|
||||||
|
console.error(`${path}:${ext}`)
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ctx.response.type = ext;
|
||||||
|
const range_text = ctx.request.get("range");
|
||||||
|
const stat = await promises.stat(path);
|
||||||
|
let start = 0;
|
||||||
|
let end = 0;
|
||||||
|
ctx.set('Last-Modified',(new Date(stat.mtime).toUTCString()));
|
||||||
|
ctx.set('Date', new Date().toUTCString());
|
||||||
|
ctx.set("Accept-Ranges", "bytes");
|
||||||
|
if(range_text === ''){
|
||||||
|
end = 1024*512;
|
||||||
|
end = Math.min(end,stat.size-1);
|
||||||
|
if(start > end){
|
||||||
|
ctx.status = 416;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ctx.status = 200;
|
||||||
|
ctx.length = stat.size;
|
||||||
|
let stream = createReadStream(path);
|
||||||
|
ctx.body = stream;
|
||||||
|
}
|
||||||
|
else{
|
||||||
|
const m = range_text.match(/^bytes=(\d+)-(\d*)/);
|
||||||
|
if(m === null){
|
||||||
|
ctx.status = 416;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
start = parseInt(m[1]);
|
||||||
|
end = m[2].length > 0 ? parseInt(m[2]) : start + 1024*1024;
|
||||||
|
end = Math.min(end,stat.size-1);
|
||||||
|
if(start > end){
|
||||||
|
ctx.status = 416;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ctx.status = 206;
|
||||||
|
ctx.length = end - start + 1;
|
||||||
|
ctx.response.set("Content-Range",`bytes ${start}-${end}/${stat.size}`);
|
||||||
|
ctx.body = createReadStream(path,{
|
||||||
|
start:start,
|
||||||
|
end:end
|
||||||
|
});//inclusive range.
|
||||||
|
}
|
||||||
|
}
|
133
src/route/contents.ts
Normal file
133
src/route/contents.ts
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
import { Context, Next } from 'koa';
|
||||||
|
import Router from 'koa-router';
|
||||||
|
import {ContentAccessor, isContentContent} from './../model/contents';
|
||||||
|
import {QueryListOption} from './../model/contents';
|
||||||
|
import {ParseQueryNumber, ParseQueryArray, ParseQueryBoolean} from './util'
|
||||||
|
import {sendError} from './error_handler';
|
||||||
|
|
||||||
|
const ContentIDHandler = (controller: ContentAccessor) => async (ctx: Context,next: Next)=>{
|
||||||
|
const num = Number.parseInt(ctx.params['num']);
|
||||||
|
if (num === NaN){
|
||||||
|
return await next();
|
||||||
|
}
|
||||||
|
let content = await controller.findById(num,true);
|
||||||
|
if (content == undefined){
|
||||||
|
sendError(404,"content does not exist.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ctx.body = content;
|
||||||
|
ctx.type = 'json';
|
||||||
|
return await next();
|
||||||
|
};
|
||||||
|
const ContentTagIDHandler = (controller: ContentAccessor) => async (ctx: Context,next: Next)=>{
|
||||||
|
const num = Number.parseInt(ctx.params['num']);
|
||||||
|
if (num === NaN){
|
||||||
|
return await next();
|
||||||
|
}
|
||||||
|
let content = await controller.findById(num,true);
|
||||||
|
if (content == undefined){
|
||||||
|
sendError(404,"content does not exist.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ctx.body = content.tags || [];
|
||||||
|
ctx.type = 'json';
|
||||||
|
return await next();
|
||||||
|
};
|
||||||
|
const ContentQueryHandler = (controller : ContentAccessor) => async (ctx: Context,next: Next)=>{
|
||||||
|
const limit = ParseQueryNumber(ctx.query['limit']);
|
||||||
|
const cursor = ParseQueryNumber(ctx.query['cursor']);
|
||||||
|
const word: string|undefined = ctx.query['word'];
|
||||||
|
const offset = ParseQueryNumber(ctx.query['offset']);
|
||||||
|
if(limit === NaN || cursor === NaN || offset === NaN){
|
||||||
|
sendError(400,"parameter limit, cursor or offset is not a number");
|
||||||
|
}
|
||||||
|
const allow_tag = ParseQueryArray(ctx.query['allow_tag[]']);
|
||||||
|
let [ok,use_offset] = ParseQueryBoolean(ctx.query['use_offset']);
|
||||||
|
if(!ok){
|
||||||
|
sendError(400,"use_offset must be true or false.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const option :QueryListOption = {
|
||||||
|
limit: limit,
|
||||||
|
allow_tag: allow_tag,
|
||||||
|
word: word,
|
||||||
|
cursor: cursor,
|
||||||
|
eager_loading: true,
|
||||||
|
offset: offset,
|
||||||
|
use_offset: use_offset
|
||||||
|
};
|
||||||
|
let content = await controller.findList(option);
|
||||||
|
ctx.body = content;
|
||||||
|
ctx.type = 'json';
|
||||||
|
}
|
||||||
|
const CreateContentHandler = (controller : ContentAccessor) => async (ctx: Context, next: Next) => {
|
||||||
|
const content_desc = ctx.request.body;
|
||||||
|
if(!isContentContent(content_desc)){
|
||||||
|
sendError(400,"it is not a valid format");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const id = await controller.add(content_desc);
|
||||||
|
ctx.body = {"ret":id};
|
||||||
|
ctx.type = 'json';
|
||||||
|
};
|
||||||
|
const AddTagHandler = (controller: ContentAccessor)=>async (ctx: Context, next: Next)=>{
|
||||||
|
let tag_name = ctx.params['tag'];
|
||||||
|
const num = Number.parseInt(ctx.params['num']);
|
||||||
|
if (num === NaN){
|
||||||
|
return await next();
|
||||||
|
}
|
||||||
|
if(typeof tag_name === undefined){
|
||||||
|
sendError(400,"??? Unreachable");
|
||||||
|
}
|
||||||
|
tag_name = String(tag_name);
|
||||||
|
const c = await controller.findById(num);
|
||||||
|
if(c === undefined){
|
||||||
|
sendError(404);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const r = await controller.addTag(c,tag_name);
|
||||||
|
ctx.body = {ret:r}
|
||||||
|
ctx.type = 'json';
|
||||||
|
};
|
||||||
|
const DelTagHandler = (controller: ContentAccessor)=>async (ctx: Context, next: Next)=>{
|
||||||
|
let tag_name = ctx.params['tag'];
|
||||||
|
const num = Number.parseInt(ctx.params['num']);
|
||||||
|
if (num === NaN){
|
||||||
|
return await next();
|
||||||
|
}
|
||||||
|
if(typeof tag_name === undefined){
|
||||||
|
sendError(400,"?? Unreachable");
|
||||||
|
}
|
||||||
|
tag_name = String(tag_name);
|
||||||
|
const c = await controller.findById(num);
|
||||||
|
if(c === undefined){
|
||||||
|
sendError(404);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const r = await controller.delTag(c,tag_name);
|
||||||
|
ctx.body = {ret:r}
|
||||||
|
ctx.type = 'json';
|
||||||
|
}
|
||||||
|
const DeleteContentHandler = (controller : ContentAccessor) => async (ctx: Context, next: Next) => {
|
||||||
|
const num = Number.parseInt(ctx.params['num']);
|
||||||
|
if (num === NaN){
|
||||||
|
return await next();
|
||||||
|
}
|
||||||
|
const r = await controller.del(num);
|
||||||
|
ctx.body = {"ret":r};
|
||||||
|
ctx.type = 'json';
|
||||||
|
};
|
||||||
|
export const getContentRouter = (controller: ContentAccessor)=>{
|
||||||
|
const ret = new Router();
|
||||||
|
ret.get("/search",ContentQueryHandler(controller));
|
||||||
|
ret.get("/:num",ContentIDHandler(controller));
|
||||||
|
ret.post("/",CreateContentHandler(controller));
|
||||||
|
ret.get("/:num/tags",ContentTagIDHandler(controller));
|
||||||
|
ret.post("/:num/tags/:tag",AddTagHandler(controller));
|
||||||
|
ret.del("/:num/tags/:tag",DelTagHandler(controller));
|
||||||
|
ret.del("/:num",DeleteContentHandler(controller));
|
||||||
|
//ret.get("/");
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default getContentRouter;
|
50
src/route/error_handler.ts
Normal file
50
src/route/error_handler.ts
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
import {Context, Next} from 'koa';
|
||||||
|
|
||||||
|
interface ErrorFormat {
|
||||||
|
code: number,
|
||||||
|
message: string,
|
||||||
|
detail?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ClientRequestError implements Error{
|
||||||
|
name: string;
|
||||||
|
message: string;
|
||||||
|
stack?: string | undefined;
|
||||||
|
code: number;
|
||||||
|
|
||||||
|
constructor(code : number,message: string){
|
||||||
|
this.name = "client request error";
|
||||||
|
this.message = message;
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const code_to_message_table:{[key:number]:string|undefined} = {
|
||||||
|
400:"BadRequest",
|
||||||
|
404:"NotFound"
|
||||||
|
}
|
||||||
|
|
||||||
|
export const error_handler = async (ctx:Context,next: Next)=>{
|
||||||
|
try {
|
||||||
|
await next();
|
||||||
|
} catch (err) {
|
||||||
|
if(err instanceof ClientRequestError){
|
||||||
|
const body : ErrorFormat= {
|
||||||
|
code: err.code,
|
||||||
|
message: code_to_message_table[err.code] || "",
|
||||||
|
detail: err.message
|
||||||
|
}
|
||||||
|
ctx.status = err.code;
|
||||||
|
ctx.body = body;
|
||||||
|
}
|
||||||
|
else{
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sendError = (code:number,message?:string) =>{
|
||||||
|
throw new ClientRequestError(code,message || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export default error_handler;
|
22
src/route/util.ts
Normal file
22
src/route/util.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
|
||||||
|
|
||||||
|
export function ParseQueryNumber(s: string|undefined): number| undefined{
|
||||||
|
if(s === undefined) return undefined;
|
||||||
|
else return Number.parseInt(s);
|
||||||
|
}
|
||||||
|
export function ParseQueryArray(s: string[]|string|undefined){
|
||||||
|
s = s || [];
|
||||||
|
return s instanceof Array ? s : [s];
|
||||||
|
}
|
||||||
|
export function ParseQueryBoolean(s: string|undefined): [boolean,boolean|undefined]{
|
||||||
|
let value:boolean|undefined;
|
||||||
|
if(s === "true")
|
||||||
|
value = true;
|
||||||
|
else if(s === "false")
|
||||||
|
value = false;
|
||||||
|
else if(s === undefined)
|
||||||
|
value = undefined;
|
||||||
|
else
|
||||||
|
return [false,undefined]
|
||||||
|
return [true,value]
|
||||||
|
}
|
76
src/server.ts
Normal file
76
src/server.ts
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import Koa from 'koa';
|
||||||
|
import Router from 'koa-router';
|
||||||
|
|
||||||
|
import {get_setting} from './setting';
|
||||||
|
import {connectDB} from './database';
|
||||||
|
import {Watcher} from './diff'
|
||||||
|
import {renderImage, renderVideo, renderZipImage} from './render';
|
||||||
|
import { createReadStream, readFileSync } from 'fs';
|
||||||
|
import getContentRouter from './route/contents';
|
||||||
|
import { createKnexContentsAccessor } from './db/contents';
|
||||||
|
import bodyparser from 'koa-bodyparser';
|
||||||
|
import {error_handler} from './route/error_handler';
|
||||||
|
//let Koa = require("koa");
|
||||||
|
async function main(){
|
||||||
|
let app = new Koa();
|
||||||
|
app.use(bodyparser());
|
||||||
|
app.use(error_handler);
|
||||||
|
let router = new Router();
|
||||||
|
|
||||||
|
let settings = get_setting();
|
||||||
|
|
||||||
|
let db = await connectDB();
|
||||||
|
let watcher = new Watcher(settings.path[0]);
|
||||||
|
await watcher.setup([]);
|
||||||
|
console.log(settings);
|
||||||
|
router.get('/', async (ctx,next)=>{
|
||||||
|
ctx.type = "html";
|
||||||
|
ctx.body = readFileSync("index.html");
|
||||||
|
});
|
||||||
|
router.get('/dist/css/style.css',async (ctx,next)=>{
|
||||||
|
ctx.type = "css";
|
||||||
|
ctx.body = createReadStream("dist/css/style.css");
|
||||||
|
});
|
||||||
|
router.get('/dist/js/bundle.js',async (ctx,next)=>{
|
||||||
|
ctx.type = "js";
|
||||||
|
ctx.body = createReadStream("dist/js/bundle.js");
|
||||||
|
});
|
||||||
|
router.get('/get'
|
||||||
|
,async (ctx,next)=>{
|
||||||
|
ctx.body = ctx.query;
|
||||||
|
console.log(JSON.stringify(ctx.query));
|
||||||
|
await next();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
let content_router = getContentRouter(createKnexContentsAccessor(db));
|
||||||
|
router.use('/content',content_router.routes());
|
||||||
|
router.get('/ss.mp4',async (ctx,next)=>{
|
||||||
|
/*for(let i in ctx.header){
|
||||||
|
if(i !== undefined)
|
||||||
|
console.log(i,ctx.get(i));
|
||||||
|
}*/
|
||||||
|
await renderVideo(ctx,"testdata/video_test.mp4");
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
router.get('/image/:number',async (ctx,next)=>{
|
||||||
|
let page = ctx.params.number;
|
||||||
|
await renderZipImage(ctx,"testdata/test_zip.zip",page);
|
||||||
|
ctx.set("cache-control","max-age=3600");
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
|
||||||
|
let mm_count=0;
|
||||||
|
app.use(async (ctx,next)=>{
|
||||||
|
console.log(`==========================${mm_count++}`);
|
||||||
|
console.log(`connect ${ctx.ip} : ${ctx.method} ${ctx.url}`);
|
||||||
|
await next();
|
||||||
|
//console.log(`404`);
|
||||||
|
});
|
||||||
|
app.use(router.routes());
|
||||||
|
app.use(router.allowedMethods());
|
||||||
|
|
||||||
|
console.log("log");
|
||||||
|
app.listen(3002);
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
main();
|
15
src/setting.ts
Normal file
15
src/setting.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import { readFileSync } from 'fs';
|
||||||
|
|
||||||
|
export type Setting = {
|
||||||
|
path: string[]
|
||||||
|
}
|
||||||
|
let setting: null|Setting = null;
|
||||||
|
export const read_setting_from_file = ()=>{
|
||||||
|
return JSON.parse(readFileSync("settings.json",{encoding:"utf8"})) as Setting;
|
||||||
|
}
|
||||||
|
export function get_setting():Setting{
|
||||||
|
if(setting === null){
|
||||||
|
setting = read_setting_from_file();
|
||||||
|
}
|
||||||
|
return setting;
|
||||||
|
}
|
16
src/util/type_check.ts
Normal file
16
src/util/type_check.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
export function check_type<T>(obj: any,check_proto:Record<string,string|undefined>):obj is T{
|
||||||
|
for (const it in check_proto) {
|
||||||
|
let defined = check_proto[it];
|
||||||
|
if(defined === undefined) return false;
|
||||||
|
defined = defined.trim();
|
||||||
|
if(defined.endsWith("[]")){
|
||||||
|
if(!(obj[it] instanceof Array)){
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if(defined !== typeof obj[it]){
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
36
src/util/ziputil.ts
Normal file
36
src/util/ziputil.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import StreamZip, { ZipEntry } from 'node-stream-zip';
|
||||||
|
import {orderBy}from 'natural-orderby';
|
||||||
|
|
||||||
|
export async function readZip(path : string):Promise<StreamZip>{
|
||||||
|
return new Promise((resolve,reject)=>{
|
||||||
|
let zip = new StreamZip({
|
||||||
|
file:path,
|
||||||
|
storeEntries: true
|
||||||
|
});
|
||||||
|
zip.on('error',(err)=>{
|
||||||
|
console.error(`read zip file ${path}`);
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
zip.on('ready',()=>{
|
||||||
|
resolve(zip);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
export function entriesByNaturalOrder(zip: StreamZip){
|
||||||
|
const entries = zip.entries();
|
||||||
|
const ret = orderBy(Object.values(entries),v=>v.name);
|
||||||
|
return ret;
|
||||||
|
}
|
||||||
|
export async function createReadStreamFromZip(zip:StreamZip,entry: ZipEntry):Promise<NodeJS.ReadableStream>{
|
||||||
|
return new Promise((resolve,reject)=>{
|
||||||
|
zip.stream(entry,(err, stream)=>{
|
||||||
|
if(stream !== undefined){
|
||||||
|
resolve(stream);
|
||||||
|
}
|
||||||
|
else{
|
||||||
|
reject(err);
|
||||||
|
}
|
||||||
|
});}
|
||||||
|
);
|
||||||
|
}
|
48
test.ts
Normal file
48
test.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import StreamZip, { ZipEntry } from 'node-stream-zip';
|
||||||
|
import {orderBy}from 'natural-orderby';
|
||||||
|
import {readZip,entriesByNaturalOrder} from './src/util/ziputil';
|
||||||
|
import {connectDB} from './src/database';
|
||||||
|
|
||||||
|
import { createKnexUserController } from './src/db/user';
|
||||||
|
import { createKnexContentsAccessor} from './src/db/contents';
|
||||||
|
|
||||||
|
console.log("on");
|
||||||
|
async function test_main1(){
|
||||||
|
let sz = await readZip("testdata/test_zip.zip");
|
||||||
|
let e = entriesByNaturalOrder(sz).filter(v=>v.name.split('.').pop() === "jpg");
|
||||||
|
console.log(e[0]);
|
||||||
|
console.log(e.map(v=>v.name));
|
||||||
|
}
|
||||||
|
async function test_main2(){
|
||||||
|
let db = await connectDB();
|
||||||
|
let user:{id:number}[] = await db.select('id').from('users');
|
||||||
|
console.log(user[0].id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function test_main3(){
|
||||||
|
let db = await connectDB();
|
||||||
|
let user_controller = createKnexUserController(db);
|
||||||
|
let bs = await user_controller.delUser("sss");
|
||||||
|
if(!bs) console.log("doesn't exist")
|
||||||
|
let retuser = await user_controller.createUser({username:"sss",password:"sss"});
|
||||||
|
let user = await user_controller.findUser("sss");
|
||||||
|
if(user !== undefined){
|
||||||
|
user.add("create");
|
||||||
|
console.log(user.username);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function test_main4(){
|
||||||
|
let db = await connectDB();
|
||||||
|
const cntr = createKnexContentsAccessor(db);
|
||||||
|
await cntr.add({
|
||||||
|
title:"aaa",
|
||||||
|
basepath:"testdata",
|
||||||
|
content_type:"manga",
|
||||||
|
filename:"test_zip.zip",
|
||||||
|
additional:{comment:"aaab"},
|
||||||
|
tags:[]
|
||||||
|
});
|
||||||
|
const list = await cntr.findList();
|
||||||
|
console.log(list);
|
||||||
|
}
|
||||||
|
test_main4();
|
70
tsconfig.json
Normal file
70
tsconfig.json
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
/* Visit https://aka.ms/tsconfig.json to read more about this file */
|
||||||
|
|
||||||
|
/* Basic Options */
|
||||||
|
// "incremental": true, /* Enable incremental compilation */
|
||||||
|
"target": "esnext", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */
|
||||||
|
"module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */
|
||||||
|
"lib": ["DOM","ES6"], /* Specify library files to be included in the compilation. */
|
||||||
|
//"allowJs": true, /* Allow javascript files to be compiled. */
|
||||||
|
// "checkJs": true, /* Report errors in .js files. */
|
||||||
|
"jsx": "react", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
|
||||||
|
// "declaration": true, /* Generates corresponding '.d.ts' file. */
|
||||||
|
// "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
|
||||||
|
// "sourceMap": true, /* Generates corresponding '.map' file. */
|
||||||
|
// "outFile": "./", /* Concatenate and emit output to single file. */
|
||||||
|
// "outDir": "./", /* Redirect output structure to the directory. */
|
||||||
|
// "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
|
||||||
|
// "composite": true, /* Enable project compilation */
|
||||||
|
// "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */
|
||||||
|
// "removeComments": true, /* Do not emit comments to output. */
|
||||||
|
// "noEmit": true, /* Do not emit outputs. */
|
||||||
|
// "importHelpers": true, /* Import emit helpers from 'tslib'. */
|
||||||
|
// "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
|
||||||
|
// "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
|
||||||
|
|
||||||
|
/* Strict Type-Checking Options */
|
||||||
|
"strict": true, /* Enable all strict type-checking options. */
|
||||||
|
"noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
|
||||||
|
"strictNullChecks": true, /* Enable strict null checks. */
|
||||||
|
// "strictFunctionTypes": true, /* Enable strict checking of function types. */
|
||||||
|
// "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
|
||||||
|
// "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
|
||||||
|
"noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
|
||||||
|
// "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
|
||||||
|
|
||||||
|
/* Additional Checks */
|
||||||
|
// "noUnusedLocals": true, /* Report errors on unused locals. */
|
||||||
|
// "noUnusedParameters": true, /* Report errors on unused parameters. */
|
||||||
|
// "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
|
||||||
|
// "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
|
||||||
|
|
||||||
|
/* Module Resolution Options */
|
||||||
|
"moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
|
||||||
|
// "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
|
||||||
|
// "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
|
||||||
|
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
|
||||||
|
// "typeRoots": [], /* List of folders to include type definitions from. */
|
||||||
|
// "types": [], /* Type declaration files to be included in compilation. */
|
||||||
|
"allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
|
||||||
|
"esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
|
||||||
|
// "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
|
||||||
|
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
|
||||||
|
|
||||||
|
/* Source Map Options */
|
||||||
|
// "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
|
||||||
|
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
|
||||||
|
// "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
|
||||||
|
// "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
|
||||||
|
|
||||||
|
/* Experimental Options */
|
||||||
|
// "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
|
||||||
|
// "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
|
||||||
|
|
||||||
|
/* Advanced Options */
|
||||||
|
"skipLibCheck": true, /* Skip type checking of declaration files. */
|
||||||
|
"forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */
|
||||||
|
},
|
||||||
|
"include": ["src","./"]
|
||||||
|
}
|
36
webpack.config.js
Normal file
36
webpack.config.js
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
const path = require("path");
|
||||||
|
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
|
||||||
|
|
||||||
|
module.exports = ()=>{return {
|
||||||
|
entry: './src/client/js/app.tsx',
|
||||||
|
output: {
|
||||||
|
path: path.resolve(__dirname, 'dist/js'),
|
||||||
|
filename: 'bundle.js'
|
||||||
|
},
|
||||||
|
module: {
|
||||||
|
rules: [
|
||||||
|
{
|
||||||
|
test: /\.(js|ts|tsx)$/,
|
||||||
|
exclude: /node_modules/,
|
||||||
|
use: {
|
||||||
|
loader: 'babel-loader',
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
test: /\.css$/,
|
||||||
|
use: [MiniCssExtractPlugin.loader,'css-loader']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
plugins : [
|
||||||
|
new MiniCssExtractPlugin({
|
||||||
|
"filename":'../css/style.css'})
|
||||||
|
],
|
||||||
|
resolve: {
|
||||||
|
extensions: ['.js','.css','.ts','.tsx']
|
||||||
|
},
|
||||||
|
devServer: {
|
||||||
|
historyApiFallback: true,
|
||||||
|
port: 3001
|
||||||
|
}
|
||||||
|
};}
|
Loading…
Reference in New Issue
Block a user