Refactor fetching of server config and settings.

The earlier approach of loading `/api/config.js` as a script on
initial page load with the necessary variables to init the UI is
ditched. Instead, it's now `/api/config` and `/api/settings` like
all other API calls. On load of the frontend, these two resources
are fetched and the frontend is initialised.
This commit is contained in:
Kailash Nadh 2021-02-13 12:34:36 +05:30
parent b6dcf2c841
commit b950d2f4ff
16 changed files with 142 additions and 166 deletions

View File

@ -1,8 +1,6 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"sort"
@ -13,41 +11,29 @@ import (
"github.com/labstack/echo"
)
type configScript struct {
RootURL string `json:"rootURL"`
FromEmail string `json:"fromEmail"`
Messengers []string `json:"messengers"`
MediaProvider string `json:"mediaProvider"`
NeedsRestart bool `json:"needsRestart"`
Update *AppUpdate `json:"update"`
Langs []i18nLang `json:"langs"`
EnablePublicSubPage bool `json:"enablePublicSubscriptionPage"`
Lang json.RawMessage `json:"lang"`
type serverConfig struct {
Messengers []string `json:"messengers"`
Langs []i18nLang `json:"langs"`
Lang string `json:"lang"`
Update *AppUpdate `json:"update"`
NeedsRestart bool `json:"needs_restart"`
}
// handleGetConfigScript returns general configuration as a Javascript
// variable that can be included in an HTML page directly.
func handleGetConfigScript(c echo.Context) error {
// handleGetServerConfig returns general server config.
func handleGetServerConfig(c echo.Context) error {
var (
app = c.Get("app").(*App)
out = configScript{
RootURL: app.constants.RootURL,
FromEmail: app.constants.FromEmail,
MediaProvider: app.constants.MediaProvider,
EnablePublicSubPage: app.constants.EnablePublicSubPage,
}
out = serverConfig{}
)
// Language list.
langList, err := geti18nLangList(app.constants.Lang, app)
langList, err := getI18nLangList(app.constants.Lang, app)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
fmt.Sprintf("Error loading language list: %v", err))
}
out.Langs = langList
// Current language.
out.Lang = json.RawMessage(app.i18n.JSON())
out.Lang = app.constants.Lang
// Sort messenger names with `email` always as the first item.
var names []string
@ -66,17 +52,7 @@ func handleGetConfigScript(c echo.Context) error {
out.Update = app.update
app.Unlock()
// Write the Javascript variable opening;
b := bytes.Buffer{}
b.Write([]byte(`var CONFIG = `))
// Encode the config payload as JSON and write as the variable's value assignment.
j := json.NewEncoder(&b)
if err := j.Encode(out); err != nil {
return echo.NewHTTPError(http.StatusInternalServerError,
app.i18n.Ts("admin.errorMarshallingConfig", "error", err.Error()))
}
return c.Blob(http.StatusOK, "application/javascript; charset=utf-8", b.Bytes())
return c.JSON(http.StatusOK, okResp{out})
}
// handleGetDashboardCharts returns chart data points to render ont he dashboard.

View File

@ -2,8 +2,6 @@ package main
import (
"crypto/subtle"
"encoding/json"
"fmt"
"net/http"
"net/url"
"regexp"
@ -44,8 +42,8 @@ func registerHTTPHandlers(e *echo.Echo) {
g := e.Group("", middleware.BasicAuth(basicAuth))
g.GET("/", handleIndexPage)
g.GET("/api/health", handleHealthCheck)
g.GET("/api/config.js", handleGetConfigScript)
g.GET("/api/lang/:lang", handleLoadLanguage)
g.GET("/api/config", handleGetServerConfig)
g.GET("/api/lang/:lang", handleGetI18nLang)
g.GET("/api/dashboard/charts", handleGetDashboardCharts)
g.GET("/api/dashboard/counts", handleGetDashboardCounts)
@ -164,23 +162,6 @@ func handleHealthCheck(c echo.Context) error {
return c.JSON(http.StatusOK, okResp{true})
}
// handleLoadLanguage returns the JSON language pack given the language code.
func handleLoadLanguage(c echo.Context) error {
app := c.Get("app").(*App)
lang := c.Param("lang")
if len(lang) > 6 || reLangCode.MatchString(lang) {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid language code.")
}
b, err := app.fs.Read(fmt.Sprintf("/lang/%s.json", lang))
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Unknown language.")
}
return c.JSON(http.StatusOK, okResp{json.RawMessage(b)})
}
// basicAuth middleware does an HTTP BasicAuth authentication for admin handlers.
func basicAuth(username, password string, c echo.Context) (bool, error) {
app := c.Get("app").(*App)

View File

@ -3,6 +3,11 @@ package main
import (
"encoding/json"
"fmt"
"net/http"
"github.com/knadh/listmonk/internal/i18n"
"github.com/knadh/stuffbin"
"github.com/labstack/echo"
)
type i18nLang struct {
@ -15,8 +20,25 @@ type i18nLangRaw struct {
Name string `json:"_.name"`
}
// geti18nLangList returns the list of available i18n languages.
func geti18nLangList(lang string, app *App) ([]i18nLang, error) {
// handleGetI18nLang returns the JSON language pack given the language code.
func handleGetI18nLang(c echo.Context) error {
app := c.Get("app").(*App)
lang := c.Param("lang")
if len(lang) > 6 || reLangCode.MatchString(lang) {
return echo.NewHTTPError(http.StatusBadRequest, "Invalid language code.")
}
i, err := getI18nLang(lang, app.fs)
if err != nil {
return echo.NewHTTPError(http.StatusBadRequest, "Unknown language.")
}
return c.JSON(http.StatusOK, okResp{json.RawMessage(i.JSON())})
}
// getI18nLangList returns the list of available i18n languages.
func getI18nLangList(lang string, app *App) ([]i18nLang, error) {
list, err := app.fs.Glob("/i18n/*.json")
if err != nil {
return nil, err
@ -42,3 +64,30 @@ func geti18nLangList(lang string, app *App) ([]i18nLang, error) {
return out, nil
}
func getI18nLang(lang string, fs stuffbin.FileSystem) (*i18n.I18n, error) {
const def = "en"
b, err := fs.Read(fmt.Sprintf("/i18n/%s.json", def))
if err != nil {
return nil, fmt.Errorf("error reading default i18n language file: %s: %v", def, err)
}
// Initialize with the default language.
i, err := i18n.New(b)
if err != nil {
return nil, fmt.Errorf("error unmarshalling i18n language: %v", err)
}
// Load the selected language on top of it.
b, err = fs.Read(fmt.Sprintf("/i18n/%s.json", lang))
if err != nil {
return nil, fmt.Errorf("error reading i18n language file: %v", err)
}
if err := i.Load(b); err != nil {
return nil, fmt.Errorf("error loading i18n language file: %v", err)
}
return i, nil
}

View File

@ -262,28 +262,10 @@ func initConstants() *constants {
// and then the selected language is loaded on top of it so that if there are
// missing translations in it, the default English translations show up.
func initI18n(lang string, fs stuffbin.FileSystem) *i18n.I18n {
const def = "en"
b, err := fs.Read(fmt.Sprintf("/i18n/%s.json", def))
i, err := getI18nLang(lang, fs)
if err != nil {
lo.Fatalf("error reading default i18n language file: %s: %v", def, err)
lo.Fatal(err)
}
// Initialize with the default language.
i, err := i18n.New(b)
if err != nil {
lo.Fatalf("error unmarshalling i18n language: %v", err)
}
// Load the selected language on top of it.
b, err = fs.Read(fmt.Sprintf("/i18n/%s.json", lang))
if err != nil {
lo.Fatalf("error reading i18n language file: %v", err)
}
if err := i.Load(b); err != nil {
lo.Fatalf("error loading i18n language file: %v", err)
}
return i
}

7
frontend/README.md vendored
View File

@ -5,7 +5,10 @@ It's best if the `listmonk/frontend` directory is opened in an IDE as a separate
For developer setup instructions, refer to the main project's README.
## Globals
`main.js` is where Buefy is injected globally into Vue. In addition two controllers, `$api` (collection of API calls from `api/index.js`), `$utils` (util functions from `util.js`), `$serverConfig` (loaded form /api/config.js) are also attached globaly to Vue. They are accessible within Vue as `this.$api` and `this.$utils`.
In `main.js`, Buefy and vue-i18n are attached globally. In addition:
- `$api` (collection of API calls from `api/index.js`)
- `$utils` (util functions from `util.js`). They are accessible within Vue as `this.$api` and `this.$utils`.
Some constants are defined in `constants.js`.
@ -14,7 +17,7 @@ The project uses a global `vuex` state to centrally store the responses to prett
There is a global state `loading` (eg: loading.campaigns, loading.lists) that indicates whether an API call for that particular "model" is running. This can be used anywhere in the project to show loading spinners for instance. All the API definitions are in `api/index.js`. It also describes how each API call sets the global `loading` status alongside storing the API responses.
*IMPORTANT*: All JSON field names in GET API responses are automatically camel-cased when they're pulled for the sake of consistentcy in the frontend code and for complying with the linter spec in the project (Vue/AirBnB schema). For example, `content_type` becomes `contentType`. When sending responses to the backend, however, they should be snake-cased manually.
*IMPORTANT*: All JSON field names in GET API responses are automatically camel-cased when they're pulled for the sake of consistentcy in the frontend code and for complying with the linter spec in the project (Vue/AirBnB schema). For example, `content_type` becomes `contentType`. When sending responses to the backend, however, they should be snake-cased manually. This is overridden for certain calls such as `/api/config` and `/api/settings` using the `preserveCase: true` param in `api/index.js`.
## Icon pack

View File

@ -7,7 +7,6 @@
<link rel="icon" href="<%= BASE_URL %>frontend/favicon.png" />
<link href="https://fonts.googleapis.com/css?family=Inter:400,600" rel="stylesheet" />
<title><%= htmlWebpackPlugin.options.title %></title>
<script src="<%= BASE_URL %>api/config.js" id="server-config"></script>
</head>
<body>
<noscript>

View File

@ -1,6 +1,6 @@
<template>
<div id="app">
<b-navbar :fixed-top="true">
<b-navbar :fixed-top="true" v-if="$root.isLoaded">
<template slot="brand">
<div class="logo">
<router-link :to="{name: 'dashboard'}">
@ -14,7 +14,7 @@
</template>
</b-navbar>
<div class="wrapper">
<div class="wrapper" v-if="$root.isLoaded">
<section class="sidebar">
<b-sidebar
position="static"
@ -100,18 +100,17 @@
<!-- body //-->
<div class="main">
<div class="global-notices" v-if="serverConfig.needsRestart || serverConfig.update">
<div v-if="serverConfig.needsRestart" class="notification is-danger">
Settings have changed. Pause all running campaigns and restart the app
<div class="global-notices" v-if="serverConfig.needs_restart || serverConfig.update">
<div v-if="serverConfig.needs_restart" class="notification is-danger">
{{ $t('settings.needsRestart') }}
&mdash;
<b-button class="is-primary" size="is-small"
@click="$utils.confirm(
'Ensure running campaigns are paused. Restart?', reloadApp)">
Restart
@click="$utils.confirm($t('settings.confirmRestart'), reloadApp)">
{{ $t('settings.restart') }}
</b-button>
</div>
<div v-if="serverConfig.update" class="notification is-success">
A new update ({{ serverConfig.update.version }}) is available.
{{ $t('settings.updateAvailable', { version: serverConfig.update.version }) }}
<a :href="serverConfig.update.url" target="_blank">View</a>
</div>
</div>
@ -120,15 +119,7 @@
</div>
</div>
<b-loading v-if="!isLoaded" active>
<div class="has-text-centered">
<h1 class="title">Oops</h1>
<p>
Can't connect to the backend.<br />
Make sure the server is running and refresh this page.
</p>
</div>
</b-loading>
<b-loading v-if="!$root.isLoaded" active />
</div>
</template>
@ -143,7 +134,6 @@ export default Vue.extend({
return {
activeItem: {},
activeGroup: {},
isLoaded: window.CONFIG,
};
},

View File

@ -195,11 +195,17 @@ export const deleteTemplate = async (id) => http.delete(`/api/templates/${id}`,
{ loading: models.templates });
// Settings.
export const getServerConfig = async () => http.get('/api/config',
{ loading: models.serverConfig, store: models.serverConfig, preserveCase: true });
export const getSettings = async () => http.get('/api/settings',
{ loading: models.settings, preserveCase: true });
{ loading: models.settings, store: models.settings, preserveCase: true });
export const updateSettings = async (data) => http.put('/api/settings', data,
{ loading: models.settings });
export const getLogs = async () => http.get('/api/logs',
{ loading: models.logs });
export const getLang = async (lang) => http.get(`/api/lang/${lang}`,
{ loading: models.lang, preserveCase: true });

View File

@ -1,8 +1,6 @@
export const models = Object.freeze({
// This is the config loaded from /api/config.js directly onto the page
// via a <script> tag.
serverConfig: 'serverConfig',
lang: 'lang',
dashboard: 'dashboard',
lists: 'lists',
subscribers: 'subscribers',

View File

@ -1,13 +1,11 @@
import Vue from 'vue';
import Buefy from 'buefy';
import humps from 'humps';
import VueI18n from 'vue-i18n';
import App from './App.vue';
import router from './router';
import store from './store';
import * as api from './api';
import { models } from './constants';
import Utils from './utils';
// Internationalisation.
@ -18,46 +16,33 @@ Vue.use(Buefy, {});
Vue.config.productionTip = false;
// Globals.
const ut = new Utils(i18n);
Vue.mixin({
computed: {
$utils: () => ut,
$api: () => api,
},
methods: {
$reloadServerConfig: () => {
// Get the config.js <script> tag, remove it, and re-add it.
let s = document.querySelector('#server-config');
const url = s.getAttribute('src');
s.remove();
s = document.createElement('script');
s.setAttribute('src', url);
s.setAttribute('id', 'server-config');
s.onload = () => {
store.commit('setModelResponse',
{ model: models.serverConfig, data: humps.camelizeKeys(window.CONFIG) });
};
document.body.appendChild(s);
},
},
});
// window.CONFIG is loaded from /api/config.js directly in a <script> tag.
if (window.CONFIG) {
store.commit('setModelResponse',
{ model: models.serverConfig, data: humps.camelizeKeys(window.CONFIG) });
// Load language.
i18n.locale = window.CONFIG.lang['_.code'];
i18n.setLocaleMessage(i18n.locale, window.CONFIG.lang);
}
Vue.prototype.$utils = new Utils(i18n);
Vue.prototype.$api = api;
new Vue({
router,
store,
i18n,
render: (h) => h(App),
data: {
isLoaded: false,
},
methods: {
loadConfig() {
api.getServerConfig().then((data) => {
api.getLang(data.lang).then((lang) => {
i18n.locale = data.lang;
i18n.setLocaleMessage(i18n.locale, lang);
this.isLoaded = true;
});
});
},
},
created() {
this.loadConfig();
api.getSettings();
},
}).$mount('#app');

View File

@ -75,7 +75,7 @@
<b-field :label="$tc('globals.terms.messenger')" label-position="on-border">
<b-select :placeholder="$tc('globals.terms.messenger')" v-model="form.messenger"
:disabled="!canEdit" required>
<option v-for="m in serverConfig.messengers"
<option v-for="m in messengers"
:value="m" :key="m">{{ m }}</option>
</b-select>
</b-field>
@ -196,7 +196,7 @@ export default Vue.extend({
form: {
name: '',
subject: '',
fromEmail: window.CONFIG.fromEmail,
fromEmail: '',
templateId: 0,
lists: [],
tags: [],
@ -352,7 +352,7 @@ export default Vue.extend({
},
computed: {
...mapState(['serverConfig', 'loading', 'lists', 'templates']),
...mapState(['settings', 'loading', 'lists', 'templates']),
canEdit() {
return this.isNew
@ -374,6 +374,10 @@ export default Vue.extend({
return this.lists.results.filter((l) => this.selListIDs.indexOf(l.id) > -1);
},
messengers() {
return ['email', ...this.settings.messengers.map((m) => m.name)];
},
},
watch: {
@ -383,6 +387,8 @@ export default Vue.extend({
},
mounted() {
this.form.fromEmail = this.settings['app.from_email'];
const { id } = this.$route.params;
// New campaign.

View File

@ -22,13 +22,12 @@
</li>
</ul>
<template v-if="serverConfig.enablePublicSubscriptionPage">
<template v-if="settings['app.enable_public_subscription_page']">
<hr />
<h4>{{ $t('forms.publicSubPage') }}</h4>
<p>
<a :href="`${serverConfig.rootURL}/subscription/form`"
target="_blank">{{ serverConfig.rootURL }}/subscription/form</a>
<a :href="`${settings['app.root_url']}/subscription/form`"
target="_blank">{{ settings['app.root_url'] }}/subscription/form</a>
</p>
</template>
</div>
@ -39,7 +38,7 @@
</p>
<!-- eslint-disable max-len -->
<pre v-if="checked.length > 0">&lt;form method=&quot;post&quot; action=&quot;{{ serverConfig.rootURL }}/subscription/form&quot; class=&quot;listmonk-form&quot;&gt;
<pre v-if="checked.length > 0">&lt;form method=&quot;post&quot; action=&quot;{{ settings['app.root_url'] }}/subscription/form&quot; class=&quot;listmonk-form&quot;&gt;
&lt;div&gt;
&lt;h3&gt;Subscribe&lt;/h3&gt;
&lt;p&gt;&lt;input type=&quot;text&quot; name=&quot;email&quot; placeholder=&quot;{{ $t('subscribers.email') }}&quot; /&gt;&lt;/p&gt;
@ -79,7 +78,7 @@ export default Vue.extend({
},
computed: {
...mapState(['lists', 'loading', 'serverConfig']),
...mapState(['loading', 'lists', 'settings']),
publicLists() {
if (!this.lists.results) {

View File

@ -192,7 +192,7 @@ export default Vue.extend({
name: this.$t('lists.optinTo', { name: list.name }),
subject: this.$t('lists.confirmSub', { name: list.name }),
lists: [list.id],
from_email: this.serverConfig.fromEmail,
from_email: this.settings['app.from_email'],
content_type: 'richtext',
messenger: 'email',
type: 'optin',
@ -206,7 +206,7 @@ export default Vue.extend({
},
computed: {
...mapState(['serverConfig', 'loading', 'lists']),
...mapState(['loading', 'lists', 'settings']),
},
mounted() {

View File

@ -3,7 +3,7 @@
<h1 class="title is-4">{{ $t('media.title') }}
<span v-if="media.length > 0">({{ media.length }})</span>
<span class="has-text-grey-light"> / {{ serverConfig.mediaProvider }}</span>
<span class="has-text-grey-light"> / {{ settings['upload.provider'] }}</span>
</h1>
<b-loading :active="isProcessing || loading.media"></b-loading>
@ -141,7 +141,7 @@ export default Vue.extend({
},
computed: {
...mapState(['media', 'serverConfig', 'loading']),
...mapState(['loading', 'media', 'settings']),
isProcessing() {
if (this.toUpload > 0 && this.uploaded < this.toUpload) {

View File

@ -1,6 +1,6 @@
<template>
<section class="settings">
<b-loading :is-full-page="true" v-if="isLoading" active />
<b-loading :is-full-page="true" v-if="loading.settings || isLoading" active />
<header class="columns">
<div class="column is-half">
<h1 class="title is-4">{{ $t('settings.title') }}</h1>
@ -528,8 +528,6 @@
<script>
import Vue from 'vue';
import { mapState } from 'vuex';
import store from '../store';
import { models } from '../constants';
const dummyPassword = ' '.repeat(8);
@ -537,7 +535,7 @@ export default Vue.extend({
data() {
return {
regDuration: '[0-9]+(ms|s|m|h|d)',
isLoading: true,
isLoading: false,
// formCopy is a stringified copy of the original settings against which
// form is compared to detect changes.
@ -635,11 +633,11 @@ export default Vue.extend({
this.isLoading = true;
this.$api.updateSettings(form).then((data) => {
if (data.needsRestart) {
// Update the 'needsRestart' flag on the global serverConfig state
// as there are running campaigns and the app couldn't auto-restart.
store.commit('setModelResponse',
{ model: models.serverConfig, data: { ...this.serverConfig, needsRestart: true } });
// There are running campaigns and the app didn't auto restart.
// The UI will show a warning.
this.$root.loadConfig();
this.getSettings();
this.isLoading = false;
return;
}
@ -650,8 +648,8 @@ export default Vue.extend({
const pollId = setInterval(() => {
this.$api.getHealth().then(() => {
clearInterval(pollId);
this.$root.loadConfig();
this.getSettings();
this.$reloadServerConfig();
});
}, 500);
}, () => {
@ -666,7 +664,7 @@ export default Vue.extend({
for (let i = 0; i < d.smtp.length; i += 1) {
d.smtp[i].strEmailHeaders = JSON.stringify(d.smtp[i].email_headers, null, 4);
// The backend doesn't send passwords, so add a dummy so that it
// The backend doesn't send passwords, so add a dummy so that
// the password looks filled on the UI.
d.smtp[i].password = dummyPassword;
}

View File

@ -271,6 +271,10 @@
"public.unsubbedInfo": "You have unsubscribed successfully.",
"public.unsubbedTitle": "Unsubscribed",
"public.unsubscribeTitle": "Unsubscribe from mailing list",
"settings.needsRestart": "Settings changed. Pause all running campaigns and restart the app",
"settings.confirmRestart": "Ensure running campaigns are paused. Restart?",
"settings.updateAvailable": "A new update {version} is available.",
"settings.restart": "Restart",
"settings.duplicateMessengerName": "Duplicate messenger name: {name}",
"settings.errorEncoding": "Error encoding settings: {error}",
"settings.errorNoSMTP": "At least one SMTP block should be enabled",