diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000000000000000000000000000000000000..dfdb8b771ce07609491fa2e83698969fd917a135
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1 @@
+*.sh text eol=lf
diff --git a/.gitignore b/.gitignore
index 3bd56f3887e7d3944e8aed785bb9ad8081e7b078..3a92663bec88c632a656c850fdc55590c6c2c73d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,4 @@
 .idea/
 build
 dist
+db/
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index 1ffbe61ba8f95dcd8226b9e6c5ddbfd4d80b9125..e817dac4023405f9d1589dc38893f77832e18c26 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -7,11 +7,13 @@ stages:
   - deploy
 
 unittest:
-  image: python:3.9
   before_script:
-    - pip install -r requirements.txt
+    - echo "Not logging in"
   script:
-    - python manage.py test
+    - docker-compose up --build unittest
+  after_script:
+    - docker-compose down
+
 
 build_nginx:
   script:
diff --git a/Dockerfile b/Dockerfile
index 955ccdcc9b9a8b701b936d6e844b476bb21b449f..0037322aa9db2d83bc3fe5913e4a60276808b40f 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -9,15 +9,11 @@ ADD netguru /app/netguru
 ADD manage.py /app/manage.py
 ADD shares /app/shares
 ADD templates /app/templates
-
-RUN python manage.py collectstatic
+ADD test.sh /app/test.sh
+RUN python manage.py collectstatic && \
+    chmod ugo+x /app/test.sh
 
 VOLUME /data
 
-CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:80", "netguru.wsgi:application"]]
-
-FROM runtime AS unittest
-
-ADD tests /app/tests
+CMD ["gunicorn", "-w", "4", "-b", "0.0.0.0:80", "netguru.wsgi:application"]
 
-CMD ["python", "manage.py", "test"]
diff --git a/docker-compose.yml b/docker-compose.yml
index 0cc7e20f9fb50932c29a8fe4d2343c15307bae21..269d93aa5794ad790dba1452d2d3e3dbb371dd79 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -6,18 +6,17 @@ services:
       - POSTGRES_DB=postgres
       - POSTGRES_USER=postgres
       - POSTGRES_PASSWORD=postgres
+    volumes:
+      - ./db:/var/lib/postgresql
   unittest:
-    build:
-      context: .
-      dockerfile: Dockerfile
-      target: unittest
+    command: "./test.sh"
+    build: .
+    environment:
+      - CI=1
     depends_on:
       - postgres
   run_local:
-    build:
-      context: .
-      dockerfile: Dockerfile
-      target: runtime
+    build: .
     depends_on:
       - postgres
     ports:
diff --git a/netguru/settings.py b/netguru/settings.py
index 95de4139e8b6d3766289f60f827afdd28d648b9a..8cd17bc5120641320b68c4757ca72fe04d084d1d 100644
--- a/netguru/settings.py
+++ b/netguru/settings.py
@@ -21,19 +21,16 @@ INSTALLED_APPS = [
     'django.contrib.auth',
     'django.contrib.contenttypes',
     'django.contrib.sessions',
+    'django.contrib.messages',
     'django.contrib.staticfiles',
     'shares.apps.SharesConfig',
-    'django_cron',
-]
-
-CRON_CLASSES = [
-    "shares.cron.ClearExpiredLinks"
 ]
 
 MIDDLEWARE = [
     'django.middleware.security.SecurityMiddleware',
     'django.contrib.sessions.middleware.SessionMiddleware',
     'django.middleware.common.CommonMiddleware',
+    'django.contrib.messages.middleware.MessageMiddleware',
     'django.middleware.csrf.CsrfViewMiddleware',
     'django.contrib.auth.middleware.AuthenticationMiddleware',
     'django.middleware.clickjacking.XFrameOptionsMiddleware',
@@ -51,6 +48,7 @@ TEMPLATES = [
                 'django.template.context_processors.debug',
                 'django.template.context_processors.request',
                 'django.contrib.auth.context_processors.auth',
+                'django.contrib.messages.context_processors.messages',
                 'netguru.context.add_user',
             ],
         },
@@ -62,8 +60,8 @@ WSGI_APPLICATION = 'netguru.wsgi.application'
 # Database
 # https://docs.djangoproject.com/en/3.0/ref/settings/#databases
 
-DATABASES = {
-    'default': {
+if True:
+    db = {
         'ENGINE': 'django.db.backends.postgresql',
         'NAME': os.environ.get('DB_NAME', 'postgres'),
         'USER': os.environ.get('DB_USER', 'postgres'),
@@ -72,6 +70,12 @@ DATABASES = {
         'PORT': int(os.environ.get('DB_PORT', '5432')),
         'ATOMIC_REQUESTS': True  # transactions aren't just a good idea
     }
+else:
+    db = {'ENGINE': 'django.db.backends.sqlite3',
+          'NAME': 'test-db.sqlite'}
+
+DATABASES = {
+    'default': db
 }
 
 # Password validation
@@ -110,3 +114,5 @@ USE_TZ = True
 
 STATIC_URL = '/static/'
 STATIC_ROOT = os.path.join(BASE_DIR, 'media')
+
+FILE_SAVE_LOCATION = '/data'
diff --git a/netguru/urls.py b/netguru/urls.py
index 3bbb55b5f9fa5ba83c1be7bbfe5de0761367b2e1..764e36c1f00384b439900da1bc0d55ac56e58ef1 100644
--- a/netguru/urls.py
+++ b/netguru/urls.py
@@ -1,11 +1,12 @@
 from django.conf.urls.static import static
 from django.contrib import admin
-from django.urls import path, include
+from django.urls import path, include, re_path
 
 from shares import views
 
 urlpatterns = [
     path('accounts/', include('django.contrib.auth.urls')),
     path('accounts/profile', views.add_share),
+    re_path(r'shares/(?P<share_id>[0-9]+)$',views.view_share),
     path('admin/', admin.site.urls),
 ] + static('/static/', document_root='/app/media/')
diff --git a/requirements.txt b/requirements.txt
index 039b9ce5250e830efb3a5d9b8bfab2f156cdd921..7f6d31d942d597e03ef3cbb3c55b0746dd3301ba 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,4 +1,5 @@
 django
-psycopg2-binary
+psycopg2
 gunicorn
 django_cron
+satella
diff --git a/shares/cron.py b/shares/cron.py
index 23e35fc5c0113a7bb7e2d33da98849e2a2dd11a9..34e80cb070692254bc319c61913f8648c011f49f 100644
--- a/shares/cron.py
+++ b/shares/cron.py
@@ -1,7 +1,10 @@
 import logging
+import os
 from datetime import datetime, timedelta
 from django_cron import CronJobBase, Schedule
-from .models import Share
+
+from netguru.settings import FILE_SAVE_LOCATION
+from .models import Share, SHARE_FILE
 
 logger = logging.getLogger(__name__)
 
@@ -13,5 +16,12 @@ class ClearExpiredLinks(CronJobBase):
     code = 'clear-expired-links'  # a unique code
 
     def do(self):
-        Share.objects.filter(created_on__lt=datetime.now() - timedelta.days(1)).delete()
+        shares = Share.objects.filter(created_on__lt=datetime.now() - timedelta.days(1))
+        for share in shares:
+            if share.share_type == SHARE_FILE:
+                path = os.path.join(FILE_SAVE_LOCATION, share.resource.split('.', 1)[0])
+                if os.path.exists(path):
+                    # we might have deleted in earlier and this call might have aborted with exception
+                    # the transaction won't proceed so we might have already deleted this file
+                    os.unlink(path)
         logger.info('Deleted expired shares')
diff --git a/shares/migrations/0001_initial.py b/shares/migrations/0001_initial.py
new file mode 100644
index 0000000000000000000000000000000000000000..0c460cbb08b5116838ddbdf1bac6ce2a45201928
--- /dev/null
+++ b/shares/migrations/0001_initial.py
@@ -0,0 +1,30 @@
+# Generated by Django 3.2.6 on 2021-08-25 16:46
+
+import datetime
+from django.conf import settings
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    initial = True
+
+    dependencies = [
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='Share',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+                ('share_type', models.IntegerField(choices=[(0, 'URL'), (1, 'File')], verbose_name='Share type')),
+                ('created_on', models.DateTimeField(db_index=True, default=datetime.datetime.now, verbose_name='Created on')),
+                ('pwd_hash', models.CharField(max_length=64, verbose_name='Hashed password salt')),
+                ('resource', models.TextField(verbose_name='Resource')),
+                ('views_count', models.IntegerField(default=0, verbose_name='Amount of displays')),
+                ('creator', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='Creator')),
+            ],
+        ),
+    ]
diff --git a/shares/models.py b/shares/models.py
index df95b4b222ed627c8de668cc5ff2679fead61321..bfadb68c31a78037dde5fd7e72dc22365c297623 100644
--- a/shares/models.py
+++ b/shares/models.py
@@ -19,5 +19,6 @@ class Share(models.Model):
     created_on = models.DateTimeField(default=datetime.now, verbose_name='Created on',
                                       db_index=True)
     pwd_hash = models.CharField(max_length=64, verbose_name='Hashed password salt')
-    # this is either an URL or a file name in /data
+    # this is either an URL or a f'{UUID}.real_file_name' in /data
     resource = models.TextField(verbose_name='Resource')
+    views_count = models.IntegerField(verbose_name='Amount of displays', default=0)
diff --git a/shares/templates/share/add.html b/shares/templates/share/add.html
index 7e6de445bc79fdbe0d0d9c6d7842abe7a811404a..ada7993fedb456a121a5233939a8afc387fc4af7 100644
--- a/shares/templates/share/add.html
+++ b/shares/templates/share/add.html
@@ -7,12 +7,13 @@
 {% block body %}
     {% if added %}
         <div><em>Resource successfully added:</em><br/>
-            Your password is <em>{{ added.password }}</em><br/>
-            Your link is: <span><a href="{{ added.url }}" id="url">{{ added.url }}</a></span>
-            <button onclick="copyTextToClipboard('{{ added.url }}')">Copy to clipboard</button><br/>
-        </div></dv>
+            Your password is <em id="link_password">{{ added.password }}</em>
+            <button onclick="copyTextToClipboard('{{ added.password }}')">Copy the link to clipboard</button><br>
+            Your link is: <span><a href="{{ added.url }}" id="link_url">{{ added.url }}</a></span>
+            <button onclick="copyTextToClipboard('{{ added.url }}')">Copy the password to clipboard</button><br>
+        </div>
     {% endif %}
-    <form class="form-group text-left" role="form" action="/accounts/profile" method="POST">
+    <form class="form-group text-left" role="form" action="/accounts/profile" method="POST" enctype="multipart/form-data">
             {% csrf_token %}
         {{ form.as_p }}
     <p>
diff --git a/shares/templates/share/view.html b/shares/templates/share/view.html
new file mode 100644
index 0000000000000000000000000000000000000000..b8b3b3f0aed55614f651496abe506d464601821d
--- /dev/null
+++ b/shares/templates/share/view.html
@@ -0,0 +1,13 @@
+{% extends "base.html" %}{% load static %}
+{% load static %}
+{% block title %}View a resource{% endblock %}
+{% block head %}
+{% endblock %}
+{% block body %}
+    <form class="form-group text-left" role="form" action="/shares/{{ share_id }}" method="POST">
+            {% csrf_token %}
+        {{ form.as_p }}
+    <p>
+        <input type="submit" value="Submit">
+    </form>
+{% endblock %}
diff --git a/shares/tests.py b/shares/tests.py
index 7ce503c2dd97ba78597f6ff6e4393132753573f6..1ef9aa9fa8c33f252b53c203a988788ea7a09ffb 100644
--- a/shares/tests.py
+++ b/shares/tests.py
@@ -1,3 +1,58 @@
-from django.test import TestCase
+from django.contrib.auth.hashers import make_password
+from django.contrib.auth.models import User
+from django.test import TestCase, Client
+from django.test.html import parse_html
+
+
+def find_element(document, field, value):
+    if isinstance(document, str):
+        return
+    attrs = dict(document.attributes)
+    if attrs.get(field) == value:
+        document.attributes = attrs
+        return document
+    for child in document.children:
+        a = find_element(child, field, value)
+        if a is not None:
+            return a
+
 
 # Create your tests here.
+class TestShares(TestCase):
+
+    def setUp(self) -> None:
+        super().setUp()
+        user = User.objects.create(username='testuser', email='admin@admin.com',
+                                   password='12345')
+        self.client = Client()  # May be you have missed this line
+        self.client.force_login(user)
+
+    def test_add_url(self):
+        """
+        Test that an URL can be added and that it can be visited
+        """
+        response = self.client.get('http://127.0.0.1/accounts/profile')
+
+        self.assertEqual(response.status_code, 200)
+        html = parse_html(response.content.decode('utf-8'))
+        csrf_token = find_element(html, 'name', 'csrfmiddlewaretoken').attributes['value']
+
+        response = self.client.post('http://127.0.0.1/accounts/profile', data={
+            'url': 'https://example.com',
+            'csrfmiddlewaretoken': csrf_token
+        })
+        self.assertEqual(response.status_code, 200)
+
+        html = parse_html(response.content.decode('utf-8'))
+        link = find_element(html, 'id', 'link_url').children[0].replace('https', 'http')
+        password = find_element(html, 'id', 'link_password').children[0]
+
+        response = self.client.get(link)
+        self.assertEqual(response.status_code, 200)
+        html = parse_html(response.content.decode('utf-8'))
+        csrf_token = find_element(html, 'name', 'csrfmiddlewaretoken').attributes['value']
+        response = self.client.post(link, data={
+            'password': password,
+            'csrfmiddlewaretoken': csrf_token
+        })
+        self.assertEqual(response.status_code, 302)
diff --git a/shares/views.py b/shares/views.py
index e52ebac72add76e75c26d8092b0bfff67abf5ae0..5e5535540474e0bcf8fc427494a2e2ca0dea36b9 100644
--- a/shares/views.py
+++ b/shares/views.py
@@ -1,34 +1,49 @@
+import mimetypes
+import typing as tp
+import os
 import random
 import string
-
+from email.header import Header
+import uuid
 from django import forms
+from django.core.validators import URLValidator
 from django.forms import fields
 from django.core.exceptions import ValidationError
-from django.shortcuts import render
+from django.http import Http404, StreamingHttpResponse
+from django.shortcuts import render, get_object_or_404, redirect
 from django.contrib.auth.decorators import login_required
 from django.utils.safestring import mark_safe
 
 from shares.models import Share, SHARE_URL, SHARE_FILE
 import hashlib
-from netguru.settings import SECRET_KEY
+from netguru.settings import SECRET_KEY, FILE_SAVE_LOCATION
 
 
 def hash_password(password: str) -> str:
     return hashlib.sha256(password.encode('utf-8') + SECRET_KEY.encode('utf-8')).hexdigest()
 
 
+def generate_correct_uuid() -> str:
+    f = uuid.uuid4().hex
+    # I know that hash collisions practically don't happen, but since it's so cheap for us
+    # to go that extra mile
+    while os.path.exists(FILE_SAVE_LOCATION, f):
+        f = uuid.uuid4().hex
+    return f
+
 # Cut off O, I and S so that the user doesn't confuse them with 0, 1 and 5 respectively
-CHARACTERS_TO_USE = string.ascii_uppercase.replace('O', '').replace('I', '').replace('S',
-                                                                                     '') + string.digits
+CHARACTERS_TO_USE = string.ascii_uppercase.replace('O', '') \
+                          .replace('I', '').replace('S', '') + string.digits
 
 
 def random_password() -> str:
-    """Generate a eight character reasonably safe passwordpy"""
+    """Generate a eight character reasonably safe password"""
     return ''.join(random.choice(CHARACTERS_TO_USE) for i in range(6))
 
 
 class AddShareURLForm(forms.Form):
-    url = fields.CharField(label='URL', required=False)
+    url = fields.CharField(label='URL', required=False,
+                           validators=[URLValidator()])
     file = fields.FileField(label='File', required=False)
 
     def clean(self):
@@ -57,8 +72,63 @@ def add_share(request):
                 share.save()
                 data_added['url'] = mark_safe(f'https://{request.get_host()}/shares/{share.id}')
             else:
-                pass
+                file_name = generate_correct_uuid()
+                file = request.FILES['file']
+                resource_name = f'{file_name}.{file.name}'
+                with open(os.path.join(FILE_SAVE_LOCATION, file_name), 'wb') as f_out:
+                    for chunk in file.chunks():
+                        f_out.write(chunk)
+                share = Share(creator=request.user,
+                              resource=resource_name,
+                              pwd_hash=pwd_hash,
+                              share_type=SHARE_FILE)
+                share.save()
+                data_added['url'] = mark_safe(f'https://{request.get_host()}/shares/{share.id}')
     else:
         form = AddShareURLForm()
 
     return render(request, 'share/add.html', {'form': form, 'added': data_added})
+
+
+class PasswordForm(forms.Form):
+    password = fields.CharField(label='Password', widget=forms.PasswordInput)
+
+
+def view_share(request, share_id: str):
+    share = get_object_or_404(Share, id=share_id)
+    if request.method == 'POST':
+        form = PasswordForm(request.POST)
+        if form.is_valid():
+            pwd = form.cleaned_data['password']
+            if hash_password(pwd) == share.pwd_hash:
+                try:
+                    if share.share_type == SHARE_URL:
+                        return redirect(share.resource)
+                    else:
+                        file_uuid, file_name = share.resource.split('.', 1)
+                        file_loc = os.path.join(FILE_SAVE_LOCATION, file_uuid)
+                        if not os.path.exists(file_loc):
+                            raise Http404()
+                        else:
+                            def file_iterator(path: str) -> tp.Iterator[bytes]:
+                                with open(path, 'rb') as f:
+                                    p = f.read(1024)
+                                    while p:
+                                        yield p
+                                        p = f.read(1024)
+
+                            # Django will have the UTF-8 mumbo jumbo for us
+                            cd = f'attachment; filename="{file_name}"'
+                            mimetype = mimetypes.guess_type(file_name)[0] or 'application/octet-stream'
+                            return StreamingHttpResponse(file_iterator(file_loc),
+                                                         content_type=mimetype,
+                                                         headers={
+                                                             'Content-Disposition': cd
+                                                         })
+                finally:
+                    share.views_count += 1
+                    share.save()
+    else:
+        form = PasswordForm()
+
+    return render(request, 'share/view.html', {'form': form, 'share_id': share_id})
diff --git a/templates/base.html b/templates/base.html
index 95c0cb28f1bdf618f7dd961ed3a56e9064216420..e2c255cafcd6b4761147cb570737851b72e57f3c 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -13,11 +13,9 @@
       crossorigin="anonymous"></script>
     <link href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/css/bootstrap.min.css"
           rel="stylesheet">
-    <link href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/js/bootstrap.min.js"
-          rel="stylesheet">
-    <link href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/js/bootstrap.bundle.min.js"
-          rel="stylesheet">
-    <link href="https://use.fontawesome.com/releases/v5.3.1/css/all.css" rel="stylesheet"/>
+    <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/js/bootstrap.min.js"></script>
+    <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.1.3/js/bootstrap.bundle.min.js"></script>
+    <link href="https://use.fontawesome.com/releases/v5.3.1/css/all.css" rel="stylesheet">
     {% block head %}{% endblock %}
 </head>
 <body>
diff --git a/test.sh b/test.sh
new file mode 100644
index 0000000000000000000000000000000000000000..a84325ee72788fec2f63ea2c9f055316236db0e6
--- /dev/null
+++ b/test.sh
@@ -0,0 +1,6 @@
+#!/bin/bash
+set -e
+
+sleep 3   # wait for postgres to start up
+python manage.py makemigrations shares
+python manage.py test --no-input
diff --git a/tests/__init__.py b/tests/__init__.py
deleted file mode 100644
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..0000000000000000000000000000000000000000