Compare commits

...

18 Commits

Author SHA1 Message Date
Cuishibing
f1b8abf414 feat: 添加图片代理接口,保护OSS API key 2026-04-26 21:57:37 +08:00
Cuishibing
f847e1c6c6 fix: 预览URL添加API key参数 2026-04-26 21:50:44 +08:00
Cuishibing
7c8cea2af7 fix: 修复my_oss API路径,添加/api前缀 2026-04-26 21:39:40 +08:00
Cuishibing
dfc9ef3e97 fix: 更新OSS API Key 2026-04-26 21:24:29 +08:00
Cuishibing
6bf666d250 feat: 添加数据库初始化SQL 2026-04-26 21:14:44 +08:00
Cuishibing
d6017c7389 fix: 使用mariadb镜像 2026-04-26 21:13:10 +08:00
Cuishibing
98eb1706ed fix: 修复docker-compose配置 2026-04-26 21:04:50 +08:00
Cuishibing
6e5cfeb960 feat: Docker配置优化,指定端口和数据目录 2026-04-26 21:03:07 +08:00
Cuishibing
3b241cd0d4 feat: Docker配置优化,MySQL数据持久化到本地 2026-04-26 20:57:29 +08:00
Cuishibing
0c03f95729 feat: 添加Docker配置,集成my_oss图片服务 2026-04-26 20:44:55 +08:00
Cuishibing
9263f7f460 feat: 图片上传改用my_oss服务 2026-04-26 20:19:44 +08:00
Cuishibing
73c6a779e0 init 2026-04-19 00:09:59 +08:00
Cuishibing
99e33b3b40 feat: 图片上传优化及审核状态交互改进 2026-03-31 00:03:08 +08:00
Cuishibing
b5e04ee3e4 feat: 添加房源审核功能及定时审核任务 2026-03-24 22:54:24 +08:00
Cuishibing
d5ab66d84e fix: 优化图片上传URL和房东入口登录检查体验 2026-03-22 23:04:39 +08:00
Cuishibing
6262bbb04b feat: 切换为MariaDB数据库存储 2026-03-22 22:10:41 +08:00
Cuishibing
9bd202fc90 docs: 添加项目文档 2026-03-22 22:01:21 +08:00
Cuishibing
762f928e5e fix: 修复SSH隧道端口转发和Cookie配置问题 2026-03-22 17:31:47 +08:00
20 changed files with 907 additions and 340 deletions

13
Dockerfile Normal file
View File

@@ -0,0 +1,13 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]

155
PROJECT.md Normal file
View File

@@ -0,0 +1,155 @@
# 城中村租房平台 - 项目文档
## 一、项目背景与目标
### 1.1 项目背景
城中村租房信息平台,为房东和租客提供简洁的租房信息服务。
### 1.2 项目目标
- 租客端:无需登录即可浏览、搜索房源,查看详情并联系房东
- 房东端:注册/登录后发布、管理房源,支持图片上传
- 移动端优先:适配手机访问
## 二、技术栈
| 类型 | 技术 |
|------|------|
| 框架 | Next.js 16 + TypeScript |
| 样式 | TailwindCSS |
| 数据库 | MariaDB 10 (192.168.0.196:3306) |
| 图片存储 | 本地文件系统 (public/uploads/) |
## 三、工程结构
```
smalltown2/
├── app/ # Next.js App Router
│ ├── api/ # API接口
│ │ ├── auth/ # 认证相关
│ │ │ ├── login/route.ts # 登录
│ │ │ ├── register/route.ts# 注册
│ │ │ └── me/route.ts # 当前用户
│ │ ├── houses/ # 房屋CRUD
│ │ │ ├── route.ts # 列表/创建
│ │ │ └── [id]/route.ts # 详情/更新/删除
│ │ ├── owner/houses/route.ts# 房东房源管理
│ │ ├── districts/route.ts # 地区列表
│ │ └── upload/route.ts # 图片上传
│ ├── house/[id]/page.tsx # 房屋详情页(租客)
│ ├── owner/ # 房东登录页
│ ├── owner/dashboard/ # 房东管理后台
│ └── page.tsx # 首页(租客浏览)
├── lib/
│ └── db.ts # 数据库连接池
├── public/uploads/ # 上传的图片
├── port_forwarding.sh # SSH隧道脚本
└── data/ # 本地数据已弃用data/已加入.gitignore
```
## 四、数据库表结构
### 4.1 users 表
```sql
CREATE TABLE users (
id VARCHAR(36) PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
password_hash VARCHAR(64) NOT NULL,
token VARCHAR(64),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
```
### 4.2 houses 表
```sql
CREATE TABLE houses (
id VARCHAR(36) PRIMARY KEY,
owner VARCHAR(50) NOT NULL,
title VARCHAR(200) NOT NULL,
description TEXT,
price INT NOT NULL,
district VARCHAR(50) NOT NULL,
address VARCHAR(500) NOT NULL,
phone VARCHAR(20) NOT NULL,
images JSON,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
```
### 4.3 districts 表
```sql
CREATE TABLE districts (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50) NOT NULL UNIQUE,
sort_order INT DEFAULT 0
);
```
## 五、页面路由
| 路径 | 说明 |
|------|------|
| `/` | 租客首页 - 浏览房源列表 |
| `/house/[id]` | 房屋详情页(租客) |
| `/owner` | 房东登录/注册页 |
| `/owner/dashboard` | 房东管理后台 |
## 六、API接口
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/api/auth/register` | 房东注册 |
| POST | `/api/auth/login` | 房东登录 |
| GET | `/api/auth/me` | 获取当前用户 |
| DELETE | `/api/auth/me` | 登出 |
| GET | `/api/houses` | 获取房源列表支持district/keyword筛选 |
| POST | `/api/houses` | 创建房源(需登录) |
| GET | `/api/houses/[id]` | 获取房源详情 |
| PUT | `/api/houses/[id]` | 更新房源(需登录,房东只能修改自己的) |
| DELETE | `/api/houses/[id]` | 删除房源(需登录,房东只能删除自己的) |
| GET | `/api/owner/houses` | 获取房东自己的房源(需登录) |
| GET | `/api/districts` | 获取地区列表 |
| POST | `/api/upload` | 上传图片 |
## 七、已完成功能
### 7.1 租客端
- [x] 房源列表浏览(按地区筛选、关键词搜索)
- [x] 房屋详情查看
- [x] 联系房东(显示电话)
- [x] 移动端UI优化
### 7.2 房东端
- [x] 用户注册/登录Cookie认证
- [x] 发布新房源
- [x] 编辑/删除自己的房源
- [x] 图片上传(文件选择/摄像头拍照)
### 7.3 系统
- [x] MariaDB数据库集成
- [x] 图片本地存储
- [x] SSH端口转发脚本远程访问
## 八、部署说明
### 8.1 本地开发
```bash
npm run dev
# 访问 http://localhost:3000
```
### 8.2 生产构建
```bash
npm run build
npm start
# 默认端口3000可通过 PORT=3001 npm start 指定端口
```
### 8.3 图片服务(规划中)
计划将图片上传独立出来,新建 `smalltown-upload` 项目处理图片存储和压缩。
## 九、注意事项
1. 数据库连接配置在 `lib/db.ts`,生产环境注意安全
2. 上传的图片存储在 `public/uploads/` 目录,已加入 .gitignore
3. data/ 目录已加入 .gitignore旧的JSON存储方式
4. Cookie设置 `secure: false` 以支持HTTP访问

View File

@@ -1,26 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import fs from 'fs/promises';
import path from 'path';
import crypto from 'crypto'; import crypto from 'crypto';
import pool from '@/lib/db';
const USERS_FILE = path.join(process.cwd(), 'data/users.json');
interface User {
id: string;
username: string;
passwordHash: string;
token: string;
createdAt: string;
}
async function readUsers(): Promise<User[]> {
try {
const data = await fs.readFile(USERS_FILE, 'utf-8');
return JSON.parse(data);
} catch {
return [];
}
}
function hashPassword(password: string): string { function hashPassword(password: string): string {
return crypto.createHash('sha256').update(password).digest('hex'); return crypto.createHash('sha256').update(password).digest('hex');
@@ -31,6 +11,7 @@ function generateToken(): string {
} }
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
let connection;
try { try {
const { username, password } = await request.json(); const { username, password } = await request.json();
@@ -38,21 +19,21 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: '用户名和密码不能为空' }, { status: 400 }); return NextResponse.json({ error: '用户名和密码不能为空' }, { status: 400 });
} }
const users = await readUsers(); connection = await pool.getConnection();
const user = users.find(u => u.username === username);
if (!user || user.passwordHash !== hashPassword(password)) { const [rows] = await connection.query<any[]>('SELECT * FROM users WHERE username = ?', [username]);
if (rows.length === 0 || rows[0].password_hash !== hashPassword(password)) {
return NextResponse.json({ error: '用户名或密码错误' }, { status: 401 }); return NextResponse.json({ error: '用户名或密码错误' }, { status: 401 });
} }
const newToken = generateToken(); const newToken = generateToken();
user.token = newToken; await connection.query('UPDATE users SET token = ? WHERE id = ?', [newToken, rows[0].id]);
await fs.writeFile(USERS_FILE, JSON.stringify(users, null, 2));
const response = NextResponse.json({ success: true, username: user.username }); const response = NextResponse.json({ success: true, username });
response.cookies.set('auth_token', newToken, { response.cookies.set('auth_token', newToken, {
httpOnly: true, httpOnly: true,
secure: process.env.NODE_ENV === 'production', secure: false,
sameSite: 'lax', sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7, maxAge: 60 * 60 * 24 * 7,
path: '/' path: '/'
@@ -62,5 +43,7 @@ export async function POST(request: NextRequest) {
} catch (error) { } catch (error) {
console.error('Login error:', error); console.error('Login error:', error);
return NextResponse.json({ error: '登录失败' }, { status: 500 }); return NextResponse.json({ error: '登录失败' }, { status: 500 });
} finally {
if (connection) connection.release();
} }
} }

View File

@@ -1,25 +1,8 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import fs from 'fs/promises'; import pool from '@/lib/db';
import path from 'path';
const USERS_FILE = path.join(process.cwd(), 'data/users.json');
interface User {
id: string;
username: string;
token: string;
}
async function readUsers(): Promise<User[]> {
try {
const data = await fs.readFile(USERS_FILE, 'utf-8');
return JSON.parse(data);
} catch {
return [];
}
}
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
let connection;
try { try {
const token = request.cookies.get('auth_token')?.value; const token = request.cookies.get('auth_token')?.value;
@@ -27,17 +10,20 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ user: null }); return NextResponse.json({ user: null });
} }
const users = await readUsers(); connection = await pool.getConnection();
const user = users.find(u => u.token === token);
if (!user) { const [rows] = await connection.query<any[]>('SELECT username FROM users WHERE token = ?', [token]);
if (rows.length === 0) {
return NextResponse.json({ user: null }); return NextResponse.json({ user: null });
} }
return NextResponse.json({ user: { username: user.username } }); return NextResponse.json({ user: { username: rows[0].username } });
} catch (error) { } catch (error) {
console.error('Get user error:', error); console.error('Get user error:', error);
return NextResponse.json({ user: null }); return NextResponse.json({ user: null });
} finally {
if (connection) connection.release();
} }
} }

View File

@@ -1,31 +1,6 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import fs from 'fs/promises';
import path from 'path';
import crypto from 'crypto'; import crypto from 'crypto';
import pool from '@/lib/db';
const DATA_DIR = path.join(process.cwd(), 'data');
const USERS_FILE = path.join(DATA_DIR, 'users.json');
interface User {
id: string;
username: string;
passwordHash: string;
token: string;
createdAt: string;
}
async function readUsers(): Promise<User[]> {
try {
const data = await fs.readFile(USERS_FILE, 'utf-8');
return JSON.parse(data);
} catch {
return [];
}
}
async function writeUsers(users: User[]): Promise<void> {
await fs.writeFile(USERS_FILE, JSON.stringify(users, null, 2));
}
function hashPassword(password: string): string { function hashPassword(password: string): string {
return crypto.createHash('sha256').update(password).digest('hex'); return crypto.createHash('sha256').update(password).digest('hex');
@@ -36,6 +11,7 @@ function generateToken(): string {
} }
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
let connection;
try { try {
const { username, password } = await request.json(); const { username, password } = await request.json();
@@ -47,28 +23,24 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: '用户名至少3位密码至少6位' }, { status: 400 }); return NextResponse.json({ error: '用户名至少3位密码至少6位' }, { status: 400 });
} }
const users = await readUsers(); connection = await pool.getConnection();
if (users.find(u => u.username === username)) { const [rows] = await connection.query<any[]>('SELECT id FROM users WHERE username = ?', [username]);
if (rows.length > 0) {
return NextResponse.json({ error: '用户名已存在' }, { status: 400 }); return NextResponse.json({ error: '用户名已存在' }, { status: 400 });
} }
const token = generateToken(); const token = generateToken();
const newUser: User = { await connection.query(
id: crypto.randomUUID(), 'INSERT INTO users (id, username, password_hash, token, created_at) VALUES (?, ?, ?, ?, ?)',
username, [crypto.randomUUID(), username, hashPassword(password), token, new Date()]
passwordHash: hashPassword(password), );
token,
createdAt: new Date().toISOString()
};
users.push(newUser);
await writeUsers(users);
const response = NextResponse.json({ success: true, username }); const response = NextResponse.json({ success: true, username });
response.cookies.set('auth_token', token, { response.cookies.set('auth_token', token, {
httpOnly: true, httpOnly: true,
secure: process.env.NODE_ENV === 'production', secure: false,
sameSite: 'lax', sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7, maxAge: 60 * 60 * 24 * 7,
path: '/' path: '/'
@@ -78,5 +50,7 @@ export async function POST(request: NextRequest) {
} catch (error) { } catch (error) {
console.error('Register error:', error); console.error('Register error:', error);
return NextResponse.json({ error: '注册失败' }, { status: 500 }); return NextResponse.json({ error: '注册失败' }, { status: 500 });
} finally {
if (connection) connection.release();
} }
} }

View File

@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from 'next/server';
import pool from '@/lib/db';
const CRON_SECRET = 'smalltown_review_secret_2024';
export async function POST(request: NextRequest) {
let connection;
try {
const authHeader = request.headers.get('authorization');
if (authHeader !== `Bearer ${CRON_SECRET}`) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
connection = await pool.getConnection();
const [result] = await connection.query(
"UPDATE houses SET status = 'approved', reviewed_at = NOW() WHERE status = 'pending'"
);
const affectedRows = (result as any).affectedRowCount || 0;
connection.release();
return NextResponse.json({ success: true, approved: affectedRows });
} catch (error) {
console.error('Review error:', error);
return NextResponse.json({ error: '审核失败' }, { status: 500 });
} finally {
if (connection) connection.release();
}
}

View File

@@ -1,17 +1,21 @@
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import fs from 'fs/promises'; import pool from '@/lib/db';
import path from 'path';
const DATA_DIR = path.join(process.cwd(), 'data');
const DISTRICTS_FILE = path.join(DATA_DIR, 'districts.json');
export async function GET() { export async function GET() {
let connection;
try { try {
const data = await fs.readFile(DISTRICTS_FILE, 'utf-8'); connection = await pool.getConnection();
const districts = JSON.parse(data);
const [rows] = await connection.query<any[]>('SELECT name FROM districts ORDER BY sort_order, id');
const districts = rows.map((row: any) => row.name);
connection.release();
return NextResponse.json({ districts }); return NextResponse.json({ districts });
} catch (error) { } catch (error) {
console.error('Get districts error:', error); console.error('Get districts error:', error);
return NextResponse.json({ districts: [] }); return NextResponse.json({ districts: [] });
} finally {
if (connection) connection.release();
} }
} }

View File

@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from 'next/server';
const OSS_URL = process.env.OSS_URL || 'http://localhost:9000';
const API_KEY = process.env.OSS_API_KEY || '7cf93760ea49b750c96e6078b364e5f0';
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ fileKey: string }> }
) {
try {
const { fileKey } = await params;
const res = await fetch(`${OSS_URL}/api/files/${fileKey}/preview?key=${API_KEY}`);
if (!res.ok) {
return new NextResponse('File not found', { status: 404 });
}
const imageBuffer = await res.arrayBuffer();
return new NextResponse(imageBuffer, {
headers: {
'Content-Type': 'image/jpeg',
'Cache-Control': 'public, max-age=31536000',
},
});
} catch (error) {
console.error('Proxy error:', error);
return new NextResponse('Error', { status: 500 });
}
}

View File

@@ -1,69 +1,43 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import fs from 'fs/promises'; import pool from '@/lib/db';
import path from 'path';
const DATA_DIR = path.join(process.cwd(), 'data');
const HOUSES_FILE = path.join(DATA_DIR, 'houses.json');
const USERS_FILE = path.join(DATA_DIR, 'users.json');
interface House {
id: string;
owner: string;
title: string;
description: string;
price: number;
district: string;
address: string;
phone: string;
images: string[];
createdAt: string;
}
interface User {
id: string;
username: string;
token: string;
}
async function readFile<T>(filePath: string, defaultValue: T): Promise<T> {
try {
const data = await fs.readFile(filePath, 'utf-8');
return JSON.parse(data);
} catch {
return defaultValue;
}
}
async function getUserFromToken(token: string): Promise<User | null> {
const users = await readFile<User[]>(USERS_FILE, []);
return users.find(u => u.token === token) || null;
}
async function readHouses(): Promise<House[]> {
return readFile<House[]>(HOUSES_FILE, []);
}
async function writeHouses(houses: House[]): Promise<void> {
await fs.writeFile(HOUSES_FILE, JSON.stringify(houses, null, 2));
}
export async function GET( export async function GET(
request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> }
) { ) {
let connection;
try { try {
const { id } = await params; const { id } = await params;
const houses = await readHouses(); connection = await pool.getConnection();
const house = houses.find(h => h.id === id);
if (!house) { const [rows] = await connection.query<any[]>("SELECT * FROM houses WHERE id = ? AND status = 'approved'", [id]);
return NextResponse.json({ error: '房屋不存在' }, { status: 404 });
if (rows.length === 0) {
connection.release();
return NextResponse.json({ error: '房屋不存在或待审核' }, { status: 404 });
} }
const row = rows[0];
const house = {
id: row.id,
owner: row.owner,
title: row.title,
description: row.description,
price: row.price,
district: row.district,
address: row.address,
phone: row.phone,
images: row.images ? JSON.parse(row.images) : [],
createdAt: row.created_at
};
connection.release();
return NextResponse.json({ house }); return NextResponse.json({ house });
} catch (error) { } catch (error) {
console.error('Get house error:', error); console.error('Get house error:', error);
return NextResponse.json({ error: '获取房屋信息失败' }, { status: 500 }); return NextResponse.json({ error: '获取房屋信息失败' }, { status: 500 });
} finally {
if (connection) connection.release();
} }
} }
@@ -71,6 +45,7 @@ export async function PUT(
request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> }
) { ) {
let connection;
try { try {
const token = request.cookies.get('auth_token')?.value; const token = request.cookies.get('auth_token')?.value;
@@ -78,43 +53,68 @@ export async function PUT(
return NextResponse.json({ error: '请先登录' }, { status: 401 }); return NextResponse.json({ error: '请先登录' }, { status: 401 });
} }
const user = await getUserFromToken(token); const { id } = await params;
if (!user) { connection = await pool.getConnection();
const [users] = await connection.query<any[]>('SELECT username FROM users WHERE token = ?', [token]);
if (users.length === 0) {
connection.release();
return NextResponse.json({ error: '用户不存在' }, { status: 401 }); return NextResponse.json({ error: '用户不存在' }, { status: 401 });
} }
const { id } = await params; const [houses] = await connection.query<any[]>('SELECT * FROM houses WHERE id = ?', [id]);
const houses = await readHouses(); if (houses.length === 0) {
const houseIndex = houses.findIndex(h => h.id === id); connection.release();
if (houseIndex === -1) {
return NextResponse.json({ error: '房屋不存在' }, { status: 404 }); return NextResponse.json({ error: '房屋不存在' }, { status: 404 });
} }
if (houses[houseIndex].owner !== user.username) { if (houses[0].owner !== users[0].username) {
connection.release();
return NextResponse.json({ error: '无权修改此房屋' }, { status: 403 }); return NextResponse.json({ error: '无权修改此房屋' }, { status: 403 });
} }
const body = await request.json(); const body = await request.json();
const { title, description, price, district, address, phone, images } = body; const { title, description, price, district, address, phone, images } = body;
houses[houseIndex] = { await connection.query(
...houses[houseIndex], 'UPDATE houses SET title = ?, description = ?, price = ?, district = ?, address = ?, phone = ?, images = ?, status = ?, reject_reason = NULL WHERE id = ?',
title: title || houses[houseIndex].title, [
description: description ?? houses[houseIndex].description, title || houses[0].title,
price: price !== undefined ? Number(price) : houses[houseIndex].price, description ?? houses[0].description,
district: district || houses[houseIndex].district, price !== undefined ? Number(price) : houses[0].price,
address: address || houses[houseIndex].address, district || houses[0].district,
phone: phone || houses[houseIndex].phone, address || houses[0].address,
images: images || houses[houseIndex].images phone || houses[0].phone,
images ? JSON.stringify(images) : houses[0].images,
'pending',
id
]
);
const [updated] = await connection.query<any[]>('SELECT * FROM houses WHERE id = ?', [id]);
const row = updated[0];
const house = {
id: row.id,
owner: row.owner,
title: row.title,
description: row.description,
price: row.price,
district: row.district,
address: row.address,
phone: row.phone,
images: row.images ? JSON.parse(row.images) : [],
status: row.status,
reject_reason: row.reject_reason,
createdAt: row.created_at
}; };
await writeHouses(houses); connection.release();
return NextResponse.json({ success: true, house });
return NextResponse.json({ success: true, house: houses[houseIndex] });
} catch (error) { } catch (error) {
console.error('Update house error:', error); console.error('Update house error:', error);
return NextResponse.json({ error: '更新房屋失败' }, { status: 500 }); return NextResponse.json({ error: '更新房屋失败' }, { status: 500 });
} finally {
if (connection) connection.release();
} }
} }
@@ -122,6 +122,7 @@ export async function DELETE(
request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> }
) { ) {
let connection;
try { try {
const token = request.cookies.get('auth_token')?.value; const token = request.cookies.get('auth_token')?.value;
@@ -129,29 +130,34 @@ export async function DELETE(
return NextResponse.json({ error: '请先登录' }, { status: 401 }); return NextResponse.json({ error: '请先登录' }, { status: 401 });
} }
const user = await getUserFromToken(token); const { id } = await params;
if (!user) { connection = await pool.getConnection();
const [users] = await connection.query<any[]>('SELECT username FROM users WHERE token = ?', [token]);
if (users.length === 0) {
connection.release();
return NextResponse.json({ error: '用户不存在' }, { status: 401 }); return NextResponse.json({ error: '用户不存在' }, { status: 401 });
} }
const { id } = await params; const [houses] = await connection.query<any[]>('SELECT * FROM houses WHERE id = ?', [id]);
const houses = await readHouses(); if (houses.length === 0) {
const house = houses.find(h => h.id === id); connection.release();
if (!house) {
return NextResponse.json({ error: '房屋不存在' }, { status: 404 }); return NextResponse.json({ error: '房屋不存在' }, { status: 404 });
} }
if (house.owner !== user.username) { if (houses[0].owner !== users[0].username) {
connection.release();
return NextResponse.json({ error: '无权删除此房屋' }, { status: 403 }); return NextResponse.json({ error: '无权删除此房屋' }, { status: 403 });
} }
const newHouses = houses.filter(h => h.id !== id); await connection.query('DELETE FROM houses WHERE id = ?', [id]);
await writeHouses(newHouses);
connection.release();
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
} catch (error) { } catch (error) {
console.error('Delete house error:', error); console.error('Delete house error:', error);
return NextResponse.json({ error: '删除房屋失败' }, { status: 500 }); return NextResponse.json({ error: '删除房屋失败' }, { status: 500 });
} finally {
if (connection) connection.release();
} }
} }

View File

@@ -1,84 +1,59 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import fs from 'fs/promises';
import path from 'path';
import crypto from 'crypto'; import crypto from 'crypto';
import pool from '@/lib/db';
const DATA_DIR = path.join(process.cwd(), 'data');
const HOUSES_FILE = path.join(DATA_DIR, 'houses.json');
const USERS_FILE = path.join(DATA_DIR, 'users.json');
interface House {
id: string;
owner: string;
title: string;
description: string;
price: number;
district: string;
address: string;
phone: string;
images: string[];
createdAt: string;
}
interface User {
id: string;
username: string;
token: string;
}
async function readFile<T>(filePath: string, defaultValue: T): Promise<T> {
try {
const data = await fs.readFile(filePath, 'utf-8');
return JSON.parse(data);
} catch {
return defaultValue;
}
}
async function getUserFromToken(token: string): Promise<User | null> {
const users = await readFile<User[]>(USERS_FILE, []);
return users.find(u => u.token === token) || null;
}
async function readHouses(): Promise<House[]> {
return readFile<House[]>(HOUSES_FILE, []);
}
async function writeHouses(houses: House[]): Promise<void> {
await fs.writeFile(HOUSES_FILE, JSON.stringify(houses, null, 2));
}
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
let connection;
try { try {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const district = searchParams.get('district'); const district = searchParams.get('district');
const keyword = searchParams.get('keyword'); const keyword = searchParams.get('keyword');
let houses = await readHouses(); connection = await pool.getConnection();
let sql = "SELECT * FROM houses WHERE status = 'approved'";
const params: any[] = [];
if (district && district !== '全部') { if (district && district !== '全部') {
houses = houses.filter(h => h.district === district); sql += ' AND district = ?';
params.push(district);
} }
if (keyword) { if (keyword) {
const kw = keyword.toLowerCase(); sql += ' AND (title LIKE ? OR address LIKE ? OR description LIKE ?)';
houses = houses.filter(h => const kw = `%${keyword}%`;
h.title.toLowerCase().includes(kw) || params.push(kw, kw, kw);
h.address.toLowerCase().includes(kw) ||
h.description.toLowerCase().includes(kw)
);
} }
houses.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); sql += ' ORDER BY created_at DESC';
const [rows] = await connection.query<any[]>(sql, params);
const houses = rows.map((row: any) => ({
id: row.id,
owner: row.owner,
title: row.title,
description: row.description,
price: row.price,
district: row.district,
address: row.address,
phone: row.phone,
images: row.images ? JSON.parse(row.images) : [],
createdAt: row.created_at
}));
connection.release();
return NextResponse.json({ houses }); return NextResponse.json({ houses });
} catch (error) { } catch (error) {
console.error('Get houses error:', error); console.error('Get houses error:', error);
return NextResponse.json({ error: '获取房屋列表失败' }, { status: 500 }); return NextResponse.json({ error: '获取房屋列表失败' }, { status: 500 });
} finally {
if (connection) connection.release();
} }
} }
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
let connection;
try { try {
const token = request.cookies.get('auth_token')?.value; const token = request.cookies.get('auth_token')?.value;
@@ -86,8 +61,12 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: '请先登录' }, { status: 401 }); return NextResponse.json({ error: '请先登录' }, { status: 401 });
} }
const user = await getUserFromToken(token); connection = await pool.getConnection();
if (!user) {
const [users] = await connection.query<any[]>('SELECT username FROM users WHERE token = ?', [token]);
if (users.length === 0) {
connection.release();
return NextResponse.json({ error: '用户不存在' }, { status: 401 }); return NextResponse.json({ error: '用户不存在' }, { status: 401 });
} }
@@ -95,12 +74,19 @@ export async function POST(request: NextRequest) {
const { title, description, price, district, address, phone, images } = body; const { title, description, price, district, address, phone, images } = body;
if (!title || !price || !district || !address || !phone) { if (!title || !price || !district || !address || !phone) {
connection.release();
return NextResponse.json({ error: '请填写完整信息' }, { status: 400 }); return NextResponse.json({ error: '请填写完整信息' }, { status: 400 });
} }
const house: House = { const id = crypto.randomUUID();
id: crypto.randomUUID(), await connection.query(
owner: user.username, 'INSERT INTO houses (id, owner, title, description, price, district, address, phone, images, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[id, users[0].username, title, description || '', Number(price), district, address, phone, JSON.stringify(images || []), 'pending', new Date()]
);
const house = {
id,
owner: users[0].username,
title, title,
description: description || '', description: description || '',
price: Number(price), price: Number(price),
@@ -108,16 +94,16 @@ export async function POST(request: NextRequest) {
address, address,
phone, phone,
images: images || [], images: images || [],
createdAt: new Date().toISOString() status: 'pending',
createdAt: new Date()
}; };
const houses = await readHouses(); connection.release();
houses.push(house);
await writeHouses(houses);
return NextResponse.json({ success: true, house }); return NextResponse.json({ success: true, house });
} catch (error) { } catch (error) {
console.error('Create house error:', error); console.error('Create house error:', error);
return NextResponse.json({ error: '创建房屋失败' }, { status: 500 }); return NextResponse.json({ error: '创建房屋失败' }, { status: 500 });
} finally {
if (connection) connection.release();
} }
} }

View File

@@ -1,49 +1,8 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import fs from 'fs/promises'; import pool from '@/lib/db';
import path from 'path';
const DATA_DIR = path.join(process.cwd(), 'data');
const HOUSES_FILE = path.join(DATA_DIR, 'houses.json');
const USERS_FILE = path.join(DATA_DIR, 'users.json');
interface House {
id: string;
owner: string;
title: string;
description: string;
price: number;
district: string;
address: string;
phone: string;
images: string[];
createdAt: string;
}
interface User {
id: string;
username: string;
token: string;
}
async function readFile<T>(filePath: string, defaultValue: T): Promise<T> {
try {
const data = await fs.readFile(filePath, 'utf-8');
return JSON.parse(data);
} catch {
return defaultValue;
}
}
async function getUserFromToken(token: string): Promise<User | null> {
const users = await readFile<User[]>(USERS_FILE, []);
return users.find(u => u.token === token) || null;
}
async function readHouses(): Promise<House[]> {
return readFile<House[]>(HOUSES_FILE, []);
}
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
let connection;
try { try {
const token = request.cookies.get('auth_token')?.value; const token = request.cookies.get('auth_token')?.value;
@@ -51,19 +10,42 @@ export async function GET(request: NextRequest) {
return NextResponse.json({ error: '请先登录' }, { status: 401 }); return NextResponse.json({ error: '请先登录' }, { status: 401 });
} }
const user = await getUserFromToken(token); connection = await pool.getConnection();
if (!user) {
const [users] = await connection.query<any[]>('SELECT username FROM users WHERE token = ?', [token]);
if (users.length === 0) {
connection.release();
return NextResponse.json({ error: '用户不存在' }, { status: 401 }); return NextResponse.json({ error: '用户不存在' }, { status: 401 });
} }
const houses = await readHouses(); const [rows] = await connection.query<any[]>(
const myHouses = houses 'SELECT * FROM houses WHERE owner = ? ORDER BY created_at DESC',
.filter(h => h.owner === user.username) [users[0].username]
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); );
return NextResponse.json({ houses: myHouses }); const houses = rows.map((row: any) => ({
id: row.id,
owner: row.owner,
title: row.title,
description: row.description,
price: row.price,
district: row.district,
address: row.address,
phone: row.phone,
images: row.images ? JSON.parse(row.images) : [],
status: row.status,
reject_reason: row.reject_reason,
reviewed_at: row.reviewed_at,
createdAt: row.created_at
}));
connection.release();
return NextResponse.json({ houses });
} catch (error) { } catch (error) {
console.error('Get my houses error:', error); console.error('Get my houses error:', error);
return NextResponse.json({ error: '获取房屋列表失败' }, { status: 500 }); return NextResponse.json({ error: '获取房屋列表失败' }, { status: 500 });
} finally {
if (connection) connection.release();
} }
} }

View File

@@ -1,10 +1,13 @@
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import fs from 'fs/promises'; import sharp from 'sharp';
import path from 'path';
const UPLOAD_DIR = path.join(process.cwd(), 'public/uploads'); const MAX_SIZE = 5 * 1024 * 1024; // 5MB
const OSS_URL = process.env.OSS_URL || 'http://localhost:9000';
const API_KEY = process.env.OSS_API_KEY || '7cf93760ea49b750c96e6078b364e5f0';
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
let imageBuffer: Buffer;
try { try {
const formData = await request.formData(); const formData = await request.formData();
const file = formData.get('file') as File | null; const file = formData.get('file') as File | null;
@@ -18,15 +21,63 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: '仅支持 JPG、PNG、GIF、WebP 格式' }, { status: 400 }); return NextResponse.json({ error: '仅支持 JPG、PNG、GIF、WebP 格式' }, { status: 400 });
} }
const ext = file.name.split('.').pop() || 'jpg';
const filename = `${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`;
const filepath = path.join(UPLOAD_DIR, filename);
const buffer = await file.arrayBuffer(); const buffer = await file.arrayBuffer();
await fs.writeFile(filepath, Buffer.from(buffer)); imageBuffer = Buffer.from(buffer);
const url = `/uploads/${filename}`; const image = sharp(imageBuffer);
return NextResponse.json({ url }); const metadata = await image.metadata();
if (imageBuffer.length > MAX_SIZE) {
let quality = 85;
if (metadata.width && metadata.width > 1920) {
imageBuffer = await image
.resize(1920, null, { withoutEnlargement: true })
.toBuffer() as any;
}
while (imageBuffer.length > MAX_SIZE && quality > 30) {
imageBuffer = await sharp(imageBuffer)
.jpeg({ quality, progressive: true })
.toBuffer() as any;
quality -= 10;
}
if (imageBuffer.length > MAX_SIZE && metadata.width && metadata.width > 800) {
imageBuffer = await sharp(imageBuffer)
.resize(800, null, { withoutEnlargement: true })
.jpeg({ quality: 70, progressive: true })
.toBuffer() as any;
}
} else {
imageBuffer = await sharp(imageBuffer)
.jpeg({ quality: 85, progressive: true })
.toBuffer() as any;
}
const formData2 = new FormData();
const uint8Array = new Uint8Array(imageBuffer);
const blob = new Blob([uint8Array], { type: 'image/jpeg' });
formData2.append('file', blob, 'image.jpg');
const uploadRes = await fetch(`${OSS_URL}/api/files`, {
method: 'POST',
headers: {
'x-api-key': API_KEY,
},
body: formData2,
});
if (!uploadRes.ok) {
const err = await uploadRes.text();
console.error('OSS upload failed:', err);
return NextResponse.json({ error: '上传失败' }, { status: 500 });
}
const fileData = await uploadRes.json();
const url = `/api/files/${fileData.fileKey}`;
return NextResponse.json({ url, fileKey: fileData.fileKey });
} catch (error) { } catch (error) {
console.error('Upload error:', error); console.error('Upload error:', error);
return NextResponse.json({ error: '上传失败' }, { status: 500 }); return NextResponse.json({ error: '上传失败' }, { status: 500 });
@@ -36,16 +87,24 @@ export async function POST(request: NextRequest) {
export async function DELETE(request: NextRequest) { export async function DELETE(request: NextRequest) {
try { try {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const filename = searchParams.get('filename'); const fileKey = searchParams.get('fileKey');
if (!filename) { if (!fileKey) {
return NextResponse.json({ error: '缺少文件' }, { status: 400 }); return NextResponse.json({ error: '缺少文件标识' }, { status: 400 });
} }
const filepath = path.join(UPLOAD_DIR, path.basename(filename)); const res = await fetch(`${OSS_URL}/api/files/${fileKey}`, {
await fs.unlink(filepath); method: 'DELETE',
headers: {
'x-api-key': API_KEY,
},
});
if (res.ok) {
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
}
return NextResponse.json({ error: '删除失败' }, { status: 500 });
} catch (error) { } catch (error) {
console.error('Delete error:', error); console.error('Delete error:', error);
return NextResponse.json({ error: '删除失败' }, { status: 500 }); return NextResponse.json({ error: '删除失败' }, { status: 500 });

View File

@@ -13,6 +13,9 @@ interface House {
address: string; address: string;
phone: string; phone: string;
images: string[]; images: string[];
status: string;
reject_reason?: string;
reviewed_at?: string;
createdAt: string; createdAt: string;
} }
@@ -249,6 +252,7 @@ export default function OwnerDashboard() {
<div className="space-y-4"> <div className="space-y-4">
{houses.map((house) => ( {houses.map((house) => (
<div key={house.id} className="bg-white rounded-xl overflow-hidden shadow-sm"> <div key={house.id} className="bg-white rounded-xl overflow-hidden shadow-sm">
{house.status === 'approved' ? (
<Link href={`/house/${house.id}`}> <Link href={`/house/${house.id}`}>
<div className="flex"> <div className="flex">
{house.images && house.images.length > 0 ? ( {house.images && house.images.length > 0 ? (
@@ -271,9 +275,48 @@ export default function OwnerDashboard() {
</span> </span>
<span className="text-gray-400 text-xs">{house.district}</span> <span className="text-gray-400 text-xs">{house.district}</span>
</div> </div>
<div className="mt-2">
<span className="inline-block bg-green-100 text-green-700 text-xs px-2 py-1 rounded"></span>
</div>
</div> </div>
</div> </div>
</Link> </Link>
) : (
<div className="flex opacity-60">
{house.images && house.images.length > 0 ? (
<img
src={house.images[0]}
alt={house.title}
className="w-28 h-28 object-cover flex-shrink-0"
/>
) : (
<div className="w-28 h-28 bg-gradient-to-br from-orange-100 to-orange-200 flex items-center justify-center text-3xl flex-shrink-0">
🏠
</div>
)}
<div className="flex-1 p-3 min-w-0">
<h3 className="font-semibold text-gray-900 truncate">{house.title}</h3>
<p className="text-gray-500 text-sm truncate">{house.address}</p>
<div className="flex items-center justify-between mt-2">
<span className="text-orange-500 font-bold">
¥{house.price}/
</span>
<span className="text-gray-400 text-xs">{house.district}</span>
</div>
<div className="mt-2">
{house.status === 'pending' && (
<span className="inline-block bg-yellow-100 text-yellow-700 text-xs px-2 py-1 rounded"></span>
)}
{house.status === 'rejected' && (
<span className="inline-block bg-red-100 text-red-700 text-xs px-2 py-1 rounded"></span>
)}
</div>
{house.status === 'rejected' && house.reject_reason && (
<p className="text-red-500 text-xs mt-1">{house.reject_reason}</p>
)}
</div>
</div>
)}
<div className="border-t border-gray-100 flex"> <div className="border-t border-gray-100 flex">
<button <button
onClick={() => openForm(house)} onClick={() => openForm(house)}

View File

@@ -11,23 +11,45 @@ export default function OwnerPage() {
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [error, setError] = useState(""); const [error, setError] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [user, setUser] = useState<{ username: string } | null>(null); const [checking, setChecking] = useState(true);
const [ready, setReady] = useState(false);
useEffect(() => { useEffect(() => {
checkAuth(); let cancelled = false;
}, []);
async function checkAuth() { async function checkAuth() {
try { try {
const res = await fetch("/api/auth/me"); const res = await fetch("/api/auth/me");
const data = await res.json(); const data = await res.json();
if (data.user) { if (!cancelled && data.user) {
setUser(data.user); router.replace("/owner/dashboard");
router.push("/owner/dashboard"); return;
} }
} catch (error) { } catch (error) {
console.error("检查登录状态失败", error); console.error("检查登录状态失败", error);
} }
if (!cancelled) {
setChecking(false);
setReady(true);
}
}
checkAuth();
return () => {
cancelled = true;
};
}, [router]);
if (!ready) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<div className="text-4xl mb-2 animate-bounce">🏗</div>
<p className="text-gray-500">...</p>
</div>
</div>
);
} }
async function handleSubmit(e: React.FormEvent) { async function handleSubmit(e: React.FormEvent) {

41
docker-compose.yml Normal file
View File

@@ -0,0 +1,41 @@
version: '3.8'
services:
mysql:
image: mariadb:10.11
container_name: smalltown_mysql
restart: always
environment:
MYSQL_ROOT_PASSWORD: rootpass
MYSQL_DATABASE: smalltown
MYSQL_USER: smalltown
MYSQL_PASSWORD: MyPassword1+
ports:
- "9001:3306"
volumes:
- /home/cui/mysql_data:/var/lib/mysql
- ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 5s
retries: 5
smalltown:
build: .
container_name: smalltown_app
restart: always
ports:
- "3001:3000"
environment:
- NODE_ENV=production
- DB_HOST=mysql
- DB_PORT=3306
- DB_USER=smalltown
- DB_PASSWORD=MyPassword1+
- DB_NAME=smalltown
- OSS_URL=http://smalltown.dubaoda.com:9000
- OSS_API_KEY=b3302a486353f762646a9073020f3036
depends_on:
mysql:
condition: service_healthy

45
init.sql Normal file
View File

@@ -0,0 +1,45 @@
-- 创建数据库(如果不存在)
CREATE DATABASE IF NOT EXISTS smalltown;
USE smalltown;
-- 用户表
CREATE TABLE IF NOT EXISTS users (
id VARCHAR(36) PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
password_hash VARCHAR(64) NOT NULL,
token VARCHAR(64),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_token (token),
INDEX idx_username (username)
);
-- 房屋表
CREATE TABLE IF NOT EXISTS houses (
id VARCHAR(36) PRIMARY KEY,
owner VARCHAR(50) NOT NULL,
title VARCHAR(200) NOT NULL,
description TEXT,
price INT NOT NULL,
district VARCHAR(50) NOT NULL,
address VARCHAR(500) NOT NULL,
phone VARCHAR(20) NOT NULL,
images JSON,
status VARCHAR(20) DEFAULT 'pending',
reject_reason VARCHAR(500),
reviewed_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
INDEX idx_owner (owner),
INDEX idx_district (district),
INDEX idx_status (status),
INDEX idx_created (created_at)
);
-- 地区表
CREATE TABLE IF NOT EXISTS districts (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(50) NOT NULL UNIQUE,
sort_order INT DEFAULT 0
);
-- 初始化地区数据
INSERT INTO districts (name, sort_order) VALUES ('北京市-奶东村', 1);

14
lib/db.ts Normal file
View File

@@ -0,0 +1,14 @@
import mysql from 'mysql2/promise';
const pool = mysql.createPool({
host: process.env.DB_HOST || 'localhost',
port: parseInt(process.env.DB_PORT || '3306'),
user: process.env.DB_USER || 'smalltown',
password: process.env.DB_PASSWORD || 'MyPassword1+',
database: process.env.DB_NAME || 'smalltown',
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0
});
export default pool;

199
package-lock.json generated
View File

@@ -8,9 +8,11 @@
"name": "smalltown2", "name": "smalltown2",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"mysql2": "^3.20.0",
"next": "16.2.1", "next": "16.2.1",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4" "react-dom": "19.2.4",
"sharp": "^0.34.5"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
@@ -510,7 +512,6 @@
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"license": "MIT", "license": "MIT",
"optional": true,
"engines": { "engines": {
"node": ">=18" "node": ">=18"
} }
@@ -1465,6 +1466,70 @@
"node": ">=14.0.0" "node": ">=14.0.0"
} }
}, },
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.8.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.1.0",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.8.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.1.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1",
"@tybys/wasm-util": "^0.10.1"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.1",
"dev": true,
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.2.2", "version": "4.2.2",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.2.tgz",
@@ -1549,7 +1614,6 @@
"version": "20.19.37", "version": "20.19.37",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.37.tgz",
"integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==", "integrity": "sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
@@ -2405,6 +2469,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/aws-ssl-profiles": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
"integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==",
"license": "MIT",
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/axe-core": { "node_modules/axe-core": {
"version": "4.11.1", "version": "4.11.1",
"resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.1.tgz", "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.1.tgz",
@@ -2783,11 +2856,19 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/denque": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.10"
}
},
"node_modules/detect-libc": { "node_modules/detect-libc": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -3662,6 +3743,15 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/generate-function": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz",
"integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==",
"license": "MIT",
"dependencies": {
"is-property": "^1.0.2"
}
},
"node_modules/generator-function": { "node_modules/generator-function": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz",
@@ -3926,6 +4016,22 @@
"hermes-estree": "0.25.1" "hermes-estree": "0.25.1"
} }
}, },
"node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/ignore": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -4248,6 +4354,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/is-property": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz",
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
"license": "MIT"
},
"node_modules/is-regex": { "node_modules/is-regex": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
@@ -4846,6 +4958,12 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/long": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
"license": "Apache-2.0"
},
"node_modules/loose-envify": { "node_modules/loose-envify": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -4869,6 +4987,21 @@
"yallist": "^3.0.2" "yallist": "^3.0.2"
} }
}, },
"node_modules/lru.min": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz",
"integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==",
"license": "MIT",
"engines": {
"bun": ">=1.0.0",
"deno": ">=1.30.0",
"node": ">=8.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wellwelwel"
}
},
"node_modules/magic-string": { "node_modules/magic-string": {
"version": "0.30.21", "version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -4943,6 +5076,40 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/mysql2": {
"version": "3.20.0",
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.20.0.tgz",
"integrity": "sha512-eCLUs7BNbgA6nf/MZXsaBO1SfGs0LtLVrJD3WeWq+jPLDWkSufTD+aGMwykfUVPdZnblaUK1a8G/P63cl9FkKg==",
"license": "MIT",
"dependencies": {
"aws-ssl-profiles": "^1.1.2",
"denque": "^2.1.0",
"generate-function": "^2.3.1",
"iconv-lite": "^0.7.2",
"long": "^5.3.2",
"lru.min": "^1.1.4",
"named-placeholders": "^1.1.6",
"sql-escaper": "^1.3.3"
},
"engines": {
"node": ">= 8.0"
},
"peerDependencies": {
"@types/node": ">= 8"
}
},
"node_modules/named-placeholders": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz",
"integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==",
"license": "MIT",
"dependencies": {
"lru.min": "^1.1.0"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/nanoid": { "node_modules/nanoid": {
"version": "3.3.11", "version": "3.3.11",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
@@ -5636,6 +5803,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/scheduler": { "node_modules/scheduler": {
"version": "0.27.0", "version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -5707,7 +5880,6 @@
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"optional": true,
"dependencies": { "dependencies": {
"@img/colour": "^1.0.0", "@img/colour": "^1.0.0",
"detect-libc": "^2.1.2", "detect-libc": "^2.1.2",
@@ -5751,7 +5923,6 @@
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"license": "ISC", "license": "ISC",
"optional": true,
"bin": { "bin": {
"semver": "bin/semver.js" "semver": "bin/semver.js"
}, },
@@ -5867,6 +6038,21 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/sql-escaper": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.3.3.tgz",
"integrity": "sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==",
"license": "MIT",
"engines": {
"bun": ">=1.0.0",
"deno": ">=2.0.0",
"node": ">=12.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/mysqljs/sql-escaper?sponsor=1"
}
},
"node_modules/stable-hash": { "node_modules/stable-hash": {
"version": "0.0.5", "version": "0.0.5",
"resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz",
@@ -6352,7 +6538,6 @@
"version": "6.21.0", "version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/unrs-resolver": { "node_modules/unrs-resolver": {

View File

@@ -9,9 +9,11 @@
"lint": "eslint" "lint": "eslint"
}, },
"dependencies": { "dependencies": {
"mysql2": "^3.20.0",
"next": "16.2.1", "next": "16.2.1",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4" "react-dom": "19.2.4",
"sharp": "^0.34.5"
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",

6
run_review.sh Executable file
View File

@@ -0,0 +1,6 @@
#!/bin/bash
CRON_SECRET="smalltown_review_secret_2024"
API_URL="http://localhost:3000/api/cron/review"
curl -X POST -H "Authorization: Bearer $CRON_SECRET" "$API_URL"