-
Notifications
You must be signed in to change notification settings - Fork 19
Expand file tree
/
Copy pathexpSite.ts
More file actions
451 lines (407 loc) · 16.6 KB
/
Copy pathexpSite.ts
File metadata and controls
451 lines (407 loc) · 16.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
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
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
/*
* Copyright 2025, Salesforce, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import fs from 'node:fs';
import path from 'node:path';
import { Org, SfError } from '@salesforce/core';
import axios from 'axios';
export type SiteMetadata = {
bundleName: string;
bundleLastModified: string;
coreVersion: string;
};
export type SiteMetadataCache = {
[key: string]: SiteMetadata;
};
/**
* Experience Site class.
* https://developer.salesforce.com/docs/platform/lwc/guide/get-started-test-components.html#enable-local-dev
*
* @param {string} siteName - The name of the experience site.
* @param {string} status - The status of the experience site.
* @param {string} bundleName - The static resource bundle name.
* @param {string} bundleLastModified - The lastModifiedDate of the static resource.
*/
export class ExperienceSite {
public siteDisplayName: string;
public siteName: string;
private org: Org;
private metadataCache: SiteMetadataCache = {};
public constructor(org: Org, siteName: string) {
this.org = org;
this.siteDisplayName = siteName.trim();
this.siteName = this.siteDisplayName.replace(' ', '_');
// Replace any special characters in site name with underscore
this.siteName = this.siteName.replace(/[^a-zA-Z0-9]/g, '_');
}
/**
* Fetches all current experience sites
*
* @param {Connection} conn - Salesforce connection object.
* @returns {Promise<string[]>} - List of experience sites.
*/
public static async getAllExpSites(org: Org): Promise<string[]> {
const result = await org.getConnection().query<{
Id: string;
Name: string;
LastModifiedDate: string;
UrlPathPrefix: string;
Status: string;
}>('SELECT Id, Name, LastModifiedDate, UrlPathPrefix, Status FROM Network');
const experienceSites: string[] = result.records.map((record) => record.Name);
return experienceSites;
}
/**
* Esablish a valid token for this local development session
*
* @returns sid token for proxied site requests
*/
public async setupAuth(): Promise<string> {
let sidToken = '';
// Default to guest user access if specified
if (process.env.SITE_GUEST_ACCESS === 'true') return sidToken;
// Use a provided token if specified in environment variables
if (process.env.SID_TOKEN) return process.env.SID_TOKEN;
// Otherwise attempt to generate one based on the currently authenticated admin user
try {
const networkId = await this.getNetworkId();
sidToken = await this.getNewSidToken(networkId);
} catch (e) {
// eslint-disable-next-line no-console
console.error('Failed to establish authentication for site', e);
}
return sidToken;
}
public async isUpdateAvailable(): Promise<boolean> {
const localMetadata = this.getLocalMetadata();
if (!localMetadata) {
return true; // If no local metadata, assume update is available
}
const remoteMetadata = await this.getRemoteMetadata();
if (!remoteMetadata) {
return false; // If no org bundle found, no update available
}
return new Date(remoteMetadata.bundleLastModified) > new Date(localMetadata.bundleLastModified);
}
// Is the site extracted locally
public isSiteSetup(): boolean {
if (fs.existsSync(path.join(this.getExtractDirectory(), 'ssr.js'))) {
return this.getLocalMetadata()?.coreVersion === '254';
}
return false;
}
// Is the static resource available on the server
public async isSitePublished(): Promise<boolean> {
const remoteMetadata = await this.getRemoteMetadata();
if (!remoteMetadata) {
return false;
}
return true;
}
// Is there a local gz file of the site
public isSiteDownloaded(): boolean {
const metadata = this.getLocalMetadata();
if (!metadata) {
return false;
}
return fs.existsSync(this.getSiteZipPath(metadata));
}
public saveMetadata(metadata: SiteMetadata): void {
const siteJsonPath = path.join(this.getSiteDirectory(), 'site.json');
const siteJson = JSON.stringify(metadata, null, 2);
fs.writeFileSync(siteJsonPath, siteJson);
}
public getLocalMetadata(): SiteMetadata | undefined {
if (this.metadataCache.localMetadata) return this.metadataCache.localMetadata;
const siteJsonPath = path.join(this.getSiteDirectory(), 'site.json');
let siteJson;
if (fs.existsSync(siteJsonPath)) {
try {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
siteJson = JSON.parse(fs.readFileSync(siteJsonPath, 'utf-8')) as SiteMetadata;
this.metadataCache.localMetadata = siteJson;
} catch (error) {
// eslint-disable-next-line no-console
console.error('Error reading site.json file', error);
}
}
return siteJson;
}
public async getRemoteMetadata(): Promise<SiteMetadata | undefined> {
if (this.metadataCache.remoteMetadata) return this.metadataCache.remoteMetadata;
const result = await this.org
.getConnection()
.query<{ Name: string; LastModifiedDate: string }>(
`SELECT Name, LastModifiedDate FROM StaticResource WHERE Name LIKE 'MRT_experience_%_${this.siteName}'`
);
if (result.records.length === 0) {
return undefined;
}
const staticResource = result.records[0];
this.metadataCache.remoteMetadata = {
bundleName: staticResource.Name,
bundleLastModified: staticResource.LastModifiedDate,
coreVersion: '254',
};
return this.metadataCache.remoteMetadata;
}
/**
* Get the local site directory path
*
* @returns the path to the site
*/
public getSiteDirectory(): string {
return path.join('.localdev', this.siteName);
}
public getExtractDirectory(): string {
return path.join('.localdev', this.siteName, 'app');
}
public getSiteZipPath(metadata: SiteMetadata): string {
const lastModifiedDate = new Date(metadata.bundleLastModified);
const timestamp = `${
lastModifiedDate.getMonth() + 1
}-${lastModifiedDate.getDate()}_${lastModifiedDate.getHours()}-${lastModifiedDate.getMinutes()}`;
const fileName = `${metadata.bundleName}_${timestamp}.gz`;
const resourcePath = path.join(this.getSiteDirectory(), fileName);
return resourcePath;
}
/**
* Download and return the site resource bundle
*
* @returns path of downloaded site zip
*/
public async downloadSite(): Promise<string> {
let retVal;
if (process.env.STATIC_MODE !== 'true') {
// Use sites API to download the site bundle on demand
retVal = await this.downloadSiteApi();
} else {
// This is for testing purposes only now - not an officially supported external path
retVal = await this.downloadSiteStaticResources();
}
return retVal;
}
public async getPreviewUrl(): Promise<string> {
// Get the community ID
const communityId = await this.getNetworkId();
const conn = this.org.getConnection();
const accessToken = conn.accessToken;
const instanceUrl = conn.instanceUrl;
if (!accessToken) {
throw new SfError(`Invalid access token, unable to get preview URL for: ${this.siteDisplayName}`);
}
try {
// Call the communities API to get the preview URL
const apiUrl = `${instanceUrl}/services/data/v64.0/connect/communities/${communityId}/preview-url/pages/Home`;
const response = await axios.get<{ previewUrl: string }>(apiUrl, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (response.data?.previewUrl) {
return response.data.previewUrl;
} else {
throw new SfError(`Invalid response from communities API for site: ${this.siteDisplayName}`);
}
} catch (error) {
// Handle axios errors
if (axios.isAxiosError(error)) {
if (error.response) {
// Server responded with non-200 status
throw new SfError(
`Failed to get preview URL: Server responded with status ${error.response.status} - ${error.response.statusText}`
);
} else if (error.request) {
// Request was made but no response received
throw new SfError('Failed to get preview URL: No response received from server');
}
}
throw new SfError(`Failed to get preview URL for site: ${this.siteDisplayName}`);
}
}
/**
* Generate a site bundle on demand and download it
*
* @returns path of downloaded site zip
*/
public async downloadSiteApi(): Promise<string> {
const remoteMetadata = await this.org
.getConnection()
.query<{ Id: string; Name: string; LastModifiedDate: string; MasterLabel: string }>(
`Select Id, Name, LastModifiedDate, MasterLabel, UrlPathPrefix, SiteType, Status from Site WHERE Name like '${this.siteName}1'`
);
if (!remoteMetadata || remoteMetadata.records.length === 0) {
throw new SfError(`No published site found for: ${this.siteDisplayName}`);
}
const theSite = remoteMetadata.records[0];
// Download the site via API
const conn = this.org.getConnection();
const metadata = {
bundleName: theSite.Name,
bundleLastModified: theSite.LastModifiedDate,
coreVersion: '254',
};
const siteId = theSite.Id;
const siteIdMinus3 = siteId.substring(0, siteId.length - 3);
const accessToken = conn.accessToken;
const instanceUrl = conn.instanceUrl; // Org URL
if (!accessToken) {
throw new SfError(`Invalid access token, unable to download site: ${this.siteDisplayName}`);
}
const resourcePath = this.getSiteZipPath(metadata);
try {
// Limit API to published sites for now until we have a patch for the issues with unpublished sites
// TODO switch api back to preview mode after issues are addressed
let apiUrl = `${instanceUrl}/services/data/v63.0/sites/${siteIdMinus3}/preview?published`;
if (process.env.SITE_API_MODE === 'preview') {
apiUrl = `${instanceUrl}/services/data/v63.0/sites/${siteIdMinus3}/preview`;
}
const response = await axios.get(apiUrl, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
responseType: 'stream',
});
if (response.statusText) fs.mkdirSync(this.getSiteDirectory(), { recursive: true });
const fileStream = fs.createWriteStream(resourcePath);
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
response.data.pipe(fileStream);
await new Promise((resolve, reject) => {
fileStream.on('finish', resolve);
fileStream.on('error', reject);
});
this.saveMetadata(metadata);
} catch (error) {
// Handle axios errors
if (axios.isAxiosError(error)) {
if (error.response) {
// Server responded with non-200 status
throw new SfError(
`Failed to download site: Server responded with status ${error.response.status} - ${error.response.statusText}`
);
} else if (error.request) {
// Request was made but no response received
throw new SfError('Failed to download site: No response received from server');
}
}
throw new SfError(`Failed to download site: ${this.siteDisplayName}`);
}
// Save the site's metadata
return resourcePath;
}
// Deprecated. Only used internally now for testing. Customer sites will no longer be stored in static resources
// and are only available via the API.
public async downloadSiteStaticResources(): Promise<string> {
const remoteMetadata = await this.getRemoteMetadata();
if (!remoteMetadata) {
throw new SfError(`No published site found for: ${this.siteDisplayName}`);
}
// Download the site from static resources
const resourcePath = this.getSiteZipPath(remoteMetadata);
const staticresource = await this.org.getConnection().metadata.read('StaticResource', remoteMetadata.bundleName);
if (staticresource?.content) {
// Save the static resource
fs.mkdirSync(this.getSiteDirectory(), { recursive: true });
const buffer = Buffer.from(staticresource.content, 'base64');
fs.writeFileSync(resourcePath, buffer);
// Save the site's metadata
this.saveMetadata(remoteMetadata);
} else {
throw new SfError(`Error occurred downloading your site: ${this.siteDisplayName}`);
}
return resourcePath;
}
private async getNetworkId(): Promise<string> {
const conn = this.org.getConnection();
// Query the Network object for the network with the given site name
const result = await conn.query<{ Id: string }>(`SELECT Id FROM Network WHERE Name = '${this.siteDisplayName}'`);
const record = result.records[0];
if (record) {
let networkId = record.Id;
// Subtract the last three characters from the Network ID
networkId = networkId.substring(0, networkId.length - 3);
return networkId;
} else {
throw new Error(`NetworkId for site: '${this.siteDisplayName}' could not be found`);
}
}
// TODO need to get auth tokens for the builder preview also once API issues are addressed
private async getNewSidToken(networkId: string): Promise<string> {
// Get the connection and access token from the org
const conn = this.org.getConnection();
const orgId = this.org.getOrgId();
// Not sure if we need to do this
const orgIdMinus3 = orgId.substring(0, orgId.length - 3);
const accessToken = conn.accessToken;
const instanceUrl = conn.instanceUrl; // Org URL
// Make the GET request without following redirects
if (accessToken) {
// Call out to the switcher servlet to establish a session
const switchUrl = `${instanceUrl}/servlet/networks/switch?networkId=${networkId}`;
const cookies = [`sid=${accessToken}`, `oid=${orgIdMinus3}`].join('; ').trim();
let response = await axios.get(switchUrl, {
headers: {
Cookie: cookies,
},
withCredentials: true,
maxRedirects: 0, // Prevent axios from following redirects
validateStatus: (status) => status >= 200 && status < 400, // Accept 3xx status codes
});
// Extract the Location callback header
const locationHeader = response.headers['location'] as string;
if (locationHeader) {
// Parse the URL to extract the 'sid' parameter
const urlObj = new URL(locationHeader);
const sid = urlObj.searchParams.get('sid') ?? '';
const cookies2 = ['__Secure-has-sid=1', `sid=${sid}`, `oid=${orgIdMinus3}`].join('; ').trim();
// Request the location header to establish our session with the servlet
response = await axios.get(urlObj.toString(), {
headers: {
Cookie: cookies2,
},
withCredentials: true,
maxRedirects: 0, // Prevent axios from following redirects
validateStatus: (status) => status >= 200 && status < 400, // Accept 3xx status codes
});
const setCookieHeader = response.headers['set-cookie'];
if (setCookieHeader) {
// Find the 'sid' cookie in the set-cookie header
const sidCookie = setCookieHeader.find((cookieStr: string) => cookieStr.startsWith('sid='));
if (sidCookie) {
// Extract the sid value from the set-cookie string
const sidMatch = sidCookie.match(/sid=([^;]+)/);
if (sidMatch?.[1]) {
const sidToken = sidMatch[1];
return sidToken;
}
}
}
}
// if we can't establish a valid session this way, lets just warn the user and utilize the guest user context for the site
// eslint-disable-next-line no-console
console.warn(
`Warning: could not establish valid auth token for your site '${this.siteDisplayName}'.` +
'Local Dev proxied requests to your site may fail or return data from the guest user context.'
);
return ''; // Site will be guest user access only
}
// Not sure what scenarios we don't have an access token at all, but lets output a separate message here so we can distinguish these edge cases
// eslint-disable-next-line no-console
console.warn(
'Warning: sf cli org connection missing accessToken. Local Dev proxied requests to your site may fail or return data from the guest user context.'
);
return '';
}
}