760 lines
31 KiB
JavaScript
Executable File
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;
|