@anthusai/papyrus 1.0.0-next.2 → 1.0.0-next.4
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/amplify/data/resource.js +26 -0
- package/amplify/data/resource.ts +30 -0
- package/amplify/data/schema.js +26 -0
- package/amplify/data/schema.ts +30 -0
- package/amplify/functions/content-actions/handler.py +107 -0
- package/amplify/functions/content-actions/requirements.txt +4 -0
- package/amplify/functions/content-actions/resource.js +40 -0
- package/amplify/functions/content-actions/resource.ts +54 -0
- package/amplify/functions/shared/python-bundle.js +17 -2
- package/amplify/functions/shared/python-bundle.ts +23 -2
- package/amplify/site-backend.js +10 -0
- package/amplify/site-backend.ts +15 -0
- package/app/[year]/[month]/[day]/[articleSlug]/page.tsx +2 -2
- package/app/globals.css +1 -4
- package/app/layout.tsx +8 -12
- package/app/robots.ts +11 -0
- package/bin/papyrus-infra.mjs +46 -16
- package/components/news-desk-page.tsx +1 -4
- package/components/staging-banner.tsx +11 -0
- package/infra/amplify-app-shell.js +123 -80
- package/infra/amplify-app-shell.ts +139 -113
- package/infra/build-specs.js +86 -0
- package/infra/build-specs.ts +132 -0
- package/infra/index.js +2 -0
- package/infra/site-config.js +125 -0
- package/infra/site-config.ts +163 -0
- package/lib/cached-content-repository.ts +2 -1
- package/lib/content-actions-client.ts +98 -0
- package/lib/content-repository.ts +3 -0
- package/lib/content-source-context.ts +16 -0
- package/lib/empty-theme.css +1 -0
- package/lib/graphql-content-repository.ts +182 -37
- package/lib/layout-scenarios.ts +8 -8
- package/lib/site-brand-tenant.ts +3 -22
- package/lib/site-brand.ts +44 -42
- package/lib/site-env.ts +52 -0
- package/lib/staging-gate.ts +37 -0
- package/middleware.ts +46 -34
- package/package.json +6 -1
- package/renderers/pretext/article-page.tsx +1 -6
- package/renderers/pretext/presentation-shell.tsx +2 -9
- package/routes.manifest.json +1 -1
- package/src/with-papyrus.mjs +15 -2
- package/publications/anth_us/brand.ts +0 -68
- package/publications/anth_us/theme.css +0 -66
- package/publications/pilobol_us/brand.ts +0 -37
- package/publications/pilobol_us/theme.css +0 -140
- package/publications/threat_intelligence/blog-defense/graph.ts +0 -636
- package/publications/threat_intelligence/blog-defense/layout.ts +0 -934
- package/publications/threat_intelligence/blog-defense/page-background.tsx +0 -590
- package/publications/threat_intelligence/brand.ts +0 -40
- package/publications/threat_intelligence/pictograms/art.tsx +0 -608
- package/publications/threat_intelligence/pictograms/figure.tsx +0 -82
- package/publications/threat_intelligence/pictograms/registry.ts +0 -30
- package/publications/threat_intelligence/pictograms/system.tsx +0 -305
- package/publications/threat_intelligence/seed/seed-edition-content.json +0 -629
- package/publications/threat_intelligence/theme.css +0 -1440
- /package/{publications/papyrus/theme.css → app/papyrus-theme.css} +0 -0
package/bin/papyrus-infra.mjs
CHANGED
|
@@ -1,35 +1,65 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* papyrus-infra synth --site infra/site.json [--out cdk.out]
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* papyrus-infra synth --site infra/site.json [--out cdk.out] [--account A] [--region R]
|
|
4
|
+
* papyrus-infra print-buildspec --site infra/site.json --app cms-production|cms-staging|reader
|
|
5
|
+
* Synthesizes the app-shell stack for a publication from its own site.json, or
|
|
6
|
+
* prints one of the generated Amplify build specs. Synth only: deploying is a
|
|
7
|
+
* separate, reviewed step.
|
|
6
8
|
*/
|
|
7
9
|
import fs from "node:fs";
|
|
8
10
|
import path from "node:path";
|
|
9
11
|
import { createRequire } from "node:module";
|
|
12
|
+
import { buildSpecFor } from "../infra/build-specs.js";
|
|
13
|
+
import { parseSiteConfig } from "../infra/site-config.js";
|
|
14
|
+
|
|
15
|
+
const USAGE = [
|
|
16
|
+
"Usage: papyrus-infra synth --site infra/site.json [--out cdk.out] [--account A] [--region R]",
|
|
17
|
+
" papyrus-infra print-buildspec --site infra/site.json --app cms-production|cms-staging|reader",
|
|
18
|
+
].join("\n");
|
|
19
|
+
|
|
20
|
+
const args = process.argv.slice(2);
|
|
21
|
+
const opt = (name, fallback) => {
|
|
22
|
+
const i = args.indexOf(`--${name}`);
|
|
23
|
+
return i >= 0 ? args[i + 1] : fallback;
|
|
24
|
+
};
|
|
25
|
+
const command = args[0];
|
|
26
|
+
if (command !== "synth" && command !== "print-buildspec") {
|
|
27
|
+
console.error(USAGE);
|
|
28
|
+
process.exit(command ? 1 : 0);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
let config;
|
|
32
|
+
try {
|
|
33
|
+
config = parseSiteConfig(JSON.parse(fs.readFileSync(path.resolve(opt("site", "infra/site.json")), "utf8")));
|
|
34
|
+
} catch (error) {
|
|
35
|
+
console.error(`papyrus-infra: ${error instanceof Error ? error.message : error}`);
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (command === "print-buildspec") {
|
|
40
|
+
const app = opt("app");
|
|
41
|
+
if (!["cms-production", "cms-staging", "reader"].includes(app)) {
|
|
42
|
+
console.error(USAGE);
|
|
43
|
+
process.exit(1);
|
|
44
|
+
}
|
|
45
|
+
try {
|
|
46
|
+
process.stdout.write(buildSpecFor(config, app));
|
|
47
|
+
} catch (error) {
|
|
48
|
+
console.error(`papyrus-infra: ${error instanceof Error ? error.message : error}`);
|
|
49
|
+
process.exit(1);
|
|
50
|
+
}
|
|
51
|
+
process.exit(0);
|
|
52
|
+
}
|
|
10
53
|
|
|
11
54
|
let AmplifyAppShellStack;
|
|
12
55
|
try {
|
|
13
56
|
({ AmplifyAppShellStack } = await import("../infra/index.js"));
|
|
14
57
|
} catch (error) {
|
|
15
|
-
// infra/index.js explains the missing optional peers; print it without a stack trace.
|
|
16
58
|
console.error(`papyrus-infra: ${error instanceof Error ? error.message : error}`);
|
|
17
59
|
process.exit(1);
|
|
18
60
|
}
|
|
19
61
|
|
|
20
|
-
// aws-cdk-lib is an optional peer: resolve it from the publication's infra/ package (cwd).
|
|
21
62
|
const { App } = createRequire(path.join(process.cwd(), "noop.js"))("aws-cdk-lib");
|
|
22
|
-
|
|
23
|
-
const args = process.argv.slice(2);
|
|
24
|
-
const opt = (name, fallback) => {
|
|
25
|
-
const i = args.indexOf(`--${name}`);
|
|
26
|
-
return i >= 0 ? args[i + 1] : fallback;
|
|
27
|
-
};
|
|
28
|
-
if (args[0] !== "synth") {
|
|
29
|
-
console.error("Usage: papyrus-infra synth --site infra/site.json [--out cdk.out]");
|
|
30
|
-
process.exit(args[0] ? 1 : 0);
|
|
31
|
-
}
|
|
32
|
-
const config = JSON.parse(fs.readFileSync(path.resolve(opt("site", "infra/site.json")), "utf8"));
|
|
33
63
|
const app = new App({ outdir: path.resolve(opt("out", "cdk.out")) });
|
|
34
64
|
new AmplifyAppShellStack(app, `AmplifyAppShell-${config.siteId}`, config, {
|
|
35
65
|
env: { account: opt("account", "335163751677"), region: opt("region", "us-east-1") },
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { cookies } from "next/headers";
|
|
2
1
|
import { NewsDeskWorkspace, type NewsDeskTab } from "./topic-steering-workspace";
|
|
3
2
|
import {
|
|
4
3
|
createDemoCategorySteeringDashboard,
|
|
@@ -7,7 +6,6 @@ import {
|
|
|
7
6
|
loadConfiguredCorpusSummaries,
|
|
8
7
|
} from "../lib/category-repository";
|
|
9
8
|
import {
|
|
10
|
-
BRAND_OVERRIDE_COOKIE,
|
|
11
9
|
getBrandAnalysisProfilesPath,
|
|
12
10
|
getBrandNewsroomSectionsConfigPath,
|
|
13
11
|
getBrandSteeringConfigPath,
|
|
@@ -65,8 +63,7 @@ export async function NewsDeskPage({ section: routeSection, sectionPageId, selec
|
|
|
65
63
|
proposal: getFirstSearchParam(resolvedSearchParams, "proposal"),
|
|
66
64
|
};
|
|
67
65
|
const useDemoDashboard = getFirstSearchParam(resolvedSearchParams, "demo") === "1";
|
|
68
|
-
const
|
|
69
|
-
const brand = resolveActiveSiteBrand(brandOverride);
|
|
66
|
+
const brand = resolveActiveSiteBrand();
|
|
70
67
|
const analysisProfiles = await loadAnalysisProfileSummaries(getBrandAnalysisProfilesPath(brand));
|
|
71
68
|
const configuredCorpora = await loadConfiguredCorpusSummaries(getBrandSteeringConfigPath(brand));
|
|
72
69
|
const dashboard = useDemoDashboard
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export function StagingBanner() {
|
|
2
|
+
return (
|
|
3
|
+
<div
|
|
4
|
+
data-staging-banner
|
|
5
|
+
role="status"
|
|
6
|
+
className="sticky top-0 z-50 bg-amber-300 px-3 py-1 text-center text-xs font-semibold uppercase tracking-wide text-black"
|
|
7
|
+
>
|
|
8
|
+
STAGING: drafts shown as published
|
|
9
|
+
</div>
|
|
10
|
+
);
|
|
11
|
+
}
|
|
@@ -1,109 +1,152 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { ArnFormat, CfnOutput, Stack } from "aws-cdk-lib";
|
|
2
2
|
import * as iam from "aws-cdk-lib/aws-iam";
|
|
3
3
|
import * as amplify from "aws-cdk-lib/aws-amplify";
|
|
4
|
+
import { cmsProductionBuildSpec, cmsStagingBuildSpec, readerBuildSpec } from "./build-specs.js";
|
|
5
|
+
import { resolveStagingDomainName } from "./site-config.js";
|
|
6
|
+
function environmentVariables(variables) {
|
|
7
|
+
return Object.entries(variables).map(([name, value]) => ({ name, value }));
|
|
8
|
+
}
|
|
4
9
|
/**
|
|
5
|
-
* Provisions the Amplify *app shell*
|
|
6
|
-
*
|
|
7
|
-
* - AWS::Amplify::App (WEB_COMPUTE, GitHub repo connection via PAT in Secrets Manager)
|
|
8
|
-
* - AWS::Amplify::Branch (main, with PAPYRUS_SITE_BRAND env vars)
|
|
9
|
-
* - AWS::Amplify::Domain (newsroom.<site>.us on the site's Route 53 zone)
|
|
10
|
-
* - IAM service role (backend deploy) + compute role (SSR rendering)
|
|
10
|
+
* Provisions the Amplify *app shell* of a Papyrus publication from its
|
|
11
|
+
* site.json:
|
|
11
12
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* - CMS app (WEB_COMPUTE) with branches `main` (production, owns the backend)
|
|
14
|
+
* and `staging` (frontend only, reads the production backend)
|
|
15
|
+
* - for `markus-static` sites, a reader app (WEB) with branch `main`
|
|
16
|
+
* - one domain association per branch/app, IAM service and compute roles
|
|
16
17
|
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
18
|
+
* No repository and no access token are configured: the apps are manual-deploy
|
|
19
|
+
* until Amplify's GitHub App is connected once in the console
|
|
20
|
+
* (docs/site-hosting.md). The Papyrus backend itself is created inside the CMS
|
|
21
|
+
* app by `ampx pipeline-deploy` from the production build.
|
|
19
22
|
*/
|
|
20
23
|
export class AmplifyAppShellStack extends Stack {
|
|
21
|
-
|
|
22
|
-
|
|
24
|
+
cmsAppId;
|
|
25
|
+
readerAppId;
|
|
23
26
|
constructor(scope, id, config, props) {
|
|
24
27
|
super(scope, id, props);
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
const serviceRole = new iam.Role(this, "AmplifyServiceRole", {
|
|
28
|
+
const cmsAppName = config.cms.appName ?? `${config.siteId}-cms`;
|
|
29
|
+
const stagingDomainName = resolveStagingDomainName(config);
|
|
30
|
+
const cmsServiceRole = new iam.Role(this, "AmplifyServiceRole", {
|
|
29
31
|
assumedBy: new iam.ServicePrincipal("amplify.amazonaws.com"),
|
|
30
|
-
description: `Backend deploy service role for Amplify app ${
|
|
32
|
+
description: `Backend deploy and staging build service role for Amplify app ${cmsAppName} (AdministratorAccess: documented risk)`,
|
|
31
33
|
managedPolicies: [iam.ManagedPolicy.fromAwsManagedPolicyName("AdministratorAccess")],
|
|
32
34
|
});
|
|
33
|
-
// WEB_COMPUTE (Next.js SSR) requires a compute role for the rendering server.
|
|
34
35
|
const computeRole = new iam.Role(this, "AmplifyComputeRole", {
|
|
35
36
|
assumedBy: new iam.ServicePrincipal("amplify.amazonaws.com"),
|
|
36
|
-
description: `SSR compute role for Amplify app ${
|
|
37
|
+
description: `SSR compute role for Amplify app ${cmsAppName}`,
|
|
37
38
|
managedPolicies: [
|
|
38
39
|
iam.ManagedPolicy.fromAwsManagedPolicyName("service-role/AWSLambdaBasicExecutionRole"),
|
|
39
40
|
iam.ManagedPolicy.fromAwsManagedPolicyName("CloudWatchLogsFullAccess"),
|
|
40
41
|
],
|
|
41
42
|
});
|
|
42
|
-
const
|
|
43
|
-
name:
|
|
44
|
-
description: config.
|
|
45
|
-
platform:
|
|
46
|
-
|
|
47
|
-
accessToken: githubToken,
|
|
48
|
-
iamServiceRole: serviceRole.roleArn,
|
|
43
|
+
const cmsApp = new amplify.CfnApp(this, "App", {
|
|
44
|
+
name: cmsAppName,
|
|
45
|
+
description: `Papyrus newsroom CMS for ${config.siteId} (WEB_COMPUTE)`,
|
|
46
|
+
platform: "WEB_COMPUTE",
|
|
47
|
+
iamServiceRole: cmsServiceRole.roleArn,
|
|
49
48
|
computeRoleArn: computeRole.roleArn,
|
|
50
|
-
buildSpec:
|
|
49
|
+
buildSpec: cmsProductionBuildSpec(config),
|
|
50
|
+
jobConfig: config.cms.buildComputeType ? { buildComputeType: config.cms.buildComputeType } : undefined,
|
|
51
51
|
customRules: [{ source: "/<*>", target: "/index.html", status: "404-200" }],
|
|
52
52
|
});
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
53
|
+
const stagingMode = config.frontend === "markus-static"
|
|
54
|
+
? { PAPYRUS_STAGING_PREVIEW: "static" }
|
|
55
|
+
: { PAPYRUS_CONTENT_SOURCE: "drafts" };
|
|
56
|
+
const productionBranch = new amplify.CfnBranch(this, "Branch", {
|
|
57
|
+
appId: cmsApp.attrAppId,
|
|
58
|
+
branchName: "main",
|
|
58
59
|
stage: "PRODUCTION",
|
|
59
|
-
enableAutoBuild:
|
|
60
|
+
enableAutoBuild: true,
|
|
61
|
+
enablePerformanceMode: false,
|
|
62
|
+
environmentVariables: environmentVariables({
|
|
63
|
+
...config.cms.environment,
|
|
64
|
+
PAPYRUS_SITE_BRAND: config.brand,
|
|
65
|
+
SITE_ENV: "production",
|
|
66
|
+
PAPYRUS_CONTENT_SOURCE: "published",
|
|
67
|
+
}),
|
|
68
|
+
});
|
|
69
|
+
const stagingBranch = new amplify.CfnBranch(this, "StagingBranch", {
|
|
70
|
+
appId: cmsApp.attrAppId,
|
|
71
|
+
branchName: "staging",
|
|
72
|
+
stage: "BETA",
|
|
73
|
+
enableAutoBuild: true,
|
|
60
74
|
enablePerformanceMode: false,
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
75
|
+
buildSpec: cmsStagingBuildSpec(config),
|
|
76
|
+
environmentVariables: environmentVariables({
|
|
77
|
+
...config.cms.environment,
|
|
78
|
+
PAPYRUS_SITE_BRAND: config.brand,
|
|
79
|
+
SITE_ENV: "staging",
|
|
80
|
+
...stagingMode,
|
|
81
|
+
NEXT_PUBLIC_PAPYRUS_STAGING_URL: `https://${stagingDomainName}`,
|
|
82
|
+
}),
|
|
83
|
+
});
|
|
84
|
+
const productionDomain = new amplify.CfnDomain(this, "Domain", {
|
|
85
|
+
appId: cmsApp.attrAppId,
|
|
86
|
+
domainName: config.cms.domainName,
|
|
87
|
+
subDomainSettings: [{ branchName: "main", prefix: "" }],
|
|
65
88
|
});
|
|
66
|
-
|
|
67
|
-
const
|
|
68
|
-
appId:
|
|
69
|
-
domainName:
|
|
70
|
-
subDomainSettings: [{ branchName:
|
|
89
|
+
productionDomain.addResourceDependency(productionBranch);
|
|
90
|
+
const stagingDomain = new amplify.CfnDomain(this, "StagingDomain", {
|
|
91
|
+
appId: cmsApp.attrAppId,
|
|
92
|
+
domainName: stagingDomainName,
|
|
93
|
+
subDomainSettings: [{ branchName: "staging", prefix: "" }],
|
|
71
94
|
});
|
|
72
|
-
|
|
73
|
-
this.
|
|
95
|
+
stagingDomain.addResourceDependency(stagingBranch);
|
|
96
|
+
this.cmsAppId = cmsApp.attrAppId;
|
|
97
|
+
new CfnOutput(this, "CmsAppId", { value: cmsApp.attrAppId, description: `Amplify app id for ${cmsAppName}` });
|
|
98
|
+
new CfnOutput(this, "CmsOrigin", { value: `https://${config.cms.domainName}/` });
|
|
99
|
+
new CfnOutput(this, "StagingOrigin", { value: `https://${stagingDomainName}/` });
|
|
100
|
+
if (config.reader) {
|
|
101
|
+
this.readerAppId = this.addReaderApp(config, config.reader, cmsApp.attrAppId);
|
|
102
|
+
}
|
|
74
103
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
"
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
"
|
|
92
|
-
|
|
93
|
-
"
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
104
|
+
addReaderApp(config, reader, cmsAppId) {
|
|
105
|
+
const readerAppName = reader.appName ?? `${config.siteId}-reader`;
|
|
106
|
+
const readerBranchName = reader.branchName ?? "main";
|
|
107
|
+
const readerServiceRole = new iam.Role(this, "ReaderServiceRole", {
|
|
108
|
+
assumedBy: new iam.ServicePrincipal("amplify.amazonaws.com"),
|
|
109
|
+
description: `Static reader build role for Amplify app ${readerAppName} (reads the CMS app's SSM secrets and media only)`,
|
|
110
|
+
});
|
|
111
|
+
readerServiceRole.addToPolicy(new iam.PolicyStatement({
|
|
112
|
+
sid: "ReadSiteSecretsForJwtMinting",
|
|
113
|
+
actions: ["ssm:GetParameter", "ssm:GetParameters"],
|
|
114
|
+
resources: [
|
|
115
|
+
this.formatArn({ service: "ssm", resource: "parameter", resourceName: `amplify/${cmsAppId}/*`, arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
|
|
116
|
+
this.formatArn({ service: "ssm", resource: "parameter", resourceName: `amplify/shared/${cmsAppId}/*`, arnFormat: ArnFormat.SLASH_RESOURCE_NAME }),
|
|
117
|
+
],
|
|
118
|
+
}));
|
|
119
|
+
readerServiceRole.addToPolicy(new iam.PolicyStatement({
|
|
120
|
+
sid: "ReadMediaForExport",
|
|
121
|
+
actions: ["s3:GetObject"],
|
|
122
|
+
resources: ["arn:aws:s3:::amplify-*/media/*"],
|
|
123
|
+
}));
|
|
124
|
+
const readerApp = new amplify.CfnApp(this, "ReaderApp", {
|
|
125
|
+
name: readerAppName,
|
|
126
|
+
description: `Papyrus static reader for ${config.siteId} (WEB)`,
|
|
127
|
+
platform: "WEB",
|
|
128
|
+
iamServiceRole: readerServiceRole.roleArn,
|
|
129
|
+
buildSpec: readerBuildSpec(config),
|
|
130
|
+
});
|
|
131
|
+
const readerBranch = new amplify.CfnBranch(this, "ReaderBranch", {
|
|
132
|
+
appId: readerApp.attrAppId,
|
|
133
|
+
branchName: readerBranchName,
|
|
134
|
+
stage: "PRODUCTION",
|
|
135
|
+
enableAutoBuild: true,
|
|
136
|
+
environmentVariables: environmentVariables({
|
|
137
|
+
...(reader.environment ?? {}),
|
|
138
|
+
PAPYRUS_SITE_BRAND: config.brand,
|
|
139
|
+
SITE_ENV: "production",
|
|
140
|
+
}),
|
|
141
|
+
});
|
|
142
|
+
const readerDomain = new amplify.CfnDomain(this, "ReaderDomain", {
|
|
143
|
+
appId: readerApp.attrAppId,
|
|
144
|
+
domainName: reader.domainName,
|
|
145
|
+
subDomainSettings: [{ branchName: readerBranchName, prefix: "" }],
|
|
146
|
+
});
|
|
147
|
+
readerDomain.addResourceDependency(readerBranch);
|
|
148
|
+
new CfnOutput(this, "ReaderAppId", { value: readerApp.attrAppId, description: `Amplify app id for ${readerAppName}` });
|
|
149
|
+
new CfnOutput(this, "ReaderOrigin", { value: `https://${reader.domainName}/` });
|
|
150
|
+
return readerApp.attrAppId;
|
|
108
151
|
}
|
|
109
152
|
}
|