mirror of
https://github.com/status-im/universal-links-handler.git
synced 2025-02-24 08:38:18 +00:00
Merge pull request #1 from status-im/features/add-android-asset-link
Add asset link & setup
This commit is contained in:
commit
832a3cba59
7
.dockerignore
Normal file
7
.dockerignore
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
|
README.md
|
||||||
|
tests/
|
||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
.git
|
26
.gitignore
vendored
Normal file
26
.gitignore
vendored
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
# OS X
|
||||||
|
.DS_Store*
|
||||||
|
Icon?
|
||||||
|
._*
|
||||||
|
|
||||||
|
# Windows
|
||||||
|
Thumbs.db
|
||||||
|
ehthumbs.db
|
||||||
|
Desktop.ini
|
||||||
|
|
||||||
|
# Linux
|
||||||
|
.directory
|
||||||
|
*~
|
||||||
|
|
||||||
|
|
||||||
|
# npm
|
||||||
|
node_modules
|
||||||
|
*.log
|
||||||
|
*.gz
|
||||||
|
|
||||||
|
|
||||||
|
# Coveralls
|
||||||
|
coverage
|
||||||
|
|
||||||
|
# Benchmarking
|
||||||
|
benchmarks/graphs
|
14
Dockerfile
Normal file
14
Dockerfile
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
FROM node:9-alpine
|
||||||
|
|
||||||
|
RUN mkdir -p /srv
|
||||||
|
|
||||||
|
ADD package.json /srv
|
||||||
|
ADD package-lock.json /srv
|
||||||
|
|
||||||
|
WORKDIR /srv
|
||||||
|
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
ADD . /srv
|
||||||
|
|
||||||
|
CMD npm start
|
50
README.md
Normal file
50
README.md
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
# Universal links handler
|
||||||
|
|
||||||
|
App to handle universal links
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
You need to have `docker` & `docker-compose` installed
|
||||||
|
|
||||||
|
### Development
|
||||||
|
|
||||||
|
Start `docker-compose` with:
|
||||||
|
|
||||||
|
```
|
||||||
|
docker-compose -p whatever up
|
||||||
|
```
|
||||||
|
|
||||||
|
It will listen on port `8080` and mount the correct volumes so any change
|
||||||
|
is then reflected.
|
||||||
|
|
||||||
|
`node_modules` are mounted as a volume so in case you change `package-lock.json`
|
||||||
|
will need to be re-installed in the docker container.
|
||||||
|
|
||||||
|
### Production locally
|
||||||
|
|
||||||
|
Start `docker-compose` with:
|
||||||
|
|
||||||
|
```
|
||||||
|
docker-compose -p whatever -f docker-compose.yml up
|
||||||
|
```
|
||||||
|
|
||||||
|
Don't forget to rebuild the image if you made any changes
|
||||||
|
|
||||||
|
```
|
||||||
|
docker-compose -p whatever -f docker-compose.yml build
|
||||||
|
```
|
||||||
|
|
||||||
|
It will build the image and start the container listening on port `8080`
|
||||||
|
|
||||||
|
## Running the tests
|
||||||
|
|
||||||
|
To run the tests, first start the container, either in `production` or `development` mode.
|
||||||
|
|
||||||
|
Then you can run `bash tests/run.sh -u localhost:8080` or to run against against the live server
|
||||||
|
`bash tests/run.sh -u http://get.status.im`
|
||||||
|
|
||||||
|
Uses the awesome `https://github.com/robwhitby/shakedown`
|
||||||
|
|
||||||
|
## Deployment
|
39
app.js
Normal file
39
app.js
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
var createError = require('http-errors');
|
||||||
|
var express = require('express');
|
||||||
|
var path = require('path');
|
||||||
|
var cookieParser = require('cookie-parser');
|
||||||
|
var logger = require('morgan');
|
||||||
|
|
||||||
|
var indexRouter = require('./routes/index');
|
||||||
|
|
||||||
|
var app = express();
|
||||||
|
|
||||||
|
// view engine setup
|
||||||
|
app.set('views', path.join(__dirname, 'views'));
|
||||||
|
app.set('view engine', 'ejs');
|
||||||
|
|
||||||
|
app.use(logger('dev'));
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(express.urlencoded({ extended: false }));
|
||||||
|
app.use(cookieParser());
|
||||||
|
app.use(express.static(path.join(__dirname, 'public')));
|
||||||
|
|
||||||
|
app.use('/', indexRouter);
|
||||||
|
|
||||||
|
// catch 404 and forward to error handler
|
||||||
|
app.use(function(req, res, next) {
|
||||||
|
next(createError(404));
|
||||||
|
});
|
||||||
|
|
||||||
|
// error handler
|
||||||
|
app.use(function(err, req, res, next) {
|
||||||
|
// set locals, only providing error in development
|
||||||
|
res.locals.message = err.message;
|
||||||
|
res.locals.error = req.app.get('env') === 'development' ? err : {};
|
||||||
|
|
||||||
|
// render the error page
|
||||||
|
res.status(err.status || 500);
|
||||||
|
res.render('error');
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = app;
|
90
bin/www
Executable file
90
bin/www
Executable file
@ -0,0 +1,90 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Module dependencies.
|
||||||
|
*/
|
||||||
|
|
||||||
|
var app = require('../app');
|
||||||
|
var debug = require('debug')('universal-links-handler:server');
|
||||||
|
var http = require('http');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get port from environment and store in Express.
|
||||||
|
*/
|
||||||
|
|
||||||
|
var port = normalizePort(process.env.PORT || '3000');
|
||||||
|
app.set('port', port);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create HTTP server.
|
||||||
|
*/
|
||||||
|
|
||||||
|
var server = http.createServer(app);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Listen on provided port, on all network interfaces.
|
||||||
|
*/
|
||||||
|
|
||||||
|
server.listen(port);
|
||||||
|
server.on('error', onError);
|
||||||
|
server.on('listening', onListening);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a port into a number, string, or false.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function normalizePort(val) {
|
||||||
|
var port = parseInt(val, 10);
|
||||||
|
|
||||||
|
if (isNaN(port)) {
|
||||||
|
// named pipe
|
||||||
|
return val;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (port >= 0) {
|
||||||
|
// port number
|
||||||
|
return port;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event listener for HTTP server "error" event.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function onError(error) {
|
||||||
|
if (error.syscall !== 'listen') {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
var bind = typeof port === 'string'
|
||||||
|
? 'Pipe ' + port
|
||||||
|
: 'Port ' + port;
|
||||||
|
|
||||||
|
// handle specific listen errors with friendly messages
|
||||||
|
switch (error.code) {
|
||||||
|
case 'EACCES':
|
||||||
|
console.error(bind + ' requires elevated privileges');
|
||||||
|
process.exit(1);
|
||||||
|
break;
|
||||||
|
case 'EADDRINUSE':
|
||||||
|
console.error(bind + ' is already in use');
|
||||||
|
process.exit(1);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Event listener for HTTP server "listening" event.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function onListening() {
|
||||||
|
var addr = server.address();
|
||||||
|
var bind = typeof addr === 'string'
|
||||||
|
? 'pipe ' + addr
|
||||||
|
: 'port ' + addr.port;
|
||||||
|
debug('Listening on ' + bind);
|
||||||
|
}
|
11
docker-compose.override.yml
Normal file
11
docker-compose.override.yml
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
version: '3'
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
image: node:9-alpine
|
||||||
|
working_dir: /srv
|
||||||
|
volumes:
|
||||||
|
- ./:/srv
|
||||||
|
- node_modules:/srv/node_modules
|
||||||
|
command: npm run watch
|
||||||
|
volumes:
|
||||||
|
node_modules:
|
9
docker-compose.yml
Normal file
9
docker-compose.yml
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
version: '3'
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
environment:
|
||||||
|
PORT: 80
|
||||||
|
ports:
|
||||||
|
- 8080:80
|
3016
package-lock.json
generated
Normal file
3016
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
package.json
Normal file
20
package.json
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "universal-links-handler",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"start": "node ./bin/www",
|
||||||
|
"watch": "nodemon ./bin/www"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"cookie-parser": "~1.4.3",
|
||||||
|
"debug": "~2.6.9",
|
||||||
|
"ejs": "~2.5.7",
|
||||||
|
"express": "~4.16.0",
|
||||||
|
"http-errors": "~1.6.2",
|
||||||
|
"morgan": "~1.9.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"nodemon": "^1.17.5"
|
||||||
|
}
|
||||||
|
}
|
31
public/javascripts/app.js
Normal file
31
public/javascripts/app.js
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
(function() {
|
||||||
|
function buildPlayStoreUrl() {
|
||||||
|
var androidId = $('meta[property="al:android:package"]').attr("content");
|
||||||
|
return "https://play.google.com/store/apps/details?id=" + androidId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildItunesUrl() {
|
||||||
|
|
||||||
|
var iosId = $('meta[property="al:ios:app_store_id"]').attr("content");
|
||||||
|
return "https://itunes.apple.com/app/id" + iosId;
|
||||||
|
}
|
||||||
|
function isAndroid(userAgent) {
|
||||||
|
return userAgent.toLowerCase().indexOf("android") > -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isIOS(userAgent) {
|
||||||
|
return userAgent.toLowerCase().indexOf("iphone") > -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
$(document).ready(function () {
|
||||||
|
|
||||||
|
var appStoreLink = $("#app-store-link");
|
||||||
|
if (isAndroid(navigator.userAgent)) {
|
||||||
|
appStoreLink.attr('href', buildPlayStoreUrl());
|
||||||
|
} else if (isIOS(navigator.userAgent)) {
|
||||||
|
appStoreLink.attr('href', buildItunesUrl());
|
||||||
|
} else {
|
||||||
|
appStoreLink.attr('href', buildPlayStoreUrl());
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}());
|
8
public/stylesheets/style.css
Normal file
8
public/stylesheets/style.css
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
body {
|
||||||
|
padding: 50px;
|
||||||
|
font: 14px "Lucida Grande", Helvetica, Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: #00B7FF;
|
||||||
|
}
|
5
resources/assetlinks.json
Normal file
5
resources/assetlinks.json
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
[{
|
||||||
|
"relation": ["delegate_permission/common.handle_all_urls"],
|
||||||
|
"target" : { "namespace": "android_app", "package_name": "im.status.ethereum",
|
||||||
|
"sha256_cert_fingerprints": ["29:58:6A:6B:A0:CA:FA:A5:38:AA:CE:EA:DF:60:55:1C:EC:22:E9:65:68:40:35:D4:75:B7:9A:0A:8B:13:E2:9F"] }
|
||||||
|
}]
|
31
routes/index.js
Normal file
31
routes/index.js
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
var express = require('express');
|
||||||
|
var router = express.Router();
|
||||||
|
var assetLinks = require('../resources/assetlinks.json');
|
||||||
|
|
||||||
|
|
||||||
|
router.get('/.well-known/assetlinks.json', function(req, res) {
|
||||||
|
res.json(assetLinks);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/chat/:chatType/:chatId', function(req, res, next) {
|
||||||
|
res.render('index', {
|
||||||
|
title: 'Status.im join ' + req.params.chatId + ' chat',
|
||||||
|
path: req.originalUrl
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/user/:userId', function(req, res, next) {
|
||||||
|
res.render('index', {
|
||||||
|
title: 'Status.im view ' + req.params.userId + ' profile',
|
||||||
|
path: req.originalUrl
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get('/browse/:url', function(req, res, next) {
|
||||||
|
res.render('index', {
|
||||||
|
title: 'Status.im browse ' + req.params.url + ' dapp',
|
||||||
|
path: req.originalUrl
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
16
tests/run.sh
Normal file
16
tests/run.sh
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
source tests/shakedown.sh
|
||||||
|
|
||||||
|
shakedown GET /.well-known/assetlinks.json
|
||||||
|
status 200
|
||||||
|
content_type 'application/json'
|
||||||
|
contains 'sha256_cert_fingerprints'
|
||||||
|
|
||||||
|
shakedown GET /chat/public/abc
|
||||||
|
status 200
|
||||||
|
|
||||||
|
shakedown GET /user/blah
|
||||||
|
status 200
|
||||||
|
|
||||||
|
shakedown GET /browse/www.test.com
|
||||||
|
status 200
|
149
tests/shakedown.sh
Normal file
149
tests/shakedown.sh
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -u
|
||||||
|
|
||||||
|
BASE_URL=${SHAKEDOWN_URL:-""}
|
||||||
|
CREDENTIALS=${SHAKEDOWN_CREDENTIALS:-""}
|
||||||
|
|
||||||
|
_usage() {
|
||||||
|
echo '
|
||||||
|
usage: $0 [options...]
|
||||||
|
Options:
|
||||||
|
-u <base URL> Base URL to test.
|
||||||
|
-c <user:password> Credentials for HTTP authentication.
|
||||||
|
'
|
||||||
|
exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
while getopts 'u:c:' OPTION
|
||||||
|
do
|
||||||
|
case $OPTION in
|
||||||
|
u) BASE_URL="$OPTARG";;
|
||||||
|
c) CREDENTIALS="$OPTARG";;
|
||||||
|
*) _usage;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
|
||||||
|
echo "Starting shakedown of ${BASE_URL:-"[base URL not set]"}"
|
||||||
|
|
||||||
|
STATE=""
|
||||||
|
FAIL_COUNT=0
|
||||||
|
PASS_COUNT=0
|
||||||
|
WORKING_DIR=$(mktemp -d -t shakedown.XXXXXX)
|
||||||
|
RESPONSE_BODY="${WORKING_DIR}/body"
|
||||||
|
RESPONSE_HEADERS="${WORKING_DIR}/headers"
|
||||||
|
|
||||||
|
AUTH=""
|
||||||
|
if [ -n "${CREDENTIALS}" ]; then
|
||||||
|
AUTH="--anyauth --user ${CREDENTIALS}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
CURL="curl -sS ${AUTH} -D ${RESPONSE_HEADERS} --connect-timeout 5 --max-time 30"
|
||||||
|
|
||||||
|
CRED=$(tput setaf 1 2> /dev/null)
|
||||||
|
CGREEN=$(tput setaf 2 2> /dev/null)
|
||||||
|
CDEFAULT=$(tput sgr0 2> /dev/null)
|
||||||
|
|
||||||
|
_pass() {
|
||||||
|
echo " ${CGREEN}✔ ${1}${CDEFAULT}"
|
||||||
|
}
|
||||||
|
|
||||||
|
_fail() {
|
||||||
|
STATE="fail"
|
||||||
|
echo " ${CRED}✘ ${1}${CDEFAULT}"
|
||||||
|
}
|
||||||
|
|
||||||
|
_start_test() {
|
||||||
|
_finish_test
|
||||||
|
STATE="pass"
|
||||||
|
}
|
||||||
|
|
||||||
|
_finish_test() {
|
||||||
|
if [ "$STATE" = "pass" ]; then
|
||||||
|
((PASS_COUNT++))
|
||||||
|
elif [ "$STATE" = "fail" ]; then
|
||||||
|
((FAIL_COUNT++))
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
_finish() {
|
||||||
|
_finish_test
|
||||||
|
rm -rf "${WORKING_DIR}"
|
||||||
|
echo
|
||||||
|
MSG="Shakedown complete. ${PASS_COUNT} passed, ${FAIL_COUNT} failed."
|
||||||
|
[[ ${FAIL_COUNT} -eq 0 ]] && echo "${CGREEN}${MSG}${CDEFAULT}" || echo "${CRED}${MSG} You're busted.${CDEFAULT}"
|
||||||
|
exit ${FAIL_COUNT}
|
||||||
|
}
|
||||||
|
|
||||||
|
trap _finish EXIT
|
||||||
|
|
||||||
|
# start test
|
||||||
|
# $1 METHOD
|
||||||
|
# $2 URL
|
||||||
|
# $3..$n Custom CURL options
|
||||||
|
shakedown() {
|
||||||
|
_start_test
|
||||||
|
METHOD="$1"
|
||||||
|
URL="$2"
|
||||||
|
if ! [[ $URL == http* ]]; then
|
||||||
|
URL="${BASE_URL}${URL}"
|
||||||
|
fi
|
||||||
|
echo
|
||||||
|
echo "${METHOD} ${URL}"
|
||||||
|
METHOD_OPT="-X ${METHOD}"
|
||||||
|
if [ "${METHOD}" = "HEAD" ]; then
|
||||||
|
METHOD_OPT="-I"
|
||||||
|
fi
|
||||||
|
${CURL} ${METHOD_OPT} "${@:3}" "${URL}" > ${RESPONSE_BODY}
|
||||||
|
}
|
||||||
|
|
||||||
|
# assertions
|
||||||
|
|
||||||
|
header() {
|
||||||
|
grep -Fq "${1}" "${RESPONSE_HEADERS}" && _pass "header ${1}" || _fail "header ${1}"
|
||||||
|
}
|
||||||
|
|
||||||
|
no_header() {
|
||||||
|
grep -Fq "${1}" "${RESPONSE_HEADERS}" && _fail "no_header ${1}" || _pass "no_header ${1}"
|
||||||
|
}
|
||||||
|
|
||||||
|
status() {
|
||||||
|
STATUS_CODE=$(grep -Eo "^HTTP.+ [1-5][0-9][0-9] " ${RESPONSE_HEADERS} | grep -Eo '[1-5][0-9][0-9]' | tail -n1)
|
||||||
|
[[ "${STATUS_CODE}" = "${1}" ]] && _pass "status ${1}" || _fail "status ${1} (actual: ${STATUS_CODE})"
|
||||||
|
}
|
||||||
|
|
||||||
|
contains() {
|
||||||
|
MSG="contains \"${1}\""
|
||||||
|
grep -Fq "${1}" "${RESPONSE_BODY}" && _pass "${MSG}" || _fail "${MSG}"
|
||||||
|
}
|
||||||
|
|
||||||
|
matches() {
|
||||||
|
MSG="matches \"${1}\""
|
||||||
|
grep -Eq "${1}" "${RESPONSE_BODY}" && _pass "${MSG}" || _fail "${MSG}"
|
||||||
|
}
|
||||||
|
|
||||||
|
content_type() {
|
||||||
|
CT_HEADER="$(_get_header 'Content-Type')"
|
||||||
|
echo "${CT_HEADER}" | grep -Fq "${1}" && _pass "Content-Type: ${1}" || _fail "Content-Type: ${1} (actual: ${CT_HEADER})"
|
||||||
|
}
|
||||||
|
|
||||||
|
header_contains() {
|
||||||
|
HEADER_NAME=${1}
|
||||||
|
HEADER="$(_get_header $HEADER_NAME)"
|
||||||
|
echo "${HEADER}" | grep -Fq "${2}" && _pass "${HEADER_NAME}: ${2}" || _fail "${HEADER_NAME}: ${2} (actual: ${HEADER})"
|
||||||
|
}
|
||||||
|
|
||||||
|
_get_header() {
|
||||||
|
grep -F "${1}" "${RESPONSE_HEADERS}" | tr -d '\r'
|
||||||
|
}
|
||||||
|
|
||||||
|
# debug
|
||||||
|
|
||||||
|
print_headers() {
|
||||||
|
cat "${RESPONSE_HEADERS}"
|
||||||
|
}
|
||||||
|
|
||||||
|
print_body() {
|
||||||
|
cat "${RESPONSE_BODY}"
|
||||||
|
}
|
||||||
|
|
3
views/error.ejs
Normal file
3
views/error.ejs
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<h1><%= message %></h1>
|
||||||
|
<h2><%= error.status %></h2>
|
||||||
|
<pre><%= error.stack %></pre>
|
22
views/index.ejs
Normal file
22
views/index.ejs
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title><%= title %></title>
|
||||||
|
|
||||||
|
<meta property="al:ios:url" content="app://get.status.im<%= path %>" />
|
||||||
|
<meta property="al:ios:app_store_id" content="1178893006" />
|
||||||
|
<meta property="al:ios:app_name" content="Status — Ethereum. Anywhere" />
|
||||||
|
|
||||||
|
<meta property="al:android:url" content="app://get.status.im<%= path %>" />
|
||||||
|
<meta property="al:android:package" content="im.status.ethereum" />
|
||||||
|
<meta property="al:android:app_name" content="Status — Ethereum. Anywhere" />
|
||||||
|
|
||||||
|
|
||||||
|
<link rel='stylesheet' href='/stylesheets/style.css' />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<a id='app-store-link' href='#'>Don't have status yet, try it</a>
|
||||||
|
<script src="https://code.jquery.com/jquery-3.3.1.min.js" integrity="sha256-FgpCb/KJQlLNfOu91ta32o/NMZxltwRo8QtmkMRdAu8=" crossorigin="anonymous"></script>
|
||||||
|
<script type='text/javascript' src='/javascripts/app.js'></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
Loading…
x
Reference in New Issue
Block a user