Initial commit

This commit is contained in:
Jennie Robinson Faber 2025-11-11 19:12:21 +00:00
commit 92e96b9107
85 changed files with 24969 additions and 0 deletions

View 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",
};
});

View 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
}
})

View 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",
});
}
});

View 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)
}
}
})

View 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'
})
}
})

View 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");
}
});

View 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);
});

View 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'
}
})

View 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;
}
});

View 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
})