feat(auth): migrate user store from SQLite to PostgreSQL
Build & Push Docker Images / test-backend (push) Successful in 44s
Build & Push Docker Images / test-frontend (push) Successful in 53s
Build & Push Docker Images / build-backend (push) Failing after 50s
Build & Push Docker Images / build-frontend (push) Successful in 54s
Build & Push Docker Images / test-backend (push) Successful in 44s
Build & Push Docker Images / test-frontend (push) Successful in 53s
Build & Push Docker Images / build-backend (push) Failing after 50s
Build & Push Docker Images / build-frontend (push) Successful in 54s
Users were wiped on container recreation because they shared the backend_data volume with ephemeral pharmacy/medicine data. Postgres gets its own named volume (postgres_data) that survives deploys. - Add postgres:16-alpine service + postgres_data volume to compose - Add pg + connect-pg-simple deps - userDbGet/userDbRun helpers: PG when PG_URL set, SQLite fallback - Sessions use connect-pg-simple when pgPool available - create-admin.js: PG-aware, SQLite fallback for local dev - push_subscriptions.user_id: plain INTEGER (cross-DB FK dropped) - Tests unchanged — no PG_URL in test env = SQLite fallback Set PG_PASSWORD in server .env before next deploy. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,10 @@ REDIS_HOST=localhost
|
|||||||
REDIS_PORT=6379
|
REDIS_PORT=6379
|
||||||
REDIS_PASSWORD=
|
REDIS_PASSWORD=
|
||||||
|
|
||||||
|
# PostgreSQL for user accounts (leave unset to fallback to SQLite — dev/test only)
|
||||||
|
PG_URL=postgresql://farmafinder:change-me@localhost:5432/farmafinder
|
||||||
|
PG_PASSWORD=change-me
|
||||||
|
|
||||||
# Web Push (VAPID). Generate with:
|
# Web Push (VAPID). Generate with:
|
||||||
# node -e "import('web-push').then(w => console.log(w.default.generateVAPIDKeys()))"
|
# node -e "import('web-push').then(w => console.log(w.default.generateVAPIDKeys()))"
|
||||||
VAPID_PUBLIC_KEY=
|
VAPID_PUBLIC_KEY=
|
||||||
|
|||||||
+58
-49
@@ -3,27 +3,60 @@ import { promisify } from 'util';
|
|||||||
import bcrypt from 'bcrypt';
|
import bcrypt from 'bcrypt';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
|
import pg from 'pg';
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
const dbPath = path.join(__dirname, 'database.sqlite');
|
const PG_URL = process.env.PG_URL;
|
||||||
const db = new sqlite3.Database(dbPath);
|
|
||||||
|
|
||||||
// Custom wrapper to get lastID from db.run
|
async function createAdmin() {
|
||||||
function dbRun(sql, params = []) {
|
const username = process.env.ADMIN_USERNAME || 'admin';
|
||||||
return new Promise((resolve, reject) => {
|
const password = process.env.ADMIN_PASSWORD || 'admin123';
|
||||||
db.run(sql, params, function(err) {
|
const passwordHash = await bcrypt.hash(password, 10);
|
||||||
if (err) reject(err);
|
|
||||||
else resolve({ lastID: this.lastID, changes: this.changes });
|
if (PG_URL) {
|
||||||
});
|
const { Pool } = pg;
|
||||||
});
|
const pool = new Pool({ connectionString: PG_URL });
|
||||||
|
try {
|
||||||
|
await pool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
username TEXT UNIQUE NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||||
|
address TEXT,
|
||||||
|
latitude DOUBLE PRECISION,
|
||||||
|
longitude DOUBLE PRECISION,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
const existing = await pool.query('SELECT id FROM users WHERE username = $1', [username]);
|
||||||
|
if (existing.rows.length > 0) {
|
||||||
|
console.log(`Admin user '${username}' already exists.`);
|
||||||
|
console.log('To reset, delete the user first and re-run.');
|
||||||
|
await pool.end();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await pool.query(
|
||||||
|
'INSERT INTO users (username, password_hash, is_admin) VALUES ($1, $2, 1)',
|
||||||
|
[username, passwordHash]
|
||||||
|
);
|
||||||
|
console.log(`Admin user '${username}' created in PostgreSQL.`);
|
||||||
|
} finally {
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const dbPath = process.env.DATABASE_PATH || path.join(__dirname, 'database.sqlite');
|
||||||
|
const db = new sqlite3.Database(dbPath);
|
||||||
|
const dbRun = (sql, params = []) =>
|
||||||
|
new Promise((resolve, reject) =>
|
||||||
|
db.run(sql, params, function (err) { err ? reject(err) : resolve({ lastID: this.lastID }); })
|
||||||
|
);
|
||||||
const dbGet = promisify(db.get.bind(db));
|
const dbGet = promisify(db.get.bind(db));
|
||||||
|
|
||||||
// Initialize users table
|
|
||||||
async function initDatabase() {
|
|
||||||
try {
|
try {
|
||||||
await dbRun(`
|
await dbRun(`
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
@@ -33,56 +66,32 @@ async function initDatabase() {
|
|||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
console.log('Database table initialized');
|
try { await dbRun('ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0'); } catch {}
|
||||||
} catch (error) {
|
|
||||||
console.error('Error initializing database:', error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createAdmin() {
|
|
||||||
try {
|
|
||||||
// Initialize database tables first
|
|
||||||
await initDatabase();
|
|
||||||
|
|
||||||
// Default admin credentials
|
|
||||||
const username = process.env.ADMIN_USERNAME || 'admin';
|
|
||||||
const password = process.env.ADMIN_PASSWORD || 'admin123';
|
|
||||||
|
|
||||||
// Check if admin already exists
|
|
||||||
const existing = await dbGet(
|
|
||||||
'SELECT * FROM users WHERE username = ?',
|
|
||||||
[username]
|
|
||||||
);
|
|
||||||
|
|
||||||
|
const existing = await dbGet('SELECT id FROM users WHERE username = ?', [username]);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
console.log(`Admin user '${username}' already exists.`);
|
console.log(`Admin user '${username}' already exists.`);
|
||||||
console.log('To change the password, delete the user first and run this script again.');
|
console.log('To reset, delete the user first and re-run.');
|
||||||
db.close();
|
db.close();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hash password
|
|
||||||
const saltRounds = 10;
|
|
||||||
const passwordHash = await bcrypt.hash(password, saltRounds);
|
|
||||||
|
|
||||||
// Create admin user
|
|
||||||
await dbRun(
|
await dbRun(
|
||||||
'INSERT INTO users (username, password_hash) VALUES (?, ?)',
|
'INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, 1)',
|
||||||
[username, passwordHash]
|
[username, passwordHash]
|
||||||
);
|
);
|
||||||
|
console.log(`Admin user '${username}' created in SQLite.`);
|
||||||
console.log('✅ Admin user created successfully!');
|
|
||||||
console.log(`Username: ${username}`);
|
|
||||||
console.log(`Password: ${password}`);
|
|
||||||
console.log('\n⚠️ IMPORTANT: Change the default password after first login!');
|
|
||||||
console.log(' You can set ADMIN_USERNAME and ADMIN_PASSWORD environment variables to customize.');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error creating admin user:', error);
|
|
||||||
} finally {
|
} finally {
|
||||||
db.close();
|
db.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
createAdmin();
|
console.log(`Username: ${username}`);
|
||||||
|
console.log(`Password: ${password}`);
|
||||||
|
console.log('\nIMPORTANT: Change the default password after first login!');
|
||||||
|
}
|
||||||
|
|
||||||
|
createAdmin().catch((err) => {
|
||||||
|
console.error('Error creating admin user:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
|||||||
Generated
+145
@@ -11,11 +11,13 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
|
"connect-pg-simple": "^10.0.0",
|
||||||
"connect-sqlite3": "^0.9.16",
|
"connect-sqlite3": "^0.9.16",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"express-rate-limit": "^8.5.2",
|
"express-rate-limit": "^8.5.2",
|
||||||
"express-session": "^1.17.3",
|
"express-session": "^1.17.3",
|
||||||
|
"pg": "^8.13.0",
|
||||||
"redis": "^4.6.0",
|
"redis": "^4.6.0",
|
||||||
"sqlite3": "^5.1.6",
|
"sqlite3": "^5.1.6",
|
||||||
"web-push": "^3.6.7"
|
"web-push": "^3.6.7"
|
||||||
@@ -2134,6 +2136,17 @@
|
|||||||
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/connect-pg-simple": {
|
||||||
|
"version": "10.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/connect-pg-simple/-/connect-pg-simple-10.0.0.tgz",
|
||||||
|
"integrity": "sha512-pBGVazlqiMrackzCr0eKhn4LO5trJXsOX0nQoey9wCOayh80MYtThCbq8eoLsjpiWgiok/h+1/uti9/2/Una8A==",
|
||||||
|
"dependencies": {
|
||||||
|
"pg": "^8.12.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.18.0 || ^20.9.0 || >=22.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/connect-sqlite3": {
|
"node_modules/connect-sqlite3": {
|
||||||
"version": "0.9.16",
|
"version": "0.9.16",
|
||||||
"resolved": "https://registry.npmjs.org/connect-sqlite3/-/connect-sqlite3-0.9.16.tgz",
|
"resolved": "https://registry.npmjs.org/connect-sqlite3/-/connect-sqlite3-0.9.16.tgz",
|
||||||
@@ -4998,6 +5011,87 @@
|
|||||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/pg": {
|
||||||
|
"version": "8.21.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz",
|
||||||
|
"integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==",
|
||||||
|
"dependencies": {
|
||||||
|
"pg-connection-string": "^2.13.0",
|
||||||
|
"pg-pool": "^3.14.0",
|
||||||
|
"pg-protocol": "^1.14.0",
|
||||||
|
"pg-types": "2.2.0",
|
||||||
|
"pgpass": "1.0.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"pg-cloudflare": "^1.4.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"pg-native": ">=3.0.1"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"pg-native": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-cloudflare": {
|
||||||
|
"version": "1.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
|
||||||
|
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"node_modules/pg-connection-string": {
|
||||||
|
"version": "2.13.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.13.0.tgz",
|
||||||
|
"integrity": "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig=="
|
||||||
|
},
|
||||||
|
"node_modules/pg-int8": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-pool": {
|
||||||
|
"version": "3.14.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
|
||||||
|
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
|
||||||
|
"peerDependencies": {
|
||||||
|
"pg": ">=8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-protocol": {
|
||||||
|
"version": "1.14.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz",
|
||||||
|
"integrity": "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA=="
|
||||||
|
},
|
||||||
|
"node_modules/pg-types": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||||
|
"dependencies": {
|
||||||
|
"pg-int8": "1.0.1",
|
||||||
|
"postgres-array": "~2.0.0",
|
||||||
|
"postgres-bytea": "~1.0.0",
|
||||||
|
"postgres-date": "~1.0.4",
|
||||||
|
"postgres-interval": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pgpass": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||||
|
"dependencies": {
|
||||||
|
"split2": "^4.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -5041,6 +5135,41 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/postgres-array": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-bytea": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-date": {
|
||||||
|
"version": "1.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||||
|
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-interval": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"xtend": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/prebuild-install": {
|
"node_modules/prebuild-install": {
|
||||||
"version": "7.1.3",
|
"version": "7.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
|
||||||
@@ -5712,6 +5841,14 @@
|
|||||||
"source-map": "^0.6.0"
|
"source-map": "^0.6.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/split2": {
|
||||||
|
"version": "4.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||||
|
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/sprintf-js": {
|
"node_modules/sprintf-js": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
||||||
@@ -6389,6 +6526,14 @@
|
|||||||
"node": "^12.13.0 || ^14.15.0 || >=16.0.0"
|
"node": "^12.13.0 || ^14.15.0 || >=16.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/xtend": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/y18n": {
|
"node_modules/y18n": {
|
||||||
"version": "5.0.8",
|
"version": "5.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||||
|
|||||||
@@ -20,7 +20,9 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.6.0",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
|
"connect-pg-simple": "^10.0.0",
|
||||||
"connect-sqlite3": "^0.9.16",
|
"connect-sqlite3": "^0.9.16",
|
||||||
|
"pg": "^8.13.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
"express-rate-limit": "^8.5.2",
|
"express-rate-limit": "^8.5.2",
|
||||||
|
|||||||
+66
-16
@@ -6,6 +6,8 @@ import path from 'path';
|
|||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import session from 'express-session';
|
import session from 'express-session';
|
||||||
import connectSqlite3 from 'connect-sqlite3';
|
import connectSqlite3 from 'connect-sqlite3';
|
||||||
|
import connectPg from 'connect-pg-simple';
|
||||||
|
import pg from 'pg';
|
||||||
import bcrypt from 'bcrypt';
|
import bcrypt from 'bcrypt';
|
||||||
import rateLimit from 'express-rate-limit';
|
import rateLimit from 'express-rate-limit';
|
||||||
import webpush from 'web-push';
|
import webpush from 'web-push';
|
||||||
@@ -38,7 +40,12 @@ app.use(cors({
|
|||||||
}));
|
}));
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
const SQLiteStore = connectSqlite3(session);
|
const PG_URL = process.env.PG_URL;
|
||||||
|
let pgPool = null;
|
||||||
|
if (PG_URL) {
|
||||||
|
const { Pool } = pg;
|
||||||
|
pgPool = new Pool({ connectionString: PG_URL });
|
||||||
|
}
|
||||||
|
|
||||||
const sessionConfig = {
|
const sessionConfig = {
|
||||||
secret: process.env.SESSION_SECRET || 'farma-finder-secret-key-change-in-production',
|
secret: process.env.SESSION_SECRET || 'farma-finder-secret-key-change-in-production',
|
||||||
@@ -52,8 +59,14 @@ const sessionConfig = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (process.env.NODE_ENV !== 'test') {
|
if (process.env.NODE_ENV !== 'test') {
|
||||||
|
if (pgPool) {
|
||||||
|
const PgStore = connectPg(session);
|
||||||
|
sessionConfig.store = new PgStore({ pool: pgPool, createTableIfMissing: true });
|
||||||
|
} else {
|
||||||
|
const SQLiteStore = connectSqlite3(session);
|
||||||
sessionConfig.store = new SQLiteStore({ db: 'sessions.sqlite', dir: __dirname });
|
sessionConfig.store = new SQLiteStore({ db: 'sessions.sqlite', dir: __dirname });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Configure session
|
// Configure session
|
||||||
app.use(session(sessionConfig));
|
app.use(session(sessionConfig));
|
||||||
@@ -90,6 +103,28 @@ function dbRun(sql, params = []) {
|
|||||||
const dbAll = promisify(db.all.bind(db));
|
const dbAll = promisify(db.all.bind(db));
|
||||||
const dbGet = promisify(db.get.bind(db));
|
const dbGet = promisify(db.get.bind(db));
|
||||||
|
|
||||||
|
// User DB helpers — route to PG when available, fallback to SQLite for tests
|
||||||
|
function toPositional(sql) {
|
||||||
|
let i = 0;
|
||||||
|
return sql.replace(/\?/g, () => `$${++i}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function userDbGet(sql, params = []) {
|
||||||
|
if (pgPool) {
|
||||||
|
const res = await pgPool.query(toPositional(sql), params);
|
||||||
|
return res.rows[0] ?? null;
|
||||||
|
}
|
||||||
|
return dbGet(sql, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function userDbRun(sql, params = []) {
|
||||||
|
if (pgPool) {
|
||||||
|
const res = await pgPool.query(toPositional(sql), params);
|
||||||
|
return { lastID: res.rows[0]?.id, changes: res.rowCount };
|
||||||
|
}
|
||||||
|
return dbRun(sql.replace(/\s+RETURNING\s+\w+\s*$/i, ''), params);
|
||||||
|
}
|
||||||
|
|
||||||
// Accept opening_hours as either an object or a JSON string from the client
|
// Accept opening_hours as either an object or a JSON string from the client
|
||||||
// and return a TEXT value (or null) safe to persist.
|
// and return a TEXT value (or null) safe to persist.
|
||||||
function serializeOpeningHours(value) {
|
function serializeOpeningHours(value) {
|
||||||
@@ -152,7 +187,21 @@ async function initDatabase() {
|
|||||||
|
|
||||||
await dbRun(`CREATE INDEX IF NOT EXISTS idx_pharmacy_medicine ON pharmacy_medicines(medicine_nregistro)`);
|
await dbRun(`CREATE INDEX IF NOT EXISTS idx_pharmacy_medicine ON pharmacy_medicines(medicine_nregistro)`);
|
||||||
|
|
||||||
// Create users table for authentication
|
// Create users table — PG when available, SQLite fallback
|
||||||
|
if (pgPool) {
|
||||||
|
await pgPool.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
username TEXT UNIQUE NOT NULL,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||||
|
address TEXT,
|
||||||
|
latitude DOUBLE PRECISION,
|
||||||
|
longitude DOUBLE PRECISION,
|
||||||
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
} else {
|
||||||
await dbRun(`
|
await dbRun(`
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
@@ -161,6 +210,7 @@ async function initDatabase() {
|
|||||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
await dbRun(`
|
await dbRun(`
|
||||||
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
CREATE TABLE IF NOT EXISTS push_subscriptions (
|
||||||
@@ -191,17 +241,17 @@ async function initDatabase() {
|
|||||||
`);
|
`);
|
||||||
await dbRun(`CREATE INDEX IF NOT EXISTS idx_push_subs_pharm ON push_subscriptions_pharmacy(medicine_nregistro, pharmacy_id)`);
|
await dbRun(`CREATE INDEX IF NOT EXISTS idx_push_subs_pharm ON push_subscriptions_pharmacy(medicine_nregistro, pharmacy_id)`);
|
||||||
|
|
||||||
// Add is_admin to users table if not yet present (migration for existing DBs)
|
if (!pgPool) {
|
||||||
|
// SQLite-only migrations for existing deployments
|
||||||
try { await dbRun('ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0'); } catch {}
|
try { await dbRun('ALTER TABLE users ADD COLUMN is_admin INTEGER NOT NULL DEFAULT 0'); } catch {}
|
||||||
|
|
||||||
// Profile fields on users (migration for existing DBs)
|
|
||||||
try { await dbRun('ALTER TABLE users ADD COLUMN address TEXT'); } catch {}
|
try { await dbRun('ALTER TABLE users ADD COLUMN address TEXT'); } catch {}
|
||||||
try { await dbRun('ALTER TABLE users ADD COLUMN latitude REAL'); } catch {}
|
try { await dbRun('ALTER TABLE users ADD COLUMN latitude REAL'); } catch {}
|
||||||
try { await dbRun('ALTER TABLE users ADD COLUMN longitude REAL'); } catch {}
|
try { await dbRun('ALTER TABLE users ADD COLUMN longitude REAL'); } catch {}
|
||||||
|
}
|
||||||
|
|
||||||
// Add user_id to push tables if not yet present (migration for existing DBs)
|
// Add user_id to push tables (SQLite — no cross-DB FK)
|
||||||
try { await dbRun('ALTER TABLE push_subscriptions ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE SET NULL'); } catch {}
|
try { await dbRun('ALTER TABLE push_subscriptions ADD COLUMN user_id INTEGER'); } catch {}
|
||||||
try { await dbRun('ALTER TABLE push_subscriptions_pharmacy ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE SET NULL'); } catch {}
|
try { await dbRun('ALTER TABLE push_subscriptions_pharmacy ADD COLUMN user_id INTEGER'); } catch {}
|
||||||
|
|
||||||
console.log('Database initialized successfully');
|
console.log('Database initialized successfully');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -323,7 +373,7 @@ app.post('/api/auth/login', loginLimiter, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Find user by username
|
// Find user by username
|
||||||
const user = await dbGet(
|
const user = await userDbGet(
|
||||||
'SELECT * FROM users WHERE username = ?',
|
'SELECT * FROM users WHERE username = ?',
|
||||||
[username.trim()]
|
[username.trim()]
|
||||||
);
|
);
|
||||||
@@ -375,14 +425,14 @@ app.post('/api/auth/register', registerLimiter, async (req, res) => {
|
|||||||
return res.status(400).json({ error: 'Password must be at least 8 characters' });
|
return res.status(400).json({ error: 'Password must be at least 8 characters' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const existing = await dbGet('SELECT id FROM users WHERE username = ?', [u]);
|
const existing = await userDbGet('SELECT id FROM users WHERE username = ?', [u]);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return res.status(409).json({ error: 'Username already taken' });
|
return res.status(409).json({ error: 'Username already taken' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const passwordHash = await bcrypt.hash(p, 10);
|
const passwordHash = await bcrypt.hash(p, 10);
|
||||||
const result = await dbRun(
|
const result = await userDbRun(
|
||||||
'INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, 0)',
|
'INSERT INTO users (username, password_hash, is_admin) VALUES (?, ?, 0) RETURNING id',
|
||||||
[u, passwordHash]
|
[u, passwordHash]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -422,7 +472,7 @@ app.post('/api/auth/logout', (req, res) => {
|
|||||||
app.get('/api/auth/check', async (req, res) => {
|
app.get('/api/auth/check', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
if (req.session && req.session.userId) {
|
if (req.session && req.session.userId) {
|
||||||
const user = await dbGet(
|
const user = await userDbGet(
|
||||||
'SELECT id, username, is_admin, address, latitude, longitude FROM users WHERE id = ?',
|
'SELECT id, username, is_admin, address, latitude, longitude FROM users WHERE id = ?',
|
||||||
[req.session.userId]
|
[req.session.userId]
|
||||||
);
|
);
|
||||||
@@ -452,7 +502,7 @@ app.get('/api/auth/check', async (req, res) => {
|
|||||||
// Current user's profile
|
// Current user's profile
|
||||||
app.get('/api/users/me', requireAuth, async (req, res) => {
|
app.get('/api/users/me', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const user = await dbGet(
|
const user = await userDbGet(
|
||||||
'SELECT id, username, is_admin, address, latitude, longitude FROM users WHERE id = ?',
|
'SELECT id, username, is_admin, address, latitude, longitude FROM users WHERE id = ?',
|
||||||
[req.session.userId]
|
[req.session.userId]
|
||||||
);
|
);
|
||||||
@@ -495,12 +545,12 @@ app.put('/api/users/me', requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await dbRun(
|
await userDbRun(
|
||||||
'UPDATE users SET address = ?, latitude = ?, longitude = ? WHERE id = ?',
|
'UPDATE users SET address = ?, latitude = ?, longitude = ? WHERE id = ?',
|
||||||
[addr, lat, lon, req.session.userId]
|
[addr, lat, lon, req.session.userId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const user = await dbGet(
|
const user = await userDbGet(
|
||||||
'SELECT id, username, is_admin, address, latitude, longitude FROM users WHERE id = ?',
|
'SELECT id, username, is_admin, address, latitude, longitude FROM users WHERE id = ?',
|
||||||
[req.session.userId]
|
[req.session.userId]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,16 @@ services:
|
|||||||
image: redis:alpine
|
image: redis:alpine
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: farmafinder
|
||||||
|
POSTGRES_USER: farmafinder
|
||||||
|
POSTGRES_PASSWORD: ${PG_PASSWORD:-change-me-in-production}
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
image: git.hacecalor.net/ichitux/farmafinder-backend:latest
|
image: git.hacecalor.net/ichitux/farmafinder-backend:latest
|
||||||
build:
|
build:
|
||||||
@@ -21,10 +31,12 @@ services:
|
|||||||
REDIS_PASSWORD: ${REDIS_PASSWORD:-}
|
REDIS_PASSWORD: ${REDIS_PASSWORD:-}
|
||||||
DATABASE_PATH: /app/data/database.sqlite
|
DATABASE_PATH: /app/data/database.sqlite
|
||||||
FARMACIAS_WEBHOOK_URL: ${FARMACIAS_WEBHOOK_URL:-}
|
FARMACIAS_WEBHOOK_URL: ${FARMACIAS_WEBHOOK_URL:-}
|
||||||
|
PG_URL: postgresql://farmafinder:${PG_PASSWORD:-change-me-in-production}@postgres:5432/farmafinder
|
||||||
volumes:
|
volumes:
|
||||||
- backend_data:/app/data
|
- backend_data:/app/data
|
||||||
depends_on:
|
depends_on:
|
||||||
- redis
|
- redis
|
||||||
|
- postgres
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
image: git.hacecalor.net/ichitux/farmafinder-frontend:latest
|
image: git.hacecalor.net/ichitux/farmafinder-frontend:latest
|
||||||
@@ -37,3 +49,4 @@ services:
|
|||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
backend_data:
|
backend_data:
|
||||||
|
postgres_data:
|
||||||
|
|||||||
Reference in New Issue
Block a user