├── flatpad ├── __init__.py ├── config │ ├── __init__.py │ ├── urls.py │ ├── wsgi.py │ ├── asgi.py │ └── settings.py ├── core │ ├── __init__.py │ ├── migrations │ │ ├── __init__.py │ │ └── 0001_initial.py │ ├── apps.py │ ├── admin.py │ ├── urls.py │ ├── models.py │ ├── templates │ │ └── index.html │ ├── static │ │ ├── style.css │ │ └── script.js │ └── views.py ├── config.ini ├── devices.json └── manage.py ├── AUTHORS ├── .gitignore ├── Screenshot.png ├── MANIFEST.in ├── setup.py ├── bin └── flatpad ├── LICENSE └── README.md /flatpad/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /flatpad/config/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /flatpad/core/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /flatpad/core/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Jonathan Hacker 2 | -------------------------------------------------------------------------------- /flatpad/config.ini: -------------------------------------------------------------------------------- 1 | [FritzBox] 2 | ip = 3 | password = 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | flatpad/db.sqlite3 2 | flatpad/secret_key 3 | *.pyc 4 | -------------------------------------------------------------------------------- /Screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jasminhacker/flatpad/HEAD/Screenshot.png -------------------------------------------------------------------------------- /flatpad/core/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class CoreConfig(AppConfig): 5 | name = 'core' 6 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include AUTHORS 2 | include LICENSE 3 | include MANIFEST.in 4 | graft flatpad 5 | global-exclude __pycache__ 6 | global-exclude *.py[co] 7 | -------------------------------------------------------------------------------- /flatpad/core/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from core.models import Pad 3 | 4 | # Register your models here. 5 | admin.site.register(Pad) 6 | -------------------------------------------------------------------------------- /flatpad/config/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import include, path 3 | 4 | urlpatterns = [ 5 | path("admin/", admin.site.urls), 6 | path("", include("core.urls")), 7 | ] 8 | -------------------------------------------------------------------------------- /flatpad/config/wsgi.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.core.wsgi import get_wsgi_application 4 | 5 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") 6 | 7 | application = get_wsgi_application() 8 | -------------------------------------------------------------------------------- /flatpad/config/asgi.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.core.asgi import get_asgi_application 4 | 5 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") 6 | 7 | ango_application = get_asgi_application() 8 | -------------------------------------------------------------------------------- /flatpad/devices.json: -------------------------------------------------------------------------------- 1 | { 2 | "People": [ 3 | {"Alice": ["d8:49:fa:d4:06:bf", "ef:93:f6:0f:d5:a1"]}, 4 | {"Bob": "c9:08:a5:7d:9b:e1"}, 5 | {"Charlie": ["1E:3B:B9:23:19:54", "25:CF:84:21:3B:A9", "41:B1:08:70:0C:D1"]} 6 | ], 7 | "Ignored": [ 8 | "9F:C1:4B:8C:15:6D", 9 | "29:89:40:D8:08:24" 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /flatpad/core/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from . import views 4 | 5 | urlpatterns = [ 6 | path("", views.index, name="index"), 7 | path("get_presence", views.get_presence, name="get_presence"), 8 | path("update_presence", views.update_presence, name="update_presence"), 9 | path("submit_pad", views.submit_pad, name="submit_pad"), 10 | ] 11 | -------------------------------------------------------------------------------- /flatpad/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") 9 | try: 10 | from django.core.management import execute_from_command_line 11 | except ImportError as exc: 12 | raise ImportError( 13 | "Couldn't import Django. Are you sure it's installed and " 14 | "available on your PYTHONPATH environment variable? Did you " 15 | "forget to activate a virtual environment?" 16 | ) from exc 17 | execute_from_command_line(sys.argv) 18 | 19 | 20 | if __name__ == "__main__": 21 | main() 22 | -------------------------------------------------------------------------------- /flatpad/core/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class Pad(models.Model): 5 | version = models.IntegerField(default=0) 6 | content = models.CharField(max_length=100000) 7 | 8 | def __str__(self) -> str: 9 | return f"{self.content[:20]}... ({self.version})" 10 | 11 | 12 | class Presence(models.Model): 13 | mac = models.CharField(max_length=100, unique=True) 14 | present = models.BooleanField(default=False) 15 | 16 | def __str__(self): 17 | return f"{self.mac}: {self.present}" 18 | 19 | 20 | class Anonymous(models.Model): 21 | count = models.IntegerField(default=0) 22 | 23 | def __str__(self): 24 | return f"Anonymous: {self.count}" 25 | 26 | 27 | class LastCheck(models.Model): 28 | performed = models.DateTimeField(auto_now=True) 29 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import setuptools 2 | 3 | with open("README.md") as f: 4 | long_description = f.read() 5 | 6 | setuptools.setup( 7 | name="flatpad", 8 | version="1.0.0", 9 | author="Jonathan Hacker", 10 | author_email="github@jhacker.de", 11 | description="A simple webpage that provides a digital pad and indicates who is at home.", 12 | long_description=long_description, 13 | long_description_content_type="text/markdown", 14 | url="https://github.com/jonathanhacker/flatpad", 15 | classifiers=[ 16 | "Framework :: Django", 17 | "License :: OSI Approved :: MIT License", 18 | "Programming Language :: Python :: 3", 19 | ], 20 | packages=["flatpad"], 21 | include_package_data=True, 22 | python_requires=">=3", 23 | install_requires=["bs4", "django", "fritzhome", "lxml", "netaddr", "netifaces",], 24 | scripts=["bin/flatpad"], 25 | ) 26 | -------------------------------------------------------------------------------- /bin/flatpad: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | import sys 5 | import subprocess 6 | 7 | try: 8 | import flatpad 9 | 10 | flatpad_directory = os.path.dirname(flatpad.__file__) 11 | except ModuleNotFoundError: 12 | print("Could not import flatpad. Are you using the correct python?") 13 | sys.exit(1) 14 | 15 | def usage(): 16 | print(f"usage: {sys.argv[0]} run|directory [port]") 17 | sys.exit(0) 18 | 19 | 20 | def main(): 21 | if len(sys.argv) == 1: 22 | usage() 23 | elif sys.argv[1] == "directory": 24 | print(flatpad_directory) 25 | elif sys.argv[1] == "run": 26 | port = 8000 27 | if len(sys.argv) > 2: 28 | port = int(sys.argv[2]) 29 | os.chdir(flatpad_directory) 30 | subprocess.call(f"python3 manage.py migrate".split()) 31 | subprocess.call(f"python3 manage.py runserver 0:{port}".split()) 32 | else: 33 | usage() 34 | 35 | 36 | if __name__ == "__main__": 37 | main() 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2020 Jonathan Hacker 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /flatpad/core/templates/index.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 | 4 | 5 | 6 | 7 | 8 | FlatPad 9 | 10 | 11 | 14 | 15 | 16 |
17 | {% for name in names %} 18 |
19 | {{ name }} 20 |
21 |
22 | {% endfor %} 23 | 27 | 28 |
29 | 30 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /flatpad/core/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2.4 on 2020-07-05 12:36 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | initial = True 9 | 10 | dependencies = [ 11 | ] 12 | 13 | operations = [ 14 | migrations.CreateModel( 15 | name='Anonymous', 16 | fields=[ 17 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 18 | ('count', models.IntegerField(default=0)), 19 | ], 20 | ), 21 | migrations.CreateModel( 22 | name='LastCheck', 23 | fields=[ 24 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 25 | ('performed', models.DateTimeField(auto_now=True)), 26 | ], 27 | ), 28 | migrations.CreateModel( 29 | name='Pad', 30 | fields=[ 31 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 32 | ('version', models.IntegerField(default=0)), 33 | ('content', models.CharField(max_length=100000)), 34 | ], 35 | ), 36 | migrations.CreateModel( 37 | name='Presence', 38 | fields=[ 39 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 40 | ('mac', models.CharField(max_length=100, unique=True)), 41 | ('present', models.BooleanField(default=False)), 42 | ], 43 | ), 44 | ] 45 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FlatPad 2 | 3 | FlatPad is a small tool that makes living with other people easier. See who is currently at home and share text on a digital pad. 4 | 5 | ![](Screenshot.png) 6 | 7 | ## Features 8 | 9 | * Know with a glance who is at home without checking for their shoes. 10 | * Works by checking your router for the MAC addresses of people's phones. (Currently only supports [AVM Fritz!Box](https://en.avm.de/products/fritzbox/)) 11 | * Falls back to using `nmap` if the router check fails. 12 | * Use the pad to share information. 13 | * Indicates whether the content is synced 14 | * Accessible to everyone in the network, no need for a messaging group. 15 | 16 | ## Installation 17 | 18 | `pip3 install flatpad` 19 | 20 | ## Usage 21 | 22 | `flatpad run [port]` (or `~/.local/bin/flatpad run` if pip's binaries are not in your PATH). 23 | Now FlatPad is running at `http://localhost:8000` or the port you specified. 24 | 25 | Add the MAC addresses of the people you want to display on top of the page to the devices.json. It is located in FlatPad's directory: 26 | `flatpad directory` 27 | (e.g. `~/.local/lib/python3.8/site-packages/flatpad/devices.json`) 28 | 29 | Simply follow the format of the default file. Names in the "People" segment will be green if at least one of their devices is in the network. 30 | All MACs not listed in this section will be summed up and are shown in an extra bubble. 31 | MACs in the "Ignored" section will not be counted towards this sum (e.g. your TV, a repeater or your toaster). 32 | 33 | In order to use the Fritz!Box feature, specify its ip and the password in the config.ini in the same directory as above (without quotes). 34 | 35 | For the `nmap` fallback, make sure that you have `nmap` installed on your system and it is able to open raw sockets without root access. 36 | `sudo setcap cap_net_raw+eip /usr/bin/nmap` 37 | 38 | Have fun! 39 | -------------------------------------------------------------------------------- /flatpad/core/static/style.css: -------------------------------------------------------------------------------- 1 | html { 2 | height: 100%; 3 | } 4 | body { 5 | height: 100%; 6 | display: flex; 7 | margin-top: 0px; 8 | margin-bottom: 0px; 9 | flex-direction: column; 10 | background-color: #272c30; 11 | font-size: large; 12 | } 13 | 14 | #presence { 15 | display: flex; 16 | flex-wrap: wrap; 17 | margin-top: 8px; 18 | margin-bottom: 8px; 19 | } 20 | .obround { 21 | background-color: #f8f9fa; 22 | color: #333333; 23 | border-radius: 1em; 24 | display: flex; 25 | align-items: center; 26 | margin: 0.2em; 27 | } 28 | .obround > span { 29 | padding-top: 0.2em; 30 | padding-bottom: 0.2em; 31 | } 32 | .presence-name { 33 | padding-left: 0.5em; 34 | margin-right: -0.2em; 35 | } 36 | .circle { 37 | text-align: center; 38 | vertical-align: middle; 39 | line-height: 1em; 40 | height: 1em; 41 | width: 1em; 42 | margin-left: 0.4em; 43 | margin-right: 0.4em; 44 | border-radius: 50%; 45 | } 46 | .no { 47 | border: 2px solid red; 48 | background-color: red; 49 | } 50 | .no:after { 51 | content: "✗"; 52 | } 53 | .yes { 54 | border: 2px solid green; 55 | background-color: green; 56 | } 57 | .yes:after { 58 | content: "✓"; 59 | } 60 | .warn { 61 | border: 2px solid yellow; 62 | background-color: yellow; 63 | } 64 | .warn:after { 65 | content: "!"; 66 | } 67 | #update-presence { 68 | text-align: center; 69 | vertical-align: middle; 70 | line-height: 1em; 71 | font-size: xx-large; 72 | color: #f8f9fa; 73 | cursor: pointer; 74 | user-select: none; 75 | } 76 | 77 | #pad { 78 | resize: none; 79 | flex-grow: 1; 80 | padding: 8px; 81 | color: #212529; 82 | border-radius: 0.25rem; 83 | border: none; 84 | font-family: sans-serif; 85 | } 86 | #pad-footer { 87 | display: flex; 88 | justify-content: space-between; 89 | margin-top: 8px; 90 | margin-bottom: 8px; 91 | } 92 | #pad-footer > * { 93 | margin-left: 8px; 94 | margin-right: 8px; 95 | } 96 | #pad-status-text { 97 | padding-right: 0.5em; 98 | margin-left: -0.2em; 99 | } 100 | 101 | @keyframes spinner { 102 | to {transform: rotate(360deg);} 103 | } 104 | .spinner { 105 | border: 2px solid #ccc; 106 | border-top-color: #000; 107 | animation: spinner .6s linear infinite; 108 | } 109 | -------------------------------------------------------------------------------- /flatpad/config/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...) 4 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 5 | 6 | 7 | # Quick-start development settings - unsuitable for production 8 | # See https://docs.djangoproject.com/en/3.0/howto/deployment/checklist/ 9 | 10 | from django.core.management import utils 11 | 12 | keyfile = os.path.join(BASE_DIR, "secret_key") 13 | if os.path.exists(keyfile): 14 | with open(keyfile) as f: 15 | SECRET_KEY = f.read().strip() 16 | else: 17 | SECRET_KEY = utils.get_random_secret_key() 18 | with open(keyfile, "w") as f: 19 | f.writelines(SECRET_KEY) 20 | 21 | # SECURITY WARNING: don't run with debug turned on in production! 22 | DEBUG = True 23 | 24 | ALLOWED_HOSTS = ["*"] 25 | 26 | 27 | # Application definition 28 | 29 | INSTALLED_APPS = [ 30 | "django.contrib.admin", 31 | "django.contrib.auth", 32 | "django.contrib.contenttypes", 33 | "django.contrib.sessions", 34 | "django.contrib.messages", 35 | "django.contrib.staticfiles", 36 | "core.apps.CoreConfig", 37 | ] 38 | 39 | MIDDLEWARE = [ 40 | "django.middleware.security.SecurityMiddleware", 41 | "django.contrib.sessions.middleware.SessionMiddleware", 42 | "django.middleware.common.CommonMiddleware", 43 | "django.middleware.csrf.CsrfViewMiddleware", 44 | "django.contrib.auth.middleware.AuthenticationMiddleware", 45 | "django.contrib.messages.middleware.MessageMiddleware", 46 | "django.middleware.clickjacking.XFrameOptionsMiddleware", 47 | ] 48 | 49 | ROOT_URLCONF = "config.urls" 50 | 51 | TEMPLATES = [ 52 | { 53 | "BACKEND": "django.template.backends.django.DjangoTemplates", 54 | "DIRS": [], 55 | "APP_DIRS": True, 56 | "OPTIONS": { 57 | "context_processors": [ 58 | "django.template.context_processors.debug", 59 | "django.template.context_processors.request", 60 | "django.contrib.auth.context_processors.auth", 61 | "django.contrib.messages.context_processors.messages", 62 | ], 63 | }, 64 | }, 65 | ] 66 | 67 | WSGI_APPLICATION = "config.wsgi.application" 68 | 69 | 70 | # Database 71 | # https://docs.djangoproject.com/en/3.0/ref/settings/#databases 72 | 73 | DATABASES = { 74 | "default": { 75 | "ENGINE": "django.db.backends.sqlite3", 76 | "NAME": os.path.join(BASE_DIR, "db.sqlite3"), 77 | } 78 | } 79 | 80 | 81 | # Password validation 82 | # https://docs.djangoproject.com/en/3.0/ref/settings/#auth-password-validators 83 | 84 | AUTH_PASSWORD_VALIDATORS = [ 85 | { 86 | "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", 87 | }, 88 | {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",}, 89 | {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",}, 90 | {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",}, 91 | ] 92 | 93 | 94 | # Internationalization 95 | # https://docs.djangoproject.com/en/3.0/topics/i18n/ 96 | 97 | LANGUAGE_CODE = "en-us" 98 | 99 | TIME_ZONE = "UTC" 100 | 101 | USE_I18N = True 102 | 103 | USE_L10N = True 104 | 105 | USE_TZ = True 106 | 107 | 108 | # Static files (CSS, JavaScript, Images) 109 | # https://docs.djangoproject.com/en/3.0/howto/static-files/ 110 | 111 | STATIC_URL = "/static/" 112 | -------------------------------------------------------------------------------- /flatpad/core/static/script.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | // https://plainjs.com/javascript/ajax/send-ajax-get-and-post-requests-47/ 4 | function postAjax(url, data, success, fail) { 5 | var params = typeof data == 'string' ? data : Object.keys(data).map( 6 | function(k){ return encodeURIComponent(k) + '=' + encodeURIComponent(data[k]) } 7 | ).join('&'); 8 | 9 | var xhr = window.XMLHttpRequest ? new XMLHttpRequest() : new ActiveXObject("Microsoft.XMLHTTP"); 10 | xhr.open('POST', url); 11 | xhr.onreadystatechange = function() { 12 | if (xhr.readyState>3 && xhr.status==200) { success(xhr.responseText); } 13 | else if (xhr.readyState>3 && xhr.status==400) { fail(xhr.responseText); } 14 | }; 15 | xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest'); 16 | xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded'); 17 | xhr.send(params); 18 | return xhr; 19 | } 20 | function getAjax(url, success) { 21 | var xhr = window.XMLHttpRequest ? new XMLHttpRequest() : new ActiveXObject('Microsoft.XMLHTTP'); 22 | xhr.open('GET', url); 23 | xhr.onreadystatechange = function() { 24 | if (xhr.readyState>3 && xhr.status==200) success(xhr.responseText); 25 | }; 26 | xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest'); 27 | xhr.send(); 28 | return xhr; 29 | } 30 | 31 | function setSynced() { 32 | let indicator = document.getElementById("pad-status-indicator"); 33 | if (indicator.classList.contains('no')) { 34 | // do not overwrite the 'Diverged' state 35 | return; 36 | } 37 | indicator.classList.remove('warn'); 38 | indicator.classList.add('yes'); 39 | let text = document.getElementById("pad-status-text"); 40 | text.textContent = 'Synced'; 41 | } 42 | function setChanged() { 43 | let indicator = document.getElementById("pad-status-indicator"); 44 | if (indicator.classList.contains('no')) { 45 | // do not overwrite the 'Diverged' state 46 | return; 47 | } 48 | indicator.classList.remove('yes'); 49 | indicator.classList.add('warn'); 50 | let text = document.getElementById("pad-status-text"); 51 | text.textContent = 'Changed'; 52 | } 53 | function setDiverged() { 54 | let indicator = document.getElementById("pad-status-indicator"); 55 | indicator.classList.remove('yes'); 56 | indicator.classList.remove('warn'); 57 | indicator.classList.add('no'); 58 | let text = document.getElementById("pad-status-text"); 59 | text.textContent = 'Diverged! Please reload.'; 60 | } 61 | 62 | function applyResponse(response) { 63 | presence = JSON.parse(response); 64 | let obrounds = document.querySelectorAll('#presence .obround'); 65 | for (let i = 0; i < presence.length; i++) { 66 | let name = presence[i][0]; 67 | let present = presence[i][1]; 68 | obrounds[i].getElementsByClassName("presence-name")[0].innerHTML = name; 69 | let circle = obrounds[i].getElementsByClassName("presence-indicator")[0]; 70 | circle.classList.remove("spinner"); 71 | if (present) { 72 | if (i == presence.length - 1) { 73 | obrounds[i].style.display = ""; 74 | } 75 | circle.classList.remove("no"); 76 | circle.classList.add("yes"); 77 | } else { 78 | if (i == presence.length - 1) { 79 | obrounds[i].style.display = "none"; 80 | } 81 | circle.classList.remove("yes"); 82 | circle.classList.add("no"); 83 | } 84 | } 85 | } 86 | 87 | function getPresence(url) { 88 | let obrounds = document.querySelectorAll('#presence .obround'); 89 | for (let i = 0; i < obrounds.length; i++) { 90 | let circle = obrounds[i].getElementsByClassName("presence-indicator")[0]; 91 | circle.classList.remove("yes"); 92 | circle.classList.remove("no"); 93 | circle.classList.add("spinner"); 94 | } 95 | 96 | getAjax(url, applyResponse); 97 | } 98 | 99 | document.addEventListener("DOMContentLoaded", function() { 100 | let pad = document.getElementById("pad"); 101 | pad.addEventListener('input', function() { 102 | setChanged(); 103 | }); 104 | 105 | let submitPad = document.getElementById("submit-pad"); 106 | //TODO: tap 107 | submitPad.addEventListener('click', function() { 108 | PAD_VERSION ++; 109 | postAjax('/submit_pad', { 110 | version: PAD_VERSION - 1, 111 | content: pad.value 112 | }, function(){ 113 | setSynced(); 114 | }, function() { 115 | PAD_VERSION --; 116 | setDiverged(); 117 | }); 118 | }); 119 | 120 | let updatePresenceButton = document.getElementById("update-presence"); 121 | updatePresenceButton.addEventListener('click', function() { 122 | getPresence('/update_presence') 123 | }); 124 | getPresence('/get_presence') 125 | }); 126 | 127 | window.addEventListener('beforeunload', function (e) { 128 | let indicator = document.getElementById("pad-status-indicator"); 129 | if (indicator.classList.contains('yes')) { 130 | // don't prompt the user if the content is synced 131 | return; 132 | } 133 | // show a prompt 134 | e.preventDefault(); 135 | e.returnValue = ''; 136 | }); 137 | -------------------------------------------------------------------------------- /flatpad/core/views.py: -------------------------------------------------------------------------------- 1 | import configparser 2 | import json 3 | import netifaces 4 | import os 5 | import subprocess 6 | 7 | from bs4 import BeautifulSoup 8 | from django.conf import settings 9 | from django.db import transaction 10 | from django.http import HttpResponse 11 | from django.http import HttpResponseBadRequest 12 | from django.http import JsonResponse 13 | from django.shortcuts import render 14 | from django.utils import timezone 15 | from django.views.decorators.csrf import csrf_exempt 16 | from fritzhome.fritz import FritzBox 17 | from netaddr import IPAddress 18 | 19 | from core.models import Anonymous, LastCheck, Pad, Presence 20 | 21 | 22 | def index(request): 23 | context = {} 24 | 25 | with open(os.path.join(settings.BASE_DIR, "devices.json")) as f: 26 | devices = json.load(f) 27 | context["names"] = [list(person.keys())[0] for person in devices["People"]] 28 | 29 | pad = Pad.objects.get_or_create(id=0)[0] 30 | context["pad_content"] = pad.content 31 | context["pad_version"] = pad.version 32 | 33 | return render(request, "index.html", context) 34 | 35 | 36 | @csrf_exempt 37 | def submit_pad(request): 38 | version = request.POST.get("version") 39 | content = request.POST.get("content") 40 | if not version or content is None: 41 | return HttpResponseBadRequest("No version or content supplied") 42 | version = int(version) 43 | 44 | with transaction.atomic(): 45 | pad = Pad.objects.get(id=0) 46 | current_version = pad.version 47 | if current_version == version: 48 | version_valid = True 49 | pad.version += 1 50 | pad.content = content 51 | pad.save() 52 | else: 53 | version_valid = False 54 | 55 | if not version_valid: 56 | return HttpResponseBadRequest( 57 | "The content was changed in the meantime, please reload" 58 | ) 59 | return HttpResponse("Updated Pad") 60 | 61 | 62 | def get_presence(request, force_update=False): 63 | with open(os.path.join(settings.BASE_DIR, "devices.json")) as f: 64 | devices = json.load(f) 65 | people = devices["People"] 66 | searched_macs = [] 67 | ignored_macs = [] 68 | for person in people: 69 | name = list(person.keys())[0] 70 | macs = person[name] 71 | if not isinstance(macs, list): 72 | macs = [macs] 73 | person[name] = macs 74 | person[name] = [mac.upper() for mac in person[name]] 75 | for mac in person[name]: 76 | searched_macs.append(mac) 77 | if not Presence.objects.filter(mac=mac).exists(): 78 | # a mac was added, force an update 79 | force_update = True 80 | for mac in devices["Ignored"]: 81 | ignored_macs.append(mac.upper()) 82 | 83 | last_check, created = LastCheck.objects.get_or_create(id=0) 84 | if created: 85 | last_check.save() 86 | 87 | if ( 88 | (timezone.now() - last_check.performed).seconds > 60 * 5 89 | or force_update 90 | or created 91 | ): 92 | present_macs, anonymous_count = search_devices(searched_macs, ignored_macs) 93 | 94 | for mac in searched_macs: 95 | presence = Presence.objects.get_or_create(mac=mac)[0] 96 | presence.present = mac in present_macs 97 | presence.save() 98 | 99 | anonymous = Anonymous.objects.get_or_create(id=0)[0] 100 | anonymous.count = anonymous_count 101 | anonymous.save() 102 | last_check.save() 103 | else: 104 | present_macs = [] 105 | for mac in searched_macs: 106 | if Presence.objects.get(mac=mac).present: 107 | present_macs.append(mac) 108 | anonymous_count = Anonymous.objects.get(id=0).count 109 | 110 | presences = [] 111 | for person in people: 112 | name = list(person.keys())[0] 113 | present = False 114 | for mac in person[name]: 115 | if mac in present_macs: 116 | present = True 117 | presences.append((name, present)) 118 | if anonymous_count > 0: 119 | presences.append((f"+{anonymous_count}", True)) 120 | else: 121 | presences.append((f"+{anonymous_count}", False)) 122 | 123 | return JsonResponse(presences, safe=False) 124 | 125 | 126 | def update_presence(request): 127 | return get_presence(request, force_update=True) 128 | 129 | 130 | def search_devices(searched_macs, ignored_macs): 131 | try: 132 | return fritzbox_query(searched_macs, ignored_macs) 133 | except FritzException: 134 | return nmap_query(searched_macs, ignored_macs) 135 | 136 | 137 | class FritzException(Exception): 138 | pass 139 | 140 | 141 | def fritzbox_query(searched_macs, ignored_macs): 142 | config = configparser.ConfigParser() 143 | config.read(os.path.join(settings.BASE_DIR, "config.ini")) 144 | ip = config["FritzBox"]["ip"] 145 | password = config["FritzBox"]["password"] 146 | if not ip or not password: 147 | raise FritzException("ip or password not specified") 148 | 149 | box = FritzBox(ip, None, password) 150 | try: 151 | box.login() 152 | except Exception: 153 | raise FritzException("Login failed") 154 | 155 | r = box.session.get( 156 | box.base_url + "/net/network_user_devices.lua", params={"sid": box.sid} 157 | ) 158 | 159 | try: 160 | table = BeautifulSoup(r.text, "lxml").find(id="uiLanActive") 161 | except AttributeError: 162 | raise FritzException("Could not extract active devices.") 163 | 164 | rows = table.find_all("tr") 165 | 166 | present_macs = [] 167 | anonymous_count = 0 168 | 169 | for row in rows: 170 | columns = row.find_all("td") 171 | if len(columns) >= 4: 172 | mac = columns[3].text.upper() 173 | if mac in searched_macs: 174 | present_macs.append(mac) 175 | elif mac not in ignored_macs: 176 | anonymous_count += 1 177 | 178 | return present_macs, anonymous_count 179 | 180 | 181 | def nmap_query(searched_macs, ignored_macs): 182 | active_devices = [] 183 | # look through all available interfaces 184 | for interface in netifaces.interfaces(): 185 | if ( 186 | interface == "lo" # loopback 187 | or interface.startswith("virbr") # libvirt 188 | or interface.startswith("lxcbr") # lxc 189 | or interface.startswith("docker") # docker 190 | or interface.startswith("br-") # bridges 191 | or interface.startswith("veth") # virtual devices 192 | ): 193 | continue 194 | # get the first set of IPv4 addresses (there will probably be only one) 195 | try: 196 | addresses = netifaces.ifaddresses(interface)[netifaces.AF_INET][0] 197 | except KeyError: 198 | # the interface has no IPv4 addresses 199 | continue 200 | # check if the interface received an address (= is connected) 201 | if "addr" in addresses: 202 | ip = addresses["addr"] 203 | netmask = addresses["netmask"] 204 | suffix = IPAddress(netmask).netmask_bits() 205 | output = subprocess.check_output( 206 | ["/usr/bin/nmap", "--privileged", "-sn", f"{ip}/{suffix}"], 207 | universal_newlines=True, 208 | ) 209 | for line in output.split("\n"): 210 | if line.startswith("MAC Address:"): 211 | mac_address = line.split()[2] 212 | active_devices.append(mac_address.upper()) 213 | 214 | present_macs = [] 215 | anonymous_count = 0 216 | 217 | for mac in active_devices: 218 | if mac in searched_macs: 219 | present_macs.append(mac) 220 | elif mac not in ignored_macs: 221 | anonymous_count += 1 222 | 223 | return present_macs, anonymous_count 224 | --------------------------------------------------------------------------------