add: dprint fmt

This commit is contained in:
monoid 2023-06-01 14:18:53 +09:00
parent 04ab39a3ec
commit edc6104a09
84 changed files with 3674 additions and 3373 deletions

View File

@ -6,4 +6,4 @@ Permission is hereby granted, free of charge, to any person obtaining a copy of
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

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

196
app.ts
View File

@ -1,115 +1,113 @@
import { app, BrowserWindow, session, dialog } from "electron";
import { get_setting } from "./src/SettingConfig";
import { create_server } from "./src/server";
import { getAdminAccessTokenValue,getAdminRefreshTokenValue, accessTokenName, refreshTokenName } from "./src/login";
import { app, BrowserWindow, dialog, session } from "electron";
import { ipcMain } from "electron";
import { join } from "path";
import { ipcMain } from 'electron';
import { accessTokenName, getAdminAccessTokenValue, getAdminRefreshTokenValue, refreshTokenName } from "./src/login";
import { UserAccessor } from "./src/model/mod";
import { create_server } from "./src/server";
import { get_setting } from "./src/SettingConfig";
function registerChannel(cntr: UserAccessor){
ipcMain.handle('reset_password', async(event,username:string,password:string)=>{
const user = await cntr.findUser(username);
if(user === undefined){
return false;
}
user.reset_password(password);
return true;
});
function registerChannel(cntr: UserAccessor) {
ipcMain.handle("reset_password", async (event, username: string, password: string) => {
const user = await cntr.findUser(username);
if (user === undefined) {
return false;
}
user.reset_password(password);
return true;
});
}
const setting = get_setting();
if (!setting.cli) {
let wnd: BrowserWindow | null = null;
let wnd: BrowserWindow | null = null;
const createWindow = async () => {
wnd = new BrowserWindow({
width: 800,
height: 600,
center: true,
useContentSize: true,
webPreferences:{
preload:join(__dirname,'preload.js'),
contextIsolation:true,
}
});
await wnd.loadURL(`data:text/html;base64,`+Buffer.from(loading_html).toString('base64'));
//await wnd.loadURL('../loading.html');
//set admin cookies.
await session.defaultSession.cookies.set({
url:`http://localhost:${setting.port}`,
name:accessTokenName,
value:getAdminAccessTokenValue(),
httpOnly: true,
secure: false,
sameSite:"strict"
});
await session.defaultSession.cookies.set({
url:`http://localhost:${setting.port}`,
name:refreshTokenName,
value:getAdminRefreshTokenValue(),
httpOnly: true,
secure: false,
sameSite:"strict"
});
try{
const server = await create_server();
const app = server.start_server();
registerChannel(server.userController);
await wnd.loadURL(`http://localhost:${setting.port}`);
}
catch(e){
if(e instanceof Error){
await dialog.showMessageBox({
type: "error",
title:"error!",
message:e.message,
const createWindow = async () => {
wnd = new BrowserWindow({
width: 800,
height: 600,
center: true,
useContentSize: true,
webPreferences: {
preload: join(__dirname, "preload.js"),
contextIsolation: true,
},
});
}
else{
await dialog.showMessageBox({
type: "error",
title:"error!",
message:String(e),
await wnd.loadURL(`data:text/html;base64,` + Buffer.from(loading_html).toString("base64"));
// await wnd.loadURL('../loading.html');
// set admin cookies.
await session.defaultSession.cookies.set({
url: `http://localhost:${setting.port}`,
name: accessTokenName,
value: getAdminAccessTokenValue(),
httpOnly: true,
secure: false,
sameSite: "strict",
});
}
await session.defaultSession.cookies.set({
url: `http://localhost:${setting.port}`,
name: refreshTokenName,
value: getAdminRefreshTokenValue(),
httpOnly: true,
secure: false,
sameSite: "strict",
});
try {
const server = await create_server();
const app = server.start_server();
registerChannel(server.userController);
await wnd.loadURL(`http://localhost:${setting.port}`);
} catch (e) {
if (e instanceof Error) {
await dialog.showMessageBox({
type: "error",
title: "error!",
message: e.message,
});
} else {
await dialog.showMessageBox({
type: "error",
title: "error!",
message: String(e),
});
}
}
wnd.on("closed", () => {
wnd = null;
});
};
const isPrimary = app.requestSingleInstanceLock();
if (!isPrimary) {
app.quit(); // exit window
app.exit();
}
wnd.on("closed", () => {
wnd = null;
app.on("second-instance", () => {
if (wnd != null) {
if (wnd.isMinimized()) {
wnd.restore();
}
wnd.focus();
}
});
app.on("ready", (event, info) => {
createWindow();
});
};
const isPrimary = app.requestSingleInstanceLock();
if (!isPrimary) {
app.quit(); //exit window
app.exit();
}
app.on("second-instance", () => {
if (wnd != null) {
if (wnd.isMinimized()) {
wnd.restore();
}
wnd.focus();
}
});
app.on("ready", (event, info) => {
createWindow();
});
app.on("window-all-closed", () => { // quit when all windows are closed
if (process.platform != "darwin") app.quit(); // (except leave MacOS app active until Cmd+Q)
});
app.on("window-all-closed", () => { // quit when all windows are closed
if (process.platform != "darwin") app.quit(); // (except leave MacOS app active until Cmd+Q)
});
app.on("activate", () => { // re-recreate window when dock icon is clicked and no other windows open
if (wnd == null) createWindow();
});
app.on("activate", () => { // re-recreate window when dock icon is clicked and no other windows open
if (wnd == null) createWindow();
});
} else {
(async () => {
try {
const server = await create_server();
server.start_server();
} catch (error) {
console.log(error);
}
})();
(async () => {
try {
const server = await create_server();
server.start_server();
} catch (error) {
console.log(error);
}
})();
}
const loading_html = `<!DOCTYPE html>
<html lang="ko"><head>
@ -142,4 +140,4 @@ h1 {
<h1>Loading...</h1>
<div id="loading"></div>
</body>
</html>`;
</html>`;

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

View File

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

View File

@ -1,54 +1,54 @@
import {Knex} from 'knex';
import { Knex } from "knex";
export async function up(knex:Knex) {
await knex.schema.createTable("schema_migration",(b)=>{
export async function up(knex: Knex) {
await knex.schema.createTable("schema_migration", (b) => {
b.string("version");
b.boolean("dirty");
});
await knex.schema.createTable("users",(b)=>{
await knex.schema.createTable("users", (b) => {
b.string("username").primary().comment("user's login id");
b.string("password_hash",64).notNullable();
b.string("password_salt",64).notNullable();
b.string("password_hash", 64).notNullable();
b.string("password_salt", 64).notNullable();
});
await knex.schema.createTable("document",(b)=>{
await knex.schema.createTable("document", (b) => {
b.increments("id").primary();
b.string("title").notNullable();
b.string("content_type",16).notNullable();
b.string("basepath",256).notNullable().comment("directory path for resource");
b.string("filename",256).notNullable().comment("filename");
b.string("content_type", 16).notNullable();
b.string("basepath", 256).notNullable().comment("directory path for resource");
b.string("filename", 256).notNullable().comment("filename");
b.string("content_hash").nullable();
b.json("additional").nullable();
b.integer("created_at").notNullable();
b.integer("modified_at").notNullable();
b.integer("deleted_at");
b.index("content_type","content_type_index");
b.index("content_type", "content_type_index");
});
await knex.schema.createTable("tags", (b)=>{
await knex.schema.createTable("tags", (b) => {
b.string("name").primary();
b.text("description");
});
await knex.schema.createTable("doc_tag_relation",(b)=>{
await knex.schema.createTable("doc_tag_relation", (b) => {
b.integer("doc_id").unsigned().notNullable();
b.string("tag_name").notNullable();
b.foreign("doc_id").references("document.id");
b.foreign("tag_name").references("tags.name");
b.primary(["doc_id","tag_name"]);
b.primary(["doc_id", "tag_name"]);
});
await knex.schema.createTable("permissions",b=>{
b.string('username').notNullable();
await knex.schema.createTable("permissions", b => {
b.string("username").notNullable();
b.string("name").notNullable();
b.primary(["username","name"]);
b.foreign('username').references('users.username');
b.primary(["username", "name"]);
b.foreign("username").references("users.username");
});
//create admin account.
// create admin account.
await knex.insert({
username:"admin",
password_hash:"unchecked",
password_salt:"unchecked"
}).into('users');
};
username: "admin",
password_hash: "unchecked",
password_salt: "unchecked",
}).into("users");
}
export async function down(knex:Knex) {
throw new Error('Downward migrations are not supported. Restore from backup.');
};
export async function down(knex: Knex) {
throw new Error("Downward migrations are not supported. Restore from backup.");
}

View File

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

View File

@ -3,6 +3,7 @@
## Routing
### server routing
- content
- \d+
- comic
@ -31,6 +32,7 @@
- profile
## TODO
- server push
- ~~permission~~
- diff
@ -44,6 +46,6 @@
add URL Render page 바꾸기
add modified_time
add support robots.txt
add support robots.txt
add vite ssr
add vite ssr

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',{
passwordReset:async (username:string,toPw:string)=>{
return await ipcRenderer.invoke('reset_password',username,toPw);
}
});
contextBridge.exposeInMainWorld("electron", {
passwordReset: async (username: string, toPw: string) => {
return await ipcRenderer.invoke("reset_password", username, toPw);
},
});

View File

@ -1,66 +1,66 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$ref": "#/definitions/SettingConfig",
"definitions": {
"SettingConfig": {
"type": "object",
"properties": {
"localmode": {
"type": "boolean",
"description": "if true, server will bind on '127.0.0.1' rather than '0.0.0.0'"
},
"guest": {
"type": "array",
"items": {
"$ref": "#/definitions/Permission"
},
"description": "guest permission"
},
"jwt_secretkey": {
"type": "string",
"description": "JWT secret key. if you change its value, all access tokens are invalidated."
},
"port": {
"type": "number",
"description": "the port which running server is binding on."
},
"mode": {
"type": "string",
"enum": [
"development",
"production"
]
},
"cli": {
"type": "boolean",
"description": "if true, do not show 'electron' window and show terminal only."
},
"forbid_remote_admin_login": {
"type": "boolean",
"description": "forbid to login admin from remote client. but, it do not invalidate access token. \r if you want to invalidate access token, change 'jwt_secretkey'."
},
"$schema": {
"type": "string"
}
},
"required": [
"localmode",
"guest",
"jwt_secretkey",
"port",
"mode",
"cli",
"forbid_remote_admin_login"
],
"additionalProperties": false
"$schema": "http://json-schema.org/draft-07/schema#",
"$ref": "#/definitions/SettingConfig",
"definitions": {
"SettingConfig": {
"type": "object",
"properties": {
"localmode": {
"type": "boolean",
"description": "if true, server will bind on '127.0.0.1' rather than '0.0.0.0'"
},
"Permission": {
"type": "string",
"enum": [
"ModifyTag",
"QueryContent",
"ModifyTagDesc"
]
"guest": {
"type": "array",
"items": {
"$ref": "#/definitions/Permission"
},
"description": "guest permission"
},
"jwt_secretkey": {
"type": "string",
"description": "JWT secret key. if you change its value, all access tokens are invalidated."
},
"port": {
"type": "number",
"description": "the port which running server is binding on."
},
"mode": {
"type": "string",
"enum": [
"development",
"production"
]
},
"cli": {
"type": "boolean",
"description": "if true, do not show 'electron' window and show terminal only."
},
"forbid_remote_admin_login": {
"type": "boolean",
"description": "forbid to login admin from remote client. but, it do not invalidate access token. \r if you want to invalidate access token, change 'jwt_secretkey'."
},
"$schema": {
"type": "string"
}
},
"required": [
"localmode",
"guest",
"jwt_secretkey",
"port",
"mode",
"cli",
"forbid_remote_admin_login"
],
"additionalProperties": false
},
"Permission": {
"type": "string",
"enum": [
"ModifyTag",
"QueryContent",
"ModifyTagDesc"
]
}
}
}
}

View File

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

View File

@ -1,99 +1,99 @@
import {Document, DocumentAccessor, DocumentBody, QueryListOption} from "../../model/doc";
import {toQueryString} from './util';
import { Document, DocumentAccessor, DocumentBody, QueryListOption } from "../../model/doc";
import { toQueryString } from "./util";
const baseurl = "/api/doc";
export * from "../../model/doc";
export class FetchFailError extends Error{}
export class FetchFailError extends Error {}
export class ClientDocumentAccessor implements DocumentAccessor{
export class ClientDocumentAccessor implements DocumentAccessor {
search: (search_word: string) => Promise<Document[]>;
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");
};
async findDeleted(content_type: string): Promise<Document[]>{
}
async findDeleted(content_type: string): Promise<Document[]> {
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) : ""}`);
if(res.status == 401) throw new FetchFailError("Unauthorized")
if(res.status !== 200) throw new FetchFailError("findList Failed");
if (res.status == 401) throw new FetchFailError("Unauthorized");
if (res.status !== 200) throw new FetchFailError("findList Failed");
let ret = await res.json();
return ret;
}
async findById(id: number, tagload?: boolean | undefined): Promise<Document | undefined>{
async findById(id: number, tagload?: boolean | undefined): Promise<Document | undefined> {
let res = await fetch(`${baseurl}/${id}`);
if(res.status !== 200) throw new FetchFailError("findById Failed");;
if (res.status !== 200) throw new FetchFailError("findById Failed");
let ret = await res.json();
return ret;
}
/**
* not implement
*/
async findListByBasePath(basepath: string): Promise<Document[]>{
async findListByBasePath(basepath: string): Promise<Document[]> {
throw new Error("not implement");
return [];
}
async update(c: Partial<Document> & { id: number; }): Promise<boolean>{
const {id,...rest} = c;
const res = await fetch(`${baseurl}/${id}`,{
async update(c: Partial<Document> & { id: number }): Promise<boolean> {
const { id, ...rest } = c;
const res = await fetch(`${baseurl}/${id}`, {
method: "POST",
body: JSON.stringify(rest),
headers:{
'content-type':"application/json"
}
headers: {
"content-type": "application/json",
},
});
const ret = await res.json();
return ret;
}
async add(c: DocumentBody): Promise<number>{
async add(c: DocumentBody): Promise<number> {
throw new Error("not allow");
const res = await fetch(`${baseurl}`,{
const res = await fetch(`${baseurl}`, {
method: "POST",
body: JSON.stringify(c),
headers:{
'content-type':"application/json"
}
headers: {
"content-type": "application/json",
},
});
const ret = await res.json();
return ret;
}
async del(id: number): Promise<boolean>{
const res = await fetch(`${baseurl}/${id}`,{
method: "DELETE"
async del(id: number): Promise<boolean> {
const res = await fetch(`${baseurl}/${id}`, {
method: "DELETE",
});
const ret = await res.json();
return ret;
}
async addTag(c: Document, tag_name: string): Promise<boolean>{
const {id,...rest} = c;
const res = await fetch(`${baseurl}/${id}/tags/${tag_name}`,{
async addTag(c: Document, tag_name: string): Promise<boolean> {
const { id, ...rest } = c;
const res = await fetch(`${baseurl}/${id}/tags/${tag_name}`, {
method: "POST",
body: JSON.stringify(rest),
headers:{
'content-type':"application/json"
}
headers: {
"content-type": "application/json",
},
});
const ret = await res.json();
return ret;
}
async delTag(c: Document, tag_name: string): Promise<boolean>{
const {id,...rest} = c;
const res = await fetch(`${baseurl}/${id}/tags/${tag_name}`,{
async delTag(c: Document, tag_name: string): Promise<boolean> {
const { id, ...rest } = c;
const res = await fetch(`${baseurl}/${id}/tags/${tag_name}`, {
method: "DELETE",
body: JSON.stringify(rest),
headers:{
'content-type':"application/json"
}
headers: {
"content-type": "application/json",
},
});
const ret = await res.json();
return ret;
}
}
export const CDocumentAccessor = new ClientDocumentAccessor;
export const makeThumbnailUrl = (x: Document)=>{
export const CDocumentAccessor = new ClientDocumentAccessor();
export const makeThumbnailUrl = (x: Document) => {
return `${baseurl}/${x.id}/${x.content_type}/thumbnail`;
}
};
export default CDocumentAccessor;
export default CDocumentAccessor;

View File

@ -1,35 +1,32 @@
type Representable = string|number|boolean;
type Representable = string | number | boolean;
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)
.filter((e): e is [string,Representable|Representable[]] =>
e[1] !== undefined)
.map(e =>
e[1] instanceof Array
? e[1].map(f=>`${e[0]}=${(f)}`).join('&')
: `${e[0]}=${(e[1])}`)
.join('&');
}
export const QueryStringToMap = (query:string) =>{
const keyValue = query.slice(query.indexOf("?")+1).split("&");
const param:{[k:string]:string|string[]} = {};
keyValue.forEach((p)=>{
const [k,v] = p.split("=");
.filter((e): e is [string, Representable | Representable[]] => e[1] !== undefined)
.map(e =>
e[1] instanceof Array
? e[1].map(f => `${e[0]}=${(f)}`).join("&")
: `${e[0]}=${(e[1])}`
)
.join("&");
};
export const QueryStringToMap = (query: string) => {
const keyValue = query.slice(query.indexOf("?") + 1).split("&");
const param: { [k: string]: string | string[] } = {};
keyValue.forEach((p) => {
const [k, v] = p.split("=");
const pv = param[k];
if(pv === undefined){
if (pv === undefined) {
param[k] = v;
}
else if(typeof pv === "string"){
param[k] = [pv,v];
}
else{
} else if (typeof pv === "string") {
param[k] = [pv, v];
} else {
pv.push(v);
}
});
return param;
}
};

View File

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

View File

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

View File

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

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 = ()=>{
return (<Box style={{position:"absolute", top:"50%", left:"50%", transform:"translate(-50%,-50%)"}}>
<CircularProgress title="loading" />
</Box>);
}
export const LoadingCircle = () => {
return (
<Box style={{ position: "absolute", top: "50%", left: "50%", transform: "translate(-50%,-50%)" }}>
<CircularProgress title="loading" />
</Box>
);
};

View File

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

View File

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

View File

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

View File

@ -1,24 +1,24 @@
{
"name": "ionian_client",
"version": "0.0.1",
"description": "client of ionian",
"scripts": {
"build:watch": "ts-node build.ts"
},
"dependencies": {
"@emotion/react": "^11.9.0",
"@emotion/styled": "^11.8.1",
"@mui/icons-material": "^5.6.2",
"@mui/material": "^5.6.2",
"@mui/x-data-grid": "^5.12.3",
"@types/react": "^18.0.5",
"@types/react-dom": "^18.0.1",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"react-router-dom": "^6.3.0"
},
"devDependencies": {
"esbuild": "^0.14.36",
"ts-node": "^10.7.0"
}
"name": "ionian_client",
"version": "0.0.1",
"description": "client of ionian",
"scripts": {
"build:watch": "ts-node build.ts"
},
"dependencies": {
"@emotion/react": "^11.9.0",
"@emotion/styled": "^11.8.1",
"@mui/icons-material": "^5.6.2",
"@mui/material": "^5.6.2",
"@mui/x-data-grid": "^5.12.3",
"@types/react": "^18.0.5",
"@types/react-dom": "^18.0.1",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"react-router-dom": "^6.3.0"
},
"devDependencies": {
"esbuild": "^0.14.36",
"ts-node": "^10.7.0"
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,65 +1,66 @@
import {ContentFile, createDefaultClass,registerContentReferrer, ContentConstructOption} from './file';
import {readZip, readAllFromZip} from '../util/zipwrap';
import { DocumentBody } from '../model/doc';
import {extname} from 'path';
import { extname } from "path";
import { DocumentBody } from "../model/doc";
import { readAllFromZip, readZip } from "../util/zipwrap";
import { ContentConstructOption, ContentFile, createDefaultClass, registerContentReferrer } from "./file";
type ComicType = "doujinshi"|"artist cg"|"manga"|"western";
interface ComicDesc{
title:string,
artist?:string[],
group?:string[],
series?:string[],
type:ComicType|[ComicType],
character?:string[],
tags?:string[]
type ComicType = "doujinshi" | "artist cg" | "manga" | "western";
interface ComicDesc {
title: string;
artist?: string[];
group?: string[];
series?: string[];
type: ComicType | [ComicType];
character?: string[];
tags?: string[];
}
const ImageExt = ['.gif', '.png', '.jpeg', '.bmp', '.webp', '.jpg'];
export class ComicReferrer extends createDefaultClass("comic"){
desc: ComicDesc|undefined;
const ImageExt = [".gif", ".png", ".jpeg", ".bmp", ".webp", ".jpg"];
export class ComicReferrer extends createDefaultClass("comic") {
desc: ComicDesc | undefined;
pagenum: number;
additional: ContentConstructOption| undefined;
constructor(path:string,option?:ContentConstructOption){
additional: ContentConstructOption | undefined;
constructor(path: string, option?: ContentConstructOption) {
super(path);
this.additional = option;
this.pagenum = 0;
}
async initDesc():Promise<void>{
if(this.desc !== undefined) return;
async initDesc(): Promise<void> {
if (this.desc !== undefined) return;
const zip = await readZip(this.path);
const entries = await zip.entries();
this.pagenum = Object.keys(entries).filter(x=>ImageExt.includes(extname(x))).length;
const entry = entries["desc.json"];
if(entry === undefined){
this.pagenum = Object.keys(entries).filter(x => ImageExt.includes(extname(x))).length;
const entry = entries["desc.json"];
if (entry === undefined) {
return;
}
const data = (await readAllFromZip(zip,entry)).toString('utf-8');
const data = (await readAllFromZip(zip, entry)).toString("utf-8");
this.desc = JSON.parse(data);
if(this.desc === undefined)
if (this.desc === undefined) {
throw new Error(`JSON.parse is returning undefined. ${this.path} desc.json format error`);
}
}
async createDocumentBody(): Promise<DocumentBody>{
async createDocumentBody(): Promise<DocumentBody> {
await this.initDesc();
const basebody = await super.createDocumentBody();
this.desc?.title;
if(this.desc === undefined){
if (this.desc === undefined) {
return basebody;
}
let tags:string[] = this.desc.tags ?? [];
tags = tags.concat(this.desc.artist?.map(x=>`artist:${x}`) ?? []);
tags = tags.concat(this.desc.character?.map(x=>`character:${x}`) ?? []);
tags = tags.concat(this.desc.group?.map(x=>`group:${x}`) ?? []);
tags = tags.concat(this.desc.series?.map(x=>`series:${x}`) ?? []);
const type = this.desc.type instanceof Array ? this.desc.type[0]: this.desc.type;
let tags: string[] = this.desc.tags ?? [];
tags = tags.concat(this.desc.artist?.map(x => `artist:${x}`) ?? []);
tags = tags.concat(this.desc.character?.map(x => `character:${x}`) ?? []);
tags = tags.concat(this.desc.group?.map(x => `group:${x}`) ?? []);
tags = tags.concat(this.desc.series?.map(x => `series:${x}`) ?? []);
const type = this.desc.type instanceof Array ? this.desc.type[0] : this.desc.type;
tags.push(`type:${type}`);
return {
...basebody,
title:this.desc.title,
additional:{
page:this.pagenum
title: this.desc.title,
additional: {
page: this.pagenum,
},
tags:tags
tags: tags,
};
}
};
registerContentReferrer(ComicReferrer);
}
registerContentReferrer(ComicReferrer);

View File

@ -1,42 +1,44 @@
import {Context, DefaultState, DefaultContext, Middleware, Next} from 'koa';
import Router from 'koa-router';
import {createHash} from 'crypto';
import {promises, Stats} from 'fs'
import {extname} from 'path';
import { DocumentBody } from '../model/mod';
import path from 'path';
import { createHash } from "crypto";
import { promises, Stats } from "fs";
import { Context, DefaultContext, DefaultState, Middleware, Next } from "koa";
import Router from "koa-router";
import { extname } from "path";
import path from "path";
import { DocumentBody } from "../model/mod";
/**
* content file or directory referrer
*/
export interface ContentFile{
getHash():Promise<string>;
createDocumentBody():Promise<DocumentBody>;
export interface ContentFile {
getHash(): Promise<string>;
createDocumentBody(): Promise<DocumentBody>;
readonly path: string;
readonly type: string;
}
export type ContentConstructOption = {
hash: string,
}
type ContentFileConstructor = (new (path:string,option?:ContentConstructOption) => ContentFile)&{content_type:string};
export const createDefaultClass = (type:string):ContentFileConstructor=>{
let cons = class implements ContentFile{
hash: string;
};
type ContentFileConstructor = (new(path: string, option?: ContentConstructOption) => ContentFile) & {
content_type: string;
};
export const createDefaultClass = (type: string): ContentFileConstructor => {
let cons = class implements ContentFile {
readonly path: string;
//type = type;
// type = type;
static content_type = type;
protected hash: string| undefined;
protected stat: Stats| undefined;
protected hash: string | undefined;
protected stat: Stats | undefined;
constructor(path:string,option?:ContentConstructOption){
constructor(path: string, option?: ContentConstructOption) {
this.path = path;
this.hash = option?.hash;
this.stat = undefined;
}
async createDocumentBody(): Promise<DocumentBody> {
const {base,dir, name} = path.parse(this.path);
const { base, dir, name } = path.parse(this.path);
const ret = {
title : name,
basepath : dir,
title: name,
basepath: dir,
additional: {},
content_type: cons.content_type,
filename: base,
@ -46,43 +48,43 @@ export const createDefaultClass = (type:string):ContentFileConstructor=>{
} as DocumentBody;
return ret;
}
get type():string{
get type(): string {
return cons.content_type;
}
async getHash():Promise<string>{
if(this.hash !== undefined) return this.hash;
async getHash(): Promise<string> {
if (this.hash !== undefined) return this.hash;
this.stat = await promises.stat(this.path);
const hash = createHash("sha512");
hash.update(extname(this.path));
hash.update(this.stat.mode.toString());
//if(this.desc !== undefined)
// if(this.desc !== undefined)
// hash.update(JSON.stringify(this.desc));
hash.update(this.stat.size.toString());
this.hash = hash.digest("base64");
return this.hash;
}
async getMtime():Promise<number>{
if(this.stat !== undefined) return this.stat.mtimeMs;
async getMtime(): Promise<number> {
if (this.stat !== undefined) return this.stat.mtimeMs;
await this.getHash();
return this.stat!.mtimeMs;
}
};
return cons;
}
let ContstructorTable:{[k:string]:ContentFileConstructor} = {};
export function registerContentReferrer(s: ContentFileConstructor){
console.log(`registered content type: ${s.content_type}`)
};
let ContstructorTable: { [k: string]: ContentFileConstructor } = {};
export function registerContentReferrer(s: ContentFileConstructor) {
console.log(`registered content type: ${s.content_type}`);
ContstructorTable[s.content_type] = s;
}
export function createContentFile(type:string,path:string,option?:ContentConstructOption){
export function createContentFile(type: string, path: string, option?: ContentConstructOption) {
const constructorMethod = ContstructorTable[type];
if(constructorMethod === undefined){
if (constructorMethod === undefined) {
console.log(`${type} are not in ${JSON.stringify(ContstructorTable)}`);
throw new Error("construction method of the content type is undefined");
}
return new constructorMethod(path,option);
return new constructorMethod(path, option);
}
export function getContentFileConstructor(type:string): ContentFileConstructor|undefined{
export function getContentFileConstructor(type: string): ContentFileConstructor | undefined {
const ret = ContstructorTable[type];
return ret;
}
}

View File

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

View File

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

View File

@ -1,49 +1,47 @@
import { existsSync } from 'fs';
import Knex from 'knex';
import {Knex as KnexConfig} from './config';
import { get_setting } from './SettingConfig';
import { existsSync } from "fs";
import Knex from "knex";
import { Knex as KnexConfig } from "./config";
import { get_setting } from "./SettingConfig";
export async function connectDB(){
export async function connectDB() {
const env = get_setting().mode;
const config = KnexConfig.config[env];
if(!config.connection){
if (!config.connection) {
throw new Error("connection options required.");
}
const connection = config.connection
if(typeof connection === "string"){
const connection = config.connection;
if (typeof connection === "string") {
throw new Error("unknown connection options");
}
if(typeof connection === "function"){
if (typeof connection === "function") {
throw new Error("connection provider not supported...");
}
if(!("filename" in connection) ){
if (!("filename" in connection)) {
throw new Error("sqlite3 config need");
}
const init_need = !existsSync(connection.filename);
const knex = Knex(config);
let tries = 0;
for(;;){
try{
for (;;) {
try {
console.log("try to connect db");
await knex.raw('select 1 + 1;');
await knex.raw("select 1 + 1;");
console.log("connect success");
}
catch(err){
if(tries < 3){
} catch (err) {
if (tries < 3) {
tries++;
console.error(`connection fail ${err} retry...`);
continue;
}
else{
} else {
throw err;
}
}
break;
}
if(init_need){
if (init_need) {
console.log("first execute: initialize database...");
const migrate = await import("../migrations/initial");
await migrate.up(knex);
}
return knex;
}
}

View File

@ -1,118 +1,118 @@
import { Document, DocumentBody, DocumentAccessor, QueryListOption } from '../model/doc';
import {Knex} from 'knex';
import {createKnexTagController} from './tag';
import { TagAccessor } from '../model/tag';
import { Knex } from "knex";
import { Document, DocumentAccessor, DocumentBody, QueryListOption } from "../model/doc";
import { TagAccessor } from "../model/tag";
import { createKnexTagController } from "./tag";
export type DBTagContentRelation = {
doc_id:number,
tag_name:string
}
doc_id: number;
tag_name: string;
};
class KnexDocumentAccessor implements DocumentAccessor{
knex : Knex;
class KnexDocumentAccessor implements DocumentAccessor {
knex: Knex;
tagController: TagAccessor;
constructor(knex : Knex){
constructor(knex: Knex) {
this.knex = knex;
this.tagController = createKnexTagController(knex);
}
async search(search_word: string): Promise<Document[]>{
async search(search_word: string): Promise<Document[]> {
throw new Error("Method not implemented.");
const sw = `%${search_word}%`;
const docs = await this.knex.select("*").from("document")
.where("title","like",sw);
.where("title", "like", sw);
return docs;
}
async addList(content_list: DocumentBody[]):Promise<number[]>{
return await this.knex.transaction(async (trx)=>{
//add tags
async addList(content_list: DocumentBody[]): Promise<number[]> {
return await this.knex.transaction(async (trx) => {
// add tags
const tagCollected = new Set<string>();
content_list.map(x=>x.tags).forEach((x)=>{
x.forEach(x=>{
content_list.map(x => x.tags).forEach((x) => {
x.forEach(x => {
tagCollected.add(x);
});
});
const tagCollectPromiseList = [];
const tagController = createKnexTagController(trx);
for (const it of tagCollected){
const p = tagController.addTag({name:it});
for (const it of tagCollected) {
const p = tagController.addTag({ name: it });
tagCollectPromiseList.push(p);
}
await Promise.all(tagCollectPromiseList);
//add for each contents
// add for each contents
const ret = [];
for (const content of content_list) {
const {tags,additional, ...rest} = content;
const { tags, additional, ...rest } = content;
const id_lst = await trx.insert({
additional:JSON.stringify(additional),
created_at:Date.now(),
...rest
additional: JSON.stringify(additional),
created_at: Date.now(),
...rest,
}).into("document");
const id = id_lst[0];
if(tags.length > 0){
await trx.insert(tags.map(y=>({
doc_id:id,
tag_name:y
}))).into('doc_tag_relation');
if (tags.length > 0) {
await trx.insert(tags.map(y => ({
doc_id: id,
tag_name: y,
}))).into("doc_tag_relation");
}
ret.push(id);
}
return ret;
});
}
async add(c: DocumentBody){
const {tags,additional, ...rest} = c;
async add(c: DocumentBody) {
const { tags, additional, ...rest } = c;
const id_lst = await this.knex.insert({
additional:JSON.stringify(additional),
created_at:Date.now(),
...rest
}).into('document');
additional: JSON.stringify(additional),
created_at: Date.now(),
...rest,
}).into("document");
const id = id_lst[0];
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>(
tags.map(x=>({doc_id:id,tag_name:x}))
tags.map(x => ({ doc_id: id, tag_name: x })),
).into("doc_tag_relation");
}
return id;
};
async del(id:number) {
if (await this.findById(id) !== undefined){
await this.knex.delete().from("doc_tag_relation").where({doc_id:id});
await this.knex.delete().from("document").where({id:id});
}
async del(id: number) {
if (await this.findById(id) !== undefined) {
await this.knex.delete().from("doc_tag_relation").where({ doc_id: id });
await this.knex.delete().from("document").where({ id: id });
return true;
}
return false;
};
async findById(id:number,tagload?:boolean): Promise<Document|undefined>{
const s = await this.knex.select("*").from("document").where({id:id});
if(s.length === 0) return undefined;
}
async findById(id: number, tagload?: boolean): Promise<Document | undefined> {
const s = await this.knex.select("*").from("document").where({ id: id });
if (s.length === 0) return undefined;
const first = s[0];
let ret_tags:string[] = []
if(tagload === true){
const tags : DBTagContentRelation[] = await this.knex.select("*")
.from("doc_tag_relation").where({doc_id:first.id});
ret_tags = tags.map(x=>x.tag_name);
let ret_tags: string[] = [];
if (tagload === true) {
const tags: DBTagContentRelation[] = await this.knex.select("*")
.from("doc_tag_relation").where({ doc_id: first.id });
ret_tags = tags.map(x => x.tag_name);
}
return {
...first,
tags:ret_tags,
tags: ret_tags,
additional: first.additional !== null ? JSON.parse(first.additional) : {},
};
};
async findDeleted(content_type:string){
}
async findDeleted(content_type: string) {
const s = await this.knex.select("*")
.where({content_type:content_type})
.where({ content_type: content_type })
.whereNotNull("update_at")
.from("document");
return s.map(x=>({
return s.map(x => ({
...x,
tags:[],
additional:{}
tags: [],
additional: {},
}));
}
async findList(option?:QueryListOption){
async findList(option?: QueryListOption) {
option = option ?? {};
const allow_tag = option.allow_tag ?? [];
const eager_loading = option.eager_loading ?? true;
@ -123,98 +123,101 @@ class KnexDocumentAccessor implements DocumentAccessor{
const content_type = option.content_type;
const cursor = option.cursor;
const buildquery = ()=>{
const buildquery = () => {
let query = this.knex.select("document.*");
if(allow_tag.length > 0){
if (allow_tag.length > 0) {
query = query.from("doc_tag_relation as tags_0");
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++) {
const element = allow_tag[index];
query = query.innerJoin(`doc_tag_relation as tags_${index}`,`tags_${index}.doc_id`,"tags_0.doc_id");
query = query.where(`tags_${index}.tag_name`,'=',element);
query = query.innerJoin(
`doc_tag_relation as tags_${index}`,
`tags_${index}.doc_id`,
"tags_0.doc_id",
);
query = query.where(`tags_${index}.tag_name`, "=", element);
}
query = query.innerJoin("document","tags_0.doc_id","document.id");
}
else{
query = query.innerJoin("document", "tags_0.doc_id", "document.id");
} else {
query = query.from("document");
}
if(word !== undefined){
//don't worry about sql injection.
query = query.where('title','like',`%${word}%`);
if (word !== undefined) {
// don't worry about sql injection.
query = query.where("title", "like", `%${word}%`);
}
if(content_type !== undefined){
query = query.where('content_type','=',content_type);
if (content_type !== undefined) {
query = query.where("content_type", "=", content_type);
}
if(use_offset){
if (use_offset) {
query = query.offset(offset);
}
else{
if(cursor !== undefined){
query = query.where('id','<',cursor);
} else {
if (cursor !== undefined) {
query = query.where("id", "<", cursor);
}
}
query = query.limit(limit);
query = query.orderBy('id',"desc");
query = query.orderBy("id", "desc");
return query;
}
};
let query = buildquery();
//console.log(query.toSQL());
let result:Document[] = await query;
for(let i of result){
// console.log(query.toSQL());
let result: Document[] = await query;
for (let i of result) {
i.additional = JSON.parse((i.additional as unknown) as string);
}
if(eager_loading){
let idmap: {[index:number]:Document} = {};
for(const r of result){
if (eager_loading) {
let idmap: { [index: number]: Document } = {};
for (const r of result) {
idmap[r.id] = r;
r.tags = [];
}
let subquery = buildquery();
let tagquery= this.knex.select("id","doc_tag_relation.tag_name").from(subquery)
.innerJoin("doc_tag_relation","doc_tag_relation.doc_id","id");
//console.log(tagquery.toSQL());
let tagresult:{id:number,tag_name:string}[] = await tagquery;
for(const {id,tag_name} of tagresult){
let tagquery = this.knex.select("id", "doc_tag_relation.tag_name").from(subquery)
.innerJoin("doc_tag_relation", "doc_tag_relation.doc_id", "id");
// console.log(tagquery.toSQL());
let tagresult: { id: number; tag_name: string }[] = await tagquery;
for (const { id, tag_name } of tagresult) {
idmap[id].tags.push(tag_name);
}
}
else{
result.forEach(v=>{v.tags = [];});
} else {
result.forEach(v => {
v.tags = [];
});
}
return result;
};
async findByPath(path:string,filename?:string):Promise<Document[]>{
const e = filename == undefined ? {} : {filename:filename}
const results = await this.knex.select("*").from("document").where({basepath:path,...e});
return results.map(x=>({
...x,
tags:[],
additional:{}
}))
}
async update(c:Partial<Document> & { id:number }){
const {id,tags,...rest} = c;
if (await this.findById(id) !== undefined){
await this.knex.update(rest).where({id: id}).from("document");
async findByPath(path: string, filename?: string): Promise<Document[]> {
const e = filename == undefined ? {} : { filename: filename };
const results = await this.knex.select("*").from("document").where({ basepath: path, ...e });
return results.map(x => ({
...x,
tags: [],
additional: {},
}));
}
async update(c: Partial<Document> & { id: number }) {
const { id, tags, ...rest } = c;
if (await this.findById(id) !== undefined) {
await this.knex.update(rest).where({ id: id }).from("document");
return true;
}
return false;
}
async addTag(c: Document,tag_name:string){
async addTag(c: Document, tag_name: string) {
if (c.tags.includes(tag_name)) return false;
this.tagController.addTag({name:tag_name});
await this.knex.insert<DBTagContentRelation>({tag_name: tag_name, doc_id: c.id})
this.tagController.addTag({ name: tag_name });
await this.knex.insert<DBTagContentRelation>({ tag_name: tag_name, doc_id: c.id })
.into("doc_tag_relation");
c.tags.push(tag_name);
return true;
}
async delTag(c: Document,tag_name:string){
async delTag(c: Document, tag_name: string) {
if (c.tags.includes(tag_name)) return false;
await this.knex.delete().where({tag_name: tag_name,doc_id: c.id}).from("doc_tag_relation");
await this.knex.delete().where({ tag_name: tag_name, doc_id: c.id }).from("doc_tag_relation");
c.tags.push(tag_name);
return true;
}
}
export const createKnexDocumentAccessor = (knex:Knex): DocumentAccessor=>{
export const createKnexDocumentAccessor = (knex: Knex): DocumentAccessor => {
return new KnexDocumentAccessor(knex);
}
};

View File

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

View File

@ -1,57 +1,57 @@
import {Tag, TagAccessor, TagCount} from '../model/tag';
import {Knex} from 'knex';
import {DBTagContentRelation} from './doc';
import { Knex } from "knex";
import { Tag, TagAccessor, TagCount } from "../model/tag";
import { DBTagContentRelation } from "./doc";
type DBTags = {
name: string,
description?: string
}
name: string;
description?: string;
};
class KnexTagAccessor implements TagAccessor{
knex:Knex<DBTags>
constructor(knex:Knex){
class KnexTagAccessor implements TagAccessor {
knex: Knex<DBTags>;
constructor(knex: Knex) {
this.knex = knex;
}
async getAllTagCount(): Promise<TagCount[]> {
const result = await this.knex<DBTagContentRelation>("doc_tag_relation").select("tag_name")
.count("*",{as:"occurs"}).groupBy<TagCount[]>("tag_name");
.count("*", { as: "occurs" }).groupBy<TagCount[]>("tag_name");
return result;
}
async getAllTagList(onlyname?:boolean){
async getAllTagList(onlyname?: boolean) {
onlyname = onlyname ?? false;
const t:DBTags[] = await this.knex.select(onlyname ? "*" : "name").from("tags")
const t: DBTags[] = await this.knex.select(onlyname ? "*" : "name").from("tags");
return t;
}
async getTagByName(name:string){
const t:DBTags[] = await this.knex.select('*').from("tags").where({name: name});
if(t.length === 0) return undefined;
async getTagByName(name: string) {
const t: DBTags[] = await this.knex.select("*").from("tags").where({ name: name });
if (t.length === 0) return undefined;
return t[0];
}
async addTag(tag: Tag){
if(await this.getTagByName(tag.name) === undefined){
async addTag(tag: Tag) {
if (await this.getTagByName(tag.name) === undefined) {
await this.knex.insert<DBTags>({
name:tag.name,
description:tag.description === undefined ? "" : tag.description
name: tag.name,
description: tag.description === undefined ? "" : tag.description,
}).into("tags");
return true;
}
return false;
}
async delTag(name:string){
if(await this.getTagByName(name) !== undefined){
await this.knex.delete().where({name:name}).from("tags");
async delTag(name: string) {
if (await this.getTagByName(name) !== undefined) {
await this.knex.delete().where({ name: name }).from("tags");
return true;
}
return false;
}
async updateTag(name:string,desc:string){
if(await this.getTagByName(name) !== undefined){
await this.knex.update({description:desc}).where({name:name}).from("tags");
async updateTag(name: string, desc: string) {
if (await this.getTagByName(name) !== undefined) {
await this.knex.update({ description: desc }).where({ name: name }).from("tags");
return true;
}
return false;
}
};
export const createKnexTagController = (knex:Knex):TagAccessor=>{
}
export const createKnexTagController = (knex: Knex): TagAccessor => {
return new KnexTagAccessor(knex);
}
};

View File

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

View File

@ -1,15 +1,15 @@
import { basename, dirname, join as pathjoin } from 'path';
import { Document, DocumentAccessor } from '../model/mod';
import { ContentFile, createContentFile } from '../content/mod';
import { IDiffWatcher } from './watcher';
import { ContentList } from './content_list';
import { basename, dirname, join as pathjoin } from "path";
import { ContentFile, createContentFile } from "../content/mod";
import { Document, DocumentAccessor } from "../model/mod";
import { ContentList } from "./content_list";
import { IDiffWatcher } from "./watcher";
//refactoring needed.
// refactoring needed.
export class ContentDiffHandler {
/** content file list waiting to add */
waiting_list: ContentList;
/** deleted contents */
tombstone: Map<string, Document>;//hash, contentfile
tombstone: Map<string, Document>; // hash, contentfile
doc_cntr: DocumentAccessor;
/** content type of handle */
content_type: string;
@ -26,21 +26,21 @@ export class ContentDiffHandler {
}
}
register(diff: IDiffWatcher) {
diff.on('create', (path) => this.OnCreated(path))
.on('delete', (path) => this.OnDeleted(path))
.on('change', (prev, cur) => this.OnChanged(prev, cur));
diff.on("create", (path) => this.OnCreated(path))
.on("delete", (path) => this.OnDeleted(path))
.on("change", (prev, cur) => this.OnChanged(prev, cur));
}
private async OnDeleted(cpath: string) {
const basepath = dirname(cpath);
const filename = basename(cpath);
console.log("deleted ", cpath);
//if it wait to add, delete it from waiting list.
// if it wait to add, delete it from waiting list.
if (this.waiting_list.hasByPath(cpath)) {
this.waiting_list.deleteByPath(cpath);
return;
}
const dbc = await this.doc_cntr.findByPath(basepath, filename);
//when there is no related content in db, ignore.
// when there is no related content in db, ignore.
if (dbc.length === 0) {
console.log("its not in waiting_list and db!!!: ", cpath);
return;
@ -48,10 +48,10 @@ export class ContentDiffHandler {
const content_hash = dbc[0].content_hash;
// When a path is changed, it takes into account when the
// creation event occurs first and the deletion occurs, not
// the change event.
// the change event.
const cf = this.waiting_list.getByHash(content_hash);
if (cf) {
//if a path is changed, update the changed path.
// if a path is changed, update the changed path.
console.log("update path from", cpath, "to", cf.path);
const newFilename = basename(cf.path);
const newBasepath = dirname(cf.path);
@ -64,7 +64,7 @@ export class ContentDiffHandler {
});
return;
}
//invalidate db and add it to tombstone.
// invalidate db and add it to tombstone.
await this.doc_cntr.update({
id: dbc[0].id,
deleted_at: Date.now(),
@ -83,14 +83,13 @@ export class ContentDiffHandler {
id: c.id,
deleted_at: null,
filename: filename,
basepath: basepath
basepath: basepath,
});
}
if (this.waiting_list.hasByHash(hash)) {
console.log("Hash Conflict!!!");
}
this.waiting_list.set(content);
}
private async OnChanged(prev_path: string, cur_path: string) {
const prev_basepath = dirname(prev_path);
@ -99,7 +98,7 @@ export class ContentDiffHandler {
const cur_filename = basename(cur_path);
console.log("modify", cur_path, "from", prev_path);
const c = this.waiting_list.getByPath(prev_path);
if(c !== undefined){
if (c !== undefined) {
await this.waiting_list.delete(c);
const content = createContentFile(this.content_type, cur_path);
await this.waiting_list.set(content);
@ -107,7 +106,7 @@ export class ContentDiffHandler {
}
const doc = await this.doc_cntr.findByPath(prev_basepath, prev_filename);
if(doc.length === 0){
if (doc.length === 0) {
await this.OnCreated(cur_path);
return;
}
@ -115,7 +114,7 @@ export class ContentDiffHandler {
await this.doc_cntr.update({
...doc[0],
basepath: cur_basepath,
filename: cur_filename
filename: cur_filename,
});
}
}
}

View File

@ -1,59 +1,59 @@
import { ContentFile } from '../content/mod';
import { ContentFile } from "../content/mod";
export class ContentList{
export class ContentList {
/** path map */
private cl:Map<string,ContentFile>;
private cl: Map<string, ContentFile>;
/** hash map */
private hl:Map<string,ContentFile>;
constructor(){
this.cl = new Map;
this.hl = new Map;
private hl: Map<string, ContentFile>;
constructor() {
this.cl = new Map();
this.hl = new Map();
}
hasByHash(s:string){
hasByHash(s: string) {
return this.hl.has(s);
}
hasByPath(p:string){
hasByPath(p: string) {
return this.cl.has(p);
}
getByHash(s:string){
return this.hl.get(s)
getByHash(s: string) {
return this.hl.get(s);
}
getByPath(p:string){
getByPath(p: string) {
return this.cl.get(p);
}
async set(c:ContentFile){
async set(c: ContentFile) {
const path = c.path;
const hash = await c.getHash();
this.cl.set(path,c);
this.hl.set(hash,c);
this.cl.set(path, c);
this.hl.set(hash, c);
}
/** delete content file */
async delete(c:ContentFile){
async delete(c: ContentFile) {
const hash = await c.getHash();
let r = true;
r = this.cl.delete(c.path) && r;
r = this.hl.delete(hash) && r;
return r;
}
async deleteByPath(p:string){
async deleteByPath(p: string) {
const o = this.getByPath(p);
if(o === undefined) return false;
if (o === undefined) return false;
return await this.delete(o);
}
deleteByHash(s:string){
deleteByHash(s: string) {
const o = this.getByHash(s);
if(o === undefined) return false;
if (o === undefined) return false;
let r = true;
r = this.cl.delete(o.path) && r;
r = this.hl.delete(s) && r;
return r;
}
clear(){
clear() {
this.cl.clear();
this.hl.clear();
}
getAll(){
getAll() {
return [...this.cl.values()];
}
}

View File

@ -1,26 +1,26 @@
import { DocumentAccessor } from '../model/doc';
import {ContentDiffHandler} from './content_handler';
import { IDiffWatcher } from './watcher';
import asyncPool from 'tiny-async-pool';
import asyncPool from "tiny-async-pool";
import { DocumentAccessor } from "../model/doc";
import { ContentDiffHandler } from "./content_handler";
import { IDiffWatcher } from "./watcher";
export class DiffManager{
watching: {[content_type:string]:ContentDiffHandler};
export class DiffManager {
watching: { [content_type: string]: ContentDiffHandler };
doc_cntr: DocumentAccessor;
constructor(contorller: DocumentAccessor){
constructor(contorller: DocumentAccessor) {
this.watching = {};
this.doc_cntr = contorller;
}
async register(content_type:string,watcher:IDiffWatcher){
if(this.watching[content_type] === undefined){
this.watching[content_type] = new ContentDiffHandler(this.doc_cntr,content_type);
async register(content_type: string, watcher: IDiffWatcher) {
if (this.watching[content_type] === undefined) {
this.watching[content_type] = new ContentDiffHandler(this.doc_cntr, content_type);
}
this.watching[content_type].register(watcher);
await watcher.setup(this.doc_cntr);
}
async commit(type:string,path:string){
async commit(type: string, path: string) {
const list = this.watching[type].waiting_list;
const c = list.getByPath(path);
if(c===undefined){
if (c === undefined) {
throw new Error("path is not exist");
}
await list.delete(c);
@ -28,18 +28,18 @@ export class DiffManager{
const id = await this.doc_cntr.add(body);
return id;
}
async commitAll(type:string){
async commitAll(type: string) {
const list = this.watching[type].waiting_list;
const contentFiles = list.getAll();
list.clear();
const bodies = await asyncPool(30,contentFiles,async (x)=>await x.createDocumentBody());
const bodies = await asyncPool(30, contentFiles, async (x) => await x.createDocumentBody());
const ids = await this.doc_cntr.addList(bodies);
return ids;
}
getAdded(){
return Object.keys(this.watching).map(x=>({
type:x,
value:this.watching[x].waiting_list.getAll(),
getAdded() {
return Object.keys(this.watching).map(x => ({
type: x,
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,70 +1,70 @@
import Koa from 'koa';
import Router from 'koa-router';
import { ContentFile } from '../content/mod';
import { sendError } from '../route/error_handler';
import {DiffManager} from './diff';
import {AdminOnlyMiddleware} from '../permission/permission';
import Koa from "koa";
import Router from "koa-router";
import { ContentFile } from "../content/mod";
import { AdminOnlyMiddleware } from "../permission/permission";
import { sendError } from "../route/error_handler";
import { DiffManager } from "./diff";
function content_file_to_return(x:ContentFile){
return {path:x.path,type:x.type};
function content_file_to_return(x: ContentFile) {
return { path: x.path, type: x.type };
}
export const getAdded = (diffmgr:DiffManager)=> (ctx:Koa.Context,next:Koa.Next)=>{
export const getAdded = (diffmgr: DiffManager) => (ctx: Koa.Context, next: Koa.Next) => {
const ret = diffmgr.getAdded();
ctx.body = ret.map(x=>({
type:x.type,
value:x.value.map(x=>({path:x.path,type:x.type})),
ctx.body = ret.map(x => ({
type: x.type,
value: x.value.map(x => ({ path: x.path, type: x.type })),
}));
ctx.type = 'json';
}
ctx.type = "json";
};
type PostAddedBody = {
type:string,
path:string,
type: string;
path: string;
}[];
function checkPostAddedBody(body: any): body is PostAddedBody{
if(body instanceof Array){
return body.map(x=> 'type' in x && 'path' in x).every(x=>x);
function checkPostAddedBody(body: any): body is PostAddedBody {
if (body instanceof Array) {
return body.map(x => "type" in x && "path" in x).every(x => x);
}
return false;
}
export const postAdded = (diffmgr:DiffManager) => async (ctx:Router.IRouterContext,next:Koa.Next)=>{
export const postAdded = (diffmgr: DiffManager) => async (ctx: Router.IRouterContext, next: Koa.Next) => {
const reqbody = ctx.request.body;
if(!checkPostAddedBody(reqbody)){
sendError(400,"format exception");
if (!checkPostAddedBody(reqbody)) {
sendError(400, "format exception");
return;
}
const allWork = reqbody.map(op=>diffmgr.commit(op.type,op.path));
const allWork = reqbody.map(op => diffmgr.commit(op.type, op.path));
const results = await Promise.all(allWork);
ctx.body = {
ok:true,
docs:results,
}
ctx.type = 'json';
}
export const postAddedAll = (diffmgr: DiffManager) => async (ctx:Router.IRouterContext,next:Koa.Next) => {
if (!ctx.is('json')){
sendError(400,"format exception");
ok: true,
docs: results,
};
ctx.type = "json";
};
export const postAddedAll = (diffmgr: DiffManager) => async (ctx: Router.IRouterContext, next: Koa.Next) => {
if (!ctx.is("json")) {
sendError(400, "format exception");
return;
}
const reqbody = ctx.request.body as Record<string,unknown>;
if(!("type" in reqbody)){
sendError(400,"format exception: there is no \"type\"");
const reqbody = ctx.request.body as Record<string, unknown>;
if (!("type" in reqbody)) {
sendError(400, "format exception: there is no \"type\"");
return;
}
const t = reqbody["type"];
if(typeof t !== "string"){
sendError(400,"format exception: invalid type of \"type\"");
if (typeof t !== "string") {
sendError(400, "format exception: invalid type of \"type\"");
return;
}
await diffmgr.commitAll(t);
ctx.body = {
ok:true
ok: true,
};
ctx.type = 'json';
}
ctx.type = "json";
};
/*
export const getNotWatched = (diffmgr : DiffManager)=> (ctx:Router.IRouterContext,next:Koa.Next)=>{
ctx.body = {
@ -74,10 +74,10 @@ export const getNotWatched = (diffmgr : DiffManager)=> (ctx:Router.IRouterContex
ctx.type = 'json';
}*/
export function createDiffRouter(diffmgr: DiffManager){
export function createDiffRouter(diffmgr: DiffManager) {
const ret = new Router();
ret.get("/list",AdminOnlyMiddleware,getAdded(diffmgr));
ret.post("/commit",AdminOnlyMiddleware,postAdded(diffmgr));
ret.post("/commitall",AdminOnlyMiddleware,postAddedAll(diffmgr));
ret.get("/list", AdminOnlyMiddleware, getAdded(diffmgr));
ret.post("/commit", AdminOnlyMiddleware, postAdded(diffmgr));
ret.post("/commitall", AdminOnlyMiddleware, postAddedAll(diffmgr));
return ret;
}
}

View File

@ -1,25 +1,25 @@
import { FSWatcher, watch } from 'fs';
import { promises } from 'fs';
import event from 'events';
import { join } from 'path';
import { DocumentAccessor } from '../model/doc';
import event from "events";
import { FSWatcher, watch } from "fs";
import { promises } from "fs";
import { join } from "path";
import { DocumentAccessor } from "../model/doc";
const readdir = promises.readdir;
export interface DiffWatcherEvent{
'create':(path:string)=>void,
'delete':(path:string)=>void,
'change':(prev_path:string,cur_path:string)=>void,
export interface DiffWatcherEvent {
"create": (path: string) => void;
"delete": (path: string) => void;
"change": (prev_path: string, cur_path: string) => void;
}
export interface IDiffWatcher extends event.EventEmitter {
on<U extends keyof DiffWatcherEvent>(event:U,listener:DiffWatcherEvent[U]): this;
emit<U extends keyof DiffWatcherEvent>(event:U,...arg:Parameters<DiffWatcherEvent[U]>): boolean;
setup(cntr:DocumentAccessor):Promise<void>;
on<U extends keyof DiffWatcherEvent>(event: U, listener: DiffWatcherEvent[U]): this;
emit<U extends keyof DiffWatcherEvent>(event: U, ...arg: Parameters<DiffWatcherEvent[U]>): boolean;
setup(cntr: DocumentAccessor): Promise<void>;
}
export function linkWatcher(fromWatcher :IDiffWatcher, toWatcher: IDiffWatcher){
fromWatcher.on("create",p=>toWatcher.emit("create",p));
fromWatcher.on("delete",p=>toWatcher.emit("delete",p));
fromWatcher.on("change",(p,c)=>toWatcher.emit("change",p,c));
}
export function linkWatcher(fromWatcher: IDiffWatcher, toWatcher: IDiffWatcher) {
fromWatcher.on("create", p => toWatcher.emit("create", p));
fromWatcher.on("delete", p => toWatcher.emit("delete", p));
fromWatcher.on("change", (p, c) => toWatcher.emit("change", p, c));
}

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 ComicSchema from "./ComicConfig.schema.json"
export interface ComicConfig{
watch:string[]
import { ConfigManager } from "../../util/configRW";
import ComicSchema from "./ComicConfig.schema.json";
export interface ComicConfig {
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 { DocumentAccessor } from '../../model/doc';
import { WatcherFilter } from './watcher_filter';
import { RecursiveWatcher } from './recursive_watcher';
import { ComicConfig } from './ComicConfig';
import {WatcherCompositer} from './compositer'
import { EventEmitter } from "events";
import { DocumentAccessor } from "../../model/doc";
import { DiffWatcherEvent, IDiffWatcher } from "../watcher";
import { ComicConfig } from "./ComicConfig";
import { WatcherCompositer } from "./compositer";
import { RecursiveWatcher } from "./recursive_watcher";
import { WatcherFilter } from "./watcher_filter";
const createComicWatcherBase = (path:string)=> {
return new WatcherFilter(new RecursiveWatcher(path),(x)=>x.endsWith(".zip"));
}
export const createComicWatcher = ()=>{
const createComicWatcherBase = (path: string) => {
return new WatcherFilter(new RecursiveWatcher(path), (x) => x.endsWith(".zip"));
};
export const createComicWatcher = () => {
const file = ComicConfig.get_config_file();
console.log(`register comic ${file.watch.join(",")}`)
return new WatcherCompositer(file.watch.map(path=>createComicWatcherBase(path)));
}
console.log(`register comic ${file.watch.join(",")}`);
return new WatcherCompositer(file.watch.map(path => createComicWatcherBase(path)));
};

View File

@ -1,45 +1,44 @@
import event from 'events';
import {FSWatcher,watch,promises} from 'fs';
import {IDiffWatcher, DiffWatcherEvent} from '../watcher';
import {join} from 'path';
import { DocumentAccessor } from '../../model/doc';
import { setupHelp } from './util';
import event from "events";
import { FSWatcher, promises, watch } from "fs";
import { join } from "path";
import { DocumentAccessor } from "../../model/doc";
import { DiffWatcherEvent, IDiffWatcher } from "../watcher";
import { setupHelp } from "./util";
const {readdir} = promises;
const { readdir } = promises;
export class CommonDiffWatcher extends event.EventEmitter implements IDiffWatcher{
on<U extends keyof DiffWatcherEvent>(event:U,listener:DiffWatcherEvent[U]): this{
return super.on(event,listener);
export class CommonDiffWatcher extends event.EventEmitter implements IDiffWatcher {
on<U extends keyof DiffWatcherEvent>(event: U, listener: DiffWatcherEvent[U]): this {
return super.on(event, listener);
}
emit<U extends keyof DiffWatcherEvent>(event:U,...arg:Parameters<DiffWatcherEvent[U]>): boolean{
return super.emit(event,...arg);
emit<U extends keyof DiffWatcherEvent>(event: U, ...arg: Parameters<DiffWatcherEvent[U]>): boolean {
return super.emit(event, ...arg);
}
private _path:string;
private _path: string;
private _watcher: FSWatcher;
constructor(path:string){
constructor(path: string) {
super();
this._path = path;
this._watcher = watch(this._path,{persistent: true, recursive:false},async (eventType,filename)=>{
if(eventType === "rename"){
this._watcher = watch(this._path, { persistent: true, recursive: false }, async (eventType, filename) => {
if (eventType === "rename") {
const cur = await readdir(this._path);
//add
if(cur.includes(filename)){
this.emit('create',join(this.path,filename));
}
else{
this.emit('delete',join(this.path,filename))
// add
if (cur.includes(filename)) {
this.emit("create", join(this.path, filename));
} else {
this.emit("delete", join(this.path, filename));
}
}
});
}
async setup(cntr: DocumentAccessor): Promise<void> {
await setupHelp(this,this.path,cntr);
await setupHelp(this, this.path, cntr);
}
public get path(){
public get path() {
return this._path;
}
watchClose(){
this._watcher.close()
watchClose() {
this._watcher.close();
}
}
}

View File

@ -2,23 +2,22 @@ import { EventEmitter } from "events";
import { DocumentAccessor } from "../../model/doc";
import { DiffWatcherEvent, IDiffWatcher, linkWatcher } from "../watcher";
export class WatcherCompositer extends EventEmitter implements IDiffWatcher{
refWatchers : IDiffWatcher[];
on<U extends keyof DiffWatcherEvent>(event:U,listener:DiffWatcherEvent[U]): this{
return super.on(event,listener);
export class WatcherCompositer extends EventEmitter implements IDiffWatcher {
refWatchers: IDiffWatcher[];
on<U extends keyof DiffWatcherEvent>(event: U, listener: DiffWatcherEvent[U]): this {
return super.on(event, listener);
}
emit<U extends keyof DiffWatcherEvent>(event:U,...arg:Parameters<DiffWatcherEvent[U]>): boolean{
return super.emit(event,...arg);
emit<U extends keyof DiffWatcherEvent>(event: U, ...arg: Parameters<DiffWatcherEvent[U]>): boolean {
return super.emit(event, ...arg);
}
constructor(refWatchers:IDiffWatcher[]){
constructor(refWatchers: IDiffWatcher[]) {
super();
this.refWatchers = refWatchers;
for(const refWatcher of this.refWatchers){
linkWatcher(refWatcher,this);
for (const refWatcher of this.refWatchers) {
linkWatcher(refWatcher, this);
}
}
async setup(cntr: DocumentAccessor): Promise<void> {
await Promise.all(this.refWatchers.map(x=>x.setup(cntr)));
await Promise.all(this.refWatchers.map(x => x.setup(cntr)));
}
}
}

View File

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

View File

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

View File

@ -2,50 +2,45 @@ import { EventEmitter } from "events";
import { DocumentAccessor } from "../../model/doc";
import { DiffWatcherEvent, IDiffWatcher, linkWatcher } from "../watcher";
export class WatcherFilter extends EventEmitter implements IDiffWatcher{
refWatcher : IDiffWatcher;
filter : (filename:string)=>boolean;;
on<U extends keyof DiffWatcherEvent>(event:U,listener:DiffWatcherEvent[U]): this{
return super.on(event,listener);
export class WatcherFilter extends EventEmitter implements IDiffWatcher {
refWatcher: IDiffWatcher;
filter: (filename: string) => boolean;
on<U extends keyof DiffWatcherEvent>(event: U, listener: DiffWatcherEvent[U]): this {
return super.on(event, listener);
}
/**
* emit event
* @param event
* @param arg
* @param event
* @param arg
* @returns `true` if the event had listeners, `false` otherwise.
*/
emit<U extends keyof DiffWatcherEvent>(event:U,...arg:Parameters<DiffWatcherEvent[U]>): boolean{
if(event === "change"){
emit<U extends keyof DiffWatcherEvent>(event: U, ...arg: Parameters<DiffWatcherEvent[U]>): boolean {
if (event === "change") {
const prev = arg[0];
const cur = arg[1] as string;
if(this.filter(prev)){
if(this.filter(cur)){
return super.emit("change",prev,cur);
if (this.filter(prev)) {
if (this.filter(cur)) {
return super.emit("change", prev, cur);
} else {
return super.emit("delete", cur);
}
else{
return super.emit("delete",cur);
}
}
else{
if(this.filter(cur)){
return super.emit("create",cur);
} else {
if (this.filter(cur)) {
return super.emit("create", cur);
}
}
return false;
}
else if(!this.filter(arg[0])){
} else if (!this.filter(arg[0])) {
return false;
}
else return super.emit(event,...arg);
} else return super.emit(event, ...arg);
}
constructor(refWatcher:IDiffWatcher, filter:(filename:string)=>boolean){
constructor(refWatcher: IDiffWatcher, filter: (filename: string) => boolean) {
super();
this.refWatcher = refWatcher;
this.filter = filter;
linkWatcher(refWatcher,this);
linkWatcher(refWatcher, this);
}
setup(cntr:DocumentAccessor): Promise<void> {
setup(cntr: DocumentAccessor): Promise<void> {
return this.refWatcher.setup(cntr);
}
}
}

View File

@ -1,289 +1,285 @@
import { request } from "http";
import { decode, sign, TokenExpiredError, verify } from "jsonwebtoken";
import Knex from "knex";
import Koa from "koa";
import Router from "koa-router";
import { sendError } from "./route/error_handler";
import Knex from "knex";
import { createKnexUserController } from "./db/mod";
import { request } from "http";
import { get_setting } from "./SettingConfig";
import { IUser, UserAccessor } from "./model/mod";
import { sendError } from "./route/error_handler";
import { get_setting } from "./SettingConfig";
type PayloadInfo = {
username: string;
permission: string[];
username: string;
permission: string[];
};
export type UserState = {
user: PayloadInfo;
user: PayloadInfo;
};
const isUserState = (obj: object | string): obj is PayloadInfo => {
if (typeof obj === "string") return false;
return "username" in obj && "permission" in obj &&
(obj as { permission: unknown }).permission instanceof Array;
if (typeof obj === "string") return false;
return "username" in obj && "permission" in obj
&& (obj as { permission: unknown }).permission instanceof Array;
};
type RefreshPayloadInfo = { username: string };
const isRefreshToken = (obj: object | string): obj is RefreshPayloadInfo => {
if (typeof obj === "string") return false;
return "username" in obj &&
typeof (obj as { username: unknown }).username === "string";
if (typeof obj === "string") return false;
return "username" in obj
&& typeof (obj as { username: unknown }).username === "string";
};
export const accessTokenName = "access_token";
export const refreshTokenName = "refresh_token";
const accessExpiredTime = 60 * 60; //1 hour
const accessExpiredTime = 60 * 60; // 1 hour
const refreshExpiredTime = 60 * 60 * 24 * 14; // 14 day;
export const getAdminAccessTokenValue = () => {
const { jwt_secretkey } = get_setting();
return publishAccessToken(jwt_secretkey, "admin", [], accessExpiredTime);
const { jwt_secretkey } = get_setting();
return publishAccessToken(jwt_secretkey, "admin", [], accessExpiredTime);
};
export const getAdminRefreshTokenValue = () => {
const { jwt_secretkey } = get_setting();
return publishRefreshToken(jwt_secretkey, "admin", refreshExpiredTime);
const { jwt_secretkey } = get_setting();
return publishRefreshToken(jwt_secretkey, "admin", refreshExpiredTime);
};
const publishAccessToken = (
secretKey: string,
username: string,
permission: string[],
expiredtime: number,
secretKey: string,
username: string,
permission: string[],
expiredtime: number,
) => {
const payload = sign(
{
username: username,
permission: permission,
},
secretKey,
{ expiresIn: expiredtime },
);
return payload;
const payload = sign(
{
username: username,
permission: permission,
},
secretKey,
{ expiresIn: expiredtime },
);
return payload;
};
const publishRefreshToken = (
secretKey: string,
username: string,
expiredtime: number,
secretKey: string,
username: string,
expiredtime: number,
) => {
const payload = sign(
{ username: username },
secretKey,
{ expiresIn: expiredtime },
);
return payload;
const payload = sign(
{ username: username },
secretKey,
{ expiresIn: expiredtime },
);
return payload;
};
function setToken(
ctx: Koa.Context,
token_name: string,
token_payload: string | null,
expiredtime: number,
ctx: Koa.Context,
token_name: string,
token_payload: string | null,
expiredtime: number,
) {
const setting = get_setting();
if (token_payload === null && !!!ctx.cookies.get(token_name)) {
return;
}
ctx.cookies.set(token_name, token_payload, {
httpOnly: true,
secure: setting.secure,
sameSite: "strict",
expires: new Date(Date.now() + expiredtime * 1000),
});
};
export const createLoginMiddleware = (userController: UserAccessor) =>
async (ctx: Koa.Context, _next: Koa.Next) => {
const setting = get_setting();
if (token_payload === null && !!!ctx.cookies.get(token_name)) {
return;
}
ctx.cookies.set(token_name, token_payload, {
httpOnly: true,
secure: setting.secure,
sameSite: "strict",
expires: new Date(Date.now() + expiredtime * 1000),
});
}
export const createLoginMiddleware = (userController: UserAccessor) => async (ctx: Koa.Context, _next: Koa.Next) => {
const setting = get_setting();
const secretKey = setting.jwt_secretkey;
const body = ctx.request.body;
//check format
// check format
if (typeof body == "string" || !("username" in body) || !("password" in body)) {
return sendError(
400,
"invalid form : username or password is not found in query.",
);
return sendError(
400,
"invalid form : username or password is not found in query.",
);
}
const username = body["username"];
const password = body["password"];
//check type
// check type
if (typeof username !== "string" || typeof password !== "string") {
return sendError(
400,
"invalid form : username or password is not string",
);
return sendError(
400,
"invalid form : username or password is not string",
);
}
//if admin login is forbidden?
// if admin login is forbidden?
if (username === "admin" && setting.forbid_remote_admin_login) {
return sendError(403, "forbidden remote admin login");
return sendError(403, "forbidden remote admin login");
}
const user = await userController.findUser(username);
//username not exist
// username not exist
if (user === undefined) return sendError(401, "not authorized");
//password not matched
// password not matched
if (!user.password.check_password(password)) {
return sendError(401, "not authorized");
return sendError(401, "not authorized");
}
//create token
// create token
const userPermission = await user.get_permissions();
const payload = publishAccessToken(
secretKey,
user.username,
userPermission,
accessExpiredTime,
secretKey,
user.username,
userPermission,
accessExpiredTime,
);
const payload2 = publishRefreshToken(
secretKey,
user.username,
refreshExpiredTime,
secretKey,
user.username,
refreshExpiredTime,
);
setToken(ctx, accessTokenName, payload, accessExpiredTime);
setToken(ctx, refreshTokenName, payload2, refreshExpiredTime);
ctx.body = {
username: user.username,
permission: userPermission,
accessExpired: (Math.floor(Date.now() / 1000) + accessExpiredTime),
username: user.username,
permission: userPermission,
accessExpired: (Math.floor(Date.now() / 1000) + accessExpiredTime),
};
console.log(`${username} logined`);
return;
};
};
export const LogoutMiddleware = (ctx: Koa.Context, next: Koa.Next) => {
const setting = get_setting()
ctx.cookies.set(accessTokenName, null);
ctx.cookies.set(refreshTokenName, null);
ctx.body = {
ok: true,
username: "",
permission: setting.guest
};
return;
};
export const createUserMiddleWare = (userController: UserAccessor) =>
async (ctx: Koa.ParameterizedContext<UserState>, next: Koa.Next) => {
const refreshToken = refreshTokenHandler(userController);
const setting = get_setting();
const setGuest = async () => {
setToken(ctx, accessTokenName, null, 0);
setToken(ctx, refreshTokenName, null, 0);
ctx.state["user"] = { username: "", permission: setting.guest };
return await next();
ctx.cookies.set(accessTokenName, null);
ctx.cookies.set(refreshTokenName, null);
ctx.body = {
ok: true,
username: "",
permission: setting.guest,
};
return await refreshToken(ctx, setGuest, next);
};
const refreshTokenHandler = (cntr: UserAccessor) =>
async (ctx: Koa.Context, fail: Koa.Next, next: Koa.Next) => {
return;
};
export const createUserMiddleWare =
(userController: UserAccessor) => async (ctx: Koa.ParameterizedContext<UserState>, next: Koa.Next) => {
const refreshToken = refreshTokenHandler(userController);
const setting = get_setting();
const setGuest = async () => {
setToken(ctx, accessTokenName, null, 0);
setToken(ctx, refreshTokenName, null, 0);
ctx.state["user"] = { username: "", permission: setting.guest };
return await next();
};
return await refreshToken(ctx, setGuest, next);
};
const refreshTokenHandler = (cntr: UserAccessor) => async (ctx: Koa.Context, fail: Koa.Next, next: Koa.Next) => {
const accessPayload = ctx.cookies.get(accessTokenName);
const setting = get_setting();
const secretKey = setting.jwt_secretkey;
if (accessPayload == undefined) {
return await checkRefreshAndUpdate();
return await checkRefreshAndUpdate();
}
try {
const o = verify(accessPayload, secretKey);
if (isUserState(o)) {
ctx.state.user = o;
return await next();
} else {
console.error("invalid token detected");
throw new Error("token form invalid");
}
} catch (e) {
if (e instanceof TokenExpiredError) {
return await checkRefreshAndUpdate();
} else throw e;
}
async function checkRefreshAndUpdate() {
const refreshPayload = ctx.cookies.get(refreshTokenName);
if (refreshPayload === undefined) {
return await fail(); // refresh token doesn't exist
} else {
try {
const o = verify(refreshPayload, secretKey);
if (isRefreshToken(o)) {
const user = await cntr.findUser(o.username);
if (user === undefined) return await fail(); //already non-existence user
const perm = await user.get_permissions();
const payload = publishAccessToken(
secretKey,
user.username,
perm,
accessExpiredTime,
);
setToken(ctx, accessTokenName, payload, accessExpiredTime);
ctx.state.user = { username: o.username, permission: perm };
} else {
const o = verify(accessPayload, secretKey);
if (isUserState(o)) {
ctx.state.user = o;
return await next();
} else {
console.error("invalid token detected");
throw new Error("token form invalid");
}
} catch (e) {
if (e instanceof TokenExpiredError) { // refresh token is expired.
return await fail();
} else throw e;
}
}
return await next();
};
};
export const createRefreshTokenMiddleware = (cntr: UserAccessor) =>
async (ctx: Koa.Context, next: Koa.Next) => {
} catch (e) {
if (e instanceof TokenExpiredError) {
return await checkRefreshAndUpdate();
} else throw e;
}
async function checkRefreshAndUpdate() {
const refreshPayload = ctx.cookies.get(refreshTokenName);
if (refreshPayload === undefined) {
return await fail(); // refresh token doesn't exist
} else {
try {
const o = verify(refreshPayload, secretKey);
if (isRefreshToken(o)) {
const user = await cntr.findUser(o.username);
if (user === undefined) return await fail(); // already non-existence user
const perm = await user.get_permissions();
const payload = publishAccessToken(
secretKey,
user.username,
perm,
accessExpiredTime,
);
setToken(ctx, accessTokenName, payload, accessExpiredTime);
ctx.state.user = { username: o.username, permission: perm };
} else {
console.error("invalid token detected");
throw new Error("token form invalid");
}
} catch (e) {
if (e instanceof TokenExpiredError) { // refresh token is expired.
return await fail();
} else throw e;
}
}
return await next();
}
};
export const createRefreshTokenMiddleware = (cntr: UserAccessor) => async (ctx: Koa.Context, next: Koa.Next) => {
const handler = refreshTokenHandler(cntr);
await handler(ctx, fail, success);
async function fail() {
const user = ctx.state.user as PayloadInfo;
ctx.body = {
refresh: false,
...user,
};
ctx.type = "json";
};
async function success() {
const user = ctx.state.user as PayloadInfo;
ctx.body = {
...user,
refresh: true,
refreshExpired: Math.floor(Date.now() / 1000 + accessExpiredTime),
};
ctx.type = "json";
};
};
export const resetPasswordMiddleware = (cntr: UserAccessor) =>
async (ctx: Koa.Context, next: Koa.Next) => {
const body = ctx.request.body;
if (typeof body !== "object" || !('username' in body) || !('oldpassword' in body) || !('newpassword' in body)) {
return sendError(400, "request body is invalid format");
const user = ctx.state.user as PayloadInfo;
ctx.body = {
refresh: false,
...user,
};
ctx.type = "json";
}
const username = body['username'];
const oldpw = body['oldpassword'];
const newpw = body['newpassword'];
async function success() {
const user = ctx.state.user as PayloadInfo;
ctx.body = {
...user,
refresh: true,
refreshExpired: Math.floor(Date.now() / 1000 + accessExpiredTime),
};
ctx.type = "json";
}
};
export const resetPasswordMiddleware = (cntr: UserAccessor) => async (ctx: Koa.Context, next: Koa.Next) => {
const body = ctx.request.body;
if (typeof body !== "object" || !("username" in body) || !("oldpassword" in body) || !("newpassword" in body)) {
return sendError(400, "request body is invalid format");
}
const username = body["username"];
const oldpw = body["oldpassword"];
const newpw = body["newpassword"];
if (typeof username !== "string" || typeof oldpw !== "string" || typeof newpw !== "string") {
return sendError(400, "request body is invalid format");
return sendError(400, "request body is invalid format");
}
const user = await cntr.findUser(username);
if (user === undefined) {
return sendError(403, "not authorized");
return sendError(403, "not authorized");
}
if (!user.password.check_password(oldpw)) {
return sendError(403, "not authorized");
return sendError(403, "not authorized");
}
user.reset_password(newpw);
ctx.body = { ok: true }
ctx.type = 'json';
}
ctx.body = { ok: true };
ctx.type = "json";
};
export function createLoginRouter(userController: UserAccessor) {
const router = new Router();
router.post('/login', createLoginMiddleware(userController));
router.post('/logout', LogoutMiddleware);
router.post('/refresh', createRefreshTokenMiddleware(userController));
router.post('/reset', resetPasswordMiddleware(userController));
return router;
const router = new Router();
router.post("/login", createLoginMiddleware(userController));
router.post("/logout", LogoutMiddleware);
router.post("/refresh", createRefreshTokenMiddleware(userController));
router.post("/reset", resetPasswordMiddleware(userController));
return router;
}
export const getAdmin = async (cntr: UserAccessor) => {
const admin = await cntr.findUser("admin");
if (admin === undefined) {
throw new Error("initial process failed!"); //???
}
return admin;
const admin = await cntr.findUser("admin");
if (admin === undefined) {
throw new Error("initial process failed!"); // ???
}
return admin;
};
export const isAdminFirst = (admin: IUser) => {
return admin.password.hash === "unchecked" &&
admin.password.salt === "unchecked";
return admin.password.hash === "unchecked"
&& admin.password.salt === "unchecked";
};

View File

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

View File

@ -1,18 +1,18 @@
export interface Tag{
readonly name: string,
description?: string
export interface Tag {
readonly name: string;
description?: string;
}
export interface TagCount{
export interface TagCount {
tag_name: string;
occurs: number;
}
export interface TagAccessor{
getAllTagList: (onlyname?:boolean)=> Promise<Tag[]>;
export interface TagAccessor {
getAllTagList: (onlyname?: boolean) => Promise<Tag[]>;
getAllTagCount(): Promise<TagCount[]>;
getTagByName: (name:string)=>Promise<Tag|undefined>;
addTag: (tag:Tag)=>Promise<boolean>;
delTag: (name:string) => Promise<boolean>;
updateTag: (name:string,tag:string) => Promise<boolean>;
getTagByName: (name: string) => Promise<Tag | undefined>;
addTag: (tag: Tag) => Promise<boolean>;
delTag: (name: string) => Promise<boolean>;
updateTag: (name: string, tag: string) => Promise<boolean>;
}

View File

@ -1,80 +1,84 @@
import { createHmac, randomBytes } from 'crypto';
import { createHmac, randomBytes } from "crypto";
function hashForPassword(salt: string,password:string){
return createHmac('sha256', salt).update(password).digest('hex')
function hashForPassword(salt: string, password: string) {
return createHmac("sha256", salt).update(password).digest("hex");
}
function createPasswordHashAndSalt(password: string):{salt:string,hash:string}{
const secret = randomBytes(32).toString('hex');
function createPasswordHashAndSalt(password: string): { salt: string; hash: string } {
const secret = randomBytes(32).toString("hex");
return {
salt: secret,
hash: hashForPassword(secret,password)
hash: hashForPassword(secret, password),
};
}
export class Password{
private _salt:string;
private _hash:string;
constructor(pw : string|{salt:string,hash:string}){
const {salt,hash} = typeof pw === "string" ? createPasswordHashAndSalt(pw) : pw;
export class Password {
private _salt: string;
private _hash: string;
constructor(pw: string | { salt: string; hash: string }) {
const { salt, hash } = typeof pw === "string" ? createPasswordHashAndSalt(pw) : pw;
this._hash = hash;
this._salt = salt;
}
set_password(password: string){
const {salt,hash} = createPasswordHashAndSalt(password);
set_password(password: string) {
const { salt, hash } = createPasswordHashAndSalt(password);
this._hash = hash;
this._salt = salt;
}
check_password(password: string):boolean{
return this._hash === hashForPassword(this._salt,password);
check_password(password: string): boolean {
return this._hash === hashForPassword(this._salt, password);
}
get salt() {
return this._salt;
}
get hash() {
return this._hash;
}
get salt(){return this._salt;}
get hash(){return this._hash;}
}
export interface UserCreateInput{
username: string,
password: string
export interface UserCreateInput {
username: string;
password: string;
}
export interface IUser{
readonly username : string;
readonly password : Password;
export interface IUser {
readonly username: string;
readonly password: Password;
/**
* return user's permission list.
*/
get_permissions():Promise<string[]>;
get_permissions(): Promise<string[]>;
/**
* add permission
* @param name permission name to add
* @returns if `name` doesn't exist, return true
*/
add(name :string):Promise<boolean>;
add(name: string): Promise<boolean>;
/**
* remove permission
* @param name permission name to remove
* @returns if `name` exist, return true
*/
remove(name :string):Promise<boolean>;
remove(name: string): Promise<boolean>;
/**
* reset password.
* @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
* @returns if user exist, return undefined
*/
createUser: (input :UserCreateInput)=> Promise<IUser|undefined>,
createUser: (input: UserCreateInput) => Promise<IUser | undefined>;
/**
* find user
*/
findUser: (username: string)=> Promise<IUser|undefined>,
findUser: (username: string) => Promise<IUser | undefined>;
/**
* remove user
* @returns if user exist, true
*/
delUser: (username: string)=>Promise<boolean>
};
delUser: (username: string) => Promise<boolean>;
}

View File

@ -1,60 +1,58 @@
import Koa from 'koa';
import { UserState } from '../login';
import { sendError } from '../route/error_handler';
import Koa from "koa";
import { UserState } from "../login";
import { sendError } from "../route/error_handler";
export enum Permission{
//========
//not implemented
//admin only
export enum Permission {
// ========
// not implemented
// admin only
/** remove document */
//removeContent = 'removeContent',
// removeContent = 'removeContent',
/** upload document */
//uploadContent = 'uploadContent',
// uploadContent = 'uploadContent',
/** modify document except base path, filename, content_hash. but admin can modify all. */
//modifyContent = 'modifyContent',
// modifyContent = 'modifyContent',
/** add tag into document */
//addTagContent = 'addTagContent',
// addTagContent = 'addTagContent',
/** remove tag from document */
//removeTagContent = 'removeTagContent',
// removeTagContent = 'removeTagContent',
/** ModifyTagInDoc */
ModifyTag = 'ModifyTag',
ModifyTag = "ModifyTag",
/** find documents with query */
//findAllContent = 'findAllContent',
// findAllContent = 'findAllContent',
/** find one document. */
//findOneContent = 'findOneContent',
// findOneContent = 'findOneContent',
/** view content*/
//viewContent = 'viewContent',
QueryContent = 'QueryContent',
// viewContent = 'viewContent',
QueryContent = "QueryContent",
/** modify description about the one tag. */
modifyTagDesc = 'ModifyTagDesc',
modifyTagDesc = "ModifyTagDesc",
}
export const createPermissionCheckMiddleware = (...permissions:string[]) =>
async (ctx: Koa.ParameterizedContext<UserState>,next:Koa.Next) => {
const user = ctx.state['user'];
if(user.username === "admin"){
return await next();
}
const user_permission = user.permission;
//if permissions is not subset of user permission
if(!permissions.map(p=>user_permission.includes(p)).every(x=>x)){
if(user.username === ""){
return sendError(401,"you are guest. login needed.");
export const createPermissionCheckMiddleware =
(...permissions: string[]) => async (ctx: Koa.ParameterizedContext<UserState>, next: Koa.Next) => {
const user = ctx.state["user"];
if (user.username === "admin") {
return await next();
}
else return sendError(403,"do not have permission");
const user_permission = user.permission;
// if permissions is not subset of user permission
if (!permissions.map(p => user_permission.includes(p)).every(x => x)) {
if (user.username === "") {
return sendError(401, "you are guest. login needed.");
} else return sendError(403, "do not have permission");
}
await next();
};
export const AdminOnlyMiddleware = async (ctx: Koa.ParameterizedContext<UserState>, next: Koa.Next) => {
const user = ctx.state["user"];
if (user.username !== "admin") {
return sendError(403, "admin only");
}
await next();
}
export const AdminOnlyMiddleware = async (ctx: Koa.ParameterizedContext<UserState>,next:Koa.Next)=>{
const user = ctx.state['user'];
if(user.username !== "admin"){
return sendError(403,"admin only");
}
await next();
}
};

View File

@ -1,55 +1,57 @@
import { DefaultContext, Middleware, Next, ParameterizedContext } from 'koa';
import compose from 'koa-compose';
import Router, { IParamMiddleware } from 'koa-router';
import { ContentContext } from './context';
import ComicRouter from './comic';
import VideoRouter from './video';
import { DefaultContext, Middleware, Next, ParameterizedContext } from "koa";
import compose from "koa-compose";
import Router, { IParamMiddleware } from "koa-router";
import ComicRouter from "./comic";
import { ContentContext } from "./context";
import VideoRouter from "./video";
const table:{[s:string]:Router|undefined} = {
"comic": new ComicRouter,
"video": new VideoRouter
}
const all_middleware = (cont: string|undefined, restarg: string|undefined)=>async (ctx:ParameterizedContext<ContentContext,DefaultContext>,next:Next)=>{
if(cont == undefined){
ctx.status = 404;
return;
}
if(ctx.state.location.type != cont){
console.error("not matched")
ctx.status = 404;
return;
}
const router = table[cont];
if(router == undefined){
ctx.status = 404;
return;
}
const rest = "/"+(restarg ?? "");
const result = router.match(rest,"GET");
if(!result.route){
return await next();
}
const chain = result.pathAndMethod.reduce((combination : Middleware<any& DefaultContext,any>[],cur)=>{
combination.push(async (ctx,next)=>{
const captures = cur.captures(rest);
ctx.params = cur.params(rest,captures);
ctx.request.params = ctx.params;
ctx.routerPath = cur.path;
return await next();
});
return combination.concat(cur.stack);
},[]);
return await compose(chain)(ctx,next);
const table: { [s: string]: Router | undefined } = {
"comic": new ComicRouter(),
"video": new VideoRouter(),
};
export class AllContentRouter extends Router<ContentContext>{
constructor(){
const all_middleware =
(cont: string | undefined, restarg: string | undefined) =>
async (ctx: ParameterizedContext<ContentContext, DefaultContext>, next: Next) => {
if (cont == undefined) {
ctx.status = 404;
return;
}
if (ctx.state.location.type != cont) {
console.error("not matched");
ctx.status = 404;
return;
}
const router = table[cont];
if (router == undefined) {
ctx.status = 404;
return;
}
const rest = "/" + (restarg ?? "");
const result = router.match(rest, "GET");
if (!result.route) {
return await next();
}
const chain = result.pathAndMethod.reduce((combination: Middleware<any & DefaultContext, any>[], cur) => {
combination.push(async (ctx, next) => {
const captures = cur.captures(rest);
ctx.params = cur.params(rest, captures);
ctx.request.params = ctx.params;
ctx.routerPath = cur.path;
return await next();
});
return combination.concat(cur.stack);
}, []);
return await compose(chain)(ctx, next);
};
export class AllContentRouter extends Router<ContentContext> {
constructor() {
super();
this.get('/:content_type',async (ctx,next)=>{
return await (all_middleware(ctx.params["content_type"],undefined))(ctx,next);
this.get("/:content_type", async (ctx, next) => {
return await (all_middleware(ctx.params["content_type"], undefined))(ctx, next);
});
this.get('/:content_type/:rest(.*)', async (ctx,next) => {
this.get("/:content_type/:rest(.*)", async (ctx, next) => {
const cont = ctx.params["content_type"] as string;
return await (all_middleware(cont,ctx.params["rest"]))(ctx,next);
return await (all_middleware(cont, ctx.params["rest"]))(ctx, next);
});
}
};
}

View File

@ -1,13 +1,8 @@
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 { createReadableStreamFromZip, entriesByNaturalOrder, readZip, ZipAsync } from "../util/zipwrap";
import { ContentContext } from "./context";
import { since_last_modified } from "./util";
/**
* zip stream cache.
@ -16,88 +11,86 @@ import Router from "koa-router";
let ZipStreamCache: { [path: string]: [ZipAsync, number] } = {};
async function acquireZip(path: string) {
if (!(path in ZipStreamCache)) {
const ret = await readZip(path);
ZipStreamCache[path] = [ret, 1];
//console.log(`acquire ${path} 1`);
return ret;
}
else {
const [ret, refCount] = ZipStreamCache[path];
ZipStreamCache[path] = [ret, refCount + 1];
//console.log(`acquire ${path} ${refCount + 1}`);
return ret;
}
if (!(path in ZipStreamCache)) {
const ret = await readZip(path);
ZipStreamCache[path] = [ret, 1];
// console.log(`acquire ${path} 1`);
return ret;
} else {
const [ret, refCount] = ZipStreamCache[path];
ZipStreamCache[path] = [ret, refCount + 1];
// console.log(`acquire ${path} ${refCount + 1}`);
return ret;
}
}
function releaseZip(path: string) {
const obj = ZipStreamCache[path];
if (obj === undefined) throw new Error("error! key invalid");
const [ref, refCount] = obj;
//console.log(`release ${path} : ${refCount}`);
if (refCount === 1) {
ref.close();
delete ZipStreamCache[path];
}
else{
ZipStreamCache[path] = [ref, refCount - 1];
}
const obj = ZipStreamCache[path];
if (obj === undefined) throw new Error("error! key invalid");
const [ref, refCount] = obj;
// console.log(`release ${path} : ${refCount}`);
if (refCount === 1) {
ref.close();
delete ZipStreamCache[path];
} else {
ZipStreamCache[path] = [ref, refCount - 1];
}
}
async function renderZipImage(ctx: Context, path: string, page: number) {
const image_ext = ["gif", "png", "jpeg", "bmp", "webp", "jpg"];
//console.log(`opened ${page}`);
let zip = await acquireZip(path);
const entries = (await entriesByNaturalOrder(zip)).filter((x) => {
const ext = x.name.split(".").pop();
return ext !== undefined && image_ext.includes(ext);
});
if (0 <= page && page < entries.length) {
const entry = entries[page];
const last_modified = new Date(entry.time);
if (since_last_modified(ctx, last_modified)) {
return;
}
const read_stream = (await createReadableStreamFromZip(zip, entry));
/**Exceptions (ECONNRESET, ECONNABORTED) may be thrown when processing this request
* for reasons such as when the browser unexpectedly closes the connection.
* Once such an exception is raised, the stream is not properly destroyed,
* so there is a problem with the zlib stream being accessed even after the stream is closed.
* So it waits for 100 ms and releases it.
* Additionaly, there is a risk of memory leak becuase zlib stream is not properly destroyed.
* @todo modify function 'stream' in 'node-stream-zip' library to prevent memory leak*/
read_stream.once("close", () => {
setTimeout(() => {
releaseZip(path);
}, 100);
const image_ext = ["gif", "png", "jpeg", "bmp", "webp", "jpg"];
// console.log(`opened ${page}`);
let zip = await acquireZip(path);
const entries = (await entriesByNaturalOrder(zip)).filter((x) => {
const ext = x.name.split(".").pop();
return ext !== undefined && image_ext.includes(ext);
});
if (0 <= page && page < entries.length) {
const entry = entries[page];
const last_modified = new Date(entry.time);
if (since_last_modified(ctx, last_modified)) {
return;
}
const read_stream = await createReadableStreamFromZip(zip, entry);
/** Exceptions (ECONNRESET, ECONNABORTED) may be thrown when processing this request
* for reasons such as when the browser unexpectedly closes the connection.
* Once such an exception is raised, the stream is not properly destroyed,
* so there is a problem with the zlib stream being accessed even after the stream is closed.
* So it waits for 100 ms and releases it.
* Additionaly, there is a risk of memory leak becuase zlib stream is not properly destroyed.
* @todo modify function 'stream' in 'node-stream-zip' library to prevent memory leak */
read_stream.once("close", () => {
setTimeout(() => {
releaseZip(path);
}, 100);
});
ctx.body = read_stream;
ctx.response.length = entry.size;
//console.log(`${entry.name}'s ${page}:${entry.size}`);
ctx.response.type = entry.name.split(".").pop() as string;
ctx.status = 200;
ctx.set("Date", new Date().toUTCString());
ctx.set("Last-Modified", last_modified.toUTCString());
} else {
ctx.status = 404;
}
ctx.body = read_stream;
ctx.response.length = entry.size;
// console.log(`${entry.name}'s ${page}:${entry.size}`);
ctx.response.type = entry.name.split(".").pop() as string;
ctx.status = 200;
ctx.set("Date", new Date().toUTCString());
ctx.set("Last-Modified", last_modified.toUTCString());
} else {
ctx.status = 404;
}
}
export class ComicRouter extends Router<ContentContext> {
constructor() {
super();
this.get("/", async (ctx, next) => {
await renderZipImage(ctx, ctx.state.location.path, 0);
});
this.get("/:page(\\d+)", async (ctx, next) => {
const page = Number.parseInt(ctx.params["page"]);
await renderZipImage(ctx, ctx.state.location.path, page);
});
this.get("/thumbnail", async (ctx, next) => {
await renderZipImage(ctx, ctx.state.location.path, 0);
});
}
constructor() {
super();
this.get("/", async (ctx, next) => {
await renderZipImage(ctx, ctx.state.location.path, 0);
});
this.get("/:page(\\d+)", async (ctx, next) => {
const page = Number.parseInt(ctx.params["page"]);
await renderZipImage(ctx, ctx.state.location.path, page);
});
this.get("/thumbnail", async (ctx, next) => {
await renderZipImage(ctx, ctx.state.location.path, 0);
});
}
}
export default ComicRouter;

View File

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

View File

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

View File

@ -1,50 +1,49 @@
import {Context, Next} from 'koa';
import { Context, Next } from "koa";
export interface ErrorFormat {
code: number,
message: string,
detail?: string
code: number;
message: string;
detail?: string;
}
class ClientRequestError implements Error{
class ClientRequestError implements Error {
name: string;
message: string;
stack?: string | undefined;
code: number;
constructor(code : number,message: string){
constructor(code: number, message: string) {
this.name = "client request error";
this.message = message;
this.code = code;
}
}
const code_to_message_table:{[key:number]:string|undefined} = {
400:"BadRequest",
404:"NotFound"
}
const code_to_message_table: { [key: number]: string | undefined } = {
400: "BadRequest",
404: "NotFound",
};
export const error_handler = async (ctx:Context,next: Next)=>{
export const error_handler = async (ctx: Context, next: Next) => {
try {
await next();
} catch (err) {
if(err instanceof ClientRequestError){
const body : ErrorFormat= {
if (err instanceof ClientRequestError) {
const body: ErrorFormat = {
code: err.code,
message: code_to_message_table[err.code] ?? "",
detail: err.message
}
detail: err.message,
};
ctx.status = err.code;
ctx.body = body;
}
else{
} else {
throw err;
}
}
}
};
export const sendError = (code:number,message?:string) =>{
throw new ClientRequestError(code,message ?? "");
}
export const sendError = (code: number, message?: string) => {
throw new ClientRequestError(code, message ?? "");
};
export default error_handler;
export default error_handler;

View File

@ -1,32 +1,29 @@
import {Context, Next} from "koa";
import Router,{RouterContext} from "koa-router";
import { Context, Next } from "koa";
import Router, { RouterContext } from "koa-router";
import { TagAccessor } from "../model/tag";
import { sendError } from "./error_handler";
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();
router.get("/",PerCheck(Permission.QueryContent),
async (ctx: Context)=>{
if(ctx.query["withCount"]){
const c = await tagController.getAllTagCount();
ctx.body = c;
}
else {
const c = await tagController.getAllTagList();
ctx.body = c;
}
ctx.type = "json";
});
router.get("/:tag_name", PerCheck(Permission.QueryContent),
async (ctx: RouterContext)=>{
const tag_name = ctx.params["tag_name"];
const c = await tagController.getTagByName(tag_name);
if (!c){
sendError(404, "tags not found");
}
router.get("/", PerCheck(Permission.QueryContent), async (ctx: Context) => {
if (ctx.query["withCount"]) {
const c = await tagController.getAllTagCount();
ctx.body = c;
ctx.type = "json";
});
} else {
const c = await tagController.getAllTagList();
ctx.body = c;
}
ctx.type = "json";
});
router.get("/:tag_name", PerCheck(Permission.QueryContent), async (ctx: RouterContext) => {
const tag_name = ctx.params["tag_name"];
const c = await tagController.getTagByName(tag_name);
if (!c) {
sendError(404, "tags not found");
}
ctx.body = c;
ctx.type = "json";
});
return router;
}
}

View File

@ -1,39 +1,37 @@
import { Context } from "koa";
import {Context} from 'koa';
export function ParseQueryNumber(s: string[] | string|undefined): number| undefined{
if(s === undefined) return undefined;
else if(typeof s === "object") return undefined;
export function ParseQueryNumber(s: string[] | string | undefined): number | undefined {
if (s === undefined) return undefined;
else if (typeof s === "object") return undefined;
else return Number.parseInt(s);
}
export function ParseQueryArray(s: string[]|string|undefined){
export function ParseQueryArray(s: string[] | string | undefined) {
s = s ?? [];
const r = s instanceof Array ? s : [s];
return r.map(x=>decodeURIComponent(x));
return r.map(x => decodeURIComponent(x));
}
export function ParseQueryArgString(s: string[]|string|undefined){
if(typeof s === "object") return undefined;
export function ParseQueryArgString(s: string[] | string | undefined) {
if (typeof s === "object") return undefined;
return s === undefined ? s : decodeURIComponent(s);
}
export function ParseQueryBoolean(s: string[] |string|undefined): [boolean,boolean|undefined]{
let value:boolean|undefined;
export function ParseQueryBoolean(s: string[] | string | undefined): [boolean, boolean | undefined] {
let value: boolean | undefined;
if(s === "true")
if (s === "true") {
value = true;
else if(s === "false")
} else if (s === "false") {
value = false;
else if(s === undefined)
} else if (s === undefined) {
value = undefined;
else return [false,undefined]
return [true,value]
} else return [false, undefined];
return [true, value];
}
export function since_last_modified(ctx: Context, last_modified: Date): boolean{
export function since_last_modified(ctx: Context, last_modified: Date): boolean {
const con = ctx.get("If-Modified-Since");
if(con === "") return false;
if (con === "") return false;
const mdate = new Date(con);
if(last_modified > mdate) return false;
if (last_modified > mdate) return false;
ctx.status = 304;
return true;
}

View File

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

View File

@ -1,13 +1,12 @@
export interface PaginationOption{
cursor:number;
limit:number;
export interface PaginationOption {
cursor: number;
limit: number;
}
export interface IIndexer{
indexDoc(word:string,doc_id:number):boolean;
indexDoc(word:string[],doc_id:number):boolean;
export interface IIndexer {
indexDoc(word: string, doc_id: number): boolean;
indexDoc(word: string[], doc_id: number): boolean;
getDoc(word:string,option?:PaginationOption):number[];
getDoc(word:string[],option?:PaginationOption):number[];
}
getDoc(word: string, option?: PaginationOption): number[];
getDoc(word: string[], option?: PaginationOption): number[];
}

View File

@ -1,10 +1,9 @@
export interface ITokenizer{
tokenize(s:string):string[];
export interface ITokenizer {
tokenize(s: string): string[];
}
export class DefaultTokenizer implements ITokenizer{
export class DefaultTokenizer implements ITokenizer {
tokenize(s: string): string[] {
return s.split(" ");
}
}
}

View File

@ -1,55 +1,55 @@
import Koa from 'koa';
import Router from 'koa-router';
import Koa from "koa";
import Router from "koa-router";
import {get_setting, SettingConfig} from './SettingConfig';
import {connectDB} from './database';
import {DiffManager, createDiffRouter} from './diff/mod';
import { connectDB } from "./database";
import { createDiffRouter, DiffManager } from "./diff/mod";
import { get_setting, SettingConfig } from "./SettingConfig";
import { createReadStream, readFileSync } from 'fs';
import getContentRouter from './route/contents';
import { createKnexDocumentAccessor, createKnexTagController, createKnexUserController } from './db/mod';
import bodyparser from 'koa-bodyparser';
import {error_handler} from './route/error_handler';
import {createUserMiddleWare, createLoginRouter, isAdminFirst, getAdmin} from './login';
import { createReadStream, readFileSync } from "fs";
import bodyparser from "koa-bodyparser";
import { createKnexDocumentAccessor, createKnexTagController, createKnexUserController } from "./db/mod";
import { createLoginRouter, createUserMiddleWare, getAdmin, isAdminFirst } from "./login";
import getContentRouter from "./route/contents";
import { error_handler } from "./route/error_handler";
import {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 documentController: DocumentAccessor;
readonly tagController: TagAccessor;
readonly diffManger: DiffManager;
readonly app: Koa;
private index_html:string;
private constructor(controller:{
userController: UserAccessor,
documentController:DocumentAccessor,
tagController: TagAccessor}){
private index_html: string;
private constructor(controller: {
userController: UserAccessor;
documentController: DocumentAccessor;
tagController: TagAccessor;
}) {
this.userController = controller.userController;
this.documentController = controller.documentController;
this.tagController = controller.tagController;
this.diffManger = new DiffManager(this.documentController);
this.app = new Koa();
this.index_html = readFileSync("index.html","utf-8");
this.index_html = readFileSync("index.html", "utf-8");
}
private async setup(){
private async setup() {
const setting = get_setting();
const app = this.app;
if(setting.cli){
if (setting.cli) {
const userAdmin = await getAdmin(this.userController);
if(await isAdminFirst(userAdmin)){
if (await isAdminFirst(userAdmin)) {
const rl = createReadlineInterface({
input:process.stdin,
output:process.stdout
input: process.stdin,
output: process.stdout,
});
const pw = await new Promise((res:(data:string)=>void,err)=>{
rl.question("put admin password :",(data)=>{
const pw = await new Promise((res: (data: string) => void, err) => {
rl.question("put admin password :", (data) => {
res(data);
});
});
@ -60,167 +60,168 @@ class ServerApplication{
app.use(bodyparser());
app.use(error_handler);
app.use(createUserMiddleWare(this.userController));
let diff_router = createDiffRouter(this.diffManger);
this.diffManger.register("comic",createComicWatcher());
this.diffManger.register("comic", createComicWatcher());
console.log("setup router");
let router = new Router();
router.use("/api/(.*)", async (ctx,next)=>{
//For CORS
router.use("/api/(.*)", async (ctx, next) => {
// For CORS
ctx.res.setHeader("access-control-allow-origin", "*");
await next();
});
router.use('/api/diff',diff_router.routes());
router.use('/api/diff',diff_router.allowedMethods());
router.use("/api/diff", diff_router.routes());
router.use("/api/diff", diff_router.allowedMethods());
const content_router = getContentRouter(this.documentController);
router.use('/api/doc',content_router.routes());
router.use('/api/doc',content_router.allowedMethods());
router.use("/api/doc", content_router.routes());
router.use("/api/doc", content_router.allowedMethods());
const tags_router = getTagRounter(this.tagController);
router.use("/api/tags",tags_router.allowedMethods());
router.use("/api/tags",tags_router.routes());
router.use("/api/tags", tags_router.allowedMethods());
router.use("/api/tags", tags_router.routes());
this.serve_with_meta_index(router);
this.serve_index(router);
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"){
let mm_count = 0;
app.use(async (ctx,next)=>{
console.log(`==========================${mm_count++}`);
const ip = (ctx.get("X-Real-IP")) ?? ctx.ip;
const fromClient = ctx.state['user'].username === "" ? ip : ctx.state['user'].username;
console.log(`${fromClient} : ${ctx.method} ${ctx.url}`);
await next();
//console.log(`404`);
});}
router.use("/user", login_router.routes());
router.use("/user", login_router.allowedMethods());
if (setting.mode == "development") {
let mm_count = 0;
app.use(async (ctx, next) => {
console.log(`==========================${mm_count++}`);
const ip = (ctx.get("X-Real-IP")) ?? ctx.ip;
const fromClient = ctx.state["user"].username === "" ? ip : ctx.state["user"].username;
console.log(`${fromClient} : ${ctx.method} ${ctx.url}`);
await next();
// console.log(`404`);
});
}
app.use(router.routes());
app.use(router.allowedMethods());
console.log("setup done");
}
private serve_index(router:Router){
const serveindex = (url:string)=>{
router.get(url, (ctx)=>{
ctx.type = 'html'; ctx.body = this.index_html;
private serve_index(router: Router) {
const serveindex = (url: string) => {
router.get(url, (ctx) => {
ctx.type = "html";
ctx.body = this.index_html;
const setting = get_setting();
ctx.set('x-content-type-options','no-sniff');
if(setting.mode === "development"){
ctx.set('cache-control','no-cache');
ctx.set("x-content-type-options", "no-sniff");
if (setting.mode === "development") {
ctx.set("cache-control", "no-cache");
} else {
ctx.set("cache-control", "public, max-age=3600");
}
else{
ctx.set('cache-control','public, max-age=3600');
}
})
}
serveindex('/');
serveindex('/doc/:rest(.*)');
serveindex('/search');
serveindex('/login');
serveindex('/profile');
serveindex('/difference');
serveindex('/setting');
serveindex('/tags');
});
};
serveindex("/");
serveindex("/doc/:rest(.*)");
serveindex("/search");
serveindex("/login");
serveindex("/profile");
serveindex("/difference");
serveindex("/setting");
serveindex("/tags");
}
private serve_with_meta_index(router:Router){
const DocMiddleware = async (ctx: Koa.ParameterizedContext)=>{
private serve_with_meta_index(router: Router) {
const DocMiddleware = async (ctx: Koa.ParameterizedContext) => {
const docId = Number.parseInt(ctx.params["id"]);
const doc = await this.documentController.findById(docId,true);
const doc = await this.documentController.findById(docId, true);
let meta;
if(doc === undefined){
if (doc === undefined) {
ctx.status = 404;
meta = NotFoundContent();
}
else {
meta = NotFoundContent();
} else {
ctx.status = 200;
meta = createOgTagContent(doc.title,doc.tags.join(", "),
`https://aeolian.prelude.duckdns.org/api/doc/${docId}/comic/thumbnail`);
meta = createOgTagContent(
doc.title,
doc.tags.join(", "),
`https://aeolian.prelude.duckdns.org/api/doc/${docId}/comic/thumbnail`,
);
}
const html = makeMetaTagInjectedHTML(this.index_html,meta);
serveHTML(ctx,html);
const html = makeMetaTagInjectedHTML(this.index_html, meta);
serveHTML(ctx, html);
};
router.get("/doc/:id(\\d+)", DocMiddleware);
function NotFoundContent() {
return createOgTagContent("Not Found Doc", "Not Found", "");
}
router.get('/doc/:id(\\d+)',DocMiddleware);
function NotFoundContent(){
return createOgTagContent("Not Found Doc","Not Found","");
function makeMetaTagInjectedHTML(html: string, tagContent: string) {
return html.replace("<!--MetaTag-Outlet-->", tagContent);
}
function makeMetaTagInjectedHTML(html:string,tagContent:string){
return html.replace("<!--MetaTag-Outlet-->",tagContent);
}
function serveHTML(ctx: Koa.Context, file: string){
ctx.type = 'html'; ctx.body = file;
function serveHTML(ctx: Koa.Context, file: string) {
ctx.type = "html";
ctx.body = file;
const setting = get_setting();
ctx.set('x-content-type-options','no-sniff');
if(setting.mode === "development"){
ctx.set('cache-control','no-cache');
}
else{
ctx.set('cache-control','public, max-age=3600');
ctx.set("x-content-type-options", "no-sniff");
if (setting.mode === "development") {
ctx.set("cache-control", "no-cache");
} else {
ctx.set("cache-control", "public, max-age=3600");
}
}
function createMetaTagContent(key: string, value:string){
function createMetaTagContent(key: string, value: string) {
return `<meta property="${key}" content="${value}">`;
}
function createOgTagContent(title:string,description: string, image: string){
return [createMetaTagContent("og:title",title),
createMetaTagContent("og:type","website"),
createMetaTagContent("og:description",description),
createMetaTagContent("og:image",image),
//createMetaTagContent("og:image:width","480"),
//createMetaTagContent("og:image","480"),
//createMetaTagContent("og:image:type","image/png"),
createMetaTagContent("twitter:card","summary_large_image"),
createMetaTagContent("twitter:title",title),
createMetaTagContent("twitter:description",description),
createMetaTagContent("twitter:image",image),
].join("\n");
function createOgTagContent(title: string, description: string, image: string) {
return [
createMetaTagContent("og:title", title),
createMetaTagContent("og:type", "website"),
createMetaTagContent("og:description", description),
createMetaTagContent("og:image", image),
// createMetaTagContent("og:image:width","480"),
// createMetaTagContent("og:image","480"),
// createMetaTagContent("og:image:type","image/png"),
createMetaTagContent("twitter:card", "summary_large_image"),
createMetaTagContent("twitter:title", title),
createMetaTagContent("twitter:description", description),
createMetaTagContent("twitter:image", image),
].join("\n");
}
}
private serve_static_file(router: Router){
const static_file_server = (path:string,type:string) => {
router.get('/'+path,async (ctx,next)=>{
private serve_static_file(router: Router) {
const static_file_server = (path: string, type: string) => {
router.get("/" + path, async (ctx, next) => {
const setting = get_setting();
ctx.type = type; ctx.body = createReadStream(path);
ctx.set('x-content-type-options','no-sniff');
if(setting.mode === "development"){
ctx.set('cache-control','no-cache');
ctx.type = type;
ctx.body = createReadStream(path);
ctx.set("x-content-type-options", "no-sniff");
if (setting.mode === "development") {
ctx.set("cache-control", "no-cache");
} else {
ctx.set("cache-control", "public, max-age=3600");
}
else{
ctx.set('cache-control','public, max-age=3600');
}
})};
});
};
const setting = get_setting();
static_file_server('dist/bundle.css','css');
static_file_server('dist/bundle.js','js');
if(setting.mode === "development"){
static_file_server('dist/bundle.js.map','text');
static_file_server('dist/bundle.css.map','text');
static_file_server("dist/bundle.css", "css");
static_file_server("dist/bundle.js", "js");
if (setting.mode === "development") {
static_file_server("dist/bundle.js.map", "text");
static_file_server("dist/bundle.css.map", "text");
}
}
start_server(){
start_server() {
let setting = get_setting();
//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() {
const setting = get_setting();
let db = await connectDB();
const app = new ServerApplication({
userController:createKnexUserController(db),
userController: createKnexUserController(db),
documentController: createKnexDocumentAccessor(db),
tagController: createKnexTagController(db),
});
@ -229,8 +230,8 @@ class ServerApplication{
}
}
export async function create_server(){
export async function create_server() {
return await ServerApplication.createServer();
}
export default {create_server};
export default { create_server };

62
src/types/db.d.ts vendored
View File

@ -1,34 +1,34 @@
import {Knex} from "knex";
import { Knex } from "knex";
declare module "knex" {
interface Tables {
tags: {
name: string;
description?: string;
};
users: {
username: string;
password_hash: string;
password_salt: string;
};
document: {
id: number;
title: string;
content_type: string;
basepath: string;
filename: string;
created_at: number;
deleted_at: number|null;
content_hash: string;
additional: string|null;
};
doc_tag_relation: {
doc_id: number;
tag_name: string;
};
permissions: {
username: string;
name: string;
};
}
interface Tables {
tags: {
name: string;
description?: string;
};
users: {
username: string;
password_hash: string;
password_salt: string;
};
document: {
id: number;
title: string;
content_type: string;
basepath: string;
filename: string;
created_at: number;
deleted_at: number | null;
content_hash: string;
additional: string | null;
};
doc_tag_relation: {
doc_id: number;
tag_name: string;
};
permissions: {
username: string;
name: string;
};
}
}

View File

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

View File

@ -1,26 +1,26 @@
import {readFileSync, existsSync, writeFileSync, promises as fs} from 'fs';
import {validate} from 'jsonschema';
import { existsSync, promises as fs, readFileSync, writeFileSync } from "fs";
import { validate } from "jsonschema";
export class ConfigManager<T>{
path:string;
export class ConfigManager<T> {
path: string;
default_config: T;
config: T| null;
schema:object;
constructor(path:string,default_config:T,schema:object){
config: T | null;
schema: object;
constructor(path: string, default_config: T, schema: object) {
this.path = path;
this.default_config = default_config;
this.config = null;
this.schema = schema;
}
get_config_file(): T{
if(this.config !== null) return this.config;
this.config = {...this.read_config_file()};
get_config_file(): T {
if (this.config !== null) return this.config;
this.config = { ...this.read_config_file() };
return this.config;
}
private emptyToDefault(target:T){
private emptyToDefault(target: T) {
let occur = false;
for(const key in this.default_config){
if(key === undefined || key in target){
for (const key in this.default_config) {
if (key === undefined || key in target) {
continue;
}
target[key] = this.default_config[key];
@ -28,24 +28,24 @@ export class ConfigManager<T>{
}
return occur;
}
read_config_file():T{
if(!existsSync(this.path)){
writeFileSync(this.path,JSON.stringify(this.default_config));
read_config_file(): T {
if (!existsSync(this.path)) {
writeFileSync(this.path, JSON.stringify(this.default_config));
return this.default_config;
}
const ret = JSON.parse(readFileSync(this.path,{encoding:"utf8"}));
if(this.emptyToDefault(ret)){
writeFileSync(this.path,JSON.stringify(ret));
const ret = JSON.parse(readFileSync(this.path, { encoding: "utf8" }));
if (this.emptyToDefault(ret)) {
writeFileSync(this.path, JSON.stringify(ret));
}
const result = validate(ret,this.schema);
if(!result.valid){
const result = validate(ret, this.schema);
if (!result.valid) {
throw new Error(result.toString());
}
return ret;
}
async write_config_file(new_config:T){
async write_config_file(new_config: T) {
this.config = new_config;
await fs.writeFile(`${this.path}.temp`,JSON.stringify(new_config));
await fs.rename(`${this.path}.temp`,this.path);
await fs.writeFile(`${this.path}.temp`, JSON.stringify(new_config));
await fs.rename(`${this.path}.temp`, this.path);
}
}
}

View File

@ -1,16 +1,15 @@
export function check_type<T>(obj: any,check_proto:Record<string,string|undefined>):obj is T{
export function check_type<T>(obj: any, check_proto: Record<string, string | undefined>): obj is T {
for (const it in check_proto) {
let defined = check_proto[it];
if(defined === undefined) return false;
if (defined === undefined) return false;
defined = defined.trim();
if(defined.endsWith("[]")){
if(!(obj[it] instanceof Array)){
if (defined.endsWith("[]")) {
if (!(obj[it] instanceof Array)) {
return false;
}
}
else if(defined !== typeof obj[it]){
} else if (defined !== typeof obj[it]) {
return false;
}
}
return true;
};
}

View File

@ -1,31 +1,33 @@
import { ZipEntry } from 'node-stream-zip';
import { ZipEntry } from "node-stream-zip";
import {orderBy} from 'natural-orderby';
import { ReadStream } from 'fs';
import StreamZip from 'node-stream-zip';
import { ReadStream } from "fs";
import { orderBy } from "natural-orderby";
import StreamZip from "node-stream-zip";
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({
file:path,
storeEntries: true
file: path,
storeEntries: true,
});
}
export async function entriesByNaturalOrder(zip: ZipAsync){
export async function entriesByNaturalOrder(zip: ZipAsync) {
const entries = await zip.entries();
const ret = orderBy(Object.values(entries),v=>v.name);
const ret = orderBy(Object.values(entries), v => v.name);
return ret;
}
export async function createReadableStreamFromZip(zip:ZipAsync,entry: ZipEntry):Promise<NodeJS.ReadableStream>{
export async function createReadableStreamFromZip(zip: ZipAsync, entry: ZipEntry): Promise<NodeJS.ReadableStream> {
return await zip.stream(entry);
}
export async function readAllFromZip(zip:ZipAsync,entry: ZipEntry):Promise<Buffer>{
const stream = await createReadableStreamFromZip(zip,entry);
const chunks:Uint8Array[] = [];
return new Promise((resolve,reject)=>{
stream.on('data',(data)=>{chunks.push(data)});
stream.on('error', (err)=>reject(err));
stream.on('end',()=>resolve(Buffer.concat(chunks)));
export async function readAllFromZip(zip: ZipAsync, entry: ZipEntry): Promise<Buffer> {
const stream = await createReadableStreamFromZip(zip, entry);
const chunks: Uint8Array[] = [];
return new Promise((resolve, reject) => {
stream.on("data", (data) => {
chunks.push(data);
});
stream.on("error", (err) => reject(err));
stream.on("end", () => resolve(Buffer.concat(chunks)));
});
}
}

View File

@ -4,17 +4,17 @@
/* Basic Options */
// "incremental": true, /* Enable incremental compilation */
"target": "esnext", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */
"module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */
"lib": ["DOM","ES6"], /* Specify library files to be included in the compilation. */
//"allowJs": true, /* Allow javascript files to be compiled. */
"target": "esnext", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */
"module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */
"lib": ["DOM", "ES6"], /* Specify library files to be included in the compilation. */
// "allowJs": true, /* Allow javascript files to be compiled. */
// "checkJs": true, /* Report errors in .js files. */
"jsx": "react", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
"jsx": "react", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
// "declaration": true, /* Generates corresponding '.d.ts' file. */
// "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
// "sourceMap": true, /* Generates corresponding '.map' file. */
// "outFile": "./", /* Concatenate and emit output to single file. */
"outDir": "./build", /* Redirect output structure to the directory. */
"outDir": "./build", /* Redirect output structure to the directory. */
// "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
// "composite": true, /* Enable project compilation */
// "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */
@ -25,13 +25,13 @@
// "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
/* Strict Type-Checking Options */
"strict": true, /* Enable all strict type-checking options. */
"noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
"strictNullChecks": true, /* Enable strict null checks. */
"strict": true, /* Enable all strict type-checking options. */
"noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
"strictNullChecks": true, /* Enable strict null checks. */
// "strictFunctionTypes": true, /* Enable strict checking of function types. */
// "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
// "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
"noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
"noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
// "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
/* Additional Checks */
@ -41,14 +41,14 @@
// "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
/* Module Resolution Options */
"moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
"moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
// "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
// "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
// "typeRoots": [], /* List of folders to include type definitions from. */
// "types": [], /* Type declaration files to be included in compilation. */
"allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
"esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
"allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
"esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
"resolveJsonModule": true,
// "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
@ -64,9 +64,9 @@
// "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
/* Advanced Options */
"skipLibCheck": true, /* Skip type checking of declaration files. */
"forceConsistentCasingInFileNames": true, /* Disallow inconsistently-cased references to the same file. */
"skipLibCheck": true, /* Skip type checking of declaration files. */
"forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */
},
"include": ["./"],
"exclude": ["src/client","app","seeds"],
"exclude": ["src/client", "app", "seeds"]
}