├── .github └── workflows │ └── codeql-analysis.yml ├── .gitignore ├── LICENSE ├── README.md ├── SECURITY.md ├── createadmin.py ├── defaults.cfg ├── pixelserver.py ├── pixelserver.service ├── pixelserver ├── __init__.py ├── customlight.py ├── pixelconfig.py ├── pixels.py ├── pixelseq.py ├── requests │ ├── __init__.py │ └── routes.py ├── serverauth.py ├── serveruser.py ├── serveruseradmin.py ├── statusmsg.py └── www │ ├── deleteuser.html │ ├── edituser.html │ ├── index.html │ ├── invalid.html │ ├── jquery-ui.min.js │ ├── jquery.min.js │ ├── login.html │ ├── newuser.html │ ├── password.html │ ├── passwordadmin.html │ ├── pixels.css │ ├── pixels.js │ ├── profile.html │ ├── settings.html │ └── useradmin.html └── tests ├── TESTS.md ├── __init__.py ├── configs ├── auth-allownone_test.cfg ├── auth-noguest_test.cfg ├── auth-proxy_test.cfg ├── auth_test.cfg ├── customlight_test.cfg ├── defaults_test.cfg ├── logins.txt ├── pixelserver_test.cfg ├── sha256_test.cfg └── users_test.cfg ├── data └── usertests.json ├── functional ├── __init__.py ├── test_flask_1.py ├── test_flask_auth_1.py ├── test_flask_passwords_1.py ├── test_flask_useradmin_1.py ├── test_flask_useradmin_2.py └── test_flask_useradmin_3.py └── unit ├── __init__.py ├── test_serverauth1.py ├── test_serveruser1.py └── test_serveruseradmin1.py /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL" 13 | 14 | on: 15 | push: 16 | branches: [ "main" ] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: [ "main" ] 20 | schedule: 21 | - cron: '21 5 * * 0' 22 | 23 | jobs: 24 | analyze: 25 | name: Analyze 26 | runs-on: ubuntu-latest 27 | permissions: 28 | actions: read 29 | contents: read 30 | security-events: write 31 | 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | language: [ 'javascript', 'python' ] 36 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 37 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 38 | 39 | steps: 40 | - name: Checkout repository 41 | uses: actions/checkout@v3 42 | 43 | # Initializes the CodeQL tools for scanning. 44 | - name: Initialize CodeQL 45 | uses: github/codeql-action/init@v2 46 | with: 47 | languages: ${{ matrix.language }} 48 | # If you wish to specify custom queries, you can do so here or in a config file. 49 | # By default, queries listed here will override any specified in a config file. 50 | # Prefix the list here with "+" to use these queries and those in the config file. 51 | 52 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 53 | # queries: security-extended,security-and-quality 54 | 55 | 56 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 57 | # If this step fails, then you should remove it and run the build manually (see below) 58 | - name: Autobuild 59 | uses: github/codeql-action/autobuild@v2 60 | 61 | # ℹ️ Command-line programs to run using the OS shell. 62 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 63 | 64 | # If the Autobuild fails above, remove it and uncomment the following three lines. 65 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 66 | 67 | # - run: | 68 | # echo "Run, Build Application using script" 69 | # ./location_of_script_within_repo/buildscript.sh 70 | 71 | - name: Perform CodeQL Analysis 72 | uses: github/codeql-action/analyze@v2 73 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Don't copy config file 2 | pixelserver.cfg 3 | customlight.cfg 4 | auth.cfg 5 | users.cfg 6 | pixelserver.log 7 | 8 | 9 | 10 | # Byte-compiled / optimized / DLL files 11 | __pycache__/ 12 | *.py[cod] 13 | *$py.class 14 | 15 | # C extensions 16 | *.so 17 | 18 | # Distribution / packaging 19 | .Python 20 | build/ 21 | develop-eggs/ 22 | dist/ 23 | downloads/ 24 | eggs/ 25 | .eggs/ 26 | lib/ 27 | lib64/ 28 | parts/ 29 | sdist/ 30 | var/ 31 | wheels/ 32 | pip-wheel-metadata/ 33 | share/python-wheels/ 34 | *.egg-info/ 35 | .installed.cfg 36 | *.egg 37 | MANIFEST 38 | 39 | # PyInstaller 40 | # Usually these files are written by a python script from a template 41 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 42 | *.manifest 43 | *.spec 44 | 45 | # Installer logs 46 | pip-log.txt 47 | pip-delete-this-directory.txt 48 | 49 | # Unit test / coverage reports 50 | htmlcov/ 51 | .tox/ 52 | .nox/ 53 | .coverage 54 | .coverage.* 55 | .cache 56 | nosetests.xml 57 | coverage.xml 58 | *.cover 59 | *.py,cover 60 | .hypothesis/ 61 | .pytest_cache/ 62 | 63 | # Translations 64 | *.mo 65 | *.pot 66 | 67 | # Django stuff: 68 | *.log 69 | local_settings.py 70 | db.sqlite3 71 | db.sqlite3-journal 72 | 73 | # Flask stuff: 74 | instance/ 75 | .webassets-cache 76 | 77 | # Scrapy stuff: 78 | .scrapy 79 | 80 | # Sphinx documentation 81 | docs/_build/ 82 | 83 | # PyBuilder 84 | target/ 85 | 86 | # Jupyter Notebook 87 | .ipynb_checkpoints 88 | 89 | # IPython 90 | profile_default/ 91 | ipython_config.py 92 | 93 | # pyenv 94 | .python-version 95 | 96 | # pipenv 97 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 98 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 99 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 100 | # install all needed dependencies. 101 | #Pipfile.lock 102 | 103 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 104 | __pypackages__/ 105 | 106 | # Celery stuff 107 | celerybeat-schedule 108 | celerybeat.pid 109 | 110 | # SageMath parsed files 111 | *.sage.py 112 | 113 | # Environments 114 | .env 115 | .venv 116 | env/ 117 | venv/ 118 | ENV/ 119 | env.bak/ 120 | venv.bak/ 121 | 122 | # Spyder project settings 123 | .spyderproject 124 | .spyproject 125 | 126 | # Rope project settings 127 | .ropeproject 128 | 129 | # mkdocs documentation 130 | /site 131 | 132 | # mypy 133 | .mypy_cache/ 134 | .dmypy.json 135 | dmypy.json 136 | 137 | # Pyre type checker 138 | .pyre/ 139 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pixel-server 2 | Wireless control of PixelStrips or NeoPixels using a web graphical interface running on a Raspberry Pi. 3 | 4 | Works with any Raspberry Pi. 5 | On older Raspberry Pi models (Raspberry Pi V1 or Raspberry Pi Zero) then it is recommended running in headless mode and without using a local web browser. For newer models that should not be necessary. 6 | 7 | For more details see: 8 | 9 | 10 | ![NeoPixel web application](http://www.penguintutor.com/projects/images/pixelserver-webapplication.png) 11 | 12 | ## Colors 13 | 14 | The color of the pixels depends upon your actual strip. In the default code then grey is set to a very low value to show a noticeable difference on my pixel strip. This may appear to be near to black on the screen, but shows fairly bright on the strip. The same problem occurs with lighter colors such as light green which may appear almost white on the pixel strip. 15 | 16 | ## Sequences 17 | 18 | The sequences are defined in pixelseq.py. You can add your own for any additional sequences you would like. You will need to create the appropriate method and update SeqList.pixel_sequences and PixelSeq.seq_methods. 19 | 20 | It's also possible to create other sequences using the built-in sequences with specific color sequences. In particular you can add black for any pixels that you would like tried turned off. 21 | 22 | For example try a chaser with: 23 | Black; Black; White; White 24 | Grey; White; White; White; Grey; Black 25 | 26 | ## Install 27 | 28 | This is designed to run on a Raspberry Pi with PixelStrip / NeoPixels / WS2812B LEDs connected to a gpio pin. It is recommended that you use a voltage level-shifter. 29 | For more details see: 30 | 31 | 32 | 33 | To install the RPI ws281x library: 34 | 35 | pip3 install rpi_ws281x 36 | 37 | To install the Argon hash algorithm 38 | 39 | sudo apt install python3-argon2 40 | 41 | To install the Flask CSRF protection 42 | 43 | pip3 install Flask-WTF 44 | 45 | To be able to run the tests 46 | 47 | sudo apt install python3-pytest 48 | 49 | 50 | It is recommended to install this program directly from git, which will allow for install of future updates. Note that updates will replace any custom sequences you have created. To provide support for different usernames I recommend installing into the /opt directory. 51 | 52 | On a Raspberry Pi, open a terminal and enter the following commands: 53 | 54 | cd /opt 55 | sudo mkdir pixel-server 56 | sudo chown $USER: pixel-server 57 | git clone https://github.com/penguintutor/pixel-server.git pixel-server 58 | 59 | Then to have it start automatically run the following: 60 | 61 | sudo cp /opt/pixel-server/pixelserver.service /etc/systemd/system/ 62 | sudo chown root:root /etc/systemd/system/pixelserver.service 63 | sudo chmod 644 /etc/systemd/system/pixelserver.service 64 | 65 | Enable using: 66 | 67 | sudo systemctl start pixelserver.service 68 | sudo systemctl enable pixelserver.service 69 | 70 | There is also a video providing a step-by-step guide to installing this on a Raspberry Pi. [Installing Pixel Server on Raspberry Pi OS 64-bit](https://youtu.be/D1VsBHWuY_I) 71 | 72 | For more information see: [Penguin Tutor guide to starting programs automatically on a Raspberry Pi](http://www.penguintutor.com/raspberrypi/startup) 73 | 74 | 75 | # Security 76 | 77 | The pixel server is designed to support varying levels of security depending upon your system requirements. 78 | 79 | If used on a private only network then it can be configured for network address authentication. 80 | 81 | If allowing incoming connections from the Internet then it is recommended that user authentication is enabled and it is configured through SSL. The configuration below is based on using Nginx as a reverse proxy to provide HTTPS using a LetsEncrypt certificate. 82 | 83 | ## Enable SSL (HTTPS) 84 | 85 | This explains how you can use the Flask (development) server with https using Nginx as a reverse proxy. This uses a free security certificate from [Let's Encrypt](https://letsencrypt.org/). This means that I am able to setup Nginx as a reverse proxy on my home server, which could be used to provide encrypted connections to different services. 86 | 87 | 88 | ### On Nginx reverse proxy 89 | 90 | This does not have to be on the same server as pixel-server. 91 | 92 | First make sure your system is up-to-date 93 | 94 | sudo apt update 95 | sudo apt upgrade 96 | sudo apt install nginx 97 | 98 | sudo apt install certbot 99 | sudo apt install python3-certbot-nginx 100 | 101 | 102 | add new file in sites-available 103 | 104 | ln -s to /etc/nginx/sites-enabled 105 | 106 | 107 | Add the following in a location file (this assumes using /rpi1/ as the route 108 | for this particular server. 109 | 110 | location /rpi1/ { 111 | proxy_set_header X-Real-IP $remote_addr; 112 | proxy_pass http:///; 113 | } 114 | 115 | 116 | sudo certbot --nginx -d 117 | 118 | This updates /etc/nginx/sites-enabled 119 | 120 | update with 121 | sudo nginx -t 122 | sudo nginx -s reload 123 | 124 | Add the following to crontab for root: 125 | 0 12 * * * /usr/bin/certbot renew --quiet 126 | 127 | This checks for updates on a daily basis and if required renew 128 | 129 | 130 | ## Login 131 | 132 | New login features requires that users login to the web interface. This would prevent automation from working, therefore an alternative is allowed where clients can be pre-authorized based on their IP address. All admin functions need to be logged in as an admin user. 133 | 134 | If automation runs on the local machine then it is recommended that only the loopback IP address 127.0.0.1 is pre-authorized, but additional IP addresses can be enabled for use by WiFi switches, such as those used in the [ESP32 wireless capacitive touch switch](http://www.penguintutor.com/projects/esp32-captouch). 135 | 136 | 137 | There are no users setup as default. Before you can login then you should create your first admin user with the following command: 138 | 139 | python3 createadmin.py >> users.cfg 140 | 141 | The angled brackets should not be included around the username or password. The double greater than symbols will append 142 | to the users.cfg file, so if the file already exists this will not remove any existing accounts. Ensure you don't end up 143 | with multiple users with the same username using this command. Instead once you have setup the initial user you should 144 | login through the web interface to configure additional users. It is recommended that you restart the server after 145 | creating the initial admin login, if not then you will continue to get warnings about having no users setup. 146 | 147 | # Automation 148 | 149 | You can automate the light sequences being turned on and off by using crontab. For example: 150 | 151 | 0 22 * * * wget -O /dev/null http://127.0.0.1/set?seq=alloff&delay=976&reverse=0&colors=ffffff 152 | 0 16 * * 1-5 wget -O /dev/null http://127.0.0.1/set?seq=chaser&delay=5000&reverse=1&colors=ffffff,ffffff,ffffff,0000ff,ffffff,ffffff,ffffff,00ffff 153 | 154 | ## Toggle 155 | 156 | An additional setting available for automation is &toggle=True. When turned on then the pixel output will toggle between the specified sequence and allOff. 157 | 158 | ## Cheerlights / Custom color 159 | 160 | New custom color option. You can create (or use an external program) a file called customlight.cfg. This file should contain a single color or list of colors (one color per line). They should be in html RRGGBB color format, with or without a #. 161 | Any lines not recognised are ignored. 162 | 163 | To use in automation use the word "custom" in place of the RGB value in the URL string. 164 | 165 | For best effect use either a single color in the customlight.cfg file, or the same number of custom entries as the number of custom LEDs selected. 166 | 167 | ## Cheerlight automation 168 | 169 | If you would like to have the lights automatically update to the latest cheerlight color then you can add the following line to crontab. 170 | 171 | */5 * * * * wget -O ~/pixel-server/customlight.cfg http://api.thingspeak.com/channels/1417/field/2/last.txt 172 | 173 | The ~ assumes that this is installed in your home directory. 174 | 175 | # Configuration 176 | 177 | The default configuration is in a file called **defaults.cfg**. You should not edit that file directly as it will be overwritten by future upgrades. Instead copy the relevant entries to a new file called **pixelserver.cfg**. 178 | 179 | The following parameters can be used: 180 | 181 | * ledcount - Number of LEDs on your pixel strip 182 | * gpiopin - GPIO pin number 183 | * ledfreq - LED frequency (normally leave at default) 184 | * leddma - LED DMA number (normally leave at default) 185 | * ledchannel - LED channel number (normally leave at default) 186 | * ledmaxbrighness - LED brightness (0 to 255) 187 | * ledinvert - Set to True if an inverting buffer is used (otherwise False) 188 | * striptype - Set to colour sequence or strip type 189 | 190 | ## Valid strip types 191 | The striptype can be set using a color sequence. For WS2811 / WS2812B strips then this should be three letters representing the order of the RGB colours eg. _GRB_ for green, red then blue. 192 | 193 | For SK6812 strips then it should be four letters also including W for white. eg. _GRBW_ for green, red, blue and then white. 194 | 195 | Alternatively the strip type can be defined as one of the following values: 196 | WS2811_STRIP_RGB 197 | WS2811_STRIP_RBG 198 | WS2811_STRIP_GRB 199 | WS2811_STRIP_BGR 200 | WS2811_STRIP_BRG 201 | WS2811_STRIP_BGR 202 | WS2812_STRIP 203 | SK6812_STRIP_RGBW 204 | SK6812_STRIP_RBGW 205 | SK6812_STRIP_GRBW 206 | SK6812_STRIP_GBRW 207 | SK6812_STRIP_BRGW 208 | SK6812_STRIP_BGRW 209 | SK6812_STRIP 210 | SK6812W_STRIP 211 | 212 | 213 | # auth.cfg 214 | Controls authentication. There is no auth.cfg by default, which results in all users needing to login, available from any network. 215 | 216 | Can have one or more of the following, which can be a single IP address, or a network subnet, multiple addresses or network subnets (comma seperated) or 0.0.0.0 (all addresses) 217 | Multiple entries will be appended to the access. 218 | 219 | proxy_server = 220 | Any addresses in this range will be treated as proxy servers. 221 | If the proxy server has X-Real-IP set then that will be used instead of the local ip address of the server. Warning if that is not a proxy server then this 222 | could be a security risk (in terms of allowing non authenticated logins). Cannot be 0.0.0.0 (everywhere is a proxy doesn't make sense) - normally this will be specific IP address rather than range. 223 | If the attempt from the proxy server does not have X-Real_IP then the address will be treated as coming from that IP address. 224 | 225 | 226 | network_allow_always = 227 | Allways allow without login (useful for automation or local) 228 | 229 | network_allow_auth = 230 | Must always be a logged in user to change the light status 231 | normally this is 0.0.0.0 = allow all, but with authentication 232 | 233 | Note that to perform any adminstration tasks then must be in either of the above - but must also be authenticated. 234 | 235 | ## Example auth.cfg 236 | For a typical authentication file which allows unauthenicated from the localhost and requires login from all other hosts then save the following into a file called auth.cfg 237 | 238 | # Authentication rules for Pixel Server 239 | # Following addresses can access without authentication 240 | network_allow_always = 127.0.0.1 241 | # Following allowed, but need to authenticate 242 | # 0.0.0.0 = all addresses 243 | network_allow_auth = 0.0.0.0 244 | 245 | 246 | # Updates and changes 247 | 248 | This code is in development and may change in operation. Details of significant changes will be included here. 249 | 250 | # Change log 251 | 252 | ## August 2022 - v0.1.0 253 | Authentication merged into main branch. 254 | 255 | ## July 2022 256 | Authentication and logging enabled. This is a significant change which brings in additional security. Changes may be needed to user configuration files as well as generating a new user to administer the system. 257 | 258 | You may need to install additional libraries including the following commands: 259 | 260 | sudo pip3 install rpi_ws281x 261 | sudo apt install python3-argon2 262 | sudo pip3 install Flask-WTF 263 | 264 | 265 | 266 | ## May 2022 267 | Additional color option of "Custom Color". This allows custom colors to be used through a custom.light.cfg file. This is particularly useful if you want to be able to control color using cheerlights or would like to provide your own home automation with non-volitile color changes. 268 | 269 | 270 | ## April 2022 271 | When setting a sequence the server will now respond with a JSON formatted status replacing the previous single word "ready" or simple error message. This provides feedback on the status of the request as well as what sequence is currently being displayed. 272 | 273 | 274 | # Upgrading to the latest version 275 | 276 | If you installed the software using a _git clone_ then you can update by issuing a `git pull`. Alternatively you can download the latest version overwriting your existing files manually. 277 | 278 | As long as you followed the instructions regarding using a custom configuration file, then your config will still be kept. 279 | 280 | If upgrading from a version prior to September 2022 you may need to add the following pre-requisites if not already installed: 281 | 282 | sudo apt install python3-argon2 283 | sudo pip3 install Flask-WTF 284 | 285 | Then you will need to create a login using the createadmin.py script explained under the install instructions. 286 | 287 | python3 createadmin.py >> users.cfg 288 | 289 | You may also need to create a new configuration file for auth.cfg 290 | 291 | # Development 292 | 293 | ## Testing 294 | 295 | Currently supports limited automated testing based around the authentication. This is achieved using: 296 | py.test-3 297 | 298 | Manual testing is required for all other functions. 299 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | The following are supported versions 6 | 7 | | Version | Supported | 8 | | ------- | ------------------ | 9 | | < 0.1 | :x: | 10 | | 0.1.x | :white_check_mark: | 11 | 12 | ## Reporting a Vulnerability 13 | 14 | Report any known vulnerabilities through GitHub Issues. 15 | Please include full details of the version number, vulnerability and how to recreate it. 16 | -------------------------------------------------------------------------------- /createadmin.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from pixelserver.serveruser import ServerUser 3 | # Create admin user and password 4 | # Run using: 5 | # python3 createadmin.py >>users.cfg 6 | ## Warning!! without two >> then it may delete all existing users. 7 | # Username should not already exist, otherwise will be ignored 8 | # Uses minimal information - login to update afterwards 9 | 10 | # Check command line arguments 11 | if (len(sys.argv) < 2): 12 | print ("Insufficient arguments\npython3 createadmin.py >> users.cfg\n") 13 | sys.exit() 14 | # check for colon in username 15 | if (':' in sys.argv[1]): 16 | print ("Colon not allowed in username") 17 | sys.exit() 18 | # Create user entry 19 | print ("{}:{}:Admin user:admin::\n".format(sys.argv[1], ServerUser.hash_password(sys.argv[2]))) 20 | -------------------------------------------------------------------------------- /defaults.cfg: -------------------------------------------------------------------------------- 1 | # Configuration file for pixel-server 2 | # Do not edit this file directly instead create a new file 3 | # pixelserver.cfg with any values you would like to override 4 | 5 | ledcount=44 6 | gpiopin=18 7 | ledfreq=800000 8 | leddma=5 9 | ledmaxbrightness=50 10 | ledinvert=False 11 | ledchannel=0 12 | striptype=GRB 13 | algorithm=Argon2 14 | -------------------------------------------------------------------------------- /pixelserver.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import pixelserver 3 | from pixelserver import create_app 4 | from pixelserver.pixels import Pixels 5 | import threading 6 | 7 | # Custom settings - filenames with further configs 8 | default_config_filename = "defaults.cfg" 9 | custom_config_filename = "pixelserver.cfg" 10 | custom_light_config_filename = "customlight.cfg" 11 | auth_config_filename = "auth.cfg" 12 | auth_users_filename = "users.cfg" 13 | log_filename = "/var/log/pixelserver.log" 14 | 15 | def flaskThread(): 16 | app.run(host='0.0.0.0', port=80) 17 | 18 | # Setup pixel strip and then start the updatePixels loop 19 | def mainThread(): 20 | pixels.run() 21 | 22 | app = create_app(auth_config_filename, auth_users_filename, log_filename) 23 | pixels = Pixels(default_config_filename, custom_config_filename, custom_light_config_filename) 24 | 25 | if __name__ == "__main__": 26 | # run as two threads - main thread and flask thread 27 | mt = threading.Thread(target=mainThread) 28 | ft = threading.Thread(target=flaskThread) 29 | mt.start() 30 | ft.start() 31 | 32 | 33 | -------------------------------------------------------------------------------- /pixelserver.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=Wireless pixelstrip server 3 | After=sshd.service 4 | 5 | [Service] 6 | WorkingDirectory=/opt/pixel-server 7 | ExecStart=/opt/pixel-server/pixelserver.py 8 | Restart=always 9 | User=root 10 | 11 | [Install] 12 | WantedBy=multi-user.target 13 | 14 | -------------------------------------------------------------------------------- /pixelserver/__init__.py: -------------------------------------------------------------------------------- 1 | import pixelserver 2 | from pixelserver.pixelconfig import PixelConfig 3 | from pixelserver.pixelseq import PixelSeq, SeqList 4 | from pixelserver.serverauth import ServerAuth 5 | from flask import Flask 6 | from flask_wtf.csrf import CSRFProtect 7 | import random 8 | import string 9 | import time 10 | import logging, os 11 | 12 | 13 | # Globals for passing information between threads 14 | # needs default settings 15 | upd_time = time.time() 16 | seq_set = { 17 | "sequence" : "alloff", 18 | "delay" : 900, 19 | "reverse" : 0, 20 | "colors" : "ffffff" 21 | } 22 | pixel_conf = None 23 | # used for toggle option 24 | # Ignored unless toggle=True parameter 25 | on_status = False 26 | 27 | # List of sequences 28 | seq_list = SeqList() 29 | 30 | 31 | 32 | def load_config(default_config_filename, custom_config_filename, custom_light_config_filename): 33 | return PixelConfig(default_config_filename, custom_config_filename, custom_light_config_filename) 34 | 35 | # Should always run with csrf=True 36 | # csrf_enable=False is only included for testing purposes (disables CSRF) 37 | # debug = True (include debug messages in log - eg Testing) 38 | # debug = False - minimum INFO messages are logged 39 | def create_app(auth_config_filename, auth_users_filename, log_filename, csrf_enable=True, debug=False): 40 | pixelserver.auth_config_filename = auth_config_filename 41 | pixelserver.auth_users_filename = auth_users_filename 42 | 43 | if debug==False: 44 | log_level = logging.INFO 45 | else: 46 | log_level = logging.DEBUG 47 | 48 | start_logging (log_filename, log_level) 49 | pixelserver.auth = ServerAuth(auth_config_filename, auth_users_filename) 50 | 51 | if csrf_enable: 52 | csrf = CSRFProtect() 53 | app = Flask( 54 | __name__, 55 | template_folder="www" 56 | ) 57 | # Create a secret_key to last whilst the program is running 58 | app.secret_key = ''.join(random.choice(string.ascii_letters) for i in range(15)) 59 | if csrf_enable: 60 | csrf.init_app(app) 61 | register_blueprints(app) 62 | return app 63 | 64 | #Turn on logging through systemd 65 | def start_logging(log_filename, log_level=logging.INFO): 66 | logging.basicConfig(level=log_level, filename=log_filename, filemode='a', format='%(asctime)s %(levelname)-4s %(message)s', datefmt='%Y-%m-%d %H:%M:%S') 67 | logging.info ("PixelServer application start") 68 | 69 | #Register routes as @requests 70 | def register_blueprints(app): 71 | from pixelserver.requests import requests_blueprint 72 | app.register_blueprint(requests_blueprint) -------------------------------------------------------------------------------- /pixelserver/customlight.py: -------------------------------------------------------------------------------- 1 | from time import time 2 | import os 3 | import re 4 | 5 | # Class used to support cheerlights or other custom light color 6 | class CustomLight(): 7 | 8 | def __init__ (self, filename): 9 | self.filename = filename 10 | # Create empty list - must always add one entry 11 | self.colors = [] 12 | 13 | # If file not found or an error then custom colors is disabled 14 | self.load_colors() 15 | 16 | self.last_updated = self.get_file_mtime() 17 | 18 | # Load colors. Also check we have at least one color 19 | def load_colors(self): 20 | self._file_load_colors() 21 | 22 | # if no colors then add default ffffff 23 | if len(self.colors) < 1: 24 | self.colors.append("ffffff") 25 | 26 | 27 | def get_file_mtime(self): 28 | try: 29 | mtime = os.path.getmtime(self.filename) 30 | except: 31 | mtime = 0 32 | return mtime 33 | 34 | 35 | # load config if it exists 36 | # If not exist return 0, if successful return 1, if not successful return -1 (file error) -2 (no colors defined) 37 | # If error also populate self.error_msg 38 | # It does not matter if custom fails as long as default works 39 | # Includes some validation checks, but these are very crude 40 | # to detect mistakes rather than security reasons 41 | def _file_load_colors(self): 42 | # Reset list of colors 43 | self.colors.clear() 44 | # Compile regexp for more efficient matching 45 | re_color = re.compile("^#?([0-9A-Fa-f]{6})") 46 | 47 | # Try and open file - if not exist then just will be caught as exception 48 | try: 49 | with open (self.filename, "r") as color_file: 50 | lines = color_file.readlines() 51 | for line in lines: 52 | # remove training / leading chars 53 | line = line.strip() 54 | result = re_color.match(line) 55 | if (result != None): 56 | self.colors.append(result.group(1)) 57 | # File not found 58 | except FileNotFoundError: 59 | return 0 60 | # Other file read error 61 | except OSError: 62 | return -1 63 | 64 | return 1 65 | 66 | 67 | # Checks if updated. 68 | # If so reloads file and updates last_updated as well as 69 | def is_updated(self): 70 | mtime = self.get_file_mtime() 71 | if (mtime != self.last_updated): 72 | self.load_colors() 73 | self.last_updated = self.get_file_mtime() 74 | return True 75 | return False 76 | 77 | 78 | # Substitute any "custom" entries with appropriate color 79 | def subs_custom_colors(self, colors): 80 | return_colors = [] 81 | custom_color_count = 0 82 | for this_color in colors: 83 | if this_color == "custom": 84 | return_colors.append(self.colors[custom_color_count]) 85 | custom_color_count += 1 86 | if (custom_color_count >= len (self.colors)): 87 | custom_color_count = 0 88 | else: 89 | return_colors.append(this_color) 90 | return return_colors 91 | 92 | 93 | -------------------------------------------------------------------------------- /pixelserver/pixelconfig.py: -------------------------------------------------------------------------------- 1 | from rpi_ws281x import * 2 | class PixelConfig(): 3 | 4 | # Dictionary of types 5 | # Based on ws281x library - these are the most common ones 6 | # Note that WS2812 works same as WS2812 7 | strip_types = { 8 | 'WS2811_STRIP_RGB' : ws.WS2811_STRIP_RGB, 9 | 'WS2811_STRIP_RBG' : ws.WS2811_STRIP_RBG, 10 | 'WS2811_STRIP_GRB' : ws.WS2811_STRIP_GRB, 11 | 'WS2811_STRIP_BGR' : ws.WS2811_STRIP_BGR, 12 | 'WS2811_STRIP_BRG' : ws.WS2811_STRIP_BRG, 13 | 'WS2811_STRIP_BGR' : ws.WS2811_STRIP_BGR, 14 | 'WS2812_STRIP' : ws.WS2812_STRIP, 15 | 'SK6812_STRIP_RGBW' : ws.SK6812_STRIP_RGBW, 16 | 'SK6812_STRIP_RBGW' : ws.SK6812_STRIP_RBGW, 17 | 'SK6812_STRIP_GRBW' : ws.SK6812_STRIP_GRBW, 18 | 'SK6812_STRIP_GBRW' : ws.SK6812_STRIP_GBRW, 19 | 'SK6812_STRIP_BRGW' : ws.SK6812_STRIP_BRGW, 20 | 'SK6812_STRIP_BGRW' : ws.SK6812_STRIP_BGRW, 21 | 'SK6812_STRIP' : ws.SK6812_STRIP, 22 | 'SK6812W_STRIP' : ws.SK6812W_STRIP 23 | 24 | } 25 | 26 | algorithm_types = ['Argon2', 'SHA256'] 27 | 28 | # What configs options can be set 29 | # Min and max are just general check mainly for numbers 30 | # number = int ; float = floating point 31 | # For text = string max is maximum number characters 32 | # min and max are required even if ignored / None (eg bool) 33 | # For list / dictionary then minimum is name of list / dictionary and maximum = "radio" or "checkbox" 34 | # dictionary shows value as option (key as value) 35 | # dictionary-keys is a compromise - dictionary for loading, uses keys only for options 36 | # Can instead use list and .keys() if want to use keys 37 | # Comment can be used for tooltips 38 | # configsetting: list [Label, type, min, max, comment] 39 | config_settings = { 40 | 'ledcount' : ["Number LEDs", "number", 1, 10000, "Number of LEDs on strip"], 41 | 'gpiopin' : ["GPIO Pin No.", "number", 0, 100, "Raspberry Pi GPIO number (eg. 18)"], 42 | 'ledfreq' : ["LED Frequency", "number", 0, 1000000, "Normally 800000"], 43 | 'leddma' : ["LED DMA", "number", 0, 100, "DMA number, normally 5"], 44 | 'ledmaxbrightness': ["Brightness", "number", 0, 255, "0 to 255"], 45 | 'ledinvert' : ['Invert LED?', "bool", "", "", "True or False"], 46 | 'ledchannel' : ["LED Channel", "number", 0, 10, "Normally 0"], 47 | 'striptype' : ["LED Strip", "dictionary-keys", strip_types, "radio", "Strip type"], 48 | 'algorithm' : ["Hash algorithm", "list", algorithm_types, "radio", "SHA256 (faster) or Argon2 (more secure)"] 49 | } 50 | 51 | def __init__ (self, default_config, custom_config, light_config): 52 | # default config file name (if not exist then uses default) 53 | self.defaultcfg = default_config 54 | self.customcfg = custom_config 55 | self.customlightcfg = light_config 56 | self.errormsg = "" 57 | 58 | # store the actual settings in this dictionary 59 | self.settings= {} 60 | 61 | ''' 62 | Example defaults 63 | 64 | self.default_led_settings = { 65 | 'ledcount': 44, 66 | 'gpiopin': 18, 67 | 'ledfreq': 800000, 68 | 'leddma' : 5, 69 | 'ledmaxbrightness': 50, 70 | 'ledinvert': False, 71 | 'ledchannel': 0, 72 | 'striptype': ws.WS2811_STRIP_GRB 73 | ''' 74 | 75 | if (self.load_config (self.defaultcfg) != 1): 76 | print ("Error loading config: "+self.errormsg) 77 | success = self.load_config (self.customcfg) 78 | if (success == 0): 79 | print ("No custom config "+self.customcfg+" using defaults") 80 | elif (success < 1): 81 | print ("Error loading custom config "+self.customcfg) 82 | print (self.errormsg) 83 | 84 | 85 | # load config if it exists 86 | # If not exist return 0, if successful return 1, if not successful return -1 (file error) -2 (data error) 87 | # After error stop reading rest of file 88 | # If error also populate self.error_msg 89 | # It does not matter if custom fails as long as default works 90 | # Includes some validation checks, but these are very crude 91 | # to detect mistakes rather than security reasons 92 | def load_config(self, filename): 93 | # Try and open file - if not exist then just return 94 | try: 95 | with open (filename, "r") as cfg_file: 96 | lines = cfg_file.readlines() 97 | for line in lines: 98 | # remove training / leading chars 99 | line = line.strip() 100 | # If comment or blank line ignore 101 | if (line.startswith('#') or len(line) < 1): 102 | continue 103 | # split based on = 104 | (key, value) = line.split('=', 1) 105 | # strip any spaces 106 | key = key.strip() 107 | value = value.strip() 108 | 109 | if key in PixelConfig.config_settings.keys(): 110 | # validate based on type 111 | this_setting = PixelConfig.config_settings[key] 112 | 113 | # Special case striptype allows shortened versions to maintain compatibility 114 | 115 | if (key == "striptype"): 116 | # # Allows abbreviated version (eg. RGB = WS2811_STRIP_RGB) 117 | # or long version 118 | # If it's short 3 chars add WS2811 119 | # If it's short 4 chars add SK6812 120 | if (len(value) == 3): 121 | value = "WS2811_STRIP_"+value 122 | elif (len(value) == 4): 123 | value = "SK6812_STRIP_"+value 124 | # Now changed this to full strip type so will be accepted using normal dictionary check 125 | 126 | # Generic values 127 | if (this_setting[1] == "number"): 128 | value_int = int(value) 129 | if (value_int >= this_setting[2] and value_int <= this_setting[3]): 130 | self.settings[key] = value_int 131 | else: 132 | self.errormsg = "Invalid number for {} - value {}".format(key, value) 133 | 134 | elif (this_setting[1] == "bool"): 135 | if (value.lower() == "false"): 136 | self.settings[key] = False 137 | elif (value.lower() == "true"): 138 | self.settings[key] = True 139 | else : 140 | self.errormsg = "Invalid boolean for {} - value {}".format(key, value) 141 | return -2 142 | # List - only radio supported at the moment 143 | elif (this_setting[1] == "dictionary" or this_setting[1] == "dictionary-keys"): 144 | if value in this_setting[2].keys(): 145 | self.settings[key] = this_setting[2][value] 146 | else: 147 | self.errormsg = "Invalid dictionary for {} value {}".format(key, value) 148 | print (self.errormsg) 149 | return -2 150 | ## List 151 | elif (this_setting[1] == "list"): 152 | if value in this_setting[2]: 153 | self.settings[key] = value 154 | else: 155 | self.errormsg = "Invalid list value for {} value {}".format(key, value) 156 | print (self.errormsg) 157 | return -2 158 | 159 | else: 160 | self.errormsg = "Unknown entry "+key+" in "+filename 161 | return -2 162 | 163 | 164 | 165 | # File not found 166 | except FileNotFoundError: 167 | self.errormsg = "File not found "+filename 168 | return 0 169 | # Other file read error 170 | except OSError: 171 | self.errormsg = "Error reading file "+filename 172 | return -1 173 | 174 | return 1 175 | 176 | def get_value (self, key): 177 | return self.settings[key] 178 | 179 | 180 | # Returns tuple (status, value / message) 181 | # status = True (success) 182 | # status = False (invalid parameter) 183 | def validate_parameter (self, parameter, value): 184 | if not parameter in PixelConfig.config_settings.keys(): 185 | return (False, "Invalid parameter {}".parameter) 186 | config_params = PixelConfig.config_settings[parameter] 187 | if config_params[1] == "number": 188 | try: 189 | temp_value = int(value) 190 | except: 191 | return (False, "Parameter is not a number {}".format(parameter)) 192 | if (temp_value < config_params[2] or temp_value > config_params[3]): 193 | return (False, "Invalid value for number {}".format(parameter)) 194 | # otherwise valid number, return true 195 | return (True, temp_value) 196 | elif config_params[1] == "text": 197 | if len(value) < config_params[2] or len(value) > config_params[2]: 198 | return (False, "Invalid string length {}".format(parameter)) 199 | else: 200 | return (True, value) 201 | elif (config_params[1] == "bool"): 202 | if value == True or value == "True" or value == "on" or value == "1": 203 | return (True, True) 204 | elif value == False or value =="False" or value == "off" or value == "0": 205 | return (True, False) 206 | else : 207 | return (False, "Invalid parameter value {}".format(parameter)) 208 | elif (config_params[1] == "dictionary" or config_params[1] == "dictionary-keys"): 209 | if (value in config_params[2].keys()): 210 | return (True, value) 211 | else: 212 | return (False, "Invalid parameter value {}".format(parameter)) 213 | elif (config_params[1] == "list"): 214 | if (value in config_params[2]): 215 | return (True, value) 216 | else: 217 | return (False, "Invalid parameter value {}".format(parameter)) 218 | else: 219 | return (False, "Invalid parameter type {}".format(parameter)) 220 | 221 | 222 | # Does not validate - use validate_parameter prior to calling this 223 | # Also call save after making all changes 224 | # value is normally passed as a string regardless 225 | # It does check for the correct type for number / bool, but not within range 226 | # Returns True for success False for fail 227 | def update_parameter (self, parameter, value): 228 | if not parameter in PixelConfig.config_settings.keys(): 229 | return (False) 230 | config_params = PixelConfig.config_settings[parameter] 231 | 232 | if (config_params[1] == "number"): 233 | try: 234 | self.settings[parameter] = int(value) 235 | except: 236 | return False 237 | elif (config_params[1] == "bool"): 238 | if value == True: 239 | self.settings[parameter] = True 240 | elif value == False: 241 | self.settings[parameter] = False 242 | else: 243 | return False 244 | # For other values store as a string 245 | else: 246 | self.settings[parameter] = value 247 | return True 248 | 249 | 250 | 251 | # returns config options as form for html 252 | # does not include
or
which can be set seperately 253 | def to_html_form(self): 254 | html_string = "" 255 | for key, value in PixelConfig.config_settings.items(): 256 | html_string += "\n".format(key, value[4], value[0]) 257 | # Text or number is the same input type 258 | if value[1] == "number" or value[1] == "text": 259 | html_string += "".format(key, key, self.settings[key]) 260 | elif value[1] == "bool": 261 | if self.settings[key]: 262 | html_string += "".format(key, key) 263 | else: 264 | html_string += "".format(key, key) 265 | elif value[1] == "dictionary": 266 | html_string += "\n" 273 | # List is same as dictionary, but key and value are same 274 | elif value[1] == "list": 275 | html_string += "\n" 282 | # special case - it is a dictionary, but treat like a list (use keys) 283 | elif value[1] == "dictionary-keys": 284 | html_string += "\n" 291 | 292 | html_string += "?
\n".format(value[4]) 293 | return html_string 294 | 295 | def get_settings_dict(self): 296 | return self.config_settings 297 | 298 | 299 | def save_settings (self): 300 | try: 301 | with open(self.customcfg, "w") as write_file: 302 | for key, value in self.settings.items(): 303 | write_file.write("{}={}\n".format(key, value)) 304 | except Exception as e: 305 | print ("Error saving file to "+self.filename+" "+str(e)) 306 | return False 307 | return True 308 | -------------------------------------------------------------------------------- /pixelserver/pixels.py: -------------------------------------------------------------------------------- 1 | import pixelserver 2 | from pixelserver.pixelconfig import PixelConfig 3 | from pixelserver.pixelseq import PixelSeq 4 | from pixelserver.customlight import CustomLight 5 | from rpi_ws281x import * 6 | import time 7 | 8 | class Pixels(): 9 | def __init__ (self, default_config, custom_config, light_config, run=True): 10 | self.default_config = default_config 11 | self.custom_config = custom_config 12 | self.light_config = light_config 13 | pixelserver.pixel_conf = pixelserver.load_config(self.default_config, self.custom_config, self.light_config) 14 | if (run == True): 15 | self.pixels = PixelSeq(pixelserver.pixel_conf) 16 | else: 17 | self.pixels = None 18 | # Use for custom color lights (eg CheerLights) 19 | self.custom_light = CustomLight(pixelserver.pixel_conf.customlightcfg) 20 | 21 | def run(self): 22 | last_update = pixelserver.upd_time 23 | current_sequence = "" 24 | 25 | sequence_position = 0 26 | colors = [Color(255,255,255)] 27 | 28 | 29 | while(1): 30 | # Check for change in custom light colors 31 | # Reloads files if updated 32 | if self.custom_light.is_updated(): 33 | pixelserver.upd_time = time.time() 34 | 35 | # If updated sequence / value etc. 36 | if (pixelserver.upd_time != last_update) : 37 | # convert colors to list instead of comma string 38 | color_list = pixelserver.seq_set['colors'].split(",") 39 | # handle custom colors 40 | color_list = self.custom_light.subs_custom_colors(color_list) 41 | # Convert color string to list of colors (pre formatted for pixels) 42 | # Value returned as seq_colors is a list of Colors(), but may also include "custom" for any custom colors 43 | colors = pixelserver.seq_list.string_to_colors(color_list) 44 | 45 | # If sequence changed then reset seq_position 46 | if (pixelserver.seq_set['sequence'] != current_sequence): 47 | sequence_position = 0 48 | current_sequence = pixelserver.seq_set['sequence'] 49 | 50 | # returns sequence_position which is used for future calls 51 | sequence_position = self.pixels.updateSeq( 52 | pixelserver.seq_set['sequence'], 53 | sequence_position, 54 | pixelserver.seq_set['reverse'], 55 | colors) 56 | last_update = pixelserver.upd_time 57 | # Sleep used for delay this means that there will be that long a delay between updates 58 | time.sleep(pixelserver.seq_set['delay']/1000) 59 | -------------------------------------------------------------------------------- /pixelserver/requests/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | The requests blueprint creates routes for this application 3 | """ 4 | from flask import Blueprint 5 | requests_blueprint = Blueprint('requests', __name__, template_folder='../www') 6 | 7 | from . import routes 8 | -------------------------------------------------------------------------------- /pixelserver/serverauth.py: -------------------------------------------------------------------------------- 1 | # Class to add authentication for Maker IOT projects 2 | 3 | import ipaddress 4 | from datetime import datetime 5 | import logging 6 | import pixelserver 7 | from pixelserver.serveruser import ServerUser 8 | 9 | 10 | # network config file is only loaded initially (needs reload) 11 | # user config file is checked each time so that any changes 12 | # to the user config file are immediately available 13 | 14 | class ServerAuth (): 15 | 16 | def __init__ (self, auth_filename, users_filename): 17 | self.user_file_exists = False # check that the user file exists and has at least one entry - otherwise we give user an error message 18 | self.auth_filename = auth_filename 19 | self.users_filename = users_filename 20 | self.error_msg = "" # Add any error messages for reporting 21 | self.proxy_servers = [] # List of proxy servers where X-Real-IP will be used 22 | self.network_allow_always = [] # list of allowed network addresses which don't require authentication (normally this is local addresses only) 23 | self.network_allow_auth = [] # as above, but does require authentication (normally this is 0.0.0.0 = allow all, but with authentication 24 | 25 | # Load the auth.cfg config file - this will include any network authorizations 26 | success = self.load_config () 27 | if (success == 0): 28 | print ("No authentication config file "+self.auth_filename) 29 | logging.warning("No authentication config file "+ self.auth_filename) 30 | # If not config file then uses a single entry - login required 31 | self.update_network("auth", "0.0.0.0") 32 | elif (success < 1): 33 | #print ("Error loading authentication config file "+self.auth_filename) 34 | #print (self.error_msg) 35 | logging.error("Error loading config file "+ self.auth_filename) 36 | 37 | self.user_file_exists = self.check_user_file() 38 | 39 | logging.info("Authentication loaded") 40 | 41 | 42 | # Checks that user file exists and has at least one entry 43 | # Returns True if exists, otherwise False 44 | def check_user_file (self): 45 | user_filename = self.users_filename 46 | try: 47 | with open(user_filename) as read_file: 48 | # Read through each entry and if valid return True 49 | for line in read_file: 50 | this_line = line.strip() 51 | # check if comment in which case skip 52 | if (this_line.startswith("#")): 53 | continue 54 | # split line into fields 55 | user_elements = this_line.split(":", 5) 56 | # Basic check only - do we have at least 5 fields 57 | if (len(user_elements) >= 5): 58 | return True 59 | except Exception as e: 60 | return False 61 | # User not found return None 62 | return False 63 | 64 | 65 | # load auth.cfg config if it exists 66 | # If not exist return 0, if successful return 1, if not successful return -1 (file error) -2 (data error) 67 | # After error stop reading rest of file 68 | # If error also populate self.error_msg 69 | # Includes some validation checks, but these are very crude 70 | # to detect mistakes rather than security reasons 71 | def load_config(self): 72 | auth_filename = self.auth_filename 73 | # Try and open file - if not exist then just return 74 | try: 75 | with open (auth_filename, "r") as cfg_file: 76 | lines = cfg_file.readlines() 77 | for line in lines: 78 | # remove training / leading chars 79 | line = line.strip() 80 | # If comment or blank line ignore 81 | if (line.startswith('#') or len(line) < 1): 82 | continue 83 | # split based on = 84 | (key, value) = line.split('=', 1) 85 | # strip any spaces 86 | key = key.strip() 87 | value = value.strip() 88 | # validate value and store 89 | if (key == "network_allow_always"): 90 | self.update_network("always", value) 91 | elif (key == "network_allow_auth"): 92 | self.update_network("auth", value) 93 | elif (key == "proxy_server"): 94 | self.add_proxy (value) 95 | 96 | # File not found 97 | except FileNotFoundError: 98 | self.errormsg = "File not found "+auth_filename 99 | return 0 100 | # Other file read error 101 | except OSError: 102 | self.errormsg = "Error reading file "+auth_filename 103 | return -1 104 | 105 | return 1 106 | 107 | 108 | 109 | # Loads the information about a single user and returns as 110 | # ServerUser object, otherwise returns None 111 | def load_user (self, username): 112 | user_filename = self.users_filename 113 | try: 114 | with open(user_filename) as read_file: 115 | for line in read_file: 116 | this_line = line.strip() 117 | # check if comment in which case skip 118 | if (this_line.startswith("#")): 119 | continue 120 | # split line into fields 121 | user_elements = this_line.split(":", 5) 122 | # If not this user then skip 123 | if user_elements[0] != username: 124 | continue 125 | return ServerUser(*user_elements) 126 | except Exception as e: 127 | logging.warning ("Error reading users file "+ user_filename+" "+str(e)) 128 | # User not found return None 129 | return None 130 | 131 | # checks for valid username and password and if so login 132 | # return True for success or False for fail login 133 | # IP Address is required. Does not do additional authentication, but used for logging purposes 134 | def login_user (self, username, password, ip_address): 135 | # Check username does not include colon 136 | if ':' in username: 137 | logging.warning (ip_address+" Login failed username contains colon "+username) 138 | return False 139 | this_user = self.load_user (username) 140 | if this_user == None: 141 | # Could also be corrupt file or similar, but most likely invalid username - check log for corrupt file error 142 | logging.warning (ip_address+" Login failed invalid username "+username) 143 | return False 144 | if (this_user.check_password (password)): 145 | logging.info (ip_address+" Login success "+username) 146 | return True 147 | else: 148 | logging.warning (ip_address+" Login failed incorrect password "+username) 149 | return False 150 | 151 | def add_proxy (self, proxy_string): 152 | # split based on commas 153 | for this_proxy in proxy_string.split(","): 154 | try: 155 | self.proxy_servers.append(ipaddress.ip_network(this_proxy)) 156 | logging.info ("Proxy server "+this_proxy) 157 | except: 158 | logging.info ("Invalid proxy server "+this_proxy) 159 | 160 | 161 | def update_network (self, auth_type, network_string): 162 | # auth_type determines which list we are updating 163 | if (auth_type == "always"): 164 | auth_list = self.network_allow_always 165 | elif (auth_type == "auth"): 166 | auth_list = self.network_allow_auth 167 | else: 168 | return 169 | # split based on commas 170 | for this_network in network_string.split(","): 171 | try: 172 | auth_list.append(ipaddress.ip_network(this_network)) 173 | logging.info ("Allow "+auth_type+" "+this_network) 174 | except: 175 | logging.info ("Invalid network "+auth_type+" "+this_network) 176 | 177 | 178 | 179 | # check if an ip address is a proxy server 180 | def check_proxy (self, ip_address): 181 | ip_addr = ipaddress.ip_address(ip_address) 182 | 183 | log_string = "Proxy check address: " + ip_address 184 | for this_network in self.proxy_servers: 185 | if ip_addr in this_network: 186 | logging.info(log_string+" true") 187 | return True 188 | else : 189 | logging.info(log_string+" false") 190 | return False 191 | 192 | 193 | 194 | # check network address against allow_always and allow_auth 195 | # returns "always", "auth" or "none" 196 | def check_network (self, ip_address): 197 | ip_addr = ipaddress.ip_address(ip_address) 198 | 199 | log_string = "Network auth address: " + ip_address 200 | 201 | # check for always first 202 | for this_network in self.network_allow_always: 203 | # special case check for 0.0.0.0 - which always passes 204 | if (ipaddress.ip_address("0.0.0.0") in this_network): 205 | logging.info(log_string+" always (global)") 206 | return ("always") 207 | elif ip_addr in this_network: 208 | logging.info(log_string+" always") 209 | return ("always") 210 | # check for always first 211 | for this_network in self.network_allow_auth: 212 | # special case check for 0.0.0.0 - which always passes 213 | if (ipaddress.ip_address("0.0.0.0") in this_network): 214 | logging.info(log_string+" auth (global)") 215 | return ("auth") 216 | elif ip_addr in this_network: 217 | logging.info(log_string+" auth") 218 | return ("auth") 219 | logging.info(log_string+" none") 220 | return ("none") 221 | 222 | def check_admin(self, username): 223 | this_user = self.load_user (username) 224 | if this_user == None: 225 | return False 226 | if this_user.is_admin(): 227 | return True 228 | return False 229 | 230 | # check authentication using network and user 231 | # return "network", "logged_in", "login_required" or "invalid" (invalid = network rules prohibit) 232 | def auth_check (self, ip_address, session): 233 | auth_type = self.check_network(ip_address) 234 | 235 | # Also need to authenticate 236 | if auth_type == "always" or auth_type=="auth": 237 | # even if also check for logged in useful for admin logins 238 | if 'username' in session: 239 | return "logged_in" 240 | elif (auth_type == "always"): 241 | return "network" 242 | else: 243 | return "login_required" 244 | return "invalid" 245 | 246 | # checks that network is allowed and user is an admin 247 | # on success return "admin" 248 | # On fail could be "invalid" (not allowed), "login" (not logged in), "notadmin" (logged in as standard user) 249 | def check_permission_admin (self, ip_address, session): 250 | # check address first 251 | login_status = pixelserver.auth.auth_check(ip_address, session) 252 | if login_status == "invalid": 253 | return "invalid" 254 | # Not logged in 255 | if not (login_status == "logged_in") : 256 | return "login" 257 | # Get username and check user is admin 258 | username = session['username'] 259 | if not (pixelserver.auth.check_admin(username)): 260 | return "notadmin" 261 | return "admin" -------------------------------------------------------------------------------- /pixelserver/serveruser.py: -------------------------------------------------------------------------------- 1 | from argon2 import PasswordHasher 2 | import hashlib 3 | import uuid 4 | 5 | 6 | class ServerUser (): 7 | 8 | # init with all details 9 | # all values are set as internal and need to be updated 10 | # using getters / setters (includes security features) 11 | def __init__ (self, username, password_hash, real_name, user_type, email, description): 12 | self._username = username 13 | self.password_hash = password_hash 14 | self._real_name = real_name 15 | self._user_type = user_type 16 | self._email = email 17 | self._description = description 18 | 19 | 20 | # Getters and setters 21 | ## These include security checks, but is not user friendly - just ignores request 22 | ## Checks should be performed higher up the request, but this is a final catch alll 23 | 24 | @property 25 | def username(self): 26 | return self._username 27 | 28 | @username.setter 29 | def username(self, new_username): 30 | # check it doesn't have : etc. 31 | if ":" in new_username: 32 | return 33 | # strip whitespace and check not empty username 34 | new_username = new_username.strip() 35 | if new_username == "": 36 | return 37 | # Only allow alpha numeric 38 | if not new_username.isalnum(): 39 | return 40 | if len(new_username) < 6 : 41 | return 42 | # checks complete - update username 43 | self._username = new_username 44 | 45 | # password property 46 | # password cannot be returned as it's stored as a hash 47 | # If requested just return asterix ********* 48 | @property 49 | def password(self): 50 | return "********" 51 | 52 | # Setting a new password will result in it converted to a hash 53 | # Does not check minimum length / other password requirements 54 | # Using this does not give control over algorithm - instead use set_password 55 | @password.setter 56 | def password(self, new_password): 57 | self.set_password (new_password) 58 | 59 | # This is recommended way to set password 60 | def set_password (self, new_password, algorithm="Argon2"): 61 | self.password_hash = ServerUser.hash_password (new_password, algorithm) 62 | 63 | @property 64 | def real_name(self): 65 | return self._real_name 66 | 67 | @real_name.setter 68 | def real_name(self, new_name): 69 | # check it doesn't have : or < > 70 | if ":" in new_name or "<" in new_name or ">" in new_name: 71 | return 72 | # strip whitespace 73 | new_name = new_name.strip() 74 | # Save 75 | self._real_name = new_name 76 | 77 | @property 78 | def user_type(self): 79 | return self._user_type 80 | 81 | @user_type.setter 82 | def user_type(self, new_type): 83 | if new_type == "admin": 84 | self._user_type = "admin" 85 | elif new_type == "standard": 86 | self._user_type = "standard" 87 | 88 | @property 89 | def email(self): 90 | return self._email 91 | 92 | @email.setter 93 | def email(self, new_email): 94 | # basic checks for : and <> 95 | if ":" in new_email or "<" in new_email or ">" in new_email: 96 | return 97 | self._email = new_email 98 | 99 | @property 100 | def description(self): 101 | return self._description 102 | 103 | @description.setter 104 | def description(self, new_description): 105 | # basic checks for : and <> 106 | if ":" in new_description or "<" in new_description or ">" in new_description: 107 | return 108 | self._description = new_description 109 | 110 | 111 | # return formatted for writing to file 112 | def colon_string(self): 113 | return "{}:{}:{}:{}:{}:{}".format(self._username, self.password_hash, self._real_name, self._user_type, self._email, self._description) 114 | 115 | 116 | # Checks plaintext password against stored password hash 117 | def check_password (self, password): 118 | # check for $5$ = SHA256 119 | if self.password_hash[0:3] == "$5$": 120 | salt, just_hash = self.password_hash[3:].split('$') 121 | hashed_given_password = hashlib.sha256(salt.encode() + password.encode()).hexdigest() 122 | return hashed_given_password == just_hash 123 | # If not should be Argon2 $Argon2id$ (id = variant - could be i or d) 124 | else: 125 | ph = PasswordHasher() 126 | # Invalid password raises exception - catch and return false 127 | try: 128 | if ph.verify(self.password_hash, password): 129 | return True 130 | except: 131 | return False 132 | # Shouldn't get this but in case changes in future 133 | return False 134 | 135 | def is_admin(self): 136 | if self._user_type == "admin": 137 | return True 138 | return False 139 | 140 | # returns string with the hashed password 141 | # uses argon2 which is a strong hash, but takes approx 8 seconds 142 | # on a Pi Zero - or sha256 which is faster, particularly on Pi Zero 143 | @staticmethod 144 | def hash_password (password, algorithm="Argon2"): 145 | if algorithm == "SHA256": 146 | salt = uuid.uuid4().hex 147 | return "$5$" + salt + "$" + hashlib.sha256(salt.encode() + password.encode()).hexdigest() 148 | else: 149 | ph = PasswordHasher() 150 | return ph.hash(password) 151 | 152 | 153 | -------------------------------------------------------------------------------- /pixelserver/serveruseradmin.py: -------------------------------------------------------------------------------- 1 | from pixelserver.serveruser import ServerUser 2 | import logging 3 | 4 | # File format - colon seperated (colon not allowed in any of the fields) 5 | # username:password:real name:usertype:email:description 6 | # Description could be comments about the user type 7 | # Lines can start with a # to comment - otherwise all entries must be valid 8 | # Note that user added comments will be discarded when next saved 9 | 10 | 11 | 12 | # Class to administer users - holds all users on system 13 | # Designed for small number of users in a typical IOT system 14 | # For servers with lots of users then replace this with database driven version 15 | # Returns True for success - False if there is a problem (eg. file not exist) 16 | # Algorithm is used for adding new users only - currently support "Argon2" or "SHA256" - Argon2 is much more secure but takes longer 17 | # may be too slow for some systems 18 | class ServerUserAdmin(): 19 | 20 | def __init__ (self, usersfile, algorithm="Argon2"): 21 | self.filename = usersfile 22 | # List of users on system - Entries in this list should not be updated directly 23 | # instead update within this class so that it saves any changes 24 | self.users = {} 25 | # track if the file loads successfully (can use other code to check) 26 | self.file_loaded = False 27 | # loads users - if fails then return false 28 | if self.load_users() == True and len(self.users) > 0: 29 | self.file_loaded = True 30 | self.algorithm = algorithm 31 | 32 | # Calls load users, but clears out any current users 33 | # Usually used in case that an error ocurred updating entry 34 | # and so risk of corrupted users 35 | def reload_users (self): 36 | self.users.clear() 37 | self.load_users() 38 | 39 | def load_users (self): 40 | try: 41 | with open(self.filename) as read_file: 42 | for line in read_file: 43 | this_line = line.strip() 44 | # skipt empty line 45 | if (this_line == ""): 46 | continue 47 | # check if comment in which case skip 48 | if (this_line.startswith("#")): 49 | continue 50 | # split line into fields 51 | user_elements = this_line.split(":", 5) 52 | # load as a class object 53 | try : 54 | # make sure doesn't already exist - first entry is kept 55 | # should not happen as saved from a dictionary 56 | if user_elements[0] in self.users.keys(): 57 | print ("Warning duplicate user entry "+user_elements[0]) 58 | else: 59 | self.users[user_elements[0]] = ServerUser(*user_elements) 60 | except Exception as e: 61 | # If corrupt file then just exit as can't trust to be safe 62 | print ("Corrupt file - invalid entry in "+self.filename) 63 | logging.error("Corrupt file - invalid entry in "+self.filename) 64 | print (str(e)) 65 | return False 66 | except FileNotFoundError: 67 | print ("Error file not found "+self.filename) 68 | logging.error ("Error file not found "+self.filename) 69 | return False 70 | except OSError: 71 | print ("Error file read error "+self.filename) 72 | logging.error ("Error file read error "+self.filename) 73 | return False 74 | except Exception as e: 75 | print ("Unknown error reading file "+self.filename+" "+str(e)) 76 | logging.error ("Unknown error reading file "+self.filename+" "+str(e)) 77 | return False 78 | # Success 79 | return True 80 | 81 | def delete_user(self, username): 82 | del self.users[username] 83 | 84 | # Replaces existing user file with new user file 85 | def save_users (self): 86 | try: 87 | with open(self.filename, "w") as write_file: 88 | for this_user in self.users.values(): 89 | write_file.write(this_user.colon_string()+"\n") 90 | except Exception as e: 91 | print ("Error saving file to "+self.filename+" "+str(e)) 92 | return False 93 | return True 94 | 95 | # When adding a new user then password is plain text in format that the 96 | # user added - this performs the conversion to hash 97 | # returns string - "success", "duplicate" (if username duplicate), "invalid" (eg. if ":" in a field) 98 | # minimum is username and password - rest optional 99 | def add_user (self, username, password_text, real_name="", user_type="standard", email="", description=""): 100 | 101 | # check it doesn't already exist first (based on username): 102 | if username in self.users.keys(): 103 | return "duplicate" 104 | 105 | # Security check - ensure that none of the fields include a colon 106 | # Further checks could be added (check for valid email address etc.) 107 | # Password is allowed a colon as that is converted into a hash which will be colon free 108 | # Could also convert colon to other character if required 109 | if (':' in username or ':' in real_name or 110 | ':' in user_type or ':' in email or ':' in description): 111 | return "invalid" 112 | 113 | # Also guest user not permitted as that has special meaning 114 | if (username == "guest"): 115 | return "invalid" 116 | 117 | password_hash = ServerUser.hash_password (password_text, self.algorithm) 118 | 119 | # create the entry 120 | self.users[username] = ServerUser(username, password_hash, real_name, user_type, email, description) 121 | 122 | # save the changes 123 | self.save_users() 124 | return "success" 125 | 126 | 127 | def change_password (self, username, new_password): 128 | self.users[username].set_password(new_password, self.algorithm) 129 | return True 130 | 131 | def user_exists (self, username): 132 | if username in self.users.keys(): 133 | return True 134 | return False 135 | 136 | def check_admin (self, username): 137 | if username in self.users.keys() and self.users[username].user_type == "admin": 138 | return True 139 | return False 140 | 141 | def get_real_name (self, username): 142 | return self.users[username].real_name 143 | 144 | def num_users(self): 145 | return len(self.users) 146 | 147 | # Update the user 148 | # Don't forget to save after calling this if required 149 | def update_user (self, current_username, new_values): 150 | # Always use new_username for updates - and update if required 151 | new_username = current_username 152 | if 'username' in new_values.keys() and new_values['username'] != current_username: 153 | new_username = new_values['username'].strip() 154 | # Validate username - check not duplicate and only alpha numeric 155 | # Does not check other rules 156 | if new_username in self.users.keys() or not new_username.isalnum(): 157 | return False 158 | # First update the username inside the existing entry 159 | self.users[current_username].username = new_username 160 | # pop existing entry into one with new name 161 | # Note will move user to the end - won't maintain dictionary order 162 | self.users[new_username] = self.users.pop(current_username) 163 | # If username changed then any other changes apply to the new user 164 | if 'real_name' in new_values.keys(): 165 | # check no : or <> character 166 | if ':' in new_values['real_name'] or '<' in new_values['real_name'] or '>' in new_values['real_name']: 167 | return False 168 | self.users[new_username].real_name = new_values['real_name'] 169 | 170 | # only allow standard or admin 171 | if 'user_type' in new_values.keys(): 172 | if new_values['user_type'] == "admin": 173 | self.users[new_username].user_type = "admin" 174 | elif new_values['user_type'] == "standard": 175 | self.users[new_username].user_type = "standard" 176 | 177 | if 'email' in new_values.keys(): 178 | # check no : or <> character 179 | if ':' in new_values['email'] or '<' in new_values['email'] or '>' in new_values['email']: 180 | return False 181 | self.users[new_username].email = new_values['email'] 182 | 183 | if 'description' in new_values.keys(): 184 | # check no : or <> character 185 | if ':' in new_values['description'] or '<' in new_values['description'] or '>' in new_values['description']: 186 | return False 187 | self.users[new_username].description = new_values['description'] 188 | 189 | return True 190 | 191 | 192 | def check_username_password (self, username, password): 193 | # Check username exists 194 | if not username in self.users.keys(): 195 | return False 196 | # check password 197 | if (self.users[username].check_password(password)): 198 | return True 199 | # If password fail then return false 200 | else: 201 | return False 202 | 203 | 204 | # New user form starts with username - as long as that is unique then 205 | # creates a basic entry and then goes to edit for rest of details 206 | def html_new_user (self): 207 | html_string = "" 208 | # Create form 209 | html_string += "\n" 210 | html_string += "\n" 211 | html_string += "
\n" 212 | return html_string 213 | 214 | # Stage 2 of add new user - add password 215 | def html_new_user_stage2 (self, username): 216 | # Check user doesn't already exist 217 | # Should have already checked - but additional check 218 | if self.user_exists(username): 219 | return ("User already exists") 220 | html_string = "" 221 | # Create form 222 | html_string += "\n" 223 | html_string += "\n" 224 | html_string += "
\n".format(username) 225 | html_string += "\n" 226 | html_string += "
\n" 227 | html_string += "\n" 228 | html_string += "
\n" 229 | 230 | return html_string 231 | 232 | # Add any specific rules (eg. special char / capitals) 233 | # Minimum length = 8, minimum 1 alpha & 1 number 234 | # No need to try and strip any chars as the password 235 | # will be hashed 236 | def validate_password (self, password): 237 | if len(password) < 8: 238 | return (False, "Password must be at least 8 characters long") 239 | if self.has_digit(password) and self.has_alpha(password): 240 | return (True, "") 241 | else: 242 | return (False, "Password must contain at least one letter and one digit") 243 | 244 | 245 | def has_digit (self, string): 246 | for i in list(string): 247 | if i.isdigit(): 248 | return True 249 | return False 250 | 251 | def has_alpha (self, string): 252 | for i in list(string): 253 | if i.isalpha(): 254 | return True 255 | return False 256 | 257 | # Checks to see if user is valid (min 6 chars - alphanumeric) 258 | def validate_user (self, username): 259 | if len(username) < 6: 260 | return (False, "Username must be minimum of 6 characters") 261 | if username.isalnum(): 262 | return (True, "") 263 | else: 264 | return (False, "Username must be letters and digits only") 265 | 266 | def html_edit_user (self, username): 267 | html_string = "" 268 | # Check user exists 269 | if not username in self.users: 270 | return "Invalid user selected\n" 271 | this_user = self.users[username] 272 | # Create form 273 | html_string += "\n" 274 | html_string += "\n".format(this_user.username) 275 | html_string += "\n" 276 | html_string += "
\n".format(this_user.username) 277 | # Does not change password - that has to be done separately 278 | html_string += "\n" 279 | html_string += "
\n".format(this_user.real_name) 280 | # Admin checkbox = user_type 281 | html_string += "\n" 282 | if (this_user.user_type == "admin"): 283 | html_string += "
\n" 284 | else: 285 | html_string += "
\n" 286 | html_string += "\n" 287 | html_string += "
\n".format(this_user.email) 288 | html_string += "\n" 289 | html_string += "
\n".format(this_user.description) 290 | return html_string 291 | 292 | 293 | def html_password_admin (self, username): 294 | html_string = "" 295 | # Check user exists 296 | if not username in self.users: 297 | return "Invalid user selected\n" 298 | this_user = self.users[username] 299 | # Create form 300 | html_string += "\n" 301 | html_string += "
\n".format(this_user.username) 302 | html_string += "\n" 303 | html_string += "
\n" 304 | html_string += "\n" 305 | html_string += "
\n" 306 | return html_string 307 | 308 | 309 | # Creates a form, but all fields are read only 310 | def html_view_profile (self, username): 311 | html_string = "" 312 | # Check user exists 313 | if not username in self.users: 314 | return "Invalid user selected\n" 315 | this_user = self.users[username] 316 | # Create form 317 | html_string += "\n" 318 | html_string += "\n".format(this_user.username) 319 | html_string += "\n" 320 | html_string += "
\n".format(this_user.username) 321 | # Does not change password - that has to be done separately 322 | html_string += "\n" 323 | html_string += "
\n".format(this_user.real_name) 324 | # Admin checkbox = user_type 325 | html_string += "\n" 326 | if (this_user.user_type == "admin"): 327 | html_string += "
\n" 328 | else: 329 | html_string += "
\n" 330 | html_string += "\n" 331 | html_string += "
\n".format(this_user.email) 332 | html_string += "\n" 333 | html_string += "
\n".format(this_user.description) 334 | return html_string 335 | 336 | 337 | 338 | def html_change_password (self): 339 | html_string = "" 340 | # Create form 341 | html_string += "\n" 342 | html_string += "\n" 343 | html_string += "
\n" 344 | html_string += "\n" 345 | html_string += "
\n" 346 | html_string += "\n" 347 | html_string += "
\n" 348 | return html_string 349 | 350 | 351 | # Returns all users as a html table entries (does not include table / th) 352 | # Links to useradmin - with action = "delete" or "edit" 353 | def html_table_all (self): 354 | html_string = "" 355 | for userkey, uservalue in self.users.items(): 356 | html_string += ""+userkey+"" 357 | html_string += ""+uservalue.real_name+"" 358 | html_string += ""+uservalue.user_type+"" 359 | html_string += ""+uservalue.email+"" 360 | html_string += ""+uservalue.description+"" 361 | html_string += "X\n" 362 | return html_string 363 | 364 | 365 | 366 | -------------------------------------------------------------------------------- /pixelserver/statusmsg.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | class StatusMsg(): 4 | def __init__(self): 5 | self.status = { 6 | # default to error - change if successful success 7 | # relates to current request - not status of server 8 | 'status' : "error", 9 | # sequence 10 | 'sequence' : "", 11 | # string for message 12 | 'msg' : "" 13 | } 14 | 15 | # Set based on current server values (may need to call later if successful) 16 | def set_server_values (self, seq_status): 17 | for key, value in seq_status.items(): 18 | self.status[key] = value 19 | 20 | 21 | def set_status (self, status_value, msg=""): 22 | self.status['status'] = status_value 23 | self.status['msg'] += msg 24 | 25 | def get_message(self): 26 | return self.get_json() 27 | 28 | def get_json(self): 29 | return json.dumps(self.status) 30 | -------------------------------------------------------------------------------- /pixelserver/www/deleteuser.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Pixel Server 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 | 15 | 16 | {% if user %} 17 | 18 | {% if admin %} 19 | 20 | {% endif %} 21 | 22 | {% else %} 23 | 24 | {% endif %} 25 | 26 | 27 | 28 |
29 | 30 | 31 | 33 |
34 | 35 |

Delete User - {{ deluser }}

36 | 37 |

{{ message }}

38 | 39 |

Confirm - delete user {{ deluser }}

40 | 41 |

Yes No

42 | 43 | 44 |
45 | 46 | 47 | -------------------------------------------------------------------------------- /pixelserver/www/edituser.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Pixel Server 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 | 15 | 16 | {% if user %} 17 | 18 | {% if admin %} 19 | 20 | {% endif %} 21 | 22 | {% else %} 23 | 24 | {% endif %} 25 | 26 | 27 | 28 |
29 | 30 | 31 | 33 |
34 | 35 |

{{ message }}

36 | 37 |
38 | {% if csrf_token %} 39 | 40 | {% endif %} 41 | {{ form|safe }} 42 | 43 | 44 | 45 |
46 | 47 | {% if user %} 48 |
49 | 50 | 51 |
52 | {% endif %} 53 | 54 |
55 | 56 | 57 | -------------------------------------------------------------------------------- /pixelserver/www/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Pixel Server 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 | 15 | 16 | {% if user %} 17 | 18 | {% if admin %} 19 | 20 | {% endif %} 21 | 22 | {% else %} 23 | 24 | {% endif %} 25 | 26 | 27 | 28 |
29 | 30 | 31 |

Sequences

32 |
    33 |
34 |
35 |
36 |
37 |

Speed

38 | 39 |

Delay 900 ms

40 |
41 |
42 |
43 |

Colors

44 |
45 |
    46 |
  • 47 |
48 |
49 |
50 |
51 |

Add Colors

52 |
    53 |
  • 54 |
  • 55 |
  • 56 |
  • 57 |
  • 58 |
  • 59 |
  • 60 |
  • 61 |
  • 62 |
  • 63 |
  • 64 |
  • 65 |
66 |
67 |
68 |
69 |
70 |

LED Status:

71 |
status
72 |
73 | 74 | -------------------------------------------------------------------------------- /pixelserver/www/invalid.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Pixel Server - Not allowed 6 | 7 | 8 | 9 | 10 | 11 | 12 |

Pixel Server

13 |
14 |

Access is not permitted from your IP address

15 |
16 |
17 | 18 | -------------------------------------------------------------------------------- /pixelserver/www/login.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Pixel Server - Login 6 | 7 | 8 | 9 | 10 | 11 | 12 |

Pixel Server Login

13 | {% if message %} 14 |

{{ message }}

15 | {% endif %} 16 |
17 |

Please login to access

18 | 19 | {% if csrf_token %} 20 | 21 | {% endif %} 22 | 23 |
24 | 25 |
26 | 27 | 28 |
29 | 30 | -------------------------------------------------------------------------------- /pixelserver/www/newuser.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Pixel Server 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 | 15 | 16 | {% if user %} 17 | 18 | {% if admin %} 19 | 20 | {% endif %} 21 | 22 | {% else %} 23 | 24 | {% endif %} 25 | 26 | 27 | 28 |
29 | 30 | 31 | 33 |
34 | 35 |

{{ message }}

36 | 37 |
38 | {% if csrf_token %} 39 | 40 | {% endif %} 41 | {{ form|safe }} 42 | 43 | 44 | 45 |
46 | 47 |
48 | 49 | 50 | -------------------------------------------------------------------------------- /pixelserver/www/password.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Pixel Server 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 | 15 | 16 | {% if user %} 17 | 18 | {% if admin %} 19 | 20 | {% endif %} 21 | 22 | {% else %} 23 | 24 | {% endif %} 25 | 26 | 27 | 28 |
29 | 30 | 31 | 33 |
34 | 35 |

{{ message }}

36 | 37 |
38 | {% if csrf_token %} 39 | 40 | {% endif %} 41 | {{ form|safe }} 42 | 43 | 44 | 45 |
46 | 47 |
48 | 49 | 50 | -------------------------------------------------------------------------------- /pixelserver/www/passwordadmin.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Pixel Server 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 | 15 | 16 | {% if user %} 17 | 18 | {% if admin %} 19 | 20 | {% endif %} 21 | 22 | {% else %} 23 | 24 | {% endif %} 25 | 26 | 27 | 28 |
29 | 30 | 31 | 33 |
34 | 35 |

{{ message }}

36 | 37 |
38 | {% if csrf_token %} 39 | 40 | {% endif %} 41 | {{ form|safe }} 42 | 43 | 44 | 45 |
46 | 47 | 48 |
49 | 50 | 51 | -------------------------------------------------------------------------------- /pixelserver/www/pixels.css: -------------------------------------------------------------------------------- 1 | /* Set padding and margins to 0 then change later 2 | Helps with cross browser support */ 3 | * { 4 | padding:0; 5 | margin:0; 6 | } 7 | 8 | 9 | li, dd { margin-left:5%; } 10 | 11 | fieldset { padding: .5em; } 12 | 13 | br.all { 14 | clear: both; 15 | } 16 | 17 | br.clearall { 18 | clear: both; 19 | } 20 | 21 | 22 | /* remove margin (added above) on nested ul */ 23 | ul ul { margin: 0;} 24 | ul ul ul { margin: 0;} 25 | 26 | 27 | /* Set selected button color */ 28 | .sequence-selected { 29 | background: #AAAAAA; 30 | } 31 | 32 | .seq-select-div { 33 | display: inline-block; 34 | } 35 | 36 | .seq-select-btn { 37 | border: none; 38 | padding: 15px; 39 | text-align: center; 40 | font-size: 14pt; 41 | margin: 10px; 42 | cursor: pointer; 43 | width: 150px; 44 | height: 75px; 45 | } 46 | 47 | #sequences-list { 48 | list-style-type: none; 49 | margin: 0; 50 | padding: 0; 51 | overflow: hidden; 52 | } 53 | 54 | .li-seq-select { 55 | float: left; 56 | padding: 0; 57 | margin: 0; 58 | } 59 | 60 | 61 | #divapplybuttons { 62 | background-color: #6666ff; 63 | } 64 | 65 | #applybutton { 66 | background-color: #ffffff; 67 | border: none; 68 | padding: 15px; 69 | text-align: center; 70 | font-size: 16pt; 71 | margin: 10px; 72 | cursor: pointer; 73 | width: 150px; 74 | height: 75px; 75 | } 76 | 77 | #loginbutton, #logoutbutton, #profilebutton { 78 | background-color: #ffffff; 79 | border: none; 80 | padding: 15px; 81 | text-align: center; 82 | font-size: 16pt; 83 | margin: 10px; 84 | cursor: pointer; 85 | width: 150px; 86 | height: 75px; 87 | } 88 | 89 | #profilebutton { 90 | background-color: #8888ff; 91 | border: none; 92 | padding: 15px; 93 | text-align: center; 94 | font-size: 16pt; 95 | margin: 10px; 96 | cursor: pointer; 97 | width: 150px; 98 | height: 75px; 99 | } 100 | 101 | #reversebutton { 102 | border: none; 103 | padding: 15px; 104 | text-align: center; 105 | font-size: 16pt; 106 | margin: 10px; 107 | cursor: pointer; 108 | width: 110px; 109 | height: 75px; 110 | border-radius: 45%; 111 | } 112 | 113 | 114 | 115 | #settingsbutton { 116 | border: none; 117 | padding: 15px; 118 | text-align: center; 119 | font-size: 16pt; 120 | margin: 10px; 121 | cursor: pointer; 122 | width: 110px; 123 | height: 75px; 124 | border-radius: 45%; 125 | } 126 | 127 | 128 | 129 | 130 | #userlogin { 131 | float:right; 132 | } 133 | 134 | #loggedinuser { 135 | font-size: 16pt; 136 | } 137 | 138 | .speed-slider { 139 | -webkit-appearance: none; /* Override default CSS styles */ 140 | appearance: none; 141 | width: 100%; /* Full-width */ 142 | height: 15px; /* Specified height */ 143 | border-radius: 5px; 144 | background: #d3d3d3; /* Grey background */ 145 | outline: none; /* Remove outline */ 146 | opacity: 0.7; /* Set transparency (for mouse-over effects on hover) */ 147 | -webkit-transition: .2s; /* 0.2 seconds transition on hover */ 148 | transition: opacity .2s; 149 | } 150 | 151 | .speed-slider::-webkit-slider-thumb { 152 | -webkit-appearance: none; 153 | appearance: none; 154 | width: 25px; 155 | height: 25px; 156 | border-radius: 50%; 157 | background: #aa0449; 158 | cursor: pointer; 159 | } 160 | 161 | .speed-slider::-moz-range-thumb { 162 | width: 25px; 163 | height: 25px; 164 | border-radius: 50%; 165 | background: #aa0449; 166 | cursor: pointer; 167 | } 168 | 169 | .speed-slider:hover { 170 | opacity: 1; /* Fully shown on mouse-over */ 171 | } 172 | 173 | 174 | .colbutton { 175 | border: solid; 176 | width: 100px; 177 | height: 75px; 178 | text-align: center; 179 | padding: 20px; 180 | /*border-color: black;*/ 181 | } 182 | 183 | .colcustom { 184 | background-image: linear-gradient(to bottom right, red, yellow, green, blue); 185 | } 186 | 187 | .colwhite { 188 | background: #ffffff; 189 | } 190 | 191 | .colgrey { 192 | background: #7f7f7f; 193 | } 194 | 195 | .colred { 196 | background: #ff0000; 197 | } 198 | 199 | .colpink { 200 | background: #ff66cc; 201 | } 202 | 203 | .colgreen { 204 | background: #008000; 205 | } 206 | 207 | .collightgreen { 208 | background: #90ee90; 209 | } 210 | 211 | .colblue { 212 | background: #0000ff; 213 | } 214 | 215 | .colaqua { 216 | background: #00ffff; 217 | } 218 | 219 | .colpurple { 220 | background: #800080; 221 | } 222 | 223 | .colorange { 224 | background: #ffa500; 225 | } 226 | 227 | .colblack { 228 | background: #000000; 229 | color: #ffffff; 230 | } 231 | 232 | /* color choice buttons */ 233 | 234 | #ulcolselect { 235 | list-style-type: none; 236 | margin: 0; 237 | padding: 0; 238 | overflow: hidden; 239 | } 240 | 241 | .licolselect { 242 | float: left; 243 | padding: 0; 244 | margin: 0; 245 | } 246 | 247 | 248 | /* Selected colours */ 249 | 250 | #ulcolchosen { 251 | list-style-type: none; 252 | margin: 0; 253 | padding: 0; 254 | overflow: hidden; 255 | } 256 | 257 | .licolchosen { 258 | float: left; 259 | padding: 0; 260 | margin: 0; 261 | } 262 | 263 | 264 | .buttoncolchosen { 265 | border-style: solid; 266 | padding: 20px; 267 | text-align: center; 268 | text-decoration: none; 269 | display: inline-block; 270 | font-size: 16px; 271 | margin: 4px 2px; 272 | border-radius: 50%; 273 | } 274 | 275 | .reverse-selected { 276 | background: #AAAAAA; 277 | } 278 | 279 | .reverse-not-selected { 280 | background-color: #ffffff; 281 | } 282 | 283 | 284 | /* User admin table */ 285 | #users { 286 | width: 100%; 287 | } 288 | 289 | #users tr:nth-child(even){background-color: #f2f2f2;} 290 | 291 | #users tr:hover {background-color: #ddd;} 292 | 293 | 294 | #users th { 295 | padding-top: 12px; 296 | padding-bottom: 12px; 297 | text-align: left; 298 | background-color: #3f00fd; 299 | color: white; 300 | } 301 | 302 | #settings-tabs { 303 | border: 1px solid #ccc!important; 304 | color: #000!important; 305 | background-color: #f1f1f1!important; 306 | /*padding: 8px 16px;*/ 307 | /*float: left;*/ 308 | width: 100%; 309 | border: none; 310 | display: block; 311 | outline: 0; 312 | height: 44px; 313 | } 314 | 315 | .tab-item, .tab-item-selected { 316 | color: #000; 317 | float:left; 318 | width: auto; 319 | font-size: 20px; 320 | padding-left: 18px; 321 | padding-right: 24px; 322 | padding-top: 10px; 323 | padding-bottom: 10px; 324 | text-decoration: none; 325 | } 326 | 327 | .tab-item-selected { 328 | color: #fff!important; 329 | background-color: #616161!important; 330 | } 331 | 332 | .tab-item:hover { 333 | color: #fff!important; 334 | background-color: #909090!important; 335 | } -------------------------------------------------------------------------------- /pixelserver/www/pixels.js: -------------------------------------------------------------------------------- 1 | 2 | // Only one sequence can be selected 3 | var sequence = ""; 4 | var reverse = false; 5 | 6 | // Used for escaping strings as additional security check 7 | var entityMap = { 8 | '&': '&', 9 | '<': '<', 10 | '>': '>', 11 | '"': '"', 12 | "'": ''', 13 | '/': '/', 14 | '`': '`', 15 | '=': '=' 16 | }; 17 | 18 | // Should not be required as data comes from our own server 19 | // but provides a little additional protection 20 | function escapeHtml (string) { 21 | return String(string).replace(/[&<>"'`=\/]/g, function (s) { 22 | return entityMap[s]; 23 | }); 24 | } 25 | 26 | 27 | $(() => { 28 | 29 | $('#status').html("

Ready

"); 30 | 31 | $.getJSON( "sequences.json", { 32 | tagmode: "any", 33 | format: "json" 34 | }) 35 | .done(function( data ) { 36 | seq_name = ""; 37 | title = ""; 38 | description = ""; 39 | $.each( data, function( i, seq_object ) { 40 | $.each (seq_object, function (key, val) { 41 | if (key == "seq_name") seq_name = val; 42 | if (key == "title") title = val; 43 | if (key == "description") description = val; 44 | if (key == "group") group = parseInt(val); 45 | }); 46 | // Only show groups up to 3 47 | if (group < 4) { 48 | // Add to list 49 | $('#sequences-list').append ("
  • \n\n
  • "); 50 | // if this is the first then set it to the sequence 51 | if (sequence == "") sequence = seq_name; 52 | } 53 | }); 54 | // Set sequence 55 | set_sequence(); 56 | 57 | 58 | $(".colbutton").click(function() { 59 | add_color($(this).attr('name')); 60 | }); 61 | 62 | 63 | 64 | }); 65 | 66 | 67 | }) 68 | 69 | // Set all sequences not selected, except one matching sequence 70 | function set_sequence() { 71 | $.each($(".seq-select-btn"), function (i, val) { 72 | if (val.id != sequence) { 73 | $(this).removeClass('sequence-selected'); 74 | } 75 | else { 76 | $(this).addClass('sequence-selected'); 77 | } 78 | }); 79 | } 80 | 81 | function select_sequence(seq_name) { 82 | sequence = seq_name; 83 | set_sequence(); 84 | } 85 | 86 | function reverse_toggle() { 87 | if (reverse == true) { 88 | reverse = false; 89 | $("#reversebutton").removeClass('reverse-selected'); 90 | $("#reversebutton").addClass('reverse-not-selected'); 91 | } 92 | else { 93 | reverse = true; 94 | $("#reversebutton").removeClass('reverse-not-selected'); 95 | $("#reversebutton").addClass('reverse-selected'); 96 | } 97 | } 98 | 99 | function apply() { 100 | // Read each of the values and create the url 101 | // sequence is in variable sequence 102 | // get speed 103 | delay = 1000 - document.getElementById("speed").value; 104 | // reverse 105 | reverse_str = 0; 106 | if (reverse == true) { 107 | reverse_str = 1; 108 | } 109 | // get all colors 110 | var color_list = ""; 111 | $.each($(".chosencolor"), function (i, val) { 112 | // if first don't need comma 113 | if (color_list != "") color_list += ","; 114 | color_list += val.name; 115 | }); 116 | // if no colors send default = white 117 | if (color_list == "") color_list = "ffffff"; 118 | url_string = "set?seq="+sequence+"&delay="+delay+"&reverse="+reverse_str+"&colors="+color_list; 119 | $.get( url_string, function (data) { 120 | // Convert from json to user string 121 | status_string = format_status (data); 122 | $("#status").html(status_string); 123 | }); 124 | 125 | 126 | } 127 | 128 | 129 | function format_status(data) { 130 | obj = JSON.parse(data); 131 | formatted_string = ""; 132 | if (obj.status =="success") { 133 | // Displays the short sequence name, which is what would be used 134 | // in automation etc. 135 | formatted_string += "LEDs updated "+escapeHtml(obj.sequence); 136 | } 137 | else { 138 | formatted_string += "Update failed "+escapeHtml(obj.msg); 139 | } 140 | return formatted_string; 141 | } 142 | 143 | 144 | function show_speed() { 145 | // JQuery does not work well with manual slider 146 | // Use normal javascript to get the value 147 | speed_val = document.getElementById("speed").value; 148 | document.getElementById("speed-val").innerHTML = 1000-speed_val; 149 | 150 | } 151 | 152 | 153 | function remove_color(this_button) { 154 | this_button.closest('li').remove(); 155 | // check if this was the list button - in which case show default 156 | if (!($(".chosencolor")[0])) { 157 | $("#defaultcolchosen").show(); 158 | } 159 | } 160 | 161 | 162 | function add_color(this_color) { 163 | // hide the default if not already hidden 164 | $("#defaultcolchosen").hide(); 165 | // Add this list element 166 | // chosencolor class is not for css, but for iterating over the 167 | // chosen colors (excluding default) 168 | $("#ulcolchosen").append("
  • "); 169 | } 170 | 171 | 172 | // Redirect to settings 173 | function settings() { 174 | window.location.href="settings"; 175 | } 176 | 177 | // redirect to profile page 178 | function profile() { 179 | window.location.href="profile"; 180 | } 181 | 182 | // Redirect to login 183 | function login() { 184 | window.location.href="login"; 185 | } 186 | 187 | // Redirect to logout 188 | function logout() { 189 | window.location.href="logout"; 190 | } 191 | 192 | // Redirect to main page 193 | function go_index() { 194 | window.location.href="home"; 195 | } 196 | 197 | function go_add_user() { 198 | window.location.href="useradmin?action=new"; 199 | } 200 | 201 | // Submit form from button outside of the form 202 | function save_settings() { 203 | $( "#settingsform" ).submit(); 204 | } 205 | 206 | -------------------------------------------------------------------------------- /pixelserver/www/profile.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Pixel Server 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |
    14 | 15 | 16 | {% if user %} 17 | 18 | {% if admin %} 19 | 20 | {% endif %} 21 | 22 | {% else %} 23 | 24 | {% endif %} 25 | 26 | 27 | 28 |
    29 | 30 | 31 | 33 |
    34 | 35 |

    {{ message }}

    36 | 37 |

    The following items are readonly. Please contact an admin for any changes.

    38 | 39 |
    40 | {% if csrf_token %} 41 | 42 | {% endif %} 43 | {{ form|safe }} 44 | 45 | 46 | 47 |
    48 | 49 |
    50 | 51 | 52 | -------------------------------------------------------------------------------- /pixelserver/www/settings.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Pixel Server 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |
    14 | 15 | 16 | {% if user %} 17 | 18 | {% if admin %} 19 | 20 | {% endif %} 21 | 22 | {% else %} 23 | 24 | {% endif %} 25 | 26 | 27 | 28 |
    29 | 30 | 31 | 33 |
    34 | 35 |

    {{ message }}

    36 | 37 |
    38 | {% if csrf_token %} 39 | 40 | {% endif %} 41 | {{ form|safe }} 42 | 43 | 44 | 45 |
    46 | 47 |
    48 | 49 | 50 | -------------------------------------------------------------------------------- /pixelserver/www/useradmin.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Pixel Server 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |
    14 | 15 | 16 | {% if user %} 17 | 18 | {% if admin %} 19 | 20 | {% endif %} 21 | 22 | {% else %} 23 | 24 | {% endif %} 25 | 26 | 27 | 28 |
    29 | 30 | 31 | 33 |
    34 | 35 |

    {{ message }}

    36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | {{ table|safe }} 48 | 49 | 50 |
    UsernameReal NameUser TypeEmailDescriptionDelete?
    51 | 52 |
    53 | {% if csrf_token %} 54 | 55 | {% endif %} 56 | 57 |
    58 | 59 | 60 | 61 |
    62 | 63 | 64 | -------------------------------------------------------------------------------- /tests/TESTS.md: -------------------------------------------------------------------------------- 1 | # Testing pixel-server 2 | 3 | To run tests with logging: 4 | pytest-3 -p no:logging 5 | 6 | The default log is /tmp/pytest-of-/pytest-current/log?/pixelserver.log 7 | 8 | ## Debug messages 9 | 10 | To add messages to the log include debug=True in create_app() 11 | Then use logging.debug after creating the app 12 | 13 | Console messages are not normally shown. To also show console messages use the -s option. 14 | 15 | 16 | # Limitations 17 | 18 | The automated testing has some limitations due to network configuration etc. The following are not included in the testing and can instead be tested manually. 19 | 20 | ## CSRF 21 | 22 | CSRF is excluded from the testing. To test CSRF manually: 23 | 24 | * run the server 25 | * login as a user 26 | * restart the server 27 | * receive a session expired message 28 | 29 | ## Network addresses 30 | 31 | When performing testing then the network address used is 127.0.0.1. The automated tests take account of this by allowing and disallowing that address, but it does not fully test different network ranges / addresses. These should be tested manually using an appropriate network setup and suitable auth.cfg files. 32 | 33 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/penguintutor/pixel-server/f7a614e1e631aaac2e33cb64feada097219c5af3/tests/__init__.py -------------------------------------------------------------------------------- /tests/configs/auth-allownone_test.cfg: -------------------------------------------------------------------------------- 1 | # Authentication rules for Pixel Server 2 | # Following addresses can access without authentication 3 | # network_allow_always = 127.0.0.1 4 | # Following allowed, but need to authenticate 5 | # 0.0.0.0 = all addresses 6 | # network_allow_auth = 0.0.0.0 -------------------------------------------------------------------------------- /tests/configs/auth-noguest_test.cfg: -------------------------------------------------------------------------------- 1 | # Authentication rules for Pixel Server 2 | # Following addresses can access without authentication 3 | # network_allow_always = 127.0.0.1 4 | # Following allowed, but need to authenticate 5 | # 0.0.0.0 = all addresses 6 | network_allow_auth = 0.0.0.0 -------------------------------------------------------------------------------- /tests/configs/auth-proxy_test.cfg: -------------------------------------------------------------------------------- 1 | # Authentication rules for Pixel Server 2 | # Following addresses can access without authentication 3 | # network_allow_always = 127.0.0.1 4 | # Following allowed, but need to authenticate 5 | # 0.0.0.0 = all addresses 6 | # network_allow_auth = 0.0.0.0 7 | proxy_server = 127.0.0.1 8 | network_allow_always = 192.168.3.7 9 | network_allow_auth = 192.168.0.0/24 -------------------------------------------------------------------------------- /tests/configs/auth_test.cfg: -------------------------------------------------------------------------------- 1 | # Authentication rules for Pixel Server 2 | # Following addresses can access without authentication 3 | network_allow_always = 127.0.0.1 4 | # Following allowed, but need to authenticate 5 | # 0.0.0.0 = all addresses 6 | network_allow_auth = 0.0.0.0 -------------------------------------------------------------------------------- /tests/configs/customlight_test.cfg: -------------------------------------------------------------------------------- 1 | #008000 -------------------------------------------------------------------------------- /tests/configs/defaults_test.cfg: -------------------------------------------------------------------------------- 1 | # Configuration file for pixel-server 2 | # Do not edit this file directly instead create a new file 3 | # pixelserver.cfg with any values you would like to override 4 | 5 | ledcount=44 6 | gpiopin=18 7 | ledfreq=800000 8 | leddma=5 9 | ledmaxbrightness=50 10 | ledinvert=False 11 | ledchannel=0 12 | striptype=GRB 13 | algorithm=Argon2 14 | -------------------------------------------------------------------------------- /tests/configs/logins.txt: -------------------------------------------------------------------------------- 1 | # Valid usernames for testing 2 | 3 | # users.cfg 4 | ## Argon 2 5 | admin / pixel1login2 6 | stduser1 / pixel1login2 7 | 8 | ## SHA256 9 | adminuser1 / pixel1login2 10 | stduser2 / pixel1login2 11 | -------------------------------------------------------------------------------- /tests/configs/pixelserver_test.cfg: -------------------------------------------------------------------------------- 1 | # Configuration file for pixel-server 2 | # Overrides entries in defaults.cfg 3 | 4 | ledcount=44 5 | gpiopin=18 6 | ledfreq=800000 7 | leddma=5 8 | ledmaxbrightness=50 9 | ledinvert=False 10 | ledchannel=0 11 | striptype=GRB 12 | algorithm=Argon2 13 | -------------------------------------------------------------------------------- /tests/configs/sha256_test.cfg: -------------------------------------------------------------------------------- 1 | algorithm=SHA256 2 | -------------------------------------------------------------------------------- /tests/configs/users_test.cfg: -------------------------------------------------------------------------------- 1 | admin:$argon2id$v=19$m=102400,t=2,p=8$0ZzMvxnNwvxePMp+y7hpfA$rCupS+lnWHUdEeIVkUHAGQ:Admin user:admin:: 2 | stduser1:$argon2id$v=19$m=102400,t=2,p=8$QHpYDjnCGTsoxbRbxST34w$AW5TCq3vw4S+NujD8EUH9Q::standard:: 3 | adminuser1:$5$1883d31e1d394719b2eab0b9b2c786bc$743be7524ea18ca3fbc53333d0cec4cfd20e115045ddb455f30d1bcf3df2621d:Admin user real name:admin:user@example938.com:Admin user sha256 4 | stduser2:$5$1948a2047f5743c4a64bde92919d3eec$25670be86447039a5e126fc8acaac5e4ee4bd7805fcb493074ea9877be03088c:Standard user real name:standard:stduser2@nouser.ex543.com:Standard user sha256 5 | -------------------------------------------------------------------------------- /tests/data/usertests.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "username":"testuser1", 4 | "password":"A very long password with spaces 1", 5 | "realname":"Fullname of test user 1", 6 | "admin":"False", 7 | "email":"", 8 | "description":"", 9 | "error": "none", 10 | "errormsg": "" 11 | }, 12 | { 13 | "username":"testuser2", 14 | "password":"pass;3%$380583", 15 | "realname":"Fullname of testuser2 with :", 16 | "admin":"False", 17 | "email":"me@here334.com", 18 | "description":"Test", 19 | "error": "edit", 20 | "errormsg": "Invalid character in name" 21 | }, 22 | { 23 | "username":"testadmin1", 24 | "password":"pass-----33383", 25 | "realname":"Fullname of test user 1 with", 26 | "admin":"True", 27 | "email":"admin@here334.com", 28 | "description":"Test description", 29 | "error": "none", 30 | "errormsg": "" 31 | }, 32 | { 33 | "username":"testadmin2", 34 | "password":"pass-----33383", 35 | "realname":"Fullname of test user 1 with", 36 | "admin":"True", 37 | "email":"admin@here334.com", 38 | "description":"Test description with ", 39 | "error": "edit", 40 | "errormsg": "Test description with embedded" 41 | }, 42 | { 43 | "username":"testadmin3", 44 | "password":"pass3383", 45 | "realname":"Fullname of test user ", 46 | "admin":"True", 47 | "email":"admin@here334.com", 48 | "description":"Test description with :", 49 | "error": "edit", 50 | "errormsg": "Invalid character in description" 51 | }, 52 | { 53 | "username":"testadmin2", 54 | "password":"pass-----33383", 55 | "realname":"Fullname of test user 1 with", 56 | "admin":"True", 57 | "email":"admin@here334.com", 58 | "description":"Test description with ", 59 | "error": "username", 60 | "errormsg": "User already exists, please try another username" 61 | }, 62 | { 63 | "username":"testpass1", 64 | "password":"pass", 65 | "realname":"Fullname of test user 1 with", 66 | "admin":"True", 67 | "email":"admin@here334.com", 68 | "description":"Pasword fail", 69 | "error": "password", 70 | "errormsg": "Password must be at least 8 characters long" 71 | }, 72 | { 73 | "username":"testpass2", 74 | "password":"passwordnodigit", 75 | "realname":"Fullname of test user 1 with", 76 | "admin":"True", 77 | "email":"admin@here334.com", 78 | "description":"Test description ", 79 | "error": "password", 80 | "errormsg": "Password must contain at least one letter and one digit" 81 | }, 82 | { 83 | "username":"testpass3", 84 | "password":"38478378947", 85 | "realname":"Fullname of test user 1 with", 86 | "admin":"True", 87 | "email":"admin@here334.com", 88 | "description":"Test description ", 89 | "error": "password", 90 | "errormsg": "Password must contain at least one letter and one digit" 91 | } 92 | ] -------------------------------------------------------------------------------- /tests/functional/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/penguintutor/pixel-server/f7a614e1e631aaac2e33cb64feada097219c5af3/tests/functional/__init__.py -------------------------------------------------------------------------------- /tests/functional/test_flask_1.py: -------------------------------------------------------------------------------- 1 | from flask import Flask 2 | import pixelserver 3 | from pixelserver import create_app 4 | import logging 5 | 6 | # Note that this is not able to test csrf protection 7 | # use csrf_enable = False in create_app() for any posts 8 | 9 | # For log debugging use debug=True in create_app() 10 | # then using logging.debug 11 | 12 | # That is tested separately outside of this. See TESTS.md for more details 13 | 14 | # default configs use alternative as required for each test 15 | default_config_filename = "tests/configs/defaults_test.cfg" 16 | custom_config_filename = "tests/configs/pixelserver_test.cfg" 17 | custom_light_config_filename = "tests/configs/customlight_test.cfg" 18 | auth_config_filename = "tests/configs/auth_test.cfg" 19 | auth_users_filename = "tests/configs/users_test.cfg" 20 | 21 | # default use _log_filename which uses directory factory 22 | log_filename = "pixelserver.log" 23 | 24 | # special configs 25 | # No network_allow_always (no guest) 26 | auth_config_noguest = "tests/configs/auth-noguest_test.cfg" 27 | auth_config_none = "tests/configs/auth-allownone_test.cfg" 28 | auth_config_proxy = "tests/configs/auth-proxy_test.cfg" 29 | 30 | # file does not exist 31 | auth_users_nofile = "tests/configs/users_notexist_test.cfg" 32 | 33 | 34 | def tmp_dir_setup (tmp_path_factory): 35 | global _log_directory, _log_filename 36 | _log_directory = str(tmp_path_factory.mktemp("log")) 37 | _log_filename = _log_directory + "/" + log_filename 38 | 39 | # Setup path factory and empty user file 40 | def test_setup_factory(tmp_path_factory): 41 | tmp_dir_setup(tmp_path_factory) 42 | 43 | # Authorized on network return index.html 44 | def test_index_1(): 45 | app = create_app(auth_config_filename, auth_users_filename, _log_filename, debug=True) 46 | logging.debug ("*TEST guest network") 47 | with app.test_client() as test_client: 48 | response = test_client.get('/') 49 | assert response.status_code == 200 50 | assert "guest" in str(response.data) 51 | 52 | # redirect to /login 53 | def test_index_2(): 54 | app = create_app(auth_config_noguest, auth_users_filename, _log_filename, debug=True) 55 | with app.test_client() as test_client: 56 | response = test_client.get('/') 57 | assert response.status_code == 302 58 | assert (response.location == "http://localhost/login") or (response.location == "/login") 59 | 60 | # Not allowed 61 | def test_index_3(): 62 | app = create_app(auth_config_none, auth_users_filename, _log_filename, debug=True) 63 | with app.test_client() as test_client: 64 | response = test_client.get('/') 65 | assert response.status_code == 302 66 | assert (response.location == "http://localhost/invalid") or (response.location == "/invalid") 67 | 68 | # Test proxy- authentication required 69 | def test_index_4(): 70 | app = create_app(auth_config_proxy, auth_users_filename, _log_filename, debug=True) 71 | logging.debug ("*TEST Test proxy - login required") 72 | with app.test_client() as test_client: 73 | response = test_client.get('/', 74 | headers={'X-Real_IP': '192.168.0.22'} 75 | ) 76 | assert response.status_code == 302 77 | assert (response.location == "http://localhost/login") or (response.location == "/login") 78 | 79 | # Test proxy - address not allowed 80 | def test_index_5(): 81 | app = create_app(auth_config_proxy, auth_users_filename, _log_filename, debug=True) 82 | logging.info ("*TEST Test proxy - invalid") 83 | with app.test_client() as test_client: 84 | response = test_client.get('/', 85 | headers={'X-Real_IP': '10.5.5.1'} 86 | ) 87 | assert response.status_code == 302 88 | assert response.location == "http://localhost/invalid" 89 | 90 | # Test proxy - no login required 91 | def test_index_5(): 92 | app = create_app(auth_config_proxy, auth_users_filename, _log_filename, debug=True) 93 | logging.info ("*TEST Test proxy - guest login") 94 | with app.test_client() as test_client: 95 | response = test_client.get('/', 96 | headers={'X-Real_IP': '192.168.3.7'} 97 | ) 98 | assert response.status_code == 200 99 | assert "guest" in str(response.data) 100 | 101 | # Network not authorised to return /login 102 | def test_login_page_2(): 103 | app = create_app(auth_config_filename, auth_users_filename, _log_filename, debug=True) 104 | with app.test_client() as test_client: 105 | response = test_client.get('/login') 106 | assert response.status_code == 200 107 | assert 'Please login to access' in str(response.data) 108 | 109 | def test_login_success_1(): 110 | app = create_app(auth_config_filename, auth_users_filename, _log_filename, csrf_enable=False, debug=True) 111 | with app.app_context(): 112 | test_client = app.test_client() 113 | response = test_client.post("/login", data={ 114 | "username": "admin", 115 | "password": "pixel1login2", 116 | }, follow_redirects=True) 117 | assert response.status_code == 200 118 | assert '' in str(response.data) 119 | 120 | def test_login_fail_1(): 121 | app = create_app(auth_config_filename, auth_users_filename, _log_filename, csrf_enable=False, debug=True) 122 | with app.app_context(): 123 | test_client = app.test_client() 124 | response = test_client.post("/login", data={ 125 | "username": "admin", 126 | "password": "pixel", 127 | }, follow_redirects=True) 128 | assert response.status_code == 200 129 | assert 'Invalid login attempt' in str(response.data) 130 | 131 | # invalid characters in username 132 | def test_login_fail_2(): 133 | app = create_app(auth_config_filename, auth_users_filename, _log_filename, csrf_enable=False, debug=True) 134 | with app.app_context(): 135 | test_client = app.test_client() 136 | response = test_client.post("/login", data={ 137 | "username": "admin", 138 | "password": "pixel", 139 | }, follow_redirects=True) 140 | assert response.status_code == 200 141 | assert 'Invalid login attempt' in str(response.data) 142 | 143 | def test_login_fail_3(): 144 | app = create_app(auth_config_filename, auth_users_filename, _log_filename, csrf_enable=False, debug=True) 145 | with app.app_context(): 146 | test_client = app.test_client() 147 | response = test_client.post("/login", data={ 148 | "username": "admin:noallowed", 149 | "password": "pixel1login2", 150 | }, follow_redirects=True) 151 | assert response.status_code == 200 152 | assert 'Invalid login attempt' in str(response.data) 153 | 154 | -------------------------------------------------------------------------------- /tests/functional/test_flask_auth_1.py: -------------------------------------------------------------------------------- 1 | from flask import Flask 2 | import pixelserver 3 | from pixelserver import create_app 4 | import logging 5 | 6 | # Note that this is not able to test csrf protection 7 | # use csrf_enable = False in create_app() for any posts 8 | 9 | ## Tests using different auth_config values 10 | 11 | # For log debugging use debug=True in create_app() 12 | # then using logging.debug 13 | 14 | # That is tested separately outside of this. See TESTS.md for more details 15 | 16 | # default configs use alternative as required for each test 17 | default_config_filename = "tests/configs/defaults_test.cfg" 18 | custom_config_filename = "tests/configs/pixelserver_test.cfg" 19 | custom_light_config_filename = "tests/configs/customlight_test.cfg" 20 | auth_config_filename = "tests/configs/auth_test.cfg" 21 | auth_users_filename = "tests/configs/users_test.cfg" 22 | 23 | # default use _log_filename which uses directory factory 24 | log_filename = "pixelserver.log" 25 | 26 | # special configs 27 | # No network_allow_always (no guest) 28 | auth_config_noguest = "tests/configs/auth-noguest_test.cfg" 29 | auth_config_none = "tests/configs/auth-allownone_test.cfg" 30 | auth_config_proxy = "tests/configs/auth-proxy_test.cfg" 31 | auth_config_nofile = "tests/configs/auth-config-notexist.cfg" # File does not exist 32 | 33 | # file does not exist 34 | auth_users_nofile = "tests/configs/users_notexist_test.cfg" 35 | 36 | 37 | 38 | def tmp_dir_setup (tmp_path_factory): 39 | global _log_directory, _log_filename 40 | _log_directory = str(tmp_path_factory.mktemp("log")) 41 | _log_filename = _log_directory + "/" + log_filename 42 | 43 | # Setup path factory and empty user file 44 | def test_setup_factory(tmp_path_factory): 45 | tmp_dir_setup(tmp_path_factory) 46 | 47 | # If not auth config file then default to login required 48 | # redirect to /login 49 | def test_indexnoconfig_1(): 50 | app = create_app(auth_config_nofile, auth_users_filename, _log_filename, debug=True) 51 | with app.test_client() as test_client: 52 | response = test_client.get('/') 53 | assert response.status_code == 302 54 | assert (response.location == "http://localhost/login") or (response.location == "/login") 55 | 56 | # If no users file - or no users in users file then gives warning 57 | # redirect to /login 58 | def test_login_nousers_1(): 59 | app = create_app(auth_config_nofile, auth_users_nofile, _log_filename, debug=True) 60 | with app.test_client() as test_client: 61 | # First check we get redirect to /login 62 | response = test_client.get('/') 63 | assert response.status_code == 302 64 | assert (response.location == "http://localhost/login") or (response.location == "/login") 65 | # Now get login page and check it gives warning message 66 | response = test_client.get('/login') 67 | assert response.status_code == 200 68 | assert "No users defined or missing users file. Run createadmin.py through the shell to setup an admin user." in str(response.data) 69 | 70 | # checks don't get the warning that no users file if we have a users file 71 | # redirect to /login 72 | def test_loginpage_users_1(): 73 | app = create_app(auth_config_nofile, auth_users_filename, _log_filename, debug=True) 74 | with app.test_client() as test_client: 75 | # First check we get redirect to /login 76 | response = test_client.get('/') 77 | assert response.status_code == 302 78 | assert (response.location == "http://localhost/login") or (response.location == "/login") 79 | # Now get login page and check it gives warning message 80 | response = test_client.get('/login') 81 | assert response.status_code == 200 82 | assert not("No users defined or missing users file." in str(response.data)) -------------------------------------------------------------------------------- /tests/functional/test_flask_passwords_1.py: -------------------------------------------------------------------------------- 1 | from flask import Flask 2 | import pixelserver 3 | from pixelserver import create_app 4 | from pixelserver.pixels import Pixels 5 | import logging 6 | import shutil 7 | import json 8 | import string 9 | import random 10 | 11 | # Note that this is not able to test csrf protection 12 | # use csrf_enable = False in create_app() for any posts 13 | 14 | # For log debugging use debug=True in create_app() 15 | # then using logging.debug 16 | 17 | # Users tmp_path_factory - files will be copied to: 18 | #/tmp/pytest-of-/pytest-current/log?/pixelserver.log 19 | 20 | ## Automated tests - generates different passwords to test user password 21 | # uses lowercase, uppercase, digits and special characters 22 | allowed_chars = list(string.ascii_letters + string.digits + string.punctuation + " ") 23 | 24 | # how many times to change the password 25 | num_changes = 10 26 | 27 | _config_src_directory = "tests/configs/" 28 | 29 | # default use _log_filename which uses directory factory 30 | log_filename = "pixelserver.log" 31 | 32 | # JSON file with data to test with 33 | data_file ="tests/data/usertests.json" 34 | 35 | # name of config files - will be mapped to temp directory 36 | # In tests use configs{} instead. 37 | config_filenames = { 38 | 'default' : "defaults_test.cfg", 39 | 'custom' : "pixelserver_test.cfg", 40 | 'sha256' : "sha256_test.cfg", 41 | 'light' : "customlight_test.cfg", 42 | 'auth' : "auth_test.cfg", 43 | 'users' : "users_test.cfg" 44 | } 45 | 46 | 47 | 48 | # default config files - created using tmp_dir_setup 49 | configs = {} 50 | 51 | def tmp_dir_setup (tmp_path_factory): 52 | global _log_directory, _log_filename, _config_directory 53 | _log_directory = str(tmp_path_factory.mktemp("log")) 54 | _log_filename = _log_directory + "/" + log_filename 55 | _config_directory = str(tmp_path_factory.mktemp("config")) 56 | # for all filenames copy files into tempdirectory and update configs 57 | for key, value in config_filenames.items(): 58 | configs[key] = _config_directory + "/" + value 59 | # copy existing file to new location 60 | shutil.copyfile(_config_src_directory + value, configs[key]) 61 | 62 | 63 | # Setup path factory and empty user file 64 | def test_setup_factory(tmp_path_factory): 65 | global user_data 66 | tmp_dir_setup(tmp_path_factory) 67 | 68 | 69 | # performs num_changes x password changes and checks each is successful 70 | def test_password_changer_1(): 71 | username = "stduser1" 72 | current_password = "pixel1login2" 73 | first_password = current_password # use to reset password at the end 74 | app = create_app(configs['auth'], configs['users'], _log_filename, csrf_enable=False, debug=True) 75 | pixels = Pixels(configs['default'], "", "", run=False) 76 | logging.debug ("*TEST automated password changer") 77 | with app.test_client() as test_client: 78 | # Login 79 | response = test_client.get('/login') 80 | assert response.status_code == 200 81 | assert "Please login to access" in str(response.data) 82 | for i in range (num_changes): 83 | # Choose new password 84 | new_password = _create_valid_password() 85 | # Perform login using current_password 86 | response = test_client.post("/login", data={ 87 | "username": username, 88 | "password": current_password, 89 | }, follow_redirects=True) 90 | assert response.status_code == 200 91 | eval_string = ''.format(username) 92 | assert eval_string in str(response.data) 93 | # Go to profile page 94 | response = test_client.get('/profile') 95 | assert response.status_code == 200 96 | assert "The following items are readonly. Please contact an admin for any changes." in str(response.data) 97 | # Go to change password page 98 | response = test_client.get('/password') 99 | assert response.status_code == 200 100 | assert '' in str(response.data) 101 | # Change to new password 102 | response = test_client.post("/password", data={ 103 | "passwordchange": "passwordchange", 104 | "currentpassword": current_password, 105 | "newpassword": new_password, 106 | "repeatpassword": new_password, 107 | }, follow_redirects=True) 108 | assert response.status_code == 200 109 | assert 'Password changed' in str(response.data) 110 | # Swap to new password 111 | current_password = new_password 112 | # logout 113 | response = test_client.get('/logout') 114 | assert response.status_code == 200 115 | assert "Logged out" in str(response.data) 116 | assert "Please login to access" in str(response.data) 117 | # outside loop - check final login 118 | response = test_client.post("/login", data={ 119 | "username": username, 120 | "password": current_password, 121 | }, follow_redirects=True) 122 | assert response.status_code == 200 123 | eval_string = ''.format(username) 124 | assert eval_string in str(response.data) 125 | # Restore original password 126 | new_password = first_password 127 | response = test_client.post("/password", data={ 128 | "passwordchange": "passwordchange", 129 | "currentpassword": current_password, 130 | "newpassword": new_password, 131 | "repeatpassword": new_password, 132 | }, follow_redirects=True) 133 | assert response.status_code == 200 134 | assert 'Password changed' in str(response.data) 135 | # Swap to new password 136 | current_password = new_password 137 | # logout 138 | response = test_client.get('/logout') 139 | assert response.status_code == 200 140 | assert "Logged out" in str(response.data) 141 | assert "Please login to access" in str(response.data) 142 | # Check restored first_password 143 | response = test_client.post("/login", data={ 144 | "username": username, 145 | "password": first_password, 146 | }, follow_redirects=True) 147 | assert response.status_code == 200 148 | eval_string = ''.format(username) 149 | assert eval_string in str(response.data) 150 | 151 | 152 | # Test with long passwords (10 x) 153 | def test_password_change_long_1(): 154 | username = "stduser1" 155 | current_password = "pixel1login2" 156 | first_password = current_password # use to reset password at the end 157 | app = create_app(configs['auth'], configs['users'], _log_filename, csrf_enable=False, debug=True) 158 | pixels = Pixels(configs['default'], "", "", run=False) 159 | logging.debug ("*TEST long passwords") 160 | with app.test_client() as test_client: 161 | # Login 162 | response = test_client.get('/login') 163 | assert response.status_code == 200 164 | assert "Please login to access" in str(response.data) 165 | for i in range (10): 166 | # Choose new password 167 | new_password = _create_valid_password(max_chars = 255) 168 | # Perform login using current_password 169 | response = test_client.post("/login", data={ 170 | "username": username, 171 | "password": current_password, 172 | }, follow_redirects=True) 173 | assert response.status_code == 200 174 | eval_string = ''.format(username) 175 | assert eval_string in str(response.data) 176 | # Go to profile page 177 | response = test_client.get('/profile') 178 | assert response.status_code == 200 179 | assert "The following items are readonly. Please contact an admin for any changes." in str(response.data) 180 | # Go to change password page 181 | response = test_client.get('/password') 182 | assert response.status_code == 200 183 | assert '' in str(response.data) 184 | # Change to new password 185 | response = test_client.post("/password", data={ 186 | "passwordchange": "passwordchange", 187 | "currentpassword": current_password, 188 | "newpassword": new_password, 189 | "repeatpassword": new_password, 190 | }, follow_redirects=True) 191 | assert response.status_code == 200 192 | assert 'Password changed' in str(response.data) 193 | # Swap to new password 194 | current_password = new_password 195 | # logout 196 | response = test_client.get('/logout') 197 | assert response.status_code == 200 198 | assert "Logged out" in str(response.data) 199 | assert "Please login to access" in str(response.data) 200 | # outside loop - check final login 201 | response = test_client.post("/login", data={ 202 | "username": username, 203 | "password": current_password, 204 | }, follow_redirects=True) 205 | assert response.status_code == 200 206 | eval_string = ''.format(username) 207 | assert eval_string in str(response.data) 208 | # Restore original password 209 | new_password = first_password 210 | response = test_client.post("/password", data={ 211 | "passwordchange": "passwordchange", 212 | "currentpassword": current_password, 213 | "newpassword": new_password, 214 | "repeatpassword": new_password, 215 | }, follow_redirects=True) 216 | assert response.status_code == 200 217 | assert 'Password changed' in str(response.data) 218 | # Swap to new password 219 | current_password = new_password 220 | # logout 221 | response = test_client.get('/logout') 222 | assert response.status_code == 200 223 | assert "Logged out" in str(response.data) 224 | assert "Please login to access" in str(response.data) 225 | # Check restored first_password 226 | response = test_client.post("/login", data={ 227 | "username": username, 228 | "password": first_password, 229 | }, follow_redirects=True) 230 | assert response.status_code == 200 231 | eval_string = ''.format(username) 232 | assert eval_string in str(response.data) 233 | 234 | # check with passwords that are too short 235 | def test_short_passwords_1(): 236 | username = "stduser1" 237 | current_password = "pixel1login2" 238 | new_passwords = ["", "1", "d", "$", "pas", "23d", "343", "34£45!", "?GHNK"] 239 | app = create_app(configs['auth'], configs['users'], _log_filename, csrf_enable=False, debug=True) 240 | pixels = Pixels(configs['default'], "", "", run=False) 241 | logging.debug ("*TEST short password changer") 242 | with app.test_client() as test_client: 243 | response = test_client.post("/login", data={ 244 | "username": username, 245 | "password": current_password, 246 | }, follow_redirects=True) 247 | assert response.status_code == 200 248 | eval_string = ''.format(username) 249 | assert eval_string in str(response.data) 250 | for test_password in new_passwords: 251 | response = test_client.post("/password", data={ 252 | "passwordchange": "passwordchange", 253 | "currentpassword": current_password, 254 | "newpassword": test_password, 255 | "repeatpassword": test_password, 256 | }, follow_redirects=True) 257 | assert response.status_code == 200 258 | assert 'Password must be at least 8 characters long' in str(response.data) 259 | 260 | # check with passwords without digits 261 | def test_nodigit_passwords_1(): 262 | username = "stduser1" 263 | current_password = "pixel1login2" 264 | new_passwords = ["smithjhek", "kjfkjkljkl", "ade%revfde", "!dkjklkrk$", "?GdddrHNK"] 265 | app = create_app(configs['auth'], configs['users'], _log_filename, csrf_enable=False, debug=True) 266 | pixels = Pixels(configs['default'], "", "", run=False) 267 | logging.debug ("*TEST nodigit password changer") 268 | with app.test_client() as test_client: 269 | response = test_client.post("/login", data={ 270 | "username": username, 271 | "password": current_password, 272 | }, follow_redirects=True) 273 | assert response.status_code == 200 274 | eval_string = ''.format(username) 275 | assert eval_string in str(response.data) 276 | for test_password in new_passwords: 277 | response = test_client.post("/password", data={ 278 | "passwordchange": "passwordchange", 279 | "currentpassword": current_password, 280 | "newpassword": test_password, 281 | "repeatpassword": test_password, 282 | }, follow_redirects=True) 283 | assert response.status_code == 200 284 | assert 'Password must contain at least one letter and one digit' in str(response.data) 285 | 286 | # check with passwords without letters 287 | def test_noletter_passwords_1(): 288 | username = "stduser1" 289 | current_password = "pixel1login2" 290 | new_passwords = ["13234323", "32332980£%", "323288898!", "38989987", "0000 000"] 291 | app = create_app(configs['auth'], configs['users'], _log_filename, csrf_enable=False, debug=True) 292 | pixels = Pixels(configs['default'], "", "", run=False) 293 | logging.debug ("*TEST nodigit password changer") 294 | with app.test_client() as test_client: 295 | response = test_client.post("/login", data={ 296 | "username": username, 297 | "password": current_password, 298 | }, follow_redirects=True) 299 | assert response.status_code == 200 300 | eval_string = ''.format(username) 301 | assert eval_string in str(response.data) 302 | for test_password in new_passwords: 303 | response = test_client.post("/password", data={ 304 | "passwordchange": "passwordchange", 305 | "currentpassword": current_password, 306 | "newpassword": test_password, 307 | "repeatpassword": test_password, 308 | }, follow_redirects=True) 309 | assert response.status_code == 200 310 | assert 'Password must contain at least one letter and one digit' in str(response.data) 311 | 312 | 313 | def _create_valid_password (max_chars = 20): 314 | # First create totally random string 315 | password = ''.join(random.choice(allowed_chars) for i in range(random.randint(8,max_chars))) 316 | # strip spaces from end 317 | password = password.strip() 318 | # If no alpha add one to end 319 | if not _has_lowercase(password): 320 | password += random.choice(string.ascii_lowercase) 321 | if not _has_uppercase(password): 322 | password += random.choice(string.ascii_uppercase) 323 | if not _has_digit(password): 324 | password += random.choice(string.digits) 325 | # Now loop checking we have sufficient characters 326 | while (len(password) < 8): 327 | password += random.choice(string.printable) 328 | return password 329 | 330 | 331 | 332 | def _has_digit (string): 333 | for i in list(string): 334 | if i.isdigit(): 335 | return True 336 | return False 337 | 338 | def _has_lowercase (string): 339 | for i in list(string): 340 | if i.islower(): 341 | return True 342 | return False 343 | 344 | def _has_uppercase (string): 345 | for i in list(string): 346 | if i.isupper(): 347 | return True 348 | return False -------------------------------------------------------------------------------- /tests/functional/test_flask_useradmin_1.py: -------------------------------------------------------------------------------- 1 | from flask import Flask 2 | import pixelserver 3 | from pixelserver import create_app 4 | from pixelserver.pixels import Pixels 5 | import logging 6 | import shutil 7 | 8 | # Note that this is not able to test csrf protection 9 | # use csrf_enable = False in create_app() for any posts 10 | 11 | # For log debugging use debug=True in create_app() 12 | # then using logging.debug 13 | 14 | # Users tmp_path_factory - files will be copied to: 15 | #/tmp/pytest-of-/pytest-current/log?/pixelserver.log 16 | 17 | _config_src_directory = "tests/configs/" 18 | 19 | # default use _log_filename which uses directory factory 20 | log_filename = "pixelserver.log" 21 | 22 | # name of config files - will be mapped to temp directory 23 | # In tests use configs{} instead. 24 | config_filenames = { 25 | 'default' : "defaults_test.cfg", 26 | 'custom' : "pixelserver_test.cfg", 27 | 'sha256' : "sha256_test.cfg", 28 | 'light' : "customlight_test.cfg", 29 | 'auth' : "auth_test.cfg", 30 | 'users' : "users_test.cfg" 31 | } 32 | 33 | 34 | 35 | # default config files - created using tmp_dir_setup 36 | configs = {} 37 | 38 | def tmp_dir_setup (tmp_path_factory): 39 | global _log_directory, _log_filename, _config_directory 40 | _log_directory = str(tmp_path_factory.mktemp("log")) 41 | _log_filename = _log_directory + "/" + log_filename 42 | _config_directory = str(tmp_path_factory.mktemp("config")) 43 | # for all filenames copy files into tempdirectory and update configs 44 | for key, value in config_filenames.items(): 45 | configs[key] = _config_directory + "/" + value 46 | # copy existing file to new location 47 | shutil.copyfile(_config_src_directory + value, configs[key]) 48 | 49 | 50 | # Setup path factory and empty user file 51 | def test_setup_factory(tmp_path_factory): 52 | tmp_dir_setup(tmp_path_factory) 53 | 54 | # Authorized on network return index.html 55 | def test_admin_login_1(): 56 | app = create_app(configs['auth'], configs['users'], _log_filename, csrf_enable=False, debug=True) 57 | logging.debug ("*TEST login as admin") 58 | with app.test_client() as test_client: 59 | response = test_client.get('/') 60 | assert response.status_code == 200 61 | assert "guest" in str(response.data) 62 | # Even though guest request login page 63 | response = test_client.get('/login') 64 | assert response.status_code == 200 65 | assert "Please login to access" in str(response.data) 66 | # Perform login using admin user 67 | response = test_client.post("/login", data={ 68 | "username": "admin", 69 | "password": "pixel1login2", 70 | }, follow_redirects=True) 71 | assert response.status_code == 200 72 | assert '' in str(response.data) 73 | 74 | 75 | # Adds a new standard user newuser1 76 | # Follows full route a normal user would do including 77 | # login and navigating through settings 78 | # Does not make any changes at the end 79 | # Uses default settings 80 | def test_add_user_1(): 81 | app = create_app(configs['auth'], configs['users'], _log_filename, csrf_enable=False, debug=True) 82 | pixels = Pixels(configs['default'], "", "", run=False) 83 | logging.debug ("*TEST add new user") 84 | with app.test_client() as test_client: 85 | response = test_client.get('/') 86 | assert response.status_code == 200 87 | assert "guest" in str(response.data) 88 | # Even though guest request login page 89 | response = test_client.get('/login') 90 | assert response.status_code == 200 91 | assert "Please login to access" in str(response.data) 92 | # Perform login using admin user 93 | response = test_client.post("/login", data={ 94 | "username": "admin", 95 | "password": "pixel1login2", 96 | }, follow_redirects=True) 97 | assert response.status_code == 200 98 | assert '' in str(response.data) 99 | # Go to settings 100 | response = test_client.get('/settings') 101 | assert response.status_code == 200 102 | assert "Number LEDs:" in str(response.data) 103 | # Go to useradmin 104 | response = test_client.get('/useradmin') 105 | assert response.status_code == 200 106 | assert '' in str(response.data) 107 | # Add new user stage 1 (no data empty form) 108 | response = test_client.post("/newuser", follow_redirects=True) 109 | assert response.status_code == 200 110 | assert '' in str(response.data) 111 | # stage 2 add "newuser1" 112 | response = test_client.post("/newuser", data={ 113 | "newuser" : "newuser", 114 | "username": "newuser1" 115 | }, follow_redirects=True) 116 | assert response.status_code == 200 117 | assert '' in str(response.data) 118 | # Add a password 119 | response = test_client.post("/newuser", data={ 120 | "newuser" : "userpassword", 121 | "username" : "newuser1", 122 | "password": "pixel1login2", 123 | "password2": "pixel1login2", 124 | }, follow_redirects=True) 125 | assert response.status_code == 200 126 | assert '' in str(response.data) 127 | 128 | # Adds a new admin user 129 | # Uses sha256 130 | def test_add_user_2(): 131 | app = create_app(configs['auth'], configs['users'], _log_filename, csrf_enable=False, debug=True) 132 | pixels = Pixels(configs['default'], configs['sha256'], "", run=False) 133 | logging.debug ("*TEST add admin user sha256") 134 | with app.test_client() as test_client: 135 | # Perform login using admin user 136 | response = test_client.post("/login", data={ 137 | "username": "admin", 138 | "password": "pixel1login2", 139 | }, follow_redirects=True) 140 | assert response.status_code == 200 141 | assert '' in str(response.data) 142 | # Add new user stage 1 (no data empty form) 143 | response = test_client.post("/newuser", follow_redirects=True) 144 | assert response.status_code == 200 145 | assert '' in str(response.data) 146 | # stage 2 add "adminuser" 147 | response = test_client.post("/newuser", data={ 148 | "newuser" : "newuser", 149 | "username": "adminuser" 150 | }, follow_redirects=True) 151 | assert response.status_code == 200 152 | assert '' in str(response.data) 153 | # Add a password 154 | response = test_client.post("/newuser", data={ 155 | "newuser" : "userpassword", 156 | "username" : "adminuser", 157 | "password": "pixel1login2", 158 | "password2": "pixel1login2", 159 | }, follow_redirects=True) 160 | assert response.status_code == 200 161 | assert '' in str(response.data) 162 | # Edit and change values from newuser calls edituser 163 | response = test_client.post("/edituser", data={ 164 | "edituser" : "edituser", 165 | "currentusername" : "adminuser", 166 | "username" : "adminuser", 167 | "realname" : "Admin user real name", 168 | "admin": "checked", 169 | "email": "user@example938.com", 170 | "description": "Admin user sha256" 171 | }, follow_redirects=True) 172 | assert response.status_code == 200 173 | # Check returns to user table - but not check admin is set at this point 174 | assert '
    ' in str(response.data) 175 | # Load the edituser and check admin set 176 | response = test_client.get('/edituser', query_string={ 177 | "user" : "adminuser", 178 | "action" : "edit" 179 | }) 180 | assert response.status_code == 200 181 | # check admin is set 182 | assert '' in str(response.data) 183 | 184 | 185 | # Adds a new standard user 186 | # Uses sha256 187 | def test_add_user_3(): 188 | app = create_app(configs['auth'], configs['users'], _log_filename, csrf_enable=False, debug=True) 189 | pixels = Pixels(configs['default'], configs['sha256'], "", run=False) 190 | logging.debug ("*TEST add standard sha256 user") 191 | with app.test_client() as test_client: 192 | # Perform login using admin user 193 | response = test_client.post("/login", data={ 194 | "username": "admin", 195 | "password": "pixel1login2", 196 | }, follow_redirects=True) 197 | assert response.status_code == 200 198 | assert '' in str(response.data) 199 | # Add new user stage 1 (no data empty form) 200 | response = test_client.post("/newuser", follow_redirects=True) 201 | assert response.status_code == 200 202 | assert '' in str(response.data) 203 | # stage 2 add "stduser" 204 | response = test_client.post("/newuser", data={ 205 | "newuser" : "newuser", 206 | "username": "stduser" 207 | }, follow_redirects=True) 208 | assert response.status_code == 200 209 | assert '' in str(response.data) 210 | # Add a password 211 | response = test_client.post("/newuser", data={ 212 | "newuser" : "userpassword", 213 | "username" : "stduser", 214 | "password": "pixel1login2", 215 | "password2": "pixel1login2", 216 | }, follow_redirects=True) 217 | assert response.status_code == 200 218 | assert '' in str(response.data) 219 | # Edit and change values from newuser calls edituser 220 | response = test_client.post("/edituser", data={ 221 | "edituser" : "edituser", 222 | "currentusername" : "stduser", 223 | "username" : "stduser", 224 | "realname" : "Standard user real name", 225 | "email": "user2@example938.com", 226 | "description": "Standard user sha256" 227 | }, follow_redirects=True) 228 | assert response.status_code == 200 229 | # Check returns to user table - but not check admin is set at this point 230 | assert '
    ' in str(response.data) 231 | # Load the edituser and check admin set 232 | response = test_client.get('/edituser', query_string={ 233 | "user" : "stduser", 234 | "action" : "edit" 235 | }) 236 | assert response.status_code == 200 237 | # check admin is set 238 | assert '' in str(response.data) 239 | 240 | # Test above users works 241 | def test_user_added_1(): 242 | app = create_app(configs['auth'], configs['users'], _log_filename, csrf_enable=False, debug=True) 243 | pixels = Pixels(configs['default'], "", "", run=False) 244 | logging.debug ("*TEST new user works") 245 | with app.test_client() as test_client: 246 | response = test_client.get('/') 247 | assert response.status_code == 200 248 | assert "guest" in str(response.data) 249 | # Even though guest request login page 250 | response = test_client.get('/login') 251 | assert response.status_code == 200 252 | assert "Please login to access" in str(response.data) 253 | # Perform login using admin user 254 | response = test_client.post("/login", data={ 255 | "username": "newuser1", 256 | "password": "pixel1login2", 257 | }, follow_redirects=True) 258 | assert response.status_code == 200 259 | assert '' in str(response.data) 260 | # check not admin by looking for settings button 261 | assert '' not in str(response.data) 262 | 263 | # Test above users works 264 | def test_user_added_2(): 265 | app = create_app(configs['auth'], configs['users'], _log_filename, csrf_enable=False, debug=True) 266 | pixels = Pixels(configs['default'], "", "", run=False) 267 | logging.debug ("*TEST new user works") 268 | with app.test_client() as test_client: 269 | response = test_client.get('/') 270 | assert response.status_code == 200 271 | assert "guest" in str(response.data) 272 | # Even though guest request login page 273 | response = test_client.get('/login') 274 | assert response.status_code == 200 275 | assert "Please login to access" in str(response.data) 276 | # Perform login using admin user 277 | response = test_client.post("/login", data={ 278 | "username": "adminuser", 279 | "password": "pixel1login2", 280 | }, follow_redirects=True) 281 | assert response.status_code == 200 282 | assert '' in str(response.data) 283 | # check not admin by looking for settings button 284 | assert '' in str(response.data) 285 | 286 | # Test above users works 287 | def test_user_added_3(): 288 | app = create_app(configs['auth'], configs['users'], _log_filename, csrf_enable=False, debug=True) 289 | pixels = Pixels(configs['default'], "", "", run=False) 290 | logging.debug ("*TEST new user works") 291 | with app.test_client() as test_client: 292 | response = test_client.get('/') 293 | assert response.status_code == 200 294 | assert "guest" in str(response.data) 295 | # Even though guest request login page 296 | response = test_client.get('/login') 297 | assert response.status_code == 200 298 | assert "Please login to access" in str(response.data) 299 | # Perform login using admin user 300 | response = test_client.post("/login", data={ 301 | "username": "stduser", 302 | "password": "pixel1login2", 303 | }, follow_redirects=True) 304 | assert response.status_code == 200 305 | assert '' in str(response.data) 306 | # check not admin by looking for settings button 307 | assert '' not in str(response.data) 308 | 309 | 310 | # Test invalid password failed login 311 | # includes special characters 312 | def test_invalid_password_1(): 313 | app = create_app(configs['auth'], configs['users'], _log_filename, csrf_enable=False, debug=True) 314 | pixels = Pixels(configs['default'], "", "", run=False) 315 | logging.debug ("*TEST new user works") 316 | with app.test_client() as test_client: 317 | response = test_client.get('/') 318 | assert response.status_code == 200 319 | assert "guest" in str(response.data) 320 | # Even though guest request login page 321 | response = test_client.get('/login') 322 | assert response.status_code == 200 323 | assert "Please login to access" in str(response.data) 324 | # Perform login using admin user 325 | response = test_client.post("/login", data={ 326 | "username": "newuser1", 327 | "password": "padmin' in str(response.data) 389 | # Add new user stage 1 (no data empty form) 390 | response = test_client.post("/newuser", follow_redirects=True) 391 | assert response.status_code == 200 392 | assert '' in str(response.data) 393 | # stage 2 add "adminuser" 394 | response = test_client.post("/newuser", data={ 395 | "newuser" : "newuser", 396 | "username": "stduser1" 397 | }, follow_redirects=True) 398 | assert response.status_code == 200 399 | assert 'User already exists, please try another username' in str(response.data) 400 | 401 | 402 | 403 | # Adds a user - too short(fail) 404 | def test_add_user_tooshort_1(): 405 | app = create_app(configs['auth'], configs['users'], _log_filename, csrf_enable=False, debug=True) 406 | pixels = Pixels(configs['default'], configs['sha256'], "", run=False) 407 | logging.debug ("*TEST add user too short") 408 | with app.test_client() as test_client: 409 | # Perform login using admin user 410 | response = test_client.post("/login", data={ 411 | "username": "admin", 412 | "password": "pixel1login2", 413 | }, follow_redirects=True) 414 | assert response.status_code == 200 415 | assert '' in str(response.data) 416 | # Add new user stage 1 (no data empty form) 417 | response = test_client.post("/newuser", follow_redirects=True) 418 | assert response.status_code == 200 419 | assert '' in str(response.data) 420 | # stage 2 add "adminuser" 421 | response = test_client.post("/newuser", data={ 422 | "newuser" : "newuser", 423 | "username": "new" 424 | }, follow_redirects=True) 425 | assert response.status_code == 200 426 | assert 'Username must be minimum of 6 characters' in str(response.data) 427 | 428 | 429 | # Adds a user - special character(fail) 430 | def test_add_user_invalidchar_1(): 431 | app = create_app(configs['auth'], configs['users'], _log_filename, csrf_enable=False, debug=True) 432 | pixels = Pixels(configs['default'], configs['sha256'], "", run=False) 433 | logging.debug ("*TEST add user special character") 434 | with app.test_client() as test_client: 435 | # Perform login using admin user 436 | response = test_client.post("/login", data={ 437 | "username": "admin", 438 | "password": "pixel1login2", 439 | }, follow_redirects=True) 440 | assert response.status_code == 200 441 | assert '' in str(response.data) 442 | # Add new user stage 1 (no data empty form) 443 | response = test_client.post("/newuser", follow_redirects=True) 444 | assert response.status_code == 200 445 | assert '' in str(response.data) 446 | # stage 2 add "adminuser" 447 | response = test_client.post("/newuser", data={ 448 | "newuser" : "newuser", 449 | "username": "new:user1" 450 | }, follow_redirects=True) 451 | assert response.status_code == 200 452 | assert 'Username must be letters and digits only' in str(response.data) 453 | 454 | # Adds a user - special character(fail) 455 | def test_add_user_invalidchar_2(): 456 | app = create_app(configs['auth'], configs['users'], _log_filename, csrf_enable=False, debug=True) 457 | pixels = Pixels(configs['default'], configs['sha256'], "", run=False) 458 | logging.debug ("*TEST add user special character") 459 | with app.test_client() as test_client: 460 | # Perform login using admin user 461 | response = test_client.post("/login", data={ 462 | "username": "admin", 463 | "password": "pixel1login2", 464 | }, follow_redirects=True) 465 | assert response.status_code == 200 466 | assert '' in str(response.data) 467 | # Add new user stage 1 (no data empty form) 468 | response = test_client.post("/newuser", follow_redirects=True) 469 | assert response.status_code == 200 470 | assert '' in str(response.data) 471 | # stage 2 add "adminuser" 472 | response = test_client.post("/newuser", data={ 473 | "newuser" : "newuser", 474 | "username": "newuser$" 475 | }, follow_redirects=True) 476 | assert response.status_code == 200 477 | assert 'Username must be letters and digits only' in str(response.data) 478 | 479 | # Adds a user - special character(fail) 480 | def test_add_user_invalidchar_3(): 481 | app = create_app(configs['auth'], configs['users'], _log_filename, csrf_enable=False, debug=True) 482 | pixels = Pixels(configs['default'], configs['sha256'], "", run=False) 483 | logging.debug ("*TEST add user special character") 484 | with app.test_client() as test_client: 485 | # Perform login using admin user 486 | response = test_client.post("/login", data={ 487 | "username": "admin", 488 | "password": "pixel1login2", 489 | }, follow_redirects=True) 490 | assert response.status_code == 200 491 | assert '' in str(response.data) 492 | # Add new user stage 1 (no data empty form) 493 | response = test_client.post("/newuser", follow_redirects=True) 494 | assert response.status_code == 200 495 | assert '' in str(response.data) 496 | # stage 2 add "adminuser" 497 | response = test_client.post("/newuser", data={ 498 | "newuser" : "newuser", 499 | "username": "new\'\'user1" 500 | }, follow_redirects=True) 501 | assert response.status_code == 200 502 | assert 'Username must be letters and digits only' in str(response.data) -------------------------------------------------------------------------------- /tests/functional/test_flask_useradmin_2.py: -------------------------------------------------------------------------------- 1 | from flask import Flask 2 | import pixelserver 3 | from pixelserver import create_app 4 | from pixelserver.pixels import Pixels 5 | import logging 6 | import shutil 7 | import json 8 | 9 | # Note that this is not able to test csrf protection 10 | # use csrf_enable = False in create_app() for any posts 11 | 12 | # For log debugging use debug=True in create_app() 13 | # then using logging.debug 14 | 15 | # Users tmp_path_factory - files will be copied to: 16 | #/tmp/pytest-of-/pytest-current/log?/pixelserver.log 17 | 18 | ## Tests using supplied data from usertests.json 19 | # Allows tests of different user data which should or should not be valid 20 | # For each test set all data, error is the action it should fail on 21 | # errormsg should be what message is received 22 | 23 | _config_src_directory = "tests/configs/" 24 | 25 | # default use _log_filename which uses directory factory 26 | log_filename = "pixelserver.log" 27 | 28 | # JSON file with data to test with 29 | data_file ="tests/data/usertests.json" 30 | 31 | # name of config files - will be mapped to temp directory 32 | # In tests use configs{} instead. 33 | config_filenames = { 34 | 'default' : "defaults_test.cfg", 35 | 'custom' : "pixelserver_test.cfg", 36 | 'sha256' : "sha256_test.cfg", 37 | 'light' : "customlight_test.cfg", 38 | 'auth' : "auth_test.cfg", 39 | 'users' : "users_test.cfg" 40 | } 41 | 42 | 43 | # default config files - created using tmp_dir_setup 44 | configs = {} 45 | 46 | def tmp_dir_setup (tmp_path_factory): 47 | global _log_directory, _log_filename, _config_directory 48 | _log_directory = str(tmp_path_factory.mktemp("log")) 49 | _log_filename = _log_directory + "/" + log_filename 50 | _config_directory = str(tmp_path_factory.mktemp("config")) 51 | # for all filenames copy files into tempdirectory and update configs 52 | for key, value in config_filenames.items(): 53 | configs[key] = _config_directory + "/" + value 54 | # copy existing file to new location 55 | shutil.copyfile(_config_src_directory + value, configs[key]) 56 | 57 | 58 | # Setup path factory and empty user file 59 | def test_setup_factory(tmp_path_factory): 60 | global user_data 61 | tmp_dir_setup(tmp_path_factory) 62 | # load json file 63 | f = open (data_file) 64 | user_data = json.load(f) 65 | f.close() 66 | 67 | 68 | # Creates users using json config file 69 | def test_create_users_1(): 70 | app = create_app(configs['auth'], configs['users'], _log_filename, csrf_enable=False, debug=True) 71 | pixels = Pixels(configs['default'], "", "", run=False) 72 | logging.debug ("*TEST add new user") 73 | with app.test_client() as test_client: 74 | # Even though guest straight to login page 75 | response = test_client.get('/login') 76 | assert response.status_code == 200 77 | assert "Please login to access" in str(response.data) 78 | # Perform login using admin user 79 | response = test_client.post("/login", data={ 80 | "username": "admin", 81 | "password": "pixel1login2", 82 | }, follow_redirects=True) 83 | assert response.status_code == 200 84 | assert '' in str(response.data) 85 | # loop over all new users 86 | for this_user in user_data: 87 | # Add new user stage 1 (no data empty form) 88 | response = test_client.post("/newuser", follow_redirects=True) 89 | assert response.status_code == 200 90 | assert '' in str(response.data) 91 | # stage 2 add "newuser1" 92 | response = test_client.post("/newuser", data={ 93 | "newuser" : "newuser", 94 | "username": this_user['username'] 95 | }, follow_redirects=True) 96 | # if expect fail username 97 | if this_user["error"] == "username": 98 | assert response.status_code == 200 99 | assert this_user["errormsg"] in str(response.data) 100 | continue 101 | else: 102 | assert response.status_code == 200 103 | assert '' in str(response.data) 104 | # Add a password 105 | response = test_client.post("/newuser", data={ 106 | "newuser" : "userpassword", 107 | "username" : this_user['username'], 108 | "password": this_user['password'], 109 | "password2": this_user['password'], 110 | }, follow_redirects=True) 111 | if this_user["error"] == "password": 112 | assert response.status_code == 200 113 | assert this_user["errormsg"] in str(response.data) 114 | continue 115 | else: 116 | assert response.status_code == 200 117 | expect_string = '' 118 | assert expect_string in str(response.data) 119 | 120 | 121 | # Edit users 122 | def test_edit_users_1(): 123 | app = create_app(configs['auth'], configs['users'], _log_filename, csrf_enable=False, debug=True) 124 | pixels = Pixels(configs['default'], "", "", run=False) 125 | logging.debug ("*TEST add new user") 126 | with app.test_client() as test_client: 127 | # Even though guest straight to login page 128 | response = test_client.get('/login') 129 | assert response.status_code == 200 130 | assert "Please login to access" in str(response.data) 131 | # Perform login using admin user 132 | response = test_client.post("/login", data={ 133 | "username": "admin", 134 | "password": "pixel1login2", 135 | }, follow_redirects=True) 136 | assert response.status_code == 200 137 | assert '' in str(response.data) 138 | # loop over all new users 139 | for this_user in user_data: 140 | # skip any with errors that prevent them being created 141 | if this_user['error'] == "username" or this_user['error'] == "password": 142 | continue 143 | # Edit and change values from newuser calls edituser 144 | data_dict = { 145 | "edituser" : "edituser", 146 | "currentusername" : this_user['username'], 147 | "username" : this_user['username'], 148 | "realname" : this_user['realname'], 149 | "email": this_user['email'], 150 | "description": this_user['description'] 151 | } 152 | if (this_user['admin'] == "True"): 153 | data_dict['admin'] = "checked" 154 | response = test_client.post("/edituser", data=data_dict, follow_redirects=True) 155 | if this_user['error'] == "edit": 156 | assert response.status_code == 200 157 | assert this_user['errormsg'] in str(response.data) 158 | continue 159 | assert response.status_code == 200 160 | # Check returns to user table - but not check updates at this point 161 | assert '
    ' in str(response.data) 162 | # Load the edituser and check values set 163 | response = test_client.get('/edituser', query_string={ 164 | "user" : this_user['username'], 165 | "action" : "edit" 166 | }) 167 | assert response.status_code == 200 168 | # check values 169 | eval_string = ''.format(this_user['realname']) 170 | assert eval_string in str(response.data) 171 | if (this_user['admin'] == "True"): 172 | assert '' in str(response.data) 173 | else: 174 | assert '' in str(response.data) 175 | eval_string = ''.format(this_user['email']) 176 | assert eval_string in str(response.data) 177 | eval_string = ''.format(this_user['description']) 178 | assert eval_string in str(response.data) -------------------------------------------------------------------------------- /tests/functional/test_flask_useradmin_3.py: -------------------------------------------------------------------------------- 1 | from flask import Flask 2 | import pixelserver 3 | from pixelserver import create_app 4 | from pixelserver.pixels import Pixels 5 | import logging 6 | import shutil 7 | import json 8 | 9 | # Note that this is not able to test csrf protection 10 | # use csrf_enable = False in create_app() for any posts 11 | 12 | # For log debugging use debug=True in create_app() 13 | # then using logging.debug 14 | 15 | # Users tmp_path_factory - files will be copied to: 16 | #/tmp/pytest-of-/pytest-current/log?/pixelserver.log 17 | 18 | ## Tests using supplied data from usertests.json 19 | # Allows tests of different user data which should or should not be valid 20 | # For each test set all data, error is the action it should fail on 21 | # errormsg should be what message is received 22 | 23 | _config_src_directory = "tests/configs/" 24 | 25 | # default use _log_filename which uses directory factory 26 | log_filename = "pixelserver.log" 27 | 28 | # JSON file with data to test with 29 | data_file ="tests/data/usertests.json" 30 | 31 | # name of config files - will be mapped to temp directory 32 | # In tests use configs{} instead. 33 | config_filenames = { 34 | 'default' : "defaults_test.cfg", 35 | 'custom' : "pixelserver_test.cfg", 36 | 'sha256' : "sha256_test.cfg", 37 | 'light' : "customlight_test.cfg", 38 | 'auth' : "auth_test.cfg", 39 | 'users' : "users_test.cfg" 40 | } 41 | 42 | 43 | # default config files - created using tmp_dir_setup 44 | configs = {} 45 | 46 | def tmp_dir_setup (tmp_path_factory): 47 | global _log_directory, _log_filename, _config_directory 48 | _log_directory = str(tmp_path_factory.mktemp("log")) 49 | _log_filename = _log_directory + "/" + log_filename 50 | _config_directory = str(tmp_path_factory.mktemp("config")) 51 | # for all filenames copy files into tempdirectory and update configs 52 | for key, value in config_filenames.items(): 53 | configs[key] = _config_directory + "/" + value 54 | # copy existing file to new location 55 | shutil.copyfile(_config_src_directory + value, configs[key]) 56 | 57 | 58 | # Setup path factory and empty user file 59 | def test_setup_factory(tmp_path_factory): 60 | global user_data 61 | tmp_dir_setup(tmp_path_factory) 62 | # load json file 63 | f = open (data_file) 64 | user_data = json.load(f) 65 | f.close() 66 | 67 | 68 | # Creates users using json config file - OOS = Out-of-sequence tests 69 | def test_create_user_oos_1(): 70 | app = create_app(configs['auth'], configs['users'], _log_filename, csrf_enable=False, debug=True) 71 | pixels = Pixels(configs['default'], "", "", run=False) 72 | logging.debug ("*TEST add new user") 73 | with app.test_client() as test_client: 74 | # Even though guest straight to login page 75 | response = test_client.get('/login') 76 | assert response.status_code == 200 77 | assert "Please login to access" in str(response.data) 78 | # Perform login using admin user 79 | response = test_client.post("/login", data={ 80 | "username": "admin", 81 | "password": "pixel1login2", 82 | }, follow_redirects=True) 83 | assert response.status_code == 200 84 | assert '' in str(response.data) 85 | # loop over all new users 86 | for this_user in user_data: 87 | # Add new user stage 1 (no data empty form) 88 | response = test_client.post("/newuser", follow_redirects=True) 89 | assert response.status_code == 200 90 | assert '' in str(response.data) 91 | # stage 2 add "newuser1" 92 | response = test_client.post("/newuser", data={ 93 | "newuser" : "newuser", 94 | "username": "alloweduser" 95 | }, follow_redirects=True) 96 | # pass this stage 97 | assert response.status_code == 200 98 | assert '' in str(response.data) 99 | # Trying to add a new user with a username that already exists - so expect fail 100 | # Add a password 101 | response = test_client.post("/newuser", data={ 102 | "newuser" : "userpassword", 103 | "username" : "stduser1", 104 | "password": this_user['password'], 105 | "password2": this_user['password'], 106 | }, follow_redirects=True) 107 | # should give a 200 succes - but then error message 108 | assert response.status_code == 200 109 | expect_string = 'User already exists' 110 | assert expect_string in str(response.data) 111 | 112 | 113 | -------------------------------------------------------------------------------- /tests/unit/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/penguintutor/pixel-server/f7a614e1e631aaac2e33cb64feada097219c5af3/tests/unit/__init__.py -------------------------------------------------------------------------------- /tests/unit/test_serverauth1.py: -------------------------------------------------------------------------------- 1 | from pixelserver.serverauth import ServerAuth 2 | import string 3 | import random 4 | import shutil 5 | 6 | # Tests ServerAuth class 7 | 8 | _config_src_directory = "tests/configs/" 9 | 10 | _test_user_filename = "users_test.cfg" 11 | _test_auth_filename = "auth_test.cfg" 12 | 13 | _user_filename = None 14 | _auth_filename = None 15 | 16 | def tmp_dir_setup (tmp_path_factory): 17 | global _user_filename, _auth_filename 18 | _test_path = str(tmp_path_factory.mktemp("users")) 19 | _user_filename = _test_path + "/" + _test_user_filename 20 | _auth_filename = _test_path + "/" + _test_auth_filename 21 | # copy existing config files 22 | shutil.copyfile(_config_src_directory + _test_user_filename, _user_filename) 23 | shutil.copyfile(_config_src_directory + _test_auth_filename, _auth_filename) 24 | 25 | # Setup path factory and empty user file 26 | def test_setup_factory(tmp_path_factory): 27 | tmp_dir_setup(tmp_path_factory) 28 | 29 | def test_login_user_1(): 30 | server_auth = ServerAuth(_auth_filename, _user_filename) 31 | result = server_auth.login_user ("admin", "pixel1login2", "192.168.2.2") 32 | assert result == True 33 | 34 | def test_login_user_2(): 35 | server_auth = ServerAuth(_auth_filename, _user_filename) 36 | result = server_auth.login_user ("stduser1", "pixel1login2", "192.168.2.2") 37 | assert result == True 38 | 39 | def test_login_user_3(): 40 | server_auth = ServerAuth(_auth_filename, _user_filename) 41 | result = server_auth.login_user ("adminuser1", "pixel1login2", "192.168.2.2") 42 | assert result == True 43 | 44 | def test_login_user_4(): 45 | server_auth = ServerAuth(_auth_filename, _user_filename) 46 | result = server_auth.login_user ("stduser2", "pixel1login2", "192.168.2.2") 47 | assert result == True 48 | 49 | def test_login_user_fail_1(): 50 | server_auth = ServerAuth(_auth_filename, _user_filename) 51 | result = server_auth.login_user ("admin", "pixel1login3", "192.168.2.2") 52 | assert result == False 53 | 54 | def test_login_user_fail_2(): 55 | server_auth = ServerAuth(_auth_filename, _user_filename) 56 | result = server_auth.login_user ("admintest", "pixel1login3", "192.168.2.2") 57 | assert result == False 58 | 59 | def test_login_user_fail_3(): 60 | server_auth = ServerAuth(_auth_filename, _user_filename) 61 | result = server_auth.login_user ("stduser1", "pixel1login2 ", "192.168.2.2") 62 | assert result == False 63 | 64 | def test_login_user_fail_4(): 65 | server_auth = ServerAuth(_auth_filename, _user_filename) 66 | result = server_auth.login_user ("stduser2", "Pixel1login2", "192.168.2.2") 67 | assert result == False 68 | 69 | def test_login_user_fail_5(): 70 | server_auth = ServerAuth(_auth_filename, _user_filename) 71 | result = server_auth.login_user ("stduser2", "pixel1login2:test", "192.168.2.2") 72 | assert result == False 73 | 74 | # Make sure still works for login if no auth.cfg config file 75 | def test_noauthcfg_user_1(): 76 | server_auth = ServerAuth("no_auth_file.cfg", _user_filename) 77 | result = server_auth.login_user ("admin", "pixel1login2", "192.168.2.2") 78 | assert result == True -------------------------------------------------------------------------------- /tests/unit/test_serveruser1.py: -------------------------------------------------------------------------------- 1 | from pixelserver.serveruser import ServerUser 2 | import string 3 | import random 4 | 5 | # Tests ServerUser class 6 | 7 | ## Includes automated password tests - generates different passwords to test user password 8 | # uses lowercase, uppercase, digits and special characters 9 | allowed_chars = list(string.ascii_letters + string.digits + string.punctuation + " ") 10 | num_changes = 20 11 | 12 | 13 | test_user_details = [ 14 | "username1", "$argon2id$v=19$m=102400,t=2,p=8$0ZzMvxnNwvxePMp+y7hpfA$rCupS+lnWHUdEeIVkUHAGQ", 15 | "Real name for username1", 16 | "standard", 17 | "email@dummydomain3874.com", 18 | "Description of username 1" 19 | ] 20 | test_user2_details = [ 21 | "username2", "$5$1948a2047f5743c4a64bde92919d3eec$25670be86447039a5e126fc8acaac5e4ee4bd7805fcb493074ea9877be03088c", 22 | "Real name for username2", 23 | "admin", 24 | "email@dummydomain3874.co.uk", 25 | "Description of username 2" 26 | ] 27 | 28 | # Test creating a new user 29 | # Adds all parameters 30 | def test_create_user1(): 31 | user1 = ServerUser( 32 | "username", 33 | "$argon2id$v=19$m=102400,t=2,p=8$0ZzMvxnNwvxePMp+y7hpfA$rCupS+lnWHUdEeIVkUHAGQ", 34 | "real name", 35 | "standard", 36 | "email@here33982.com", 37 | "Description of new user" 38 | ) 39 | # Test each of the values 40 | assert user1.username == "username" 41 | assert user1.password_hash == "$argon2id$v=19$m=102400,t=2,p=8$0ZzMvxnNwvxePMp+y7hpfA$rCupS+lnWHUdEeIVkUHAGQ" 42 | assert user1.real_name == "real name" 43 | assert user1.user_type == "standard" 44 | assert user1.email == "email@here33982.com" 45 | assert user1.description == "Description of new user" 46 | assert user1.is_admin() == False 47 | 48 | 49 | # Test creating a new user 50 | # Minimum details - admin 51 | # SHA256 password 52 | def test_create_user2(): 53 | user1 = ServerUser( 54 | "user", # username very short - not permitted by serveradmin, but is allowed if created manually (eg. createadmin.py) 55 | "$5$1948a2047f5743c4a64bde92919d3eec$25670be86447039a5e126fc8acaac5e4ee4bd7805fcb493074ea9877be03088c", 56 | "", 57 | "admin", 58 | "", 59 | "" 60 | ) 61 | # Test each of the values 62 | assert user1.username == "user" 63 | assert user1.password_hash == "$5$1948a2047f5743c4a64bde92919d3eec$25670be86447039a5e126fc8acaac5e4ee4bd7805fcb493074ea9877be03088c" 64 | assert user1.real_name == "" 65 | assert user1.user_type == "admin" 66 | assert user1.email == "" 67 | assert user1.description == "" 68 | assert user1.is_admin() == True 69 | 70 | # Test using example user 71 | def test_create_user_3(): 72 | user1 = ServerUser(*test_user_details) 73 | assert user1.username == test_user_details[0] 74 | assert user1.password_hash == test_user_details[1] 75 | assert user1.real_name == test_user_details[2] 76 | assert user1.user_type == test_user_details[3] 77 | assert user1.email == test_user_details[4] 78 | assert user1.description == test_user_details[5] 79 | assert user1.is_admin() == False 80 | 81 | # Create user then change to admin 82 | # Test using example user 83 | def test_set_admin_1(): 84 | user1 = ServerUser(*test_user_details) 85 | assert user1.is_admin() == False 86 | user1.user_type = "admin" 87 | assert user1.username == test_user_details[0] 88 | assert user1.password_hash == test_user_details[1] 89 | assert user1.real_name == test_user_details[2] 90 | assert user1.user_type == "admin" 91 | assert user1.email == test_user_details[4] 92 | assert user1.description == test_user_details[5] 93 | assert user1.is_admin() == True 94 | 95 | # Create user then change to standard user 96 | # Test using example user 97 | def test_set_admin_2(): 98 | user1 = ServerUser(*test_user2_details) 99 | assert user1.is_admin() == True 100 | user1.user_type = "standard" 101 | assert user1.username == test_user2_details[0] 102 | assert user1.password_hash == test_user2_details[1] 103 | assert user1.real_name == test_user2_details[2] 104 | assert user1.user_type == "standard" 105 | assert user1.email == test_user2_details[4] 106 | assert user1.description == test_user2_details[5] 107 | assert user1.is_admin() == False 108 | 109 | # Fail to change to an invalid user type 110 | # should leave as non admin 111 | def test_set_admin_3(): 112 | user1 = ServerUser(*test_user_details) 113 | assert user1.is_admin() == False 114 | user1.user_type = "admin1" 115 | assert user1.username == test_user_details[0] 116 | assert user1.password_hash == test_user_details[1] 117 | assert user1.real_name == test_user_details[2] 118 | assert user1.user_type == "standard" 119 | assert user1.email == test_user_details[4] 120 | assert user1.description == test_user_details[5] 121 | assert user1.is_admin() == False 122 | 123 | 124 | # Create user then change all values to other username 125 | def test_change_values_1(): 126 | user1 = ServerUser(*test_user_details) 127 | user1.username = test_user2_details[0] 128 | user1.password_hash = test_user2_details[1] 129 | user1.real_name = test_user2_details[2] 130 | user1.user_type = test_user2_details[3] 131 | user1.email = test_user2_details[4] 132 | user1.description = test_user2_details[5] 133 | assert user1.username == test_user2_details[0] 134 | assert user1.password_hash == test_user2_details[1] 135 | assert user1.real_name == test_user2_details[2] 136 | assert user1.user_type == test_user2_details[3] 137 | assert user1.email == test_user2_details[4] 138 | assert user1.description == test_user2_details[5] 139 | 140 | # Try invalid entries 141 | # Ensure not changed 142 | def test_change_values_2(): 143 | user1 = ServerUser(*test_user_details) 144 | user1.username = " " 145 | assert user1.username == test_user_details[0] 146 | user1.username = "short" 147 | assert user1.username == test_user_details[0] 148 | user1.username = "user:name1" 149 | assert user1.username == test_user_details[0] 150 | user1.username = "user1234$" 151 | assert user1.username == test_user_details[0] 152 | user1.username = "!user1234" 153 | assert user1.username == test_user_details[0] 154 | user1.username = "user~12345" 155 | assert user1.username == test_user_details[0] 156 | 157 | # Try invalid entries 158 | # Ensure not changed 159 | def test_change_values_3(): 160 | user1 = ServerUser(*test_user_details) 161 | user1.real_name = "Invalid name" 162 | assert user1.real_name == test_user_details[2] 163 | user1.real_name = "Invalid : name" 164 | assert user1.real_name == test_user_details[2] 165 | user1.real_name = "Invalid 3:al name" 166 | assert user1.real_name == test_user_details[2] 167 | 168 | # Try invalid entries 169 | # Ensure not changed 170 | def test_change_values_4(): 171 | user1 = ServerUser(*test_user_details) 172 | user1.email = "Invalid@name.com" 173 | assert user1.email == test_user_details[4] 174 | user1.email = "Invalid:@name.co.uk" 175 | assert user1.email == test_user_details[4] 176 | user1.email = "Invalid 3:al email" 177 | assert user1.email == test_user_details[4] 178 | 179 | 180 | # Try invalid entries 181 | # Ensure not changed 182 | def test_change_values_5(): 183 | user1 = ServerUser(*test_user_details) 184 | user1.description = "Invalid description" 185 | assert user1.description == test_user_details[5] 186 | user1.description = "Invalid : description" 187 | assert user1.description == test_user_details[5] 188 | user1.description = "Invalid 3:al description" 189 | assert user1.description == test_user_details[5] 190 | 191 | 192 | # Automated test - generate passwords 193 | # change the password and then see if it passes 194 | def test_passwords_argon2_1(): 195 | user1 = ServerUser(*test_user_details) 196 | for i in range (num_changes): 197 | # Set password 198 | new_password = _create_valid_password() 199 | # change password 200 | user1.set_password(new_password, algorithm="Argon2") 201 | assert user1.check_password(new_password) 202 | 203 | # Automated test - generate passwords 204 | # change the password and then see if it passes 205 | def test_passwords_sha256_1(): 206 | user1 = ServerUser(*test_user_details) 207 | for i in range (num_changes): 208 | # Set password 209 | new_password = _create_valid_password() 210 | # change password 211 | user1.set_password(new_password, algorithm="SHA256") 212 | assert user1.check_password(new_password) 213 | 214 | # Test empty password 215 | # This class will accept, although flask code won't 216 | def test_passwords_empty_1(): 217 | user1 = ServerUser(*test_user_details) 218 | user1.set_password("") 219 | assert user1.check_password("") 220 | 221 | # does not apply full rules, so simplified valid passwords 222 | def _create_valid_password (max_chars = 20): 223 | # First create totally random string 224 | password = ''.join(random.choice(allowed_chars) for i in range(random.randint(1,max_chars))) 225 | return password -------------------------------------------------------------------------------- /tests/unit/test_serveruseradmin1.py: -------------------------------------------------------------------------------- 1 | from pixelserver.serveruseradmin import ServerUserAdmin 2 | 3 | _test_user_file = "user_test.cfg" 4 | _user_filename = None 5 | 6 | def tmp_dir_setup (tmp_path_factory): 7 | global _user_filename 8 | _user_filename = str(tmp_path_factory.mktemp("users") / _test_user_file) 9 | 10 | # Setup path factory and empty user file 11 | def test_setup_factory(tmp_path_factory): 12 | tmp_dir_setup(tmp_path_factory) 13 | # Create an empty file 14 | open(_user_filename, 'a').close() 15 | 16 | # Test creating a new user 17 | def test_add_user1(): 18 | user_admin = ServerUserAdmin(_user_filename) 19 | result = user_admin.add_user( 20 | "test1", "password", 21 | "Test User 1", 22 | "admin", "me@here.com", 23 | "") 24 | assert result == "success" 25 | assert user_admin.user_exists("test1") 26 | 27 | def test_add_another_user(): 28 | user_admin = ServerUserAdmin(_user_filename) 29 | result = user_admin.add_user( 30 | "longerusername", "password123", 31 | "Person Real Name", 32 | "normal", "", 33 | "") 34 | assert result == "success" 35 | assert user_admin.user_exists("longerusername") 36 | 37 | # password includes : 38 | def test_add_user_2(): 39 | user_admin = ServerUserAdmin(_user_filename) 40 | user_admin.add_user( 41 | "test2", "password123!:4", 42 | "", 43 | "normal", "email@somewhere.com", 44 | "User has no real name") 45 | assert user_admin.user_exists("longerusername") 46 | 47 | # Check if users added previously still exists 48 | def test_persistant_users(): 49 | user_admin = ServerUserAdmin(_user_filename) 50 | assert user_admin.user_exists("test1") 51 | assert user_admin.user_exists("longerusername") 52 | 53 | # Check user doesn't exist 54 | def test_invalid_user(): 55 | user_admin = ServerUserAdmin(_user_filename) 56 | assert not user_admin.user_exists("invalid1") 57 | 58 | # Test passwords 59 | def test_user_password(): 60 | user_admin = ServerUserAdmin(_user_filename) 61 | assert user_admin.check_username_password ("longerusername", 62 | "password123") 63 | 64 | def test_incorrect_password(): 65 | user_admin = ServerUserAdmin(_user_filename) 66 | assert not user_admin.check_username_password ("longerusername", 67 | "passw0rd123") 68 | 69 | # Test permissions 70 | def test_user1_admin(): 71 | user_admin = ServerUserAdmin(_user_filename) 72 | assert user_admin.check_admin ("test1") 73 | 74 | def test_user2_not_admin(): 75 | user_admin = ServerUserAdmin(_user_filename) 76 | assert not user_admin.check_admin ("test2") 77 | 78 | # Test handling of invalid username (includes a :) 79 | def test_username_colon(): 80 | user_admin = ServerUserAdmin(_user_filename) 81 | result = user_admin.add_user( 82 | "user:name", "djkD38dhJ!", 83 | "Username with colon", 84 | "normal", "email@test.com", 85 | "This should fail") 86 | assert result != "success" 87 | assert not user_admin.user_exists("user:name") 88 | 89 | # Test allows password with : (as converted to hash) 90 | def test_password_colon(): 91 | user_admin = ServerUserAdmin(_user_filename) 92 | result = user_admin.add_user( 93 | "user_pass_colon", "djdd:8dhJ!", 94 | "Password with colon", 95 | "normal", "email@test.com", 96 | "This should pass as colon allowed in password") 97 | assert result == "success" 98 | 99 | # Test duplicate is rejected 100 | def test_duplicate_user(): 101 | user_admin = ServerUserAdmin(_user_filename) 102 | result = user_admin.add_user( 103 | "test1", "djddhJ!", 104 | "Duplicate user", 105 | "normal", "email@test.com", 106 | "This should not be a success") 107 | assert result == "duplicate" 108 | 109 | 110 | # Test successful change of username 111 | def test_username_change(): 112 | user_admin = ServerUserAdmin(_user_filename) 113 | result = user_admin.add_user( 114 | "oldusername", "pa'#swo!d123", 115 | "My name", 116 | "normal", "", 117 | "") 118 | assert result == "success" 119 | assert user_admin.user_exists("oldusername") 120 | # change username here 121 | result = user_admin.update_user("oldusername", {"username":"newusername"}) 122 | assert result == True 123 | assert not user_admin.user_exists("oldusername") 124 | assert user_admin.user_exists("newusername") 125 | # Also check full name to make sure it's the original entry 126 | assert user_admin.get_real_name("newusername") == "My name" 127 | 128 | # Test changing username after creation - doesn't allow colon 129 | def test_username_change_colon(): 130 | user_admin = ServerUserAdmin(_user_filename) 131 | result = user_admin.add_user( 132 | "olduser01", ":a'#s:o!d123", 133 | "My name", 134 | "normal", "", 135 | "") 136 | assert result == "success" 137 | assert user_admin.user_exists("olduser01") 138 | # change username here 139 | result = user_admin.update_user("oldusername", {"username":"new:username"}) 140 | assert result == False 141 | assert user_admin.user_exists("olduser01") 142 | assert not user_admin.user_exists("new:username") 143 | # Also check full name to make sure it's the original entry 144 | assert user_admin.get_real_name("olduser01") == "My name" --------------------------------------------------------------------------------