@zauru-sdk/utils 6.13.0 → 6.15.0
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/esm/index.js +2 -0
- package/dist/esm/zauru/api.loaders.js +173 -0
- package/dist/esm/zauru/auth.loaders.js +141 -0
- package/dist/index.d.ts +2 -0
- package/dist/zauru/api.loaders.d.ts +41 -0
- package/dist/zauru/auth.loaders.d.ts +40 -0
- package/package.json +2 -2
package/dist/esm/index.js
CHANGED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { createElement } from "react";
|
|
2
|
+
import { redirect } from "@remix-run/node";
|
|
3
|
+
import { config } from "@zauru-sdk/config";
|
|
4
|
+
import { getAgencyInfo, getEmployeeInfo, getHeaders, getOauthUserInfo, getProfileInformation, getReceptionTemplate, getSession, getVariablesByName, } from "@zauru-sdk/services";
|
|
5
|
+
import { createCatalogsLoader, } from "./catalogs.loader.js";
|
|
6
|
+
const missingParamPayload = (description) => ({
|
|
7
|
+
description,
|
|
8
|
+
title: "Ocurrió un error.",
|
|
9
|
+
error: true,
|
|
10
|
+
type: "error",
|
|
11
|
+
});
|
|
12
|
+
const namedErrorPayload = (name, empty, description) => ({
|
|
13
|
+
[name]: empty,
|
|
14
|
+
error: true,
|
|
15
|
+
type: "error",
|
|
16
|
+
title: `Ocurrió un error al obtener la información de ${name}!.`,
|
|
17
|
+
description,
|
|
18
|
+
});
|
|
19
|
+
const sdkProfileHandlers = {
|
|
20
|
+
oauthProfile: ({ session }) => getOauthUserInfo(session.get("code")),
|
|
21
|
+
agencyProfile: ({ headers, session }) => getAgencyInfo(headers, session),
|
|
22
|
+
employeeProfile: ({ headers, session }) => getEmployeeInfo(Number(session.get("employee_id")), headers),
|
|
23
|
+
userProfile: ({ headers }) => getProfileInformation(headers),
|
|
24
|
+
};
|
|
25
|
+
const sdkTemplateHandlers = {
|
|
26
|
+
receptionTemplate: async ({ headers, session, name }) => {
|
|
27
|
+
try {
|
|
28
|
+
const { reception_template_webapp_var } = await getVariablesByName(headers, session, ["reception_template_webapp_var"]);
|
|
29
|
+
headers.Accept = "text/html";
|
|
30
|
+
return getReceptionTemplate(headers, reception_template_webapp_var);
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
console.error(error);
|
|
34
|
+
return Response.json(namedErrorPayload(name, {}, error?.toString() ?? ""));
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
const createNamedResourceLoader = (queryParam, sdkHandlers, extras, emptyOnError, missingDescription) => {
|
|
39
|
+
return async ({ request }) => {
|
|
40
|
+
try {
|
|
41
|
+
const cookie = request.headers.get("Cookie");
|
|
42
|
+
const session = await getSession(cookie);
|
|
43
|
+
if (!session.has("username")) {
|
|
44
|
+
return redirect("/");
|
|
45
|
+
}
|
|
46
|
+
const headers = await getHeaders(cookie, session);
|
|
47
|
+
const url = new URL(request.url);
|
|
48
|
+
const name = url.searchParams.get(queryParam) ?? "";
|
|
49
|
+
if (!name) {
|
|
50
|
+
return Response.json(missingParamPayload(missingDescription));
|
|
51
|
+
}
|
|
52
|
+
const handler = extras?.[name] ?? sdkHandlers[name];
|
|
53
|
+
if (!handler) {
|
|
54
|
+
return Response.json(missingParamPayload(missingDescription));
|
|
55
|
+
}
|
|
56
|
+
const result = await handler({
|
|
57
|
+
request,
|
|
58
|
+
session,
|
|
59
|
+
headers,
|
|
60
|
+
url,
|
|
61
|
+
name,
|
|
62
|
+
});
|
|
63
|
+
if (result instanceof Response) {
|
|
64
|
+
return result;
|
|
65
|
+
}
|
|
66
|
+
if (result.error || !result.data) {
|
|
67
|
+
return Response.json(namedErrorPayload(name, emptyOnError, `${result.userMsg ?? ""}`));
|
|
68
|
+
}
|
|
69
|
+
return { [name]: result.data };
|
|
70
|
+
}
|
|
71
|
+
catch (error) {
|
|
72
|
+
console.error(error);
|
|
73
|
+
const name = new URL(request.url).searchParams.get(queryParam) || queryParam;
|
|
74
|
+
return namedErrorPayload(name, emptyOnError, `${error}`);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
/**
|
|
79
|
+
* Remix loader for `/api/profiles`. Pass extras to add or override profile names.
|
|
80
|
+
*/
|
|
81
|
+
export function createProfilesLoader(extras) {
|
|
82
|
+
return createNamedResourceLoader("profile", sdkProfileHandlers, extras, {}, "No se envió ningún parámetro de perfil a buscar");
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Remix loader for `/api/session`.
|
|
86
|
+
* Modern query: `name` + `type` (`environment` | `sessionVariable` | session attr).
|
|
87
|
+
* Legacy Remix 1: `attribute` only, read from the session.
|
|
88
|
+
*/
|
|
89
|
+
export function createSessionLoader() {
|
|
90
|
+
return async ({ request }) => {
|
|
91
|
+
const cookie = request.headers.get("Cookie");
|
|
92
|
+
const session = await getSession(cookie);
|
|
93
|
+
const headers = await getHeaders(cookie, session);
|
|
94
|
+
if (!session.has("username")) {
|
|
95
|
+
return redirect("/");
|
|
96
|
+
}
|
|
97
|
+
const url = new URL(request.url);
|
|
98
|
+
const name = url.searchParams.get("name") ?? url.searchParams.get("attribute") ?? "";
|
|
99
|
+
const type = url.searchParams.get("type");
|
|
100
|
+
if (!name) {
|
|
101
|
+
return Response.json({
|
|
102
|
+
data: "",
|
|
103
|
+
...missingParamPayload("No se envió ningún parámetro de name o type a buscar"),
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
if (type === "environment") {
|
|
107
|
+
return Response.json({
|
|
108
|
+
data: config[name],
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
if (type === "sessionVariable") {
|
|
112
|
+
try {
|
|
113
|
+
const response = await getVariablesByName(headers, session, [name]);
|
|
114
|
+
return Response.json({ data: response[name] });
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return Response.json({
|
|
118
|
+
data: "",
|
|
119
|
+
...missingParamPayload(`No se encontró la variable ${name} en la sesión`),
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return Response.json({
|
|
124
|
+
data: session.get(name),
|
|
125
|
+
});
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Remix loader for `/api/templates`. Pass extras to add or override template names.
|
|
130
|
+
*/
|
|
131
|
+
export function createTemplatesLoader(extras) {
|
|
132
|
+
return createNamedResourceLoader("object", sdkTemplateHandlers, extras, {}, "No se envió ningún parámetro de templates a buscar");
|
|
133
|
+
}
|
|
134
|
+
const apiResourceLoaders = (extras) => ({
|
|
135
|
+
catalogs: createCatalogsLoader(extras?.catalogs),
|
|
136
|
+
profiles: createProfilesLoader(extras?.profiles),
|
|
137
|
+
session: createSessionLoader(),
|
|
138
|
+
templates: createTemplatesLoader(extras?.templates),
|
|
139
|
+
});
|
|
140
|
+
const apiPathSegment = (request, splat) => {
|
|
141
|
+
if (splat) {
|
|
142
|
+
return splat.split("/").filter(Boolean)[0] ?? "";
|
|
143
|
+
}
|
|
144
|
+
const parts = new URL(request.url).pathname.split("/").filter(Boolean);
|
|
145
|
+
return parts.at(-1) ?? "";
|
|
146
|
+
};
|
|
147
|
+
/**
|
|
148
|
+
* Single Remix splat loader for `/api/catalogs|profiles|session|templates`.
|
|
149
|
+
* App-specific handlers go in `extras` under the matching resource.
|
|
150
|
+
*/
|
|
151
|
+
export function createZauruApiLoader(extras) {
|
|
152
|
+
const loaders = apiResourceLoaders(extras);
|
|
153
|
+
return async (args) => {
|
|
154
|
+
const segment = apiPathSegment(args.request, args.params?.["*"]);
|
|
155
|
+
const loader = loaders[segment];
|
|
156
|
+
if (!loader) {
|
|
157
|
+
return Response.json(missingParamPayload("No se envió ningún parámetro de catálogo a buscar"));
|
|
158
|
+
}
|
|
159
|
+
return loader(args);
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
export function ProfilesRoute() {
|
|
163
|
+
return createElement("div", { style: { fontFamily: "system-ui, sans-serif", lineHeight: "1.4" } }, "PROFILES LOAD");
|
|
164
|
+
}
|
|
165
|
+
export function SessionRoute() {
|
|
166
|
+
return createElement("div", { style: { fontFamily: "system-ui, sans-serif", lineHeight: "1.4" } }, "SESSION LOAD");
|
|
167
|
+
}
|
|
168
|
+
export function TemplatesRoute() {
|
|
169
|
+
return createElement("div", { style: { fontFamily: "system-ui, sans-serif", lineHeight: "1.4" } }, "TEMPLATES LOAD");
|
|
170
|
+
}
|
|
171
|
+
export function ZauruApiRoute() {
|
|
172
|
+
return createElement("div", { style: { fontFamily: "system-ui, sans-serif", lineHeight: "1.4" } }, "API LOAD");
|
|
173
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { redirect } from "@remix-run/node";
|
|
2
|
+
import { config } from "@zauru-sdk/config";
|
|
3
|
+
import { commitSession, destroySession, getRefreshSession, getSession, loginWebApp, } from "@zauru-sdk/services";
|
|
4
|
+
const loginErrorPayload = (description, title) => ({
|
|
5
|
+
error: true,
|
|
6
|
+
type: "error",
|
|
7
|
+
title: title ??
|
|
8
|
+
"Ocurrió un error al cargar la información de inicio de sesión (Carga de webapp)",
|
|
9
|
+
description,
|
|
10
|
+
});
|
|
11
|
+
const isDenied = (result) => result === false;
|
|
12
|
+
const authorizeUrl = (request) => {
|
|
13
|
+
const url = new URL(request.url);
|
|
14
|
+
const hostname = `${url.port ? "http://" : "https://"}${url.hostname}${url.port ? `:${url.port}` : ""}`;
|
|
15
|
+
return `${config.oauthBaseURL}/dialog/authorize?client_id=${config.oauthClientID}&response_type=code&redirect_uri=${hostname}/login`;
|
|
16
|
+
};
|
|
17
|
+
export function createLoginLoader() {
|
|
18
|
+
return async () => Response.json({});
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Remix action for `/login`: OAuth authorize, `loginWebApp`, cookie commit.
|
|
22
|
+
* `afterLogin` runs before `commitSession`. `requireAccess` can deny after
|
|
23
|
+
* a successful OAuth exchange or when a session already has `code`.
|
|
24
|
+
*/
|
|
25
|
+
export function createLoginAction(extras) {
|
|
26
|
+
return async ({ request }) => {
|
|
27
|
+
const formData = await request.formData();
|
|
28
|
+
const { action, ...values } = Object.fromEntries(formData);
|
|
29
|
+
if (action === "redirect") {
|
|
30
|
+
const cookie = values?.cookie?.toString() ?? "";
|
|
31
|
+
return redirect("/home", {
|
|
32
|
+
headers: {
|
|
33
|
+
"Set-Cookie": cookie,
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
const codeValue = new URL(request.url).searchParams.get("code") ?? "";
|
|
38
|
+
const cookie = request.headers.get("Cookie") ?? "";
|
|
39
|
+
const session = await getSession(cookie);
|
|
40
|
+
if (!session.has("code") && !codeValue) {
|
|
41
|
+
return redirect(authorizeUrl(request));
|
|
42
|
+
}
|
|
43
|
+
if (session.has("code")) {
|
|
44
|
+
if (isDenied(extras?.requireAccess?.(session))) {
|
|
45
|
+
return redirect("/");
|
|
46
|
+
}
|
|
47
|
+
return redirect("/home");
|
|
48
|
+
}
|
|
49
|
+
const loginResponse = await loginWebApp(session, codeValue, cookie);
|
|
50
|
+
if (loginResponse.error || !loginResponse.data) {
|
|
51
|
+
return Response.json(loginErrorPayload(loginResponse.userMsg?.toString() ?? ""));
|
|
52
|
+
}
|
|
53
|
+
if (isDenied(extras?.requireAccess?.(session))) {
|
|
54
|
+
return Response.json(loginErrorPayload("No tienes permiso para acceder a esta aplicación. Si crees que esto es un error, contacta al administrador.", "Acceso denegado"));
|
|
55
|
+
}
|
|
56
|
+
if (extras?.afterLogin) {
|
|
57
|
+
await extras.afterLogin({
|
|
58
|
+
request,
|
|
59
|
+
session,
|
|
60
|
+
headers: loginResponse.data.headers,
|
|
61
|
+
loginData: loginResponse.data,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
const expires = new Date();
|
|
65
|
+
expires.setSeconds(expires.getSeconds() + Number(config.expirationDurationInSeconds));
|
|
66
|
+
return Response.json({
|
|
67
|
+
...loginResponse.data,
|
|
68
|
+
cookie: await commitSession(session, { expires }),
|
|
69
|
+
});
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Guard for `/home` and `/reload-catalogs`: no `username` → `/`.
|
|
74
|
+
*/
|
|
75
|
+
export function createSessionGuardLoader(extras) {
|
|
76
|
+
return async ({ request }) => {
|
|
77
|
+
const session = await getSession(request.headers.get("Cookie"));
|
|
78
|
+
if (!session.has("username")) {
|
|
79
|
+
return redirect("/");
|
|
80
|
+
}
|
|
81
|
+
if (isDenied(extras?.requireAccess?.(session))) {
|
|
82
|
+
return redirect("/");
|
|
83
|
+
}
|
|
84
|
+
return Response.json({});
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
export function createLogoutLoader(extras) {
|
|
88
|
+
return createSessionGuardLoader(extras);
|
|
89
|
+
}
|
|
90
|
+
export function createLogoutAction() {
|
|
91
|
+
return async ({ request }) => {
|
|
92
|
+
const session = await getSession(request.headers.get("Cookie"));
|
|
93
|
+
return redirect(`${config.oauthBaseURL}/logout`, {
|
|
94
|
+
headers: {
|
|
95
|
+
"Set-Cookie": await destroySession(session),
|
|
96
|
+
},
|
|
97
|
+
});
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* `/` welcome: existing session (and optional access) → `/home`.
|
|
102
|
+
*/
|
|
103
|
+
export function createIndexLoader(extras) {
|
|
104
|
+
return async ({ request }) => {
|
|
105
|
+
const session = await getSession(request.headers.get("Cookie"));
|
|
106
|
+
if (session.has("username")) {
|
|
107
|
+
if (isDenied(extras?.requireAccess?.(session))) {
|
|
108
|
+
return Response.json({});
|
|
109
|
+
}
|
|
110
|
+
return redirect("/home");
|
|
111
|
+
}
|
|
112
|
+
return Response.json({});
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* `/reload-catalogs`: session guard plus optional cookie refresh.
|
|
117
|
+
*/
|
|
118
|
+
export function createReloadCatalogsLoader(extras) {
|
|
119
|
+
return async ({ request }) => {
|
|
120
|
+
const session = await getSession(request.headers.get("Cookie"));
|
|
121
|
+
if (!session.has("username")) {
|
|
122
|
+
return redirect("/");
|
|
123
|
+
}
|
|
124
|
+
if (isDenied(extras?.requireAccess?.(session))) {
|
|
125
|
+
return redirect("/");
|
|
126
|
+
}
|
|
127
|
+
try {
|
|
128
|
+
const refreshSession = await getRefreshSession(request);
|
|
129
|
+
return Response.json({}, refreshSession
|
|
130
|
+
? {
|
|
131
|
+
headers: {
|
|
132
|
+
"Set-Cookie": refreshSession,
|
|
133
|
+
},
|
|
134
|
+
}
|
|
135
|
+
: {});
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return Response.json({});
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { type ReactElement } from "react";
|
|
2
|
+
import type { LoaderFunction } from "@remix-run/node";
|
|
3
|
+
import { getHeaders, getSession } from "@zauru-sdk/services";
|
|
4
|
+
import type { AxiosUtilsResponse } from "@zauru-sdk/types";
|
|
5
|
+
import { type CatalogHandler } from "./catalogs.loader.js";
|
|
6
|
+
export type ApiHandlerContext = {
|
|
7
|
+
request: Request;
|
|
8
|
+
session: Awaited<ReturnType<typeof getSession>>;
|
|
9
|
+
headers: Awaited<ReturnType<typeof getHeaders>>;
|
|
10
|
+
url: URL;
|
|
11
|
+
name: string;
|
|
12
|
+
};
|
|
13
|
+
export type ApiHandler = (ctx: ApiHandlerContext) => Promise<AxiosUtilsResponse<unknown> | Response>;
|
|
14
|
+
export type ZauruApiLoaderExtras = {
|
|
15
|
+
catalogs?: Record<string, CatalogHandler>;
|
|
16
|
+
profiles?: Record<string, ApiHandler>;
|
|
17
|
+
templates?: Record<string, ApiHandler>;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Remix loader for `/api/profiles`. Pass extras to add or override profile names.
|
|
21
|
+
*/
|
|
22
|
+
export declare function createProfilesLoader(extras?: Record<string, ApiHandler>): LoaderFunction;
|
|
23
|
+
/**
|
|
24
|
+
* Remix loader for `/api/session`.
|
|
25
|
+
* Modern query: `name` + `type` (`environment` | `sessionVariable` | session attr).
|
|
26
|
+
* Legacy Remix 1: `attribute` only, read from the session.
|
|
27
|
+
*/
|
|
28
|
+
export declare function createSessionLoader(): LoaderFunction;
|
|
29
|
+
/**
|
|
30
|
+
* Remix loader for `/api/templates`. Pass extras to add or override template names.
|
|
31
|
+
*/
|
|
32
|
+
export declare function createTemplatesLoader(extras?: Record<string, ApiHandler>): LoaderFunction;
|
|
33
|
+
/**
|
|
34
|
+
* Single Remix splat loader for `/api/catalogs|profiles|session|templates`.
|
|
35
|
+
* App-specific handlers go in `extras` under the matching resource.
|
|
36
|
+
*/
|
|
37
|
+
export declare function createZauruApiLoader(extras?: ZauruApiLoaderExtras): LoaderFunction;
|
|
38
|
+
export declare function ProfilesRoute(): ReactElement;
|
|
39
|
+
export declare function SessionRoute(): ReactElement;
|
|
40
|
+
export declare function TemplatesRoute(): ReactElement;
|
|
41
|
+
export declare function ZauruApiRoute(): ReactElement;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ActionFunction, LoaderFunction } from "@remix-run/node";
|
|
2
|
+
import { getSession, loginWebApp } from "@zauru-sdk/services";
|
|
3
|
+
export type AuthSession = Awaited<ReturnType<typeof getSession>>;
|
|
4
|
+
export type LoginWebAppData = NonNullable<Awaited<ReturnType<typeof loginWebApp>>["data"]>;
|
|
5
|
+
export type AuthAccessExtras = {
|
|
6
|
+
/**
|
|
7
|
+
* Return `false` to deny. Throw `redirect(...)` to send the user elsewhere.
|
|
8
|
+
* Any other return (including `void`) allows access.
|
|
9
|
+
*/
|
|
10
|
+
requireAccess?: (session: AuthSession) => boolean | void;
|
|
11
|
+
};
|
|
12
|
+
export type LoginActionExtras = AuthAccessExtras & {
|
|
13
|
+
afterLogin?: (ctx: {
|
|
14
|
+
request: Request;
|
|
15
|
+
session: AuthSession;
|
|
16
|
+
headers: LoginWebAppData["headers"];
|
|
17
|
+
loginData: LoginWebAppData;
|
|
18
|
+
}) => Promise<void>;
|
|
19
|
+
};
|
|
20
|
+
export declare function createLoginLoader(): LoaderFunction;
|
|
21
|
+
/**
|
|
22
|
+
* Remix action for `/login`: OAuth authorize, `loginWebApp`, cookie commit.
|
|
23
|
+
* `afterLogin` runs before `commitSession`. `requireAccess` can deny after
|
|
24
|
+
* a successful OAuth exchange or when a session already has `code`.
|
|
25
|
+
*/
|
|
26
|
+
export declare function createLoginAction(extras?: LoginActionExtras): ActionFunction;
|
|
27
|
+
/**
|
|
28
|
+
* Guard for `/home` and `/reload-catalogs`: no `username` → `/`.
|
|
29
|
+
*/
|
|
30
|
+
export declare function createSessionGuardLoader(extras?: AuthAccessExtras): LoaderFunction;
|
|
31
|
+
export declare function createLogoutLoader(extras?: AuthAccessExtras): LoaderFunction;
|
|
32
|
+
export declare function createLogoutAction(): ActionFunction;
|
|
33
|
+
/**
|
|
34
|
+
* `/` welcome: existing session (and optional access) → `/home`.
|
|
35
|
+
*/
|
|
36
|
+
export declare function createIndexLoader(extras?: AuthAccessExtras): LoaderFunction;
|
|
37
|
+
/**
|
|
38
|
+
* `/reload-catalogs`: session guard plus optional cookie refresh.
|
|
39
|
+
*/
|
|
40
|
+
export declare function createReloadCatalogsLoader(extras?: AuthAccessExtras): LoaderFunction;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zauru-sdk/utils",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.15.0",
|
|
4
4
|
"description": "Utilidades, parseo de request y demás para desarrollo de webapps de Zauru",
|
|
5
5
|
"main": "./dist/esm/index.js",
|
|
6
6
|
"module": "./dist/esm/index.js",
|
|
@@ -37,5 +37,5 @@
|
|
|
37
37
|
"@zauru-sdk/types": "^6.16.0",
|
|
38
38
|
"moment": "^2.30.1"
|
|
39
39
|
},
|
|
40
|
-
"gitHead": "
|
|
40
|
+
"gitHead": "7ccc6b0a3d1c426f8b62579a64a0bb0be73d2fcd"
|
|
41
41
|
}
|