skill-base
Advanced tools
| function addColumnIfMissing(db, sql) { | ||
| try { | ||
| db.exec(sql); | ||
| } catch (error) { | ||
| if (!String(error && error.message ? error.message : error).includes('duplicate column name')) { | ||
| throw error; | ||
| } | ||
| } | ||
| } | ||
| function up(db) { | ||
| addColumnIfMissing(db, "ALTER TABLE users ADD COLUMN is_super_admin INTEGER NOT NULL DEFAULT 0"); | ||
| addColumnIfMissing(db, "ALTER TABLE skills ADD COLUMN favorite_count INTEGER NOT NULL DEFAULT 0"); | ||
| addColumnIfMissing(db, "ALTER TABLE skills ADD COLUMN download_count INTEGER NOT NULL DEFAULT 0"); | ||
| addColumnIfMissing(db, "ALTER TABLE skill_versions ADD COLUMN download_count INTEGER NOT NULL DEFAULT 0"); | ||
| db.exec(` | ||
| CREATE TABLE IF NOT EXISTS skill_favorites ( | ||
| user_id INTEGER NOT NULL, | ||
| skill_id TEXT NOT NULL, | ||
| created_at DATETIME DEFAULT CURRENT_TIMESTAMP, | ||
| PRIMARY KEY (user_id, skill_id), | ||
| FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, | ||
| FOREIGN KEY (skill_id) REFERENCES skills(id) ON DELETE CASCADE | ||
| ); | ||
| CREATE TABLE IF NOT EXISTS tags ( | ||
| id INTEGER PRIMARY KEY AUTOINCREMENT, | ||
| name TEXT NOT NULL UNIQUE, | ||
| created_at DATETIME DEFAULT CURRENT_TIMESTAMP, | ||
| updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, | ||
| created_by INTEGER NOT NULL, | ||
| updated_by INTEGER NOT NULL, | ||
| FOREIGN KEY (created_by) REFERENCES users(id), | ||
| FOREIGN KEY (updated_by) REFERENCES users(id) | ||
| ); | ||
| CREATE TABLE IF NOT EXISTS skill_tags ( | ||
| skill_id TEXT NOT NULL, | ||
| tag_id INTEGER NOT NULL, | ||
| created_at DATETIME DEFAULT CURRENT_TIMESTAMP, | ||
| created_by INTEGER NOT NULL, | ||
| PRIMARY KEY (skill_id, tag_id), | ||
| FOREIGN KEY (skill_id) REFERENCES skills(id) ON DELETE CASCADE, | ||
| FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE, | ||
| FOREIGN KEY (created_by) REFERENCES users(id) | ||
| ); | ||
| CREATE INDEX IF NOT EXISTS idx_skill_favorites_skill_id ON skill_favorites(skill_id); | ||
| CREATE INDEX IF NOT EXISTS idx_skill_favorites_user_id ON skill_favorites(user_id); | ||
| CREATE INDEX IF NOT EXISTS idx_skill_tags_tag_id ON skill_tags(tag_id); | ||
| CREATE INDEX IF NOT EXISTS idx_tags_name ON tags(name); | ||
| `); | ||
| db.exec(` | ||
| UPDATE skills | ||
| SET favorite_count = ( | ||
| SELECT COUNT(*) | ||
| FROM skill_favorites | ||
| WHERE skill_favorites.skill_id = skills.id | ||
| ); | ||
| UPDATE skills | ||
| SET download_count = COALESCE(download_count, 0); | ||
| UPDATE skill_versions | ||
| SET download_count = COALESCE(download_count, 0); | ||
| `); | ||
| const existingSuperAdmin = db.prepare('SELECT id FROM users WHERE is_super_admin = 1 LIMIT 1').get(); | ||
| if (!existingSuperAdmin) { | ||
| db.prepare(` | ||
| UPDATE users | ||
| SET is_super_admin = 1 | ||
| WHERE id = ( | ||
| SELECT id | ||
| FROM users | ||
| WHERE role = 'admin' | ||
| ORDER BY created_at ASC, id ASC | ||
| LIMIT 1 | ||
| ) | ||
| `).run(); | ||
| } | ||
| } | ||
| module.exports = { | ||
| version: '005-skill-favorites-tags-downloads-super-admin', | ||
| up | ||
| }; |
| const db = require('../database'); | ||
| const modelCache = require('../utils/model-cache'); | ||
| function syncFavoriteCount(skillId) { | ||
| db.prepare(` | ||
| UPDATE skills | ||
| SET favorite_count = ( | ||
| SELECT COUNT(*) | ||
| FROM skill_favorites | ||
| WHERE skill_id = ? | ||
| ) | ||
| WHERE id = ? | ||
| `).run(skillId, skillId); | ||
| modelCache.invalidateSkill(skillId); | ||
| } | ||
| const FavoriteModel = { | ||
| isFavorited(userId, skillId) { | ||
| return !!db.prepare('SELECT 1 FROM skill_favorites WHERE user_id = ? AND skill_id = ?').get(userId, skillId); | ||
| }, | ||
| add(userId, skillId) { | ||
| db.transaction(() => { | ||
| db.prepare(` | ||
| INSERT OR IGNORE INTO skill_favorites (user_id, skill_id) | ||
| VALUES (?, ?) | ||
| `).run(userId, skillId); | ||
| syncFavoriteCount(skillId); | ||
| })(); | ||
| return this.isFavorited(userId, skillId); | ||
| }, | ||
| remove(userId, skillId) { | ||
| db.transaction(() => { | ||
| db.prepare(` | ||
| DELETE FROM skill_favorites | ||
| WHERE user_id = ? AND skill_id = ? | ||
| `).run(userId, skillId); | ||
| syncFavoriteCount(skillId); | ||
| })(); | ||
| return false; | ||
| }, | ||
| countBySkillId(skillId) { | ||
| return db.prepare(` | ||
| SELECT COUNT(*) AS count | ||
| FROM skill_favorites | ||
| WHERE skill_id = ? | ||
| `).get(skillId).count; | ||
| } | ||
| }; | ||
| module.exports = FavoriteModel; |
| const db = require('../database'); | ||
| const modelCache = require('../utils/model-cache'); | ||
| function normalizeName(name) { | ||
| return String(name || '').trim(); | ||
| } | ||
| function getTagById(id) { | ||
| return db.prepare(` | ||
| SELECT id, name, created_at, updated_at, created_by, updated_by | ||
| FROM tags | ||
| WHERE id = ? | ||
| `).get(id); | ||
| } | ||
| function invalidateSkillTagRefs(skillIds) { | ||
| for (const skillId of skillIds || []) { | ||
| modelCache.invalidateSkill(skillId); | ||
| } | ||
| } | ||
| const TagModel = { | ||
| create({ name, actorId }) { | ||
| const normalizedName = normalizeName(name); | ||
| const result = db.prepare(` | ||
| INSERT INTO tags (name, created_by, updated_by) | ||
| VALUES (?, ?, ?) | ||
| `).run(normalizedName, actorId, actorId); | ||
| return getTagById(result.lastInsertRowid); | ||
| }, | ||
| update(id, { name, actorId }) { | ||
| const normalizedName = normalizeName(name); | ||
| db.prepare(` | ||
| UPDATE tags | ||
| SET name = ?, updated_at = CURRENT_TIMESTAMP, updated_by = ? | ||
| WHERE id = ? | ||
| `).run(normalizedName, actorId, id); | ||
| return getTagById(id); | ||
| }, | ||
| delete(id) { | ||
| const affectedSkills = db.prepare(` | ||
| SELECT skill_id | ||
| FROM skill_tags | ||
| WHERE tag_id = ? | ||
| `).all(id).map((row) => row.skill_id); | ||
| const result = db.prepare('DELETE FROM tags WHERE id = ?').run(id); | ||
| invalidateSkillTagRefs(affectedSkills); | ||
| return result.changes > 0; | ||
| }, | ||
| listSkillTags(skillId) { | ||
| return db.prepare(` | ||
| SELECT t.id, t.name | ||
| FROM skill_tags st | ||
| JOIN tags t ON t.id = st.tag_id | ||
| WHERE st.skill_id = ? | ||
| ORDER BY t.name ASC | ||
| `).all(skillId); | ||
| }, | ||
| listAllWithUsage() { | ||
| return db.prepare(` | ||
| SELECT t.id, t.name, COUNT(st.skill_id) AS usage_count | ||
| FROM tags t | ||
| LEFT JOIN skill_tags st ON st.tag_id = t.id | ||
| GROUP BY t.id, t.name | ||
| ORDER BY t.name ASC | ||
| `).all(); | ||
| }, | ||
| replaceSkillTags(skillId, tagIds, actorId) { | ||
| const uniqueTagIds = [...new Set((tagIds || []).map((id) => Number(id)))]; | ||
| return db.transaction(() => { | ||
| if (uniqueTagIds.length > 0) { | ||
| const placeholders = uniqueTagIds.map(() => '?').join(', '); | ||
| const rows = db.prepare(`SELECT id FROM tags WHERE id IN (${placeholders})`).all(...uniqueTagIds); | ||
| if (rows.length !== uniqueTagIds.length) { | ||
| throw new Error('One or more tags do not exist'); | ||
| } | ||
| } | ||
| db.prepare('DELETE FROM skill_tags WHERE skill_id = ?').run(skillId); | ||
| const insert = db.prepare(` | ||
| INSERT INTO skill_tags (skill_id, tag_id, created_by) | ||
| VALUES (?, ?, ?) | ||
| `); | ||
| for (const tagId of uniqueTagIds) { | ||
| insert.run(skillId, tagId, actorId); | ||
| } | ||
| modelCache.invalidateSkill(skillId); | ||
| return this.listSkillTags(skillId); | ||
| })(); | ||
| } | ||
| }; | ||
| module.exports = TagModel; |
| const TagModel = require('../models/tag'); | ||
| const { isSuperAdmin } = require('../utils/permission'); | ||
| async function tagsRoutes(fastify) { | ||
| async function requireSuperAdmin(request, reply) { | ||
| if (!isSuperAdmin(request.user)) { | ||
| return reply.code(403).send({ | ||
| ok: false, | ||
| error: 'forbidden', | ||
| detail: 'Super admin permission required' | ||
| }); | ||
| } | ||
| } | ||
| fastify.get('/', { | ||
| preHandler: [fastify.authenticate] | ||
| }, async () => { | ||
| return { tags: TagModel.listAllWithUsage() }; | ||
| }); | ||
| fastify.post('/', { | ||
| preHandler: [fastify.authenticate, requireSuperAdmin] | ||
| }, async (request, reply) => { | ||
| const name = String(request.body?.name || '').trim(); | ||
| if (!name) { | ||
| return reply.code(400).send({ detail: 'Tag name is required' }); | ||
| } | ||
| const tag = TagModel.create({ name, actorId: request.user.id }); | ||
| return reply.code(201).send({ ok: true, tag }); | ||
| }); | ||
| fastify.patch('/:tag_id', { | ||
| preHandler: [fastify.authenticate, requireSuperAdmin] | ||
| }, async (request, reply) => { | ||
| const tagId = parseInt(request.params.tag_id, 10); | ||
| const name = String(request.body?.name || '').trim(); | ||
| if (!Number.isInteger(tagId) || tagId <= 0) { | ||
| return reply.code(400).send({ detail: 'Invalid tag id' }); | ||
| } | ||
| if (!name) { | ||
| return reply.code(400).send({ detail: 'Tag name is required' }); | ||
| } | ||
| const tag = TagModel.update(tagId, { name, actorId: request.user.id }); | ||
| if (!tag) { | ||
| return reply.code(404).send({ detail: 'Tag not found' }); | ||
| } | ||
| return { ok: true, tag }; | ||
| }); | ||
| fastify.delete('/:tag_id', { | ||
| preHandler: [fastify.authenticate, requireSuperAdmin] | ||
| }, async (request, reply) => { | ||
| const tagId = parseInt(request.params.tag_id, 10); | ||
| if (!Number.isInteger(tagId) || tagId <= 0) { | ||
| return reply.code(400).send({ detail: 'Invalid tag id' }); | ||
| } | ||
| const deleted = TagModel.delete(tagId); | ||
| if (!deleted) { | ||
| return reply.code(404).send({ detail: 'Tag not found' }); | ||
| } | ||
| return { ok: true }; | ||
| }); | ||
| } | ||
| module.exports = tagsRoutes; |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
@@ -62,2 +62,4 @@ # Skill Base | ||
| Web 端还支持账号级收藏、下载次数统计,以及由超级管理员维护的全局标签库。详情页内联预览使用独立的 `/view` 接口,避免「浏览文件」计入下载次数。 | ||
| ### 数据结构克制,天然适合 GitOps | ||
@@ -64,0 +66,0 @@ |
+1
-1
| { | ||
| "name": "skill-base", | ||
| "version": "2.0.36", | ||
| "version": "2.0.37", | ||
| "description": "Skill Base - A lightweight, privately deployable agent skills distribution hub", | ||
@@ -5,0 +5,0 @@ "main": "src/index.js", |
+2
-0
@@ -62,2 +62,4 @@ # Skill Base | ||
| The web UI also supports account-based favorites, download counters, and an optional global tag library (managed by the super admin). Inline previews use a dedicated `/view` endpoint so browsing a version ZIP does not inflate download statistics. | ||
| ### Small data model, GitOps-friendly | ||
@@ -64,0 +66,0 @@ |
+29
-0
@@ -251,2 +251,29 @@ const { execFileSync } = require('child_process'); | ||
| const migrations = [ | ||
| require('./migrations/005-skill-favorites-tags-downloads-super-admin') | ||
| ]; | ||
| function ensureSchemaMigrationsTable() { | ||
| db.exec(` | ||
| CREATE TABLE IF NOT EXISTS schema_migrations ( | ||
| version TEXT PRIMARY KEY, | ||
| applied_at DATETIME DEFAULT CURRENT_TIMESTAMP | ||
| ); | ||
| `); | ||
| } | ||
| function runMigrations() { | ||
| ensureSchemaMigrationsTable(); | ||
| for (const migration of migrations) { | ||
| const row = db.prepare('SELECT version FROM schema_migrations WHERE version = ?').get(migration.version); | ||
| if (row) continue; | ||
| db.transaction(() => { | ||
| migration.up(db); | ||
| db.prepare('INSERT INTO schema_migrations (version) VALUES (?)').run(migration.version); | ||
| })(); | ||
| } | ||
| } | ||
| // node-sqlite3-wasm: keep default DELETE journal (WAL + this VFS breaks on many existing DBs). | ||
@@ -362,2 +389,4 @@ | ||
| runMigrations(); | ||
| // Data migration: insert skill_collaborators record for existing Skills owners | ||
@@ -364,0 +393,0 @@ const existingSkills = db.prepare('SELECT id, owner_id FROM skills').all(); |
+1
-0
@@ -164,2 +164,3 @@ const fs = require('fs'); | ||
| await fastify.register(require('./routes/collaborators'), { prefix: `${API_PREFIX}/skills` }); | ||
| await fastify.register(require('./routes/tags'), { prefix: `${API_PREFIX}/tags` }); | ||
| await fastify.register(require('./routes/users'), { prefix: `${API_PREFIX}/users` }); | ||
@@ -166,0 +167,0 @@ debugLog({ zh: 'API 路由已注册。', en: 'API routes registered.' }); |
@@ -83,2 +83,4 @@ const fp = require('fastify-plugin'); | ||
| async function authPlugin(fastify, options) { | ||
| const authUserColumns = 'id, username, name, role, status, is_super_admin'; | ||
| // Expose sessionStore for routes to use | ||
@@ -96,3 +98,3 @@ fastify.decorate('sessionStore', sessionStore); | ||
| if (session) { | ||
| const user = db.prepare('SELECT id, username, name, role, status FROM users WHERE id = ?').get(session.userId); | ||
| const user = db.prepare(`SELECT ${authUserColumns} FROM users WHERE id = ?`).get(session.userId); | ||
| if (!user || user.status === 'disabled') { | ||
@@ -116,3 +118,3 @@ return reply.code(401).send({ | ||
| if (pat) { | ||
| const user = db.prepare('SELECT id, username, name, role, status FROM users WHERE id = ?').get(pat.user_id); | ||
| const user = db.prepare(`SELECT ${authUserColumns} FROM users WHERE id = ?`).get(pat.user_id); | ||
| if (!user || user.status === 'disabled') { | ||
@@ -144,3 +146,3 @@ return reply.code(401).send({ | ||
| if (session) { | ||
| const user = db.prepare('SELECT id, username, name, role, status FROM users WHERE id = ?').get(session.userId); | ||
| const user = db.prepare(`SELECT ${authUserColumns} FROM users WHERE id = ?`).get(session.userId); | ||
| if (user && user.status !== 'disabled') { request.user = user; return; } | ||
@@ -154,3 +156,3 @@ } | ||
| if (pat) { | ||
| const user = db.prepare('SELECT id, username, name, role, status FROM users WHERE id = ?').get(pat.user_id); | ||
| const user = db.prepare(`SELECT ${authUserColumns} FROM users WHERE id = ?`).get(pat.user_id); | ||
| if (user && user.status !== 'disabled') { request.user = user; return; } | ||
@@ -157,0 +159,0 @@ } |
@@ -98,2 +98,11 @@ const db = require('../database'); | ||
| incrementDownloadCount(id) { | ||
| db.prepare(` | ||
| UPDATE skills | ||
| SET download_count = COALESCE(download_count, 0) + 1 | ||
| WHERE id = ? | ||
| `).run(id); | ||
| modelCache.invalidateSkill(id); | ||
| }, | ||
| // Check if Skill exists | ||
@@ -100,0 +109,0 @@ exists(id) { |
+28
-4
@@ -5,3 +5,3 @@ const db = require('../database'); | ||
| function queryById(id) { | ||
| return db.prepare('SELECT id, username, name, role, status, created_at, updated_at FROM users WHERE id = ?').get(id); | ||
| return db.prepare('SELECT id, username, name, role, status, is_super_admin, created_at, updated_at FROM users WHERE id = ?').get(id); | ||
| } | ||
@@ -33,3 +33,3 @@ | ||
| list({ q, status, page = 1, limit = 20 } = {}) { | ||
| let sql = 'SELECT id, username, name, role, status, created_at, updated_at FROM users WHERE 1=1'; | ||
| let sql = 'SELECT id, username, name, role, status, is_super_admin, created_at, updated_at FROM users WHERE 1=1'; | ||
| let countSql = 'SELECT COUNT(*) as total FROM users WHERE 1=1'; | ||
@@ -85,3 +85,3 @@ const params = []; | ||
| update(id, fields) { | ||
| const allowed = ['role', 'status', 'username', 'name']; | ||
| const allowed = ['role', 'status', 'username', 'name', 'is_super_admin']; | ||
| const sets = []; | ||
@@ -120,6 +120,14 @@ const params = []; | ||
| delete(id) { | ||
| const result = db.prepare('DELETE FROM users WHERE id = ?').run(id); | ||
| if (result.changes > 0) { | ||
| modelCache.invalidateUser(id); | ||
| } | ||
| return result.changes > 0; | ||
| }, | ||
| // Find user details (includes creator info) | ||
| findByIdWithCreator(id) { | ||
| return db.prepare(` | ||
| SELECT u.id, u.username, u.name, u.role, u.status, u.created_at, u.updated_at, | ||
| SELECT u.id, u.username, u.name, u.role, u.status, u.is_super_admin, u.created_at, u.updated_at, | ||
| c.id as creator_id, c.username as creator_username | ||
@@ -132,2 +140,18 @@ FROM users u | ||
| countSuperAdmins() { | ||
| return db.prepare('SELECT COUNT(*) AS count FROM users WHERE is_super_admin = 1').get().count; | ||
| }, | ||
| canDemoteOrDisableSuperAdmin(id) { | ||
| const user = db.prepare('SELECT is_super_admin FROM users WHERE id = ?').get(id); | ||
| if (!user?.is_super_admin) { | ||
| return true; | ||
| } | ||
| return this.countSuperAdmins() > 1; | ||
| }, | ||
| canDeleteSuperAdmin(id) { | ||
| return this.canDemoteOrDisableSuperAdmin(id); | ||
| }, | ||
| // Update username and name | ||
@@ -134,0 +158,0 @@ updateProfile(id, { username, name }) { |
@@ -82,2 +82,12 @@ const db = require('../database'); | ||
| return this.findById(id); | ||
| }, | ||
| incrementDownloadCount(skillId, version) { | ||
| db.prepare(` | ||
| UPDATE skill_versions | ||
| SET download_count = COALESCE(download_count, 0) + 1 | ||
| WHERE skill_id = ? AND version = ? | ||
| `).run(skillId, version); | ||
| modelCache.invalidateSkill(skillId); | ||
| return this.findByVersion(skillId, version); | ||
| } | ||
@@ -84,0 +94,0 @@ }; |
+10
-1
@@ -38,3 +38,12 @@ const db = require('../database'); | ||
| return { ok: true, user: { id: user.id, username: user.username, name: user.name || null, role: user.role } }; | ||
| return { | ||
| ok: true, | ||
| user: { | ||
| id: user.id, | ||
| username: user.username, | ||
| name: user.name || null, | ||
| role: user.role, | ||
| is_super_admin: user.is_super_admin || 0 | ||
| } | ||
| }; | ||
| }); | ||
@@ -41,0 +50,0 @@ |
@@ -75,3 +75,3 @@ const bcrypt = require('bcryptjs'); | ||
| const result = db.prepare( | ||
| 'INSERT INTO users (username, password_hash, role) VALUES (?, ?, ?)' | ||
| 'INSERT INTO users (username, password_hash, role, is_super_admin) VALUES (?, ?, ?, 1)' | ||
| ).run(username, passwordHash, 'admin'); | ||
@@ -78,0 +78,0 @@ |
+113
-10
@@ -6,2 +6,4 @@ const fs = require('fs'); | ||
| const VersionModel = require('../models/version'); | ||
| const FavoriteModel = require('../models/favorite'); | ||
| const TagModel = require('../models/tag'); | ||
| const { getZipPath, resolveZipPath } = require('../utils/zip'); | ||
@@ -34,2 +36,5 @@ const { canManageSkill } = require('../utils/permission'); | ||
| latest_version: skill.latest_version, | ||
| favorite_count: skill.favorite_count || 0, | ||
| download_count: skill.download_count || 0, | ||
| tags: TagModel.listSkillTags(skill.id), | ||
| owner: { | ||
@@ -59,2 +64,4 @@ id: skill.owner_id, | ||
| result.is_favorited = currentUser ? FavoriteModel.isFavorited(currentUser.id, skill.id) : false; | ||
| if (canViewSkillWebhook(currentUser, result.permission)) { | ||
@@ -76,2 +83,3 @@ result.webhook_url = skill.webhook_url || null; | ||
| description: version.description, | ||
| download_count: version.download_count || 0, | ||
| zip_path: version.zip_path, | ||
@@ -88,2 +96,13 @@ uploader: { | ||
| async function skillsRoutes(fastify, options) { | ||
| function resolveExistingZipPath(skillId, versionRecord) { | ||
| const zipPath = resolveZipPath(versionRecord.zip_path, skillId, versionRecord.version); | ||
| const fallbackZipPath = getZipPath(skillId, versionRecord.version); | ||
| if (!fs.existsSync(zipPath) && !fs.existsSync(fallbackZipPath)) { | ||
| return null; | ||
| } | ||
| return fs.existsSync(zipPath) ? zipPath : fallbackZipPath; | ||
| } | ||
| // GET / - Get skills list | ||
@@ -149,14 +168,9 @@ fastify.get('/', { preHandler: [fastify.optionalAuth] }, async (request, reply) => { | ||
| // Prefer using zip_path from database for backward compatibility; fallback to rule-based path if missing | ||
| const zipPath = resolveZipPath(versionRecord.zip_path, skill_id, versionRecord.version); | ||
| const fallbackZipPath = getZipPath(skill_id, versionRecord.version); | ||
| // Check if file exists | ||
| if (!fs.existsSync(zipPath)) { | ||
| if (!fs.existsSync(fallbackZipPath)) { | ||
| return reply.code(404).send({ detail: 'Version not found' }); | ||
| } | ||
| const finalZipPath = resolveExistingZipPath(skill_id, versionRecord); | ||
| if (!finalZipPath) { | ||
| return reply.code(404).send({ detail: 'Version not found' }); | ||
| } | ||
| const finalZipPath = fs.existsSync(zipPath) ? zipPath : fallbackZipPath; | ||
| SkillModel.incrementDownloadCount(skill_id); | ||
| VersionModel.incrementDownloadCount(skill_id, versionRecord.version); | ||
@@ -171,2 +185,91 @@ // Set response headers and return file stream | ||
| // GET /:skill_id/versions/:version/view - View version zip file without counting download | ||
| fastify.get('/:skill_id/versions/:version/view', async (request, reply) => { | ||
| const { skill_id, version } = request.params; | ||
| const versionRecord = version === 'latest' | ||
| ? VersionModel.getLatest(skill_id) | ||
| : VersionModel.findByVersion(skill_id, version); | ||
| if (!versionRecord) { | ||
| return reply.code(404).send({ detail: 'Version not found' }); | ||
| } | ||
| const finalZipPath = resolveExistingZipPath(skill_id, versionRecord); | ||
| if (!finalZipPath) { | ||
| return reply.code(404).send({ detail: 'Version not found' }); | ||
| } | ||
| const fileName = `${skill_id}-${versionRecord.version}.zip`; | ||
| reply.header('Content-Type', 'application/zip'); | ||
| reply.header('Content-Disposition', `inline; filename="${fileName}"`); | ||
| return fs.createReadStream(finalZipPath); | ||
| }); | ||
| // POST /:skill_id/favorite - Favorite skill | ||
| fastify.post('/:skill_id/favorite', { | ||
| preHandler: [fastify.authenticate] | ||
| }, async (request, reply) => { | ||
| const { skill_id } = request.params; | ||
| if (!SkillModel.exists(skill_id)) { | ||
| return reply.code(404).send({ detail: 'Skill not found' }); | ||
| } | ||
| FavoriteModel.add(request.user.id, skill_id); | ||
| const skill = SkillModel.findById(skill_id); | ||
| return { | ||
| ok: true, | ||
| skill_id, | ||
| favorited: true, | ||
| favorite_count: skill.favorite_count || 0 | ||
| }; | ||
| }); | ||
| // DELETE /:skill_id/favorite - Unfavorite skill | ||
| fastify.delete('/:skill_id/favorite', { | ||
| preHandler: [fastify.authenticate] | ||
| }, async (request, reply) => { | ||
| const { skill_id } = request.params; | ||
| if (!SkillModel.exists(skill_id)) { | ||
| return reply.code(404).send({ detail: 'Skill not found' }); | ||
| } | ||
| FavoriteModel.remove(request.user.id, skill_id); | ||
| const skill = SkillModel.findById(skill_id); | ||
| return { | ||
| ok: true, | ||
| skill_id, | ||
| favorited: false, | ||
| favorite_count: skill.favorite_count || 0 | ||
| }; | ||
| }); | ||
| // PUT /:skill_id/tags - Replace skill tags | ||
| fastify.put('/:skill_id/tags', { | ||
| preHandler: [fastify.authenticate] | ||
| }, async (request, reply) => { | ||
| const { skill_id } = request.params; | ||
| const { tag_ids } = request.body || {}; | ||
| if (!SkillModel.exists(skill_id)) { | ||
| return reply.code(404).send({ detail: 'Skill not found' }); | ||
| } | ||
| if (!canManageSkill(request.user, skill_id)) { | ||
| return reply.code(403).send({ ok: false, error: 'forbidden', detail: 'Owner or admin permission required' }); | ||
| } | ||
| if (tag_ids !== undefined && !Array.isArray(tag_ids)) { | ||
| return reply.code(400).send({ detail: 'tag_ids must be an array' }); | ||
| } | ||
| const tags = TagModel.replaceSkillTags(skill_id, tag_ids || [], request.user.id); | ||
| return { ok: true, skill_id, tags }; | ||
| }); | ||
| // PUT /:skill_id - Update skill basic info | ||
@@ -173,0 +276,0 @@ fastify.put('/:skill_id', { |
+43
-7
@@ -12,8 +12,16 @@ const UserModel = require('../models/user'); | ||
| const { q } = request.query; | ||
| if (!q || q.trim().length < 1) { | ||
| return reply.code(400).send({ ok: false, error: 'invalid_params', detail: 'Search keyword must be at least 1 character' }); | ||
| const trimmed = q != null ? String(q).trim() : ''; | ||
| if (!trimmed) { | ||
| const users = db.prepare(` | ||
| SELECT id, username, name, status | ||
| FROM users | ||
| WHERE status = 'active' | ||
| ORDER BY username ASC | ||
| LIMIT 2000 | ||
| `).all(); | ||
| return reply.send({ users }); | ||
| } | ||
| const pattern = `%${q.trim()}%`; | ||
| const pattern = `%${trimmed}%`; | ||
| const users = db.prepare(` | ||
@@ -23,5 +31,6 @@ SELECT id, username, name, status | ||
| WHERE (username LIKE ? OR name LIKE ?) AND status = 'active' | ||
| LIMIT 10 | ||
| ORDER BY username ASC | ||
| LIMIT 100 | ||
| `).all(pattern, pattern); | ||
| return reply.send({ users }); | ||
@@ -92,2 +101,3 @@ }); | ||
| status: user.status, | ||
| is_super_admin: user.is_super_admin || 0, | ||
| created_at: user.created_at, | ||
@@ -135,4 +145,10 @@ updated_at: user.updated_at | ||
| } | ||
| if (status === 'disabled' && !UserModel.canDemoteOrDisableSuperAdmin(userId)) { | ||
| return reply.code(400).send({ ok: false, error: 'last_super_admin', detail: 'Cannot disable the last super admin' }); | ||
| } | ||
| fields.status = status; | ||
| } | ||
| if (role !== undefined && role === 'developer' && !UserModel.canDemoteOrDisableSuperAdmin(userId)) { | ||
| return reply.code(400).send({ ok: false, error: 'last_super_admin', detail: 'Cannot downgrade the last super admin' }); | ||
| } | ||
| if (name !== undefined) { | ||
@@ -170,5 +186,25 @@ fields.name = name; | ||
| }); | ||
| // DELETE /:user_id - Delete user | ||
| fastify.delete('/:user_id', async (request, reply) => { | ||
| const userId = parseInt(request.params.user_id); | ||
| const user = UserModel.findById(userId); | ||
| if (!user) { | ||
| return reply.code(404).send({ ok: false, error: 'not_found', detail: 'User not found' }); | ||
| } | ||
| if (userId === request.user.id) { | ||
| return reply.code(400).send({ ok: false, error: 'self_protection', detail: 'Cannot delete your own account' }); | ||
| } | ||
| if (!UserModel.canDeleteSuperAdmin(userId)) { | ||
| return reply.code(400).send({ ok: false, error: 'last_super_admin', detail: 'Cannot delete the last super admin' }); | ||
| } | ||
| return reply.send({ ok: UserModel.delete(userId) }); | ||
| }); | ||
| }); | ||
| } | ||
| module.exports = usersRoutes; |
@@ -41,6 +41,11 @@ const db = require('../database'); | ||
| function isSuperAdmin(user) { | ||
| return !!user && user.role === 'admin' && Number(user.is_super_admin || 0) === 1; | ||
| } | ||
| module.exports = { | ||
| hasSkillPermission, | ||
| canPublishSkill, | ||
| canManageSkill | ||
| canManageSkill, | ||
| isSuperAdmin | ||
| }; |
@@ -12,4 +12,4 @@ <!DOCTYPE html> | ||
| <title>Skill Base</title> | ||
| <script type="module" crossorigin src="./assets/index-HwIh4LJF.js"></script> | ||
| <link rel="stylesheet" crossorigin href="./assets/index-De_i-3yU.css"> | ||
| <script type="module" crossorigin src="./assets/index-CXr6bCUN.js"></script> | ||
| <link rel="stylesheet" crossorigin href="./assets/index-B_WXtVS0.css"> | ||
| </head> | ||
@@ -16,0 +16,0 @@ <body> |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
14393141
0.31%137
3.01%10455
5.73%370
0.54%46
2.22%