UNPKG

12.5 kBJavaScriptView Raw
1/**
2 * Copyright 2017 Google Inc. All rights reserved.
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17// #set _NODE 1
18import express from 'express';
19import fs from 'fs';
20import {URL} from 'url';
21import compression from 'compression';
22import {
23 getCompiledTemplate,
24 cacheStorage, paths,
25 generateCSPPolicy,
26 generateIncrementalNonce
27} from './public/scripts/platform/common.js';
28import * as node from './public/scripts/platform/node.js';
29
30import {handler as root} from './public/scripts/routes/root.js';
31import {handler as proxy} from './public/scripts/routes/proxy.js';
32import {handler as all} from './public/scripts/routes/all.js';
33import {handler as manifest} from './public/scripts/routes/manifest.js';
34import {DepGraph, DepGraphCycleError} from 'dependency-graph';
35
36const preload = '</scripts/client.js>; rel=preload; as=script';
37const generator = generateIncrementalNonce('server');
38const RSSCombiner = require('rss-combiner-ns');
39const path = require('path');
40
41// A global server feedcache so we are not overloading remote servers
42class FeedFetcher {
43 constructor(fetchInterval, configPath) {
44 this.feedConfigs = new Map;
45 this.rootConfigPath = configPath;
46 this.loadConfigs(configPath);
47 this.latestFeeds = {};
48 this._fetchInterval = fetchInterval;
49 }
50
51 get configs() {
52 return this.feedConfigs;
53 }
54
55 get fetchInterval() {
56 return this._fetchInterval;
57 }
58
59 get feeds() {
60 return this.latestFeeds;
61 }
62
63 loadConfigs(basePath) {
64 // Dynamically import the config objects
65 console.log('loading config files', basePath);
66 const files = fs.readdirSync(basePath, {withFileTypes: true});
67
68 for (const file of files) {
69 const filePath = path.join(basePath, file.name);
70 if (file.isFile && file.name === 'config.json') {
71 this.feedConfigs.set(basePath.replace(this.rootConfigPath, ''), require(filePath));
72 continue;
73 }
74 if (file.isDirectory) {
75 this.loadConfigs(filePath);
76 }
77 }
78 }
79
80 async fetchFeeds() {
81 const feeds = this.feedConfigs;
82 const feedList = Array.from(feeds.values());
83
84 // Build up a simple graph of feeds so we know which order to boot them.
85 // If feed A depends on feed B, then we should boot B first.
86 const dg = new DepGraph();
87 // Our list of servers that we host. We care about these because they need to boot in correct order.
88 const hostedOrigins = [];
89
90 for (const config of feedList) {
91 hostedOrigins.push(config.feedUrl);
92 dg.addNode(config.feedUrl);
93
94 const feeds = config.columns.map(column => column.feedUrl);
95 feeds.forEach(feed => {
96 dg.addNode(feed);
97 dg.addDependency(config.feedUrl, feed);
98 });
99 }
100
101 // After the graph is loaded, ensure config data is attached.
102 // The first time a feed is added to the graph it might not have data.
103 for (const config of feedList) {
104 dg.setNodeData(config.feedUrl, config);
105 }
106
107 // Get the list of feeds in the order we should start them up.
108 const orderedConfigs = [];
109 try {
110 const orderedFeedList = dg.overallOrder().filter(feed => hostedOrigins.indexOf(feed) >= 0);
111 orderedFeedList.forEach(feedUrl => orderedConfigs.push(dg.getNodeData(feedUrl)));
112 } catch (err) {
113 if (err instanceof DepGraphCycleError) {
114 console.error(`Unable to start server, cyclic dependencies found in feed configuration: ${err}`);
115 process.exit(-1);
116 }
117 }
118
119 const success = (streamInfo) => {
120 console.log(`Fetched feed: ${streamInfo.url}`);
121 if ((streamInfo.url in cacheStorage) == false) {
122 cacheStorage[streamInfo.url] = streamInfo.stream;
123 }
124 };
125
126 // Required for closure.
127 const combine = (config, feedConfig) => {
128 return RSSCombiner(feedConfig).then(combinedFeed => {
129 console.log(`${config.origin} Feed Ready`, Date.now());
130 const feedXml = combinedFeed.xml();
131 cacheStorage[config.feedUrl] = feedXml;
132 this.latestFeeds[config.feedUrl] = feedXml;
133 }).catch(err => {
134 console.log(`Error when fetching feeds for ${config.feedUrl}, ${err}`);
135 });
136 };
137
138 for (const config of orderedConfigs) {
139 console.log(`${config.origin} Checking Feeds`, Date.now());
140
141 const feedConfig = {
142 title: config.title,
143 size: 100,
144 feeds: config.columns.map(column => column.feedUrl),
145 generator: config.origin,
146 site_url: config.origin,
147 softFail: true,
148 custom_namespaces: {
149 'content': 'http://purl.org/rss/1.0/modules/content/',
150 'dc': 'http://purl.org/dc/elements/1.1/',
151 'a10': 'http://www.w3.org/2005/Atom',
152 'feedburner': 'http://rssnamespace.org/feedburner/ext/1.0'
153 },
154 pubDate: new Date(),
155 successfulFetchCallback: success
156 };
157
158 feedConfig.pubDate = new Date();
159
160 try {
161 await combine(config, feedConfig);
162 }
163 catch (err) {
164 console.log(err);
165 }
166 }
167 }
168}
169
170
171class Server {
172 constructor(configPath, feedFetcher) {
173 this.configPath = configPath;
174 this.feeds = feedFetcher;
175 this.assetPathBase = configPath.assetPathBase;
176 this.overridePathBase = configPath.overridePathBase || this.assetPathBase;
177 this.assetPath = `${configPath.assetPathBase}/${paths.assetPath}`;
178 this.dataPath = `${configPath.dataPath}/`;
179 }
180
181 _resolveAssets(filePath, {defaultBase, overridePathBase}) {
182 const overridePath = path.join(overridePathBase, paths.assetPath, filePath);
183 const defaultPath = path.join(defaultBase, paths.assetPath, filePath);
184 return fs.existsSync(overridePath) ? overridePath : defaultPath;
185 }
186
187 getPathName(pathName = '') {
188 pathName = pathName.replace(/\/$/, '');
189 if (this.feeds.feedConfigs.has(pathName) === true) {
190 return pathName;
191 }
192
193 if (pathName === '') {
194 return pathName;
195 }
196
197 return undefined;
198 }
199
200 start(port) {
201 const assetPaths = {overridePathBase: this.overridePathBase, defaultBase: this.assetPathBase};
202 const templates = {
203 head: getCompiledTemplate(this._resolveAssets('templates/head.html', assetPaths)),
204 allStyle: getCompiledTemplate(this._resolveAssets('templates/all-styles.html', assetPaths)),
205 columnsStyle: getCompiledTemplate(this._resolveAssets('templates/columns-styles.html', assetPaths)),
206 column: getCompiledTemplate(this._resolveAssets('templates/column.html', assetPaths)),
207 columns: getCompiledTemplate(this._resolveAssets('templates/columns.html', assetPaths)),
208 item: getCompiledTemplate(this._resolveAssets('templates/item.html', assetPaths)),
209 manifest: getCompiledTemplate(`${this.assetPath}templates/manifest.json`)
210 };
211
212 const app = express();
213 app.use(compression({
214 filter: (req, res) => true
215 }));
216
217 const overridePathBase = path.join(this.overridePathBase, 'public');
218 const assetPathBase = path.join(this.assetPathBase, 'public');
219 if (fs.existsSync(overridePathBase)) {
220 console.log('Exposing overridePathBase Static', `${overridePathBase}`);
221 app.use(express.static(overridePathBase));
222 }
223 app.use(express.static(assetPathBase));
224 console.log('Exposing assetPathBases Static', assetPathBase);
225
226 app.set('trust proxy', true);
227
228 app.all('*', (req, res, next) => {
229 // protocol check, if http, redirect to https
230 const forwarded = req.get('X-Forwarded-Proto');
231 const hostname = req.hostname;
232
233 if (forwarded && forwarded.indexOf('https') == 0 || hostname === '127.0.0.1') {
234 res.setHeader('Access-Control-Allow-Origin', '*');
235 return next();
236 } else {
237 res.redirect('https://' + hostname + req.url);
238 return;
239 }
240 });
241
242 app.get('/proxy', (req, res) => {
243 const pathName = this.getPathName(req.params.path);
244
245 console.log('/proxy', pathName);
246
247 // Get the base config file.
248 const hostname = this.feeds.feedConfigs.get(this.configPath);
249
250 const url = new URL(`${req.protocol}://${hostname}${req.originalUrl}`);
251
252 proxy(url, {
253 dataPath: path.join(this.dataPath, pathName),
254 assetPath: paths.assetPath
255 }, templates).then(response => {
256 if (!!response == false) {
257 return res.status(500).send(`Response undefined Error ${url}`);
258 }
259
260 if (typeof(response.body) === 'string') {
261 res.status(response.status).send(response.body);
262 } else {
263 node.sendStream(response.body, true, res);
264 }
265 });
266 });
267
268 app.get('/:path(*)?/all', (req, res) => {
269 const pathName = this.getPathName(req.params.path);
270 console.log('/:path(*)?/all', pathName);
271
272 const nonce = {
273 analytics: generator(),
274 inlinedcss: generator(),
275 style: generator()
276 };
277
278 res.setHeader('Content-Type', 'text/html');
279 res.setHeader('Content-Security-Policy', generateCSPPolicy(nonce));
280 res.setHeader('Link', preload);
281
282 all(nonce, {
283 dataPath: path.join(this.dataPath, `${pathName}`),
284 assetPath: __dirname + paths.assetPath
285 }, templates).then(response => {
286 if (!!response == false) {
287 console.error(req, path);
288 return res.status(500).send(`Response undefined Error ${path}`);
289 }
290 node.responseToExpressStream(res, response.body);
291 });
292 });
293
294 app.get('/:path(*)?/manifest.json', (req, res, next) => {
295 const pathName = this.getPathName(req.params.path);
296 if (pathName === undefined) return res.status(404);
297
298 console.log('/:path(*)?/manifest.json', pathName);
299
300 res.setHeader('Content-Type', 'application/manifest+json');
301
302 manifest({
303 dataPath: path.join(this.dataPath, pathName),
304 assetPath: paths.assetPath
305 }, templates).then(response => {
306 node.responseToExpressStream(res, response.body);
307 });
308 });
309
310 /*
311 Server specific routes
312 */
313
314 app.get('/:path(*)?/all.rss', (req, res) => {
315 const pathName = this.getPathName(req.params.path);
316 if (pathName === undefined) return res.status(404);
317
318 console.log('/:path(*)?/all.rss', pathName);
319 res.setHeader('Content-Type', 'text/xml');
320 res.send(this.feeds.feeds[req.protocol + '://' + req.get('host') + req.originalUrl]);
321 });
322
323 app.get('/:path(*)?/data/config.json', (req, res) => {
324 const pathName = this.getPathName(req.params.path);
325 if (pathName === undefined) return res.status(404);
326
327 console.log('/:path(*)?/data/config.json', pathName);
328 res.setHeader('Content-Type', 'application/json');
329 res.sendFile(path.join(this.dataPath, pathName, 'config.json'));
330 });
331
332 app.get('/:path(*)?/', (req, res) => {
333 const pathName = this.getPathName(req.params.path);
334 console.log('/:path(*)?/', pathName);
335 if (pathName === undefined) return res.status(404);
336
337 const nonce = {
338 analytics: generator(),
339 inlinedcss: generator(),
340 style: generator()
341 };
342
343 res.setHeader('Content-Type', 'text/html');
344 res.setHeader('Content-Security-Policy', generateCSPPolicy(nonce));
345 res.setHeader('Link', preload);
346 root(nonce, {
347 dataPath: path.join(this.dataPath, pathName),
348 assetPath: paths.assetPath
349 }, templates).then(response => {
350 if (!!response == false) {
351 console.error(req, path);
352 return res.status(500).send(`Response undefined Error ${path}`);
353 }
354 node.responseToExpressStream(res, response.body);
355 });
356 });
357
358 /*
359 Start the app.
360 */
361 app.listen(port);
362 this.feeds.fetchFeeds();
363 setInterval(this.feeds.fetchFeeds.bind(this.feeds), this.feeds.fetchInterval);
364 }
365}
366
367if (typeof process === 'object') {
368 process.on('unhandledRejection', (error, promise) => {
369 console.error('== Node detected an unhandled rejection! ==');
370 console.error(error.stack);
371 });
372}
373
374module.exports = {
375 Server: Server,
376 FeedFetcher: FeedFetcher
377};