feat(profil): eigenes Profilbild hochladen
Jeder setzt sein Bild unter "Mein Konto", Vorgesetzte und Administratoren auch fuer die Benutzer, die sie verwalten. Wo bisher die Initialen standen, steht jetzt das Bild - Seitenleiste, Kopfzeile, Team, Dashboard, Reports, Projekte, Kunden und die Buchungsliste. Ohne Bild bleiben die Initialen. Der Browser verkleinert das Bild vor dem Hochladen auf ein Quadrat von 256 px und schneidet mittig zu (lib/imageCompress.ts): aus einem 4-MB-Foto werden wenige Kilobyte, und der Server muss kein Bild verarbeiten. Gespeichert wird es als Data-URL in pulse_users.avatar (LONGTEXT). Kein Upload-Verzeichnis, das gesichert und beim Deployment am Leben gehalten werden muesste. Geprueft wird serverseitig in lib/avatar.js: nur PNG, JPEG, GIF, WebP und AVIF als Data-URL, hoechstens 2 MB. SVG ist bewusst nicht erlaubt, es kann Skripte tragen. Externe http(s)-Adressen ebenso wenig - sonst laedt jeder Seitenaufruf ein Bild von einem fremden Server und verraet, wer wann mit Pulse arbeitet. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
2d3d843988
commit
9576d18abc
@@ -40,6 +40,7 @@ export async function initDatabase() {
|
||||
}
|
||||
|
||||
await migrateAbrufType();
|
||||
await migrateAvatarColumn();
|
||||
await ensureAdminUser();
|
||||
await ensureInternalCustomer();
|
||||
}
|
||||
@@ -69,6 +70,21 @@ async function migrateAbrufType() {
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Profilbild am Benutzer. Steht als Data-URL in der Spalte - siehe
|
||||
* lib/avatar.js, warum kein Dateisystem.
|
||||
*/
|
||||
async function migrateAvatarColumn() {
|
||||
const [columns] = await pool.query(
|
||||
`SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'pulse_users' AND COLUMN_NAME = 'avatar'`
|
||||
);
|
||||
if (!columns.length) {
|
||||
await pool.query('ALTER TABLE pulse_users ADD COLUMN avatar LONGTEXT NULL AFTER color');
|
||||
console.log('[Pulse] Spalte pulse_users.avatar ergaenzt.');
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureAdminUser() {
|
||||
const [rows] = await pool.query('SELECT COUNT(*) AS n FROM pulse_users');
|
||||
if (rows[0].n > 0) return;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Profilbild eines Benutzers.
|
||||
*
|
||||
* Das Bild liegt als Data-URL in der Datenbank (pulse_users.avatar, LONGTEXT).
|
||||
* Kein Dateisystem: der Dienst laeuft als eigener Benutzer in /var/www/pulse,
|
||||
* ein Upload-Verzeichnis muesste gesichert, gesichert-gesichert und beim Deploy
|
||||
* am Leben gehalten werden. Die Bilder sind nach dem Verkleinern im Browser
|
||||
* wenige Kilobyte gross, das traegt eine Spalte problemlos.
|
||||
*
|
||||
* Erlaubt sind nur Bild-Data-URLs. Eine externe http(s)-Adresse waere bequem,
|
||||
* wuerde aber bedeuten, dass jeder Seitenaufruf ein Bild von einem fremden
|
||||
* Server nachlaedt - das gibt die Benutzung von Pulse nach draussen preis.
|
||||
*/
|
||||
|
||||
/** Data-URL eines Bildes. SVG ist bewusst nicht dabei: es kann Skripte tragen. */
|
||||
const AVATAR_RE = /^data:image\/(png|jpe?g|gif|webp|avif);base64,[A-Za-z0-9+/=\s]+$/i;
|
||||
|
||||
/**
|
||||
* Obergrenze der Data-URL. Das JSON-Limit der Anwendung ist 4 MB; hier bleibt
|
||||
* bewusst Luft fuer den Rest des Requests.
|
||||
*/
|
||||
export const AVATAR_MAX_LENGTH = 2_000_000;
|
||||
|
||||
/** Prueft ein Profilbild; null bzw. '' entfernt es. */
|
||||
export function normalizeAvatar(value) {
|
||||
if (value === null || value === undefined || String(value).trim() === '') return { value: null };
|
||||
const avatar = String(value).trim();
|
||||
if (avatar.length > AVATAR_MAX_LENGTH) {
|
||||
return { error: 'Das Bild ist zu groß. Bitte ein kleineres verwenden.' };
|
||||
}
|
||||
if (!AVATAR_RE.test(avatar)) {
|
||||
return { error: 'Ungültiges Bild (erwartet PNG, JPEG, GIF, WebP oder AVIF).' };
|
||||
}
|
||||
return { value: avatar };
|
||||
}
|
||||
@@ -43,7 +43,7 @@ export async function loadEntries(settings, { from, to, userIds = null, customer
|
||||
const [rows] = await pool.query(
|
||||
`SELECT e.id, e.user_id, e.project_id, e.task_id, e.pot_id, e.entry_date, e.minutes, e.note, e.billable,
|
||||
e.created_at, e.updated_at,
|
||||
u.name AS user_name, u.color AS user_color, u.is_external, u.company AS user_company, u.weekly_hours,
|
||||
u.name AS user_name, u.color AS user_color, u.avatar AS user_avatar, u.is_external, u.company AS user_company, u.weekly_hours,
|
||||
u.manager_id,
|
||||
p.name AS project_name, p.code AS project_code, p.type AS project_type, p.billing_unit,
|
||||
p.rate AS project_rate,
|
||||
|
||||
@@ -186,7 +186,7 @@ export async function loadProjects(settings, { where = '1=1', params = [] } = {}
|
||||
pool.query(`SELECT * FROM pulse_budget_pots WHERE project_id IN (${inList}) ORDER BY valid_from, id`, ids),
|
||||
pool.query(`SELECT * FROM pulse_tasks WHERE project_id IN (${inList}) ORDER BY name`, ids),
|
||||
pool.query(
|
||||
`SELECT m.project_id, u.id, u.name, u.color, u.is_external
|
||||
`SELECT m.project_id, u.id, u.name, u.color, u.avatar, u.is_external
|
||||
FROM pulse_project_members m JOIN pulse_users u ON u.id = m.user_id
|
||||
WHERE m.project_id IN (${inList}) AND u.role <> 'admin' ORDER BY u.name`,
|
||||
ids
|
||||
@@ -242,7 +242,7 @@ export async function loadProjects(settings, { where = '1=1', params = [] } = {}
|
||||
.map((t) => ({ ...t, billable: Boolean(t.billable), is_active: Boolean(t.is_active), used_minutes: taskMinutes.get(t.id) || 0 })),
|
||||
members: members
|
||||
.filter((m) => m.project_id === p.id)
|
||||
.map((m) => ({ id: m.id, name: m.name, color: m.color, is_external: Boolean(m.is_external) })),
|
||||
.map((m) => ({ id: m.id, name: m.name, color: m.color, avatar: m.avatar ?? null, is_external: Boolean(m.is_external) })),
|
||||
used_minutes: usedMinutes,
|
||||
billable_minutes: Number(usage?.billable_minutes || 0),
|
||||
// Was gegen das Budget zaehlt (bei Billable nur abrechenbare Stunden).
|
||||
|
||||
@@ -25,7 +25,7 @@ export function verifyShort(token) {
|
||||
}
|
||||
|
||||
export const USER_COLUMNS = `id, username, email, name, role, manager_id, job_title, is_external, company,
|
||||
source, external_id, weekly_hours, color, theme, theme_mode, is_active, last_login_at, created_at, updated_at`;
|
||||
source, external_id, weekly_hours, color, avatar, theme, theme_mode, is_active, last_login_at, created_at, updated_at`;
|
||||
|
||||
/**
|
||||
* Prueft das Bearer-Token und laedt den Benutzer bei jeder Anfrage frisch aus
|
||||
@@ -84,6 +84,7 @@ export function publicUser(row) {
|
||||
source: row.source,
|
||||
weekly_hours: row.weekly_hours == null ? null : Number(row.weekly_hours),
|
||||
color: row.color ?? null,
|
||||
avatar: row.avatar ?? null,
|
||||
theme: row.theme ?? null,
|
||||
theme_mode: row.theme_mode ?? null,
|
||||
is_active: Boolean(row.is_active),
|
||||
|
||||
@@ -5,6 +5,7 @@ import { pool } from '../db.js';
|
||||
import { publicUser, requireAuth, signShort, signToken, verifyShort } from '../middleware/auth.js';
|
||||
import { getSettings } from '../lib/settings.js';
|
||||
import { authorizeUrl, exchangeCode, ssoConfigured } from '../lib/sso.js';
|
||||
import { normalizeAvatar } from '../lib/avatar.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -70,7 +71,7 @@ router.post('/login', async (req, res, next) => {
|
||||
|
||||
router.get('/me', requireAuth, (req, res) => res.json({ user: publicUser(req.user) }));
|
||||
|
||||
// Eigene Darstellung: Design, Modus, Farbe.
|
||||
// Eigene Darstellung: Design, Modus, Farbe, Profilbild.
|
||||
router.patch('/me', requireAuth, async (req, res, next) => {
|
||||
const updates = {};
|
||||
if (req.body?.theme !== undefined) {
|
||||
@@ -90,6 +91,11 @@ router.patch('/me', requireAuth, async (req, res, next) => {
|
||||
if (color !== null && !/^#[0-9a-f]{6}$/i.test(color)) return res.status(400).json({ error: 'Ungültige Farbe.' });
|
||||
updates.color = color;
|
||||
}
|
||||
if (req.body?.avatar !== undefined) {
|
||||
const avatar = normalizeAvatar(req.body.avatar);
|
||||
if (avatar.error) return res.status(400).json({ error: avatar.error });
|
||||
updates.avatar = avatar.value;
|
||||
}
|
||||
const keys = Object.keys(updates);
|
||||
if (keys.length === 0) return res.status(400).json({ error: 'Keine Änderungen übergeben.' });
|
||||
try {
|
||||
|
||||
@@ -61,7 +61,7 @@ router.get('/', async (req, res, next) => {
|
||||
|
||||
const filter = userFilter(userIds, 'id');
|
||||
const [users] = await pool.query(
|
||||
`SELECT id, name, color, role, weekly_hours, is_external, company, job_title FROM pulse_users
|
||||
`SELECT id, name, color, avatar, role, weekly_hours, is_external, company, job_title FROM pulse_users
|
||||
WHERE is_active = 1 AND role <> 'admin' AND ${filter.sql} ORDER BY name`,
|
||||
filter.params
|
||||
);
|
||||
@@ -86,6 +86,7 @@ router.get('/', async (req, res, next) => {
|
||||
user_id: u.id,
|
||||
name: u.name,
|
||||
color: u.color,
|
||||
avatar: u.avatar ?? null,
|
||||
job_title: u.job_title,
|
||||
is_external: Boolean(u.is_external),
|
||||
...totals,
|
||||
@@ -127,6 +128,7 @@ router.get('/', async (req, res, next) => {
|
||||
user_id: e.user_id,
|
||||
user_name: e.user_name,
|
||||
user_color: e.user_color,
|
||||
user_avatar: e.user_avatar ?? null,
|
||||
project_display: e.project_display,
|
||||
customer_color: e.customer_color,
|
||||
task_name: e.task_name,
|
||||
|
||||
@@ -14,6 +14,7 @@ function publicEntry(entry, showAmounts) {
|
||||
id: entry.id,
|
||||
user_id: entry.user_id,
|
||||
user_name: entry.user_name,
|
||||
user_avatar: entry.user_avatar ?? null,
|
||||
project_id: entry.project_id,
|
||||
project_name: entry.project_name,
|
||||
project_code: entry.project_code,
|
||||
|
||||
@@ -50,7 +50,7 @@ export async function buildTimeReport(viewer, query) {
|
||||
|
||||
const filter = userFilter(userIds, 'id');
|
||||
const [users] = await pool.query(
|
||||
`SELECT id, name, color, weekly_hours, is_external, company, is_active FROM pulse_users WHERE role <> 'admin' AND ${filter.sql} ORDER BY name`,
|
||||
`SELECT id, name, color, avatar, weekly_hours, is_external, company, is_active FROM pulse_users WHERE role <> 'admin' AND ${filter.sql} ORDER BY name`,
|
||||
filter.params
|
||||
);
|
||||
const capTo = to > today() ? today() : to;
|
||||
@@ -60,12 +60,12 @@ export async function buildTimeReport(viewer, query) {
|
||||
const amount = entries.reduce((s, e) => s + e.amount, 0);
|
||||
const fixedAmount = entries.reduce((s, e) => s + e.fixed_amount, 0);
|
||||
|
||||
const userRows = group(entries, (e) => e.user_id, (e) => ({ user_id: e.user_id, name: e.user_name, color: e.user_color, is_external: e.is_external }), includeFixed);
|
||||
const userRows = group(entries, (e) => e.user_id, (e) => ({ user_id: e.user_id, name: e.user_name, color: e.user_color, avatar: e.user_avatar ?? null, is_external: e.is_external }), includeFixed);
|
||||
// Mitarbeiter ohne Buchung trotzdem zeigen (nur aktive, nicht bei Kunden-/Projektfilter).
|
||||
if (!query.customer_id && !query.project_id) {
|
||||
for (const u of users) {
|
||||
if (u.is_active && !userRows.some((row) => row.user_id === u.id)) {
|
||||
userRows.push({ key: u.id, user_id: u.id, name: u.name, color: u.color, is_external: Boolean(u.is_external), minutes: 0, billable_minutes: 0, non_billable_minutes: 0, amount: 0 });
|
||||
userRows.push({ key: u.id, user_id: u.id, name: u.name, color: u.color, avatar: u.avatar ?? null, is_external: Boolean(u.is_external), minutes: 0, billable_minutes: 0, non_billable_minutes: 0, amount: 0 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import bcrypt from 'bcryptjs';
|
||||
import { pool } from '../db.js';
|
||||
import { ROLES, USER_COLUMNS, publicUser, requireRole } from '../middleware/auth.js';
|
||||
import { visibleUserIds } from '../lib/access.js';
|
||||
import { normalizeAvatar } from '../lib/avatar.js';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -81,6 +82,11 @@ function normalize(body, { creating }) {
|
||||
if (body.is_external !== undefined) out.is_external = body.is_external ? 1 : 0;
|
||||
if (body.company !== undefined) out.company = str(body.company, 160);
|
||||
if (body.color !== undefined) out.color = /^#[0-9a-f]{6}$/i.test(body.color || '') ? body.color : null;
|
||||
if (body.avatar !== undefined) {
|
||||
const avatar = normalizeAvatar(body.avatar);
|
||||
if (avatar.error) errors.push(avatar.error);
|
||||
else out.avatar = avatar.value;
|
||||
}
|
||||
if (body.is_active !== undefined) out.is_active = body.is_active ? 1 : 0;
|
||||
if (body.weekly_hours !== undefined) {
|
||||
out.weekly_hours = body.weekly_hours === '' || body.weekly_hours == null ? null : Number(body.weekly_hours);
|
||||
@@ -93,7 +99,7 @@ function normalize(body, { creating }) {
|
||||
router.get('/directory', async (req, res, next) => {
|
||||
try {
|
||||
const [rows] = await pool.query(
|
||||
'SELECT id, name, color, role, manager_id, is_external, company, job_title, is_active FROM pulse_users ORDER BY name'
|
||||
'SELECT id, name, color, avatar, role, manager_id, is_external, company, job_title, is_active FROM pulse_users ORDER BY name'
|
||||
);
|
||||
res.json(rows.map((r) => ({ ...r, is_external: Boolean(r.is_external), is_active: Boolean(r.is_active) })));
|
||||
} catch (err) {
|
||||
|
||||
@@ -16,6 +16,7 @@ CREATE TABLE IF NOT EXISTS pulse_users (
|
||||
external_id VARCHAR(64) NULL,
|
||||
weekly_hours DECIMAL(5,2) NULL,
|
||||
color VARCHAR(7) NULL,
|
||||
avatar LONGTEXT NULL,
|
||||
theme VARCHAR(20) NULL,
|
||||
theme_mode VARCHAR(10) NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
|
||||
Reference in New Issue
Block a user