Files
apxtri/models/Pagans.js
T
2026-09-26 21:42:06 +00:00

760 lines
31 KiB
JavaScript
Executable File

const fs = require("fs-extra");
const path = require("path");
const Mustache = require("mustache");
const dayjs = require("dayjs");
const openpgp = require("openpgp");
const Notifications = require("./Notifications.js");
const Odmdb = require("./Odmdb.js");
const DATA_PATH = process.env.DATAPATH;
const NODE_PATH = process.env.NODEPATH;
const TRIBE_NAME = process.env.TOWN;
const log = process.env.ACTIVELOG?.split(',').includes('Pagans');
/**
* Manages pagan identities, including key recovery, authentication tokens, and signatures.
*/
class Pagans {
/**
* Removes an authentication token file after logout.
* This logic MUST match the token creation logic in 'isAuthenticated.js'.
*/
static async logout(header) {
const { xalias, xtribe, xdays, xhash } = header;
if (!xalias || !xtribe || !xdays || !xhash) {
return { status: 400, ref: "Pagans", msg: "missing_header_info_for_logout" };
}
const baseName = `${xalias}_${xtribe}_${xdays}`;
const hashSuffix = xhash.substring(xhash.length - 50);
const tokenFileName = `${baseName}_${hashSuffix}.json`;
const tokenPath = path.join(DATA_PATH, TRIBE_NAME, "tmp", "tokens", tokenFileName);
try {
if (await fs.pathExists(tokenPath)) {
await fs.remove(tokenPath);
if (log) console.log(`[Pagans] Logout: Removed token file at ${tokenPath}`);
}
return { status: 200, ref: "Pagans", msg: "logout_successful" };
} catch (error) {
console.error(`[Pagans] Error removing token file ${tokenPath}:`, error);
return { status: 500, ref: "Pagans", msg: "logout_failed", error: error.message };
}
}
/**
* Retrieves a pagan's public key if the alias exists.
*/
static async getalias(alias) {
try {
const pagansDb = new Odmdb('pagans', TRIBE_NAME);
const role = { xprofils: ['anonymous'], xalias: 'anonymous' };
const result = await pagansDb.read(alias, role);
if (result.status === 200) {
return { status: 200, ref: "Pagans", msg: "alias_exists", data: result.data };
} else {
return { status: 404, ref: "Pagans", msg: "alias_not_found", data: { alias } };
}
} catch (error) {
console.error(`[Pagans] Error in getalias for '${alias}':`, error);
return { status: 500, ref: "Pagans", msg: "internal_server_error", error: error.message };
}
}
/**
* Finds users for key recovery and sends emails.
*/
static async keyrecovery(emailalias, tribe, search, lg) {
if (!search || search.length < 3 || !["email", "alias"].includes(emailalias)) {
return { status: 400, ref: "Pagans", msg: "invalid_recovery_parameters" };
}
let emailsToSend = [];
if (emailalias === "email") {
const searchEmail = search.toLowerCase();
const idxPath = path.join(DATA_PATH, tribe, 'objects', 'persons', 'idx', 'emailrecovery_alias.json');
if (await fs.pathExists(idxPath)) {
const recoveryIndex = await fs.readJson(idxPath);
const aliases = recoveryIndex[searchEmail] || [];
aliases.forEach(alias => emailsToSend.push({ alias, tribe, lg }));
}
} else { // search by alias
const personsDb = new Odmdb('persons', tribe);
const personResult = await personsDb.read(search, { xprofils: ['admin'] });
if (personResult.status === 200 && personResult.data.recoveryauth) {
emailsToSend.push({ alias: search, tribe, lg });
}
}
if (emailsToSend.length === 0) {
return { status: 404, ref: "Pagans", msg: "recovery_email_not_found" };
}
// Limit to 3 emails to prevent abuse
for (let i = 0; i < emailsToSend.length && i < 3; i++) {
await this.sendmailkey(emailsToSend[i]);
}
return { status: 200, ref: "Pagans", msg: "recovery_email_sent", data: { count: emailsToSend.length } };
}
/**
* Sends an email with the user's keys for recovery.
*/
static async sendmailkey(data) {
const personData = { ...data };
if (!personData.publickey || !personData.email || !personData.privatekey) {
const personsDb = new Odmdb('persons', personData.tribe);
const result = await personsDb.read(personData.alias, { xprofils: ['admin'] });
if (result.status !== 200) return { status: 404, ref: "Pagans", msg: "person_not_found_for_key_recovery" };
if (!result.data.recoveryauth) return { status: 404, ref: "Pagans", msg: "person_has_no_recovery_auth" };
const { recoveryauth } = result.data;
personData.email = recoveryauth.email;
personData.publickey = recoveryauth.publickey;
personData.privatekey = recoveryauth.privatekey;
personData.passphrase = recoveryauth.passphrase;
}
personData.avecpassphrase = !!personData.passphrase;
const tplPath = path.resolve(__dirname, `../template/createidentity_${data.lg}.js`);
if (!(await fs.pathExists(tplPath))) {
return { status: 404, ref: "Pagans", msg: "email_template_not_found" };
}
const tplemail = require(tplPath);
const maildata = {
from: tplemail.sender,
to: personData.email,
subject: Mustache.render(tplemail.subject, personData),
html: Mustache.render(tplemail.html, personData),
text: Mustache.render(tplemail.text, personData),
attachments: [{ filename: `${personData.alias}_privatekey.txt`, content: personData.privatekey, contentType: "text/plain" }],
};
return Notifications.sendmail(maildata, data.tribe);
}
/**
* Verifies a detached PGP signature.
*/
static async authenticatedetachedSignature(alias, pubK, detachedSignature, message) {
try {
const publicKey = await openpgp.readKey({ armoredKey: pubK });
const msg = await openpgp.createMessage({ text: message });
const signature = await openpgp.readSignature({ armoredSignature: detachedSignature });
const verificationResult = await openpgp.verify({
message: msg,
signature,
verificationKeys: publicKey,
});
await verificationResult.signatures[0].verified;
return true;
} catch (e) {
if (log) console.log(`[Pagans] Signature could not be verified for alias ${alias}: ${e.message}`);
return false;
}
}
static async createPagan(paganData, role) {
try {
const pagansDb = new Odmdb('pagans', TRIBE_NAME);
const paganItem = {
alias: paganData.alias,
publickey: paganData.publickey
};
const result = await pagansDb.create(paganItem, role);
if (result.status === 200) {
return { status: 200, ref: "Pagans", msg: "pagan_created", data: result.data };
}
return result;
} catch (error) {
console.error(`[Pagans] Error creating pagan '${paganData.alias}':`, error);
return { status: 500, ref: "Pagans", msg: "pagan_creation_failed", error: error.message };
}
}
static async updatePagan(paganData, role) {
try {
const pagansDb = new Odmdb('pagans', TRIBE_NAME);
const result = await pagansDb.update(paganData, role);
if (result.status === 200) {
return { status: 200, ref: "Pagans", msg: "pagan_updated", data: result.data };
}
return result;
} catch (error) {
console.error(`[Pagans] Error updating pagan '${paganData.alias}':`, error);
return { status: 500, ref: "Pagans", msg: "pagan_update_failed", error: error.message };
}
}
static async createPerson(personData, tribe, role) {
try {
const personsDb = new Odmdb('persons', tribe);
const personItem = {
alias: personData.alias,
owner: personData.owner || personData.alias,
profils: personData.profils || ["anonymous", "pagans", "persons"],
recoveryauth: personData.recoveryauth
};
const result = await personsDb.create(personItem, role);
if (result.status === 200) {
return { status: 200, ref: "Pagans", msg: "person_created", data: result.data };
}
return result;
} catch (error) {
console.error(`[Pagans] Error creating person '${personData.alias}' in tribe '${tribe}':`, error);
return { status: 500, ref: "Pagans", msg: "person_creation_failed", error: error.message };
}
}
static async updatePerson(personData, tribe, role) {
try {
const personsDb = new Odmdb('persons', tribe);
const result = await personsDb.update(personData, role);
if (result.status === 200) {
return { status: 200, ref: "Pagans", msg: "person_updated", data: result.data };
}
return result;
} catch (error) {
console.error(`[Pagans] Error updating person '${personData.alias}' in tribe '${tribe}':`, error);
return { status: 500, ref: "Pagans", msg: "person_update_failed", error: error.message };
}
}
/**
* Create an invitation code for a pagan to join a tribe
* @param {string} tribe - Tribe identifier
* @param {string|null} alias - Alias to invite (null = non-nominative)
* @param {object} role - Session role/headers
*/
static async createInvitation(tribe, alias, role) {
try {
const tribesDb = new Odmdb('tribes', TRIBE_NAME);
const tribeResult = await tribesDb.read(tribe, { xprofils: ['admin'] });
if (tribeResult.status !== 200) {
return { status: 404, ref: "Pagans", msg: "tribe_not_found", data: { tribe } };
}
const condition = tribeResult.data.conditiontojoin || 'public';
if (condition === 'public') {
return { status: 400, ref: "Pagans", msg: "invitation_not_needed", data: { tribe, condition } };
}
// Determine the requester's profile within the TARGET tribe. The session
// xprofils reflect the town tribe ("dev"), not the target tribe, so we
// read the person record (or fall back to the tribe owner).
const isOwner = tribeResult.data.owner === role.xalias;
let tribeProfils = [];
const personPath = path.join(DATA_PATH, tribe, "objects", "persons", "itm", `${role.xalias}.json`);
if (await fs.pathExists(personPath)) {
const person = await fs.readJson(personPath);
tribeProfils = Array.isArray(person.profils) ? person.profils : [];
}
const isDruid = isOwner || role.xprofils.includes('druids') || tribeProfils.includes('druids');
const isPerson = role.xprofils.includes('persons') || tribeProfils.includes('persons');
if (condition === 'druidinvitation' && !isDruid) {
return { status: 403, ref: "Pagans", msg: "druid_invitation_required", data: { tribe, requester: role.xalias } };
}
if (condition === 'personinvitation' && !isDruid && !isPerson) {
return { status: 403, ref: "Pagans", msg: "person_invitation_required", data: { tribe, requester: role.xalias } };
}
const code = crypto.randomUUID();
const invDir = path.join(DATA_PATH, tribe, "tmp", "invitations");
const invFile = path.join(invDir, `${code}.json`);
await fs.ensureDir(invDir);
const invitation = {
alias: alias || null,
tribe,
code,
isNominative: !!alias,
createdBy: role.xalias,
createdAt: Date.now()
};
await fs.outputJson(invFile, invitation, { spaces: 2 });
if (log) console.log(`[Pagans] Invitation created for ${alias || '(anyone)'} in ${tribe} by ${role.xalias}`);
const msg = alias ? 'invitation_created' : 'invitation_created_anonymous';
return { status: 200, ref: "Pagans", msg, data: { code, alias: alias || null, tribe } };
} catch (error) {
console.error(`[Pagans] Error creating invitation for ${alias || '(anyone)'} in ${tribe}:`, error);
return { status: 500, ref: "Pagans", msg: "internal_server_error", error: error.message };
}
}
/**
* Verify and consume an invitation code for joining a tribe
* @param {string} tribe - Tribe identifier
* @param {string|null} alias - Alias of the user joining (null for non-nominative)
* @param {string} code - Invitation code to verify
*/
static async verifyInvitation(tribe, alias, code) {
try {
const invFile = path.join(DATA_PATH, tribe, "tmp", "invitations", `${code}.json`);
if (!await fs.pathExists(invFile)) {
return { status: 404, ref: "Pagans", msg: "invitation_not_found", data: { code, tribe } };
}
const invitation = await fs.readJson(invFile);
if (invitation.code !== code) {
return { status: 403, ref: "Pagans", msg: "invalid_invitation_code", data: { tribe } };
}
// For nominative invitations, ensure the alias matches
if (invitation.isNominative && invitation.alias !== alias) {
return { status: 403, ref: "Pagans", msg: "invitation_alias_mismatch", data: { expected: invitation.alias, provided: alias } };
}
await fs.remove(invFile);
if (log) console.log(`[Pagans] Invitation consumed for ${alias || '(anyone)'} in ${tribe}`);
const { code: _removed, ...invitationData } = invitation;
return { status: 200, ref: "Pagans", msg: "invitation_verified", data: invitationData };
} catch (error) {
console.error(`[Pagans] Error verifying invitation for ${alias} in ${tribe}:`, error);
return { status: 500, ref: "Pagans", msg: "internal_server_error", error: error.message };
}
}
static async getaliasauth(alias, role, appLocals) {
try {
const pagansDb = new Odmdb('pagans', TRIBE_NAME);
const ownerRole = { xalias: alias, xprofils: ['owner'] };
// 1. Read pagan data as owner
const paganResult = await pagansDb.read(alias, ownerRole);
if (paganResult.status !== 200) {
return { status: 404, ref: "Pagans", msg: "alias_not_found", data: { alias } };
}
const paganData = paganResult.data;
// Dynamically discover tribe IDs from the tribes objects directory
var tribeIds = appLocals?.tribeids || [];
try {
var tribesItmPath = path.join(DATA_PATH, TRIBE_NAME, 'objects', 'tribes', 'itm');
if (await fs.pathExists(tribesItmPath)) {
var files = await fs.readdir(tribesItmPath);
var dynamicIds = files.filter(function(f) { return f.endsWith('.json'); }).map(function(f) { return f.replace('.json', ''); });
// Merge with cached tribeIds, deduplicate
dynamicIds.forEach(function(id) { if (tribeIds.indexOf(id) === -1) tribeIds.push(id); });
}
} catch (e) {
if (log) console.log('[Pagans] Failed to scan tribes itm:', e.message);
}
// 2. Check if alias is major in TOWN
let isMajorInTown = false;
const townPersonsDb = new Odmdb('persons', TRIBE_NAME);
const townPersonResult = await townPersonsDb.read(alias, ownerRole);
if (townPersonResult.status === 200) {
const townProfils = townPersonResult.data.profils || [];
isMajorInTown = townProfils.includes('mayors');
}
// 3. Build tribes data
const tribesData = {};
for (const tribeId of tribeIds) {
const personsDb = new Odmdb('persons', tribeId);
const personResult = await personsDb.read(alias, ownerRole);
const personProfils = personResult.status === 200 ? (personResult.data.profils || []) : [];
// Skip if user has no profile in this tribe AND is not major in TOWN
if (personResult.status !== 200 && !isMajorInTown) continue;
const entry = {};
// Person info (include for ALL profiles)
if (personResult.status === 200) {
const { alias: _a, owner: _o, ...personData } = personResult.data;
entry.person = personData;
// APX3 gain for mayor/druid users (current month)
const isDruidOrMayor = personProfils.includes('druids') || personProfils.includes('mayors');
if (isDruidOrMayor && personData.apxgain) {
const currentMonth = dayjs().format('YYYYMM');
const prevMonth = dayjs().subtract(1, 'month').format('YYYYMM');
entry.apxgain = {};
if (personData.apxgain[currentMonth] !== undefined) {
entry.apxgain[currentMonth] = personData.apxgain[currentMonth];
}
if (personData.apxgain[prevMonth] !== undefined) {
entry.apxgain[prevMonth] = personData.apxgain[prevMonth];
}
}
}
// Tribe config
const tribesDb = new Odmdb('tribes', TRIBE_NAME);
const tribeResult = await tribesDb.read(tribeId, ownerRole);
if (tribeResult.status === 200) {
const Tribes = require("./Tribes.js");
entry.tribe = Tribes._redactRepository(tribeResult.data);
}
// Nb persons total
const idxResult = await personsDb.getidx('lst_alias', { xprofils: ['admin'] });
entry.nbpersons = idxResult.status === 200 ? idxResult.data.idx.length : 0;
// Nb persons by profile (using distribution index)
const profilsIdxResult = await personsDb.getidx('profils_alias', { xprofils: ['admin'] });
if (profilsIdxResult.status === 200) {
const profilsIdx = profilsIdxResult.data.idx;
entry.nbpersonsByProfile = {};
for (const [profil, aliases] of Object.entries(profilsIdx)) {
if (Array.isArray(aliases)) {
entry.nbpersonsByProfile[profil] = aliases.length;
}
}
}
// Nb apps
const wwwsPath = path.join(NODE_PATH, tribeId, 'wwws');
let nbapp = 0;
try {
if (await fs.pathExists(wwwsPath)) {
const dirs = await fs.readdir(wwwsPath);
for (const dir of dirs) {
const appconfPath = path.join(wwwsPath, dir, 'appconf.json');
if (await fs.pathExists(appconfPath)) {
nbapp++;
}
}
}
} catch (e) {
if (log) console.log(`[Pagans] Error counting wwws for ${tribeId}: ${e.message}`);
}
entry.nbapp = nbapp;
tribesData[tribeId] = entry;
}
return {
status: 200,
ref: "Pagans",
msg: "alias_auth_info",
data: { ...paganData, tribes: tribesData }
};
} catch (error) {
console.error(`[Pagans] Error in getaliasauth for '${alias}':`, error);
return { status: 500, ref: "Pagans", msg: "internal_server_error", error: error.message };
}
}
static async createPaganAndJoinTribe(paganData, tribe, role) {
try {
const paganResult = await this.createPagan(paganData, role);
if (paganResult.status !== 200) {
return paganResult;
}
if (tribe) {
const personRole = {
xprofils: ["anonymous", "pagans"],
xalias: paganData.alias
};
const personResult = await this.createPerson(paganData, tribe, personRole);
return {
status: 200,
ref: "Pagans",
msg: "pagan_and_person_created",
data: {
pagan: paganResult.data,
person: personResult.status === 200 ? personResult.data : null,
person_error: personResult.status !== 200 ? personResult : null
}
};
}
return paganResult;
} catch (error) {
console.error(`[Pagans] Error in createPaganAndJoinTribe for '${paganData.alias}':`, error);
return { status: 500, ref: "Pagans", msg: "pagan_tribe_join_failed", error: error.message };
}
}
/**
* Path of the per-tribe member access ACL (mirrors the files ACL model).
*/
static _memberAclPath(tribe) {
return path.join(DATA_PATH, tribe, 'objects', 'options', 'memberaccess.json');
}
/**
* Loads the member access ACL entries of a tribe.
* @param {string} tribe - Tribe identifier.
* @returns {Promise<Array>} Array of { target, rights, fields, schemas }.
*/
static async _loadMemberAcl(tribe) {
const aclPath = this._memberAclPath(tribe);
if (!await fs.pathExists(aclPath)) return [];
try {
const data = await fs.readJson(aclPath);
return Array.isArray(data.entries) ? data.entries : [];
} catch { return []; }
}
/**
* Determines the effective readable/updatable fields a viewer has on a person
* record, combining the viewer's tribe profile (schema rights) with the
* per-tribe member access ACL.
* @param {object} viewer - { alias, profils } of the requesting user in this tribe.
* @param {object} person - The target person record.
* @param {Array} acl - The member access ACL entries.
* @returns {{ readable: string[], updatable: string[], isOwner: boolean }}
*/
static _personFieldAccess(viewer, person, acl) {
const schemaRights = { owner: {}, druids: {}, pagans: {}, mayors: {} };
// Mirrors persons.json apxaccessrights (R/U field lists).
schemaRights.owner.R = ["alias", "owner", "profils", "dt_create", "dt_update", "dt_lastlogin", "dt_birth", "emailcom", "hobbies", "biography", "imgavatar"];
schemaRights.owner.U = ["dt_birth", "emailcom", "hobbies", "biography", "imgavatar", "imgbase64_imgavatar", "profils"];
schemaRights.druids.R = ["alias", "owner", "profils", "dt_create", "dt_update", "dt_lastlogin", "dt_birth", "emailcom", "hobbies", "biography", "imgavatar", "apxgain", "druidsinfo"];
schemaRights.druids.U = ["apxgain", "profils", "druidsinfo", "imgavatar", "imgbase64_imgavatar"];
schemaRights.pagans.R = [];
schemaRights.mayors.R = ["alias", "apxgain"];
const profils = Array.isArray(viewer.profils) ? viewer.profils : [];
const isOwner = person.alias === viewer.alias;
const isDruid = profils.includes('druids');
const readable = new Set();
const updatable = new Set();
// Schema profile rights (union of all profiles the viewer holds in the tribe)
for (const p of profils) {
const r = schemaRights[p];
if (!r) continue;
(r.R || []).forEach(f => readable.add(f));
(r.U || []).forEach(f => updatable.add(f));
}
// Owner of the record (self)
if (isOwner) {
(schemaRights.owner.R || []).forEach(f => readable.add(f));
(schemaRights.owner.U || []).forEach(f => updatable.add(f));
}
// Per-tribe member access ACL: entries target druids / profils:x / alias:x
(Array.isArray(acl) ? acl : []).forEach(entry => {
const target = entry.target || '';
let matches = false;
if (target === 'druids' && profils.includes('druids')) matches = true;
else if (target.startsWith('profils:') && profils.includes(target.split(':')[1])) matches = true;
else if (target.startsWith('alias:') && target.split(':')[1] === viewer.alias) matches = true;
if (!matches) return;
const rights = Array.isArray(entry.rights) ? entry.rights : [];
(Array.isArray(entry.fields) ? entry.fields : []).forEach(f => {
if (rights.includes('R')) readable.add(f);
if (rights.includes('U')) updatable.add(f);
});
});
return { readable: Array.from(readable), updatable: Array.from(updatable), isOwner, isDruid };
}
/**
* Resolves the viewer's profiles in a given tribe (reads the person record,
* falling back to the session xprofils which reflect the town tribe).
* @param {string} tribe - Tribe identifier.
* @param {object} role - Session role.
* @returns {Promise<{ alias: string, profils: string[], isTribeOwner: boolean }>}
*/
static async _resolveViewer(tribe, role) {
const alias = role.xalias;
let profils = Array.isArray(role.xprofils) ? role.xprofils.slice() : [];
const personPath = path.join(DATA_PATH, tribe, "objects", "persons", "itm", `${alias}.json`);
if (await fs.pathExists(personPath)) {
const p = await fs.readJson(personPath);
if (Array.isArray(p.profils)) profils = p.profils;
}
const tribesDb = new Odmdb('tribes', TRIBE_NAME);
const tribeResult = await tribesDb.read(tribe, { xprofils: ['admin'] });
const tribeOwner = tribeResult.status === 200 ? (tribeResult.data.owner || '') : '';
return { alias, profils, isTribeOwner: tribeOwner === alias };
}
/**
* Returns the calling alias's own access summary in a tribe (used to decide
* which icons/actions are visible in the frontend).
* @param {string} tribe - Tribe identifier.
* @param {object} role - Session role.
* @returns {Promise<object>} { druid, schemas: {R,W,D} }
*/
static async myAccess(tribe, role) {
try {
const v = await this._resolveViewer(tribe, role);
const isDruid = v.isTribeOwner || v.profils.includes('druids');
const acl = await this._loadMemberAcl(tribe);
const schemas = { R: [], W: [] };
if (!isDruid) {
acl.forEach(entry => {
const target = entry.target || '';
let matches = false;
if (target === 'druids' && v.profils.includes('druids')) matches = true;
else if (target.startsWith('profils:') && v.profils.includes(target.split(':')[1])) matches = true;
else if (target.startsWith('alias:') && target.split(':')[1] === v.alias) matches = true;
if (!matches) return;
const rights = Array.isArray(entry.rights) ? entry.rights : [];
(Array.isArray(entry.schemas) ? entry.schemas : []).forEach(s => {
if (rights.includes('R') && schemas.R.indexOf(s) === -1) schemas.R.push(s);
if (rights.includes('U') && schemas.W.indexOf(s) === -1) schemas.W.push(s);
});
});
}
let hasFiles = isDruid;
try {
const Files = require("./Files.js");
hasFiles = await Files.hasAnyAccess(tribe, v.alias, v.profils);
} catch (e) {
if (log) console.log('[Pagans] Files.hasAnyAccess skipped:', e.message);
}
// Mailbox access: druids see the box; others see it if they hold a
// mail-{mailboxid}-reader/-writer profile in this tribe. Store is per-tribe.
let mailboxCount = 0;
try {
const mbDir = path.join(DATA_PATH, tribe, "objects", "mailboxes", "itm");
if (await fs.pathExists(mbDir)) {
const mbFiles = (await fs.readdir(mbDir)).filter(f => f.endsWith('.json'));
for (const f of mbFiles) {
try {
const item = await fs.readJson(path.join(mbDir, f));
if (item.tribe !== tribe) continue;
const isWriter = v.profils.includes(`mail-${item.mailboxid}-writer`);
const isReader = v.profils.includes(`mail-${item.mailboxid}-reader`);
if (isDruid || isWriter || isReader) mailboxCount++;
} catch { /* skip */ }
}
}
} catch (e) {
if (log) console.log('[Pagans] mailbox scan skipped:', e.message);
}
return { status: 200, ref: "Pagans", msg: "my_access_found", data: { tribe, alias: v.alias, druid: isDruid, schemas, files: hasFiles, mailbox: mailboxCount } };
} catch (error) {
console.error(`[Pagans] Error resolving myAccess for ${tribe}:`, error);
return { status: 500, ref: "Pagans", msg: "error_my_access", error: error.message };
}
}
/**
* Lists the members of a tribe visible to the requesting alias, with fields
* filtered server-side according to the viewer's profile + grants.
* @param {string} tribe - Tribe identifier.
* @param {object} role - Session role (xalias + town xprofils).
* @returns {Promise<object>} Filtered member list.
*/
static async getMembers(tribe, role) {
try {
const viewerAlias = role.xalias;
const v = await this._resolveViewer(tribe, role);
const viewerProfils = v.profils;
const isTribeOwner = v.isTribeOwner;
const isDruid = v.isTribeOwner || viewerProfils.includes('druids');
const acl = await this._loadMemberAcl(tribe);
const personsDir = path.join(DATA_PATH, tribe, "objects", "persons", "itm");
const members = [];
if (!await fs.pathExists(personsDir)) {
return { status: 200, ref: "Pagans", msg: "members_found", data: { tribe, members, druid: isDruid, acl } };
}
const files = await fs.readdir(personsDir);
for (const f of files) {
if (!f.endsWith('.json')) continue;
let person;
try { person = await fs.readJson(path.join(personsDir, f)); } catch { continue; }
const viewer = { alias: viewerAlias, profils: viewerProfils };
const access = this._personFieldAccess(viewer, person, acl);
// Visibility rule: druid/owner sees all; otherwise only self.
const canSee = isTribeOwner || access.isDruid || access.isOwner;
if (!canSee) continue;
const filtered = {};
access.readable.forEach(k => { if (person[k] !== undefined) filtered[k] = person[k]; });
members.push({
alias: person.alias,
profils: Array.isArray(person.profils) ? person.profils : [],
readable: access.readable,
updatable: access.updatable,
isSelf: access.isOwner,
person: filtered
});
}
members.sort((a, b) => (a.alias || '').localeCompare(b.alias || ''));
return { status: 200, ref: "Pagans", msg: "members_found", data: { tribe, members, druid: isDruid, acl } };
} catch (error) {
console.error(`[Pagans] Error listing members of ${tribe}:`, error);
return { status: 500, ref: "Pagans", msg: "error_listing_members", error: error.message };
}
}
/**
* Returns the member access ACL of a tribe. Druid/owner only.
* @param {string} tribe - Tribe identifier.
* @param {object} role - Session role.
* @returns {Promise<object>} { entries }
*/
static async getMemberAcl(tribe, role) {
try {
const v = await this._resolveViewer(tribe, role);
const isDruid = v.isTribeOwner || v.profils.includes('druids');
if (!isDruid) return { status: 403, ref: "Pagans", msg: "grant_forbidden", data: { tribe } };
const entries = await this._loadMemberAcl(tribe);
return { status: 200, ref: "Pagans", msg: "acl_found", data: { entries } };
} catch (error) {
console.error(`[Pagans] Error reading member ACL for ${tribe}:`, error);
return { status: 500, ref: "Pagans", msg: "error_getting_acl", error: error.message };
}
}
/**
* Saves the member access ACL of a tribe. Druid/owner only.
* @param {string} tribe - Tribe identifier.
* @param {Array} entries - ACL entries [{ target, rights, fields, schemas }].
* @param {object} role - Session role.
* @returns {Promise<object>} Result.
*/
static async saveMemberAcl(tribe, entries, role) {
try {
const v = await this._resolveViewer(tribe, role);
const isDruid = v.isTribeOwner || v.profils.includes('druids');
if (!isDruid) return { status: 403, ref: "Pagans", msg: "grant_forbidden", data: { tribe } };
if (!Array.isArray(entries)) return { status: 400, ref: "Pagans", msg: "acl_entries_required" };
const aclPath = this._memberAclPath(tribe);
await fs.ensureDir(path.dirname(aclPath));
await fs.outputJson(aclPath, { entries }, { spaces: 2 });
return { status: 200, ref: "Pagans", msg: "acl_saved", data: { tribe, entries } };
} catch (error) {
console.error(`[Pagans] Error saving member ACL for ${tribe}:`, error);
return { status: 500, ref: "Pagans", msg: "error_saving_acl", error: error.message };
}
}
}
module.exports = Pagans;