This commit is contained in:
monoid 2020-12-31 03:06:16 +09:00
parent fd4c2ff0b0
commit c8c0f3e209
33 changed files with 1463 additions and 0 deletions

11
.babelrc Normal file
View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,3 @@
{
"path":["data"]
}

9
src/client/css/style.css Normal file
View 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
View 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
View File

@ -0,0 +1,4 @@
export default function (){
console.log("hello");
console.log("???");
};

88
src/client/js/test.tsx Normal file
View 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
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,3 @@
export * from './contents';
export * from './tag';
export * from './user';

50
src/db/tag.ts Normal file
View 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
View 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
View 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
View 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
View File

@ -0,0 +1,3 @@
export * from './contents';
export * from './tag';
export * from './user';

12
src/model/tag.ts Normal file
View 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
View 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
View 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
View 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;

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
}
};}