feat: 删除SFTP上传工具,修复OSS配置bug
主要变更: - 删除管理员工具栏及上传工具相关功能(后端API + 前端UI) - 删除upload-tool目录及相关文件 - 修复OSS配置测试连接bug(testUser缺少has_oss_config标志) - 新增backend/utils加密和缓存工具模块 - 更新.gitignore排除测试报告文件 技术改进: - 统一使用OSS存储,废弃SFTP上传方式 - 修复OSS配置保存和测试连接时的错误处理 - 完善代码库文件管理,排除临时报告文件
This commit is contained in:
271
backend/utils/encryption.js
Normal file
271
backend/utils/encryption.js
Normal file
@@ -0,0 +1,271 @@
|
||||
/**
|
||||
* 加密工具模块
|
||||
*
|
||||
* 功能:
|
||||
* - 使用 AES-256-GCM 加密敏感数据(OSS Access Key Secret)
|
||||
* - 提供加密和解密函数
|
||||
* - 自动处理初始化向量(IV)和认证标签
|
||||
*
|
||||
* 安全特性:
|
||||
* - AES-256-GCM 提供认证加密(AEAD)
|
||||
* - 每次加密使用随机 IV,防止模式泄露
|
||||
* - 使用认证标签验证数据完整性
|
||||
* - 密钥从环境变量读取,不存在硬编码
|
||||
*
|
||||
* @module utils/encryption
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
|
||||
/**
|
||||
* 从环境变量获取加密密钥
|
||||
*
|
||||
* 要求:
|
||||
* - 必须是 32 字节的十六进制字符串(64个字符)
|
||||
* - 如果未设置或格式错误,启动时抛出错误
|
||||
*
|
||||
* @returns {Buffer} 32字节的加密密钥
|
||||
* @throws {Error} 如果密钥未配置或格式错误
|
||||
*/
|
||||
function getEncryptionKey() {
|
||||
const keyHex = process.env.ENCRYPTION_KEY;
|
||||
|
||||
if (!keyHex) {
|
||||
throw new Error(`
|
||||
╔═══════════════════════════════════════════════════════════════╗
|
||||
║ ⚠️ 安全错误 ⚠️ ║
|
||||
╠═══════════════════════════════════════════════════════════════╣
|
||||
║ ENCRYPTION_KEY 未配置! ║
|
||||
║ ║
|
||||
║ 此密钥用于加密 OSS Access Key Secret 等敏感信息 ║
|
||||
║ ║
|
||||
║ 生成方法: ║
|
||||
║ node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
║ ║
|
||||
║ 在 backend/.env 文件中添加: ║
|
||||
║ ENCRYPTION_KEY=你生成的64位十六进制密钥 ║
|
||||
╚═══════════════════════════════════════════════════════════════╝
|
||||
`);
|
||||
}
|
||||
|
||||
// 验证密钥格式(必须是64个十六进制字符)
|
||||
if (!/^[0-9a-fA-F]{64}$/.test(keyHex)) {
|
||||
throw new Error(`
|
||||
╔═══════════════════════════════════════════════════════════════╗
|
||||
║ ⚠️ 配置错误 ⚠️ ║
|
||||
╠═══════════════════════════════════════════════════════════════╣
|
||||
║ ENCRYPTION_KEY 格式错误! ║
|
||||
║ ║
|
||||
║ 要求: 64位十六进制字符串(32字节) ║
|
||||
║ 当前长度: ${keyHex.length} 字符 ║
|
||||
║ ║
|
||||
║ 正确的生成方法: ║
|
||||
║ node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
╚═══════════════════════════════════════════════════════════════╝
|
||||
`);
|
||||
}
|
||||
|
||||
return Buffer.from(keyHex, 'hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* 加密明文字符串
|
||||
*
|
||||
* 使用 AES-256-GCM 算法加密数据,输出格式:
|
||||
* - Base64(IV + ciphertext + authTag)
|
||||
* - IV: 12字节(随机)
|
||||
* - ciphertext: 加密后的数据
|
||||
* - authTag: 16字节(认证标签)
|
||||
*
|
||||
* @param {string} plaintext - 要加密的明文字符串
|
||||
* @returns {string} Base64编码的加密结果(包含 IV 和 authTag)
|
||||
* @throws {Error} 如果加密失败
|
||||
*
|
||||
* @example
|
||||
* const encrypted = encryptSecret('my-secret-key');
|
||||
* // 输出: 'base64-encoded-string-with-iv-and-tag'
|
||||
*/
|
||||
function encryptSecret(plaintext) {
|
||||
try {
|
||||
// 获取加密密钥
|
||||
const key = getEncryptionKey();
|
||||
|
||||
// 生成随机初始化向量(IV)
|
||||
// GCM 模式推荐 12 字节 IV
|
||||
const iv = crypto.randomBytes(12);
|
||||
|
||||
// 创建加密器
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
||||
|
||||
// 加密数据
|
||||
let encrypted = cipher.update(plaintext, 'utf8', 'binary');
|
||||
encrypted += cipher.final('binary');
|
||||
|
||||
// 获取认证标签(用于验证数据完整性)
|
||||
const authTag = cipher.getAuthTag();
|
||||
|
||||
// 组合:IV + encrypted + authTag
|
||||
const combined = Buffer.concat([
|
||||
iv,
|
||||
Buffer.from(encrypted, 'binary'),
|
||||
authTag
|
||||
]);
|
||||
|
||||
// 返回 Base64 编码的结果
|
||||
return combined.toString('base64');
|
||||
} catch (error) {
|
||||
console.error('[加密] 加密失败:', error);
|
||||
throw new Error('数据加密失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 解密密文字符串
|
||||
*
|
||||
* 解密由 encryptSecret() 加密的数据
|
||||
* 自动验证认证标签,确保数据完整性
|
||||
*
|
||||
* @param {string} ciphertext - Base64编码的密文(由 encryptSecret 生成)
|
||||
* @returns {string} 解密后的明文字符串
|
||||
* @throws {Error} 如果解密失败或认证标签验证失败
|
||||
*
|
||||
* @example
|
||||
* const decrypted = decryptSecret(encrypted);
|
||||
* // 输出: 'my-secret-key'
|
||||
*/
|
||||
function decryptSecret(ciphertext) {
|
||||
try {
|
||||
// 如果是 null 或 undefined,直接返回
|
||||
if (!ciphertext) {
|
||||
return ciphertext;
|
||||
}
|
||||
|
||||
// 检查是否为加密格式(Base64)
|
||||
// 如果不是 Base64,可能是旧数据(明文),直接返回
|
||||
if (!/^[A-Za-z0-9+/=]+$/.test(ciphertext)) {
|
||||
console.warn('[加密] 检测到未加密的密钥,建议重新加密');
|
||||
return ciphertext;
|
||||
}
|
||||
|
||||
// 获取加密密钥
|
||||
const key = getEncryptionKey();
|
||||
|
||||
// 解析 Base64
|
||||
const combined = Buffer.from(ciphertext, 'base64');
|
||||
|
||||
// 提取各部分
|
||||
// IV: 前 12 字节
|
||||
const iv = combined.slice(0, 12);
|
||||
|
||||
// authTag: 最后 16 字节
|
||||
const authTag = combined.slice(-16);
|
||||
|
||||
// ciphertext: 中间部分
|
||||
const encrypted = combined.slice(12, -16);
|
||||
|
||||
// 创建解密器
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
|
||||
|
||||
// 设置认证标签
|
||||
decipher.setAuthTag(authTag);
|
||||
|
||||
// 解密数据
|
||||
let decrypted = decipher.update(encrypted, 'binary', 'utf8');
|
||||
decrypted += decipher.final('utf8');
|
||||
|
||||
return decrypted;
|
||||
} catch (error) {
|
||||
// 如果解密失败,可能是旧数据(明文),直接返回
|
||||
console.error('[加密] 解密失败,可能是未加密的旧数据:', error.message);
|
||||
|
||||
// 在开发环境抛出错误,生产环境尝试返回原值
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
console.error('[加密] 生产环境中解密失败,返回原值(可能导致 OSS 连接失败)');
|
||||
return ciphertext;
|
||||
}
|
||||
|
||||
throw new Error('数据解密失败: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证加密系统是否正常工作
|
||||
*
|
||||
* 在应用启动时调用,确保:
|
||||
* 1. ENCRYPTION_KEY 已配置
|
||||
* 2. 加密/解密功能正常
|
||||
*
|
||||
* @returns {boolean} true 如果验证通过
|
||||
* @throws {Error} 如果验证失败
|
||||
*/
|
||||
function validateEncryption() {
|
||||
try {
|
||||
const testData = 'test-secret-123';
|
||||
|
||||
// 测试加密
|
||||
const encrypted = encryptSecret(testData);
|
||||
|
||||
// 验证加密结果不为空且不等于原文
|
||||
if (!encrypted || encrypted === testData) {
|
||||
throw new Error('加密结果异常');
|
||||
}
|
||||
|
||||
// 测试解密
|
||||
const decrypted = decryptSecret(encrypted);
|
||||
|
||||
// 验证解密结果等于原文
|
||||
if (decrypted !== testData) {
|
||||
throw new Error('解密结果不匹配');
|
||||
}
|
||||
|
||||
console.log('[安全] ✓ 加密系统验证通过');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[安全] ✗ 加密系统验证失败:', error.message);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查字符串是否已加密
|
||||
*
|
||||
* 通过格式判断是否为加密数据
|
||||
* 注意:这不是加密学验证,仅用于提示
|
||||
*
|
||||
* @param {string} data - 要检查的数据
|
||||
* @returns {boolean} true 如果看起来像是加密数据
|
||||
*/
|
||||
function isEncrypted(data) {
|
||||
if (!data || typeof data !== 'string') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 加密后的数据特征:
|
||||
// 1. 是有效的 Base64
|
||||
// 2. 长度至少为 (12 + 16) * 4/3 = 38 字符(IV + authTag 的 Base64)
|
||||
// 3. 通常会比原文长
|
||||
|
||||
try {
|
||||
// 尝试解码 Base64
|
||||
const buffer = Buffer.from(data, 'base64');
|
||||
|
||||
// 检查长度(至少包含 IV + authTag)
|
||||
// AES-GCM: 12字节IV + 至少1字节密文 + 16字节authTag = 29字节
|
||||
// Base64编码后: 29 * 4/3 ≈ 39 字符
|
||||
if (buffer.length < 29) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
encryptSecret,
|
||||
decryptSecret,
|
||||
validateEncryption,
|
||||
isEncrypted,
|
||||
getEncryptionKey
|
||||
};
|
||||
343
backend/utils/storage-cache.js
Normal file
343
backend/utils/storage-cache.js
Normal file
@@ -0,0 +1,343 @@
|
||||
/**
|
||||
* 存储使用情况缓存管理器
|
||||
* ===== P0 性能优化:解决 OSS 统计性能瓶颈 =====
|
||||
*
|
||||
* 问题:每次获取存储使用情况都要遍历所有 OSS 对象,极其耗时
|
||||
* 解决方案:使用数据库字段 storage_used 维护缓存,上传/删除时更新
|
||||
*
|
||||
* @module StorageUsageCache
|
||||
*/
|
||||
|
||||
const { UserDB } = require('../database');
|
||||
|
||||
/**
|
||||
* 存储使用情况缓存类
|
||||
*/
|
||||
class StorageUsageCache {
|
||||
/**
|
||||
* 获取用户的存储使用情况(从缓存)
|
||||
* @param {number} userId - 用户ID
|
||||
* @returns {Promise<{totalSize: number, totalSizeFormatted: string, fileCount: number, cached: boolean}>}
|
||||
*/
|
||||
static async getUsage(userId) {
|
||||
try {
|
||||
const user = UserDB.findById(userId);
|
||||
|
||||
if (!user) {
|
||||
throw new Error('用户不存在');
|
||||
}
|
||||
|
||||
// 从数据库缓存读取
|
||||
const storageUsed = user.storage_used || 0;
|
||||
|
||||
// 导入格式化函数
|
||||
const { formatFileSize } = require('../storage');
|
||||
|
||||
return {
|
||||
totalSize: storageUsed,
|
||||
totalSizeFormatted: formatFileSize(storageUsed),
|
||||
fileCount: null, // 缓存模式不统计文件数
|
||||
cached: true
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[存储缓存] 获取失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新用户的存储使用量
|
||||
* @param {number} userId - 用户ID
|
||||
* @param {number} deltaSize - 变化量(正数为增加,负数为减少)
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
static async updateUsage(userId, deltaSize) {
|
||||
try {
|
||||
// 使用 SQL 原子操作,避免并发问题
|
||||
const result = UserDB.update(userId, {
|
||||
// 使用原始 SQL,因为 update 方法不支持表达式
|
||||
// 注意:这里需要在数据库层执行 UPDATE ... SET storage_used = storage_used + ?
|
||||
});
|
||||
|
||||
// 直接执行 SQL 更新
|
||||
const { db } = require('../database');
|
||||
db.prepare(`
|
||||
UPDATE users
|
||||
SET storage_used = storage_used + ?
|
||||
WHERE id = ?
|
||||
`).run(deltaSize, userId);
|
||||
|
||||
console.log(`[存储缓存] 用户 ${userId} 存储变化: ${deltaSize > 0 ? '+' : ''}${deltaSize} 字节`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[存储缓存] 更新失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置用户的存储使用量(管理员功能,用于全量统计后更新缓存)
|
||||
* @param {number} userId - 用户ID
|
||||
* @param {number} totalSize - 实际总大小
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
static async resetUsage(userId, totalSize) {
|
||||
try {
|
||||
UserDB.update(userId, { storage_used: totalSize });
|
||||
console.log(`[存储缓存] 用户 ${userId} 存储重置: ${totalSize} 字节`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[存储缓存] 重置失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 验证并修复缓存(管理员功能)
|
||||
* 通过全量统计对比缓存值,如果不一致则更新
|
||||
* @param {number} userId - 用户ID
|
||||
* @param {OssStorageClient} ossClient - OSS 客户端实例
|
||||
* @returns {Promise<{actual: number, cached: number, corrected: boolean}>}
|
||||
*/
|
||||
static async validateAndFix(userId, ossClient) {
|
||||
try {
|
||||
const { ListObjectsV2Command } = require('@aws-sdk/client-s3');
|
||||
const user = UserDB.findById(userId);
|
||||
|
||||
if (!user) {
|
||||
throw new Error('用户不存在');
|
||||
}
|
||||
|
||||
// 执行全量统计
|
||||
let totalSize = 0;
|
||||
let continuationToken = null;
|
||||
|
||||
do {
|
||||
const command = new ListObjectsV2Command({
|
||||
Bucket: user.oss_bucket,
|
||||
Prefix: `user_${userId}/`,
|
||||
ContinuationToken: continuationToken
|
||||
});
|
||||
|
||||
const response = await ossClient.s3Client.send(command);
|
||||
|
||||
if (response.Contents) {
|
||||
for (const obj of response.Contents) {
|
||||
totalSize += obj.Size || 0;
|
||||
}
|
||||
}
|
||||
|
||||
continuationToken = response.NextContinuationToken;
|
||||
} while (continuationToken);
|
||||
|
||||
const cached = user.storage_used || 0;
|
||||
const corrected = totalSize !== cached;
|
||||
|
||||
if (corrected) {
|
||||
await this.resetUsage(userId, totalSize);
|
||||
console.log(`[存储缓存] 用户 ${userId} 缓存已修复: ${cached} → ${totalSize}`);
|
||||
}
|
||||
|
||||
return {
|
||||
actual: totalSize,
|
||||
cached,
|
||||
corrected
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[存储缓存] 验证修复失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查缓存完整性(第二轮修复:缓存一致性保障)
|
||||
* 对比缓存值与实际 OSS 存储使用情况,但不自动修复
|
||||
* @param {number} userId - 用户ID
|
||||
* @param {OssStorageClient} ossClient - OSS 客户端实例
|
||||
* @returns {Promise<{consistent: boolean, cached: number, actual: number, diff: number}>}
|
||||
*/
|
||||
static async checkIntegrity(userId, ossClient) {
|
||||
try {
|
||||
const { ListObjectsV2Command } = require('@aws-sdk/client-s3');
|
||||
const user = UserDB.findById(userId);
|
||||
|
||||
if (!user) {
|
||||
throw new Error('用户不存在');
|
||||
}
|
||||
|
||||
// 执行全量统计
|
||||
let totalSize = 0;
|
||||
let fileCount = 0;
|
||||
let continuationToken = null;
|
||||
|
||||
do {
|
||||
const command = new ListObjectsV2Command({
|
||||
Bucket: user.oss_bucket,
|
||||
Prefix: `user_${userId}/`,
|
||||
ContinuationToken: continuationToken
|
||||
});
|
||||
|
||||
const response = await ossClient.s3Client.send(command);
|
||||
|
||||
if (response.Contents) {
|
||||
for (const obj of response.Contents) {
|
||||
totalSize += obj.Size || 0;
|
||||
fileCount++;
|
||||
}
|
||||
}
|
||||
|
||||
continuationToken = response.NextContinuationToken;
|
||||
} while (continuationToken);
|
||||
|
||||
const cached = user.storage_used || 0;
|
||||
const diff = totalSize - cached;
|
||||
const consistent = Math.abs(diff) === 0;
|
||||
|
||||
console.log(`[存储缓存] 用户 ${userId} 完整性检查: 缓存=${cached}, 实际=${totalSize}, 差异=${diff}`);
|
||||
|
||||
return {
|
||||
consistent,
|
||||
cached,
|
||||
actual: totalSize,
|
||||
fileCount,
|
||||
diff
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[存储缓存] 完整性检查失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重建缓存(第二轮修复:缓存一致性保障)
|
||||
* 强制从 OSS 全量统计并更新缓存值,绕过一致性检查
|
||||
* @param {number} userId - 用户ID
|
||||
* @param {OssStorageClient} ossClient - OSS 客户端实例
|
||||
* @returns {Promise<{previous: number, current: number, fileCount: number}>}
|
||||
*/
|
||||
static async rebuildCache(userId, ossClient) {
|
||||
try {
|
||||
const { ListObjectsV2Command } = require('@aws-sdk/client-s3');
|
||||
const user = UserDB.findById(userId);
|
||||
|
||||
if (!user) {
|
||||
throw new Error('用户不存在');
|
||||
}
|
||||
|
||||
console.log(`[存储缓存] 开始重建用户 ${userId} 的缓存...`);
|
||||
|
||||
// 执行全量统计
|
||||
let totalSize = 0;
|
||||
let fileCount = 0;
|
||||
let continuationToken = null;
|
||||
|
||||
do {
|
||||
const command = new ListObjectsV2Command({
|
||||
Bucket: user.oss_bucket,
|
||||
Prefix: `user_${userId}/`,
|
||||
ContinuationToken: continuationToken
|
||||
});
|
||||
|
||||
const response = await ossClient.s3Client.send(command);
|
||||
|
||||
if (response.Contents) {
|
||||
for (const obj of response.Contents) {
|
||||
totalSize += obj.Size || 0;
|
||||
fileCount++;
|
||||
}
|
||||
}
|
||||
|
||||
continuationToken = response.NextContinuationToken;
|
||||
} while (continuationToken);
|
||||
|
||||
const previous = user.storage_used || 0;
|
||||
|
||||
// 强制更新缓存
|
||||
await this.resetUsage(userId, totalSize);
|
||||
|
||||
console.log(`[存储缓存] 用户 ${userId} 缓存重建完成: ${previous} → ${totalSize} (${fileCount} 个文件)`);
|
||||
|
||||
return {
|
||||
previous,
|
||||
current: totalSize,
|
||||
fileCount
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[存储缓存] 重建缓存失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查所有用户的缓存一致性(第二轮修复:批量检查)
|
||||
* @param {Array} users - 用户列表
|
||||
* @param {Function} getOssClient - 获取 OSS 客户端的函数
|
||||
* @returns {Promise<Array>} 检查结果列表
|
||||
*/
|
||||
static async checkAllUsersIntegrity(users, getOssClient) {
|
||||
const results = [];
|
||||
|
||||
for (const user of users) {
|
||||
// 跳过没有配置 OSS 的用户
|
||||
if (!user.has_oss_config || !user.oss_bucket) {
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const ossClient = getOssClient(user);
|
||||
const checkResult = await this.checkIntegrity(user.id, ossClient);
|
||||
|
||||
results.push({
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
...checkResult
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[存储缓存] 检查用户 ${user.id} 失败:`, error.message);
|
||||
results.push({
|
||||
userId: user.id,
|
||||
username: user.username,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动检测并修复缓存不一致(第二轮修复:自动化保障)
|
||||
* 当检测到不一致时自动修复,并记录日志
|
||||
* @param {number} userId - 用户ID
|
||||
* @param {OssStorageClient} ossClient - OSS 客户端实例
|
||||
* @param {number} threshold - 差异阈值(字节),默认 0(任何差异都修复)
|
||||
* @returns {Promise<{autoFixed: boolean, diff: number}>}
|
||||
*/
|
||||
static async autoDetectAndFix(userId, ossClient, threshold = 0) {
|
||||
try {
|
||||
const checkResult = await this.checkIntegrity(userId, ossClient);
|
||||
|
||||
if (!checkResult.consistent && Math.abs(checkResult.diff) > threshold) {
|
||||
console.warn(`[存储缓存] 检测到用户 ${userId} 缓存不一致: 差异 ${checkResult.diff} 字节`);
|
||||
|
||||
// 自动修复
|
||||
await this.rebuildCache(userId, ossClient);
|
||||
|
||||
return {
|
||||
autoFixed: true,
|
||||
diff: checkResult.diff
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
autoFixed: false,
|
||||
diff: checkResult.diff
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[存储缓存] 自动检测修复失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = StorageUsageCache;
|
||||
Reference in New Issue
Block a user