Initial commit
This commit is contained in:
commit
92e96b9107
85 changed files with 24969 additions and 0 deletions
31
app/server/api/articles/[slug].delete.ts
Normal file
31
app/server/api/articles/[slug].delete.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import { Article } from "../../models/Article";
|
||||
import { requireRole } from "../../utils/auth";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const slug = getRouterParam(event, "slug");
|
||||
|
||||
// Only admins can delete articles
|
||||
const user = await requireRole(event, ["admin"]);
|
||||
|
||||
if (!slug) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Slug is required",
|
||||
});
|
||||
}
|
||||
|
||||
// Find and delete article
|
||||
const article = await Article.findOneAndDelete({ slug });
|
||||
|
||||
if (!article) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: "Article not found",
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: "Article deleted successfully",
|
||||
};
|
||||
});
|
||||
71
app/server/api/articles/[slug].get.ts
Normal file
71
app/server/api/articles/[slug].get.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { Article } from '../../models/Article'
|
||||
import { checkAccessLevel, verifyAuth } from '../../utils/auth'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const slug = getRouterParam(event, 'slug')
|
||||
|
||||
if (!slug) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Slug is required'
|
||||
})
|
||||
}
|
||||
|
||||
// Find article
|
||||
const article = await Article.findOne({ slug })
|
||||
.populate('author', 'username displayName avatar')
|
||||
.populate('contributors', 'username displayName avatar')
|
||||
|
||||
if (!article) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: 'Article not found'
|
||||
})
|
||||
}
|
||||
|
||||
// Check access
|
||||
const hasAccess = await checkAccessLevel(
|
||||
event,
|
||||
article.accessLevel,
|
||||
article.cohorts
|
||||
)
|
||||
|
||||
if (!hasAccess) {
|
||||
// For protected content, return limited preview
|
||||
if (article.accessLevel !== 'admin') {
|
||||
return {
|
||||
slug: article.slug,
|
||||
title: article.title,
|
||||
description: article.description.substring(0, 200) + '...',
|
||||
category: article.category,
|
||||
tags: article.tags,
|
||||
accessLevel: article.accessLevel,
|
||||
restricted: true,
|
||||
message: 'This content requires authentication'
|
||||
}
|
||||
}
|
||||
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: 'Access denied'
|
||||
})
|
||||
}
|
||||
|
||||
// Increment view count
|
||||
article.views += 1
|
||||
await article.save()
|
||||
|
||||
// Get user to check if they've liked
|
||||
const user = await verifyAuth(event)
|
||||
const userLiked = false // TODO: Implement likes tracking
|
||||
|
||||
return {
|
||||
...article.toObject(),
|
||||
userLiked,
|
||||
revisionCount: article.revisions.length,
|
||||
commentCount: article.comments.length,
|
||||
// Don't send full revisions and comments in main response
|
||||
revisions: undefined,
|
||||
comments: undefined
|
||||
}
|
||||
})
|
||||
109
app/server/api/articles/[slug].put.ts
Normal file
109
app/server/api/articles/[slug].put.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
import { Article } from "../../models/Article";
|
||||
import { requireAuth } from "../../utils/auth";
|
||||
import { Types } from "mongoose";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const slug = getRouterParam(event, "slug");
|
||||
const user = await requireAuth(event);
|
||||
const body = await readBody(event);
|
||||
|
||||
if (!slug) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: "Slug is required",
|
||||
});
|
||||
}
|
||||
|
||||
// Find article
|
||||
const article = await Article.findOne({ slug });
|
||||
|
||||
if (!article) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: "Article not found",
|
||||
});
|
||||
}
|
||||
|
||||
// Check permissions
|
||||
const isAuthor = article.author.toString() === user.userId;
|
||||
const isAdmin = user.permissions.canAdmin;
|
||||
const isModerator = user.permissions.canModerate;
|
||||
const canEdit = user.permissions.canEdit;
|
||||
|
||||
if (!isAuthor && !isAdmin && !isModerator) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: "You do not have permission to edit this article",
|
||||
});
|
||||
}
|
||||
|
||||
// Check if article is locked by another user
|
||||
if (article.lockedBy && article.lockedBy.toString() !== user.userId) {
|
||||
const lockExpired =
|
||||
article.lockedAt &&
|
||||
new Date().getTime() - article.lockedAt.getTime() > 30 * 60 * 1000; // 30 minutes
|
||||
|
||||
if (!lockExpired) {
|
||||
throw createError({
|
||||
statusCode: 423,
|
||||
statusMessage: "Article is currently being edited by another user",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Update fields
|
||||
if (body.title) article.title = body.title;
|
||||
if (body.description) article.description = body.description;
|
||||
if (body.content) {
|
||||
// Add to revision history
|
||||
article.revisions.push({
|
||||
content: body.content,
|
||||
author: new Types.ObjectId(user.userId),
|
||||
message: body.revisionMessage || "Content updated",
|
||||
createdAt: new Date(),
|
||||
});
|
||||
article.content = body.content;
|
||||
}
|
||||
if (body.category) article.category = body.category;
|
||||
if (body.tags) article.tags = body.tags;
|
||||
if (body.accessLevel && (isAdmin || isModerator)) {
|
||||
article.accessLevel = body.accessLevel;
|
||||
}
|
||||
if (body.cohorts && (isAdmin || isModerator)) {
|
||||
article.cohorts = body.cohorts;
|
||||
}
|
||||
if (body.status) {
|
||||
article.status = body.status;
|
||||
if (body.status === "published" && !article.publishedAt) {
|
||||
article.publishedAt = new Date();
|
||||
}
|
||||
}
|
||||
|
||||
// Add contributor if not already listed
|
||||
const userObjectId = new Types.ObjectId(user.userId);
|
||||
if (!article.contributors.some((c: any) => c.toString() === user.userId)) {
|
||||
article.contributors.push(userObjectId);
|
||||
}
|
||||
|
||||
// Clear lock
|
||||
article.lockedBy = undefined;
|
||||
article.lockedAt = undefined;
|
||||
|
||||
// Save changes
|
||||
try {
|
||||
await article.save();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
slug: article.slug,
|
||||
message: "Article updated successfully",
|
||||
revision: article.revisions.length,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Error updating article:", error);
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: "Failed to update article",
|
||||
});
|
||||
}
|
||||
});
|
||||
76
app/server/api/articles/index.get.ts
Normal file
76
app/server/api/articles/index.get.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { Article } from '../../models/Article'
|
||||
import { checkAccessLevel } from '../../utils/auth'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const query = getQuery(event)
|
||||
|
||||
// Pagination
|
||||
const page = parseInt(query.page as string) || 1
|
||||
const limit = parseInt(query.limit as string) || 20
|
||||
const skip = (page - 1) * limit
|
||||
|
||||
// Build filter
|
||||
const filter: any = { status: 'published' }
|
||||
|
||||
// Category filter
|
||||
if (query.category) {
|
||||
filter.category = query.category
|
||||
}
|
||||
|
||||
// Tags filter
|
||||
if (query.tags) {
|
||||
const tags = (query.tags as string).split(',')
|
||||
filter.tags = { $in: tags }
|
||||
}
|
||||
|
||||
// Search filter
|
||||
if (query.search) {
|
||||
filter.$or = [
|
||||
{ title: { $regex: query.search, $options: 'i' } },
|
||||
{ description: { $regex: query.search, $options: 'i' } },
|
||||
{ content: { $regex: query.search, $options: 'i' } }
|
||||
]
|
||||
}
|
||||
|
||||
// Get articles
|
||||
const articles = await Article.find(filter)
|
||||
.populate('author', 'username displayName avatar')
|
||||
.select('-content -revisions -comments')
|
||||
.sort({ publishedAt: -1 })
|
||||
.skip(skip)
|
||||
.limit(limit)
|
||||
|
||||
// Filter by access level
|
||||
const filteredArticles = []
|
||||
for (const article of articles) {
|
||||
const hasAccess = await checkAccessLevel(
|
||||
event,
|
||||
article.accessLevel,
|
||||
article.cohorts
|
||||
)
|
||||
|
||||
if (hasAccess) {
|
||||
filteredArticles.push(article)
|
||||
} else if (article.accessLevel === 'member' || article.accessLevel === 'cohort') {
|
||||
// Show preview for protected content
|
||||
filteredArticles.push({
|
||||
...article.toObject(),
|
||||
description: article.description.substring(0, 200) + '...',
|
||||
restricted: true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Get total count
|
||||
const total = await Article.countDocuments(filter)
|
||||
|
||||
return {
|
||||
articles: filteredArticles,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
pages: Math.ceil(total / limit)
|
||||
}
|
||||
}
|
||||
})
|
||||
71
app/server/api/articles/index.post.ts
Normal file
71
app/server/api/articles/index.post.ts
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
import { Article } from '../../models/Article'
|
||||
import { requireAuth } from '../../utils/auth'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
// Require authentication
|
||||
const user = await requireAuth(event)
|
||||
|
||||
// Check if user can create articles
|
||||
if (!user.permissions.canEdit) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: 'You do not have permission to create articles'
|
||||
})
|
||||
}
|
||||
|
||||
// Get request body
|
||||
const body = await readBody(event)
|
||||
|
||||
// Validate required fields
|
||||
if (!body.title || !body.slug || !body.content) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
statusMessage: 'Title, slug, and content are required'
|
||||
})
|
||||
}
|
||||
|
||||
// Check if slug already exists
|
||||
const existing = await Article.findOne({ slug: body.slug })
|
||||
if (existing) {
|
||||
throw createError({
|
||||
statusCode: 409,
|
||||
statusMessage: 'An article with this slug already exists'
|
||||
})
|
||||
}
|
||||
|
||||
// Create article
|
||||
try {
|
||||
const article = await Article.create({
|
||||
slug: body.slug,
|
||||
title: body.title,
|
||||
description: body.description || '',
|
||||
content: body.content,
|
||||
category: body.category || 'general',
|
||||
tags: body.tags || [],
|
||||
accessLevel: body.accessLevel || 'member',
|
||||
cohorts: body.cohorts || [],
|
||||
author: user.userId,
|
||||
status: body.status || 'draft',
|
||||
publishedAt: body.status === 'published' ? new Date() : null,
|
||||
revisions: [{
|
||||
content: body.content,
|
||||
author: user.userId,
|
||||
message: 'Initial creation',
|
||||
createdAt: new Date()
|
||||
}]
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
slug: article.slug,
|
||||
message: 'Article created successfully'
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error creating article:', error)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
statusMessage: 'Failed to create article'
|
||||
})
|
||||
}
|
||||
})
|
||||
100
app/server/api/auth/callback.get.ts
Normal file
100
app/server/api/auth/callback.get.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { User } from "../../models/User";
|
||||
import jwt from "jsonwebtoken";
|
||||
import type {
|
||||
OAuthTokenResponse,
|
||||
GhostGuildUserInfo,
|
||||
} from "../../../types/auth";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig();
|
||||
const query = getQuery(event);
|
||||
|
||||
// Verify state for CSRF protection
|
||||
const storedState = getCookie(event, "oauth_state");
|
||||
if (!storedState || storedState !== query.state) {
|
||||
return sendRedirect(event, "/login?error=invalid_state");
|
||||
}
|
||||
|
||||
// Clear the state cookie
|
||||
deleteCookie(event, "oauth_state");
|
||||
|
||||
// Exchange authorization code for access token
|
||||
try {
|
||||
const tokenResponse = await $fetch<OAuthTokenResponse>(
|
||||
`${config.ghostguildApiUrl}/oauth/token`,
|
||||
{
|
||||
method: "POST",
|
||||
body: {
|
||||
grant_type: "authorization_code",
|
||||
code: query.code,
|
||||
redirect_uri: `${config.public.siteUrl}/api/auth/callback`,
|
||||
client_id: config.ghostguildClientId,
|
||||
client_secret: config.ghostguildClientSecret,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Get user information from Ghost Guild
|
||||
const userInfo = await $fetch<GhostGuildUserInfo>(
|
||||
`${config.ghostguildApiUrl}/user/me`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${tokenResponse.access_token}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
// Find or create user in our database
|
||||
let user = await User.findOne({ ghostguildId: userInfo.id });
|
||||
|
||||
if (!user) {
|
||||
user = await User.create({
|
||||
ghostguildId: userInfo.id,
|
||||
email: userInfo.email,
|
||||
username: userInfo.username,
|
||||
displayName: userInfo.displayName || userInfo.username,
|
||||
avatar: userInfo.avatar,
|
||||
roles: userInfo.roles || ["member"],
|
||||
permissions: {
|
||||
canEdit: userInfo.roles?.includes("member") || false,
|
||||
canModerate: userInfo.roles?.includes("moderator") || false,
|
||||
canAdmin: userInfo.roles?.includes("admin") || false,
|
||||
},
|
||||
lastLogin: new Date(),
|
||||
});
|
||||
} else {
|
||||
// Update existing user
|
||||
user.displayName = userInfo.displayName || userInfo.username;
|
||||
user.avatar = userInfo.avatar;
|
||||
user.roles = userInfo.roles || ["member"];
|
||||
user.lastLogin = new Date();
|
||||
await user.save();
|
||||
}
|
||||
|
||||
// Create JWT token
|
||||
const token = jwt.sign(
|
||||
{
|
||||
userId: user._id,
|
||||
username: user.username,
|
||||
roles: user.roles,
|
||||
permissions: user.permissions,
|
||||
},
|
||||
config.jwtSecret as string,
|
||||
{ expiresIn: "7d" },
|
||||
);
|
||||
|
||||
// Set JWT as httpOnly cookie
|
||||
setCookie(event, "auth-token", token, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "lax",
|
||||
maxAge: 60 * 60 * 24 * 7, // 7 days
|
||||
});
|
||||
|
||||
// Redirect to dashboard or home
|
||||
return sendRedirect(event, "/dashboard");
|
||||
} catch (error) {
|
||||
console.error("OAuth callback error:", error);
|
||||
return sendRedirect(event, "/login?error=authentication_failed");
|
||||
}
|
||||
});
|
||||
28
app/server/api/auth/login.get.ts
Normal file
28
app/server/api/auth/login.get.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig();
|
||||
|
||||
// Generate state for CSRF protection
|
||||
const state = Math.random().toString(36).substring(7);
|
||||
|
||||
// Store state in session (you'll need to implement session storage)
|
||||
setCookie(event, "oauth_state", state, {
|
||||
httpOnly: true,
|
||||
secure: true,
|
||||
sameSite: "lax",
|
||||
maxAge: 60 * 10, // 10 minutes
|
||||
});
|
||||
|
||||
// Build OAuth authorization URL
|
||||
const params = new URLSearchParams({
|
||||
client_id: String(config.ghostguildClientId || ""),
|
||||
redirect_uri: `${config.public.siteUrl}/api/auth/callback`,
|
||||
response_type: "code",
|
||||
scope: "read:user read:member",
|
||||
state: state,
|
||||
});
|
||||
|
||||
const authUrl = `${config.ghostguildApiUrl}/oauth/authorize?${params}`;
|
||||
|
||||
// Redirect to Ghost Guild OAuth
|
||||
return sendRedirect(event, authUrl);
|
||||
});
|
||||
10
app/server/api/auth/logout.post.ts
Normal file
10
app/server/api/auth/logout.post.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
export default defineEventHandler(async (event) => {
|
||||
// Clear the auth token cookie
|
||||
deleteCookie(event, 'auth-token')
|
||||
|
||||
// Return success response
|
||||
return {
|
||||
success: true,
|
||||
message: 'Logged out successfully'
|
||||
}
|
||||
})
|
||||
53
app/server/api/auth/me.get.ts
Normal file
53
app/server/api/auth/me.get.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import jwt from "jsonwebtoken";
|
||||
import { User } from "../../models/User";
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const config = useRuntimeConfig();
|
||||
const token = getCookie(event, "auth-token");
|
||||
|
||||
if (!token) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: "Unauthorized - No token provided",
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
// Verify and decode the token
|
||||
const decoded = jwt.verify(token, config.jwtSecret as string) as any;
|
||||
|
||||
// Get fresh user data from database
|
||||
const user = await User.findById(decoded.userId).select("-__v");
|
||||
|
||||
if (!user) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: "User not found",
|
||||
});
|
||||
}
|
||||
|
||||
// Return user data (without sensitive fields)
|
||||
return {
|
||||
id: user._id,
|
||||
username: user.username,
|
||||
displayName: user.displayName,
|
||||
email: user.email,
|
||||
avatar: user.avatar,
|
||||
roles: user.roles,
|
||||
permissions: user.permissions,
|
||||
contributions: user.contributions,
|
||||
};
|
||||
} catch (error: any) {
|
||||
if (
|
||||
error.name === "JsonWebTokenError" ||
|
||||
error.name === "TokenExpiredError"
|
||||
) {
|
||||
deleteCookie(event, "auth-token");
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: "Invalid or expired token",
|
||||
});
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
32
app/server/api/health.get.ts
Normal file
32
app/server/api/health.get.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import mongoose from 'mongoose'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const checks = {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: process.uptime(),
|
||||
mongodb: 'disconnected',
|
||||
memory: process.memoryUsage(),
|
||||
}
|
||||
|
||||
// Check MongoDB connection
|
||||
try {
|
||||
if (mongoose.connection.readyState === 1) {
|
||||
checks.mongodb = 'connected'
|
||||
} else {
|
||||
checks.mongodb = 'disconnected'
|
||||
}
|
||||
} catch (error) {
|
||||
checks.mongodb = 'error'
|
||||
}
|
||||
|
||||
// Return 503 if any critical service is down
|
||||
const isHealthy = checks.mongodb === 'connected'
|
||||
|
||||
if (!isHealthy) {
|
||||
setResponseStatus(event, 503)
|
||||
checks.status = 'unhealthy'
|
||||
}
|
||||
|
||||
return checks
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue