diff --git a/backend/package-lock.json b/backend/package-lock.json index 8e0b00a..547f38b 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -16,6 +16,7 @@ "express-rate-limit": "^8.2.1", "helmet": "^8.1.0", "jsonwebtoken": "^9.0.2", + "multer": "^2.0.2", "pg": "^8.11.3", "validator": "^13.11.0" }, @@ -50,6 +51,12 @@ "node": ">= 8" } }, + "node_modules/append-field": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz", + "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", + "license": "MIT" + }, "node_modules/array-flatten": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", @@ -144,6 +151,23 @@ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", "license": "BSD-3-Clause" }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", + "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -214,6 +238,21 @@ "dev": true, "license": "MIT" }, + "node_modules/concat-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", + "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -914,12 +953,51 @@ "node": "*" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, "node_modules/ms": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "license": "MIT" }, + "node_modules/multer": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/multer/-/multer-2.0.2.tgz", + "integrity": "sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "mkdirp": "^0.5.6", + "object-assign": "^4.1.1", + "type-is": "^1.6.18", + "xtend": "^4.0.2" + }, + "engines": { + "node": ">= 10.16.0" + } + }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -1261,6 +1339,20 @@ "node": ">= 0.8" } }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/readdirp": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", @@ -1466,6 +1558,23 @@ "node": ">= 0.8" } }, + "node_modules/streamsearch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz", + "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -1524,6 +1633,12 @@ "node": ">= 0.6" } }, + "node_modules/typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", + "license": "MIT" + }, "node_modules/undefsafe": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/undefsafe/-/undefsafe-2.0.5.tgz", @@ -1540,6 +1655,12 @@ "node": ">= 0.8" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", diff --git a/backend/package.json b/backend/package.json index eb56908..023668e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -18,6 +18,7 @@ "express-rate-limit": "^8.2.1", "helmet": "^8.1.0", "jsonwebtoken": "^9.0.2", + "multer": "^2.0.2", "pg": "^8.11.3", "validator": "^13.11.0" }, diff --git a/backend/src/controllers/avatarController.js b/backend/src/controllers/avatarController.js new file mode 100644 index 0000000..0a02deb --- /dev/null +++ b/backend/src/controllers/avatarController.js @@ -0,0 +1,289 @@ +const Avatar = require('../models/Avatar'); +const Pet = require('../models/Pet'); +const path = require('path'); +const fs = require('fs'); + +/** + * Upload a new avatar/image for a pet or user (replaces existing) + * Only pet owner can upload/edit pet avatars + * @route POST /api/avatars + */ +const uploadAvatar = async (req, res) => { + try { + const userId = req.user.userId; + const { pet_id } = req.body; + + // File from multer middleware + if (!req.file) { + return res.status(400).json({ + success: false, + message: 'No file uploaded' + }); + } + + // If pet_id is provided, verify pet ownership (OWNER ONLY) + if (pet_id) { + const petBelongsToUser = await Pet.belongsToUser(pet_id, userId); + if (!petBelongsToUser) { + return res.status(403).json({ + success: false, + message: 'Not authorized to add avatar to this pet' + }); + } + + // Delete existing pet avatar + await Avatar.deleteByPetId(pet_id); + } else { + // If no pet_id, this is a user profile avatar + // Delete existing user avatar + await Avatar.deleteByUserId(userId); + } + + // Create new avatar with safe random filename from multer + const newAvatar = await Avatar.create({ + user_id: userId, + pet_id: pet_id || null, + filename: req.file.filename // Random name from multer (e.g., a3f9c2e1b4d7...jpg) + }); + + res.status(201).json({ + success: true, + message: 'Avatar uploaded successfully', + data: newAvatar + }); + } catch (error) { + console.error('Upload avatar error:', error); + res.status(500).json({ + success: false, + message: 'Failed to upload avatar' + }); + } +}; + +/** + * Get avatar by ID + * @route GET /api/avatars/:id + */ +const getAvatarById = async (req, res) => { + try { + const { id } = req.params; + + const avatar = await Avatar.getById(id); + + if (!avatar) { + return res.status(404).json({ + success: false, + message: 'Avatar not found' + }); + } + + res.status(200).json({ + success: true, + message: 'Avatar retrieved successfully', + data: avatar + }); + } catch (error) { + console.error('Get avatar error:', error); + res.status(500).json({ + success: false, + message: 'Failed to retrieve avatar' + }); + } +}; + +/** + * Download/Get avatar file + * @route GET /api/avatars/:id/download + */ +const downloadAvatar = async (req, res) => { + try { + const { id } = req.params; + + const avatar = await Avatar.getById(id); + + if (!avatar) { + return res.status(404).json({ + success: false, + message: 'Avatar not found' + }); + } + + // Build full file path + const uploadDir = 'uploads/avatars'; + const filepath = path.join(uploadDir, avatar.filename); + + // Check if file exists on disk + if (!fs.existsSync(filepath)) { + return res.status(404).json({ + success: false, + message: 'Image file not found on server' + }); + } + + // Send file to client + // sendFile serves inline (displays in browser/app) + res.sendFile(path.resolve(filepath)); + } catch (error) { + console.error('Download avatar error:', error); + res.status(500).json({ + success: false, + message: 'Failed to download avatar' + }); + } +}; + +/** + * Delete avatar by ID (only pet owner can delete pet avatars) + * @route DELETE /api/avatars/:id + */ +const deleteAvatar = async (req, res) => { + try { + const userId = req.user.userId; + const { id } = req.params; + + // Get avatar to check permissions + const avatar = await Avatar.getById(id); + if (!avatar) { + return res.status(404).json({ + success: false, + message: 'Avatar not found' + }); + } + + // Check if user owns this avatar + const belongsToUser = await Avatar.belongsToUser(id, userId); + if (!belongsToUser) { + return res.status(403).json({ + success: false, + message: 'Not authorized to delete this avatar' + }); + } + + // For pet avatars, only pet owner can delete + if (avatar.pet_id) { + const petBelongsToUser = await Pet.belongsToUser(avatar.pet_id, userId); + if (!petBelongsToUser) { + return res.status(403).json({ + success: false, + message: 'Only pet owner can delete pet avatars' + }); + } + } + + // Delete file from disk + const uploadDir = 'uploads/avatars'; + const filepath = path.join(uploadDir, avatar.filename); + + if (fs.existsSync(filepath)) { + fs.unlinkSync(filepath); // Delete the file synchronously + } + + // Delete from database + const deleted = await Avatar.deleteById(id); + if (!deleted) { + return res.status(500).json({ + success: false, + message: 'Failed to delete avatar' + }); + } + + res.status(200).json({ + success: true, + message: 'Avatar deleted successfully' + }); + } catch (error) { + console.error('Delete avatar error:', error); + res.status(500).json({ + success: false, + message: 'Failed to delete avatar' + }); + } +}; + +/** + * Get current avatar for a specific pet (only one avatar per pet) + * @route GET /api/avatars/pet/:petId + */ +const getAvatarByPet = async (req, res) => { + try { + const userId = req.user.userId; + const { petId } = req.params; + + if (!petId) { + return res.status(400).json({ + success: false, + message: 'Pet ID required' + }); + } + + // Verify user has access to pet + const hasAccess = await Pet.userHasAccess(petId, userId); + if (!hasAccess) { + return res.status(403).json({ + success: false, + message: 'Not authorized to view this pet' + }); + } + + const avatar = await Avatar.getByPetId(petId); + + if (!avatar) { + return res.status(404).json({ + success: false, + message: 'No avatar found for this pet' + }); + } + + res.status(200).json({ + success: true, + message: 'Pet avatar retrieved successfully', + data: avatar + }); + } catch (error) { + console.error('Get pet avatar error:', error); + res.status(500).json({ + success: false, + message: 'Failed to retrieve pet avatar' + }); + } +}; + +/** + * Get current avatar for authenticated user (only one avatar per user) + * @route GET /api/avatars/user/current + */ +const getAvatarByUser = async (req, res) => { + try { + const userId = req.user.userId; + + const avatar = await Avatar.getByUserId(userId); + + if (!avatar) { + return res.status(404).json({ + success: false, + message: 'No profile avatar found' + }); + } + + res.status(200).json({ + success: true, + message: 'User avatar retrieved successfully', + data: avatar + }); + } catch (error) { + console.error('Get user avatar error:', error); + res.status(500).json({ + success: false, + message: 'Failed to retrieve user avatar' + }); + } +}; + +module.exports = { + uploadAvatar, + getAvatarById, + downloadAvatar, + deleteAvatar, + getAvatarByPet, + getAvatarByUser +}; diff --git a/backend/src/index.js b/backend/src/index.js index fec2230..68ca25b 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -7,6 +7,7 @@ require('dotenv').config(); // Import routes const userRoutes = require('./routes/userRoutes'); const petRoutes = require('./routes/petRoutes'); +const avatarRoutes = require('./routes/avatarRoutes'); const medicationRoutes = require('./routes/medicationRoutes'); const vaccinationRoutes = require('./routes/vaccinationRoutes'); const vetVisitRoutes = require('./routes/vetVisitRoutes'); @@ -70,6 +71,7 @@ app.get('/health', (req, res) => { // API Routes app.use('/api/auth', authLimiter, userRoutes); app.use('/api/pets', authLimiter, petRoutes); +app.use('/api/avatars', authLimiter, avatarRoutes); app.use('/api/medications', authLimiter, medicationRoutes); app.use('/api/vaccinations', authLimiter, vaccinationRoutes); app.use('/api/vet-visits', authLimiter, vetVisitRoutes); diff --git a/backend/src/middleware/avatarUpload.js b/backend/src/middleware/avatarUpload.js new file mode 100644 index 0000000..85730ea --- /dev/null +++ b/backend/src/middleware/avatarUpload.js @@ -0,0 +1,97 @@ +const multer = require('multer'); +const path = require('path'); +const crypto = require('crypto'); +const fs = require('fs'); + +// Create uploads directory if it doesn't exist +const uploadDir = 'uploads/avatars'; +if (!fs.existsSync(uploadDir)) { + fs.mkdirSync(uploadDir, { recursive: true }); +} + +/** + * Configure multer for avatar uploads + * - Stores files with random names for security + * - Validates file types (jpg, png) + * - Limits file size to 500KB + * - Keeps original filename in database for reference + */ +const storage = multer.diskStorage({ + destination: (req, file, cb) => { + cb(null, uploadDir); + }, + filename: (req, file, cb) => { + // Generate random filename with original extension + const ext = path.extname(file.originalname).toLowerCase(); + const randomName = crypto.randomBytes(16).toString('hex'); + const filename = `${randomName}${ext}`; + cb(null, filename); + } +}); + +/** + * File filter to only allow jpg and png + */ +const fileFilter = (req, file, cb) => { + const allowedMimes = ['image/jpeg', 'image/jpg', 'image/png']; + + if (allowedMimes.includes(file.mimetype)) { + cb(null, true); + } else { + cb(new Error(`Invalid file type: ${file.mimetype}. Only JPG and PNG are allowed.`), false); + } +}; + +/** + * Multer configuration + */ +const upload = multer({ + storage: storage, + fileFilter: fileFilter, + limits: { + fileSize: 500 * 1024 // 500KB max + } +}); + +/** + * Middleware to handle single avatar upload + * Attach to route: router.post('/', authenticateToken, uploadAvatarMiddleware, uploadAvatar); + */ +const uploadAvatarMiddleware = (req, res, next) => { + upload.single('avatar')(req, res, (err) => { + if (err instanceof multer.MulterError) { + if (err.code === 'LIMIT_FILE_SIZE') { + return res.status(400).json({ + success: false, + message: 'File too large. Maximum size is 500KB.' + }); + } + if (err.code === 'LIMIT_UNEXPECTED_FILE') { + return res.status(400).json({ + success: false, + message: 'Too many files uploaded. Only one avatar allowed.' + }); + } + return res.status(400).json({ + success: false, + message: `Upload error: ${err.message}` + }); + } else if (err) { + // Custom error from fileFilter + return res.status(400).json({ + success: false, + message: err.message + }); + } + + // File uploaded successfully + // Attach file info to request for controller to use + if (req.file) { + req.file.originalName = req.file.originalname; + req.file.uploadedPath = `${uploadDir}/${req.file.filename}`; + } + next(); + }); +}; + +module.exports = { uploadAvatarMiddleware, upload }; diff --git a/backend/src/models/Avatar.js b/backend/src/models/Avatar.js new file mode 100644 index 0000000..d7b9c83 --- /dev/null +++ b/backend/src/models/Avatar.js @@ -0,0 +1,166 @@ +const pool = require('../config/database'); + +class Avatar { + /** + * Create a new avatar/image record + * @param {string} user_id - User ID (UUID) + * @param {number} pet_id - Pet ID + * @param {string} filename - Filename of the image + * @returns {Promise} Created avatar record + */ + static async create({ user_id, pet_id, filename }) { + const query = ` + INSERT INTO images (user_id, pet_id, filename) + VALUES ($1, $2, $3) + RETURNING * + `; + + try { + const result = await pool.query(query, [user_id, pet_id, filename]); + return result.rows[0]; + } catch (error) { + throw error; + } + } + + /** + * Get avatar by ID + * @param {number} imageId - Image ID + * @returns {Promise} Avatar object or undefined if not found + */ + static async getById(imageId) { + const query = 'SELECT * FROM images WHERE id = $1'; + + try { + const result = await pool.query(query, [imageId]); + return result.rows[0]; + } catch (error) { + throw error; + } + } + + /** + * Delete avatar by ID + * @param {number} imageId - Image ID + * @returns {Promise} true if avatar was deleted + */ + static async deleteById(imageId) { + const query = 'DELETE FROM images WHERE id = $1 RETURNING id'; + + try { + const result = await pool.query(query, [imageId]); + return result.rows.length > 0; + } catch (error) { + throw error; + } + } + + /** + * Get current avatar for a specific pet + * @param {number} petId - Pet ID + * @returns {Promise} Avatar object or undefined + */ + static async getByPetId(petId) { + const query = ` + SELECT id, user_id, pet_id, filename, created_at + FROM images + WHERE pet_id = $1 + `; + + try { + const result = await pool.query(query, [petId]); + return result.rows[0]; + } catch (error) { + throw error; + } + } + + + /** + * Delete avatar for a pet + * @param {number} petId - Pet ID + * @returns {Promise} true if avatar was deleted + */ + static async deleteByPetId(petId) { + const query = 'DELETE FROM images WHERE pet_id = $1 RETURNING id'; + + try { + const result = await pool.query(query, [petId]); + return result.rows.length > 0; + } catch (error) { + throw error; + } + } + + /** + * Get current avatar for a specific user (only one avatar per user) + * @param {string} userId - User ID (UUID) + * @returns {Promise} Avatar object or undefined + */ + static async getByUserId(userId) { + const query = ` + SELECT id, user_id, pet_id, filename, created_at + FROM images + WHERE user_id = $1 AND pet_id IS NULL + `; + + try { + const result = await pool.query(query, [userId]); + return result.rows[0]; + } catch (error) { + throw error; + } + } + + /** + * Delete user's profile avatar (pet_id is NULL) + * @param {string} userId - User ID (UUID) + * @returns {Promise} true if deleted + */ + static async deleteByUserId(userId) { + const query = 'DELETE FROM images WHERE user_id = $1 AND pet_id IS NULL RETURNING id'; + + try { + const result = await pool.query(query, [userId]); + return result.rows.length > 0; + } catch (error) { + throw error; + } + } + + /** + * Verify if an avatar belongs to a specific user + * @param {number} imageId - Image ID + * @param {string} userId - User ID (UUID) + * @returns {Promise} true if avatar belongs to user + */ + static async belongsToUser(imageId, userId) { + const query = 'SELECT id FROM images WHERE id = $1 AND user_id = $2'; + + try { + const result = await pool.query(query, [imageId, userId]); + return result.rows.length > 0; + } catch (error) { + throw error; + } + } + + /** + * Verify if an avatar is associated with a specific pet + * @param {number} imageId - Image ID + * @param {number} petId - Pet ID + * @returns {Promise} true if avatar belongs to pet + */ + static async belongsToPet(imageId, petId) { + const query = 'SELECT id FROM images WHERE id = $1 AND pet_id = $2'; + + try { + const result = await pool.query(query, [imageId, petId]); + return result.rows.length > 0; + } catch (error) { + throw error; + } + } +} + +module.exports = Avatar; diff --git a/backend/src/routes/avatarRoutes.js b/backend/src/routes/avatarRoutes.js new file mode 100644 index 0000000..e8c38fa --- /dev/null +++ b/backend/src/routes/avatarRoutes.js @@ -0,0 +1,56 @@ +const express = require('express'); +const router = express.Router(); +const { + uploadAvatar, + getAvatarById, + downloadAvatar, + deleteAvatar, + getAvatarByPet, + getAvatarByUser +} = require('../controllers/avatarController'); +const authenticateToken = require('../middleware/authenticateToken'); +const { uploadAvatarMiddleware } = require('../middleware/avatarUpload'); + +/** + * @route GET /api/avatars/user/current + * @desc Get current avatar for authenticated user (only one avatar per user) + * @access Private + */ +router.get('/user/current', authenticateToken, getAvatarByUser); + +/** + * @route GET /api/avatars/pet/:petId + * @desc Get current avatar for a specific pet (only one avatar per pet) + * @access Private + */ +router.get('/pet/:petId', authenticateToken, getAvatarByPet); + +/** + * @route GET /api/avatars/:id/download + * @desc Download/Get avatar file information + * @access Private + */ +router.get('/:id/download', authenticateToken, downloadAvatar); + +/** + * @route GET /api/avatars/:id + * @desc Get a specific avatar by ID + * @access Private + */ +router.get('/:id', authenticateToken, getAvatarById); + +/** + * @route POST /api/avatars + * @desc Upload a new avatar for a pet or user profile (replaces existing) + * @access Private (pet owner only for pet avatars) + */ +router.post('/', authenticateToken, uploadAvatarMiddleware, uploadAvatar); + +/** + * @route DELETE /api/avatars/:id + * @desc Delete an avatar by ID + * @access Private (only owner, pet owner for pet avatars) + */ +router.delete('/:id', authenticateToken, deleteAvatar); + +module.exports = router; diff --git a/backend/uploads/avatars/74158174ee13668b04caf740a61858f3.jpg b/backend/uploads/avatars/74158174ee13668b04caf740a61858f3.jpg new file mode 100644 index 0000000..4c10bdf Binary files /dev/null and b/backend/uploads/avatars/74158174ee13668b04caf740a61858f3.jpg differ diff --git a/backend/uploads/avatars/fd99b8f6c6a67590cbecc4ab55646f96.png b/backend/uploads/avatars/fd99b8f6c6a67590cbecc4ab55646f96.png new file mode 100644 index 0000000..36c0669 Binary files /dev/null and b/backend/uploads/avatars/fd99b8f6c6a67590cbecc4ab55646f96.png differ