This commit is contained in:
2025-07-20 20:43:06 +02:00
parent 0abee5b794
commit 29592c7fc8
93 changed files with 23400 additions and 131 deletions

View File

@ -0,0 +1,434 @@
const dbConnection = require('../database/connection');
const { dbErrorHandler } = require('../middleware/errorHandler');
class Bookmark {
constructor(bookmarkData = {}) {
this.id = bookmarkData.id;
this.user_id = bookmarkData.user_id;
this.title = bookmarkData.title;
this.url = bookmarkData.url;
this.folder = bookmarkData.folder || '';
this.add_date = bookmarkData.add_date;
this.last_modified = bookmarkData.last_modified;
this.icon = bookmarkData.icon;
this.status = bookmarkData.status || 'unknown';
this.created_at = bookmarkData.created_at;
this.updated_at = bookmarkData.updated_at;
}
/**
* Validate bookmark data
* @param {object} bookmarkData - Bookmark data to validate
* @returns {object} - Validation result with isValid and errors
*/
static validateBookmark(bookmarkData) {
const errors = [];
if (!bookmarkData.title || bookmarkData.title.trim().length === 0) {
errors.push('Title is required');
}
if (bookmarkData.title && bookmarkData.title.length > 500) {
errors.push('Title must be 500 characters or less');
}
if (!bookmarkData.url || bookmarkData.url.trim().length === 0) {
errors.push('URL is required');
}
if (bookmarkData.url) {
try {
new URL(bookmarkData.url);
} catch (error) {
errors.push('Invalid URL format');
}
}
if (bookmarkData.folder && bookmarkData.folder.length > 255) {
errors.push('Folder name must be 255 characters or less');
}
const validStatuses = ['unknown', 'valid', 'invalid', 'testing', 'duplicate'];
if (bookmarkData.status && !validStatuses.includes(bookmarkData.status)) {
errors.push('Invalid status value');
}
return {
isValid: errors.length === 0,
errors
};
}
/**
* Create a new bookmark
* @param {string} userId - User ID
* @param {object} bookmarkData - Bookmark data
* @returns {Promise<Bookmark>} - Created bookmark instance
*/
static async create(userId, bookmarkData) {
// Validate bookmark data
const validation = Bookmark.validateBookmark(bookmarkData);
if (!validation.isValid) {
throw new Error(`Bookmark validation failed: ${validation.errors.join(', ')}`);
}
const {
title,
url,
folder = '',
add_date = new Date(),
last_modified,
icon,
status = 'unknown'
} = bookmarkData;
const query = `
INSERT INTO bookmarks (user_id, title, url, folder, add_date, last_modified, icon, status)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING *
`;
const values = [
userId,
title.trim(),
url.trim(),
folder.trim(),
add_date,
last_modified,
icon,
status
];
return await dbErrorHandler(async () => {
const result = await dbConnection.query(query, values);
return new Bookmark(result.rows[0]);
});
}
/**
* Find bookmarks by user ID with pagination and filtering
* @param {string} userId - User ID
* @param {object} options - Query options
* @returns {Promise<object>} - Bookmarks with pagination info
*/
static async findByUserId(userId, options = {}) {
return await dbErrorHandler(async () => {
const {
page = 1,
limit = null,
folder,
status,
search,
sortBy = 'add_date',
sortOrder = 'DESC'
} = options;
const offset = limit ? (page - 1) * limit : 0;
const validSortColumns = ['add_date', 'title', 'url', 'folder', 'created_at', 'updated_at'];
const validSortOrders = ['ASC', 'DESC'];
const sortColumn = validSortColumns.includes(sortBy) ? sortBy : 'add_date';
const sortDirection = validSortOrders.includes(sortOrder.toUpperCase()) ? sortOrder.toUpperCase() : 'DESC';
let whereConditions = ['user_id = $1'];
let queryParams = [userId];
let paramIndex = 2;
// Add folder filter
if (folder !== undefined) {
whereConditions.push(`folder = $${paramIndex}`);
queryParams.push(folder);
paramIndex++;
}
// Add status filter
if (status) {
whereConditions.push(`status = $${paramIndex}`);
queryParams.push(status);
paramIndex++;
}
// Add search filter
if (search) {
whereConditions.push(`(title ILIKE $${paramIndex} OR url ILIKE $${paramIndex})`);
queryParams.push(`%${search}%`);
paramIndex++;
}
const whereClause = whereConditions.join(' AND ');
// Get total count
const countQuery = `SELECT COUNT(*) FROM bookmarks WHERE ${whereClause}`;
const countResult = await dbConnection.query(countQuery, queryParams);
const totalCount = parseInt(countResult.rows[0].count);
// Build query with optional LIMIT and OFFSET
let query = `
SELECT * FROM bookmarks
WHERE ${whereClause}
ORDER BY ${sortColumn} ${sortDirection}
`;
// Only add LIMIT and OFFSET if limit is specified
if (limit !== null && limit > 0) {
query += ` LIMIT $${paramIndex} OFFSET $${paramIndex + 1}`;
queryParams.push(limit, offset);
}
const result = await dbConnection.query(query, queryParams);
const bookmarks = result.rows.map(row => new Bookmark(row));
// Calculate pagination info
const totalPages = limit ? Math.ceil(totalCount / limit) : 1;
return {
bookmarks,
pagination: {
page,
limit,
totalCount,
totalPages,
hasNext: limit ? page < totalPages : false,
hasPrev: page > 1
}
};
});
}
/**
* Find bookmark by ID and user ID (for ownership validation)
* @param {string} bookmarkId - Bookmark ID
* @param {string} userId - User ID
* @returns {Promise<Bookmark|null>} - Bookmark instance or null
*/
static async findByIdAndUserId(bookmarkId, userId) {
return await dbErrorHandler(async () => {
const query = 'SELECT * FROM bookmarks WHERE id = $1 AND user_id = $2';
const result = await dbConnection.query(query, [bookmarkId, userId]);
if (result.rows.length === 0) {
return null;
}
return new Bookmark(result.rows[0]);
});
}
/**
* Update bookmark
* @param {object} updates - Fields to update
* @returns {Promise<boolean>} - True if update successful
*/
async update(updates) {
const allowedFields = ['title', 'url', 'folder', 'last_modified', 'icon', 'status'];
const updateFields = [];
const values = [];
let paramIndex = 1;
// Validate updates
if (Object.keys(updates).length > 0) {
const validation = Bookmark.validateBookmark({ ...this.toObject(), ...updates });
if (!validation.isValid) {
throw new Error(`Bookmark validation failed: ${validation.errors.join(', ')}`);
}
}
for (const [field, value] of Object.entries(updates)) {
if (allowedFields.includes(field)) {
updateFields.push(`${field} = $${paramIndex}`);
values.push(field === 'title' || field === 'url' || field === 'folder' ? value.trim() : value);
paramIndex++;
}
}
if (updateFields.length === 0) {
return false;
}
updateFields.push(`updated_at = CURRENT_TIMESTAMP`);
values.push(this.id, this.user_id);
const query = `
UPDATE bookmarks
SET ${updateFields.join(', ')}
WHERE id = $${paramIndex} AND user_id = $${paramIndex + 1}
`;
return await dbErrorHandler(async () => {
const result = await dbConnection.query(query, values);
if (result.rowCount > 0) {
// Update instance properties
for (const [field, value] of Object.entries(updates)) {
if (allowedFields.includes(field)) {
this[field] = value;
}
}
this.updated_at = new Date();
return true;
}
return false;
});
}
/**
* Delete bookmark
* @returns {Promise<boolean>} - True if deletion successful
*/
async delete() {
return await dbErrorHandler(async () => {
const query = 'DELETE FROM bookmarks WHERE id = $1 AND user_id = $2';
const result = await dbConnection.query(query, [this.id, this.user_id]);
return result.rowCount > 0;
});
}
/**
* Get all folders for a user
* @param {string} userId - User ID
* @returns {Promise<Array>} - Array of folder names with counts
*/
static async getFoldersByUserId(userId) {
return await dbErrorHandler(async () => {
const query = `
SELECT folder, COUNT(*) as count
FROM bookmarks
WHERE user_id = $1
GROUP BY folder
ORDER BY folder
`;
const result = await dbConnection.query(query, [userId]);
return result.rows;
});
}
/**
* Get bookmark statistics for a user
* @param {string} userId - User ID
* @returns {Promise<object>} - Statistics object
*/
static async getStatsByUserId(userId) {
return await dbErrorHandler(async () => {
const query = `
SELECT
COUNT(*) as total_bookmarks,
COUNT(DISTINCT folder) as total_folders,
COUNT(CASE WHEN status = 'valid' THEN 1 END) as valid_bookmarks,
COUNT(CASE WHEN status = 'invalid' THEN 1 END) as invalid_bookmarks,
COUNT(CASE WHEN status = 'duplicate' THEN 1 END) as duplicate_bookmarks,
COUNT(CASE WHEN status = 'unknown' THEN 1 END) as unknown_bookmarks
FROM bookmarks
WHERE user_id = $1
`;
const result = await dbConnection.query(query, [userId]);
return result.rows[0];
});
}
/**
* Bulk create bookmarks (for import functionality)
* @param {string} userId - User ID
* @param {Array} bookmarksData - Array of bookmark data
* @returns {Promise<Array>} - Array of created bookmarks
*/
static async bulkCreate(userId, bookmarksData) {
if (!Array.isArray(bookmarksData) || bookmarksData.length === 0) {
return [];
}
// Validate all bookmarks first
for (const bookmarkData of bookmarksData) {
const validation = Bookmark.validateBookmark(bookmarkData);
if (!validation.isValid) {
throw new Error(`Bookmark validation failed: ${validation.errors.join(', ')}`);
}
}
return await dbErrorHandler(async () => {
const values = [];
const placeholders = [];
let paramIndex = 1;
for (const bookmarkData of bookmarksData) {
const {
title,
url,
folder = '',
add_date = new Date(),
last_modified,
icon,
status = 'unknown'
} = bookmarkData;
placeholders.push(`($${paramIndex}, $${paramIndex + 1}, $${paramIndex + 2}, $${paramIndex + 3}, $${paramIndex + 4}, $${paramIndex + 5}, $${paramIndex + 6}, $${paramIndex + 7})`);
values.push(userId, title.trim(), url.trim(), folder.trim(), add_date, last_modified, icon, status);
paramIndex += 8;
}
const query = `
INSERT INTO bookmarks (user_id, title, url, folder, add_date, last_modified, icon, status)
VALUES ${placeholders.join(', ')}
RETURNING *
`;
const result = await dbConnection.query(query, values);
return result.rows.map(row => new Bookmark(row));
});
}
/**
* Delete all bookmarks for a user
* @param {string} userId - User ID
* @returns {Promise<number>} - Number of deleted bookmarks
*/
static async deleteAllByUserId(userId) {
return await dbErrorHandler(async () => {
const query = 'DELETE FROM bookmarks WHERE user_id = $1';
const result = await dbConnection.query(query, [userId]);
return result.rowCount;
});
}
/**
* Convert bookmark to plain object
* @returns {object} - Plain object representation
*/
toObject() {
return {
id: this.id,
user_id: this.user_id,
title: this.title,
url: this.url,
folder: this.folder,
add_date: this.add_date,
last_modified: this.last_modified,
icon: this.icon,
status: this.status,
created_at: this.created_at,
updated_at: this.updated_at
};
}
/**
* Convert bookmark to safe object (without user_id for API responses)
* @returns {object} - Safe object representation
*/
toSafeObject() {
return {
id: this.id,
title: this.title,
url: this.url,
folder: this.folder,
add_date: this.add_date,
last_modified: this.last_modified,
icon: this.icon,
status: this.status,
created_at: this.created_at,
updated_at: this.updated_at
};
}
}
module.exports = Bookmark;

420
backend/src/models/User.js Normal file
View File

@ -0,0 +1,420 @@
const bcrypt = require('bcrypt');
const crypto = require('crypto');
const dbConnection = require('../database/connection');
const { dbErrorHandler } = require('../middleware/errorHandler');
class User {
constructor(userData = {}) {
this.id = userData.id;
this.email = userData.email;
this.password_hash = userData.password_hash;
this.is_verified = userData.is_verified || false;
this.created_at = userData.created_at;
this.updated_at = userData.updated_at;
this.last_login = userData.last_login;
this.verification_token = userData.verification_token;
this.reset_token = userData.reset_token;
this.reset_expires = userData.reset_expires;
}
/**
* Hash a password using bcrypt
* @param {string} password - Plain text password
* @returns {Promise<string>} - Hashed password
*/
static async hashPassword(password) {
const saltRounds = 12;
return await bcrypt.hash(password, saltRounds);
}
/**
* Verify a password against a hash
* @param {string} password - Plain text password
* @param {string} hash - Hashed password
* @returns {Promise<boolean>} - True if password matches
*/
static async verifyPassword(password, hash) {
return await bcrypt.compare(password, hash);
}
/**
* Validate email format
* @param {string} email - Email address to validate
* @returns {boolean} - True if email format is valid
*/
static validateEmail(email) {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
/**
* Validate password strength
* @param {string} password - Password to validate
* @returns {object} - Validation result with isValid and errors
*/
static validatePassword(password) {
const errors = [];
if (!password || password.length < 8) {
errors.push('Password must be at least 8 characters long');
}
if (!/[A-Z]/.test(password)) {
errors.push('Password must contain at least one uppercase letter');
}
if (!/[a-z]/.test(password)) {
errors.push('Password must contain at least one lowercase letter');
}
if (!/\d/.test(password)) {
errors.push('Password must contain at least one number');
}
if (!/[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password)) {
errors.push('Password must contain at least one special character');
}
return {
isValid: errors.length === 0,
errors
};
}
/**
* Generate a secure random token
* @returns {string} - Random token
*/
static generateToken() {
return crypto.randomBytes(32).toString('hex');
}
/**
* Create a new user
* @param {object} userData - User data
* @returns {Promise<User>} - Created user instance
*/
static async create(userData) {
const { email, password } = userData;
// Validate email format
if (!User.validateEmail(email)) {
throw new Error('Invalid email format');
}
// Validate password strength
const passwordValidation = User.validatePassword(password);
if (!passwordValidation.isValid) {
throw new Error(`Password validation failed: ${passwordValidation.errors.join(', ')}`);
}
// Check if user already exists
const existingUser = await User.findByEmail(email);
if (existingUser) {
throw new Error('User with this email already exists');
}
// Hash password
const password_hash = await User.hashPassword(password);
// Generate verification token
const verification_token = User.generateToken();
const query = `
INSERT INTO users (email, password_hash, verification_token)
VALUES ($1, $2, $3)
RETURNING *
`;
return await dbErrorHandler(async () => {
const result = await dbConnection.query(query, [email, password_hash, verification_token]);
return new User(result.rows[0]);
});
}
/**
* Find user by email
* @param {string} email - Email address
* @returns {Promise<User|null>} - User instance or null
*/
static async findByEmail(email) {
return await dbErrorHandler(async () => {
const query = 'SELECT * FROM users WHERE email = $1';
const result = await dbConnection.query(query, [email]);
if (result.rows.length === 0) {
return null;
}
return new User(result.rows[0]);
});
}
/**
* Find user by ID
* @param {string} id - User ID
* @returns {Promise<User|null>} - User instance or null
*/
static async findById(id) {
return await dbErrorHandler(async () => {
const query = 'SELECT * FROM users WHERE id = $1';
const result = await dbConnection.query(query, [id]);
if (result.rows.length === 0) {
return null;
}
return new User(result.rows[0]);
});
}
/**
* Find user by verification token
* @param {string} token - Verification token
* @returns {Promise<User|null>} - User instance or null
*/
static async findByVerificationToken(token) {
return await dbErrorHandler(async () => {
const query = 'SELECT * FROM users WHERE verification_token = $1';
const result = await dbConnection.query(query, [token]);
if (result.rows.length === 0) {
return null;
}
return new User(result.rows[0]);
});
}
/**
* Find user by reset token
* @param {string} token - Reset token
* @returns {Promise<User|null>} - User instance or null
*/
static async findByResetToken(token) {
return await dbErrorHandler(async () => {
const query = `
SELECT * FROM users
WHERE reset_token = $1 AND reset_expires > NOW()
`;
const result = await dbConnection.query(query, [token]);
if (result.rows.length === 0) {
return null;
}
return new User(result.rows[0]);
});
}
/**
* Verify user email
* @returns {Promise<boolean>} - True if verification successful
*/
async verifyEmail() {
return await dbErrorHandler(async () => {
const query = `
UPDATE users
SET is_verified = true, verification_token = NULL, updated_at = NOW()
WHERE id = $1
`;
const result = await dbConnection.query(query, [this.id]);
if (result.rowCount > 0) {
this.is_verified = true;
this.verification_token = null;
return true;
}
return false;
});
}
/**
* Update last login timestamp
* @returns {Promise<boolean>} - True if update successful
*/
async updateLastLogin() {
return await dbErrorHandler(async () => {
const query = `
UPDATE users
SET last_login = NOW(), updated_at = NOW()
WHERE id = $1
`;
const result = await dbConnection.query(query, [this.id]);
if (result.rowCount > 0) {
this.last_login = new Date();
return true;
}
return false;
});
}
/**
* Set password reset token
* @returns {Promise<string>} - Reset token
*/
async setResetToken() {
return await dbErrorHandler(async () => {
const reset_token = User.generateToken();
const reset_expires = new Date(Date.now() + 60 * 60 * 1000); // 1 hour from now
const query = `
UPDATE users
SET reset_token = $1, reset_expires = $2, updated_at = NOW()
WHERE id = $3
`;
await dbConnection.query(query, [reset_token, reset_expires, this.id]);
this.reset_token = reset_token;
this.reset_expires = reset_expires;
return reset_token;
});
}
/**
* Update password
* @param {string} newPassword - New password
* @returns {Promise<boolean>} - True if update successful
*/
async updatePassword(newPassword) {
// Validate new password
const passwordValidation = User.validatePassword(newPassword);
if (!passwordValidation.isValid) {
throw new Error(`Password validation failed: ${passwordValidation.errors.join(', ')}`);
}
return await dbErrorHandler(async () => {
const password_hash = await User.hashPassword(newPassword);
const query = `
UPDATE users
SET password_hash = $1, reset_token = NULL, reset_expires = NULL, updated_at = NOW()
WHERE id = $2
`;
const result = await dbConnection.query(query, [password_hash, this.id]);
if (result.rowCount > 0) {
this.password_hash = password_hash;
this.reset_token = null;
this.reset_expires = null;
return true;
}
return false;
});
}
/**
* Update user profile
* @param {object} updates - Fields to update
* @returns {Promise<boolean>} - True if update successful
*/
async update(updates) {
const allowedFields = ['email'];
const updateFields = [];
const values = [];
let paramIndex = 1;
for (const [field, value] of Object.entries(updates)) {
if (allowedFields.includes(field)) {
if (field === 'email' && !User.validateEmail(value)) {
throw new Error('Invalid email format');
}
updateFields.push(`${field} = $${paramIndex}`);
values.push(value);
paramIndex++;
}
}
if (updateFields.length === 0) {
return false;
}
updateFields.push(`updated_at = NOW()`);
values.push(this.id);
const query = `
UPDATE users
SET ${updateFields.join(', ')}
WHERE id = $${paramIndex}
`;
return await dbErrorHandler(async () => {
const result = await dbConnection.query(query, values);
if (result.rowCount > 0) {
// Update instance properties
for (const [field, value] of Object.entries(updates)) {
if (allowedFields.includes(field)) {
this[field] = value;
}
}
return true;
}
return false;
});
}
/**
* Delete user account
* @returns {Promise<boolean>} - True if deletion successful
*/
async delete() {
return await dbErrorHandler(async () => {
const query = 'DELETE FROM users WHERE id = $1';
const result = await dbConnection.query(query, [this.id]);
return result.rowCount > 0;
});
}
/**
* Get user data without sensitive information
* @returns {object} - Safe user data
*/
toSafeObject() {
return {
id: this.id,
email: this.email,
is_verified: this.is_verified,
created_at: this.created_at,
updated_at: this.updated_at,
last_login: this.last_login
};
}
/**
* Authenticate user with email and password
* @param {string} email - Email address
* @param {string} password - Password
* @returns {Promise<User|null>} - User instance if authentication successful
*/
static async authenticate(email, password) {
const user = await User.findByEmail(email);
if (!user) {
return null;
}
const isValidPassword = await User.verifyPassword(password, user.password_hash);
if (!isValidPassword) {
return null;
}
// Update last login
await user.updateLastLogin();
return user;
}
}
module.exports = User;