diff --git a/package.json b/package.json index c108138..7c417d3 100644 --- a/package.json +++ b/package.json @@ -11,11 +11,11 @@ "start": "ts-node src/server.ts", "check-types": "tsc" }, - "browserslist":{ - "production":[ + "browserslist": { + "production": [ "> 10%" ], - "development":[ + "development": [ "last 1 chrome version", "last 1 firefox version" ] @@ -25,6 +25,7 @@ "dependencies": { "@material-ui/core": "^4.11.2", "@material-ui/icons": "^4.11.2", + "jsonwebtoken": "^8.5.1", "knex": "^0.21.14", "koa": "^2.13.0", "koa-bodyparser": "^4.3.0", @@ -44,6 +45,7 @@ "@babel/preset-env": "^7.12.11", "@babel/preset-react": "^7.12.10", "@babel/preset-typescript": "^7.12.7", + "@types/jsonwebtoken": "^8.5.0", "@types/knex": "^0.16.1", "@types/koa": "^2.11.6", "@types/koa-bodyparser": "^4.3.0", diff --git a/settings.json b/settings.json index ef5107b..a34dd05 100644 --- a/settings.json +++ b/settings.json @@ -1,3 +1,8 @@ { - "path":["data"] + "path": [ + "data" + ], + "localmode": true, + "guest": false, + "jwt_secretkey": "itsRandom" } \ No newline at end of file diff --git a/src/client/accessor/contents.ts b/src/client/accessor/contents.ts index 98ace4e..f8014ea 100644 --- a/src/client/accessor/contents.ts +++ b/src/client/accessor/contents.ts @@ -20,7 +20,7 @@ export class ClientContentAccessor implements ContentAccessor{ * not implement */ async findListByBasePath(basepath: string): Promise{ - throw new Error(""); + throw new Error("not implement"); return []; } async update(c: Partial & { id: number; }): Promise{ diff --git a/src/client/app.tsx b/src/client/app.tsx index d96f9af..5b911b4 100644 --- a/src/client/app.tsx +++ b/src/client/app.tsx @@ -1,28 +1,34 @@ import React, { createContext, useRef, useState } from 'react'; import ReactDom from 'react-dom'; import {BrowserRouter, Route, Switch as RouterSwitch} from 'react-router-dom'; -import { Gallery, ContentAbout} from './page/mod'; -import {BackLinkContext} from './state'; +import { Gallery, ContentAbout, LoginPage, NotFoundPage} from './page/mod'; +import {UserContext} from './state'; import './css/style.css'; const FooProfile = ()=>
test profile
; const App = () => { - const [path,setPath] = useState("/"); + const [user,setUser] = useState(""); + const [userPermission,setUserPermission] = useState([]); return ( - + }> }> }> + }/> -
404 Not Found
+
-
); + ); }; ReactDom.render( diff --git a/src/client/component/headline.tsx b/src/client/component/headline.tsx index a1f4115..d5e8f5d 100644 --- a/src/client/component/headline.tsx +++ b/src/client/component/headline.tsx @@ -1,5 +1,5 @@ import ReactDom from 'react-dom'; -import React, { ReactNode, useState } from 'react'; +import React, { ReactNode, useContext, useState } from 'react'; import { Button, CssBaseline, Divider, IconButton, List, ListItem, Drawer, AppBar, Toolbar, Typography, InputBase, ListItemIcon, ListItemText, Menu, MenuItem, @@ -8,6 +8,7 @@ import { import { makeStyles, Theme, useTheme, fade } from '@material-ui/core/styles'; import { ChevronLeft, ChevronRight, Menu as MenuIcon, Search as SearchIcon, AccountCircle } from '@material-ui/icons'; import { Link as RouterLink, useRouteMatch } from 'react-router-dom'; +import { UserContext } from '../state'; const drawerWidth = 240; @@ -106,7 +107,6 @@ const useStyles = makeStyles((theme: Theme) => ({ export const Headline = (prop: { children?: React.ReactNode, - isLogin?: boolean, classes?:{ content?:string, toolbar?:string, @@ -122,7 +122,9 @@ export const Headline = (prop: { const handleProfileMenuClose = () => setAnchorEl(null); const isProfileMenuOpened = Boolean(anchorEl); const menuId = 'primary-search-account-menu'; - const isLogin = prop.isLogin || false; + const user_ctx = useContext(UserContext); + const isLogin = user_ctx.username !== ""; + const renderProfileMenu = (})=>{ + const history = useHistory(); return ( @@ -18,4 +19,8 @@ export const NavList = (props: {children?:React.ReactNode})=>{ return ( {props.children} ); +} + +export const BackItem = (props:{to?:string})=>{ + return }/>; } \ No newline at end of file diff --git a/src/client/page/404.tsx b/src/client/page/404.tsx new file mode 100644 index 0000000..a7e9672 --- /dev/null +++ b/src/client/page/404.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import {Typography} from '@material-ui/core'; +import {ArrowBack as ArrowBackIcon} from '@material-ui/icons'; +import { Headline, BackItem, NavList } from '../component/mod'; + +export const NotFoundPage = ()=>{ + const menu = (); + return + 404 Not Found + +}; \ No newline at end of file diff --git a/src/client/page/contentinfo.tsx b/src/client/page/contentinfo.tsx index a20aee9..b377e95 100644 --- a/src/client/page/contentinfo.tsx +++ b/src/client/page/contentinfo.tsx @@ -5,8 +5,7 @@ import { LoadingCircle } from '../component/loading'; import { Link, Paper, makeStyles, Theme, Box, useTheme, Typography } from '@material-ui/core'; import {ArrowBack as ArrowBackIcon } from '@material-ui/icons'; import { getPresenter } from './reader/reader'; -import { ContentInfo, Headline, NavItem, NavList } from '../component/mod'; -import {BackLinkContext} from '../state'; +import { BackItem, ContentInfo, Headline, NavItem, NavList } from '../component/mod'; export const makeContentInfoUrl = (id: number) => `/doc/${id}`; export const makeMangaReaderUrl = (id: number) => `/doc/${id}/reader`; @@ -35,29 +34,20 @@ export const ContentAbout = (prop: { match: MatchType }) => { } const id = Number.parseInt(match.params['id']); const [info, setInfo] = useState({ content: undefined, notfound:false }); - const location = useLocation(); - console.log("state : "+location.state); + const history = useHistory(); const menu_list = (link?:string)=>( - - { - (ctx) => link === undefined ? - }/> - : }/> - } - + ); useEffect(() => { (async () => { - console.log("mount content about"); if (!isNaN(id)) { const c = await ContentAccessor.findById(id); setInfo({ content: c, notfound: c === undefined }); } })() - return ()=>{console.log("unmount content about")} }, []); const classes = useStyles(); diff --git a/src/client/page/gallery.tsx b/src/client/page/gallery.tsx index fb26106..05b7a8e 100644 --- a/src/client/page/gallery.tsx +++ b/src/client/page/gallery.tsx @@ -1,19 +1,23 @@ -import React, { useContext } from 'react'; -import { NavList, NavItem, Headline } from '../component/mod'; -import {ArrowBack as ArrowBackIcon} from '@material-ui/icons'; +import React, { useContext, useEffect } from 'react'; +import { NavList, NavItem, Headline, BackItem } from '../component/mod'; +import {ArrowBack as ArrowBackIcon, Settings as SettingIcon, + Collections as CollectionIcon, VideoLibrary as VideoIcon, Home as HomeIcon} from '@material-ui/icons'; import {GalleryInfo} from '../component/mod'; -import {BackLinkContext} from '../state'; import {useLocation} from 'react-router-dom'; import { QueryStringToMap } from '../accessor/util'; +import { Divider } from '@material-ui/core'; export const Gallery = ()=>{ const location = useLocation(); - const backctx = useContext(BackLinkContext); - backctx.setPath("/"); - const query = QueryStringToMap(location.search); + const menu_list = ( - {Object.keys(query).length !== 0 && }>} + {location.search !== "" && <> } + }/> + }> + }/> + + }/> ); return ( diff --git a/src/client/page/login.tsx b/src/client/page/login.tsx new file mode 100644 index 0000000..8c9d42e --- /dev/null +++ b/src/client/page/login.tsx @@ -0,0 +1,70 @@ +import React, { useContext, useState } from 'react'; +import {Headline} from '../component/mod'; +import { Button, Dialog, DialogActions, DialogContent, DialogContentText, + DialogTitle, MenuList, Paper, TextField, Typography, useTheme } from '@material-ui/core'; +import { UserContext } from '../state'; +import { useHistory } from 'react-router-dom'; + +export const LoginPage = ()=>{ + const theme = useTheme(); + const [userLoginInfo,setUserLoginInfo]= useState({username:"",password:""}); + const [openDialog,setOpenDialog] = useState({open:false,message:""}); + const {username,setUsername,permission,setPermission} = useContext(UserContext); + const history = useHistory(); + const handleDialogClose = ()=>{ + setOpenDialog({...openDialog,open:false}); + } + const doLogin = async ()=>{ + const res = await fetch('/user/login',{ + method:'POST', + body:JSON.stringify(userLoginInfo), + headers:{"content-type":"application/json"} + }); + try{ + const b = await res.json(); + if(res.status !== 200){ + setOpenDialog({open:true,message: b.detail}); + return; + } + setUsername(b.username); + setPermission(b.permission); + } + catch(e){ + if(e instanceof Error){ + console.error(e); + setOpenDialog({open:true,message:e.message}); + } + else console.error(e); + return; + } + history.push("/"); + } + const menu = ( + ); + return + + Login +
+
+ setUserLoginInfo({...userLoginInfo,username:e.target.value ?? "",})}> + setUserLoginInfo({...userLoginInfo,password:e.target.value ?? "",})}/> +
+
+ + +
+ +
+ + Login Failed + + detail : {openDialog.message} + + + + + +
+} diff --git a/src/client/page/mod.ts b/src/client/page/mod.ts index 4d2c1fc..1ea5f24 100644 --- a/src/client/page/mod.ts +++ b/src/client/page/mod.ts @@ -1,2 +1,4 @@ export * from './contentinfo'; -export * from './gallery'; \ No newline at end of file +export * from './gallery'; +export * from './login'; +export * from './404'; diff --git a/src/client/state.tsx b/src/client/state.tsx index 6f31cf5..9cab4b4 100644 --- a/src/client/state.tsx +++ b/src/client/state.tsx @@ -1,3 +1,8 @@ import React, { createContext, useRef, useState } from 'react'; -export const BackLinkContext = createContext({path:"",setPath:(s:string)=>{}}); \ No newline at end of file +export const UserContext = createContext({ + username:"", + permission:["openContent"], + setUsername:(s:string)=>{}, + setPermission:(permission:string[])=>{} +}); \ No newline at end of file diff --git a/src/database.ts b/src/database.ts index e883d54..e2b90c3 100644 --- a/src/database.ts +++ b/src/database.ts @@ -8,7 +8,7 @@ export async function connectDB(){ if(env != "production" && env != "development"){ throw new Error("process unknown value in NODE_ENV: must be either \"development\" or \"production\""); } - const init_need = existsSync(config[env].connection.filename); + const init_need = !existsSync(config[env].connection.filename); const knex = Knex(config[env]); let tries = 0; for(;;){ @@ -30,6 +30,7 @@ export async function connectDB(){ break; } if(init_need){ + console.log("first execute: initialize database..."); const migrate = await import("../migrations/initial"); await migrate.up(knex); } diff --git a/src/login.ts b/src/login.ts new file mode 100644 index 0000000..c678ec5 --- /dev/null +++ b/src/login.ts @@ -0,0 +1,76 @@ +import {sign, decode, verify} from 'jsonwebtoken'; +import Koa from 'koa'; +import Router from 'koa-router'; +import { sendError } from './route/error_handler'; +import Knex from 'knex' +import { createKnexUserController } from './db/mod'; +import { request } from 'http'; +import { get_setting } from './setting'; +import { IUser } from './model/mod'; + +const loginTokenName = 'access_token' + +export const createLoginMiddleware = (knex: Knex)=>{ + const userController = createKnexUserController(knex); + return async (ctx: Koa.Context,next: Koa.Next)=>{ + const setting = get_setting(); + const secretKey = setting.jwt_secretkey; + const body = ctx.request.body; + if(!('username' in body)||!('password' in body)){ + sendError(400,"invalid form : username or password is not found in query."); + return; + } + const username = body['username']; + const password = body['password']; + if(typeof username !== "string" || typeof password !== "string"){ + sendError(400,"invalid form : username or password is not string") + return; + } + const user = await userController.findUser(username); + if(user === undefined){ + sendError(401,"not authorized"); + return; + } + if(!user.password.check_password(password)){ + sendError(401,"not authorized"); + return; + } + const userPermission = await user.get_permissions(); + const payload = sign({ + username: user.username, + permission: userPermission + },secretKey,{expiresIn:'3d'}); + ctx.cookies.set(loginTokenName,payload,{httpOnly:true, secure: !setting.localmode,sameSite:'strict'}); + ctx.body = {ok:true, username: user.username, permission: userPermission} + console.log(`${username} logined`); + return; + }; +}; +export const LogoutMiddleware = (ctx:Koa.Context,next:Koa.Next)=>{ + ctx.cookies.set(loginTokenName,undefined); + ctx.body = {ok:true}; + return; +} +export const UserMiddleWare = async (ctx:Koa.Context,next:Koa.Next)=>{ + const secretKey = get_setting().jwt_secretkey; + const payload = ctx.cookies.get(loginTokenName); + if(payload == undefined){ + ctx.state['user'] = undefined; + return await next(); + } + ctx.state['user'] = verify(payload,secretKey); + await next(); +} + +export const getAdmin = async(knex : Knex)=>{ + const cntr = createKnexUserController(knex); + const admin = await cntr.findUser("admin"); + if(admin === undefined){ + throw new Error("initial process failed!");//??? + } + return admin; +} + +export const isAdminFirst = (admin: IUser)=>{ + return admin.password.hash === "unchecked" && admin.password.salt === "unchecked"; +} \ No newline at end of file diff --git a/src/route/contents.ts b/src/route/contents.ts index 9ea88e4..7b1125a 100644 --- a/src/route/contents.ts +++ b/src/route/contents.ts @@ -34,6 +34,7 @@ const ContentQueryHandler = (controller : ContentAccessor) => async (ctx: Contex const limit = ParseQueryNumber(ctx.query['limit']); const cursor = ParseQueryNumber(ctx.query['cursor']); const word: string|undefined = ctx.query['word']; + const content_type:string|undefined = ctx.query['content_type']; const offset = ParseQueryNumber(ctx.query['offset']); if(limit === NaN || cursor === NaN || offset === NaN){ sendError(400,"parameter limit, cursor or offset is not a number"); @@ -51,7 +52,8 @@ const ContentQueryHandler = (controller : ContentAccessor) => async (ctx: Contex cursor: cursor, eager_loading: true, offset: offset, - use_offset: use_offset + use_offset: use_offset, + content_type:content_type, }; let content = await controller.findList(option); ctx.body = content; diff --git a/src/server.ts b/src/server.ts index 5a01c22..80445dd 100644 --- a/src/server.ts +++ b/src/server.ts @@ -11,56 +11,64 @@ import { createKnexContentsAccessor } from './db/contents'; import bodyparser from 'koa-bodyparser'; import {error_handler} from './route/error_handler'; +import {UserMiddleWare,createLoginMiddleware, isAdminFirst, getAdmin, LogoutMiddleware} from './login'; + +import {createInterface as createReadlineInterface} from 'readline'; + //let Koa = require("koa"); async function main(){ + let settings = get_setting(); + let db = await connectDB(); + const userAdmin = await getAdmin(db); + if(await isAdminFirst(userAdmin)){ + const rl = createReadlineInterface({input:process.stdin,output:process.stdout}); + rl.setPrompt("put admin password : "); + rl.prompt(); + const pw = await new Promise((res:(data:string)=>void,err)=>{ + rl.on('line',(data)=>res(data)); + }); + userAdmin.reset_password(pw); + } let app = new Koa(); app.use(bodyparser()); app.use(error_handler); + app.use(UserMiddleWare); + //app.use(ctx=>{ctx.state['setting'] = settings}); + const index_html = readFileSync("index.html"); 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 = 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('/dist/js/bundle.js.map',async (ctx,next)=>{ - ctx.type = "text"; - ctx.body = createReadStream("dist/js/bundle.js.map"); - }); - router.get('/doc/:rest(.*)' - ,async (ctx,next)=>{ - ctx.type = "html"; - ctx.body = index_html; + const serveindex = (url:string)=>{ + router.get(url, (ctx)=>{ctx.type = 'html'; ctx.body = index_html;}) } - ); - router.get('/search' - ,async (ctx,next)=>{ - ctx.type = "html"; - ctx.body = index_html; - } - ); - let content_router = getContentRouter(createKnexContentsAccessor(db)); + serveindex('/'); + serveindex('/doc/:rest(.*)'); + serveindex('/search'); + serveindex('/login'); + + const static_file_server = (path:string,type:string) => { + router.get('/'+path,async (ctx,next)=>{ + ctx.type = type; ctx.body = createReadStream(path); + })} + static_file_server('dist/css/style.css','css'); + static_file_server('dist/js/bundle.js','js'); + static_file_server('dist/js/bundle.js.map','text'); + + const content_router = getContentRouter(createKnexContentsAccessor(db)); router.use('/content',content_router.routes()); router.use('/content',content_router.allowedMethods()); + + router.post('/user/login',createLoginMiddleware(db)); + router.post('/user/logout',LogoutMiddleware); - let mm_count=0; + let mm_count = 0; app.use(async (ctx,next)=>{ console.log(`==========================${mm_count++}`); - console.log(`connect ${ctx.ip} : ${ctx.method} ${ctx.url}`); + const fromClient = ctx.state['user'] === undefined ? ctx.ip : ctx.state['user'].username; + console.log(`${fromClient} : ${ctx.method} ${ctx.url}`); await next(); //console.log(`404`); }); @@ -68,7 +76,7 @@ async function main(){ app.use(router.allowedMethods()); console.log("start server"); - app.listen(8080,"0.0.0.0"); + app.listen(8080,settings.localmode ? "127.0.0.1" : "0.0.0.0"); return app; } main(); \ No newline at end of file diff --git a/src/setting.ts b/src/setting.ts index f8536e7..acd59c2 100644 --- a/src/setting.ts +++ b/src/setting.ts @@ -1,26 +1,28 @@ import { Settings } from '@material-ui/icons'; +import { randomBytes } from 'crypto'; import { readFileSync, writeFileSync } from 'fs'; export type Setting = { path: string[], - initial_admin_password:string, localmode: boolean, guest: boolean, + jwt_secretkey: string } const default_setting:Setting = { path:[], - initial_admin_password:"admin", localmode: true, guest:false, + jwt_secretkey:"itsRandom", } let setting: null|Setting = null; -const setEmptyToDefault = (target:any,default_table:any)=>{ + +const setEmptyToDefault = (target:any,default_table:Setting)=>{ let diff_occur = false; for(const key in default_table){ if(key === undefined || key in target){ continue; } - target[key] = default_table[key]; + target[key] = default_table[key as keyof Setting]; diff_occur = true; } return diff_occur; @@ -28,8 +30,8 @@ const setEmptyToDefault = (target:any,default_table:any)=>{ export const read_setting_from_file = ()=>{ let ret = JSON.parse(readFileSync("settings.json",{encoding:"utf8"})) as Setting; - const diff_occur = setEmptyToDefault(ret,default_setting); - if(diff_occur){ + const partial_occur = setEmptyToDefault(ret,default_setting); + if(partial_occur){ writeFileSync("settings.json",JSON.stringify(ret)); } return ret;