Compare commits

..

No commits in common. "5670a12910f4c140d826ce43903a8197440c1967" and "f24c0d207861b9afcba7c5c2e092b404e4dec404" have entirely different histories.

85 changed files with 3596 additions and 4239 deletions

View File

@ -4,27 +4,23 @@ Content File Management Program.
For study about nodejs, typescript and react.
### deployment
```bash
pnpm run app:build
```
$ npm run app:build
```
### test
```bash
$ pnpm run app
```
$ npm run app
```
### server build
```bash
$ pnpm run compile
```
$ npm run compile
```
### client build
```bash
$ pnpm run build
```
$ npm run build
```
## License

30
app.ts
View File

@ -1,13 +1,13 @@
import { app, BrowserWindow, dialog, session } from "electron";
import { ipcMain } from "electron";
import { join } from "path";
import { accessTokenName, getAdminAccessTokenValue, getAdminRefreshTokenValue, refreshTokenName } from "./src/login";
import { UserAccessor } from "./src/model/mod";
import { create_server } from "./src/server";
import { app, BrowserWindow, session, dialog } from "electron";
import { get_setting } from "./src/SettingConfig";
import { create_server } from "./src/server";
import { getAdminAccessTokenValue,getAdminRefreshTokenValue, accessTokenName, refreshTokenName } from "./src/login";
import { join } from "path";
import { ipcMain } from 'electron';
import { UserAccessor } from "./src/model/mod";
function registerChannel(cntr: UserAccessor){
ipcMain.handle("reset_password", async (event, username: string, password: string) => {
ipcMain.handle('reset_password', async(event,username:string,password:string)=>{
const user = await cntr.findUser(username);
if(user === undefined){
return false;
@ -27,11 +27,11 @@ if (!setting.cli) {
center: true,
useContentSize: true,
webPreferences:{
preload: join(__dirname, "preload.js"),
preload:join(__dirname,'preload.js'),
contextIsolation:true,
},
}
});
await wnd.loadURL(`data:text/html;base64,` + Buffer.from(loading_html).toString("base64"));
await wnd.loadURL(`data:text/html;base64,`+Buffer.from(loading_html).toString('base64'));
//await wnd.loadURL('../loading.html');
//set admin cookies.
await session.defaultSession.cookies.set({
@ -40,7 +40,7 @@ if (!setting.cli) {
value:getAdminAccessTokenValue(),
httpOnly: true,
secure: false,
sameSite: "strict",
sameSite:"strict"
});
await session.defaultSession.cookies.set({
url:`http://localhost:${setting.port}`,
@ -48,21 +48,23 @@ if (!setting.cli) {
value:getAdminRefreshTokenValue(),
httpOnly: true,
secure: false,
sameSite: "strict",
sameSite:"strict"
});
try{
const server = await create_server();
const app = server.start_server();
registerChannel(server.userController);
await wnd.loadURL(`http://localhost:${setting.port}`);
} catch (e) {
}
catch(e){
if(e instanceof Error){
await dialog.showMessageBox({
type: "error",
title:"error!",
message:e.message,
});
} else {
}
else{
await dialog.showMessageBox({
type: "error",
title:"error!",

View File

@ -1,23 +0,0 @@
{
"incremental": true,
"typescript": {
"indentWidth": 2
},
"json": {
},
"markdown": {
},
"includes": ["**/*.{ts,tsx,js,jsx,cjs,mjs,json,md}"],
"excludes": [
"**/node_modules",
"**/*-lock.json",
"**/dist",
"build/",
"app/"
],
"plugins": [
"https://plugins.dprint.dev/typescript-0.84.4.wasm",
"https://plugins.dprint.dev/json-0.17.2.wasm",
"https://plugins.dprint.dev/markdown-0.15.2.wasm"
]
}

View File

@ -1,13 +1,13 @@
import { promises } from "fs";
import { promises } from 'fs';
const { readdir, writeFile } = promises;
import { dirname, join } from "path";
import { createGenerator } from "ts-json-schema-generator";
import {createGenerator} from 'ts-json-schema-generator';
import {dirname,join} from 'path';
async function genSchema(path:string,typename:string){
const gen = createGenerator({
path:path,
type:typename,
tsconfig: "tsconfig.json",
tsconfig:"tsconfig.json"
});
const schema = gen.createSchema(typename);
if(schema.definitions != undefined){
@ -16,8 +16,8 @@ async function genSchema(path: string, typename: string) {
if(typeof definition == "object" ){
let property = definition.properties;
if(property){
property["$schema"] = {
type: "string",
property['$schema'] = {
type:"string"
};
}
}
@ -29,7 +29,7 @@ function capitalize(s: string) {
return s.charAt(0).toUpperCase() + s.slice(1);
}
async function setToALL(path:string) {
console.log(`scan ${path}`);
console.log(`scan ${path}`)
const direntry = await readdir(path,{withFileTypes:true});
const works = direntry.filter(x=>x.isFile()&&x.name.endsWith("Config.ts")).map(x=>{
const name = x.name;
@ -38,11 +38,11 @@ async function setToALL(path: string) {
const typename = m[1];
return genSchema(join(path,typename),capitalize(typename));
}
});
})
await Promise.all(works);
const subdir = direntry.filter(x=>x.isDirectory()).map(x=>x.name);
for(const x of subdir){
await setToALL(join(path,x));
}
}
setToALL("src");
setToALL("src")

View File

@ -1,5 +1,5 @@
require("ts-node").register();
const { Knex } = require("./src/config");
require('ts-node').register();
const {Knex} = require('./src/config');
// Update with your config settings.
module.exports = Knex.config;

View File

@ -1,4 +1,4 @@
import { Knex } from "knex";
import {Knex} from 'knex';
export async function up(knex:Knex) {
await knex.schema.createTable("schema_migration",(b)=>{
@ -36,19 +36,19 @@ export async function up(knex: Knex) {
b.primary(["doc_id","tag_name"]);
});
await knex.schema.createTable("permissions",b=>{
b.string("username").notNullable();
b.string('username').notNullable();
b.string("name").notNullable();
b.primary(["username","name"]);
b.foreign("username").references("users.username");
b.foreign('username').references('users.username');
});
//create admin account.
await knex.insert({
username:"admin",
password_hash:"unchecked",
password_salt: "unchecked",
}).into("users");
}
password_salt:"unchecked"
}).into('users');
};
export async function down(knex:Knex) {
throw new Error("Downward migrations are not supported. Restore from backup.");
}
throw new Error('Downward migrations are not supported. Restore from backup.');
};

View File

@ -6,9 +6,8 @@
"scripts": {
"compile": "tsc",
"compile:watch": "tsc -w",
"build": "cd src/client && pnpm run build:prod",
"build:watch": "cd src/client && pnpm run build:watch",
"fmt": "dprint fmt",
"build": "cd src/client && npm run build:prod",
"build:watch": "cd src/client && npm run build:watch",
"app": "electron build/app.js",
"app:build": "electron-builder",
"app:pack": "electron-builder --dir",
@ -57,7 +56,6 @@
"@louislam/sqlite3": "^6.0.1",
"@types/koa-compose": "^3.2.5",
"chokidar": "^3.5.3",
"dprint": "^0.36.1",
"jsonschema": "^1.4.1",
"jsonwebtoken": "^8.5.1",
"knex": "^0.95.15",

View File

@ -3,7 +3,6 @@
## Routing
### server routing
- content
- \d+
- comic
@ -32,7 +31,6 @@
- profile
## TODO
- server push
- ~~permission~~
- diff

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,7 @@
import { contextBridge, ipcRenderer } from "electron";
import {ipcRenderer, contextBridge} from 'electron';
contextBridge.exposeInMainWorld("electron", {
contextBridge.exposeInMainWorld('electron',{
passwordReset:async (username:string,toPw:string)=>{
return await ipcRenderer.invoke("reset_password", username, toPw);
},
return await ipcRenderer.invoke('reset_password',username,toPw);
}
});

View File

@ -1,38 +1,38 @@
import { randomBytes } from "crypto";
import { existsSync, readFileSync, writeFileSync } from "fs";
import { Permission } from "./permission/permission";
import { randomBytes } from 'crypto';
import { existsSync, readFileSync, writeFileSync } from 'fs';
import { Permission } from './permission/permission';
export interface SettingConfig {
/**
* if true, server will bind on '127.0.0.1' rather than '0.0.0.0'
*/
localmode: boolean;
localmode: boolean,
/**
* secure only
*/
secure: boolean;
secure: boolean,
/**
* guest permission
*/
guest: (Permission)[];
guest: (Permission)[],
/**
* JWT secret key. if you change its value, all access tokens are invalidated.
*/
jwt_secretkey: string;
jwt_secretkey: string,
/**
* the port which running server is binding on.
*/
port: number;
port:number,
mode: "development" | "production";
mode:"development"|"production",
/**
* if true, do not show 'electron' window and show terminal only.
*/
cli: boolean;
cli:boolean,
/** forbid to login admin from remote client. but, it do not invalidate access token.
* if you want to invalidate access token, change 'jwt_secretkey'.*/
forbid_remote_admin_login: boolean;
forbid_remote_admin_login:boolean,
}
const default_setting:SettingConfig = {
localmode: true,
@ -43,7 +43,7 @@ const default_setting: SettingConfig = {
mode:"production",
cli:false,
forbid_remote_admin_login:true,
};
}
let setting: null|SettingConfig = null;
const setEmptyToDefault = (target:any,default_table:SettingConfig)=>{
@ -56,7 +56,7 @@ const setEmptyToDefault = (target: any, default_table: SettingConfig) => {
diff_occur = true;
}
return diff_occur;
};
}
export const read_setting_from_file = ()=>{
let ret = existsSync("settings.json") ? JSON.parse(readFileSync("settings.json",{encoding:"utf8"})) : {};
@ -65,7 +65,7 @@ export const read_setting_from_file = () => {
writeFileSync("settings.json",JSON.stringify(ret));
}
return ret as SettingConfig;
};
}
export function get_setting():SettingConfig{
if(setting === null){
setting = read_setting_from_file();

View File

@ -1,5 +1,5 @@
import {Document, DocumentAccessor, DocumentBody, QueryListOption} from "../../model/doc";
import { toQueryString } from "./util";
import {toQueryString} from './util';
const baseurl = "/api/doc";
export * from "../../model/doc";
@ -11,20 +11,20 @@ export class ClientDocumentAccessor implements DocumentAccessor {
addList: (content_list: DocumentBody[]) => Promise<number[]>;
async findByPath(basepath: string, filename?: string): Promise<Document[]>{
throw new Error("not allowed");
}
};
async findDeleted(content_type: string): Promise<Document[]>{
throw new Error("not allowed");
}
};
async findList(option?: QueryListOption | undefined): Promise<Document[]>{
let res = await fetch(`${baseurl}/search?${option !== undefined ? toQueryString(option) : ""}`);
if (res.status == 401) throw new FetchFailError("Unauthorized");
if(res.status == 401) throw new FetchFailError("Unauthorized")
if(res.status !== 200) throw new FetchFailError("findList Failed");
let ret = await res.json();
return ret;
}
async findById(id: number, tagload?: boolean | undefined): Promise<Document | undefined>{
let res = await fetch(`${baseurl}/${id}`);
if (res.status !== 200) throw new FetchFailError("findById Failed");
if(res.status !== 200) throw new FetchFailError("findById Failed");;
let ret = await res.json();
return ret;
}
@ -35,14 +35,14 @@ export class ClientDocumentAccessor implements DocumentAccessor {
throw new Error("not implement");
return [];
}
async update(c: Partial<Document> & { id: number }): Promise<boolean> {
async update(c: Partial<Document> & { id: number; }): Promise<boolean>{
const {id,...rest} = c;
const res = await fetch(`${baseurl}/${id}`,{
method: "POST",
body: JSON.stringify(rest),
headers:{
"content-type": "application/json",
},
'content-type':"application/json"
}
});
const ret = await res.json();
return ret;
@ -53,15 +53,15 @@ export class ClientDocumentAccessor implements DocumentAccessor {
method: "POST",
body: JSON.stringify(c),
headers:{
"content-type": "application/json",
},
'content-type':"application/json"
}
});
const ret = await res.json();
return ret;
}
async del(id: number): Promise<boolean>{
const res = await fetch(`${baseurl}/${id}`,{
method: "DELETE",
method: "DELETE"
});
const ret = await res.json();
return ret;
@ -72,8 +72,8 @@ export class ClientDocumentAccessor implements DocumentAccessor {
method: "POST",
body: JSON.stringify(rest),
headers:{
"content-type": "application/json",
},
'content-type':"application/json"
}
});
const ret = await res.json();
return ret;
@ -84,16 +84,16 @@ export class ClientDocumentAccessor implements DocumentAccessor {
method: "DELETE",
body: JSON.stringify(rest),
headers:{
"content-type": "application/json",
},
'content-type':"application/json"
}
});
const ret = await res.json();
return ret;
}
}
export const CDocumentAccessor = new ClientDocumentAccessor();
export const CDocumentAccessor = new ClientDocumentAccessor;
export const makeThumbnailUrl = (x: Document)=>{
return `${baseurl}/${x.id}/${x.content_type}/thumbnail`;
};
}
export default CDocumentAccessor;

View File

@ -1,19 +1,20 @@
type Representable = string|number|boolean;
type ToQueryStringA = {
[name: string]: Representable | Representable[] | undefined;
[name:string]:Representable|Representable[]|undefined
};
export const toQueryString = (obj:ToQueryStringA)=> {
return Object.entries(obj)
.filter((e): e is [string, Representable | Representable[]] => e[1] !== undefined)
.filter((e): e is [string,Representable|Representable[]] =>
e[1] !== undefined)
.map(e =>
e[1] instanceof Array
? e[1].map(f => `${e[0]}=${(f)}`).join("&")
: `${e[0]}=${(e[1])}`
)
.join("&");
};
? e[1].map(f=>`${e[0]}=${(f)}`).join('&')
: `${e[0]}=${(e[1])}`)
.join('&');
}
export const QueryStringToMap = (query:string) =>{
const keyValue = query.slice(query.indexOf("?")+1).split("&");
const param:{[k:string]:string|string[]} = {};
@ -22,11 +23,13 @@ export const QueryStringToMap = (query: string) => {
const pv = param[k];
if(pv === undefined){
param[k] = v;
} else if (typeof pv === "string") {
}
else if(typeof pv === "string"){
param[k] = [pv,v];
} else {
}
else{
pv.push(v);
}
});
return param;
};
}

View File

@ -1,21 +1,21 @@
import { createTheme, ThemeProvider } from "@mui/material";
import React, { createContext, useEffect, useRef, useState } from "react";
import ReactDom from "react-dom";
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
import React, { createContext, useEffect, useRef, useState } from 'react';
import ReactDom from 'react-dom';
import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom';
import {
DifferencePage,
DocumentAbout,
Gallery,
DocumentAbout,
LoginPage,
NotFoundPage,
ProfilePage,
ReaderPage,
DifferencePage,
SettingPage,
TagsPage,
} from "./page/mod";
import { getInitialValue, UserContext } from "./state";
ReaderPage,
TagsPage
} from './page/mod';
import { getInitialValue, UserContext } from './state';
import { ThemeProvider, createTheme } from '@mui/material';
import "./css/style.css";
import './css/style.css';
const theme = createTheme();
@ -31,18 +31,16 @@ const App = () => {
})();
//useEffect(()=>{});
return (
<UserContext.Provider
value={{
<UserContext.Provider value={{
username: user,
setUsername: setUser,
permission: userPermission,
setPermission: setUserPermission,
}}
>
setPermission: setUserPermission
}}>
<ThemeProvider theme={theme}>
<BrowserRouter>
<Routes>
<Route path="/" element={<Navigate replace to="/search?" />} />
<Route path="/" element={<Navigate replace to='/search?' />} />
<Route path="/search" element={<Gallery />} />
<Route path="/doc/:id" element={<DocumentAbout />}></Route>
<Route path="/doc/:id/reader" element={<ReaderPage />}></Route>
@ -55,11 +53,10 @@ const App = () => {
</Routes>
</BrowserRouter>
</ThemeProvider>
</UserContext.Provider>
);
</UserContext.Provider>);
};
ReactDom.render(
<App />,
document.getElementById("root"),
document.getElementById("root")
);

View File

@ -1,24 +1,25 @@
import esbuild from "esbuild";
import esbuild from 'esbuild';
async function main() {
try {
const result = await esbuild.build({
entryPoints: ["app.tsx"],
entryPoints: ['app.tsx'],
bundle: true,
outfile: "../../dist/bundle.js",
platform: "browser",
outfile: '../../dist/bundle.js',
platform: 'browser',
sourcemap: true,
minify: true,
target: ["chrome100", "firefox100"],
target: ['chrome100', 'firefox100'],
watch: {
onRebuild: async (err, _result) => {
if (err) {
console.error("watch build failed: ", err);
} else {
console.log("watch build success");
console.error('watch build failed: ',err);
}
else{
console.log('watch build success');
}
}
}
},
},
});
console.log("watching...");
return result;

View File

@ -1,27 +1,27 @@
import React, {} from "react";
import { Link as RouterLink } from "react-router-dom";
import { Document } from "../accessor/document";
import React, { } from 'react';
import { Link as RouterLink } from 'react-router-dom';
import { Document } from '../accessor/document';
import { Box, Button, Grid, Link, Paper, Theme, Typography, useTheme } from "@mui/material";
import { TagChip } from "../component/tagchip";
import { ThumbnailContainer } from "../page/reader/reader";
import { Link, Paper, Theme, Box, useTheme, Typography, Grid, Button } from '@mui/material';
import { ThumbnailContainer } from '../page/reader/reader';
import { TagChip } from '../component/tagchip';
import DocumentAccessor from "../accessor/document";
import DocumentAccessor from '../accessor/document';
export const makeContentInfoUrl = (id: number) => `/doc/${id}`;
export const makeContentReaderUrl = (id: number) => `/doc/${id}/reader`;
const useStyles = (theme: Theme) => ({
const useStyles = ((theme: Theme) => ({
thumbnail_content: {
maxHeight: "400px",
maxWidth: "min(400px, 100vw)",
maxHeight: '400px',
maxWidth: 'min(400px, 100vw)',
},
tag_list: {
display: "flex",
justifyContent: "flex-start",
flexWrap: "wrap",
overflowY: "hidden",
"& > *": {
display: 'flex',
justifyContent: 'flex-start',
flexWrap: 'wrap',
overflowY: 'hidden',
'& > *': {
margin: theme.spacing(0.5),
},
},
@ -32,125 +32,107 @@ const useStyles = (theme: Theme) => ({
padding: theme.spacing(2),
},
subinfoContainer: {
display: "grid",
gridTemplateColumns: "100px auto",
overflowY: "hidden",
alignItems: "baseline",
display: 'grid',
gridTemplateColumns: '100px auto',
overflowY: 'hidden',
alignItems: 'baseline',
},
short_subinfoContainer: {
[theme.breakpoints.down("md")]: {
display: "none",
display: 'none',
},
},
short_root: {
overflowY: "hidden",
display: "flex",
flexDirection: "column",
overflowY: 'hidden',
display: 'flex',
flexDirection: 'column',
[theme.breakpoints.up("sm")]: {
height: 200,
flexDirection: "row",
flexDirection: 'row',
},
},
short_thumbnail_anchor: {
background: "#272733",
display: "flex",
alignItems: "center",
justifyContent: "center",
background: '#272733',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
[theme.breakpoints.up("sm")]: {
width: theme.spacing(25),
height: theme.spacing(25),
flexShrink: 0,
},
}
},
short_thumbnail_content: {
maxWidth: "100%",
maxHeight: "100%",
maxWidth: '100%',
maxHeight: '100%',
},
});
}))
export const ContentInfo = (props: {
document: Document;
children?: React.ReactNode;
classes?: {
root?: string;
thumbnail_anchor?: string;
thumbnail_content?: string;
tag_list?: string;
title?: string;
infoContainer?: string;
subinfoContainer?: string;
};
gallery?: string;
short?: boolean;
document: Document, children?: React.ReactNode, classes?: {
root?: string,
thumbnail_anchor?: string,
thumbnail_content?: string,
tag_list?: string,
title?: string,
infoContainer?: string,
subinfoContainer?: string
},
gallery?: string,
short?: boolean
}) => {
//const classes = useStyles();
const theme = useTheme();
const document = props.document;
/*const rootName = props.short ? classes.short_root : classes.root;
const thumbnail_anchor = props.short ? classes.short_thumbnail_anchor : "";
const thumbnail_content = props.short ? classes.short_thumbnail_content :
classes.thumbnail_content;
const subinfoContainer = props.short ? classes.short_subinfoContainer :
classes.subinfoContainer;*/
const url = props.gallery === undefined ? makeContentReaderUrl(document.id) : makeContentInfoUrl(document.id);
return (
<Paper
sx={{
return (<Paper sx={{
display: "flex",
height: "400px",
[theme.breakpoints.down("sm")]: {
flexDirection: "column",
alignItems: "center",
height: "auto",
},
}}
elevation={4}
>
<Link
component={RouterLink}
to={{
pathname: makeContentReaderUrl(document.id),
}}
>
{document.deleted_at === null
? <ThumbnailContainer content={document} />
: <Typography variant="h4">Deleted</Typography>}
}
}} elevation={4}>
<Link /*className={propclasses.thumbnail_anchor ?? thumbnail_anchor}*/ component={RouterLink} to={{
pathname: makeContentReaderUrl(document.id)
}}>
{document.deleted_at === null ?
(<ThumbnailContainer content={document}/>)
: (<Typography/* className={propclasses.thumbnail_content ?? thumbnail_content} */ variant='h4'>Deleted</Typography>)}
</Link>
<Box>
<Link variant="h5" color="inherit" component={RouterLink} to={{ pathname: url }}>
<Box /*className={propclasses.infoContainer ?? classes.infoContainer}*/>
<Link variant='h5' color='inherit' component={RouterLink} to={{pathname: url}}
/*className={propclasses.title ?? classes.title}*/>
{document.title}
</Link>
<Box>
{props.short
? (
<Box>
{document.tags.map(x => (
<TagChip key={x} label={x} clickable tagname={x} size="small"></TagChip>
))}
</Box>
)
: (
<ComicDetailTag
tags={document.tags}
path={document.basepath + "/" + document.filename}
<Box /*className={propclasses.subinfoContainer ?? subinfoContainer}*/>
{props.short ? (<Box /*className={propclasses.tag_list ?? classes.tag_list}*/>{document.tags.map(x =>
(<TagChip key={x} label={x} clickable tagname={x} size="small"></TagChip>)
)}</Box>) : (
<ComicDetailTag tags={document.tags} path={document.basepath+"/"+document.filename}
createdAt={document.created_at}
deletedAt={document.deleted_at != null ? document.deleted_at : undefined}
>
</ComicDetailTag>
)}
/* classes={({tag_list:classes.tag_list})}*/ ></ComicDetailTag>)
}
</Box>
{document.deleted_at != null
&& (
<Button
onClick={() => {
documentDelete(document.id);
}}
>
Delete
</Button>
)}
{document.deleted_at != null &&
<Button onClick={()=>{documentDelete(document.id);}}>Delete</Button>
}
</Box>
</Paper>
);
};
</Paper>);
}
async function documentDelete(id: number){
const t = await DocumentAccessor.del(id);
if(t){
alert("document deleted!");
} else {
}
else{
alert("document already deleted.");
}
}
@ -171,54 +153,40 @@ function ComicDetailTag(prop: {
tagTable[kind] = tags;
allTag = allTag.filter(x => !x.startsWith(kind + ":"));
}
return (
<Grid container>
return (<Grid container>
{tagKind.map(key => (
<React.Fragment key={key}>
<Grid item xs={3}>
<Typography variant="subtitle1">{key}</Typography>
<Typography variant='subtitle1'>{key}</Typography>
</Grid>
<Grid item xs={9}>
<Box>{tagTable[key].length !== 0 ? tagTable[key].join(", ") : "N/A"}</Box>
</Grid>
</React.Fragment>
))}
{prop.path != undefined && (
<>
<Grid item xs={3}>
<Typography variant="subtitle1">Path</Typography>
</Grid>
<Grid item xs={9}>
{ prop.path != undefined && <><Grid item xs={3}>
<Typography variant='subtitle1'>Path</Typography>
</Grid><Grid item xs={9}>
<Box>{prop.path}</Box>
</Grid>
</>
)}
{prop.createdAt != undefined && (
<>
<Grid item xs={3}>
<Typography variant="subtitle1">CreatedAt</Typography>
</Grid>
<Grid item xs={9}>
<Box>{new Date(prop.createdAt).toUTCString()}</Box>
</Grid>
</>
)}
{prop.deletedAt != undefined && (
<>
<Grid item xs={3}>
<Typography variant="subtitle1">DeletedAt</Typography>
</Grid>
<Grid item xs={9}>
<Box>{new Date(prop.deletedAt).toUTCString()}</Box>
</Grid>
</>
)}
<Grid item xs={3}>
<Typography variant="subtitle1">Tags</Typography>
</Grid>
<Grid item xs={9}>
{allTag.map(x => <TagChip key={x} label={x} clickable tagname={x} size="small"></TagChip>)}
</Grid>
</Grid>
);
</Grid></>
}
{ prop.createdAt != undefined && <><Grid item xs={3}>
<Typography variant='subtitle1'>CreatedAt</Typography>
</Grid><Grid item xs={9}>
<Box>{new Date(prop.createdAt).toUTCString()}</Box>
</Grid></>
}
{ prop.deletedAt != undefined && <><Grid item xs={3}>
<Typography variant='subtitle1'>DeletedAt</Typography>
</Grid><Grid item xs={9}>
<Box>{new Date(prop.deletedAt).toUTCString()}</Box>
</Grid></>
}
<Grid item xs={3}>
<Typography variant='subtitle1'>Tags</Typography>
</Grid>
<Grid item xs={9}>
{allTag.map(x => (<TagChip key={x} label={x} clickable tagname={x} size="small"></TagChip>))}
</Grid>
</Grid>);
}

View File

@ -1,35 +1,21 @@
import { AccountCircle, ChevronLeft, ChevronRight, Menu as MenuIcon, Search as SearchIcon } from "@mui/icons-material";
import React, { useContext, useState } from 'react';
import {
AppBar,
Button,
CssBaseline,
Divider,
Drawer,
Hidden,
IconButton,
InputBase,
Link,
List,
ListItem,
ListItemIcon,
ListItemText,
Menu,
MenuItem,
styled,
Toolbar,
Tooltip,
Typography,
} from "@mui/material";
import { alpha, Theme, useTheme } from "@mui/material/styles";
import React, { useContext, useState } from "react";
Button, CssBaseline, Divider, IconButton, List, ListItem, Drawer,
AppBar, Toolbar, Typography, InputBase, ListItemIcon, ListItemText, Menu, MenuItem,
Hidden, Tooltip, Link, styled
} from '@mui/material';
import { alpha, Theme, useTheme } from '@mui/material/styles';
import {
ChevronLeft, ChevronRight, Menu as MenuIcon, Search as SearchIcon, AccountCircle
} from '@mui/icons-material';
import { Link as RouterLink, useNavigate } from "react-router-dom";
import { doLogout, UserContext } from "../state";
import { Link as RouterLink, useNavigate } from 'react-router-dom';
import { doLogout, UserContext } from '../state';
const drawerWidth = 270;
const DrawerHeader = styled("div")(({ theme }) => ({
...theme.mixins.toolbar,
const DrawerHeader = styled('div')(({ theme }) => ({
...theme.mixins.toolbar
}));
const StyledDrawer = styled(Drawer)(({ theme }) => ({
@ -38,56 +24,51 @@ const StyledDrawer = styled(Drawer)(({ theme }) => ({
[theme.breakpoints.up("sm")]: {
width: drawerWidth,
},
}));
const StyledSearchBar = styled("div")(({ theme }) => ({
position: "relative",
}
));
const StyledSearchBar = styled('div')(({ theme }) => ({
position: 'relative',
borderRadius: theme.shape.borderRadius,
backgroundColor: alpha(theme.palette.common.white, 0.15),
"&:hover": {
'&:hover': {
backgroundColor: alpha(theme.palette.common.white, 0.25),
},
marginLeft: 0,
width: "100%",
[theme.breakpoints.up("sm")]: {
width: '100%',
[theme.breakpoints.up('sm')]: {
marginLeft: theme.spacing(1),
width: "auto",
width: 'auto',
},
}));
const StyledInputBase = styled(InputBase)(({ theme }) => ({
color: "inherit",
"& .MuiInputBase-input": {
color: 'inherit',
'& .MuiInputBase-input': {
padding: theme.spacing(1, 1, 1, 0),
// vertical padding + font size from searchIcon
paddingLeft: `calc(1em + ${theme.spacing(4)})`,
transition: theme.transitions.create("width"),
width: "100%",
[theme.breakpoints.up("sm")]: {
width: "12ch",
"&:focus": {
width: "20ch",
transition: theme.transitions.create('width'),
width: '100%',
[theme.breakpoints.up('sm')]: {
width: '12ch',
'&:focus': {
width: '20ch',
},
},
},
}));
const StyledNav = styled("nav")(({ theme }) => ({
[theme.breakpoints.up("sm")]: {
width: theme.spacing(7),
},
}));
const closedMixin = (theme: Theme) => ({
overflowX: "hidden",
overflowX: 'hidden',
width: `calc(${theme.spacing(7)} + 1px)`,
});
export const Headline = (prop: {
children?: React.ReactNode;
children?: React.ReactNode,
classes?: {
content?: string;
toolbar?: string;
};
menu: React.ReactNode;
content?: string,
toolbar?: string,
},
menu: React.ReactNode
}) => {
const [v, setv] = useState(false);
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
@ -96,36 +77,25 @@ export const Headline = (prop: {
const handleProfileMenuOpen = (e: React.MouseEvent<HTMLElement>) => setAnchorEl(e.currentTarget);
const handleProfileMenuClose = () => setAnchorEl(null);
const isProfileMenuOpened = Boolean(anchorEl);
const menuId = "primary-search-account-menu";
const menuId = 'primary-search-account-menu';
const user_ctx = useContext(UserContext);
const isLogin = user_ctx.username !== "";
const navigate = useNavigate();
const [search, setSearch] = useState("");
const renderProfileMenu = (
<Menu
const renderProfileMenu = (<Menu
anchorEl={anchorEl}
anchorOrigin={{ horizontal: "right", vertical: "top" }}
anchorOrigin={{ horizontal: 'right', vertical: "top" }}
id={menuId}
open={isProfileMenuOpened}
keepMounted
transformOrigin={{ horizontal: "right", vertical: "top" }}
transformOrigin={{ horizontal: 'right', vertical: "top" }}
onClose={handleProfileMenuClose}
>
<MenuItem component={RouterLink} to="/profile">Profile</MenuItem>
<MenuItem
onClick={async () => {
handleProfileMenuClose();
await doLogout();
user_ctx.setUsername("");
}}
>
Logout
</MenuItem>
</Menu>
);
const drawer_contents = (
<>
<MenuItem component={RouterLink} to='/profile'>Profile</MenuItem>
<MenuItem onClick={async () => { handleProfileMenuClose(); await doLogout(); user_ctx.setUsername(""); }}>Logout</MenuItem>
</Menu>);
const drawer_contents = (<>
<DrawerHeader>
<IconButton onClick={toggleV}>
{theme.direction === "ltr" ? <ChevronLeft /> : <ChevronRight />}
@ -133,25 +103,19 @@ export const Headline = (prop: {
</DrawerHeader>
<Divider />
{prop.menu}
</>
);
</>);
return (
<div style={{ display: "flex" }}>
return (<div style={{ display: 'flex' }}>
<CssBaseline />
<AppBar
position="fixed"
sx={{
<AppBar position="fixed" sx={{
zIndex: theme.zIndex.drawer + 1,
transition: theme.transitions.create(["width", "margin"], {
transition: theme.transitions.create(['width', 'margin'], {
easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.leavingScreen,
}),
}}
>
duration: theme.transitions.duration.leavingScreen
})
}}>
<Toolbar>
<IconButton
color="inherit"
<IconButton color="inherit"
aria-label="open drawer"
onClick={toggleV}
edge="start"
@ -159,114 +123,90 @@ export const Headline = (prop: {
>
<MenuIcon></MenuIcon>
</IconButton>
<Link
variant="h5"
noWrap
sx={{
display: "none",
<Link variant="h5" noWrap sx={{
display: 'none',
[theme.breakpoints.up("sm")]: {
display: "block",
},
}}
color="inherit"
component={RouterLink}
to="/"
>
display: 'block'
}
}} color="inherit" component={RouterLink} to="/">
Ionian
</Link>
<div style={{ flexGrow: 1 }}></div>
<StyledSearchBar >
<div
style={{
<div style={{
padding: theme.spacing(0, 2),
height: "100%",
position: "absolute",
pointerEvents: "none",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
height: '100%',
position: 'absolute',
pointerEvents: 'none',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}>
<SearchIcon onClick={() => navSearch(search)} />
</div>
<StyledInputBase
placeholder="search"
<StyledInputBase placeholder="search"
onChange={(e) => setSearch(e.target.value)}
onKeyUp={(e) => {
if (e.key === "Enter") {
navSearch(search);
}
}}
value={search}
>
</StyledInputBase>
value={search}></StyledInputBase>
</StyledSearchBar>
{isLogin
? (
{
isLogin ?
<IconButton
edge="end"
aria-label="account of current user"
aria-controls={menuId}
aria-haspopup="true"
onClick={handleProfileMenuOpen}
color="inherit"
>
color="inherit">
<AccountCircle />
</IconButton>
)
: <Button color="inherit" component={RouterLink} to="/login">Login</Button>}
: <Button color="inherit" component={RouterLink} to="/login">Login</Button>
}
</Toolbar>
</AppBar>
{renderProfileMenu}
<StyledNav>
<nav style={{ width: theme.spacing(7) }}>
<Hidden smUp implementation="css">
<StyledDrawer
variant="temporary"
anchor="left"
open={v}
onClose={toggleV}
<StyledDrawer variant="temporary" anchor='left' open={v} onClose={toggleV}
sx={{
width: drawerWidth,
width: drawerWidth
}}
>
{drawer_contents}
</StyledDrawer>
</Hidden>
<Hidden smDown implementation="css">
<StyledDrawer
variant="permanent"
anchor="left"
<Hidden xsDown implementation="css">
<StyledDrawer variant='permanent' anchor='left'
sx={{
...closedMixin(theme),
"& .MuiDrawer-paper": closedMixin(theme),
}}
>
'& .MuiDrawer-paper': closedMixin(theme),
}}>
{drawer_contents}
</StyledDrawer>
</Hidden>
</StyledNav>
<main
style={{
display: "flex",
flexFlow: "column",
</nav>
<main style={{
display: 'flex',
flexFlow: 'column',
flexGrow: 1,
padding: theme.spacing(3),
marginTop: theme.spacing(6),
}}
>
<div style={{}}></div>
}}>
<div style={{
}} ></div>
{prop.children}
</main>
</div>
);
</div>);
function navSearch(search: string){
let words = search.includes("&") ? search.split("&") : [search];
words = words.map(w => w.trim())
.map(w =>
w.includes(":")
? `allow_tag=${w}`
: `word=${encodeURIComponent(w)}`
);
.map(w => w.includes(":") ?
`allow_tag=${w}`
: `word=${encodeURIComponent(w)}`);
navigate(`/search?${words.join("&")}`);
}
};

View File

@ -1,10 +1,8 @@
import { Box, CircularProgress } from "@mui/material";
import React from "react";
import React from 'react';
import {Box, CircularProgress} from '@mui/material';
export const LoadingCircle = ()=>{
return (
<Box style={{ position: "absolute", top: "50%", left: "50%", transform: "translate(-50%,-50%)" }}>
return (<Box style={{position:"absolute", top:"50%", left:"50%", transform:"translate(-50%,-50%)"}}>
<CircularProgress title="loading" />
</Box>
);
};
</Box>);
}

View File

@ -1,5 +1,5 @@
export * from "./contentinfo";
export * from "./headline";
export * from "./loading";
export * from "./navlist";
export * from "./tagchip";
export * from './contentinfo';
export * from './loading';
export * from './tagchip';
export * from './navlist';
export * from './headline';

View File

@ -1,50 +1,36 @@
import {
ArrowBack as ArrowBackIcon,
Collections as CollectionIcon,
Folder as FolderIcon,
Home as HomeIcon,
import React from 'react';
import {List, ListItem, ListItemIcon, Tooltip, ListItemText, Divider} from '@mui/material';
import {ArrowBack as ArrowBackIcon, Settings as SettingIcon,
Collections as CollectionIcon, VideoLibrary as VideoIcon, Home as HomeIcon,
List as ListIcon,
Settings as SettingIcon,
VideoLibrary as VideoIcon,
} from "@mui/icons-material";
import { Divider, List, ListItem, ListItemIcon, ListItemText, Tooltip } from "@mui/material";
import React from "react";
import { Link as RouterLink } from "react-router-dom";
Folder as FolderIcon } from '@mui/icons-material';
import {Link as RouterLink} from 'react-router-dom';
export const NavItem = (props: { name: string; to: string; icon: React.ReactElement<any, any> }) => {
return (
<ListItem button key={props.name} component={RouterLink} to={props.to}>
export const NavItem = (props:{name:string,to:string, icon:React.ReactElement<any,any>})=>{
return (<ListItem button key={props.name} component={RouterLink} to={props.to}>
<ListItemIcon>
<Tooltip title={props.name.toLocaleLowerCase()} placement="bottom">
{props.icon}
</Tooltip>
</ListItemIcon>
<ListItemText primary={props.name}></ListItemText>
</ListItem>
);
};
</ListItem>);
}
export const NavList = (props: {children?:React.ReactNode})=>{
return (
<List>
return (<List>
{props.children}
</List>
);
};
</List>);
}
export const BackItem = (props:{to?:string})=>{
return <NavItem name="Back" to={props.to ?? "/"} icon={<ArrowBackIcon></ArrowBackIcon>}/>;
};
}
export function CommonMenuList(props?:{url?:string}) {
let url = props?.url ?? "";
return (
<NavList>
{url !== "" && (
<>
<BackItem to={url} /> <Divider />
</>
)}
return (<NavList>
{url !== "" && <><BackItem to={url} /> <Divider /></>}
<NavItem name="All" to="/" icon={<HomeIcon />} />
<NavItem name="Comic" to="/search?content_type=comic" icon={<CollectionIcon />}></NavItem>
<NavItem name="Video" to="/search?content_type=video" icon={<VideoIcon />} />
@ -53,6 +39,5 @@ export function CommonMenuList(props?: { url?: string }) {
<Divider />
<NavItem name="Difference" to="/difference" icon={<FolderIcon/>}></NavItem>
<NavItem name="Settings" to="/setting" icon={<SettingIcon />} />
</NavList>
);
</NavList>);
}

View File

@ -1,78 +1,86 @@
import * as colors from "@mui/material/colors";
import Chip, { ChipTypeMap } from "@mui/material/Chip";
import { emphasize, styled, Theme, useTheme } from "@mui/material/styles";
import React from "react";
import { Link as RouterLink } from "react-router-dom";
import React from 'react';
import {ChipTypeMap} from '@mui/material/Chip';
import { Chip, colors } from '@mui/material';
import { Theme, emphasize} from '@mui/material/styles';
import {Link as RouterLink} from 'react-router-dom';
type TagChipStyleProp = {
color: `rgba(${number},${number},${number},${number})` | `#${string}` | 'default';
};
color: string
}
const useTagStyles = ((theme:Theme)=>({
root:(props:TagChipStyleProp)=>({
color: theme.palette.getContrastText(props.color),
backgroundColor: props.color,
}),
clickable:(props:TagChipStyleProp)=>({
'&:hover, &:focus':{
backgroundColor:emphasize(props.color,0.08)
}
}),
deletable: {
'&:focus': {
backgroundColor: (props:TagChipStyleProp)=>emphasize(props.color, 0.2),
}
},
outlined:{
color: (props:TagChipStyleProp)=>props.color,
border: (props:TagChipStyleProp)=> `1px solid ${props.color}`,
'$clickable&:hover, $clickable&:focus, $deletable&:focus': {
//backgroundColor:(props:TagChipStyleProp)=> (props.color,theme.palette.action.hoverOpacity),
},
},
icon:{
color:"inherit",
},
deleteIcon:{
//color:(props:TagChipStyleProp)=> (theme.palette.getContrastText(props.color),0.7),
"&:hover, &:active":{
color:(props:TagChipStyleProp)=>theme.palette.getContrastText(props.color),
}
}
}));
const {blue, pink} = colors;
const getTagColorName = (tagname: string): TagChipStyleProp['color'] => {
const getTagColorName = (tagname :string):string=>{
if(tagname.startsWith("female")){
return pink[600];
} else if (tagname.startsWith("male")) {
}
else if(tagname.startsWith("male")){
return blue[600];
} else return "default";
};
}
else return "default";
}
type ColorChipProp = Omit<ChipTypeMap["props"], "color"> & TagChipStyleProp & {
component?: React.ElementType;
to?: string;
};
type ColorChipProp = Omit<ChipTypeMap['props'],"color"> & TagChipStyleProp & {
component?: React.ElementType,
to?: string
}
export const ColorChip = (props:ColorChipProp)=>{
const {color,...rest} = props;
const theme = useTheme();
let newcolor = color;
if (color === "default"){
newcolor = "#ebebeb";
//const classes = useTagStyles({color : color !== "default" ? color : "#000"});
return <Chip color="default" {...rest}></Chip>;
}
return <Chip
sx={{
color: theme.palette.getContrastText(newcolor),
backgroundColor: newcolor,
["&:hover, &:focus"]: {
backgroundColor: emphasize(newcolor, 0.08),
},
}}
{...rest}></Chip>;
};
type TagChipProp = Omit<ChipTypeMap["props"], "color"> & {
tagname: string;
};
type TagChipProp = Omit<ChipTypeMap['props'],"color"> & {
tagname:string
}
export const TagChip = (props:TagChipProp)=>{
const {tagname,label,clickable,...rest} = props;
const colorName = getTagColorName(tagname);
let newlabel: React.ReactNode = label;
let newlabel:string|undefined = undefined;
if(typeof label === "string"){
const female = "female:";
const male = "male:";
if (label.startsWith(female)) {
newlabel = "♀ " + label.slice(female.length);
} else if (label.startsWith(male)) {
newlabel = "♂ " + label.slice(male.length);
if(label.startsWith("female:")){
newlabel ="♀ "+label.slice(7);
}
else if(label.startsWith("male:")){
newlabel = "♂ "+label.slice(5);
}
}
const inner = clickable
? (
<ColorChip
color={colorName}
clickable={clickable}
label={newlabel ?? label}
{...rest}
component={RouterLink}
to={`/search?allow_tag=${tagname}`}
/>
)
: (
<ColorChip color={colorName} clickable={clickable} label={newlabel ?? label} {...rest}/>
);
const inner = clickable ?
(<ColorChip color={getTagColorName(tagname)} clickable={clickable} label={newlabel??label} {...rest}
component={RouterLink} to={`/search?allow_tag=${tagname}`}></ColorChip>):
(<ColorChip color={getTagColorName(tagname)} clickable={clickable} label={newlabel??label} {...rest}></ColorChip>);
return inner;
};
}

View File

@ -1,13 +1,11 @@
import { ArrowBack as ArrowBackIcon } from "@mui/icons-material";
import { Typography } from "@mui/material";
import React from "react";
import { BackItem, CommonMenuList, Headline, NavList } from "../component/mod";
import React from 'react';
import {Typography} from '@mui/material';
import {ArrowBack as ArrowBackIcon} from '@mui/icons-material';
import { Headline, BackItem, NavList, CommonMenuList } from '../component/mod';
export const NotFoundPage = ()=>{
const menu = CommonMenuList();
return (
<Headline menu={menu}>
<Typography variant="h2">404 Not Found</Typography>
return <Headline menu={menu}>
<Typography variant='h2'>404 Not Found</Typography>
</Headline>
);
};

View File

@ -1,31 +1,31 @@
import { Theme, Typography } from "@mui/material";
import React, { useEffect, useState } from "react";
import { Route, Routes, useLocation, useParams } from "react-router-dom";
import DocumentAccessor, { Document } from "../accessor/document";
import { LoadingCircle } from "../component/loading";
import { CommonMenuList, ContentInfo, Headline } from "../component/mod";
import { NotFoundPage } from "./404";
import { getPresenter } from "./reader/reader";
import React, { useState, useEffect } from 'react';
import { Route, Routes, useLocation, useParams } from 'react-router-dom';
import DocumentAccessor, { Document } from '../accessor/document';
import { LoadingCircle } from '../component/loading';
import { Theme, Typography } from '@mui/material';
import { getPresenter } from './reader/reader';
import { CommonMenuList, ContentInfo, Headline } from '../component/mod';
import { NotFoundPage } from './404';
export const makeContentInfoUrl = (id: number) => `/doc/${id}`;
export const makeComicReaderUrl = (id: number) => `/doc/${id}/reader`;
type DocumentState = {
doc: Document | undefined;
notfound: boolean;
};
doc: Document | undefined,
notfound: boolean,
}
const styles = (theme: Theme) => ({
const styles = ((theme: Theme) => ({
noPaddingContent: {
display: "flex",
flexDirection: "column",
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
},
noPaddingToolbar: {
flex: "0 1 auto",
flex: '0 1 auto',
...theme.mixins.toolbar,
},
});
}
}));
export function ReaderPage(props?: {}) {
const location = useLocation();
@ -49,28 +49,28 @@ export function ReaderPage(props?: {}) {
if (isNaN(id)) {
return (
<Headline menu={menu_list()}>
<Typography variant="h2">Oops. Invalid ID</Typography>
<Typography variant='h2'>Oops. Invalid ID</Typography>
</Headline>
);
} else if (info.notfound) {
}
else if (info.notfound) {
return (
<Headline menu={menu_list()}>
<Typography variant="h2">Content has been removed.</Typography>
<Typography variant='h2'>Content has been removed.</Typography>
</Headline>
);
} else if (info.doc === undefined) {
return (
<Headline menu={menu_list()}>
)
}
else if (info.doc === undefined) {
return (<Headline menu={menu_list()}>
<LoadingCircle />
</Headline>
);
} else {
}
else {
const ReaderPage = getPresenter(info.doc);
return (
<Headline menu={menu_list(location.pathname)}>
return <Headline menu={menu_list(location.pathname)}>
<ReaderPage doc={info.doc}></ReaderPage>
</Headline>
);
}
}
@ -95,26 +95,28 @@ export const DocumentAbout = (prop?: {}) => {
if (isNaN(id)) {
return (
<Headline menu={menu_list()}>
<Typography variant="h2">Oops. Invalid ID</Typography>
<Typography variant='h2'>Oops. Invalid ID</Typography>
</Headline>
);
} else if (info.notfound) {
}
else if (info.notfound) {
return (
<Headline menu={menu_list()}>
<Typography variant="h2">Content has been removed.</Typography>
<Typography variant='h2'>Content has been removed.</Typography>
</Headline>
);
} else if (info.doc === undefined) {
return (
<Headline menu={menu_list()}>
)
}
else if (info.doc === undefined) {
return (<Headline menu={menu_list()}>
<LoadingCircle />
</Headline>
);
} else {
}
else {
return (
<Headline menu={menu_list()}>
<ContentInfo document={info.doc}></ContentInfo>
</Headline>
);
}
};
}

View File

@ -1,72 +1,60 @@
import { Box, Button, Grid, Paper, Theme, Typography } from "@mui/material";
import { Stack } from "@mui/material";
import React, { useContext, useEffect, useState } from "react";
import React, { useContext, useEffect, useState } from 'react';
import { CommonMenuList, Headline } from "../component/mod";
import { UserContext } from "../state";
import { Box, Grid, Paper, Typography,Button, Theme } from "@mui/material";
import {Stack} from '@mui/material';
const useStyles = (theme: Theme) => ({
const useStyles = ((theme:Theme)=>({
paper:{
padding: theme.spacing(2),
},
commitable:{
display: "grid",
display:'grid',
gridTemplateColumns: `100px auto`,
},
contentTitle:{
marginLeft: theme.spacing(2),
},
});
marginLeft: theme.spacing(2)
}
}));
type FileDifference = {
type: string;
type:string,
value:{
type: string;
path: string;
}[];
};
type:string,
path:string,
}[]
}
function TypeDifference(prop:{
content: FileDifference;
onCommit: (v: { type: string; path: string }) => void;
onCommitAll: (type: string) => void;
content:FileDifference,
onCommit:(v:{type:string,path:string})=>void,
onCommitAll:(type:string) => void
}){
//const classes = useStyles();
const x = prop.content;
const [button_disable,set_disable] = useState(false);
return (
<Paper /*className={classes.paper}*/>
return (<Paper /*className={classes.paper}*/>
<Box /*className={classes.contentTitle}*/>
<Typography variant="h3">{x.type}</Typography>
<Button
variant="contained"
key={x.type}
onClick={() => {
<Typography variant='h3' >{x.type}</Typography>
<Button variant="contained" key={x.type} onClick={()=>{
set_disable(true);
prop.onCommitAll(x.type);
set_disable(false);
}}
>
Commit all
</Button>
}}>Commit all</Button>
</Box>
{x.value.map(y=>(
<Box sx={{display:"flex"}} key={y.path}>
<Button
variant="contained"
onClick={() => {
<Button variant="contained" onClick={()=>{
set_disable(true);
prop.onCommit(y);
set_disable(false);
}}
disabled={button_disable}
>
Commit
</Button>
<Typography variant="h5">{y.path}</Typography>
disabled={button_disable}>Commit</Button>
<Typography variant='h5'>{y.path}</Typography>
</Box>
))}
</Paper>
);
</Paper>);
}
export function DifferencePage(){
@ -76,66 +64,64 @@ export function DifferencePage() {
FileDifference[]
>([]);
const doLoad = async ()=>{
const list = await fetch("/api/diff/list");
const list = await fetch('/api/diff/list');
if(list.ok){
const inner = await list.json();
setDiffList(inner);
} else {
}
else{
//setDiffList([]);
}
};
const Commit = async (x: { type: string; path: string }) => {
const res = await fetch("/api/diff/commit", {
method: "POST",
const Commit = async(x:{type:string,path:string})=>{
const res = await fetch('/api/diff/commit',{
method:'POST',
body: JSON.stringify([{...x}]),
headers:{
"content-type": "application/json",
},
'content-type':'application/json'
}
});
const bb = await res.json();
if(bb.ok){
doLoad();
} else {
}
else{
console.error("fail to add document");
}
};
}
const CommitAll = async (type :string)=>{
const res = await fetch("/api/diff/commitall",{
method:"POST",
body: JSON.stringify({type:type}),
headers:{
"content-type": "application/json",
},
'content-type':'application/json'
}
});
const bb = await res.json();
if(bb.ok){
doLoad();
} else {
}
else{
console.error("fail to add document");
}
};
}
useEffect(
()=>{
doLoad();
const i = setInterval(doLoad,5000);
return ()=>{
clearInterval(i);
};
},
[],
);
const menu = CommonMenuList();
return (
<Headline menu={menu}>
{(ctx.username == "admin")
? (
<div>
{diffList.map(x => (
<TypeDifference key={x.type} content={x} onCommit={Commit} onCommitAll={CommitAll} />
))}
</div>
)
: <Typography variant="h2">Not Allowed : please login as an admin</Typography>}
</Headline>
);
}
},[]
)
const menu = CommonMenuList();
return (<Headline menu={menu}>
{(ctx.username == "admin") ? (<div>
{(diffList.map(x=>
<TypeDifference key={x.type} content={x} onCommit={Commit} onCommitAll={CommitAll}/>))}
</div>)
:(<Typography variant='h2'>Not Allowed : please login as an admin</Typography>)
}
</Headline>)
}

View File

@ -1,13 +1,15 @@
import React, { useContext, useEffect, useState } from "react";
import { CommonMenuList, ContentInfo, Headline, LoadingCircle, NavItem, NavList, TagChip } from "../component/mod";
import React, { useContext, useEffect, useState } from 'react';
import { Headline, CommonMenuList, LoadingCircle, ContentInfo, NavList, NavItem, TagChip } from '../component/mod';
import { Box, Typography, Chip, Pagination, Button } from '@mui/material';
import ContentAccessor, { QueryListOption, Document } from '../accessor/document';
import { toQueryString } from '../accessor/util';
import { useLocation } from 'react-router-dom';
import { QueryStringToMap } from '../accessor/util';
import { useIsElementInViewport } from './reader/reader';
import { Box, Button, Chip, Pagination, Typography } from "@mui/material";
import ContentAccessor, { Document, QueryListOption } from "../accessor/document";
import { toQueryString } from "../accessor/util";
import { useLocation } from "react-router-dom";
import { QueryStringToMap } from "../accessor/util";
import { useIsElementInViewport } from "./reader/reader";
export type GalleryProp = {
option?: QueryListOption;
@ -15,7 +17,7 @@ export type GalleryProp = {
};
type GalleryState = {
documents: Document[] | undefined;
};
}
export const GalleryInfo = (props: GalleryProp) => {
const [state, setState] = useState<GalleryState>({ documents: undefined });
@ -31,72 +33,60 @@ export const GalleryInfo = (props: GalleryProp) => {
useEffect(() => {
const abortController = new AbortController();
console.log("load first", props.option);
const load = async () => {
console.log('load first',props.option);
const load = (async () => {
try{
const c = await ContentAccessor.findList(props.option);
//todo : if c is undefined, retry to fetch 3 times. and show error message.
setState({ documents: c });
setLoadAll(c.length == 0);
} catch (e) {
}
catch(e){
if(e instanceof Error){
setError(e.message);
} else {
}
else{
setError("unknown error");
}
}
};
});
load();
}, [props.diff]);
const queryString = toQueryString(props.option ?? {});
if (state.documents === undefined && error == null) {
return <LoadingCircle />;
} else {
return (<LoadingCircle />);
}
else {
return (
<Box
sx={{
display: "grid",
gridRowGap: "1rem",
}}
>
{props.option !== undefined && props.diff !== "" && (
<Box>
<Box sx={{
display: 'grid',
gridRowGap: '1rem'
}}>
{props.option !== undefined && props.diff !== "" && <Box>
<Typography variant="h6">search for</Typography>
{props.option.word !== undefined && (
<Chip label={"search : " + decodeURIComponent(props.option.word)}></Chip>
)}
{props.option.content_type !== undefined && (
<Chip label={"type : " + props.option.content_type}></Chip>
)}
{props.option.allow_tag !== undefined
&& props.option.allow_tag.map(x => (
<TagChip key={x} tagname={decodeURIComponent(x)} label={decodeURIComponent(x)}>
</TagChip>
))}
</Box>
)}
{state.documents && state.documents.map(x => {
return <ContentInfo document={x} key={x.id} gallery={`/search?${queryString}`} short />;
})}
{error && <Typography variant="h5">Error : {error}</Typography>}
<Typography
variant="body1"
sx={{
{props.option.word !== undefined && <Chip label={"search : " + decodeURIComponent(props.option.word)}></Chip>}
{props.option.content_type !== undefined && <Chip label={"type : " + props.option.content_type}></Chip>}
{props.option.allow_tag !== undefined && props.option.allow_tag.map(x => (
<TagChip key={x} tagname={decodeURIComponent(x)} label={decodeURIComponent(x)}></TagChip>))}
</Box>}
{
state.documents && state.documents.map(x => {
return (<ContentInfo document={x} key={x.id}
gallery={`/search?${queryString}`} short />);
})
}
{(error && <Typography variant="h5">Error : {error}</Typography>)}
<Typography variant="body1" sx={{
justifyContent: "center",
textAlign: "center",
}}
>
{state.documents ? state.documents.length : "null"} loaded...
</Typography>
<Button onClick={() => loadMore()} disabled={loadAll} ref={elementRef}>
{loadAll ? "Load All" : "Load More"}
</Button>
textAlign:"center"
}}>{state.documents ? state.documents.length : "null"} loaded...</Typography>
<Button onClick={()=>loadMore()} disabled={loadAll} ref={elementRef} >{loadAll ? "Load All" : "Load More"}</Button>
</Box>
);
}
function loadMore() {
let option = {...props.option};
console.log(elementRef);
console.log(elementRef)
if(state.documents === undefined || state.documents.length === 0){
console.log("loadall");
setLoadAll(true);
@ -105,17 +95,18 @@ export const GalleryInfo = (props: GalleryProp) => {
const prev_documents = state.documents;
option.cursor = prev_documents[prev_documents.length - 1].id;
console.log("load more", option);
const load = async () => {
const load = (async () => {
const c = await ContentAccessor.findList(option);
if (c.length === 0) {
setLoadAll(true);
} else {
}
else{
setState({ documents: [...prev_documents, ...c] });
}
};
});
load();
}
};
}
export const Gallery = () => {
const location = useLocation();
@ -123,10 +114,8 @@ export const Gallery = () => {
const menu_list = CommonMenuList({ url: location.search });
let option: QueryListOption = query;
option.allow_tag = typeof option.allow_tag === "string" ? [option.allow_tag] : option.allow_tag;
option.limit = typeof query["limit"] === "string" ? parseInt(query["limit"]) : undefined;
return (
<Headline menu={menu_list}>
option.limit = typeof query['limit'] === "string" ? parseInt(query['limit']) : undefined;
return (<Headline menu={menu_list}>
<GalleryInfo diff={location.search} option={query}></GalleryInfo>
</Headline>
);
};
</Headline>)
}

View File

@ -1,21 +1,10 @@
import {
Button,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
MenuList,
Paper,
TextField,
Typography,
useTheme,
} from "@mui/material";
import React, { useContext, useState } from "react";
import { useNavigate } from "react-router-dom";
import { CommonMenuList, Headline } from "../component/mod";
import { UserContext } from "../state";
import { doLogin as doSessionLogin } from "../state";
import React, { useContext, useState } from 'react';
import {CommonMenuList, Headline} from '../component/mod';
import { Button, Dialog, DialogActions, DialogContent, DialogContentText,
DialogTitle, MenuList, Paper, TextField, Typography, useTheme } from '@mui/material';
import { UserContext } from '../state';
import { useNavigate } from 'react-router-dom';
import {doLogin as doSessionLogin} from '../state';
export const LoginPage = ()=>{
const theme = useTheme();
@ -25,7 +14,7 @@ export const LoginPage = () => {
const navigate = useNavigate();
const handleDialogClose = ()=>{
setOpenDialog({...openDialog,open:false});
};
}
const doLogin = async ()=>{
try{
const b = await doSessionLogin(userLoginInfo);
@ -36,43 +25,35 @@ export const LoginPage = () => {
console.log(`login as ${b.username}`);
setUsername(b.username);
setPermission(b.permission);
} catch (e) {
}
catch(e){
if(e instanceof Error){
console.error(e);
setOpenDialog({open:true,message:e.message});
} else console.error(e);
}
else console.error(e);
return;
}
navigate("/");
};
}
const menu = CommonMenuList();
return (
<Headline menu={menu}>
<Paper style={{ width: theme.spacing(40), padding: theme.spacing(4), alignSelf: "center" }}>
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"
onKeyDown={(e) => {
if (e.key === "Enter") doLogin();
}}
onChange={(e) => setUserLoginInfo({ ...userLoginInfo, password: e.target.value ?? "" })}
/>
<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" onKeyDown={(e)=>{if(e.key === 'Enter') doLogin();}}
onChange={(e)=>setUserLoginInfo({...userLoginInfo,password:e.target.value ?? "",})}/>
<div style={{minHeight:theme.spacing(2)}}></div>
<div style={{ display: "flex" }}>
<div style={{display:'flex'}}>
<Button onClick={doLogin}>login</Button>
<Button>signin</Button>
</div>
</form>
</Paper>
<Dialog open={openDialog.open} onClose={handleDialogClose}>
<Dialog open={openDialog.open}
onClose={handleDialogClose}>
<DialogTitle>Login Failed</DialogTitle>
<DialogContent>
<DialogContentText>detail : {openDialog.message}</DialogContentText>
@ -82,5 +63,4 @@ export const LoginPage = () => {
</DialogActions>
</Dialog>
</Headline>
);
};
}

View File

@ -1,8 +1,8 @@
export * from "./404";
export * from "./contentinfo";
export * from "./difference";
export * from "./gallery";
export * from "./login";
export * from "./profile";
export * from "./setting";
export * from "./tags";
export * from './contentinfo';
export * from './gallery';
export * from './login';
export * from './404';
export * from './profile';
export * from './difference';
export * from './setting';
export * from './tags';

View File

@ -1,32 +1,19 @@
import {
Button,
Chip,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
Divider,
Grid,
Paper,
TextField,
Theme,
Typography,
} from "@mui/material";
import React, { useContext, useState } from "react";
import { CommonMenuList, Headline } from "../component/mod";
import React, { useContext, useState } from 'react';
import { UserContext } from "../state";
import { Chip, Grid, Paper, Theme, Typography, Divider, Button,
Dialog, DialogTitle, DialogContentText, DialogContent, TextField, DialogActions } from "@mui/material";
const useStyles = (theme: Theme) => ({
const useStyles = ((theme:Theme)=>({
paper:{
alignSelf:"center",
padding:theme.spacing(2),
},
formfield:{
display: "flex",
flexFlow: "column",
},
});
display:'flex',
flexFlow:'column',
}
}));
export function ProfilePage(){
const userctx = useContext(UserContext);
@ -37,8 +24,10 @@ export function ProfilePage() {
const [newpw,setNewpw] = useState("");
const [newpwch,setNewpwch] = useState("");
const [msg_dialog,set_msg_dialog] = useState({opened:false,msg:""});
const permission_list = userctx.permission.map(p => <Chip key={p} label={p}></Chip>);
const isElectronContent = ((window["electron"] as any) !== undefined) as boolean;
const permission_list =userctx.permission.map(p=>(
<Chip key={p} label={p}></Chip>
));
const isElectronContent = (((window['electron'] as any) !== undefined) as boolean);
const handle_open = ()=>set_pw_open(true);
const handle_close = ()=>{
set_pw_open(false);
@ -52,35 +41,35 @@ export function ProfilePage() {
return;
}
if(isElectronContent){
const elec = window["electron"] as any;
const elec = window['electron'] as any;
const success = elec.passwordReset(userctx.username,newpw);
if(!success){
set_msg_dialog({opened:true,msg:"user not exist."});
}
} else {
}
else{
const res = await fetch("/user/reset",{
method: "POST",
method: 'POST',
body:JSON.stringify({
username:userctx.username,
oldpassword:oldpw,
newpassword:newpw,
}),
headers:{
"content-type": "application/json",
},
"content-type":"application/json"
}
});
if(res.status != 200){
set_msg_dialog({opened:true,msg:"failed to change password."});
}
}
handle_close();
};
return (
<Headline menu={menu}>
}
return (<Headline menu={menu}>
<Paper /*className={classes.paper}*/>
<Grid container direction="column" alignItems="center">
<Grid item>
<Typography variant="h4">{userctx.username}</Typography>
<Typography variant='h4'>{userctx.username}</Typography>
</Grid>
<Divider></Divider>
<Grid item>
@ -99,33 +88,12 @@ export function ProfilePage() {
<DialogContent>
<Typography>type the old and new password</Typography>
<div /*className={classes.formfield}*/>
{(!isElectronContent) && (
<TextField
autoFocus
margin="dense"
type="password"
label="old password"
value={oldpw}
onChange={(e) => setOldpw(e.target.value)}
>
</TextField>
)}
<TextField
margin="dense"
type="password"
label="new password"
value={newpw}
onChange={e => setNewpw(e.target.value)}
>
</TextField>
<TextField
margin="dense"
type="password"
label="new password check"
value={newpwch}
onChange={e => setNewpwch(e.target.value)}
>
</TextField>
{(!isElectronContent) && (<TextField autoFocus margin='dense' type="password" label="old password"
value={oldpw} onChange={(e)=>setOldpw(e.target.value)}></TextField>)}
<TextField margin='dense' type="password" label="new password"
value={newpw} onChange={e=>setNewpw(e.target.value)}></TextField>
<TextField margin='dense' type="password" label="new password check"
value={newpwch} onChange={e=>setNewpwch(e.target.value)}></TextField>
</div>
</DialogContent>
<DialogActions>
@ -142,6 +110,5 @@ export function ProfilePage() {
<Button onClick={()=>set_msg_dialog({opened:false,msg:""})} color="primary">Close</Button>
</DialogActions>
</Dialog>
</Headline>
);
</Headline>)
}

View File

@ -1,52 +1,47 @@
import { Typography, useTheme } from "@mui/material";
import React, { useEffect, useState } from "react";
import { Document } from "../../accessor/document";
import React, {useState, useEffect} from 'react';
import { Typography, useTheme } from '@mui/material';
import { Document } from '../../accessor/document';
type ComicType = "comic"|"artist cg"|"donjinshi"|"western";
export type PresentableTag = {
artist: string[];
group: string[];
series: string[];
type: ComicType;
character: string[];
tags: string[];
};
artist:string[],
group: string[],
series: string[],
type: ComicType,
character: string[],
tags: string[],
}
export const ComicReader = (props:{doc:Document})=>{
const additional = props.doc.additional;
const [curPage,setCurPage] = useState(0);
if (!("page" in additional)) {
if(!('page' in additional)){
console.error("invalid content : page read fail : "+ JSON.stringify(additional));
return <Typography>Error. DB error. page restriction</Typography>;
return <Typography>Error. DB error. page restriction</Typography>
}
const PageDown = ()=>setCurPage(Math.max(curPage - 1 , 0));
const PageUP = ()=>setCurPage(Math.min(curPage + 1, page - 1));
const page: number = additional["page"] as number;
const page:number = additional['page'] as number;
const onKeyUp = (e: KeyboardEvent)=>{
if(e.code === "ArrowLeft"){
PageDown();
} else if (e.code === "ArrowRight") {
}
else if(e.code === "ArrowRight"){
PageUP();
}
};
}
useEffect(()=>{
document.addEventListener("keydown",onKeyUp);
return ()=>{
document.removeEventListener("keydown",onKeyUp);
};
}
});
//theme.mixins.toolbar.minHeight;
return (
<div style={{ overflow: "hidden", alignSelf: "center" }}>
<img
onClick={PageUP}
src={`/api/doc/${props.doc.id}/comic/${curPage}`}
style={{ maxWidth: "100%", maxHeight: "calc(100vh - 64px)" }}
>
</img>
</div>
);
};
return (<div style={{overflow: 'hidden', alignSelf:'center'}}>
<img onClick={PageUP} src={`/api/doc/${props.doc.id}/comic/${curPage}`}
style={{maxWidth:'100%', maxHeight:'calc(100vh - 64px)'}}></img>
</div>);
}
export default ComicReader;

View File

@ -1,15 +1,15 @@
import { styled, Typography } from "@mui/material";
import React from "react";
import { Document, makeThumbnailUrl } from "../../accessor/document";
import { ComicReader } from "./comic";
import { VideoReader } from "./video";
import { Typography, styled } from '@mui/material';
import React from 'react';
import { Document, makeThumbnailUrl } from '../../accessor/document';
import {ComicReader} from './comic';
import {VideoReader} from './video'
export interface PagePresenterProp{
doc: Document;
className?: string;
doc:Document,
className?:string
}
interface PagePresenter{
(prop: PagePresenterProp): JSX.Element;
(prop:PagePresenterProp):JSX.Element
}
export const getPresenter = (content:Document):PagePresenter => {
@ -19,19 +19,20 @@ export const getPresenter = (content: Document): PagePresenter => {
case "video":
return VideoReader;
}
return () => <Typography variant="h2">Not implemented reader</Typography>;
};
return ()=><Typography variant='h2'>Not implemented reader</Typography>;
}
const BackgroundDiv = styled("div")({
height: "400px",
width: "300px",
height: '400px',
width:'300px',
backgroundColor:"#272733",
display:"flex",
alignItems:"center",
justifyContent: "center",
});
justifyContent:"center"}
);
import { useEffect, useRef, useState } from "react";
import "./thumbnail.css";
import { useRef, useState, useEffect } from 'react';
import "./thumbnail.css"
export function useIsElementInViewport<T extends HTMLElement>(options?: IntersectionObserverInit) {
const elementRef = useRef<T>(null);
@ -49,11 +50,11 @@ export function useIsElementInViewport<T extends HTMLElement>(options?: Intersec
}, [elementRef, options]);
return { elementRef, isVisible };
}
};
export function ThumbnailContainer(props:{
content: Document;
className?: string;
content:Document,
className?:string,
}){
const {elementRef, isVisible} = useIsElementInViewport<HTMLDivElement>({});
const [loaded, setLoaded] = useState(false);
@ -61,17 +62,19 @@ export function ThumbnailContainer(props: {
if(isVisible){
setLoaded(true);
}
}, [isVisible]);
},[isVisible])
const style = {
maxHeight: "400px",
maxWidth: "min(400px, 100vw)",
maxHeight: '400px',
maxWidth: 'min(400px, 100vw)',
};
const thumbnailurl = makeThumbnailUrl(props.content);
if(props.content.content_type === "video"){
return <video src={thumbnailurl} muted autoPlay loop className={props.className} style={style}></video>;
} else {return (
<BackgroundDiv ref={elementRef}>
{loaded && <img src={thumbnailurl} className={props.className + " thumbnail_img"} loading="lazy"></img>}
</BackgroundDiv>
);}
return (<video src={thumbnailurl} muted autoPlay loop className={props.className} style={style}></video>)
}
else return (<BackgroundDiv ref={elementRef}>
{loaded && <img src={thumbnailurl}
className={props.className + " thumbnail_img"}
loading="lazy"></img>}
</BackgroundDiv>)
}

View File

@ -1,10 +1,7 @@
import React from "react";
import { Document } from "../../accessor/document";
import React from 'react';
import { Document } from '../../accessor/document';
export const VideoReader = (props:{doc:Document})=>{
const id = props.doc.id;
return (
<video controls autoPlay src={`/api/doc/${props.doc.id}/video`} style={{ maxHeight: "100%", maxWidth: "100%" }}>
</video>
);
};
return <video controls autoPlay src={`/api/doc/${props.doc.id}/video`} style={{maxHeight:'100%',maxWidth:'100%'}}></video>;
}

View File

@ -1,15 +1,13 @@
import { ArrowBack as ArrowBackIcon } from "@mui/icons-material";
import { Paper, Typography } from "@mui/material";
import React from "react";
import { BackItem, CommonMenuList, Headline, NavList } from "../component/mod";
import React from 'react';
import {Typography, Paper} from '@mui/material';
import {ArrowBack as ArrowBackIcon} from '@mui/icons-material';
import { Headline, BackItem, NavList, CommonMenuList } from '../component/mod';
export const SettingPage = ()=>{
const menu = CommonMenuList();
return (
<Headline menu={menu}>
return (<Headline menu={menu}>
<Paper>
<Typography variant="h2">Setting</Typography>
<Typography variant='h2'>Setting</Typography>
</Paper>
</Headline>
);
</Headline>);
};

View File

@ -1,8 +1,8 @@
import { Box, Paper, Typography } from "@mui/material";
import { DataGrid, GridColDef } from "@mui/x-data-grid";
import React, { useEffect, useState } from "react";
import React, { useEffect, useState } from 'react';
import {Typography, Box, Paper} from '@mui/material';
import {LoadingCircle} from "../component/loading";
import { CommonMenuList, Headline } from "../component/mod";
import { Headline, CommonMenuList } from '../component/mod';
import {DataGrid, GridColDef} from "@mui/x-data-grid"
type TagCount = {
tag_name: string;
@ -19,9 +19,9 @@ const tagTableColumn: GridColDef[] = [
field:"occurs",
headerName:"Occurs",
width:100,
type: "number",
},
];
type:"number"
}
]
function TagTable(){
const [data,setData] = useState<TagCount[] | undefined>();
@ -36,26 +36,26 @@ function TagTable() {
return <LoadingCircle/>;
}
if(error !== undefined){
return <Typography variant="h3">{error}</Typography>;
return <Typography variant="h3">{error}</Typography>
}
return (
<Box sx={{ height: "400px", width: "100%" }}>
return <Box sx={{height:"400px",width:"100%"}}>
<Paper sx={{height:"100%"}} elevation={2}>
<DataGrid rows={data} columns={tagTableColumn} getRowId={(t)=>t.tag_name} ></DataGrid>
</Paper>
</Box>
);
async function loadData(){
try{
const res = await fetch("/api/tags?withCount=true");
const data = await res.json();
setData(data);
} catch (e) {
}
catch(e){
setData([]);
if(e instanceof Error){
setErrorMsg(e.message);
} else {
}
else{
console.log(e);
setErrorMsg("");
}
@ -65,9 +65,7 @@ function TagTable() {
export const TagsPage = ()=>{
const menu = CommonMenuList();
return (
<Headline menu={menu}>
return <Headline menu={menu}>
<TagTable></TagTable>
</Headline>
);
};

File diff suppressed because it is too large Load Diff

View File

@ -1,16 +1,16 @@
import React, { createContext, useRef, useState } from "react";
import React, { createContext, useRef, useState } from 'react';
export const BackLinkContext = createContext({backLink:"",setBackLink:(s:string)=>{} });
export const UserContext = createContext({
username: "",
permission: [] as string[],
setUsername: (s: string) => { },
setPermission: (permission: string[]) => {},
setPermission: (permission: string[]) => { }
});
type LoginLocalStorage = {
username: string;
permission: string[];
accessExpired: number;
username: string,
permission: string[],
accessExpired: number
};
let localObj: LoginLocalStorage | null = null;
@ -25,64 +25,65 @@ export const getInitialValue = async () => {
return {
username: localObj.username,
permission: localObj.permission,
};
}
const res = await fetch("/user/refresh", {
method: "POST",
}
const res = await fetch('/user/refresh', {
method: 'POST',
});
if (res.status !== 200) throw new Error("Maybe Network Error");
if (res.status !== 200) throw new Error("Maybe Network Error")
const r = await res.json() as LoginLocalStorage & { refresh: boolean };
if (r.refresh) {
localObj = {
username: r.username,
permission: r.permission,
accessExpired: r.accessExpired,
};
} else {
accessExpired: r.accessExpired
}
}
else {
localObj = {
accessExpired: 0,
username: "",
permission: r.permission,
};
permission: r.permission
}
}
window.localStorage.setItem("UserLoginContext", JSON.stringify(localObj));
return {
username: r.username,
permission: r.permission,
};
};
permission: r.permission
}
}
export const doLogout = async () => {
const req = await fetch("/user/logout", {
method: "POST",
const req = await fetch('/user/logout', {
method: 'POST'
});
try {
const res = await req.json();
localObj = {
accessExpired: 0,
username: "",
permission: res["permission"],
};
permission: res["permission"]
}
window.localStorage.setItem("UserLoginContext", JSON.stringify(localObj));
return {
username: localObj.username,
permission: localObj.permission,
};
}
} catch (error) {
console.error(`Server Error ${error}`);
return {
username: "",
permission: [],
};
}
};
}
}
export const doLogin = async (userLoginInfo:{
username: string;
password: string;
username:string,
password:string,
}): Promise<string|LoginLocalStorage>=>{
const res = await fetch("/user/login", {
method: "POST",
const res = await fetch('/user/login',{
method:'POST',
body:JSON.stringify(userLoginInfo),
headers: { "content-type": "application/json" },
headers:{"content-type":"application/json"}
});
const b = await res.json();
if(res.status !== 200){
@ -91,4 +92,4 @@ export const doLogin = async (userLoginInfo: {
localObj = b;
window.localStorage.setItem("UserLoginContext", JSON.stringify(localObj));
return b;
};
}

View File

@ -2,21 +2,21 @@ import { Knex as k } from "knex";
export namespace Knex {
export const config: {
development: k.Config;
production: k.Config;
development: k.Config,
production: k.Config
} = {
development: {
client: "sqlite3",
client: 'sqlite3',
connection: {
filename: "./devdb.sqlite3",
filename: './devdb.sqlite3'
},
debug: true,
},
production: {
client: "sqlite3",
client: 'sqlite3',
connection: {
filename: "./db.sqlite3",
},
filename: './db.sqlite3',
},
}
};
}

View File

@ -1,19 +1,19 @@
import { extname } from "path";
import { DocumentBody } from "../model/doc";
import { readAllFromZip, readZip } from "../util/zipwrap";
import { ContentConstructOption, ContentFile, createDefaultClass, registerContentReferrer } from "./file";
import {ContentFile, createDefaultClass,registerContentReferrer, ContentConstructOption} from './file';
import {readZip, readAllFromZip} from '../util/zipwrap';
import { DocumentBody } from '../model/doc';
import {extname} from 'path';
type ComicType = "doujinshi"|"artist cg"|"manga"|"western";
interface ComicDesc{
title: string;
artist?: string[];
group?: string[];
series?: string[];
type: ComicType | [ComicType];
character?: string[];
tags?: string[];
title:string,
artist?:string[],
group?:string[],
series?:string[],
type:ComicType|[ComicType],
character?:string[],
tags?:string[]
}
const ImageExt = [".gif", ".png", ".jpeg", ".bmp", ".webp", ".jpg"];
const ImageExt = ['.gif', '.png', '.jpeg', '.bmp', '.webp', '.jpg'];
export class ComicReferrer extends createDefaultClass("comic"){
desc: ComicDesc|undefined;
pagenum: number;
@ -32,12 +32,11 @@ export class ComicReferrer extends createDefaultClass("comic") {
if(entry === undefined){
return;
}
const data = (await readAllFromZip(zip, entry)).toString("utf-8");
const data = (await readAllFromZip(zip,entry)).toString('utf-8');
this.desc = JSON.parse(data);
if (this.desc === undefined) {
if(this.desc === undefined)
throw new Error(`JSON.parse is returning undefined. ${this.path} desc.json format error`);
}
}
async createDocumentBody(): Promise<DocumentBody>{
await this.initDesc();
@ -57,10 +56,10 @@ export class ComicReferrer extends createDefaultClass("comic") {
...basebody,
title:this.desc.title,
additional:{
page: this.pagenum,
page:this.pagenum
},
tags: tags,
tags:tags
};
}
}
};
registerContentReferrer(ComicReferrer);

View File

@ -1,10 +1,10 @@
import { createHash } from "crypto";
import { promises, Stats } from "fs";
import { Context, DefaultContext, DefaultState, Middleware, Next } from "koa";
import Router from "koa-router";
import { extname } from "path";
import path from "path";
import { DocumentBody } from "../model/mod";
import {Context, DefaultState, DefaultContext, Middleware, Next} from 'koa';
import Router from 'koa-router';
import {createHash} from 'crypto';
import {promises, Stats} from 'fs'
import {extname} from 'path';
import { DocumentBody } from '../model/mod';
import path from 'path';
/**
* content file or directory referrer
*/
@ -15,11 +15,9 @@ export interface ContentFile {
readonly type: string;
}
export type ContentConstructOption = {
hash: string;
};
type ContentFileConstructor = (new(path: string, option?: ContentConstructOption) => ContentFile) & {
content_type: string;
};
hash: string,
}
type ContentFileConstructor = (new (path:string,option?:ContentConstructOption) => ContentFile)&{content_type:string};
export const createDefaultClass = (type:string):ContentFileConstructor=>{
let cons = class implements ContentFile{
readonly path: string;
@ -70,10 +68,10 @@ export const createDefaultClass = (type: string): ContentFileConstructor => {
}
};
return cons;
};
}
let ContstructorTable:{[k:string]:ContentFileConstructor} = {};
export function registerContentReferrer(s: ContentFileConstructor){
console.log(`registered content type: ${s.content_type}`);
console.log(`registered content type: ${s.content_type}`)
ContstructorTable[s.content_type] = s;
}
export function createContentFile(type:string,path:string,option?:ContentConstructOption){

View File

@ -1,3 +1,3 @@
import "./comic";
import "./video";
export { ContentFile, createContentFile } from "./file";
import './comic';
import './video';
export {ContentFile, createContentFile} from './file';

View File

@ -1,5 +1,5 @@
import { ContentConstructOption, ContentFile, registerContentReferrer } from "./file";
import { createDefaultClass } from "./file";
import {ContentFile, registerContentReferrer, ContentConstructOption} from './file';
import {createDefaultClass} from './file';
export class VideoReferrer extends createDefaultClass("video"){
constructor(path:string,desc?:ContentConstructOption){

View File

@ -1,7 +1,7 @@
import { existsSync } from "fs";
import Knex from "knex";
import { Knex as KnexConfig } from "./config";
import { get_setting } from "./SettingConfig";
import { existsSync } from 'fs';
import Knex from 'knex';
import {Knex as KnexConfig} from './config';
import { get_setting } from './SettingConfig';
export async function connectDB(){
const env = get_setting().mode;
@ -9,7 +9,7 @@ export async function connectDB() {
if(!config.connection){
throw new Error("connection options required.");
}
const connection = config.connection;
const connection = config.connection
if(typeof connection === "string"){
throw new Error("unknown connection options");
}
@ -25,14 +25,16 @@ export async function connectDB() {
for(;;){
try{
console.log("try to connect db");
await knex.raw("select 1 + 1;");
await knex.raw('select 1 + 1;');
console.log("connect success");
} catch (err) {
}
catch(err){
if(tries < 3){
tries++;
console.error(`connection fail ${err} retry...`);
continue;
} else {
}
else{
throw err;
}
}

View File

@ -1,12 +1,12 @@
import { Knex } from "knex";
import { Document, DocumentAccessor, DocumentBody, QueryListOption } from "../model/doc";
import { TagAccessor } from "../model/tag";
import { createKnexTagController } from "./tag";
import { Document, DocumentBody, DocumentAccessor, QueryListOption } from '../model/doc';
import {Knex} from 'knex';
import {createKnexTagController} from './tag';
import { TagAccessor } from '../model/tag';
export type DBTagContentRelation = {
doc_id: number;
tag_name: string;
};
doc_id:number,
tag_name:string
}
class KnexDocumentAccessor implements DocumentAccessor{
knex : Knex;
@ -45,14 +45,14 @@ class KnexDocumentAccessor implements DocumentAccessor {
const id_lst = await trx.insert({
additional:JSON.stringify(additional),
created_at:Date.now(),
...rest,
...rest
}).into("document");
const id = id_lst[0];
if(tags.length > 0){
await trx.insert(tags.map(y=>({
doc_id:id,
tag_name: y,
}))).into("doc_tag_relation");
tag_name:y
}))).into('doc_tag_relation');
}
ret.push(id);
}
@ -64,19 +64,19 @@ class KnexDocumentAccessor implements DocumentAccessor {
const id_lst = await this.knex.insert({
additional:JSON.stringify(additional),
created_at:Date.now(),
...rest,
}).into("document");
...rest
}).into('document');
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 => ({ doc_id: id, tag_name: x })),
tags.map(x=>({doc_id:id,tag_name:x}))
).into("doc_tag_relation");
}
return id;
}
};
async del(id:number) {
if (await this.findById(id) !== undefined){
await this.knex.delete().from("doc_tag_relation").where({doc_id:id});
@ -84,12 +84,12 @@ class KnexDocumentAccessor implements DocumentAccessor {
return true;
}
return false;
}
};
async findById(id:number,tagload?:boolean): Promise<Document|undefined>{
const s = await this.knex.select("*").from("document").where({id:id});
if(s.length === 0) return undefined;
const first = s[0];
let ret_tags: string[] = [];
let ret_tags:string[] = []
if(tagload === true){
const tags : DBTagContentRelation[] = await this.knex.select("*")
.from("doc_tag_relation").where({doc_id:first.id});
@ -100,7 +100,7 @@ class KnexDocumentAccessor implements DocumentAccessor {
tags:ret_tags,
additional: first.additional !== null ? JSON.parse(first.additional) : {},
};
}
};
async findDeleted(content_type:string){
const s = await this.knex.select("*")
.where({content_type:content_type})
@ -109,7 +109,7 @@ class KnexDocumentAccessor implements DocumentAccessor {
return s.map(x=>({
...x,
tags:[],
additional: {},
additional:{}
}));
}
async findList(option?:QueryListOption){
@ -130,35 +130,33 @@ class KnexDocumentAccessor implements DocumentAccessor {
query = query.where("tags_0.tag_name","=",allow_tag[0]);
for (let index = 1; index < allow_tag.length; index++) {
const element = allow_tag[index];
query = query.innerJoin(
`doc_tag_relation as tags_${index}`,
`tags_${index}.doc_id`,
"tags_0.doc_id",
);
query = query.where(`tags_${index}.tag_name`, "=", element);
query = query.innerJoin(`doc_tag_relation as tags_${index}`,`tags_${index}.doc_id`,"tags_0.doc_id");
query = query.where(`tags_${index}.tag_name`,'=',element);
}
query = query.innerJoin("document","tags_0.doc_id","document.id");
} else {
}
else{
query = query.from("document");
}
if(word !== undefined){
//don't worry about sql injection.
query = query.where("title", "like", `%${word}%`);
query = query.where('title','like',`%${word}%`);
}
if(content_type !== undefined){
query = query.where("content_type", "=", content_type);
query = query.where('content_type','=',content_type);
}
if(use_offset){
query = query.offset(offset);
} else {
}
else{
if(cursor !== undefined){
query = query.where("id", "<", cursor);
query = query.where('id','<',cursor);
}
}
query = query.limit(limit);
query = query.orderBy("id", "desc");
query = query.orderBy('id',"desc");
return query;
};
}
let query = buildquery();
//console.log(query.toSQL());
let result:Document[] = await query;
@ -175,25 +173,24 @@ class KnexDocumentAccessor implements DocumentAccessor {
let tagquery= this.knex.select("id","doc_tag_relation.tag_name").from(subquery)
.innerJoin("doc_tag_relation","doc_tag_relation.doc_id","id");
//console.log(tagquery.toSQL());
let tagresult: { id: number; tag_name: string }[] = await tagquery;
let tagresult:{id:number,tag_name:string}[] = await tagquery;
for(const {id,tag_name} of tagresult){
idmap[id].tags.push(tag_name);
}
} else {
result.forEach(v => {
v.tags = [];
});
}
else{
result.forEach(v=>{v.tags = [];});
}
return result;
}
};
async findByPath(path:string,filename?:string):Promise<Document[]>{
const e = filename == undefined ? {} : { filename: filename };
const e = filename == undefined ? {} : {filename:filename}
const results = await this.knex.select("*").from("document").where({basepath:path,...e});
return results.map(x=>({
...x,
tags:[],
additional: {},
}));
additional:{}
}))
}
async update(c:Partial<Document> & { id:number }){
const {id,tags,...rest} = c;
@ -220,4 +217,4 @@ class KnexDocumentAccessor implements DocumentAccessor {
}
export const createKnexDocumentAccessor = (knex:Knex): DocumentAccessor=>{
return new KnexDocumentAccessor(knex);
};
}

View File

@ -1,3 +1,3 @@
export * from "./doc";
export * from "./tag";
export * from "./user";
export * from './doc';
export * from './tag';
export * from './user';

View File

@ -1,14 +1,14 @@
import { Knex } from "knex";
import { Tag, TagAccessor, TagCount } from "../model/tag";
import { DBTagContentRelation } from "./doc";
import {Tag, TagAccessor, TagCount} from '../model/tag';
import {Knex} from 'knex';
import {DBTagContentRelation} from './doc';
type DBTags = {
name: string;
description?: string;
};
name: string,
description?: string
}
class KnexTagAccessor implements TagAccessor{
knex: Knex<DBTags>;
knex:Knex<DBTags>
constructor(knex:Knex){
this.knex = knex;
}
@ -19,11 +19,11 @@ class KnexTagAccessor implements TagAccessor {
}
async getAllTagList(onlyname?:boolean){
onlyname = onlyname ?? false;
const t: DBTags[] = await this.knex.select(onlyname ? "*" : "name").from("tags");
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 });
const t:DBTags[] = await this.knex.select('*').from("tags").where({name: name});
if(t.length === 0) return undefined;
return t[0];
}
@ -31,7 +31,7 @@ class KnexTagAccessor implements TagAccessor {
if(await this.getTagByName(tag.name) === undefined){
await this.knex.insert<DBTags>({
name:tag.name,
description: tag.description === undefined ? "" : tag.description,
description:tag.description === undefined ? "" : tag.description
}).into("tags");
return true;
}
@ -51,7 +51,7 @@ class KnexTagAccessor implements TagAccessor {
}
return false;
}
}
};
export const createKnexTagController = (knex:Knex):TagAccessor=>{
return new KnexTagAccessor(knex);
};
}

View File

@ -1,15 +1,15 @@
import { Knex } from "knex";
import { IUser, Password, UserAccessor, UserCreateInput } from "../model/user";
import {Knex} from 'knex';
import {IUser,UserCreateInput, UserAccessor, Password} from '../model/user';
type PermissionTable = {
username: string;
name: string;
username:string,
name:string
};
type DBUser = {
username: string;
password_hash: string;
password_salt: string;
};
username : string,
password_hash: string,
password_salt: string
}
class KnexUser implements IUser{
private knex: Knex;
readonly username: string;
@ -27,7 +27,7 @@ class KnexUser implements IUser {
.update({password_hash:this.password.hash,password_salt:this.password.salt});
}
async get_permissions(){
let b = (await this.knex.select("*").from("permissions")
let b = (await this.knex.select('*').from("permissions")
.where({username : this.username})) as PermissionTable[];
return b.map(x=>x.name);
}
@ -35,7 +35,7 @@ class KnexUser implements IUser {
if(!(await this.get_permissions()).includes(name)){
const r = await this.knex.insert({
username: this.username,
name: name,
name: name
}).into("permissions");
return true;
}
@ -45,8 +45,7 @@ class KnexUser implements IUser {
const r = await this.knex
.from("permissions")
.where({
username: this.username,
name: name,
username:this.username, name:name
}).delete();
return r !== 0;
}
@ -61,27 +60,23 @@ export const createKnexUserController = (knex: Knex): UserAccessor => {
await knex.insert<DBUser>({
username: user.username,
password_hash: user.password.hash,
password_salt: user.password.salt,
}).into("users");
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,
);
};
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,
};
};
}

View File

@ -1,8 +1,8 @@
import { basename, dirname, join as pathjoin } from "path";
import { ContentFile, createContentFile } from "../content/mod";
import { Document, DocumentAccessor } from "../model/mod";
import { ContentList } from "./content_list";
import { IDiffWatcher } from "./watcher";
import { basename, dirname, join as pathjoin } from 'path';
import { Document, DocumentAccessor } from '../model/mod';
import { ContentFile, createContentFile } from '../content/mod';
import { IDiffWatcher } from './watcher';
import { ContentList } from './content_list';
//refactoring needed.
export class ContentDiffHandler {
@ -26,9 +26,9 @@ export class ContentDiffHandler {
}
}
register(diff: IDiffWatcher) {
diff.on("create", (path) => this.OnCreated(path))
.on("delete", (path) => this.OnDeleted(path))
.on("change", (prev, cur) => this.OnChanged(prev, cur));
diff.on('create', (path) => this.OnCreated(path))
.on('delete', (path) => this.OnDeleted(path))
.on('change', (prev, cur) => this.OnChanged(prev, cur));
}
private async OnDeleted(cpath: string) {
const basepath = dirname(cpath);
@ -83,13 +83,14 @@ export class ContentDiffHandler {
id: c.id,
deleted_at: null,
filename: filename,
basepath: basepath,
basepath: basepath
});
}
if (this.waiting_list.hasByHash(hash)) {
console.log("Hash Conflict!!!");
}
this.waiting_list.set(content);
}
private async OnChanged(prev_path: string, cur_path: string) {
const prev_basepath = dirname(prev_path);
@ -114,7 +115,7 @@ export class ContentDiffHandler {
await this.doc_cntr.update({
...doc[0],
basepath: cur_basepath,
filename: cur_filename,
filename: cur_filename
});
}
}

View File

@ -1,4 +1,4 @@
import { ContentFile } from "../content/mod";
import { ContentFile } from '../content/mod';
export class ContentList{
/** path map */
@ -7,8 +7,8 @@ export class ContentList {
private hl:Map<string,ContentFile>;
constructor(){
this.cl = new Map();
this.hl = new Map();
this.cl = new Map;
this.hl = new Map;
}
hasByHash(s:string){
return this.hl.has(s);
@ -17,7 +17,7 @@ export class ContentList {
return this.cl.has(p);
}
getByHash(s:string){
return this.hl.get(s);
return this.hl.get(s)
}
getByPath(p:string){
return this.cl.get(p);

View File

@ -1,7 +1,7 @@
import asyncPool from "tiny-async-pool";
import { DocumentAccessor } from "../model/doc";
import { ContentDiffHandler } from "./content_handler";
import { IDiffWatcher } from "./watcher";
import { DocumentAccessor } from '../model/doc';
import {ContentDiffHandler} from './content_handler';
import { IDiffWatcher } from './watcher';
import asyncPool from 'tiny-async-pool';
export class DiffManager{
watching: {[content_type:string]:ContentDiffHandler};
@ -42,4 +42,4 @@ export class DiffManager {
value:this.watching[x].waiting_list.getAll(),
}));
}
}
};

View File

@ -1,2 +1,2 @@
export * from "./diff";
export * from "./router";
export * from './router';
export * from './diff';

View File

@ -1,9 +1,9 @@
import Koa from "koa";
import Router from "koa-router";
import { ContentFile } from "../content/mod";
import { AdminOnlyMiddleware } from "../permission/permission";
import { sendError } from "../route/error_handler";
import { DiffManager } from "./diff";
import Koa from 'koa';
import Router from 'koa-router';
import { ContentFile } from '../content/mod';
import { sendError } from '../route/error_handler';
import {DiffManager} from './diff';
import {AdminOnlyMiddleware} from '../permission/permission';
function content_file_to_return(x:ContentFile){
return {path:x.path,type:x.type};
@ -15,17 +15,17 @@ export const getAdded = (diffmgr: DiffManager) => (ctx: Koa.Context, next: Koa.N
type:x.type,
value:x.value.map(x=>({path:x.path,type:x.type})),
}));
ctx.type = "json";
};
ctx.type = 'json';
}
type PostAddedBody = {
type: string;
path: string;
type:string,
path:string,
}[];
function checkPostAddedBody(body: any): body is PostAddedBody{
if(body instanceof Array){
return body.map(x => "type" in x && "path" in x).every(x => x);
return body.map(x=> 'type' in x && 'path' in x).every(x=>x);
}
return false;
}
@ -41,11 +41,11 @@ export const postAdded = (diffmgr: DiffManager) => async (ctx: Router.IRouterCon
ctx.body = {
ok:true,
docs:results,
};
ctx.type = "json";
};
}
ctx.type = 'json';
}
export const postAddedAll = (diffmgr: DiffManager) => async (ctx:Router.IRouterContext,next:Koa.Next) => {
if (!ctx.is("json")) {
if (!ctx.is('json')){
sendError(400,"format exception");
return;
}
@ -61,10 +61,10 @@ export const postAddedAll = (diffmgr: DiffManager) => async (ctx: Router.IRouter
}
await diffmgr.commitAll(t);
ctx.body = {
ok: true,
};
ctx.type = "json";
ok:true
};
ctx.type = 'json';
}
/*
export const getNotWatched = (diffmgr : DiffManager)=> (ctx:Router.IRouterContext,next:Koa.Next)=>{
ctx.body = {

View File

@ -1,15 +1,15 @@
import event from "events";
import { FSWatcher, watch } from "fs";
import { promises } from "fs";
import { join } from "path";
import { DocumentAccessor } from "../model/doc";
import { FSWatcher, watch } from 'fs';
import { promises } from 'fs';
import event from 'events';
import { join } from 'path';
import { DocumentAccessor } from '../model/doc';
const readdir = promises.readdir;
export interface DiffWatcherEvent{
"create": (path: string) => void;
"delete": (path: string) => void;
"change": (prev_path: string, cur_path: string) => void;
'create':(path:string)=>void,
'delete':(path:string)=>void,
'change':(prev_path:string,cur_path:string)=>void,
}
export interface IDiffWatcher extends event.EventEmitter {

View File

@ -1,12 +1 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$ref": "#/definitions/ComicConfig",
"definitions": {
"ComicConfig": {
"type": "object",
"properties": { "watch": { "type": "array", "items": { "type": "string" } }, "$schema": { "type": "string" } },
"required": ["watch"],
"additionalProperties": false
}
}
}
{"$schema":"http://json-schema.org/draft-07/schema#","$ref":"#/definitions/ComicConfig","definitions":{"ComicConfig":{"type":"object","properties":{"watch":{"type":"array","items":{"type":"string"}},"$schema":{"type":"string"}},"required":["watch"],"additionalProperties":false}}}

View File

@ -1,7 +1,8 @@
import { ConfigManager } from "../../util/configRW";
import ComicSchema from "./ComicConfig.schema.json";
import {ConfigManager} from '../../util/configRW';
import ComicSchema from "./ComicConfig.schema.json"
export interface ComicConfig{
watch: string[];
watch:string[]
}
export const ComicConfig = new ConfigManager<ComicConfig>("comic_config.json",{watch:[]},ComicSchema);

View File

@ -1,16 +1,17 @@
import { EventEmitter } from "events";
import { DocumentAccessor } from "../../model/doc";
import { DiffWatcherEvent, IDiffWatcher } from "../watcher";
import { ComicConfig } from "./ComicConfig";
import { WatcherCompositer } from "./compositer";
import { RecursiveWatcher } from "./recursive_watcher";
import { WatcherFilter } from "./watcher_filter";
import {IDiffWatcher, DiffWatcherEvent} from '../watcher';
import {EventEmitter} from 'events';
import { DocumentAccessor } from '../../model/doc';
import { WatcherFilter } from './watcher_filter';
import { RecursiveWatcher } from './recursive_watcher';
import { ComicConfig } from './ComicConfig';
import {WatcherCompositer} from './compositer'
const createComicWatcherBase = (path:string)=> {
return new WatcherFilter(new RecursiveWatcher(path),(x)=>x.endsWith(".zip"));
};
}
export const createComicWatcher = ()=>{
const file = ComicConfig.get_config_file();
console.log(`register comic ${file.watch.join(",")}`);
console.log(`register comic ${file.watch.join(",")}`)
return new WatcherCompositer(file.watch.map(path=>createComicWatcherBase(path)));
};
}

View File

@ -1,9 +1,9 @@
import event from "events";
import { FSWatcher, promises, watch } from "fs";
import { join } from "path";
import { DocumentAccessor } from "../../model/doc";
import { DiffWatcherEvent, IDiffWatcher } from "../watcher";
import { setupHelp } from "./util";
import event from 'events';
import {FSWatcher,watch,promises} from 'fs';
import {IDiffWatcher, DiffWatcherEvent} from '../watcher';
import {join} from 'path';
import { DocumentAccessor } from '../../model/doc';
import { setupHelp } from './util';
const {readdir} = promises;
@ -25,9 +25,10 @@ export class CommonDiffWatcher extends event.EventEmitter implements IDiffWatche
const cur = await readdir(this._path);
//add
if(cur.includes(filename)){
this.emit("create", join(this.path, filename));
} else {
this.emit("delete", join(this.path, filename));
this.emit('create',join(this.path,filename));
}
else{
this.emit('delete',join(this.path,filename))
}
}
});
@ -39,6 +40,6 @@ export class CommonDiffWatcher extends event.EventEmitter implements IDiffWatche
return this._path;
}
watchClose(){
this._watcher.close();
this._watcher.close()
}
}

View File

@ -2,6 +2,7 @@ import { EventEmitter } from "events";
import { DocumentAccessor } from "../../model/doc";
import { DiffWatcherEvent, IDiffWatcher, linkWatcher } from "../watcher";
export class WatcherCompositer extends EventEmitter implements IDiffWatcher{
refWatchers : IDiffWatcher[];
on<U extends keyof DiffWatcherEvent>(event:U,listener:DiffWatcherEvent[U]): this{

View File

@ -1,16 +1,16 @@
import { FSWatcher, watch } from "chokidar";
import { EventEmitter } from "events";
import { join } from "path";
import { DocumentAccessor } from "../../model/doc";
import { DiffWatcherEvent, IDiffWatcher } from "../watcher";
import { setupHelp, setupRecursive } from "./util";
import {watch, FSWatcher} from 'chokidar';
import { EventEmitter } from 'events';
import { join } from 'path';
import { DocumentAccessor } from '../../model/doc';
import { DiffWatcherEvent, IDiffWatcher } from '../watcher';
import { setupHelp, setupRecursive } from './util';
type RecursiveWatcherOption={
/** @default true */
watchFile?: boolean;
watchFile?:boolean,
/** @default false */
watchDir?: boolean;
};
watchDir?:boolean,
}
export class RecursiveWatcher extends EventEmitter implements IDiffWatcher {
on<U extends keyof DiffWatcherEvent>(event:U,listener:DiffWatcherEvent[U]): this{
@ -20,7 +20,7 @@ export class RecursiveWatcher extends EventEmitter implements IDiffWatcher {
return super.emit(event,...arg);
}
readonly path: string;
private watcher: FSWatcher;
private watcher: FSWatcher
constructor(path:string, option:RecursiveWatcherOption = {
watchDir:false,
@ -52,7 +52,7 @@ export class RecursiveWatcher extends EventEmitter implements IDiffWatcher {
}).on("unlinkDir",path=>{
const cpath = path;
this.emit("delete",cpath);
});
})
}
}
async setup(cntr: DocumentAccessor): Promise<void> {

View File

@ -5,17 +5,18 @@ const { readdir } = promises;
import { DocumentAccessor } from "../../model/doc";
import { IDiffWatcher } from "../watcher";
function setupCommon(watcher:IDiffWatcher,basepath:string,initial_filenames:string[],cur:string[]){
//Todo : reduce O(nm) to O(n+m) using hash map.
let added = cur.filter(x => !initial_filenames.includes(x));
let deleted = initial_filenames.filter(x=>!cur.includes(x));
for (const it of added) {
const cpath = join(basepath,it);
watcher.emit("create", cpath);
watcher.emit('create',cpath);
}
for (const it of deleted){
const cpath = join(basepath,it);
watcher.emit("delete", cpath);
watcher.emit('delete',cpath);
}
}
export async function setupHelp(watcher:IDiffWatcher,basepath:string,cntr:DocumentAccessor){
@ -29,8 +30,6 @@ export async function setupRecursive(watcher: IDiffWatcher, basepath: string, cn
const initial_filenames = initial_document.map(x=>x.filename);
const cur = await readdir(basepath,{withFileTypes:true});
setupCommon(watcher,basepath,initial_filenames,cur.map(x=>x.name));
await Promise.all([
cur.filter(x => x.isDirectory())
.map(x => setupHelp(watcher, join(basepath, x.name), cntr)),
]);
await Promise.all([cur.filter(x=>x.isDirectory())
.map(x=>setupHelp(watcher,join(basepath,x.name),cntr))]);
}

View File

@ -2,9 +2,10 @@ import { EventEmitter } from "events";
import { DocumentAccessor } from "../../model/doc";
import { DiffWatcherEvent, IDiffWatcher, linkWatcher } from "../watcher";
export class WatcherFilter extends EventEmitter implements IDiffWatcher{
refWatcher : IDiffWatcher;
filter: (filename: string) => boolean;
filter : (filename:string)=>boolean;;
on<U extends keyof DiffWatcherEvent>(event:U,listener:DiffWatcherEvent[U]): this{
return super.on(event,listener);
}
@ -21,18 +22,22 @@ export class WatcherFilter extends EventEmitter implements IDiffWatcher {
if(this.filter(prev)){
if(this.filter(cur)){
return super.emit("change",prev,cur);
} else {
}
else{
return super.emit("delete",cur);
}
} else {
}
else{
if(this.filter(cur)){
return super.emit("create",cur);
}
}
return false;
} else if (!this.filter(arg[0])) {
}
else if(!this.filter(arg[0])){
return false;
} else return super.emit(event, ...arg);
}
else return super.emit(event,...arg);
}
constructor(refWatcher:IDiffWatcher, filter:(filename:string)=>boolean){
super();

View File

@ -1,12 +1,12 @@
import { request } from "http";
import { decode, sign, TokenExpiredError, verify } from "jsonwebtoken";
import Knex from "knex";
import Koa from "koa";
import Router from "koa-router";
import { createKnexUserController } from "./db/mod";
import { IUser, UserAccessor } from "./model/mod";
import { sendError } from "./route/error_handler";
import Knex from "knex";
import { createKnexUserController } from "./db/mod";
import { request } from "http";
import { get_setting } from "./SettingConfig";
import { IUser, UserAccessor } from "./model/mod";
type PayloadInfo = {
username: string;
@ -19,14 +19,14 @@ export type UserState = {
const isUserState = (obj: object | string): obj is PayloadInfo => {
if (typeof obj === "string") return false;
return "username" in obj && "permission" in obj
&& (obj as { permission: unknown }).permission instanceof Array;
return "username" in obj && "permission" in obj &&
(obj as { permission: unknown }).permission instanceof Array;
};
type RefreshPayloadInfo = { username: string };
const isRefreshToken = (obj: object | string): obj is RefreshPayloadInfo => {
if (typeof obj === "string") return false;
return "username" in obj
&& typeof (obj as { username: unknown }).username === "string";
return "username" in obj &&
typeof (obj as { username: unknown }).username === "string";
};
export const accessTokenName = "access_token";
@ -86,8 +86,9 @@ function setToken(
sameSite: "strict",
expires: new Date(Date.now() + expiredtime * 1000),
});
}
export const createLoginMiddleware = (userController: UserAccessor) => async (ctx: Koa.Context, _next: Koa.Next) => {
};
export const createLoginMiddleware = (userController: UserAccessor) =>
async (ctx: Koa.Context, _next: Koa.Next) => {
const setting = get_setting();
const secretKey = setting.jwt_secretkey;
const body = ctx.request.body;
@ -143,18 +144,18 @@ export const createLoginMiddleware = (userController: UserAccessor) => async (ct
};
export const LogoutMiddleware = (ctx: Koa.Context, next: Koa.Next) => {
const setting = get_setting();
const setting = get_setting()
ctx.cookies.set(accessTokenName, null);
ctx.cookies.set(refreshTokenName, null);
ctx.body = {
ok: true,
username: "",
permission: setting.guest,
permission: setting.guest
};
return;
};
export const createUserMiddleWare =
(userController: UserAccessor) => async (ctx: Koa.ParameterizedContext<UserState>, next: Koa.Next) => {
export const createUserMiddleWare = (userController: UserAccessor) =>
async (ctx: Koa.ParameterizedContext<UserState>, next: Koa.Next) => {
const refreshToken = refreshTokenHandler(userController);
const setting = get_setting();
const setGuest = async () => {
@ -165,7 +166,8 @@ export const createUserMiddleWare =
};
return await refreshToken(ctx, setGuest, next);
};
const refreshTokenHandler = (cntr: UserAccessor) => async (ctx: Koa.Context, fail: Koa.Next, next: Koa.Next) => {
const refreshTokenHandler = (cntr: UserAccessor) =>
async (ctx: Koa.Context, fail: Koa.Next, next: Koa.Next) => {
const accessPayload = ctx.cookies.get(accessTokenName);
const setting = get_setting();
const secretKey = setting.jwt_secretkey;
@ -216,9 +218,10 @@ const refreshTokenHandler = (cntr: UserAccessor) => async (ctx: Koa.Context, fai
}
}
return await next();
}
};
export const createRefreshTokenMiddleware = (cntr: UserAccessor) => async (ctx: Koa.Context, next: Koa.Next) => {
};
export const createRefreshTokenMiddleware = (cntr: UserAccessor) =>
async (ctx: Koa.Context, next: Koa.Next) => {
const handler = refreshTokenHandler(cntr);
await handler(ctx, fail, success);
async function fail() {
@ -228,7 +231,7 @@ export const createRefreshTokenMiddleware = (cntr: UserAccessor) => async (ctx:
...user,
};
ctx.type = "json";
}
};
async function success() {
const user = ctx.state.user as PayloadInfo;
ctx.body = {
@ -237,16 +240,17 @@ export const createRefreshTokenMiddleware = (cntr: UserAccessor) => async (ctx:
refreshExpired: Math.floor(Date.now() / 1000 + accessExpiredTime),
};
ctx.type = "json";
}
};
export const resetPasswordMiddleware = (cntr: UserAccessor) => async (ctx: Koa.Context, next: Koa.Next) => {
};
export const resetPasswordMiddleware = (cntr: UserAccessor) =>
async (ctx: Koa.Context, next: Koa.Next) => {
const body = ctx.request.body;
if (typeof body !== "object" || !("username" in body) || !("oldpassword" in body) || !("newpassword" in body)) {
if (typeof body !== "object" || !('username' in body) || !('oldpassword' in body) || !('newpassword' in body)) {
return sendError(400, "request body is invalid format");
}
const username = body["username"];
const oldpw = body["oldpassword"];
const newpw = body["newpassword"];
const username = body['username'];
const oldpw = body['oldpassword'];
const newpw = body['newpassword'];
if (typeof username !== "string" || typeof oldpw !== "string" || typeof newpw !== "string") {
return sendError(400, "request body is invalid format");
}
@ -258,16 +262,16 @@ export const resetPasswordMiddleware = (cntr: UserAccessor) => async (ctx: Koa.C
return sendError(403, "not authorized");
}
user.reset_password(newpw);
ctx.body = { ok: true };
ctx.type = "json";
};
ctx.body = { ok: true }
ctx.type = 'json';
}
export function createLoginRouter(userController: UserAccessor) {
const router = new Router();
router.post("/login", createLoginMiddleware(userController));
router.post("/logout", LogoutMiddleware);
router.post("/refresh", createRefreshTokenMiddleware(userController));
router.post("/reset", resetPasswordMiddleware(userController));
router.post('/login', createLoginMiddleware(userController));
router.post('/logout', LogoutMiddleware);
router.post('/refresh', createRefreshTokenMiddleware(userController));
router.post('/reset', resetPasswordMiddleware(userController));
return router;
}
@ -280,6 +284,6 @@ export const getAdmin = async (cntr: UserAccessor) => {
};
export const isAdminFirst = (admin: IUser) => {
return admin.password.hash === "unchecked"
&& admin.password.salt === "unchecked";
return admin.password.hash === "unchecked" &&
admin.password.salt === "unchecked";
};

View File

@ -1,16 +1,16 @@
import { JSONMap } from "../types/json";
import { check_type } from "../util/type_check";
import { TagAccessor } from "./tag";
import {TagAccessor} from './tag';
import {check_type} from '../util/type_check'
import {JSONMap} from '../types/json';
export interface DocumentBody{
title: string;
content_type: string;
basepath: string;
filename: string;
modified_at: number;
content_hash: string;
additional: JSONMap;
tags: string[]; // eager loading
title : string,
content_type : string,
basepath : string,
filename : string,
modified_at : number,
content_hash : string,
additional : JSONMap,
tags : string[],//eager loading
}
export const MetaContentBody = {
@ -21,71 +21,71 @@ export const MetaContentBody = {
content_hash : "string",
additional : "object",
tags : "string[]",
};
}
export const isDocBody = (c : any):c is DocumentBody =>{
return check_type<DocumentBody>(c,MetaContentBody);
};
}
export interface Document extends DocumentBody{
readonly id: number;
readonly created_at:number;
readonly deleted_at:number|null;
}
};
export const isDoc = (c: any):c is Document =>{
if ("id" in c && typeof c["id"] === "number") {
if('id' in c && typeof c['id'] === "number"){
const {id, ...rest} = c;
return isDocBody(rest);
}
return false;
};
}
export type QueryListOption = {
/**
* search word
*/
word?: string;
allow_tag?: string[];
word?:string,
allow_tag?:string[],
/**
* limit of list
* @default 20
*/
limit?: number;
limit?:number,
/**
* use offset if true, otherwise
* @default false
*/
use_offset?: boolean;
use_offset?:boolean,
/**
* cursor of documents
*/
cursor?: number;
cursor?:number,
/**
* offset of documents
*/
offset?: number;
offset?:number,
/**
* tag eager loading
* @default true
*/
eager_loading?: boolean;
eager_loading?:boolean,
/**
* content type
*/
content_type?: string;
};
content_type?:string
}
export interface DocumentAccessor{
/**
* find list by option
* @returns documents list
*/
findList: (option?: QueryListOption) => Promise<Document[]>;
findList: (option?:QueryListOption)=>Promise<Document[]>,
/**
* @returns document if exist, otherwise undefined
*/
findById: (id: number, tagload?: boolean) => Promise<Document | undefined>;
findById: (id:number,tagload?:boolean)=> Promise<Document| undefined>,
/**
* find by base path and filename.
* if you call this function with filename, its return array length is 0 or 1.
@ -98,7 +98,7 @@ export interface DocumentAccessor {
/**
* search by in document
*/
search: (search_word: string) => Promise<Document[]>;
search:(search_word:string)=>Promise<Document[]>
/**
* update document except tag.
*/
@ -126,4 +126,4 @@ export interface DocumentAccessor {
* @returns if success, return true
*/
delTag:(c:Document,tag_name:string)=>Promise<boolean>;
}
};

View File

@ -1,3 +1,3 @@
export * from "./doc";
export * from "./tag";
export * from "./user";
export * from './doc';
export * from './tag';
export * from './user';

View File

@ -1,6 +1,6 @@
export interface Tag{
readonly name: string;
description?: string;
readonly name: string,
description?: string
}
export interface TagCount{

View File

@ -1,20 +1,20 @@
import { createHmac, randomBytes } from "crypto";
import { createHmac, randomBytes } from 'crypto';
function hashForPassword(salt: string,password:string){
return createHmac("sha256", salt).update(password).digest("hex");
return createHmac('sha256', salt).update(password).digest('hex')
}
function createPasswordHashAndSalt(password: string): { salt: string; hash: string } {
const secret = randomBytes(32).toString("hex");
function createPasswordHashAndSalt(password: string):{salt:string,hash:string}{
const secret = randomBytes(32).toString('hex');
return {
salt: secret,
hash: hashForPassword(secret, password),
hash: hashForPassword(secret,password)
};
}
export class Password{
private _salt:string;
private _hash:string;
constructor(pw: string | { salt: string; hash: string }) {
constructor(pw : string|{salt:string,hash:string}){
const {salt,hash} = typeof pw === "string" ? createPasswordHashAndSalt(pw) : pw;
this._hash = hash;
this._salt = salt;
@ -27,17 +27,13 @@ export class Password {
check_password(password: string):boolean{
return this._hash === hashForPassword(this._salt,password);
}
get salt() {
return this._salt;
}
get hash() {
return this._hash;
}
get salt(){return this._salt;}
get hash(){return this._hash;}
}
export interface UserCreateInput{
username: string;
password: string;
username: string,
password: string
}
export interface IUser{
@ -64,21 +60,21 @@ export interface IUser {
* @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>;
createUser: (input :UserCreateInput)=> Promise<IUser|undefined>,
/**
* find user
*/
findUser: (username: string) => Promise<IUser | undefined>;
findUser: (username: string)=> Promise<IUser|undefined>,
/**
* remove user
* @returns if user exist, true
*/
delUser: (username: string) => Promise<boolean>;
}
delUser: (username: string)=>Promise<boolean>
};

View File

@ -1,6 +1,7 @@
import Koa from "koa";
import { UserState } from "../login";
import { sendError } from "../route/error_handler";
import Koa from 'koa';
import { UserState } from '../login';
import { sendError } from '../route/error_handler';
export enum Permission{
//========
@ -20,7 +21,7 @@ export enum Permission {
/** remove tag from document */
//removeTagContent = 'removeTagContent',
/** ModifyTagInDoc */
ModifyTag = "ModifyTag",
ModifyTag = 'ModifyTag',
/** find documents with query */
//findAllContent = 'findAllContent',
@ -28,15 +29,15 @@ export enum Permission {
//findOneContent = 'findOneContent',
/** view content*/
//viewContent = 'viewContent',
QueryContent = "QueryContent",
QueryContent = 'QueryContent',
/** modify description about the one tag. */
modifyTagDesc = "ModifyTagDesc",
modifyTagDesc = 'ModifyTagDesc',
}
export const createPermissionCheckMiddleware =
(...permissions: string[]) => async (ctx: Koa.ParameterizedContext<UserState>, next: Koa.Next) => {
const user = ctx.state["user"];
export const createPermissionCheckMiddleware = (...permissions:string[]) =>
async (ctx: Koa.ParameterizedContext<UserState>,next:Koa.Next) => {
const user = ctx.state['user'];
if(user.username === "admin"){
return await next();
}
@ -45,14 +46,15 @@ export const createPermissionCheckMiddleware =
if(!permissions.map(p=>user_permission.includes(p)).every(x=>x)){
if(user.username === ""){
return sendError(401,"you are guest. login needed.");
} else return sendError(403, "do not have permission");
}
else return sendError(403,"do not have permission");
}
await next();
};
}
export const AdminOnlyMiddleware = async (ctx: Koa.ParameterizedContext<UserState>,next:Koa.Next)=>{
const user = ctx.state["user"];
const user = ctx.state['user'];
if(user.username !== "admin"){
return sendError(403,"admin only");
}
await next();
};
}

View File

@ -1,23 +1,21 @@
import { DefaultContext, Middleware, Next, ParameterizedContext } from "koa";
import compose from "koa-compose";
import Router, { IParamMiddleware } from "koa-router";
import ComicRouter from "./comic";
import { ContentContext } from "./context";
import VideoRouter from "./video";
import { DefaultContext, Middleware, Next, ParameterizedContext } from 'koa';
import compose from 'koa-compose';
import Router, { IParamMiddleware } from 'koa-router';
import { ContentContext } from './context';
import ComicRouter from './comic';
import VideoRouter from './video';
const table:{[s:string]:Router|undefined} = {
"comic": new ComicRouter(),
"video": new VideoRouter(),
};
const all_middleware =
(cont: string | undefined, restarg: string | undefined) =>
async (ctx: ParameterizedContext<ContentContext, DefaultContext>, next: Next) => {
"comic": new ComicRouter,
"video": new VideoRouter
}
const all_middleware = (cont: string|undefined, restarg: string|undefined)=>async (ctx:ParameterizedContext<ContentContext,DefaultContext>,next:Next)=>{
if(cont == undefined){
ctx.status = 404;
return;
}
if(ctx.state.location.type != cont){
console.error("not matched");
console.error("not matched")
ctx.status = 404;
return;
}
@ -46,12 +44,12 @@ const all_middleware =
export class AllContentRouter extends Router<ContentContext>{
constructor(){
super();
this.get("/:content_type", async (ctx, next) => {
this.get('/:content_type',async (ctx,next)=>{
return await (all_middleware(ctx.params["content_type"],undefined))(ctx,next);
});
this.get("/:content_type/:rest(.*)", async (ctx, next) => {
this.get('/:content_type/:rest(.*)', async (ctx,next) => {
const cont = ctx.params["content_type"] as string;
return await (all_middleware(cont,ctx.params["rest"]))(ctx,next);
});
}
}
};

View File

@ -1,8 +1,13 @@
import { Context, DefaultContext, DefaultState, Next } from "koa";
import Router from "koa-router";
import { createReadableStreamFromZip, entriesByNaturalOrder, readZip, ZipAsync } from "../util/zipwrap";
import { ContentContext } from "./context";
import {
createReadableStreamFromZip,
entriesByNaturalOrder,
readZip,
ZipAsync,
} from "../util/zipwrap";
import { since_last_modified } from "./util";
import { ContentContext } from "./context";
import Router from "koa-router";
/**
* zip stream cache.
@ -16,7 +21,8 @@ async function acquireZip(path: string) {
ZipStreamCache[path] = [ret, 1];
//console.log(`acquire ${path} 1`);
return ret;
} else {
}
else {
const [ret, refCount] = ZipStreamCache[path];
ZipStreamCache[path] = [ret, refCount + 1];
//console.log(`acquire ${path} ${refCount + 1}`);
@ -32,7 +38,8 @@ function releaseZip(path: string) {
if (refCount === 1) {
ref.close();
delete ZipStreamCache[path];
} else {
}
else{
ZipStreamCache[path] = [ref, refCount - 1];
}
}
@ -51,7 +58,7 @@ async function renderZipImage(ctx: Context, path: string, page: number) {
if (since_last_modified(ctx, last_modified)) {
return;
}
const read_stream = await createReadableStreamFromZip(zip, entry);
const read_stream = (await createReadableStreamFromZip(zip, entry));
/**Exceptions (ECONNRESET, ECONNABORTED) may be thrown when processing this request
* for reasons such as when the browser unexpectedly closes the connection.
* Once such an exception is raised, the stream is not properly destroyed,

View File

@ -1,52 +1,47 @@
import { Context, Next } from "koa";
import Router from "koa-router";
import { join } from "path";
import { Document, DocumentAccessor, isDocBody } from "../model/doc";
import { QueryListOption } from "../model/doc";
import {
AdminOnlyMiddleware as AdminOnly,
createPermissionCheckMiddleware as PerCheck,
Permission as Per,
} from "../permission/permission";
import { AllContentRouter } from "./all";
import { ContentLocation } from "./context";
import { sendError } from "./error_handler";
import { ParseQueryArgString, ParseQueryArray, ParseQueryBoolean, ParseQueryNumber } from "./util";
import { Context, Next } from 'koa';
import Router from 'koa-router';
import {Document, DocumentAccessor, isDocBody} from '../model/doc';
import {QueryListOption} from '../model/doc';
import {ParseQueryNumber, ParseQueryArray, ParseQueryBoolean, ParseQueryArgString} from './util'
import {sendError} from './error_handler';
import { join } from 'path';
import {AllContentRouter} from './all';
import {createPermissionCheckMiddleware as PerCheck, Permission as Per, AdminOnlyMiddleware as AdminOnly} from '../permission/permission';
import {ContentLocation} from './context'
const ContentIDHandler = (controller: DocumentAccessor) => async (ctx: Context,next: Next)=>{
const num = Number.parseInt(ctx.params["num"]);
const num = Number.parseInt(ctx.params['num']);
let document = await controller.findById(num,true);
if (document == undefined){
return sendError(404,"document does not exist.");
}
ctx.body = document;
ctx.type = "json";
ctx.type = 'json';
console.log(document.additional);
};
const ContentTagIDHandler = (controller: DocumentAccessor) => async (ctx: Context,next: Next)=>{
const num = Number.parseInt(ctx.params["num"]);
const num = Number.parseInt(ctx.params['num']);
let document = await controller.findById(num,true);
if (document == undefined){
return sendError(404,"document does not exist.");
}
ctx.body = document.tags;
ctx.type = "json";
ctx.type = 'json';
};
const ContentQueryHandler = (controller : DocumentAccessor) => async (ctx: Context,next: Next)=>{
let query_limit = ctx.query["limit"];
let query_cursor = ctx.query["cursor"];
let query_word = ctx.query["word"];
let query_content_type = ctx.query["content_type"];
let query_offset = ctx.query["offset"];
let query_use_offset = ctx.query["use_offset"];
if (
query_limit instanceof Array
let query_limit = (ctx.query['limit']);
let query_cursor = (ctx.query['cursor']);
let query_word = (ctx.query['word']);
let query_content_type = (ctx.query['content_type']);
let query_offset = (ctx.query['offset']);
let query_use_offset = ctx.query['use_offset'];
if(query_limit instanceof Array
|| query_cursor instanceof Array
|| query_word instanceof Array
|| query_content_type instanceof Array
|| query_offset instanceof Array
|| query_use_offset instanceof Array
) {
|| query_use_offset instanceof Array){
return sendError(400,"paramter can not be array");
}
const limit = ParseQueryNumber(query_limit);
@ -57,7 +52,7 @@ const ContentQueryHandler = (controller: DocumentAccessor) => async (ctx: Contex
if(limit === NaN || cursor === NaN || offset === NaN){
return sendError(400,"parameter limit, cursor or offset is not a number");
}
const allow_tag = ParseQueryArray(ctx.query["allow_tag"]);
const allow_tag = ParseQueryArray(ctx.query['allow_tag']);
const [ok,use_offset] = ParseQueryBoolean(query_use_offset);
if(!ok){
return sendError(400,"use_offset must be true or false.");
@ -74,29 +69,28 @@ const ContentQueryHandler = (controller: DocumentAccessor) => async (ctx: Contex
};
let document = await controller.findList(option);
ctx.body = document;
ctx.type = "json";
};
ctx.type = 'json';
}
const UpdateContentHandler = (controller : DocumentAccessor) => async (ctx: Context, next: Next) => {
const num = Number.parseInt(ctx.params["num"]);
const num = Number.parseInt(ctx.params['num']);
if (ctx.request.type !== "json") {
if(ctx.request.type !== 'json'){
return sendError(400,"update fail. invalid document type: it is not json.");
}
if(typeof ctx.request.body !== "object"){
return sendError(400,"update fail. invalid argument: not");
}
const content_desc: Partial<Document> & {id: number} = {
id: num,
...ctx.request.body,
id:num,...ctx.request.body
};
const success = await controller.update(content_desc);
ctx.body = JSON.stringify(success);
ctx.type = "json";
};
ctx.type = 'json';
}
const AddTagHandler = (controller: DocumentAccessor)=>async (ctx: Context, next: Next)=>{
let tag_name = ctx.params["tag"];
const num = Number.parseInt(ctx.params["num"]);
let tag_name = ctx.params['tag'];
const num = Number.parseInt(ctx.params['num']);
if(typeof tag_name === undefined){
return sendError(400,"??? Unreachable");
}
@ -107,11 +101,11 @@ const AddTagHandler = (controller: DocumentAccessor) => async (ctx: Context, nex
}
const r = await controller.addTag(c,tag_name);
ctx.body = JSON.stringify(r);
ctx.type = "json";
ctx.type = 'json';
};
const DelTagHandler = (controller: DocumentAccessor)=>async (ctx: Context, next: Next)=>{
let tag_name = ctx.params["tag"];
const num = Number.parseInt(ctx.params["num"]);
let tag_name = ctx.params['tag'];
const num = Number.parseInt(ctx.params['num']);
if(typeof tag_name === undefined){
return sendError(400,"?? Unreachable");
}
@ -122,16 +116,16 @@ const DelTagHandler = (controller: DocumentAccessor) => async (ctx: Context, nex
}
const r = await controller.delTag(c,tag_name);
ctx.body = JSON.stringify(r);
ctx.type = "json";
};
ctx.type = 'json';
}
const DeleteContentHandler = (controller : DocumentAccessor) => async (ctx: Context, next: Next) => {
const num = Number.parseInt(ctx.params["num"]);
const num = Number.parseInt(ctx.params['num']);
const r = await controller.del(num);
ctx.body = JSON.stringify(r);
ctx.type = "json";
ctx.type = 'json';
};
const ContentHandler = (controller : DocumentAccessor) => async (ctx:Context, next:Next) => {
const num = Number.parseInt(ctx.params["num"]);
const num = Number.parseInt(ctx.params['num']);
let document = await controller.findById(num,true);
if (document == undefined){
return sendError(404,"document does not exist.");
@ -140,7 +134,7 @@ const ContentHandler = (controller: DocumentAccessor) => async (ctx: Context, ne
return sendError(404,"document has been removed.");
}
const path = join(document.basepath,document.filename);
ctx.state["location"] = {
ctx.state['location'] = {
path:path,
type:document.content_type,
additional:document.additional,
@ -160,8 +154,8 @@ export const getContentRouter = (controller: DocumentAccessor) => {
ret.del("/:num(\\d+)/tags/:tag",PerCheck(Per.ModifyTag),DelTagHandler(controller));
ret.del("/:num(\\d+)",AdminOnly,DeleteContentHandler(controller));
ret.all("/:num(\\d+)/(.*)",PerCheck(Per.QueryContent),ContentHandler(controller));
ret.use("/:num(\\d+)", PerCheck(Per.QueryContent), (new AllContentRouter()).routes());
ret.use("/:num(\\d+)",PerCheck(Per.QueryContent),(new AllContentRouter).routes());
return ret;
};
}
export default getContentRouter;

View File

@ -1,8 +1,8 @@
export type ContentLocation = {
path: string;
type: string;
additional: object | undefined;
};
export interface ContentContext {
location: ContentLocation;
path:string,
type:string,
additional:object|undefined,
}
export interface ContentContext{
location:ContentLocation
}

View File

@ -1,9 +1,9 @@
import { Context, Next } from "koa";
import {Context, Next} from 'koa';
export interface ErrorFormat {
code: number;
message: string;
detail?: string;
code: number,
message: string,
detail?: string
}
class ClientRequestError implements Error{
@ -21,8 +21,8 @@ class ClientRequestError implements Error {
const code_to_message_table:{[key:number]:string|undefined} = {
400:"BadRequest",
404: "NotFound",
};
404:"NotFound"
}
export const error_handler = async (ctx:Context,next: Next)=>{
try {
@ -32,18 +32,19 @@ export const error_handler = async (ctx: Context, next: Next) => {
const body : ErrorFormat= {
code: err.code,
message: code_to_message_table[err.code] ?? "",
detail: err.message,
};
detail: err.message
}
ctx.status = err.code;
ctx.body = body;
} else {
}
else{
throw err;
}
}
};
}
export const sendError = (code:number,message?:string) =>{
throw new ClientRequestError(code,message ?? "");
};
}
export default error_handler;

View File

@ -1,22 +1,25 @@
import {Context, Next} from "koa";
import Router,{RouterContext} from "koa-router";
import { TagAccessor } from "../model/tag";
import { createPermissionCheckMiddleware as PerCheck, Permission } from "../permission/permission";
import { sendError } from "./error_handler";
import { createPermissionCheckMiddleware as PerCheck, Permission } from "../permission/permission";
export function getTagRounter(tagController: TagAccessor){
let router = new Router();
router.get("/", PerCheck(Permission.QueryContent), async (ctx: Context) => {
router.get("/",PerCheck(Permission.QueryContent),
async (ctx: Context)=>{
if(ctx.query["withCount"]){
const c = await tagController.getAllTagCount();
ctx.body = c;
} else {
}
else {
const c = await tagController.getAllTagList();
ctx.body = c;
}
ctx.type = "json";
});
router.get("/:tag_name", PerCheck(Permission.QueryContent), async (ctx: RouterContext) => {
router.get("/:tag_name", PerCheck(Permission.QueryContent),
async (ctx: RouterContext)=>{
const tag_name = ctx.params["tag_name"];
const c = await tagController.getTagByName(tag_name);
if (!c){

View File

@ -1,4 +1,6 @@
import { Context } from "koa";
import {Context} from 'koa';
export function ParseQueryNumber(s: string[] | string|undefined): number| undefined{
if(s === undefined) return undefined;
@ -17,14 +19,14 @@ export function ParseQueryArgString(s: string[] | string | undefined) {
export function ParseQueryBoolean(s: string[] |string|undefined): [boolean,boolean|undefined]{
let value:boolean|undefined;
if (s === "true") {
if(s === "true")
value = true;
} else if (s === "false") {
else if(s === "false")
value = false;
} else if (s === undefined) {
else if(s === undefined)
value = undefined;
} else return [false, undefined];
return [true, value];
else return [false,undefined]
return [true,value]
}
export function since_last_modified(ctx: Context, last_modified: Date): boolean{

View File

@ -1,13 +1,13 @@
import { createReadStream, promises } from "fs";
import { Context } from "koa";
import Router from "koa-router";
import { ContentContext } from "./context";
import {Context } from 'koa';
import {promises, createReadStream} from "fs";
import {ContentContext} from './context';
import Router from 'koa-router';
export async function renderVideo(ctx: Context,path : string){
const ext = path.trim().split(".").pop();
const ext = path.trim().split('.').pop();
if(ext === undefined) {
//ctx.status = 404;
console.error(`${path}:${ext}`);
console.error(`${path}:${ext}`)
return;
}
ctx.response.type = ext;
@ -15,10 +15,10 @@ export async function renderVideo(ctx: Context, path: string) {
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('Last-Modified',(new Date(stat.mtime).toUTCString()));
ctx.set('Date', new Date().toUTCString());
ctx.set("Accept-Ranges", "bytes");
if (range_text === "") {
if(range_text === ''){
end = 1024*512;
end = Math.min(end,stat.size-1);
if(start > end){
@ -29,7 +29,8 @@ export async function renderVideo(ctx: Context, path: string) {
ctx.length = stat.size;
let stream = createReadStream(path);
ctx.body = stream;
} else {
}
else{
const m = range_text.match(/^bytes=(\d+)-(\d*)/);
if(m === null){
ctx.status = 416;
@ -47,7 +48,7 @@ export async function renderVideo(ctx: Context, path: string) {
ctx.response.set("Content-Range",`bytes ${start}-${end}/${stat.size}`);
ctx.body = createReadStream(path,{
start:start,
end: end,
end:end
});//inclusive range.
}
}
@ -60,7 +61,7 @@ export class VideoRouter extends Router<ContentContext> {
});
this.get("/thumbnail", async (ctx,next)=>{
await renderVideo(ctx,ctx.state.location.path);
});
})
}
}

View File

@ -1,3 +1,4 @@
export interface PaginationOption{
cursor:number;
limit:number;

View File

@ -1,3 +1,4 @@
export interface ITokenizer{
tokenize(s:string):string[];
}

View File

@ -1,21 +1,22 @@
import Koa from "koa";
import Router from "koa-router";
import Koa from 'koa';
import Router from 'koa-router';
import { connectDB } from "./database";
import { createDiffRouter, DiffManager } from "./diff/mod";
import { get_setting, SettingConfig } from "./SettingConfig";
import {get_setting, SettingConfig} from './SettingConfig';
import {connectDB} from './database';
import {DiffManager, createDiffRouter} from './diff/mod';
import { createReadStream, readFileSync } from "fs";
import bodyparser from "koa-bodyparser";
import { createKnexDocumentAccessor, createKnexTagController, createKnexUserController } from "./db/mod";
import { createLoginRouter, createUserMiddleWare, getAdmin, isAdminFirst } from "./login";
import getContentRouter from "./route/contents";
import { error_handler } from "./route/error_handler";
import { createReadStream, readFileSync } from 'fs';
import getContentRouter from './route/contents';
import { createKnexDocumentAccessor, createKnexTagController, createKnexUserController } from './db/mod';
import bodyparser from 'koa-bodyparser';
import {error_handler} from './route/error_handler';
import {createUserMiddleWare, createLoginRouter, isAdminFirst, getAdmin} from './login';
import {createInterface as createReadlineInterface} from 'readline';
import { DocumentAccessor, UserAccessor, TagAccessor } from './model/mod';
import { createComicWatcher } from './diff/watcher/comic_watcher';
import { getTagRounter } from './route/tags';
import { createInterface as createReadlineInterface } from "readline";
import { createComicWatcher } from "./diff/watcher/comic_watcher";
import { DocumentAccessor, TagAccessor, UserAccessor } from "./model/mod";
import { getTagRounter } from "./route/tags";
class ServerApplication{
readonly userController: UserAccessor;
@ -25,10 +26,9 @@ class ServerApplication {
readonly app: Koa;
private index_html:string;
private constructor(controller:{
userController: UserAccessor;
documentController: DocumentAccessor;
tagController: TagAccessor;
}) {
userController: UserAccessor,
documentController:DocumentAccessor,
tagController: TagAccessor}){
this.userController = controller.userController;
this.documentController = controller.documentController;
this.tagController = controller.tagController;
@ -46,7 +46,7 @@ class ServerApplication {
if(await isAdminFirst(userAdmin)){
const rl = createReadlineInterface({
input:process.stdin,
output: process.stdout,
output:process.stdout
});
const pw = await new Promise((res:(data:string)=>void,err)=>{
rl.question("put admin password :",(data)=>{
@ -63,72 +63,68 @@ class ServerApplication {
let diff_router = createDiffRouter(this.diffManger);
this.diffManger.register("comic",createComicWatcher());
console.log("setup router");
let router = new Router();
router.use("/api/(.*)", async (ctx, next) => {
router.use("/api/*", async (ctx,next)=>{
//For CORS
ctx.res.setHeader("access-control-allow-origin", "*");
await next();
});
router.use("/api/diff", diff_router.routes());
router.use("/api/diff", diff_router.allowedMethods());
const content_router = getContentRouter(this.documentController);
router.use("/api/doc", content_router.routes());
router.use("/api/doc", content_router.allowedMethods());
const tags_router = getTagRounter(this.tagController);
router.use("/api/tags", tags_router.allowedMethods());
router.use("/api/tags", tags_router.routes());
router.use('/api/diff',diff_router.routes());
router.use('/api/diff',diff_router.allowedMethods());
this.serve_with_meta_index(router);
this.serve_index(router);
this.serve_static_file(router);
const content_router = getContentRouter(this.documentController);
router.use('/api/doc',content_router.routes());
router.use('/api/doc',content_router.allowedMethods());
const tags_router = getTagRounter(this.tagController);
router.use("/api/tags",tags_router.allowedMethods());
router.use("/api/tags",tags_router.routes());
const login_router = createLoginRouter(this.userController);
router.use("/user", login_router.routes());
router.use("/user", login_router.allowedMethods());
router.use('/user',login_router.routes());
router.use('/user',login_router.allowedMethods());
if(setting.mode == "development"){
let mm_count = 0;
app.use(async (ctx,next)=>{
console.log(`==========================${mm_count++}`);
const ip = (ctx.get("X-Real-IP")) ?? ctx.ip;
const fromClient = ctx.state["user"].username === "" ? ip : ctx.state["user"].username;
const fromClient = ctx.state['user'].username === "" ? ip : ctx.state['user'].username;
console.log(`${fromClient} : ${ctx.method} ${ctx.url}`);
await next();
//console.log(`404`);
});
}
});}
app.use(router.routes());
app.use(router.allowedMethods());
console.log("setup done");
}
private serve_index(router:Router){
const serveindex = (url:string)=>{
router.get(url, (ctx)=>{
ctx.type = "html";
ctx.body = this.index_html;
ctx.type = 'html'; ctx.body = this.index_html;
const setting = get_setting();
ctx.set("x-content-type-options", "no-sniff");
ctx.set('x-content-type-options','no-sniff');
if(setting.mode === "development"){
ctx.set("cache-control", "no-cache");
} else {
ctx.set("cache-control", "public, max-age=3600");
ctx.set('cache-control','no-cache');
}
});
};
serveindex("/");
serveindex("/doc/:rest(.*)");
serveindex("/search");
serveindex("/login");
serveindex("/profile");
serveindex("/difference");
serveindex("/setting");
serveindex("/tags");
else{
ctx.set('cache-control','public, max-age=3600');
}
})
}
serveindex('/');
serveindex('/doc/:rest(.*)');
serveindex('/search');
serveindex('/login');
serveindex('/profile');
serveindex('/difference');
serveindex('/setting');
serveindex('/tags');
}
private serve_with_meta_index(router:Router){
const DocMiddleware = async (ctx: Koa.ParameterizedContext)=>{
@ -138,18 +134,16 @@ class ServerApplication {
if(doc === undefined){
ctx.status = 404;
meta = NotFoundContent();
} else {
}
else {
ctx.status = 200;
meta = createOgTagContent(
doc.title,
doc.tags.join(", "),
`https://aeolian.prelude.duckdns.org/api/doc/${docId}/comic/thumbnail`,
);
meta = createOgTagContent(doc.title,doc.tags.join(", "),
`https://aeolian.prelude.duckdns.org/api/doc/${docId}/comic/thumbnail`);
}
const html = makeMetaTagInjectedHTML(this.index_html,meta);
serveHTML(ctx,html);
};
router.get("/doc/:id(\\d+)", DocMiddleware);
}
router.get('/doc/:id(\\d+)',DocMiddleware);
function NotFoundContent(){
return createOgTagContent("Not Found Doc","Not Found","");
@ -158,14 +152,14 @@ class ServerApplication {
return html.replace("<!--MetaTag-Outlet-->",tagContent);
}
function serveHTML(ctx: Koa.Context, file: string){
ctx.type = "html";
ctx.body = file;
ctx.type = 'html'; ctx.body = file;
const setting = get_setting();
ctx.set("x-content-type-options", "no-sniff");
ctx.set('x-content-type-options','no-sniff');
if(setting.mode === "development"){
ctx.set("cache-control", "no-cache");
} else {
ctx.set("cache-control", "public, max-age=3600");
ctx.set('cache-control','no-cache');
}
else{
ctx.set('cache-control','public, max-age=3600');
}
}
@ -173,8 +167,7 @@ class ServerApplication {
return `<meta property="${key}" content="${value}">`;
}
function createOgTagContent(title:string,description: string, image: string){
return [
createMetaTagContent("og:title", title),
return [createMetaTagContent("og:title",title),
createMetaTagContent("og:type","website"),
createMetaTagContent("og:description",description),
createMetaTagContent("og:image",image),
@ -190,30 +183,29 @@ class ServerApplication {
}
private serve_static_file(router: Router){
const static_file_server = (path:string,type:string) => {
router.get("/" + path, async (ctx, next) => {
router.get('/'+path,async (ctx,next)=>{
const setting = get_setting();
ctx.type = type;
ctx.body = createReadStream(path);
ctx.set("x-content-type-options", "no-sniff");
ctx.type = type; ctx.body = createReadStream(path);
ctx.set('x-content-type-options','no-sniff');
if(setting.mode === "development"){
ctx.set("cache-control", "no-cache");
} else {
ctx.set("cache-control", "public, max-age=3600");
ctx.set('cache-control','no-cache');
}
});
};
else{
ctx.set('cache-control','public, max-age=3600');
}
})};
const setting = get_setting();
static_file_server("dist/bundle.css", "css");
static_file_server("dist/bundle.js", "js");
static_file_server('dist/bundle.css','css');
static_file_server('dist/bundle.js','js');
if(setting.mode === "development"){
static_file_server("dist/bundle.js.map", "text");
static_file_server("dist/bundle.css.map", "text");
static_file_server('dist/bundle.js.map','text');
static_file_server('dist/bundle.css.map','text');
}
}
start_server(){
let setting = get_setting();
console.log("start server");
//todo : support https
console.log(`listen on http://${setting.localmode ? "localhost" : "0.0.0.0"}:${setting.port}`);
return this.app.listen(setting.port,setting.localmode ? "127.0.0.1" : "0.0.0.0");
}
static async createServer(){
@ -229,6 +221,7 @@ class ServerApplication {
return app;
}
}
//let Koa = require("koa");
export async function create_server(){
return await ServerApplication.createServer();

View File

@ -1,4 +1,5 @@
export type JSONPrimitive = null|boolean|number|string;
export interface JSONMap extends Record<string, JSONType>{}
export interface JSONArray extends Array<JSONType> {}
export interface JSONArray extends Array<JSONType>{};
export type JSONType = JSONMap|JSONPrimitive|JSONArray;

View File

@ -1,5 +1,5 @@
import { existsSync, promises as fs, readFileSync, writeFileSync } from "fs";
import { validate } from "jsonschema";
import {readFileSync, existsSync, writeFileSync, promises as fs} from 'fs';
import {validate} from 'jsonschema';
export class ConfigManager<T>{
path:string;

View File

@ -7,9 +7,10 @@ export function check_type<T>(obj: any, check_proto: Record<string, string | und
if(!(obj[it] instanceof Array)){
return false;
}
} else if (defined !== typeof obj[it]) {
}
else if(defined !== typeof obj[it]){
return false;
}
}
return true;
}
};

View File

@ -1,14 +1,14 @@
import { ZipEntry } from "node-stream-zip";
import { ZipEntry } from 'node-stream-zip';
import { ReadStream } from "fs";
import { orderBy } from "natural-orderby";
import StreamZip from "node-stream-zip";
import {orderBy} from 'natural-orderby';
import { ReadStream } from 'fs';
import StreamZip from 'node-stream-zip';
export type ZipAsync = InstanceType<typeof StreamZip.async>;
export async function readZip(path : string): Promise<ZipAsync>{
return new StreamZip.async({
file:path,
storeEntries: true,
storeEntries: true
});
}
export async function entriesByNaturalOrder(zip: ZipAsync){
@ -24,10 +24,8 @@ export async function readAllFromZip(zip: ZipAsync, entry: ZipEntry): Promise<Bu
const stream = await createReadableStreamFromZip(zip,entry);
const chunks:Uint8Array[] = [];
return new Promise((resolve,reject)=>{
stream.on("data", (data) => {
chunks.push(data);
});
stream.on("error", (err) => reject(err));
stream.on("end", () => resolve(Buffer.concat(chunks)));
stream.on('data',(data)=>{chunks.push(data)});
stream.on('error', (err)=>reject(err));
stream.on('end',()=>resolve(Buffer.concat(chunks)));
});
}

View File

@ -65,8 +65,8 @@
/* Advanced Options */
"skipLibCheck": true, /* Skip type checking of declaration files. */
"forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */
"forceConsistentCasingInFileNames": true, /* Disallow inconsistently-cased references to the same file. */
},
"include": ["./"],
"exclude": ["src/client", "app", "seeds"]
"exclude": ["src/client","app","seeds"],
}