426 lines
12 KiB
JavaScript
426 lines
12 KiB
JavaScript
require('dotenv').config()
|
|
const express = require('express')
|
|
const cors = require('cors')
|
|
const { MongoClient, ObjectId } = require('mongodb')
|
|
const { createAuthRoutes } = require('./routes/auth')
|
|
|
|
const app = express()
|
|
const PORT = process.env.PORT || 3001
|
|
const MONGO_URL = process.env.MONGO_URL || 'mongodb://localhost:27017'
|
|
const DB_NAME = process.env.DB_NAME || 'skills_market'
|
|
|
|
app.use(cors())
|
|
app.use(express.json({ limit: '50mb' }))
|
|
|
|
let db
|
|
let skillsCollection
|
|
let authRoutes
|
|
|
|
async function connectDB() {
|
|
const client = new MongoClient(MONGO_URL)
|
|
await client.connect()
|
|
db = client.db(DB_NAME)
|
|
skillsCollection = db.collection('skills')
|
|
authRoutes = createAuthRoutes(db)
|
|
console.log(`[MongoDB] Connected to ${DB_NAME}`)
|
|
|
|
await skillsCollection.createIndex({ name: 1 }, { unique: true })
|
|
await skillsCollection.createIndex({ owner: 1 })
|
|
await skillsCollection.createIndex({ is_public: 1 })
|
|
|
|
const usersCollection = db.collection('users')
|
|
await usersCollection.createIndex({ email: 1 }, { unique: true })
|
|
|
|
const codesCollection = db.collection('verification_codes')
|
|
await codesCollection.createIndex({ email: 1 })
|
|
await codesCollection.createIndex({ expires_at: 1 }, { expireAfterSeconds: 0 })
|
|
}
|
|
|
|
function extractDescription(files) {
|
|
const skillFile = files.find(f => f.path === 'SKILL.md' || f.path.endsWith('SKILL.md'))
|
|
if (!skillFile) return ''
|
|
|
|
const content = skillFile.content
|
|
const fmMatch = content.match(/^---\s*\r?\n([\s\S]*?)\r?\n---/)
|
|
if (fmMatch) {
|
|
const descMatch = fmMatch[1].match(/^description:\s*(.+)$/m)
|
|
if (descMatch) {
|
|
return descMatch[1].trim().replace(/^["']|["']$/g, '')
|
|
}
|
|
}
|
|
|
|
const lines = content.split('\n')
|
|
let inFrontmatter = false
|
|
for (const line of lines) {
|
|
const trimmed = line.trim()
|
|
if (trimmed === '---') {
|
|
inFrontmatter = !inFrontmatter
|
|
continue
|
|
}
|
|
if (inFrontmatter) continue
|
|
if (!trimmed) continue
|
|
if (trimmed.startsWith('#')) continue
|
|
return trimmed.length > 120 ? trimmed.slice(0, 120) + '...' : trimmed
|
|
}
|
|
return ''
|
|
}
|
|
|
|
app.post('/api/auth/send-code', async (req, res) => {
|
|
await authRoutes.sendCode(req, res)
|
|
})
|
|
|
|
app.post('/api/auth/login', async (req, res) => {
|
|
await authRoutes.login(req, res)
|
|
})
|
|
|
|
app.get('/api/auth/profile', async (req, res, next) => {
|
|
authRoutes.verifyToken(req, res, async () => {
|
|
await authRoutes.getProfile(req, res)
|
|
})
|
|
})
|
|
|
|
app.put('/api/auth/profile', async (req, res, next) => {
|
|
authRoutes.verifyToken(req, res, async () => {
|
|
await authRoutes.updateProfile(req, res)
|
|
})
|
|
})
|
|
|
|
app.get('/api/skills', async (req, res) => {
|
|
try {
|
|
const { query, offset = 0, limit = 50 } = req.query
|
|
|
|
let filter = { is_public: true }
|
|
if (query && query.trim()) {
|
|
const q = query.trim().toLowerCase()
|
|
filter.$or = [
|
|
{ name: { $regex: q, $options: 'i' } },
|
|
{ description: { $regex: q, $options: 'i' } },
|
|
{ owner: { $regex: q, $options: 'i' } }
|
|
]
|
|
}
|
|
|
|
const total = await skillsCollection.countDocuments(filter)
|
|
const skills = await skillsCollection
|
|
.find(filter, {
|
|
projection: {
|
|
name: 1,
|
|
description: 1,
|
|
owner: 1,
|
|
downloads: 1,
|
|
updated_at: 1,
|
|
tags: 1
|
|
}
|
|
})
|
|
.sort({ updated_at: -1 })
|
|
.skip(parseInt(offset))
|
|
.limit(parseInt(limit))
|
|
.toArray()
|
|
|
|
res.json({
|
|
success: true,
|
|
total,
|
|
skills: skills.map(s => ({
|
|
id: s._id,
|
|
name: s.name,
|
|
description: s.description,
|
|
owner: s.owner,
|
|
downloads: s.downloads || 0,
|
|
updated_at: s.updated_at,
|
|
tags: s.tags || []
|
|
}))
|
|
})
|
|
} catch (err) {
|
|
console.error('[API] List skills error:', err)
|
|
res.status(500).json({ success: false, error: err.message })
|
|
}
|
|
})
|
|
|
|
app.get('/api/skills/:name', async (req, res) => {
|
|
try {
|
|
const skill = await skillsCollection.findOne({
|
|
name: req.params.name,
|
|
is_public: true
|
|
})
|
|
|
|
if (!skill) {
|
|
return res.status(404).json({ success: false, error: 'Skill not found' })
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
skill: {
|
|
id: skill._id,
|
|
name: skill.name,
|
|
description: skill.description,
|
|
owner: skill.owner,
|
|
downloads: skill.downloads || 0,
|
|
files: skill.files,
|
|
versions: skill.versions || [],
|
|
tags: skill.tags || [],
|
|
created_at: skill.created_at,
|
|
updated_at: skill.updated_at
|
|
}
|
|
})
|
|
} catch (err) {
|
|
console.error('[API] Get skill error:', err)
|
|
res.status(500).json({ success: false, error: err.message })
|
|
}
|
|
})
|
|
|
|
app.get('/api/skills/:name/download', async (req, res) => {
|
|
try {
|
|
const skill = await skillsCollection.findOne({
|
|
name: req.params.name,
|
|
is_public: true
|
|
})
|
|
|
|
if (!skill) {
|
|
return res.status(404).json({ success: false, error: 'Skill not found' })
|
|
}
|
|
|
|
await skillsCollection.updateOne(
|
|
{ _id: skill._id },
|
|
{ $inc: { downloads: 1 } }
|
|
)
|
|
|
|
res.json({
|
|
success: true,
|
|
files: skill.files,
|
|
name: skill.name,
|
|
description: skill.description
|
|
})
|
|
} catch (err) {
|
|
console.error('[API] Download skill error:', err)
|
|
res.status(500).json({ success: false, error: err.message })
|
|
}
|
|
})
|
|
|
|
app.post('/api/skills/:name/publish', async (req, res) => {
|
|
authRoutes.verifyToken(req, res, async () => {
|
|
try {
|
|
const { files, description, tags, localModifiedAt } = req.body
|
|
const userName = req.user.nickname || req.user.email
|
|
|
|
if (!files || !Array.isArray(files) || files.length === 0) {
|
|
return res.status(400).json({ success: false, error: 'No files provided' })
|
|
}
|
|
|
|
const skillName = req.params.name.toLowerCase().replace(/[^a-z0-9-]/g, '-')
|
|
const skillDescription = description || extractDescription(files)
|
|
const now = new Date()
|
|
|
|
const existingSkill = await skillsCollection.findOne({ name: skillName })
|
|
|
|
if (existingSkill) {
|
|
const remoteModifiedTime = new Date(existingSkill.updated_at).getTime()
|
|
const localModifiedTime = localModifiedAt ? new Date(localModifiedAt).getTime() : 0
|
|
|
|
if (localModifiedTime < remoteModifiedTime) {
|
|
return res.status(409).json({
|
|
success: false,
|
|
conflict: true,
|
|
conflictInfo: {
|
|
remote_updated_at: existingSkill.updated_at,
|
|
local_modified_at: localModifiedAt,
|
|
remote_updated_by: existingSkill.updated_by || existingSkill.owner,
|
|
message: '远程有新版本,发布会丢失远程的修改'
|
|
}
|
|
})
|
|
}
|
|
|
|
const versionEntry = {
|
|
version: (existingSkill.versions?.length || 0) + 1,
|
|
description: `Updated by ${userName}`,
|
|
files: existingSkill.files,
|
|
created_at: now,
|
|
created_by: userName
|
|
}
|
|
|
|
const updateData = {
|
|
$set: {
|
|
files,
|
|
description: skillDescription,
|
|
updated_at: now,
|
|
updated_by: userName,
|
|
tags: tags || existingSkill.tags || []
|
|
},
|
|
$push: { versions: versionEntry }
|
|
}
|
|
|
|
await skillsCollection.updateOne(
|
|
{ _id: existingSkill._id },
|
|
updateData
|
|
)
|
|
|
|
res.json({
|
|
success: true,
|
|
action: 'updated',
|
|
name: skillName,
|
|
version: versionEntry.version
|
|
})
|
|
} else {
|
|
const newSkill = {
|
|
name: skillName,
|
|
description: skillDescription,
|
|
owner: userName,
|
|
files,
|
|
downloads: 0,
|
|
is_public: true,
|
|
tags: tags || [],
|
|
versions: [{
|
|
version: 1,
|
|
description: 'Initial version',
|
|
files,
|
|
created_at: now,
|
|
created_by: userName
|
|
}],
|
|
created_at: now,
|
|
updated_at: now,
|
|
created_by: userName,
|
|
updated_by: userName
|
|
}
|
|
|
|
await skillsCollection.insertOne(newSkill)
|
|
|
|
res.json({
|
|
success: true,
|
|
action: 'created',
|
|
name: skillName,
|
|
version: 1
|
|
})
|
|
}
|
|
} catch (err) {
|
|
console.error('[API] Publish skill error:', err)
|
|
if (err.code === 11000) {
|
|
return res.status(409).json({
|
|
success: false,
|
|
error: 'Skill name already exists'
|
|
})
|
|
}
|
|
res.status(500).json({ success: false, error: err.message })
|
|
}
|
|
})
|
|
})
|
|
|
|
app.get('/api/skills/:name/versions', async (req, res) => {
|
|
try {
|
|
const skill = await skillsCollection.findOne(
|
|
{ name: req.params.name },
|
|
{ projection: { versions: 1 } }
|
|
)
|
|
|
|
if (!skill) {
|
|
return res.status(404).json({ success: false, error: 'Skill not found' })
|
|
}
|
|
|
|
const versions = (skill.versions || []).map(v => ({
|
|
version: v.version,
|
|
description: v.description,
|
|
created_at: v.created_at,
|
|
created_by: v.created_by
|
|
}))
|
|
|
|
res.json({ success: true, versions })
|
|
} catch (err) {
|
|
console.error('[API] Get versions error:', err)
|
|
res.status(500).json({ success: false, error: err.message })
|
|
}
|
|
})
|
|
|
|
app.get('/api/skills/:name/versions/:version', async (req, res) => {
|
|
try {
|
|
const versionNum = parseInt(req.params.version)
|
|
|
|
const skill = await skillsCollection.findOne(
|
|
{ name: req.params.name },
|
|
{ projection: { versions: 1 } }
|
|
)
|
|
|
|
if (!skill) {
|
|
return res.status(404).json({ success: false, error: 'Skill not found' })
|
|
}
|
|
|
|
const version = (skill.versions || []).find(v => v.version === versionNum)
|
|
|
|
if (!version) {
|
|
return res.status(404).json({ success: false, error: 'Version not found' })
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
version: {
|
|
version: version.version,
|
|
description: version.description,
|
|
files: version.files,
|
|
created_at: version.created_at,
|
|
created_by: version.created_by
|
|
}
|
|
})
|
|
} catch (err) {
|
|
console.error('[API] Get version error:', err)
|
|
res.status(500).json({ success: false, error: err.message })
|
|
}
|
|
})
|
|
|
|
app.delete('/api/skills/:name', async (req, res) => {
|
|
authRoutes.verifyToken(req, res, async () => {
|
|
try {
|
|
const skill = await skillsCollection.findOne({ name: req.params.name })
|
|
|
|
if (!skill) {
|
|
return res.status(404).json({ success: false, error: 'Skill not found' })
|
|
}
|
|
|
|
await skillsCollection.deleteOne({ _id: skill._id })
|
|
|
|
res.json({ success: true })
|
|
} catch (err) {
|
|
console.error('[API] Delete skill error:', err)
|
|
res.status(500).json({ success: false, error: err.message })
|
|
}
|
|
})
|
|
})
|
|
|
|
app.get('/api/health', (req, res) => {
|
|
res.json({
|
|
success: true,
|
|
status: 'healthy',
|
|
timestamp: new Date()
|
|
})
|
|
})
|
|
|
|
app.get('/api/stats', async (req, res) => {
|
|
try {
|
|
const totalSkills = await skillsCollection.countDocuments({ is_public: true })
|
|
const totalDownloads = await skillsCollection.aggregate([
|
|
{ $match: { is_public: true } },
|
|
{ $group: { _id: null, total: { $sum: '$downloads' } } }
|
|
]).toArray()
|
|
|
|
res.json({
|
|
success: true,
|
|
stats: {
|
|
total_skills: totalSkills,
|
|
total_downloads: totalDownloads[0]?.total || 0
|
|
}
|
|
})
|
|
} catch (err) {
|
|
console.error('[API] Get stats error:', err)
|
|
res.status(500).json({ success: false, error: err.message })
|
|
}
|
|
})
|
|
|
|
async function start() {
|
|
try {
|
|
await connectDB()
|
|
app.listen(PORT, () => {
|
|
console.log(`[Server] Skills Market API running on http://localhost:${PORT}`)
|
|
})
|
|
} catch (err) {
|
|
console.error('[Server] Failed to start:', err)
|
|
process.exit(1)
|
|
}
|
|
}
|
|
|
|
start()
|