Commit ec05fa46 authored by Matija Obreza's avatar Matija Obreza
Browse files

Merge branch '3-entry-page' into 'master'

Resolve web application with expressjs

Closes #3

See merge request grin-global/grin-global-ui!3
parents e20d79da 01171422
import languages from '@gringlobal/i18n/data/Languages';
export default function detectLocaleFromPath(virtualPath: string, path: string, index: number) {
if (virtualPath && path.startsWith(virtualPath)) {
path = path.substring(virtualPath.length);
}
let found = 'en';
const language = path.match(/\/([a-zA-Z-]*)/g);
if (language instanceof Array) {
const foundLang = language[index].replace('/', '');
languages.some((item, i, arr) => {
if (item.short === foundLang) {
found = foundLang;
return true;
}
});
}
return found;
}
import { END } from 'redux-saga';
export default function fetchComponentData(dispatch, branch, search, state = null) {
const promisesWithProps = [];
branch.map(({ route, match }) => {
const component = route.component;
if (!component || !component.needs) {
return;
}
const ownProps = { route, match };
// console.log('SSR need with arguments (state, ownProps, search)=', '..snip..'); // , ownProps, search);
// Provide params to static needs
component.needs.filter((need) => need({ state, ownProps, search, params: match.params })).map((need) => {
return dispatch(need({ state, ownProps, search, params: match.params }));
})
// a nice array of Promises
.forEach((promise) => promisesWithProps.push(promise));
});
return Promise.all(promisesWithProps).then(() => dispatch(END));
}
import * as proxy from 'express-http-proxy';
import config from 'server/config';
import refreshAuthToken from 'server/middleware/refreshAuthToken';
const httpProxy = proxy(config.apiUrl, {
parseReqBody: false,
timeout: config.apiTimeout,
filter: (req, res) => {
if (req.url.startsWith('/oauth/') || req.url.startsWith('/token') || req.url.startsWith('/google')) {
// console.log('Will proxy /oauth');
if (req.query.grant_type === 'refresh_token' && !req.query.refresh_token) {
refreshAuthToken(req, res);
return true;
}
return true;
}
// Not proxying the request
console.log(`Not proxying ${req.url}`);
return false;
},
proxyReqPathResolver: (req) => {
let path = req.url;
if (path.startsWith('/oauth/token')) {
const grantType = req.query.grant_type;
if (grantType === 'client_credentials' || grantType === 'password' || grantType === 'refresh_token') {
path = `${path}&client_id=${config.clientId}&client_secret=${config.clientSecret}`;
}
} else if (path.startsWith('/google/verify-token')) {
path = `${path}&clientId=${config.clientId}`;
}
console.log(`HTTP proxy to ${config.apiUrl}${path}`);
return path;
},
});
export default httpProxy;
import * as React from 'react';
import { renderToString } from 'react-dom/server';
import Loadable from 'react-loadable';
import { getBundles } from 'react-loadable/webpack';
import { createMemoryHistory } from 'history';
import { StaticRouter } from 'react-router-dom';
import { Provider as ReduxProvider } from 'react-redux';
import * as serialize from 'serialize-javascript';
import { routerMiddleware } from 'connected-react-router';
import { createStore, applyMiddleware } from 'redux';
import { I18nextProvider } from 'react-i18next';
import thunk from 'redux-thunk';
import createSagaMiddleware from 'redux-saga'
import rootReducer from 'core/reducer';
import languages from '@gringlobal/i18n/data/Languages';
import { SheetsRegistry } from 'jss';
import { MuiThemeProvider, StylesProvider, createGenerateClassName } from '@material-ui/core/styles';
import theme from 'core/ui/theme';
// import checkAuthToken from './checkAuthToken';
import { routes } from 'core/ui/routes';
import matchRoutes from '@gringlobal/client/ui/matchRoutes';
import renderRoutes from '@gringlobal/client/ui/renderRoutes';
import fetchComponentData from 'server/middleware/fetchComponentData';
import detectLocaleFromPath from 'server/middleware/detectLocaleFromPath';
import getDir from 'server/middleware/detectDirection';
import config from 'server/config';
import checkAuthToken from 'server/middleware/checkAuthToken';
import { configure, receiveLang } from '@gringlobal/client/action/applicationConfig';
import ApiError from '@gringlobal/client/model/common/ApiError';
import sagas from 'core/action/saga';
// react-loadable webpack stats
import * as path from 'path';
import { readFileSync } from 'fs';
const reactLoadableStats = JSON.parse(readFileSync(path.join('./', 'react-loadable.json'), { encoding: 'utf8' }));
// console.log(`react-loadable stats: `, reactLoadableStats);
const prerenderer = (html, errHtml) => (req, res) => {
console.log('Init prerenderer, request url:', req.url);
const startTime = Date.now();
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Headers', 'X-Requested-With');
const his = createMemoryHistory();
const sagaMiddleware = createSagaMiddleware();
const store = createStore(rootReducer(), {} as any, applyMiddleware(thunk, sagaMiddleware, routerMiddleware(his)));
// Only send public data to client
store.dispatch(configure({ frontendPath: config.frontendPath, apiUrl: config.apiUrl, googleClientId: config.googleClientId, clientId: config.clientId, anonToken: config.access_token }));
const sagaReady = sagaMiddleware.run(sagas);
console.log('Processing request', req._parsedOriginalUrl);
const pathname = req._parsedOriginalUrl.pathname;
const search = req._parsedOriginalUrl.search;
const context = {};
function setLocale() {
const locale = detectLocaleFromPath(config.frontendPath, pathname, 0);
console.log('Detected locale for SSR is', locale);
req.i18n.changeLanguage(locale);
}
function renderView() {
// const jss = createJss(jssPreset());
const sheets = new SheetsRegistry();
const generateClassName = createGenerateClassName();
const initialLanguage = req.i18n.language;
const initialI18nStore = {};
const defaultLanguage = 'en';
initialI18nStore[initialLanguage] = req.i18n.services.resourceStore.data[initialLanguage];
if (initialLanguage !== defaultLanguage) {
// So that we have missing translations
initialI18nStore[defaultLanguage] = req.i18n.services.resourceStore.data[defaultLanguage];
}
const basename = initialLanguage !== defaultLanguage ? `${config.frontendPath}/${initialLanguage}` : `${config.frontendPath}`;
const pathWithoutLang = req.url.substr(initialLanguage !== defaultLanguage ? 3 : 0, req.url.length);
console.log(`<StaticRouter location="${pathWithoutLang}" basename="${basename}"`);
const direction = getDir(initialLanguage);
const modules = [];
const InitialView = (
<Loadable.Capture report={ (moduleName) => modules.push(moduleName) }>
<ReduxProvider store={ store }>
<StylesProvider generateClassName={ generateClassName } sheetsRegistry={ sheets }>
<MuiThemeProvider theme={ theme(direction) }>
<I18nextProvider i18n={ req.i18n }>
<StaticRouter location={ pathWithoutLang } context={ context } basename={ basename }>
{ renderRoutes(routes) }
</StaticRouter>
</I18nextProvider>
</MuiThemeProvider>
</StylesProvider>
</ReduxProvider>
</Loadable.Capture>
);
const componentHTML = renderToString(InitialView);
const initialState = store.getState();
const titleState = store.getState().pageTitle.title;
// console.log('react-loadable modules', modules);
const bundles = getBundles(reactLoadableStats, modules);
// console.log(`react-loadable bundles`, bundles);
const bundleStyles = bundles.filter((bundle) => bundle.file.endsWith('.css'));
// console.log(`react-loadable bundleStyles`, bundleStyles);
const bundleScripts = bundles.filter((bundle) => bundle.file.endsWith('.js'));
// console.log(`react-loadable bundleScripts`, bundleScripts);
if (process.env.DEBUG) {
console.log('componentHTML:', componentHTML);
console.log('initialState:', initialState);
}
const languageLinks = languages.map((lang) => {
if (lang.short === 'en') {
return `<link rel="alternate" hreflang="${lang.short}" href="${req.protocol + '://' + req.get('host')}${req.url}" />`;
} else {
return `<link rel="alternate" hreflang="${lang.short}" href="${req.protocol + '://' + req.get('host')}/${lang.short}${req.url}" />`;
}
});
// HTML meta for activity posts
return html.replace(/SERVER_RENDERED_(CSS|STATE|HTML|TITLE|I18NSTORE|DIR|LANG|HEADLINKS|BUNDLECSS|BUNDLESCRIPTS)/g, (match, x) => {
console.log(`Injecting ${match}`);
switch (match) {
case 'SERVER_RENDERED_BUNDLECSS': return bundleStyles.map((style) => {
return `<link href="${style.file}" rel="stylesheet"/>`;
}).join('\n');
case 'SERVER_RENDERED_BUNDLESCRIPTS': return bundleScripts.map((bundle) => {
return `<script src="${bundle.file}"></script>`;
// alternatively if you are using publicPath option in webpack config
// you can use the publicPath value from bundle, e.g:
// return `<script src="${bundle.publicPath}"></script>`
}).join('\n');
case 'SERVER_RENDERED_CSS': return sheets.toString();
case 'SERVER_RENDERED_STATE': return serialize(initialState, { isJSON: true });
case 'SERVER_RENDERED_HTML': return componentHTML;
case 'SERVER_RENDERED_TITLE': return titleState;
case 'SERVER_RENDERED_I18NSTORE': return serialize(initialI18nStore, { isJSON: true });
case 'SERVER_RENDERED_DIR': return serialize(getDir(initialLanguage), { isJSON: false });
case 'SERVER_RENDERED_LANG': return serialize(initialLanguage, { isJSON: false });
case 'SERVER_RENDERED_HEADLINKS': return languageLinks.join('');
default: console.log(`Unrecognized variable in ssr-template.html: ${match}`); return '';
}
});
}
setLocale();
checkAuthToken(req, res, store.dispatch).then(() => {
const language = req.i18n.language;
const pathWithoutLang = pathname.substr(language !== 'en' ? 3 : 0, pathname.length);
console.log(`Rendering ${pathWithoutLang} for ${pathname}`);
const branch = matchRoutes(routes, pathWithoutLang);
store.dispatch(receiveLang(language));
fetchComponentData(store.dispatch, branch, search, store.getState())
.catch((err) => {
console.log('Error fetching component data', ApiError.axiosError(err));
res.status(500).end(makeErrorHtml(errHtml, { status: err.code || 500, data: err.message }, true));
// const errFilledHtml = makeErrorHtml(errHtml, err);
// res.status(500).set('Content-Type', 'text/html').send(errFilledHtml);
})
.then(() => {
return sagaReady.toPromise().then(() => {
console.log('Fetched all component data');
return renderView();
});
}).then((html) => {
const serverRenderTime = `${Date.now() - startTime}ms`;
console.log('Server render time:', startTime, Date.now(), serverRenderTime);
const keyStatus = 'status';
const keyUrl = 'url';
const status = context[keyStatus];
const url = context[keyUrl];
if (status && status === 404) {
res.status(404);
}
if (url) {
if (url === '/login') {
res.status(401);
} else {
res.status(302);
}
}
return res.set('Content-Type', 'text/html').send(html);
}).catch((err) => {
console.error('Error:', err);
const errFilledHtml = makeErrorHtml(errHtml, err, false);
res.status(503).set('Content-Type', 'text/html').send(errFilledHtml);
});
}).catch((err) => {
console.error('Error:', err.message);
const errFilledHtml = makeErrorHtml(errHtml, err, true);
res.status(503).set('Content-Type', 'text/html').send(errFilledHtml);
});
};
const makeErrorHtml = (template, error, withExplanation = false) => {
let theFilledHtml = template.replace('ERROR_MESSAGE', error.status).replace('ERROR_DETAILS', error.data);
const explanation = 'The server is currently unavailable (because it is overloaded or down for maintenance). Generally, this is a temporary state.';
theFilledHtml = theFilledHtml.replace('ERROR_EXPLANATION', withExplanation ? explanation : '');
return theFilledHtml;
};
export default prerenderer;
import * as jwt from 'jsonwebtoken';
import config from 'server/config';
const refreshAuthToken = async (req, res) => {
const refreshToken = req.query.refresh_token;
if (!refreshToken) {
return res.json({
...config.access_token,
...jwt.decode(config.access_token),
});
}
};
export default refreshAuthToken;
import config from 'server/config';
const robotsAllow = `User-Agent: *
Allow: /
`;
const robotsDisallow = `User-Agent: *
Disallow: /
`;
const robots = async (req, res) => {
res.header('Cache-Control', 'public, max-age=600');
res.type('text/plain');
res.send(config.allowRobots ? robotsAllow : robotsDisallow);
};
export default robots;
/**
* Express HTTP server configuration
*/
import * as express from 'express';
import * as path from 'path';
import * as cookieParser from 'cookie-parser';
import * as compression from 'compression';
import { readFileSync } from 'fs';
const i18nextMiddleware = require('i18next-express-middleware'); // has no proper import yet
// server
import config from 'server/config';
import robots from 'server/robots';
import sitemap from 'server/sitemap';
// middleware
import prerenderer from 'server/middleware/prerenderer';
import httpProxy from 'server/middleware/httpProxy';
// i18n
import { i18nServer } from '@gringlobal/i18n/i18n-server';
const app = express();
// This reads the ssr-template.html compiled to ssr-compiled.html by webpack
console.log(`Reading SSR template from ${path.join('../assets', 'ssr-compiled.html')}`);
const html = readFileSync(path.join('../assets', 'ssr-compiled.html'), { encoding: 'utf8' }).replace('FRONTEND_PATH', `${config.frontendPath}/`);
const errHtml = readFileSync(path.join('../assets', 'ssr-error.html'), { encoding: 'utf8' }).replace('FRONTEND_PATH', `${config.frontendPath}/`);
// Log all requests
app.use((req, res, next) => {
console.log('Incoming request, url:', req.url);
next();
});
// Redirect to `/` if path is `/welcome`
app.get('/welcome', (req, res) => {
const oldPath = req.url;
console.log(`Redirecting ${oldPath} to ${config.frontendPath}/`);
res.redirect(301, `${config.frontendPath}/`);
});
// Redirect to `/` if path is `/{lang}/welcome`
app.get('/*/welcome', (req, res) => {
const oldPath = req.url;
const lang = oldPath.substr(1, 3);
console.log(`Redirecting ${oldPath} to ${config.frontendPath}/${lang}`);
res.redirect(301, `${config.frontendPath}/${lang}`);
});
// Redirect to `/` if path contains `/en`
app.get('/en/*', (req, res) => {
const oldPath = req.url;
const redirectPath = oldPath.substr(3, oldPath.length);
res.redirect(301, `${config.frontendPath}${redirectPath}`);
});
// Handle sitemap.xml and references files
app.get('/sitemap*.xml', sitemap);
// Enable compression
app.use(compression());
// robots.txt
app.get('/robots.txt', robots);
// Redirect to api
app.use('/api', (req, res) => {
const url = req.url;
const path = req.path;
const method = req.method;
// console.log(url, path, method);
if (method === 'GET') {
res.redirect(301, `${config.apiUrl}/api${path}`);
} else {
res.redirect(307, `${config.apiUrl}/api${url}`);
}
});
// Proxy all requests starting with /proxy
app.use('/proxy', httpProxy);
// Prevent access to manifest.json
app.use(/^\/manifest.json$/, (req, res) => {
res.status(403).set('Content-Type', 'text/plain').send('Access denied.');
});
// Serve Git commithash
app.use(/^\/COMMITHASH$/, express.static(path.join('../assets'), {
etag: true,
maxAge: '0',
index: false,
redirect: false,
immutable: true,
}));
// Serve /locales
app.use('/locales/', express.static(path.join('../assets/locales'), {
etag: true,
maxAge: '31d',
index: false,
redirect: false,
immutable: true,
}));
// Serve static resources (this should be the only thing publicly accessible)
app.use(express.static(path.join('../assets'), {
etag: true,
maxAge: '7d',
index: false,
redirect: false,
immutable: true,
}));
const localeModules = ['express', 'client', 'common'];
const i18nS = i18nServer(localeModules);
// Register i18n
app.use(i18nextMiddleware.handle(i18nS));
// Parse cookies
app.use(cookieParser());
// Relay requests to React
app.use(prerenderer(html, errHtml));
export default app;
import axios from 'axios';
// server
import config from 'server/config';
const sitemap = (req, res) => {
axios({
method: 'get',
url: `${config.apiUrl}${req.path}`,
}).then((response) => {
return res.set('Content-Type', 'text/xml').send(response.data);
}).catch((error) => {
res.status(error.status).end(error.data);
});
};
export default sitemap;
import { put, takeEvery } from 'redux-saga/effects';
// Constants
import {
RECEIVE_COOPERATOR, RECEIVE_COOPERATORS,
SAGA_RECEIVE_COOPERATOR, SAGA_RECEIVE_COOPERATORS,
} from 'cooperator/constants';
// Model
import Cooperator from '@gringlobal/client/model/gringlobal/Cooperator';
import { IPageRequest, FilteredPage } from '@gringlobal/client/model/page';
// Service
import CooperatorService from '@gringlobal/client/service/CooperatorService';
import { dereferenceReferences } from '@gringlobal/client/utilities';
export const cooperatorPublicSagas = [
takeEvery(SAGA_RECEIVE_COOPERATOR, getCooperatorSaga),
takeEvery(SAGA_RECEIVE_COOPERATORS, listCooperatorsSaga),
];
// #getUser
export const getCooperatorAction = (id) => ({
type: SAGA_RECEIVE_COOPERATOR,
payload: {
id,
},
});
function* getCooperatorSaga(action) {
const { id } = action.payload;
yield put({
type: 'API',
target: RECEIVE_COOPERATOR,
method: CooperatorService.getCooperator,
params: [id],
// onSuccess: (cooperator) => receiveCooperatorSuccess(cooperator),
});
}
// #listUsers
export const listCooperatorsAction = (filter: string = '', pageR: IPageRequest = { page: 0 }) => ({
type: SAGA_RECEIVE_COOPERATORS,
payload: {
filter,
pageR,
},
});
function * listCooperatorsSaga(action) {
yield put({
type: 'API',
target: RECEIVE_COOPERATORS,
method: CooperatorService.filter_1,
params: [action.payload.filter, action.payload.pageR],
onSuccess: (cooperators: FilteredPage<Cooperator>) => {
dereferenceReferences(cooperators.content, { _self: 'cooperator', id: [ 'cooperator', 'ownedBy' ] });
// console.log(cooperators.content);
return cooperators;
},
});
}
export const RECEIVE_COOPERATOR = 'success/cooperator/public/RECEIVE_COOPERATOR';
export const RECEIVE_COOPERATORS = 'success/cooperator/public/RECEIVE_COOPERATORS';
export const SAGA_RECEIVE_COOPERATOR = 'saga/cooperator/public/RECEIVE_COOPERATOR';
export const SAGA_RECEIVE_COOPERATORS = 'saga/cooperator/public/RECEIVE_COOPERATORS';
export const API_RECEIVE_COOPERATOR = 'api/cooperator/public/RECEIVE_COOPERATOR';
export const API_RECEIVE_COOPERATORS = 'api/cooperator/public/RECEIVE_COOPERATORS';
import { combineReducers } from 'redux';
import cooperatorPublic from 'cooperator/reducer/public';
const rootReducer = combineReducers({
public: cooperatorPublic,
});
export default rootReducer;
import update from 'immutability-helper';
// Constants
import { RECEIVE_COOPERATOR, RECEIVE_COOPERATORS } from 'cooperator/constants';
// Model
import Cooperator from '@gringlobal/client/model/gringlobal/Cooperator';
import { FilteredPage } from '@gringlobal/client/model/page';
import { ApiCall } from '@gringlobal/client/model/common';
const initialState: {
cooperators: ApiCall<FilteredPage<Cooperator>>,
cooperator: ApiCall<Cooperator>,
} = {