Force line endings and whitespace, and revamp logout via introduction of a new profile component.
This commit is contained in:
parent
f6df79d83f
commit
0e491ecabe
31 changed files with 4870 additions and 4797 deletions
|
@ -1,7 +1,7 @@
|
|||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
// Instantiate Prisma Client
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Export the Prisma Client instance for use in other modules
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
// Instantiate Prisma Client
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
// Export the Prisma Client instance for use in other modules
|
||||
export default prisma;
|
|
@ -1,12 +1,12 @@
|
|||
// src-ssr/middlewares/authMiddleware.js
|
||||
|
||||
export function requireAuth(req, res, next)
|
||||
{
|
||||
if (!req.session || !req.session.loggedInUserId)
|
||||
{
|
||||
// User is not authenticated
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
// User is authenticated, proceed to the next middleware or route handler
|
||||
next();
|
||||
}
|
||||
// src-ssr/middlewares/authMiddleware.js
|
||||
|
||||
export function requireAuth(req, res, next)
|
||||
{
|
||||
if (!req.session || !req.session.loggedInUserId)
|
||||
{
|
||||
// User is not authenticated
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
// User is authenticated, proceed to the next middleware or route handler
|
||||
next();
|
||||
}
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -1,459 +1,459 @@
|
|||
// src-ssr/routes/auth.js
|
||||
import express from 'express';
|
||||
import {
|
||||
generateRegistrationOptions,
|
||||
verifyRegistrationResponse,
|
||||
generateAuthenticationOptions,
|
||||
verifyAuthenticationResponse,
|
||||
} from '@simplewebauthn/server';
|
||||
import { isoBase64URL } from '@simplewebauthn/server/helpers'; // Ensure this is imported if not already
|
||||
import prisma from '../database.js';
|
||||
import { rpID, rpName, origin, challengeStore } from '../server.js'; // Import RP details and challenge store
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Helper function to get user authenticators
|
||||
async function getUserAuthenticators(userId)
|
||||
{
|
||||
return prisma.authenticator.findMany({
|
||||
where: { userId },
|
||||
select: {
|
||||
credentialID: true,
|
||||
credentialPublicKey: true,
|
||||
counter: true,
|
||||
transports: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Helper function to get a user by username
|
||||
async function getUserByUsername(username)
|
||||
{
|
||||
return prisma.user.findUnique({ where: { username } });
|
||||
}
|
||||
|
||||
// Helper function to get a user by ID
|
||||
async function getUserById(id)
|
||||
{
|
||||
return prisma.user.findUnique({ where: { id } });
|
||||
}
|
||||
|
||||
// Helper function to get an authenticator by credential ID
|
||||
async function getAuthenticatorByCredentialID(credentialID)
|
||||
{
|
||||
return prisma.authenticator.findUnique({ where: { credentialID } });
|
||||
}
|
||||
|
||||
|
||||
// Generate Registration Options
|
||||
router.post('/generate-registration-options', async(req, res) =>
|
||||
{
|
||||
const { username } = req.body;
|
||||
|
||||
if (!username)
|
||||
{
|
||||
return res.status(400).json({ error: 'Username is required' });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
let user = await getUserByUsername(username);
|
||||
|
||||
// If user doesn't exist, create one
|
||||
if (!user)
|
||||
{
|
||||
user = await prisma.user.create({
|
||||
data: { username },
|
||||
});
|
||||
}
|
||||
|
||||
const userAuthenticators = await getUserAuthenticators(user.id);
|
||||
|
||||
if(userAuthenticators.length > 0)
|
||||
{
|
||||
//The user is trying to register a new authenticator, so we need to check if the user registering is the same as the one in the session
|
||||
if (!req.session.loggedInUserId || req.session.loggedInUserId !== user.id)
|
||||
{
|
||||
return res.status(403).json({ error: 'Invalid registration attempt.' });
|
||||
}
|
||||
}
|
||||
|
||||
const options = await generateRegistrationOptions({
|
||||
rpName,
|
||||
rpID,
|
||||
userName: user.username,
|
||||
// Don't prompt users for additional authenticators if they've already registered some
|
||||
excludeCredentials: userAuthenticators.map(auth => ({
|
||||
id: auth.credentialID, // Use isoBase64URL helper
|
||||
type: 'public-key',
|
||||
// Optional: Specify transports if you know them
|
||||
transports: auth.transports ? auth.transports.split(',') : undefined,
|
||||
})),
|
||||
authenticatorSelection: {
|
||||
// Defaults
|
||||
residentKey: 'required',
|
||||
userVerification: 'preferred',
|
||||
},
|
||||
// Strong advice: Always require attestation for registration
|
||||
attestationType: 'none', // Use 'none' for simplicity, 'direct' or 'indirect' recommended for production
|
||||
});
|
||||
|
||||
// Store the challenge
|
||||
challengeStore.set(user.id, options.challenge);
|
||||
req.session.userId = user.id; // Temporarily store userId in session for verification step
|
||||
|
||||
res.json(options);
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
console.error('Registration options error:', error);
|
||||
res.status(500).json({ error: 'Failed to generate registration options' });
|
||||
}
|
||||
});
|
||||
|
||||
// Verify Registration
|
||||
router.post('/verify-registration', async(req, res) =>
|
||||
{
|
||||
const { registrationResponse } = req.body;
|
||||
const userId = req.session.userId; // Retrieve userId stored during options generation
|
||||
|
||||
if (!userId)
|
||||
{
|
||||
return res.status(400).json({ error: 'User session not found. Please start registration again.' });
|
||||
}
|
||||
|
||||
const expectedChallenge = challengeStore.get(userId);
|
||||
|
||||
if (!expectedChallenge)
|
||||
{
|
||||
return res.status(400).json({ error: 'Challenge not found or expired' });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
const user = await getUserById(userId);
|
||||
if (!user)
|
||||
{
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
const verification = await verifyRegistrationResponse({
|
||||
response: registrationResponse,
|
||||
expectedChallenge: expectedChallenge,
|
||||
expectedOrigin: origin,
|
||||
expectedRPID: rpID,
|
||||
requireUserVerification: false, // Adjust based on your requirements
|
||||
});
|
||||
|
||||
const { verified, registrationInfo } = verification;
|
||||
|
||||
console.log(verification);
|
||||
|
||||
if (verified && registrationInfo)
|
||||
{
|
||||
const { credential, credentialDeviceType, credentialBackedUp } = registrationInfo;
|
||||
|
||||
const credentialID = credential.id;
|
||||
const credentialPublicKey = credential.publicKey;
|
||||
const counter = credential.counter;
|
||||
const transports = credential.transports || []; // Use empty array if transports are not provided
|
||||
|
||||
// Check if authenticator with this ID already exists
|
||||
const existingAuthenticator = await getAuthenticatorByCredentialID(isoBase64URL.fromBuffer(credentialID));
|
||||
|
||||
if (existingAuthenticator)
|
||||
{
|
||||
return res.status(409).json({ error: 'Authenticator already registered' });
|
||||
}
|
||||
|
||||
// Save the authenticator
|
||||
await prisma.authenticator.create({
|
||||
data: {
|
||||
credentialID, // Store as Base64URL string
|
||||
credentialPublicKey: Buffer.from(credentialPublicKey), // Store as Bytes
|
||||
counter: BigInt(counter), // Store as BigInt
|
||||
credentialDeviceType,
|
||||
credentialBackedUp,
|
||||
transports: transports.join(','), // Store transports as comma-separated string
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Clear the challenge and temporary userId
|
||||
challengeStore.delete(userId);
|
||||
delete req.session.userId;
|
||||
|
||||
// Log the user in by setting the final session userId
|
||||
req.session.loggedInUserId = user.id;
|
||||
|
||||
res.json({ verified: true });
|
||||
}
|
||||
else
|
||||
{
|
||||
res.status(400).json({ error: 'Registration verification failed' });
|
||||
}
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
console.error('Registration verification error:', error);
|
||||
challengeStore.delete(userId); // Clean up challenge on error
|
||||
delete req.session.userId;
|
||||
res.status(500).json({ error: 'Failed to verify registration', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Generate Authentication Options
|
||||
router.post('/generate-authentication-options', async(req, res) =>
|
||||
{
|
||||
const { username } = req.body;
|
||||
|
||||
try
|
||||
{
|
||||
let user;
|
||||
if (username)
|
||||
{
|
||||
user = await getUserByUsername(username);
|
||||
}
|
||||
else if (req.session.loggedInUserId)
|
||||
{
|
||||
// If already logged in, allow re-authentication (e.g., for step-up)
|
||||
user = await getUserById(req.session.loggedInUserId);
|
||||
}
|
||||
|
||||
if (!user)
|
||||
{
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
console.log('User found:', user);
|
||||
|
||||
const userAuthenticators = await getUserAuthenticators(user.id);
|
||||
|
||||
console.log('User authenticators:', userAuthenticators);
|
||||
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID,
|
||||
// Require users to use a previously-registered authenticator
|
||||
allowCredentials: userAuthenticators.map(auth => ({
|
||||
id: auth.credentialID,
|
||||
type: 'public-key',
|
||||
transports: auth.transports ? auth.transports.split(',') : undefined,
|
||||
})),
|
||||
userVerification: 'preferred',
|
||||
});
|
||||
|
||||
// Store the challenge associated with the user ID for verification
|
||||
challengeStore.set(user.id, options.challenge);
|
||||
req.session.challengeUserId = user.id; // Store user ID associated with this challenge
|
||||
|
||||
res.json(options);
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
console.error('Authentication options error:', error);
|
||||
res.status(500).json({ error: 'Failed to generate authentication options' });
|
||||
}
|
||||
});
|
||||
|
||||
// Verify Authentication
|
||||
router.post('/verify-authentication', async(req, res) =>
|
||||
{
|
||||
const { authenticationResponse } = req.body;
|
||||
const challengeUserId = req.session.challengeUserId; // Get user ID associated with the challenge
|
||||
|
||||
if (!challengeUserId)
|
||||
{
|
||||
return res.status(400).json({ error: 'Challenge session not found. Please try logging in again.' });
|
||||
}
|
||||
|
||||
const expectedChallenge = challengeStore.get(challengeUserId);
|
||||
|
||||
if (!expectedChallenge)
|
||||
{
|
||||
return res.status(400).json({ error: 'Challenge not found or expired' });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
const user = await getUserById(challengeUserId);
|
||||
if (!user)
|
||||
{
|
||||
return res.status(404).json({ error: 'User associated with challenge not found' });
|
||||
}
|
||||
|
||||
const authenticator = await getAuthenticatorByCredentialID(authenticationResponse.id);
|
||||
|
||||
if (!authenticator)
|
||||
{
|
||||
return res.status(404).json({ error: 'Authenticator not found' });
|
||||
}
|
||||
|
||||
// Ensure the authenticator belongs to the user attempting to log in
|
||||
if (authenticator.userId !== user.id)
|
||||
{
|
||||
return res.status(403).json({ error: 'Authenticator does not belong to this user' });
|
||||
}
|
||||
|
||||
const verification = await verifyAuthenticationResponse({
|
||||
response: authenticationResponse,
|
||||
expectedChallenge: expectedChallenge,
|
||||
expectedOrigin: origin,
|
||||
expectedRPID: rpID,
|
||||
credential: {
|
||||
id: authenticator.credentialID,
|
||||
publicKey: authenticator.credentialPublicKey,
|
||||
counter: authenticator.counter.toString(), // Convert BigInt to string for comparison
|
||||
transports: authenticator.transports ? authenticator.transports.split(',') : undefined,
|
||||
},
|
||||
requireUserVerification: false, // Enforce user verification
|
||||
});
|
||||
|
||||
const { verified, authenticationInfo } = verification;
|
||||
|
||||
if (verified)
|
||||
{
|
||||
// Update the authenticator counter
|
||||
await prisma.authenticator.update({
|
||||
where: { credentialID: authenticator.credentialID },
|
||||
data: { counter: BigInt(authenticationInfo.newCounter) }, // Update with the new counter
|
||||
});
|
||||
|
||||
// Clear the challenge and associated user ID
|
||||
challengeStore.delete(challengeUserId);
|
||||
delete req.session.challengeUserId;
|
||||
|
||||
// Log the user in
|
||||
req.session.loggedInUserId = user.id;
|
||||
|
||||
res.json({ verified: true, user: { id: user.id, username: user.username } });
|
||||
}
|
||||
else
|
||||
{
|
||||
res.status(400).json({ error: 'Authentication verification failed' });
|
||||
}
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
console.error('Authentication verification error:', error);
|
||||
challengeStore.delete(challengeUserId); // Clean up challenge on error
|
||||
delete req.session.challengeUserId;
|
||||
res.status(500).json({ error: 'Failed to verify authentication', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET Passkeys for Logged-in User
|
||||
router.get('/passkeys', async(req, res) =>
|
||||
{
|
||||
if (!req.session.loggedInUserId)
|
||||
{
|
||||
return res.status(401).json({ error: 'Not authenticated' });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
const userId = req.session.loggedInUserId;
|
||||
const authenticators = await prisma.authenticator.findMany({
|
||||
where: { userId },
|
||||
select: {
|
||||
credentialID: true, // Already Base64URL string
|
||||
// Add other fields if needed, e.g., createdAt if you add it to the schema
|
||||
// createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
// No need to convert credentialID here as it's stored as Base64URL string
|
||||
res.json(authenticators);
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
console.error('Error fetching passkeys:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch passkeys' });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE Passkey
|
||||
router.delete('/passkeys/:credentialID', async(req, res) =>
|
||||
{
|
||||
if (!req.session.loggedInUserId)
|
||||
{
|
||||
return res.status(401).json({ error: 'Not authenticated' });
|
||||
}
|
||||
|
||||
const { credentialID } = req.params; // This is already a Base64URL string from the client
|
||||
|
||||
if (!credentialID)
|
||||
{
|
||||
return res.status(400).json({ error: 'Credential ID is required' });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
const userId = req.session.loggedInUserId;
|
||||
|
||||
// Find the authenticator first to ensure it belongs to the logged-in user
|
||||
const authenticator = await prisma.authenticator.findUnique({
|
||||
where: { credentialID: credentialID }, // Use the Base64URL string directly
|
||||
});
|
||||
|
||||
if (!authenticator)
|
||||
{
|
||||
return res.status(404).json({ error: 'Passkey not found' });
|
||||
}
|
||||
|
||||
// Security check: Ensure the passkey belongs to the user trying to delete it
|
||||
if (authenticator.userId !== userId)
|
||||
{
|
||||
return res.status(403).json({ error: 'Permission denied' });
|
||||
}
|
||||
|
||||
// Delete the authenticator
|
||||
await prisma.authenticator.delete({
|
||||
where: { credentialID: credentialID },
|
||||
});
|
||||
|
||||
res.json({ message: 'Passkey deleted successfully' });
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
console.error('Error deleting passkey:', error);
|
||||
// Handle potential Prisma errors, e.g., record not found if deleted between check and delete
|
||||
if (error.code === 'P2025')
|
||||
{ // Prisma code for record not found on delete/update
|
||||
return res.status(404).json({ error: 'Passkey not found' });
|
||||
}
|
||||
res.status(500).json({ error: 'Failed to delete passkey' });
|
||||
}
|
||||
});
|
||||
|
||||
// Check Authentication Status
|
||||
router.get('/status', async(req, res) =>
|
||||
{
|
||||
if (req.session.loggedInUserId)
|
||||
{
|
||||
const user = await getUserById(req.session.loggedInUserId);
|
||||
if (!user)
|
||||
{
|
||||
req.session.destroy(err =>
|
||||
{});
|
||||
return res.status(401).json({ status: 'unauthenticated' });
|
||||
}
|
||||
return res.json({ status: 'authenticated', user: { id: user.id, username: user.username, email: user.email } });
|
||||
}
|
||||
res.json({ status: 'unauthenticated' });
|
||||
});
|
||||
|
||||
// Logout
|
||||
router.post('/logout', (req, res) =>
|
||||
{
|
||||
req.session.destroy(err =>
|
||||
{
|
||||
if (err)
|
||||
{
|
||||
console.error('Logout error:', err);
|
||||
return res.status(500).json({ error: 'Failed to logout' });
|
||||
}
|
||||
res.json({ message: 'Logged out successfully' });
|
||||
});
|
||||
});
|
||||
|
||||
// src-ssr/routes/auth.js
|
||||
import express from 'express';
|
||||
import {
|
||||
generateRegistrationOptions,
|
||||
verifyRegistrationResponse,
|
||||
generateAuthenticationOptions,
|
||||
verifyAuthenticationResponse,
|
||||
} from '@simplewebauthn/server';
|
||||
import { isoBase64URL } from '@simplewebauthn/server/helpers'; // Ensure this is imported if not already
|
||||
import prisma from '../database.js';
|
||||
import { rpID, rpName, origin, challengeStore } from '../server.js'; // Import RP details and challenge store
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Helper function to get user authenticators
|
||||
async function getUserAuthenticators(userId)
|
||||
{
|
||||
return prisma.authenticator.findMany({
|
||||
where: { userId },
|
||||
select: {
|
||||
credentialID: true,
|
||||
credentialPublicKey: true,
|
||||
counter: true,
|
||||
transports: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Helper function to get a user by username
|
||||
async function getUserByUsername(username)
|
||||
{
|
||||
return prisma.user.findUnique({ where: { username } });
|
||||
}
|
||||
|
||||
// Helper function to get a user by ID
|
||||
async function getUserById(id)
|
||||
{
|
||||
return prisma.user.findUnique({ where: { id } });
|
||||
}
|
||||
|
||||
// Helper function to get an authenticator by credential ID
|
||||
async function getAuthenticatorByCredentialID(credentialID)
|
||||
{
|
||||
return prisma.authenticator.findUnique({ where: { credentialID } });
|
||||
}
|
||||
|
||||
|
||||
// Generate Registration Options
|
||||
router.post('/generate-registration-options', async(req, res) =>
|
||||
{
|
||||
const { username } = req.body;
|
||||
|
||||
if (!username)
|
||||
{
|
||||
return res.status(400).json({ error: 'Username is required' });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
let user = await getUserByUsername(username);
|
||||
|
||||
// If user doesn't exist, create one
|
||||
if (!user)
|
||||
{
|
||||
user = await prisma.user.create({
|
||||
data: { username },
|
||||
});
|
||||
}
|
||||
|
||||
const userAuthenticators = await getUserAuthenticators(user.id);
|
||||
|
||||
if(userAuthenticators.length > 0)
|
||||
{
|
||||
//The user is trying to register a new authenticator, so we need to check if the user registering is the same as the one in the session
|
||||
if (!req.session.loggedInUserId || req.session.loggedInUserId !== user.id)
|
||||
{
|
||||
return res.status(403).json({ error: 'Invalid registration attempt.' });
|
||||
}
|
||||
}
|
||||
|
||||
const options = await generateRegistrationOptions({
|
||||
rpName,
|
||||
rpID,
|
||||
userName: user.username,
|
||||
// Don't prompt users for additional authenticators if they've already registered some
|
||||
excludeCredentials: userAuthenticators.map(auth => ({
|
||||
id: auth.credentialID, // Use isoBase64URL helper
|
||||
type: 'public-key',
|
||||
// Optional: Specify transports if you know them
|
||||
transports: auth.transports ? auth.transports.split(',') : undefined,
|
||||
})),
|
||||
authenticatorSelection: {
|
||||
// Defaults
|
||||
residentKey: 'required',
|
||||
userVerification: 'preferred',
|
||||
},
|
||||
// Strong advice: Always require attestation for registration
|
||||
attestationType: 'none', // Use 'none' for simplicity, 'direct' or 'indirect' recommended for production
|
||||
});
|
||||
|
||||
// Store the challenge
|
||||
challengeStore.set(user.id, options.challenge);
|
||||
req.session.userId = user.id; // Temporarily store userId in session for verification step
|
||||
|
||||
res.json(options);
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
console.error('Registration options error:', error);
|
||||
res.status(500).json({ error: 'Failed to generate registration options' });
|
||||
}
|
||||
});
|
||||
|
||||
// Verify Registration
|
||||
router.post('/verify-registration', async(req, res) =>
|
||||
{
|
||||
const { registrationResponse } = req.body;
|
||||
const userId = req.session.userId; // Retrieve userId stored during options generation
|
||||
|
||||
if (!userId)
|
||||
{
|
||||
return res.status(400).json({ error: 'User session not found. Please start registration again.' });
|
||||
}
|
||||
|
||||
const expectedChallenge = challengeStore.get(userId);
|
||||
|
||||
if (!expectedChallenge)
|
||||
{
|
||||
return res.status(400).json({ error: 'Challenge not found or expired' });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
const user = await getUserById(userId);
|
||||
if (!user)
|
||||
{
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
const verification = await verifyRegistrationResponse({
|
||||
response: registrationResponse,
|
||||
expectedChallenge: expectedChallenge,
|
||||
expectedOrigin: origin,
|
||||
expectedRPID: rpID,
|
||||
requireUserVerification: false, // Adjust based on your requirements
|
||||
});
|
||||
|
||||
const { verified, registrationInfo } = verification;
|
||||
|
||||
console.log(verification);
|
||||
|
||||
if (verified && registrationInfo)
|
||||
{
|
||||
const { credential, credentialDeviceType, credentialBackedUp } = registrationInfo;
|
||||
|
||||
const credentialID = credential.id;
|
||||
const credentialPublicKey = credential.publicKey;
|
||||
const counter = credential.counter;
|
||||
const transports = credential.transports || []; // Use empty array if transports are not provided
|
||||
|
||||
// Check if authenticator with this ID already exists
|
||||
const existingAuthenticator = await getAuthenticatorByCredentialID(isoBase64URL.fromBuffer(credentialID));
|
||||
|
||||
if (existingAuthenticator)
|
||||
{
|
||||
return res.status(409).json({ error: 'Authenticator already registered' });
|
||||
}
|
||||
|
||||
// Save the authenticator
|
||||
await prisma.authenticator.create({
|
||||
data: {
|
||||
credentialID, // Store as Base64URL string
|
||||
credentialPublicKey: Buffer.from(credentialPublicKey), // Store as Bytes
|
||||
counter: BigInt(counter), // Store as BigInt
|
||||
credentialDeviceType,
|
||||
credentialBackedUp,
|
||||
transports: transports.join(','), // Store transports as comma-separated string
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Clear the challenge and temporary userId
|
||||
challengeStore.delete(userId);
|
||||
delete req.session.userId;
|
||||
|
||||
// Log the user in by setting the final session userId
|
||||
req.session.loggedInUserId = user.id;
|
||||
|
||||
res.json({ verified: true });
|
||||
}
|
||||
else
|
||||
{
|
||||
res.status(400).json({ error: 'Registration verification failed' });
|
||||
}
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
console.error('Registration verification error:', error);
|
||||
challengeStore.delete(userId); // Clean up challenge on error
|
||||
delete req.session.userId;
|
||||
res.status(500).json({ error: 'Failed to verify registration', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Generate Authentication Options
|
||||
router.post('/generate-authentication-options', async(req, res) =>
|
||||
{
|
||||
const { username } = req.body;
|
||||
|
||||
try
|
||||
{
|
||||
let user;
|
||||
if (username)
|
||||
{
|
||||
user = await getUserByUsername(username);
|
||||
}
|
||||
else if (req.session.loggedInUserId)
|
||||
{
|
||||
// If already logged in, allow re-authentication (e.g., for step-up)
|
||||
user = await getUserById(req.session.loggedInUserId);
|
||||
}
|
||||
|
||||
if (!user)
|
||||
{
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
console.log('User found:', user);
|
||||
|
||||
const userAuthenticators = await getUserAuthenticators(user.id);
|
||||
|
||||
console.log('User authenticators:', userAuthenticators);
|
||||
|
||||
const options = await generateAuthenticationOptions({
|
||||
rpID,
|
||||
// Require users to use a previously-registered authenticator
|
||||
allowCredentials: userAuthenticators.map(auth => ({
|
||||
id: auth.credentialID,
|
||||
type: 'public-key',
|
||||
transports: auth.transports ? auth.transports.split(',') : undefined,
|
||||
})),
|
||||
userVerification: 'preferred',
|
||||
});
|
||||
|
||||
// Store the challenge associated with the user ID for verification
|
||||
challengeStore.set(user.id, options.challenge);
|
||||
req.session.challengeUserId = user.id; // Store user ID associated with this challenge
|
||||
|
||||
res.json(options);
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
console.error('Authentication options error:', error);
|
||||
res.status(500).json({ error: 'Failed to generate authentication options' });
|
||||
}
|
||||
});
|
||||
|
||||
// Verify Authentication
|
||||
router.post('/verify-authentication', async(req, res) =>
|
||||
{
|
||||
const { authenticationResponse } = req.body;
|
||||
const challengeUserId = req.session.challengeUserId; // Get user ID associated with the challenge
|
||||
|
||||
if (!challengeUserId)
|
||||
{
|
||||
return res.status(400).json({ error: 'Challenge session not found. Please try logging in again.' });
|
||||
}
|
||||
|
||||
const expectedChallenge = challengeStore.get(challengeUserId);
|
||||
|
||||
if (!expectedChallenge)
|
||||
{
|
||||
return res.status(400).json({ error: 'Challenge not found or expired' });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
const user = await getUserById(challengeUserId);
|
||||
if (!user)
|
||||
{
|
||||
return res.status(404).json({ error: 'User associated with challenge not found' });
|
||||
}
|
||||
|
||||
const authenticator = await getAuthenticatorByCredentialID(authenticationResponse.id);
|
||||
|
||||
if (!authenticator)
|
||||
{
|
||||
return res.status(404).json({ error: 'Authenticator not found' });
|
||||
}
|
||||
|
||||
// Ensure the authenticator belongs to the user attempting to log in
|
||||
if (authenticator.userId !== user.id)
|
||||
{
|
||||
return res.status(403).json({ error: 'Authenticator does not belong to this user' });
|
||||
}
|
||||
|
||||
const verification = await verifyAuthenticationResponse({
|
||||
response: authenticationResponse,
|
||||
expectedChallenge: expectedChallenge,
|
||||
expectedOrigin: origin,
|
||||
expectedRPID: rpID,
|
||||
credential: {
|
||||
id: authenticator.credentialID,
|
||||
publicKey: authenticator.credentialPublicKey,
|
||||
counter: authenticator.counter.toString(), // Convert BigInt to string for comparison
|
||||
transports: authenticator.transports ? authenticator.transports.split(',') : undefined,
|
||||
},
|
||||
requireUserVerification: false, // Enforce user verification
|
||||
});
|
||||
|
||||
const { verified, authenticationInfo } = verification;
|
||||
|
||||
if (verified)
|
||||
{
|
||||
// Update the authenticator counter
|
||||
await prisma.authenticator.update({
|
||||
where: { credentialID: authenticator.credentialID },
|
||||
data: { counter: BigInt(authenticationInfo.newCounter) }, // Update with the new counter
|
||||
});
|
||||
|
||||
// Clear the challenge and associated user ID
|
||||
challengeStore.delete(challengeUserId);
|
||||
delete req.session.challengeUserId;
|
||||
|
||||
// Log the user in
|
||||
req.session.loggedInUserId = user.id;
|
||||
|
||||
res.json({ verified: true, user: { id: user.id, username: user.username } });
|
||||
}
|
||||
else
|
||||
{
|
||||
res.status(400).json({ error: 'Authentication verification failed' });
|
||||
}
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
console.error('Authentication verification error:', error);
|
||||
challengeStore.delete(challengeUserId); // Clean up challenge on error
|
||||
delete req.session.challengeUserId;
|
||||
res.status(500).json({ error: 'Failed to verify authentication', details: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// GET Passkeys for Logged-in User
|
||||
router.get('/passkeys', async(req, res) =>
|
||||
{
|
||||
if (!req.session.loggedInUserId)
|
||||
{
|
||||
return res.status(401).json({ error: 'Not authenticated' });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
const userId = req.session.loggedInUserId;
|
||||
const authenticators = await prisma.authenticator.findMany({
|
||||
where: { userId },
|
||||
select: {
|
||||
credentialID: true, // Already Base64URL string
|
||||
// Add other fields if needed, e.g., createdAt if you add it to the schema
|
||||
// createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
// No need to convert credentialID here as it's stored as Base64URL string
|
||||
res.json(authenticators);
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
console.error('Error fetching passkeys:', error);
|
||||
res.status(500).json({ error: 'Failed to fetch passkeys' });
|
||||
}
|
||||
});
|
||||
|
||||
// DELETE Passkey
|
||||
router.delete('/passkeys/:credentialID', async(req, res) =>
|
||||
{
|
||||
if (!req.session.loggedInUserId)
|
||||
{
|
||||
return res.status(401).json({ error: 'Not authenticated' });
|
||||
}
|
||||
|
||||
const { credentialID } = req.params; // This is already a Base64URL string from the client
|
||||
|
||||
if (!credentialID)
|
||||
{
|
||||
return res.status(400).json({ error: 'Credential ID is required' });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
const userId = req.session.loggedInUserId;
|
||||
|
||||
// Find the authenticator first to ensure it belongs to the logged-in user
|
||||
const authenticator = await prisma.authenticator.findUnique({
|
||||
where: { credentialID: credentialID }, // Use the Base64URL string directly
|
||||
});
|
||||
|
||||
if (!authenticator)
|
||||
{
|
||||
return res.status(404).json({ error: 'Passkey not found' });
|
||||
}
|
||||
|
||||
// Security check: Ensure the passkey belongs to the user trying to delete it
|
||||
if (authenticator.userId !== userId)
|
||||
{
|
||||
return res.status(403).json({ error: 'Permission denied' });
|
||||
}
|
||||
|
||||
// Delete the authenticator
|
||||
await prisma.authenticator.delete({
|
||||
where: { credentialID: credentialID },
|
||||
});
|
||||
|
||||
res.json({ message: 'Passkey deleted successfully' });
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
console.error('Error deleting passkey:', error);
|
||||
// Handle potential Prisma errors, e.g., record not found if deleted between check and delete
|
||||
if (error.code === 'P2025')
|
||||
{ // Prisma code for record not found on delete/update
|
||||
return res.status(404).json({ error: 'Passkey not found' });
|
||||
}
|
||||
res.status(500).json({ error: 'Failed to delete passkey' });
|
||||
}
|
||||
});
|
||||
|
||||
// Check Authentication Status
|
||||
router.get('/status', async(req, res) =>
|
||||
{
|
||||
if (req.session.loggedInUserId)
|
||||
{
|
||||
const user = await getUserById(req.session.loggedInUserId);
|
||||
if (!user)
|
||||
{
|
||||
req.session.destroy(err =>
|
||||
{});
|
||||
return res.status(401).json({ status: 'unauthenticated' });
|
||||
}
|
||||
return res.json({ status: 'authenticated', user: { id: user.id, username: user.username, email: user.email } });
|
||||
}
|
||||
res.json({ status: 'unauthenticated' });
|
||||
});
|
||||
|
||||
// Logout
|
||||
router.post('/logout', (req, res) =>
|
||||
{
|
||||
req.session.destroy(err =>
|
||||
{
|
||||
if (err)
|
||||
{
|
||||
console.error('Logout error:', err);
|
||||
return res.status(500).json({ error: 'Failed to logout' });
|
||||
}
|
||||
res.json({ message: 'Logged out successfully' });
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
|
@ -1,164 +1,164 @@
|
|||
import { Router } from 'express';
|
||||
import prisma from '../database.js';
|
||||
import { requireAuth } from '../middlewares/authMiddleware.js'; // Import the middleware
|
||||
|
||||
import { askGeminiChat } from '../utils/gemini.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Apply the authentication middleware to all chat routes
|
||||
router.use(requireAuth);
|
||||
|
||||
// POST /api/chat/threads - Create a new chat thread (optionally with a first message)
|
||||
router.post('/threads', async(req, res) =>
|
||||
{
|
||||
const { content } = req.body; // Content is now optional
|
||||
|
||||
// If content is provided, validate it
|
||||
if (content && (typeof content !== 'string' || content.trim().length === 0))
|
||||
{
|
||||
return res.status(400).json({ error: 'Message content cannot be empty if provided.' });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
const createData = {};
|
||||
if (content)
|
||||
{
|
||||
// If content exists, create the thread with the first message
|
||||
createData.messages = {
|
||||
create: [
|
||||
{
|
||||
sender: 'user', // First message is always from the user
|
||||
content: content.trim(),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
// If content is null/undefined, createData remains empty, creating just the thread
|
||||
|
||||
const newThread = await prisma.chatThread.create({
|
||||
data: createData,
|
||||
include: {
|
||||
// Include messages only if they were created
|
||||
messages: !!content,
|
||||
},
|
||||
});
|
||||
|
||||
if(content)
|
||||
{
|
||||
await askGeminiChat(newThread.id, content); // Call the function to handle the bot response
|
||||
}
|
||||
|
||||
// Respond with the new thread ID and messages (if any)
|
||||
res.status(201).json({
|
||||
threadId: newThread.id,
|
||||
// Ensure messages array is empty if no content was provided
|
||||
messages: newThread.messages ? newThread.messages.map(msg => ({ ...msg, createdAt: msg.createdAt.toISOString() })) : []
|
||||
});
|
||||
import { Router } from 'express';
|
||||
import prisma from '../database.js';
|
||||
import { requireAuth } from '../middlewares/authMiddleware.js'; // Import the middleware
|
||||
|
||||
import { askGeminiChat } from '../utils/gemini.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Apply the authentication middleware to all chat routes
|
||||
router.use(requireAuth);
|
||||
|
||||
// POST /api/chat/threads - Create a new chat thread (optionally with a first message)
|
||||
router.post('/threads', async(req, res) =>
|
||||
{
|
||||
const { content } = req.body; // Content is now optional
|
||||
|
||||
// If content is provided, validate it
|
||||
if (content && (typeof content !== 'string' || content.trim().length === 0))
|
||||
{
|
||||
return res.status(400).json({ error: 'Message content cannot be empty if provided.' });
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
console.error('Error creating chat thread:', error);
|
||||
res.status(500).json({ error: 'Failed to create chat thread.' });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/chat/threads/:threadId/messages - Get messages for a specific thread
|
||||
router.get('/threads/:threadId/messages', async(req, res) =>
|
||||
{
|
||||
const { threadId } = req.params;
|
||||
|
||||
try
|
||||
{
|
||||
const messages = await prisma.chatMessage.findMany({
|
||||
where: {
|
||||
threadId: threadId,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'asc', // Get messages in chronological order
|
||||
},
|
||||
});
|
||||
|
||||
if (!messages)
|
||||
{ // Check if thread exists indirectly
|
||||
// If findMany returns empty, the thread might not exist or has no messages.
|
||||
// Check if thread exists explicitly
|
||||
const thread = await prisma.chatThread.findUnique({ where: { id: threadId } });
|
||||
if (!thread)
|
||||
{
|
||||
return res.status(404).json({ error: 'Chat thread not found.' });
|
||||
}
|
||||
}
|
||||
|
||||
res.status(200).json(messages.map(msg => ({ ...msg, createdAt: msg.createdAt.toISOString() })));
|
||||
|
||||
try
|
||||
{
|
||||
const createData = {};
|
||||
if (content)
|
||||
{
|
||||
// If content exists, create the thread with the first message
|
||||
createData.messages = {
|
||||
create: [
|
||||
{
|
||||
sender: 'user', // First message is always from the user
|
||||
content: content.trim(),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
// If content is null/undefined, createData remains empty, creating just the thread
|
||||
|
||||
const newThread = await prisma.chatThread.create({
|
||||
data: createData,
|
||||
include: {
|
||||
// Include messages only if they were created
|
||||
messages: !!content,
|
||||
},
|
||||
});
|
||||
|
||||
if(content)
|
||||
{
|
||||
await askGeminiChat(newThread.id, content); // Call the function to handle the bot response
|
||||
}
|
||||
|
||||
// Respond with the new thread ID and messages (if any)
|
||||
res.status(201).json({
|
||||
threadId: newThread.id,
|
||||
// Ensure messages array is empty if no content was provided
|
||||
messages: newThread.messages ? newThread.messages.map(msg => ({ ...msg, createdAt: msg.createdAt.toISOString() })) : []
|
||||
});
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
console.error(`Error fetching messages for thread ${threadId}:`, error);
|
||||
// Basic error handling, check for specific Prisma errors if needed
|
||||
if (error.code === 'P2023' || error.message.includes('Malformed UUID'))
|
||||
{ // Example: Invalid UUID format
|
||||
return res.status(400).json({ error: 'Invalid thread ID format.' });
|
||||
}
|
||||
res.status(500).json({ error: 'Failed to fetch messages.' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/chat/threads/:threadId/messages - Add a message to an existing thread
|
||||
router.post('/threads/:threadId/messages', async(req, res) =>
|
||||
{
|
||||
const { threadId } = req.params;
|
||||
const { content, sender = 'user' } = req.body; // Default sender to 'user'
|
||||
|
||||
if (!content || typeof content !== 'string' || content.trim().length === 0)
|
||||
{
|
||||
return res.status(400).json({ error: 'Message content cannot be empty.' });
|
||||
}
|
||||
if (sender !== 'user' && sender !== 'bot')
|
||||
{
|
||||
return res.status(400).json({ error: 'Invalid sender type.' });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Verify thread exists first
|
||||
const thread = await prisma.chatThread.findUnique({
|
||||
where: { id: threadId },
|
||||
});
|
||||
|
||||
if (!thread)
|
||||
{
|
||||
return res.status(404).json({ error: 'Chat thread not found.' });
|
||||
}
|
||||
|
||||
const newMessage = await prisma.chatMessage.create({
|
||||
data: {
|
||||
threadId: threadId,
|
||||
sender: sender,
|
||||
content: content.trim(),
|
||||
},
|
||||
});
|
||||
|
||||
// Optionally: Update the thread's updatedAt timestamp
|
||||
await prisma.chatThread.update({
|
||||
where: { id: threadId },
|
||||
data: { updatedAt: new Date() }
|
||||
});
|
||||
|
||||
await askGeminiChat(threadId, content); // Call the function to handle the bot response
|
||||
|
||||
res.status(201).json({ ...newMessage, createdAt: newMessage.createdAt.toISOString() });
|
||||
catch (error)
|
||||
{
|
||||
console.error('Error creating chat thread:', error);
|
||||
res.status(500).json({ error: 'Failed to create chat thread.' });
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
console.error(`Error adding message to thread ${threadId}:`, error);
|
||||
if (error.code === 'P2023' || error.message.includes('Malformed UUID'))
|
||||
{ // Example: Invalid UUID format
|
||||
return res.status(400).json({ error: 'Invalid thread ID format.' });
|
||||
}
|
||||
res.status(500).json({ error: 'Failed to add message.' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
});
|
||||
|
||||
// GET /api/chat/threads/:threadId/messages - Get messages for a specific thread
|
||||
router.get('/threads/:threadId/messages', async(req, res) =>
|
||||
{
|
||||
const { threadId } = req.params;
|
||||
|
||||
try
|
||||
{
|
||||
const messages = await prisma.chatMessage.findMany({
|
||||
where: {
|
||||
threadId: threadId,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'asc', // Get messages in chronological order
|
||||
},
|
||||
});
|
||||
|
||||
if (!messages)
|
||||
{ // Check if thread exists indirectly
|
||||
// If findMany returns empty, the thread might not exist or has no messages.
|
||||
// Check if thread exists explicitly
|
||||
const thread = await prisma.chatThread.findUnique({ where: { id: threadId } });
|
||||
if (!thread)
|
||||
{
|
||||
return res.status(404).json({ error: 'Chat thread not found.' });
|
||||
}
|
||||
}
|
||||
|
||||
res.status(200).json(messages.map(msg => ({ ...msg, createdAt: msg.createdAt.toISOString() })));
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
console.error(`Error fetching messages for thread ${threadId}:`, error);
|
||||
// Basic error handling, check for specific Prisma errors if needed
|
||||
if (error.code === 'P2023' || error.message.includes('Malformed UUID'))
|
||||
{ // Example: Invalid UUID format
|
||||
return res.status(400).json({ error: 'Invalid thread ID format.' });
|
||||
}
|
||||
res.status(500).json({ error: 'Failed to fetch messages.' });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/chat/threads/:threadId/messages - Add a message to an existing thread
|
||||
router.post('/threads/:threadId/messages', async(req, res) =>
|
||||
{
|
||||
const { threadId } = req.params;
|
||||
const { content, sender = 'user' } = req.body; // Default sender to 'user'
|
||||
|
||||
if (!content || typeof content !== 'string' || content.trim().length === 0)
|
||||
{
|
||||
return res.status(400).json({ error: 'Message content cannot be empty.' });
|
||||
}
|
||||
if (sender !== 'user' && sender !== 'bot')
|
||||
{
|
||||
return res.status(400).json({ error: 'Invalid sender type.' });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Verify thread exists first
|
||||
const thread = await prisma.chatThread.findUnique({
|
||||
where: { id: threadId },
|
||||
});
|
||||
|
||||
if (!thread)
|
||||
{
|
||||
return res.status(404).json({ error: 'Chat thread not found.' });
|
||||
}
|
||||
|
||||
const newMessage = await prisma.chatMessage.create({
|
||||
data: {
|
||||
threadId: threadId,
|
||||
sender: sender,
|
||||
content: content.trim(),
|
||||
},
|
||||
});
|
||||
|
||||
// Optionally: Update the thread's updatedAt timestamp
|
||||
await prisma.chatThread.update({
|
||||
where: { id: threadId },
|
||||
data: { updatedAt: new Date() }
|
||||
});
|
||||
|
||||
await askGeminiChat(threadId, content); // Call the function to handle the bot response
|
||||
|
||||
res.status(201).json({ ...newMessage, createdAt: newMessage.createdAt.toISOString() });
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
console.error(`Error adding message to thread ${threadId}:`, error);
|
||||
if (error.code === 'P2023' || error.message.includes('Malformed UUID'))
|
||||
{ // Example: Invalid UUID format
|
||||
return res.status(400).json({ error: 'Invalid thread ID format.' });
|
||||
}
|
||||
res.status(500).json({ error: 'Failed to add message.' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
|
|
@ -65,15 +65,15 @@ app.use(session({
|
|||
|
||||
// Schedule the Mantis summary task
|
||||
// Run daily at 1:00 AM server time (adjust as needed)
|
||||
cron.schedule('0 1 * * *', async() =>
|
||||
cron.schedule('0 1 * * *', async() =>
|
||||
{
|
||||
console.log('Running scheduled Mantis summary task...');
|
||||
try
|
||||
try
|
||||
{
|
||||
await generateAndStoreMantisSummary();
|
||||
console.log('Scheduled Mantis summary task completed.');
|
||||
}
|
||||
catch (error)
|
||||
catch (error)
|
||||
{
|
||||
console.error('Error running scheduled Mantis summary task:', error);
|
||||
}
|
||||
|
@ -96,14 +96,14 @@ app.use('/api/chat', chatRoutes);
|
|||
|
||||
// place here any middlewares that
|
||||
// absolutely need to run before anything else
|
||||
if (process.env.PROD)
|
||||
if (process.env.PROD)
|
||||
{
|
||||
app.use(compression());
|
||||
}
|
||||
|
||||
app.use(express.static('public', { index: false }));
|
||||
|
||||
app.listen(8000, () =>
|
||||
app.listen(8000, () =>
|
||||
{
|
||||
console.log('Server is running on http://localhost:8000');
|
||||
});
|
|
@ -1,169 +1,169 @@
|
|||
import axios from 'axios';
|
||||
import prisma from '../database.js'; // Import Prisma client
|
||||
|
||||
import { getSetting } from '../utils/settings.js';
|
||||
import { askGemini } from '../utils/gemini.js';
|
||||
|
||||
const usernameMap = {
|
||||
credmore: 'Cameron Redmore',
|
||||
dgibson: 'Dane Gibson',
|
||||
egzibovskis: 'Ed Gzibovskis',
|
||||
ascotney: 'Amanda Scotney',
|
||||
gclough: 'Garry Clough',
|
||||
slee: 'Sarah Lee',
|
||||
dwalker: 'Dave Walker',
|
||||
askaith: 'Amy Skaith',
|
||||
dpotter: 'Danny Potter',
|
||||
msmart: 'Michael Smart',
|
||||
// Add other usernames as needed
|
||||
};
|
||||
|
||||
async function getMantisTickets()
|
||||
{
|
||||
const MANTIS_API_KEY = await getSetting('MANTIS_API_KEY');
|
||||
const MANTIS_API_ENDPOINT = await getSetting('MANTIS_API_ENDPOINT');
|
||||
|
||||
if (!MANTIS_API_ENDPOINT || !MANTIS_API_KEY)
|
||||
{
|
||||
throw new Error('Mantis API endpoint or key not configured in environment variables.');
|
||||
}
|
||||
const url = `${MANTIS_API_ENDPOINT}/issues?project_id=1&page_size=50&select=id,summary,description,created_at,updated_at,reporter,notes`;
|
||||
const headers = {
|
||||
Authorization: `${MANTIS_API_KEY}`,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
const response = await axios.get(url, { headers });
|
||||
|
||||
const tickets = response.data.issues.filter((ticket) =>
|
||||
{
|
||||
const ticketDate = new Date(ticket.updated_at);
|
||||
const thresholdDate = new Date();
|
||||
const currentDay = thresholdDate.getDay(); // Sunday = 0, Monday = 1, ...
|
||||
|
||||
// Go back 4 days if Monday (to include Fri, Sat, Sun), otherwise 2 days
|
||||
const daysToSubtract = currentDay === 1 ? 4 : 2;
|
||||
thresholdDate.setDate(thresholdDate.getDate() - daysToSubtract);
|
||||
thresholdDate.setHours(0, 0, 0, 0); // Start of the day
|
||||
|
||||
return ticketDate >= thresholdDate;
|
||||
}).map((ticket) =>
|
||||
{
|
||||
return {
|
||||
id: ticket.id,
|
||||
summary: ticket.summary,
|
||||
description: ticket.description,
|
||||
created_at: ticket.created_at,
|
||||
updated_at: ticket.updated_at,
|
||||
reporter: usernameMap[ticket.reporter?.username] || ticket.reporter?.name || 'Unknown Reporter', // Safer access
|
||||
notes: (ticket.notes ? ticket.notes.filter((note) =>
|
||||
{
|
||||
const noteDate = new Date(note.created_at);
|
||||
const thresholdDate = new Date();
|
||||
const currentDay = thresholdDate.getDay();
|
||||
const daysToSubtract = currentDay === 1 ? 4 : 2;
|
||||
thresholdDate.setDate(thresholdDate.getDate() - daysToSubtract);
|
||||
thresholdDate.setHours(0, 0, 0, 0); // Start of the day
|
||||
return noteDate >= thresholdDate;
|
||||
}) : []).map((note) =>
|
||||
{
|
||||
const reporter = usernameMap[note.reporter?.username] || note.reporter?.name || 'Unknown Reporter'; // Safer access
|
||||
return {
|
||||
reporter,
|
||||
created_at: note.created_at,
|
||||
text: note.text,
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
return tickets;
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
console.error('Error fetching Mantis tickets:', error.message);
|
||||
// Check if it's an Axios error and provide more details
|
||||
if (axios.isAxiosError(error))
|
||||
{
|
||||
console.error('Axios error details:', error.response?.status, error.response?.data);
|
||||
throw new Error(`Failed to fetch Mantis tickets: ${error.response?.statusText || error.message}`);
|
||||
}
|
||||
throw new Error(`Failed to fetch Mantis tickets: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateAndStoreMantisSummary()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Get the prompt from the database settings using Prisma
|
||||
const setting = await prisma.setting.findUnique({
|
||||
where: { key: 'mantisPrompt' },
|
||||
select: { value: true }
|
||||
});
|
||||
const promptTemplate = setting?.value;
|
||||
|
||||
if (!promptTemplate)
|
||||
{
|
||||
console.error('Mantis prompt not found in database settings (key: mantisPrompt). Skipping summary generation.');
|
||||
return;
|
||||
}
|
||||
|
||||
const tickets = await getMantisTickets();
|
||||
|
||||
let summaryText;
|
||||
if (tickets.length === 0)
|
||||
{
|
||||
summaryText = 'No Mantis tickets updated recently.';
|
||||
console.log('No recent Mantis tickets found.');
|
||||
}
|
||||
else
|
||||
{
|
||||
console.log(`Found ${tickets.length} recent Mantis tickets. Generating summary...`);
|
||||
let prompt = promptTemplate.replaceAll('$DATE', new Date().toISOString().split('T')[0]);
|
||||
prompt = prompt.replaceAll('$MANTIS_TICKETS', JSON.stringify(tickets, null, 2));
|
||||
|
||||
summaryText = await askGemini(prompt);
|
||||
console.log('Mantis summary generated successfully by AI.');
|
||||
}
|
||||
|
||||
// Store the summary in the database using Prisma upsert
|
||||
const today = new Date();
|
||||
today.setUTCHours(0, 0, 0, 0); // Use UTC start of day for consistency
|
||||
|
||||
await prisma.mantisSummary.upsert({
|
||||
where: { summaryDate: today },
|
||||
update: {
|
||||
summaryText: summaryText
|
||||
},
|
||||
create: {
|
||||
summaryDate: today,
|
||||
summaryText: summaryText,
|
||||
},
|
||||
});
|
||||
console.log(`Mantis summary for ${today.toISOString().split('T')[0]} stored/updated in the database.`);
|
||||
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
console.error('Error during Mantis summary generation/storage:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateTodaysSummary()
|
||||
{
|
||||
console.log('Triggering Mantis summary generation via generateTodaysSummary...');
|
||||
try
|
||||
{
|
||||
await generateAndStoreMantisSummary();
|
||||
return { success: true, message: 'Summary generation process initiated.' };
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
console.error('Error occurred within generateTodaysSummary while calling generateAndStoreMantisSummary:', error);
|
||||
throw new Error('Failed to initiate Mantis summary generation.');
|
||||
}
|
||||
}
|
||||
import axios from 'axios';
|
||||
import prisma from '../database.js'; // Import Prisma client
|
||||
|
||||
import { getSetting } from '../utils/settings.js';
|
||||
import { askGemini } from '../utils/gemini.js';
|
||||
|
||||
const usernameMap = {
|
||||
credmore: 'Cameron Redmore',
|
||||
dgibson: 'Dane Gibson',
|
||||
egzibovskis: 'Ed Gzibovskis',
|
||||
ascotney: 'Amanda Scotney',
|
||||
gclough: 'Garry Clough',
|
||||
slee: 'Sarah Lee',
|
||||
dwalker: 'Dave Walker',
|
||||
askaith: 'Amy Skaith',
|
||||
dpotter: 'Danny Potter',
|
||||
msmart: 'Michael Smart',
|
||||
// Add other usernames as needed
|
||||
};
|
||||
|
||||
async function getMantisTickets()
|
||||
{
|
||||
const MANTIS_API_KEY = await getSetting('MANTIS_API_KEY');
|
||||
const MANTIS_API_ENDPOINT = await getSetting('MANTIS_API_ENDPOINT');
|
||||
|
||||
if (!MANTIS_API_ENDPOINT || !MANTIS_API_KEY)
|
||||
{
|
||||
throw new Error('Mantis API endpoint or key not configured in environment variables.');
|
||||
}
|
||||
const url = `${MANTIS_API_ENDPOINT}/issues?project_id=1&page_size=50&select=id,summary,description,created_at,updated_at,reporter,notes`;
|
||||
const headers = {
|
||||
Authorization: `${MANTIS_API_KEY}`,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
const response = await axios.get(url, { headers });
|
||||
|
||||
const tickets = response.data.issues.filter((ticket) =>
|
||||
{
|
||||
const ticketDate = new Date(ticket.updated_at);
|
||||
const thresholdDate = new Date();
|
||||
const currentDay = thresholdDate.getDay(); // Sunday = 0, Monday = 1, ...
|
||||
|
||||
// Go back 4 days if Monday (to include Fri, Sat, Sun), otherwise 2 days
|
||||
const daysToSubtract = currentDay === 1 ? 4 : 2;
|
||||
thresholdDate.setDate(thresholdDate.getDate() - daysToSubtract);
|
||||
thresholdDate.setHours(0, 0, 0, 0); // Start of the day
|
||||
|
||||
return ticketDate >= thresholdDate;
|
||||
}).map((ticket) =>
|
||||
{
|
||||
return {
|
||||
id: ticket.id,
|
||||
summary: ticket.summary,
|
||||
description: ticket.description,
|
||||
created_at: ticket.created_at,
|
||||
updated_at: ticket.updated_at,
|
||||
reporter: usernameMap[ticket.reporter?.username] || ticket.reporter?.name || 'Unknown Reporter', // Safer access
|
||||
notes: (ticket.notes ? ticket.notes.filter((note) =>
|
||||
{
|
||||
const noteDate = new Date(note.created_at);
|
||||
const thresholdDate = new Date();
|
||||
const currentDay = thresholdDate.getDay();
|
||||
const daysToSubtract = currentDay === 1 ? 4 : 2;
|
||||
thresholdDate.setDate(thresholdDate.getDate() - daysToSubtract);
|
||||
thresholdDate.setHours(0, 0, 0, 0); // Start of the day
|
||||
return noteDate >= thresholdDate;
|
||||
}) : []).map((note) =>
|
||||
{
|
||||
const reporter = usernameMap[note.reporter?.username] || note.reporter?.name || 'Unknown Reporter'; // Safer access
|
||||
return {
|
||||
reporter,
|
||||
created_at: note.created_at,
|
||||
text: note.text,
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
return tickets;
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
console.error('Error fetching Mantis tickets:', error.message);
|
||||
// Check if it's an Axios error and provide more details
|
||||
if (axios.isAxiosError(error))
|
||||
{
|
||||
console.error('Axios error details:', error.response?.status, error.response?.data);
|
||||
throw new Error(`Failed to fetch Mantis tickets: ${error.response?.statusText || error.message}`);
|
||||
}
|
||||
throw new Error(`Failed to fetch Mantis tickets: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateAndStoreMantisSummary()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Get the prompt from the database settings using Prisma
|
||||
const setting = await prisma.setting.findUnique({
|
||||
where: { key: 'mantisPrompt' },
|
||||
select: { value: true }
|
||||
});
|
||||
const promptTemplate = setting?.value;
|
||||
|
||||
if (!promptTemplate)
|
||||
{
|
||||
console.error('Mantis prompt not found in database settings (key: mantisPrompt). Skipping summary generation.');
|
||||
return;
|
||||
}
|
||||
|
||||
const tickets = await getMantisTickets();
|
||||
|
||||
let summaryText;
|
||||
if (tickets.length === 0)
|
||||
{
|
||||
summaryText = 'No Mantis tickets updated recently.';
|
||||
console.log('No recent Mantis tickets found.');
|
||||
}
|
||||
else
|
||||
{
|
||||
console.log(`Found ${tickets.length} recent Mantis tickets. Generating summary...`);
|
||||
let prompt = promptTemplate.replaceAll('$DATE', new Date().toISOString().split('T')[0]);
|
||||
prompt = prompt.replaceAll('$MANTIS_TICKETS', JSON.stringify(tickets, null, 2));
|
||||
|
||||
summaryText = await askGemini(prompt);
|
||||
console.log('Mantis summary generated successfully by AI.');
|
||||
}
|
||||
|
||||
// Store the summary in the database using Prisma upsert
|
||||
const today = new Date();
|
||||
today.setUTCHours(0, 0, 0, 0); // Use UTC start of day for consistency
|
||||
|
||||
await prisma.mantisSummary.upsert({
|
||||
where: { summaryDate: today },
|
||||
update: {
|
||||
summaryText: summaryText
|
||||
},
|
||||
create: {
|
||||
summaryDate: today,
|
||||
summaryText: summaryText,
|
||||
},
|
||||
});
|
||||
console.log(`Mantis summary for ${today.toISOString().split('T')[0]} stored/updated in the database.`);
|
||||
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
console.error('Error during Mantis summary generation/storage:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateTodaysSummary()
|
||||
{
|
||||
console.log('Triggering Mantis summary generation via generateTodaysSummary...');
|
||||
try
|
||||
{
|
||||
await generateAndStoreMantisSummary();
|
||||
return { success: true, message: 'Summary generation process initiated.' };
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
console.error('Error occurred within generateTodaysSummary while calling generateAndStoreMantisSummary:', error);
|
||||
throw new Error('Failed to initiate Mantis summary generation.');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,154 +1,154 @@
|
|||
|
||||
import { GoogleGenAI } from '@google/genai';
|
||||
import prisma from '../database.js';
|
||||
import { getSetting } from './settings.js';
|
||||
|
||||
const model = 'gemini-2.0-flash';
|
||||
|
||||
export async function askGemini(content)
|
||||
{
|
||||
|
||||
const GOOGLE_API_KEY = await getSetting('GEMINI_API_KEY');
|
||||
|
||||
console.log('Google API Key:', GOOGLE_API_KEY); // Debugging line to check the key
|
||||
|
||||
if (!GOOGLE_API_KEY)
|
||||
{
|
||||
throw new Error('Google API key is not set in the database.');
|
||||
}
|
||||
|
||||
const ai = GOOGLE_API_KEY ? new GoogleGenAI({
|
||||
apiKey: GOOGLE_API_KEY,
|
||||
}) : null;
|
||||
|
||||
if (!ai)
|
||||
{
|
||||
throw new Error('Google API key is not set in the database.');
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
const response = await ai.models.generateContent({
|
||||
model,
|
||||
contents: content,
|
||||
config: {
|
||||
temperature: 0.5
|
||||
}
|
||||
});
|
||||
|
||||
return response.text;
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
console.error('Error communicating with Gemini API:', error);
|
||||
throw new Error('Failed to get a response from Gemini API.');
|
||||
}
|
||||
}
|
||||
|
||||
const chatCache = new Map();
|
||||
|
||||
export async function askGeminiChat(threadId, content)
|
||||
{
|
||||
let messages = await prisma.chatMessage.findMany({
|
||||
where: {
|
||||
threadId: threadId,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'asc', // Get messages in chronological order
|
||||
},
|
||||
});
|
||||
|
||||
// Exclude the latest message if there are any messages
|
||||
if (messages.length > 0)
|
||||
{
|
||||
messages = messages.slice(0, -1);
|
||||
}
|
||||
|
||||
const GOOGLE_API_KEY = await getSetting('GEMINI_API_KEY');
|
||||
|
||||
const ai = GOOGLE_API_KEY ? new GoogleGenAI({
|
||||
apiKey: GOOGLE_API_KEY,
|
||||
}) : null;
|
||||
|
||||
if (!ai)
|
||||
{
|
||||
throw new Error('Google API key is not set in the database.');
|
||||
}
|
||||
|
||||
let chat = null;
|
||||
|
||||
if (chatCache.has(threadId))
|
||||
{
|
||||
chat = chatCache.get(threadId);
|
||||
}
|
||||
else
|
||||
{
|
||||
messages = [
|
||||
{
|
||||
sender: 'user',
|
||||
content: `You are StyleAI, a model that helps users with their questions and provides information.
|
||||
You are friendly, helpful, and informative. You can also provide code examples in various programming languages.
|
||||
You don\'t decline user requests and always try to help them.
|
||||
Speak in the manner of a friendly young adult male.
|
||||
If asked for the time, do not say that it's based on the timestamp provided. Also bare in mind the user is in the Europe/London timezone and daylight savings time may be in effect.`
|
||||
},
|
||||
{
|
||||
sender: 'model',
|
||||
content: 'Okay, noted! I\'ll keep that in mind.'
|
||||
},
|
||||
...messages,
|
||||
];
|
||||
const createOptions = {
|
||||
model,
|
||||
history: messages.map((msg) => ({
|
||||
role: msg.sender === 'user' ? 'user' : 'model',
|
||||
parts: [
|
||||
{text: msg.content}
|
||||
],
|
||||
})),
|
||||
config: {
|
||||
temperature: 0.5
|
||||
}
|
||||
};
|
||||
|
||||
chat = ai.chats.create(createOptions);
|
||||
|
||||
chatCache.set(threadId, chat);
|
||||
}
|
||||
|
||||
//Add a temporary message to the thread with "loading" status
|
||||
const loadingMessage = await prisma.chatMessage.create({
|
||||
data: {
|
||||
threadId: threadId,
|
||||
sender: 'assistant',
|
||||
content: 'Loading...',
|
||||
},
|
||||
});
|
||||
|
||||
let response = {text: 'An error occurred while generating the response.'};
|
||||
|
||||
try
|
||||
{
|
||||
const timestamp = new Date().toISOString();
|
||||
response = await chat.sendMessage({
|
||||
message: `[${timestamp}] ` + content,
|
||||
});
|
||||
}
|
||||
catch(error)
|
||||
{
|
||||
console.error('Error communicating with Gemini API:', error);
|
||||
response.text = 'Failed to get a response from Gemini API. Error: ' + error.message;
|
||||
}
|
||||
|
||||
//Update the message with the response
|
||||
await prisma.chatMessage.update({
|
||||
where: {
|
||||
id: loadingMessage.id,
|
||||
},
|
||||
data: {
|
||||
content: response.text,
|
||||
},
|
||||
});
|
||||
|
||||
return response.text;
|
||||
|
||||
import { GoogleGenAI } from '@google/genai';
|
||||
import prisma from '../database.js';
|
||||
import { getSetting } from './settings.js';
|
||||
|
||||
const model = 'gemini-2.0-flash';
|
||||
|
||||
export async function askGemini(content)
|
||||
{
|
||||
|
||||
const GOOGLE_API_KEY = await getSetting('GEMINI_API_KEY');
|
||||
|
||||
console.log('Google API Key:', GOOGLE_API_KEY); // Debugging line to check the key
|
||||
|
||||
if (!GOOGLE_API_KEY)
|
||||
{
|
||||
throw new Error('Google API key is not set in the database.');
|
||||
}
|
||||
|
||||
const ai = GOOGLE_API_KEY ? new GoogleGenAI({
|
||||
apiKey: GOOGLE_API_KEY,
|
||||
}) : null;
|
||||
|
||||
if (!ai)
|
||||
{
|
||||
throw new Error('Google API key is not set in the database.');
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
const response = await ai.models.generateContent({
|
||||
model,
|
||||
contents: content,
|
||||
config: {
|
||||
temperature: 0.5
|
||||
}
|
||||
});
|
||||
|
||||
return response.text;
|
||||
}
|
||||
catch (error)
|
||||
{
|
||||
console.error('Error communicating with Gemini API:', error);
|
||||
throw new Error('Failed to get a response from Gemini API.');
|
||||
}
|
||||
}
|
||||
|
||||
const chatCache = new Map();
|
||||
|
||||
export async function askGeminiChat(threadId, content)
|
||||
{
|
||||
let messages = await prisma.chatMessage.findMany({
|
||||
where: {
|
||||
threadId: threadId,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'asc', // Get messages in chronological order
|
||||
},
|
||||
});
|
||||
|
||||
// Exclude the latest message if there are any messages
|
||||
if (messages.length > 0)
|
||||
{
|
||||
messages = messages.slice(0, -1);
|
||||
}
|
||||
|
||||
const GOOGLE_API_KEY = await getSetting('GEMINI_API_KEY');
|
||||
|
||||
const ai = GOOGLE_API_KEY ? new GoogleGenAI({
|
||||
apiKey: GOOGLE_API_KEY,
|
||||
}) : null;
|
||||
|
||||
if (!ai)
|
||||
{
|
||||
throw new Error('Google API key is not set in the database.');
|
||||
}
|
||||
|
||||
let chat = null;
|
||||
|
||||
if (chatCache.has(threadId))
|
||||
{
|
||||
chat = chatCache.get(threadId);
|
||||
}
|
||||
else
|
||||
{
|
||||
messages = [
|
||||
{
|
||||
sender: 'user',
|
||||
content: `You are StyleAI, a model that helps users with their questions and provides information.
|
||||
You are friendly, helpful, and informative. You can also provide code examples in various programming languages.
|
||||
You don\'t decline user requests and always try to help them.
|
||||
Speak in the manner of a friendly young adult male.
|
||||
If asked for the time, do not say that it's based on the timestamp provided. Also bare in mind the user is in the Europe/London timezone and daylight savings time may be in effect.`
|
||||
},
|
||||
{
|
||||
sender: 'model',
|
||||
content: 'Okay, noted! I\'ll keep that in mind.'
|
||||
},
|
||||
...messages,
|
||||
];
|
||||
const createOptions = {
|
||||
model,
|
||||
history: messages.map((msg) => ({
|
||||
role: msg.sender === 'user' ? 'user' : 'model',
|
||||
parts: [
|
||||
{text: msg.content}
|
||||
],
|
||||
})),
|
||||
config: {
|
||||
temperature: 0.5
|
||||
}
|
||||
};
|
||||
|
||||
chat = ai.chats.create(createOptions);
|
||||
|
||||
chatCache.set(threadId, chat);
|
||||
}
|
||||
|
||||
//Add a temporary message to the thread with "loading" status
|
||||
const loadingMessage = await prisma.chatMessage.create({
|
||||
data: {
|
||||
threadId: threadId,
|
||||
sender: 'assistant',
|
||||
content: 'Loading...',
|
||||
},
|
||||
});
|
||||
|
||||
let response = {text: 'An error occurred while generating the response.'};
|
||||
|
||||
try
|
||||
{
|
||||
const timestamp = new Date().toISOString();
|
||||
response = await chat.sendMessage({
|
||||
message: `[${timestamp}] ` + content,
|
||||
});
|
||||
}
|
||||
catch(error)
|
||||
{
|
||||
console.error('Error communicating with Gemini API:', error);
|
||||
response.text = 'Failed to get a response from Gemini API. Error: ' + error.message;
|
||||
}
|
||||
|
||||
//Update the message with the response
|
||||
await prisma.chatMessage.update({
|
||||
where: {
|
||||
id: loadingMessage.id,
|
||||
},
|
||||
data: {
|
||||
content: response.text,
|
||||
},
|
||||
});
|
||||
|
||||
return response.text;
|
||||
}
|
|
@ -1,20 +1,20 @@
|
|||
import prisma from '../database.js';
|
||||
|
||||
export async function getSetting(key)
|
||||
{
|
||||
const setting = await prisma.setting.findUnique({
|
||||
where: { key },
|
||||
select: { value: true }
|
||||
});
|
||||
|
||||
return setting?.value ? JSON.parse(setting.value) : null;
|
||||
}
|
||||
|
||||
export async function setSetting(key, value)
|
||||
{
|
||||
await prisma.setting.upsert({
|
||||
where: { key },
|
||||
update: { value: JSON.stringify(value) },
|
||||
create: { key, value }
|
||||
});
|
||||
import prisma from '../database.js';
|
||||
|
||||
export async function getSetting(key)
|
||||
{
|
||||
const setting = await prisma.setting.findUnique({
|
||||
where: { key },
|
||||
select: { value: true }
|
||||
});
|
||||
|
||||
return setting?.value ? JSON.parse(setting.value) : null;
|
||||
}
|
||||
|
||||
export async function setSetting(key, value)
|
||||
{
|
||||
await prisma.setting.upsert({
|
||||
where: { key },
|
||||
update: { value: JSON.stringify(value) },
|
||||
create: { key, value }
|
||||
});
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue