From 1a1d73a08b34325be57dce4f292e69cfec467257 Mon Sep 17 00:00:00 2001 From: Xiao Gui <xgui3783@gmail.com> Date: Thu, 9 Apr 2020 09:42:59 +0200 Subject: [PATCH] feat: saneUrl --- deploy/app.js | 9 + deploy/package.json | 6 +- deploy/saneUrl/index.js | 99 ++++++ deploy/saneUrl/index.spec.js | 199 +++++++++++ deploy/saneUrl/store.js | 85 +++++ deploy/saneUrl/store.spec.js | 57 ++++ deploy/server.js | 2 + src/auth/auth.directive.ts | 18 + src/auth/auth.module.ts | 26 ++ src/{services => auth}/auth.service.ts | 2 +- src/auth/index.ts | 2 + .../signinModal/signinModal.component.ts | 2 +- .../signinModal/signinModal.style.css | 0 .../signinModal/signinModal.template.html | 0 src/main.module.ts | 2 +- src/share/saneUrl/saneUrl.component.spec.ts | 315 ++++++++++++++++++ src/share/saneUrl/saneUrl.component.ts | 189 +++++++++++ src/share/saneUrl/saneUrl.template.html | 46 +++ src/share/share.module.ts | 16 +- src/share/shareSaneLink.directive.ts | 0 src/state/index.ts | 1 + src/state/state.module.ts | 18 + src/state/stateAggregator.directive.ts | 38 +++ .../statusCard/statusCard.component.ts | 8 +- .../statusCard/statusCard.template.html | 71 +++- .../signinBanner/signinBanner.components.ts | 4 +- src/ui/ui.module.ts | 10 +- src/util/constants.ts | 3 +- 28 files changed, 1211 insertions(+), 17 deletions(-) create mode 100644 deploy/saneUrl/index.js create mode 100644 deploy/saneUrl/index.spec.js create mode 100644 deploy/saneUrl/store.js create mode 100644 deploy/saneUrl/store.spec.js create mode 100644 src/auth/auth.directive.ts create mode 100644 src/auth/auth.module.ts rename src/{services => auth}/auth.service.ts (95%) create mode 100644 src/auth/index.ts rename src/{ui => auth}/signinModal/signinModal.component.ts (88%) rename src/{ui => auth}/signinModal/signinModal.style.css (100%) rename src/{ui => auth}/signinModal/signinModal.template.html (100%) create mode 100644 src/share/saneUrl/saneUrl.component.spec.ts create mode 100644 src/share/saneUrl/saneUrl.component.ts create mode 100644 src/share/saneUrl/saneUrl.template.html delete mode 100644 src/share/shareSaneLink.directive.ts create mode 100644 src/state/index.ts create mode 100644 src/state/state.module.ts create mode 100644 src/state/stateAggregator.directive.ts diff --git a/deploy/app.js b/deploy/app.js index 67c350ee2..4ad2980db 100644 --- a/deploy/app.js +++ b/deploy/app.js @@ -106,11 +106,20 @@ const { compressionMiddleware } = require('nomiseco') app.use(compressionMiddleware, express.static(PUBLIC_PATH)) +/** + * saneUrl end points + */ +const saneUrlRouter = require('./saneUrl') +app.use('/saneUrl', saneUrlRouter) + const jsonMiddleware = (req, res, next) => { if (!res.get('Content-Type')) res.set('Content-Type', 'application/json') next() } +/** + * resources endpoints + */ const templateRouter = require('./templates') const nehubaConfigRouter = require('./nehubaConfig') const datasetRouter = require('./datasets') diff --git a/deploy/package.json b/deploy/package.json index 61322a472..168ec3c3b 100644 --- a/deploy/package.json +++ b/deploy/package.json @@ -18,6 +18,7 @@ "archiver": "^3.0.0", "body-parser": "^1.19.0", "express": "^4.16.4", + "express-rate-limit": "^5.1.1", "express-session": "^1.15.6", "hbp-seafile": "0.0.6", "helmet-csp": "^2.8.0", @@ -26,7 +27,9 @@ "nomiseco": "0.0.2", "openid-client": "^2.4.5", "passport": "^0.4.0", - "request": "^2.88.0" + "rate-limit-redis": "^1.7.0", + "request": "^2.88.0", + "soswrap": "0.0.1" }, "devDependencies": { "chai": "^4.2.0", @@ -36,6 +39,7 @@ "google-spreadsheet": "^3.0.8", "got": "^10.5.5", "mocha": "^6.1.4", + "nock": "^12.0.3", "sinon": "^8.0.2" } } diff --git a/deploy/saneUrl/index.js b/deploy/saneUrl/index.js new file mode 100644 index 000000000..ec870d3dc --- /dev/null +++ b/deploy/saneUrl/index.js @@ -0,0 +1,99 @@ +const router = require('express').Router() +const RateLimit = require('express-rate-limit') +const RedisStore = require('rate-limit-redis') +const { Store, NotFoundError } = require('./store') +const bodyParser = require('body-parser') +const { readUserData, saveUserData } = require('../user/store') + +const store = new Store() + +const { + REDIS_PROTO, + REDIS_ADDR, + REDIS_PORT, + + REDIS_RATE_LIMITING_DB_EPHEMERAL_PORT_6379_TCP_PROTO, + REDIS_RATE_LIMITING_DB_EPHEMERAL_PORT_6379_TCP_ADDR, + REDIS_RATE_LIMITING_DB_EPHEMERAL_PORT_6379_TCP_PORT, + + HOSTNAME, +} = process.env + +const redisProto = REDIS_PROTO || REDIS_RATE_LIMITING_DB_EPHEMERAL_PORT_6379_TCP_PROTO || 'redis' +const redisAddr = REDIS_ADDR || REDIS_RATE_LIMITING_DB_EPHEMERAL_PORT_6379_TCP_ADDR || null +const redisPort = REDIS_PORT || REDIS_RATE_LIMITING_DB_EPHEMERAL_PORT_6379_TCP_PORT || 6379 + +const redisURL = redisAddr && `${redisProto}://${redisAddr}:${redisPort}` + +const limiter = new RateLimit({ + windowMs: 1e3 * 5, + max: 5, + ...( redisURL ? { store: new RedisStore({ redisURL }) } : {} ) +}) + +const passthrough = (_, __, next) => next() + +const acceptHtmlProg = /text\/html/i + +router.get('/:name', DISABLE_LIMITER ? passthrough : limiter, async (req, res) => { + const { name } = req.params + const { headers } = req + + const redirectFlag = acceptHtmlProg.test(headers['accept']) + + try { + const value = await store.get(name) + const json = JSON.parse(value) + const { expiry, queryString } = json + if ( expiry && ((Date.now() - expiry) > 0) ) { + return res.status(404).end() + } + + if (redirectFlag) res.redirect(`${HOSTNAME}/?${queryString}`) + else res.status(200).send(value) + + } catch (e) { + if (e instanceof NotFoundError) return res.status(404).end() + res.status(500).send(e.toString()) + } +}) + +router.post('/:name', DISABLE_LIMITER ? passthrough : limiter, bodyParser.json(), async (req, res) => { + const { name } = req.params + const { body, user } = req + + try { + const payload = { + ...body, + userId: user && user.id, + expiry: !user && (Date.now() + 1e3 * 60 * 60 * 72) + } + + await store.set(name, JSON.stringify(payload)) + res.status(200).end() + + try { + if (!user) return + const { savedCustomLinks = [], ...rest } = await readUserData(user) + await saveUserData(user, { + ...rest, + savedCustomLinks: [ + ...savedCustomLinks, + name + ] + }) + } catch (e) { + console.error(`reading/writing user data error ${user && user.id}, ${name}`, e) + } + } catch (e) { + console.error(`saneUrl /POST error`, e) + const { statusCode, statusMessage } = e + res.status(statusCode || 500).send(statusMessage || 'Error encountered.') + } +}) + +router.use((_, res) => { + res.status(405).send('Not implemneted') +}) + +module.exports = router diff --git a/deploy/saneUrl/index.spec.js b/deploy/saneUrl/index.spec.js new file mode 100644 index 000000000..39162a25c --- /dev/null +++ b/deploy/saneUrl/index.spec.js @@ -0,0 +1,199 @@ +const sinon = require('sinon') +const { Store } = require('./store') + +sinon + .stub(Store.prototype, 'getToken') + .returns(Promise.resolve(`--fake-token--`)) + +const userStore = require('../user/store') + +const savedUserDataPayload = { + otherData: 'not relevant data', + savedCustomLinks: [ + '111222', + '333444' + ] +} + +const readUserDataStub = sinon + .stub(userStore, 'readUserData') + .returns(Promise.resolve(savedUserDataPayload)) + +const saveUserDataStub = sinon + .stub(userStore, 'saveUserData') + .returns(Promise.resolve()) + +const express = require('express') +const router = require('./index') +const got = require('got') +const { expect } = require('chai') + +const app = express() +let user +app.use('', (req, res, next) => { + req.user = user + next() +}, router) + +const name = `nameme` + +const payload = { + ver: '0.0.1', + queryString: 'test_test' +} + +describe('> saneUrl/index.js', () => { + + describe('> router', () => { + + let server, setStub + before(() => { + + setStub = sinon + .stub(Store.prototype, 'set') + .returns(Promise.resolve()) + server = app.listen(50000) + }) + + afterEach(() => { + setStub.resetHistory() + }) + + after(() => { + server.close() + }) + + it('> works', async () => { + const body = { + ...payload + } + const getStub = sinon + .stub(Store.prototype, 'get') + .returns(Promise.resolve(JSON.stringify(body))) + const { body: respBody } = await got(`http://localhost:50000/${name}`) + + expect(getStub.calledWith(name)).to.be.true + expect(respBody).to.equal(JSON.stringify(body)) + getStub.restore() + }) + + it('> get on expired returns 404', async () => { + const body = { + ...payload, + expiry: Date.now() - 1e3 * 60 + } + const getStub = sinon + .stub(Store.prototype, 'get') + .returns(Promise.resolve(JSON.stringify(body))) + + const { statusCode } = await got(`http://localhost:50000/${name}`, { + throwHttpErrors: false + }) + expect(statusCode).to.equal(404) + expect(getStub.calledWith(name)).to.be.true + getStub.restore() + }) + + it('> set works', async () => { + + await got(`http://localhost:50000/${name}`, { + method: 'POST', + headers: { + 'Content-type': 'application/json' + }, + body: JSON.stringify(payload) + }) + + const [ storedName, _ ] = setStub.args[0] + + expect(storedName).to.equal(name) + expect(setStub.called).to.be.true + }) + + describe('> set with unauthenticated user', () => { + + it('> set with anonymous user has user undefined and expiry as defined', async () => { + + await got(`http://localhost:50000/${name}`, { + method: 'POST', + headers: { + 'Content-type': 'application/json' + }, + body: JSON.stringify(payload) + }) + + expect(setStub.called).to.be.true + const [ _, storedContent] = setStub.args[0] + const { userId, expiry } = JSON.parse(storedContent) + expect(!!userId).to.be.false + expect(!!expiry).to.be.true + + // there will be some discrepencies, but the server lag should not exceed 5 seconds + expect( 1e3 * 60 * 60 * 72 - expiry + Date.now() ).to.be.lessThan(1e3 * 5) + }) + }) + + describe('> set with authenticated user', () => { + + before(() => { + user = { + id: 'test/1', + name: 'hello world' + } + }) + + afterEach(() => { + readUserDataStub.resetHistory() + saveUserDataStub.resetHistory() + }) + + after(() => { + user = null + readUserDataStub.restore() + saveUserDataStub.restore() + }) + + it('> userId set, expiry unset', async () => { + + await got(`http://localhost:50000/${name}`, { + method: 'POST', + headers: { + 'Content-type': 'application/json' + }, + body: JSON.stringify(payload) + }) + + expect(setStub.called).to.be.true + const [ _, storedContent] = setStub.args[0] + const { userId, expiry } = JSON.parse(storedContent) + expect(!!userId).to.be.true + expect(!!expiry).to.be.false + + expect( userId ).to.equal('test/1') + }) + + it('> readUserDataset saveUserDataset data stubs called', async () => { + + await got(`http://localhost:50000/${name}`, { + method: 'POST', + headers: { + 'Content-type': 'application/json' + }, + body: JSON.stringify(payload) + }) + + expect(readUserDataStub.called).to.be.true + expect(readUserDataStub.calledWith(user)).to.be.true + + expect(saveUserDataStub.called).to.be.true + expect(saveUserDataStub.calledWith(user, { + ...savedUserDataPayload, + savedCustomLinks: [ + ...savedUserDataPayload.savedCustomLinks, + name + ] + })).to.be.true + }) + }) + }) +}) diff --git a/deploy/saneUrl/store.js b/deploy/saneUrl/store.js new file mode 100644 index 000000000..1cb6cb8db --- /dev/null +++ b/deploy/saneUrl/store.js @@ -0,0 +1,85 @@ +const { SamlOpenstackWrapper } = require('soswrap') +const request = require('request') + +const { + OBJ_STORAGE_AUTH_URL, + OBJ_STORAGE_IDP_NAME, + OBJ_STORAGE_IDP_PROTO, + OBJ_STORAGE_IDP_URL, + OBJ_STORAGE_USERNAME, + OBJ_STORAGE_PASSWORD, + OBJ_STORAGE_PROJECT_ID, + OBJ_STORAGE_ROOT_URL, +} = process.env + +class NotFoundError extends Error{} + +class Store { + constructor({ + authUrl, + idPName, + idPProto, + idPUrl, + username, + password, + + objStorateRootUrl, + } = {}){ + + this.wrapper = new SamlOpenstackWrapper({ + authUrl: authUrl || OBJ_STORAGE_AUTH_URL, + idPName: idPName || OBJ_STORAGE_IDP_NAME, + idPProto: idPProto || OBJ_STORAGE_IDP_PROTO, + idPUrl: idPUrl || OBJ_STORAGE_IDP_URL, + }) + + this.objStorateRootUrl = objStorateRootUrl || OBJ_STORAGE_ROOT_URL + + this.wrapper.username = username || OBJ_STORAGE_USERNAME + this.wrapper.password = password || OBJ_STORAGE_PASSWORD + + this.getToken() + } + + async getToken() { + this.token = await this.wrapper.getScopedToken({ projectId: OBJ_STORAGE_PROJECT_ID }) + } + + get(id) { + return new Promise((rs, rj) => { + request.get(`${this.objStorateRootUrl}/${id}`, { + headers: { + 'X-Auth-Token': this.token + } + }, (err, resp, body) => { + if (err) return rj(err) + if (resp.statusCode === 404) return rj(new NotFoundError()) + if (resp.statusCode >= 400) return rj(resp) + return rs(body) + }) + }) + } + + set(id, value) { + return new Promise((rs, rj) => { + request.put(`${this.objStorateRootUrl}/${id}`, { + headers: { + 'X-Auth-Token': this.token + }, + body: value + }, (err, resp, body) => { + if (err) return rj(err) + if (resp.statusCode >= 400) return rj(resp) + return rs(body) + }) + }) + } + + async healthCheck(){ + + } +} + + +exports.Store = Store +exports.NotFoundError = NotFoundError diff --git a/deploy/saneUrl/store.spec.js b/deploy/saneUrl/store.spec.js new file mode 100644 index 000000000..32ce7c043 --- /dev/null +++ b/deploy/saneUrl/store.spec.js @@ -0,0 +1,57 @@ +const { NotFoundError, Store } = require('./store') +const sinon = require('sinon') +const { expect } = require("chai") +const nock = require('nock') + +const fakeToken = `token-123-token` +const objStorateRootUrl = `http://fake.obj` +const objName = `objname` +const objContent = `objContent` + +describe('> store.js', () => { + + describe('> Store', () => { + const getTokenSpy = sinon + .stub(Store.prototype, 'getToken') + .returns(Promise.resolve(fakeToken)) + + const store = new Store({ objStorateRootUrl }) + + afterEach(() => { + getTokenSpy.resetHistory() + }) + + it('> spy works', async () => { + expect(getTokenSpy.called).to.be.true + + const token = await store.getToken() + expect(token).to.equal(fakeToken) + }) + + it('> get works', async () => { + const scope = nock(objStorateRootUrl) + .get(`/${objName}`) + .reply(200, objContent) + + const content = await store.get(objName) + expect(content).to.equal(objContent) + expect(scope.isDone()).to.be.true + + }) + + it('> set works', async () => { + + const scope = nock(objStorateRootUrl) + .put(`/${objName}`) + .reply(200) + + scope.on('request', (req, int, body) => { + expect(body).to.equal(objContent) + }) + + await store.set(objName, objContent) + + expect(scope.isDone()).to.be.true + }) + }) +}) diff --git a/deploy/server.js b/deploy/server.js index ce483fb83..e2a2d8024 100644 --- a/deploy/server.js +++ b/deploy/server.js @@ -71,6 +71,8 @@ if(HOST_PATHNAME !== '') { if (HOST_PATHNAME.slice(-1) === '/') throw new Error(`HOST_PATHNAME, if defined and non-emtpy, should NOT end with a slash. HOST_PATHNAME: ${HOST_PATHNAME}`) } +server.set('trust proxy', 1) + server.disable('x-powered-by') server.use(HOST_PATHNAME, app) diff --git a/src/auth/auth.directive.ts b/src/auth/auth.directive.ts new file mode 100644 index 000000000..982a57b3d --- /dev/null +++ b/src/auth/auth.directive.ts @@ -0,0 +1,18 @@ +import { Directive } from "@angular/core"; +import { Observable } from "rxjs"; +import { IUser, AuthService } from './auth.service' + +@Directive({ + selector: '[iav-auth-authState]', + exportAs: 'iavAuthAuthState' +}) + +export class AuthStateDdirective{ + public user$: Observable<IUser> + + constructor( + private authService: AuthService, + ){ + this.user$ = this.authService.user$ + } +} diff --git a/src/auth/auth.module.ts b/src/auth/auth.module.ts new file mode 100644 index 000000000..bbd75a841 --- /dev/null +++ b/src/auth/auth.module.ts @@ -0,0 +1,26 @@ +import { NgModule } from "@angular/core"; +import { SigninModal } from "./signinModal/signinModal.component"; +import { CommonModule } from "@angular/common"; +import { AngularMaterialModule } from "src/ui/sharedModules/angularMaterial.module"; +import { AuthService } from "./auth.service"; +import { AuthStateDdirective } from "./auth.directive"; + +@NgModule({ + imports: [ + CommonModule, + AngularMaterialModule, + ], + declarations: [ + SigninModal, + AuthStateDdirective, + ], + exports: [ + SigninModal, + AuthStateDdirective, + ], + providers: [ + AuthService, + ] +}) + +export class AuthModule{} diff --git a/src/services/auth.service.ts b/src/auth/auth.service.ts similarity index 95% rename from src/services/auth.service.ts rename to src/auth/auth.service.ts index fdec1e6ef..12720ca92 100644 --- a/src/services/auth.service.ts +++ b/src/auth/auth.service.ts @@ -1,7 +1,7 @@ import { HttpClient } from "@angular/common/http"; import { Injectable, OnDestroy } from "@angular/core"; import { Observable, of, Subscription } from "rxjs"; -import { catchError, shareReplay } from "rxjs/operators"; +import { catchError, shareReplay, mapTo } from "rxjs/operators"; const IV_REDIRECT_TOKEN = `IV_REDIRECT_TOKEN` diff --git a/src/auth/index.ts b/src/auth/index.ts new file mode 100644 index 000000000..25b4f9e47 --- /dev/null +++ b/src/auth/index.ts @@ -0,0 +1,2 @@ +export { AuthModule } from './auth.module' +export { AuthService } from './auth.service' \ No newline at end of file diff --git a/src/ui/signinModal/signinModal.component.ts b/src/auth/signinModal/signinModal.component.ts similarity index 88% rename from src/ui/signinModal/signinModal.component.ts rename to src/auth/signinModal/signinModal.component.ts index c05a1e81e..5cb7ba742 100644 --- a/src/ui/signinModal/signinModal.component.ts +++ b/src/auth/signinModal/signinModal.component.ts @@ -1,5 +1,5 @@ import { Component } from "@angular/core"; -import { AuthService, IAuthMethod, IUser } from "src/services/auth.service"; +import { AuthService, IAuthMethod, IUser } from "../auth.service"; @Component({ selector: 'signin-modal', diff --git a/src/ui/signinModal/signinModal.style.css b/src/auth/signinModal/signinModal.style.css similarity index 100% rename from src/ui/signinModal/signinModal.style.css rename to src/auth/signinModal/signinModal.style.css diff --git a/src/ui/signinModal/signinModal.template.html b/src/auth/signinModal/signinModal.template.html similarity index 100% rename from src/ui/signinModal/signinModal.template.html rename to src/auth/signinModal/signinModal.template.html diff --git a/src/main.module.ts b/src/main.module.ts index 074035bba..fa397086a 100644 --- a/src/main.module.ts +++ b/src/main.module.ts @@ -20,7 +20,6 @@ import { ModalUnit } from "./atlasViewer/modalUnit/modalUnit.component"; import { TransformOnhoverSegmentPipe } from "./atlasViewer/onhoverSegment.pipe"; import { ConfirmDialogComponent } from "./components/confirmDialog/confirmDialog.component"; import { DialogComponent } from "./components/dialog/dialog.component"; -import { AuthService } from "./services/auth.service"; import { DialogService } from "./services/dialogService.service"; import { UseEffects } from "./services/effect/effect"; import { LocalFileService } from "./services/localFile.service"; @@ -52,6 +51,7 @@ import 'src/res/css/extra_styles.css' import 'src/res/css/version.css' import 'src/theme.scss' import { ShareModule } from './share'; +import { AuthService } from './auth' @NgModule({ imports : [ diff --git a/src/share/saneUrl/saneUrl.component.spec.ts b/src/share/saneUrl/saneUrl.component.spec.ts new file mode 100644 index 000000000..4b830bb1a --- /dev/null +++ b/src/share/saneUrl/saneUrl.component.spec.ts @@ -0,0 +1,315 @@ +import { TestBed, async, fakeAsync, tick, flush } from '@angular/core/testing' +import { ShareModule } from '../share.module' +import { SaneUrl } from './saneUrl.component' +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing' +import { By } from '@angular/platform-browser' +import { BACKENDURL } from 'src/util/constants' +import { NoopAnimationsModule } from '@angular/platform-browser/animations' + +const inputCss = `input[aria-label="Custom link"]` +const submitCss = `button[aria-label="Create custom link"]` +const copyBtnCss = `button[aria-label="Copy created custom URL to clipboard"]` + +describe('> saneUrl.component.ts', () => { + describe('> SaneUrl', () => { + beforeEach(async(() => { + TestBed.configureTestingModule({ + imports: [ + ShareModule, + HttpClientTestingModule, + NoopAnimationsModule, + ] + }).compileComponents() + })) + + afterEach(() => { + const ctrl = TestBed.inject(HttpTestingController) + ctrl.verify() + }) + + it('> can be created', () => { + const fixture = TestBed.createComponent(SaneUrl) + const el = fixture.debugElement.componentInstance + expect(el).toBeTruthy() + }) + + it('> all elements exist', () => { + const fixture = TestBed.createComponent(SaneUrl) + + const input = fixture.debugElement.query( By.css( inputCss ) ) + expect(input).toBeTruthy() + + const submit = fixture.debugElement.query( By.css( submitCss ) ) + expect(submit).toBeTruthy() + + const cpyBtn = fixture.debugElement.query( By.css( copyBtnCss ) ) + expect(cpyBtn).toBeFalsy() + }) + + it('> catches invalid input syncly', fakeAsync(() => { + + const failValue = `test-1` + + // Necessary to detectChanges, or formControl will not initialise properly + // See https://stackoverflow.com/a/56600762/6059235 + const fixture = TestBed.createComponent(SaneUrl) + fixture.detectChanges() + + // Set fail value + fixture.componentInstance.customUrl.setValue(failValue) + + // Expect validator to fail catch it + expect(fixture.componentInstance.customUrl.invalid).toEqual(true) + + // on change detection, UI should catch it + fixture.detectChanges() + + const input = fixture.debugElement.query( By.css( inputCss ) ) + const invalid = input.attributes['aria-invalid'] + expect(invalid.toString()).toEqual('true') + + })) + + it('> when user inputs valid input, does not not invalidate', () => { + + const successValue = `test_1` + + // Necessary to detectChanges, or formControl will not initialise properly + // See https://stackoverflow.com/a/56600762/6059235 + const fixture = TestBed.createComponent(SaneUrl) + fixture.detectChanges() + + // Set fail value + fixture.componentInstance.customUrl.setValue(successValue) + + // Expect validator to fail catch it + expect(fixture.componentInstance.customUrl.invalid).toEqual(false) + + // on change detection, UI should catch it + fixture.detectChanges() + + const input = fixture.debugElement.query( By.css( inputCss ) ) + const invalid = input.attributes['aria-invalid'] + expect(invalid.toString()).toEqual('false') + }) + + it('> on entering string in input, makes debounced GET request', fakeAsync(() => { + + const value = 'test_1' + + const httpTestingController = TestBed.inject(HttpTestingController) + + // Necessary to detectChanges, or formControl will not initialise properly + // See https://stackoverflow.com/a/56600762/6059235 + const fixture = TestBed.createComponent(SaneUrl) + fixture.detectChanges() + + // Set value + fixture.componentInstance.customUrl.setValue(value) + + tick(500) + + const req = httpTestingController.expectOne(`${BACKENDURL}saneUrl/${value}`) + req.flush(200) + })) + + it('> on 200 response, show error', fakeAsync(() => { + + const value = 'test_1' + + const httpTestingController = TestBed.inject(HttpTestingController) + + // Necessary to detectChanges, or formControl will not initialise properly + // See https://stackoverflow.com/a/56600762/6059235 + const fixture = TestBed.createComponent(SaneUrl) + fixture.detectChanges() + + // Set value + fixture.componentInstance.customUrl.setValue(value) + + tick(500) + + const req = httpTestingController.expectOne(`${BACKENDURL}saneUrl/${value}`) + req.flush('OK') + + // Expect validator to fail catch it + expect(fixture.componentInstance.customUrl.invalid).toEqual(true) + + // on change detection, UI should catch it + fixture.detectChanges() + + const input = fixture.debugElement.query( By.css( inputCss ) ) + const invalid = input.attributes['aria-invalid'] + expect(invalid.toString()).toEqual('true') + + const submit = fixture.debugElement.query( By.css( submitCss ) ) + const disabled = !!submit.attributes['disabled'] + expect(disabled.toString()).toEqual('true') + })) + + it('> on 404 response, show available', fakeAsync(() => { + + const value = 'test_1' + + const httpTestingController = TestBed.inject(HttpTestingController) + + // Necessary to detectChanges, or formControl will not initialise properly + // See https://stackoverflow.com/a/56600762/6059235 + const fixture = TestBed.createComponent(SaneUrl) + fixture.detectChanges() + + // Set value + fixture.componentInstance.customUrl.setValue(value) + + tick(500) + + const req = httpTestingController.expectOne(`${BACKENDURL}saneUrl/${value}`) + req.flush('some reason', { status: 404, statusText: 'Not Found.' }) + + // Expect validator to fail catch it + expect(fixture.componentInstance.customUrl.invalid).toEqual(false) + + // on change detection, UI should catch it + fixture.detectChanges() + + const input = fixture.debugElement.query( By.css( inputCss ) ) + const invalid = input.attributes['aria-invalid'] + expect(invalid.toString()).toEqual('false') + + const submit = fixture.debugElement.query( By.css( submitCss ) ) + const disabled = !!submit.attributes['disabled'] + expect(disabled.toString()).toEqual('false') + })) + + it('> on other error codes, show invalid', fakeAsync(() => { + + const value = 'test_1' + + const httpTestingController = TestBed.inject(HttpTestingController) + + // Necessary to detectChanges, or formControl will not initialise properly + // See https://stackoverflow.com/a/56600762/6059235 + const fixture = TestBed.createComponent(SaneUrl) + fixture.detectChanges() + + // Set value + fixture.componentInstance.customUrl.setValue(value) + + tick(500) + + const req = httpTestingController.expectOne(`${BACKENDURL}saneUrl/${value}`) + req.flush('some reason', { status: 401, statusText: 'Unauthorised.' }) + + // Expect validator to fail catch it + expect(fixture.componentInstance.customUrl.invalid).toEqual(true) + + // on change detection, UI should catch it + fixture.detectChanges() + + const input = fixture.debugElement.query( By.css( inputCss ) ) + const invalid = input.attributes['aria-invalid'] + expect(invalid.toString()).toEqual('true') + + const submit = fixture.debugElement.query( By.css( submitCss ) ) + const disabled = !!submit.attributes['disabled'] + expect(disabled.toString()).toEqual('true') + })) + + it('> on click create link btn calls correct API', fakeAsync(() => { + + const value = 'test_1' + + const httpTestingController = TestBed.inject(HttpTestingController) + + // Necessary to detectChanges, or formControl will not initialise properly + // See https://stackoverflow.com/a/56600762/6059235 + const fixture = TestBed.createComponent(SaneUrl) + fixture.detectChanges() + + // Set value + fixture.componentInstance.customUrl.setValue(value) + + tick(500) + + const req = httpTestingController.expectOne(`${BACKENDURL}saneUrl/${value}`) + req.flush('some reason', { status: 404, statusText: 'Not Found.' }) + + fixture.detectChanges() + flush() + + const submit = fixture.debugElement.query( By.css( submitCss ) ) + const disabled = !!submit.attributes['disabled'] + expect(disabled.toString()).toEqual('false') + + submit.triggerEventHandler('click', {}) + + fixture.detectChanges() + + const disabledInProgress = !!submit.attributes['disabled'] + expect(disabledInProgress.toString()).toEqual('true') + + const req2 = httpTestingController.expectOne({ + method: 'POST', + url: `${BACKENDURL}saneUrl/${value}` + }) + + req2.flush({}) + + fixture.detectChanges() + + const disabledAfterComplete = !!submit.attributes['disabled'] + expect(disabledAfterComplete.toString()).toEqual('true') + + const cpyBtn = fixture.debugElement.query( By.css( copyBtnCss ) ) + expect(cpyBtn).toBeTruthy() + })) + + it('> on click create link btn fails show result', fakeAsync(() => { + + const value = 'test_1' + + const httpTestingController = TestBed.inject(HttpTestingController) + + // Necessary to detectChanges, or formControl will not initialise properly + // See https://stackoverflow.com/a/56600762/6059235 + const fixture = TestBed.createComponent(SaneUrl) + fixture.detectChanges() + + // Set value + fixture.componentInstance.customUrl.setValue(value) + + tick(500) + + const req = httpTestingController.expectOne(`${BACKENDURL}saneUrl/${value}`) + req.flush('some reason', { status: 404, statusText: 'Not Found.' }) + + fixture.detectChanges() + flush() + + const submit = fixture.debugElement.query( By.css( submitCss ) ) + const disabled = !!submit.attributes['disabled'] + expect(disabled.toString()).toEqual('false') + + submit.triggerEventHandler('click', {}) + + fixture.detectChanges() + + const disabledInProgress = !!submit.attributes['disabled'] + expect(disabledInProgress.toString()).toEqual('true') + + const req2 = httpTestingController.expectOne({ + method: 'POST', + url: `${BACKENDURL}saneUrl/${value}` + }) + + req2.flush('Something went wrong', { statusText: 'Wrong status text', status: 500 }) + + fixture.detectChanges() + + const input = fixture.debugElement.query( By.css( inputCss ) ) + const invalid = input.attributes['aria-invalid'] + expect(invalid.toString()).toEqual('true') + + })) + }) +}) diff --git a/src/share/saneUrl/saneUrl.component.ts b/src/share/saneUrl/saneUrl.component.ts new file mode 100644 index 000000000..c397478f5 --- /dev/null +++ b/src/share/saneUrl/saneUrl.component.ts @@ -0,0 +1,189 @@ +import { Component, OnDestroy, Input } from "@angular/core"; +import { HttpClient } from '@angular/common/http' +import { BACKENDURL } from 'src/util/constants' +import { Observable, merge, of, Subscription, BehaviorSubject, combineLatest } from "rxjs"; +import { startWith, mapTo, map, debounceTime, switchMap, catchError, shareReplay, filter, tap, takeUntil, distinctUntilChanged } from "rxjs/operators"; +import { FormControl } from "@angular/forms"; +import { ErrorStateMatcher } from "@angular/material/core"; +import { Clipboard } from "@angular/cdk/clipboard"; +import { MatSnackBar } from "@angular/material/snack-bar"; + +export class SaneUrlErrorStateMatcher implements ErrorStateMatcher{ + isErrorState(ctrl: FormControl | null){ + return !!(ctrl && ctrl.invalid) + } +} + +enum ESavingProgress { + INIT, + INPROGRESS, + DONE, + DEFAULT, + ERROR, +} + +enum EBtnTxt { + AVAILABLE = 'Available', + VERIFYING = 'Verifying ...', + CREATING = 'Creating ...', + CREATED = 'Created!', + DEFAULT = '...', +} + +enum ESavingStatus { + PENDING, + FREE, + NOTFREE, +} + +@Component({ + selector: 'iav-sane-url', + templateUrl: './saneUrl.template.html' +}) + +export class SaneUrl implements OnDestroy{ + + @Input() stateTobeSaved: any + + private subscriptions: Subscription[] = [] + + private validator = (val: string) => /^[a-zA-Z0-9_]+$/.test(val) + public customUrl = new FormControl('') + + public matcher = new SaneUrlErrorStateMatcher() + + public createBtnDisabled$: Observable<boolean> + public iconClass$: Observable<string> + + public savingStatus$: Observable<ESavingStatus> + public btnHintTxt$: Observable<EBtnTxt> + + public savingProgress$: BehaviorSubject<ESavingProgress> = new BehaviorSubject(ESavingProgress.INIT) + public saved$: Observable<boolean> + + constructor( + private http: HttpClient, + private clipboard: Clipboard, + private snackbar: MatSnackBar, + ){ + + const validatedValueInput$ = this.customUrl.valueChanges.pipe( + tap(val => { + if (!this.validator(val)) { + this.customUrl.setErrors({ + message: 'Shortname must only use the following characters: a-zA-Z0-9_' + }) + } + }), + filter(this.validator), + distinctUntilChanged(), + shareReplay(1), + ) + + const checkAvailable$ = validatedValueInput$.pipe( + debounceTime(500), + switchMap(val => val === '' + ? of(false) + : this.http.get(`${this.saneUrlRoot}${val}`).pipe( + mapTo(false), + catchError((err, obs) => { + const { status } = err + if (status === 404) return of(true) + return of(false) + }) + ) + ), + shareReplay(1) + ) + + this.savingStatus$ = merge( + this.customUrl.valueChanges.pipe( + mapTo(ESavingStatus.PENDING) + ), + checkAvailable$.pipe( + map(available => available ? ESavingStatus.FREE : ESavingStatus.NOTFREE) + ) + ) + + this.btnHintTxt$ = combineLatest( + this.savingStatus$, + this.savingProgress$, + ).pipe( + map(([savingStatus, savingProgress]) => { + if (savingProgress === ESavingProgress.DONE) return EBtnTxt.CREATED + if (savingProgress === ESavingProgress.INPROGRESS) return EBtnTxt.CREATING + if (savingStatus === ESavingStatus.FREE) return EBtnTxt.AVAILABLE + if (savingStatus === ESavingStatus.PENDING) return EBtnTxt.VERIFYING + return EBtnTxt.DEFAULT + }) + ) + + this.createBtnDisabled$ = this.savingStatus$.pipe( + map(val => val !== ESavingStatus.FREE), + startWith(true) + ) + + this.iconClass$ = combineLatest( + this.savingStatus$, + this.savingProgress$, + ).pipe( + map(([savingStatus, savingProgress]) => { + if (savingProgress === ESavingProgress.DONE) return `fas fa-check` + if (savingProgress === ESavingProgress.INPROGRESS) return `fas fa-spinner fa-spin` + if (savingStatus === ESavingStatus.FREE) return `fas fa-link` + if (savingStatus === ESavingStatus.PENDING) return `fas fa-spinner fa-spin` + if (savingStatus === ESavingStatus.NOTFREE) return `fas fa-ban` + return EBtnTxt.DEFAULT + }), + startWith('fas fa-ban'), + ) + + this.subscriptions.push( + checkAvailable$.subscribe(flag => { + if (!flag) this.customUrl.setErrors({ message: 'Shortname not available' }) + }) + ) + + this.saved$ = this.savingProgress$.pipe( + map(v => v === ESavingProgress.DONE), + startWith(false), + ) + } + + ngOnDestroy(){ + while(this.subscriptions.length > 0){ + this.subscriptions.pop().unsubscribe() + } + } + + saveLink(){ + this.savingProgress$.next(ESavingProgress.INPROGRESS) + this.customUrl.disable() + this.http.post( + `${this.saneUrlRoot}${this.customUrl.value}`, + this.stateTobeSaved + ).subscribe( + resp => { + this.savingProgress$.next(ESavingProgress.DONE) + }, + err => { + this.customUrl.enable() + + const { status, error, statusText } = err + this.customUrl.setErrors({ message: `${status}: ${error || statusText}` }) + this.savingProgress$.next(ESavingProgress.INIT) + }, + ) + } + + copyLinkToClipboard(){ + const success = this.clipboard.copy(`${this.saneUrlRoot}${this.customUrl.value}`) + this.snackbar.open( + success ? `Copied URL to clipboard!` : `Failed to copy URL to clipboard!`, + null, + { duration: 1000 } + ) + } + + public saneUrlRoot = `${BACKENDURL}saneUrl/` +} diff --git a/src/share/saneUrl/saneUrl.template.html b/src/share/saneUrl/saneUrl.template.html new file mode 100644 index 000000000..a673dcef7 --- /dev/null +++ b/src/share/saneUrl/saneUrl.template.html @@ -0,0 +1,46 @@ +<mat-form-field class="mr-2"> + <span matPrefix class="text-muted"> + {{ saneUrlRoot }} + </span> + + <input type="text" + autocomplete="off" + placeholder="my_custom_url" + matInput + aria-label="Custom link" + (keyup.enter)="submitBtn.disabled ? null : saveLink()" + [formControl]="customUrl" + [errorStateMatcher]="matcher"> + + <button mat-icon-button + matSuffix + aria-label="Copy created custom URL to clipboard" + matTooltip="Copy created custom URL to clipboard." + (click)="copyLinkToClipboard()" + *ngIf="saved$ | async" + color="primary"> + <i class="fas fa-copy"></i> + </button> + + <mat-error *ngIf="customUrl.invalid"> + Error: {{ customUrl.errors.message }} + </mat-error> + + <mat-hint> + {{ btnHintTxt$ | async }} + </mat-hint> + +</mat-form-field> + +<button mat-flat-button + (click)="saveLink()" + color="primary" + aria-label="Create custom link" + [disabled]="createBtnDisabled$ | async" + #submitBtn="matButton"> + + <i [class]="iconClass$ | async"></i> + <span> + {{ (saved$ | async) ? 'Created!' : 'Create' }} + </span> +</button> diff --git a/src/share/share.module.ts b/src/share/share.module.ts index 8ef45901e..385e347b9 100644 --- a/src/share/share.module.ts +++ b/src/share/share.module.ts @@ -1,16 +1,26 @@ import { NgModule } from "@angular/core"; import { AngularMaterialModule } from "src/ui/sharedModules/angularMaterial.module"; import { ClipboardCopy } from "./clipboardCopy.directive"; +import { HttpClientModule } from "@angular/common/http"; +import { SaneUrl } from "./saneUrl/saneUrl.component"; +import { CommonModule } from "@angular/common"; +import { ReactiveFormsModule, FormsModule } from "@angular/forms"; @NgModule({ imports: [ - AngularMaterialModule + AngularMaterialModule, + HttpClientModule, + CommonModule, + FormsModule, + ReactiveFormsModule, ], declarations: [ - ClipboardCopy + ClipboardCopy, + SaneUrl, ], exports: [ - ClipboardCopy + ClipboardCopy, + SaneUrl, ] }) diff --git a/src/share/shareSaneLink.directive.ts b/src/share/shareSaneLink.directive.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/src/state/index.ts b/src/state/index.ts new file mode 100644 index 000000000..d5efade12 --- /dev/null +++ b/src/state/index.ts @@ -0,0 +1 @@ +export { StateModule } from './state.module' diff --git a/src/state/state.module.ts b/src/state/state.module.ts new file mode 100644 index 000000000..2695945ac --- /dev/null +++ b/src/state/state.module.ts @@ -0,0 +1,18 @@ +import { NgModule } from "@angular/core"; +import { StateAggregator } from "./stateAggregator.directive"; + +// TODO +// perhaps this should be called StateUtilModule? +// or alternatively, slowly move all state related components to this module? +// urlutil should also be at least in this module folder + +@NgModule({ + declarations: [ + StateAggregator + ], + exports: [ + StateAggregator + ] +}) + +export class StateModule{} diff --git a/src/state/stateAggregator.directive.ts b/src/state/stateAggregator.directive.ts new file mode 100644 index 000000000..c9fd436c3 --- /dev/null +++ b/src/state/stateAggregator.directive.ts @@ -0,0 +1,38 @@ +import { Directive } from "@angular/core"; +import { Store } from "@ngrx/store"; +import { Observable } from "rxjs"; +import { map, debounceTime, shareReplay } from "rxjs/operators"; +import { IavRootStoreInterface } from "src/services/stateStore.service"; +import { cvtStateToSearchParam } from "src/atlasViewer/atlasViewer.urlUtil"; + +const jsonVersion = '0.0.1' + +interface IJsonifiedState { + ver: string + queryString: any +} + +@Directive({ + selector: '[iav-state-aggregator]', + exportAs: 'iavStateAggregator' +}) + +export class StateAggregator{ + + public jsonifiedSstate$: Observable<IJsonifiedState> + constructor( + private store$: Store<IavRootStoreInterface> + ){ + this.jsonifiedSstate$ = this.store$.pipe( + debounceTime(100), + map(json => { + const queryString = cvtStateToSearchParam(json) + return { + ver: jsonVersion, + queryString: queryString.toString() + } + }), + shareReplay(1) + ) + } +} diff --git a/src/ui/nehubaContainer/statusCard/statusCard.component.ts b/src/ui/nehubaContainer/statusCard/statusCard.component.ts index 0adce7701..f64f9c3ee 100644 --- a/src/ui/nehubaContainer/statusCard/statusCard.component.ts +++ b/src/ui/nehubaContainer/statusCard/statusCard.component.ts @@ -6,6 +6,7 @@ import { NehubaViewerUnit } from "../nehubaViewer/nehubaViewer.component"; import { Observable, Subscription, of, combineLatest, BehaviorSubject } from "rxjs"; import { distinctUntilChanged, shareReplay, map, filter, startWith } from "rxjs/operators"; import { MatBottomSheet } from "@angular/material/bottom-sheet"; +import { MatDialog } from "@angular/material/dialog"; @Component({ selector : 'ui-status-card', @@ -29,7 +30,8 @@ export class StatusCardComponent implements OnInit, OnChanges{ private store: Store<ViewerStateInterface>, private log: LoggingService, private store$: Store<IavRootStoreInterface>, - private bottomSheet: MatBottomSheet + private bottomSheet: MatBottomSheet, + private dialog: MatDialog, ) { const viewerState$ = this.store$.pipe( select('viewerState'), @@ -154,4 +156,8 @@ export class StatusCardComponent implements OnInit, OnChanges{ }, }) } + + openDialog(tmpl: TemplateRef<any>) { + this.dialog.open(tmpl) + } } diff --git a/src/ui/nehubaContainer/statusCard/statusCard.template.html b/src/ui/nehubaContainer/statusCard/statusCard.template.html index 3af8b0b15..fbb52fae9 100644 --- a/src/ui/nehubaContainer/statusCard/statusCard.template.html +++ b/src/ui/nehubaContainer/statusCard/statusCard.template.html @@ -78,7 +78,7 @@ <mat-form-field *ngIf="!isMobile" class="w-100"> <mat-label> - Cursor Pos + Cursor Position </mat-label> <input type="text" matInput @@ -99,11 +99,76 @@ <mat-icon class="mr-4" fontSet="fas" - fontIcon="fa-link"> + fontIcon="fa-copy"> </mat-icon> <span> Copy link to this view </span> </mat-list-item> + <mat-list-item (click)="openDialog(shareSaneUrl)"> + <mat-icon + class="mr-4" + fontSet="fas" + fontIcon="fa-link"> + </mat-icon> + + <span> + Create custom URL + </span> + + </mat-list-item> </mat-nav-list> -</ng-template> \ No newline at end of file +</ng-template> + +<ng-template #shareSaneUrl> + <h2 mat-dialog-title> + Create custom URL + </h2> + + <div mat-dialog-content> + <div iav-auth-authState + #authState="iavAuthAuthState"> + + <!-- Logged in. Explain that links will not expire, offer to logout --> + <ng-container *ngIf="authState.user$ | async as user; else otherTmpl"> + <span> + Logged in as {{ user.name }} + </span> + <button mat-button + color="warn" + tabindex="-1"> + <i class="fas fa-sign-in-alt"></i> + <span> + Logout + </span> + </button> + </ng-container> + + <!-- Not logged in. Offer to login --> + <ng-template #otherTmpl> + <span> + Not logged in + </span> + <signin-modal></signin-modal> + </ng-template> + </div> + + <!-- explain links expiration --> + <div class="text-muted mat-small"> + {{ (authState.user$ | async) ? 'Links you generate will not expire' : 'Links you generate will expire after 72 hours' }} + </div> + + <iav-sane-url iav-state-aggregator + [stateTobeSaved]="stateAggregator.jsonifiedSstate$ | async" + #stateAggregator="iavStateAggregator"> + </iav-sane-url> + </div> + + <div mat-dialog-actions + class="d-flex justify-content-center"> + <button mat-button + mat-dialog-close> + close + </button> + </div> +</ng-template> diff --git a/src/ui/signinBanner/signinBanner.components.ts b/src/ui/signinBanner/signinBanner.components.ts index 0e9c9cc97..c5036ae52 100644 --- a/src/ui/signinBanner/signinBanner.components.ts +++ b/src/ui/signinBanner/signinBanner.components.ts @@ -10,7 +10,7 @@ import { import { select, Store } from "@ngrx/store"; import { Observable } from "rxjs"; import { map } from "rxjs/operators"; -import { AuthService, IUser } from "src/services/auth.service"; +import { AuthService } from "src/auth"; import { IavRootStoreInterface, IDataEntry } from "src/services/stateStore.service"; import {MatDialog, MatDialogRef} from "@angular/material/dialog"; import {MatBottomSheet} from "@angular/material/bottom-sheet"; @@ -32,7 +32,7 @@ export class SigninBanner { @ViewChild('takeScreenshotElement', {read: ElementRef}) takeScreenshotElement: ElementRef - public user$: Observable<IUser> + public user$: Observable<any> public userBtnTooltip$: Observable<string> public favDataEntries$: Observable<IDataEntry[]> diff --git a/src/ui/ui.module.ts b/src/ui/ui.module.ts index 066031db5..3b57eea7d 100644 --- a/src/ui/ui.module.ts +++ b/src/ui/ui.module.ts @@ -47,7 +47,7 @@ import { MobileOverlay } from "./nehubaContainer/mobileOverlay/mobileOverlay.com import { MobileControlNubStylePipe } from "./nehubaContainer/pipes/mobileControlNubStyle.pipe"; import { StatusCardComponent } from "./nehubaContainer/statusCard/statusCard.component"; import { SigninBanner } from "./signinBanner/signinBanner.components"; -import { SigninModal } from "./signinModal/signinModal.component"; + import { TemplateParcellationCitationsContainer } from "./templateParcellationCitations/templateParcellationCitations.component"; import { FilterNameBySearch } from "./viewerStateController/regionHierachy/filterNameBySearch.pipe"; @@ -82,6 +82,8 @@ import { LandmarkUIComponent } from "./landmarkUI/landmarkUI.component"; import { NehubaModule } from "./nehubaContainer/nehuba.module"; import { LayerDetailComponent } from "./layerbrowser/layerDetail/layerDetail.component"; import { ShareModule } from "src/share"; +import { StateModule } from "src/state"; +import { AuthModule } from "src/auth"; @NgModule({ imports : [ @@ -96,6 +98,8 @@ import { ShareModule } from "src/share"; AngularMaterialModule, NehubaModule, ShareModule, + StateModule, + AuthModule, ], declarations : [ NehubaContainer, @@ -114,7 +118,7 @@ import { ShareModule } from "src/share"; HelpComponent, ConfigComponent, SigninBanner, - SigninModal, + StatusCardComponent, CookieAgreement, KGToS, @@ -192,7 +196,7 @@ import { ShareModule } from "src/share"; HelpComponent, ConfigComponent, SigninBanner, - SigninModal, + CookieAgreement, KGToS, StatusCardComponent, diff --git a/src/util/constants.ts b/src/util/constants.ts index b6f7198ec..a5776463a 100644 --- a/src/util/constants.ts +++ b/src/util/constants.ts @@ -11,4 +11,5 @@ export const LOCAL_STORAGE_CONST = { export const COOKIE_VERSION = '0.3.0' export const KG_TOS_VERSION = '0.3.0' -export const DS_PREVIEW_URL = DATASET_PREVIEW_URL \ No newline at end of file +export const DS_PREVIEW_URL = DATASET_PREVIEW_URL +export const BACKENDURL = BACKEND_URL || 'http://localhost:3000/' -- GitLab