chore: pending changes before release

This commit is contained in:
philc
2026-10-02 12:35:57 +00:00
parent ddf166aa81
commit 2bf64e41b8
52 changed files with 2510 additions and 290 deletions
+10
View File
@@ -35,6 +35,7 @@ ALL documentation (READMEs, JSDoc/apiDoc, code comments, skill content, agent de
- Routes: Express.Router, checkHeaders, isAuthenticated, handleError
- Always async/await for DB/file ops
- apiDoc JSDoc BEFORE every endpoint
- Never write scratch/test files (e.g. `tmp_*.js`) at the project root or inside `{NODEPATH}`/`{DATAPATH}` code trees; write them to `/tmp/opencode/` and delete them when done.
## Invitation System
Tribes can restrict joining via `conditiontojoin` in tribe config:
@@ -53,6 +54,15 @@ After a successful join with an invitation (`POST /person/:tribe`), the core aut
This allows tribes to define custom invitation profiles (e.g., `support`, `technique`) and create collection-specific records after a person joins. Example: `edifiance/models/Persons.js` creates a record in `supports` or `operateurs` collections based on the invitation's `profil` field.
## Permissions
The `permission` block in this frontmatter is the strict template for every nation: `bash` is `ask` unless a command is explicitly allowed. `external_directory` is not declared on purpose — OpenCode defaults it to `ask` for any path outside the project directory (`{DATAPATH}/**`, `/opt/apxtowns/{TOWN}.{NATION}/{tribe}/**`, `/etc/caddy/**`), which is already the strict posture.
A town may relax it through an optional overlay file, `{DATAPATH}/{TOWN}/opencode-permissions.json`. `apxtri.js::_injectAgentPermissions()`, called by `_syncOpenCode()`, injects it into the generated `{NODEPATH}/.opencode/agents/*.md` on every server start, so the overlay lives in the data directory (it survives both the `.opencode/` regeneration and `setup/update.sh` repo resets). The overlay replaces the agent's whole `permission` block — rules are evaluated in order and the last match wins. Absent file = strict template, unchanged.
Nation `zero` is apXtri's experimental convention: declared hackable and at risk, its token carries no value, and this is known by everyone. Its towns ship a relaxed overlay (`bash` allowed except destructive commands denied, `external_directory` allowed outside the project directory except `/etc/caddy/**` on approval and `/root/**` and `~/.ssh/**` denied, `sudo` still on approval). Every other nation stays strict. `external_directory` rules are matched against the parent directory (`dirname(<path>) + "/*"`), so a rule naming a file directly inside a directory (`/etc/shadow*`) never fires; see the `Agent Permissions` section of `AGENTS.md`. The overlay belongs to the town's mayor: edit it to change the permission level, or delete it to fall back to strict.
Note: `sudo` is always on approval, so service management (`sudo systemctl {start|stop|restart|status} apxtri@{town}.{nation}`) and ownership-sensitive writes are the user's action, not yours.
## Skills to load
When working on routes, load `be-api-response`, `be-route-pattern`, `be-tplstrings`.
When working on schemas, coordinate with @schema or load `schema-odmdb`.
+11
View File
@@ -199,6 +199,8 @@ Verify each screen at 360px width before considering a PWA task complete.
## Key Conventions
- Never write scratch/test files (e.g. `tmp_*.js`) at the project root or inside `{NODEPATH}`/`{DATAPATH}` code trees; write them to `/tmp/opencode/` and delete them when done.
### Auth Headers (automatic via interceptor — DO NOT pass manually)
`Auth.initAxios()` registers an axios request interceptor that **automatically** adds headers (`xalias`, `xdays`, `xlang`, `xtribe`, `xapp`, `xuuid`) to every `/api/` call. The interceptor reads from the active identity stored in localStorage (`Auth.getHeaders()`).
@@ -505,6 +507,15 @@ goBack() {
Les 3 colonnes garantissent que le titre reste centré que `btn-back` soit visible ou non.
## Permissions
The `permission` block in this frontmatter is the strict template for every nation: `bash` is `ask` unless a command is explicitly allowed. `external_directory` is not declared on purpose — OpenCode defaults it to `ask` for any path outside the project directory (`{DATAPATH}/**`, `/opt/apxtowns/{TOWN}.{NATION}/{tribe}/**`, `/etc/caddy/**`), which is already the strict posture.
A town may relax it through an optional overlay file, `{DATAPATH}/{TOWN}/opencode-permissions.json`. `apxtri.js::_injectAgentPermissions()`, called by `_syncOpenCode()`, injects it into the generated `{NODEPATH}/.opencode/agents/*.md` on every server start, so the overlay lives in the data directory (it survives both the `.opencode/` regeneration and `setup/update.sh` repo resets). The overlay replaces the agent's whole `permission` block — rules are evaluated in order and the last match wins. Absent file = strict template, unchanged.
Nation `zero` is apXtri's experimental convention: declared hackable and at risk, its token carries no value, and this is known by everyone. Its towns ship a relaxed overlay (`bash` allowed except destructive commands denied, `external_directory` allowed outside the project directory except `/etc/caddy/**` on approval and `/root/**` and `~/.ssh/**` denied, `sudo` still on approval). Every other nation stays strict. `external_directory` rules are matched against the parent directory (`dirname(<path>) + "/*"`), so a rule naming a file directly inside a directory (`/etc/shadow*`) never fires; see the `Agent Permissions` section of `AGENTS.md`. The overlay belongs to the town's mayor: edit it to change the permission level, or delete it to fall back to strict.
Note: `sudo` is always on approval, so the PWA build (`sudo -u apxtri ... node models/Wwws.js {tribe} {app} pwa`) and the systemd restart are the user's action, not yours.
## Skills
When creating a new PWA, load `fe-pwa-pattern` skill first.
+10
View File
@@ -35,5 +35,15 @@ Load the `schema-odmdb` skill using `skill({ name: "schema-odmdb" })`.
4. Create/modify the schema file
5. Validate: apxid, apxuniquekey, apxidx, apxaccessrights are all present
## Conventions
- Never write scratch/test files (e.g. `tmp_*.js`) at the project root or inside `{NODEPATH}`/`{DATAPATH}` code trees; write them to `/tmp/opencode/` and delete them when done.
## Permissions
The `permission` block in this frontmatter is the strict template for every nation: `bash` is `ask` unless a command is explicitly allowed. `external_directory` is not declared on purpose — OpenCode defaults it to `ask` for any path outside the project directory (`{DATAPATH}/**`, `/opt/apxtowns/{TOWN}.{NATION}/{tribe}/**`, `/etc/caddy/**`), which is already the strict posture.
A town may relax it through an optional overlay file, `{DATAPATH}/{TOWN}/opencode-permissions.json`. `apxtri.js::_injectAgentPermissions()`, called by `_syncOpenCode()`, injects it into the generated `{NODEPATH}/.opencode/agents/*.md` on every server start, so the overlay lives in the data directory (it survives both the `.opencode/` regeneration and `setup/update.sh` repo resets). The overlay replaces the agent's whole `permission` block — rules are evaluated in order and the last match wins. Absent file = strict template, unchanged.
Nation `zero` is apXtri's experimental convention: declared hackable and at risk, its token carries no value, and this is known by everyone. Its towns ship a relaxed overlay (`bash` allowed except destructive commands denied, `external_directory` allowed outside the project directory except `/etc/caddy/**` on approval and `/root/**` and `~/.ssh/**` denied, `sudo` still on approval). Every other nation stays strict. `external_directory` rules are matched against the parent directory (`dirname(<path>) + "/*"`), so a rule naming a file directly inside a directory (`/etc/shadow*`) never fires; see the `Agent Permissions` section of `AGENTS.md`. The overlay belongs to the town's mayor: edit it to change the permission level, or delete it to fall back to strict.
## Handoff
After creating a schema, inform the user that @be-apxtri can create the corresponding CRUD routes.
+159
View File
@@ -8,6 +8,22 @@
- **Tribe** (Community): A community or organization within a town. Each tribe has its own data namespace
- **Pagan** (User): A user identity based on PGP keypairs
### Nation zero — experimental convention (everyone must know)
Every apxtri install is a production install: there is no dev/prod variant of
`setup/init.sh` (`--domain` is required).
Nation `zero` is the single exception, **by convention**:
- it is **declared hackable and at risk**, and its **token carries no value**
- this is known by everyone, on purpose — it is the playground where the
protocol can be broken safely
- its towns may run with **relaxed agent permissions** (see
[Agent Permissions](#agent-permissions))
- any other nation must be treated as production-grade and stays **strict**
Never rely on nation zero for anything of value, and never copy its settings to
another nation without an explicit decision by that nation's mayor.
### Directory Structure (New Architecture)
```
/opt/apxtowns/{TOWN}.{NATION}/ # Code directory
@@ -111,6 +127,145 @@ Each agent can read from `/documents/` in its directory:
---
## Agent Permissions
OpenCode resolves agent permissions from three layers, from lowest to highest
priority: global config (`~/.config/opencode/opencode.jsonc`) → agent
frontmatter (`.opencode/agents/*.md`) → session. Within a key (`bash`,
`external_directory`, ...), **the last matching rule wins**, so rule order is
significant.
### Shipped templates (strict by default)
`apxtri/.opencode/agents/*.md` ship a strict `permission` block: `bash` is `ask`
for any command that is not explicitly allowed. This is the profile every nation
must keep unless its mayor decides otherwise.
The templates deliberately do **not** declare `external_directory`. OpenCode
gives every agent a built-in `external_directory: { "*": "ask" }`, with carve-outs
for `~/.local/share/opencode/tool-output/*`, `/tmp/*`, and any directory added
through the config `dirs` / `references` keys. That default is already the strict
posture: any path outside the project working directory — `{DATAPATH}/**`,
`/opt/apxtowns/{TOWN}.{NATION}/{tribe}/**`, `/etc/caddy/**` — asks. Declaring the
key would either silently *tighten* (a bare `"*": "ask"` is appended after the
carve-outs and, last match winning, overrides `/tmp` too) or copy OpenCode
internals into the template. Leaving it implicit keeps the strict behaviour and
inherits any upstream fix.
Note that the strict block does declare `read: "allow"`, which is a single rule
`{"*": "allow"}` appended after OpenCode's built-in ones. Since last match wins,
it also overrides the built-in `*.env` / `*.env.*` → `ask` guards, so the strict
profile is not a guard against reading a `.env` file inside the project. Gate it
explicitly (`"read": { "*.env": "ask", "*": "allow" }`) if that matters to a
nation.
### Per-town overlay
A town may override its agents' `permission` block with a single optional file:
```
{DATAPATH}/{TOWN}/opencode-permissions.json
```
It lives in the **data** directory on purpose: `{NODEPATH}/.opencode/` is emptied
and re-copied from the repositories on every start, and tracked repo files are
wiped by `setup/update.sh`, while `{DATAPATH}/{TOWN}/` is never reset.
`apxtri.js::_injectAgentPermissions()`, called from `_syncOpenCode()` at every
server start, replaces the `permission:` block of each `agents.{name}` entry in
the generated `{NODEPATH}/.opencode/agents/{name}.md`. Behaviour:
- overlay absent → strict template, nothing happens at all
- overlay unreadable or without an `agents` object → warning, nothing injected
- unknown agent name → warning, that agent is skipped
- injection is idempotent and never blocks startup
```json
{
"town": "dev",
"nation": "zero",
"agents": {
"be-apxtri": {
"read": "allow", "edit": "allow", "glob": "allow", "grep": "allow",
"bash": {
"*": "allow",
"rm -rf *": "deny", "rm -fr *": "deny", "rm -Rf *": "deny",
"mkfs*": "deny", "dd if=*": "deny",
"shutdown*": "deny", "reboot*": "deny", "halt*": "deny",
"git push*": "ask", "sudo *": "ask", "* sudo *": "ask"
},
"external_directory": {
"*": "allow",
"/etc/caddy/**": "ask",
"/etc/sudoers*": "deny", "/etc/shadow*": "deny", "/etc/gshadow*": "deny",
"/root/**": "deny", "/home/*/.ssh/**": "deny"
}
}
}
}
```
Never sort these keys: `"*": "allow"` must stay first so the narrow rules after
it can override it. The same applies to `external_directory`.
### How `external_directory` is matched
Rules are globs (`*` and `?` are wildcards, `~` is expanded) and the **last**
matching one wins. They are not matched against the file that is being accessed:
OpenCode asks with the **parent directory**, i.e. `dirname(<path>) + "/*"` for a
file and `<path>` for a directory. So a read of `/etc/caddy/Caddyfile` is
evaluated against `/etc/caddy/*`, and a read of `/etc/shadow` against `/etc/*`.
| asked pattern | matched rule | result |
| --- | --- | --- |
| `/etc/caddy/*` | `/etc/caddy/**` | `ask` |
| `/etc/*` | `*` | `allow` |
| `/root/*`, `/home/phil/.ssh/*` | `/root/**`, `/home/*/.ssh/**` | `deny` |
Consequence: **a rule can only gate a directory.** `/root/**` and
`/home/*/.ssh/**` work as intended, but the file-level entries of the seeded
profile (`/etc/sudoers*`, `/etc/shadow*`, `/etc/gshadow*`) never match the
`/etc/*` question, so those files fall back to `"*": "allow"`. To protect them,
gate the directory instead (`"/etc/*": "deny"` or `"ask"`) — and remember that a
later `deny` overrides the earlier `/etc/caddy/**` `ask`, so re-declare the
caddy carve-out after it:
```json
"external_directory": {
"*": "allow",
"/etc/*": "deny",
"/etc/caddy/*": "ask",
"/root/**": "deny",
"/home/*/.ssh/**": "deny"
}
```
Only `read`, `edit` and `external_directory` expand `~`. The strict templates
omit the key, so this block only ever comes from an overlay that chooses to
relax it.
### The file belongs to the mayor
The overlay is hand-edited, never written by apxtri after install. The mayor
edits it to raise or lower the permission level, or deletes it to fall back to
strict. `setup/init.sh` only **seeds** it once, from `AGENT_PERMISSIONS_NATIONS`,
and only when it is missing.
Nation `zero` (the experimental convention) seeds the relaxed profile above:
ordinary shell work runs without prompts, destructive commands are denied, reads
and writes outside the project directory (`{DATAPATH}/**`, tribe code) run
without prompts, `/etc/caddy/**` still asks, `/root/**` and `~/.ssh/**` are
denied, and `sudo` still requires the mayor's password. No other nation is
seeded. Note that the seeded `/etc/{sudoers,shadow,gshadow}*` entries are
ineffective as written (see [How `external_directory` is matched](#how-external_directory-is-matched))
— replace them with a `/etc/*` rule to actually protect those files.
Note: `sudo` stays on approval, so service management
(`sudo systemctl restart apxtri@{town}.{nation}`) and ownership-sensitive writes
(the PWA build must run as the `apxtri` user) remain the user's action.
---
## Documentation Language (CRITICAL)
**ALL documentation MUST be written in English.** This includes:
@@ -251,6 +406,10 @@ const log = process.env.ACTIVELOG.split(',').includes('{ModuleName}');
if (log) console.log(...) // Debug logging
```
### Temporary / Scratch Files
- Never write scratch or test files (e.g. `tmp_*.js`, throwaway test scripts) at the project root or inside any `{NODEPATH}` / `{DATAPATH}` code tree.
- Write them to `/tmp/opencode/` (pre-approved scratch directory) and delete them when done.
---
## API Documentation (CRITICAL)
+37
View File
@@ -261,6 +261,43 @@ opencode
See `documents/04-AIOPENCODE.md` for the full documentation.
### Agent Permissions
Agents ship with a strict `permission` profile: any `bash` command that is not
explicitly allowed asks for confirmation, and any path outside the project
directory (`{DATAPATH}/**`, tribe code, `/etc/caddy/**`) asks for approval —
OpenCode's built-in `external_directory` default, which the strict templates do
not override. Each town may relax or tighten this for its own agents with a
single optional file:
```
{DATAPATH}/{TOWN}/opencode-permissions.json
```
`apxtri.js::_injectAgentPermissions()` injects it into the generated
`{NODEPATH}/.opencode/agents/*.md` on every server start. The file belongs to the
town's mayor: edit it to change the level, or delete it to fall back to strict.
`sudo` always stays on approval.
### Nation zero — experimental convention
Nation `zero` is the one exception, **by convention**: it is **declared hackable
and at risk**, its **token carries no value**, and this is known by everyone on
purpose. It is the playground where the protocol can be broken safely, and its
towns ship a relaxed agent permission profile (ordinary shell work runs without
prompts, reads and writes outside the project directory run without prompts,
destructive commands are denied, `/etc/caddy/**` and `sudo` still ask, `/root/`
and `~/.ssh/` are denied).
Every other nation is production-grade and keeps the strict profile. Never rely
on nation zero for anything of value, and never copy its settings to another
nation without an explicit decision by that nation's mayor.
`external_directory` rules are matched against the **parent directory** of the
accessed path (`dirname(<path>) + "/*"`), not the file itself, so a rule that
names a file directly inside a directory — `/etc/shadow*`, for instance — never
matches. Gate the directory (`/etc/*`) instead. See `AGENTS.md`.
---
## ❓ FAQ
+209 -7
View File
@@ -314,6 +314,8 @@ class ApxtriApp {
if (log) console.log(` - Synced ${repo}/.opencode/`);
}
await this._injectAgentPermissions(targetDotOpencode);
const instructions = repos
.filter(r => fs.existsSync(path.join(root, r, 'AGENTS.md')))
.map(r => `${r}/AGENTS.md`);
@@ -330,6 +332,175 @@ class ApxtriApp {
}
}
/**
* Emits a YAML scalar.
* Simple tokens (allow / ask / deny) stay plain, anything else falls back to
* JSON.stringify: YAML double-quoted scalars are a superset of JSON string
* syntax, so the result is always valid YAML.
* @private
* @param {*} value The value to render.
* @returns {string} The YAML scalar.
*/
static _yamlScalar(value) {
if (typeof value === "string" && /^[A-Za-z_][A-Za-z0-9_-]*$/.test(value)) {
return value;
}
return JSON.stringify(value);
}
/**
* Renders one agent permission object as a YAML frontmatter block.
* @private
* @param {object} permission The permission object of a single agent.
* @returns {string|null} The `permission:` block, or null if not an object.
*/
_renderPermissionYaml(permission) {
if (!permission || typeof permission !== "object" || Array.isArray(permission)) {
return null;
}
const lines = ["permission:"];
// KEY ORDER IS CRITICAL: opencode evaluates the LAST matching permission
// rule, so the overlay relies on `"*": "allow"` coming FIRST and the narrow
// rules (sudo, destructive commands) after it. JSON.parse preserves the file
// order and Object.entries() keeps it: never sort these keys.
for (const [key, value] of Object.entries(permission)) {
if (value && typeof value === "object" && !Array.isArray(value)) {
// Nested keys are command patterns ("rm -rf *"): always double-quoted.
lines.push(` ${key}:`);
for (const [pattern, rule] of Object.entries(value)) {
lines.push(` ${JSON.stringify(pattern)}: ${ApxtriApp._yamlScalar(rule)}`);
}
} else {
lines.push(` ${key}: ${ApxtriApp._yamlScalar(value)}`);
}
}
return lines.join("\n");
}
/**
* Replaces (or inserts) the `permission:` block of a markdown YAML frontmatter.
* Everything outside that block — description, mode, body/prompt — is kept
* byte-identical. The operation is idempotent: injecting a block into its own
* output yields the very same file.
* @private
* @param {string} content Full markdown file content.
* @param {string} yamlBlock The rendered `permission:` block.
* @returns {string|null} The patched content, or null if unparsable.
*/
_replacePermissionBlock(content, yamlBlock) {
if (!yamlBlock) return null;
if (!content.startsWith("---\n")) return null;
const lines = content.split("\n");
// Closing delimiter: the first line that is exactly `---`.
let close = -1;
for (let i = 1; i < lines.length; i++) {
if (lines[i] === "---") {
close = i;
break;
}
}
if (close === -1) return null;
// Top-level `permission:` key (column 0) inside the frontmatter.
let start = -1;
for (let i = 1; i < close; i++) {
if (/^permission:/.test(lines[i])) {
start = i;
break;
}
}
if (start === -1) {
// No permission key at all: insert the block before the closing `---`.
lines.splice(close, 0, yamlBlock);
} else {
// The block ends on the next top-level key (column 0, not a comment),
// or on the closing `---` when no other key follows.
let end = close;
for (let i = start + 1; i < close; i++) {
if (/^[A-Za-z]/.test(lines[i])) {
end = i;
break;
}
}
lines.splice(start, end - start, yamlBlock);
}
return lines.join("\n");
}
/**
* Injects the town permission overlay into the generated agent files.
*
* The overlay is `{DATAPATH}/{TOWN}/opencode-permissions.json`, i.e. it lives
* in the town DATA directory: it survives both the .opencode/ regeneration
* done by _syncOpenCode() and the repo resets of setup/update.sh.
*
* When the file is absent nothing happens at all: that is the strict-by-default
* path for every nation but the experimental one. Any error is logged as a
* warning and swallowed — this runs during startup and must never be able to
* block the server.
* @private
* @param {string} targetDotOpencode Path of the generated .opencode directory.
*/
async _injectAgentPermissions(targetDotOpencode) {
const log = process.env.ACTIVELOG?.split(',').includes('apxtri');
const overlayPath = path.join(
this.dataPath,
process.env.TOWN || "",
"opencode-permissions.json"
);
// Strict default: no overlay file, nothing to do.
if (!fs.existsSync(overlayPath)) return;
let overlay;
try {
overlay = await fs.readJson(overlayPath);
} catch (err) {
logger.warn(`Agent permissions: unreadable overlay ${overlayPath}: ${err.message}`);
return;
}
if (
!overlay || typeof overlay !== "object" || Array.isArray(overlay) ||
!overlay.agents || typeof overlay.agents !== "object" ||
Array.isArray(overlay.agents)
) {
logger.warn(`Agent permissions: no "agents" object in ${overlayPath}, nothing injected.`);
return;
}
for (const [agentName, permission] of Object.entries(overlay.agents)) {
try {
const targetFile = path.join(targetDotOpencode, "agents", agentName + ".md");
if (!fs.existsSync(targetFile)) {
logger.warn(`Agent permissions: ${agentName}.md not shipped by any repo, skipped.`);
continue;
}
const original = await fs.readFile(targetFile, "utf8");
const patched = this._replacePermissionBlock(
original,
this._renderPermissionYaml(permission)
);
if (patched === null) {
logger.warn(`Agent permissions: ${agentName}.md has no parsable frontmatter, skipped.`);
continue;
}
if (patched === original) {
if (log) console.log(` - Agent permissions already up to date: ${agentName}`);
continue;
}
await fs.writeFile(targetFile, patched);
if (log) console.log(` - Agent permissions injected: ${agentName}`);
} catch (err) {
logger.warn(`Agent permissions: injection failed for ${agentName}: ${err.message}`);
}
}
}
/**
* Scans the filesystem to discover all tribes, objects, and routes.
* @private
@@ -592,8 +763,10 @@ class ApxtriApp {
);
app.use(bodyParser.urlencoded(globalConf.api.bodyparse.urlencoded));
// Serve mainapp subapp draft sources with the /cdn/dev/ → /cdn/{town}/ rewrite
// Serve PWA subapp draft sources with the /cdn/dev/ -> /cdn/{town}/ rewrite
// (mirrors buildpwa so draft mode works on non-dev towns too).
// Caddy forwards host-based webapp drafts here with X-Apxtri-Webapp set to
// the owning webapp name; without it the subapp is resolved against mainapp.
app.use(async (req, res, next) => {
if (!["GET", "HEAD"].includes(req.method)) return next();
const m = req.path.match(/^\/([^/]+)\/draft(\/.*)?$/);
@@ -601,10 +774,30 @@ class ApxtriApp {
const subapp = m[1];
const rest = m[2] || "";
const mainappRoot = path.join(this.nodePath, "apxtri", "mainapp", subapp);
if (!fs.existsSync(path.join(mainappRoot, "manifest.json"))) return next();
const webappName = req.headers["x-apxtri-webapp"];
let appRoot = null;
let tribe = process.env.TOWN || "dev";
if (webappName) {
// Sanitize: the header is client-reachable, so it must never escape the
// webapps source tree.
const safeName = String(webappName).replace(/[^a-zA-Z0-9_-]/g, "");
if (safeName) {
try {
const Caddy = require("./models/Caddy.js");
const wa = Caddy.discoverWebapps().find((w) => w.name === safeName);
if (wa && wa.sourcePath) {
appRoot = wa.sourcePath;
tribe = wa.tribe;
}
} catch (e) { /* fall back to mainapp */ }
}
}
if (!appRoot) appRoot = path.join(this.nodePath, "apxtri", "mainapp");
// /subapp/draft → /subapp/draft/ so relative assets resolve correctly
const subappRoot = path.join(appRoot, subapp);
if (!fs.existsSync(path.join(subappRoot, "manifest.json"))) return next();
// /subapp/draft -> /subapp/draft/ so relative assets resolve correctly
if (rest === "") {
return res.redirect(301, req.path + "/");
}
@@ -612,8 +805,8 @@ class ApxtriApp {
let rel = rest.replace(/^\/+/, "");
if (rel === "" || rel.endsWith("/")) rel += "index.html";
const srcFile = path.resolve(mainappRoot, rel);
if (srcFile !== mainappRoot && !srcFile.startsWith(mainappRoot + path.sep)) {
const srcFile = path.resolve(subappRoot, rel);
if (srcFile !== subappRoot && !srcFile.startsWith(subappRoot + path.sep)) {
return res.status(400).end();
}
@@ -637,8 +830,17 @@ class ApxtriApp {
let text = buf.toString("utf8");
const town = process.env.TOWN || "dev";
text = text.replace(/\/cdn\/dev\//g, "/cdn/" + town + "/");
text = text.replace(/<meta name="xtribe" content="[^"]*"/g, `<meta name="xtribe" content="${town}"`);
// xtribe is the webapp's own tribe (mainapp falls back to the town), not
// the town: rewriting it to the town would point a tribe webapp draft at
// the wrong data namespace.
text = text.replace(/<meta name="xtribe" content="[^"]*"/g, `<meta name="xtribe" content="${tribe}"`);
text = text.replace(/<meta name="xnation" content="[^"]*"/g, `<meta name="xnation" content="${process.env.NATION || ""}"`);
// Draft is always a dev-mode read: sources no longer carry the xmode
// meta (it used to ship in the built dist and disable profile gating),
// so inject it here for html documents only.
if (ext === ".html" && !/<meta\s+name="xmode"/i.test(text)) {
text = text.replace(/<head([^>]*)>/i, `<head$1>\n <meta name="xmode" content="dev" />`);
}
res.setHeader("Content-Type", MIME[ext]);
res.send(text);
} catch (err) {
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+60 -5
View File
@@ -54,6 +54,33 @@
return /^(https?:)?\/\//i.test(u) && !/^javascript:/i.test(u.replace(/^\s+/, '').toLowerCase());
}
// Inject the /draft/ segment into a leading /subapp/ path when running in
// draft mode, mirroring menu.js. Stored templates keep the clean path; the
// client decides the mode at render time.
function draftify(path) {
const p = String(path || '');
if (typeof IS_DRAFT === 'undefined' || !IS_DRAFT) return p;
if (p.indexOf('/draft/') !== -1) return p;
return p.replace(/^\/([^/]+)/, '/$1/draft');
}
// Resolve a button urlTemplate into an absolute URL.
// - A relative template (starting with "/" or "?") is resolved against the
// plugin's own target.dns, which the same-origin filter in renderPluginSlot
// has already proven to be the current origin. This keeps plugin data free
// of hardcoded nation domains.
// - An absolute template is returned untouched (and still checked by safeUrl).
function resolveUrl(tpl, values) {
const filled = fillTemplate(tpl, values);
const s = String(filled || '');
if (s.startsWith('/') || s.startsWith('?')) {
const dns = values && values.dns ? String(values.dns) : '';
const origin = (window.location.protocol + '//' + dns).replace(/\/+$/, '');
return origin + (s.startsWith('/') ? draftify(s) : ('/' + s));
}
return filled;
}
const PLUG_SVG = '<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22v-5"/><path d="M9 8V2"/><path d="M15 8V2"/><path d="M18 8v5a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4V8z"/></svg>';
async function loadOptions(source) {
@@ -104,6 +131,7 @@
if (!target) return;
const i18n = typeof opts.i18n === 'function' ? opts.i18n : (k, f) => f;
const onError = typeof opts.onError === 'function' ? opts.onError : () => {};
const onAction = typeof opts.onAction === 'function' ? opts.onAction : () => {};
const base = Object.assign(
{ baseUrl: window.location.origin, origin: window.location.origin, tribe: opts.tribe },
opts.context || {}
@@ -160,7 +188,7 @@
}).join('');
const bhtml = (Array.isArray(p.buttons) ? p.buttons : []).map((b, bi) => (
`<button type="button" class="btn btn-ghost btn-xs btn-square" data-plugin-button data-plugin-action="${esc(p.pluginId)}" data-plugin-index="${bi}" data-open="${b.open === 'self' ? 'self' : 'tab'}" data-confirm="${b.confirm ? '1' : '0'}" title="${esc(i18n(b.label, b.label))}">${PLUG_SVG}</button>`
`<button type="button" class="btn btn-ghost btn-xs btn-square" data-plugin-button data-plugin-action="${esc(p.pluginId)}" data-plugin-index="${bi}" data-open="${b.open === 'self' ? 'self' : 'tab'}" data-confirm="${b.confirm ? '1' : '0'}"${b.action ? ' data-has-action="1"' : ''} title="${esc(i18n(b.label, b.label))}">${PLUG_SVG}</button>`
)).join('');
return `<span class="flex flex-wrap items-center gap-1" data-plugin="${esc(p.pluginId)}">${fhtml}${bhtml}</span>`;
@@ -174,17 +202,44 @@
const plugin = plugins.find((p) => p.pluginId === btn.dataset.pluginAction);
if (!plugin) return;
const button = (plugin.buttons || [])[Number(btn.dataset.pluginIndex)];
if (!button || !button.urlTemplate) return;
if (!button) return;
const values = Object.assign({}, base, wrap ? formValues(wrap) : {});
(plugin.fields || []).forEach((f) => {
if (f.type === 'hidden' && values[f.key] == null) values[f.key] = f.default || '';
});
const url = fillTemplate(button.urlTemplate, values);
if (button.confirm && !window.confirm(i18n(plugin.name, plugin.name))) return;
// Server-side action: POST to the core plugin action route. The core
// dispatches to the tribe's PluginActions handler; the host app can react
// through opts.onAction (e.g. refresh its list).
if (button.action) {
btn.disabled = true;
try {
const res = await axios.post(
`/api/apxtri/plugins/${encodeURIComponent(opts.tribe)}/${encodeURIComponent(opts.placement)}/${encodeURIComponent(plugin.pluginId)}/action`,
{ action: button.action, values },
{ validateStatus: () => true }
);
const result = res.data || {};
onAction(result, plugin, button, values);
if (result.status !== 200) {
onError(i18n(result.msg || 'plugins_internal_error', result.msg || 'Action failed'));
}
} catch (e) {
onError(i18n('plugins_internal_error', 'Action failed'));
} finally {
btn.disabled = false;
}
return;
}
if (!button.urlTemplate) return;
// Expose the target DNS so a relative urlTemplate can be resolved against
// it instead of requiring a hardcoded absolute URL in the plugin data.
values.dns = (plugin.target && plugin.target.dns) || '';
const url = resolveUrl(button.urlTemplate, values);
if (!safeUrl(url)) {
onError(i18n('plugin.blocked_url', 'Blocked unsafe link'));
return;
}
if (button.confirm && !window.confirm(i18n(plugin.name, plugin.name))) return;
if (button.open === 'self') window.location.href = url;
else window.open(url, '_blank', 'noopener');
});
@@ -210,5 +265,5 @@
if (tasks.length) await Promise.all(tasks);
}
window.Plugins = { renderPluginSlot, fillTemplate, esc };
window.Plugins = { renderPluginSlot, fillTemplate, resolveUrl, esc };
})();
+3 -1
View File
@@ -334,7 +334,9 @@ For dev instances, either:
- set a **per-developer subdomain** (e.g. `apptest.dev1.zero.smatchit.io`) and add it to your
A records + frpc `customDomains`.
> Note: apps named `apidoc` are only served on a dedicated `dns` (never path-based).
> Note: apps named `apidoc` are served path-based on every domain — the town's docs on
> `/apidoc`, a tribe's on `/{tribe}/apidoc` (e.g. `https://dev.zero.ndda.fr/edifiance/apidoc`).
> A dedicated `dns` is not required and not used.
**Development workflow (mainapp / cdn):**
+4 -3
View File
@@ -15,9 +15,10 @@
"Odmdb":"apxtri/models/tplstrings/Odmdb",
"Notifications":"apxtri/models/tplstrings/Notifications",
"Middlewares": "apxtri/models/tplstrings/Middlewares",
"APX3": "apxtri/models/tplstrings/APX3",
"Wwws": "apxtri/models/tplstrings/Wwws"
}
"APX3": "apxtri/models/tplstrings/APX3",
"Wwws": "apxtri/models/tplstrings/Wwws",
"Plugins": "apxtri/models/tplstrings/Plugins"
}
},
"subapp": {
"identities": {
+71 -7
View File
@@ -179,7 +179,7 @@
<button class="btn btn-primary btn-sm flex-1" data-action="druid-create" data-i18n="druid.create_btn">Create &amp; connect</button>
</div>
<div id="druid-test-result" class="hidden mt-2 p-2 rounded-lg text-[11px]"></div>
<p class="text-[10px] opacity-50 mt-2" data-i18n="druid.connect_hint">Create first, then select it in the list and click ↻ to sync.</p>
<p class="text-[10px] opacity-50 mt-2" data-i18n="druid.connect_hint">Create first, then open it from Home and click ↻ to sync.</p>
</div>
</div>
</div>
@@ -189,7 +189,9 @@
<div class="flex items-center gap-2 mb-3">
<button class="btn btn-ghost btn-sm btn-square" data-action="goto-home" title="‹">‹</button>
<h2 id="mail-mb-title" class="text-lg font-bold truncate flex-1">-</h2>
<span id="mail-sync-info" class="text-xs opacity-60 hidden min-w-0"></span>
<span id="mail-mb-unread" class="badge badge-primary badge-sm hidden"></span>
<button id="mail-sync-btn" class="btn btn-ghost btn-sm btn-square" data-action="mail-sync" data-i18n-title="mail.sync" title="Sync now">↻</button>
</div>
<div class="flex flex-col md:flex-row gap-3 md:gap-4 min-h-[85vh]">
@@ -210,6 +212,21 @@
<option value="archived" data-i18n="mail.status.archived">archived</option>
<option value="trash" data-i18n="mail.status.trash">trash</option>
</select>
<div class="relative">
<button id="mail-filter-tag-btn" class="btn btn-ghost btn-xs gap-1 hidden" data-action="mail-filter-tag-toggle">
<span data-i18n="mail.tag_filter">Tag</span> <span class="badge badge-xs badge-ghost">0</span>
</button>
<div id="mail-filter-tag-dropdown" class="hidden absolute z-50 bg-base-100 border border-base-300 rounded-lg shadow-lg p-2 min-w-[200px]">
<div class="flex items-center justify-between mb-1">
<span class="text-xs font-medium" data-i18n="mail.tag_filter">Tags</span>
<div class="flex gap-1">
<button class="btn btn-xs btn-ghost text-[10px]" data-action="mail-filter-tag-mode" data-mode="or">OR</button>
<button class="btn btn-xs btn-ghost text-[10px]" data-action="mail-filter-tag-mode" data-mode="and">AND</button>
</div>
</div>
<div id="mail-filter-tag-list" class="space-y-1 max-h-[200px] overflow-auto"></div>
</div>
</div>
<input id="mail-filter-from" type="text" class="input input-xs w-36" placeholder="" data-i18n="placeholder.from_filter" autocomplete="off" />
<span class="text-xs opacity-50 ml-auto" id="mail-total"></span>
</div>
@@ -223,7 +240,11 @@
</div>
<div id="mail-list" class="space-y-2 overflow-y-auto md:max-h-[70vh] md:flex-1"></div>
<button id="mail-loadmore" class="btn btn-ghost btn-sm w-full mb-3 hidden" data-action="mail-loadmore">↓ <span data-i18n="mail.load_more">Load more</span></button>
<div id="mail-pager" class="hidden flex items-center justify-center gap-2 mb-3">
<button class="btn btn-ghost btn-xs btn-square" data-action="mail-prev" data-i18n-title="mail.pager.prev" title="Previous">‹</button>
<span id="mail-pager-info" class="text-xs opacity-60"></span>
<button class="btn btn-ghost btn-xs btn-square" data-action="mail-next" data-i18n-title="mail.pager.next" title="Next">›</button>
</div>
</div>
<!-- Right pane: mail content (wide mode) / full screen (mobile) -->
@@ -253,9 +274,23 @@
<p id="detail-to" class="opacity-60 line-clamp-5"></p>
<p id="detail-cc" class="opacity-60 line-clamp-5 hidden"></p>
</div>
<div id="detail-tags" class="flex flex-wrap items-center gap-1 my-2 hidden">
<div id="detail-tags-list" class="flex flex-wrap gap-1"></div>
<div class="relative ml-auto">
<button id="detail-tag-add-btn" class="btn btn-ghost btn-xs hidden" data-action="mail-toggle-tag-dropdown" title="Add tag">
<span>+</span> <span data-i18n="mail.tags_label">Tags</span>
</button>
<div id="detail-tag-dropdown" class="hidden absolute z-50 bg-base-100 border border-base-300 rounded-lg shadow-lg p-1 max-h-[200px] overflow-auto right-0 top-full mt-1 min-w-[150px]"></div>
</div>
</div>
<div id="detail-attachments" class="space-y-1 my-2"></div>
<div id="detail-tabs" class="hidden flex gap-1 my-1">
<button id="tab-message" class="btn btn-xs btn-primary" data-action="mail-tab-message" data-i18n="mail.tab.message">Message</button>
<button id="tab-attachment" class="btn btn-xs btn-ghost" data-action="mail-tab-attachment" data-i18n="mail.tab.attachment">Pièce jointe</button>
</div>
<div class="divider my-1"></div>
<div id="detail-body" class="text-sm break-words min-h-[60px]"></div>
<div id="detail-body" class="text-sm break-words flex flex-col flex-1 min-h-0"></div>
<div id="detail-preview" class="hidden my-2 flex flex-col flex-1 min-h-0"></div>
</div>
</div>
</div>
@@ -275,19 +310,48 @@
<div class="card bg-base-100 shadow-xl">
<div class="card-body p-4">
<h3 class="font-bold text-sm mb-2" data-i18n="druid.access_title">Manage access rights</h3>
<select id="druid-access-mailbox" class="select select-sm w-full mb-2"></select>
<select id="druid-access-mailbox" class="select select-sm w-full mb-3"></select>
<div id="druid-access-members" class="space-y-1 mb-3"></div>
<div class="grid grid-cols-2 gap-2 mb-2">
<input id="druid-access-alias" class="input input-sm w-full" placeholder="alias" autocomplete="off" />
<select id="druid-access-right" class="select select-sm w-full">
<option value="read" data-i18n="mail.right.read">read</option>
<option value="write" data-i18n="mail.right.write">write</option>
<option value="" data-i18n="druid.right_none">none (revoke)</option>
<option value="read" data-i18n="mail.right.read">Lecture</option>
<option value="write" data-i18n="mail.right.write">Lecture + Écriture</option>
</select>
</div>
<button class="btn btn-primary btn-sm w-full" data-action="druid-set-access" data-i18n="druid.set_access_btn">Set access</button>
</div>
</div>
<div class="card bg-base-100 shadow-xl mt-4">
<div class="card-body p-4">
<div class="flex items-center justify-between mb-2">
<h3 class="font-bold text-sm" data-i18n="druid.manage_tags">Manage tags</h3>
</div>
<select id="druid-tags-mailbox" class="select select-sm w-full mb-3"></select>
<div id="druid-tags-list" class="space-y-1 mb-3"></div>
<div class="grid grid-cols-[1fr_auto_1fr_auto] gap-2 items-end mb-2">
<input id="druid-tag-key" class="input input-sm w-full" placeholder="key (e.g. bdc)" autocomplete="off" />
<select id="druid-tag-color" class="select select-sm w-28">
<option value="primary">Primary</option>
<option value="secondary">Secondary</option>
<option value="accent">Accent</option>
<option value="success">Success</option>
<option value="warning">Warning</option>
<option value="error">Error</option>
<option value="info">Info</option>
<option value="neutral">Neutral</option>
</select>
<input id="druid-tag-label" class="input input-sm w-full" placeholder="label (e.g. BDC créé)" autocomplete="off" />
<button class="btn btn-primary btn-sm" data-action="druid-add-tag">+</button>
</div>
</div>
</div>
<div class="card bg-base-100 shadow-xl mt-4">
<div class="card-body p-4">
<div class="flex items-center justify-between mb-2">
+621 -65
View File
@@ -38,6 +38,10 @@ const App = {
_lang: 'en',
_lg: {},
_searchTimer: null,
_previewUrl: null, // object URL of the attachment currently previewed
_previewTab: 'message', // active detail tab: 'message' | 'attachment'
_tagFilter: new Set(), // active tag filter keys
_tagFilterMode: 'or', // 'or' or 'and'
async init() {
Log.debug('App.init() starting');
@@ -260,14 +264,18 @@ const App = {
const unread = mb.counts?.unread || 0;
document.getElementById('mail-mb-unread').textContent = unread > 0 ? `${unread}` : '';
document.getElementById('mail-mb-unread').classList.toggle('hidden', !(unread > 0));
this.updateMailSyncInfo(mb);
this.page = 1;
this.selected.clear();
document.getElementById('mail-search').value = '';
document.getElementById('mail-filter-from').value = '';
document.getElementById('mail-filter-status').value = '';
this._tagFilter.clear();
this._tagFilterMode = 'or';
this.renderTagFilter();
this.closeMailDetail(true);
Menu.showScreen('mail');
await this.fetchMails(false);
await this.fetchMails();
},
buildQuery() {
const params = new URLSearchParams({ mailboxid: this.currentMailbox.mailboxid, page: this.page, limit: this.limit });
@@ -278,14 +286,18 @@ const App = {
else if (status) params.set('status', status);
if (from) params.set('from', from);
if (q) params.set('q', q);
if (this._tagFilter.size > 0) {
params.set('tags', [...this._tagFilter].join(','));
params.set('tagMode', this._tagFilterMode);
}
return params.toString();
},
async fetchMails(append) {
async fetchMails() {
this.setLoading(true);
try {
const res = await axios.get(`/api/apxtri/mails/${this.currentTribe}?` + this.buildQuery(), { validateStatus: () => true });
if (res.data?.status === 200) {
this.mails = append ? this.mails.concat(res.data.data.mails || []) : res.data.data.mails || [];
this.mails = res.data.data.mails || [];
this.total = res.data.data.total || this.mails.length;
} else {
this.notify(this.t('common.error', 'An error occurred'), true);
@@ -300,42 +312,57 @@ const App = {
renderMails() {
const list = document.getElementById('mail-list');
const writable = this.currentMailbox?.right === 'write';
document.getElementById('mail-total').textContent = `${this.mails.length}/${this.total}`;
document.getElementById('mail-loadmore').classList.toggle('hidden', this.mails.length >= this.total);
const totalPages = Math.max(1, Math.ceil(this.total / this.limit));
const start = this.total > 0 ? (this.page - 1) * this.limit + 1 : 0;
const end = Math.min(this.page * this.limit, this.total);
document.getElementById('mail-total').textContent = this.total > 0 ? `${start}–${end} / ${this.total}` : '';
document.getElementById('mail-actions').classList.toggle('hidden', !writable || this.selected.size === 0);
if (this.mails.length === 0) {
list.innerHTML = `<p class="text-sm opacity-50 text-center py-4">${this.t('mail.no_mails', 'No message')}</p>`;
return;
}
list.innerHTML = this.mails.map((m) => {
const isNew = m.status === 'new';
const checked = this.selected.has(m.emailId);
const cb = writable
? `<input type="checkbox" class="checkbox checkbox-sm checkbox-primary mt-1 shrink-0" data-action="mail-select" data-emailid="${this.esc(m.emailId)}" ${checked ? 'checked' : ''} />`
: '';
const badges = m.apxtriSent
? `<span class="badge badge-xs badge-info shrink-0 mt-1" title="${this.t('mail.status.sent', 'apxtriSent')}">📤</span>`
: '';
return `
<div class="card bg-base-100 shadow ${isNew ? 'border-l-4 border-l-primary' : ''}" data-mail-row="${this.esc(m.emailId)}">
<div class="card-body p-2.5 flex-row items-start gap-2">
${cb}
<div class="flex-1 min-w-0 cursor-pointer" data-action="open-mail" data-emailid="${this.esc(m.emailId)}">
<div class="flex justify-between gap-2">
<p class="text-sm font-medium truncate ${isNew ? '' : 'opacity-70'}">${this.esc(m.from?.name || m.from?.email || '-')}</p>
<span class="text-[10px] opacity-50 shrink-0">${this.fmtDate(m.dateReceived || m.dateSent)}</span>
} else {
list.innerHTML = this.mails.map((m) => {
const isNew = m.status === 'new';
const checked = this.selected.has(m.emailId);
const cb = writable
? `<input type="checkbox" class="checkbox checkbox-sm checkbox-primary mt-1 shrink-0" data-action="mail-select" data-emailid="${this.esc(m.emailId)}" ${checked ? 'checked' : ''} />`
: '';
const badges = m.apxtriSent
? `<span class="badge badge-xs badge-info shrink-0 mt-1" title="${this.t('mail.status.sent', 'apxtriSent')}">📤</span>`
: '';
return `
<div class="card bg-base-100 shadow ${isNew ? 'border-l-4 border-l-primary' : ''}" data-mail-row="${this.esc(m.emailId)}">
<div class="card-body p-2.5 flex-row items-start gap-2">
${cb}
<div class="flex-1 min-w-0 cursor-pointer" data-action="open-mail" data-emailid="${this.esc(m.emailId)}">
<div class="flex justify-between gap-2">
<p class="text-sm font-medium truncate ${isNew ? '' : 'opacity-70'}">${this.esc(m.from?.name || m.from?.email || '-')}</p>
<span class="text-[10px] opacity-50 shrink-0">${this.fmtDate(m.dateReceived || m.dateSent)}</span>
</div>
<p class="text-xs truncate ${isNew ? 'font-semibold' : 'opacity-60'}">${this.esc(m.subject || this.t('mail.no_subject', '(no subject)'))}</p>
<p class="text-[11px] opacity-40 line-clamp-1">${this.esc(m.snippet || '')}</p>
${m.hasAttachments ? `<span class="text-[10px] opacity-60">${this.t('mail.has_attachments', '📎 attachments')}</span>` : ''}
</div>
<div class="flex flex-col items-end gap-1 shrink-0">
${badges}
<span class="badge badge-xs ${isNew ? 'badge-primary' : m.status === 'trash' ? 'badge-error' : 'badge-ghost'}">${this.esc(this.t('mail.status.' + m.status, m.status))}</span>
</div>
<p class="text-xs truncate ${isNew ? 'font-semibold' : 'opacity-60'}">${this.esc(m.subject || this.t('mail.no_subject', '(no subject)'))}</p>
<p class="text-[11px] opacity-40 line-clamp-1">${this.esc(m.snippet || '')}</p>
${m.hasAttachments ? `<span class="text-[10px] opacity-60">${this.t('mail.has_attachments', '📎 attachments')}</span>` : ''}
</div>
<div class="flex flex-col items-end gap-1 shrink-0">
${badges}
<span class="badge badge-xs ${isNew ? 'badge-primary' : m.status === 'trash' ? 'badge-error' : 'badge-ghost'}">${this.esc(this.t('mail.status.' + m.status, m.status))}</span>
</div>
</div>
</div>`;
}).join('');
</div>`;
}).join('');
}
this.renderPager(totalPages);
},
renderPager(totalPages) {
const pager = document.getElementById('mail-pager');
const hasPages = this.total > 0 && totalPages > 1;
pager.classList.toggle('hidden', !hasPages);
if (!hasPages) return;
const prev = pager.querySelector('[data-action="mail-prev"]');
const next = pager.querySelector('[data-action="mail-next"]');
prev.disabled = this.page <= 1;
next.disabled = this.page >= totalPages;
document.getElementById('mail-pager-info').textContent = this.t('mail.pager.page', 'Page {{page}} / {{total}}')
.replace('{{page}}', this.page).replace('{{total}}', totalPages);
},
updateActionBar() {
const writable = this.currentMailbox?.right === 'write';
@@ -392,22 +419,178 @@ const App = {
// attachments
const attEl = document.getElementById('detail-attachments');
const atts = Array.isArray(detail.attachments) ? detail.attachments : [];
const hasPreviewable = atts.some((a) => this.previewableType(a.mimeType));
attEl.innerHTML = atts.map((a, i) => {
const ext = (a.mimeType || '').split('/')[1] || 'bin';
const name = a.filename || this.t('mail.attachment_file', 'attachment.{{ext}}').replace('{{ext}}', ext);
const size = a.size ? `<span class="text-[10px] opacity-50">${this.t('mail.size_kb', '{{size}} kB').replace('{{size}}', Math.round(a.size / 1024))}</span>` : '';
const viewBtn = this.previewableType(a.mimeType)
? `<button class="btn btn-ghost btn-xs btn-square shrink-0" data-action="mail-preview-attach" data-index="${i}" data-i18n-title="mail.preview" title="View">👁</button>`
: '';
return `
<button class="btn btn-outline btn-xs w-full justify-start gap-2" data-action="mail-dl-attach" data-index="${i}">
<span>📎</span>
<span class="truncate flex-1 text-left">${this.esc(name)}</span>
${a.size ? `<span class="text-[10px] opacity-50">${this.t('mail.size_kb', '{{size}} kB').replace('{{size}}', Math.round(a.size / 1024))}</span>` : ''}
</button>`;
<div class="flex items-center gap-1">
<button class="btn btn-outline btn-xs flex-1 justify-start gap-2 min-w-0" data-action="mail-dl-attach" data-index="${i}">
<span>📎</span>
<span class="truncate flex-1 text-left">${this.esc(name)}</span>
${size}
</button>
${viewBtn}
</div>`;
}).join('');
attEl.classList.toggle('hidden', atts.length === 0);
// tags
this.renderMailTags(detail);
// tabs (only when at least one attachment is previewable)
document.getElementById('detail-tabs').classList.toggle('hidden', !hasPreviewable);
// body
const body = detail.body || '';
document.getElementById('detail-body').innerHTML = body
? `<iframe class="w-full min-h-[300px] border-0 rounded bg-base-100" sandbox="" srcdoc="${String(body).replace(/"/g, '&quot;')}"></iframe>`
: (detail.snippet || '');
document.getElementById('detail-body').innerHTML = body
? `<iframe class="w-full flex-1 min-h-0 border-0 rounded bg-base-100" sandbox="allow-popups" srcdoc="${String(body).replace(/"/g, '&quot;')}"></iframe>`
: (detail.snippet || '');
// reset to the message tab (also revokes any previous preview object URL)
this.setDetailTab('message');
},
renderMailTags(detail) {
const container = document.getElementById('detail-tags');
const listEl = document.getElementById('detail-tags-list');
const addBtn = document.getElementById('detail-tag-add-btn');
if (!container || !listEl) return;
const tags = Array.isArray(detail?.tags) ? detail.tags : [];
const mailboxid = this.currentMailbox?.mailboxid || '';
const mb = this._druidBoxes.find((b) => b.mailboxid === mailboxid);
const availableTags = Array.isArray(mb?.availableTags) ? mb.availableTags : [];
const isWriter = mb && mb.right === 'write';
if (tags.length === 0 && !isWriter) {
container.classList.add('hidden');
return;
}
container.classList.remove('hidden');
// Render existing tags
listEl.innerHTML = tags.map((t) => {
const def = availableTags.find((a) => a.key === t);
const label = def ? def.label : t;
const color = def ? def.color : 'primary';
return `<span class="badge badge-${this.esc(color)} badge-sm inline-flex items-center gap-1">${this.esc(label)}<button class="btn btn-ghost btn-xs btn-square p-0 h-3 w-3" data-action="mail-untag" data-tag="${this.esc(t)}" title="Remove">✕</button></span>`;
}).join('');
// Show add button only for writers
if (isWriter) {
addBtn.classList.remove('hidden');
} else {
addBtn.classList.add('hidden');
}
},
async mailToggleTag(tag) {
if (!this.currentDetail) return;
const emailId = this.currentDetail.emailId;
const tags = Array.isArray(this.currentDetail.data.tags) ? [...this.currentDetail.data.tags] : [];
const action = tags.includes(tag) ? 'untag' : 'tag';
try {
const res = await axios.post(`/api/apxtri/mails/${this.currentTribe}/action`, {
emailIds: [emailId],
action,
tag
}, { validateStatus: () => true });
if (res.data?.status === 200) {
if (action === 'tag') {
if (!tags.includes(tag)) tags.push(tag);
} else {
const idx = tags.indexOf(tag);
if (idx >= 0) tags.splice(idx, 1);
}
this.currentDetail.data.tags = tags;
this.renderMailTags(this.currentDetail.data);
} else {
this.notify(this.t('common.error', 'An error occurred'), true);
}
} catch (e) {
Log.error('mailToggleTag:', e.message);
this.notify(this.t('common.error', 'An error occurred'), true);
}
},
renderTagDropdown() {
const dd = document.getElementById('detail-tag-dropdown');
if (!dd || !this.currentDetail) return;
const mailboxid = this.currentMailbox?.mailboxid || '';
const mb = this._druidBoxes.find((b) => b.mailboxid === mailboxid);
const availableTags = Array.isArray(mb?.availableTags) ? mb.availableTags : [];
const currentTags = Array.isArray(this.currentDetail.data?.tags) ? this.currentDetail.data.tags : [];
const available = availableTags.filter((t) => !currentTags.includes(t.key));
if (available.length === 0) {
dd.innerHTML = '<p class="text-xs opacity-50 px-2 py-1">' + this.t('mail.no_more_tags', 'All tags applied') + '</p>';
} else {
dd.innerHTML = available.map((t) =>
`<button class="badge badge-${this.esc(t.color || 'primary')} badge-sm w-full justify-start cursor-pointer" data-action="mail-add-tag" data-tag="${this.esc(t.key)}">${this.esc(t.label)}</button>`
).join('');
}
dd.classList.remove('hidden');
},
hideTagDropdown() {
const dd = document.getElementById('detail-tag-dropdown');
if (dd) dd.classList.add('hidden');
},
renderTagFilter() {
const btn = document.getElementById('mail-filter-tag-btn');
const dd = document.getElementById('mail-filter-tag-dropdown');
const listEl = document.getElementById('mail-filter-tag-list');
if (!btn || !dd || !listEl) return;
const mailboxid = this.currentMailbox?.mailboxid || '';
const mb = this._druidBoxes.find((b) => b.mailboxid === mailboxid);
const availableTags = Array.isArray(mb?.availableTags) ? mb.availableTags : [];
// Update button badge
const count = this._tagFilter.size;
btn.classList.toggle('hidden', availableTags.length === 0);
const badge = btn.querySelector('.badge');
if (badge) badge.textContent = count > 0 ? String(count) : '0';
btn.classList.toggle('btn-primary', count > 0);
// Update mode buttons
dd.querySelectorAll('[data-action="mail-filter-tag-mode"]').forEach((el) => {
el.classList.toggle('btn-primary', el.dataset.mode === this._tagFilterMode);
el.classList.toggle('btn-ghost', el.dataset.mode !== this._tagFilterMode);
});
// Render tag checkboxes
if (availableTags.length === 0) {
listEl.innerHTML = '<p class="text-xs opacity-50 text-center py-1">' + this.t('mail.no_tags_available', 'No tags available') + '</p>';
} else {
listEl.innerHTML = availableTags.map((t) => {
const checked = this._tagFilter.has(t.key) ? 'checked' : '';
return `<label class="flex items-center gap-2 cursor-pointer py-1">` +
`<input type="checkbox" class="checkbox checkbox-xs checkbox-primary" data-action="mail-filter-tag-check" data-tag="${this.esc(t.key)}" ${checked} />` +
`<span class="badge badge-${this.esc(t.color || 'primary')} badge-xs">${this.esc(t.label)}</span>` +
`</label>`;
}).join('');
}
},
toggleTagFilterDropdown() {
const dd = document.getElementById('mail-filter-tag-dropdown');
if (!dd) return;
const isOpen = !dd.classList.contains('hidden');
if (!isOpen) {
this.renderTagFilter();
dd.classList.remove('hidden');
} else {
dd.classList.add('hidden');
}
},
setTagFilterMode(mode) {
this._tagFilterMode = mode;
this.renderTagFilter();
this.page = 1;
this.fetchMails();
},
toggleTagFilter(tag) {
if (this._tagFilter.has(tag)) this._tagFilter.delete(tag);
else this._tagFilter.add(tag);
this.renderTagFilter();
this.page = 1;
this.fetchMails();
},
async downloadAttachment(index) {
if (!this.currentDetail) return;
@@ -420,20 +603,110 @@ const App = {
});
if (!res.data || res.data.size === 0) { this.notify(this.t('common.error', 'An error occurred'), true); return; }
const url = URL.createObjectURL(res.data);
const a = document.createElement('a');
a.href = url;
a.download = att?.filename || this.t('mail.download_file', 'attachment-{{index}}').replace('{{index}}', index);
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 4000);
// previewable types (image, PDF) open in a new tab; others download as before
if (this.previewableType(att?.mimeType) && (att?.mimeType.startsWith('image/') || att?.mimeType === 'application/pdf')) {
const tab = window.open(url, '_blank');
if (!tab) { this.notify(this.t('mail.popup_blocked', 'Pop-up blocked — allow pop-ups to preview attachments'), true); URL.revokeObjectURL(url); }
// revoke after a delay; the tab will have loaded the blob before then
setTimeout(() => URL.revokeObjectURL(url), 30000);
} else {
const a = document.createElement('a');
a.href = url;
a.download = att?.filename || this.t('mail.download_file', 'attachment-{{index}}').replace('{{index}}', index);
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 4000);
}
} catch (e) {
Log.error('downloadAttachment failed:', e.message);
this.notify(this.t('common.error', 'An error occurred'), true);
}
},
// Client-side mirror of the server attachment preview allowlist (see backend contract).
previewableType(mimeType) {
return [
'image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/bmp', 'image/x-icon',
'application/pdf', 'text/plain', 'text/markdown', 'text/csv', 'application/json',
].includes(mimeType);
},
_revokePreviewUrl() {
if (this._previewUrl) {
URL.revokeObjectURL(this._previewUrl);
this._previewUrl = null;
}
},
setDetailTab(tab) {
this._previewTab = tab;
const isAttachment = tab === 'attachment';
const msgBtn = document.getElementById('tab-message');
const attBtn = document.getElementById('tab-attachment');
const body = document.getElementById('detail-body');
const preview = document.getElementById('detail-preview');
msgBtn.classList.toggle('btn-primary', !isAttachment);
msgBtn.classList.toggle('btn-ghost', isAttachment);
attBtn.classList.toggle('btn-primary', isAttachment);
attBtn.classList.toggle('btn-ghost', !isAttachment);
body.classList.toggle('hidden', isAttachment);
preview.classList.toggle('hidden', !isAttachment);
if (!isAttachment) {
// leaving the preview tab: drop the object URL and clear the container
this._revokePreviewUrl();
preview.innerHTML = '';
}
},
async previewAttachment(index) {
if (!this.currentDetail) return;
const { emailId } = this.currentDetail;
const att = (this.currentDetail.data.attachments || [])[index];
if (!att || !this.previewableType(att.mimeType)) {
this.notify(this.t('mail.preview_unsupported', 'This attachment cannot be previewed'), true);
return;
}
this.setDetailTab('attachment');
const container = document.getElementById('detail-preview');
container.classList.remove('hidden');
container.innerHTML = `<div class="flex items-center justify-center py-6"><span class="loading loading-spinner loading-lg text-primary"></span></div>`;
try {
const res = await axios.get(`/api/apxtri/mails/${this.currentTribe}/attachment/${emailId}/${index}`, {
responseType: 'blob',
params: { disposition: 'inline' },
validateStatus: () => true,
});
if (!res.data || res.data.size === 0) {
container.innerHTML = `<p class="text-sm opacity-50">${this.t('mail.preview_unsupported', 'This attachment cannot be previewed')}</p>`;
return;
}
const mimeType = att.mimeType || '';
const filename = att.filename || '';
this._revokePreviewUrl();
if (mimeType.startsWith('image/') || mimeType === 'application/pdf') {
const url = URL.createObjectURL(res.data);
this._previewUrl = url;
container.innerHTML = this._renderPreviewMedia(mimeType, url, filename);
} else {
const text = await res.data.text();
container.innerHTML = this._renderPreviewText(text);
}
} catch (e) {
Log.error('previewAttachment failed:', e.message);
container.innerHTML = `<p class="text-sm opacity-50">${this.t('common.error', 'An error occurred')}</p>`;
}
},
_renderPreviewMedia(mimeType, url, filename) {
const safeName = this.esc(filename || '');
if (mimeType.startsWith('image/')) {
return `<div class="flex flex-col items-center gap-2"><img src="${url}" alt="${safeName}" class="max-w-full max-h-[60vh] object-contain rounded bg-base-200" /><p class="text-xs opacity-50 break-all max-w-full text-center">${safeName}</p></div>`;
}
// application/pdf
return `<iframe src="${url}" class="w-full h-[70vh] border-0 rounded bg-base-100"></iframe>`;
},
_renderPreviewText(text) {
return `<pre class="text-xs whitespace-pre-wrap break-words bg-base-200 rounded p-3 max-h-[60vh] overflow-auto">${this.esc(text)}</pre>`;
},
closeMailDetail(skipRerender) {
this.currentDetail = null;
this._revokePreviewUrl();
const wrap = document.getElementById('mail-list-wrap');
const panel = document.getElementById('mail-detail-panel');
const empty = document.getElementById('detail-empty');
@@ -447,6 +720,66 @@ const App = {
}
if (!skipRerender) this.renderMails();
},
// Sync the currently open mailbox from the mail screen header (visible to any reader).
async mailSync() {
const mb = this.currentMailbox;
if (!mb || !mb.mailboxid) return;
// optimistic in-flight state: show "syncing…" and disable the button
this.updateMailSyncInfo({ isSyncing: true, syncing: { alias: Auth.getActiveAlias() } });
let ok = false;
try {
const res = await axios.post(`/api/apxtri/mailboxes/${mb.mailboxid}/sync`, {}, { validateStatus: () => true });
if (res.data?.status === 409) {
this.notify(this.t('mail.sync_in_progress', 'A sync is already running'), true);
} else if (res.data?.status === 200) {
ok = true;
const d = res.data?.data || {};
this.notify(this.t('druid.synced', 'Sync done: {{fetched}} fetched, {{created}} new')
.replace('{{fetched}}', d.fetched || 0).replace('{{created}}', d.created || 0), false);
} else {
this.notify(this.t('common.error', 'An error occurred'), true);
}
} catch (e) {
Log.error('mailSync failed:', e.message);
this.notify(this.t('common.error', 'An error occurred'), true);
} finally {
await this.refreshMailbox(mb.mailboxid);
if (ok) await this.fetchMails();
}
},
// Re-fetch the mailbox list to get the up-to-date lastSync / isSyncing flags.
async refreshMailbox(mailboxid) {
try {
const res = await axios.get('/api/apxtri/mailboxes', { validateStatus: () => true });
if (res.data?.status === 200) {
this.mailboxes = res.data.data.mailboxes || [];
const mb = this.mailboxes.find((m) => m.mailboxid === mailboxid);
if (mb) this.currentMailbox = mb;
}
} catch (e) {
Log.error('refreshMailbox failed:', e.message);
}
this.updateMailSyncInfo(this.currentMailbox);
},
// Render the header sync state: last sync date, or "syncing…" while in flight.
updateMailSyncInfo(mb) {
const m = mb || this.currentMailbox;
const info = document.getElementById('mail-sync-info');
const btn = document.getElementById('mail-sync-btn');
if (!m) { if (info) info.classList.add('hidden'); return; }
const syncing = !!(m.isSyncing || m.syncing);
if (syncing) {
info.textContent = this.t('mail.syncing', 'Synchronizing…');
info.classList.remove('hidden');
} else if (m.lastSync) {
info.textContent = this.t('mail.sync_info', 'Last sync: {{date}}').replace('{{date}}', this.fmtDate(m.lastSync));
info.classList.remove('hidden');
} else {
info.textContent = '';
info.classList.add('hidden');
}
if (btn) btn.disabled = !!syncing;
},
async doAction(action) {
if (this.selected.size === 0) return;
const emailIds = [...this.selected];
@@ -454,7 +787,7 @@ const App = {
await axios.post(`/api/apxtri/mails/${this.currentTribe}/action`, { emailIds, action }, { validateStatus: () => true });
this.selected.clear();
this.page = 1;
await this.fetchMails(false);
await this.fetchMails();
} catch (e) {
Log.error('doAction failed:', e.message);
this.notify(this.t('common.error', 'An error occurred'), true);
@@ -489,6 +822,15 @@ const App = {
},
i18n: (k, f) => this.t(k, f),
onError: (m) => this.notify(m, true),
onAction: (result) => {
// A server-side plugin action ran (e.g. a BDC was created from the mail).
// Surface its message and refresh the folder so derived state stays fresh.
if (result && result.status === 200) {
const bdcid = (result.data && result.data.bdcid) || '';
this.notify(this.t('plugin.action_done', 'Action completed') + (bdcid ? ' ' + bdcid : ''), false);
if (typeof this.fetchMails === 'function') this.fetchMails();
}
},
});
},
@@ -521,14 +863,15 @@ const App = {
${connBadge}
<span class="badge badge-xs ${mb.right === 'write' ? 'badge-success' : 'badge-info'}">${this.t('mail.right.' + mb.right, mb.right)}</span>
<button class="btn btn-ghost btn-xs btn-square" data-action="druid-edit" data-mailboxid="${this.esc(mb.mailboxid)}" title="${this.t('druid.edit_btn', 'Edit settings')}">⚙️</button>
<button class="btn btn-ghost btn-xs btn-square" data-action="druid-sync" data-mailboxid="${this.esc(mb.mailboxid)}" title="${this.t('druid.sync_btn', 'Sync now')}">↻</button>
<button class="btn btn-ghost btn-xs btn-square text-error" data-action="druid-delete-mailbox" data-mailboxid="${this.esc(mb.mailboxid)}" title="${this.t('druid.delete_btn', 'Delete')}">🗑</button>
</div>
</div>`;
}).join('');
}
const sel = document.getElementById('druid-access-mailbox');
sel.innerHTML = boxes.map((mb) => `<option value="${this.esc(mb.mailboxid)}">${this.esc(mb.label || mb.email)}</option>`).join('');
if (sel) sel.innerHTML = boxes.map((mb) => `<option value="${this.esc(mb.mailboxid)}">${this.esc(mb.label || mb.email)}</option>`).join('');
const tagSel = document.getElementById('druid-tags-mailbox');
if (tagSel) tagSel.innerHTML = boxes.map((mb) => `<option value="${this.esc(mb.mailboxid)}">${this.esc(mb.label || mb.email)}</option>`).join('');
},
/* ---------- Druid: provider-aware connector form ---------- */
druidProvider() {
@@ -735,7 +1078,11 @@ const App = {
},
async druidSync(mailboxid) {
try {
const res = await axios.post(`/api/apxtri/mailboxes/${mailboxid}/sync`, { maxResults: 50 }, { validateStatus: () => true });
const res = await axios.post(`/api/apxtri/mailboxes/${mailboxid}/sync`, {}, { validateStatus: () => true });
if (res.data?.status === 409) {
this.notify(this.t('mail.sync_in_progress', 'A sync is already running'), true);
return;
}
const d = res.data?.data || {};
this.notify(this.t('druid.synced', 'Sync done: {{fetched}} fetched, {{created}} new').replace('{{fetched}}', d.fetched || 0).replace('{{created}}', d.created || 0), res.data?.status !== 200);
this.druidLoadList();
@@ -755,6 +1102,7 @@ const App = {
if (res.data?.status === 200) {
this.notify(this.t('druid.access_set', 'Access updated'));
document.getElementById('druid-access-alias').value = '';
this.druidLoadAccessMembers();
} else {
this.notify(this.t('common.error', 'An error occurred'), true);
}
@@ -763,6 +1111,136 @@ const App = {
this.notify(this.t('common.error', 'An error occurred'), true);
}
},
async druidLoadAccessMembers() {
const mailboxid = document.getElementById('druid-access-mailbox').value;
const tribe = document.getElementById('druid-tribe').value || APP_TRIBE;
const el = document.getElementById('druid-access-members');
if (!el) return;
if (!mailboxid) { el.innerHTML = ''; return; }
el.innerHTML = `<p class="text-xs opacity-50 text-center py-1">${this.t('common.loading', 'Loading…')}</p>`;
try {
const res = await axios.post(`/api/apxtri/odmdb/search/${encodeURIComponent(tribe)}/persons`, { fields: 'all' }, { validateStatus: () => true });
if (res.data?.status === 200 && res.data.data) {
const readerProfile = `mail-${mailboxid}-reader`;
const writerProfile = `mail-${mailboxid}-writer`;
const members = Object.keys(res.data.data)
.map((alias) => ({ alias, ...res.data.data[alias] }))
.filter((p) => {
const profs = Array.isArray(p.profils) ? p.profils : [];
return profs.includes(writerProfile) || profs.includes(readerProfile);
})
.sort((a, b) => (a.alias || '').localeCompare(b.alias || ''));
if (members.length === 0) {
el.innerHTML = `<p class="text-xs opacity-50 text-center py-1">${this.t('druid.no_access_members', 'No members with access yet')}</p>`;
} else {
el.innerHTML = '<p class="text-xs font-medium opacity-70 mb-1">' + this.t('druid.access_members', 'Members with access') + '</p>' +
members.map((m) => {
const profs = Array.isArray(m.profils) ? m.profils : [];
const isWriter = profs.includes(writerProfile);
const badgeClass = isWriter ? 'badge-success' : 'badge-info';
const badgeLabel = isWriter ? this.t('mail.right.rw', 'RW') : this.t('mail.right.r', 'R');
return `<div class="flex items-center justify-between bg-base-200/50 rounded px-2 py-1">` +
`<span class="text-xs font-mono">${this.esc(m.alias)}</span>` +
`<div class="flex items-center gap-1">` +
`<span class="badge badge-xs ${badgeClass}">${this.esc(badgeLabel)}</span>` +
`<button class="btn btn-ghost btn-xs btn-square text-error" data-action="druid-revoke-access" data-mailboxid="${this.esc(mailboxid)}" data-alias="${this.esc(m.alias)}" title="${this.t('druid.revoke_access', 'Revoke')}">✕</button>` +
`</div></div>`;
}).join('');
}
} else {
el.innerHTML = '';
}
} catch (e) {
Log.error('druidLoadAccessMembers:', e.message);
el.innerHTML = '';
}
},
async druidRevokeAccess(mailboxid, alias) {
try {
const res = await axios.put(`/api/apxtri/mailboxes/${mailboxid}/access`, { alias, right: '' }, { validateStatus: () => true });
if (res.data?.status === 200) {
this.notify(this.t('druid.access_revoked', 'Access revoked'));
this.druidLoadAccessMembers();
} else {
this.notify(this.t('common.error', 'An error occurred'), true);
}
} catch (e) {
Log.error('druidRevokeAccess:', e.message);
this.notify(this.t('common.error', 'An error occurred'), true);
}
},
async druidLoadTags() {
const mailboxid = document.getElementById('druid-tags-mailbox')?.value;
const el = document.getElementById('druid-tags-list');
if (!el) return;
if (!mailboxid) { el.innerHTML = ''; return; }
el.innerHTML = `<p class="text-xs opacity-50 text-center py-1">${this.t('common.loading', 'Loading…')}</p>`;
try {
const mb = this._druidBoxes.find((b) => b.mailboxid === mailboxid);
const tags = Array.isArray(mb?.availableTags) ? mb.availableTags : [];
if (tags.length === 0) {
el.innerHTML = `<p class="text-xs opacity-50 text-center py-1">${this.t('druid.no_tags', 'No tags defined yet')}</p>`;
} else {
el.innerHTML = tags.map((t) =>
`<div class="flex items-center justify-between bg-base-200/50 rounded px-2 py-1">` +
`<span class="badge badge-${this.esc(t.color || 'primary')} badge-sm cursor-help" title="${this.esc(t.label || t.key)}">${this.esc(t.label)}</span>` +
`<span class="text-xs opacity-40 font-mono ml-2 flex-1">${this.esc(t.key)}</span>` +
`<button class="btn btn-ghost btn-xs btn-square text-error" data-action="druid-del-tag" data-mailboxid="${this.esc(mailboxid)}" data-tagkey="${this.esc(t.key)}" title="${this.t('druid.remove_tag', 'Remove tag')}">✕</button>` +
`</div>`
).join('');
}
} catch (e) {
Log.error('druidLoadTags:', e.message);
el.innerHTML = '';
}
},
async druidAddTag() {
const mailboxid = document.getElementById('druid-tags-mailbox')?.value;
const key = document.getElementById('druid-tag-key')?.value.trim().toLowerCase();
const label = document.getElementById('druid-tag-label')?.value.trim();
const color = document.getElementById('druid-tag-color')?.value || 'primary';
if (!mailboxid || !key || !label) { this.notify(this.t('druid.tag_key_label_required', 'Key and label required'), true); return; }
const mb = this._druidBoxes.find((b) => b.mailboxid === mailboxid);
const currentTags = Array.isArray(mb?.availableTags) ? [...mb.availableTags] : [];
// Update or add
const existing = currentTags.findIndex((t) => t.key === key);
const newTag = { key, label, color };
if (existing >= 0) currentTags[existing] = newTag;
else currentTags.push(newTag);
try {
const res = await axios.put(`/api/apxtri/mailboxes/${mailboxid}/tags`, { tags: currentTags }, { validateStatus: () => true });
if (res.data?.status === 200) {
this.notify(this.t('druid.tag_added', 'Tag added'));
document.getElementById('druid-tag-key').value = '';
document.getElementById('druid-tag-label').value = '';
this.druidLoadList();
this.druidLoadTags();
} else {
this.notify(this.t('common.error', 'An error occurred'), true);
}
} catch (e) {
Log.error('druidAddTag:', e.message);
this.notify(this.t('common.error', 'An error occurred'), true);
}
},
async druidDelTag(mailboxid, tagkey) {
const mb = this._druidBoxes.find((b) => b.mailboxid === mailboxid);
const currentTags = Array.isArray(mb?.availableTags) ? mb.availableTags.filter((t) => t.key !== tagkey) : [];
try {
const res = await axios.put(`/api/apxtri/mailboxes/${mailboxid}/tags`, { tags: currentTags }, { validateStatus: () => true });
if (res.data?.status === 200) {
this.notify(this.t('druid.tag_removed', 'Tag removed'));
this.druidLoadList();
this.druidLoadTags();
} else {
this.notify(this.t('common.error', 'An error occurred'), true);
}
} catch (e) {
Log.error('druidDelTag:', e.message);
this.notify(this.t('common.error', 'An error occurred'), true);
}
},
async druidDelete(mailboxid) {
if (!confirm(this.t('druid.confirm_delete', 'Delete this mailbox?'))) return;
try {
@@ -876,9 +1354,10 @@ const App = {
</div>
</div>`).join('');
document.getElementById('plugin-buttons-editor').innerHTML = m.buttons.map((b, i) => `
<div class="grid grid-cols-[1fr_2fr_auto_auto_auto] gap-1 items-center" data-plugin-button-row>
<div class="grid grid-cols-[1fr_2fr_1fr_auto_auto_auto] gap-1 items-center" data-plugin-button-row>
<input type="text" class="input input-xs" value="${this.esc(b.label || '')}" data-button-label="${i}" placeholder="${this.t('plugin.btn_label', 'label')}" />
<input type="text" class="input input-xs font-mono" value="${this.esc(b.urlTemplate || '')}" data-button-url="${i}" placeholder="${this.t('plugin.btn_url', 'url template')}" spellcheck="false" />
<input type="text" class="input input-xs font-mono" value="${this.esc(b.action || '')}" data-button-action="${i}" placeholder="${this.t('plugin.btn_action', 'server action')}" spellcheck="false" />
<select class="select select-xs" data-button-open="${i}">
<option value="tab"${b.open !== 'self' ? ' selected' : ''}>${this.t('plugin.open_tab', 'new tab')}</option>
<option value="self"${b.open === 'self' ? ' selected' : ''}>${this.t('plugin.open_self', 'same tab')}</option>
@@ -907,6 +1386,7 @@ const App = {
document.querySelectorAll('[data-field-required]').forEach((el) => { const i = Number(el.dataset.fieldRequired); if (m.fields[i]) m.fields[i].required = el.checked; });
document.querySelectorAll('[data-button-label]').forEach((el) => { const i = Number(el.dataset.buttonLabel); if (m.buttons[i]) m.buttons[i].label = el.value; });
document.querySelectorAll('[data-button-url]').forEach((el) => { const i = Number(el.dataset.buttonUrl); if (m.buttons[i]) m.buttons[i].urlTemplate = el.value; });
document.querySelectorAll('[data-button-action]').forEach((el) => { const i = Number(el.dataset.buttonAction); if (m.buttons[i]) { const v = el.value.trim(); if (v) m.buttons[i].action = v; else delete m.buttons[i].action; } });
document.querySelectorAll('[data-button-open]').forEach((el) => { const i = Number(el.dataset.buttonOpen); if (m.buttons[i]) m.buttons[i].open = el.value; });
document.querySelectorAll('[data-button-confirm]').forEach((el) => { const i = Number(el.dataset.buttonConfirm); if (m.buttons[i]) m.buttons[i].confirm = el.checked; });
return m;
@@ -921,7 +1401,7 @@ const App = {
this.pluginUpdateJson();
},
pluginAddButton() {
this._pluginModel.buttons.push({ label: '', urlTemplate: '', open: 'tab', confirm: false });
this._pluginModel.buttons.push({ label: '', urlTemplate: '', action: '', open: 'tab', confirm: false });
this.pluginRenderEditors();
this.pluginUpdateJson();
},
@@ -1013,6 +1493,8 @@ const App = {
Menu.showScreen('druid');
this.druidLoadList();
this.druidLoadPlugins();
this.druidLoadAccessMembers();
this.druidLoadTags();
break;
case 'goto-addmail':
this.druidResetForm();
@@ -1030,11 +1512,17 @@ const App = {
break;
case 'mail-search-go':
this.page = 1;
this.fetchMails(false);
this.fetchMails();
break;
case 'mail-loadmore':
case 'mail-sync':
this.mailSync();
break;
case 'mail-prev':
if (this.page > 1) { this.page -= 1; this.fetchMails(); }
break;
case 'mail-next':
this.page += 1;
this.fetchMails(true);
this.fetchMails();
break;
case 'open-mail':
this.openMailDetail(el.dataset.emailid);
@@ -1045,6 +1533,23 @@ const App = {
case 'mail-dl-attach':
this.downloadAttachment(parseInt(el.dataset.index, 10));
break;
case 'mail-preview-attach':
this.previewAttachment(parseInt(el.dataset.index, 10));
break;
case 'mail-tab-message':
this.setDetailTab('message');
break;
case 'mail-tab-attachment':
if (this._previewUrl || this._previewTab === 'attachment') {
this.setDetailTab('attachment');
} else {
// no preview loaded yet: preview the first previewable attachment
const atts = Array.isArray(this.currentDetail?.data?.attachments) ? this.currentDetail.data.attachments : [];
const idx = atts.findIndex((a) => this.previewableType(a.mimeType));
if (idx >= 0) this.previewAttachment(idx);
else this.notify(this.t('mail.preview_unsupported', 'This attachment cannot be previewed'), true);
}
break;
case 'mail-mark-read':
this.doAction('read');
break;
@@ -1057,13 +1562,31 @@ const App = {
case 'mail-trash-one':
this.trashCurrentMail();
break;
case 'mail-untag':
ev.stopPropagation();
this.mailToggleTag(el.dataset.tag);
break;
case 'mail-add-tag':
ev.stopPropagation();
this.mailToggleTag(el.dataset.tag);
this.hideTagDropdown();
break;
case 'mail-toggle-tag-dropdown':
this.renderTagDropdown();
break;
case 'mail-filter-tag-toggle':
this.toggleTagFilterDropdown();
break;
case 'mail-filter-tag-mode':
this.setTagFilterMode(el.dataset.mode);
break;
case 'mail-filter-tag-check':
this.toggleTagFilter(el.dataset.tag);
break;
case 'druid-create':
if (this.druidEditId) this.druidSave();
else this.druidCreate();
break;
case 'druid-sync':
this.druidSync(el.dataset.mailboxid);
break;
case 'druid-set-access':
this.druidSetAccess();
break;
@@ -1071,6 +1594,17 @@ const App = {
ev.stopPropagation();
this.druidDelete(el.dataset.mailboxid);
break;
case 'druid-revoke-access':
ev.stopPropagation();
this.druidRevokeAccess(el.dataset.mailboxid, el.dataset.alias);
break;
case 'druid-add-tag':
this.druidAddTag();
break;
case 'druid-del-tag':
ev.stopPropagation();
this.druidDelTag(el.dataset.mailboxid, el.dataset.tagkey);
break;
case 'plugin-new':
this.pluginNew();
break;
@@ -1111,7 +1645,18 @@ const App = {
}
if (ev.target.id === 'mail-filter-status') {
this.page = 1;
this.fetchMails(false);
this.fetchMails();
}
if (ev.target.id === 'druid-access-mailbox') {
this.druidLoadAccessMembers();
}
if (ev.target.id === 'druid-tribe') {
this.druidLoadList();
this.druidLoadAccessMembers();
this.druidLoadTags();
}
if (ev.target.id === 'druid-tags-mailbox') {
this.druidLoadTags();
}
});
// plugin editor live updates
@@ -1123,14 +1668,25 @@ const App = {
// live search (debounced) + enter key
document.getElementById('mail-search').addEventListener('input', () => {
clearTimeout(this._searchTimer);
this._searchTimer = setTimeout(() => { this.page = 1; this.fetchMails(false); }, 500);
this._searchTimer = setTimeout(() => { this.page = 1; this.fetchMails(); }, 500);
});
document.getElementById('mail-filter-from').addEventListener('change', () => {
this.page = 1;
this.fetchMails(false);
this.fetchMails();
});
// close tag dropdowns on outside click
document.addEventListener('click', (ev) => {
const dd = document.getElementById('detail-tag-dropdown');
if (dd && !dd.classList.contains('hidden') && !ev.target.closest('#detail-tag-dropdown') && !ev.target.closest('[data-action="mail-toggle-tag-dropdown"]')) {
this.hideTagDropdown();
}
const filterDd = document.getElementById('mail-filter-tag-dropdown');
if (filterDd && !filterDd.classList.contains('hidden') && !ev.target.closest('#mail-filter-tag-dropdown') && !ev.target.closest('[data-action="mail-filter-tag-toggle"]')) {
filterDd.classList.add('hidden');
}
});
// druid tribe selector
document.getElementById('druid-tribe').addEventListener('change', () => { this.druidLoadList(); this.druidLoadPlugins(); });
document.getElementById('druid-tribe').addEventListener('change', () => { this.druidLoadList(); this.druidLoadPlugins(); this.druidLoadAccessMembers(); this.druidLoadTags(); });
// provider switch → show/hide connector fields
document.getElementById('druid-provider').addEventListener('change', (ev) => this.druidShowProvider(ev.target.value));
// alias switch → reload
+36 -6
View File
@@ -31,19 +31,36 @@
"mail.mark_read": "Mark read",
"mail.mark_unread": "Mark unread",
"mail.trash": "Trash",
"mail.load_more": "Load more",
"mail.sync": "Sync now",
"mail.sync_info": "Last sync: {{date}}",
"mail.syncing": "Synchronizing…",
"mail.sync_in_progress": "A sync is already running",
"mail.pager.prev": "Previous",
"mail.pager.next": "Next",
"mail.pager.page": "Page {{page}} / {{total}}",
"mail.tab.message": "Message",
"mail.tab.attachment": "Attachment",
"mail.preview": "View",
"mail.preview_unsupported": "This attachment cannot be previewed",
"mail.back": "Back to list",
"mail.select_hint": "Select a message to read it",
"mail.to": "To",
"mail.cc": "Cc",
"mail.right.read": "read",
"mail.right.write": "write",
"mail.right.read": "Read",
"mail.right.write": "Read + Write",
"mail.right.rw": "RW",
"mail.right.r": "R",
"druid.access_members": "Members with access",
"druid.revoke_access": "Revoke",
"druid.access_revoked": "Access revoked",
"druid.no_access_members": "No members with access yet",
"mail.no_mails": "No message",
"mail.has_attachments": "📎 attachments",
"mail.no_subject": "(no subject)",
"mail.attachment_file": "attachment.{{ext}}",
"mail.size_kb": "{{size}} kB",
"mail.download_file": "attachment-{{index}}",
"mail.popup_blocked": "Pop-up blocked — allow pop-ups to preview attachments",
"mail.trash_one_btn": "Delete this mail",
"mail.trash_one_confirm": "Move this mail to trash?",
"mail.deleted_to_trash": "Mail moved to trash",
@@ -74,6 +91,8 @@
"plugin.field_required": "req.",
"plugin.btn_label": "label",
"plugin.btn_url": "url template",
"plugin.btn_action": "server action",
"plugin.action_done": "Action completed",
"plugin.open_tab": "new tab",
"plugin.open_self": "same tab",
"plugin.btn_confirm": "confirm",
@@ -122,8 +141,7 @@
"druid.label": "Label",
"druid.tribe": "Tribe",
"druid.create_btn": "Create & connect",
"druid.connect_hint": "Create first, then select it in the list and click ↻ to sync.",
"druid.sync_btn": "Sync now",
"druid.connect_hint": "Create first, then open it from Home and click ↻ to sync.",
"druid.synced": "Sync done: {{fetched}} fetched, {{created}} new",
"druid.access_title": "Manage access rights",
"druid.right_none": "none (revoke)",
@@ -137,5 +155,17 @@
"druid.select_first": "Select a mailbox in the list first",
"druid.created": "Mailbox created",
"druid.access_set": "Access updated",
"druid.alias_required": "Alias required"
"druid.alias_required": "Alias required",
"druid.manage_tags": "Manage tags",
"druid.no_tags": "No tags defined",
"druid.tag_key_label_required": "Key and label required",
"druid.tag_added": "Tag added",
"druid.tag_removed": "Tag removed",
"druid.remove_tag": "Remove tag",
"mail.tags_label": "Tags",
"mail.tag_filter": "Tags",
"mail.no_more_tags": "All tags applied",
"mail.no_tags_available": "No tags available",
"mail.tag_added": "Tag added",
"mail.tag_removed": "Tag removed"
}
+36 -6
View File
@@ -31,19 +31,36 @@
"mail.mark_read": "Marquer lu",
"mail.mark_unread": "Marquer non lu",
"mail.trash": "Corbeille",
"mail.load_more": "Charger plus",
"mail.sync": "Synchroniser",
"mail.sync_info": "Dernière synchro : {{date}}",
"mail.syncing": "Synchronisation en cours…",
"mail.sync_in_progress": "Une synchronisation est déjà en cours",
"mail.pager.prev": "Précédent",
"mail.pager.next": "Suivant",
"mail.pager.page": "Page {{page}} / {{total}}",
"mail.tab.message": "Message",
"mail.tab.attachment": "Pièce jointe",
"mail.preview": "Aperçu",
"mail.preview_unsupported": "Cette pièce jointe ne peut pas être affichée",
"mail.back": "Retour à la liste",
"mail.select_hint": "Sélectionnez un message pour le lire",
"mail.to": "À",
"mail.cc": "Cc",
"mail.right.read": "lecture",
"mail.right.write": "écriture",
"mail.right.read": "Lecture",
"mail.right.write": "Lecture + Écriture",
"mail.right.rw": "RW",
"mail.right.r": "R",
"druid.access_members": "Membres avec accès",
"druid.revoke_access": "Révoquer",
"druid.access_revoked": "Accès révoqué",
"druid.no_access_members": "Aucun membre avec accès",
"mail.no_mails": "Aucun message",
"mail.has_attachments": "📎 pièces jointes",
"mail.no_subject": "(sans objet)",
"mail.attachment_file": "pièce-jointe.{{ext}}",
"mail.size_kb": "{{size}} Ko",
"mail.download_file": "pièce-jointe-{{index}}",
"mail.popup_blocked": "Pop-up bloqué — autorisez les pop-ups pour prévisualiser les pièces jointes",
"mail.trash_one_btn": "Supprimer ce mail",
"mail.trash_one_confirm": "Déplacer ce mail dans la corbeille ?",
"mail.deleted_to_trash": "Mail déplacé dans la corbeille",
@@ -74,6 +91,8 @@
"plugin.field_required": "oblig.",
"plugin.btn_label": "libellé",
"plugin.btn_url": "modèle d'URL",
"plugin.btn_action": "action serveur",
"plugin.action_done": "Action terminée",
"plugin.open_tab": "nouvel onglet",
"plugin.open_self": "même onglet",
"plugin.btn_confirm": "confirmer",
@@ -122,8 +141,7 @@
"druid.label": "Libellé",
"druid.tribe": "Tribu",
"druid.create_btn": "Créer & connecter",
"druid.connect_hint": "Créez d'abord la boîte, puis sélectionnez-la dans la liste et cliquez sur ↻ pour synchroniser.",
"druid.sync_btn": "Synchroniser",
"druid.connect_hint": "Créez d'abord la boîte, puis ouvrez-la depuis l'accueil et cliquez sur ↻ pour la synchroniser.",
"druid.synced": "Synchro finie : {{fetched}} récupérés, {{created}} nouveaux",
"druid.access_title": "Gérer les droits d'accès",
"druid.right_none": "aucun (révoquer)",
@@ -137,5 +155,17 @@
"druid.select_first": "Sélectionnez une boîte dans la liste",
"druid.created": "Boîte créée",
"druid.access_set": "Droits mis à jour",
"druid.alias_required": "Alias obligatoire"
"druid.alias_required": "Alias obligatoire",
"druid.manage_tags": "Gérer les tags",
"druid.no_tags": "Aucun tag défini",
"druid.tag_key_label_required": "Clé et libellé obligatoires",
"druid.tag_added": "Tag ajouté",
"druid.tag_removed": "Tag supprimé",
"druid.remove_tag": "Supprimer le tag",
"mail.tags_label": "Tags",
"mail.tag_filter": "Tags",
"mail.no_more_tags": "Tous les tags appliqués",
"mail.no_tags_available": "Aucun tag disponible",
"mail.tag_added": "Tag ajouté",
"mail.tag_removed": "Tag retiré"
}
+64 -22
View File
@@ -177,6 +177,8 @@ const LG = {
'webapps.badge_disabled': 'Disabled',
'webapps.delete_confirm': 'Delete this webapp?',
'webapps.open_app': 'Open app',
'webapps.open_app_com': 'Open the com sub-application',
'webapps.tribe_label': 'Web applications of {{tribe}}',
'label.alias': 'Alias',
'label.owner': 'Owner',
'label.profils': 'Profiles',
@@ -288,13 +290,15 @@ var App = {
_membersSelected: null,// selected member alias
_membersDruid: false, // whether the active alias is druid of this tribe
_membersAcl: [], // member access ACL entries
_membersProfilOptions: null, // profil option doc of the tribe in the members screen
_cmEditors: {}, // CodeMirror instances for JSON fields
pendingNavigation: null, // {screen, callback} pending after unsaved modal
currentScreen: null, // tracks the visible screen name
// === WEBAPP STATE ===
webappsList: [], // [{name, dns, description, enabled, navtype}]
webappsList: [], // [{name, dns, description, enabled, navtype, subapps}]
currentWebappTribeId: null,// tribe being managed in webapps screens
currentWebappCanManage: false, // active alias is druid in currentWebappTribeId (edit/create allowed)
editingWebappName: null, // app name being edited (null = create mode)
_loadedWebappConf: null, // full appconf.json loaded in edit mode (preserve unrendered fields)
_webappDnsActive: null, // {field, value} DNS served by this nation, from GET /appconf
@@ -644,6 +648,8 @@ var App = {
var isDruid = tribe.person && tribe.person.person && tribe.person.person.profils && tribe.person.person.profils.indexOf('druids') !== -1;
var acc = tribe.myAccess || {};
if (acc.druid === true) isDruid = true;
var tribeProfils = (tribe.person && tribe.person.person && tribe.person.person.profils) ? tribe.person.person.profils : [];
var canViewWebapps = isDruid || tribeProfils.indexOf('supports') !== -1 || tribeProfils.indexOf('experts') !== -1;
var isMayor = alias ? ((Auth.getIdentity(alias)?.profils || []).indexOf('mayors') !== -1) : false;
var schemasR = (acc.schemas && acc.schemas.R) ? acc.schemas.R : [];
var hasFiles = acc.files === true;
@@ -681,7 +687,7 @@ var App = {
(showSchemas ? '<button class="btn btn-xs btn-ghost" data-action="objects-tribe" data-tribe-id="' + tribe.tribeId + '" title="' + LG.t('objects.schemas_title') + '">' +
'<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/></svg>' +
'</button>' : '') +
(isDruid ? '<button class="btn btn-xs btn-ghost" data-action="webapp-tribe" data-tribe-id="' + tribe.tribeId + '" title="' + LG.t('webapps.title') + '">' +
(canViewWebapps ? '<button class="btn btn-xs btn-ghost" data-action="webapp-tribe" data-tribe-id="' + tribe.tribeId + '" title="' + LG.t('webapps.title') + '">' +
'<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 013 12c0-1.605.42-3.113 1.157-4.418"/></svg>' +
'</button>' : '') +
(hasFiles ? '<button class="btn btn-xs btn-ghost" data-action="files-tribe" data-tribe-id="' + tribe.tribeId + '" title="' + LG.t('drawer.files') + '">' +
@@ -861,6 +867,10 @@ var App = {
var listEl = document.getElementById('members-list');
listEl.innerHTML = '<div class="flex justify-center py-4"><span class="loading loading-spinner loading-sm"></span></div>';
try {
this._membersProfilOptions = null;
var optRes = await axios.get('/api/apxtri/wwws/option/' + encodeURIComponent(tribeId) + '/profil', { validateStatus: function () { return true; } });
if (optRes.data.status === 200) this._membersProfilOptions = optRes.data.data;
var res = await axios.get('/api/apxtri/pagans/members/' + encodeURIComponent(tribeId), { validateStatus: function () { return true; } });
if (res.data.status !== 200) {
listEl.innerHTML = '<p class="text-xs opacity-50">' + (res.data.msg || 'error') + '</p>';
@@ -998,21 +1008,25 @@ var App = {
return ['mayors', 'admin', 'system', 'anonymous', 'pagans'];
},
_membersProfil() {
return this._membersProfilOptions || this._getProfilOptions();
},
_assignableProfils() {
var opts = this._getProfilOptions();
var opts = this._membersProfil();
var forbidden = this._forbiddenProfiles();
return (opts.lst_idx || []).filter(function (p) { return forbidden.indexOf(p) === -1; });
},
_profilLabel(p) {
var opts = this._getProfilOptions();
var opts = this._membersProfil();
var itms = opts.itms || {};
return (itms[p] && (itms[p].title || itms[p].titre)) ? (itms[p].title || itms[p].titre) : p;
},
_renderProfilBadges(profils, m) {
var self = this;
var opts = this._getProfilOptions();
var opts = this._membersProfil();
var itms = opts.itms || {};
var isDruid = this._membersDruid;
return (profils || []).map(function (p) {
@@ -2390,8 +2404,13 @@ var App = {
async showWebapps(tribeId) {
Log.debug('showWebapps:', tribeId);
this.currentWebappTribeId = tribeId;
this.currentWebappCanManage = this._canManageWebapps(tribeId);
document.getElementById('webapps-tribeid').textContent = LG.t('webapps.tribe_label', { tribe: tribeId });
document.getElementById('btn-webapp-create').dataset.tribeId = tribeId;
var createBtn = document.getElementById('btn-webapp-create');
if (createBtn) {
createBtn.dataset.tribeId = tribeId;
createBtn.classList.toggle('hidden', !this.currentWebappCanManage);
}
var listEl = document.getElementById('webapps-list');
listEl.innerHTML = '<div class="flex justify-center py-8"><span class="loading loading-spinner loading-md text-primary"></span></div>';
@@ -2411,9 +2430,29 @@ var App = {
}
},
_canManageWebapps: function (tribeId) {
var entry = (this.tribes || []).find(function (t) { return t.tribeId === tribeId; });
var fromAccess = !!(entry && entry.myAccess && entry.myAccess.druid === true);
if (entry && entry.person && entry.person.person && entry.person.person.profils && entry.person.person.profils.length > 0) {
return entry.person.person.profils.indexOf('druids') !== -1 || fromAccess;
}
var alias = Auth.getActiveAlias();
var tribeProfils = alias ? (Auth.getTribeProfils(alias, tribeId) || []) : [];
if (tribeProfils.length > 0) return tribeProfils.indexOf('druids') !== -1 || fromAccess;
if (fromAccess) return true;
return alias ? (Auth.getIdentity(alias)?.profils || []).indexOf('druids') !== -1 : false;
},
resolveAppUrl: function (w) {
if (w.dns) return 'https://' + w.dns;
if (w.name === 'mainapp') return window.location.origin;
return window.location.origin + '/' + w.name;
},
renderWebapps: function () {
var listEl = document.getElementById('webapps-list');
var webapps = this.webappsList;
var canManage = this.currentWebappCanManage === true;
if (!webapps || webapps.length === 0) {
listEl.innerHTML = '<div class="text-center py-8"><p class="text-sm opacity-60">' + LG.t('webapps.empty') + '</p></div>';
@@ -2424,31 +2463,34 @@ var App = {
var enabledBadge = w.enabled !== false
? '<span class="badge badge-success badge-xs">' + LG.t('webapps.badge_enabled') + '</span>'
: '<span class="badge badge-ghost badge-xs">' + LG.t('webapps.badge_disabled') + '</span>';
var appUrl;
if (w.dns) {
appUrl = 'https://' + w.dns;
} else if (w.name === 'mainapp') {
appUrl = window.location.origin;
} else {
appUrl = window.location.origin + '/' + w.name;
}
var appUrl = App.resolveAppUrl(w);
var subapps = Array.isArray(w.subapps) ? w.subapps : [];
var comLinkHtml = subapps.indexOf('com') !== -1
? '<a class="btn btn-xs btn-outline btn-secondary" href="' + appUrl + '/com" target="_blank" rel="noopener noreferrer" title="' + LG.t('webapps.open_app_com') + '">' +
'<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.625 12a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H8.25m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0H12m4.125 0a.375.375 0 1 1-.75 0 .375.375 0 0 1 .75 0Zm0 0h-.375M21 12c0 4.556-4.03 8.25-9 8.25a9.764 9.764 0 0 1-2.555-.337A5.972 5.972 0 0 1 5.41 20.97a5.969 5.969 0 0 1-.474-.065 4.48 4.48 0 0 0 .978-2.025c.09-.457-.133-.901-.467-1.226C3.93 16.178 3 14.189 3 12c0-4.556 4.03-8.25 9-8.25s9 3.694 9 8.25Z"/></svg>' +
'</a>'
: '';
var editBtnHtml = canManage
? '<button class="btn btn-xs btn-ghost" data-action="webapp-edit" data-app-name="' + App._escapeHtml(w.name) + '">' +
'<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10"/></svg>' +
'</button>'
: '';
return '<div class="card bg-base-100 shadow-sm">' +
'<div class="card-body p-3">' +
'<div class="flex items-center justify-between">' +
'<div class="flex items-center justify-between gap-2">' +
'<div class="min-w-0 flex-1">' +
'<p class="font-medium text-sm truncate">' + App._escapeHtml(w.name) + '</p>' +
(w.dns ? '<p class="text-xs opacity-50 truncate">' + App._escapeHtml(w.dns) + '</p>' : '') +
(w.description ? '<p class="text-xs opacity-40 truncate">' + App._escapeHtml(w.description) + '</p>' : '') +
'</div>' +
'<div class="flex items-center gap-2 shrink-0">' +
'<div class="flex flex-wrap items-center justify-end gap-2 shrink-0">' +
enabledBadge +
'<button class="btn btn-xs btn-ghost" data-action="webapp-edit" data-app-name="' + App._escapeHtml(w.name) + '">' +
'<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m16.862 4.487 1.687-1.688a1.875 1.875 0 1 1 2.652 2.652L10.582 16.07a4.5 4.5 0 0 1-1.897 1.13L6 18l.8-2.685a4.5 4.5 0 0 1 1.13-1.897l8.932-8.931Zm0 0L19.5 7.125M18 14v4.75A2.25 2.25 0 0 1 15.75 21H5.25A2.25 2.25 0 0 1 3 18.75V8.25A2.25 2.25 0 0 1 5.25 6H10"/></svg>' +
'</button>' +
'<a class="btn btn-xs btn-outline btn-secondary" href="' + appUrl + '/" title="' + LG.t('webapps.open_app') + '">' +
editBtnHtml +
'<a class="btn btn-xs btn-outline btn-secondary" href="' + appUrl + '/" target="_blank" rel="noopener noreferrer" title="' + LG.t('webapps.open_app') + '">' +
'<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"/></svg>' +
'</a>' +
comLinkHtml +
'</div>' +
'</div>' +
'</div>' +
@@ -2832,7 +2874,7 @@ var App = {
sectionHtml += '<div class="collapse-title font-medium text-sm">' + LG.t('webapps.form_subapp') + ': ' + key + '</div>';
sectionHtml += '<div class="collapse-content space-y-2">';
sectionHtml += '<div class="flex justify-between">';
sectionHtml += '<a class="btn btn-xs btn-outline" href="https://' + dns + '/' + key + '/draft" target="_blank">' + LG.t('webapps.form_draft_link') + '</a>';
sectionHtml += '<a class="btn btn-xs btn-outline" href="https://' + dns + '/' + key + '/draft" target="_blank" rel="noopener noreferrer">' + LG.t('webapps.form_draft_link') + '</a>';
sectionHtml += '<button class="btn btn-xs btn-primary" data-action="subapp-buildpwa" data-subapp-key="' + key + '">' + LG.t('webapps.form_build_prod') + '</button>';
sectionHtml += '</div>';
sectionHtml += '<div class="flex justify-between">';
@@ -2911,7 +2953,7 @@ var App = {
sectionHtml += '<div class="collapse-title font-medium text-sm">' + LG.t('webapps.form_subapp') + ': ' + key + '</div>';
sectionHtml += '<div class="collapse-content space-y-2">';
sectionHtml += '<div class="flex justify-between">';
sectionHtml += '<a class="btn btn-xs btn-outline" href="https://' + dns + '/' + key + '/draft" target="_blank">' + LG.t('webapps.form_draft_link') + '</a>';
sectionHtml += '<a class="btn btn-xs btn-outline" href="https://' + dns + '/' + key + '/draft" target="_blank" rel="noopener noreferrer">' + LG.t('webapps.form_draft_link') + '</a>';
sectionHtml += '<button class="btn btn-xs btn-primary" data-action="subapp-buildpwa" data-subapp-key="' + key + '">' + LG.t('webapps.form_build_prod') + '</button>';
sectionHtml += '</div>';
sectionHtml += '<div class="flex justify-between">';
+2 -1
View File
@@ -43,5 +43,6 @@
"label.imgavatar": "Avatar",
"label.apxgain": "Monatliche APX3-Gewinne",
"label.druidsinfo": "Druiden-Notizen",
"label.grants": "Zugriffsrechte"
"label.grants": "Zugriffsrechte",
"webapps.open_app_com": "Die com-Unteranwendung öffnen"
}
+1
View File
@@ -238,6 +238,7 @@
"webapps.form_subapp_deleted": "Sub-application {{name}} deleted",
"webapps.delete_confirm": "Delete this webapp?",
"webapps.open_app": "Open app",
"webapps.open_app_com": "Open the com sub-application",
"webapps.tribe_label": "Web applications of {{tribe}}",
"webapps.name_label": "name",
"webapps.dns_label": "dns",
+2 -1
View File
@@ -43,5 +43,6 @@
"label.imgavatar": "Avatar",
"label.apxgain": "Ganancias APX3 mensuales",
"label.druidsinfo": "Notas del druid",
"label.grants": "Derechos de acceso"
"label.grants": "Derechos de acceso",
"webapps.open_app_com": "Abrir la subaplicación com"
}
+1
View File
@@ -238,6 +238,7 @@
"webapps.form_subapp_deleted": "Sous-application {{name}} supprimée",
"webapps.delete_confirm": "Supprimer cette application web ?",
"webapps.open_app": "Ouvrir l'app",
"webapps.open_app_com": "Ouvrir la sous-application com",
"webapps.tribe_label": "Applications web de {{tribe}}",
"webapps.name_label": "nom",
"webapps.dns_label": "dns",
+2 -1
View File
@@ -43,5 +43,6 @@
"label.imgavatar": "Avatar",
"label.apxgain": "Guadagni APX3 mensili",
"label.druidsinfo": "Note del druid",
"label.grants": "Diritti di accesso"
"label.grants": "Diritti di accesso",
"webapps.open_app_com": "Apri la sottoapplicazione com"
}
+2 -1
View File
@@ -43,5 +43,6 @@
"label.imgavatar": "Avatar",
"label.apxgain": "Maandelijkse APX3-winsten",
"label.druidsinfo": "Druïde-notities",
"label.grants": "Toegangsrechten"
"label.grants": "Toegangsrechten",
"webapps.open_app_com": "De com-subtoepassing openen"
}
+51
View File
@@ -165,6 +165,53 @@ const getUserProfiles = async (header) => {
return profiles;
};
/**
* Attaches the resolved tribe context to the request.
*
* The authenticated profiles are always those of the person record in the
* *requested* tribe (see getUserProfiles / the token's xtribe), so they can be
* exposed directly without any extra IO. `header.xprofils` is intentionally
* left untouched for backward compatibility; the context is an additive,
* explicit view of the same data plus a lazy accessor for the full record.
*
* - `req.tribeContext` : { tribe, alias, profils }
* - `header.tribeContext` : same plain object, so model helpers that only
* receive `role` (=== req.session.header) share it
* - `req.getTribePerson()` : memoized lazy read of the tribe person record
*
* @param {object} req - The Express request object.
* @param {object} header - The request's session header.
*/
const attachTribeContext = (req, header) => {
const tribe = header.xtribe;
const alias = header.xalias;
const profils = Array.isArray(header.xprofils) ? header.xprofils.slice() : [];
const ctx = { tribe, alias, profils };
req.tribeContext = ctx;
header.tribeContext = ctx;
let personPromise = null;
req.getTribePerson = () => {
if (!personPromise) {
personPromise = (async () => {
const personPath = path.join(
DATA_PATH,
tribe,
"objects",
"persons",
"itm",
`${alias}.json`
);
if (await fs.pathExists(personPath)) {
return fs.readJson(personPath);
}
return null;
})();
}
return personPromise;
};
};
/**
* Main authentication middleware.
* Orchestrates the authentication flow.
@@ -199,6 +246,7 @@ function isAuthenticated(continueAsAnonymous = false) {
logger.debug("Anonymous user allowed by configuration.");
header.xprofils = ["anonymous"];
console.log("DEBUG: Allowing anonymous access, returning next()");
attachTribeContext(req, header);
return next();
}
logger.warn("Anonymous access not allowed for this endpoint continueAsAnonymous is false by default.");
@@ -220,12 +268,14 @@ function isAuthenticated(continueAsAnonymous = false) {
if (Array.isArray(tokenContent)) {
header.xprofils = tokenContent;
logger.info(`Authenticated via legacy token file for ${header.xalias}.`);
attachTribeContext(req, header);
return next();
}
// New format: { xtribe, profils } - tribe must match current request
if (tokenContent && tokenContent.xtribe === header.xtribe && Array.isArray(tokenContent.profils)) {
header.xprofils = tokenContent.profils;
logger.info(`Authenticated via token file for ${header.xalias}.`);
attachTribeContext(req, header);
return next();
}
logger.info(
@@ -306,6 +356,7 @@ function isAuthenticated(continueAsAnonymous = false) {
});
if (log) console.log(`Cookie set: ${cookieName} for alias ${header.xalias}`);
attachTribeContext(req, header);
next();
};
}
+70
View File
@@ -0,0 +1,70 @@
/**
* @file middlewares/resolveTribeFromHost.js
* @description Middleware that resolves the tribe from the HTTP Host header
* by matching it against discovered webapps' DNS. Overrides xtribe in the
* session header so that isAuthenticated() resolves profiles from the correct
* tribe data namespace.
*
* Usage: place after checkHeaders() and before isAuthenticated().
*
* router.get("/some-route", checkHeaders(), resolveTribeFromHost(), isAuthenticated(), handler);
*/
const logger = require("../utils/logger");
let _cachedWebapps = null;
let _cacheTime = 0;
const CACHE_TTL = 60 * 1000; // 1 minute
/**
* Lazily resolves the tribe for a given hostname by scanning discovered webapps.
* @param {string} host - The HTTP Host header value.
* @returns {string|null} The resolved tribe ID, or null if no match.
*/
function resolveTribeFromHostHeader(host) {
if (!host) return null;
const now = Date.now();
if (!_cachedWebapps || now - _cacheTime > CACHE_TTL) {
try {
const Caddy = require("../models/Caddy.js");
_cachedWebapps = Caddy.discoverWebapps();
_cacheTime = now;
} catch (e) {
logger.warn("resolveTribeFromHost: failed to discover webapps:", e.message);
return null;
}
}
for (const wa of _cachedWebapps) {
if (wa.dns && wa.dns === host) {
return wa.tribe;
}
}
return null;
}
/**
* Express middleware: resolves tribe from Host header and overrides xtribe
* in req.session.header if a match is found.
* @returns {Function} Express middleware
*/
function resolveTribeFromHost() {
return (req, res, next) => {
const host = req.headers.host || req.hostname;
const resolvedTribe = resolveTribeFromHostHeader(host);
if (resolvedTribe && req.session?.header) {
const originalTribe = req.session.header.xtribe;
if (originalTribe !== resolvedTribe) {
logger.info(`resolveTribeFromHost: overriding xtribe "${originalTribe}" → "${resolvedTribe}" (host: ${host})`);
req.session.header.xtribe = resolvedTribe;
}
}
next();
};
}
module.exports = resolveTribeFromHost;
+104 -5
View File
@@ -151,7 +151,9 @@ class Caddy {
* Generates API documentation for webapps named 'apidoc'.
* Checks if appconf.json contains valid apidoc config,
* then runs apidoc to generate the documentation.
* In dev mode, skips regeneration if dist already exists.
* Input paths that do not exist on disk are dropped, and the generation is
* skipped when none is left, so one bad input cannot abort apidoc.
* Documentation is always regenerated into runtimePath.
*/
static async generateApidoc(webapp) {
const confPath = path.join(webapp.sourcePath, 'appconf.json');
@@ -178,16 +180,33 @@ class Caddy {
// Ensure output directory exists
fs.ensureDirSync(runtimePath);
// Create temporary conf.json for apidoc CLI
// Run apidoc from source path to resolve relative paths
const sourcePath = webapp.sourcePath;
// apidoc fails hard on an input path that does not exist (ENOENT on
// scandir, no documentation produced at all), so the ones missing on
// disk are dropped here. Relative inputs are resolved against
// sourcePath, the cwd apidoc runs from below. An input pointing at an
// empty directory is legitimate and is kept.
const resolvedInput = (conf.apidoc.input || [])
.map(p => p.replace(/\{\{NODEPATH\}\}/g, NODE_PATH));
const input = resolvedInput.filter(p => fs.existsSync(path.resolve(sourcePath, p)));
const missingInput = resolvedInput.filter(p => !input.includes(p));
if (missingInput.length) {
console.warn(`apidoc: non-existing input path(s) dropped for ${webapp.tribe}: ${missingInput.join(', ')}`);
}
if (input.length === 0) {
console.warn(`apidoc: no existing input path for ${webapp.tribe}, skipping documentation generation`);
return;
}
// Create temporary conf.json for apidoc CLI
const tempConf = {
name: conf.name || webapp.name,
version: conf.version || "1.0.0",
description: conf.description || "",
url: "",
input: (conf.apidoc.input || []).map(function (p) {
return p.replace(/\{\{NODEPATH\}\}/g, NODE_PATH);
}),
input: input,
output: runtimePath,
order: conf.apidoc.order || [],
template: conf.apidoc.template || {},
@@ -507,6 +526,66 @@ class Caddy {
});
});
const apidocWebapps = webapps.filter(w => w.name === 'apidoc');
const hasApidoc = w => fs.existsSync(path.join(w.runtimePath, 'index.html'));
// 1b. apidoc, host-based: on a host declared by a tribe the docs
// served on /apidoc are that tribe's. Several webapps of one tribe
// can share a dns and a tribe can declare several, so the
// (host, tribe) pairs are deduplicated before being emitted.
const apidocHosts = [];
const seenHostPairs = new Set();
webapps.forEach(w => {
if (!w.dns || w.tribe === TOWN) return;
const pair = `${w.dns}|${w.tribe}`;
if (seenHostPairs.has(pair)) return;
const apidoc = apidocWebapps.find(a => a.tribe === w.tribe);
if (!apidoc || !hasApidoc(apidoc)) return;
seenHostPairs.add(pair);
apidocHosts.push({ host: w.dns, apidoc });
});
apidocHosts.forEach(({ host, apidoc }) => {
routes.push({
match: [{ host: [host], path: ["/apidoc"] }],
handle: [{ handler: "static_response", status_code: 301,
headers: { Location: ["/apidoc/"] } }],
terminal: true
});
routes.push({
match: [{ host: [host], path: ["/apidoc/*"] }],
handle: [{ handler: "rewrite", strip_path_prefix: "/apidoc" },
{ handler: "file_server", root: apidoc.runtimePath,
index_names: ["index.html", "index_fr.html", "index_en.html"] }],
terminal: true
});
log(`apidoc host route: ${host}/apidoc/* → ${apidoc.runtimePath}`);
});
// 1c. apidoc, path-based: the town's docs on /apidoc (on every host
// no tribe claimed above), a tribe's on /{tribe}/apidoc to reach it
// from the town domain. These come after the host-based routes of
// 1b because Caddy keeps the first matching route and /apidoc/*
// matches on any host: emitted first, it would absorb the request
// before the host matcher of 1b is evaluated.
apidocWebapps.filter(hasApidoc).forEach(w => {
const base = w.tribe === TOWN ? '/apidoc' : `/${w.tribe}/apidoc`;
routes.push({
match: [{ path: [base] }],
handle: [{ handler: "static_response", status_code: 301,
headers: { Location: [`${base}/`] } }],
terminal: true
});
routes.push({
match: [{ path: [`${base}/*`] }],
handle: [{ handler: "rewrite", strip_path_prefix: base },
{ handler: "file_server", root: w.runtimePath,
index_names: ["index.html", "index_fr.html", "index_en.html"] }],
terminal: true
});
log(`apidoc route: ${base}/* → ${w.runtimePath}`);
});
// 2. Host-based: apps with explicit dns (future use)
webapps.forEach(w => {
if (!w.dns || w.name === 'apidoc') return;
@@ -517,6 +596,26 @@ class Caddy {
terminal: true
});
}
// Draft: serve a host-based subapp's source through Express so
// /cdn/dev/ -> /cdn/{tribe}/ and the xtribe/xnation metas are
// rewritten, matching buildpwa. Without this, /{subapp}/draft/* is
// served by the file_server below and draft.js cannot resolve.
let hostSubapps = [];
try {
hostSubapps = fs.readdirSync(w.runtimePath)
.filter(n => fs.existsSync(path.join(w.runtimePath, n, 'manifest.json')));
} catch (e) { /* webapp not provisioned yet */ }
hostSubapps.forEach(subapp => {
routes.push({
match: [{ host: [w.dns], path: [`/${subapp}/draft`, `/${subapp}/draft/`, `/${subapp}/draft/*`] }],
handle: [{
handler: "reverse_proxy",
upstreams: [{ dial: `${process.env.BACKENDURL}:${process.env.APIPORT}` }],
headers: { request: { set: { "X-Apxtri-Webapp": [w.name] } } }
}],
terminal: true
});
});
routes.push({
match: [{ host: [w.dns] }],
handle: [{
+280 -28
View File
@@ -24,6 +24,10 @@ const DATAPATH = process.env.DATAPATH;
const NODEPATH = process.env.NODEPATH;
const CRON_IDENTIFIER = "mailbox-cron";
const DEFAULT_POLL_MIN = 5;
// In-process sync locks, keyed by mailboxid: {alias, since}. A lock older than
// SYNC_LOCK_STALE_MIN is considered stale and reclaimed instead of blocking.
const SYNC_LOCKS = new Map();
const SYNC_LOCK_STALE_MIN = 10;
class Mailboxes {
/**
@@ -375,6 +379,8 @@ class Mailboxes {
status: "active",
lastSync: null,
nextSync: null,
syncCursor: {},
syncing: null,
counts: { total: 0, unread: 0 },
connected,
lastTest,
@@ -553,6 +559,8 @@ class Mailboxes {
/**
* Public shape of a mailbox for API consumers (no secrets, no auth internals).
* Exposes the raw 'syncing' marker plus a derived isSyncing boolean, true only while
* a non-stale sync is running (see _isSyncStale).
* @private
*/
static _boxPublic(item) {
@@ -575,6 +583,8 @@ class Mailboxes {
tribe: item.tribe,
status: item.status,
lastSync: item.lastSync || null,
syncing: item.syncing || null,
isSyncing: !!(item.syncing && !this._isSyncStale(item.syncing)),
downloadMode: item.downloadMode || "lazy",
pollIntervalMin: item.pollIntervalMin || DEFAULT_POLL_MIN,
counts: item.counts || { total: 0, unread: 0 },
@@ -675,9 +685,9 @@ class Mailboxes {
* Paginated list of synchronised mails of a tribe, filtered by readable mailboxes.
* Uses the Odmdb distribution index by_mailboxid for fast lookup.
*/
static async listMails(role, tribe, query = {}) {
static async listMails(role, tribe, query = {}) {
try {
const { mailboxid, status, from, q, sent } = query;
const { mailboxid, status, from, q, sent, tags, tagMode } = query;
const page = Math.max(1, parseInt(query.page, 10) || 1);
const limit = Math.min(200, parseInt(query.limit, 10) || 25);
const rights = await this.readableMailboxIds(role, tribe);
@@ -687,6 +697,12 @@ class Mailboxes {
if (mailboxid && !rights.has(mailboxid)) {
return { status: 403, ref: "Mailboxes", msg: "right_required", data: { right: "read", mailboxid } };
}
// Parse tags filter
const tagList = typeof tags === "string" && tags
? tags.split(",").map((t) => t.trim().toLowerCase()).filter(Boolean)
: [];
const tagOp = tagMode === "and" ? "and" : "or";
const itmDir = path.join(DATAPATH, tribe, "objects", "mails", "itm");
if (!(await fs.pathExists(itmDir))) {
return { status: 200, ref: "Mailboxes", msg: "mails_listed", data: { mails: [], total: 0 } };
@@ -723,8 +739,23 @@ class Mailboxes {
if (sent === "false" && item.apxtriSent === true) continue;
if (from && !(item.from?.email || "").toLowerCase().includes(String(from).toLowerCase())) continue;
if (q) {
const hay = `${item.subject || ""} ${item.snippet || ""}`.toLowerCase();
if (!hay.includes(String(q).toLowerCase())) continue;
const qLower = String(q).toLowerCase();
const fromEmail = (item.from?.email || "").toLowerCase();
const fromName = (item.from?.name || "").toLowerCase();
const toEmails = (Array.isArray(item.to) ? item.to : [])
.map((t) => ((t?.email || "") + " " + (t?.name || "")).toLowerCase()).join(" ");
const ccEmails = (Array.isArray(item.cc) ? item.cc : [])
.map((t) => ((t?.email || "") + " " + (t?.name || "")).toLowerCase()).join(" ");
const hay = `${item.subject || ""} ${item.snippet || ""} ${fromEmail} ${fromName} ${toEmails} ${ccEmails}`.toLowerCase();
if (!hay.includes(qLower)) continue;
}
if (tagList.length > 0) {
const itemTags = (Array.isArray(item.tags) ? item.tags : []).map((t) => t.toLowerCase());
if (tagOp === "and") {
if (!tagList.every((t) => itemTags.includes(t))) continue;
} else {
if (!tagList.some((t) => itemTags.includes(t))) continue;
}
}
mails.push(item);
} catch (e) {
@@ -743,8 +774,23 @@ class Mailboxes {
if (sent === "false" && item.apxtriSent === true) continue;
if (from && !(item.from?.email || "").toLowerCase().includes(String(from).toLowerCase())) continue;
if (q) {
const hay = `${item.subject || ""} ${item.snippet || ""}`.toLowerCase();
if (!hay.includes(String(q).toLowerCase())) continue;
const qLower = String(q).toLowerCase();
const fromEmail = (item.from?.email || "").toLowerCase();
const fromName = (item.from?.name || "").toLowerCase();
const toEmails = (Array.isArray(item.to) ? item.to : [])
.map((t) => ((t?.email || "") + " " + (t?.name || "")).toLowerCase()).join(" ");
const ccEmails = (Array.isArray(item.cc) ? item.cc : [])
.map((t) => ((t?.email || "") + " " + (t?.name || "")).toLowerCase()).join(" ");
const hay = `${item.subject || ""} ${item.snippet || ""} ${fromEmail} ${fromName} ${toEmails} ${ccEmails}`.toLowerCase();
if (!hay.includes(qLower)) continue;
}
if (tagList.length > 0) {
const itemTags = (Array.isArray(item.tags) ? item.tags : []).map((t) => t.toLowerCase());
if (tagOp === "and") {
if (!tagList.every((t) => itemTags.includes(t))) continue;
} else {
if (!tagList.some((t) => itemTags.includes(t))) continue;
}
}
mails.push(item);
} catch (e) {
@@ -814,6 +860,97 @@ class Mailboxes {
}
}
/**
* Set the list of available tags for a mailbox (druid only).
* @param {object} role - Session role ({ xalias, xprofils }).
* @param {string} mailboxid - Mailbox identifier.
* @param {Array} tags - Array of { key, label, color? }.
* @returns {Promise<{status:number,ref:string,msg:string,data?:object}>}
*/
static async setAvailableTags(role, mailboxid, tags) {
try {
const mailbox = await this._loadMailbox(mailboxid);
if (!mailbox) {
return { status: 404, ref: "Mailboxes", msg: "mailbox_not_found", data: { mailboxid } };
}
if (!(await this._isDruidIn(role, mailbox.tribe))) {
return { status: 403, ref: "Mailboxes", msg: "druid_only" };
}
const mailboxDb = new Odmdb("mailboxes", mailbox.tribe);
const safeTags = Array.isArray(tags)
? tags.filter((t) => t && t.key && t.label).map((t) => ({
key: String(t.key).trim().toLowerCase(),
label: String(t.label).trim(),
color: ["primary", "secondary", "accent", "success", "warning", "error", "info", "neutral"].includes(t.color) ? t.color : "primary",
}))
: [];
const result = await mailboxDb.update({ ...mailbox, availableTags: safeTags }, { xalias: role.xalias, xprofils: ["druids"] });
if (result.status !== 200) return result;
return { status: 200, ref: "Mailboxes", msg: "tags_updated", data: { mailboxid, tags: safeTags } };
} catch (error) {
console.error("[Mailboxes] setAvailableTags error:", error);
return { status: 500, ref: "Mailboxes", msg: "internal_error", error: error.message };
}
}
/**
* Add or remove a tag on a list of mails. Requires writer right on the mailbox.
* @param {object} role - Session role.
* @param {string} tribe - Tribe identifier.
* @param {Array} emailIds - List of emailId values.
* @param {string} tag - Tag key to add or remove.
* @param {string} action - "tag" | "untag".
* @returns {Promise<{status:number,ref:string,msg:string,data?:object}>}
*/
static async applyTag(role, tribe, emailIds, tag, action) {
try {
if (!Array.isArray(emailIds) || emailIds.length === 0) {
return { status: 400, ref: "Mailboxes", msg: "missing_emailids" };
}
if (!tag || typeof tag !== "string") {
return { status: 400, ref: "Mailboxes", msg: "missing_tag" };
}
if (!["tag", "untag"].includes(action)) {
return { status: 400, ref: "Mailboxes", msg: "invalid_tag_action", data: { action } };
}
const tagKey = tag.trim().toLowerCase();
let updated = 0;
for (const emailId of emailIds) {
const itemPath = path.join(DATAPATH, tribe, "objects", "mails", "itm", `${this._emailIdSafe(emailId)}.json`);
if (!(await fs.pathExists(itemPath))) continue;
const item = await fs.readJson(itemPath);
if (item.tribe !== tribe) continue;
// Check writer right
const mailbox = await this._loadMailbox(item.mailboxid);
if (!mailbox || (await this._rightFor(role, mailbox)) !== "write") {
return { status: 403, ref: "Mailboxes", msg: "right_required", data: { right: "write", mailboxid: item.mailboxid } };
}
const currentTags = Array.isArray(item.tags) ? [...item.tags] : [];
if (action === "tag") {
if (!currentTags.includes(tagKey)) currentTags.push(tagKey);
} else {
const idx = currentTags.indexOf(tagKey);
if (idx >= 0) currentTags.splice(idx, 1);
}
item.tags = currentTags;
item.dt_update = dayjs().toISOString();
await fs.outputJson(itemPath, item, { spaces: 2 });
updated += 1;
}
return { status: 200, ref: "Mailboxes", msg: "tags_applied", data: { updated, action, tag: tagKey } };
} catch (error) {
console.error("[Mailboxes] applyTag error:", error);
return { status: 500, ref: "Mailboxes", msg: "internal_error", error: error.message };
}
}
/**
* Patch profils of person {alias} in {tribe} using a transform function.
* @private
@@ -831,13 +968,74 @@ class Mailboxes {
return { status: 200, ref: "Mailboxes", msg: "access_updated", data: { tribe, alias, profils } };
}
/**
* True when a sync lock / syncing marker is older than SYNC_LOCK_STALE_MIN. A stale
* marker is ignored (the mailbox is considered idle) and its lock reclaimed.
* @private
*/
static _isSyncStale(marker) {
if (!marker) return true;
const since = marker.since ? new Date(marker.since).getTime() : NaN;
if (isNaN(since)) return true;
return Date.now() - since > SYNC_LOCK_STALE_MIN * 60 * 1000;
}
/**
* Try to acquire the in-process sync lock of a mailbox. A stale lock (older than
* SYNC_LOCK_STALE_MIN) is reclaimed. Returns {acquired, since} or {acquired:false,
* holder} when another sync holds it.
* @private
*/
static _acquireSyncLock(mailboxid, alias) {
const held = SYNC_LOCKS.get(mailboxid);
if (held && !this._isSyncStale(held)) {
return { acquired: false, holder: held, since: held.since };
}
const since = dayjs().toISOString();
SYNC_LOCKS.set(mailboxid, { alias: alias || "", since });
return { acquired: true, since };
}
/**
* Release the in-process sync lock of a mailbox.
* @private
*/
static _releaseSyncLock(mailboxid) {
SYNC_LOCKS.delete(mailboxid);
}
/**
* Persist the syncing marker on the mailbox document, or clear it (marker=null).
* Only the 'syncing' field is written so this never clobbers fields a sync may have
* persisted in the meantime (syncCursor, lastSync, nextSync, counts, connected).
* Same Odmdb update pattern as _markSyncDone/_markSyncError.
* @private
*/
static async _markSyncing(mailbox, marker) {
if (!mailbox) return;
try {
const mailboxesDb = new Odmdb("mailboxes", mailbox.tribe);
await mailboxesDb.update(
{ mailboxid: mailbox.mailboxid, syncing: marker },
{ xalias: mailbox.owner, xprofils: ["druids"] }
);
} catch (e) {
console.error("[Mailboxes] _markSyncing error:", e.message);
}
}
/**
* Poll a mailbox and upsert messages into {DATAPATH}/{tribe}/objects/mails/.
* Dispatches to the provider adapter in mailconn.js.
* Dispatches to the provider adapter in mailconn.js. Protected by an in-process lock
* (mirrored on the mailbox document as the 'syncing' marker) so concurrent syncs of
* the same mailbox cannot overlap; a lock older than SYNC_LOCK_STALE_MIN is reclaimed.
* The per-folder high-watermark cursors (mailbox.syncCursor) are passed down to the
* adapter and persisted back from the adapter result.
* @param {object} role - needs at least reader right on the mailbox
* @param {object} opts - {q, maxResults}
*/
static async syncMailbox(role, mailboxid, opts = {}) {
let lockedMailbox = null;
try {
const mailbox = await this._loadMailbox(mailboxid);
if (!mailbox) {
@@ -851,14 +1049,48 @@ class Mailboxes {
return { status: 400, ref: "Mailboxes", msg: "provider_unsupported", data: { provider } };
}
const eager = mailbox.downloadMode === "eager";
const maxResults = opts.maxResults || 50;
// maxResults is only forced when the caller asked for it: the adapter otherwise
// applies its own cap (50 for a cursor-less full rescan, 500 on the incremental
// path, where a small window would re-introduce the "never syncs older mail" bug).
const adapterOpts = { eager, cursors: mailbox.syncCursor || {} };
if (opts.maxResults) adapterOpts.maxResults = opts.maxResults;
// Concurrency: honour a fresh persisted marker, then take the in-process lock.
if (mailbox.syncing && !this._isSyncStale(mailbox.syncing)) {
return {
status: 409,
ref: "Mailboxes",
msg: "sync_in_progress",
data: { mailboxid, alias: mailbox.syncing.alias || "", since: mailbox.syncing.since || null },
};
}
const lock = this._acquireSyncLock(mailboxid, role?.xalias);
if (!lock.acquired) {
return {
status: 409,
ref: "Mailboxes",
msg: "sync_in_progress",
data: { mailboxid, alias: lock.holder.alias || "", since: lock.holder.since },
};
}
lockedMailbox = { ...mailbox, syncing: { alias: role?.xalias || "", since: lock.since, remote: false } };
await this._markSyncing(lockedMailbox, lockedMailbox.syncing);
const secrets = await this._loadSecrets(mailbox);
const cursors = adapterOpts.cursors;
const result =
provider === "imap"
? await mailconn.fetchImapMessages(mailbox, secrets, { eager, maxResults })
: await mailconn.fetchPopMessages(mailbox, secrets, { maxResults });
await this._markSyncing({ ...lockedMailbox }, { ...lockedMailbox.syncing, remote: true });
let result;
try {
result =
provider === "imap"
? await mailconn.fetchImapMessages(mailbox, secrets, adapterOpts)
: await mailconn.fetchPopMessages(mailbox, secrets, adapterOpts);
} finally {
if (lockedMailbox) {
await this._markSyncing({ ...lockedMailbox }, { ...lockedMailbox.syncing, remote: false });
}
}
if (!result.ok) {
await this._markSyncError(mailbox, result.error || "sync failed");
return { status: 502, ref: "Mailboxes", msg: "sync_failed", data: { mailboxid, error: result.error } };
@@ -903,39 +1135,55 @@ class Mailboxes {
}
const fresh = await this._loadMailbox(mailboxid);
if (fresh) await this._markSyncDone(fresh);
if (fresh) await this._markSyncDone(fresh, result.cursors);
return {
status: 200,
ref: "Mailboxes",
msg: "sync_done",
data: { fetched: (result.messages || []).length, created, updated, skipped },
data: {
fetched: (result.messages || []).length,
created,
updated,
skipped,
cursors: (result.cursors || cursors) || {},
},
};
} catch (error) {
console.error("[Mailboxes] syncMailbox error:", error);
return { status: 500, ref: "Mailboxes", msg: "sync_failed", error: error.message };
} finally {
if (lockedMailbox) {
this._releaseSyncLock(mailboxid);
await this._markSyncing({ ...lockedMailbox, syncing: null }, null);
}
}
}
/**
* Persist lastSync/nextSync/counts after a successful sync.
* Persist lastSync/nextSync/counts after a successful sync, plus the per-folder
* high-watermark cursors returned by the provider adapter (when any) and the cleared
* 'syncing' marker.
* @param {object} fresh - freshly reloaded mailbox document
* @param {object} [cursors] - cursors returned by the adapter ({folder:{...}})
* @private
*/
static async _markSyncDone(fresh) {
static async _markSyncDone(fresh, cursors) {
try {
const counts = await this._mailboxCounts(fresh.tribe, fresh.mailboxid);
const pollMin = Math.max(1, parseInt(fresh.pollIntervalMin, 10) || DEFAULT_POLL_MIN);
const mailboxesDb = new Odmdb("mailboxes", fresh.tribe);
await mailboxesDb.update(
{
...fresh,
lastSync: dayjs().toISOString(),
nextSync: dayjs().add(pollMin, "minute").toISOString(),
lastError: "",
connected: true,
counts,
},
{ xalias: fresh.owner, xprofils: ["druids"] }
);
const doc = {
...fresh,
lastSync: dayjs().toISOString(),
nextSync: dayjs().add(pollMin, "minute").toISOString(),
lastError: "",
connected: true,
counts,
};
if (cursors && typeof cursors === "object" && Object.keys(cursors).length) {
doc.syncCursor = { ...(fresh.syncCursor || {}), ...cursors };
}
await mailboxesDb.update(doc, { xalias: fresh.owner, xprofils: ["druids"] });
} catch (e) {
console.error("[Mailboxes] _markSyncDone error:", e.message);
}
@@ -943,6 +1191,7 @@ class Mailboxes {
/**
* Store a failing sync marker (lastError) without resetting the poll schedule.
* Rewrites {...mailbox}, so an existing syncCursor is preserved.
* @private
*/
static async _markSyncError(mailbox, error) {
@@ -1089,7 +1338,10 @@ class Mailboxes {
if (!fetched.ok) {
return { status: 404, ref: "Mailboxes", msg: "attachment_unavailable", data: { emailid, index, error: fetched.error } };
}
storageName = await this._safeAttachmentName(fetched.att, fetched.att.mimeType || fetched.att.contentType, idx);
storageName = await this._safeAttachmentName(
{ ...fetched.att, tribe: item.tribe, emailId: item.emailId },
fetched.att.mimeType || fetched.att.contentType, idx
);
await fs.ensureDir(dir);
await fs.outputFile(path.join(dir, storageName), fetched.buffer);
if (!storageName) {
+14 -5
View File
@@ -583,13 +583,22 @@ class Pagans {
* @param {object} role - Session role.
* @returns {Promise<{ alias: string, profils: string[], isTribeOwner: boolean }>}
*/
static async _resolveViewer(tribe, role) {
static async _resolveViewer(tribe, role, ctx) {
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;
// Prefer an explicit tribe context (attached by isAuthenticated, also shared
// through role.tribeContext since role === req.session.header). It is only
// trustworthy when it targets the same tribe; otherwise (e.g. a town-authenticated
// caller acting on another tribe) fall back to reading the tribe person record.
const context = ctx || role.tribeContext;
if (context && context.tribe === tribe && Array.isArray(context.profils)) {
profils = context.profils.slice();
} else {
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'] });
-1
View File
@@ -185,7 +185,6 @@ module.exports = router;
description: "API Documentation Generator",
apidoc: {
input: [
`${process.env.NODEPATH}/${tribeId}/middlewares`,
`${process.env.NODEPATH}/${tribeId}/routes`
],
template: {
+45 -6
View File
@@ -58,6 +58,42 @@ class Wwws {
return path.join(tmpDir, `core${coreVersion}-${subappName}${subappVersion}_${lg}.json`);
}
/**
* Read a single option document of a tribe (e.g. 'profil', 'conditiontojoin').
* Resolves the same on-disk layout as the localdb loader: {DATAPATH}/{tribe}/
* objects/options/{optionid}.json (or itm/, or the _lg language variant).
* @param {string} tribe - Tribe identifier
* @param {string} optionid - Option identifier (optionid)
* @param {string} [lg] - Language code (en/fr/...), default en
* @returns {Promise<object>} {status, ref, msg, data}
*/
static async readOption(tribe, optionid, lg) {
const name = String(optionid || "").replace(/[^a-zA-Z0-9_-]/g, "");
if (!tribe || !name) {
return { status: 400, ref: "Wwws", msg: "option_invalid_params", data: { tribe, optionid } };
}
const lgCode = String(lg || "en").replace(/[^a-zA-Z-]/g, "").slice(0, 5);
const dir = path.join(DATA_PATH, tribe, "objects", "options");
const tryPaths = [
path.join(dir, `${name}.json`),
path.join(dir, "itm", `${name}.json`),
path.join(dir, `${name}_${lgCode}.json`),
path.join(dir, "itm", `${name}_${lgCode}.json`),
path.join(dir, `${name}_en.json`),
path.join(dir, "itm", `${name}_en.json`),
];
for (const p of tryPaths) {
if (await fs.pathExists(p)) {
try {
return { status: 200, ref: "Wwws", msg: "option_found", data: await fs.readJson(p) };
} catch (e) {
return { status: 500, ref: "Wwws", msg: "option_read_failed", data: { tribe, optionid, error: e.message } };
}
}
}
return { status: 404, ref: "Wwws", msg: "option_not_found", data: { tribe, optionid } };
}
/**
* Delete old cache files for a subapp (cleanup)
*/
@@ -338,18 +374,21 @@ class Wwws {
return { status: 200, ref: "Wwws", msg: "data_model_update", data: localData };
}
// Check profile access BEFORE the version short-circuit: a client already
// holding the current versions must still be denied when its profile does
// not match the app/subapp gate, otherwise "no_new_data_model" would leak
// access to an unauthorized page.
const allowedProfils = [...(coreConf.profils || []), ...(subappConf.profils || [])];
if (allowedProfils.length > 0 && !profils.some(p => allowedProfils.includes(p))) {
return { status: 403, ref: "Wwws", msg: "permission_denied_for_page" };
}
// Check if versions match
if (coreConf.version == coreVersion && subappConf.version == subappVersion) {
logger.debug(`Versions up to date for ${subappName}: core=${coreVersion} subapp=${subappVersion}`);
return { status: 200, ref: "Wwws", msg: "no_new_data_model" };
}
// Check profile access
const allowedProfils = [...(coreConf.profils || []), ...(subappConf.profils || [])];
if (allowedProfils.length > 0 && !profils.some(p => allowedProfils.includes(p))) {
return { status: 403, ref: "Wwws", msg: "permission_denied_for_page" };
}
// Check versioned cache file
const cachePath = this._getCachePath(tribe, appname, subappName, coreConf.version, subappConf.version, lg);
if (await fs.pathExists(cachePath)) {
+125 -32
View File
@@ -22,6 +22,13 @@ const PROVIDERS = {
const PASSWORD_PROVIDERS = ["imap", "pop3"];
// Safety nets, not windows: with a working high-watermark cursor the natural batch is the
// handful of messages received since the last sync, so the incremental path allows a much
// larger cap. The cursor-less full rescan keeps the historical 50 because it walks the
// whole mailbox result set.
const CURSOR_MAX_RESULTS = 500;
const FULL_SCAN_MAX_RESULTS = 50;
// ---------------------------------------------------------------------------
// Metadata
// ---------------------------------------------------------------------------
@@ -378,15 +385,19 @@ function mapImapEager(mailbox, rec, full, folder) {
* Open the configured folder(s) — inbox and, unless disabled via opts.includeSent === false,
* the resolved Sent folder — list the most recent messages' metadata and, when eager, also
* download + parse every message. A missing Sent folder is skipped silently; inbox errors
* abort the sync. Returns {ok, folder, folders, messages, error} where each message is
* abort the sync. Returns {ok, folder, folders, messages, error, cursors} where each message is
* {item, buffers?}. Sent-folder items get apxtriSent:true and an emailId derived from their
* Message-ID (emailIdForSent).
* Message-ID (emailIdForSent). When cursors are provided (opts.cursors or mailbox.syncCursor),
* a per-folder cursor (uidValidity + lastUid) is used to fetch UIDs in the range lastUid+1:*;
* if uidValidity differs the cursor is reset to the current full scan behaviour. The default
* maxResults is 500 when a cursor applies, 50 for the cursor-less full-rescan path.
*/
async function fetchImapMessages(mailbox, secrets, opts = {}) {
const maxResults = Math.max(1, parseInt(opts.maxResults, 10) || 50);
const cursorsIn = opts.cursors || mailbox.syncCursor || {};
const eager = opts.eager === true;
const folders = opts.includeSent === false ? [imapFolder(mailbox)] : (opts.folders && opts.folders.length ? opts.folders : imapFolders(mailbox));
let client = null;
const cursorsOut = {};
try {
client = await connectImap(mailbox, secrets);
const messages = [];
@@ -399,35 +410,78 @@ async function fetchImapMessages(mailbox, secrets, opts = {}) {
throw e;
}
try {
const uids = await client.search({ all: true }, { uid: true });
const recent = (uids || []).slice(-maxResults);
if (recent.length === 0) continue;
const records = await client.fetchAll(
recent,
{ uid: true, flags: true, internalDate: true, size: true, envelope: true, bodyStructure: true },
{ uid: true }
);
for (const rec of records || []) {
let entry;
if (eager) {
try {
const full = await downloadImapMessage(client, rec.uid);
entry = mapImapEager(mailbox, rec, full, folder);
} catch (e) {
const status = await client.status(folder, { uidValidity: true, uidNext: true });
const rawUidValidity = status ? status.uidValidity : null;
const serverUidValidity = rawUidValidity == null ? null : Number(rawUidValidity);
const cursor = cursorsIn[folder] || null;
const prevUidValidity = cursor ? parseInt(cursor.uidValidity, 10) : NaN;
const prevLastUid = cursor && cursor.lastUid !== null && cursor.lastUid !== undefined
? parseInt(cursor.lastUid, 10)
: NaN;
// A cursor is only usable when the server UIDVALIDITY is unchanged: a different
// value means the folder was re-created and every stored UID is meaningless.
const useCursor =
!!cursor && !isNaN(prevUidValidity) && !isNaN(prevLastUid) && serverUidValidity === prevUidValidity;
const maxResults = Math.max(1, parseInt(opts.maxResults, 10) || (useCursor ? CURSOR_MAX_RESULTS : FULL_SCAN_MAX_RESULTS));
let recent = [];
if (useCursor) {
const uids = await client.search({ uid: `${prevLastUid + 1}:*` }, { uid: true });
// Defensive: never re-read a uid the cursor already covers.
recent = (uids || []).filter((u) => {
const n = parseInt(u, 10);
return !isNaN(n) && n > prevLastUid;
});
// Cap the incremental batch at maxResults, taking the LOWEST new UIDs first so a
// large backlog is drained in bounded chunks without ever skipping: the cursor
// lands on the highest uid actually retrieved and the next poll continues there.
if (recent.length > maxResults) recent = recent.slice(0, maxResults);
} else {
const uids = await client.search({ all: true }, { uid: true });
recent = (uids || []).slice(-maxResults);
}
let maxSeenUid = 0;
let records = [];
if (recent.length > 0) {
records =
(await client.fetchAll(
recent,
{ uid: true, flags: true, internalDate: true, size: true, envelope: true, bodyStructure: true },
{ uid: true }
)) || [];
for (const rec of records) {
let entry;
if (eager) {
try {
const full = await downloadImapMessage(client, rec.uid);
entry = mapImapEager(mailbox, rec, full, folder);
} catch (e) {
entry = mapImapMeta(mailbox, rec, folder);
}
} else {
entry = mapImapMeta(mailbox, rec, folder);
}
} else {
entry = mapImapMeta(mailbox, rec, folder);
messages.push(entry);
const seenUid = parseInt(rec.uid, 10);
if (!isNaN(seenUid) && seenUid > maxSeenUid) maxSeenUid = seenUid;
}
messages.push(entry);
}
// The cursor never goes backwards: keep the previous lastUid when this pass saw
// nothing (deleted or expunged messages must not rewind the watermark).
let lastUidOut = maxSeenUid;
if (lastUidOut <= 0) lastUidOut = useCursor ? prevLastUid : 0;
cursorsOut[folder] = {
uidValidity: serverUidValidity === null || serverUidValidity === undefined ? null : serverUidValidity,
lastUid: lastUidOut,
lastRemoteNum: null,
updatedAt: new Date().toISOString(),
};
} finally {
try { lock.release(); } catch (e) { /* noop */ }
}
}
return { ok: true, folder: imapFolder(mailbox), folders, messages, error: null };
return { ok: true, folder: imapFolder(mailbox), folders, messages, error: null, cursors: cursorsOut };
} catch (error) {
return { ok: false, folder: imapFolder(mailbox), folders, messages: [], error: error.message };
return { ok: false, folder: imapFolder(mailbox), folders, messages: [], error: error.message, cursors: cursorsIn || {} };
} finally {
await closeImap(client);
}
@@ -650,23 +704,35 @@ function popCommand(client, method, args = [], event = method) {
/**
* Fetch the most recent messages. POP3 always downloads full messages (no header-only
* mode), so body and attachment buffers are always available on the returned items.
* Returns {ok, messages, error}.
* Returns {ok, messages, error, cursors}. Notes: deleteOnDownload makes POP3 numbering
* unstable, so the cursor can drift; this is acceptable for POP3 and is why IMAP is
* the primary path.
*/
async function fetchPopMessages(mailbox, secrets, opts = {}) {
const maxResults = Math.max(1, parseInt(opts.maxResults, 10) || 50);
const cursorsIn = opts.cursors || mailbox.syncCursor || {};
const popCursor = cursorsIn.pop3 || null;
const maxResults = Math.max(1, parseInt(opts.maxResults, 10) || CURSOR_MAX_RESULTS);
const cfg = pop3Config(mailbox, secrets);
if (!cfg.host) return { ok: false, messages: [], error: "missing POP3 host" };
if (!cfg.host) return { ok: false, messages: [], error: "missing POP3 host", cursors: cursorsIn || {} };
let client = null;
try {
client = await connectPop(cfg);
const stat = await popCommand(client, "stat");
const count = parseInt(stat.a && stat.a.count !== undefined ? stat.a.count : stat.data[0], 10) || 0;
if (!count) return { ok: true, messages: [], error: null };
if (!count) {
const cursorsOut = { ...cursorsIn };
if (popCursor) {
cursorsOut.pop3 = { ...popCursor, updatedAt: new Date().toISOString() };
} else {
cursorsOut.pop3 = { lastRemoteNum: 0, updatedAt: new Date().toISOString() };
}
return { ok: true, messages: [], error: null, cursors: cursorsOut };
}
let uidlMap = {};
try {
const uidlRes = await popCommand(client, "uidl");
if (uidlRes.data && typeof uidlRes.data === "object" && !Array.isArray(uidlRes.data)) {
uidlMap = uidlRes.data; // {msgnumber: uidl}
uidlMap = uidlRes.data;
} else {
const lines = Array.isArray(uidlRes.data) ? uidlRes.data.join("\n") : String(uidlRes.data || "");
for (const line of lines.split("\n")) {
@@ -675,9 +741,26 @@ async function fetchPopMessages(mailbox, secrets, opts = {}) {
}
}
} catch (e) {
// UIDL not available: fall back to sequence-based ids
// UIDL not available
}
// Incremental: retrieve only the messages numbered after the stored watermark.
const prevLastNum = popCursor && popCursor.lastRemoteNum !== null && popCursor.lastRemoteNum !== undefined
? parseInt(popCursor.lastRemoteNum, 10)
: NaN;
// A shrunk or reset remote mailbox (count < lastRemoteNum) invalidates the cursor.
const useCursor = !isNaN(prevLastNum) && prevLastNum <= count;
let numbers = [];
if (useCursor) {
for (let i = prevLastNum + 1; i <= count; i += 1) numbers.push(i);
} else {
numbers = Array.from({ length: count }, (_, i) => i + 1).slice(-FULL_SCAN_MAX_RESULTS);
}
if (useCursor && numbers.length > maxResults) {
// Cap the incremental batch, keeping the LOWEST unread numbers first so the cursor
// only ever lands on the highest message actually retrieved and the next sync
// continues from there (never advances past a message we did not retrieve).
numbers = numbers.slice(0, maxResults);
}
const numbers = Array.from({ length: count }, (_, i) => i + 1).slice(-maxResults);
const messages = [];
for (const num of numbers) {
try {
@@ -697,9 +780,19 @@ async function fetchPopMessages(mailbox, secrets, opts = {}) {
// skip message on parse error
}
}
return { ok: true, messages, error: null };
// The cursor never goes backwards: with an incremental pass and nothing retrieved the
// previous value is kept, otherwise it lands on the last retrieved message.
let lastRemoteNumOut = count;
if (useCursor && numbers.length === 0) lastRemoteNumOut = prevLastNum;
else if (numbers.length) lastRemoteNumOut = numbers[numbers.length - 1];
const cursorsOut = { ...cursorsIn };
cursorsOut.pop3 = {
lastRemoteNum: lastRemoteNumOut,
updatedAt: new Date().toISOString(),
};
return { ok: true, messages, error: null, cursors: cursorsOut };
} catch (error) {
return { ok: false, messages: [], error: error.message };
return { ok: false, messages: [], error: error.message, cursors: cursorsIn || {} };
} finally {
if (client) await disconnectPop(client);
}
+1
View File
@@ -23,6 +23,7 @@
"invalid_right": "Invalid right '{{data.right}}' (expected read or write).",
"sync_done": "Synchronisation done: {{data.fetched}} fetched, {{data.created}} created, {{data.updated}} updated.",
"sync_failed": "Synchronisation failed: {{error}}",
"sync_in_progress": "A synchronisation is already running on mailbox {{data.mailboxid}} (started by {{data.alias}}).",
"right_required": "Missing '{{data.right}}' right{{#data.mailboxid}} on mailbox {{data.mailboxid}}{{/data.mailboxid}}.",
"actions_applied": "Actions applied on {{data.updated}} mail(s).",
"missing_emailids": "Field emailIds (non-empty array) is required.",
+1
View File
@@ -23,6 +23,7 @@
"invalid_right": "Droit invalide '{{data.right}}' (read ou write attendu).",
"sync_done": "Synchronisation terminée : {{data.fetched}} récupérés, {{data.created}} créés, {{data.updated}} mis à jour.",
"sync_failed": "Échec de la synchronisation : {{error}}",
"sync_in_progress": "Une synchronisation est déjà en cours sur la boîte {{data.mailboxid}} (démarrée par {{data.alias}}).",
"right_required": "Droit '{{data.right}}' requis{{#data.mailboxid}} sur la boîte {{data.mailboxid}}{{/data.mailboxid}}.",
"actions_applied": "Actions appliquées sur {{data.updated}} mail(s).",
"missing_emailids": "Le champ emailIds (tableau non vide) est obligatoire.",
+12 -2
View File
@@ -2,5 +2,15 @@
"plugins_list": "Plugin slot {{data.placement}} loaded with {{data.plugins.length}} plugin(s)",
"plugins_invalid_tribe": "Invalid tribe identifier",
"plugins_invalid_placement": "Invalid placement identifier",
"plugins_internal_error": "Internal server error"
}
"plugins_invalid_pluginid": "Invalid plugin identifier",
"plugins_invalid_action": "Invalid action name",
"plugins_unknown_plugin": "Unknown or disabled plugin",
"plugins_action_not_declared": "The plugin does not declare this action",
"plugins_action_unavailable": "No server handler available for this action",
"plugins_internal_error": "Internal server error",
"plugin_action_ok": "Action completed: {{data.bdcid}}",
"plugin_action_email_required": "An email is required to run this action",
"plugin_action_prepare_failed": "Could not prepare the draft ({{data.reason}})",
"plugin_action_client_required": "No known client could be matched to this email",
"plugin_action_create_failed": "Could not create the record ({{data.reason}})"
}
+12 -2
View File
@@ -2,5 +2,15 @@
"plugins_list": "Slot {{data.placement}} chargé avec {{data.plugins.length}} plugin(s)",
"plugins_invalid_tribe": "Identifiant de tribu invalide",
"plugins_invalid_placement": "Identifiant de placement invalide",
"plugins_internal_error": "Erreur interne du serveur"
}
"plugins_invalid_pluginid": "Identifiant de plugin invalide",
"plugins_invalid_action": "Nom d'action invalide",
"plugins_unknown_plugin": "Plugin inconnu ou désactivé",
"plugins_action_not_declared": "Le plugin ne déclare pas cette action",
"plugins_action_unavailable": "Aucun gestionnaire serveur pour cette action",
"plugins_internal_error": "Erreur interne du serveur",
"plugin_action_ok": "Action terminée : {{data.bdcid}}",
"plugin_action_email_required": "Un email est requis pour cette action",
"plugin_action_prepare_failed": "Préparation du brouillon impossible ({{data.reason}})",
"plugin_action_client_required": "Aucun client connu n'a pu être associé à cet email",
"plugin_action_create_failed": "Création de l'enregistrement impossible ({{data.reason}})"
}
+30 -7
View File
@@ -9,6 +9,7 @@
const express = require("express");
const Mailboxes = require("../models/Mailboxes.js");
const checkHeaders = require("../middlewares/checkHeaders");
const resolveTribeFromHost = require("../middlewares/resolveTribeFromHost.js");
const isAuthenticated = require("../middlewares/isAuthenticated");
const router = express.Router();
@@ -52,7 +53,7 @@ router.get("/", checkHeaders(), isAuthenticated(), async (req, res) => {
* @apiParam {String} tribe Tribe identifier
* @apiSuccess {Array} data.mailboxes List of {mailboxid,email,label,tribe,status,lastSync,downloadMode,pollIntervalMin,counts,right}
*/
router.get("/:tribe", checkHeaders(), isAuthenticated(), async (req, res) => {
router.get("/:tribe", checkHeaders(), resolveTribeFromHost(), isAuthenticated(), async (req, res) => {
try {
const result = await Mailboxes.listForAlias(req.session.header, req.params.tribe);
res.status(result.status).json(result);
@@ -78,7 +79,7 @@ router.get("/:tribe", checkHeaders(), isAuthenticated(), async (req, res) => {
* @apiSuccess {String|null} data.smtp "ok" or an SMTP error when SMTP was tested
* @apiError conn_test_fail Provider rejected the connection
*/
router.post("/test", checkHeaders(), isAuthenticated(), async (req, res) => {
router.post("/test", checkHeaders(), resolveTribeFromHost(), isAuthenticated(), async (req, res) => {
try {
const result = await Mailboxes.testConfig(req.session.header, req.body || {});
res.status(result.status).json(result);
@@ -108,7 +109,7 @@ router.post("/test", checkHeaders(), isAuthenticated(), async (req, res) => {
* @apiBody {Boolean} apx3Agree Must be true (APX3 payment agreement)
* @apiBody {Number} apx3VolumePerWeek Declared weekly volume (GB) used for APX3 billing
*/
router.post("/:mailboxid", checkHeaders(), isAuthenticated(), async (req, res) => {
router.post("/:mailboxid", checkHeaders(), resolveTribeFromHost(), isAuthenticated(), async (req, res) => {
try {
const result = await Mailboxes.createMailbox(req.session.header, req.body || {});
res.status(result.status).json(result);
@@ -125,7 +126,7 @@ router.post("/:mailboxid", checkHeaders(), isAuthenticated(), async (req, res) =
* @apiDescription Patches a mailbox (druid only). Identity/auth fields are protected.
* @apiParam {String} mailboxid Mailbox identifier
*/
router.put("/:mailboxid", checkHeaders(), isAuthenticated(), async (req, res) => {
router.put("/:mailboxid", checkHeaders(), resolveTribeFromHost(), isAuthenticated(), async (req, res) => {
try {
const result = await Mailboxes.updateMailbox(req.session.header, req.params.mailboxid, req.body || {});
res.status(result.status).json(result);
@@ -142,7 +143,7 @@ router.put("/:mailboxid", checkHeaders(), isAuthenticated(), async (req, res) =>
* @apiDescription Deletes a mailbox and its secrets file (druid only). Synchronised mails are kept.
* @apiParam {String} mailboxid Mailbox identifier
*/
router.delete("/:mailboxid", checkHeaders(), isAuthenticated(), async (req, res) => {
router.delete("/:mailboxid", checkHeaders(), resolveTribeFromHost(), isAuthenticated(), async (req, res) => {
try {
const result = await Mailboxes.deleteMailbox(req.session.header, req.params.mailboxid);
res.status(result.status).json(result);
@@ -164,7 +165,7 @@ router.delete("/:mailboxid", checkHeaders(), isAuthenticated(), async (req, res)
* @apiSuccess {Number} data.created New mails stored
* @apiSuccess {Number} data.updated Existing mails updated
*/
router.post("/:mailboxid/sync", checkHeaders(), isAuthenticated(), async (req, res) => {
router.post("/:mailboxid/sync", checkHeaders(), resolveTribeFromHost(), isAuthenticated(), async (req, res) => {
try {
const result = await Mailboxes.syncMailbox(req.session.header, req.params.mailboxid, req.body || {});
res.status(result.status).json(result);
@@ -183,7 +184,7 @@ router.post("/:mailboxid/sync", checkHeaders(), isAuthenticated(), async (req, r
* @apiBody {String} alias Alias to grant/revoke
* @apiBody {String} right "read" | "write" | null (null = revoke)
*/
router.put("/:mailboxid/access", checkHeaders(), isAuthenticated(), async (req, res) => {
router.put("/:mailboxid/access", checkHeaders(), resolveTribeFromHost(), isAuthenticated(), async (req, res) => {
try {
const { alias, right } = req.body || {};
if (!alias) {
@@ -201,4 +202,26 @@ router.put("/:mailboxid/access", checkHeaders(), isAuthenticated(), async (req,
}
});
/**
* @api {put} /mailboxes/:mailboxid/tags Set Available Tags
* @apiName SetAvailableTags
* @apiGroup 13-Mailboxes
* @apiVersion 1.0.0
* @apiDescription Sets the list of available tags for a mailbox (druid only). Each tag has key, label, and optional color.
* @apiParam {String} mailboxid Mailbox identifier
* @apiBody {Array} tags Array of { key, label, color? }
*/
router.put("/:mailboxid/tags", checkHeaders(), resolveTribeFromHost(), isAuthenticated(), async (req, res) => {
try {
const { tags } = req.body || {};
if (!Array.isArray(tags)) {
return res.status(400).json({ status: 400, ref: "Mailboxes", msg: "missing_fields", data: { fields: ["tags"] } });
}
const result = await Mailboxes.setAvailableTags(req.session.header, req.params.mailboxid, tags);
res.status(result.status).json(result);
} catch (error) {
handleError(res, error, "PUT /:mailboxid/tags");
}
});
module.exports = router;
+47 -7
View File
@@ -14,6 +14,31 @@ const isAuthenticated = require("../middlewares/isAuthenticated");
const router = express.Router();
const DATAPATH = process.env.DATAPATH;
// SECURITY BOUNDARY: content types allowed to be served inline (previewed in a browser
// tab) instead of forced as a download. An attachment comes from a remote mailbox and is
// therefore attacker-controlled: rendering it in the app origin would be a stored-XSS /
// content-sniffing vector. Only inert, non-scriptable types are allowed. Explicitly
// EXCLUDED: text/html, image/svg+xml, application/xml, application/javascript (and
// anything else that can execute script or load sub-resources in our origin). Anything
// outside this list always falls back to Content-Disposition: attachment.
const INLINE_MIME_ALLOWLIST = [
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
"image/bmp",
"image/x-icon",
"application/pdf",
"text/plain",
"text/markdown",
"text/csv",
"application/json",
];
// Strip parameters and lowercase, so "text/plain; charset=utf-8" matches "text/plain".
const inlineMimeAllowed = (contentType) =>
INLINE_MIME_ALLOWLIST.includes(String(contentType || "").split(";")[0].trim().toLowerCase());
// --- Helper function for consistent error handling ---
const handleError = (res, error, context) => {
console.error(`[MailsRoute] Error in ${context}:`, error);
@@ -100,10 +125,11 @@ router.get("/:tribe/item/:emailid", checkHeaders(), isAuthenticated(), async (re
* @apiName DownloadAttachment
* @apiGroup 13-Mails
* @apiVersion 1.0.0
* @apiDescription Streams a mail attachment to the client. Requires reader right on the mail's mailbox. In lazy mode the first access fetches the file from the provider and caches it on disk; in eager mode it is already present.
* @apiDescription Streams a mail attachment to the client. Requires reader right on the mail's mailbox. In lazy mode the first access fetches the file from the provider and caches it on disk; in eager mode it is already present. Served as a download by default; with disposition=inline an allowlisted content type is previewed in the browser, any other type still falls back to attachment.
* @apiParam {String} tribe Tribe identifier
* @apiParam {String} emailid Mail identifier (emailId)
* @apiParam {Number} index 0-based attachment index within the mail
* @apiQuery {String} [disposition] "inline" to preview in the browser (allowlisted content types only), default "attachment"
*/
router.get("/:tribe/attachment/:emailid/:index", checkHeaders(), isAuthenticated(), async (req, res) => {
try {
@@ -113,10 +139,18 @@ router.get("/:tribe/attachment/:emailid/:index", checkHeaders(), isAuthenticated
return res.status(result.status).json(result);
}
const d = result.data;
res.setHeader("Content-Type", d.contentType || "application/octet-stream");
const contentType = d.contentType || "application/octet-stream";
// Inline only on explicit request AND an allowlisted (inert) content type.
const inline = req.query.disposition === "inline" && inlineMimeAllowed(contentType);
res.setHeader("Content-Type", contentType);
// Harmless on the download path, mandatory on the inline one: a remote attachment must
// never be sniffed into an active type nor run script in the app origin.
res.setHeader("X-Content-Type-Options", "nosniff");
res.setHeader("Content-Security-Policy", "default-src 'none'; sandbox");
const disposition = inline ? "inline" : "attachment";
res.setHeader(
"Content-Disposition",
`attachment; filename="${String(d.filename || "attachment").replace(/["\\]/g, "_")}"; filename*=UTF-8''${encodeURIComponent(
`${disposition}; filename="${String(d.filename || "attachment").replace(/["\\]/g, "_")}"; filename*=UTF-8''${encodeURIComponent(
d.filename || "attachment"
)}`
);
@@ -135,15 +169,21 @@ router.get("/:tribe/attachment/:emailid/:index", checkHeaders(), isAuthenticated
* @apiName ApplyMailsAction
* @apiGroup 13-Mails
* @apiVersion 1.0.0
* @apiDescription Applies an action (read|unread|trash) on a list of mails. Requires writer right on each mail's mailbox. trash moves the message to the mailbox trash folder.
* @apiDescription Applies an action (read|unread|trash|tag|untag) on a list of mails. Requires writer right on each mail's mailbox. trash moves the message to the mailbox trash folder. tag/untag add or remove a workflow tag.
* @apiParam {String} tribe Tribe identifier
* @apiBody {Array} emailIds List of emailId values
* @apiBody {String} action "read" | "unread" | "trash"
* @apiBody {String} action "read" | "unread" | "trash" | "tag" | "untag"
* @apiBody {String} [tag] Tag key (required for tag/untag actions)
*/
router.post("/:tribe/action", checkHeaders(), isAuthenticated(), async (req, res) => {
try {
const { emailIds, action } = req.body || {};
const result = await Mailboxes.applyAction(req.session.header, req.params.tribe, emailIds, action);
const { emailIds, action, tag } = req.body || {};
let result;
if (action === "tag" || action === "untag") {
result = await Mailboxes.applyTag(req.session.header, req.params.tribe, emailIds, tag, action);
} else {
result = await Mailboxes.applyAction(req.session.header, req.params.tribe, emailIds, action);
}
res.status(result.status).json(result);
} catch (error) {
handleError(res, error, "POST /:tribe/action");
+12 -11
View File
@@ -18,6 +18,7 @@ const path = require("path");
const Odmdb = require("../models/Odmdb.js"); // Using the new class-based model
const Pagans = require("../models/Pagans.js");
const checkHeaders = require("../middlewares/checkHeaders.js");
const resolveTribeFromHost = require("../middlewares/resolveTribeFromHost.js");
const isAuthenticated = require("../middlewares/isAuthenticated.js");
const router = express.Router();
@@ -88,7 +89,7 @@ function _canWriteSchema(access, objectname) {
* @apiDescription Lists all available schema files for a given tribe by scanning the schema directory.
* @apiParam {String} tribe The tribe identifier (use 'apxtri' for core schemas).
*/
router.get("/schema/:tribe", checkHeaders(), isAuthenticated(), async (req, res) => {
router.get("/schema/:tribe", checkHeaders(), resolveTribeFromHost(), isAuthenticated(), async (req, res) => {
try {
const { tribe } = req.params;
const role = req.session.header;
@@ -126,7 +127,7 @@ router.get("/schema/:tribe", checkHeaders(), isAuthenticated(), async (req, res)
}
});
router.get("/schema/:tribe/:objectname", checkHeaders(), isAuthenticated(), async (req, res) => {
router.get("/schema/:tribe/:objectname", checkHeaders(), resolveTribeFromHost(), isAuthenticated(), async (req, res) => {
try {
const { tribe, objectname } = req.params;
const role = req.session.header;
@@ -163,7 +164,7 @@ router.get("/schema/:tribe/:objectname", checkHeaders(), isAuthenticated(), asyn
* @apiParam {String} obj Object name
* @apiParam {String} file Image file name
*/
router.get("/img/:tribe/:obj/:file", checkHeaders(), isAuthenticated(), async (req, res) => {
router.get("/img/:tribe/:obj/:file", checkHeaders(), resolveTribeFromHost(), isAuthenticated(), async (req, res) => {
try {
const { tribe, obj, file } = req.params;
const role = req.session.header;
@@ -231,7 +232,7 @@ router.get("/img/:tribe/:obj/:file", checkHeaders(), isAuthenticated(), async (r
* @apiError (404 Not Found) DoesNotExist The requested item does not exist.
* @apiError (500 Internal Server Error) InternalServerError An unexpected error occurred on the server.
*/
router.get("/isexist/:tribe/:objectname/:keyid", checkHeaders(), isAuthenticated(), async (req, res) => {
router.get("/isexist/:tribe/:objectname/:keyid", checkHeaders(), resolveTribeFromHost(), isAuthenticated(), async (req, res) => {
try {
const { tribe, objectname, keyid } = req.params;
const db = new Odmdb(objectname, tribe);
@@ -267,7 +268,7 @@ router.get("/isexist/:tribe/:objectname/:keyid", checkHeaders(), isAuthenticated
* @apiError (404 Not Found) ItemNotFound The requested item was not found.
* @apiError (500 Internal Server Error) InternalServerError An unexpected error occurred on the server.
*/
router.get("/itm/:tribe/:objectname/:keyid", checkHeaders(), isAuthenticated(), async (req, res) => {
router.get("/itm/:tribe/:objectname/:keyid", checkHeaders(), resolveTribeFromHost(), isAuthenticated(), async (req, res) => {
try {
const { tribe, objectname, keyid } = req.params;
const db = new Odmdb(objectname, tribe);
@@ -300,7 +301,7 @@ router.get("/itm/:tribe/:objectname/:keyid", checkHeaders(), isAuthenticated(),
* @apiError (400 Bad Request) Validation Error The provided item data is invalid.
* @apiError (500 Internal Server Error) InternalServerError An unexpected error occurred on the server.
*/
router.post("/itm/:tribe/:objectname", checkHeaders(), isAuthenticated(), async (req, res) => {
router.post("/itm/:tribe/:objectname", checkHeaders(), resolveTribeFromHost(), isAuthenticated(), async (req, res) => {
try {
const { tribe, objectname } = req.params;
const db = new Odmdb(objectname, tribe);
@@ -335,7 +336,7 @@ router.post("/itm/:tribe/:objectname", checkHeaders(), isAuthenticated(), async
* @apiError (404 Not Found) ItemNotFound The item to update was not found.
* @apiError (500 Internal Server Error) InternalServerError An unexpected error occurred on the server.
*/
router.put("/itm/:tribe/:objectname", checkHeaders(), isAuthenticated(), async (req, res) => {
router.put("/itm/:tribe/:objectname", checkHeaders(), resolveTribeFromHost(), isAuthenticated(), async (req, res) => {
try {
const { tribe, objectname } = req.params;
const db = new Odmdb(objectname, tribe);
@@ -370,7 +371,7 @@ router.put("/itm/:tribe/:objectname", checkHeaders(), isAuthenticated(), async (
* @apiError (404 Not Found) ItemNotFound The item to delete was not found.
* @apiError (500 Internal Server Error) InternalServerError An unexpected error occurred on the server.
*/
router.delete("/itm/:tribe/:objectname/:keyid", checkHeaders(), isAuthenticated(), async (req, res) => {
router.delete("/itm/:tribe/:objectname/:keyid", checkHeaders(), resolveTribeFromHost(), isAuthenticated(), async (req, res) => {
try {
const { tribe, objectname, keyid } = req.params;
const db = new Odmdb(objectname, tribe);
@@ -426,7 +427,7 @@ router.delete("/itm/:tribe/:objectname/:keyid", checkHeaders(), isAuthenticated(
* @apiError (400 Bad Request) Validation Error The provided query is invalid.
* @apiError (500 Internal Server Error) InternalServerError An unexpected error occurred on the server.
*/
router.post("/search/:tribe/:objectname", checkHeaders(), isAuthenticated(), async (req, res) => {
router.post("/search/:tribe/:objectname", checkHeaders(), resolveTribeFromHost(), isAuthenticated(), async (req, res) => {
try {
const { tribe, objectname } = req.params;
const db = new Odmdb(objectname, tribe);
@@ -471,7 +472,7 @@ router.post("/search/:tribe/:objectname", checkHeaders(), isAuthenticated(), asy
* @apiError (404 Not Found) IndexNotFound The requested index does not exist or is not defined in the schema.
* @apiError (500 Internal Server Error) InternalServerError An unexpected error occurred on the server.
*/
router.get("/idx/:tribe/:objectname/:idxname", checkHeaders(), isAuthenticated(), async (req, res) => {
router.get("/idx/:tribe/:objectname/:idxname", checkHeaders(), resolveTribeFromHost(), isAuthenticated(), async (req, res) => {
try {
const { tribe, objectname, idxname } = req.params;
const db = new Odmdb(objectname, tribe);
@@ -500,7 +501,7 @@ router.get("/idx/:tribe/:objectname/:idxname", checkHeaders(), isAuthenticated()
* @apiError (400) InvalidSchema Schema must contain 'properties' and 'apxid'.
* @apiError (403) Forbidden Modifying core 'apxtri' schemas is not allowed.
*/
router.put("/schema/:tribe/:objectname", checkHeaders(), isAuthenticated(), async (req, res) => {
router.put("/schema/:tribe/:objectname", checkHeaders(), resolveTribeFromHost(), isAuthenticated(), async (req, res) => {
try {
const { tribe, objectname } = req.params;
const schema = req.body;
+18 -17
View File
@@ -21,6 +21,7 @@ const Blockchain = require("../models/Blockchain.js");
const Nations = require("../models/Nations.js");
const checkHeaders = require("../middlewares/checkHeaders.js");
const isAuthenticated = require("../middlewares/isAuthenticated.js");
const resolveTribeFromHost = require("../middlewares/resolveTribeFromHost.js");
const router = express.Router();
const log = process.env.ACTIVELOG?.split(',').includes('Pagans');
@@ -41,7 +42,7 @@ router.get("/alias/:alias", async (req, res) => {
}
});
router.get("/logout", checkHeaders(), isAuthenticated(), async (req, res) => {
router.get("/logout", checkHeaders(), resolveTribeFromHost(), isAuthenticated(), async (req, res) => {
try {
const result = await Pagans.logout(req.session.header);
const alias = req.session?.header?.xalias;
@@ -59,7 +60,7 @@ router.get("/logout", checkHeaders(), isAuthenticated(), async (req, res) => {
}
});
router.get("/isauth", checkHeaders(), isAuthenticated(), (req, res) => {
router.get("/isauth", checkHeaders(), resolveTribeFromHost(), isAuthenticated(), (req, res) => {
res.status(200).json({
status: 200,
ref: "Pagans",
@@ -71,7 +72,7 @@ router.get("/isauth", checkHeaders(), isAuthenticated(), (req, res) => {
});
});
router.post("/", checkHeaders(), isAuthenticated(), async (req, res) => {
router.post("/", checkHeaders(), resolveTribeFromHost(), isAuthenticated(), async (req, res) => {
try {
const { header } = req.session;
const { body } = req;
@@ -251,7 +252,7 @@ router.post("/", checkHeaders(), isAuthenticated(), async (req, res) => {
* }
* }
*/
router.put("/", checkHeaders(), isAuthenticated(), async (req, res) => {
router.put("/", checkHeaders(), resolveTribeFromHost(), isAuthenticated(), async (req, res) => {
try {
const { header } = req.session;
const { body } = req;
@@ -346,7 +347,7 @@ router.put("/", checkHeaders(), isAuthenticated(), async (req, res) => {
}
});
router.delete("/alias/:alias", checkHeaders(), isAuthenticated(), async (req, res) => {
router.delete("/alias/:alias", checkHeaders(), resolveTribeFromHost(), isAuthenticated(), async (req, res) => {
try {
const pagansDb = new Odmdb('pagans', TRIBE_NAME);
const role = req.session.header;
@@ -363,7 +364,7 @@ router.delete("/alias/:alias", checkHeaders(), isAuthenticated(), async (req, re
}
});
router.delete("/person/:tribe/:alias", checkHeaders(), isAuthenticated(), async (req, res) => {
router.delete("/person/:tribe/:alias", checkHeaders(), resolveTribeFromHost(), isAuthenticated(), async (req, res) => {
try {
const { tribe, alias } = req.params;
const personsDb = new Odmdb('persons', tribe);
@@ -449,7 +450,7 @@ router.delete("/person/:tribe/:alias", checkHeaders(), isAuthenticated(), async
* }
* }
*/
router.post("/invitation/:tribe", checkHeaders(), isAuthenticated(), async (req, res) => {
router.post("/invitation/:tribe", checkHeaders(), resolveTribeFromHost(), isAuthenticated(), async (req, res) => {
try {
const { tribe } = req.params;
const alias = req.body.alias || null;
@@ -506,7 +507,7 @@ router.post("/invitation/:tribe", checkHeaders(), isAuthenticated(), async (req,
* "msg": "item_already_exists"
* }
*/
router.post("/person/:tribe", checkHeaders(), isAuthenticated(), async (req, res) => {
router.post("/person/:tribe", checkHeaders(), resolveTribeFromHost(), isAuthenticated(), async (req, res) => {
try {
const { tribe } = req.params;
const { body } = req;
@@ -574,7 +575,7 @@ router.post("/person/:tribe", checkHeaders(), isAuthenticated(), async (req, res
}
});
router.get("/person/:tribe/:alias", checkHeaders(), isAuthenticated(), async (req, res) => {
router.get("/person/:tribe/:alias", checkHeaders(), resolveTribeFromHost(), isAuthenticated(), async (req, res) => {
try {
const { tribe, alias } = req.params;
const personsDb = new Odmdb('persons', tribe);
@@ -594,7 +595,7 @@ router.get("/person/:tribe/:alias", checkHeaders(), isAuthenticated(), async (re
* @apiDescription Lists the members of a tribe visible to the requesting alias, with fields filtered server-side according to the viewer's profile + per-viewer grants (druid/owner see all).
* @apiParam {String} tribe Tribe identifier
*/
router.get("/members/:tribe", checkHeaders(), isAuthenticated(), async (req, res) => {
router.get("/members/:tribe", checkHeaders(), resolveTribeFromHost(), isAuthenticated(), async (req, res) => {
try {
const role = req.session.header;
const result = await Pagans.getMembers(req.params.tribe, role);
@@ -612,7 +613,7 @@ router.get("/members/:tribe", checkHeaders(), isAuthenticated(), async (req, res
* @apiDescription Returns the calling alias's own access summary in a tribe (druid flag + granted schema R/W/D) used to decide visible icons/actions.
* @apiParam {String} tribe Tribe identifier
*/
router.get("/myaccess/:tribe", checkHeaders(), isAuthenticated(), async (req, res) => {
router.get("/myaccess/:tribe", checkHeaders(), resolveTribeFromHost(), isAuthenticated(), async (req, res) => {
try {
const role = req.session.header;
const result = await Pagans.myAccess(req.params.tribe, role);
@@ -630,7 +631,7 @@ router.get("/myaccess/:tribe", checkHeaders(), isAuthenticated(), async (req, re
* @apiDescription Returns the member access ACL of a tribe. Druid/owner only.
* @apiParam {String} tribe Tribe identifier
*/
router.get("/members/:tribe/acl", checkHeaders(), isAuthenticated(), async (req, res) => {
router.get("/members/:tribe/acl", checkHeaders(), resolveTribeFromHost(), isAuthenticated(), async (req, res) => {
try {
const role = req.session.header;
const result = await Pagans.getMemberAcl(req.params.tribe, role);
@@ -649,7 +650,7 @@ router.get("/members/:tribe/acl", checkHeaders(), isAuthenticated(), async (req,
* @apiParam {String} tribe Tribe identifier
* @apiBody {Array} entries ACL entries [{ target, rights, fields, schemas }]
*/
router.post("/members/:tribe/acl", checkHeaders(), isAuthenticated(), async (req, res) => {
router.post("/members/:tribe/acl", checkHeaders(), resolveTribeFromHost(), isAuthenticated(), async (req, res) => {
try {
const role = req.session.header;
const result = await Pagans.saveMemberAcl(req.params.tribe, req.body.entries, role);
@@ -659,7 +660,7 @@ router.post("/members/:tribe/acl", checkHeaders(), isAuthenticated(), async (req
}
});
router.get("/person/:alias", checkHeaders(), isAuthenticated(), async (req, res) => {
router.get("/person/:alias", checkHeaders(), resolveTribeFromHost(), isAuthenticated(), async (req, res) => {
try {
const { xtribe } = req.session.header;
const { alias } = req.params;
@@ -672,7 +673,7 @@ router.get("/person/:alias", checkHeaders(), isAuthenticated(), async (req, res)
}
});
router.put("/person/:tribe", checkHeaders(), isAuthenticated(), async (req, res) => {
router.put("/person/:tribe", checkHeaders(), resolveTribeFromHost(), isAuthenticated(), async (req, res) => {
try {
const { tribe } = req.params;
const { body } = req;
@@ -778,7 +779,7 @@ router.put("/person/:tribe", checkHeaders(), isAuthenticated(), async (req, res)
* @apiError (404) AliasNotFound Alias not found
* @apiError (500) InternalServerError Server error occurred
*/
router.get("/aliasauth/:alias", checkHeaders(), isAuthenticated(), async (req, res) => {
router.get("/aliasauth/:alias", checkHeaders(), resolveTribeFromHost(), isAuthenticated(), async (req, res) => {
try {
const { alias } = req.params;
if (alias !== req.session.header.xalias) {
@@ -814,7 +815,7 @@ router.get("/aliasauth/:alias", checkHeaders(), isAuthenticated(), async (req, r
* @apiError (403) Forbidden Access denied
* @apiError (500) InternalServerError Server error
*/
router.get("/persons/stats/:tribe?", checkHeaders(), isAuthenticated(), async (req, res) => {
router.get("/persons/stats/:tribe?", checkHeaders(), resolveTribeFromHost(), isAuthenticated(), async (req, res) => {
try {
const { tribe } = req.params;
const header = req.session.header;
+90 -5
View File
@@ -38,7 +38,7 @@ const handleError = (res, error, context) => {
console.error(`[PluginsRoute] Error in ${context}:`, error);
res.status(500).json({
status: 500,
ref: "Plugin",
ref: "Plugins",
msg: "plugins_internal_error",
context,
error: error.message,
@@ -69,10 +69,10 @@ router.get("/:tribe/:placement", checkHeaders(), isAuthenticated(), async (req,
try {
const { tribe, placement } = req.params;
if (typeof tribe !== "string" || !SLUG_RE.test(tribe)) {
return res.status(400).json({ status: 400, ref: "Plugin", msg: "plugins_invalid_tribe", data: { tribe } });
return res.status(400).json({ status: 400, ref: "Plugins", msg: "plugins_invalid_tribe", data: { tribe } });
}
if (typeof placement !== "string" || !SLUG_RE.test(placement)) {
return res.status(400).json({ status: 400, ref: "Plugin", msg: "plugins_invalid_placement", data: { placement } });
return res.status(400).json({ status: 400, ref: "Plugins", msg: "plugins_invalid_placement", data: { placement } });
}
const role = req.session.header;
@@ -82,7 +82,7 @@ router.get("/:tribe/:placement", checkHeaders(), isAuthenticated(), async (req,
// Missing list index or empty collection: no plugins yet, not an error.
return res.status(200).json({
status: 200,
ref: "Plugin",
ref: "Plugins",
msg: "plugins_list",
data: { tribe, placement, plugins: [] },
});
@@ -104,7 +104,7 @@ router.get("/:tribe/:placement", checkHeaders(), isAuthenticated(), async (req,
res.status(200).json({
status: 200,
ref: "Plugin",
ref: "Plugins",
msg: "plugins_list",
data: { tribe, placement, plugins },
});
@@ -113,4 +113,89 @@ router.get("/:tribe/:placement", checkHeaders(), isAuthenticated(), async (req,
}
});
/**
* @api {post} /plugins/:tribe/:placement/:pluginId/action Run A Plugin Button Action
* @apiName RunPluginAction
* @apiGroup 13-Plugins
* @apiVersion 1.0.0
* @apiDescription Executes a server-side action declared by a plugin button
* (buttons[].action) instead of navigating with an URL template. The core only
* validates the request and dispatches to the tribe handler
* {NODEPATH}/{tribe}/models/PluginActions.js, exporting one function per action
* name. The handler runs with the caller's identity and enforces its own data
* rights (e.g. via Odmdb). A button action not declared on the plugin is refused.
* @apiParam {String} tribe Tribe identifier holding the plugins data
* @apiParam {String} placement Placement slot inside the host webapp
* @apiParam {String} pluginId Plugin identifier carrying the button
* @apiBody {String} action Action name (must match a buttons[].action of the plugin)
* @apiBody {Object} [values] Mini-form values collected by the plugin slot
* @apiSuccess {Number} status 200
* @apiSuccess {String} ref Response reference
* @apiSuccess {String} msg Result template key
* @apiSuccess {Object} [data] Handler payload
* @apiError (400) InvalidRequest Invalid slug, missing action, or action not declared
* @apiError (404) NotFound Unknown/disabled plugin or placement mismatch
* @apiError (501) NotAvailable No tribe PluginActions handler for this action
*/
router.post("/:tribe/:placement/:pluginId/action", checkHeaders(), isAuthenticated(), async (req, res) => {
try {
const { tribe, placement, pluginId } = req.params;
for (const [key, value] of [["tribe", tribe], ["placement", placement], ["pluginId", pluginId]]) {
if (typeof value !== "string" || !SLUG_RE.test(value)) {
return res.status(400).json({ status: 400, ref: "Plugins", msg: `plugins_invalid_${key.toLowerCase()}`, data: { [key]: value } });
}
}
const action = String((req.body && req.body.action) || "").trim();
if (!action || !SLUG_RE.test(action)) {
return res.status(400).json({ status: 400, ref: "Plugins", msg: "plugins_invalid_action", data: { action } });
}
const role = req.session.header;
const db = new Odmdb("plugins", tribe);
const read = await db.read(pluginId, role);
if (read.status !== 200 || !read.data) {
return res.status(404).json({ status: 404, ref: "Plugins", msg: "plugins_unknown_plugin", data: { pluginId } });
}
const plugin = read.data;
if (plugin.placement !== placement || plugin.enabled === false) {
return res.status(404).json({ status: 404, ref: "Plugins", msg: "plugins_unknown_plugin", data: { pluginId, placement } });
}
const declared = (Array.isArray(plugin.buttons) ? plugin.buttons : [])
.some((b) => b && b.action === action);
if (!declared) {
return res.status(400).json({ status: 400, ref: "Plugins", msg: "plugins_action_not_declared", data: { pluginId, action } });
}
const handlerPath = path.join(process.env.NODEPATH, tribe, "models", "PluginActions.js");
if (!fs.existsSync(handlerPath)) {
return res.status(501).json({ status: 501, ref: "Plugins", msg: "plugins_action_unavailable", data: { tribe, action } });
}
let handlers;
try {
handlers = require(handlerPath);
} catch (error) {
return res.status(501).json({ status: 501, ref: "Plugins", msg: "plugins_action_unavailable", data: { tribe, action } });
}
const handler = handlers && handlers[action];
if (typeof handler !== "function") {
return res.status(501).json({ status: 501, ref: "Plugins", msg: "plugins_action_unavailable", data: { tribe, action } });
}
const result = await handler((req.body && req.body.values) || {}, {
tribe,
placement,
pluginId,
plugin,
header: req.session.header,
tribeContext: req.tribeContext,
});
const status = Number(result && result.status) || 200;
return res.status(status).json(result || { status: 200, ref: "Plugins", msg: "plugin_action_ok" });
} catch (error) {
handleError(res, error, `POST /:tribe/:placement/:pluginId/action`);
}
});
module.exports = router;
+10 -10
View File
@@ -36,7 +36,7 @@ const handleError = (res, error, context) => {
/**
* @api {post} /api/apxtri/thread/:tribe - Add a message
* @apiName AddThreadMessage
* @apiGroup 13-Thread
* @apiGroup 06-Thread
* @apiVersion 1.0.0
* @apiDescription Adds a new message to a thread thread. If it's the first message for a given UUID, it creates the thread.
* @apiParam {String} tribe Tribe identifier
@@ -57,7 +57,7 @@ router.post("/:tribe", checkHeaders(), isAuthenticated(), async (req, res) => {
/**
* @api {get} /api/apxtri/thread/:tribe/list/:alias - List threads
* @apiName GetThreadThreads
* @apiGroup 13-Thread
* @apiGroup 06-Thread
* @apiVersion 1.0.0
* @apiDescription Lists all threads in a tribe (sorted by most recent activity).
* @apiParam {String} tribe Tribe identifier
@@ -75,7 +75,7 @@ router.get("/:tribe/list/:alias", checkHeaders(), isAuthenticated(), async (req,
/**
* @api {get} /api/apxtri/thread/:tribe/:uuid - Get messages
* @apiName GetThreadMessages
* @apiGroup 13-Thread
* @apiGroup 06-Thread
* @apiVersion 1.0.0
* @apiDescription Gets all messages for a thread since a given timestamp.
* @apiParam {String} tribe Tribe identifier
@@ -93,7 +93,7 @@ router.get("/:tribe/:uuid", checkHeaders(), isAuthenticated(), async (req, res)
/**
* @api {get} /api/apxtri/thread/:tribe/:uuid/details - Get thread details
* @apiName GetThreadThreadDetails
* @apiGroup 13-Thread
* @apiGroup 06-Thread
* @apiVersion 1.0.0
* @apiDescription Gets the publicKey and aliases for a thread.
* @apiParam {String} tribe Tribe identifier
@@ -111,7 +111,7 @@ router.get("/:tribe/:uuid/details", checkHeaders(), isAuthenticated(), async (re
/**
* @api {delete} /api/apxtri/thread/:tribe/:uuid - Delete a whole thread
* @apiName DeleteThreadThread
* @apiGroup 13-Thread
* @apiGroup 06-Thread
* @apiVersion 1.0.0
* @apiDescription Deletes an entire thread (all messages + attachment blobs) owned by the authenticated alias.
* @apiParam {String} tribe Tribe identifier
@@ -130,7 +130,7 @@ router.delete("/:tribe/:uuid", checkHeaders(), isAuthenticated(), async (req, re
/**
* @api {delete} /api/apxtri/thread/:tribe/:uuid/:privatriid - Delete a message
* @apiName DeleteThreadMessage
* @apiGroup 13-Thread
* @apiGroup 06-Thread
* @apiVersion 1.0.0
* @apiDescription Deletes a single message (and its attachment blobs) owned by the authenticated alias.
* @apiParam {String} tribe Tribe identifier
@@ -150,7 +150,7 @@ router.delete("/:tribe/:uuid/:privatriid", checkHeaders(), isAuthenticated(), as
/**
* @api {post} /api/apxtri/thread/:tribe/:uuid/:privatriid/resolve - Resolve an action tag
* @apiName ResolveThreadAction
* @apiGroup 13-Thread
* @apiGroup 06-Thread
* @apiVersion 1.0.0
* @apiDescription Marks an action tag as resolved on a message so its buttons are hidden. Owner only.
* @apiParam {String} tribe Tribe identifier
@@ -175,7 +175,7 @@ router.post("/:tribe/:uuid/:privatriid/resolve", checkHeaders(), isAuthenticated
/**
* @api {post} /api/apxtri/thread/:tribe/:uuid/invites - Create an invite
* @apiName CreateThreadInvite
* @apiGroup 13-Thread
* @apiGroup 06-Thread
* @apiVersion 1.0.0
* @apiDescription Creates an invitation for a user to join a thread.
* @apiParam {String} tribe Tribe identifier
@@ -194,7 +194,7 @@ router.post("/:tribe/:uuid/invites", checkHeaders(), isAuthenticated(), async (r
/**
* @api {get} /api/apxtri/thread/invites/:token - Get an invite
* @apiName GetThreadInvite
* @apiGroup 13-Thread
* @apiGroup 06-Thread
* @apiVersion 1.0.0
* @apiDescription Retrieves the invitation details. This is a public endpoint.
* @apiParam {String} token The invitation token
@@ -211,7 +211,7 @@ router.get("/invites/:token", async (req, res) => {
/**
* @api {get} /api/apxtri/thread/:tribe/:uuid/attachment/:name - Get thread attachment
* @apiName GetThreadAttachment
* @apiGroup 13-Thread
* @apiGroup 06-Thread
* @apiVersion 1.0.0
* @apiDescription Serves an encrypted attachment (.pgp) of a thread. The requester must be a participant of the thread.
* @apiParam {String} tribe Tribe identifier
+9 -9
View File
@@ -22,7 +22,7 @@ const handleError = (res, error, context) => {
/**
* @api {post} /api/apxtri/thread_public/:tribe - Create public thread
* @apiName CreatePublicThread
* @apiGroup 13-ThreadPublic
* @apiGroup 14-ThreadPublic
* @apiVersion 1.0.0
* @apiDescription Creates a new public thread (annonce/besoin/service/produit/article). Requires APX3 fee.
* @apiParam {String} tribe Tribe identifier
@@ -40,7 +40,7 @@ router.post("/:tribe", checkHeaders(), isAuthenticated(), async (req, res) => {
/**
* @api {get} /api/apxtri/thread_public/list - List public threads
* @apiName ListPublicThreads
* @apiGroup 13-ThreadPublic
* @apiGroup 14-ThreadPublic
* @apiVersion 1.0.0
* @apiDescription Lists public threads with optional filters (?type=&town=&owner=).
*/
@@ -56,7 +56,7 @@ router.get("/list", checkHeaders(), async (req, res) => {
/**
* @api {get} /api/apxtri/thread_public/:tribe/:uuid - Get public thread details
* @apiName GetPublicThread
* @apiGroup 13-ThreadPublic
* @apiGroup 14-ThreadPublic
* @apiVersion 1.0.0
* @apiDescription Gets details of a public thread including reviews and contact transparency.
* @apiParam {String} tribe Tribe identifier
@@ -74,7 +74,7 @@ router.get("/:tribe/:uuid", checkHeaders(), async (req, res) => {
/**
* @api {post} /api/apxtri/thread_public/:tribe/:uuid/review - Add review
* @apiName AddPublicThreadReview
* @apiGroup 13-ThreadPublic
* @apiGroup 14-ThreadPublic
* @apiVersion 1.0.0
* @apiDescription Adds a positive or negative review. Requires comment fee.
* @apiParam {String} tribe Tribe identifier
@@ -93,7 +93,7 @@ router.post("/:tribe/:uuid/review", checkHeaders(), isAuthenticated(), async (re
/**
* @api {post} /api/apxtri/thread_public/:tribe/:uuid/contact - Pay to contact owner
* @apiName PayContact
* @apiGroup 13-ThreadPublic
* @apiGroup 14-ThreadPublic
* @apiVersion 1.0.0
* @apiDescription Pays the contact price (via tab or APX3) and creates a private contact thread.
* @apiParam {String} tribe Tribe identifier
@@ -112,7 +112,7 @@ router.post("/:tribe/:uuid/contact", checkHeaders(), isAuthenticated(), async (r
/**
* @api {post} /api/apxtri/thread_public/:tribe/:uuid/refuse-refund - Refuse refund
* @apiName RefuseRefund
* @apiGroup 13-ThreadPublic
* @apiGroup 14-ThreadPublic
* @apiVersion 1.0.0
* @apiDescription Owner refuses to refund a contact (keeps the contact price).
* @apiParam {String} tribe Tribe identifier
@@ -131,7 +131,7 @@ router.post("/:tribe/:uuid/refuse-refund", checkHeaders(), isAuthenticated(), as
/**
* @api {post} /api/apxtri/thread_public/:tribe/:uuid/close-contact - Close contact
* @apiName CloseContact
* @apiGroup 13-ThreadPublic
* @apiGroup 14-ThreadPublic
* @apiVersion 1.0.0
* @apiDescription Closes a contact exchange and opens the refund decision window.
* @apiParam {String} tribe Tribe identifier
@@ -149,7 +149,7 @@ router.post("/:tribe/:uuid/close-contact", checkHeaders(), isAuthenticated(), as
/**
* @api {delete} /api/apxtri/thread_public/:tribe/:uuid - Delete public thread
* @apiName DeletePublicThread
* @apiGroup 13-ThreadPublic
* @apiGroup 14-ThreadPublic
* @apiVersion 1.0.0
* @apiDescription Soft-deletes a public thread. Must have survived at least one reputation cycle.
* @apiParam {String} tribe Tribe identifier
@@ -168,7 +168,7 @@ router.delete("/:tribe/:uuid", checkHeaders(), isAuthenticated(), async (req, re
/**
* @api {put} /api/apxtri/thread_public/:tribe/:uuid - Update public thread
* @apiName UpdatePublicThread
* @apiGroup 13-ThreadPublic
* @apiGroup 14-ThreadPublic
* @apiVersion 1.0.0
* @apiDescription Updates a public thread (owner only). Sets dt_update so readers see it as unread again.
* @apiParam {String} tribe Tribe identifier
+2 -2
View File
@@ -13,7 +13,7 @@ const handleError = (res, error, context) => {
/**
* @api {get} /adminapi/towntabs/:alias - Get tab balance
* @apiName GetTownTab
* @apiGroup 13-TownTabs
* @apiGroup 15-TownTabs
* @apiVersion 1.0.0
* @apiDescription Gets the current tab (ardoise) balance and status for an alias on this town.
* @apiParam {String} alias Pagan alias
@@ -30,7 +30,7 @@ router.get("/:alias", checkHeaders(), isAuthenticated(), async (req, res) => {
/**
* @api {post} /adminapi/towntabs/:alias/settle - Settle tab
* @apiName SettleTownTab
* @apiGroup 13-TownTabs
* @apiGroup 15-TownTabs
* @apiVersion 1.0.0
* @apiDescription Marks a tab as settled after a real APX3 transfer was made. Amount reduces net debt.
* @apiParam {String} alias Pagan alias
+3 -3
View File
@@ -27,7 +27,7 @@ const handleError = (res, error, context) => {
/**
* @api {get} /vault/:alias Get Vault
* @apiName GetVault
* @apiGroup 13-Vault
* @apiGroup 16-Vault
* @apiVersion 1.0.0
* @apiDescription Returns the caller's vault (passphrase-encrypted key blobs). Only the owner may read it.
* @apiParam {String} alias Owner alias (must equal the authenticated alias)
@@ -46,7 +46,7 @@ router.get("/:alias", checkHeaders(), isAuthenticated(), async (req, res) => {
/**
* @api {post} /vault/:alias Save Vault
* @apiName SaveVault
* @apiGroup 13-Vault
* @apiGroup 16-Vault
* @apiVersion 1.0.0
* @apiDescription Upserts passphrase-encrypted key blobs into the caller's vault (merged by entity id). The server never sees plaintext keys.
* @apiParam {String} alias Owner alias (must equal the authenticated alias)
@@ -67,7 +67,7 @@ router.post("/:alias", checkHeaders(), isAuthenticated(), async (req, res) => {
/**
* @api {delete} /vault/:alias/:entityId Remove Vault Key
* @apiName RemoveVaultKey
* @apiGroup 13-Vault
* @apiGroup 16-Vault
* @apiVersion 1.0.0
* @apiDescription Removes a single encrypted key blob from the caller's vault.
* @apiParam {String} alias Owner alias (must equal the authenticated alias)
+27 -1
View File
@@ -79,6 +79,29 @@ router.get("/updatelocaldb/:tribe/:appname/:subapp/:coreVersion/:subappVersion",
}
});
/**
* @api {get} /wwws/option/:tribe/:optionid Get Tribe Option
* @apiName GetTribeOption
* @apiGroup 09-Wwws
* @apiVersion 1.0.0
* @apiDescription Reads a single option document of a tribe (e.g. 'profil', 'conditiontojoin') with language resolution.
* @apiParam {String} tribe Tribe identifier
* @apiParam {String} optionid Option identifier (optionid)
* @apiSuccess {Number} status 200
* @apiSuccess {String} msg "option_found"
* @apiSuccess {Object} data The option document (optionid, lst_idx, itms, ...)
*/
router.get("/option/:tribe/:optionid", checkHeaders(), isAuthenticated(), async (req, res) => {
try {
const { tribe, optionid } = req.params;
const lg = (req.session.header && req.session.header.xlang) || 'en';
const result = await Wwws.readOption(tribe, optionid, lg);
res.status(result.status).json(result);
} catch (error) {
handleError(res, error, 'GET /option/:tribe/:optionid');
}
});
/**
* @api {get} /wwws/buildpwa/:tribe/:app Build PWA application
* @apiName BuildPWA
@@ -178,6 +201,7 @@ router.get("/tribestyle/:tribe", checkHeaders(), isAuthenticated(), async (req,
* @apiSuccess {Object} data Response data
* @apiSuccess {Array} data.webapps Array of webapp objects with name, dns, description, enabled
* @apiSuccess {String} data.webapps[].dns DNS served by this nation: `dnszero` for the nation zero, `dns` otherwise
* @apiSuccess {Array} data.webapps[].subapps Subapp keys declared in appconf.json (e.g. ["bdc","planif","com"])
* @apiSuccessExample {json} Success-Response:
* HTTP/1.1 200 OK
* {
@@ -186,7 +210,7 @@ router.get("/tribestyle/:tribe", checkHeaders(), isAuthenticated(), async (req,
* "msg": "webapps_listed",
* "data": {
* "webapps": [
* { "name": "mainapp", "dns": "dev.zero.ndda.fr", "description": "Town Admin", "enabled": true }
* { "name": "mainapp", "dns": "dev.zero.ndda.fr", "description": "Town Admin", "enabled": true, "subapps": ["bdc", "planif", "com"] }
* ]
* }
* }
@@ -208,6 +232,7 @@ router.get("/list/:tribe", checkHeaders(), isAuthenticated(), async (req, res) =
description: conf.description || '',
enabled: conf.enabled !== false,
navtype: conf.navtype || 'burger',
subapps: Object.keys(conf.subapp || {}),
appDir: appDir
});
} catch (e) {
@@ -217,6 +242,7 @@ router.get("/list/:tribe", checkHeaders(), isAuthenticated(), async (req, res) =
description: '(invalid appconf.json)',
enabled: false,
navtype: 'burger',
subapps: [],
appDir: appDir
});
}
+39 -1
View File
@@ -2,7 +2,7 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "/schema/mailboxes",
"title": "Mailbox - External email account (multi-provider)",
"description": "A mailbox is an external email account (provider imap/pop3) attached to a tribe for synchronisation. Mailbox documents and their password secret live in the attached tribe namespace ({DATAPATH}/{tribe}/objects/mailboxes/itm and .../secrets). Synchronised mails are stored in the objects/mails collection of the same tribe. mailboxid stays globally unique across all tribes so a mailbox is resolvable by id. Passwords are never stored in the document: the password secret is stored separately at {DATAPATH}/{tribe}/objects/mailboxes/secrets/{mailboxid}.json (chmod 600). GRANULAR ACCESS: per-mailbox virtual profiles 'mail-{mailboxid}-reader' (read only) and 'mail-{mailboxid}-writer' (read + actions read/unread/trash) are added to the 'profils' array of the person document of the granted alias INSIDE the attached tribe. The model provides helpers setAccess/removeAccess to manage these profiles. APX3 BILLING: creating a mailbox requires the APX3 payment agreement and a declared weekly volume stored in the 'apx3' object.",
"description": "A mailbox is an external email account (provider imap/pop3) attached to a tribe for synchronisation. Mailbox documents and their password secret live in the attached tribe namespace ({DATAPATH}/{tribe}/objects/mailboxes/itm and .../secrets). Synchronised mails are stored in the objects/mails collection of the same tribe. mailboxid stays globally unique across all tribes so a mailbox is resolvable by id. Passwords are never stored in the document: the password secret is stored separately at {DATAPATH}/{tribe}/objects/mailboxes/secrets/{mailboxid}.json (chmod 600). GRANULAR ACCESS: per-mailbox virtual profiles 'mail-{mailboxid}-reader' (read only) and 'mail-{mailboxid}-writer' (read + actions read/unread/trash) are added to the 'profils' array of the person document of the granted alias INSIDE the attached tribe. The model provides helpers setAccess/removeAccess to manage these profiles. APX3 BILLING: creating a mailbox requires the APX3 payment agreement and a declared weekly volume stored in the 'apx3' object. SYNC WATERMARK: a sync only fetches messages beyond the stored per-folder high-watermark 'syncCursor' instead of re-reading the N newest remote ids on every poll, and the 'syncing' marker prevents concurrent provider syncs when several users press sync at the same time.",
"type": "object",
"required": [
"mailboxid",
@@ -129,6 +129,30 @@
"type": ["string", "null"],
"description": "Time after which cron may poll this mailbox again (lastSync + pollIntervalMin)"
},
"syncCursor": {
"type": "object",
"description": "High-watermark cursor per synchronised folder so a sync only fetches messages never stored before, instead of re-reading the N newest remote ids on every poll. IMAP keys are folder names and store {uidValidity, lastUid, lastRemoteNum}; POP3 uses the single key 'pop3' and stores {lastRemoteNum}. uidValidity guards against a remote mailbox being re-created: when the server value changes the cursor is discarded and the folder is fully re-scanned.",
"additionalProperties": {
"type": "object",
"additionalProperties": false,
"properties": {
"uidValidity": { "type": ["number", "null"] },
"lastUid": { "type": ["number", "null"] },
"lastRemoteNum": { "type": ["number", "null"] },
"updatedAt": { "type": ["string", "null"] }
}
}
},
"syncing": {
"type": ["object", "null"],
"description": "Concurrency marker set while a synchronisation of this mailbox is running, so several users pressing sync at the same time cannot start concurrent provider syncs. Holds the alias that started it and the start date; null or absent when idle. A marker older than 10 minutes is treated as stale and ignored.",
"additionalProperties": false,
"properties": {
"alias": { "type": "string" },
"since": { "type": "string" },
"remote": { "type": "boolean" }
}
},
"downloadMode": {
"type": "string",
"enum": ["lazy", "eager"],
@@ -157,6 +181,20 @@
"type": "object"
}
},
"availableTags": {
"type": "array",
"description": "Tags available for this mailbox, managed by druids. Each tag has a key (stored in mail.tags), label (display), and color (badge styling).",
"items": {
"type": "object",
"additionalProperties": false,
"properties": {
"key": { "type": "string", "description": "Tag key stored in mail.tags" },
"label": { "type": "string", "description": "Display label" },
"color": { "type": "string", "enum": ["primary", "secondary", "accent", "success", "warning", "error", "info", "neutral"], "default": "primary", "description": "DaisyUI badge color" }
},
"required": ["key", "label"]
}
},
"apx3": {
"type": "object",
"additionalProperties": false,
+10
View File
@@ -39,6 +39,11 @@
"name": "by_from",
"type": "distribution",
"keyval": "from.email"
},
{
"name": "by_tags",
"type": "array",
"keyval": "tags"
}
],
"apxaccessrights": {
@@ -221,6 +226,11 @@
"enum": ["new", "read", "archived", "trash"],
"default": "new"
},
"tags": {
"type": "array",
"description": "Workflow tags applied by tribe users (keys match mailbox.availableTags)",
"items": { "type": "string" }
},
"bodyRef": {
"type": "string",
"description": "Relative path to the full body content if stored on disk (cache)"
+12 -1
View File
@@ -119,8 +119,15 @@
"type": "string"
},
"urlTemplate": {
"title": "Navigation URL template (mutually exclusive with action)",
"type": "string"
},
"action": {
"title": "Server-side action name (mutually exclusive with urlTemplate)",
"description": "Name of a function exported by {NODEPATH}/{tribe}/models/PluginActions.js. When set, clicking the button POSTs to /api/apxtri/plugins/:tribe/:placement/:pluginId/action instead of navigating.",
"type": "string",
"pattern": "^[a-z0-9][a-z0-9-]*$"
},
"open": {
"type": "string",
"enum": ["tab", "self"]
@@ -130,7 +137,11 @@
"default": false
}
},
"required": ["label", "urlTemplate"]
"required": ["label"],
"anyOf": [
{ "required": ["urlTemplate"] },
{ "required": ["action"] }
]
}
},
"owner": {
+137 -5
View File
@@ -9,9 +9,22 @@
# sudo ./init.sh --town <name> --nation <name> [--clean] [--help]
#
# Examples:
# sudo ./init.sh --town farm --nation test # Dev mode
# sudo ./init.sh --town nova --nation libera --domain nova.libera.maildigit.fr # Prod mode
# sudo ./init.sh --town nova --nation libera --domain x.fr --trusted-proxy-ips 1.2.3.4 # Prod behind proxy
# sudo ./init.sh --town nova --nation libera --domain nova.libera.maildigit.fr
# sudo ./init.sh --town nova --nation libera --domain x.fr --trusted-proxy-ips 1.2.3.4
# sudo ./init.sh --town dev --nation zero --domain dev.zero.example # Experimental nation
#
# Every install is a production install: --domain is required from the first
# prompt (see VALIDATION below), there is no relaxed variant of this script.
#
# Nation "zero" is apXtri's experimental convention:
# - it is declared hackable and at risk, and its token carries no value;
# this is known by everyone, on purpose;
# - its towns may run with relaxed agent permissions (see
# AGENT_PERMISSIONS_NATIONS in STEP 7, which seeds the per-town overlay
# {DATAPATH}/{TOWN}/opencode-permissions.json).
# Every other nation must be treated as production-grade and therefore stays
# strict by default: agents keep the restrictive template shipped in
# apxtri/.opencode/agents/ and no overlay file is created.
#
# Note: Install is always HTTP only. HTTPS activation is done later via web UI.
@@ -204,7 +217,7 @@ if [[ -z "$DOMAIN" ]]; then
usage
fi
echo "[INFO] Using domain: $DOMAIN"
echo "[INFO] PROD mode (nation=$NATION)"
echo "[INFO] Installation profile: production (nation=$NATION)"
# ==== DEVELOPER RESOLUTION ====
if [[ -z "$DEVEL_USER" ]]; then
@@ -292,7 +305,7 @@ else
fi
# ==== STEP 3: HOSTS CONFIGURATION ====
echo "[STEP 3/11] Skip /etc/hosts update (production mode)"
echo "[STEP 3/11] Skip /etc/hosts update"
# ==== STEP 4: CREATE USER ====
echo "[STEP 4/11] Creating system user..."
@@ -394,6 +407,125 @@ fi
echo "[INFO] mainapp + cdn provisioned from apxtri repository."
# Agent permissions: the town data directory may carry an optional
# opencode-permissions.json overlay, injected into the frontmatter of
# {NODEPATH}/.opencode/agents/*.md by apxtri.js::_injectAgentPermissions() on
# every server start. It lives in {DATAPATH} because {NODEPATH}/.opencode/ is
# emptied and re-copied from the repositories on each start (and tracked repo
# files are wiped by setup/update.sh), while the data directory is never reset.
#
# AGENT_PERMISSIONS_NATIONS below is the single place where the policy lives:
# a nation listed there gets a relaxed permission profile seeded at install time.
# Any other nation ships no file and keeps the strict template from
# apxtri/.opencode/agents/.
#
# The seeded profile is the relaxed bash + external_directory profile for the experimental nation
# zero (sudo stays on approval, destructive commands denied). The file belongs to
# the town's mayor: edit it to change the permission level, or delete it to fall
# back to strict. It is never overwritten.
AGENT_PERMISSIONS_NATIONS=("zero")
if [[ " ${AGENT_PERMISSIONS_NATIONS[*]} " == *" $NATION "* ]] \
&& [[ ! -f "$TRIBE_DATA_DIR/opencode-permissions.json" ]]; then
# Quoted heredoc: nothing is expanded by the shell, the {{TOWN}}/{{NATION}}
# placeholders are rendered right after by sed (same pattern as the apidoc
# appconf.json rendering above).
cat > "$TRIBE_DATA_DIR/opencode-permissions.json" <<'JSON'
{
"_doc": "apXtri agent permission overrides for this town. Injected into the frontmatter of {NODEPATH}/.opencode/agents/*.md at server start by _syncOpenCode(). Nation zero is apXtri's experimental convention: declared hackable and at risk, its token carries no value. Any other nation ships no file and keeps the strict template from apxtri/.opencode/agents/. This file belongs to the town's mayor: edit it to change the permission level, or delete it to fall back to strict. Rules are evaluated in order and the last match wins.",
"town": "{{TOWN}}",
"nation": "{{NATION}}",
"agents": {
"be-apxtri": {
"read": "allow",
"edit": "allow",
"glob": "allow",
"grep": "allow",
"bash": {
"*": "allow",
"rm -rf *": "deny",
"rm -fr *": "deny",
"rm -Rf *": "deny",
"mkfs*": "deny",
"dd if=*": "deny",
"shutdown*": "deny",
"reboot*": "deny",
"halt*": "deny",
"git push*": "ask",
"sudo *": "ask",
"* sudo *": "ask"
},
"external_directory": {
"*": "allow",
"/etc/*": "deny",
"/etc/caddy/*": "ask",
"/root/**": "deny",
"/home/*/.ssh/**": "deny"
}
},
"fe-pwa": {
"read": "allow",
"edit": "allow",
"glob": "allow",
"grep": "allow",
"bash": {
"*": "allow",
"rm -rf *": "deny",
"rm -fr *": "deny",
"rm -Rf *": "deny",
"mkfs*": "deny",
"dd if=*": "deny",
"shutdown*": "deny",
"reboot*": "deny",
"halt*": "deny",
"git push*": "ask",
"sudo *": "ask",
"* sudo *": "ask"
},
"external_directory": {
"*": "allow",
"/etc/*": "deny",
"/etc/caddy/*": "ask",
"/root/**": "deny",
"/home/*/.ssh/**": "deny"
}
},
"schema": {
"read": "allow",
"edit": "allow",
"glob": "allow",
"grep": "allow",
"bash": {
"*": "allow",
"rm -rf *": "deny",
"rm -fr *": "deny",
"rm -Rf *": "deny",
"mkfs*": "deny",
"dd if=*": "deny",
"shutdown*": "deny",
"reboot*": "deny",
"halt*": "deny",
"git push*": "ask",
"sudo *": "ask",
"* sudo *": "ask"
},
"external_directory": {
"*": "allow",
"/etc/*": "deny",
"/etc/caddy/*": "ask",
"/root/**": "deny",
"/home/*/.ssh/**": "deny"
}
}
}
}
JSON
sed -i \
-e "s/{{TOWN}}/$TOWN/g" \
-e "s/{{NATION}}/$NATION/g" \
"$TRIBE_DATA_DIR/opencode-permissions.json"
echo "[INFO] Relaxed agent permissions seeded (nation=$NATION)."
fi
# Create conf.json and initial items for essential objects
for obj in nations towns tribes; do
CONF_FILE="$TRIBE_DATA_DIR/objects/$obj/conf.json"