Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 45e835a51a | |||
| afc00090cf | |||
| 59750dea15 | |||
| 2fe32e9a69 | |||
| 5f8c8c5508 | |||
| fb739f221d | |||
| b5399aaa9b |
+11
-1
@@ -420,7 +420,17 @@ Upon first login, the system will **automatically redirect** you to change your
|
|||||||
|
|
||||||
If you lose your admin credentials after the first login, you'll need to manually reset the password in the database or create a new admin user through the database.
|
If you lose your admin credentials after the first login, you'll need to manually reset the password in the database or create a new admin user through the database.
|
||||||
|
|
||||||
**Note**: The credentials file (`ADMIN_CREDENTIALS.txt`) is only created during initial deployment and contains the first admin password. After changing the password, this file becomes outdated but is kept for reference.
|
**Note**: The credentials file (`ADMIN_CREDENTIALS.txt`) is only created during initial deployment and contains the first admin password. After changing the password, this file becomes outdated but is kept for reference. If you need to regenerate the password and file during a reinstall, re-run the installer with the `--force-admin-password-reset` flag:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Native reinstall example
|
||||||
|
sudo ./setup.sh --native --force-admin-password-reset
|
||||||
|
|
||||||
|
# Docker reinstall example
|
||||||
|
sudo ./setup.sh --docker --force-admin-password-reset
|
||||||
|
```
|
||||||
|
|
||||||
|
The flag calls `scripts/reset-admin-password.js` in non-interactive mode, writes a fresh random password into `data/ADMIN_CREDENTIALS.txt`, and prints the new credentials at the end of the installer run.
|
||||||
|
|
||||||
#### Configuring Admin Email
|
#### Configuring Admin Email
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const fsPromises = fs.promises;
|
||||||
|
const os = require('os');
|
||||||
|
const path = require('path');
|
||||||
|
const express = require('express');
|
||||||
|
const request = require('supertest');
|
||||||
|
|
||||||
|
describe('Admin settings logo upload flow', () => {
|
||||||
|
let tmpDir;
|
||||||
|
let router;
|
||||||
|
let app;
|
||||||
|
let settingsStore;
|
||||||
|
|
||||||
|
const resetModules = () => {
|
||||||
|
jest.resetModules();
|
||||||
|
jest.clearAllMocks();
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
resetModules();
|
||||||
|
|
||||||
|
tmpDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), 'picpeak-logo-'));
|
||||||
|
process.env.STORAGE_PATH = tmpDir;
|
||||||
|
|
||||||
|
settingsStore = new Map();
|
||||||
|
|
||||||
|
const buildQuery = (table) => {
|
||||||
|
const filters = [];
|
||||||
|
const applyFilters = (rows) => {
|
||||||
|
if (filters.length === 0) {
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
return rows.filter((row) =>
|
||||||
|
filters.every(({ column, value }) => row[column] === value)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const makeRow = (row) => ({ ...row });
|
||||||
|
|
||||||
|
return {
|
||||||
|
where(column, value) {
|
||||||
|
filters.push({ column, value });
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
first() {
|
||||||
|
if (table === 'app_settings') {
|
||||||
|
const rows = applyFilters(Array.from(settingsStore.values()).map(makeRow));
|
||||||
|
return Promise.resolve(rows[0]);
|
||||||
|
}
|
||||||
|
return Promise.resolve(undefined);
|
||||||
|
},
|
||||||
|
select() {
|
||||||
|
return Promise.resolve([]);
|
||||||
|
},
|
||||||
|
sum() {
|
||||||
|
return Promise.resolve({ total: 0 });
|
||||||
|
},
|
||||||
|
join() {
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
groupBy() {
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
orderBy() {
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
limit() {
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
insert(payload) {
|
||||||
|
const rows = Array.isArray(payload) ? payload : [payload];
|
||||||
|
const upsert = (row, overrides = {}) => {
|
||||||
|
if (table === 'app_settings') {
|
||||||
|
const key = row.setting_key;
|
||||||
|
const existing = settingsStore.get(key) || {};
|
||||||
|
settingsStore.set(key, { ...existing, ...row, ...overrides });
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
onConflict() {
|
||||||
|
return {
|
||||||
|
merge(overrides) {
|
||||||
|
return Promise.all(rows.map((row) => upsert(row, overrides))).then(() => undefined);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const dbMock = jest.fn((table) => buildQuery(table));
|
||||||
|
dbMock.raw = jest.fn();
|
||||||
|
dbMock.transaction = async (handler) => handler({
|
||||||
|
commit: async () => {},
|
||||||
|
rollback: async () => {}
|
||||||
|
});
|
||||||
|
|
||||||
|
jest.doMock('../src/database/db', () => ({
|
||||||
|
db: dbMock,
|
||||||
|
logActivity: jest.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.doMock('../src/middleware/auth', () => ({
|
||||||
|
adminAuth: (req, res, next) => {
|
||||||
|
req.admin = { id: 1, username: 'tester' };
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.doMock('../src/services/publicSiteService', () => ({
|
||||||
|
clearPublicSiteCache: jest.fn(),
|
||||||
|
getDefaultPublicSitePayload: jest.fn(),
|
||||||
|
getRawPublicSiteSettings: jest.fn().mockResolvedValue({})
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.doMock('../src/services/rateLimitService', () => ({
|
||||||
|
clearSettingsCache: jest.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
jest.doMock('../src/middleware/maintenance', () => ({
|
||||||
|
maintenanceMiddleware: (req, res, next) => next(),
|
||||||
|
clearMaintenanceCache: jest.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
router = require('../src/routes/adminSettings');
|
||||||
|
|
||||||
|
app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use('/api/admin/settings', router);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
resetModules();
|
||||||
|
if (tmpDir) {
|
||||||
|
await fsPromises.rm(tmpDir, { recursive: true, force: true });
|
||||||
|
tmpDir = null;
|
||||||
|
}
|
||||||
|
delete process.env.STORAGE_PATH;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores logo uploads under STORAGE_PATH and deletes on branding reset', async () => {
|
||||||
|
const fileBuffer = Buffer.from('fake image data');
|
||||||
|
|
||||||
|
const uploadResponse = await request(app)
|
||||||
|
.post('/api/admin/settings/logo')
|
||||||
|
.attach('logo', fileBuffer, 'logo.png');
|
||||||
|
|
||||||
|
expect(uploadResponse.status).toBe(200);
|
||||||
|
expect(uploadResponse.body).toHaveProperty('logoUrl');
|
||||||
|
const logoUrl = uploadResponse.body.logoUrl;
|
||||||
|
expect(logoUrl.startsWith('/uploads/logos/')).toBe(true);
|
||||||
|
|
||||||
|
const storedPath = path.join(tmpDir, logoUrl.replace('/uploads/', 'uploads/'));
|
||||||
|
await expect(fsPromises.access(storedPath)).resolves.toBeUndefined();
|
||||||
|
|
||||||
|
await request(app)
|
||||||
|
.put('/api/admin/settings/branding')
|
||||||
|
.send({
|
||||||
|
company_name: 'Test Co',
|
||||||
|
company_tagline: 'Tagline',
|
||||||
|
support_email: 'test@example.com',
|
||||||
|
footer_text: 'Footer',
|
||||||
|
watermark_enabled: false,
|
||||||
|
watermark_position: 'bottom-right',
|
||||||
|
watermark_opacity: 0.5,
|
||||||
|
watermark_size: 'medium',
|
||||||
|
favicon_url: null,
|
||||||
|
logo_url: '',
|
||||||
|
watermark_logo_url: null,
|
||||||
|
logo_size: 'medium',
|
||||||
|
logo_max_height: 120,
|
||||||
|
logo_position: 'left',
|
||||||
|
logo_display_header: true,
|
||||||
|
logo_display_hero: false,
|
||||||
|
logo_display_mode: 'default'
|
||||||
|
})
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
await expect(fsPromises.access(storedPath)).rejects.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "1.1.3",
|
"version": "1.1.5",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "1.1.3",
|
"version": "1.1.5",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.850.0",
|
"@aws-sdk/client-s3": "^3.850.0",
|
||||||
"@aws-sdk/lib-storage": "^3.850.0",
|
"@aws-sdk/lib-storage": "^3.850.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "1.1.3",
|
"version": "1.1.5",
|
||||||
"description": "Backend for PicPeak event photo sharing platform",
|
"description": "Backend for PicPeak event photo sharing platform",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Executable
+102
@@ -0,0 +1,102 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const fsp = fs.promises;
|
||||||
|
|
||||||
|
async function pathExists(location) {
|
||||||
|
try {
|
||||||
|
await fsp.access(location);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
if (error && error.code === 'ENOENT') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function moveFile(source, destination) {
|
||||||
|
await fsp.mkdir(path.dirname(destination), { recursive: true });
|
||||||
|
try {
|
||||||
|
await fsp.rename(source, destination);
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'EXDEV') {
|
||||||
|
await fsp.copyFile(source, destination);
|
||||||
|
await fsp.unlink(source);
|
||||||
|
} else if (error.code === 'EEXIST') {
|
||||||
|
console.warn(`Destination already exists, leaving original in place: ${destination}`);
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function migrate() {
|
||||||
|
const backendRoot = path.resolve(__dirname, '..');
|
||||||
|
const defaultStorage = path.resolve(backendRoot, '../storage');
|
||||||
|
const targetStorage = path.resolve(process.env.STORAGE_PATH || defaultStorage);
|
||||||
|
const legacyUploadsRoot = path.resolve(backendRoot, 'storage/uploads');
|
||||||
|
const targetUploadsRoot = path.join(targetStorage, 'uploads');
|
||||||
|
|
||||||
|
if (legacyUploadsRoot === targetUploadsRoot) {
|
||||||
|
console.log('Legacy uploads directory already matches target STORAGE_PATH. Nothing to migrate.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(legacyUploadsRoot)) {
|
||||||
|
console.log(`Legacy uploads directory not found at ${legacyUploadsRoot}. Nothing to migrate.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const categories = ['logos', 'favicons'];
|
||||||
|
let migratedCounter = 0;
|
||||||
|
|
||||||
|
for (const category of categories) {
|
||||||
|
const legacyDir = path.join(legacyUploadsRoot, category);
|
||||||
|
if (!fs.existsSync(legacyDir)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetDir = path.join(targetUploadsRoot, category);
|
||||||
|
await fsp.mkdir(targetDir, { recursive: true });
|
||||||
|
|
||||||
|
const entries = await fsp.readdir(legacyDir, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isFile()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourcePath = path.join(legacyDir, entry.name);
|
||||||
|
const destinationPath = path.join(targetDir, entry.name);
|
||||||
|
|
||||||
|
if (await pathExists(destinationPath)) {
|
||||||
|
console.warn(`Skipping ${sourcePath} because ${destinationPath} already exists.`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await moveFile(sourcePath, destinationPath);
|
||||||
|
migratedCounter += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const remaining = await fsp.readdir(legacyDir);
|
||||||
|
if (remaining.length === 0) {
|
||||||
|
await fsp.rm(legacyDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (migratedCounter === 0) {
|
||||||
|
console.log('No legacy logo or favicon files needed migration.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Migrated ${migratedCounter} files into ${targetUploadsRoot}.`);
|
||||||
|
console.log('If the database still references legacy absolute paths, they will be cleaned up automatically on the next upload.');
|
||||||
|
}
|
||||||
|
|
||||||
|
migrate().catch((error) => {
|
||||||
|
console.error('Migration failed:', error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
@@ -7,12 +7,32 @@ const fs = require('fs').promises;
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const readline = require('readline');
|
const readline = require('readline');
|
||||||
|
|
||||||
const rl = readline.createInterface({
|
const args = process.argv.slice(2);
|
||||||
|
const hasFlag = (flag) => args.includes(flag);
|
||||||
|
const getOption = (name) => {
|
||||||
|
const index = args.indexOf(`--${name}`);
|
||||||
|
if (index !== -1 && index + 1 < args.length) {
|
||||||
|
return args[index + 1];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const force = hasFlag('--force') || hasFlag('--yes') || hasFlag('--non-interactive');
|
||||||
|
const credentialsFileArg = getOption('credentials-file');
|
||||||
|
const resolvedCredentialsFile = credentialsFileArg
|
||||||
|
? path.resolve(process.cwd(), credentialsFileArg)
|
||||||
|
: path.join(__dirname, '..', '..', 'ADMIN_PASSWORD_RESET.txt');
|
||||||
|
|
||||||
|
const rl = force ? null : readline.createInterface({
|
||||||
input: process.stdin,
|
input: process.stdin,
|
||||||
output: process.stdout
|
output: process.stdout
|
||||||
});
|
});
|
||||||
|
|
||||||
async function question(prompt) {
|
async function ask(prompt) {
|
||||||
|
if (force) {
|
||||||
|
return 'yes';
|
||||||
|
}
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
rl.question(prompt, resolve);
|
rl.question(prompt, resolve);
|
||||||
});
|
});
|
||||||
@@ -37,13 +57,18 @@ async function resetAdminPassword() {
|
|||||||
|
|
||||||
console.log('Found admin user:', admin.username);
|
console.log('Found admin user:', admin.username);
|
||||||
console.log('Email:', admin.email);
|
console.log('Email:', admin.email);
|
||||||
console.log('\nThis will reset the password for this admin account.');
|
if (!force) {
|
||||||
|
console.log('\nThis will reset the password for this admin account.');
|
||||||
const confirm = await question('\nDo you want to continue? (yes/no): ');
|
}
|
||||||
|
|
||||||
if (confirm.toLowerCase() !== 'yes' && confirm.toLowerCase() !== 'y') {
|
const confirm = await ask('\nDo you want to continue? (yes/no): ');
|
||||||
console.log('\n❌ Password reset cancelled.');
|
|
||||||
process.exit(0);
|
if (!force) {
|
||||||
|
const normalized = confirm.trim().toLowerCase();
|
||||||
|
if (normalized !== 'yes' && normalized !== 'y') {
|
||||||
|
console.log('\n❌ Password reset cancelled.');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate new password
|
// Generate new password
|
||||||
@@ -60,39 +85,44 @@ async function resetAdminPassword() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Save to file
|
// Save to file
|
||||||
const resetInfoPath = path.join(__dirname, '..', '..', 'ADMIN_PASSWORD_RESET.txt');
|
const credentialsDir = path.dirname(resolvedCredentialsFile);
|
||||||
|
await fs.mkdir(credentialsDir, { recursive: true });
|
||||||
|
|
||||||
|
const adminUrl = `${process.env.ADMIN_URL || 'http://localhost:3001'}/admin`;
|
||||||
const resetInfo = `
|
const resetInfo = `
|
||||||
========================================
|
========================================
|
||||||
PicPeak Admin Password Reset
|
PicPeak Admin Credentials
|
||||||
========================================
|
========================================
|
||||||
|
|
||||||
Password has been reset for admin account:
|
Your admin account has been reset with these credentials:
|
||||||
|
|
||||||
Username: admin
|
Username: ${admin.username}
|
||||||
New Password: ${newPassword}
|
Email: ${admin.email}
|
||||||
|
Password: ${newPassword}
|
||||||
|
|
||||||
IMPORTANT:
|
IMPORTANT SECURITY NOTES:
|
||||||
1. You MUST change this password on next login
|
1. You MUST change this password after first login
|
||||||
2. This file contains sensitive information
|
2. This file contains sensitive information
|
||||||
3. Delete this file after noting the password
|
3. Delete this file after noting the password
|
||||||
|
|
||||||
Login URL: ${process.env.ADMIN_URL || 'http://localhost:3001'}/admin
|
Login URL: ${adminUrl}
|
||||||
|
|
||||||
Reset performed on: ${new Date().toISOString()}
|
Reset performed on: ${new Date().toISOString()}
|
||||||
========================================
|
========================================
|
||||||
`;
|
`;
|
||||||
|
|
||||||
await fs.writeFile(resetInfoPath, resetInfo, 'utf8');
|
await fs.writeFile(resolvedCredentialsFile, resetInfo, 'utf8');
|
||||||
|
|
||||||
console.log('\n✅ Password reset successful!\n');
|
console.log('\n✅ Password reset successful!\n');
|
||||||
console.log('========================================');
|
console.log('========================================');
|
||||||
console.log('New Credentials:');
|
console.log('New Credentials:');
|
||||||
console.log('========================================');
|
console.log('========================================');
|
||||||
console.log('Username: admin');
|
console.log(`Username: ${admin.username}`);
|
||||||
|
console.log(`Email: ${admin.email}`);
|
||||||
console.log(`Password: ${newPassword}`);
|
console.log(`Password: ${newPassword}`);
|
||||||
console.log('\n⚠️ IMPORTANT:');
|
console.log('\n⚠️ IMPORTANT:');
|
||||||
console.log('1. You will be required to change this password on next login');
|
console.log('1. You will be required to change this password on next login');
|
||||||
console.log('2. Credentials are also saved in: ADMIN_PASSWORD_RESET.txt');
|
console.log(`2. Credentials are also saved in: ${resolvedCredentialsFile}`);
|
||||||
console.log('3. Delete the file after noting the password');
|
console.log('3. Delete the file after noting the password');
|
||||||
console.log('========================================\n');
|
console.log('========================================\n');
|
||||||
|
|
||||||
@@ -100,10 +130,12 @@ Reset performed on: ${new Date().toISOString()}
|
|||||||
console.error('❌ Error resetting password:', error.message);
|
console.error('❌ Error resetting password:', error.message);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
} finally {
|
} finally {
|
||||||
rl.close();
|
if (rl) {
|
||||||
|
rl.close();
|
||||||
|
}
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run the reset
|
// Run the reset
|
||||||
resetAdminPassword();
|
resetAdminPassword();
|
||||||
|
|||||||
@@ -20,10 +20,12 @@ const {
|
|||||||
const { sanitizeCss } = require('../utils/cssSanitizer');
|
const { sanitizeCss } = require('../utils/cssSanitizer');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
|
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||||
|
|
||||||
// Configure multer for logo uploads
|
// Configure multer for logo uploads
|
||||||
const storage = multer.diskStorage({
|
const storage = multer.diskStorage({
|
||||||
destination: async (req, file, cb) => {
|
destination: async (req, file, cb) => {
|
||||||
const uploadDir = path.join(__dirname, '../../storage/uploads/logos');
|
const uploadDir = path.join(getStoragePath(), 'uploads/logos');
|
||||||
await fs.mkdir(uploadDir, { recursive: true });
|
await fs.mkdir(uploadDir, { recursive: true });
|
||||||
cb(null, uploadDir);
|
cb(null, uploadDir);
|
||||||
},
|
},
|
||||||
@@ -53,7 +55,7 @@ const upload = multer({
|
|||||||
// Configure multer for favicon uploads
|
// Configure multer for favicon uploads
|
||||||
const faviconStorage = multer.diskStorage({
|
const faviconStorage = multer.diskStorage({
|
||||||
destination: async (req, file, cb) => {
|
destination: async (req, file, cb) => {
|
||||||
const uploadDir = path.join(__dirname, '../../storage/uploads/favicons');
|
const uploadDir = path.join(getStoragePath(), 'uploads/favicons');
|
||||||
await fs.mkdir(uploadDir, { recursive: true });
|
await fs.mkdir(uploadDir, { recursive: true });
|
||||||
cb(null, uploadDir);
|
cb(null, uploadDir);
|
||||||
},
|
},
|
||||||
@@ -228,7 +230,8 @@ router.put('/branding', adminAuth, async (req, res) => {
|
|||||||
|
|
||||||
if (currentFaviconUrl && typeof currentFaviconUrl === 'string' && currentFaviconUrl.startsWith('/uploads/favicons/')) {
|
if (currentFaviconUrl && typeof currentFaviconUrl === 'string' && currentFaviconUrl.startsWith('/uploads/favicons/')) {
|
||||||
// Delete the file from filesystem
|
// Delete the file from filesystem
|
||||||
const faviconPath = path.join(__dirname, '..', '..', 'storage', currentFaviconUrl.replace('/uploads/', ''));
|
const relativePath = currentFaviconUrl.replace(/^\//, '');
|
||||||
|
const faviconPath = path.join(getStoragePath(), relativePath);
|
||||||
try {
|
try {
|
||||||
await fs.unlink(faviconPath);
|
await fs.unlink(faviconPath);
|
||||||
console.log('Deleted favicon file:', faviconPath);
|
console.log('Deleted favicon file:', faviconPath);
|
||||||
@@ -258,7 +261,8 @@ router.put('/branding', adminAuth, async (req, res) => {
|
|||||||
|
|
||||||
if (currentLogoUrl && typeof currentLogoUrl === 'string' && currentLogoUrl.startsWith('/uploads/logos/')) {
|
if (currentLogoUrl && typeof currentLogoUrl === 'string' && currentLogoUrl.startsWith('/uploads/logos/')) {
|
||||||
// Delete the file from filesystem
|
// Delete the file from filesystem
|
||||||
const logoPath = path.join(__dirname, '..', '..', 'storage', currentLogoUrl.replace('/uploads/', ''));
|
const relativePath = currentLogoUrl.replace(/^\//, '');
|
||||||
|
const logoPath = path.join(getStoragePath(), relativePath);
|
||||||
try {
|
try {
|
||||||
await fs.unlink(logoPath);
|
await fs.unlink(logoPath);
|
||||||
console.log('Deleted logo file:', logoPath);
|
console.log('Deleted logo file:', logoPath);
|
||||||
@@ -643,7 +647,7 @@ router.get('/storage/info', adminAuth, async (req, res) => {
|
|||||||
for (const archive of archives) {
|
for (const archive of archives) {
|
||||||
if (archive.archive_path) {
|
if (archive.archive_path) {
|
||||||
try {
|
try {
|
||||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
const storagePath = getStoragePath();
|
||||||
const fullArchivePath = path.join(storagePath, archive.archive_path);
|
const fullArchivePath = path.join(storagePath, archive.archive_path);
|
||||||
const stats = await fs.stat(fullArchivePath);
|
const stats = await fs.stat(fullArchivePath);
|
||||||
archiveStorage += stats.size;
|
archiveStorage += stats.size;
|
||||||
@@ -654,7 +658,7 @@ router.get('/storage/info', adminAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_SOFT_LIMIT_BYTES = 10 * 1024 * 1024 * 1024; // 10GB fallback
|
const DEFAULT_SOFT_LIMIT_BYTES = 10 * 1024 * 1024 * 1024; // 10GB fallback
|
||||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
const storagePath = getStoragePath();
|
||||||
|
|
||||||
let diskStats = null;
|
let diskStats = null;
|
||||||
let rawDiskTotal = null;
|
let rawDiskTotal = null;
|
||||||
|
|||||||
@@ -108,6 +108,8 @@ If ADMIN_CREDENTIALS.txt is missing:
|
|||||||
- Check the console output from when you ran migrations
|
- Check the console output from when you ran migrations
|
||||||
- File is created in the backend directory root
|
- File is created in the backend directory root
|
||||||
- File might have been deleted for security (as recommended)
|
- File might have been deleted for security (as recommended)
|
||||||
|
- Regenerate it by running `node scripts/reset-admin-password.js --force --credentials-file data/ADMIN_CREDENTIALS.txt`
|
||||||
|
- When using the unified `setup.sh` installer for a reinstall, append `--force-admin-password-reset` to have the script perform the reset automatically
|
||||||
|
|
||||||
## Best Practices
|
## Best Practices
|
||||||
|
|
||||||
@@ -161,4 +163,4 @@ If upgrading from the old system with hardcoded `admin123`:
|
|||||||
- [ ] Stored new password in password manager
|
- [ ] Stored new password in password manager
|
||||||
- [ ] Tested login with new password
|
- [ ] Tested login with new password
|
||||||
- [ ] Set up additional admin accounts if needed
|
- [ ] Set up additional admin accounts if needed
|
||||||
- [ ] Configured password policies for organization
|
- [ ] Configured password policies for organization
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-frontend",
|
"name": "picpeak-frontend",
|
||||||
"version": "1.1.1",
|
"version": "1.1.7",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "picpeak-frontend",
|
"name": "picpeak-frontend",
|
||||||
"version": "1.1.1",
|
"version": "1.1.7",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "^5.0.0",
|
"@tanstack/react-query": "^5.0.0",
|
||||||
"@tiptap/extension-character-count": "^2.26.1",
|
"@tiptap/extension-character-count": "^2.26.1",
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "picpeak-frontend",
|
"name": "picpeak-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.1.1",
|
"version": "1.1.7",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -94,7 +94,6 @@ export const SettingsPage: React.FC = () => {
|
|||||||
|
|
||||||
// Security settings state
|
// Security settings state
|
||||||
const [securitySettings, setSecuritySettings] = useState({
|
const [securitySettings, setSecuritySettings] = useState({
|
||||||
require_password: true,
|
|
||||||
password_min_length: 8,
|
password_min_length: 8,
|
||||||
password_complexity: 'moderate',
|
password_complexity: 'moderate',
|
||||||
enable_2fa: false,
|
enable_2fa: false,
|
||||||
@@ -146,7 +145,6 @@ export const SettingsPage: React.FC = () => {
|
|||||||
|
|
||||||
// Extract security settings
|
// Extract security settings
|
||||||
setSecuritySettings({
|
setSecuritySettings({
|
||||||
require_password: toBoolean(settings.security_require_password, true),
|
|
||||||
password_min_length: toNumber(settings.security_password_min_length, 8),
|
password_min_length: toNumber(settings.security_password_min_length, 8),
|
||||||
password_complexity: settings.security_password_complexity ?? 'moderate',
|
password_complexity: settings.security_password_complexity ?? 'moderate',
|
||||||
enable_2fa: toBoolean(settings.security_enable_2fa, false),
|
enable_2fa: toBoolean(settings.security_enable_2fa, false),
|
||||||
@@ -1105,16 +1103,6 @@ export const SettingsPage: React.FC = () => {
|
|||||||
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.security.passwordSettings')}</h2>
|
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.security.passwordSettings')}</h2>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<label className="flex items-center">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={securitySettings.require_password}
|
|
||||||
onChange={(e) => setSecuritySettings(prev => ({ ...prev, require_password: e.target.checked }))}
|
|
||||||
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
|
|
||||||
/>
|
|
||||||
<span className="ml-2 text-sm text-neutral-700">{t('settings.security.requirePassword')}</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||||
{t('settings.security.minPasswordLength')}
|
{t('settings.security.minPasswordLength')}
|
||||||
|
|||||||
+23
-1
@@ -55,6 +55,7 @@ CUSTOM_PORT=""
|
|||||||
UNATTENDED=false
|
UNATTENDED=false
|
||||||
UPDATE_MODE=false
|
UPDATE_MODE=false
|
||||||
UNINSTALL_MODE=false
|
UNINSTALL_MODE=false
|
||||||
|
FORCE_ADMIN_PASSWORD_RESET=false
|
||||||
|
|
||||||
################################################################################
|
################################################################################
|
||||||
# Helper Functions
|
# Helper Functions
|
||||||
@@ -487,6 +488,15 @@ EOF
|
|||||||
# Run database migrations
|
# Run database migrations
|
||||||
log_step "Running database migrations..."
|
log_step "Running database migrations..."
|
||||||
docker compose exec -T backend npm run migrate
|
docker compose exec -T backend npm run migrate
|
||||||
|
|
||||||
|
if [[ "$FORCE_ADMIN_PASSWORD_RESET" == "true" ]]; then
|
||||||
|
log_step "Resetting admin credentials..."
|
||||||
|
if docker compose exec -T backend node scripts/reset-admin-password.js --force --credentials-file data/ADMIN_CREDENTIALS.txt; then
|
||||||
|
docker compose cp backend:/app/data/ADMIN_CREDENTIALS.txt "$app_dir/data/ADMIN_CREDENTIALS.txt" 2>/dev/null || true
|
||||||
|
else
|
||||||
|
log_warn "Automatic admin password reset failed; run reset-admin-password.js inside the backend container."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
log_success "Docker installation completed!"
|
log_success "Docker installation completed!"
|
||||||
}
|
}
|
||||||
@@ -737,6 +747,13 @@ EOF
|
|||||||
log_step "Initializing database..."
|
log_step "Initializing database..."
|
||||||
cd "$NATIVE_APP_DIR/app/backend"
|
cd "$NATIVE_APP_DIR/app/backend"
|
||||||
run_as_user "npm run migrate"
|
run_as_user "npm run migrate"
|
||||||
|
|
||||||
|
if [[ "$FORCE_ADMIN_PASSWORD_RESET" == "true" ]]; then
|
||||||
|
log_step "Resetting admin credentials..."
|
||||||
|
if ! run_as_user "node scripts/reset-admin-password.js --force --credentials-file data/ADMIN_CREDENTIALS.txt"; then
|
||||||
|
log_warn "Automatic admin password reset failed; please run reset-admin-password.js manually."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
# Create systemd services
|
# Create systemd services
|
||||||
create_systemd_services
|
create_systemd_services
|
||||||
@@ -989,7 +1006,7 @@ print_success_message() {
|
|||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
echo -e "Email: ${CYAN}$ADMIN_EMAIL${NC}"
|
echo -e "Email: ${CYAN}$ADMIN_EMAIL${NC}"
|
||||||
echo -e "Password: ${YELLOW}(credentials file not found)${NC}"
|
echo -e "Password: ${YELLOW}(credentials file not found - rerun setup with --force-admin-password-reset or run node scripts/reset-admin-password.js manually)${NC}"
|
||||||
fi
|
fi
|
||||||
echo
|
echo
|
||||||
echo -e "${YELLOW}⚠️ IMPORTANT: Change the admin password on first login!${NC}"
|
echo -e "${YELLOW}⚠️ IMPORTANT: Change the admin password on first login!${NC}"
|
||||||
@@ -1256,6 +1273,10 @@ parse_arguments() {
|
|||||||
SMTP_PASS="$2"
|
SMTP_PASS="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
|
--force-admin-password-reset)
|
||||||
|
FORCE_ADMIN_PASSWORD_RESET=true
|
||||||
|
shift
|
||||||
|
;;
|
||||||
--enable-ssl)
|
--enable-ssl)
|
||||||
ENABLE_SSL=true
|
ENABLE_SSL=true
|
||||||
shift
|
shift
|
||||||
@@ -1301,6 +1322,7 @@ Options:
|
|||||||
--smtp-port PORT SMTP server port
|
--smtp-port PORT SMTP server port
|
||||||
--smtp-user USER SMTP username
|
--smtp-user USER SMTP username
|
||||||
--smtp-pass PASS SMTP password
|
--smtp-pass PASS SMTP password
|
||||||
|
--force-admin-password-reset Regenerate admin credentials after setup
|
||||||
--enable-ssl Enable HTTPS with Let's Encrypt
|
--enable-ssl Enable HTTPS with Let's Encrypt
|
||||||
--port PORT Custom port (native only)
|
--port PORT Custom port (native only)
|
||||||
--update Update existing installation
|
--update Update existing installation
|
||||||
|
|||||||
Reference in New Issue
Block a user