configファイルに移行・多言語化完全対応・manifest系対応・PWA対応・ロード表示・アカウント初期化処理完成・mailsend.tsの型を修正

This commit is contained in:
Last2014 2025-07-13 01:06:42 +09:00
parent 8d7a968c75
commit ebacf6e1af
30 changed files with 987 additions and 554 deletions

View File

@ -1,13 +0,0 @@
// DATABASE(MySQL/MariaDB)
DB_HOST=
DB_PORT=
DB_USER=
DB_PASSWORD=
DB_DATABASE=
// MAIL(SMTP)
SMTP_HOST=
SMTP_PORT=
SMTP_SECURE=
SMTP_USER=
SMTP_PASSWORD=

7
.gitignore vendored
View File

@ -9,6 +9,7 @@
!.yarn/plugins
!.yarn/releases
!.yarn/versions
/package-lock.json
# testing
/coverage
@ -31,8 +32,7 @@ yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env
.env.local
.env*
# vercel
.vercel
@ -45,3 +45,6 @@ next-env.d.ts
/.vscode/
/.trae/
/.vs/
# config
/peas.config.ts

View File

@ -1,10 +1,9 @@
<center>
# Peas
<img width="140" style="border-radius: 100%" src="./src/asset/peas.svg">
<img width="140" src="./src/asset/peas.svg">
## Technologys
<img width="30" src="./src/asset/simpleicons/html5.svg">
<img width="30" src="./src/asset/simpleicons/css.svg">
<img width="30" src="./src/asset/simpleicons/js.svg">
@ -20,11 +19,12 @@
<img width="30" src="./src/asset/simpleicons/svg.svg">
## About Peas
Peas is a SNS designed for sharing profiles.
You can register and share your user profile.
You can freely create profile sections suitable for use at school or work, and also change the privacy settings.
It also supports printing QR codes, making sharing easy.
## LICENSE
AGPL-v3.0
</center>

29
examples/peas.config.ts Normal file
View File

@ -0,0 +1,29 @@
import { PeasConfigType } from "@/lib/peas.config";
const PeasConfig: PeasConfigType = {
// Server Config
serverName: "Peas Server",
serverDescription: "Peas SNS",
serverHost: "peas.example.com",
// SSL Config
ssl: {
ssl: false,
},
// SMTP Config
smtpHost: "mail.example.com",
smtpPort: 587,
smtpSecure: false,
smtpUser: "info@mail.example.com",
smtpPassword: "SMTPMailPassword",
// Database Config
databaseHost: "db.example.com",
databasePort: 3306,
databaseUser: "peas",
databasePassword: "DatabasePassword",
databaseDB: "peas",
};
export default PeasConfig;

108
package-lock.json generated
View File

@ -1,14 +1,15 @@
{
"name": "peas",
"version": "1.0",
"version": "nonerelease",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "peas",
"version": "1.0",
"license": "ISC",
"version": "nonerelease",
"license": "AGPL-3.0-later",
"dependencies": {
"@iconify/react": "^6.0.0",
"@types/dotenv": "^6.1.1",
"@types/eslint": "^9.6.1",
"@types/js-cookie": "^3.0.6",
@ -19,7 +20,7 @@
"dotenv": "^16.5.0",
"js-cookie": "^3.0.5",
"mysql2": "^3.14.1",
"next": "^15.3.2",
"next": "^15.3.4",
"nodemailer": "^7.0.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
@ -289,6 +290,27 @@
"url": "https://github.com/sponsors/nzakas"
}
},
"node_modules/@iconify/react": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@iconify/react/-/react-6.0.0.tgz",
"integrity": "sha512-eqNscABVZS8eCpZLU/L5F5UokMS9mnCf56iS1nM9YYHdH8ZxqZL9zyjSwW60IOQFsXZkilbBiv+1paMXBhSQnw==",
"license": "MIT",
"dependencies": {
"@iconify/types": "^2.0.0"
},
"funding": {
"url": "https://github.com/sponsors/cyberalien"
},
"peerDependencies": {
"react": ">=16"
}
},
"node_modules/@iconify/types": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@iconify/types/-/types-2.0.0.tgz",
"integrity": "sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==",
"license": "MIT"
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.34.2",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.2.tgz",
@ -724,9 +746,9 @@
}
},
"node_modules/@next/env": {
"version": "15.3.2",
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.3.2.tgz",
"integrity": "sha512-xURk++7P7qR9JG1jJtLzPzf0qEvqCN0A/T3DXf8IPMKo9/6FfjxtEffRJIIew/bIL4T3C2jLLqBor8B/zVlx6g==",
"version": "15.3.4",
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.3.4.tgz",
"integrity": "sha512-ZkdYzBseS6UjYzz6ylVKPOK+//zLWvD6Ta+vpoye8cW11AjiQjGYVibF0xuvT4L0iJfAPfZLFidaEzAOywyOAQ==",
"license": "MIT"
},
"node_modules/@next/eslint-plugin-next": {
@ -740,9 +762,9 @@
}
},
"node_modules/@next/swc-darwin-arm64": {
"version": "15.3.2",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.3.2.tgz",
"integrity": "sha512-2DR6kY/OGcokbnCsjHpNeQblqCZ85/1j6njYSkzRdpLn5At7OkSdmk7WyAmB9G0k25+VgqVZ/u356OSoQZ3z0g==",
"version": "15.3.4",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-15.3.4.tgz",
"integrity": "sha512-z0qIYTONmPRbwHWvpyrFXJd5F9YWLCsw3Sjrzj2ZvMYy9NPQMPZ1NjOJh4ojr4oQzcGYwgJKfidzehaNa1BpEg==",
"cpu": [
"arm64"
],
@ -756,9 +778,9 @@
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "15.3.2",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.3.2.tgz",
"integrity": "sha512-ro/fdqaZWL6k1S/5CLv1I0DaZfDVJkWNaUU3un8Lg6m0YENWlDulmIWzV96Iou2wEYyEsZq51mwV8+XQXqMp3w==",
"version": "15.3.4",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-15.3.4.tgz",
"integrity": "sha512-Z0FYJM8lritw5Wq+vpHYuCIzIlEMjewG2aRkc3Hi2rcbULknYL/xqfpBL23jQnCSrDUGAo/AEv0Z+s2bff9Zkw==",
"cpu": [
"x64"
],
@ -772,9 +794,9 @@
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "15.3.2",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.3.2.tgz",
"integrity": "sha512-covwwtZYhlbRWK2HlYX9835qXum4xYZ3E2Mra1mdQ+0ICGoMiw1+nVAn4d9Bo7R3JqSmK1grMq/va+0cdh7bJA==",
"version": "15.3.4",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-15.3.4.tgz",
"integrity": "sha512-l8ZQOCCg7adwmsnFm8m5q9eIPAHdaB2F3cxhufYtVo84pymwKuWfpYTKcUiFcutJdp9xGHC+F1Uq3xnFU1B/7g==",
"cpu": [
"arm64"
],
@ -788,9 +810,9 @@
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "15.3.2",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.3.2.tgz",
"integrity": "sha512-KQkMEillvlW5Qk5mtGA/3Yz0/tzpNlSw6/3/ttsV1lNtMuOHcGii3zVeXZyi4EJmmLDKYcTcByV2wVsOhDt/zg==",
"version": "15.3.4",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-15.3.4.tgz",
"integrity": "sha512-wFyZ7X470YJQtpKot4xCY3gpdn8lE9nTlldG07/kJYexCUpX1piX+MBfZdvulo+t1yADFVEuzFfVHfklfEx8kw==",
"cpu": [
"arm64"
],
@ -804,9 +826,9 @@
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "15.3.2",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.3.2.tgz",
"integrity": "sha512-uRBo6THWei0chz+Y5j37qzx+BtoDRFIkDzZjlpCItBRXyMPIg079eIkOCl3aqr2tkxL4HFyJ4GHDes7W8HuAUg==",
"version": "15.3.4",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-15.3.4.tgz",
"integrity": "sha512-gEbH9rv9o7I12qPyvZNVTyP/PWKqOp8clvnoYZQiX800KkqsaJZuOXkWgMa7ANCCh/oEN2ZQheh3yH8/kWPSEg==",
"cpu": [
"x64"
],
@ -820,9 +842,9 @@
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "15.3.2",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.3.2.tgz",
"integrity": "sha512-+uxFlPuCNx/T9PdMClOqeE8USKzj8tVz37KflT3Kdbx/LOlZBRI2yxuIcmx1mPNK8DwSOMNCr4ureSet7eyC0w==",
"version": "15.3.4",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-15.3.4.tgz",
"integrity": "sha512-Cf8sr0ufuC/nu/yQ76AnarbSAXcwG/wj+1xFPNbyNo8ltA6kw5d5YqO8kQuwVIxk13SBdtgXrNyom3ZosHAy4A==",
"cpu": [
"x64"
],
@ -836,9 +858,9 @@
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "15.3.2",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.3.2.tgz",
"integrity": "sha512-LLTKmaI5cfD8dVzh5Vt7+OMo+AIOClEdIU/TSKbXXT2iScUTSxOGoBhfuv+FU8R9MLmrkIL1e2fBMkEEjYAtPQ==",
"version": "15.3.4",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-15.3.4.tgz",
"integrity": "sha512-ay5+qADDN3rwRbRpEhTOreOn1OyJIXS60tg9WMYTWCy3fB6rGoyjLVxc4dR9PYjEdR2iDYsaF5h03NA+XuYPQQ==",
"cpu": [
"arm64"
],
@ -852,9 +874,9 @@
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "15.3.2",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.3.2.tgz",
"integrity": "sha512-aW5B8wOPioJ4mBdMDXkt5f3j8pUr9W8AnlX0Df35uRWNT1Y6RIybxjnSUe+PhM+M1bwgyY8PHLmXZC6zT1o5tA==",
"version": "15.3.4",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-15.3.4.tgz",
"integrity": "sha512-4kDt31Bc9DGyYs41FTL1/kNpDeHyha2TC0j5sRRoKCyrhNcfZ/nRQkAUlF27mETwm8QyHqIjHJitfcza2Iykfg==",
"cpu": [
"x64"
],
@ -4349,12 +4371,12 @@
"license": "MIT"
},
"node_modules/next": {
"version": "15.3.2",
"resolved": "https://registry.npmjs.org/next/-/next-15.3.2.tgz",
"integrity": "sha512-CA3BatMyHkxZ48sgOCLdVHjFU36N7TF1HhqAHLFOkV6buwZnvMI84Cug8xD56B9mCuKrqXnLn94417GrZ/jjCQ==",
"version": "15.3.4",
"resolved": "https://registry.npmjs.org/next/-/next-15.3.4.tgz",
"integrity": "sha512-mHKd50C+mCjam/gcnwqL1T1vPx/XQNFlXqFIVdgQdVAFY9iIQtY0IfaVflEYzKiqjeA7B0cYYMaCrmAYFjs4rA==",
"license": "MIT",
"dependencies": {
"@next/env": "15.3.2",
"@next/env": "15.3.4",
"@swc/counter": "0.1.3",
"@swc/helpers": "0.5.15",
"busboy": "1.6.0",
@ -4369,14 +4391,14 @@
"node": "^18.18.0 || ^19.8.0 || >= 20.0.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "15.3.2",
"@next/swc-darwin-x64": "15.3.2",
"@next/swc-linux-arm64-gnu": "15.3.2",
"@next/swc-linux-arm64-musl": "15.3.2",
"@next/swc-linux-x64-gnu": "15.3.2",
"@next/swc-linux-x64-musl": "15.3.2",
"@next/swc-win32-arm64-msvc": "15.3.2",
"@next/swc-win32-x64-msvc": "15.3.2",
"@next/swc-darwin-arm64": "15.3.4",
"@next/swc-darwin-x64": "15.3.4",
"@next/swc-linux-arm64-gnu": "15.3.4",
"@next/swc-linux-arm64-musl": "15.3.4",
"@next/swc-linux-x64-gnu": "15.3.4",
"@next/swc-linux-x64-musl": "15.3.4",
"@next/swc-win32-arm64-msvc": "15.3.4",
"@next/swc-win32-x64-msvc": "15.3.4",
"sharp": "^0.34.1"
},
"peerDependencies": {

View File

@ -1,7 +1,7 @@
{
"type": "module",
"name": "peas",
"version": "1.0",
"version": "nonerelease",
"license": "AGPL-3.0-later",
"author": {
"name": "Last2014",
@ -15,6 +15,7 @@
"lint": "next lint"
},
"dependencies": {
"@iconify/react": "^6.0.0",
"@types/dotenv": "^6.1.1",
"@types/eslint": "^9.6.1",
"@types/js-cookie": "^3.0.6",
@ -25,7 +26,7 @@
"dotenv": "^16.5.0",
"js-cookie": "^3.0.5",
"mysql2": "^3.14.1",
"next": "^15.3.2",
"next": "^15.3.4",
"nodemailer": "^7.0.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",

View File

@ -1,27 +1,33 @@
import pool from '@/lib/database';
import type { RowDataPacket } from 'mysql2';
import pool from "@/lib/database";
import type { RowDataPacket } from "mysql2";
import { NextResponse, NextRequest } from 'next/server';
import { NextResponse, NextRequest } from "next/server";
export async function POST(request: NextRequest) {
const body = await request.json();
const { code } = body;
const [rows] = await pool.query<RowDataPacket[]>(
'SELECT * FROM mailverifiedcode WHERE code = ?',
[code]
"SELECT * FROM mailverifiedcode WHERE code = ?",
[code],
);
if (rows.length === 0) {
return NextResponse.json({status: "error", message: 'Code not found' }, { status: 401 });
}else{
if(rows[0].code === code){
return NextResponse.json(
{ status: "error", message: "Code not found" },
{ status: 401 },
);
} else {
if (rows[0].code === code) {
await pool.query<RowDataPacket[]>(
'UPDATE `users` SET `mailverified` = 1 WHERE `users`.`id` = ?',
[rows[0].user]
"UPDATE `users` SET `mailverified` = 1 WHERE `users`.`id` = ?",
[rows[0].user],
);
return NextResponse.json({status: "success", message: 'Code verified' }, { status: 200 });
return NextResponse.json(
{ status: "success", message: "Code verified" },
{ status: 200 },
);
}
}
}

View File

@ -1,16 +1,18 @@
import pool from '@/lib/database';
import type { RowDataPacket } from 'mysql2';
import pool from "@/lib/database";
import type { RowDataPacket } from "mysql2";
import { sendMail } from '@/lib/mailsend';
import { sendMail } from "@/lib/mailsend";
import PeasConfig from "@/../peas.config";
import { NextResponse, NextRequest } from 'next/server';
import { NextResponse, NextRequest } from "next/server";
// コード生成
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]
"SELECT * FROM mailverifiedcode WHERE code = ?",
[code],
);
if (existingCodes.length === 0) {
return code;
@ -28,44 +30,49 @@ export async function POST(request: NextRequest) {
// コードが存在する場合
const [existingCodes] = await pool.execute<RowDataPacket[]>(
"SELECT * FROM mailverifiedcode WHERE user = ?",[user]
"SELECT * FROM mailverifiedcode WHERE user = ?",
[user],
);
let row: RowDataPacket[] = [];
if(existingCodes.length !== 0){
if (existingCodes.length !== 0) {
[row] = await pool.execute<RowDataPacket[]>(
"UPDATE `mailverifiedcode` SET `code` = ?, `time` = current_timestamp(3) WHERE `mailverifiedcode`.`user` = ?",
[code, user]
)
}else{
[code, user],
);
} else {
// コードが存在しない場合
[row] = await pool.execute<RowDataPacket[]>(
"INSERT INTO `mailverifiedcode` (`code`, `user`, `email`, `time`, `num`) VALUES (?, ?, ?, current_timestamp(3), NULL);",
[code, user, email]
)
[code, user, email],
);
}
// メール送信
if ((row as RowDataPacket).affectedRows === 1) {
await sendMail({
to: email,
subject: "【Peas】メール認証",
text: `${user}さんこんにちは。\n
Peasではメール認証が必要となっています\n
Peasの機能を利用することができます\n
${request.nextUrl.origin}/mailverify?code=${code} `
})
subject: `${PeasConfig.serverName}】メール認証`,
text: `
${user}\n
${PeasConfig.serverName}\n
${PeasConfig.serverName}\n
${request.nextUrl.origin}/mailverify?code=${code} `,
});
return NextResponse.json({
status: "success",
code: code,
})
}else{
});
} else {
// コード保存失敗
return NextResponse.json({
return NextResponse.json(
{
status: "error",
error: "Failed to Save Code"
}, { status: 400 });
error: "Failed to Save Code",
},
{ status: 400 },
);
}
}

View File

@ -1,47 +1,57 @@
import pool from '@/lib/database';
import type { RowDataPacket } from 'mysql2';
import pool from "@/lib/database";
import type { RowDataPacket } from "mysql2";
import bcrypt from 'bcrypt';
import bcrypt from "bcrypt";
import { NextResponse, NextRequest } from 'next/server';
import { cookies } from 'next/headers'
import { NextResponse, NextRequest } from "next/server";
import { cookies } from "next/headers";
export async function POST(request: NextRequest) {
// body取得
// body取得
const body = await request.json();
const { email, password } = body;
// ユーザー取得
// ユーザー取得
const [existingUsers] = await pool.execute<RowDataPacket[]>(
"SELECT * FROM users WHERE email = ?", [email]
"SELECT * FROM users WHERE email = ?",
[email],
);
// ユーザーが存在しない場合
// ユーザーが存在しない場合
if (existingUsers.length === 0) {
return NextResponse.json({
return NextResponse.json(
{
status: "error",
error: "User not found"
}, { status: 404 });
error: "User not found",
},
{ status: 404 },
);
}
const user = existingUsers[0];
const passwordMatch = await bcrypt.compare(password, user.password);
// パスワード確認
// パスワード確認
if (!passwordMatch) {
return NextResponse.json({
return NextResponse.json(
{
status: "error",
error: "Incorrect password"
}, { status: 401 });
error: "Incorrect password",
},
{ status: 401 },
);
} else {
// 成功
// 成功
const sessionCookie = await cookies();
sessionCookie.set('user', user.id);
sessionCookie.set('password', password);
sessionCookie.set("user", user.id);
sessionCookie.set("password", password);
return NextResponse.json({
return NextResponse.json(
{
status: "success",
message: "Login successful"
}, { status: 200 });
message: "Login successful",
},
{ status: 200 },
);
}
}

View File

@ -1,87 +1,114 @@
import pool from '@/lib/database';
import type { RowDataPacket } from 'mysql2';
import pool from "@/lib/database";
import type { RowDataPacket } from "mysql2";
import bcrypt from 'bcrypt';
import bcrypt from "bcrypt";
import { NextResponse, NextRequest } from 'next/server';
import { NextResponse, NextRequest } from "next/server";
export async function POST(request: NextRequest) {
const body = await request.json();
const { email, password, id, rule } = body;
try{
if(!rule || rule === false){
return NextResponse.json({
try {
if (!rule || rule === false) {
return NextResponse.json(
{
status: "error",
error: "Please read and accept the terms and conditions"
}, { status: 400 });
error: "Please read and accept the terms and conditions",
},
{ status: 400 },
);
}
}catch(e){
} catch (e) {
console.log(e);
}
// bodyが空になっていないか
if(!email || !password || !id || !rule){
return NextResponse.json({
if (!email || !password || !id || !rule) {
return NextResponse.json(
{
status: "error",
error: "Body Required"
}, { status: 400 });
error: "Body Required",
},
{ status: 400 },
);
}
// パスワードが八文字以上か
if(password.length < 8){
return NextResponse.json({
// パスワードが8文字以上か
if (password.length < 8) {
return NextResponse.json(
{
status: "error",
error: "Password must be at least 8 characters long"
}, { status: 400 });
error: "Password must be at least 8 characters long",
},
{ status: 400 },
);
}
// ユーザー名が英数字3文字以上か
if(!/^[a-zA-Z0-9]{3,}$/.test(id)){
return NextResponse.json({
if (!/^[a-zA-Z0-9]{3,}$/.test(id)) {
return NextResponse.json(
{
status: "error",
error: "Username must be at least 3 alphanumeric characters"
}, { status: 400 });
error: "Username must be at least 3 alphanumeric characters",
},
{ status: 400 },
);
}
const [existingUsers] = await pool.execute<RowDataPacket[]>(
"SELECT * FROM users WHERE id = ?",[id]
"SELECT * FROM users WHERE id = ?",
[id],
);
if(existingUsers.length === 0){
if (existingUsers.length === 0) {
const passwordHash = await bcrypt.hash(password, 10);
const [result] = await pool.execute<RowDataPacket[]>(
"INSERT INTO `users` (`id`, `password`, `email`, `mailverified`, `time`, `num`) VALUES (?, ?, ?, '0', current_timestamp(3), NULL)",
[id, passwordHash, email]
[id, passwordHash, email],
);
if ((result as RowDataPacket).affectedRows === 1) {
await fetch(request.nextUrl.protocol + request.nextUrl.host + "/api/mailverified/send", {
await fetch(
request.nextUrl.protocol +
request.nextUrl.host +
"/api/mailverified/send",
{
method: "POST",
headers: {
"Content-Type": "application/json"
"Content-Type": "application/json",
},
body: JSON.stringify({
email: email,
user: id,
})
});
}),
},
);
return NextResponse.json({
return NextResponse.json(
{
status: "success",
message: "User created successfully"
}, { status: 201 });
message: "User created successfully",
},
{ status: 201 },
);
} else {
return NextResponse.json({
return NextResponse.json(
{
status: "error",
error: "Failed to create user"
}, { status: 500 });
error: "Failed to create user",
},
{ status: 500 },
);
}
}else{
return NextResponse.json({
} else {
return NextResponse.json(
{
status: "error",
error: "User already exists"
}, { status: 400 });
error: "User already exists",
},
{ status: 400 },
);
}
}

2
src/app/bulma.css vendored
View File

@ -1 +1 @@
@import 'bulma/css/bulma.css';
@import "bulma/css/bulma.min.css";

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 15 KiB

View File

@ -2,9 +2,11 @@ import type { Metadata } from "next";
import "./bulma.css";
import "./global.css";
import PeasConfig from "@/../peas.config";
export const metadata: Metadata = {
title: "Peas",
description: "Peas SNS",
title: PeasConfig.serverName,
description: PeasConfig.serverDescription,
};
export default function RootLayout({
@ -14,13 +16,7 @@ export default function RootLayout({
}>) {
return (
<html>
<body>
<header className="header">
<span className="title">Peas</span>
</header>
{children}
</body>
<body>{children}</body>
</html>
);
}

View File

@ -1,28 +1,38 @@
"use client";
import { useState } from "react";
import { useSearchParams } from "next/navigation";
import { Icon } from "@iconify/react";
import { MessageLang } from "@/lang/component/client";
import { MessageLangVar } from "@/lang/component/variable";
export default function MailVerify() {
const [loading, setLoading] = useState(false);
const searchParams = useSearchParams();
const URLCode = searchParams.get("code");
let code: string = "";
if(URLCode){
if (URLCode !== null) {
code = URLCode;
}
const verify = async (e: React.FormEvent<HTMLFormElement>) => {
if(code === ""){
e.preventDefault();
setLoading(true);
if (code === "") {
code = e.currentTarget.code.value;
}
if (code.length !== 6) {
alert("コードは6桁で入力してください。");
alert(MessageLangVar({text: "codeCharacterCount"}));
return;
}
const res = await fetch(`${location.origin}/api/mailverify/check`, {
try {
const res = await fetch(`${location.origin}/api/mailverified/check`, {
method: "POST",
headers: {
"Content-Type": "application/json",
@ -31,23 +41,50 @@ export default function MailVerify() {
code,
}),
});
const data = await res.text();
const result = JSON.parse(data);
if (result.status === "success") {
alert("メール認証に成功しました。\nログインしてください。");
window.location.href = "/login";
alert(MessageLangVar({ text: "emailVerificationSuccess" }));
window.location.href = "/signin";
}
} catch (err) {
console.error(err)
} finally {
setLoading(false);
}
};
return (
<>
<h1 className="title"></h1>
<h1 className="title has-text-centered"><MessageLang text="mailVerify" /></h1>
<form onSubmit={verify}>
<label className="label"></label>
<input type="text" name="code" defaultValue={code} className="input" placeholder="123456" style={{width: "10rem"}} maxLength={6} />
{loading && (
<div style={{ marginBottom: "1rem" }}>
<Icon icon="svg-spinners:6-dots-scale" width={32} height={32} />
</div>
)}
<form onSubmit={verify} className="has-text-centered">
<label className="label"><MessageLang text="code" />:</label>
<input
type="text"
name="code"
defaultValue={code}
className="input"
placeholder="123456"
style={{
width: "10rem",
marginBottom: "10px"
}}
maxLength={6}
/>
<br />
<button className="button" type="submit">
<MessageLang text="submit" />
</button>
</form>
</>
)
);
}

20
src/app/manifest.ts Normal file
View File

@ -0,0 +1,20 @@
import type { MetadataRoute } from "next";
import PeasConfig from "@/../peas.config";
export default function manifest(): MetadataRoute.Manifest {
return {
name: PeasConfig.serverName,
description: PeasConfig.serverDescription,
start_url: "/",
display: "standalone",
background_color: "#fff",
theme_color: "#fff",
icons: [
{
src: "/favicon.ico",
sizes: "any",
type: "image/x-icon",
},
],
};
}

View File

@ -1,26 +1,54 @@
"use client";
import "./style.css"
import "./style.css";
import Link from "next/link";
import { Icon } from "@iconify/react";
import PeasConfig from "@/../peas.config";
import { MessageLang } from "@/lang/component/client";
export default function Home() {
return (
<>
<h1 className="has-text-centered title">{PeasConfig.serverName}</h1>
<div className="message about">
<h1 className="message-header"><MessageLang text="PeasAboutTitle" /></h1>
<h1 className="message-header">
<Icon icon="ix:about-filled" />
<MessageLang className="peasAboutTitle" text="ServerAboutTitle" />
</h1>
<p className="message-body">{PeasConfig.serverDescription}</p>
</div>
<div className="message about">
<h1 className="message-header">
<Icon icon="ix:about-filled" />
<MessageLang className="peasAboutTitle" text="PeasAboutTitle" />
</h1>
<p className="message-body">
<MessageLang text="PeasAboutText" />
</p>
</div>
<div style={{width: "15rem"}}>
<Link href="/signup" className="card card-footer card-footer-item signup"><MessageLang text="Start" /></Link>
<p className="has-text-centered"><MessageLang text="or" /></p>
<Link href="/signin" className="card card-footer card-footer-item signin"><MessageLang text="Signin" /></Link>
<div className="has-text-centered">
<Link
href="/signup"
className="card card-footer card-footer-item signup"
>
<MessageLang text="Start" />
</Link>
<p className="has-text-centered">
<MessageLang text="or" />
</p>
<Link
href="/signin"
className="card card-footer card-footer-item signin"
>
<MessageLang text="Signin" />
</Link>
</div>
</>
);

20
src/app/robots.ts Normal file
View File

@ -0,0 +1,20 @@
import type { MetadataRoute } from "next";
import PeasConfig from "../../peas.config";
let protocol: string;
if (PeasConfig.ssl.ssl) {
protocol = "https";
} else {
protocol = "http";
}
export default function robots(): MetadataRoute.Robots {
return {
rules: {
userAgent: "*",
allow: "/",
},
sitemap: `${protocol}://${PeasConfig.serverHost}/sitemap.xml`,
};
}

View File

@ -1,13 +1,20 @@
"use client";
import "./style.css";
import { useState } from "react";
import { MessageLang } from "@/lang/component/client";
import { Icon } from "@iconify/react";
export default function SignIn() {
const [error, setError] = useState(false);
const [loading, setLoading] = useState(false);
const FormSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setLoading(true);
try {
const res = await fetch("/api/signin", {
method: "POST",
headers: {
@ -23,24 +30,64 @@ export default function SignIn() {
if (json.status === "success") {
window.location.href = "/";
} else {
setError(true);
window.location.replace("/signin?error=true");
}
} catch (err) {
console.log(err);
setError(true);
window.location.replace("/signin?error=true");
} finally {
setLoading(false);
}
};
useState(() => {
const locationURL = new URL(window.location.href);
if (locationURL.searchParams.get("error")) {
setError(true);
}
});
return (
<form onSubmit={FormSubmit}>
<h1 className="title"><MessageLang text="Signin" /></h1>
<form onSubmit={FormSubmit} className="has-text-centered">
{error && (
<div className="message is-danger">
<div className="message-body">
<Icon icon="iconoir:warning-circle-solid" />
<MessageLang text="signinError" />
</div>
</div>
)}
<label className="label"><MessageLang text="email" /></label>
<h1 className="title">
<MessageLang text="Signin" />
</h1>
{loading && (
<div style={{ marginBottom: "1rem" }}>
<Icon icon="svg-spinners:6-dots-scale" width={32} height={32} />
</div>
)}
<label className="label">
<MessageLang text="email" />
</label>
<input type="email" name="email" className="input" required />
<br />
<label className="label"><MessageLang text="password" /></label>
<label className="label">
<MessageLang text="password" />
</label>
<input type="password" name="password" className="input" required />
<br />
<button type="submit" className="button"><MessageLang text="Signin" /></button>
<button type="submit" className="button">
<MessageLang text="Signin" />
</button>
</form>
)
);
}

View File

@ -1,7 +1,7 @@
.input{
.input {
width: 20rem;
}
.button{
.button {
margin-top: 5px;
}

View File

@ -1,15 +1,22 @@
"use client";
import Link from "next/link";
import { useState } from "react";
import { Icon } from "@iconify/react";
import "./style.css";
import { MessageLang } from "@/lang/component/client";
import { MessageLangVar } from "@/lang/component/variable";
export default function SignUp() {
const [loading, setLoading] = useState(false);
const FormSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
setLoading(true);
try {
const res = await fetch("/api/signup", {
method: "POST",
headers: {
@ -22,56 +29,146 @@ export default function SignUp() {
rule: e.currentTarget.ruleCheck.checked,
}),
});
const data = await res.text();
const json = JSON.parse(data);
if (json.status === "success") {
alert("アカウントの作成に成功しました。\nご登録いただいたメールアドレスに確認用メールを送信しました。\n確認メールをクリックしてアカウントを有効化してください。")
alert(MessageLangVar({ text: "accountCreateSuccess" }));
window.location.href = "/";
}else{
if(json.error === "User already exists"){
alert("同じユーザー名が既に使用されています")
}else if(json.error === "Please read and accept the terms and conditions"){
alert("利用規約とプライバシーポリシーに同意してください")
}else if(json.error === "Body Required"){
alert("Bodyは必須です")
}else if(json.error === "Password must be at least 8 characters long"){
alert("パスワードは8文字以上にしてください")
}else if(json.error === "Username must be at least 3 alphanumeric characters"){
alert("ユーザー名は3文字以上の英数字のIDにしてください")
}else if(json.error === "Failed to create user"){
alert("ユーザーの作成に失敗しました")
} else {
if (json.error === "User already exists") {
alert(MessageLangVar({ text: "sameUsername" }));
} else if (
json.error === "Please read and accept the terms and conditions"
) {
alert(MessageLangVar({ text: "termsAgreement" }));
} else if (json.error === "Body Required") {
alert(MessageLangVar({ text: "bodyRequired" }));
} else if (
json.error === "Password must be at least 8 characters long"
) {
alert(MessageLangVar({ text: "passwordCharacterLimit" }));
} else if (
json.error === "Username must be at least 3 alphanumeric characters"
) {
alert(MessageLangVar({ text: "usernameRestriction" }));
} else if (json.error === "Failed to create user") {
alert(MessageLangVar({ text: "userCreationFailed" }));
}
}
} catch (err) {
alert(`${MessageLangVar({ text: "connectionError" })}: ${err}`);
} finally {
setLoading(false);
}
};
return (
<form onSubmit={FormSubmit}>
<h1 className="title"><MessageLang text="Signup" /></h1>
<div
className="has-text-centered"
style={{ maxWidth: "400px", margin: "2rem auto" }}
>
<form onSubmit={FormSubmit} style={{ width: "100%" }}>
<h1 className="title">
<MessageLang text="Signup" />
</h1>
<label className="label"><MessageLang text="username" /></label>
<div className="field" style={{ position: 'relative' }}>
<span className="input-out">@</span>
<input type="text" name="username" placeholder="username" className="input" style={{ paddingLeft: '25px' }} required />
{loading && (
<div style={{ marginBottom: "1rem" }}>
<Icon icon="svg-spinners:6-dots-scale" width={32} height={32} />
</div>
<p className="help"><MessageLang text="idHelp" /></p>
)}
<label className="label"><MessageLang text="email" /></label>
<input className="input" type="email" name="email" placeholder="info@example.com" required />
<p className="help"><MessageLang text="mailHelp" /></p>
<label className="label">
<MessageLang text="username" />
</label>
<div className="field" style={{ position: "relative", width: "100%" }}>
<span
className="input-out"
style={{
position: "absolute",
left: "10px",
top: "50%",
transform: "translateY(-50%)",
pointerEvents: "none",
}}
>
@
</span>
<input
type="text"
name="username"
placeholder="username"
className="input"
style={{ paddingLeft: "30px", width: "100%" }}
required
disabled={loading}
/>
</div>
<p className="help">
<MessageLang text="idHelp" />
</p>
<label className="label"><MessageLang text="password" /></label>
<input type="password" name="password" className="input" placeholder="password" required />
<p className="help"><MessageLang text="passwordHelp" /></p>
<label className="label">
<MessageLang text="email" />
</label>
<input
className="input"
type="email"
name="email"
placeholder="info@example.com"
style={{ width: "100%" }}
required
disabled={loading}
/>
<p className="help">
<MessageLang text="mailHelp" />
</p>
<span style={{margin: "5px"}} />
<label className="label">
<MessageLang text="password" />
</label>
<input
type="password"
name="password"
className="input"
placeholder="password"
style={{ width: "100%" }}
required
disabled={loading}
/>
<p className="help">
<MessageLang text="passwordHelp" />
</p>
<label className="label checkbox">
<input type="checkbox" name="ruleCheck" required />
<Link href="/terms"></Link><Link href="/privacypolicy"></Link>
<span style={{ margin: "5px" }} />
<label className="label checkbox" style={{ justifyContent: "center" }}>
<input type="checkbox" name="ruleCheck" required disabled={loading} />
<MessageLang text="agree" />
{": "}
<Link href="/terms">
<MessageLang text="terms" />
</Link>
<MessageLang text="and" />
<Link href="/privacypolicy">
<MessageLang text="privacypolicy" />
</Link>
</label>
<button type="submit" className="button"></button>
<button
type="submit"
className="button is-primary"
style={{ width: "100%" }}
id="signup"
disabled={loading}
>
<MessageLang text="Signup" />
</button>
</form>
)
</div>
);
}

View File

@ -1,12 +1,12 @@
.input{
.input {
width: 20rem;
}
.button{
.button {
margin-top: 5px;
}
.input-out{
.input-out {
position: absolute;
left: 10px;
top: 50%;
@ -18,6 +18,6 @@
font-weight: normal;
}
.inputout-email{
.inputout-email {
z-index: 1;
}

5
src/app/sitemap.ts Normal file
View File

@ -0,0 +1,5 @@
import type { MetadataRoute } from "next";
export default function sitemap(): MetadataRoute.Sitemap {
return [];
}

View File

@ -1,7 +1,16 @@
.about{
margin: 10px;
.about {
margin: 0 auto;
margin-top: 10px;
max-width: 40rem;
}
.signup,.signin{
.signup,
.signin {
margin: 10px;
display: inline-block;
}
.peasAboutTitle {
position: absolute;
margin-left: 16px;
}

View File

@ -1,27 +1,33 @@
'use client';
"use client";
import { useEffect, useState } from "react";
import Cookies from "js-cookie";
import { messages } from "../messages";
import { MessageLangProps, SupportedLanguage, isValidLanguage } from "../types";
import { useEffect, useState } from 'react';
import Cookies from 'js-cookie';
import { messages } from '../messages';
import { MessageLangProps, SupportedLanguage, isValidLanguage } from '../types';
interface MessageLangPropsWithStyle
extends MessageLangProps,
React.HTMLAttributes<HTMLSpanElement> {}
export function MessageLang({ text, data = [] }: MessageLangProps) {
const [lang, setLang] = useState<SupportedLanguage>('ja');
export function MessageLang({
text,
data = [],
...htmlProps
}: MessageLangPropsWithStyle) {
const [lang, setLang] = useState<SupportedLanguage>("ja");
useEffect(() => {
const browserLang = navigator.language.split('-')[0];
const savedLang = Cookies.get('lang') || browserLang || 'ja';
const browserLang = navigator.language.split("-")[0];
const savedLang = Cookies.get("lang") || browserLang || "ja";
if (isValidLanguage(savedLang)) {
setLang(savedLang);
Cookies.set('lang', savedLang);
Cookies.set("lang", savedLang);
}
}, []);
let message = messages[lang][text];
data.forEach((value, index) => {
message = message.replace(new RegExp('\\{' + index + '\\}', 'g'), value);
message = message.replace(new RegExp("\\{" + index + "\\}", "g"), value);
});
return <span dangerouslySetInnerHTML={{__html: message}} />;
return <span dangerouslySetInnerHTML={{ __html: message }} {...htmlProps} />;
}

View File

@ -1,7 +1,12 @@
import { messages } from '../messages';
import { MessageLangProps, SupportedLanguage } from '../types';
export function MessageLangVar({ text, data = [], variables = {} }: MessageLangProps & { variables?: Record<string, string>; }, langFunc?: string) {
export function MessageLangVar({
text, data = [],
variables = {}
}: MessageLangProps & {
variables?: Record<string, string>;
}, langFunc?: string) {
const lang: SupportedLanguage = (langFunc as SupportedLanguage) || 'ja';
let message = messages[lang][text];

View File

@ -7,7 +7,10 @@ export const messages = {
使<br />
QRコードの印刷にも対応していて共有にも最適です
`,
ServerAboutTitle: "このサーバーについて",
or: "または",
and: "と",
agree: "同意",
Start: "今すぐ始める",
Signin: "サインイン",
Signup: "サインアップ",
@ -16,7 +19,30 @@ export const messages = {
username: "ユーザー名",
idHelp: "@から始まる英数字のID(3文字以上)",
mailHelp: "メールアドレスは受信できるものを使用してください",
passwordHelp: "8文字以上のパスワードを使用してください"
passwordHelp: "8文字以上のパスワードを使用してください",
signinError: "サインインに失敗しました",
mailVerify: "メール認証",
code: "コード",
submit: "送信",
codeCharacterCount: "コードは6桁で入力してください",
emailVerificationSuccess: `
`,
connectionError: "通信エラー",
accountCreateSuccess: `
`,
sameUsername: "同じユーザー名が既に使用されています",
termsAgreement: "利用規約とプライバシーポリシーに同意してください",
bodyRequired: "フォーム内容が送信されていません",
passwordCharacterLimit: "パスワードは8文字以上にしてください",
usernameRestriction: "ユーザー名は3文字以上の英数字のIDにしてください",
userCreationFailed: "ユーザーの作成に失敗しました",
terms: "利用規約",
privacypolicy: "プライバシーポリシー",
},
en: {
PeasAboutTitle: "About Peas",
@ -26,15 +52,44 @@ export const messages = {
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",
and: "and",
agree: "Agree",
Start: "Get started now",
Signin: "Sign In",
Signup: "Sign Up",
email: "Email",
password: "Password",
username: "Username",
idHelp: "ID starting with @ and consisting of English numbers (at least 3 characters)",
idHelp:
"ID starting with @ and consisting of English numbers (at least 3 characters)",
mailHelp: "Use an email address that can be received",
passwordHelp: "Use a password of at least 8 characters"
}
passwordHelp: "Use a password of at least 8 characters",
signinError: "Sign in failed",
mailVerify: "Mail Verify",
code: "Code",
submit: "Submit",
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",
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",
},
};

View File

@ -1,11 +1,12 @@
import mysql from 'mysql2/promise';
import mysql from "mysql2/promise";
import PeasConfig from "@/../peas.config";
const pool = mysql.createPool({
host: process.env.DB_HOST,
port: Number(process.env.DB_PORT),
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_DATABASE,
host: PeasConfig.databaseHost,
port: PeasConfig.databasePort,
user: PeasConfig.databaseUser,
password: PeasConfig.databasePassword,
database: PeasConfig.databaseDB,
waitForConnections: true,
connectionLimit: 10,
});

View File

@ -1,41 +1,32 @@
import * as nodemailer from 'nodemailer';
import * as dotenv from 'dotenv';
dotenv.config();
import * as nodemailer from "nodemailer";
import type SMTPTransport from "nodemailer/lib/smtp-transport";
import PeasConfig from "@/../peas.config";
export interface EmailMessage {
to: string | string[],
subject: string,
text?: string,
html?: string,
to: string | string[];
subject: string;
text?: string;
html?: string;
}
export async function createTransporter() {
// 環境変数の値をログ出力して確認
console.log('SMTP設定:', {
host: process.env.SMTP_HOST,
port: process.env.SMTP_PORT,
secure: process.env.SMTP_SECURE,
user: process.env.SMTP_USER
});
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: Number(process.env.SMTP_PORT),
secure: process.env.SMTP_SECURE === 'true',
host: PeasConfig.smtpHost,
port: PeasConfig.smtpPort,
secure: PeasConfig.smtpSecure,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASSWORD,
user: PeasConfig.smtpUser,
pass: PeasConfig.smtpPassword,
},
debug: process.env.DEBUG === 'true',
});
debug: process.env.DEBUG === "true",
} as SMTPTransport.Options);
// 接続テスト
try {
await transporter.verify();
console.log('SMTPサーバーに接続できました');
console.log("SMTPサーバーに接続できました");
} catch (error) {
console.error('SMTP接続テストに失敗:', error);
console.error("SMTP接続テストに失敗:", error);
throw error;
}
@ -43,18 +34,18 @@ export async function createTransporter() {
}
export async function sendMail(message: EmailMessage): Promise<void> {
try{
try {
const transporter = await createTransporter();
await transporter.sendMail({
from: process.env.SMTP_USER,
to: Array.isArray(message.to) ? message.to.join(',') : message.to,
from: PeasConfig.smtpUser,
to: Array.isArray(message.to) ? message.to.join(",") : message.to,
subject: message.subject,
text: message.text,
html: message.html
html: message.html,
});
console.log('メール送信成功');
}catch (error){
console.error('メール送信に失敗しました:', error);
console.log("メール送信成功");
} catch (error) {
console.error("メール送信に失敗しました:", error);
throw error;
}
}

24
src/lib/peas.config.d.ts vendored Normal file
View File

@ -0,0 +1,24 @@
interface SSLConfig {
ssl: boolean;
}
export interface PeasConfigType {
// Server Config
serverName: string;
serverDescription: string;
serverHost: string;
// SSL Config
ssl: SSLConfig;
// SMTP Config
smtpHost: string;
smtpPort: number;
smtpSecure: boolean;
smtpUser: string;
smtpPassword: string;
// Database Config
databaseHost: string;
databasePort: number;
databaseUser: string;
databasePassword: string;
databaseDB: string;
}