feat(cropper): save with the session token — no OTP, domain from a2loft
Consumes the a2loft cropper-token-auth change: the save now sends the
session's PocketBase token as a bearer credential and no phone/otp. The
backend authorizes with OpenFGA crop_image (domain:<code> -> domain:idid),
so an operator no longer needs super-user rights in the profile's domain —
which is what made saves fail with a blanket 'Invalid OTP specified.'.
- the save's domain comes from a2loft's own record of the picture
(dims/picture/{uploadId} -> domainCode), falling back to the queue label,
guarded by a path-safety pattern instead of the catalog check
- the 'core' -> 'uc' alias is deleted: 'core' is a2loft's own pseudo-domain
for a profile picture (special-cased there), so rewriting it changed the
attribution and the ID-card notification
- queue loads are enriched with the profile's domainCode; the panel shows
the profile's domain and, when it differs, the picture target
- the cropper's OTP field, state and parameters are gone; the fingerprint
wizard is untouched (its own OTP flow on its own endpoints)
- tests updated: bearer + no query auth, resolved domain wins, unsafe values
refused, no OTP field served (46 pass)
Requires the a2loft change deployed first.
This commit is contained in:
+36
-26
@@ -13,17 +13,11 @@ const require = createRequire(import.meta.url);
|
||||
|
||||
const QUEUE_NAME = 'id-images';
|
||||
/**
|
||||
* Queue domains that differ from their a2loft identity code.
|
||||
*
|
||||
* A queue item's `domain` comes from the Telegram intake: the operator tags a
|
||||
* photo (e.g. `#core`) and the bot stores that label verbatim, then translated
|
||||
* it before every a2loft call. a2loft has no `core` domain — `GET
|
||||
* {a2loft}/identity/core/units` answers "Invalid domain code specified." while
|
||||
* `uc` is valid — so the label must be mapped before the save is forwarded.
|
||||
* (The proper fix is on the intake side: enqueue the a2loft code, or register
|
||||
* `core` upstream; until then this keeps existing queue data working.)
|
||||
* Domains are forwarded as the picture record states them. The queue's label
|
||||
* (e.g. `core` from the Telegram intake) and the profile's identity domain
|
||||
* (`domainCode`, e.g. `ucth`) are both legitimate values there: a2loft
|
||||
* authorizes the actor with OpenFGA, not with the domain's uploader list.
|
||||
*/
|
||||
const DOMAIN_ALIASES = { core: 'uc' };
|
||||
const BUCKET = 'files';
|
||||
const REMOVEBG_URL = 'https://removebg.itcass.net/api/removebg';
|
||||
|
||||
@@ -138,6 +132,26 @@ export function createCropperRouter(cfg) {
|
||||
|
||||
const UPSTREAM_TIMEOUT_MS = 15000;
|
||||
const base = `${cfg.a2loftBase.replace(/\/+$/, '')}/identity`;
|
||||
|
||||
/**
|
||||
* The profile's identity domain as a2loft records it (`domainCode`), falling
|
||||
* back to the queue item's own label. Used for the save and for display.
|
||||
*/
|
||||
const resolvePictureDomain = async (uploadId, fallback) => {
|
||||
if (!uploadId) return fallback;
|
||||
try {
|
||||
const r = await fetchImpl(`${base}/dims/picture/${encodeURIComponent(uploadId)}`, {
|
||||
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
|
||||
});
|
||||
const body = await r.json().catch(() => null);
|
||||
return (body && body.ok && body.data && body.data.domainCode) || fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
|
||||
/** Path-safety only: a2loft decides which codes exist or are authorized. */
|
||||
const SAFE_DOMAIN = /^[A-Za-z0-9_-]+$/;
|
||||
const nodered = (path) => `${noderedBase.replace(/\/+$/, '')}${path}`;
|
||||
|
||||
const getMessage = () =>
|
||||
@@ -153,14 +167,6 @@ export function createCropperRouter(cfg) {
|
||||
)
|
||||
);
|
||||
|
||||
const requireOtp = (req, res) => {
|
||||
if (!req.query.otp) {
|
||||
res.status(400).json({ ok: false, message: 'OTP is required.' });
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
// ── pictures ────────────────────────────────────────────────────────────
|
||||
router.get('/picture/next', async (req, res) => {
|
||||
let message;
|
||||
@@ -178,6 +184,7 @@ export function createCropperRouter(cfg) {
|
||||
} catch {
|
||||
return res.json({ ok: false, message: 'Queue item is malformed.' });
|
||||
}
|
||||
data.domainCode = await resolvePictureDomain(data.upload_id, null);
|
||||
const { messageId, popReceipt, dequeueCount } = message;
|
||||
res.json({
|
||||
ok: true,
|
||||
@@ -193,14 +200,14 @@ export function createCropperRouter(cfg) {
|
||||
const body = await r.json().catch(() => null);
|
||||
const data = body && body.data;
|
||||
if (!r.ok || !data) return res.json({ ok: false, message: 'No upload with the specified id.' });
|
||||
const { upload_id, file_name, caption, domain, raw_file_id } = data;
|
||||
const { upload_id, file_name, caption, domain, raw_file_id, domainCode } = data;
|
||||
return res.json({
|
||||
ok: true,
|
||||
data: {
|
||||
message_id: 'custom',
|
||||
pop_receipt: 'custom',
|
||||
dequeue_count: 0,
|
||||
message: { upload_id, file_name, caption, domain, raw_file_id, from_queue: false },
|
||||
message: { upload_id, file_name, caption, domain, domainCode, raw_file_id, from_queue: false },
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -331,7 +338,6 @@ export function createCropperRouter(cfg) {
|
||||
// ── save (the one a2loft call that needs the operator OTP) ──────────────
|
||||
router.post('/profile/picture', async (req, res) => {
|
||||
if (!requireMessageBody(req, res)) return;
|
||||
if (!requireOtp(req, res)) return;
|
||||
let live;
|
||||
try {
|
||||
live = await cropPermission({
|
||||
@@ -360,18 +366,22 @@ export function createCropperRouter(cfg) {
|
||||
message: { upload_id, domain, agentDeviceHash } = {},
|
||||
},
|
||||
} = req.body;
|
||||
const urlDomain = DOMAIN_ALIASES[domain] || domain;
|
||||
if (!domainCodes.has(urlDomain)) {
|
||||
// The profile's own domain comes from a2loft's record, not the client.
|
||||
const urlDomain = await resolvePictureDomain(upload_id, domain);
|
||||
if (!SAFE_DOMAIN.test(urlDomain || '')) {
|
||||
return res.status(400).json({ ok: false, message: 'Unknown domain.' });
|
||||
}
|
||||
const q = new URLSearchParams({ phone: req.session.phone, otp: req.query.otp });
|
||||
try {
|
||||
const r = await fetchImpl(
|
||||
`${base}/${encodeURIComponent(urlDomain)}/profile/picture?${q.toString()}`,
|
||||
`${base}/${encodeURIComponent(urlDomain)}/profile/picture`,
|
||||
{
|
||||
signal: AbortSignal.timeout(UPSTREAM_TIMEOUT_MS),
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
// the session token proves the operator; a2loft authorizes with OpenFGA
|
||||
authorization: `Bearer ${req.session.token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
pictureDataUrl,
|
||||
phone,
|
||||
|
||||
Reference in New Issue
Block a user