Compare commits

..

5 Commits

Author SHA1 Message Date
5670a12910 add: restore colored tag chip 2023-06-01 17:50:23 +09:00
edc6104a09 add: dprint fmt 2023-06-01 14:18:53 +09:00
04ab39a3ec chore: remove useless comments 2023-06-01 14:10:41 +09:00
a2a2407af6 fix: drawer for mobile view 2023-06-01 13:28:00 +09:00
65192c6c72 use pnpm 2023-06-01 11:09:00 +09:00
85 changed files with 4236 additions and 3593 deletions

View File

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

28
app.ts
View File

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

23
dprint.json Normal file
View File

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

View File

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

View File

@ -1,4 +1,4 @@
import {Knex} from 'knex'; import { Knex } from "knex";
export async function up(knex: Knex) { export async function up(knex: Knex) {
await knex.schema.createTable("schema_migration", (b) => { await knex.schema.createTable("schema_migration", (b) => {
@ -36,19 +36,19 @@ export async function up(knex:Knex) {
b.primary(["doc_id", "tag_name"]); b.primary(["doc_id", "tag_name"]);
}); });
await knex.schema.createTable("permissions", b => { await knex.schema.createTable("permissions", b => {
b.string('username').notNullable(); b.string("username").notNullable();
b.string("name").notNullable(); b.string("name").notNullable();
b.primary(["username", "name"]); b.primary(["username", "name"]);
b.foreign('username').references('users.username'); b.foreign("username").references("users.username");
}); });
// create admin account. // create admin account.
await knex.insert({ await knex.insert({
username: "admin", username: "admin",
password_hash: "unchecked", password_hash: "unchecked",
password_salt:"unchecked" password_salt: "unchecked",
}).into('users'); }).into("users");
}; }
export async function down(knex: Knex) { 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,8 +6,9 @@
"scripts": { "scripts": {
"compile": "tsc", "compile": "tsc",
"compile:watch": "tsc -w", "compile:watch": "tsc -w",
"build": "cd src/client && npm run build:prod", "build": "cd src/client && pnpm run build:prod",
"build:watch": "cd src/client && npm run build:watch", "build:watch": "cd src/client && pnpm run build:watch",
"fmt": "dprint fmt",
"app": "electron build/app.js", "app": "electron build/app.js",
"app:build": "electron-builder", "app:build": "electron-builder",
"app:pack": "electron-builder --dir", "app:pack": "electron-builder --dir",
@ -56,6 +57,7 @@
"@louislam/sqlite3": "^6.0.1", "@louislam/sqlite3": "^6.0.1",
"@types/koa-compose": "^3.2.5", "@types/koa-compose": "^3.2.5",
"chokidar": "^3.5.3", "chokidar": "^3.5.3",
"dprint": "^0.36.1",
"jsonschema": "^1.4.1", "jsonschema": "^1.4.1",
"jsonwebtoken": "^8.5.1", "jsonwebtoken": "^8.5.1",
"knex": "^0.95.15", "knex": "^0.95.15",

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,27 +1,27 @@
import React, { } from 'react'; import React, {} from "react";
import { Link as RouterLink } from 'react-router-dom'; import { Link as RouterLink } from "react-router-dom";
import { Document } from '../accessor/document'; import { Document } from "../accessor/document";
import { Link, Paper, Theme, Box, useTheme, Typography, Grid, Button } from '@mui/material'; import { Box, Button, Grid, Link, Paper, Theme, Typography, useTheme } from "@mui/material";
import { ThumbnailContainer } from '../page/reader/reader'; import { TagChip } from "../component/tagchip";
import { TagChip } from '../component/tagchip'; import { ThumbnailContainer } from "../page/reader/reader";
import DocumentAccessor from '../accessor/document'; import DocumentAccessor from "../accessor/document";
export const makeContentInfoUrl = (id: number) => `/doc/${id}`; export const makeContentInfoUrl = (id: number) => `/doc/${id}`;
export const makeContentReaderUrl = (id: number) => `/doc/${id}/reader`; export const makeContentReaderUrl = (id: number) => `/doc/${id}/reader`;
const useStyles = ((theme: Theme) => ({ const useStyles = (theme: Theme) => ({
thumbnail_content: { thumbnail_content: {
maxHeight: '400px', maxHeight: "400px",
maxWidth: 'min(400px, 100vw)', maxWidth: "min(400px, 100vw)",
}, },
tag_list: { tag_list: {
display: 'flex', display: "flex",
justifyContent: 'flex-start', justifyContent: "flex-start",
flexWrap: 'wrap', flexWrap: "wrap",
overflowY: 'hidden', overflowY: "hidden",
'& > *': { "& > *": {
margin: theme.spacing(0.5), margin: theme.spacing(0.5),
}, },
}, },
@ -32,107 +32,125 @@ const useStyles = ((theme: Theme) => ({
padding: theme.spacing(2), padding: theme.spacing(2),
}, },
subinfoContainer: { subinfoContainer: {
display: 'grid', display: "grid",
gridTemplateColumns: '100px auto', gridTemplateColumns: "100px auto",
overflowY: 'hidden', overflowY: "hidden",
alignItems: 'baseline', alignItems: "baseline",
}, },
short_subinfoContainer: { short_subinfoContainer: {
[theme.breakpoints.down("md")]: { [theme.breakpoints.down("md")]: {
display: 'none', display: "none",
}, },
}, },
short_root: { short_root: {
overflowY: 'hidden', overflowY: "hidden",
display: 'flex', display: "flex",
flexDirection: 'column', flexDirection: "column",
[theme.breakpoints.up("sm")]: { [theme.breakpoints.up("sm")]: {
height: 200, height: 200,
flexDirection: 'row', flexDirection: "row",
}, },
}, },
short_thumbnail_anchor: { short_thumbnail_anchor: {
background: '#272733', background: "#272733",
display: 'flex', display: "flex",
alignItems: 'center', alignItems: "center",
justifyContent: 'center', justifyContent: "center",
[theme.breakpoints.up("sm")]: { [theme.breakpoints.up("sm")]: {
width: theme.spacing(25), width: theme.spacing(25),
height: theme.spacing(25), height: theme.spacing(25),
flexShrink: 0, flexShrink: 0,
} },
}, },
short_thumbnail_content: { short_thumbnail_content: {
maxWidth: '100%', maxWidth: "100%",
maxHeight: '100%', maxHeight: "100%",
}, },
})) });
export const ContentInfo = (props: { export const ContentInfo = (props: {
document: Document, children?: React.ReactNode, classes?: { document: Document;
root?: string, children?: React.ReactNode;
thumbnail_anchor?: string, classes?: {
thumbnail_content?: string, root?: string;
tag_list?: string, thumbnail_anchor?: string;
title?: string, thumbnail_content?: string;
infoContainer?: string, tag_list?: string;
subinfoContainer?: string title?: string;
}, infoContainer?: string;
gallery?: string, subinfoContainer?: string;
short?: boolean };
gallery?: string;
short?: boolean;
}) => { }) => {
//const classes = useStyles();
const theme = useTheme(); const theme = useTheme();
const document = props.document; 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); const url = props.gallery === undefined ? makeContentReaderUrl(document.id) : makeContentInfoUrl(document.id);
return (<Paper sx={{ return (
<Paper
sx={{
display: "flex", display: "flex",
height: "400px", height: "400px",
[theme.breakpoints.down("sm")]: { [theme.breakpoints.down("sm")]: {
flexDirection: "column", flexDirection: "column",
alignItems: "center", alignItems: "center",
} height: "auto",
}} elevation={4}> },
<Link /*className={propclasses.thumbnail_anchor ?? thumbnail_anchor}*/ component={RouterLink} to={{ }}
pathname: makeContentReaderUrl(document.id) elevation={4}
}}> >
{document.deleted_at === null ? <Link
(<ThumbnailContainer content={document}/>) component={RouterLink}
: (<Typography/* className={propclasses.thumbnail_content ?? thumbnail_content} */ variant='h4'>Deleted</Typography>)} to={{
pathname: makeContentReaderUrl(document.id),
}}
>
{document.deleted_at === null
? <ThumbnailContainer content={document} />
: <Typography variant="h4">Deleted</Typography>}
</Link> </Link>
<Box /*className={propclasses.infoContainer ?? classes.infoContainer}*/> <Box>
<Link variant='h5' color='inherit' component={RouterLink} to={{pathname: url}} <Link variant="h5" color="inherit" component={RouterLink} to={{ pathname: url }}>
/*className={propclasses.title ?? classes.title}*/>
{document.title} {document.title}
</Link> </Link>
<Box /*className={propclasses.subinfoContainer ?? subinfoContainer}*/> <Box>
{props.short ? (<Box /*className={propclasses.tag_list ?? classes.tag_list}*/>{document.tags.map(x => {props.short
(<TagChip key={x} label={x} clickable tagname={x} size="small"></TagChip>) ? (
)}</Box>) : ( <Box>
<ComicDetailTag tags={document.tags} path={document.basepath+"/"+document.filename} {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} createdAt={document.created_at}
deletedAt={document.deleted_at != null ? document.deleted_at : undefined} deletedAt={document.deleted_at != null ? document.deleted_at : undefined}
/* classes={({tag_list:classes.tag_list})}*/ ></ComicDetailTag>) >
} </ComicDetailTag>
)}
</Box> </Box>
{document.deleted_at != null && {document.deleted_at != null
<Button onClick={()=>{documentDelete(document.id);}}>Delete</Button> && (
} <Button
onClick={() => {
documentDelete(document.id);
}}
>
Delete
</Button>
)}
</Box> </Box>
</Paper>); </Paper>
} );
};
async function documentDelete(id: number) { async function documentDelete(id: number) {
const t = await DocumentAccessor.del(id); const t = await DocumentAccessor.del(id);
if (t) { if (t) {
alert("document deleted!"); alert("document deleted!");
} } else {
else{
alert("document already deleted."); alert("document already deleted.");
} }
} }
@ -153,40 +171,54 @@ function ComicDetailTag(prop: {
tagTable[kind] = tags; tagTable[kind] = tags;
allTag = allTag.filter(x => !x.startsWith(kind + ":")); allTag = allTag.filter(x => !x.startsWith(kind + ":"));
} }
return (<Grid container> return (
<Grid container>
{tagKind.map(key => ( {tagKind.map(key => (
<React.Fragment key={key}> <React.Fragment key={key}>
<Grid item xs={3}> <Grid item xs={3}>
<Typography variant='subtitle1'>{key}</Typography> <Typography variant="subtitle1">{key}</Typography>
</Grid> </Grid>
<Grid item xs={9}> <Grid item xs={9}>
<Box>{tagTable[key].length !== 0 ? tagTable[key].join(", ") : "N/A"}</Box> <Box>{tagTable[key].length !== 0 ? tagTable[key].join(", ") : "N/A"}</Box>
</Grid> </Grid>
</React.Fragment> </React.Fragment>
))} ))}
{ prop.path != undefined && <><Grid item xs={3}> {prop.path != undefined && (
<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}> <Grid item xs={3}>
<Typography variant='subtitle1'>Tags</Typography> <Typography variant="subtitle1">Path</Typography>
</Grid> </Grid>
<Grid item xs={9}> <Grid item xs={9}>
{allTag.map(x => (<TagChip key={x} label={x} clickable tagname={x} size="small"></TagChip>))} <Box>{prop.path}</Box>
</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,21 +1,35 @@
import React, { useContext, useState } from 'react'; import { AccountCircle, ChevronLeft, ChevronRight, Menu as MenuIcon, Search as SearchIcon } from "@mui/icons-material";
import { import {
Button, CssBaseline, Divider, IconButton, List, ListItem, Drawer, AppBar,
AppBar, Toolbar, Typography, InputBase, ListItemIcon, ListItemText, Menu, MenuItem, Button,
Hidden, Tooltip, Link, styled CssBaseline,
} from '@mui/material'; Divider,
import { alpha, Theme, useTheme } from '@mui/material/styles'; Drawer,
import { Hidden,
ChevronLeft, ChevronRight, Menu as MenuIcon, Search as SearchIcon, AccountCircle IconButton,
} from '@mui/icons-material'; 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";
import { Link as RouterLink, useNavigate } from 'react-router-dom'; import { Link as RouterLink, useNavigate } from "react-router-dom";
import { doLogout, UserContext } from '../state'; import { doLogout, UserContext } from "../state";
const drawerWidth = 270; const drawerWidth = 270;
const DrawerHeader = styled('div')(({ theme }) => ({ const DrawerHeader = styled("div")(({ theme }) => ({
...theme.mixins.toolbar ...theme.mixins.toolbar,
})); }));
const StyledDrawer = styled(Drawer)(({ theme }) => ({ const StyledDrawer = styled(Drawer)(({ theme }) => ({
@ -24,51 +38,56 @@ const StyledDrawer = styled(Drawer)(({ theme }) => ({
[theme.breakpoints.up("sm")]: { [theme.breakpoints.up("sm")]: {
width: drawerWidth, width: drawerWidth,
}, },
} }));
)); const StyledSearchBar = styled("div")(({ theme }) => ({
const StyledSearchBar = styled('div')(({ theme }) => ({ position: "relative",
position: 'relative',
borderRadius: theme.shape.borderRadius, borderRadius: theme.shape.borderRadius,
backgroundColor: alpha(theme.palette.common.white, 0.15), backgroundColor: alpha(theme.palette.common.white, 0.15),
'&:hover': { "&:hover": {
backgroundColor: alpha(theme.palette.common.white, 0.25), backgroundColor: alpha(theme.palette.common.white, 0.25),
}, },
marginLeft: 0, marginLeft: 0,
width: '100%', width: "100%",
[theme.breakpoints.up('sm')]: { [theme.breakpoints.up("sm")]: {
marginLeft: theme.spacing(1), marginLeft: theme.spacing(1),
width: 'auto', width: "auto",
}, },
})); }));
const StyledInputBase = styled(InputBase)(({ theme }) => ({ const StyledInputBase = styled(InputBase)(({ theme }) => ({
color: 'inherit', color: "inherit",
'& .MuiInputBase-input': { "& .MuiInputBase-input": {
padding: theme.spacing(1, 1, 1, 0), padding: theme.spacing(1, 1, 1, 0),
// vertical padding + font size from searchIcon // vertical padding + font size from searchIcon
paddingLeft: `calc(1em + ${theme.spacing(4)})`, paddingLeft: `calc(1em + ${theme.spacing(4)})`,
transition: theme.transitions.create('width'), transition: theme.transitions.create("width"),
width: '100%', width: "100%",
[theme.breakpoints.up('sm')]: { [theme.breakpoints.up("sm")]: {
width: '12ch', width: "12ch",
'&:focus': { "&:focus": {
width: '20ch', width: "20ch",
}, },
}, },
}, },
})); }));
const StyledNav = styled("nav")(({ theme }) => ({
[theme.breakpoints.up("sm")]: {
width: theme.spacing(7),
},
}));
const closedMixin = (theme: Theme) => ({ const closedMixin = (theme: Theme) => ({
overflowX: 'hidden', overflowX: "hidden",
width: `calc(${theme.spacing(7)} + 1px)`, width: `calc(${theme.spacing(7)} + 1px)`,
}); });
export const Headline = (prop: { export const Headline = (prop: {
children?: React.ReactNode, children?: React.ReactNode;
classes?: { classes?: {
content?: string, content?: string;
toolbar?: string, toolbar?: string;
}, };
menu: React.ReactNode menu: React.ReactNode;
}) => { }) => {
const [v, setv] = useState(false); const [v, setv] = useState(false);
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null); const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
@ -77,25 +96,36 @@ export const Headline = (prop: {
const handleProfileMenuOpen = (e: React.MouseEvent<HTMLElement>) => setAnchorEl(e.currentTarget); const handleProfileMenuOpen = (e: React.MouseEvent<HTMLElement>) => setAnchorEl(e.currentTarget);
const handleProfileMenuClose = () => setAnchorEl(null); const handleProfileMenuClose = () => setAnchorEl(null);
const isProfileMenuOpened = Boolean(anchorEl); const isProfileMenuOpened = Boolean(anchorEl);
const menuId = 'primary-search-account-menu'; const menuId = "primary-search-account-menu";
const user_ctx = useContext(UserContext); const user_ctx = useContext(UserContext);
const isLogin = user_ctx.username !== ""; const isLogin = user_ctx.username !== "";
const navigate = useNavigate(); const navigate = useNavigate();
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const renderProfileMenu = (<Menu const renderProfileMenu = (
<Menu
anchorEl={anchorEl} anchorEl={anchorEl}
anchorOrigin={{ horizontal: 'right', vertical: "top" }} anchorOrigin={{ horizontal: "right", vertical: "top" }}
id={menuId} id={menuId}
open={isProfileMenuOpened} open={isProfileMenuOpened}
keepMounted keepMounted
transformOrigin={{ horizontal: 'right', vertical: "top" }} transformOrigin={{ horizontal: "right", vertical: "top" }}
onClose={handleProfileMenuClose} onClose={handleProfileMenuClose}
> >
<MenuItem component={RouterLink} to='/profile'>Profile</MenuItem> <MenuItem component={RouterLink} to="/profile">Profile</MenuItem>
<MenuItem onClick={async () => { handleProfileMenuClose(); await doLogout(); user_ctx.setUsername(""); }}>Logout</MenuItem> <MenuItem
</Menu>); onClick={async () => {
const drawer_contents = (<> handleProfileMenuClose();
await doLogout();
user_ctx.setUsername("");
}}
>
Logout
</MenuItem>
</Menu>
);
const drawer_contents = (
<>
<DrawerHeader> <DrawerHeader>
<IconButton onClick={toggleV}> <IconButton onClick={toggleV}>
{theme.direction === "ltr" ? <ChevronLeft /> : <ChevronRight />} {theme.direction === "ltr" ? <ChevronLeft /> : <ChevronRight />}
@ -103,19 +133,25 @@ export const Headline = (prop: {
</DrawerHeader> </DrawerHeader>
<Divider /> <Divider />
{prop.menu} {prop.menu}
</>); </>
);
return (<div style={{ display: 'flex' }}> return (
<div style={{ display: "flex" }}>
<CssBaseline /> <CssBaseline />
<AppBar position="fixed" sx={{ <AppBar
position="fixed"
sx={{
zIndex: theme.zIndex.drawer + 1, zIndex: theme.zIndex.drawer + 1,
transition: theme.transitions.create(['width', 'margin'], { transition: theme.transitions.create(["width", "margin"], {
easing: theme.transitions.easing.sharp, easing: theme.transitions.easing.sharp,
duration: theme.transitions.duration.leavingScreen duration: theme.transitions.duration.leavingScreen,
}) }),
}}> }}
>
<Toolbar> <Toolbar>
<IconButton color="inherit" <IconButton
color="inherit"
aria-label="open drawer" aria-label="open drawer"
onClick={toggleV} onClick={toggleV}
edge="start" edge="start"
@ -123,90 +159,114 @@ export const Headline = (prop: {
> >
<MenuIcon></MenuIcon> <MenuIcon></MenuIcon>
</IconButton> </IconButton>
<Link variant="h5" noWrap sx={{ <Link
display: 'none', variant="h5"
noWrap
sx={{
display: "none",
[theme.breakpoints.up("sm")]: { [theme.breakpoints.up("sm")]: {
display: 'block' display: "block",
} },
}} color="inherit" component={RouterLink} to="/"> }}
color="inherit"
component={RouterLink}
to="/"
>
Ionian Ionian
</Link> </Link>
<div style={{ flexGrow: 1 }}></div> <div style={{ flexGrow: 1 }}></div>
<StyledSearchBar> <StyledSearchBar>
<div style={{ <div
style={{
padding: theme.spacing(0, 2), padding: theme.spacing(0, 2),
height: '100%', height: "100%",
position: 'absolute', position: "absolute",
pointerEvents: 'none', pointerEvents: "none",
display: 'flex', display: "flex",
alignItems: 'center', alignItems: "center",
justifyContent: 'center' justifyContent: "center",
}}> }}
>
<SearchIcon onClick={() => navSearch(search)} /> <SearchIcon onClick={() => navSearch(search)} />
</div> </div>
<StyledInputBase placeholder="search" <StyledInputBase
placeholder="search"
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
onKeyUp={(e) => { onKeyUp={(e) => {
if (e.key === "Enter") { if (e.key === "Enter") {
navSearch(search); navSearch(search);
} }
}} }}
value={search}></StyledInputBase> value={search}
>
</StyledInputBase>
</StyledSearchBar> </StyledSearchBar>
{ {isLogin
isLogin ? ? (
<IconButton <IconButton
edge="end" edge="end"
aria-label="account of current user" aria-label="account of current user"
aria-controls={menuId} aria-controls={menuId}
aria-haspopup="true" aria-haspopup="true"
onClick={handleProfileMenuOpen} onClick={handleProfileMenuOpen}
color="inherit"> color="inherit"
>
<AccountCircle /> <AccountCircle />
</IconButton> </IconButton>
: <Button color="inherit" component={RouterLink} to="/login">Login</Button> )
} : <Button color="inherit" component={RouterLink} to="/login">Login</Button>}
</Toolbar> </Toolbar>
</AppBar> </AppBar>
{renderProfileMenu} {renderProfileMenu}
<nav style={{ width: theme.spacing(7) }}> <StyledNav>
<Hidden smUp implementation="css"> <Hidden smUp implementation="css">
<StyledDrawer variant="temporary" anchor='left' open={v} onClose={toggleV} <StyledDrawer
variant="temporary"
anchor="left"
open={v}
onClose={toggleV}
sx={{ sx={{
width: drawerWidth width: drawerWidth,
}} }}
> >
{drawer_contents} {drawer_contents}
</StyledDrawer> </StyledDrawer>
</Hidden> </Hidden>
<Hidden xsDown implementation="css"> <Hidden smDown implementation="css">
<StyledDrawer variant='permanent' anchor='left' <StyledDrawer
variant="permanent"
anchor="left"
sx={{ sx={{
...closedMixin(theme), ...closedMixin(theme),
'& .MuiDrawer-paper': closedMixin(theme), "& .MuiDrawer-paper": closedMixin(theme),
}}> }}
>
{drawer_contents} {drawer_contents}
</StyledDrawer> </StyledDrawer>
</Hidden> </Hidden>
</nav> </StyledNav>
<main style={{ <main
display: 'flex', style={{
flexFlow: 'column', display: "flex",
flexFlow: "column",
flexGrow: 1, flexGrow: 1,
padding: theme.spacing(3), padding: theme.spacing(3),
marginTop: theme.spacing(6), marginTop: theme.spacing(6),
}}> }}
<div style={{ >
}} ></div> <div style={{}}></div>
{prop.children} {prop.children}
</main> </main>
</div>); </div>
);
function navSearch(search: string) { function navSearch(search: string) {
let words = search.includes("&") ? search.split("&") : [search]; let words = search.includes("&") ? search.split("&") : [search];
words = words.map(w => w.trim()) words = words.map(w => w.trim())
.map(w => w.includes(":") ? .map(w =>
`allow_tag=${w}` w.includes(":")
: `word=${encodeURIComponent(w)}`); ? `allow_tag=${w}`
: `word=${encodeURIComponent(w)}`
);
navigate(`/search?${words.join("&")}`); navigate(`/search?${words.join("&")}`);
} }
}; };

View File

@ -1,8 +1,10 @@
import React from 'react'; import { Box, CircularProgress } from "@mui/material";
import {Box, CircularProgress} from '@mui/material'; import React from "react";
export const LoadingCircle = () => { 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" /> <CircularProgress title="loading" />
</Box>); </Box>
} );
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,7 +1,10 @@
import React from 'react'; import React from "react";
import { Document } from '../../accessor/document'; import { Document } from "../../accessor/document";
export const VideoReader = (props: { doc: Document }) => { export const VideoReader = (props: { doc: Document }) => {
const id = props.doc.id; 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,13 +1,15 @@
import React from 'react'; import { ArrowBack as ArrowBackIcon } from "@mui/icons-material";
import {Typography, Paper} from '@mui/material'; import { Paper, Typography } from "@mui/material";
import {ArrowBack as ArrowBackIcon} from '@mui/icons-material'; import React from "react";
import { Headline, BackItem, NavList, CommonMenuList } from '../component/mod'; import { BackItem, CommonMenuList, Headline, NavList } from "../component/mod";
export const SettingPage = () => { export const SettingPage = () => {
const menu = CommonMenuList(); const menu = CommonMenuList();
return (<Headline menu={menu}> return (
<Headline menu={menu}>
<Paper> <Paper>
<Typography variant='h2'>Setting</Typography> <Typography variant="h2">Setting</Typography>
</Paper> </Paper>
</Headline>); </Headline>
);
}; };

View File

@ -1,8 +1,8 @@
import React, { useEffect, useState } from 'react'; import { Box, Paper, Typography } from "@mui/material";
import {Typography, Box, Paper} from '@mui/material'; import { DataGrid, GridColDef } from "@mui/x-data-grid";
import React, { useEffect, useState } from "react";
import { LoadingCircle } from "../component/loading"; import { LoadingCircle } from "../component/loading";
import { Headline, CommonMenuList } from '../component/mod'; import { CommonMenuList, Headline } from "../component/mod";
import {DataGrid, GridColDef} from "@mui/x-data-grid"
type TagCount = { type TagCount = {
tag_name: string; tag_name: string;
@ -19,9 +19,9 @@ const tagTableColumn: GridColDef[] = [
field: "occurs", field: "occurs",
headerName: "Occurs", headerName: "Occurs",
width: 100, width: 100,
type:"number" type: "number",
} },
] ];
function TagTable() { function TagTable() {
const [data, setData] = useState<TagCount[] | undefined>(); const [data, setData] = useState<TagCount[] | undefined>();
@ -36,26 +36,26 @@ function TagTable(){
return <LoadingCircle />; return <LoadingCircle />;
} }
if (error !== undefined) { 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}> <Paper sx={{ height: "100%" }} elevation={2}>
<DataGrid rows={data} columns={tagTableColumn} getRowId={(t) => t.tag_name}></DataGrid> <DataGrid rows={data} columns={tagTableColumn} getRowId={(t) => t.tag_name}></DataGrid>
</Paper> </Paper>
</Box> </Box>
);
async function loadData() { async function loadData() {
try { try {
const res = await fetch("/api/tags?withCount=true"); const res = await fetch("/api/tags?withCount=true");
const data = await res.json(); const data = await res.json();
setData(data); setData(data);
} } catch (e) {
catch(e){
setData([]); setData([]);
if (e instanceof Error) { if (e instanceof Error) {
setErrorMsg(e.message); setErrorMsg(e.message);
} } else {
else{
console.log(e); console.log(e);
setErrorMsg(""); setErrorMsg("");
} }
@ -65,7 +65,9 @@ function TagTable(){
export const TagsPage = () => { export const TagsPage = () => {
const menu = CommonMenuList(); const menu = CommonMenuList();
return <Headline menu={menu}> return (
<Headline menu={menu}>
<TagTable></TagTable> <TagTable></TagTable>
</Headline> </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 BackLinkContext = createContext({ backLink: "", setBackLink: (s: string) => {} });
export const UserContext = createContext({ export const UserContext = createContext({
username: "", username: "",
permission: [] as string[], permission: [] as string[],
setUsername: (s: string) => {}, setUsername: (s: string) => {},
setPermission: (permission: string[]) => { } setPermission: (permission: string[]) => {},
}); });
type LoginLocalStorage = { type LoginLocalStorage = {
username: string, username: string;
permission: string[], permission: string[];
accessExpired: number accessExpired: number;
}; };
let localObj: LoginLocalStorage | null = null; let localObj: LoginLocalStorage | null = null;
@ -25,65 +25,64 @@ export const getInitialValue = async () => {
return { return {
username: localObj.username, username: localObj.username,
permission: localObj.permission, permission: localObj.permission,
};
} }
} const res = await fetch("/user/refresh", {
const res = await fetch('/user/refresh', { method: "POST",
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 }; const r = await res.json() as LoginLocalStorage & { refresh: boolean };
if (r.refresh) { if (r.refresh) {
localObj = { localObj = {
username: r.username, username: r.username,
permission: r.permission, permission: r.permission,
accessExpired: r.accessExpired accessExpired: r.accessExpired,
} };
} } else {
else {
localObj = { localObj = {
accessExpired: 0, accessExpired: 0,
username: "", username: "",
permission: r.permission permission: r.permission,
} };
} }
window.localStorage.setItem("UserLoginContext", JSON.stringify(localObj)); window.localStorage.setItem("UserLoginContext", JSON.stringify(localObj));
return { return {
username: r.username, username: r.username,
permission: r.permission permission: r.permission,
} };
} };
export const doLogout = async () => { export const doLogout = async () => {
const req = await fetch('/user/logout', { const req = await fetch("/user/logout", {
method: 'POST' method: "POST",
}); });
try { try {
const res = await req.json(); const res = await req.json();
localObj = { localObj = {
accessExpired: 0, accessExpired: 0,
username: "", username: "",
permission: res["permission"] permission: res["permission"],
} };
window.localStorage.setItem("UserLoginContext", JSON.stringify(localObj)); window.localStorage.setItem("UserLoginContext", JSON.stringify(localObj));
return { return {
username: localObj.username, username: localObj.username,
permission: localObj.permission, permission: localObj.permission,
} };
} catch (error) { } catch (error) {
console.error(`Server Error ${error}`); console.error(`Server Error ${error}`);
return { return {
username: "", username: "",
permission: [], permission: [],
};
} }
} };
}
export const doLogin = async (userLoginInfo: { export const doLogin = async (userLoginInfo: {
username:string, username: string;
password:string, password: string;
}): Promise<string | LoginLocalStorage> => { }): Promise<string | LoginLocalStorage> => {
const res = await fetch('/user/login',{ const res = await fetch("/user/login", {
method:'POST', method: "POST",
body: JSON.stringify(userLoginInfo), body: JSON.stringify(userLoginInfo),
headers:{"content-type":"application/json"} headers: { "content-type": "application/json" },
}); });
const b = await res.json(); const b = await res.json();
if (res.status !== 200) { if (res.status !== 200) {
@ -92,4 +91,4 @@ export const doLogin = async (userLoginInfo:{
localObj = b; localObj = b;
window.localStorage.setItem("UserLoginContext", JSON.stringify(localObj)); window.localStorage.setItem("UserLoginContext", JSON.stringify(localObj));
return b; return b;
} };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,17 +1,16 @@
import {IDiffWatcher, DiffWatcherEvent} from '../watcher'; import { EventEmitter } from "events";
import {EventEmitter} from 'events'; import { DocumentAccessor } from "../../model/doc";
import { DocumentAccessor } from '../../model/doc'; import { DiffWatcherEvent, IDiffWatcher } from "../watcher";
import { WatcherFilter } from './watcher_filter'; import { ComicConfig } from "./ComicConfig";
import { RecursiveWatcher } from './recursive_watcher'; import { WatcherCompositer } from "./compositer";
import { ComicConfig } from './ComicConfig'; import { RecursiveWatcher } from "./recursive_watcher";
import {WatcherCompositer} from './compositer' import { WatcherFilter } from "./watcher_filter";
const createComicWatcherBase = (path: string) => { const createComicWatcherBase = (path: string) => {
return new WatcherFilter(new RecursiveWatcher(path), (x) => x.endsWith(".zip")); return new WatcherFilter(new RecursiveWatcher(path), (x) => x.endsWith(".zip"));
} };
export const createComicWatcher = () => { export const createComicWatcher = () => {
const file = ComicConfig.get_config_file(); 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))); return new WatcherCompositer(file.watch.map(path => createComicWatcherBase(path)));
} };

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
export interface Tag { export interface Tag {
readonly name: string, readonly name: string;
description?: string description?: string;
} }
export interface TagCount { 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) { 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}{ function createPasswordHashAndSalt(password: string): { salt: string; hash: string } {
const secret = randomBytes(32).toString('hex'); const secret = randomBytes(32).toString("hex");
return { return {
salt: secret, salt: secret,
hash: hashForPassword(secret,password) hash: hashForPassword(secret, password),
}; };
} }
export class Password { export class Password {
private _salt: string; private _salt: string;
private _hash: 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; const { salt, hash } = typeof pw === "string" ? createPasswordHashAndSalt(pw) : pw;
this._hash = hash; this._hash = hash;
this._salt = salt; this._salt = salt;
@ -27,13 +27,17 @@ export class Password{
check_password(password: string): boolean { check_password(password: string): boolean {
return this._hash === hashForPassword(this._salt, password); return this._hash === hashForPassword(this._salt, password);
} }
get salt(){return this._salt;} get salt() {
get hash(){return this._hash;} return this._salt;
}
get hash() {
return this._hash;
}
} }
export interface UserCreateInput { export interface UserCreateInput {
username: string, username: string;
password: string password: string;
} }
export interface IUser { export interface IUser {
@ -60,21 +64,21 @@ export interface IUser{
* @param password password to set * @param password password to set
*/ */
reset_password(password: string): Promise<void>; reset_password(password: string): Promise<void>;
}; }
export interface UserAccessor { export interface UserAccessor {
/** /**
* create user * create user
* @returns if user exist, return undefined * @returns if user exist, return undefined
*/ */
createUser: (input :UserCreateInput)=> Promise<IUser|undefined>, createUser: (input: UserCreateInput) => Promise<IUser | undefined>;
/** /**
* find user * find user
*/ */
findUser: (username: string)=> Promise<IUser|undefined>, findUser: (username: string) => Promise<IUser | undefined>;
/** /**
* remove user * remove user
* @returns if user exist, true * @returns if user exist, true
*/ */
delUser: (username: string)=>Promise<boolean> delUser: (username: string) => Promise<boolean>;
}; }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -65,8 +65,8 @@
/* Advanced Options */ /* Advanced Options */
"skipLibCheck": true, /* Skip type checking of declaration files. */ "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": ["./"], "include": ["./"],
"exclude": ["src/client","app","seeds"], "exclude": ["src/client", "app", "seeds"]
} }