diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..2961d01 --- /dev/null +++ b/.babelrc @@ -0,0 +1,11 @@ +{ + "presets": [ + "@babel/preset-env", + "@babel/preset-typescript", + "@babel/preset-react" + ], + "plugins": [ + "@babel/proposal-class-properties", + "@babel/proposal-object-rest-spread" + ] +} \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..44262bb --- /dev/null +++ b/index.html @@ -0,0 +1,13 @@ + + + + + react-sample + + + + +
+ + + \ No newline at end of file diff --git a/knexfile.js b/knexfile.js new file mode 100644 index 0000000..54a887a --- /dev/null +++ b/knexfile.js @@ -0,0 +1,5 @@ +require('ts-node').register(); +const {Knex} = require('./src/config'); +// Update with your config settings. + +module.exports = Knex.config; diff --git a/migrations/initial.ts b/migrations/initial.ts new file mode 100644 index 0000000..1040564 --- /dev/null +++ b/migrations/initial.ts @@ -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"); +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..cfea48e --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/settings.json b/settings.json new file mode 100644 index 0000000..ef5107b --- /dev/null +++ b/settings.json @@ -0,0 +1,3 @@ +{ + "path":["data"] +} \ No newline at end of file diff --git a/src/client/css/style.css b/src/client/css/style.css new file mode 100644 index 0000000..257cfc8 --- /dev/null +++ b/src/client/css/style.css @@ -0,0 +1,9 @@ +body { + margin: 0; + padding: 0; + font-family: sans-serif; +} + +h1 { + text-decoration: underline; +} \ No newline at end of file diff --git a/src/client/js/app.tsx b/src/client/js/app.tsx new file mode 100644 index 0000000..53cc3fa --- /dev/null +++ b/src/client/js/app.tsx @@ -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( + , + document.getElementById("root") +) \ No newline at end of file diff --git a/src/client/js/hello.ts b/src/client/js/hello.ts new file mode 100644 index 0000000..480562d --- /dev/null +++ b/src/client/js/hello.ts @@ -0,0 +1,4 @@ +export default function (){ + console.log("hello"); + console.log("???"); +}; \ No newline at end of file diff --git a/src/client/js/test.tsx b/src/client/js/test.tsx new file mode 100644 index 0000000..20689d1 --- /dev/null +++ b/src/client/js/test.tsx @@ -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 (
+ +
+ setv(false)}> + {theme.direction === "ltr" ? : } + +
+ + + + +
NO
+
+
+
+
+

aaa{`a${v} ${classes.content}`}aaa

+ +
+
); +}; + +export default Headline; \ No newline at end of file diff --git a/src/client/js/util.ts b/src/client/js/util.ts new file mode 100644 index 0000000..57e3c6b --- /dev/null +++ b/src/client/js/util.ts @@ -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('&'); +} \ No newline at end of file diff --git a/src/config.ts b/src/config.ts new file mode 100644 index 0000000..435a4a6 --- /dev/null +++ b/src/config.ts @@ -0,0 +1,16 @@ +export namespace Knex { + export const config = { + development: { + client: 'sqlite3', + connection: { + filename: './devdb.sqlite3' + } + }, + production: { + client: 'sqlite3', + connection: { + database: './db.sqlite3', + }, + } + }; +} diff --git a/src/database.ts b/src/database.ts new file mode 100644 index 0000000..73e5186 --- /dev/null +++ b/src/database.ts @@ -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; +} \ No newline at end of file diff --git a/src/db/contents.ts b/src/db/contents.ts new file mode 100644 index 0000000..51b4ad1 --- /dev/null +++ b/src/db/contents.ts @@ -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( + 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{ + 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 & { 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({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); +} \ No newline at end of file diff --git a/src/db/mod.ts b/src/db/mod.ts new file mode 100644 index 0000000..c36784f --- /dev/null +++ b/src/db/mod.ts @@ -0,0 +1,3 @@ +export * from './contents'; +export * from './tag'; +export * from './user'; \ No newline at end of file diff --git a/src/db/tag.ts b/src/db/tag.ts new file mode 100644 index 0000000..e992c9c --- /dev/null +++ b/src/db/tag.ts @@ -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({ + 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); +} \ No newline at end of file diff --git a/src/db/user.ts b/src/db/user.ts new file mode 100644 index 0000000..1a2e529 --- /dev/null +++ b/src/db/user.ts @@ -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({ + 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, + }; +} \ No newline at end of file diff --git a/src/diff.ts b/src/diff.ts new file mode 100644 index 0000000..ad1adbd --- /dev/null +++ b/src/diff.ts @@ -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} = {}; +} \ No newline at end of file diff --git a/src/model/contents.ts b/src/model/contents.ts new file mode 100644 index 0000000..34cf15f --- /dev/null +++ b/src/model/contents.ts @@ -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(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, + /** + * @returns content if exist, otherwise undefined + */ + findById: (id:number,tagload?:boolean)=> Promise, + /** + * + */ + findListByBasePath:(basepath: string)=>Promise; + /** + * update content except tag. + */ + update:(c:Partial & { id:number })=>Promise; + /** + * add content + */ + add:(c:ContentContent)=>Promise; + /** + * delete content + * @returns if it exists, return true. + */ + del:(id:number)=>Promise; + /** + * @param c Valid Content + * @param tagname tag name to add + * @returns if success, return true + */ + addTag:(c:Content,tag_name:string)=>Promise; + /** + * @returns if success, return true + */ + delTag:(c:Content,tag_name:string)=>Promise; +}; \ No newline at end of file diff --git a/src/model/mod.ts b/src/model/mod.ts new file mode 100644 index 0000000..c36784f --- /dev/null +++ b/src/model/mod.ts @@ -0,0 +1,3 @@ +export * from './contents'; +export * from './tag'; +export * from './user'; \ No newline at end of file diff --git a/src/model/tag.ts b/src/model/tag.ts new file mode 100644 index 0000000..5071e8d --- /dev/null +++ b/src/model/tag.ts @@ -0,0 +1,12 @@ +export interface Tag{ + readonly name: string, + description?: string +} + +export interface TagAccessor{ + getTagAllList: (onlyname?:boolean)=> Promise + getTagByName: (name:string)=>Promise, + addTag: (tag:Tag)=>Promise, + delTag: (name:string) => Promise, + updateTag: (name:string,tag:string) => Promise +} diff --git a/src/model/user.ts b/src/model/user.ts new file mode 100644 index 0000000..02b3f4c --- /dev/null +++ b/src/model/user.ts @@ -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; + /** + * add permission + * @param name permission name to add + * @returns if `name` doesn't exist, return true + */ + add(name :string):Promise; + /** + * remove permission + * @param name permission name to remove + * @returns if `name` exist, return true + */ + remove(name :string):Promise; + /** + * reset password. + * @param password password to set + */ + reset_password(password: string):Promise; +}; + +export interface UserAccessor{ + /** + * create user + * @returns if user exist, return undefined + */ + createUser: (input :UserCreateInput)=> Promise, + /** + * find user + */ + findUser: (username: string)=> Promise, + /** + * remove user + * @returns if user exist, true + */ + delUser: (username: string)=>Promise +}; \ No newline at end of file diff --git a/src/render.ts b/src/render.ts new file mode 100644 index 0000000..3a076b3 --- /dev/null +++ b/src/render.ts @@ -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. + } +} \ No newline at end of file diff --git a/src/route/contents.ts b/src/route/contents.ts new file mode 100644 index 0000000..cf06b82 --- /dev/null +++ b/src/route/contents.ts @@ -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; \ No newline at end of file diff --git a/src/route/error_handler.ts b/src/route/error_handler.ts new file mode 100644 index 0000000..fa07782 --- /dev/null +++ b/src/route/error_handler.ts @@ -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; \ No newline at end of file diff --git a/src/route/util.ts b/src/route/util.ts new file mode 100644 index 0000000..0116694 --- /dev/null +++ b/src/route/util.ts @@ -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] +} \ No newline at end of file diff --git a/src/server.ts b/src/server.ts new file mode 100644 index 0000000..11091fa --- /dev/null +++ b/src/server.ts @@ -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(); \ No newline at end of file diff --git a/src/setting.ts b/src/setting.ts new file mode 100644 index 0000000..f11d26e --- /dev/null +++ b/src/setting.ts @@ -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; +} diff --git a/src/util/type_check.ts b/src/util/type_check.ts new file mode 100644 index 0000000..b85f915 --- /dev/null +++ b/src/util/type_check.ts @@ -0,0 +1,16 @@ +export function check_type(obj: any,check_proto:Record):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; +}; \ No newline at end of file diff --git a/src/util/ziputil.ts b/src/util/ziputil.ts new file mode 100644 index 0000000..3c54bd9 --- /dev/null +++ b/src/util/ziputil.ts @@ -0,0 +1,36 @@ +import StreamZip, { ZipEntry } from 'node-stream-zip'; +import {orderBy}from 'natural-orderby'; + +export async function readZip(path : string):Promise{ + 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{ + return new Promise((resolve,reject)=>{ + zip.stream(entry,(err, stream)=>{ + if(stream !== undefined){ + resolve(stream); + } + else{ + reject(err); + } + });} + ); +} \ No newline at end of file diff --git a/test.ts b/test.ts new file mode 100644 index 0000000..6c52fc4 --- /dev/null +++ b/test.ts @@ -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(); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..8773d4d --- /dev/null +++ b/tsconfig.json @@ -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","./"] +} diff --git a/webpack.config.js b/webpack.config.js new file mode 100644 index 0000000..75c6c1a --- /dev/null +++ b/webpack.config.js @@ -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 + } +};} \ No newline at end of file