1 | import * as _ from "lodash";
|
2 | import * as cookie from "cookie";
|
3 | import * as fs from "fs";
|
4 | import * as moment from "moment";
|
5 | import * as OS from "os";
|
6 | import * as request from "request";
|
7 | import * as Debug from "debug";
|
8 | import * as url_parser from "url";
|
9 |
|
10 | import {SeoMiddleware, SeoOptions} from "./seo";
|
11 | import {ExtendedRequest, ExtendedResponse, getLocaleByURL, getLink, SiteMeta, KeywordMapper} from "./common";
|
12 |
|
13 | const debug = Debug("bablic:seo");
|
14 |
|
15 | const BABLIC_ROOT = "https://www.bablic.com";
|
16 |
|
17 | function escapeRegex(str: string): string {
|
18 | return str.replace(/([.?+^$[\]\\(){}|-])/g, "\\$1");
|
19 | }
|
20 |
|
21 | export interface BablicOptions {
|
22 | siteId: string;
|
23 | rootUrl?: string;
|
24 | locale?: string;
|
25 | subDir?: boolean;
|
26 | subDirBase?: string;
|
27 | subDirOptional?: boolean;
|
28 | onReady?: () => void;
|
29 | seo?: SeoOptions;
|
30 | folders?: {
|
31 | [locale: string]: string,
|
32 | };
|
33 | meta?: SiteMeta;
|
34 | snippet?: string;
|
35 | keywords?: {
|
36 | [urlKeyword: string]: {
|
37 | [locale: string]: string,
|
38 | },
|
39 | };
|
40 | }
|
41 |
|
42 | export interface SiteData {
|
43 | id?: string;
|
44 | error?: string;
|
45 | snippet: string;
|
46 | meta: SiteMeta;
|
47 | keywords?: {
|
48 | [urlKeyword: string]: {
|
49 | [locale: string]: string,
|
50 | },
|
51 | };
|
52 | }
|
53 |
|
54 | const Defaults: BablicOptions = {
|
55 | siteId: null,
|
56 | rootUrl: null,
|
57 | locale: null,
|
58 | subDir: false,
|
59 | subDirBase: "",
|
60 | subDirOptional: false,
|
61 | onReady: null,
|
62 | seo: {
|
63 | useCache: true,
|
64 | defaultCache: [],
|
65 | test: false,
|
66 | },
|
67 | folders: null,
|
68 | };
|
69 |
|
70 | const BackwardCompOptions = {
|
71 | siteId: ["site_id"],
|
72 | rootUrl: ["root_url"],
|
73 | subDir: ["subdir", "sub_dir"],
|
74 | subDirBase: ["subdir_base"],
|
75 | subDirOptional: ["subdir_optional"],
|
76 | };
|
77 | export const BackwardCompSEOOptions = {
|
78 | useCache: ["use_cache"],
|
79 | defaultCache: ["default_cache"],
|
80 | };
|
81 |
|
82 | export class BablicMiddleware {
|
83 | public meta: SiteMeta = null;
|
84 | public snippet = "";
|
85 | private options: BablicOptions;
|
86 | private LOCALE_REGEX: RegExp;
|
87 | private seoMiddleware: (meta: SiteMeta, keywordsByLocale: KeywordMapper, reverseKeywordByLocale: KeywordMapper, req: ExtendedRequest, res: ExtendedResponse, next: () => void) => void;
|
88 |
|
89 | private keywordsByLocale: KeywordMapper = null;
|
90 | private reverseKeywordByLocale: KeywordMapper = null;
|
91 | constructor(options: BablicOptions) {
|
92 | let generalOptions = options as any;
|
93 | for (let key in BackwardCompOptions) {
|
94 | if (!options[key]) {
|
95 | BackwardCompOptions[key].forEach((alt) => {
|
96 | if (generalOptions[alt]) {
|
97 | generalOptions[key] = generalOptions[alt];
|
98 | }
|
99 | });
|
100 | }
|
101 | }
|
102 | if (options.seo) {
|
103 | for (let key in BackwardCompSEOOptions) {
|
104 | if (!options.seo[key]) {
|
105 | BackwardCompSEOOptions[key].forEach((alt) => {
|
106 | if (generalOptions.seo[alt]) {
|
107 | generalOptions.seo[key] = generalOptions.seo[alt];
|
108 | }
|
109 | });
|
110 | }
|
111 | }
|
112 | }
|
113 |
|
114 | if (!options.siteId) {
|
115 | throw new Error("Middleware requires and site_id");
|
116 | }
|
117 |
|
118 | this.options = _.defaultsDeep(options, Defaults);
|
119 | let seoHandler = new SeoMiddleware(this.options.siteId, this.options.seo, {subDir: this.options.subDir, subDirBase: this.options.subDirBase, subDirOptional: this.options.subDirOptional});
|
120 | this.seoMiddleware = seoHandler.middleware();
|
121 |
|
122 | if (this.options.meta) {
|
123 | this.meta = this.options.meta;
|
124 | this.processKeywords(this.options.keywords);
|
125 | }
|
126 | if (this.options.snippet) {
|
127 | this.snippet = this.options.snippet;
|
128 | }
|
129 |
|
130 | if (this.meta && this.snippet) {
|
131 | if (this.options.onReady) {
|
132 | this.options.onReady();
|
133 | }
|
134 | return;
|
135 | }
|
136 | this.loadSiteMeta(() => {
|
137 | if (this.options.onReady) {
|
138 | this.options.onReady();
|
139 | }
|
140 | });
|
141 | }
|
142 | public getSiteMeta(cbk: (e?: Error) => void) {
|
143 | debug("getting from bablic");
|
144 | request({
|
145 | method: "GET",
|
146 | url: `${BABLIC_ROOT}/api/v1/site/${this.options.siteId}?channel_id=node`,
|
147 | }, (error, response, body) => {
|
148 | if (error) {
|
149 | return cbk(error);
|
150 | }
|
151 | if (!body) {
|
152 | return cbk(new Error("empty response"));
|
153 | }
|
154 |
|
155 | try {
|
156 | let data: SiteData = JSON.parse(body);
|
157 | debug("data:", data);
|
158 | this.saveSiteMeta(data);
|
159 | cbk();
|
160 | } catch (e) {
|
161 | debug(e);
|
162 | }
|
163 | });
|
164 | }
|
165 | public saveSiteMeta(data: SiteData) {
|
166 | let {snippet, meta} = data;
|
167 | this.snippet = snippet;
|
168 | this.meta = meta;
|
169 | this.processKeywords(data.keywords);
|
170 | this.LOCALE_REGEX = null;
|
171 | data.id = this.options.siteId;
|
172 | fs.writeFile(this.snippetUrl(), JSON.stringify(data), (error) => {
|
173 | if (error) {
|
174 | console.error("Error saving snippet to cache", error);
|
175 | }
|
176 | });
|
177 | }
|
178 | public snippetUrl() {
|
179 | return `${OS.tmpdir()}/snippet.${this.options.siteId}`;
|
180 | }
|
181 | public getLocale(req: ExtendedRequest): string {
|
182 | if (req.headers["bablic-locale"]) {
|
183 | return req.headers["bablic-locale"] as string;
|
184 | }
|
185 |
|
186 | let auto = this.meta.autoDetect;
|
187 | let defaultLocale = this.meta.default;
|
188 | let customUrls = this.meta.customUrls;
|
189 | let localeKeys = this.meta.localeKeys.slice();
|
190 | localeKeys.push(this.meta.original);
|
191 | let localeDetection = this.meta.localeDetection;
|
192 | if (this.options.subDir) {
|
193 | localeDetection = "subdir";
|
194 | }
|
195 | return getLocaleByURL(
|
196 | url_parser.parse(getCurrentUrl(req)),
|
197 | localeDetection,
|
198 | customUrls,
|
199 | detectLocaleFromCookie(req, this.meta),
|
200 | defaultLocale,
|
201 | auto ? detectLocaleFromHeader(req) : "",
|
202 | false,
|
203 | this.options.locale,
|
204 | this.options.subDirBase,
|
205 | this.options.folders,
|
206 | localeKeys,
|
207 | );
|
208 |
|
209 | }
|
210 | public loadSiteMeta(cbk: (e?: Error) => void) {
|
211 | debug("loading meta from file");
|
212 | fs.readFile(this.snippetUrl(), (error, data) => {
|
213 | if (error) {
|
214 | debug("no local file, getting from server");
|
215 | return this.getSiteMeta(cbk);
|
216 | }
|
217 |
|
218 | debug("reading from temp file");
|
219 | try {
|
220 | let object: SiteData = JSON.parse(data.toString("utf8"));
|
221 | if (object.id != this.options.siteId || object.error) {
|
222 | debug("not of this site id");
|
223 | return this.getSiteMeta(cbk);
|
224 | }
|
225 | this.meta = object.meta;
|
226 | this.snippet = object.snippet;
|
227 | this.processKeywords(object.keywords);
|
228 | cbk();
|
229 | } catch (e) {
|
230 | debug(e);
|
231 | return this.getSiteMeta(cbk);
|
232 | }
|
233 |
|
234 | debug("checking snippet time");
|
235 | fs.stat(this.snippetUrl(), (e, stats) => {
|
236 | if (e) {
|
237 | return cbk();
|
238 | }
|
239 | let last_modified = moment(stats.mtime.getTime());
|
240 | if (last_modified > moment().subtract(4, "hours")) {
|
241 | return debug("snippet cache is good");
|
242 | }
|
243 | debug("refresh snippet");
|
244 | this.getSiteMeta(() => debug("refreshed snippet"));
|
245 | });
|
246 | });
|
247 | }
|
248 | public handleBablicCallback(req: ExtendedRequest, res: ExtendedResponse) {
|
249 | this.getSiteMeta(() => debug("site snippet refreshed"));
|
250 | res.end("OK");
|
251 | }
|
252 | public getLink(locale: string, url: string): string {
|
253 | let parsed = url_parser.parse(url);
|
254 | return getLink(locale, parsed, this.meta, this.options);
|
255 | }
|
256 | public altTags(url: string, locale: string) {
|
257 | let locales = this.meta.localeKeys || [];
|
258 | let tags = _(locales)
|
259 | .concat([this.meta.original])
|
260 | .without(locale)
|
261 | .map((l: string) => `<link rel="alternate" href="${this.getLink(l, url)}" hreflang="${l == this.meta.original ? "x-default" : l}">`)
|
262 | .valueOf() as string[];
|
263 | return tags.join("");
|
264 | }
|
265 | private generateOriginalPath(url: string, locale: string): string {
|
266 | let urlParts = url.split("?");
|
267 | let pathname = urlParts[0];
|
268 | let reversed = this.reverseKeywordByLocale[locale];
|
269 | let original = pathname.split("/").map((p) => reversed[p] || p).join("/");
|
270 | if (original != pathname) {
|
271 | urlParts[0] = original;
|
272 | return urlParts.join("?");
|
273 | } else {
|
274 | return null;
|
275 | }
|
276 | }
|
277 | private generateTranslatedPath(url: string, locale: string): string {
|
278 | let urlParts = url.split("?");
|
279 | let pathname = urlParts[0];
|
280 | let proper = this.keywordsByLocale[locale];
|
281 | let translated = pathname.split("/").map((p) => proper[p] || p).join("/");
|
282 | if (translated != pathname) {
|
283 | urlParts[0] = translated;
|
284 | return urlParts.join("?");
|
285 | } else {
|
286 | return null;
|
287 | }
|
288 | }
|
289 |
|
290 | public handle(req: ExtendedRequest, res: ExtendedResponse, next: () => void) {
|
291 | if (!req.originalUrl) {
|
292 | req.originalUrl = req.url;
|
293 | }
|
294 | if ((req.originalUrl == "/_bablicCallback" && req.method == "POST") || req.headers["x-bablic-refresh"]) {
|
295 | debug("Redirecting to Bablic callback");
|
296 | return this.handleBablicCallback(req, res);
|
297 | }
|
298 | res.setHeader("x-bablic-id", this.options.siteId);
|
299 | if (!this.LOCALE_REGEX && this.options.subDir && this.meta && this.meta.localeKeys) {
|
300 | this.LOCALE_REGEX = RegExp("^(?:" + escapeRegex(this.options.subDirBase) + ")?\\/(" + this.meta.localeKeys.join("|") + ")\\b");
|
301 | }
|
302 | if (!this.meta) {
|
303 | debug("not loaded yet", req.originalUrl);
|
304 | req.bablic = {
|
305 | locale: "",
|
306 | };
|
307 | extendResponseLocals(res, {
|
308 | bablic: {
|
309 | locale: "",
|
310 | snippet: "",
|
311 | snippetBottom: "<!-- Bablic Footer OFF -->",
|
312 | snippetTop: "<!-- Bablic Head OFF -->",
|
313 | },
|
314 | });
|
315 |
|
316 | return next();
|
317 | }
|
318 |
|
319 | let locale = this.getLocale(req);
|
320 |
|
321 | req.bablic = {
|
322 | locale,
|
323 | proxied: false,
|
324 | };
|
325 |
|
326 | let _snippet = this.snippet;
|
327 |
|
328 | if (this.options.subDir && this.LOCALE_REGEX) {
|
329 | req.url = req.url.replace(this.LOCALE_REGEX, "");
|
330 | req.originalUrl = req.originalUrl.replace(this.LOCALE_REGEX, "");
|
331 | _snippet = `<script type="text/javascript">var bablic=bablic||{};bablic.localeURL="subdir";bablic.subDirBase="${this.options.subDirBase}";bablic.subDirOptional=${!!this.options.subDirOptional};</script>` + _snippet;
|
332 | }
|
333 |
|
334 | if (this.reverseKeywordByLocale && this.reverseKeywordByLocale[locale]) {
|
335 | let original = this.generateOriginalPath(req.url, locale);
|
336 |
|
337 | if (original) {
|
338 | req.url = original;
|
339 | req.originalUrl = this.generateOriginalPath(req.originalUrl, locale) || req.originalUrl;
|
340 | } else {
|
341 |
|
342 | let translated = this.generateTranslatedPath(req.originalUrl, locale);
|
343 | if (translated) {
|
344 | res.writeHead(301, {location: translated});
|
345 | return res.end();
|
346 | }
|
347 |
|
348 | }
|
349 | }
|
350 |
|
351 | if (this.meta.original == locale) {
|
352 | _snippet = _snippet.replace("<script", "<script async");
|
353 | }
|
354 |
|
355 | extendResponseLocals(res, {
|
356 | bablic: {
|
357 | locale,
|
358 | snippet: _snippet,
|
359 | snippetBottom: "",
|
360 | snippetTop: "<!-- start Bablic Head -->" + this.altTags(req.originalUrl, locale) + _snippet + "<!-- start Bablic Head -->",
|
361 | },
|
362 | });
|
363 |
|
364 | if (!this.seoMiddleware) {
|
365 | return next();
|
366 | }
|
367 |
|
368 | if (locale == this.meta.original) {
|
369 | debug("ignored same language", req.url);
|
370 | return next();
|
371 | }
|
372 | return this.seoMiddleware(this.meta, this.keywordsByLocale, this.reverseKeywordByLocale, req, res, next);
|
373 | }
|
374 |
|
375 | private processKeywords(keywords: {[keyword: string]: {[locale: string]: string}}) {
|
376 | if (!keywords) {
|
377 | return;
|
378 | }
|
379 |
|
380 | this.keywordsByLocale = {};
|
381 | this.reverseKeywordByLocale = {};
|
382 | this.meta.localeKeys.forEach((locale) => {
|
383 | let proper = {};
|
384 | let reverse = {};
|
385 | for (let keyword in keywords) {
|
386 | if (!keywords[keyword][locale]) {
|
387 | continue;
|
388 | }
|
389 | proper[keyword] = keywords[keyword][locale];
|
390 | reverse[keywords[keyword][locale]] = keyword;
|
391 | }
|
392 | this.keywordsByLocale[locale] = proper;
|
393 | this.reverseKeywordByLocale[locale] = reverse;
|
394 | });
|
395 | }
|
396 | }
|
397 |
|
398 | function extendResponseLocals(res: ExtendedResponse, context: {}) {
|
399 | if (typeof(res.locals) == "function") {
|
400 | res.locals(context);
|
401 | } else if (res.locals) {
|
402 | _.extend(res.locals, context);
|
403 | } else {
|
404 | res.locals = context;
|
405 | }
|
406 | }
|
407 |
|
408 | function detectLocaleFromHeader(req: ExtendedRequest): string {
|
409 | let header = req.headers["accept-language"];
|
410 | if (!header) {
|
411 | return "";
|
412 | }
|
413 | let langs = (header as string).split(",");
|
414 | if (langs.length > 0) {
|
415 | return langs[0].replace("-", "_");
|
416 | }
|
417 | return "";
|
418 | }
|
419 |
|
420 | function detectLocaleFromCookie(req: ExtendedRequest, meta: SiteMeta) {
|
421 | let cookieHeader = req.headers.cookie;
|
422 | if (!cookieHeader) {
|
423 | return "";
|
424 | }
|
425 | if (!meta.localeKeys) {
|
426 | return "";
|
427 | }
|
428 | let cookies = (req as any).cookies || cookie.parse(cookieHeader as string);
|
429 | if (!cookies) {
|
430 | return "";
|
431 | }
|
432 | let bablicCookie = cookies.bab_locale;
|
433 | if (!bablicCookie) {
|
434 | return "";
|
435 | }
|
436 | let index = meta.localeKeys.indexOf(bablicCookie);
|
437 | if (index > -1) {
|
438 | return bablicCookie;
|
439 | }
|
440 |
|
441 | let partialFound = _.find(meta.localeKeys, (l) => l[0] == bablicCookie[0] && l[1] == bablicCookie[1]);
|
442 | return partialFound || "";
|
443 | }
|
444 |
|
445 | function getCurrentUrl(req){
|
446 | return `http://${req.headers.host}${req.originalUrl}`;
|
447 | }
|
448 |
|
449 |
|
450 |
|