forked from peas-dev/peas
1
0
Fork 0

サインイン・サインアップまで完成

This commit is contained in:
Last2014 2025-10-02 04:48:08 +09:00
commit da54ec6ee3
52 changed files with 5655 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
.env
node_modules

3
files/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
*
!/.gitignore
!/peas.svg

96
files/peas.svg Normal file
View File

@ -0,0 +1,96 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="512px"
height="512px"
viewBox="0 0 210 297"
version="1.1"
id="svg1"
inkscape:version="1.4 (86a8ad7, 2024-10-11)"
sodipodi:docname="peas.svg"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:dc="http://purl.org/dc/elements/1.1/">
<defs
id="defs1" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#000000"
borderopacity="0.3"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="1.4"
inkscape:cx="244.28571"
inkscape:cy="228.92857"
inkscape:document-units="mm"
inkscape:showpageshadow="0"
inkscape:pagecheckerboard="0"
inkscape:deskcolor="#505050"
inkscape:window-width="1920"
inkscape:window-height="1094"
inkscape:window-x="-11"
inkscape:window-y="-11"
inkscape:window-maximized="1"
inkscape:current-layer="svg1" />
<metadata
id="metadata1">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
</cc:Work>
</rdf:RDF>
</metadata>
<rect
style="fill:#99ff55;stroke-width:2.22859"
id="rect1"
width="297"
height="297"
x="-43.5"
y="-3.5527137e-15" />
<g
id="g2"
transform="matrix(1.1469899,0,0,1.1469899,-26.767316,-13.251856)">
<path
style="fill:#119b20;fill-opacity:1;stroke:#147202;stroke-width:1.83106;stroke-dasharray:none;stroke-opacity:1"
d="m 35.7551,61.017585 c -8.783005,22.596126 -5.761437,69.762885 16.492646,91.222475 l 21.858215,23.82446 c 41.098899,35.55484 90.612599,67.5229 124.660009,21.58881"
id="path9"
sodipodi:nodetypes="cccc" />
<g
id="g1"
transform="matrix(0.85872413,0.14665947,-0.14081857,0.89434245,34.95914,6.2378533)">
<ellipse
style="fill:#11bd20;fill-opacity:1;stroke:#147202;stroke-width:1.65397;stroke-dasharray:none;stroke-opacity:1"
id="path2"
cx="-78.743805"
cy="102.15148"
rx="22.052917"
ry="22.052916"
transform="rotate(-60.606551)" />
<ellipse
style="fill:#11bd20;fill-opacity:1;stroke:#147202;stroke-width:1.48433;stroke-dasharray:none;stroke-opacity:1"
id="circle2"
cx="-81.856644"
cy="158.49173"
rx="19.791082"
ry="19.79108"
transform="rotate(-60.606551)" />
<ellipse
style="fill:#11bd20;fill-opacity:1;stroke:#147202;stroke-width:1.59035;stroke-dasharray:none;stroke-opacity:1"
id="circle4"
cx="-81.383789"
cy="218.60431"
rx="21.204731"
ry="21.204727"
transform="rotate(-60.606551)" />
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

86
locales/en.yaml Normal file
View File

@ -0,0 +1,86 @@
PeasAboutTitle: About Peas
PeasAboutText: |
Peas is a SNS designed for sharing profiles.<br>
You can register and share your user profile.<br>
You can freely create profile sections suitable for use at school or work and also change the privacy settings.<br>
It also supports printing QR codes making sharing easy.
ServerAboutTitle: About this server
or: or
Start: Get started now
Signin: Sign In
Signup: Sign Up
email: Email
password: Password
username: Username
help: Help
idHelp: An alphanumeric ID consisting of 3 to 20 characters and beginning with @
mailHelp: Use an email address that can be received
passwordHelp: Use a password of at least 8 characters
signinError: Sign in failed
mailVerify: Mail Verify
code: Code
submit: Submit
Home: Home
codeCharacterCount: Please enter a 6-digit code
emailVerificationSuccess: |
Email verification successful.
Please log in.
connectionError: Connection Error
accountCreateSuccess: |
Your account has been successfully created.
We have sent a confirmation email to the email address you registered.
Please click the confirmation email to activate your account.
sameUsername: That username is already taken
sameEmail: This email address is already in use.
termsAgreement: Please agree to the Terms of Service and Privacy Policy
bodyRequired: Form contents have not been submitted
passwordCharacterLimit: Please make your password at least 8 characters long.
usernameRestriction: Please make your username an alphanumeric ID with at least 3 characters.
userCreationFailed: Failed to create the user.
terms: Terms of Service
privacypolicy: Privacy Policy
agreeRule: I agree to the <a href="/terms" class="link">Terms</a> of Use and <a href="/privacypolicy" class="link">Privacy Policy</a>
userCount: User Count
communityCount: Community Count
loading: Loading
topPeasCredit: This server uses Peas({0}) an open-source software.
notFound: The page you are looking for could not be found
notFoundAbout: The specified page was not found on this server.
backtoHome: Return to home
error: An error occurred
mailverifyText: |
Hello {0}.
Email authentication is required for {1}.
By authenticating your email address, you will be able to use the {1} features.
Please visit {2}.
serverName: Server name
serverDescription: Server description
serverIconURL: Server icon image URL
Q_origin: Origin to use
Q_port: Port to broadcast to
Q_db_host: MariaDB host name
Q_db_port: MariaDB port
Q_db_user: MariaDB user
Q_db_pass: MariaDB password
Q_db_db: MariaDB database name
Q_turnstile_is_enabled: Do you want to enable Cloudflare Turnstile?
Q_turnstile_sitekey: Turnstile site key
Q_turnstile_secret: Turnstile secret key
Q_smtp_host: SMTP server hostname
Q_smtp_port: SMTP server port
Q_smtp_user: SMTP server user
Q_smtp_pass: SMTP server password
Q_smtp_secure: Do you use encryption for communications to the SMTP server?
CLISETUP_success: ✔Setup complete
CLI_config_file_write_success: The configuration file was written successfully.
ERR_input_lack: Insufficient input
ERR_turnstile_request_failed: An error occurred on the server while authenticating Turnstile
ERR_turnstile_failed: Turnstile authentication failed
ERR_username_duplicate: That username is already in use
ERR_email_duplicate: That email is already in use
ERR_account_create_failed: Account creation failed
ERR_length_different: Invalid character count
ERR_code_invalid: Incorrect code
ERR_code_not_found: Code not found
ERR_user_not_found: User not found
ERR_password_invalid: Incorrect password

86
locales/ja.yaml Normal file
View File

@ -0,0 +1,86 @@
PeasAboutTitle: Peasについて
PeasAboutText: |
Peasはプロフィール共有を目的としたSNSです。<br>
ユーザーのプロフィールを登録し、共有することが出来ます。<br>
学校や職場で使用できるようなプロフィール欄も自由に作成でき、公開設定も変更できます。<br>
QRコードの印刷にも対応していて共有にも最適です。
ServerAboutTitle: このサーバーについて
or: または
Start: 今すぐ始める
Signin: サインイン
Signup: サインアップ
email: メールアドレス
password: パスワード
username: ユーザー名
help: ヘルプ
idHelp: 3文字以上20文字以下で@から始まる英数字のID
mailHelp: メールアドレスは受信できるものを使用してください
passwordHelp: 8文字以上15文字以下のパスワードを使用してください
signinError: サインインに失敗しました
mailVerify: メール認証
code: コード
submit: 送信
Home: ホーム
codeCharacterCount: コードは6桁で入力してください
emailVerificationSuccess: |
メール認証に成功しました。
ログインしてください。
connectionError: 通信エラー
accountCreateSuccess: |
アカウントの作成に成功しました。
ご登録いただいたメールアドレスに確認用メールを送信しました。
確認メールをクリックしてアカウントを有効化してください。
sameUsername: 同じユーザー名が既に使用されています
sameEmail: 同じメールアドレスが既に使用されています
termsAgreement: 利用規約とプライバシーポリシーに同意してください
bodyRequired: フォーム内容が送信されていません
passwordCharacterLimit: パスワードは8文字以上にしてください
usernameRestriction: ユーザー名は3文字以上の英数字のIDにしてください
userCreationFailed: ユーザーの作成に失敗しました
terms: 利用規約
privacypolicy: プライバシーポリシー
agreeRule: <a href="/terms" class="link">利用規約</a>及び<a href="/privacypolicy" class="link">プライバシーポリシー</a>に同意する
userCount: ユーザー数
communityCount: 投稿数
loading: 読み込み中
topPeasCredit: このサーバーはオープンソースソフトウェアのPeas({0})を使用しています。
notFound: お探しのページが見つかりませんでした
notFoundAbout: このサーバー上に指定されたページが見つかりません。
backtoHome: ホームに戻る
error: エラーが発生しました
mailverifyText: |
{0}さんこんにちは。
{1}ではメール認証が必要となっています。
メール認証をすることで{1}の機能を利用することができます。
{2} にアクセスしてください。
serverName: サーバー名
serverDescription: サーバー説明
serverIconURL: サーバーアイコン画像のURL
Q_origin: 使用するオリジン
Q_port: 配信するポート
Q_db_host: MariaDBのホスト名
Q_db_port: MariaDBのポート
Q_db_user: MariaDBのユーザー
Q_db_pass: MariaDBのパスワード
Q_db_db: MariaDBのデータベース名
Q_turnstile_is_enabled: Cloudflare Turnstileを有効化しますか
Q_turnstile_sitekey: Turnstileのサイトキー
Q_turnstile_secret: Turnstileのシークレットキー
Q_smtp_host: SMTPサーバーのホスト名
Q_smtp_port: SMTPサーバーのポート
Q_smtp_user: SMTPサーバーのユーザー
Q_smtp_pass: SMTPサーバーのパスワード
Q_smtp_secure: SMTPサーバーへの通信に暗号化を使用しますか
CLISETUP_success: ✔ セットアップが完了しました
CLI_config_file_write_success: 設定ファイルの書き込みに成功しました
ERR_input_lack: 入力が不足しています
ERR_turnstile_request_failed: Turnstileの認証中にサーバーでエラーが発生しました
ERR_turnstile_failed: Turnstileの認証ができませんでした
ERR_username_duplicate: そのユーザー名は既に使用されています
ERR_email_duplicate: そのメールアドレスは既に使用されています
ERR_account_create_failed: アカウントの作成に失敗しました
ERR_length_different: 文字数が正しくありません
ERR_code_invalid: コードが正しくありません
ERR_code_not_found: コードが見つかりません
ERR_user_not_found: ユーザーが見つかりません
ERR_password_invalid: パスワードが異なります

30
package.json Normal file
View File

@ -0,0 +1,30 @@
{
"name": "peas",
"version": "1.0.0",
"private": true,
"description": "Peas is a SNS designed for sharing profiles.",
"scripts": {
"build:locale": "tsc"
},
"keywords": [
"sns",
"profile"
],
"author": {
"name": "Last2014",
"email": "info@last2014.com",
"url": "https://last2014.com"
},
"contributors": [],
"license": "AGPL-3.0-later",
"packageManager": "pnpm@10.17.0",
"type": "module",
"dependencies": {
"@inquirer/prompts": "^7.8.6",
"@types/node": "^24.5.2",
"fs": "0.0.1-security",
"mysql2": "^3.15.1",
"typescript": "^5.9.2",
"yaml": "^2.8.1"
}
}

14
packages/web/.gitignore vendored Normal file
View File

@ -0,0 +1,14 @@
# modules
node_modules/
# logs
logs/
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
# dist
/dist

2
packages/web/config/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
!*
/peas.config.ts

13
packages/web/config/config.d.ts vendored Normal file
View File

@ -0,0 +1,13 @@
export default interface ConfigType {
server: {
port: number;
origin: string;
},
database: {
host: string;
port: number;
user: string;
pass: string;
db: string;
},
}

View File

@ -0,0 +1,17 @@
import type ConfigType from "./config";
const Config: ConfigType = {
server: {
port: 3000,
origin: "https://peas.example.com",
},
database: {
host: "db.example.com",
port: 3306,
user: "peas",
pass: "DatabasePassword",
db: "peas",
},
}
export default Config;

39
packages/web/package.json Normal file
View File

@ -0,0 +1,39 @@
{
"name": "web",
"type": "module",
"main": "dist/src/main.js",
"scripts": {
"build": "pnpm run \"/^build:.*/\"",
"build:ts": "tsc",
"build:css": "tailwindcss -i ./tailwind.init.css -o ./public/tailwind.css -m",
"start": "node .",
"dev": "pnpm run dev:build:css && pnpm run dev:start",
"dev:build:css": "pnpm run build:css",
"dev:start": "tsx --no-cache src/main.ts -tsx-develop"
},
"dependencies": {
"@hono/node-server": "^1.19.3",
"@tailwindcss/cli": "^4.1.13",
"@types/bcrypt": "^6.0.0",
"@types/nodemailer": "^7.0.2",
"bcrypt": "^6.0.0",
"daisyui": "^5.1.25",
"fs": "0.0.1-security",
"globals": "^16.4.0",
"hono": "^4.9.8",
"jiti": "^2.5.1",
"mysql2": "^3.15.0",
"nodemailer": "^7.0.6",
"path": "^0.12.7",
"tailwindcss": "^4.1.13",
"ua-parser-js": "^2.0.5",
"url": "^0.11.4",
"uuid": "^13.0.0"
},
"devDependencies": {
"@types/node": "^20.11.17",
"tsx": "^4.7.1",
"typescript": "^5.8.3"
},
"packageManager": "pnpm@10.17.1"
}

2406
packages/web/pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,5 @@
onlyBuiltDependencies:
- '@parcel/watcher'
- '@tailwindcss/oxide'
- bcrypt
- esbuild

1
packages/web/public/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
tailwind.css

View File

@ -0,0 +1,45 @@
const form = document.querySelector("form");
form.addEventListener("submit", async (e) => {
e.preventDefault();
document.getElementsByName("code")[0].disable = "";
document.getElementsByName("submit")[0].disable = "";
let error = "";
const infoReq = await fetch("/api/info", {
method: "POST",
cache: "no-store",
});
if (!infoReq.ok) {
error = "通信エラーが発生しました";
}
const infoRes = await infoReq.json();
const code = document.getElementsByName("code")[0].value;
let turnstile;
if (infoRes.botprotection.turnstile === true) {
turnstile = document.getElementsByName("cf-turnstile-response")[0].value;
}
const req = await fetch("/api/mailverify", {
method: "POST",
cache: "no-store",
headers: {
'Content-Type': "application/json",
},
body: JSON.stringify({
code: code,
turnstile: turnstile ?? undefined,
}),
});
if (!req.ok) {
error = "通信エラーが発生しました";
}
const res = await req.json();
if (res.success === true) {
location.href = "/signin";
} else {
location.replace(`/mailverify?error=${res.error}&code=${document.getElementsByName("code")[0].value}`);
}
})

View File

@ -0,0 +1,48 @@
const form = document.querySelector("form");
form.addEventListener("submit", async (e) => {
e.preventDefault();
document.getElementsByName("username")[0].disable = "";
document.getElementsByName("password")[0].disable = "";
document.getElementsByName("submit")[0].disable = "";
let error = "";
const infoReq = await fetch("/api/info", {
method: "POST",
cache: "no-store",
});
if (!infoReq.ok) {
error = "通信エラーが発生しました";
}
const infoRes = await infoReq.json();
const username = document.getElementsByName("username")[0].value;
const password = document.getElementsByName("password")[0].value;
let turnstile;
if (infoRes.botprotection.turnstile === true) {
turnstile = document.getElementsByName("cf-turnstile-response")[0].value;
}
const req = await fetch("/api/signin", {
method: "POST",
cache: "no-store",
headers: {
'Content-Type': "application/json",
},
body: JSON.stringify({
username: username,
password: password,
turnstile: turnstile ?? undefined,
}),
});
if (!req.ok) {
error = "通信エラーが発生しました";
}
const res = await req.json();
if (res.success === true) {
location.href = "/home";
} else {
location.replace(`/signin?error=${res.error}`);
}
});

View File

@ -0,0 +1,52 @@
const form = document.querySelector("form");
form.addEventListener("submit", async (e) => {
e.preventDefault();
document.getElementsByName("username")[0].disable = "";
document.getElementsByName("email")[0].disable = "";
document.getElementsByName("password")[0].disable = "";
document.getElementsByName("agreeRule")[0].disable = "";
document.getElementsByName("submit")[0].disable = "";
let error = "";
const infoReq = await fetch("/api/info", {
method: "POST",
cache: "no-store",
});
if (!infoReq.ok) {
error = "通信エラーが発生しました";
}
const infoRes = await infoReq.json();
const username = document.getElementsByName("username")[0].value;
const email = document.getElementsByName("email")[0].value;
const password = document.getElementsByName("password")[0].value;
let turnstile;
if (infoRes.botprotection.turnstile === true) {
turnstile = document.getElementsByName("cf-turnstile-response")[0].value;
}
const req = await fetch("/api/signup", {
method: "POST",
cache: "no-store",
headers: {
'Content-Type': "application/json",
},
body: JSON.stringify({
username: username,
email: email,
password: password,
turnstile: turnstile ?? undefined,
}),
});
if (!req.ok) {
error = "通信エラーが発生しました";
}
const res = await req.json();
if (res.success === true) {
location.href = "/signin";
} else {
location.replace(`/signup?error=${res.error}`)
}
});

View File

@ -0,0 +1,56 @@
import { Locale } from "../lib/locale.js";
import Icon from "./iconify.js";
const Help = (props: {
text: string;
lang: string;
}) => {
return (
<div class={`
dropdown
dropdown-end
m-0
`}>
<div
tabindex={0}
role="button"
class={`
btn
btn-circle
btn-ghost
btn-xs
text-info
`}
>
<Icon icon="mdi:information" />
</div>
<div
tabindex={0}
class={`
card
card-sm
dropdown-content
bg-base-100
rounded-box
z-1
w-64
shadow-sm
`}
>
<div
tabindex={0}
class="card-body"
>
<h2 class="card-title">
{Locale({ text: "help" }, props.lang)}
</h2>
<p>
{props.text}
</p>
</div>
</div>
</div>
);
};
export default Help;

View File

@ -0,0 +1,26 @@
import type { JSX, DOMAttributes } from "hono/jsx";
const Icon = async (props: {
icon: string;
color?: string;
} & DOMAttributes) => {
const { icon, color, ...rest } = props;
const iconColor = color === undefined ? "" : `?color=${color}`;
const url = `https://api.iconify.design/${icon}.svg${iconColor}`;
const req = await fetch(url, {
method: "GET"
});
if (!req.ok) {
return (<></>);
}
const res = await req.text();
return (
<span
dangerouslySetInnerHTML={{__html: res}}
/>
);
};
export default Icon;

View File

@ -0,0 +1,37 @@
import { html } from "hono/html";
import type { HtmlEscapedString } from "hono/utils/html";
const Turnstile = (props: {
sitekey: string;
button: HtmlEscapedString | Promise<HtmlEscapedString>;
}) => {
return (
<div>
{html`<script
src="https://challenges.cloudflare.com/turnstile/v0/api.js"
async
defer
></script>`}
<div id="turnstile-box" />
{html`<script>
window.onload = function () {
document.querySelector("${props.button}").disabled = true;
turnstile.render("#turnstile-box", {
sitekey: "${props.sitekey}",
callback: (token) => {
if (token) {
document.querySelector("${props.button}").disabled = false;
}
},
});
};
</script>`}
</div>
);
};
export default Turnstile;

View File

@ -0,0 +1,46 @@
import mysql from "mysql2/promise";
import Config from "../../config/peas.config.js";
const option = (connectionLimit: number = 10) => {
return {
host: Config.database.host,
port: Config.database.port,
user: Config.database.user,
password: Config.database.pass,
database: Config.database.db,
waitForConnections: true,
connectionLimit: connectionLimit,
multipleStatements: true,
}
};
const verify = async (option: {
host: string;
port: number;
user: string;
password: string;
database: string;
waitForConnections: boolean;
connectionLimit?: number;
}) => {
option.connectionLimit = option.connectionLimit ?? 10;
let connection;
try {
connection = await mysql.createConnection(option);
} catch (err) {
console.error("Error: Could not connect to MariaDB");
console.error(err);
process.exit();
} finally {
if (connection) {
await connection.end();
}
}
};
export { verify, option };
const pool = mysql.createPool(option());
export default pool;

View File

@ -0,0 +1,52 @@
import * as fs from "fs";
import { parse as yamlParse } from "yaml";
export const supportedLanguages = [
"en",
"ja",
];
import { getDirname } from "./path.js";
const dirname = getDirname(import.meta.url);
export function Check() {
let error = false;
for (let i = 0; i < supportedLanguages.length; i++) {
if (!fs.existsSync(`${dirname}../../../../locales/${supportedLanguages[i]}.yaml`)) {
error = true;
console.error(`Error: ${supportedLanguages[i]} language pack not found`);
}
}
return error;
}
export function Locale({
text, data = [],
variables = {}
}: {
text: string;
data?: string[];
} & {
variables?: Record<string, string>;
}, lang: string) {
if (supportedLanguages.indexOf(lang) === -1) {
return "__ERROR:LANG_NOT_SUPPORT";
}
const yaml = fs.readFileSync(`${dirname}../../../../locales/${lang}.yaml`, "utf-8");
const localeData = yamlParse(yaml);
let message = localeData[text];
data.forEach((value, index) => {
message = message.replace(new RegExp('\\{' + index + '\\}', 'g'), value);
});
if (variables) {
Object.keys(variables).forEach(key => {
message = message.replace(new RegExp('\\{' + key + '\\}', 'g'), variables[key]);
});
}
return message;
}

View File

@ -0,0 +1,67 @@
import * as nodemailer from "nodemailer";
import type SMTPTransport from "nodemailer/lib/smtp-transport";
import pool from "./database.js";
import type { RowDataPacket } from "mysql2";
export interface EmailMessage {
to: string | string[];
subject: string;
text?: string;
html?: string;
}
export async function createTransporter() {
const [host] = await pool.execute<RowDataPacket[]>(
"SELECT value FROM config WHERE name = 'smtp_host'"
);
const [port] = await pool.execute<RowDataPacket[]>(
"SELECT value FROM config WHERE name = 'smtp_port'"
);
const [secure] = await pool.execute<RowDataPacket[]>(
"SELECT value FROM config WHERE name = 'smtp_secure'"
);
const [user] = await pool.execute<RowDataPacket[]>(
"SELECT value FROM config WHERE name = 'smtp_user'"
);
const [password] = await pool.execute<RowDataPacket[]>(
"SELECT value FROM config WHERE name = 'smtp_password'"
);
const transporter = nodemailer.createTransport({
host: host[0].value,
port: Number(port[0].value),
secure: secure[0].value === "1" ? true : false,
auth: {
user: user[0].value,
pass: password[0].value,
},
debug: process.argv.includes("-tsx-develop"),
} as SMTPTransport.Options);
try {
await transporter.verify();
} catch (error) {
throw error;
}
return transporter;
}
export async function sendMail(message: EmailMessage): Promise<void> {
const [user] = await pool.execute<RowDataPacket[]>(
"SELECT value FROM config WHERE name = 'smtp_user'"
);
try {
const transporter = await createTransporter();
await transporter.sendMail({
from: user[0].value,
to: Array.isArray(message.to) ? message.to.join(",") : message.to,
subject: message.subject,
text: message.text,
html: message.html,
});
} catch (error) {
throw error;
}
}

View File

@ -0,0 +1,76 @@
import pool from "./database.js";
import type { RowDataPacket } from "mysql2";
import { sendMail } from "./mailer.js";
import Config from "../../config/peas.config.js";
import type InfoAPI from "../../types/api/info.js";
import { Locale } from "./locale.js";
async function createCode(maxAttempts = 10) {
for (let i = 0; i < maxAttempts; i++) {
const code = Math.floor(100000 + Math.random() * 900000).toString();
const [existingCodes] = await pool.execute<RowDataPacket[]>(
"SELECT * FROM mailverifiedcode WHERE code = ?",
[code],
);
if (existingCodes.length === 0) {
return code;
}
}
}
export default async function MailVerifySend(user: string, email: string, lang: string) {
const [userData] = await pool.execute<RowDataPacket[]>(
"SELECT * FROM mailverifiedcode WHERE user = ?",
[user],
);
if (user === undefined) {
return "user_not_found";
}
const code = await createCode();
const [existingCodes] = await pool.execute<RowDataPacket[]>(
"SELECT * FROM mailverifiedcode WHERE user = ?",
[user],
);
let row: RowDataPacket[] = [];
if (existingCodes.length !== 0) {
[row] = await pool.execute<RowDataPacket[]>(
"UPDATE `mailverifiedcode` SET `code` = ?, `time` = current_timestamp(3) WHERE `mailverifiedcode`.`user` = ?",
[code, user],
);
} else {
[row] = await pool.execute<RowDataPacket[]>(
"INSERT INTO `mailverifiedcode` (`code`, `user`, `email`, `time`, `num`) VALUES (?, ?, ?, current_timestamp(3), NULL);",
[code, user, email],
);
}
if ((row as RowDataPacket).affectedRows === 1) {
const infoReq = await fetch(`${Config.server.origin}/api/info`, {
method: "POST",
cache: "no-store",
});
if (!infoReq.ok) {
return "network_error";
}
const infoRes: InfoAPI = await infoReq.json();
await sendMail({
to: email,
subject: `${infoRes.server.name}】メール認証`,
text: Locale({ text: "mailverifyText", data: [
user,
infoRes.server.name,
`${Config.server.origin}/mailverify?code=${code}`
]}, lang),
});
return true;
} else {
return "save_failed";
}
}

View File

@ -0,0 +1,16 @@
import { fileURLToPath } from "url";
import { dirname } from "path";
const getDirname = (moduleUrl: string) => {
const basicDirname = dirname(
fileURLToPath(moduleUrl)
) + "/";
if (process.argv.includes("-tsx-develop")) {
return basicDirname;
} else {
return basicDirname + "../";
}
};
export { getDirname };

View File

@ -0,0 +1,3 @@
export default function toComponent(message: string) {
return <span dangerouslySetInnerHTML={{__html: message}} />;
}

View File

@ -0,0 +1,30 @@
import { randomBytes } from "crypto";
import pool from "./database.js";
import type { RowDataPacket } from "mysql2";
import type scope from "../../types/api/scope";
export async function createAPIToken(
user: string,
scope: scope[],
name: string,
) {
const token = randomBytes(32).toString("hex");
const [insert] = await pool.execute<RowDataPacket[]>(
"INSERT INTO `token` (`user`, `scope`, `name`, `token`) VALUES (?, ?, ?, ?);",
[user, scope.join(","), name, token],
);
return token;
}
export async function getScopeAPIToken(token: string) {
const [tokenData] = await pool.execute<RowDataPacket[]>(
"SELECT * FROM `token` WHERE token = ?",
[token]
);
const scope: scope[] = tokenData[0].scope.split(",");
return scope;
}

View File

@ -0,0 +1,27 @@
import pool from "./database.js";
import type { RowDataPacket } from "mysql2";
export async function isEnabled() {
const [isEnabled] = await pool.execute<RowDataPacket[]>(
"SELECT value FROM config WHERE name = 'turnstile_isEnabled'"
);
if (isEnabled[0].value === "1") {
return true;
} else {
return false;
}
}
export async function getKeys() {
if (!isEnabled()) {
return [];
}
const [Sitekey] = await pool.execute<RowDataPacket[]>(
"SELECT value FROM config WHERE name = 'turnstile_sitekey'"
);
const [Secret] = await pool.execute<RowDataPacket[]>(
"SELECT value FROM config WHERE name = 'turnstile_secret'"
);
return [Sitekey[0].value, Secret[0].value];
}

59
packages/web/src/main.ts Normal file
View File

@ -0,0 +1,59 @@
import { serve } from "@hono/node-server";
import { Hono } from "hono";
const app = new Hono();
// Checks
/// Locale Data Check
import { Check as LocaleDataCheck } from "./lib/locale.js";
LocaleDataCheck();
/// Database Connect Check
import { verify as DBCheck, option as DBOption } from "./lib/database.js";
DBCheck(DBOption(0));
// Middleware
/// Language
import { languageDetector } from "hono/language"
app.use(languageDetector({
lookupCookie: "lang",
caches: ["cookie"],
cookieOptions: {
path: "/",
sameSite: "Lax",
maxAge: 86400 * 365,
},
supportedLanguages: ["en", "ja"],
fallbackLanguage: "ja",
}))
// Routing
/// API
import API from "./routes/api/main.js";
app.route("/api", API);
/// Static
import { serveStatic } from "@hono/node-server/serve-static";
import { getDirname } from "./lib/path.js";
//// Hono/Public
app.use("*", serveStatic({
root: `${getDirname(import.meta.url)}../public`,
}));
//// Project Root/Files
app.use("*", serveStatic({
root: `${getDirname(import.meta.url)}../../../files`,
}));
/// Pages
import Pages from "./routes/pages/main.js";
app.route("/", Pages);
// Server
serve({
fetch: app.fetch,
port: 3000
}, (info) => {
console.log(`Server is running on http://${info.address}:${info.port}`);
});

View File

@ -0,0 +1,65 @@
import { Hono } from "hono";
import pool from "../../lib/database.js";
import type { RowDataPacket } from "mysql2";
import { readFileSync } from "fs";
import { getDirname } from "../../lib/path.js";
import { getKeys as TurnstileKeys, isEnabled as Turnstile } from "../../lib/turnstile.js";
const infoAPI = new Hono();
infoAPI.post("/", async (c) => {
const packageJson = JSON.parse(readFileSync(
`${getDirname(import.meta.url)}/../../../../../package.json`,
"utf-8"));
const version = packageJson.version;
const author = packageJson.author;
const contributors = packageJson.contributors;
const [IconURL] = await pool.execute<RowDataPacket[]>(
"SELECT value FROM config WHERE name = 'server_icon'"
);
const [serverName] = await pool.execute<RowDataPacket[]>(
"SELECT value FROM config WHERE name = 'server_name'"
);
const [serverDescription] = await pool.execute<RowDataPacket[]>(
"SELECT value FROM config WHERE name = 'server_description'"
);
const [users] = await pool.execute<RowDataPacket[]>(
"SELECT COUNT(*) AS count FROM users"
);
const [communitys] = await pool.execute<RowDataPacket[]>(
"SELECT COUNT(*) AS count FROM community"
);
return c.json({
success: true,
server: {
icon: IconURL[0].value,
name: serverName[0].value,
description: serverDescription[0].value,
},
botprotection: {
turnstile: await Turnstile(),
},
software: {
version: version,
name: "Peas",
author: author,
contributors: contributors,
},
counts: {
user: users[0].count,
community: communitys[0].count,
},
});
});
export default infoAPI;

View File

@ -0,0 +1,101 @@
import { Hono } from "hono";
import type { RowDataPacket } from "mysql2";
import pool from "../../lib/database.js";
import { getKeys as TurnstileKeys, isEnabled as Turnstile } from "../../lib/turnstile.js";
import { v4 as uuid } from "uuid";
import { getConnInfo } from "@hono/node-server/conninfo";
const Check = new Hono();
Check.post("/", async (c) => {
const body = await c.req.json();
if (body.code === undefined) {
return c.json({
success: false,
error: "input_lack",
}, 400);
}
if (
await Turnstile() &&
body.turnstile === undefined
) {
return c.json({
success: false,
error: "input_lack",
}, 400);
}
if (await Turnstile()) {
const sendData = new FormData();
const ip = getConnInfo(c).remote.address;
if (ip !== undefined) {
sendData.append("remoteip", ip);
}
sendData.append("secret", (await TurnstileKeys())[1]);
sendData.append("response", body.turnstile);
sendData.append("idempotency_key", uuid())
try {
const req = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
method: "POST",
cache: "no-store",
body: sendData,
});
if (!req.ok) {
return c.json({
success: false,
error: "turnstile_request_failed",
}, 500);
}
const res = await req.json();
if (res.success === false) {
return c.json({
success: false,
error: "turnstile_failed",
}, 400);
}
} catch (err) {
return c.json({
success: false,
error: "turnstile_request_failed",
}, 500);
}
}
const [rows] = await pool.execute<RowDataPacket[]>(
"SELECT * FROM mailverifiedcode WHERE code = ?",
[body.code],
);
if (rows.length === 0) {
return c.json({
success: false,
error: "code_not_found",
}, 400);
} else {
if (rows[0].code === body.code) {
await pool.query<RowDataPacket[]>(
"UPDATE `users` SET `mailverified` = 1 WHERE `users`.`id` = ?",
[rows[0].user],
);
await pool.query<RowDataPacket[]>(
"DELETE FROM `mailverifiedcode` WHERE (`user` = ?)",
[rows[0].user],
);
return c.json({
success: true,
});
} else {
return c.json({
success: false,
error: "code_invalid",
});
}
}
});
export default Check;

View File

@ -0,0 +1,20 @@
import { Hono } from "hono";
const API = new Hono();
import infoAPI from "./info.js";
API.route("/info", infoAPI);
import SignUpAPI from "./signup.js";
API.route("/signup", SignUpAPI);
import SignInAPI from "./signin.js";
API.route("/signin", SignInAPI);
import mailverifyAPI from "./mailverify.js";
API.route("/mailverify", mailverifyAPI);
import NotFound from "./notfound.js";
API.route("*", NotFound);
export default API;

View File

@ -0,0 +1,12 @@
import { Hono } from "hono";
const NotFound = new Hono();
NotFound.all("/", (c) => {
return c.json({
success: false,
error: "not_found",
}, 404);
});
export default NotFound;

View File

@ -0,0 +1,123 @@
import { Hono } from "hono";
import pool from "../../lib/database.js";
import type { RowDataPacket } from "mysql2";
import { getKeys as TurnstileKeys, isEnabled as Turnstile } from "../../lib/turnstile.js";
import { getConnInfo } from "@hono/node-server/conninfo";
import { compareSync as passwordHashCheck } from "bcrypt";
import { v4 as uuid } from "uuid";
import MailVerifySend from "../../lib/mailverify.js";
import { createAPIToken } from "src/lib/token.js";
import { UAParser } from "ua-parser-js";
import { setCookie } from "hono/cookie";
const SignInAPI = new Hono();
SignInAPI.post("/", async (c) => {
const body = await c.req.json();
if (
body.username === undefined ||
body.password === undefined
) {
return c.json({
success: false,
error: "input_lack",
}, 400);
}
if (
body.username.length < 3 ||
body.username.length > 15 ||
body.password.length < 8 ||
body.password.length > 15
) {
return c.json({
success: false,
error: "length_different",
}, 400);
}
if (
await Turnstile() &&
body.turnstile === undefined
) {
return c.json({
success: false,
error: "input_lack",
}, 400);
}
if (await Turnstile()) {
const sendData = new FormData();
const ip = getConnInfo(c).remote.address;
if (ip !== undefined) {
sendData.append("remoteip", ip);
}
sendData.append("secret", (await TurnstileKeys())[1]);
sendData.append("response", body.turnstile);
sendData.append("idempotency_key", uuid())
try {
const req = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
method: "POST",
cache: "no-store",
body: sendData,
});
if (!req.ok) {
return c.json({
success: false,
error: "turnstile_request_failed",
}, 500);
}
const res = await req.json();
if (res.success === false) {
return c.json({
success: false,
error: "turnstile_failed",
}, 400);
}
} catch (err) {
return c.json({
success: false,
error: "turnstile_request_failed",
}, 500);
}
}
const [user] = await pool.execute<RowDataPacket[]>(
"SELECT * FROM users WHERE id = ?",
[body.username],
);
if (user === undefined) {
return c.json({
success: false,
error: "user_not_found",
}, 400);
}
if (passwordHashCheck(body.password, user[0].password)) {
const token = await createAPIToken(
body.username,
["client"],
"Peas Client"
);
setCookie(c, "token", token, {
path: "/",
sameSite: "Lax",
maxAge: 86400 * 365,
});
return c.json({
success: true,
token: token,
});
} else {
return c.json({
success: false,
error: "password_invalid",
}, 400);
}
});
export default SignInAPI;

View File

@ -0,0 +1,135 @@
import { Hono } from "hono";
import pool from "../../lib/database.js";
import type { RowDataPacket } from "mysql2";
import { getKeys as TurnstileKeys, isEnabled as Turnstile } from "../../lib/turnstile.js";
import { getConnInfo } from "@hono/node-server/conninfo";
import { hash as generatePasswordHash } from "bcrypt";
import { v4 as uuid } from "uuid";
import MailVerifySend from "../../lib/mailverify.js";
const SignUpAPI = new Hono();
SignUpAPI.post("/", async (c) => {
const body = await c.req.json();
if (
body.username === undefined ||
body.email === undefined ||
body.password === undefined
) {
return c.json({
success: false,
error: "input_lack",
}, 400);
}
if (
body.username.length < 3 ||
body.username.length > 15 ||
body.password.length < 8 ||
body.password.length > 15
) {
return c.json({
success: false,
error: "length_different",
}, 400);
}
if (
await Turnstile() &&
body.turnstile === undefined
) {
return c.json({
success: false,
error: "input_lack",
}, 400);
}
if (await Turnstile()) {
const sendData = new FormData();
const ip = getConnInfo(c).remote.address;
if (ip !== undefined) {
sendData.append("remoteip", ip);
}
sendData.append("secret", (await TurnstileKeys())[1]);
sendData.append("response", body.turnstile);
sendData.append("idempotency_key", uuid())
try {
const req = await fetch("https://challenges.cloudflare.com/turnstile/v0/siteverify", {
method: "POST",
cache: "no-store",
body: sendData,
});
if (!req.ok) {
return c.json({
success: false,
error: "turnstile_request_failed",
}, 500);
}
const res = await req.json();
if (res.success === false) {
return c.json({
success: false,
error: "turnstile_failed",
}, 400);
}
} catch (err) {
return c.json({
success: false,
error: "turnstile_request_failed",
}, 500);
}
}
const [duplicateUsers] = await pool.execute<RowDataPacket[]>(
"SELECT * FROM users WHERE id = ?",
[body.username],
);
const [duplicateEmails] = await pool.execute<RowDataPacket[]>(
"SELECT * FROM users WHERE email = ?",
[body.email],
);
if (duplicateUsers.length > 0) {
return c.json({
success: false,
error: "username_duplicate",
}, 400);
}
if (duplicateEmails.length > 0) {
return c.json({
success: false,
error: "email_duplicate",
}, 400);
}
const passwordHash = await generatePasswordHash(body.password, 10);
const [result] = await pool.execute<RowDataPacket[]>(
"INSERT INTO `users` (`id`, `password`, `email`, `mailverified`, `time`) VALUES (?, ?, ?, '0', current_timestamp(3))",
[body.username, passwordHash, body.email],
);
if ((result as RowDataPacket).affectedRows === 1) {
const send = await MailVerifySend(body.username, body.email, c.get("language"));
if (send === true) {
return c.json({
success: true,
}, 201);
} else {
return c.json({
success: false,
error: send,
}, 500);
}
} else {
return c.json({
success: false,
error: "account_create_failed",
}, 500);
}
});
export default SignUpAPI;

View File

@ -0,0 +1,33 @@
import { Hono } from "hono";
import { Layout } from "./layout.js";
import type InfoAPI from "../../../types/api/info";
import Config from "../../../config/peas.config.js";
import { getCookie } from "hono/cookie";
import { Locale } from "../../lib/locale.js";
const Home = new Hono();
Home.get("/", async (c) => {
if (getCookie(c, "token") === undefined) return c.redirect("/signin");
const InfoReq = await fetch(`${Config.server.origin}/api/info`, {
method: "POST",
cache: "no-store",
});
if (!InfoReq.ok) {
return c.status(500);
}
const InfoRes: InfoAPI = await InfoReq.json();
return c.html(
<Layout
title={`${Locale({ text: "Home" }, c.get("language"))} - ${InfoRes.server.name}`}
noindex
>
</Layout>
);
})
export default Home;

View File

@ -0,0 +1,155 @@
import { Hono } from "hono";
import { Layout } from "./layout.js";
import type InfoAPI from "../../../types/api/info";
import Config from "../../../config/peas.config.js";
import Icon from "../../components/iconify.js";
import { Locale } from "../../lib/locale.js";
import toComponent from "../../lib/toComponent.js";
import { getCookie } from "hono/cookie";
import { getScopeAPIToken } from "../../lib/token.js";
const Index = new Hono();
Index.get("/", async (c) => {
if (getCookie(c, "token") !== undefined) {
const scope = await getScopeAPIToken(getCookie(c, "token") ?? "");
if (scope !== undefined) return c.redirect("/home");
}
const InfoReq = await fetch(`${Config.server.origin}/api/info`, {
method: "POST",
cache: "no-store",
});
if (!InfoReq.ok) {
return c.status(500);
}
const InfoRes: InfoAPI = await InfoReq.json();
return c.html(
<Layout
title={InfoRes.server.name}
description={InfoRes.server.description}
>
<main class={`
pt-5
`}>
<h1 class={`
text-3xl
font-bold
flex
items-center
justify-center
`}>
<img
src={InfoRes.server.icon}
width={30}
height={30}
class={`
rounded-[30%]
align-middle
`}
/>
{InfoRes.server.name}
</h1>
<div class={`
card
card-body
bg-base-100
w-100
shadow-sm
m-auto
mt-2
`}>
<h2 class="card-title">
<Icon icon="ix:about-filled" />
{Locale({ text: "PeasAboutTitle" }, c.get("language"))}
</h2>
{toComponent(Locale({ text: "PeasAboutText" }, c.get("language")))}
</div>
<div class={`
card
card-body
bg-base-100
w-100
shadow-sm
m-auto
mt-2
`}>
<h2 class="card-title">
<Icon icon="ix:about-filled" />
{Locale({ text: "ServerAboutTitle" }, c.get("language"))}
</h2>
{toComponent(InfoRes.server.description)}
<div className={`
w-fit
flex
justify-center
gap-[2rem]
pr-[20px]
pl-[20px]
m-auto
mt-[1rem]
mb-[1rem]
rounded-[5px]
border-solid
border-white
border
text-center
`}>
<div class={`
w-fit
m-0
`}>
{Locale({ text: "userCount" }, c.get("language"))}
<br />
{InfoRes.counts.user}
</div>
<div class={`
w-fit
m-0
`}>
{Locale({ text: "communityCount" }, c.get("language"))}
<br />
{InfoRes.counts.community}
</div>
</div>
{Locale({ text: "topPeasCredit", data: [InfoRes.software.version] }, c.get("language"))}
</div>
<div class={`
text-center
mt-2
`}>
<a
class="btn"
href="/signup"
>
{Locale({ text: "Start" }, c.get("language"))}
</a>
<p class={`
mt-1
mb-1
`}>
{Locale({ text: "or" }, c.get("language"))}
</p>
<a
class="btn"
href="/signin"
>
{Locale({ text: "Signin" }, c.get("language"))}
</a>
</div>
</main>
</Layout>
);
});
export default Index;

View File

@ -0,0 +1,73 @@
import type { FC } from "hono/jsx";
import Config from "../../../config/peas.config.js";
import type InfoAPI from "../../../types/api/info";
interface Option {
title: string;
description?: string;
noindex?: boolean;
noai?: boolean;
noimageai?: boolean;
children?: any;
};
const Layout: FC<Option> = async (props: Option) => {
const InfoReq = await fetch(`${Config.server.origin}/api/info`, {
method: "POST",
cache: "no-store",
});
if (!InfoReq.ok) {
return (
<></>
);
}
const InfoRes: InfoAPI = await InfoReq.json();
const noai = props.noai ?? false;
const noimageai = props.noimageai ?? false;
return (
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
{props.description && (
<meta name="description" content={props.description} />
)}
{props.noindex && (
<meta name="robots" content="none" />
)}
{props.noai && (
<meta name="robots" content="noai" />
)}
{props.noimageai && (
<meta name="robots" content="noimageai" />
)}
<title>{props.title}</title>
<link rel="icon" href={InfoRes.server.icon} />
<link rel="stylesheet" href="/tailwind.css" />
</head>
<body class={`
bg-white
text-black
dark:bg-zinc-800
dark:text-white
w-full
h-full
`}>
{props.children}
</body>
{/*
==Warning==
This hidden tag is there to allow TailwindCSS and DaisyUI
to detect classes that are only used for i18n,
so please do not remove it.
*/}
<span class="hidden link"></span>
</html>
)
}
export { Layout };

View File

@ -0,0 +1,141 @@
import { Hono } from "hono";
import Config from "../../../config/peas.config.js";
import type InfoAPI from "../../../types/api/info";
import { Layout } from "./layout.js";
import { Locale } from "../../lib/locale.js";
import Icon from "../../components/iconify.js";
import { getKeys as getTurnstileKeys, isEnabled as TurnstileIsEnabled } from "../../lib/turnstile.js";
import Turnstile from "../../components/turnstile.js";
import { html } from "hono/html";
import { getCookie } from "hono/cookie";
import { getScopeAPIToken } from "../../lib/token.js";
const MailVerify = new Hono();
MailVerify.get("/", async (c) => {
if (getCookie(c, "token") !== undefined) {
const scope = await getScopeAPIToken(getCookie(c, "token") ?? "");
if (scope !== undefined) return c.redirect("/home");
}
const InfoReq = await fetch(`${Config.server.origin}/api/info`, {
method: "POST",
cache: "no-store",
});
if (!InfoReq.ok) {
return c.status(500);
}
const InfoRes: InfoAPI = await InfoReq.json();
let code = "";
if (c.req.query("code") !== undefined) {
code = c.req.query("code") ?? "";
}
const TurnstileEnabled = await TurnstileIsEnabled();
let TurnstileKeys: Array<string> = [];
if (TurnstileEnabled) {
TurnstileKeys = await getTurnstileKeys();
}
let error = false;
let errorMsg = "";
if (c.req.query("error") !== undefined) {
error = true;
errorMsg = Locale({ text: `ERR_${c.req.query("error")}` }, c.get("language"));
}
return c.html(
<Layout
title={`${Locale({ text: "mailVerify" }, c.get("language"))} - ${InfoRes.server.name}`}
noindex
>
<main class="text-center">
{error && (
<div
role="alert"
class="alert alert-error w-fit m-auto"
>
<Icon
icon="mdi:alert-circle-outline"
color="#ff0000"
/>
<span>{errorMsg}</span>
</div>
)}
<h1 class={`
text-3xl
font-bold
flex
items-center
justify-center
`}>
<Icon
icon="material-symbols:mail-shield"
class="w-4"
/>
{Locale({ text: "mailVerify" }, c.get("language"))}
</h1>
<form
class={`
flex
flex-col
items-center
justify-center
`}
id="form"
>
<div>
<label class={`
input
w-70
m-3
`}>
<span class="label">
<Icon icon="material-symbols:barcode" />
</span>
<input
type="text"
placeholder="123456"
minlength={6}
maxlength={6}
value={code}
name="code"
required
/>
</label>
</div>
{TurnstileEnabled && (
<Turnstile
sitekey={TurnstileKeys[0]}
button={html`[name='submit']`}
/>
)}
<button
class={`
btn
btn-primary
m-3
`}
type="submit"
name="submit"
disabled
>
<Icon icon="material-symbols:barcode" />
{Locale({ text: "submit" }, c.get("language"))}
</button>
</form>
</main>
<script src="/js/mailverify.js" />
</Layout>
);
});
export default MailVerify;

View File

@ -0,0 +1,23 @@
import { Hono } from "hono";
const Pages = new Hono();
import Index from "./index.js";
Pages.route("/", Index);
import Home from "./home.js";
Pages.route("/home", Home);
import SignUp from "./signup.js";
Pages.route("/signup", SignUp);
import SignIn from "./signin.js";
Pages.route("/signin", SignIn);
import MailVerify from "./mailverify.js";
Pages.route("/mailverify", MailVerify);
import NotFound from "./notfound.js";
Pages.route("*", NotFound);
export default Pages;

View File

@ -0,0 +1,33 @@
import { Hono } from "hono";
import Config from "../../../config/peas.config.js";
import Icon from "../../components/iconify.js";
import type InfoAPI from "../../../types/api/info.js";
import { Layout } from "./layout.js";
import { Locale } from "../../lib/locale.js";
const NotFound = new Hono();
NotFound.get("/", async (c) => {
const InfoReq = await fetch(`${Config.server.origin}/api/info`, {
method: "POST",
cache: "no-store",
});
if (!InfoReq.ok) {
return c.body("Internal Server Error", 500);
}
const InfoRes: InfoAPI = await InfoReq.json();
c.status(404);
return c.html(
<Layout
title={`${Locale({ text: "notFound" }, c.get("language"))} - ${InfoRes.server.name}`}
noindex
>
<Icon icon="line-md:question-circle" />
</Layout>
);
});
export default NotFound;

View File

@ -0,0 +1,165 @@
import { Hono } from "hono";
import { Layout } from "./layout.js";
import type InfoAPI from "../../../types/api/info";
import Config from "../../../config/peas.config.js";
import { Locale } from "../../lib/locale.js";
import Icon from "../../components/iconify.js";
import Help from "../../components/help.js";
import Turnstile from "../../components/turnstile.js";
import { getKeys as getTurnstileKeys, isEnabled as TurnstileIsEnabled } from "../../lib/turnstile.js";
import { html } from "hono/html";
import { getCookie } from "hono/cookie";
import { getScopeAPIToken } from "../../lib/token.js";
const SignIn = new Hono();
SignIn.get("/", async (c) => {
if (getCookie(c, "token") !== undefined) {
const scope = await getScopeAPIToken(getCookie(c, "token") ?? "");
if (scope !== undefined) return c.redirect("/home");
}
const InfoReq = await fetch(`${Config.server.origin}/api/info`, {
method: "POST",
cache: "no-store",
});
if (!InfoReq.ok) {
return c.status(500);
}
const InfoRes: InfoAPI = await InfoReq.json();
const TurnstileEnabled = await TurnstileIsEnabled();
let TurnstileKeys: Array<string> = [];
if (TurnstileEnabled) {
TurnstileKeys = await getTurnstileKeys();
}
let error = false;
let errorMsg = "";
if (c.req.query("error") !== undefined) {
error = true;
errorMsg = Locale({ text: `ERR_${c.req.query("error")}` }, c.get("language"));
}
return c.html(
<Layout
title={`${Locale({ text: "Signin" }, c.get("language"))} - ${InfoRes.server.name}`}
noindex
>
<main class="text-center">
{error && (
<div
role="alert"
class="alert alert-error w-fit m-auto"
>
<Icon
icon="mdi:alert-circle-outline"
color="#ff0000"
/>
<span>{errorMsg}</span>
</div>
)}
<h1 class={`
text-3xl
font-bold
flex
items-center
justify-center
`}>
<Icon
icon="material-symbols:power-settings-new"
class="w-4"
/>
{Locale({ text: "Signin" }, c.get("language"))}
</h1>
<form
class={`
flex
flex-col
items-center
justify-center
`}
id="form"
>
<div>
<label class={`
input
w-70
m-3
`}>
<span class="label">@</span>
<input
type="text"
placeholder={Locale({ text: "username" }, c.get("language"))}
minlength={3}
maxlength={20}
name="username"
required
/>
</label>
<Help
text={Locale({ text: "idHelp" }, c.get("language"))}
lang={c.get("language")}
/>
</div>
<div>
<label class={`
input
w-70
m-3
`}>
<span class="label">
<Icon
icon="material-symbols:key-rounded"
color="#A4A7A9"
/>
</span>
<input
type="password"
placeholder={Locale({ text: "password" }, c.get("language"))}
name="password"
minlength={8}
maxlength={15}
required
/>
</label>
<Help
text={Locale({ text: "passwordHelp" }, c.get("language"))}
lang={c.get("language")}
/>
</div>
{TurnstileEnabled && (
<Turnstile
sitekey={TurnstileKeys[0]}
button={html`[name='submit']`}
/>
)}
<button
class={`
btn
btn-primary
m-3
`}
type="submit"
name="submit"
disabled
>
<Icon icon="material-symbols:power-settings-new" />
{Locale({ text: "Signin" }, c.get("language"))}
</button>
</form>
</main>
<script src="/js/signin.js" />
</Layout>
);
});
export default SignIn;

View File

@ -0,0 +1,201 @@
import { Hono } from "hono";
import { Layout } from "./layout.js";
import type InfoAPI from "../../../types/api/info";
import Config from "../../../config/peas.config.js";
import { Locale } from "../../lib/locale.js";
import Icon from "../../components/iconify.js";
import Help from "../../components/help.js";
import toComponent from "../../lib/toComponent.js";
import Turnstile from "../../components/turnstile.js";
import { getKeys as getTurnstileKeys, isEnabled as TurnstileIsEnabled } from "../../lib/turnstile.js";
import { html } from "hono/html";
import { getCookie } from "hono/cookie";
import { getScopeAPIToken } from "../../lib/token.js";
const SignUp = new Hono();
SignUp.get("/", async (c) => {
if (getCookie(c, "token") !== undefined) {
const scope = await getScopeAPIToken(getCookie(c, "token") ?? "");
if (scope !== undefined) return c.redirect("/home");
}
const InfoReq = await fetch(`${Config.server.origin}/api/info`, {
method: "POST",
cache: "no-store",
});
if (!InfoReq.ok) {
return c.status(500);
}
const InfoRes: InfoAPI = await InfoReq.json();
const TurnstileEnabled = await TurnstileIsEnabled();
let TurnstileKeys: Array<string> = [];
if (TurnstileEnabled) {
TurnstileKeys = await getTurnstileKeys();
}
let error = false;
let errorMsg = "";
if (c.req.query("error") !== undefined) {
error = true;
errorMsg = Locale({ text: `ERR_${c.req.query("error")}` }, c.get("language"));
}
return c.html(
<Layout
title={`${Locale({ text: "Signup" }, c.get("language"))} - ${InfoRes.server.name}`}
noindex
>
<main class="text-center">
{error && (
<div
role="alert"
class="alert alert-error w-fit m-auto"
>
<Icon
icon="mdi:alert-circle-outline"
color="#ff0000"
/>
<span>{errorMsg}</span>
</div>
)}
<h1 class={`
text-3xl
font-bold
flex
items-center
justify-center
`}>
<Icon
icon="material-symbols:person-add-rounded"
class="w-4"
/>
{Locale({ text: "Signup" }, c.get("language"))}
</h1>
<form
class={`
flex
flex-col
items-center
justify-center
`}
id="form"
>
<div>
<label class={`
input
w-70
m-3
`}>
<span class="label">@</span>
<input
type="text"
placeholder={Locale({ text: "username" }, c.get("language"))}
name="username"
minlength={3}
maxlength={20}
required
/>
</label>
<Help
text={Locale({ text: "idHelp" }, c.get("language"))}
lang={c.get("language")}
/>
</div>
<div>
<label class={`
input
w-70
m-3
`}>
<span class="label">
<Icon
icon="material-symbols:mail"
color="#A4A7A9"
/>
</span>
<input
type="email"
placeholder="info@mail.example.com"
name="email"
required
/>
</label>
<Help
text={Locale({ text: "mailHelp" }, c.get("language"))}
lang={c.get("language")}
/>
</div>
<div>
<label class={`
input
w-70
m-3
`}>
<span class="label">
<Icon
icon="material-symbols:key-rounded"
color="#A4A7A9"
/>
</span>
<input
type="password"
placeholder={Locale({ text: "password" }, c.get("language"))}
name="password"
minlength={8}
maxlength={15}
required
/>
</label>
<Help
text={Locale({ text: "passwordHelp" }, c.get("language"))}
lang={c.get("language")}
/>
</div>
<label class="label mb-3">
<input
type="checkbox"
class="checkbox"
name="agreeRule"
required
/>
{toComponent(Locale({ text: "agreeRule" }, c.get("language")))}
</label>
{TurnstileEnabled && (
<Turnstile
sitekey={TurnstileKeys[0]}
button={html`[name='submit']`}
/>
)}
<button
class={`
btn
btn-primary
m-3
`}
type="submit"
name="submit"
disabled
>
<Icon icon="material-symbols:person-add-rounded" />
{Locale({ text: "Signup" }, c.get("language"))}
</button>
</form>
</main>
<script src="/js/signup.js" />
</Layout>
);
});
export default SignUp;

View File

@ -0,0 +1,15 @@
import type { Config } from "tailwindcss";
const config: Config = {
content: [
'./pages/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
'./app/**/*.{js,ts,jsx,tsx,mdx}'
],
theme: {
extend: {},
},
plugins: [],
};
export default config;

View File

@ -0,0 +1,2 @@
@import "tailwindcss";
@plugin "daisyui";

View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2024",
"module": "ESNext",
"moduleResolution": "bundler",
"outDir": "./dist",
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx",
"esModuleInterop": true,
"verbatimModuleSyntax": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"baseUrl": "./",
"types": [
"node"
],
"typeRoots": [
"./node_modules/@types",
],
"removeComments": true,
},
"exclude": ["node_modules"],
}

31
packages/web/types/api/info.d.ts vendored Normal file
View File

@ -0,0 +1,31 @@
interface developer {
name: string;
email: string;
url: string;
}
export default interface InfoAPI {
success: true;
server: {
icon: string;
name: string;
description: string;
};
botprotection: {
turnstile: boolean;
};
software: {
version: string;
name: string;
author: developer;
contributors: developer[];
};
counts: {
user: number;
community: number;
};
}

5
packages/web/types/api/scope.d.ts vendored Normal file
View File

@ -0,0 +1,5 @@
type scope =
"read:user" |
"client";
export default scope;

506
pnpm-lock.yaml Normal file
View File

@ -0,0 +1,506 @@
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
dependencies:
'@inquirer/prompts':
specifier: ^7.8.6
version: 7.8.6(@types/node@24.5.2)
'@types/node':
specifier: ^24.5.2
version: 24.5.2
fs:
specifier: 0.0.1-security
version: 0.0.1-security
mysql2:
specifier: ^3.15.1
version: 3.15.1
typescript:
specifier: ^5.9.2
version: 5.9.2
yaml:
specifier: ^2.8.1
version: 2.8.1
packages:
'@inquirer/ansi@1.0.0':
resolution: {integrity: sha512-JWaTfCxI1eTmJ1BIv86vUfjVatOdxwD0DAVKYevY8SazeUUZtW+tNbsdejVO1GYE0GXJW1N1ahmiC3TFd+7wZA==}
engines: {node: '>=18'}
'@inquirer/checkbox@4.2.4':
resolution: {integrity: sha512-2n9Vgf4HSciFq8ttKXk+qy+GsyTXPV1An6QAwe/8bkbbqvG4VW1I/ZY1pNu2rf+h9bdzMLPbRSfcNxkHBy/Ydw==}
engines: {node: '>=18'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/confirm@5.1.18':
resolution: {integrity: sha512-MilmWOzHa3Ks11tzvuAmFoAd/wRuaP3SwlT1IZhyMke31FKLxPiuDWcGXhU+PKveNOpAc4axzAgrgxuIJJRmLw==}
engines: {node: '>=18'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/core@10.2.2':
resolution: {integrity: sha512-yXq/4QUnk4sHMtmbd7irwiepjB8jXU0kkFRL4nr/aDBA2mDz13cMakEWdDwX3eSCTkk03kwcndD1zfRAIlELxA==}
engines: {node: '>=18'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/editor@4.2.20':
resolution: {integrity: sha512-7omh5y5bK672Q+Brk4HBbnHNowOZwrb/78IFXdrEB9PfdxL3GudQyDk8O9vQ188wj3xrEebS2M9n18BjJoI83g==}
engines: {node: '>=18'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/expand@4.0.20':
resolution: {integrity: sha512-Dt9S+6qUg94fEvgn54F2Syf0Z3U8xmnBI9ATq2f5h9xt09fs2IJXSCIXyyVHwvggKWFXEY/7jATRo2K6Dkn6Ow==}
engines: {node: '>=18'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/external-editor@1.0.2':
resolution: {integrity: sha512-yy9cOoBnx58TlsPrIxauKIFQTiyH+0MK4e97y4sV9ERbI+zDxw7i2hxHLCIEGIE/8PPvDxGhgzIOTSOWcs6/MQ==}
engines: {node: '>=18'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/figures@1.0.13':
resolution: {integrity: sha512-lGPVU3yO9ZNqA7vTYz26jny41lE7yoQansmqdMLBEfqaGsmdg7V3W9mK9Pvb5IL4EVZ9GnSDGMO/cJXud5dMaw==}
engines: {node: '>=18'}
'@inquirer/input@4.2.4':
resolution: {integrity: sha512-cwSGpLBMwpwcZZsc6s1gThm0J+it/KIJ+1qFL2euLmSKUMGumJ5TcbMgxEjMjNHRGadouIYbiIgruKoDZk7klw==}
engines: {node: '>=18'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/number@3.0.20':
resolution: {integrity: sha512-bbooay64VD1Z6uMfNehED2A2YOPHSJnQLs9/4WNiV/EK+vXczf/R988itL2XLDGTgmhMF2KkiWZo+iEZmc4jqg==}
engines: {node: '>=18'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/password@4.0.20':
resolution: {integrity: sha512-nxSaPV2cPvvoOmRygQR+h0B+Av73B01cqYLcr7NXcGXhbmsYfUb8fDdw2Us1bI2YsX+VvY7I7upgFYsyf8+Nug==}
engines: {node: '>=18'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/prompts@7.8.6':
resolution: {integrity: sha512-68JhkiojicX9SBUD8FE/pSKbOKtwoyaVj1kwqLfvjlVXZvOy3iaSWX4dCLsZyYx/5Ur07Fq+yuDNOen+5ce6ig==}
engines: {node: '>=18'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/rawlist@4.1.8':
resolution: {integrity: sha512-CQ2VkIASbgI2PxdzlkeeieLRmniaUU1Aoi5ggEdm6BIyqopE9GuDXdDOj9XiwOqK5qm72oI2i6J+Gnjaa26ejg==}
engines: {node: '>=18'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/search@3.1.3':
resolution: {integrity: sha512-D5T6ioybJJH0IiSUK/JXcoRrrm8sXwzrVMjibuPs+AgxmogKslaafy1oxFiorNI4s3ElSkeQZbhYQgLqiL8h6Q==}
engines: {node: '>=18'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/select@4.3.4':
resolution: {integrity: sha512-Qp20nySRmfbuJBBsgPU7E/cL62Hf250vMZRzYDcBHty2zdD1kKCnoDFWRr0WO2ZzaXp3R7a4esaVGJUx0E6zvA==}
engines: {node: '>=18'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/type@3.0.8':
resolution: {integrity: sha512-lg9Whz8onIHRthWaN1Q9EGLa/0LFJjyM8mEUbL1eTi6yMGvBf8gvyDLtxSXztQsxMvhxxNpJYrwa1YHdq+w4Jw==}
engines: {node: '>=18'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@types/node@24.5.2':
resolution: {integrity: sha512-FYxk1I7wPv3K2XBaoyH2cTnocQEu8AOZ60hPbsyukMPLv5/5qr7V1i8PLHdl6Zf87I+xZXFvPCXYjiTFq+YSDQ==}
ansi-regex@5.0.1:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
ansi-styles@4.3.0:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
aws-ssl-profiles@1.1.2:
resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==}
engines: {node: '>= 6.0.0'}
chardet@2.1.0:
resolution: {integrity: sha512-bNFETTG/pM5ryzQ9Ad0lJOTa6HWD/YsScAR3EnCPZRPlQh77JocYktSHOUHelyhm8IARL+o4c4F1bP5KVOjiRA==}
cli-width@4.1.0:
resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==}
engines: {node: '>= 12'}
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
denque@2.1.0:
resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==}
engines: {node: '>=0.10'}
emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
fs@0.0.1-security:
resolution: {integrity: sha512-3XY9e1pP0CVEUCdj5BmfIZxRBTSDycnbqhIOGec9QYtmVH2fbLpj86CFWkrNOkt/Fvty4KZG5lTglL9j/gJ87w==}
generate-function@2.3.1:
resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==}
iconv-lite@0.7.0:
resolution: {integrity: sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==}
engines: {node: '>=0.10.0'}
is-fullwidth-code-point@3.0.0:
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
engines: {node: '>=8'}
is-property@1.0.2:
resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==}
long@5.3.2:
resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==}
lru-cache@7.18.3:
resolution: {integrity: sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA==}
engines: {node: '>=12'}
lru.min@1.1.2:
resolution: {integrity: sha512-Nv9KddBcQSlQopmBHXSsZVY5xsdlZkdH/Iey0BlcBYggMd4two7cZnKOK9vmy3nY0O5RGH99z1PCeTpPqszUYg==}
engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'}
mute-stream@2.0.0:
resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==}
engines: {node: ^18.17.0 || >=20.5.0}
mysql2@3.15.1:
resolution: {integrity: sha512-WZMIRZstT2MFfouEaDz/AGFnGi1A2GwaDe7XvKTdRJEYiAHbOrh4S3d8KFmQeh11U85G+BFjIvS1Di5alusZsw==}
engines: {node: '>= 8.0'}
named-placeholders@1.1.3:
resolution: {integrity: sha512-eLoBxg6wE/rZkJPhU/xRX1WTpkFEwDJEN96oxFrTsqBdbT5ec295Q+CoHrL9IT0DipqKhmGcaZmwOt8OON5x1w==}
engines: {node: '>=12.0.0'}
safer-buffer@2.1.2:
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
seq-queue@0.0.5:
resolution: {integrity: sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==}
signal-exit@4.1.0:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'}
sqlstring@2.3.3:
resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==}
engines: {node: '>= 0.6'}
string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
strip-ansi@6.0.1:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'}
typescript@5.9.2:
resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==}
engines: {node: '>=14.17'}
hasBin: true
undici-types@7.12.0:
resolution: {integrity: sha512-goOacqME2GYyOZZfb5Lgtu+1IDmAlAEu5xnD3+xTzS10hT0vzpf0SPjkXwAw9Jm+4n/mQGDP3LO8CPbYROeBfQ==}
wrap-ansi@6.2.0:
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
engines: {node: '>=8'}
yaml@2.8.1:
resolution: {integrity: sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw==}
engines: {node: '>= 14.6'}
hasBin: true
yoctocolors-cjs@2.1.3:
resolution: {integrity: sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==}
engines: {node: '>=18'}
snapshots:
'@inquirer/ansi@1.0.0': {}
'@inquirer/checkbox@4.2.4(@types/node@24.5.2)':
dependencies:
'@inquirer/ansi': 1.0.0
'@inquirer/core': 10.2.2(@types/node@24.5.2)
'@inquirer/figures': 1.0.13
'@inquirer/type': 3.0.8(@types/node@24.5.2)
yoctocolors-cjs: 2.1.3
optionalDependencies:
'@types/node': 24.5.2
'@inquirer/confirm@5.1.18(@types/node@24.5.2)':
dependencies:
'@inquirer/core': 10.2.2(@types/node@24.5.2)
'@inquirer/type': 3.0.8(@types/node@24.5.2)
optionalDependencies:
'@types/node': 24.5.2
'@inquirer/core@10.2.2(@types/node@24.5.2)':
dependencies:
'@inquirer/ansi': 1.0.0
'@inquirer/figures': 1.0.13
'@inquirer/type': 3.0.8(@types/node@24.5.2)
cli-width: 4.1.0
mute-stream: 2.0.0
signal-exit: 4.1.0
wrap-ansi: 6.2.0
yoctocolors-cjs: 2.1.3
optionalDependencies:
'@types/node': 24.5.2
'@inquirer/editor@4.2.20(@types/node@24.5.2)':
dependencies:
'@inquirer/core': 10.2.2(@types/node@24.5.2)
'@inquirer/external-editor': 1.0.2(@types/node@24.5.2)
'@inquirer/type': 3.0.8(@types/node@24.5.2)
optionalDependencies:
'@types/node': 24.5.2
'@inquirer/expand@4.0.20(@types/node@24.5.2)':
dependencies:
'@inquirer/core': 10.2.2(@types/node@24.5.2)
'@inquirer/type': 3.0.8(@types/node@24.5.2)
yoctocolors-cjs: 2.1.3
optionalDependencies:
'@types/node': 24.5.2
'@inquirer/external-editor@1.0.2(@types/node@24.5.2)':
dependencies:
chardet: 2.1.0
iconv-lite: 0.7.0
optionalDependencies:
'@types/node': 24.5.2
'@inquirer/figures@1.0.13': {}
'@inquirer/input@4.2.4(@types/node@24.5.2)':
dependencies:
'@inquirer/core': 10.2.2(@types/node@24.5.2)
'@inquirer/type': 3.0.8(@types/node@24.5.2)
optionalDependencies:
'@types/node': 24.5.2
'@inquirer/number@3.0.20(@types/node@24.5.2)':
dependencies:
'@inquirer/core': 10.2.2(@types/node@24.5.2)
'@inquirer/type': 3.0.8(@types/node@24.5.2)
optionalDependencies:
'@types/node': 24.5.2
'@inquirer/password@4.0.20(@types/node@24.5.2)':
dependencies:
'@inquirer/ansi': 1.0.0
'@inquirer/core': 10.2.2(@types/node@24.5.2)
'@inquirer/type': 3.0.8(@types/node@24.5.2)
optionalDependencies:
'@types/node': 24.5.2
'@inquirer/prompts@7.8.6(@types/node@24.5.2)':
dependencies:
'@inquirer/checkbox': 4.2.4(@types/node@24.5.2)
'@inquirer/confirm': 5.1.18(@types/node@24.5.2)
'@inquirer/editor': 4.2.20(@types/node@24.5.2)
'@inquirer/expand': 4.0.20(@types/node@24.5.2)
'@inquirer/input': 4.2.4(@types/node@24.5.2)
'@inquirer/number': 3.0.20(@types/node@24.5.2)
'@inquirer/password': 4.0.20(@types/node@24.5.2)
'@inquirer/rawlist': 4.1.8(@types/node@24.5.2)
'@inquirer/search': 3.1.3(@types/node@24.5.2)
'@inquirer/select': 4.3.4(@types/node@24.5.2)
optionalDependencies:
'@types/node': 24.5.2
'@inquirer/rawlist@4.1.8(@types/node@24.5.2)':
dependencies:
'@inquirer/core': 10.2.2(@types/node@24.5.2)
'@inquirer/type': 3.0.8(@types/node@24.5.2)
yoctocolors-cjs: 2.1.3
optionalDependencies:
'@types/node': 24.5.2
'@inquirer/search@3.1.3(@types/node@24.5.2)':
dependencies:
'@inquirer/core': 10.2.2(@types/node@24.5.2)
'@inquirer/figures': 1.0.13
'@inquirer/type': 3.0.8(@types/node@24.5.2)
yoctocolors-cjs: 2.1.3
optionalDependencies:
'@types/node': 24.5.2
'@inquirer/select@4.3.4(@types/node@24.5.2)':
dependencies:
'@inquirer/ansi': 1.0.0
'@inquirer/core': 10.2.2(@types/node@24.5.2)
'@inquirer/figures': 1.0.13
'@inquirer/type': 3.0.8(@types/node@24.5.2)
yoctocolors-cjs: 2.1.3
optionalDependencies:
'@types/node': 24.5.2
'@inquirer/type@3.0.8(@types/node@24.5.2)':
optionalDependencies:
'@types/node': 24.5.2
'@types/node@24.5.2':
dependencies:
undici-types: 7.12.0
ansi-regex@5.0.1: {}
ansi-styles@4.3.0:
dependencies:
color-convert: 2.0.1
aws-ssl-profiles@1.1.2: {}
chardet@2.1.0: {}
cli-width@4.1.0: {}
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
color-name@1.1.4: {}
denque@2.1.0: {}
emoji-regex@8.0.0: {}
fs@0.0.1-security: {}
generate-function@2.3.1:
dependencies:
is-property: 1.0.2
iconv-lite@0.7.0:
dependencies:
safer-buffer: 2.1.2
is-fullwidth-code-point@3.0.0: {}
is-property@1.0.2: {}
long@5.3.2: {}
lru-cache@7.18.3: {}
lru.min@1.1.2: {}
mute-stream@2.0.0: {}
mysql2@3.15.1:
dependencies:
aws-ssl-profiles: 1.1.2
denque: 2.1.0
generate-function: 2.3.1
iconv-lite: 0.7.0
long: 5.3.2
lru.min: 1.1.2
named-placeholders: 1.1.3
seq-queue: 0.0.5
sqlstring: 2.3.3
named-placeholders@1.1.3:
dependencies:
lru-cache: 7.18.3
safer-buffer@2.1.2: {}
seq-queue@0.0.5: {}
signal-exit@4.1.0: {}
sqlstring@2.3.3: {}
string-width@4.2.3:
dependencies:
emoji-regex: 8.0.0
is-fullwidth-code-point: 3.0.0
strip-ansi: 6.0.1
strip-ansi@6.0.1:
dependencies:
ansi-regex: 5.0.1
typescript@5.9.2: {}
undici-types@7.12.0: {}
wrap-ansi@6.2.0:
dependencies:
ansi-styles: 4.3.0
string-width: 4.2.3
strip-ansi: 6.0.1
yaml@2.8.1: {}
yoctocolors-cjs@2.1.3: {}

111
scripts/peas.sql Normal file
View File

@ -0,0 +1,111 @@
-- MariaDB dump 10.19 Distrib 10.4.32-MariaDB, for Win64 (AMD64)
--
-- Host: 192.168.3.7 Database: peas
-- ------------------------------------------------------
-- Server version 10.11.11-MariaDB-0+deb12u1
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */;
/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */;
/*!40101 SET NAMES utf8 */;
/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
/*!40103 SET TIME_ZONE='+00:00' */;
/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */;
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
--
-- Table structure for table `community`
--
DROP TABLE IF EXISTS `community`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `community` (
`user` text NOT NULL,
`value` text NOT NULL,
`time` datetime(3) NOT NULL DEFAULT current_timestamp(3),
`num` int(11) NOT NULL AUTO_INCREMENT,
PRIMARY KEY (`num`)
) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `config`
--
DROP TABLE IF EXISTS `config`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `config` (
`num` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(50) NOT NULL,
`value` varchar(255) NOT NULL,
PRIMARY KEY (`num`)
) ENGINE=InnoDB AUTO_INCREMENT=12 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `mailverifiedcode`
--
DROP TABLE IF EXISTS `mailverifiedcode`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `mailverifiedcode` (
`code` text NOT NULL,
`user` text NOT NULL,
`email` text NOT NULL,
`time` datetime(3) NOT NULL DEFAULT current_timestamp(3),
`num` int(11) NOT NULL AUTO_INCREMENT,
PRIMARY KEY (`num`)
) ENGINE=InnoDB AUTO_INCREMENT=25 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `token`
--
DROP TABLE IF EXISTS `token`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `token` (
`num` int(11) NOT NULL AUTO_INCREMENT,
`user` varchar(20) NOT NULL,
`scope` text NOT NULL,
`name` varchar(50) NOT NULL,
`token` varchar(64) NOT NULL,
PRIMARY KEY (`num`)
) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
--
-- Table structure for table `users`
--
DROP TABLE IF EXISTS `users`;
/*!40101 SET @saved_cs_client = @@character_set_client */;
/*!40101 SET character_set_client = utf8 */;
CREATE TABLE `users` (
`id` text NOT NULL,
`password` text NOT NULL,
`email` text NOT NULL,
`mailverified` int(1) NOT NULL DEFAULT 0,
`time` datetime(3) NOT NULL DEFAULT current_timestamp(3),
`num` int(11) NOT NULL AUTO_INCREMENT,
PRIMARY KEY (`num`),
KEY `id` (`id`(768))
) ENGINE=InnoDB AUTO_INCREMENT=36 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
/*!40101 SET character_set_client = @saved_cs_client */;
/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */;
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */;
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */;
/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */;
/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */;
-- Dump completed on 2025-10-01 19:45:29

215
scripts/setup.ts Normal file
View File

@ -0,0 +1,215 @@
import { input, number, select, password, editor, confirm } from "@inquirer/prompts";
import { Locale } from "../packages/web/src/lib/locale.js";
import { getDirname } from "../packages/web/src/lib/path.js";
import { verify as DBVerify } from "../packages/web/src/lib/database.js";
import { readFileSync, writeFileSync } from "fs";
import pool from "../packages/web/src/lib/database.js";
import type { RowDataPacket } from "mysql2";
async function wizard() {
try {
const lang = await select({
message: "🌐",
choices: [
{
name: "日本語",
value: "ja",
},
{
name: "English",
value: "en",
},
],
});
const origin = await input({
message: Locale({ text: "Q_origin" }, lang),
default: "http://localhost:3000",
required: true,
});
const port = await number({
message: Locale({ text: "Q_port" }, lang),
min: 0,
max: 65535,
default: 3000,
required: true,
});
let db: {
host?: string;
port?: number;
user?: string;
pass?: string;
db?: string;
} = {};
db.host = await input({
message: Locale({ text: "Q_db_host" }, lang),
default: "db.example.com",
required: true,
});
db.port = await number({
message: Locale({ text: "Q_db_port" }, lang),
min: 0,
max: 65535,
default: 3306,
required: true,
});
db.user = await input({
message: Locale({ text: "Q_db_user" }, lang),
default: "root",
required: true,
});
db.pass = await password({
message: Locale({ text: "Q_db_pass" }, lang),
mask: "*",
});
db.db = await input({
message: Locale({ text: "Q_db_db" }, lang),
default: "peas",
required: true,
});
await DBVerify({
host: db.host,
port: db.port,
user: db.user,
password: db.pass,
database: db.db,
waitForConnections: true,
connectionLimit: 0,
});
writeFileSync(`${getDirname(import.meta.url)}../packages/web/config/peas.config.ts`,
`import type ConfigType from "./config";
const Config: ConfigType = {
server: {
port: ${String(port)},
origin: "${origin}",
},
database: {
host: "${db.host}",
port: ${String(db.port)},
user: "${db.user}",
pass: "${db.pass}",
db: "${db.db}",
},
}
export default Config;`, "utf-8");
console.log(Locale({ text: "CLI_config_file_write_success" }, lang));
const sql = readFileSync(`${getDirname(import.meta.url)}peas.sql`, "utf-8");
await pool.query<RowDataPacket[]>(sql);
await pool.execute<RowDataPacket[]>(
"INSERT INTO `config` (`name`, `value`) VALUES ('server_name', ?)",
[await input({
message: Locale({ text: "serverName" }, lang),
default: "Peas",
required: true,
})]
);
await pool.execute<RowDataPacket[]>(
"INSERT INTO `config` (`name`, `value`) VALUES ('server_description', ?)",
[await editor({
message: Locale({ text: "serverDescription" }, lang),
default: "Peas server",
})]
);
await pool.execute<RowDataPacket[]>(
"INSERT INTO `config` (`name`, `value`) VALUES ('server_icon', ?)",
[await input({
message: Locale({ text: "serverIconURL" }, lang),
default: `${origin}/peas.svg`,
required: true,
})]
);
const turnstileIsEnabled = await confirm({
message: Locale({ text: "Q_turnstile_is_enabled" }, lang),
default: true,
});
await pool.execute<RowDataPacket[]>(
"INSERT INTO `config` (`name`, `value`) VALUES ('turnstile_isEnabled', ?)",
[turnstileIsEnabled ? "1" : "0"]
);
if (turnstileIsEnabled) {
await pool.execute<RowDataPacket[]>(
"INSERT INTO `config` (`name`, `value`) VALUES ('turnstile_sitekey', ?)",
[await input({
message: Locale({ text: "Q_turnstile_sitekey" }, lang),
default: "1x00000000000000000000AA",
required: true,
})]
);
await pool.execute<RowDataPacket[]>(
"INSERT INTO `config` (`name`, `value`) VALUES ('turnstile_secret', ?)",
[await password({
message: Locale({ text: "Q_turnstile_secret" }, lang),
mask: "*",
})]
);
}
await pool.execute<RowDataPacket[]>(
"INSERT INTO `config` (`name`, `value`) VALUES ('smtp_host', ?)",
[await input({
message: Locale({ text: "Q_smtp_host" }, lang),
default: "smtp.mail.example.com",
required: true,
})]
);
await pool.execute<RowDataPacket[]>(
"INSERT INTO `config` (`name`, `value`) VALUES ('smtp_port', ?)",
[String(await number({
message: Locale({ text: "Q_smtp_port" }, lang),
default: 465,
min: 0,
max: 65535,
required: true,
}))]
);
await pool.execute<RowDataPacket[]>(
"INSERT INTO `config` (`name`, `value`) VALUES ('smtp_user', ?)",
[await input({
message: Locale({ text: "Q_smtp_user" }, lang),
default: "peas@mail.example.com",
required: true,
})]
);
await pool.execute<RowDataPacket[]>(
"INSERT INTO `config` (`name`, `value`) VALUES ('smtp_password', ?)",
[await password({
message: Locale({ text: "Q_smtp_pass" }, lang),
mask: "*",
})]
);
await pool.execute<RowDataPacket[]>(
"INSERT INTO `config` (`name`, `value`) VALUES ('smtp_secure', ?)",
[await confirm({
message: Locale({ text: "Q_smtp_secure" }, lang),
default: true,
}) ? "1": "0"]
);
console.log(Locale({ text: "CLISETUP_success" }, lang));
process.exit();
} catch (err: any) {
if (err.name === 'ExitPromptError') {
process.exit();
} else {
console.error(err);
}
}
}
wizard();

25
tsconfig.json Normal file
View File

@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2024",
"module": "ESNext",
"moduleResolution": "bundler",
"outDir": "./dist",
"esModuleInterop": true,
"verbatimModuleSyntax": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"skipLibCheck": true,
"baseUrl": "./",
"types": [
"node"
],
"typeRoots": [
"./node_modules/@types",
],
"removeComments": true,
},
"include": [
"scripts/**/*"
],
}