feat: N1d/N2/N3 + قانون فلاتر وإعادة ضبط تطبيق السائق
الباك إند: - تعيين أول أدمن صار يُنشئ المستخدم إن لم يوجد — كان يشترط دخولاً سابقاً، وهي بيضة ودجاجة تمنع الدخول إلى لوحة أي مستأجر جديد أصلاً. - OTP_DEV_MODE كان يفشل مفتوحاً (`!== 'false'`): غياب المتغيّر أو خطأ مطبعي يترك الإنتاج برمز ثابت يفتح كل حساب، ويحرس السحب المالي كذلك. صار `=== 'true'` في الإعداد وفي قارئَيه، مع تحذير عند الإقلاع. - N2: التحقّق من اللوغو عند الرفع (PNG/JPEG · 512+ · مربّع · سقف 5MB) عبر قراءة الترويسة بلا اعتمادية — بدل اكتشاف أيقونة ممطوطة بعد النشر. N3: السكربت يولّد build_config.dart بأعلام const (طبقات docs/22 §1.5)، ويضبط bundle IDs واسم التطبيق والأيقونات/splash، و--build يشغّل Shorebird. فلاتر: - docs/23: قانون مُلزِم للتطبيقين (البنية · Cubit · طبقة الشبكة · الأعلام). - السائق: أُزيل Dart القديم (GetX) مع الإبقاء على الأصيل والإضافات وشهادات التوقيع وShorebird وFirebase الخاص به، وأُعيد هيكلته مطابقاً للراكب. - طبقة الشبكة ترسل x-device-id وتجدّد التوكن عند 401 مرة واحدة فقط. - الحزم موحّدة بين التطبيقين، والـAPI https حصراً في الاثنين. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
3ab74a13a8
commit
de4bbd00ac
@@ -0,0 +1,65 @@
|
||||
import { readImageMeta, validateLogo, LOGO_MIN_SIDE } from './image-meta';
|
||||
|
||||
/** يبني ترويسة PNG صالحة بأبعاد محدّدة (لا نحتاج بقية الملف للقراءة). */
|
||||
function png(width: number, height: number): Buffer {
|
||||
const buf = Buffer.alloc(24);
|
||||
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]).copy(buf, 0);
|
||||
buf.write('IHDR', 12, 'ascii');
|
||||
buf.writeUInt32BE(width, 16);
|
||||
buf.writeUInt32BE(height, 20);
|
||||
return buf;
|
||||
}
|
||||
|
||||
/** JPEG بجزء APP0 قبل SOF0 — يثبت أننا نتنقّل بالأطوال لا بموضع ثابت. */
|
||||
function jpeg(width: number, height: number): Buffer {
|
||||
const app0 = Buffer.alloc(4 + 14);
|
||||
app0.writeUInt16BE(0xffe0, 0);
|
||||
app0.writeUInt16BE(16, 2); // طول APP0
|
||||
|
||||
const sof = Buffer.alloc(11);
|
||||
sof.writeUInt16BE(0xffc0, 0);
|
||||
sof.writeUInt16BE(8, 2);
|
||||
sof.writeUInt8(8, 4);
|
||||
sof.writeUInt16BE(height, 5);
|
||||
sof.writeUInt16BE(width, 7);
|
||||
|
||||
return Buffer.concat([Buffer.from([0xff, 0xd8]), app0, sof]);
|
||||
}
|
||||
|
||||
describe('readImageMeta — قراءة الأبعاد بلا اعتمادية', () => {
|
||||
it('يقرأ أبعاد PNG', () => {
|
||||
expect(readImageMeta(png(1024, 1024))).toEqual({ format: 'png', width: 1024, height: 1024 });
|
||||
});
|
||||
|
||||
it('يقرأ أبعاد JPEG متجاوزاً الأجزاء التي تسبق SOF', () => {
|
||||
expect(readImageMeta(jpeg(800, 600))).toEqual({ format: 'jpeg', width: 800, height: 600 });
|
||||
});
|
||||
|
||||
it('يرفض ما ليس صورة', () => {
|
||||
expect(readImageMeta(Buffer.from('هذا ملف نصي لا صورة'))).toBeNull();
|
||||
});
|
||||
|
||||
it('لا يدور بلا نهاية على JPEG بطول معطوب', () => {
|
||||
// طول = 0 كان سيُبقي المؤشّر مكانه إلى الأبد.
|
||||
const bad = Buffer.concat([Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x00]), Buffer.alloc(40)]);
|
||||
expect(readImageMeta(bad)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateLogo — بوابة جودة الأيقونة (docs/22 N2)', () => {
|
||||
it('يقبل لوغو مربّعاً كبيراً', () => {
|
||||
expect(validateLogo(png(1024, 1024))).toBeNull();
|
||||
});
|
||||
|
||||
it('يرفض الصغير — الأيقونة ستخرج مشوّشة', () => {
|
||||
expect(validateLogo(png(LOGO_MIN_SIDE - 1, LOGO_MIN_SIDE - 1))).toContain('صغير');
|
||||
});
|
||||
|
||||
it('يرفض المستطيل — الأيقونة ستُمطّ', () => {
|
||||
expect(validateLogo(png(1024, 512))).toContain('غير مربّع');
|
||||
});
|
||||
|
||||
it('يتسامح مع انحراف بسيط عن التربيع', () => {
|
||||
expect(validateLogo(png(1024, 1000))).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* قراءة أبعاد الصورة ونوعها من ترويسة الملف — بلا أي اعتمادية (docs/22 — N2).
|
||||
*
|
||||
* لم نُدخل `sharp`: توليد الأيقونات نفسه شغل `flutter_launcher_icons` في
|
||||
* سكربت N3، والباك إند لا يحتاج إلا **التحقّق** قبل القبول. إدخال مكتبة
|
||||
* صور أصيلة (libvips) في صورة alpine ثمنٌ كبير مقابل قراءة ٢٤ بايت.
|
||||
*
|
||||
* السبب في وجود هذا أصلاً: لوغو صغير أو غير مربّع يُنتج أيقونة ممطوطة أو
|
||||
* مشوّشة في كل تطبيقات المستأجر — ويُكتشف بعد البناء والنشر لا قبله.
|
||||
*/
|
||||
|
||||
export interface ImageMeta {
|
||||
format: 'png' | 'jpeg';
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
/** يعيد null إن لم يكن الملف PNG أو JPEG صالحاً. */
|
||||
export function readImageMeta(buf: Buffer): ImageMeta | null {
|
||||
return readPng(buf) ?? readJpeg(buf);
|
||||
}
|
||||
|
||||
function readPng(buf: Buffer): ImageMeta | null {
|
||||
// توقيع PNG ثم قطعة IHDR: العرض والارتفاع عند 16 و20 (big-endian).
|
||||
const SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
if (buf.length < 24 || !buf.subarray(0, 8).equals(SIG)) return null;
|
||||
if (buf.subarray(12, 16).toString('ascii') !== 'IHDR') return null;
|
||||
return { format: 'png', width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
|
||||
}
|
||||
|
||||
function readJpeg(buf: Buffer): ImageMeta | null {
|
||||
if (buf.length < 4 || buf[0] !== 0xff || buf[1] !== 0xd8) return null;
|
||||
|
||||
// JPEG سلسلة أجزاء متغيّرة الطول؛ الأبعاد في جزء SOF ولا موضع ثابت لها،
|
||||
// فنتنقّل بالأطوال المعلنة حتى نصل SOFn.
|
||||
let i = 2;
|
||||
while (i + 9 < buf.length) {
|
||||
if (buf[i] !== 0xff) return null; // خرجنا عن المحاذاة = ملف تالف
|
||||
const marker = buf[i + 1];
|
||||
const len = buf.readUInt16BE(i + 2);
|
||||
if (len < 2) return null; // طول معطوب: بلا هذا الفحص تصير الحلقة لانهائية
|
||||
|
||||
// SOF0..SOF15 عدا DHT(c4) وJPG(c8) وDAC(cc) — هذه ليست أجزاء إطار.
|
||||
const isSof = marker >= 0xc0 && marker <= 0xcf &&
|
||||
marker !== 0xc4 && marker !== 0xc8 && marker !== 0xcc;
|
||||
if (isSof) {
|
||||
return { format: 'jpeg', height: buf.readUInt16BE(i + 5), width: buf.readUInt16BE(i + 7) };
|
||||
}
|
||||
i += 2 + len;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** الحدّ الأدنى لأيقونة تطبيق: أندرويد يحتاج 512 لمتجر Play، وiOS 1024. */
|
||||
export const LOGO_MIN_SIDE = 512;
|
||||
|
||||
/**
|
||||
* يتحقّق من صلاحية اللوغو كمصدر للأيقونات ويعيد رسالة الخطأ أو null.
|
||||
* نسمح بانحراف بسيط عن التربيع (٥٪) لأن التصدير اليدوي نادراً ما يكون مضبوطاً
|
||||
* بالبكسل، لكن شعاراً مستطيلاً فعلاً يجب أن يُرفض لا أن يُمطّ.
|
||||
*/
|
||||
export function validateLogo(buf: Buffer): string | null {
|
||||
const meta = readImageMeta(buf);
|
||||
if (!meta) return 'الملف ليس PNG أو JPEG صالحاً';
|
||||
if (meta.width < LOGO_MIN_SIDE || meta.height < LOGO_MIN_SIDE) {
|
||||
return `اللوغو صغير: ${meta.width}×${meta.height} — الحدّ الأدنى ${LOGO_MIN_SIDE}×${LOGO_MIN_SIDE}`;
|
||||
}
|
||||
const ratio = meta.width / meta.height;
|
||||
if (ratio < 0.95 || ratio > 1.05) {
|
||||
return `اللوغو غير مربّع: ${meta.width}×${meta.height} — الأيقونة ستُمطّ`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -35,9 +35,15 @@ export default () => ({
|
||||
},
|
||||
|
||||
auth: {
|
||||
// وضع تطوير: رمز OTP ثابت 0000 يُطبع باللوغ بلا مزوّد SMS (راجع docs/07).
|
||||
// للإنتاج: OTP_DEV_MODE=false + ربط محوّل SMS في P2.
|
||||
otpDevMode: process.env.OTP_DEV_MODE !== 'false',
|
||||
/**
|
||||
* وضع تطوير: رمز OTP ثابت `1234` يُطبع باللوغ بلا مناداة مزوّد (docs/07).
|
||||
*
|
||||
* **يفشل مُغلقاً عمداً**: لا يُفتح إلا بـ`OTP_DEV_MODE=true` حرفياً. كان
|
||||
* `!== 'false'` أي أن غياب المتغيّر — أو خطأً مطبعياً فيه، أو `OTP_DEV_MODE=0`
|
||||
* (ونصّ `'0'` ليس `'false'`) — يترك الإنتاج برمز ثابت يفتح **كل حساب في
|
||||
* المنصة**. الافتراض الآمن أن نرسل رسالة حقيقية، لا أن نفتح الباب.
|
||||
*/
|
||||
otpDevMode: process.env.OTP_DEV_MODE === 'true',
|
||||
otpTtl: parseInt(process.env.OTP_TTL ?? '300', 10),
|
||||
otpLength: parseInt(process.env.OTP_LENGTH ?? '4', 10),
|
||||
// ربط الجلسة بالجهاز (docs/17 — D2). مطفأ حتى يرسل فلاتر x-device-id؛
|
||||
|
||||
@@ -72,6 +72,12 @@ async function bootstrap() {
|
||||
if (!process.env.PLATFORM_SECRET) {
|
||||
Logger.warn('PLATFORM_SECRET غير مضبوط — كل نقاط السوبر-أدمن مغلقة.', 'Security');
|
||||
}
|
||||
if (cfg.get<boolean>('auth.otpDevMode') === true) {
|
||||
Logger.warn(
|
||||
'OTP_DEV_MODE=true — رمز الدخول ثابت (1234) ولا تُرسَل رسائل، ورمز السحب كذلك. أطفئه في الإنتاج.',
|
||||
'Security',
|
||||
);
|
||||
}
|
||||
if (cfg.get<boolean>('payments.requireSignature') !== true) {
|
||||
Logger.warn(
|
||||
'PAYMENTS_REQUIRE_SIGNATURE=false — العمليات المالية غير موقَّعة. فعّله بعد أن يوقّع تطبيق فلاتر (docs/17 I6).',
|
||||
|
||||
@@ -44,8 +44,10 @@ export class AuthService {
|
||||
return tenant;
|
||||
}
|
||||
|
||||
// `=== true` لا `!== false`: قيمة مفقودة يجب أن تعني إرسالاً حقيقياً، لا
|
||||
// رمزاً ثابتاً يفتح كل الحسابات (نفس منطق الإعداد في configuration.ts).
|
||||
private get devMode(): boolean {
|
||||
return this.config.get<boolean>('auth.otpDevMode') !== false;
|
||||
return this.config.get<boolean>('auth.otpDevMode') === true;
|
||||
}
|
||||
|
||||
private otpKey(tenantId: string, phone: string): string {
|
||||
|
||||
@@ -63,8 +63,10 @@ export class PayoutsService {
|
||||
return `payout:otp:${payoutId}`;
|
||||
}
|
||||
|
||||
// `=== true` لا `!== false`: هنا الرمز يحرس **سحب المال**؛ قيمة مفقودة كانت
|
||||
// تعني رمزاً ثابتاً يكفي لسحب رصيد أي سائق.
|
||||
private get devMode(): boolean {
|
||||
return this.config.get<boolean>('auth.otpDevMode') !== false;
|
||||
return this.config.get<boolean>('auth.otpDevMode') === true;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
@@ -19,6 +20,7 @@ import { FEATURES } from '../../common/entitlements/features';
|
||||
import { PAYMENT_METHODS } from '../../common/entitlements/payment-methods';
|
||||
import { PlatformGuard } from '../../common/platform/platform.guard';
|
||||
import { StorageService } from '../../common/storage/storage.service';
|
||||
import { readImageMeta, validateLogo } from '../../common/storage/image-meta';
|
||||
|
||||
@ApiTags('tenants')
|
||||
@Controller()
|
||||
@@ -142,14 +144,28 @@ export class TenantsController {
|
||||
return this.tenants.setPaymentMethods(id, methods);
|
||||
}
|
||||
|
||||
/** رفع لوغو المستأجر (docs/22 — N2) → يُخزَّن ويُربط في الهوية البصرية. */
|
||||
/**
|
||||
* رفع لوغو المستأجر (docs/22 — N2) → يُخزَّن ويُربط في الهوية البصرية.
|
||||
*
|
||||
* يُتحقَّق منه هنا لا عند البناء: لوغو صغير أو مستطيل يُنتج أيقونة ممطوطة
|
||||
* في **كل** تطبيقات المستأجر، ولا يُكتشف إلا بعد بناءٍ ونشرٍ كاملين.
|
||||
* الحدّ 5MB يمنع رفعاً غير محدود يملأ قرص السيرفر (وهو مزدحم أصلاً).
|
||||
*/
|
||||
@ApiSecurity('x-platform-secret')
|
||||
@UseGuards(PlatformGuard)
|
||||
@Post('admin/tenants/:id/logo')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 5 * 1024 * 1024 } }))
|
||||
async logo(@Param('id') id: string, @UploadedFile() file: any) {
|
||||
const key = await this.storage.save(id, 'branding', file.buffer, file.originalname);
|
||||
return this.tenants.setBranding(id, { logo_key: key });
|
||||
if (!file?.buffer) throw new BadRequestException('لا ملف مرفوع (الحقل: file)');
|
||||
const problem = validateLogo(file.buffer);
|
||||
if (problem) throw new BadRequestException(problem);
|
||||
|
||||
const meta = readImageMeta(file.buffer)!;
|
||||
// نوحّد الامتداد من المحتوى لا من الاسم: `logo.png` قد يكون JPEG فعلياً،
|
||||
// فتُرسَل ترويسة Content-Type خاطئة من نقطة اللوغو العامة.
|
||||
const key = await this.storage.save(id, 'branding', file.buffer, `logo.${meta.format === 'png' ? 'png' : 'jpg'}`);
|
||||
const tenant = await this.tenants.setBranding(id, { logo_key: key });
|
||||
return { ...tenant, logo: { ...meta, url: `/api/tenant/logo/${tenant.slug}` } };
|
||||
}
|
||||
|
||||
/** الاستحقاقات الفعّالة كما يراها الحارس — للتشخيص ولعرضها في اللوحة. */
|
||||
|
||||
@@ -57,20 +57,33 @@ export class AdminUsersController {
|
||||
return this.users.findById(user.tenantId, id);
|
||||
}
|
||||
|
||||
// تمهيد: تعيين أول أدمن قبل وجود أدمن — سوبر-أدمن عبر PlatformGuard.
|
||||
/**
|
||||
* تمهيد: تعيين أول أدمن قبل وجود أدمن — سوبر-أدمن عبر PlatformGuard.
|
||||
*
|
||||
* **يُنشئ المستخدم إن لم يكن موجوداً** (docs/22 — N1d). كان يشترط أن يكون
|
||||
* صاحب الرقم سجّل دخوله مرة أولاً، وهذه بيضة ودجاجة: المستأجر المزوَّد
|
||||
* حديثاً بلا مستخدمين إطلاقاً، فلا سبيل لصنع أول أدمن — أي أن اللوحة
|
||||
* المسلَّمة للمستأجر لا يمكن الدخول إليها أصلاً.
|
||||
*
|
||||
* الإنشاء هنا آمن: النقطة خلف سرّ المنصة، والحساب المُنشأ بلا كلمة مرور
|
||||
* (المصادقة هاتف+OTP) فلا يملكه أحد إلا صاحب الرقم فعلياً.
|
||||
*/
|
||||
@ApiSecurity('x-platform-secret')
|
||||
@UseGuards(PlatformGuard)
|
||||
@Post('platform/users/role')
|
||||
async bootstrap(@Body() body: { tenantSlug: string; phone: string; role: string }) {
|
||||
if (!ROLES.includes(body.role)) throw new BadRequestException('invalid role');
|
||||
if (!body.phone) throw new BadRequestException('phone is required');
|
||||
const tenant = await this.tenants.resolve(body.tenantSlug);
|
||||
if (!tenant) throw new NotFoundException('unknown tenant');
|
||||
// المستخدم مخزَّن برقمه المطبَّع (docs/17 — D1) — بحثٌ بالرقم الخام
|
||||
// يفشل لو كتبه الأدمن بصيغة مختلفة عن التي دخل بها المستخدم أول مرة.
|
||||
const canonical = this.phones.normalize(body.phone, tenant.countryPack);
|
||||
const u = await this.users.findByPhone(tenant.id, canonical);
|
||||
if (!u) throw new NotFoundException('user not found (must log in once first)');
|
||||
await this.users.setRole(tenant.id, u.id, body.role);
|
||||
return this.users.findById(tenant.id, u.id);
|
||||
|
||||
const existing = await this.users.findByPhone(tenant.id, canonical);
|
||||
const u = existing ?? (await this.users.create(tenant.id, canonical, body.role));
|
||||
if (existing) await this.users.setRole(tenant.id, existing.id, body.role);
|
||||
|
||||
return { ...(await this.users.findById(tenant.id, u.id)), created: !existing };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user