From 815d7293675dc717fe7064b4e8bac689f31b9ee2 Mon Sep 17 00:00:00 2001
From: Eliot Berriot <contact@eliotberriot.com>
Date: Wed, 19 Dec 2018 14:03:21 +0100
Subject: [PATCH] Fix #578: added embed.html page to power iframe widget

---
 changes/changelog.d/578.feature           |  48 ++
 dev.yml                                   |  42 +-
 docker/nginx/conf.dev                     |  49 +-
 docker/nginx/entrypoint.sh                |  20 +-
 front/package.json                        |   1 +
 front/public/embed.html                   |  20 +
 front/src/Embed.vue                       | 567 ++++++++++++++++++++++
 front/src/assets/embed/default-cover.jpeg | Bin 0 -> 8991 bytes
 front/src/components/Logo.vue             |  16 +-
 front/src/embed.js                        |  16 +
 front/src/utils/time.js                   |   8 +
 front/vue.config.js                       |  46 +-
 front/yarn.lock                           |  43 ++
 13 files changed, 797 insertions(+), 79 deletions(-)
 create mode 100644 changes/changelog.d/578.feature
 create mode 100644 front/public/embed.html
 create mode 100644 front/src/Embed.vue
 create mode 100644 front/src/assets/embed/default-cover.jpeg
 create mode 100644 front/src/embed.js

diff --git a/changes/changelog.d/578.feature b/changes/changelog.d/578.feature
new file mode 100644
index 0000000000..9ab4fcf670
--- /dev/null
+++ b/changes/changelog.d/578.feature
@@ -0,0 +1,48 @@
+Allow embedding of albums and tracks available in public libraries via an <iframe> (#578)
+
+Iframe widget to embed public tracks and albums [manual action required]
+^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
+
+Funkwhale now support embedding a lightweight audio player on external websites
+for album and tracks that are available in public libraries. Important pages,
+such as artist, album and track pages also include OpenGraph tags that will
+enable previews on compatible apps (like sharing a Funkwhale track link on Mastodon
+or Twitter).
+
+To achieve that, we had to tweak the way Funkwhale front-end is served. You'll have
+to modify your nginx configuration when upgrading to keep your instance working.
+
+**On docker setups**, edit your ``/srv/funkwhale/nginx/funkwhale.template`` and replace
+the ``location /api/`` and `location /` blocks by the following snippets::
+
+    location / {
+        include /etc/nginx/funkwhale_proxy.conf;
+        # this is needed if you have file import via upload enabled
+        client_max_body_size ${NGINX_MAX_BODY_SIZE};
+        proxy_pass   http://funkwhale-api/;
+    }
+
+    location /front/ {
+        alias /frontend;
+    }
+
+The change of configuration will be picked when restarting your nginx container.
+
+**On non-docker setups**, edit your ``/etc/nginx/sites-available/funkwhale.conf`` file,
+and replace the ``location /api/`` and `location /` blocks by the following snippets::
+
+
+    location / {
+        include /etc/nginx/funkwhale_proxy.conf;
+        # this is needed if you have file import via upload enabled
+        client_max_body_size ${NGINX_MAX_BODY_SIZE};
+        proxy_pass   http://funkwhale-api/;
+    }
+
+    location /front/ {
+        alias ${FUNKWHALE_FRONTEND_PATH};
+    }
+
+Replace ``${FUNKWHALE_FRONTEND_PATH}`` by the corresponding variable from your .env file,
+which should be ``/srv/funkwhale/front/dist`` by default, then reload your nginx process with
+``sudo systemctl reload nginx``.
diff --git a/dev.yml b/dev.yml
index a77edb7db2..39f891a3a8 100644
--- a/dev.yml
+++ b/dev.yml
@@ -10,20 +10,13 @@ services:
       - "HOST=0.0.0.0"
       - "VUE_PORT=${VUE_PORT-8080}"
     ports:
-      - "${VUE_PORT-8080}:${VUE_PORT-8080}"
+      - "${VUE_PORT-8080}"
     volumes:
       - "./front:/app"
       - "/app/node_modules"
       - "./po:/po"
     networks:
-      - federation
       - internal
-    labels:
-      traefik.backend: "${COMPOSE_PROJECT_NAME-node1}"
-      traefik.frontend.rule: "Host:${COMPOSE_PROJECT_NAME-node1}.funkwhale.test,${NODE_IP-127.0.0.1}"
-      traefik.enable: "true"
-      traefik.federation.protocol: "http"
-      traefik.federation.port: "${VUE_PORT-8080}"
 
   postgres:
     env_file:
@@ -66,7 +59,7 @@ services:
       - "CACHE_URL=redis://redis:6379/0"
     volumes:
       - ./api:/app
-      - "${MUSIC_DIRECTORY-./data/music}:/music:ro"
+      - "${MUSIC_DIRECTORY_PATH-./data/music}:/music:ro"
     networks:
       - internal
   api:
@@ -76,10 +69,10 @@ services:
     build:
       context: ./api
       dockerfile: docker/Dockerfile.test
-    command: python /app/manage.py runserver 0.0.0.0:12081
+    command: python /app/manage.py runserver 0.0.0.0:${FUNKWHALE_API_PORT-5000}
     volumes:
       - ./api:/app
-      - "${MUSIC_DIRECTORY-./data/music}:/music:ro"
+      - "${MUSIC_DIRECTORY_PATH-./data/music}:/music:ro"
     environment:
       - "FUNKWHALE_HOSTNAME=${FUNKWHALE_HOSTNAME-localhost}"
       - "FUNKWHALE_HOSTNAME_SUFFIX=funkwhale.test"
@@ -99,22 +92,35 @@ services:
       - .env
     image: nginx
     environment:
-      - "VUE_PORT=${VUE_PORT-8080}"
+      - "NGINX_MAX_BODY_SIZE=${NGINX_MAX_BODY_SIZE-30M}"
+      - "FUNKWHALE_API_IP=${FUNKHALE_API_IP-api}"
+      - "FUNKWHALE_API_PORT=${FUNKWHALE_API_PORT-5000}"
+      - "FUNKWHALE_FRONT_IP=${FUNKHALE_FRONT_IP-front}"
+      - "FUNKWHALE_FRONT_PORT=${VUE_PORT-8080}"
       - "COMPOSE_PROJECT_NAME=${COMPOSE_PROJECT_NAME- }"
       - "FUNKWHALE_HOSTNAME=${FUNKWHALE_HOSTNAME-localhost}"
     links:
       - api
       - front
     volumes:
-      - ./docker/nginx/conf.dev:/etc/nginx/nginx.conf
+      - ./docker/nginx/conf.dev:/etc/nginx/nginx.conf.template:ro
       - ./docker/nginx/entrypoint.sh:/entrypoint.sh:ro
-      - "${MUSIC_DIRECTORY-./data/music}:/music:ro"
-      - ./deploy/funkwhale_proxy.conf:/etc/nginx/funkwhale_proxy.conf.template:ro
-      - ./api/funkwhale_api/media:/protected/media
-    ports:
-      - "6001"
+      - "${MUSIC_DIRECTORY_PATH-./data/music}:/music:ro"
+      - ./deploy/funkwhale_proxy.conf:/etc/nginx/funkwhale_proxy.conf:ro
+      - "${MEDIA_ROOT-./api/funkwhale_api/media}:/protected/media:ro"
     networks:
+      - federation
       - internal
+
+    labels:
+      traefik.backend: "${COMPOSE_PROJECT_NAME-node1}"
+      traefik.frontend.rule: "Host:${COMPOSE_PROJECT_NAME-node1}.funkwhale.test,${NODE_IP-127.0.0.1}"
+      traefik.enable: "true"
+      traefik.federation.protocol: "http"
+      traefik.federation.port: "80"
+      traefik.frontend.passHostHeader: true
+      traefik.docker.network: federation
+
   docs:
     build: docs
     command: python serve.py
diff --git a/docker/nginx/conf.dev b/docker/nginx/conf.dev
index 2ed1a97d54..297cfa5096 100644
--- a/docker/nginx/conf.dev
+++ b/docker/nginx/conf.dev
@@ -32,26 +32,57 @@ http {
         ''      close;
     }
 
+    upstream funkwhale-api {
+        server ${FUNKWHALE_API_IP}:${FUNKWHALE_API_PORT};
+    }
+    upstream funkwhale-front {
+        server ${FUNKWHALE_FRONT_IP}:${FUNKWHALE_FRONT_PORT};
+    }
     server {
-        listen 6001;
+        listen 80;
         charset     utf-8;
         client_max_body_size 30M;
         include /etc/nginx/funkwhale_proxy.conf;
-        location /_protected/media {
-            internal;
-            alias   /protected/media;
+
+        location /front/ {
+            proxy_pass   http://funkwhale-front/front/;
         }
-        location /_protected/music {
-            internal;
-            alias   /music;
+        location /front-server/ {
+            proxy_pass   http://funkwhale-front/;
         }
+
         location / {
             include /etc/nginx/funkwhale_proxy.conf;
-            proxy_pass   http://api:12081/;
+            # this is needed if you have file import via upload enabled
+            client_max_body_size ${NGINX_MAX_BODY_SIZE};
+            proxy_pass   http://funkwhale-api/;
         }
+
+        # You can comment this if you do not plan to use the Subsonic API
         location /rest/ {
             include /etc/nginx/funkwhale_proxy.conf;
-            proxy_pass   http://api:12081/api/subsonic/rest/;
+            proxy_pass   http://funkwhale-api/api/subsonic/rest/;
+        }
+
+        location /media/ {
+            alias /protected/media/;
+        }
+
+        location /_protected/media {
+            # this is an internal location that is used to serve
+            # audio files once correct permission / authentication
+            # has been checked on API side
+            internal;
+            alias   /protected/media;
+        }
+
+        location /_protected/music {
+            # this is an internal location that is used to serve
+            # audio files once correct permission / authentication
+            # has been checked on API side
+            # Set this to the same value as your MUSIC_DIRECTORY_PATH setting
+            internal;
+            alias   /music;
         }
     }
 }
diff --git a/docker/nginx/entrypoint.sh b/docker/nginx/entrypoint.sh
index f359f4da98..7c36bcd2f6 100755
--- a/docker/nginx/entrypoint.sh
+++ b/docker/nginx/entrypoint.sh
@@ -1,18 +1,8 @@
 #!/bin/bash -eux
 
-FORWARDED_PORT="$VUE_PORT"
-COMPOSE_PROJECT_NAME="${COMPOSE_PROJECT_NAME// /}"
-if [ -n "$COMPOSE_PROJECT_NAME" ]; then
-  echo
-    FUNKWHALE_HOSTNAME="$COMPOSE_PROJECT_NAME.funkwhale.test"
-    FORWARDED_PORT="443"
-fi
-echo "Copying template file..."
-cp /etc/nginx/funkwhale_proxy.conf{.template,}
-sed -i "s/X-Forwarded-Host \$host:\$server_port/X-Forwarded-Host ${FUNKWHALE_HOSTNAME}/" /etc/nginx/funkwhale_proxy.conf
-sed -i "s/proxy_set_header Host \$host/proxy_set_header Host ${FUNKWHALE_HOSTNAME}/" /etc/nginx/funkwhale_proxy.conf
-sed -i "s/proxy_set_header X-Forwarded-Port \$server_port/proxy_set_header X-Forwarded-Port ${FORWARDED_PORT}/" /etc/nginx/funkwhale_proxy.conf
-sed -i "s/proxy_set_header X-Forwarded-Proto \$scheme/proxy_set_header X-Forwarded-Proto ${FORWARDED_PROTO}/" /etc/nginx/funkwhale_proxy.conf
 
-cat /etc/nginx/funkwhale_proxy.conf
-nginx -g "daemon off;"
+envsubst "`env | awk -F = '{printf \" $$%s\", $$1}'`" \
+  < /etc/nginx/nginx.conf.template \
+  > /etc/nginx/nginx.conf \
+  && cat /etc/nginx/nginx.conf \
+  && nginx-debug -g 'daemon off;'
diff --git a/front/package.json b/front/package.json
index 9c8cba9fee..23894600e8 100644
--- a/front/package.json
+++ b/front/package.json
@@ -27,6 +27,7 @@
     "vue-gettext": "^2.1.0",
     "vue-lazyload": "^1.2.6",
     "vue-masonry": "^0.11.5",
+    "vue-plyr": "^5.0.4",
     "vue-router": "^3.0.1",
     "vue-upload-component": "^2.8.11",
     "vuedraggable": "^2.16.0",
diff --git a/front/public/embed.html b/front/public/embed.html
new file mode 100644
index 0000000000..241e1cd8d9
--- /dev/null
+++ b/front/public/embed.html
@@ -0,0 +1,20 @@
+<!DOCTYPE html>
+<html lang="en">
+
+<head>
+  <meta charset="utf-8">
+  <meta http-equiv="X-UA-Compatible" content="IE=edge">
+  <meta name="viewport" content="width=device-width,initial-scale=1.0">
+  <link rel="icon" href="<%= BASE_URL %>favicon.png">
+  <title>Funkwhale Widget</title>
+</head>
+
+<body>
+  <noscript>
+    <strong>We're sorry but this widget doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
+  </noscript>
+  <div id="app"></div>
+  <!-- built files will be auto injected -->
+</body>
+
+</html>
diff --git a/front/src/Embed.vue b/front/src/Embed.vue
new file mode 100644
index 0000000000..fdd3406faa
--- /dev/null
+++ b/front/src/Embed.vue
@@ -0,0 +1,567 @@
+<template>
+  <main :class="[theme]">
+    <!-- SVG from https://cdn.plyr.io/3.4.7/plyr.svg -->
+    <svg aria-hidden="true" style="display: none" xmlns="http://www.w3.org/2000/svg">
+      <symbol id="plyr-download"><path d="M9 13c.3 0 .5-.1.7-.3L15.4 7 14 5.6l-4 4V1H8v8.6l-4-4L2.6 7l5.7 5.7c.2.2.4.3.7.3zM2 15h14v2H2z"/></symbol>
+      <symbol id="plyr-enter-fullscreen"><path d="M10 3h3.6l-4 4L11 8.4l4-4V8h2V1h-7zM7 9.6l-4 4V10H1v7h7v-2H4.4l4-4z"/></symbol>
+      <symbol id="plyr-exit-fullscreen"><path d="M1 12h3.6l-4 4L2 17.4l4-4V17h2v-7H1zM16 .6l-4 4V1h-2v7h7V6h-3.6l4-4z"/></symbol>
+      <symbol id="plyr-fast-forward"><path d="M7.875 7.171L0 1v16l7.875-6.171V17L18 9 7.875 1z"/></symbol>
+      <symbol id="plyr-muted"><path d="M12.4 12.5l2.1-2.1 2.1 2.1 1.4-1.4L15.9 9 18 6.9l-1.4-1.4-2.1 2.1-2.1-2.1L11 6.9 13.1 9 11 11.1zM3.786 6.008H.714C.286 6.008 0 6.31 0 6.76v4.512c0 .452.286.752.714.752h3.072l4.071 3.858c.5.3 1.143 0 1.143-.602V2.752c0-.601-.643-.977-1.143-.601L3.786 6.008z"/></symbol>
+      <symbol id="plyr-pause"><path d="M6 1H3c-.6 0-1 .4-1 1v14c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V2c0-.6-.4-1-1-1zM12 1c-.6 0-1 .4-1 1v14c0 .6.4 1 1 1h3c.6 0 1-.4 1-1V2c0-.6-.4-1-1-1h-3z"/></symbol>
+      <symbol id="plyr-pip"><path d="M13.293 3.293L7.022 9.564l1.414 1.414 6.271-6.271L17 7V1h-6z"/><path d="M13 15H3V5h5V3H2a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-6h-2v5z"/></symbol>
+      <symbol id="plyr-play"><path d="M15.562 8.1L3.87.225C3.052-.337 2 .225 2 1.125v15.75c0 .9 1.052 1.462 1.87.9L15.563 9.9c.584-.45.584-1.35 0-1.8z"/></symbol>
+      <symbol id="plyr-restart"><path d="M9.7 1.2l.7 6.4 2.1-2.1c1.9 1.9 1.9 5.1 0 7-.9 1-2.2 1.5-3.5 1.5-1.3 0-2.6-.5-3.5-1.5-1.9-1.9-1.9-5.1 0-7 .6-.6 1.4-1.1 2.3-1.3l-.6-1.9C6 2.6 4.9 3.2 4 4.1 1.3 6.8 1.3 11.2 4 14c1.3 1.3 3.1 2 4.9 2 1.9 0 3.6-.7 4.9-2 2.7-2.7 2.7-7.1 0-9.9L16 1.9l-6.3-.7z"/></symbol>
+      <symbol id="plyr-rewind"><path d="M10.125 1L0 9l10.125 8v-6.171L18 17V1l-7.875 6.171z"/></symbol>
+      <symbol id="plyr-settings"><path d="M16.135 7.784a2 2 0 0 1-1.23-2.969c.322-.536.225-.998-.094-1.316l-.31-.31c-.318-.318-.78-.415-1.316-.094a2 2 0 0 1-2.969-1.23C10.065 1.258 9.669 1 9.219 1h-.438c-.45 0-.845.258-.997.865a2 2 0 0 1-2.969 1.23c-.536-.322-.999-.225-1.317.093l-.31.31c-.318.318-.415.781-.093 1.317a2 2 0 0 1-1.23 2.969C1.26 7.935 1 8.33 1 8.781v.438c0 .45.258.845.865.997a2 2 0 0 1 1.23 2.969c-.322.536-.225.998.094 1.316l.31.31c.319.319.782.415 1.316.094a2 2 0 0 1 2.969 1.23c.151.607.547.865.997.865h.438c.45 0 .845-.258.997-.865a2 2 0 0 1 2.969-1.23c.535.321.997.225 1.316-.094l.31-.31c.318-.318.415-.781.094-1.316a2 2 0 0 1 1.23-2.969c.607-.151.865-.547.865-.997v-.438c0-.451-.26-.846-.865-.997zM9 12a3 3 0 1 1 0-6 3 3 0 0 1 0 6z"/></symbol>
+      <symbol id="plyr-volume"><path d="M15.6 3.3c-.4-.4-1-.4-1.4 0-.4.4-.4 1 0 1.4C15.4 5.9 16 7.4 16 9c0 1.6-.6 3.1-1.8 4.3-.4.4-.4 1 0 1.4.2.2.5.3.7.3.3 0 .5-.1.7-.3C17.1 13.2 18 11.2 18 9s-.9-4.2-2.4-5.7z"/><path d="M11.282 5.282a.909.909 0 0 0 0 1.316c.735.735.995 1.458.995 2.402 0 .936-.425 1.917-.995 2.487a.909.909 0 0 0 0 1.316c.145.145.636.262 1.018.156a.725.725 0 0 0 .298-.156C13.773 11.733 14.13 10.16 14.13 9c0-.17-.002-.34-.011-.51-.053-.992-.319-2.005-1.522-3.208a.909.909 0 0 0-1.316 0zM3.786 6.008H.714C.286 6.008 0 6.31 0 6.76v4.512c0 .452.286.752.714.752h3.072l4.071 3.858c.5.3 1.143 0 1.143-.602V2.752c0-.601-.643-.977-1.143-.601L3.786 6.008z"/></symbol></svg>
+      <!-- those ones are from fork-awesome -->
+      <symbol id="plyr-step-backward"><path d="M979 141c25-25 45-16 45 19v1472c0 35-20 44-45 19L269 941c-6-6-10-12-13-19v678c0 35-29 64-64 64H64c-35 0-64-29-64-64V192c0-35 29-64 64-64h128c35 0 64 29 64 64v678c3-7 7-13 13-19z"/></symbol>
+      <symbol id="plyr-step-forward"><path d="M45 1651c-25 25-45 16-45-19V160c0-35 20-44 45-19l710 710c6 6 10 12 13 19V192c0-35 29-64 64-64h128c35 0 64 29 64 64v1408c0 35-29 64-64 64H832c-35 0-64-29-64-64V922c-3 7-7 13-13 19z"/></symbol>
+    </svg>
+    <article>
+      <aside class="cover main" v-if="currentTrack">
+        <img height="120" v-if="currentTrack.cover" :src="currentTrack.cover" alt="Cover" />
+        <img height="120" v-else src="./assets/embed/default-cover.jpeg" alt="Cover" />
+      </aside>
+      <div class="content" aria-label="Track information">
+        <header v-if="currentTrack">
+          <h3><a :href="fullUrl('/library/tracks/' + currentTrack.id)" target="_blank" rel="noopener noreferrer">{{ currentTrack.title }}</a></h3>
+          By <a :href="fullUrl('/library/artists/' + currentTrack.artist.id)" target="_blank" rel="noopener noreferrer">{{ currentTrack.artist.name }}</a>
+        </header>
+        <section v-if="!isLoading" class="controls" aria-label="Audio player">
+          <template v-if="currentTrack && currentTrack.sources.length > 0">
+            <div class="queue-controls plyr--audio" v-if="tracks.length > 1">
+              <div class="plyr__controls">
+                <button
+                  @focus="setControlFocus($event, true)"
+                  @blur="setControlFocus($event, false)"
+                  @click="previous()"
+                  type="button"
+                  class="plyr__control"
+                  aria-label="Play previous track">
+                  <svg class="icon--not-pressed" role="presentation" focusable="false" viewBox="0 0 1100 1650" width="80" height="80">
+                    <use xlink:href="#plyr-step-backward"></use>
+                  </svg>
+                </button>
+                <button
+                  @click="next()"
+                  @focus="setControlFocus($event, true)"
+                  @blur="setControlFocus($event, false)"
+                  type="button"
+                  class="plyr__control"
+                  aria-label="Play next track">
+                  <svg class="icon--not-pressed" role="presentation" focusable="false" viewBox="0 0 1100 1650" width="80" height="80">
+                    <use xlink:href="#plyr-step-forward"></use>
+                  </svg>
+                </button>
+              </div>
+            </div>
+
+            <vue-plyr
+              :key="currentIndex"
+              ref="player"
+              class="player"
+              :options="{loadSprite: false, controls: controls, duration: currentTrack.sources[0].duration}">
+              <audio preload="none">
+                <source v-for="source in currentTrack.sources" :src="source.src" :type="source.type"/>
+              </audio>
+            </vue-plyr>
+          </template>
+          <div v-else class="player">
+            <span v-if="error === 'invalid_type'" class="error">Widget improperly configured (bad resource type {{ type }}).</span>
+            <span v-else-if="error === 'invalid_id'" class="error">Widget improperly configured (missing resource id).</span>
+            <span v-else-if="error === 'server_not_found'" class="error">Track not found.</span>
+            <span v-else-if="error === 'server_requires_auth'" class="error">You need to login to access this resource.</span>
+            <span v-else-if="error === 'server_error'" class="error">A server error occured.</span>
+            <span v-else-if="error === 'server_error'" class="error">An unknown error occured while loading track data from server.</span>
+            <span v-else-if="currentTrack && currentTrack.sources.length === 0" class="error">This track is unavailable.</span>
+            <span v-else class="error">An unknown error occured while loading track data.</span>
+          </div>
+          <a title="Funkwhale" href="https://funkwhale.audio" target="_blank" rel="noopener noreferrer" class="logo-wrapper">
+            <logo :fill="currentTheme.textColor" class="logo"></logo>
+          </a>
+        </section>
+      </div>
+    </article>
+    <div v-if="tracks.length > 1" class="queue-wrapper" id="queue">
+      <table class="queue">
+        <tbody>
+          <tr
+            :id="'queue-item-' + index"
+            role="button"
+            tabindex="0"
+            v-if="track.sources.length > 0"
+            :key="index"
+            :class="[{active: index === currentIndex}]"
+            @click="play(index)"
+            @keyup.enter="play(index)"
+            v-for="(track, index) in tracks">
+            <td class="position-cell" width="40">
+              <span class="position">
+                {{ index + 1 }}
+              </span>
+            </td>
+            <td class="title" :title="track.title" ><div colspan="2" class="ellipsis">{{ track.title }}</div></td>
+            <td class="artist" :title="track.artist.name" ><div class="ellipsis">{{ track.artist.name }}</div></td>
+            <td class="album">
+              <div class="ellipsis " v-if="track.album" :title="track.album.title">{{ track.album.title }}</div>
+            </td>
+            <td width="50">{{ time.durationFormatted(track.sources[0].duration) }}</td>
+          </tr>
+        </tbody>
+      </table>
+    </div>
+  </main>
+</template>
+
+<script>
+import axios from 'axios'
+import Logo from "@/components/Logo"
+import url from '@/utils/url'
+import time from '@/utils/time'
+
+function getURLParams () {
+  var urlParams
+  var match,
+      pl     = /\+/g,  // Regex for replacing addition symbol with a space
+      search = /([^&=]+)=?([^&]*)/g,
+      decode = function (s) { return decodeURIComponent(s.replace(pl, " ")); },
+      query  = window.location.search.substring(1);
+
+  urlParams = {};
+  while (match = search.exec(query))
+      urlParams[decode(match[1])] = decode(match[2]);
+  return urlParams
+}
+export default {
+  name: 'app',
+  components: {Logo},
+  data () {
+    return {
+      time,
+      supportedTypes: ['track', 'album'],
+      baseUrl: '',
+      error: null,
+      type: null,
+      id: null,
+      tracks: [],
+      url: null,
+      isLoading: true,
+      theme: 'dark',
+      currentIndex: -1,
+      themes: {
+        dark: {
+          textColor: 'white',
+        }
+      }
+    }
+  },
+  created () {
+    let params = getURLParams()
+    this.type = params.type
+    if (this.supportedTypes.indexOf(this.type) === -1) {
+      this.error = 'invalid_type'
+    }
+    this.id = params.id
+    if (!this.id) {
+      this.error = 'invalid_id'
+    }
+    if (this.error) {
+      this.isLoading = false
+      return
+    }
+    if (!!params.instance) {
+      this.baseUrl = params.instance
+    }
+    this.fetch(this.type, this.id)
+  },
+  mounted () {
+    var parser = document.createElement('a')
+    parser.href = this.baseUrl
+    this.url = parser
+  },
+  computed: {
+    currentTrack () {
+      if (this.tracks.length === 0) {
+        return null
+      }
+      return this.tracks[this.currentIndex]
+    },
+    currentTheme () {
+      return this.themes[this.theme]
+    },
+    controls () {
+      return  [
+        'play', // Play/pause playback
+        'progress', // The progress bar and scrubber for playback and buffering
+        'current-time', // The current time of playback
+        'mute', // Toggle mute
+        'volume', // Volume control
+      ]
+    },
+    hasPrevious () {
+      return this.currentIndex > 0
+    },
+    hasNext () {
+      return this.currentIndex < this.tracks.length - 1
+    },
+  },
+  methods: {
+    next () {
+      if (this.hasNext) {
+        this.play(this.currentIndex + 1)
+      }
+    },
+    previous () {
+      if (this.hasPrevious) {
+        this.play(this.currentIndex - 1)
+      }
+    },
+    setControlFocus(event, enable) {
+      if (enable) {
+        event.target.classList.add("plyr__tab-focus");
+      } else {
+        event.target.classList.remove("plyr__tab-focus");
+      }
+    },
+    fetch (type, id) {
+      if (type === 'track') {
+        this.fetchTrack(id)
+      }
+      if (type === 'album') {
+        this.fetchTracks({album: id, playable: true})
+      }
+    },
+    play (index) {
+      this.currentIndex = index
+      let self = this
+      this.$nextTick(() => {
+        self.$refs.player.player.play()
+      })
+    },
+    fetchTrack (id) {
+      let self = this
+      let url = `${this.baseUrl}/api/v1/tracks/${id}/`
+      axios.get(url).then(response => {
+        self.tracks = self.parseTracks([response.data])
+        self.isLoading = false;
+      }).catch(error => {
+        if (error.response) {
+          console.log(error.response)
+          if (error.response.status === 404) {
+            self.error = 'server_not_found'
+          }
+          else if (error.response.status === 403) {
+            self.error = 'server_requires_auth'
+          }
+          else if (error.response.status === 500) {
+            self.error = 'server_error'
+          }
+          else {
+            self.error = 'server_unknown_error'
+          }
+        } else {
+          self.error = 'server_unknown_error'
+        }
+        self.isLoading = false;
+      })
+    },
+    fetchTracks (filters) {
+      let self = this
+      let url = `${this.baseUrl}/api/v1/tracks/`
+      axios.get(url, {params: filters}).then(response => {
+        self.tracks = self.parseTracks(response.data.results)
+        self.isLoading = false;
+      }).catch(error => {
+        if (error.response) {
+          console.log(error.response)
+          if (error.response.status === 404) {
+            self.error = 'server_not_found'
+          }
+          else if (error.response.status === 403) {
+            self.error = 'server_requires_auth'
+          }
+          else if (error.response.status === 500) {
+            self.error = 'server_error'
+          }
+          else {
+            self.error = 'server_unknown_error'
+          }
+        } else {
+          self.error = 'server_unknown_error'
+        }
+        self.isLoading = false;
+      })
+    },
+    parseTracks (tracks) {
+      let self = this
+      return tracks.map(t => {
+        return {
+          id: t.id,
+          title: t.title,
+          artist: t.artist,
+          album: t.album,
+          cover: self.getCover(t.album.cover),
+          sources: self.getSources(t.uploads)
+        }
+      })
+    },
+    bindEvents () {
+      let self = this
+      this.$refs.player.player.on('ended', () => {
+        self.next()
+      })
+    },
+    fullUrl (path) {
+      if (path.startsWith('/')) {
+        return this.baseUrl + path
+      }
+      return path
+    },
+    getCover(albumCover) {
+      if (albumCover) {
+        return albumCover.medium_square_crop
+      }
+    },
+    getSources (uploads) {
+      let self = this;
+      let sources = uploads.map(u => {
+        return {
+          type: u.mimetype,
+          src: self.fullUrl(u.listen_url),
+          duration: u.duration
+        }
+      })
+      if (sources.length > 0) {
+        // We always add a transcoded MP3 src at the end
+        // because transcoding is expensive, but we want browsers that do
+        // not support other codecs to be able to play it :)
+        sources.push({
+          type: 'audio/mpeg',
+          src: url.updateQueryString(
+            self.fullUrl(sources[0].src),
+            'to',
+            'mp3'
+          )
+        })
+      }
+      return sources
+    }
+  },
+  watch: {
+    currentIndex (v) {
+      // we bind player events
+      let self = this
+      this.$nextTick(() => {
+        self.bindEvents()
+        if (self.tracks.length > 0) {
+          var topPos = document.getElementById(`queue-item-${v}`).offsetTop;
+          document.getElementById('queue').scrollTop = topPos-10;
+        }
+      })
+    },
+    tracks () {
+      this.currentIndex = 0
+    }
+  }
+}
+</script>
+
+<style lang="scss">
+html,
+body,
+main {
+  height: 100%;
+}
+body {
+  margin: 0;
+  font-family: sans-serif;
+}
+main {
+  display: flex;
+  flex-direction: column;
+}
+article {
+  display: flex;
+  position: relative;
+  aside {
+    padding: 0.5em;
+  }
+}
+
+a {
+  text-decoration: none;
+}
+a:hover {
+  text-decoration: underline;
+}
+section.controls {
+  display: flex;
+}
+.cover {
+  max-width: 120px;
+  max-height: 120px;
+}
+
+.player {
+  flex: 1;
+  align-self: flex-end;
+}
+article .content {
+  flex: 1;
+  display: flex;
+  flex-direction: column;
+  h3 {
+    margin: 0 0 0.5em;
+  }
+  header {
+    flex: 1;
+    padding: 1em;
+  }
+}
+.player,
+.queue-controls {
+  padding: 0.25em 0;
+  margin-right: 0.25em;
+  align-self: center;
+}
+section .plyr--audio .plyr__controls {
+  padding: 0;
+}
+
+.error {
+  font-weight: bold;
+  display: block;
+  text-align: center;
+}
+.logo-wrapper {
+  height: 2em;
+  width: 2em;
+  padding: 0.25em;
+  margin-left: 0.5em;
+  display: block;
+}
+[role="button"] {
+  cursor: pointer;
+}
+.ellipsis {
+  white-space: nowrap;
+  text-overflow: ellipsis;
+  overflow: hidden;
+}
+.queue-wrapper {
+  flex: 1;
+  overflow-y: auto;
+  padding: 0.5em;
+}
+.queue {
+  width: 100%;
+  border-collapse: collapse;
+  table-layout: fixed;
+  margin-bottom: 0.5em;
+  td {
+    padding: 0.5em;
+    font-size: 90%;
+    img {
+      vertical-align: middle;
+      margin-right: 1em;
+    }
+  }
+  td:last-child {
+    text-align: right;
+  }
+  .position {
+    padding: 0.1em 0.3em;
+    display: inline-block;
+  }
+}
+@media screen and (max-width: 640px) {
+  .queue .album {
+    display: none;
+  }
+  .plyr__controls .plyr__time {
+    display: none;
+  }
+}
+@media screen and (max-width: 460px) {
+  article,
+  article .content {
+    display: block;
+  }
+  .cover.main {
+    float: right;
+    img {
+      height: 60px;
+      width: 60px;
+    }
+  }
+}
+
+@media screen and (max-width: 320px) {
+  .logo-wrapper,
+  .position-cell {
+    display: none;
+  }
+}
+
+// themes
+
+.dark {
+  $primary-color: rgb(242, 113, 28);
+  $dark: rgb(27, 28, 29);
+  $lighter: rgb(47, 48, 48);
+  $clear: rgb(242, 242, 242);
+  // $primary-color: rgb(255, 88, 78);
+  .logo-wrapper {
+    background-color: $primary-color;
+  }
+  .plyr--audio .plyr__control.plyr__tab-focus,
+  .plyr--audio .plyr__control:hover,
+  .plyr--audio .plyr__control[aria-expanded="true"] {
+    background-color: $primary-color;
+  }
+  .plyr--audio .plyr__control.plyr__tab-focus,
+  .plyr--audio .plyr__control:hover,
+  .plyr--audio .plyr__control[aria-expanded="true"] {
+    background-color: $primary-color;
+  }
+  .plyr--full-ui input[type="range"] {
+    color: $primary-color;
+  }
+  article,
+  .player,
+  .plyr--audio .plyr__controls {
+    background-color: $dark;
+  }
+  .queue-wrapper {
+    background-color: $lighter;
+  }
+  article,
+  article a,
+  .player,
+  .queue tr,
+  .plyr--audio .plyr__controls {
+    color: white;
+  }
+  .plyr__control.plyr__tab-focus {
+    -webkit-box-shadow: 0 0 0 2px rgba(26, 175, 255, 0.5);
+    box-shadow: 0 0 0 2px rgba(26, 175, 255, 0.5);
+    outline: 0;
+  }
+  tr:hover,
+  tr:focus {
+    background-color: $dark;
+  }
+  tr.active {
+    background-color: $clear;
+    color: $dark;
+  }
+
+  tr.active {
+    .position {
+      background-color: $primary-color;
+      color: $clear;
+    }
+  }
+}
+</style>
diff --git a/front/src/assets/embed/default-cover.jpeg b/front/src/assets/embed/default-cover.jpeg
new file mode 100644
index 0000000000000000000000000000000000000000..3eb05a40a2254469095d4ec177df445b951833b8
GIT binary patch
literal 8991
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
TdvWh%gjfC7hW{V`o#FovDp{{q

literal 0
HcmV?d00001

diff --git a/front/src/components/Logo.vue b/front/src/components/Logo.vue
index f63bd7ab6a..ff87dc2998 100644
--- a/front/src/components/Logo.vue
+++ b/front/src/components/Logo.vue
@@ -3,16 +3,16 @@
   	 viewBox="0 0 141.7 141.7" enable-background="new 0 0 141.7 141.7" xml:space="preserve">
   <g>
   	<g>
-  		<path fill="#4082B4" d="M70.9,86.1c11.7,0,21.2-9.5,21.2-21.2c0-0.6-0.5-1.1-1.1-1.1h-8c-0.6,0-1.1,0.5-1.1,1.1c0,6-4.9,11-11,11
+  		<path :fill="fill" d="M70.9,86.1c11.7,0,21.2-9.5,21.2-21.2c0-0.6-0.5-1.1-1.1-1.1h-8c-0.6,0-1.1,0.5-1.1,1.1c0,6-4.9,11-11,11
   			c-6,0-11-4.9-11-11c0-0.6-0.5-1.1-1.1-1.1h-8c-0.6,0-1.1,0.5-1.1,1.1C49.7,76.6,59.2,86.1,70.9,86.1z"/>
-  		<path fill="#4082B4" d="M70.9,106.1c22.7,0,41.2-18.5,41.2-41.2c0-0.6-0.5-1.1-1.1-1.1h-8c-0.6,0-1.1,0.5-1.1,1.1
+  		<path :fill="fill" d="M70.9,106.1c22.7,0,41.2-18.5,41.2-41.2c0-0.6-0.5-1.1-1.1-1.1h-8c-0.6,0-1.1,0.5-1.1,1.1
   			c0,17.1-13.9,31-31,31c-17.1,0-31-13.9-31-31c0-0.6-0.5-1.1-1.1-1.1h-8c-0.6,0-1.1,0.5-1.1,1.1C29.6,87.6,48.1,106.1,70.9,106.1z"
   			/>
-  		<path fill="#4082B4" d="M131.1,63.8h-8c-0.6,0-1.1,0.5-1.1,1.1C122,93.1,99,116,70.9,116c-28.2,0-51.1-22.9-51.1-51.1
+  		<path :fill="fill" d="M131.1,63.8h-8c-0.6,0-1.1,0.5-1.1,1.1C122,93.1,99,116,70.9,116c-28.2,0-51.1-22.9-51.1-51.1
   			c0-0.6-0.5-1.1-1.1-1.1h-8c-0.6,0-1.1,0.5-1.1,1.1c0,33.8,27.5,61.3,61.3,61.3c33.8,0,61.3-27.5,61.3-61.3
   			C132.2,64.3,131.7,63.8,131.1,63.8z"/>
   	</g>
-  	<path fill="#222222" d="M43.3,37.3c4.1,2.1,8.5,2.5,12.5,4.8c2.6,1.5,4.2,3.2,5.8,5.7c2.5,3.8,2.4,8.5,2.4,8.5l0.3,5.2
+  	<path :fill="fill" d="M43.3,37.3c4.1,2.1,8.5,2.5,12.5,4.8c2.6,1.5,4.2,3.2,5.8,5.7c2.5,3.8,2.4,8.5,2.4,8.5l0.3,5.2
   		c0,0,2,5.2,6.4,5.2c4.7,0,6.4-5.2,6.4-5.2l0.3-5.2c0,0-0.1-4.7,2.4-8.5c1.6-2.5,3.2-4.3,5.8-5.7c4-2.3,8.4-2.7,12.5-4.8
   		c4.1-2.1,8.1-4.8,10.8-8.6c2.7-3.8,4-8.8,2.5-13.2c-7.8-0.4-16.8,0.5-23.7,4.2c-9.6,5.1-15.4,3.3-17.1,10.9h-0.1
   		c-1.7-7.7-7.5-5.8-17.1-10.9c-6.9-3.7-15.9-4.6-23.7-4.2c-1.5,4.4-0.2,9.4,2.5,13.2C35.2,32.5,39.2,35.2,43.3,37.3z"/>
@@ -24,10 +24,8 @@
 <script>
 
 export default {
+  props: {
+    fill: {type: String, default: '#222222'}
+  }
 }
 </script>
-
-<!-- Add "scoped" attribute to limit CSS to this component only -->
-<style scoped lang="scss">
-
-</style>
diff --git a/front/src/embed.js b/front/src/embed.js
new file mode 100644
index 0000000000..31ada5480b
--- /dev/null
+++ b/front/src/embed.js
@@ -0,0 +1,16 @@
+
+import Vue from 'vue'
+import Embed from './Embed'
+import axios from 'axios'
+import VuePlyr from 'vue-plyr'
+
+Vue.use(VuePlyr)
+
+Vue.config.productionTip = false
+
+/* eslint-disable no-new */
+new Vue({
+  el: '#app',
+  template: '<Embed/>',
+  components: { Embed }
+})
diff --git a/front/src/utils/time.js b/front/src/utils/time.js
index 022a365bf2..028131cf47 100644
--- a/front/src/utils/time.js
+++ b/front/src/utils/time.js
@@ -12,5 +12,13 @@ export default {
     min = Math.floor(sec / 60)
     sec = sec - min * 60
     return pad(min) + ':' + pad(sec)
+  },
+  durationFormatted (v) {
+    let duration = parseInt(v)
+    if (duration % 1 !== 0) {
+      return time.parse(0)
+    }
+    duration = Math.round(duration)
+    return this.parse(duration)
   }
 }
diff --git a/front/vue.config.js b/front/vue.config.js
index 243c8bc0f5..c689342465 100644
--- a/front/vue.config.js
+++ b/front/vue.config.js
@@ -1,5 +1,21 @@
 
 module.exports = {
+  baseUrl: '/front/',
+  pages: {
+    embed: {
+      entry: 'src/embed.js',
+      template: 'public/embed.html',
+      filename: 'embed.html',
+    },
+    index: {
+      entry: 'src/main.js',
+      template: 'public/index.html',
+      filename: 'index.html'
+    }
+  },
+  chainWebpack: config => {
+    config.optimization.delete('splitChunks')
+  },
   configureWebpack: {
     resolve: {
       alias: {
@@ -9,33 +25,7 @@ module.exports = {
   },
   devServer: {
     disableHostCheck: true,
-    proxy: {
-      '^/rest': {
-        target: 'http://nginx:6001',
-        changeOrigin: true,
-      },
-      '^/staticfiles': {
-        target: 'http://nginx:6001',
-        changeOrigin: true,
-      },
-      '^/.well-known': {
-        target: 'http://nginx:6001',
-        changeOrigin: true,
-      },
-      '^/media': {
-        target: 'http://nginx:6001',
-        changeOrigin: true,
-      },
-      '^/federation': {
-        target: 'http://nginx:6001',
-        changeOrigin: true,
-        ws: true,
-      },
-      '^/api': {
-        target: 'http://nginx:6001',
-        changeOrigin: true,
-        ws: true,
-      },
-    }
+    // use https://node1.funkwhale.test/front-server/ if you use docker with federation
+    public: process.env.FRONT_DEVSERVER_URL || ('http://localhost:' + (process.env.VUE_PORT || '8080'))
   }
 }
diff --git a/front/yarn.lock b/front/yarn.lock
index b3d6a460b9..e1b637c732 100644
--- a/front/yarn.lock
+++ b/front/yarn.lock
@@ -2168,6 +2168,11 @@ core-js@^2.4.0, core-js@^2.5.3:
   version "2.5.7"
   resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.5.7.tgz#f972608ff0cead68b841a16a932d0b183791814e"
 
+core-js@^2.5.7:
+  version "2.6.0"
+  resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.0.tgz#1e30793e9ee5782b307e37ffa22da0eacddd84d4"
+  integrity sha512-kLRC6ncVpuEW/1kwrOXYX6KQASCVtrh1gQr/UiaVgFlf9WE5Vp+lNe5+h3LuMr5PAucWnnEXwH0nQHRH/gpGtw==
+
 core-util-is@1.0.2, core-util-is@~1.0.0:
   version "1.0.2"
   resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7"
@@ -2430,6 +2435,11 @@ currently-unhandled@^0.4.1:
   dependencies:
     array-find-index "^1.0.1"
 
+custom-event-polyfill@^1.0.6:
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/custom-event-polyfill/-/custom-event-polyfill-1.0.6.tgz#6b026e81cd9f7bc896bd6b016a427407bb068db1"
+  integrity sha512-3FxpFlzGcHrDykwWu+xWVXZ8PfykM/9/bI3zXb953sh+AjInZWcQmrnmvPoZgiqNjmbtTm10PWvYqvRW527x6g==
+
 cyclist@~0.2.2:
   version "0.2.2"
   resolved "https://registry.yarnpkg.com/cyclist/-/cyclist-0.2.2.tgz#1b33792e11e914a2fd6d6ed6447464444e5fa640"
@@ -4602,6 +4612,11 @@ loader-utils@^1.0.1, loader-utils@^1.0.2, loader-utils@^1.1.0:
     emojis-list "^2.0.0"
     json5 "^0.5.0"
 
+loadjs@^3.5.4:
+  version "3.5.5"
+  resolved "https://registry.yarnpkg.com/loadjs/-/loadjs-3.5.5.tgz#2fbaa981ffdd079e0f8786ea75aeed643483b368"
+  integrity sha512-qBuLnKt4C6+vctutozFqPHQ6s4SSa9tcE64NsvDJ92UZmUrFvqGI1oVOtnZz2xwpgOT+2niQtHtQIDP4e/wlTA==
+
 locate-path@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-2.0.0.tgz#2b568b265eec944c6d9c0de9c3dbbbca0354cd8e"
@@ -5677,6 +5692,17 @@ pluralize@^7.0.0:
   version "7.0.0"
   resolved "https://registry.yarnpkg.com/pluralize/-/pluralize-7.0.0.tgz#298b89df8b93b0221dbf421ad2b1b1ea23fc6777"
 
+plyr@^3.4.5:
+  version "3.4.7"
+  resolved "https://registry.yarnpkg.com/plyr/-/plyr-3.4.7.tgz#7d92470fb27f8019422c6d4edfd3b172d902ef06"
+  integrity sha512-RxxT2WdC4/sEZQT7CBZqKx5ImVw96aWjT6kB6DM82jy9GcWDiBBnv04m/AeeaXg9S5ambPdiHhB6Pzfm2q84Gw==
+  dependencies:
+    core-js "^2.5.7"
+    custom-event-polyfill "^1.0.6"
+    loadjs "^3.5.4"
+    raven-js "^3.27.0"
+    url-polyfill "^1.1.0"
+
 pn@^1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/pn/-/pn-1.1.0.tgz#e2f4cef0e219f463c179ab37463e4e1ecdccbafb"
@@ -6246,6 +6272,11 @@ raven-js@^3.26.4:
   version "3.26.4"
   resolved "https://registry.yarnpkg.com/raven-js/-/raven-js-3.26.4.tgz#32aae3a63a9314467a453c94c89a364ea43707be"
 
+raven-js@^3.27.0:
+  version "3.27.0"
+  resolved "https://registry.yarnpkg.com/raven-js/-/raven-js-3.27.0.tgz#9f47c03e17933ce756e189f3669d49c441c1ba6e"
+  integrity sha512-vChdOL+yzecfnGA+B5EhEZkJ3kY3KlMzxEhShKh6Vdtooyl0yZfYNFQfYzgMf2v4pyQa+OTZ5esTxxgOOZDHqw==
+
 raw-body@2.3.2:
   version "2.3.2"
   resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.3.2.tgz#bcd60c77d3eb93cde0050295c3f379389bc88f89"
@@ -7558,6 +7589,11 @@ url-parse@^1.1.8, url-parse@^1.4.3:
     querystringify "^2.0.0"
     requires-port "^1.0.0"
 
+url-polyfill@^1.1.0:
+  version "1.1.3"
+  resolved "https://registry.yarnpkg.com/url-polyfill/-/url-polyfill-1.1.3.tgz#ce0bdf2e923aa6f66bc198ab776323dfc5a91e62"
+  integrity sha512-xIAXc0DyXJCd767sSeRu4eqisyYhR0z0sohWArCn+WPwIatD39xGrc09l+tluIUi6jGkpGa8Gz8TKwkKYxMQvQ==
+
 url@^0.11.0:
   version "0.11.0"
   resolved "https://registry.yarnpkg.com/url/-/url-0.11.0.tgz#3838e97cfc60521eb73c525a8e55bfdd9e2e28f1"
@@ -7682,6 +7718,13 @@ vue-masonry@^0.11.5:
     masonry-layout "4.2.0"
     vue "^2.0.0"
 
+vue-plyr@^5.0.4:
+  version "5.0.4"
+  resolved "https://registry.yarnpkg.com/vue-plyr/-/vue-plyr-5.0.4.tgz#13083b71a876d01200a3c93ebfd11585b671afda"
+  integrity sha512-zOLD7SZiYR/8DPYkZZR9zGTV+04GAc+fhnBymAWSRryncAG4889cYxXJSbIvlsNVGpdGRIOSIZ4p6pIupfmZ5w==
+  dependencies:
+    plyr "^3.4.5"
+
 vue-router@^3.0.1:
   version "3.0.1"
   resolved "https://registry.yarnpkg.com/vue-router/-/vue-router-3.0.1.tgz#d9b05ad9c7420ba0f626d6500d693e60092cc1e9"
-- 
GitLab