add login

This commit is contained in:
monoid 2021-01-09 23:23:37 +09:00
parent f70cfd041a
commit a72225aff7
17 changed files with 273 additions and 82 deletions

View File

@ -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",

View File

@ -1,3 +1,8 @@
{
"path":["data"]
"path": [
"data"
],
"localmode": true,
"guest": false,
"jwt_secretkey": "itsRandom"
}

View File

@ -20,7 +20,7 @@ export class ClientContentAccessor implements ContentAccessor{
* not implement
*/
async findListByBasePath(basepath: string): Promise<Content[]>{
throw new Error("");
throw new Error("not implement");
return [];
}
async update(c: Partial<Content> & { id: number; }): Promise<boolean>{

View File

@ -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 = ()=><div>test profile</div>;
const App = () => {
const [path,setPath] = useState("/");
const [user,setUser] = useState("");
const [userPermission,setUserPermission] = useState<string[]>([]);
return (
<BackLinkContext.Provider value={{path:path,setPath:setPath}}>
<UserContext.Provider value={{
username:user,
setUsername:setUser,
permission:userPermission,
setPermission:setUserPermission}}>
<BrowserRouter>
<RouterSwitch>
<Route path="/" exact render={()=><Gallery />}></Route>
<Route path="/search" render={()=><Gallery />}></Route>
<Route path="/doc" render={(prop)=><ContentAbout {...prop}/>}></Route>
<Route path="/login" render={()=><LoginPage></LoginPage>}/>
<Route path="/profile" component={FooProfile}></Route>
<Route>
<div>404 Not Found</div>
<NotFoundPage/>
</Route>
</RouterSwitch>
</BrowserRouter>
</BackLinkContext.Provider>);
</UserContext.Provider>);
};
ReactDom.render(

View File

@ -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 = (<Menu
anchorEl={anchorEl}
anchorOrigin={{ horizontal: 'right', vertical: "top" }}

View File

@ -1,9 +1,10 @@
import {List, ListItem, ListItemIcon, Tooltip, ListItemText} from '@material-ui/core';
import React from 'react';
import {Link as RouterLink} from 'react-router-dom';
import {LocationDescriptorObject} from 'history';
import {List, ListItem, ListItemIcon, Tooltip, ListItemText} from '@material-ui/core';
import {ArrowBack as ArrowBackIcon} from '@material-ui/icons';
import {Link as RouterLink, useHistory} from 'react-router-dom';
export const NavItem = (props:{name:string,to:string, icon:React.ReactElement<any,any>})=>{
const history = useHistory();
return (<ListItem button key={props.name} component={RouterLink} to={props.to}>
<ListItemIcon>
<Tooltip title={props.name.toLocaleLowerCase()} placement="bottom">
@ -18,4 +19,8 @@ export const NavList = (props: {children?:React.ReactNode})=>{
return (<List>
{props.children}
</List>);
}
export const BackItem = (props:{to?:string})=>{
return <NavItem name="Back" to={props.to ?? "/"} icon={<ArrowBackIcon></ArrowBackIcon>}/>;
}

11
src/client/page/404.tsx Normal file
View File

@ -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 = (<NavList><BackItem to="/"/></NavList>);
return <Headline menu={menu}>
<Typography variant='h2'>404 Not Found</Typography>
</Headline>
};

View File

@ -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<ContentState>({ content: undefined, notfound:false });
const location = useLocation();
console.log("state : "+location.state);
const history = useHistory();
const menu_list = (link?:string)=>(
<NavList>
<BackLinkContext.Consumer>
{
(ctx) => link === undefined ?
<NavItem name="Back" to={ctx.path} icon={<ArrowBackIcon/>}/>
: <NavItem name="Back" to={link} icon={<ArrowBackIcon/>}/>
}
</BackLinkContext.Consumer>
<BackItem to={link}/>
</NavList>
);
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();

View File

@ -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 = (<NavList>
{Object.keys(query).length !== 0 && <NavItem name="Back" to="/" icon={<ArrowBackIcon></ArrowBackIcon>}></NavItem>}
{location.search !== "" && <><BackItem/> <Divider/></>}
<NavItem name="All" to="/" icon={<HomeIcon/>}/>
<NavItem name="Manga" to="/search?content_type=manga" icon={<CollectionIcon/>}></NavItem>
<NavItem name="Video" to="/search?content_type=video" icon={<VideoIcon/>}/>
<Divider/>
<NavItem name="Settings" to="/setting" icon={<SettingIcon/>}/>
</NavList>);
return (<Headline menu={menu_list}>

70
src/client/page/login.tsx Normal file
View File

@ -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 = (<MenuList>
</MenuList>);
return <Headline menu={menu}>
<Paper style={{ width: theme.spacing(40), padding: theme.spacing(4), alignSelf:'center'}}>
<Typography variant="h4">Login</Typography>
<div style={{minHeight:theme.spacing(2)}}></div>
<form style={{display:'flex', flexFlow:'column', alignItems:'stretch'}}>
<TextField label="username" onChange={(e)=>setUserLoginInfo({...userLoginInfo,username:e.target.value ?? "",})}></TextField>
<TextField label="password" type="password"
onChange={(e)=>setUserLoginInfo({...userLoginInfo,password:e.target.value ?? "",})}/>
<div style={{minHeight:theme.spacing(2)}}></div>
<div style={{display:'flex'}}>
<Button onClick={doLogin}>login</Button>
<Button>signin</Button>
</div>
</form>
</Paper>
<Dialog open={openDialog.open}
onClose={handleDialogClose}>
<DialogTitle>Login Failed</DialogTitle>
<DialogContent>
<DialogContentText>detail : {openDialog.message}</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={handleDialogClose} color="primary" autoFocus>Close</Button>
</DialogActions>
</Dialog>
</Headline>
}

View File

@ -1,2 +1,4 @@
export * from './contentinfo';
export * from './gallery';
export * from './gallery';
export * from './login';
export * from './404';

View File

@ -1,3 +1,8 @@
import React, { createContext, useRef, useState } from 'react';
export const BackLinkContext = createContext({path:"",setPath:(s:string)=>{}});
export const UserContext = createContext({
username:"",
permission:["openContent"],
setUsername:(s:string)=>{},
setPermission:(permission:string[])=>{}
});

View File

@ -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);
}

76
src/login.ts Normal file
View File

@ -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";
}

View File

@ -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;

View File

@ -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();

View File

@ -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;