frontend: load config.json at runtime from public dir, to be overwritable

This commit is contained in:
Paul Bienkowski 2021-09-14 22:02:57 +02:00
parent 85d93fe598
commit c7202eadd2
12 changed files with 96 additions and 42 deletions

2
.gitignore vendored
View file

@ -1,4 +1,2 @@
local local
config.json
src/config.json
data data

View file

@ -1,3 +1,9 @@
# This docker-compose file is intended for development use. You can simply run
# `docker-compose up -d` in the repository and it should build and run all
# required parts of the application. See README.md for details.
#
# For a production docker setup, please check the corresponding documentation.
version: '3' version: '3'
services: services:
@ -75,7 +81,9 @@ services:
- ./frontend/public:/opt/obs/frontend/public - ./frontend/public:/opt/obs/frontend/public
- ./frontend/tsconfig.json:/opt/obs/frontend/tsconfig.json - ./frontend/tsconfig.json:/opt/obs/frontend/tsconfig.json
- ./frontend/package.json:/opt/obs/frontend/package.json - ./frontend/package.json:/opt/obs/frontend/package.json
- ./frontend/src/config.dev.json:/opt/obs/frontend/src/config.json
# Overwrite the default config with the development mode configuration
- ./frontend/config.dev.json:/opt/obs/frontend/public/config.json
environment: environment:
- PORT=3000 - PORT=3000
links: links:

0
frontend/public/config.json Executable file
View file

View file

@ -6,7 +6,7 @@ import {useObservable} from 'rxjs-hooks'
import {from} from 'rxjs' import {from} from 'rxjs'
import {pluck} from 'rxjs/operators' import {pluck} from 'rxjs/operators'
import config from 'config.json' import {useConfig} from 'config'
import styles from './App.module.scss' import styles from './App.module.scss'
import { import {
@ -38,6 +38,7 @@ function DropdownItemForLink({navigate, ...props}) {
} }
const App = connect((state) => ({login: state.login}))(function App({login}) { const App = connect((state) => ({login: state.login}))(function App({login}) {
const config = useConfig()
const apiVersion = useObservable(() => from(api.get('/info')).pipe(pluck('version'))) const apiVersion = useObservable(() => from(api.get('/info')).pipe(pluck('version')))
return ( return (
@ -148,12 +149,12 @@ const App = connect((state) => ({login: state.login}))(function App({login}) {
<Header as="h5">This installation</Header> <Header as="h5">This installation</Header>
<List> <List>
<List.Item> <List.Item>
<a href={config.privacyPolicyUrl} target="_blank" rel="noreferrer"> <a href={config?.privacyPolicyUrl} target="_blank" rel="noreferrer">
Privacy policy Privacy policy
</a> </a>
</List.Item> </List.Item>
<List.Item> <List.Item>
<a href={config.imprintUrl} target="_blank" rel="noreferrer"> <a href={config?.imprintUrl} target="_blank" rel="noreferrer">
Imprint Imprint
</a> </a>
</List.Item> </List.Item>

View file

@ -2,7 +2,7 @@ import {stringifyParams} from 'query'
import globalStore from 'store' import globalStore from 'store'
import {setAuth, invalidateAccessToken, resetAuth} from 'reducers/auth' import {setAuth, invalidateAccessToken, resetAuth} from 'reducers/auth'
import {setLogin} from 'reducers/login' import {setLogin} from 'reducers/login'
import config from 'config.json' import configPromise from 'config'
import {create as createPkce} from 'pkce' import {create as createPkce} from 'pkce'
import download from 'downloadjs' import download from 'downloadjs'
@ -39,6 +39,7 @@ class API {
* it supports PKCE. * it supports PKCE.
*/ */
async getAuthorizationServerMetadata() { async getAuthorizationServerMetadata() {
const config = await configPromise
const url = new URL(config.auth.server) const url = new URL(config.auth.server)
const pathSuffix = url.pathname.replace(/^\/+|\/+$/, '') const pathSuffix = url.pathname.replace(/^\/+|\/+$/, '')
url.pathname = '/.well-known/oauth-authorization-server' + (pathSuffix ? '/' + pathSuffix : '') url.pathname = '/.well-known/oauth-authorization-server' + (pathSuffix ? '/' + pathSuffix : '')
@ -117,6 +118,7 @@ class API {
// Try to use the refresh token // Try to use the refresh token
const {tokenEndpoint} = await this.getAuthorizationServerMetadata() const {tokenEndpoint} = await this.getAuthorizationServerMetadata()
const config = await configPromise
const url = new URL(tokenEndpoint) const url = new URL(tokenEndpoint)
url.searchParams.append('refresh_token', refreshToken) url.searchParams.append('refresh_token', refreshToken)
url.searchParams.append('grant_type', 'refresh_token') url.searchParams.append('grant_type', 'refresh_token')
@ -142,6 +144,7 @@ class API {
} }
const {tokenEndpoint} = await this.getAuthorizationServerMetadata() const {tokenEndpoint} = await this.getAuthorizationServerMetadata()
const config = await configPromise
const url = new URL(tokenEndpoint) const url = new URL(tokenEndpoint)
url.searchParams.append('code', code) url.searchParams.append('code', code)
url.searchParams.append('grant_type', 'authorization_code') url.searchParams.append('grant_type', 'authorization_code')
@ -178,6 +181,7 @@ class API {
async makeLoginUrl() { async makeLoginUrl() {
const {authorizationEndpoint} = await this.getAuthorizationServerMetadata() const {authorizationEndpoint} = await this.getAuthorizationServerMetadata()
const config = await configPromise
const {codeVerifier, codeChallenge} = createPkce() const {codeVerifier, codeChallenge} = createPkce()
localStorage.setItem('codeVerifier', codeVerifier) localStorage.setItem('codeVerifier', codeVerifier)
@ -197,6 +201,7 @@ class API {
async fetch(url, options = {}) { async fetch(url, options = {}) {
const accessToken = await this.getValidAccessToken() const accessToken = await this.getValidAccessToken()
const config = await configPromise
const {returnResponse = false, ...fetchOptions} = options const {returnResponse = false, ...fetchOptions} = options

View file

@ -13,7 +13,7 @@ import {fromLonLat} from 'ol/proj'
// Import styles for open layers + addons // Import styles for open layers + addons
import 'ol/ol.css' import 'ol/ol.css'
import config from 'config.json' import {useConfig} from 'config'
// Prepare projection // Prepare projection
proj4.defs( proj4.defs(
@ -86,6 +86,11 @@ export function TileLayer({osm, ...props}) {
} }
export function BaseLayer(props) { export function BaseLayer(props) {
const config = useConfig()
if (!config) {
return null
}
return ( return (
<TileLayer <TileLayer
osm={{ osm={{
@ -117,26 +122,30 @@ function FitView({extent}) {
return null return null
} }
const minZoom = config.mapTileset?.minZoom ?? 0
const maxZoom = config.mapTileset?.maxZoom ?? 18
const mapHomeZoom = config.mapHome?.zoom ?? 15
const mapHomeLongitude = config.mapHome?.longitude ?? 9.1797
const mapHomeLatitude = config.mapHome?.latitude ?? 48.7784
function View({...options}) { function View({...options}) {
const map = React.useContext(MapContext) const map = React.useContext(MapContext)
const config = useConfig()
const view = React.useMemo( const view = React.useMemo(
() => () => {
new OlView({ if (!config) return null
const minZoom = config.mapTileset?.minZoom ?? 0
const maxZoom = config.mapTileset?.maxZoom ?? 18
const mapHomeZoom = config.mapHome?.zoom ?? 15
const mapHomeLongitude = config.mapHome?.longitude ?? 9.1797
const mapHomeLatitude = config.mapHome?.latitude ?? 48.7784
return new OlView({
minZoom, minZoom,
maxZoom, maxZoom,
zoom: Math.max(Math.min(mapHomeZoom, maxZoom), minZoom), zoom: Math.max(Math.min(mapHomeZoom, maxZoom), minZoom),
center: fromLonLat([mapHomeLongitude, mapHomeLatitude]), center: fromLonLat([mapHomeLongitude, mapHomeLatitude]),
...options, ...options,
}), })
},
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
[] [config]
) )
React.useEffect(() => { React.useEffect(() => {

26
frontend/src/config.ts Normal file
View file

@ -0,0 +1,26 @@
import React from 'react'
interface Config {
apiUrl: string
}
async function loadConfig(): Promise<Config> {
const response = await fetch('./config.json')
const config = await response.json()
return config
}
let _configPromise: Promise<Config> = loadConfig()
let _configCache: null | Config = null
export function useConfig() {
const [config, setConfig] = React.useState<Config>(_configCache)
React.useEffect(() => {
if (!_configCache) {
_configPromise.then(setConfig)
}
}, [])
return config
}
export default _configPromise

View file

@ -7,7 +7,7 @@ import {setLogin} from 'reducers/login'
import {Page, Stats} from 'components' import {Page, Stats} from 'components'
import api from 'api' import api from 'api'
import {findInput} from 'utils' import {findInput} from 'utils'
import config from 'config.json' import {useConfig} from 'config'
const SettingsPage = connect((state) => ({login: state.login}), {setLogin})(function SettingsPage({login, setLogin}) { const SettingsPage = connect((state) => ({login: state.login}), {setLogin})(function SettingsPage({login, setLogin}) {
const {register, handleSubmit} = useForm() const {register, handleSubmit} = useForm()
@ -102,6 +102,7 @@ function CopyInput({value, ...props}) {
const selectField = findInput((ref) => ref?.select()) const selectField = findInput((ref) => ref?.select())
function ApiKeyDialog({login}) { function ApiKeyDialog({login}) {
const config = useConfig()
const [show, setShow] = React.useState(false) const [show, setShow] = React.useState(false)
const onClick = React.useCallback( const onClick = React.useCallback(
(e) => { (e) => {
@ -131,7 +132,7 @@ function ApiKeyDialog({login}) {
)} )}
</div> </div>
<p>The API URL should be set to:</p> <p>The API URL should be set to:</p>
<CopyInput label="API URL" value={config.apiUrl} /> <CopyInput label="API URL" value={config?.apiUrl ?? '...'} />
</> </>
) )
} }

View file

@ -7,7 +7,6 @@ import {Fill, Stroke, Style, Text, Circle} from 'ol/style'
import {Map} from 'components' import {Map} from 'components'
import type {TrackData, TrackPoint} from 'types' import type {TrackData, TrackPoint} from 'types'
import config from 'config.json'
const isValidTrackPoint = (point: TrackPoint): boolean => { const isValidTrackPoint = (point: TrackPoint): boolean => {
const longitude = point.geometry?.coordinates?.[0] const longitude = point.geometry?.coordinates?.[0]

View file

@ -6,7 +6,7 @@ import {Link} from 'react-router-dom'
import {FileUploadField, Page} from 'components' import {FileUploadField, Page} from 'components'
import type {Track} from 'types' import type {Track} from 'types'
import api from 'api' import api from 'api'
import config from '../config.json' import configPromise from 'config'
function isSameFile(a: File, b: File) { function isSameFile(a: File, b: File) {
return a.name === b.name && a.size === b.size return a.name === b.name && a.size === b.size
@ -56,34 +56,41 @@ export function FileUploadStatus({
React.useEffect( React.useEffect(
() => { () => {
const formData = new FormData() let xhr
formData.append('body', file)
const xhr = new XMLHttpRequest() async function _work() {
const formData = new FormData()
formData.append('body', file)
const onProgress = (e) => { xhr = new XMLHttpRequest()
const progress = (e.loaded || 0) / (e.total || 1)
setProgress(progress)
}
const onLoad = (e) => { const onProgress = (e) => {
onComplete(id, xhr.response) const progress = (e.loaded || 0) / (e.total || 1)
} setProgress(progress)
}
xhr.responseType = 'json' const onLoad = (e) => {
xhr.onload = onLoad onComplete(id, xhr.response)
xhr.upload.onprogress = onProgress }
if (slug) {
xhr.open('PUT', `${config.apiUrl}/api/tracks/${slug}`) xhr.responseType = 'json'
} else { xhr.onload = onLoad
xhr.open('POST', `${config.apiUrl}/api/tracks`) xhr.upload.onprogress = onProgress
}
const config = await configPromise
if (slug) {
xhr.open('PUT', `${config.apiUrl}/api/tracks/${slug}`)
} else {
xhr.open('POST', `${config.apiUrl}/api/tracks`)
}
const accessToken = await api.getValidAccessToken()
api.getValidAccessToken().then((accessToken) => {
xhr.setRequestHeader('Authorization', accessToken) xhr.setRequestHeader('Authorization', accessToken)
xhr.send(formData) xhr.send(formData)
}) }
_work()
return () => xhr.abort() return () => xhr.abort()
}, },
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps