@tekkare/romulus 0.1.241 → 0.1.243
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/module.json +1 -1
- package/layer/app/app.config.ts +90 -0
- package/layer/app/assets/tekkare_logo.svg +20 -0
- package/layer/app/components/AppUpdateToast.vue +157 -0
- package/layer/app/components/OipDirectusSources.vue +192 -0
- package/layer/app/components/OipDocumentPreview.vue +158 -0
- package/layer/app/components/OipMetadataEditDialog.vue +350 -0
- package/layer/app/components/OipNoteEditDialog.vue +382 -0
- package/layer/app/components/OipShell.vue +713 -0
- package/layer/app/components/OipSourceEditDialog.vue +419 -0
- package/layer/app/components/OipSourceGroups.vue +107 -0
- package/layer/app/components/auth/SplitLayout.vue +261 -0
- package/layer/app/components/layout/firstVisitVideo.vue +165 -0
- package/layer/app/components/panels/OipPanelDocuments.vue +220 -0
- package/layer/app/components/panels/OipPanelMetadata.vue +388 -0
- package/layer/app/components/panels/OipPanelNotes.vue +99 -0
- package/layer/app/components/panels/OipPanelProjects.vue +94 -0
- package/layer/app/components/panels/OipPanelSourceTables.vue +8 -0
- package/layer/app/components/panels/OipPanelSources.vue +361 -0
- package/layer/app/components/panels/OipPanelTopics.vue +339 -0
- package/layer/app/components/panels/OipPanelTutorials.vue +122 -0
- package/layer/app/composables/useOipCrumbLabel.ts +11 -0
- package/layer/app/composables/useOipEsIndices.ts +57 -0
- package/layer/app/composables/useOipFavorites.ts +69 -0
- package/layer/app/composables/useOipNotes.ts +90 -0
- package/layer/app/composables/useOipNotify.ts +21 -0
- package/layer/app/composables/useOipSourceTableGroups.ts +54 -0
- package/layer/app/composables/useViewEditing.ts +11 -0
- package/layer/app/layouts/anonymous.vue +6 -0
- package/layer/app/layouts/error.vue +38 -0
- package/layer/app/notes/categories.ts +46 -0
- package/layer/app/notes/types.ts +100 -0
- package/layer/app/plugins/oip-es-indices.client.ts +18 -0
- package/layer/app/types/metadata.ts +38 -0
- package/layer/app/types/oip.ts +60 -0
- package/layer/app/types/tableGroups.ts +42 -0
- package/layer/app/utils/countries.ts +30 -0
- package/layer/app/utils/exportPage.ts +418 -0
- package/layer/app/utils/headerTabs.ts +15 -0
- package/layer/app/utils/hybridRows.ts +22 -0
- package/layer/app/utils/oipRail.ts +82 -0
- package/layer/app/utils/postLoginRedirect.ts +58 -0
- package/layer/app/utils/valueTable.ts +126 -0
- package/layer/nuxt.config.ts +32 -0
- package/layer/server/api/oip/asset/[id].get.ts +89 -0
- package/layer/server/api/oip/documents.get.ts +92 -0
- package/layer/server/api/oip/es-indices.get.ts +21 -0
- package/layer/server/api/oip/favorites.get.ts +107 -0
- package/layer/server/api/oip/metadata.get.ts +109 -0
- package/layer/server/api/oip/metadata.patch.ts +163 -0
- package/layer/server/api/oip/note-options.get.ts +89 -0
- package/layer/server/api/oip/note.get.ts +95 -0
- package/layer/server/api/oip/notes.get.ts +173 -0
- package/layer/server/api/oip/notes.patch.ts +198 -0
- package/layer/server/api/oip/sources.patch.ts +145 -0
- package/layer/server/api/oip/sources.post.ts +88 -0
- package/layer/server/api/oip/table-groups.get.ts +145 -0
- package/layer/server/api/oip/tutorial-video.get.ts +44 -0
- package/layer/server/api/romulus/version.get.ts +4 -0
- package/layer/server/utils/oipAuth.ts +69 -0
- package/layer/server/utils/oipEsIndices.ts +99 -0
- package/layer/server/utils/oipGuards.ts +90 -0
- package/layer/server/utils/oipNoteMarkup.ts +197 -0
- package/package.json +6 -3
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Catalogue du rail droit OIP 7 : l'ordre, le nom, l'icone et le regroupement
|
|
2
|
+
// de chaque tuile. Fixe pour toutes les apps : le lecteur retrouve chaque
|
|
3
|
+
// outil a la meme place d'un ecran et d'une app a l'autre. Une app ne declare
|
|
4
|
+
// que l'etat des tuiles (servie ou non, pastille, point), cf. OipShell `tiles`.
|
|
5
|
+
|
|
6
|
+
export type OipRailTileKey =
|
|
7
|
+
| "Sources"
|
|
8
|
+
| "SourcesTables"
|
|
9
|
+
| "Notes"
|
|
10
|
+
| "Documents"
|
|
11
|
+
| "Views"
|
|
12
|
+
| "Alerts"
|
|
13
|
+
| "AdminTemplates"
|
|
14
|
+
| "Export"
|
|
15
|
+
| "Projects"
|
|
16
|
+
| "Topics"
|
|
17
|
+
| "Tutorials"
|
|
18
|
+
| "Accessibility"
|
|
19
|
+
| "Metadata";
|
|
20
|
+
|
|
21
|
+
export type OipRailGroupKey = "Documentation" | "ViewsAlerts";
|
|
22
|
+
|
|
23
|
+
export type OipRailEntry = {
|
|
24
|
+
key: OipRailTileKey;
|
|
25
|
+
label: { fr: string; en: string };
|
|
26
|
+
icon: string;
|
|
27
|
+
/** Lue en onglet d'un panneau commun avec les autres membres du groupe. */
|
|
28
|
+
group?: OipRailGroupKey;
|
|
29
|
+
/** Reservee aux superadmins. */
|
|
30
|
+
admin?: boolean;
|
|
31
|
+
/** Toujours servie, quel que soit l'ecran : elle ne depend pas de la page. */
|
|
32
|
+
frame?: boolean;
|
|
33
|
+
/** Sa pastille reste sur son onglet, sans s'ajouter a celle du groupe. */
|
|
34
|
+
ownCount?: boolean;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export const OIP_RAIL_GROUPS: Record<OipRailGroupKey, { label: { fr: string; en: string }; icon?: string }> = {
|
|
38
|
+
Documentation: { label: { fr: "À propos de l'app", en: "About the app" }, icon: "info" },
|
|
39
|
+
ViewsAlerts: { label: { fr: "Vues et alertes", en: "Views and alerts" } },
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const OIP_RAIL_TILES: OipRailEntry[] = [
|
|
43
|
+
{ key: "Sources", label: { fr: "Sources", en: "Sources" }, icon: "database", group: "Documentation" },
|
|
44
|
+
// Seconde vue du panneau Sources : les tablegroups Directus de l'ecran.
|
|
45
|
+
{
|
|
46
|
+
key: "SourcesTables",
|
|
47
|
+
label: { fr: "Tables", en: "Tables" },
|
|
48
|
+
icon: "table",
|
|
49
|
+
group: "Documentation",
|
|
50
|
+
ownCount: true,
|
|
51
|
+
},
|
|
52
|
+
{ key: "Notes", label: { fr: "Notes", en: "Notes" }, icon: "notepad", group: "Documentation" },
|
|
53
|
+
{ key: "Documents", label: { fr: "Documents", en: "Documents" }, icon: "file-text", group: "Documentation" },
|
|
54
|
+
{ key: "Views", label: { fr: "Mes vues", en: "My views" }, icon: "bookmark-simple", group: "ViewsAlerts" },
|
|
55
|
+
{ key: "Alerts", label: { fr: "Alertes", en: "Alerts" }, icon: "bell", group: "ViewsAlerts" },
|
|
56
|
+
{
|
|
57
|
+
key: "AdminTemplates",
|
|
58
|
+
label: { fr: "Admin templates", en: "Admin templates" },
|
|
59
|
+
icon: "magic-wand",
|
|
60
|
+
group: "ViewsAlerts",
|
|
61
|
+
admin: true,
|
|
62
|
+
},
|
|
63
|
+
{ key: "Export", label: { fr: "Export", en: "Export" }, icon: "export" },
|
|
64
|
+
{ key: "Projects", label: { fr: "Projets", en: "Projects" }, icon: "folder-simple" },
|
|
65
|
+
{ key: "Topics", label: { fr: "Mes sujets", en: "My topics" }, icon: "star" },
|
|
66
|
+
{ key: "Tutorials", label: { fr: "Tutoriels", en: "Tutorials" }, icon: "graduation-cap", frame: true },
|
|
67
|
+
{ key: "Accessibility", label: { fr: "Accessibilité", en: "Accessibility" }, icon: "person-arms-spread", frame: true },
|
|
68
|
+
{ key: "Metadata", label: { fr: "Métadonnées", en: "Metadata" }, icon: "brackets-curly", admin: true, frame: true },
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
/** Etat d'une tuile pour l'ecran : `true` la sert, un objet la sert avec sa pastille. */
|
|
72
|
+
export type OipRailTileState =
|
|
73
|
+
| boolean
|
|
74
|
+
| {
|
|
75
|
+
count?: number;
|
|
76
|
+
/** Point d'etat actif (une vue appliquee), avec son infobulle. */
|
|
77
|
+
dot?: boolean;
|
|
78
|
+
hint?: string;
|
|
79
|
+
disabled?: boolean;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export type OipRailTileStates = Partial<Record<OipRailTileKey, OipRailTileState>>;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Post-login redirect handling.
|
|
3
|
+
*
|
|
4
|
+
* When an unauthenticated user hits a protected deep link, the auth middleware
|
|
5
|
+
* bounces them to `/?redirect=<path>`. That query param is enough for in-SPA
|
|
6
|
+
* login flows, but it is lost as soon as we leave the app (OAuth round-trip
|
|
7
|
+
* through FusionAuth, magic-link email opened later). We therefore also persist
|
|
8
|
+
* the intended destination in sessionStorage so it survives a full reload.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const STORAGE_KEY = "postLoginRedirect";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Only accept in-app relative paths to avoid open-redirect attacks.
|
|
15
|
+
* Must start with a single "/" (not "//" or "/\" which browsers treat as
|
|
16
|
+
* protocol-relative external URLs).
|
|
17
|
+
*/
|
|
18
|
+
function isSafeRedirectPath(target: unknown): target is string {
|
|
19
|
+
return (
|
|
20
|
+
typeof target === "string" &&
|
|
21
|
+
target.startsWith("/") &&
|
|
22
|
+
!target.startsWith("//") &&
|
|
23
|
+
!target.startsWith("/\\")
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Persist the intended destination (no-op if invalid). */
|
|
28
|
+
export function savePostLoginRedirect(target: unknown): void {
|
|
29
|
+
if (import.meta.client && isSafeRedirectPath(target)) {
|
|
30
|
+
sessionStorage.setItem(STORAGE_KEY, target);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Resolve the best post-login destination without mutating storage: query
|
|
36
|
+
* param first (most explicit), then persisted storage, then "/".
|
|
37
|
+
*/
|
|
38
|
+
export function getPostLoginRedirect(queryRedirect?: unknown): string {
|
|
39
|
+
if (isSafeRedirectPath(queryRedirect)) {
|
|
40
|
+
return queryRedirect;
|
|
41
|
+
}
|
|
42
|
+
if (import.meta.client) {
|
|
43
|
+
const stored = sessionStorage.getItem(STORAGE_KEY);
|
|
44
|
+
if (isSafeRedirectPath(stored)) {
|
|
45
|
+
return stored;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return "/";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Same as getPostLoginRedirect but clears storage so it is used only once. */
|
|
52
|
+
export function takePostLoginRedirect(queryRedirect?: unknown): string {
|
|
53
|
+
const target = getPostLoginRedirect(queryRedirect);
|
|
54
|
+
if (import.meta.client) {
|
|
55
|
+
sessionStorage.removeItem(STORAGE_KEY);
|
|
56
|
+
}
|
|
57
|
+
return target;
|
|
58
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* valueTable - un tableau Auriga rendu par RmTable.
|
|
3
|
+
*
|
|
4
|
+
* Les analyses du portail construisent leurs tableaux comme `arg-table` les
|
|
5
|
+
* attendait : une liste de libelles de tete, et des lignes `{ values: [...] }`
|
|
6
|
+
* dans le meme ordre. RmTable, lui, lit des colonnes nommees et des lignes
|
|
7
|
+
* indexees par ces noms. La conversion est mecanique : elle vit ici plutot que
|
|
8
|
+
* recopiee dans chaque composant, et le calcul des valeurs reste ou il est.
|
|
9
|
+
*
|
|
10
|
+
* Les colonnes portent des cles positionnelles (`c0`, `c1`...) : les libelles
|
|
11
|
+
* de tete sont traduits et ne font pas des cles stables. `valueKeys` donne les
|
|
12
|
+
* colonnes de valeurs, celles dont le composant rend le contenu par un slot.
|
|
13
|
+
*
|
|
14
|
+
* `types` reprend `column-type` d'`arg-table`, `style` reprend `columnStyle`.
|
|
15
|
+
*/
|
|
16
|
+
export interface ValueTableRow {
|
|
17
|
+
values: any[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ValueTableStyle {
|
|
21
|
+
/** Parts de largeur, comme `columnStyle.size` d'`arg-table`. */
|
|
22
|
+
size?: readonly number[];
|
|
23
|
+
/** Alignements imposes, comme `columnStyle.align`. Priment sur `types`. */
|
|
24
|
+
align?: readonly ("left" | "right" | "center")[];
|
|
25
|
+
/**
|
|
26
|
+
* Largeur plancher par colonne (CSS). A la difference de `size`, qui repartit
|
|
27
|
+
* la place restante, elle ne se rogne pas : la table defile plutot que
|
|
28
|
+
* d'ecraser une colonne de libelles longs.
|
|
29
|
+
*/
|
|
30
|
+
minWidth?: readonly (string | undefined)[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function valueTable(
|
|
34
|
+
heads: any[],
|
|
35
|
+
contents: any[],
|
|
36
|
+
types?: any[],
|
|
37
|
+
style?: ValueTableStyle,
|
|
38
|
+
) {
|
|
39
|
+
// Sans types, la premiere colonne porte le libelle et les suivantes des
|
|
40
|
+
// valeurs. Certains tableaux en ont deux (le nom puis le code) : leurs types
|
|
41
|
+
// de colonnes le disent deja, une colonne sans type ne porte pas un nombre.
|
|
42
|
+
// `url` est la colonne d'action d'`arg-table` : un bouton se centre.
|
|
43
|
+
const alignOf = (index: number): "left" | "right" | "center" => {
|
|
44
|
+
if (style?.align?.[index]) return style.align[index];
|
|
45
|
+
if (!types) return index === 0 ? "left" : "right";
|
|
46
|
+
if (!types[index]) return "left";
|
|
47
|
+
return types[index] === "url" ? "center" : "right";
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// `arg-table` reglait la largeur des colonnes par des parts (`size: [1, 4, 1]`)
|
|
51
|
+
// : la colonne a 4 prend quatre fois la place de celle a 1. RmTable prend une
|
|
52
|
+
// largeur CSS, on rend donc la part en pourcentage du total.
|
|
53
|
+
const sizes = style?.size;
|
|
54
|
+
const total = sizes ? sizes.reduce((sum, part) => sum + part, 0) : 0;
|
|
55
|
+
const widthOf = (index: number) =>
|
|
56
|
+
sizes && total && sizes[index]
|
|
57
|
+
? `${Math.round((sizes[index] * 1000) / total) / 10}%`
|
|
58
|
+
: undefined;
|
|
59
|
+
|
|
60
|
+
const allColumns = heads.map((label: any, index: number) => ({
|
|
61
|
+
key: `c${index}`,
|
|
62
|
+
label: String(label),
|
|
63
|
+
sortable: true,
|
|
64
|
+
align: alignOf(index),
|
|
65
|
+
width: widthOf(index),
|
|
66
|
+
minWidth: style?.minWidth?.[index],
|
|
67
|
+
}));
|
|
68
|
+
|
|
69
|
+
// La colonne de type `More` d'`arg-table` ne portait pas une valeur a lire :
|
|
70
|
+
// elle tenait le chevron de depliage et la charge utile du detail. RmTable
|
|
71
|
+
// pose son propre chevron (`expandable`) dans une colonne a lui, celle-ci
|
|
72
|
+
// sort donc de la tete ; la charge reste dans la ligne, sous sa cle, ou le
|
|
73
|
+
// slot `expanded` va la chercher.
|
|
74
|
+
const moreIndex = (types ?? []).findIndex((type: any) => type === "More");
|
|
75
|
+
// Cle vide plutot que `null` quand la table n'a pas de colonne `More` : les
|
|
76
|
+
// pages lisent `row[table.moreKey]` dans leurs slots, et une cle nullable ne
|
|
77
|
+
// s'indexe pas. Une cle vide ne designe aucune colonne, ce que `null`
|
|
78
|
+
// disait deja.
|
|
79
|
+
const moreKey = moreIndex >= 0 ? `c${moreIndex}` : "";
|
|
80
|
+
const columns = allColumns.filter((column) => column.key !== moreKey);
|
|
81
|
+
|
|
82
|
+
const rows = (Array.isArray(contents) ? contents : [])
|
|
83
|
+
.filter((row: any) => Array.isArray(row?.values))
|
|
84
|
+
.map((row: ValueTableRow) =>
|
|
85
|
+
Object.fromEntries(row.values.map((value, index) => [`c${index}`, value])),
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
// `arg-table` nommait ses slots de cellule par le type de colonne
|
|
89
|
+
// (`#Date`, `#Price`), plusieurs colonnes pouvant partager un type. RmTable
|
|
90
|
+
// nomme les siens par la cle de colonne : `typeKeys` dit quelles cles porte
|
|
91
|
+
// un type, pour que la page rende le slot une fois par cle.
|
|
92
|
+
const typeKeys: Record<string, string[]> = {};
|
|
93
|
+
(types ?? []).forEach((type: any, index: number) => {
|
|
94
|
+
if (!type || type === "More" || !allColumns[index]) return;
|
|
95
|
+
(typeKeys[type] ??= []).push(allColumns[index]!.key);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
columns,
|
|
100
|
+
rows,
|
|
101
|
+
typeKeys,
|
|
102
|
+
/** Vrai quand la table avait une colonne `More` : passer `expandable` a RmTable. */
|
|
103
|
+
expandable: moreIndex >= 0,
|
|
104
|
+
/** Cle ou vit la charge utile du detail, a lire dans le slot `expanded`. Vide sans colonne `More`. */
|
|
105
|
+
moreKey,
|
|
106
|
+
valueKeys: columns.slice(1).map((column) => column.key),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Les cles de colonne qui portent un type donne, quand la table a ete
|
|
112
|
+
* construite sans ses `types` : `columnKeysOfType(tableTypes, "int")` rend
|
|
113
|
+
* `["c2", "c3"]`, de quoi rendre le slot de cellule une fois par colonne.
|
|
114
|
+
*
|
|
115
|
+
* C'est le pendant de `typeKeys` ci-dessus pour les tableaux qui gardent
|
|
116
|
+
* leurs types a part, parce que les passer a `valueTable` changerait aussi
|
|
117
|
+
* l'alignement des colonnes. Vingt et un composants le recopiaient, sous
|
|
118
|
+
* trois ecritures differentes.
|
|
119
|
+
*/
|
|
120
|
+
export const columnKeysOfType = (
|
|
121
|
+
types: readonly any[] | undefined,
|
|
122
|
+
wanted: string,
|
|
123
|
+
): string[] =>
|
|
124
|
+
(types ?? [])
|
|
125
|
+
.map((type, index) => (type === wanted ? `c${index}` : null))
|
|
126
|
+
.filter((key): key is string => key !== null);
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Layer Nuxt livre avec @tekkare/romulus : une app l'active avec
|
|
2
|
+
// `extends: ['@tekkare/romulus/layer']`.
|
|
3
|
+
// Ses dossiers app/ et server/ fusionnent avec ceux de l'app, qui garde la
|
|
4
|
+
// priorite sur tout fichier de meme nom (c'est la porte de sortie pour
|
|
5
|
+
// surcharger un composant, un layout ou une route).
|
|
6
|
+
//
|
|
7
|
+
// Le layer s'appuie sur le module Romulus et @nuxtjs/i18n mais ne les declare
|
|
8
|
+
// pas dans `modules` : l'app les enregistre deja avec ses options (romulus
|
|
9
|
+
// sans baseStyles, ses locales), et un second enregistrement par le layer
|
|
10
|
+
// les reinstallerait avec les options par defaut.
|
|
11
|
+
export default defineNuxtConfig({
|
|
12
|
+
future: {
|
|
13
|
+
compatibilityVersion: 4,
|
|
14
|
+
},
|
|
15
|
+
// Reglages serveur propres au layer. Les cles Directus (directus*Url,
|
|
16
|
+
// directus*Token) restent declarees par l'app, qui les avait deja.
|
|
17
|
+
runtimeConfig: {
|
|
18
|
+
/** Nom du cookie de session, sinon le premier `*.session-token`. */
|
|
19
|
+
oipSessionCookie: "",
|
|
20
|
+
/** Element OipTutorials de la video de prise en main. Vide : pas de video. */
|
|
21
|
+
oipTutorialVideoId: "",
|
|
22
|
+
/** `prod_url` de l'app dans le portail (fiche OipApplication), sinon NOTES_APP_URL. */
|
|
23
|
+
oipAppUrl: "",
|
|
24
|
+
/** API Virtual Lab pour le droit superadmin, sinon VIRTUAL_LAB_API_URL. */
|
|
25
|
+
oipVirtualLabApiUrl: "",
|
|
26
|
+
/** Directus qui porte la collection ElasticIndices, et un jeton qui la lit. */
|
|
27
|
+
oipEsIndicesUrl: "",
|
|
28
|
+
oipEsIndicesToken: "",
|
|
29
|
+
/** Vrai en preproduction : interroge `staging_physical` quand il existe. */
|
|
30
|
+
oipEsIndicesStaging: false,
|
|
31
|
+
},
|
|
32
|
+
});
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { defineEventHandler } from "h3";
|
|
2
|
+
import { requireOipSession } from "../../../utils/oipAuth";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* GET /api/oip/asset/<id>, servi par le layer OIP.
|
|
6
|
+
*
|
|
7
|
+
* Sert un fichier Directus (les documents du panneau Documents).
|
|
8
|
+
*
|
|
9
|
+
* Passe par le serveur plutot qu'un lien direct pour que l'hote Directus ne
|
|
10
|
+
* parte jamais dans le navigateur. Le header Range est relaye — c'est lui qui
|
|
11
|
+
* permet d'ouvrir un PDF page par page — et `?download` demande a Directus une
|
|
12
|
+
* disposition de piece jointe.
|
|
13
|
+
*/
|
|
14
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
15
|
+
|
|
16
|
+
const PASS_THROUGH = [
|
|
17
|
+
"content-type",
|
|
18
|
+
"content-length",
|
|
19
|
+
"content-range",
|
|
20
|
+
"content-disposition",
|
|
21
|
+
"accept-ranges",
|
|
22
|
+
"etag",
|
|
23
|
+
"last-modified",
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Types que le navigateur peut afficher en place. Tout autre type (HTML, SVG,
|
|
28
|
+
* texte...) part en piece jointe : servi sur l'origine de l'app, un document
|
|
29
|
+
* HTML s'executerait avec ses cookies.
|
|
30
|
+
*/
|
|
31
|
+
const INLINE_TYPES = new Set([
|
|
32
|
+
"application/pdf",
|
|
33
|
+
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
34
|
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
35
|
+
"text/csv",
|
|
36
|
+
"image/png",
|
|
37
|
+
"image/jpeg",
|
|
38
|
+
"image/gif",
|
|
39
|
+
"image/webp",
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
export default defineEventHandler(async (event) => {
|
|
43
|
+
// Documents reserves aux personnes connectees, comme le dit le cache-control.
|
|
44
|
+
await requireOipSession(event);
|
|
45
|
+
|
|
46
|
+
const config = useRuntimeConfig();
|
|
47
|
+
const base = (config.directusDocumentsUrl as string | undefined)?.replace(/\/+$/, "");
|
|
48
|
+
const id = getRouterParam(event, "id") ?? "";
|
|
49
|
+
|
|
50
|
+
if (!base) {
|
|
51
|
+
throw createError({ status: 503, message: "Directus is not configured: set DIRECTUS_URL" });
|
|
52
|
+
}
|
|
53
|
+
if (!UUID.test(id)) {
|
|
54
|
+
throw createError({ status: 400, message: "Invalid asset id" });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const { download } = getQuery(event);
|
|
58
|
+
const range = getHeader(event, "range");
|
|
59
|
+
|
|
60
|
+
const upstream = await fetch(
|
|
61
|
+
`${base}/assets/${id}${download === undefined ? "" : "?download"}`,
|
|
62
|
+
{ headers: range ? { range } : {} }
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
if (!upstream.ok && upstream.status !== 206) {
|
|
66
|
+
throw createError({ status: upstream.status, message: "Asset unavailable" });
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
setResponseStatus(event, upstream.status);
|
|
70
|
+
// Pas le `cache-control` de Directus (public, 30 jours) : ces documents ne
|
|
71
|
+
// sont servis qu'aux personnes connectees, une reponse gardee un mois par
|
|
72
|
+
// les caches partages n'a rien a faire la. Une heure suffit a eviter de
|
|
73
|
+
// retelecharger un PDF qu'on rouvre, et un en-tete de securite corrige
|
|
74
|
+
// arrive au navigateur dans la journee au lieu du mois prochain.
|
|
75
|
+
setResponseHeader(event, "cache-control", "private, max-age=3600");
|
|
76
|
+
for (const header of PASS_THROUGH) {
|
|
77
|
+
const value = upstream.headers.get(header);
|
|
78
|
+
if (value) setResponseHeader(event, header, value);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const type = (upstream.headers.get("content-type") ?? "").split(";")[0]?.trim().toLowerCase() ?? "";
|
|
82
|
+
if (!INLINE_TYPES.has(type)) {
|
|
83
|
+
// Le nom de fichier de Directus est garde, seule la disposition change.
|
|
84
|
+
const disposition = upstream.headers.get("content-disposition") || "attachment";
|
|
85
|
+
setResponseHeader(event, "content-disposition", disposition.replace(/^inline/i, "attachment"));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return upstream.body;
|
|
89
|
+
});
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { defineEventHandler } from "h3";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* GET /api/oip/documents, servi par le layer OIP (panneau « Documents »).
|
|
5
|
+
*
|
|
6
|
+
* Documents de reference du portail OIP 7 (collection Directus oipDocuments),
|
|
7
|
+
* restreints a ceux qui parlent des donnees de l'app.
|
|
8
|
+
*
|
|
9
|
+
* La selection est explicite, ligne a ligne, et propre a chaque app : elle
|
|
10
|
+
* vit dans app.config.oip.panels.Documents.catalog (id Directus, categorie,
|
|
11
|
+
* parties de l'app concernees). Un document sans `apps` vaut pour toutes.
|
|
12
|
+
* `?app=` filtre la liste ; sans lui elle sort entiere (accueil d'un hub).
|
|
13
|
+
*
|
|
14
|
+
* Le navigateur ne voit ni l'URL ni le token Directus : les fichiers passent
|
|
15
|
+
* par /api/oip/asset/<id>.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const COLLECTION = "oipDocuments";
|
|
19
|
+
|
|
20
|
+
export default defineEventHandler(async (event) => {
|
|
21
|
+
const config = useRuntimeConfig();
|
|
22
|
+
// La partie du hub qui demande. Inconnue ou absente : toute la liste, comme
|
|
23
|
+
// sur l'accueil, qui n'est aucune des cinq.
|
|
24
|
+
const { app } = getQuery(event) as { app?: string };
|
|
25
|
+
const catalog = useAppConfig().oip.panels.Documents.catalog as {
|
|
26
|
+
id: number;
|
|
27
|
+
category: string;
|
|
28
|
+
apps?: string[];
|
|
29
|
+
}[];
|
|
30
|
+
const wanted = catalog.filter((doc) => !app || !doc.apps?.length || doc.apps.includes(app));
|
|
31
|
+
if (!wanted.length) return [];
|
|
32
|
+
const url = (config.directusDocumentsUrl as string | undefined)?.replace(/\/+$/, "");
|
|
33
|
+
const token = config.directusDocumentsToken as string | undefined;
|
|
34
|
+
|
|
35
|
+
if (!url || !token) {
|
|
36
|
+
throw createError({
|
|
37
|
+
status: 503,
|
|
38
|
+
message:
|
|
39
|
+
"Documents are not configured: set DIRECTUS_URL and DIRECTUS_DOCUMENTS_TOKEN " +
|
|
40
|
+
"(or DIRECTUS_FAVORITES_TOKEN). The token needs read access on oipDocuments.",
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const response = await $fetch<{ data: any[] }>(`${url}/items/${COLLECTION}`, {
|
|
45
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
46
|
+
query: {
|
|
47
|
+
limit: -1,
|
|
48
|
+
filter: JSON.stringify({ id: { _in: wanted.map((doc) => doc.id) } }),
|
|
49
|
+
fields: ["id", "title", "document"].join(","),
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
const rows = new Map<number, any>((response.data ?? []).map((row) => [Number(row.id), row]));
|
|
54
|
+
|
|
55
|
+
// L'ordre vient de la selection, pas de Directus : c'est le seul endroit ou
|
|
56
|
+
// il est decide, et un document retire du CMS disparait sans trou.
|
|
57
|
+
const selected = wanted.flatMap(({ id, category }) => {
|
|
58
|
+
const row = rows.get(id);
|
|
59
|
+
if (!row?.document) return [];
|
|
60
|
+
return [
|
|
61
|
+
{
|
|
62
|
+
id,
|
|
63
|
+
category,
|
|
64
|
+
title: row.title ?? "",
|
|
65
|
+
fileId: row.document as string,
|
|
66
|
+
src: `/api/oip/asset/${row.document}`,
|
|
67
|
+
downloadSrc: `/api/oip/asset/${row.document}?download`,
|
|
68
|
+
},
|
|
69
|
+
];
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
// Le type du fichier decide de ce que le panneau propose : un PDF s'ouvre en
|
|
73
|
+
// apercu dans l'app, un tableur non. Directus refuse /files a ce token, il
|
|
74
|
+
// vient donc des entetes de l'asset lui-meme.
|
|
75
|
+
return Promise.all(
|
|
76
|
+
selected.map(async ({ fileId, ...doc }) => ({
|
|
77
|
+
...doc,
|
|
78
|
+
mime: await assetMime(`${url}/assets/${fileId}`),
|
|
79
|
+
})),
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
/** Type d'un asset, lu dans ses entetes. Jamais fatal : un document dont le
|
|
84
|
+
* type est inconnu se liste et se telecharge quand meme. */
|
|
85
|
+
async function assetMime(url: string): Promise<string | null> {
|
|
86
|
+
try {
|
|
87
|
+
const head = await fetch(url, { method: "HEAD" });
|
|
88
|
+
return head.headers.get("content-type");
|
|
89
|
+
} catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { defineEventHandler } from "h3";
|
|
2
|
+
import { requireOipSession } from "../../utils/oipAuth";
|
|
3
|
+
import { oipEsIndexRegistry } from "../../utils/oipEsIndices";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* GET /api/oip/es-indices, servi par le layer OIP.
|
|
7
|
+
*
|
|
8
|
+
* L'id Directus, le nom canonique et l'index physique de chaque index, pour les rares endroits
|
|
9
|
+
* du navigateur qui doivent transmettre un NOM a un autre systeme (moteur
|
|
10
|
+
* d'alertes, registre des types de ressources). Les requetes, elles, passent
|
|
11
|
+
* l'id au proxy, qui resout lui-meme.
|
|
12
|
+
*/
|
|
13
|
+
export default defineEventHandler(async (event) => {
|
|
14
|
+
await requireOipSession(event);
|
|
15
|
+
const { byId } = await oipEsIndexRegistry();
|
|
16
|
+
return [...byId.values()].map((row) => ({
|
|
17
|
+
id: row.id,
|
|
18
|
+
name: row.canonical_name,
|
|
19
|
+
physical: row.physical_index,
|
|
20
|
+
}));
|
|
21
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { defineEventHandler } from "h3";
|
|
2
|
+
import { requireOipUserId } from "../../utils/oipAuth";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* GET /api/oip/favorites, servi par le layer OIP (panneau « Mes sujets »).
|
|
6
|
+
*
|
|
7
|
+
* The caller's starred topics, as set from the OIP platform menus.
|
|
8
|
+
*
|
|
9
|
+
* Read-only on purpose: the star lives in the platform, this app only lists
|
|
10
|
+
* what it holds so the reader can come back to a topic. Rows are resolved
|
|
11
|
+
* here (translations, icon, country, target URL) so the browser needs nothing
|
|
12
|
+
* from Directus, and never sees its URL or token.
|
|
13
|
+
*
|
|
14
|
+
* A favourite points at exactly one of two things: a menu item (a catalogue
|
|
15
|
+
* entry) or a subject (a page, with its application and path).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Directus renders "Oip Favorites" for this raw name. Overridable so a rename in the CMS is an env change. */
|
|
19
|
+
const COLLECTION = process.env.DIRECTUS_FAVORITES_COLLECTION || "Oip_Favorites";
|
|
20
|
+
|
|
21
|
+
const FIELDS = {
|
|
22
|
+
user: "user_id",
|
|
23
|
+
menuItem: "Menu_item",
|
|
24
|
+
subject: "Subject",
|
|
25
|
+
} as const;
|
|
26
|
+
|
|
27
|
+
export default defineEventHandler(async (event) => {
|
|
28
|
+
const userId = await requireOipUserId(event);
|
|
29
|
+
const config = useRuntimeConfig();
|
|
30
|
+
const url = (config.directusFavoritesUrl as string | undefined)?.replace(/\/+$/, "");
|
|
31
|
+
const token = config.directusFavoritesToken as string | undefined;
|
|
32
|
+
|
|
33
|
+
if (!url || !token) {
|
|
34
|
+
throw createError({
|
|
35
|
+
status: 503,
|
|
36
|
+
message:
|
|
37
|
+
"Favourites are not configured: set DIRECTUS_URL and DIRECTUS_FAVORITES_TOKEN. " +
|
|
38
|
+
"The token needs read access on the Oip_Favorites collection.",
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const F = FIELDS;
|
|
43
|
+
try {
|
|
44
|
+
const response = await $fetch<{ data: any[] }>(`${url}/items/${COLLECTION}`, {
|
|
45
|
+
headers: { Authorization: `Bearer ${token}`, "Cache-Control": "no-store" },
|
|
46
|
+
query: {
|
|
47
|
+
limit: 200,
|
|
48
|
+
// A star lit a moment ago in the platform must show up here. This
|
|
49
|
+
// Directus instance keeps its own five-minute cache keyed on the
|
|
50
|
+
// normalised query, so the extra clause carries the current instant
|
|
51
|
+
// and gives every read its own key. It excludes nothing: the bound is
|
|
52
|
+
// a day ahead, which no row reaches.
|
|
53
|
+
filter: JSON.stringify({
|
|
54
|
+
_and: [
|
|
55
|
+
{ [F.user]: { _eq: userId } },
|
|
56
|
+
{ date_created: { _lte: new Date(Date.now() + 86400000).toISOString() } },
|
|
57
|
+
],
|
|
58
|
+
}),
|
|
59
|
+
sort: "-date_created",
|
|
60
|
+
fields: [
|
|
61
|
+
"id",
|
|
62
|
+
`${F.menuItem}.id`,
|
|
63
|
+
`${F.menuItem}.Label`,
|
|
64
|
+
`${F.menuItem}.Emoji`,
|
|
65
|
+
`${F.menuItem}.translations.*`,
|
|
66
|
+
`${F.menuItem}.Country.country_label`,
|
|
67
|
+
// Le code ISO-2 sert a poser le drapeau : Directus ne stocke pas
|
|
68
|
+
// d'emoji de pays.
|
|
69
|
+
`${F.menuItem}.Country.country_iso`,
|
|
70
|
+
// A menu item is a catalogue entry with no address of its own. Its
|
|
71
|
+
// subjects come along so a row can open the one behind it instead of
|
|
72
|
+
// dropping the reader on the platform hub.
|
|
73
|
+
`${F.menuItem}.Subjects.OipSubject_subject_id.subject_id`,
|
|
74
|
+
`${F.menuItem}.Subjects.OipSubject_subject_id.relative_path`,
|
|
75
|
+
`${F.menuItem}.Subjects.OipSubject_subject_id.app.prod_url`,
|
|
76
|
+
`${F.subject}.subject_id`,
|
|
77
|
+
`${F.subject}.relative_path`,
|
|
78
|
+
`${F.subject}.app.prod_url`,
|
|
79
|
+
`${F.subject}.translations.*`,
|
|
80
|
+
`${F.subject}.country_iso.country_label`,
|
|
81
|
+
`${F.subject}.country_iso.country_iso`,
|
|
82
|
+
].join(","),
|
|
83
|
+
},
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// Normalised on the way out: the CMS spelling of the columns stays here,
|
|
87
|
+
// the interface always sees `menu_item` and `subject`.
|
|
88
|
+
return (response.data ?? []).map((row: any) => ({
|
|
89
|
+
id: row.id,
|
|
90
|
+
menu_item: row[F.menuItem] ?? null,
|
|
91
|
+
subject: row[F.subject] ?? null,
|
|
92
|
+
}));
|
|
93
|
+
} catch (error: any) {
|
|
94
|
+
// Directus answers 403 both for "no permission" and for "no such
|
|
95
|
+
// collection", on purpose. Saying so plainly beats a bare 403.
|
|
96
|
+
const status = error?.response?.status ?? error?.statusCode;
|
|
97
|
+
if (status === 403 || status === 404) {
|
|
98
|
+
throw createError({
|
|
99
|
+
status: 503,
|
|
100
|
+
message:
|
|
101
|
+
`Directus collection "${COLLECTION}" is unreachable: the token in ` +
|
|
102
|
+
"DIRECTUS_FAVORITES_TOKEN has no read access on it.",
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
throw error;
|
|
106
|
+
}
|
|
107
|
+
});
|