├── .devcontainer ├── Dockerfile ├── devcontainer.json └── docker-compose.yml ├── .flake8 ├── .github ├── release-drafter.yml └── workflows │ ├── build-docker-edge.yml │ ├── build-docker-manual.yml │ ├── build-docker-release.yml │ ├── draft.yml │ └── run-tests.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Dockerfile ├── GUIDE.md ├── LICENSE ├── README.md ├── TEMPLATE.env ├── app.json ├── assets ├── GeoLite2-ASN_20191224.tar.gz ├── GeoLite2-City_20191224.tar.gz └── README.md ├── docker-compose.yml ├── heroku.yml ├── images ├── homepage.png ├── logo.png ├── service.png └── slogo.png ├── kubernetes ├── deployments.yml └── secrets_template.yml ├── nginx.conf ├── package-lock.json ├── package.json ├── poetry.lock ├── pyproject.toml ├── shynet ├── a17t │ ├── __init__.py │ ├── apps.py │ ├── locale │ │ ├── de │ │ │ └── LC_MESSAGES │ │ │ │ └── django.po │ │ └── zh_TW │ │ │ └── LC_MESSAGES │ │ │ └── django.po │ ├── templates │ │ └── a17t │ │ │ └── includes │ │ │ ├── field.html │ │ │ ├── form.html │ │ │ ├── formset.html │ │ │ ├── head.html │ │ │ ├── label.html │ │ │ └── pagination.html │ └── templatetags │ │ ├── __init__.py │ │ ├── a17t_tags.py │ │ └── pagination.py ├── analytics │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── ingress_urls.py │ ├── locale │ │ ├── de │ │ │ └── LC_MESSAGES │ │ │ │ └── django.po │ │ └── zh_TW │ │ │ └── LC_MESSAGES │ │ │ └── django.po │ ├── migrations │ │ ├── 0001_initial.py │ │ ├── 0002_auto_20200415_1742.py │ │ ├── 0003_auto_20200502_1227.py │ │ ├── 0004_auto_20210328_1514.py │ │ ├── 0005_auto_20210328_1518.py │ │ ├── 0006_hit_service.py │ │ ├── 0007_auto_20210328_1634.py │ │ ├── 0008_session_is_bounce.py │ │ ├── 0009_auto_20210329_1100.py │ │ ├── 0010_auto_20220624_0744.py │ │ └── __init__.py │ ├── models.py │ ├── tasks.py │ ├── templates │ │ └── analytics │ │ │ └── scripts │ │ │ └── page.js │ └── views │ │ ├── __init__.py │ │ └── ingress.py ├── api │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── migrations │ │ └── __init__.py │ ├── mixins.py │ ├── models.py │ ├── tests │ │ ├── __init__.py │ │ ├── test_mixins.py │ │ └── test_views.py │ ├── urls.py │ └── views.py ├── celeryworker.sh ├── core │ ├── __init__.py │ ├── admin.py │ ├── apps.py │ ├── factories.py │ ├── locale │ │ ├── de │ │ │ └── LC_MESSAGES │ │ │ │ └── django.po │ │ └── zh_TW │ │ │ └── LC_MESSAGES │ │ │ └── django.po │ ├── management │ │ └── commands │ │ │ ├── __init__.py │ │ │ ├── demo.py │ │ │ ├── registeradmin.py │ │ │ ├── startup_checks.py │ │ │ └── whitelabel.py │ ├── migrations │ │ ├── 0001_initial.py │ │ ├── 0002_auto_20200415_1742.py │ │ ├── 0003_service_respect_dnt.py │ │ ├── 0004_service_collect_ips.py │ │ ├── 0005_service_ignored_ips.py │ │ ├── 0006_service_hide_referrer_regex.py │ │ ├── 0007_service_ignore_robots.py │ │ ├── 0008_auto_20200628_1403.py │ │ ├── 0009_auto_20211117_0217.py │ │ ├── 0010_auto_20220624_0744.py │ │ └── __init__.py │ ├── models.py │ ├── rules.py │ ├── tests │ │ └── __init__.py │ ├── urls.py │ ├── utils.py │ └── views.py ├── dashboard │ ├── __init__.py │ ├── apps.py │ ├── forms.py │ ├── locale │ │ ├── de │ │ │ └── LC_MESSAGES │ │ │ │ └── django.po │ │ └── zh_TW │ │ │ └── LC_MESSAGES │ │ │ └── django.po │ ├── mixins.py │ ├── static │ │ └── dashboard │ │ │ ├── css │ │ │ └── global.css │ │ │ └── images │ │ │ └── icon.png │ ├── tasks.py │ ├── templates │ │ ├── account │ │ │ ├── account_inactive.html │ │ │ ├── base.html │ │ │ ├── email.html │ │ │ ├── email │ │ │ │ ├── email_confirmation_message.txt │ │ │ │ ├── email_confirmation_signup_message.txt │ │ │ │ ├── email_confirmation_signup_subject.txt │ │ │ │ ├── email_confirmation_subject.txt │ │ │ │ ├── password_reset_key_message.txt │ │ │ │ └── password_reset_key_subject.txt │ │ │ ├── email_confirm.html │ │ │ ├── login.html │ │ │ ├── logout.html │ │ │ ├── messages │ │ │ │ ├── cannot_delete_primary_email.txt │ │ │ │ ├── email_confirmation_sent.txt │ │ │ │ ├── email_confirmed.txt │ │ │ │ ├── email_deleted.txt │ │ │ │ ├── logged_in.txt │ │ │ │ ├── logged_out.txt │ │ │ │ ├── password_changed.txt │ │ │ │ ├── password_set.txt │ │ │ │ ├── primary_email_set.txt │ │ │ │ └── unverified_primary_email.txt │ │ │ ├── password_change.html │ │ │ ├── password_reset.html │ │ │ ├── password_reset_done.html │ │ │ ├── password_reset_from_key.html │ │ │ ├── password_reset_from_key_done.html │ │ │ ├── password_set.html │ │ │ ├── signup.html │ │ │ ├── signup_closed.html │ │ │ ├── snippets │ │ │ │ └── already_logged_in.html │ │ │ ├── verification_sent.html │ │ │ └── verified_email_required.html │ │ ├── base.html │ │ └── dashboard │ │ │ ├── includes │ │ │ ├── bar.html │ │ │ ├── date_range.html │ │ │ ├── map_chart.html │ │ │ ├── service_form.html │ │ │ ├── service_overview.html │ │ │ ├── service_snippet.html │ │ │ ├── session_list.html │ │ │ ├── sidebar_footer.html │ │ │ ├── sidebar_portal.html │ │ │ ├── stat_comparison.html │ │ │ ├── stats_status_chip.html │ │ │ └── time_chart.html │ │ │ ├── pages │ │ │ ├── dashboard.html │ │ │ ├── index.html │ │ │ ├── service.html │ │ │ ├── service_create.html │ │ │ ├── service_delete.html │ │ │ ├── service_location_list.html │ │ │ ├── service_session.html │ │ │ ├── service_session_list.html │ │ │ └── service_update.html │ │ │ └── service_base.html │ ├── templatetags │ │ ├── __init__.py │ │ └── helpers.py │ ├── tests │ │ ├── __init__.py │ │ └── tests_dashboard_views.py │ ├── urls.py │ └── views.py ├── entrypoint.sh ├── manage.py ├── shynet │ ├── __init__.py │ ├── celery.py │ ├── settings.py │ ├── urls.py │ └── wsgi.py ├── ssl.webserver.sh ├── startup_checks.sh └── webserver.sh └── tests ├── js.html └── pixel.html /.devcontainer/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM mcr.microsoft.com/devcontainers/python:1-3.11-bullseye 2 | 3 | ENV PYTHONUNBUFFERED 1 4 | 5 | # [Optional] If your requirements rarely change, uncomment this section to add them to the image. 6 | # COPY requirements.txt /tmp/pip-tmp/ 7 | # RUN pip3 --disable-pip-version-check --no-cache-dir install -r /tmp/pip-tmp/requirements.txt \ 8 | # && rm -rf /tmp/pip-tmp 9 | 10 | # [Optional] Uncomment this section to install additional OS packages. 11 | # RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ 12 | # && apt-get -y install --no-install-recommends 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the 2 | // README at: https://github.com/devcontainers/templates/tree/main/src/postgres 3 | { 4 | "name": "Python 3 & PostgreSQL", 5 | "dockerComposeFile": "docker-compose.yml", 6 | "service": "app", 7 | "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", 8 | "features": { 9 | "ghcr.io/devcontainers/features/docker-outside-of-docker:1": {}, 10 | "ghcr.io/devcontainers/features/node:1": {}, 11 | "ghcr.io/devcontainers-contrib/features/poetry:2": {} 12 | } 13 | 14 | // Features to add to the dev container. More info: https://containers.dev/features. 15 | // "features": {}, 16 | 17 | // Use 'forwardPorts' to make a list of ports inside the container available locally. 18 | // This can be used to network with other containers or the host. 19 | // "forwardPorts": [5000, 5432], 20 | 21 | // Use 'postCreateCommand' to run commands after the container is created. 22 | // "postCreateCommand": "pip install --user -r requirements.txt", 23 | 24 | // Configure tool-specific properties. 25 | // "customizations": {}, 26 | 27 | // Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root. 28 | // "remoteUser": "root" 29 | } 30 | -------------------------------------------------------------------------------- /.devcontainer/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | app: 5 | build: 6 | context: .. 7 | dockerfile: .devcontainer/Dockerfile 8 | 9 | volumes: 10 | - ../..:/workspaces:cached 11 | 12 | # Overrides default command so things don't shut down after the process ends. 13 | command: sleep infinity 14 | 15 | # Runs app on the same network as the database container, allows "forwardPorts" in devcontainer.json function. 16 | network_mode: service:db 17 | 18 | # Use "forwardPorts" in **devcontainer.json** to forward an app port locally. 19 | # (Adding the "ports" property to this file will not forward from a Codespace.) 20 | 21 | db: 22 | image: postgres:latest 23 | restart: unless-stopped 24 | volumes: 25 | - postgres-data:/var/lib/postgresql/data 26 | environment: 27 | POSTGRES_USER: postgres 28 | POSTGRES_DB: postgres 29 | POSTGRES_PASSWORD: postgres 30 | 31 | # Add "forwardPorts": ["5432"] to **devcontainer.json** to forward PostgreSQL locally. 32 | # (Adding the "ports" property to this file will not forward from a Codespace.) 33 | 34 | volumes: 35 | postgres-data: 36 | -------------------------------------------------------------------------------- /.flake8: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 88 3 | -------------------------------------------------------------------------------- /.github/release-drafter.yml: -------------------------------------------------------------------------------- 1 | template: | 2 | ## What’s Changed 3 | 4 | $CHANGES -------------------------------------------------------------------------------- /.github/workflows/build-docker-edge.yml: -------------------------------------------------------------------------------- 1 | name: Build edge Docker images 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | publish_to_docker_hub: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Create Docker Metadata 13 | id: metadata 14 | uses: docker/metadata-action@v5 15 | with: 16 | images: | 17 | milesmcc/shynet 18 | ghcr.io/milesmcc/shynet 19 | tags: 20 | type=edge 21 | 22 | - name: Set swap space 23 | uses: pierotofy/set-swap-space@master 24 | with: 25 | swap-size-gb: 5 26 | 27 | - name: Checkout code 28 | uses: actions/checkout@v4 29 | 30 | - name: Set up QEMU 31 | uses: docker/setup-qemu-action@v3 32 | 33 | - name: Set up Docker Buildx 34 | uses: docker/setup-buildx-action@v3 35 | 36 | - name: Login to DockerHub 37 | uses: docker/login-action@v3 38 | with: 39 | username: ${{ secrets.DOCKER_HUB_USERNAME }} 40 | password: ${{ secrets.DOCKER_HUB_TOKEN }} 41 | 42 | - name: Login to GitHub Container Registry 43 | uses: docker/login-action@v3 44 | with: 45 | registry: ghcr.io 46 | username: ${{ github.repository_owner }} 47 | password: ${{ secrets.GITHUB_TOKEN }} 48 | 49 | - name: Build and push advanced image 50 | id: docker_build 51 | uses: docker/build-push-action@v5 52 | with: 53 | context: . 54 | file: ./Dockerfile 55 | platforms: linux/amd64,linux/arm64 56 | push: true 57 | tags: ${{ steps.metadata.outputs.tags }} 58 | labels: ${{ steps.metadata.outputs.labels }} 59 | -------------------------------------------------------------------------------- /.github/workflows/build-docker-manual.yml: -------------------------------------------------------------------------------- 1 | name: Build manual Docker images 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | tag: 7 | description: 'Docker image tag' 8 | jobs: 9 | publish_to_docker_hub: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Create Docker Metadata 13 | id: metadata 14 | uses: docker/metadata-action@v5 15 | with: 16 | images: | 17 | milesmcc/shynet 18 | ghcr.io/milesmcc/shynet 19 | tags: 20 | type=raw,value=${{ github.event.inputs.tag }} 21 | 22 | - name: Set swap space 23 | uses: pierotofy/set-swap-space@master 24 | with: 25 | swap-size-gb: 5 26 | 27 | - name: Checkout code 28 | uses: actions/checkout@v4 29 | 30 | - name: Set up QEMU 31 | uses: docker/setup-qemu-action@v3 32 | 33 | - name: Set up Docker Buildx 34 | uses: docker/setup-buildx-action@v3 35 | 36 | - name: Login to DockerHub 37 | uses: docker/login-action@v3 38 | with: 39 | username: ${{ secrets.DOCKER_HUB_USERNAME }} 40 | password: ${{ secrets.DOCKER_HUB_TOKEN }} 41 | 42 | - name: Login to GitHub Container Registry 43 | uses: docker/login-action@v3 44 | with: 45 | registry: ghcr.io 46 | username: ${{ github.repository_owner }} 47 | password: ${{ secrets.GITHUB_TOKEN }} 48 | 49 | - name: Build and push advanced image 50 | id: docker_build 51 | uses: docker/build-push-action@v5 52 | with: 53 | context: . 54 | file: ./Dockerfile 55 | platforms: linux/amd64,linux/arm64 56 | push: true 57 | tags: ${{ steps.metadata.outputs.tags }} 58 | labels: ${{ steps.metadata.outputs.labels }} 59 | -------------------------------------------------------------------------------- /.github/workflows/build-docker-release.yml: -------------------------------------------------------------------------------- 1 | name: Build release Docker images 2 | 3 | on: 4 | push: 5 | tags: 6 | - "*" 7 | 8 | jobs: 9 | publish_to_docker_hub: 10 | runs-on: ubuntu-latest 11 | steps: 12 | # https://github.com/docker/metadata-action/tree/v4/#typeref 13 | - name: Create Docker Metadata 14 | id: metadata 15 | uses: docker/metadata-action@v5 16 | with: 17 | images: | 18 | milesmcc/shynet 19 | ghcr.io/milesmcc/shynet 20 | tags: 21 | type=raw,value=latest 22 | type=ref,event=tag 23 | 24 | - name: Set swap space 25 | uses: pierotofy/set-swap-space@master 26 | with: 27 | swap-size-gb: 5 28 | 29 | - name: Checkout code 30 | uses: actions/checkout@v4 31 | 32 | - name: Set up QEMU 33 | uses: docker/setup-qemu-action@v3 34 | 35 | - name: Set up Docker Buildx 36 | uses: docker/setup-buildx-action@v3 37 | 38 | - name: Login to DockerHub 39 | uses: docker/login-action@v3 40 | with: 41 | username: ${{ secrets.DOCKER_HUB_USERNAME }} 42 | password: ${{ secrets.DOCKER_HUB_TOKEN }} 43 | 44 | - name: Login to GitHub Container Registry 45 | uses: docker/login-action@v3 46 | with: 47 | registry: ghcr.io 48 | username: ${{ github.repository_owner }} 49 | password: ${{ secrets.GITHUB_TOKEN }} 50 | 51 | - name: Build and push advanced image 52 | id: docker_build 53 | uses: docker/build-push-action@v5 54 | with: 55 | context: . 56 | file: ./Dockerfile 57 | platforms: linux/amd64,linux/arm64 58 | push: true 59 | tags: ${{ steps.metadata.outputs.tags }} 60 | labels: ${{ steps.metadata.outputs.labels }} -------------------------------------------------------------------------------- /.github/workflows/draft.yml: -------------------------------------------------------------------------------- 1 | name: Release Drafter 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | jobs: 9 | update_release_draft: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: release-drafter/release-drafter@v5.11.0 13 | env: 14 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.github/workflows/run-tests.yml: -------------------------------------------------------------------------------- 1 | name: Run tests 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | services: 9 | db: 10 | image: postgres:12.3-alpine 11 | env: 12 | POSTGRES_USER: shynet_db_user 13 | POSTGRES_PASSWORD: shynet_db_user_password 14 | POSTGRES_DB: shynet_db 15 | ports: 16 | - 5432:5432 17 | strategy: 18 | max-parallel: 4 19 | matrix: 20 | python-version: [3.9] 21 | 22 | steps: 23 | - uses: actions/checkout@v2 24 | - uses: actions/setup-python@v2 25 | with: 26 | python-version: ${{ matrix.python-version }} 27 | - name: Run image 28 | uses: abatilo/actions-poetry@v2.0.0 29 | with: 30 | poetry-version: 1.2.2 31 | - name: Preinstall dependencies (temporary) 32 | run: poetry run pip install "Cython<3.0" "pyyaml==5.4.1" "django-allauth==0.45.0" --no-build-isolation 33 | - name: Install dependencies 34 | run: poetry install 35 | - name: Django Testing project 36 | run: | 37 | cp TEMPLATE.env .env 38 | poetry run ./shynet/manage.py test 39 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # JavaScript packages 7 | node_modules/ 8 | 9 | # C extensions 10 | *.so 11 | 12 | # Distribution / packaging 13 | .Python 14 | build/ 15 | develop-eggs/ 16 | dist/ 17 | downloads/ 18 | eggs/ 19 | .eggs/ 20 | lib/ 21 | lib64/ 22 | parts/ 23 | sdist/ 24 | var/ 25 | wheels/ 26 | pip-wheel-metadata/ 27 | share/python-wheels/ 28 | *.egg-info/ 29 | .installed.cfg 30 | *.egg 31 | MANIFEST 32 | 33 | # PyInstaller 34 | # Usually these files are written by a python script from a template 35 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 36 | *.manifest 37 | *.spec 38 | 39 | # Installer logs 40 | pip-log.txt 41 | pip-delete-this-directory.txt 42 | 43 | # Unit test / coverage reports 44 | htmlcov/ 45 | .tox/ 46 | .nox/ 47 | .coverage 48 | .coverage.* 49 | .cache 50 | nosetests.xml 51 | coverage.xml 52 | *.cover 53 | *.py,cover 54 | .hypothesis/ 55 | .pytest_cache/ 56 | 57 | # Translations 58 | *.mo 59 | *.pot 60 | 61 | # Django stuff: 62 | *.log 63 | local_settings.py 64 | db.sqlite3 65 | db.sqlite3-journal 66 | 67 | # Flask stuff: 68 | instance/ 69 | .webassets-cache 70 | 71 | # Scrapy stuff: 72 | .scrapy 73 | 74 | # Sphinx documentation 75 | docs/_build/ 76 | 77 | # PyBuilder 78 | target/ 79 | 80 | # Jupyter Notebook 81 | .ipynb_checkpoints 82 | 83 | # IPython 84 | profile_default/ 85 | ipython_config.py 86 | 87 | # pyenv 88 | .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 98 | __pypackages__/ 99 | 100 | # Celery stuff 101 | celerybeat-schedule 102 | celerybeat.pid 103 | 104 | # SageMath parsed files 105 | *.sage.py 106 | 107 | # Environments 108 | .env 109 | .venv 110 | env/ 111 | venv/ 112 | ENV/ 113 | env.bak/ 114 | venv.bak/ 115 | Vagrantfile 116 | .vagrant 117 | ubuntu-xenial-16.04-cloudimg-console.log 118 | 119 | # Spyder project settings 120 | .spyderproject 121 | .spyproject 122 | 123 | # Rope project settings 124 | .ropeproject 125 | 126 | # mkdocs documentation 127 | /site 128 | 129 | # mypy 130 | .mypy_cache/ 131 | .dmypy.json 132 | dmypy.json 133 | 134 | # Pyre type checker 135 | .pyre/ 136 | 137 | # Secrets & env 138 | secrets.yml 139 | .vscode 140 | .DS_Store 141 | compiledstatic/ 142 | 143 | # Pycharm 144 | .idea 145 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | repos: 4 | #- repo: https://github.com/pre-commit/pre-commit-hooks 5 | #rev: v3.2.0 6 | #hooks: 7 | #- id: trailing-whitespace 8 | #- id: end-of-file-fixer 9 | #- id: check-yaml 10 | #- id: check-added-large-files 11 | 12 | - repo: https://github.com/psf/black 13 | rev: 22.8.0 14 | hooks: 15 | - id: black 16 | exclude: 'migrations|^shynet/shynet/settings.py' 17 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, sex characteristics, gender identity and expression, 9 | level of experience, education, socio-economic status, nationality, personal 10 | appearance, race, religion, or sexual identity and orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at shynet@sendmiles.email. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html 72 | 73 | [homepage]: https://www.contributor-covenant.org 74 | 75 | For answers to common questions about this code of conduct, see 76 | https://www.contributor-covenant.org/faq 77 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | This document provides an overview of how to contribute to Shynet. Currently, it focuses on the more technical elements of contributing --- for example, setting up your development environment. Eventually, we will expand this guide to cover the social and governance oriented side of contributing as well. 4 | 5 | ## Setting up your development environment 6 | 7 | To contribute to Shynet, you must have a reliable development environment. Because Shynet is intended to be run inside containers, we strongly encourage you to run Shynet in a container in development as well. The development setup described in this guide will use Docker and Docker Compose. 8 | 9 | To begin, clone the Shynet repository to your computer, and ensure that you have Docker and Docker Compose installed. 10 | 11 | Copy `TEMPLATE.env` to a new file called `.env`. This `.env` file will be used in your development environment. Paste `DEBUG=True` into the end of your new `.env` file so that Shynet will know to run in development mode. 12 | 13 | Finally, follow the steps in [GUIDE.md](GUIDE.md) on setting up a Shynet instance with Docker Compose. This is where you'll setup an admin user. 14 | 15 | _Did you have to perform additional steps to setup your environment? Document them here and submit a pull request!_ -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:alpine3.14 2 | 3 | # Getting things ready 4 | WORKDIR /usr/src/shynet 5 | 6 | # Install dependencies & configure machine 7 | ARG GF_UID="500" 8 | ARG GF_GID="500" 9 | RUN apk update && \ 10 | apk add --no-cache gettext bash npm postgresql-libs && \ 11 | test "$(arch)" != "x86_64" && apk add libffi-dev rust cargo || echo "amd64 build, skipping Rust installation" 12 | # libffi-dev and rust are used for the cryptography package, 13 | # which we indirectly rely on. Necessary for aarch64 support. 14 | 15 | # MaxMind scans GitHub for exposed license keys and deactivates them. This 16 | # (encoded) license key is intened to be public; it is not configured with any 17 | # billing, and can only access MaxMind's public databases. These databases used 18 | # to be available for download without authentication, but they are now auth 19 | # gated. It is very important that the Shynet community have a simple, 20 | # easily-pullable Docker image with all "batteries included." As a result, we 21 | # intentionally "expose" this API key to the community. The "fix" is for MaxMind 22 | # to offer these free, public datasets in a way that doesn't require an API key. 23 | ARG MAXMIND_LICENSE_KEY_BASE64="Z2tySDgwX1htSEtmS3d4cDB1SnlMWTdmZ1hMMTQxNzRTQ2o5X21taw==" 24 | 25 | RUN echo $MAXMIND_LICENSE_KEY_BASE64 > .mmdb_key 26 | 27 | # Collect GeoIP Database 28 | COPY assets/GeoLite2-ASN_20191224.tar.gz GeoLite2-ASN.tar.gz 29 | COPY assets/GeoLite2-City_20191224.tar.gz GeoLite2-City.tar.gz 30 | RUN apk add --no-cache curl && \ 31 | tar -xvz -C /tmp < GeoLite2-ASN.tar.gz && \ 32 | tar -xvz -C /tmp < GeoLite2-City.tar.gz && \ 33 | mv /tmp/GeoLite2*/*.mmdb /etc && \ 34 | rm GeoLite2-ASN.tar.gz GeoLite2-City.tar.gz && \ 35 | apk --purge del curl 36 | 37 | # Move dependency files 38 | COPY poetry.lock pyproject.toml ./ 39 | COPY package.json package-lock.json ../ 40 | # Django expects node_modules to be in its parent directory. 41 | 42 | # Install more dependencies and cleanup build dependencies afterwards 43 | RUN apk add --no-cache --virtual .build-deps gcc musl-dev postgresql-dev libressl-dev libffi-dev && \ 44 | npm i -P --prefix .. && \ 45 | pip install poetry==1.2.2 && \ 46 | poetry config virtualenvs.create false && \ 47 | poetry run pip install "Cython<3.0" "pyyaml==5.4.1" "django-allauth==0.45.0" --no-build-isolation && \ 48 | poetry install --no-dev --no-interaction --no-ansi && \ 49 | apk --purge del .build-deps 50 | 51 | # Setup user group 52 | RUN addgroup --system -g $GF_GID appgroup && \ 53 | adduser appuser --system --uid $GF_UID -G appgroup && \ 54 | mkdir -p /var/local/shynet/db/ && \ 55 | chown -R appuser:appgroup /var/local/shynet 56 | 57 | # Install Shynet 58 | COPY shynet . 59 | RUN python manage.py collectstatic --noinput && \ 60 | python manage.py compilemessages 61 | 62 | # Launch 63 | USER appuser 64 | EXPOSE 8080 65 | HEALTHCHECK CMD bash -c 'wget -o /dev/null -O /dev/null --header "Host: ${ALLOWED_HOSTS%%,*}" "http://127.0.0.1:${PORT:-8080}/healthz/?format=json"' 66 | CMD [ "./entrypoint.sh" ] 67 | -------------------------------------------------------------------------------- /TEMPLATE.env: -------------------------------------------------------------------------------- 1 | # This file shows all of the environment variables you can 2 | # set to configure Shynet, as well as information about their 3 | # effects. Make a copy of this file to configure your deployment. 4 | 5 | # Database settings (PostgreSQL) 6 | DB_NAME=shynet_db 7 | DB_USER=shynet_db_user 8 | DB_PASSWORD=shynet_db_user_password 9 | DB_HOST=db 10 | DB_PORT=5432 11 | 12 | # Database settings (SQLite) - comment PostgreSQL settings 13 | # SQLITE=True 14 | # DB_NAME=/var/local/shynet/db/db.sqlite3 15 | 16 | # Email settings (optional) 17 | EMAIL_HOST_USER=example 18 | EMAIL_HOST_PASSWORD=example_password 19 | EMAIL_HOST=smtp.example.com 20 | EMAIL_PORT=465 21 | EMAIL_USE_SSL=True 22 | # Comment out EMAIL_USE_SSL & uncomment EMAIL_USE_TLS if your SMTP server uses TLS. 23 | # EMAIL_USE_TLS=True 24 | SERVER_EMAIL=Shynet 25 | 26 | # General Django settings - to generate run: python3 -c "import secrets; print(secrets.token_urlsafe())" 27 | DJANGO_SECRET_KEY=random_string 28 | 29 | # Set these to your deployment's domain. Both are comma separated, but CSRF_TRUSTED_ORIGINS also requires a scheme (e.g., `https://`). 30 | ALLOWED_HOSTS=example.com 31 | CSRF_TRUSTED_ORIGINS=https://example.com 32 | 33 | # Localization 34 | # https://docs.djangoproject.com/en/2.2/topics/i18n/ 35 | LANGUAGE_CODE=en-us 36 | 37 | # Set to True (capitalized) if you want people to be able to sign up for your Shynet instance (not recommended) 38 | ACCOUNT_SIGNUPS_ENABLED=False 39 | 40 | # Should user email addresses be verified? Only set this to `required` if you've setup the email settings and allow 41 | # public sign-ups; otherwise, it's unnecessary. 42 | ACCOUNT_EMAIL_VERIFICATION=none 43 | 44 | # The timezone of the admin panel. Affects how dates are displayed. 45 | # This must match a value from the IANA's tz database. 46 | # Wikipedia has a list of valid strings: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones 47 | TIME_ZONE=America/New_York 48 | 49 | # Set to "False" if you will not be serving content over HTTPS 50 | SCRIPT_USE_HTTPS=True 51 | 52 | # How frequently should the monitoring script "phone home" (in ms)? 53 | SCRIPT_HEARTBEAT_FREQUENCY=5000 54 | 55 | # How much time can elapse between requests from the same user before a new 56 | # session is created, in seconds? 57 | SESSION_MEMORY_TIMEOUT=1800 58 | 59 | # Should only superusers (admins) be able to create services? This is helpful 60 | # when you'd like to invite others to your Shynet instance but don't want 61 | # them to be able to create services of their own. 62 | ONLY_SUPERUSERS_CREATE=True 63 | 64 | # Whether to perform checks and setup at startup, including applying unapplied 65 | # migrations. For most setups, the recommended value is True. Defaults to True. 66 | # Will skip only if value is False. 67 | PERFORM_CHECKS_AND_SETUP=True 68 | 69 | # The port that Shynet should bind to. Don't set this if you're deploying on Heroku. 70 | PORT=8080 71 | 72 | # Set to "False" if you do not want the version to be displayed on the frontend. 73 | SHOW_SHYNET_VERSION=True 74 | 75 | # Redis, queue, and parellization settings; not necessary for single-instance deployments. 76 | # Don't uncomment these unless you know what you are doing! 77 | # NUM_WORKERS=1 78 | # Make sure you set a REDIS_CACHE_LOCATION if you have more than one frontend worker/instance. 79 | # REDIS_CACHE_LOCATION=redis://redis.default.svc.cluster.local/0 80 | # If CELERY_BROKER_URL is set, make sure CELERY_TASK_ALWAYS_EAGER is False and 81 | # that you have a separate queue consumer running somewhere via `celeryworker.sh`. 82 | # CELERY_TASK_ALWAYS_EAGER=False 83 | # CELERY_BROKER_URL=redis://redis.default.svc.cluster.local/1 84 | 85 | # Should Shynet show third-party icons in the dashboard? 86 | SHOW_THIRD_PARTY_ICONS=True 87 | 88 | # Should Shynet block collection of IP addresses globally? 89 | BLOCK_ALL_IPS=False 90 | 91 | # Should Shynet include the date and site ID when hashing users? 92 | # This will prevent any possibility of cross-site tracking provided 93 | # that IP collection is also disabled, and external keys (primary 94 | # keys) aren't supplied. It will also prevent sessions from spanning 95 | # one day to another. 96 | AGGRESSIVE_HASH_SALTING=True 97 | 98 | # Custom location url to link to in frontend. 99 | # $LATITUDE will get replaced by the latitude, $LONGITUDE will get 100 | # replaced by the longitude. 101 | # Examples: 102 | # - https://www.openstreetmap.org/?mlat=$LATITUDE&mlon=$LONGITUDE (default) 103 | # - https://www.google.com/maps/search/?api=1&query=$LATITUDE,$LONGITUDE 104 | # - https://www.mapquest.com/near-$LATITUDE,$LONGITUDE 105 | LOCATION_URL=https://www.openstreetmap.org/?mlat=$$LATITUDE&mlon=$$LONGITUDE 106 | 107 | # How many services should be displayed on dashboard page? 108 | # Set to big number if you don't want pagination at all. 109 | DASHBOARD_PAGE_SIZE=5 110 | 111 | # Should background bars be scaled to full width? 112 | USE_RELATIVE_MAX_IN_BAR_VISUALIZATION=True 113 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Shynet", 3 | "description": "Modern, privacy-friendly, and detailed web analytics that works without cookies or JS.", 4 | "keywords": [ 5 | "app.json", 6 | "shynet", 7 | "heroku", 8 | "analytics", 9 | "privacy", 10 | "friendly" 11 | ], 12 | "website": "https://github.com/milesmcc/shynet", 13 | "repository": "https://github.com/milesmcc/shynet", 14 | "logo": "https://github.com/milesmcc/shynet/raw/master/images/slogo.png", 15 | "success_url": "/", 16 | "stack": "container", 17 | "addons": [ 18 | "heroku-postgresql:hobby-dev" 19 | ], 20 | "formation": { 21 | "web": { 22 | "quantity": 1, 23 | "size": "free" 24 | } 25 | }, 26 | "env": { 27 | "DB_NAME": { 28 | "description": "Postgres database name (not required if using Postgres addon)", 29 | "value": "shynet", 30 | "required": false 31 | }, 32 | "DB_USER": { 33 | "description": "Postgres database username (not required if using Postgres addon)", 34 | "value": "", 35 | "required": false 36 | }, 37 | "DB_PASSWORD": { 38 | "description": "Postgres database password (not required if using Postgres addon)", 39 | "value": "", 40 | "required": false 41 | }, 42 | "DB_HOST": { 43 | "description": "Postgres database hostname (not required if using Postgres addon)", 44 | "value": "", 45 | "required": false 46 | }, 47 | "DB_PORT": { 48 | "description": "Postgres database port (not required if using Postgres addon)", 49 | "value": "5432", 50 | "required": false 51 | }, 52 | "EMAIL_HOST": { 53 | "description": "SMTP server hostname (for sending emails)", 54 | "value": "smtp.gmail.com", 55 | "required": false 56 | }, 57 | "EMAIL_PORT": { 58 | "description": "SMTP server port (for sending emails)", 59 | "value": "465", 60 | "required": false 61 | }, 62 | "EMAIL_HOST_USER": { 63 | "description": "SMTP server username (for sending emails)", 64 | "value": "", 65 | "required": false 66 | }, 67 | "EMAIL_HOST_PASSWORD": { 68 | "description": "SMTP server password (for sending emails)", 69 | "value": "", 70 | "required": false 71 | }, 72 | "SERVER_EMAIL": { 73 | "description": "Email address (for sending emails)", 74 | "value": " noreply@shynet.example.com", 75 | "required": false 76 | }, 77 | "DJANGO_SECRET_KEY": { 78 | "description": "Django secret key", 79 | "generator": "secret" 80 | }, 81 | "ALLOWED_HOSTS": { 82 | "description": "For better security, set this to your deployment's domain. (Where you will actually host, not embed, Shynet.) Set to '*' to allow serving all domains.", 83 | "value": "*", 84 | "required": false 85 | }, 86 | "ACCOUNT_SIGNUPS_ENABLED": { 87 | "description": "Set to True (capitalized) if you want people to be able to sign up for your Shynet instance (not recommended).", 88 | "value": "False", 89 | "required": false 90 | }, 91 | "TIME_ZONE": { 92 | "description": "The timezone of the admin panel. Affects how dates are displayed.", 93 | "value": "America/New_York", 94 | "required": false 95 | }, 96 | "SCRIPT_USE_HTTPS": { 97 | "description": "Set to 'False' if you will not be serving Shynet over HTTPS.", 98 | "value": "True", 99 | "required": false 100 | }, 101 | "SCRIPT_HEARTBEAT_FREQUENCY": { 102 | "description": "How frequently should the monitoring script 'phone home' (in ms)?", 103 | "value": "5000", 104 | "required": false 105 | }, 106 | "SESSION_MEMORY_TIMEOUT": { 107 | "description": "How much time can elapse between requests from the same user before a new session is created, in seconds?", 108 | "value": "1800", 109 | "required": false 110 | }, 111 | "ONLY_SUPERUSERS_CREATE": { 112 | "description": "Should only superusers (admins) be able to create tracked services?", 113 | "value": "True", 114 | "required": false 115 | }, 116 | "PERFORM_CHECKS_AND_SETUP": { 117 | "description": "Whether to perform checks and setup at startup. Recommended value is 'True' for Heroku users.", 118 | "value": "True", 119 | "required": false 120 | }, 121 | "SHOW_SHYNET_VERSION": { 122 | "description": "Set to 'False' if you do not want the version to be displayed on the frontend.", 123 | "value": "True", 124 | "required": false 125 | }, 126 | "LOCATION_URL": { 127 | "description": "Custom location url to link to in frontend.", 128 | "value": "https://www.openstreetmap.org/?mlat=$LATITUDE&mlon=$LONGITUDE", 129 | "required": false 130 | }, 131 | "DASHBOARD_PAGE_SIZE": { 132 | "description": "How many services should be displayed on dashboard page?", 133 | "value": "5", 134 | "required": false 135 | }, 136 | "USE_RELATIVE_MAX_IN_BAR_VISUALIZATION": { 137 | "description": "Should background bars be scaled to full width?", 138 | "value": "True", 139 | "required": false 140 | } 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /assets/GeoLite2-ASN_20191224.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/milesmcc/shynet/d07380bb27602d117f07f79b2b2728d43998619d/assets/GeoLite2-ASN_20191224.tar.gz -------------------------------------------------------------------------------- /assets/GeoLite2-City_20191224.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/milesmcc/shynet/d07380bb27602d117f07f79b2b2728d43998619d/assets/GeoLite2-City_20191224.tar.gz -------------------------------------------------------------------------------- /assets/README.md: -------------------------------------------------------------------------------- 1 | This file contains GeoIP databases accurate as of 2019. We'd use newer databases, but MaxMind gates their free GeoIP databases behind a license key citing (an overly strict interpretation of) global data privacy regulation. 2 | 3 | These files are the most recent version licensed under Creative Commons, pulled from the Internet Archive. For more information, see https://forum.matomo.org/t/maxmind-is-changing-access-to-free-geolite2-databases/35439/2. -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | shynet: 4 | container_name: shynet_main 5 | image: milesmcc/shynet:latest 6 | restart: unless-stopped 7 | expose: 8 | - 8080 9 | env_file: 10 | # Create a file called '.env' if it doesn't already exist. 11 | # You can use `TEMPLATE.env` as a guide. 12 | - .env 13 | environment: 14 | - DB_HOST=db 15 | networks: 16 | - internal 17 | depends_on: 18 | - db 19 | db: 20 | container_name: shynet_database 21 | image: postgres 22 | restart: always 23 | environment: 24 | - "POSTGRES_USER=${DB_USER}" 25 | - "POSTGRES_PASSWORD=${DB_PASSWORD}" 26 | - "POSTGRES_DB=${DB_NAME}" 27 | volumes: 28 | - shynet_db:/var/lib/postgresql/data 29 | networks: 30 | - internal 31 | webserver: 32 | container_name: shynet_webserver 33 | image: nginx 34 | restart: always 35 | volumes: 36 | - ./nginx.conf:/etc/nginx/conf.d/default.conf 37 | ports: 38 | - 8080:80 39 | depends_on: 40 | - shynet 41 | networks: 42 | - internal 43 | volumes: 44 | shynet_db: 45 | networks: 46 | internal: 47 | -------------------------------------------------------------------------------- /heroku.yml: -------------------------------------------------------------------------------- 1 | build: 2 | docker: 3 | web: Dockerfile -------------------------------------------------------------------------------- /images/homepage.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/milesmcc/shynet/d07380bb27602d117f07f79b2b2728d43998619d/images/homepage.png -------------------------------------------------------------------------------- /images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/milesmcc/shynet/d07380bb27602d117f07f79b2b2728d43998619d/images/logo.png -------------------------------------------------------------------------------- /images/service.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/milesmcc/shynet/d07380bb27602d117f07f79b2b2728d43998619d/images/service.png -------------------------------------------------------------------------------- /images/slogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/milesmcc/shynet/d07380bb27602d117f07f79b2b2728d43998619d/images/slogo.png -------------------------------------------------------------------------------- /kubernetes/deployments.yml: -------------------------------------------------------------------------------- 1 | apiVersion: "apps/v1" 2 | kind: "Deployment" 3 | metadata: 4 | name: "shynet-webserver" 5 | namespace: "default" 6 | labels: 7 | app: "shynet-webserver" 8 | spec: 9 | replicas: 3 10 | selector: 11 | matchLabels: 12 | app: "shynet-webserver" 13 | template: 14 | metadata: 15 | labels: 16 | app: "shynet-webserver" 17 | spec: 18 | containers: 19 | - name: "shynet-webserver" 20 | image: "milesmcc/shynet:edge" # Change to the version appropriate for you (e.g., :latest) 21 | imagePullPolicy: Always 22 | envFrom: 23 | - secretRef: 24 | name: shynet-settings 25 | --- 26 | apiVersion: "apps/v1" 27 | kind: "Deployment" 28 | metadata: 29 | name: "shynet-celeryworker" 30 | namespace: "default" 31 | labels: 32 | app: "shynet-celeryworker" 33 | spec: 34 | replicas: 3 35 | selector: 36 | matchLabels: 37 | app: "shynet-celeryworker" 38 | template: 39 | metadata: 40 | labels: 41 | app: "shynet-celeryworker" 42 | spec: 43 | containers: 44 | - name: "shynet-celeryworker" 45 | image: "milesmcc/shynet:edge" # Change to the version appropriate for you (e.g., :latest) 46 | command: ["./celeryworker.sh"] 47 | imagePullPolicy: Always 48 | envFrom: 49 | - secretRef: 50 | name: shynet-settings 51 | --- 52 | apiVersion: v1 53 | kind: Service 54 | metadata: 55 | name: shynet-redis 56 | spec: 57 | ports: 58 | - port: 6379 59 | name: redis 60 | clusterIP: None 61 | selector: 62 | app: shynet-redis 63 | --- 64 | apiVersion: apps/v1 65 | kind: StatefulSet 66 | metadata: 67 | name: shynet-redis 68 | spec: 69 | selector: 70 | matchLabels: 71 | app: shynet-redis 72 | serviceName: shynet-redis 73 | replicas: 1 74 | template: 75 | metadata: 76 | labels: 77 | app: shynet-redis 78 | spec: 79 | containers: 80 | - name: shynet-redis 81 | image: redis:latest 82 | imagePullPolicy: Always 83 | ports: 84 | - containerPort: 6379 85 | name: redis 86 | --- 87 | apiVersion: v1 88 | kind: Service 89 | metadata: 90 | name: shynet-webserver-service 91 | spec: 92 | type: ClusterIP 93 | ports: 94 | - port: 8080 95 | selector: 96 | app: shynet-webserver 97 | --- 98 | apiVersion: networking.k8s.io/v1 99 | kind: Ingress 100 | metadata: 101 | name: shynet-webserver-ingress 102 | annotations: 103 | kubernetes.io/ingress.class: addon-http-application-routing 104 | spec: 105 | rules: 106 | - host: shynet.rmrm.io 107 | http: 108 | paths: 109 | - backend: 110 | serviceName: shynet-webserver-service 111 | servicePort: 8080 112 | path: / 113 | - host: shynet-beta.rmrm.io 114 | http: 115 | paths: 116 | - backend: 117 | serviceName: shynet-webserver-service 118 | servicePort: 8080 119 | path: / -------------------------------------------------------------------------------- /kubernetes/secrets_template.yml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Secret 3 | metadata: 4 | name: shynet-settings 5 | type: Opaque 6 | stringData: 7 | # Django settings 8 | DEBUG: "False" 9 | ALLOWED_HOSTS: "*" # For better security, set this to your deployment's domain. Comma separated. 10 | DJANGO_SECRET_KEY: "" 11 | ACCOUNT_SIGNUPS_ENABLED: "False" 12 | TIME_ZONE: "America/New_York" 13 | 14 | # Redis configuration (if you use the default Kubernetes config, this will work) 15 | REDIS_CACHE_LOCATION: "redis://shynet-redis.default.svc.cluster.local/0" 16 | CELERY_BROKER_URL: "redis://shynet-redis.default.svc.cluster.local/1" 17 | 18 | # PostgreSQL settings 19 | DB_NAME: "" 20 | DB_USER: "" 21 | DB_PASSWORD: "" 22 | DB_HOST: "" 23 | 24 | # Email settings 25 | EMAIL_HOST_USER: "" 26 | EMAIL_HOST_PASSWORD: "" 27 | EMAIL_HOST: "" 28 | SERVER_EMAIL: "Shynet " 29 | -------------------------------------------------------------------------------- /nginx.conf: -------------------------------------------------------------------------------- 1 | server { 2 | server_name example.com; 3 | access_log /var/log/nginx/bin.access.log; 4 | error_log /var/log/nginx/bin.error.log error; 5 | 6 | 7 | location / { 8 | proxy_pass http://shynet:8080; 9 | proxy_redirect off; 10 | proxy_set_header Host $http_host; 11 | proxy_set_header X-Real-IP $remote_addr; 12 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 13 | proxy_set_header X-Forwarded-Proto $scheme; 14 | proxy_set_header X-Forwarded-Protocol $scheme; 15 | proxy_set_header X-Url-Scheme $scheme; 16 | } 17 | listen 80; 18 | 19 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "shynet", 3 | "description": "Modern, privacy-friendly, and cookie-free web analytics.", 4 | "repository": { 5 | "type": "git", 6 | "url": "git+https://github.com/milesmcc/shynet.git" 7 | }, 8 | "keywords": [ 9 | "privacy", 10 | "analytics", 11 | "self-host" 12 | ], 13 | "author": "R. Miles McCain ", 14 | "license": "Apache-2.0", 15 | "bugs": { 16 | "url": "https://github.com/milesmcc/shynet/issues" 17 | }, 18 | "homepage": "https://github.com/milesmcc/shynet#readme", 19 | "dependencies": { 20 | "@fortawesome/fontawesome-free": "^5.15.1", 21 | "a17t": "^0.5.1", 22 | "apexcharts": "^3.24.0", 23 | "datamaps": "^0.5.9", 24 | "flag-icon-css": "^3.5.0", 25 | "inter-ui": "^3.15.0", 26 | "litepicker": "^2.0.11" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "shynet" 3 | version = "0.13.1" 4 | description = "Modern, privacy-friendly, and cookie-free web analytics." 5 | authors = ["R. Miles McCain "] 6 | license = "Apache-2.0" 7 | 8 | [tool.poetry.dependencies] 9 | python = "^3.8" 10 | Django = "^4" 11 | django-allauth = "^0.45.0" 12 | geoip2 = "^4.2.0" 13 | whitenoise = "^5.3.0" 14 | celery = "^5.2.2" 15 | django-ipware = "^4.0.2" 16 | PyYAML = "^5.4.1" 17 | user-agents = "^2.2.0" 18 | rules = "^3.0" 19 | gunicorn = "^20.1.0" 20 | psycopg2-binary = "^2.9.2" 21 | redis = "^3.5.3" 22 | django-redis-cache = "^3.0.0" 23 | pycountry = "^20.7.3" 24 | html2text = "^2020.1.16" 25 | django-health-check = "^3.16.4" 26 | django-npm = "^1.0.0" 27 | python-dotenv = "^0.18.0" 28 | django-debug-toolbar = "^3.2.1" 29 | django-cors-headers = "^3.11.0" 30 | 31 | [tool.poetry.dev-dependencies] 32 | pytest-sugar = "^0.9.4" 33 | factory-boy = "^3.2.0" 34 | pytest-django = "^4.4.0" 35 | django-coverage-plugin = "^2.0.0" 36 | django-stubs = "^1.8.0" 37 | mypy = "^0.910" 38 | 39 | [build-system] 40 | requires = ["poetry-core>=1.0.0"] 41 | build-backend = "poetry.core.masonry.api" 42 | 43 | [tool.black] 44 | line-length = 88 45 | -------------------------------------------------------------------------------- /shynet/a17t/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/milesmcc/shynet/d07380bb27602d117f07f79b2b2728d43998619d/shynet/a17t/__init__.py -------------------------------------------------------------------------------- /shynet/a17t/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class A17TConfig(AppConfig): 5 | name = "a17t" 6 | -------------------------------------------------------------------------------- /shynet/a17t/locale/de/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2022-06-24 13:20+0200\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 20 | 21 | #: a17t/templates/a17t/includes/pagination.html:5 22 | #: a17t/templates/a17t/includes/pagination.html:7 23 | msgid "Previous" 24 | msgstr "Zurück" 25 | 26 | #: a17t/templates/a17t/includes/pagination.html:10 27 | #: a17t/templates/a17t/includes/pagination.html:12 28 | msgid "Next" 29 | msgstr "Vor" 30 | -------------------------------------------------------------------------------- /shynet/a17t/locale/zh_TW/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2022-06-24 13:20+0200\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 20 | 21 | #: a17t/templates/a17t/includes/pagination.html:5 22 | #: a17t/templates/a17t/includes/pagination.html:7 23 | msgid "Previous" 24 | msgstr "上一頁" 25 | 26 | #: a17t/templates/a17t/includes/pagination.html:10 27 | #: a17t/templates/a17t/includes/pagination.html:12 28 | msgid "Next" 29 | msgstr "下一頁" 30 | -------------------------------------------------------------------------------- /shynet/a17t/templates/a17t/includes/field.html: -------------------------------------------------------------------------------- 1 | {% load a17t_tags %} 2 | 3 | 4 | {% if field|is_checkbox %} 5 | 9 | {% elif field|is_multiple_checkbox %} 10 | {% if field.auto_id %} 11 | 12 | {% endif %} 13 | {% for choice in field %} 14 | 18 | {% endfor %} 19 | {% elif field|is_radio %} 20 | {% if field.auto_id %} 21 | 22 | {% endif %} 23 | {% for choice in field %} 24 | 28 | {% endfor %} 29 | {% elif field|is_input %} 30 | {% include 'a17t/includes/label.html' %} 31 | {{field|add_class:"input my-1"}} 32 | {% elif field|is_textarea %} 33 | {% include 'a17t/includes/label.html' %} 34 | {{ field|add_class:'textarea my-1' }} 35 | {% elif field|is_select %} 36 | {% include 'a17t/includes/label.html' %} 37 |
38 | {{field}} 39 |
40 | {% else %} 41 | {% include 'a17t/includes/label.html' %} 42 | {{field|add_class:"field my-1"}} 43 | {% endif %} 44 | 45 | {% for error in field.errors %} 46 |

{{ error }}

47 | {% endfor %} 48 | {% if field.help_text %} 49 |

{{field.help_text|safe}}

50 | {% endif %} 51 |
-------------------------------------------------------------------------------- /shynet/a17t/templates/a17t/includes/form.html: -------------------------------------------------------------------------------- 1 | {% if form.non_field_errors %} 2 | 7 | {% endif %} 8 | 9 | {% for field in form.hidden_fields %} 10 | {{ field }} 11 | {% endfor %} 12 | 13 | {% for field in form.visible_fields %} 14 | {% include 'a17t/includes/field.html' %} 15 | {% endfor %} -------------------------------------------------------------------------------- /shynet/a17t/templates/a17t/includes/formset.html: -------------------------------------------------------------------------------- 1 | {{ formset.management_form }} 2 | {% for form in formset %} 3 | {% include "a17t/includes/form.html" with form=form %} 4 | {% endfor %} 5 | -------------------------------------------------------------------------------- /shynet/a17t/templates/a17t/includes/head.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /shynet/a17t/templates/a17t/includes/label.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | -------------------------------------------------------------------------------- /shynet/a17t/templates/a17t/includes/pagination.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | -------------------------------------------------------------------------------- /shynet/a17t/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/milesmcc/shynet/d07380bb27602d117f07f79b2b2728d43998619d/shynet/a17t/templatetags/__init__.py -------------------------------------------------------------------------------- /shynet/a17t/templatetags/a17t_tags.py: -------------------------------------------------------------------------------- 1 | from django import forms, template 2 | from django.forms import BoundField 3 | from django.template.loader import get_template 4 | from django.utils.safestring import mark_safe 5 | 6 | register = template.Library() 7 | 8 | 9 | @register.filter 10 | def a17t(element): 11 | markup_classes = {"label": "", "value": "", "single_value": ""} 12 | return render(element, markup_classes) 13 | 14 | 15 | @register.filter 16 | def a17t_inline(element): 17 | markup_classes = {"label": "", "value": "", "single_value": ""} 18 | return render(element, markup_classes) 19 | 20 | 21 | def render(element, markup_classes): 22 | if isinstance(element, BoundField): 23 | template = get_template("a17t/includes/field.html") 24 | context = {"field": element, "classes": markup_classes, "form": element.form} 25 | else: 26 | has_management = getattr(element, "management_form", None) 27 | if has_management: 28 | template = get_template("a17t/includes/formset.html") 29 | context = {"formset": element, "classes": markup_classes} 30 | else: 31 | template = get_template("a17t/includes/form.html") 32 | context = {"form": element, "classes": markup_classes} 33 | 34 | return template.render(context) 35 | 36 | 37 | @register.filter 38 | def widget_type(field): 39 | return field.field.widget 40 | 41 | 42 | @register.filter 43 | def is_select(field): 44 | return isinstance(field.field.widget, forms.Select) 45 | 46 | 47 | @register.filter 48 | def is_multiple_select(field): 49 | return isinstance(field.field.widget, forms.SelectMultiple) 50 | 51 | 52 | @register.filter 53 | def is_textarea(field): 54 | return isinstance(field.field.widget, forms.Textarea) 55 | 56 | 57 | @register.filter 58 | def is_input(field): 59 | return isinstance( 60 | field.field.widget, 61 | ( 62 | forms.TextInput, 63 | forms.NumberInput, 64 | forms.EmailInput, 65 | forms.PasswordInput, 66 | forms.URLInput, 67 | ), 68 | ) 69 | 70 | 71 | @register.filter 72 | def is_checkbox(field): 73 | return isinstance(field.field.widget, forms.CheckboxInput) 74 | 75 | 76 | @register.filter 77 | def is_multiple_checkbox(field): 78 | return isinstance(field.field.widget, forms.CheckboxSelectMultiple) 79 | 80 | 81 | @register.filter 82 | def is_radio(field): 83 | return isinstance(field.field.widget, forms.RadioSelect) 84 | 85 | 86 | @register.filter 87 | def is_file(field): 88 | return isinstance(field.field.widget, forms.FileInput) 89 | 90 | 91 | @register.filter 92 | def add_class(field, css_class): 93 | if len(field.errors) > 0: 94 | css_class += " ~critical" 95 | if field.field.widget.attrs.get("class") != None: 96 | css_class += " " + field.field.widget.attrs["class"] 97 | return field.as_widget(attrs={"class": field.css_classes(extra_classes=css_class)}) 98 | -------------------------------------------------------------------------------- /shynet/a17t/templatetags/pagination.py: -------------------------------------------------------------------------------- 1 | # From https://djangosnippets.org/snippets/1441/ 2 | 3 | from django import template 4 | from django.utils.http import urlencode 5 | 6 | register = template.Library() 7 | 8 | 9 | @register.inclusion_tag("a17t/includes/pagination.html") 10 | def pagination( 11 | page, 12 | request, 13 | begin_pages=2, 14 | end_pages=2, 15 | before_current_pages=4, 16 | after_current_pages=4, 17 | ): 18 | url_parameters = urlencode( 19 | [(key, value) for key, value in request.GET.items() if key != "page"] 20 | ) 21 | 22 | before = max(page.number - before_current_pages - 1, 0) 23 | after = page.number + after_current_pages 24 | 25 | begin = page.paginator.page_range[:begin_pages] 26 | middle = page.paginator.page_range[before:after] 27 | end = page.paginator.page_range[-end_pages:] 28 | last_page_number = end[-1] 29 | 30 | def collides(firstlist, secondlist): 31 | return any(item in secondlist for item in firstlist) 32 | 33 | if collides(middle, end): 34 | end = range(max(page.number - before_current_pages, 1), last_page_number + 1) 35 | middle = [] 36 | 37 | if collides(begin, middle): 38 | begin = range(1, min(page.number + after_current_pages, last_page_number) + 1) 39 | middle = [] 40 | 41 | if collides(begin, end): 42 | begin = range(1, last_page_number + 1) 43 | end = [] 44 | 45 | return { 46 | "page": page, 47 | "begin": begin, 48 | "middle": middle, 49 | "end": end, 50 | "url_parameters": url_parameters, 51 | } 52 | -------------------------------------------------------------------------------- /shynet/analytics/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/milesmcc/shynet/d07380bb27602d117f07f79b2b2728d43998619d/shynet/analytics/__init__.py -------------------------------------------------------------------------------- /shynet/analytics/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from .models import Hit, Session 4 | 5 | 6 | class HitInline(admin.TabularInline): 7 | model = Hit 8 | fk_name = "session" 9 | extra = 0 10 | 11 | 12 | class SessionAdmin(admin.ModelAdmin): 13 | list_display = ( 14 | "uuid", 15 | "service", 16 | "start_time", 17 | "last_seen", 18 | "identifier", 19 | "ip", 20 | "asn", 21 | "country", 22 | ) 23 | list_display_links = ("uuid",) 24 | search_fields = ( 25 | "ip", 26 | "user_agent", 27 | "device", 28 | "device_type", 29 | "identifier", 30 | "asn", 31 | "time_zone", 32 | ) 33 | list_filter = ("device_type",) 34 | inlines = [HitInline] 35 | 36 | 37 | admin.site.register(Session, SessionAdmin) 38 | 39 | 40 | class HitAdmin(admin.ModelAdmin): 41 | list_display = ( 42 | "session", 43 | "initial", 44 | "start_time", 45 | "heartbeats", 46 | "tracker", 47 | "load_time", 48 | "location", 49 | ) 50 | list_display_links = ("session",) 51 | search_fields = ("initial", "tracker", "location", "referrer") 52 | list_filter = ("initial", "tracker") 53 | 54 | 55 | admin.site.register(Hit, HitAdmin) 56 | -------------------------------------------------------------------------------- /shynet/analytics/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class AnalyticsConfig(AppConfig): 5 | name = "analytics" 6 | -------------------------------------------------------------------------------- /shynet/analytics/ingress_urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import include, path 3 | 4 | from .views import ingress 5 | 6 | urlpatterns = [ 7 | path( 8 | "/pixel.gif", ingress.PixelView.as_view(), name="endpoint_pixel" 9 | ), 10 | path( 11 | "/script.js", ingress.ScriptView.as_view(), name="endpoint_script" 12 | ), 13 | path( 14 | "//pixel.gif", 15 | ingress.PixelView.as_view(), 16 | name="endpoint_pixel_id", 17 | ), 18 | path( 19 | "//script.js", 20 | ingress.ScriptView.as_view(), 21 | name="endpoint_script_id", 22 | ), 23 | ] 24 | -------------------------------------------------------------------------------- /shynet/analytics/locale/de/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: PACKAGE VERSION\n" 9 | "Report-Msgid-Bugs-To: \n" 10 | "POT-Creation-Date: 2022-06-24 13:20+0200\n" 11 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 12 | "Last-Translator: FULL NAME \n" 13 | "Language-Team: LANGUAGE \n" 14 | "Language: \n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=UTF-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 19 | 20 | #: analytics/models.py:18 21 | msgid "Service" 22 | msgstr "Dienst" 23 | 24 | #: analytics/models.py:24 25 | msgid "Identifier" 26 | msgstr "Kennung" 27 | 28 | #: analytics/models.py:29 29 | msgid "Start time" 30 | msgstr "Startzeit" 31 | 32 | #: analytics/models.py:32 33 | msgid "Last seen" 34 | msgstr "Zuletzt gesehen" 35 | 36 | #: analytics/models.py:36 37 | msgid "User agent" 38 | msgstr "" 39 | 40 | #: analytics/models.py:37 41 | msgid "Browser" 42 | msgstr "" 43 | 44 | #: analytics/models.py:38 45 | msgid "Device" 46 | msgstr "Gerät" 47 | 48 | #: analytics/models.py:42 49 | msgid "Phone" 50 | msgstr "" 51 | 52 | #: analytics/models.py:43 53 | msgid "Tablet" 54 | msgstr "" 55 | 56 | #: analytics/models.py:44 57 | msgid "Desktop" 58 | msgstr "" 59 | 60 | #: analytics/models.py:45 61 | msgid "Robot" 62 | msgstr "" 63 | 64 | #: analytics/models.py:46 65 | msgid "Other" 66 | msgstr "Andere" 67 | 68 | #: analytics/models.py:49 69 | msgid "Device type" 70 | msgstr "Gerätetyp" 71 | 72 | #: analytics/models.py:51 73 | msgid "OS" 74 | msgstr "Betriessystem" 75 | 76 | #: analytics/models.py:52 77 | msgid "IP" 78 | msgstr "" 79 | 80 | #: analytics/models.py:55 81 | msgid "Asn" 82 | msgstr "" 83 | 84 | #: analytics/models.py:56 85 | msgid "Country" 86 | msgstr "Land" 87 | 88 | #: analytics/models.py:57 89 | msgid "Longitude" 90 | msgstr "Längengrad" 91 | 92 | #: analytics/models.py:58 93 | msgid "Latitude" 94 | msgstr "Breitengrad" 95 | 96 | #: analytics/models.py:59 97 | msgid "Time zone" 98 | msgstr "Zeitzone" 99 | 100 | #: analytics/models.py:61 101 | msgid "Is bounce" 102 | msgstr "Absprung" 103 | 104 | #: analytics/models.py:64 analytics/models.py:100 105 | msgid "Session" 106 | msgstr "Sitzung" 107 | 108 | #: analytics/models.py:65 109 | msgid "Sessions" 110 | msgstr "Sitzungen" 111 | 112 | #: analytics/models.py:122 113 | msgid "Hit" 114 | msgstr "Besuch" 115 | 116 | #: analytics/models.py:123 117 | msgid "Hits" 118 | msgstr "Besuche" 119 | -------------------------------------------------------------------------------- /shynet/analytics/locale/zh_TW/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | msgid "" 7 | msgstr "" 8 | "Project-Id-Version: PACKAGE VERSION\n" 9 | "Report-Msgid-Bugs-To: \n" 10 | "POT-Creation-Date: 2022-06-24 13:20+0200\n" 11 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 12 | "Last-Translator: FULL NAME \n" 13 | "Language-Team: LANGUAGE \n" 14 | "Language: \n" 15 | "MIME-Version: 1.0\n" 16 | "Content-Type: text/plain; charset=UTF-8\n" 17 | "Content-Transfer-Encoding: 8bit\n" 18 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 19 | 20 | #: analytics/models.py:18 21 | msgid "Service" 22 | msgstr "服務" 23 | 24 | #: analytics/models.py:24 25 | msgid "Identifier" 26 | msgstr "識別碼" 27 | 28 | #: analytics/models.py:29 29 | msgid "Start time" 30 | msgstr "開始時間" 31 | 32 | #: analytics/models.py:32 33 | msgid "Last seen" 34 | msgstr "最後瀏覽" 35 | 36 | #: analytics/models.py:36 37 | msgid "User agent" 38 | msgstr "使用者代理程式" 39 | 40 | #: analytics/models.py:37 41 | msgid "Browser" 42 | msgstr "瀏覽器" 43 | 44 | #: analytics/models.py:38 45 | msgid "Device" 46 | msgstr "裝置" 47 | 48 | #: analytics/models.py:42 49 | msgid "Phone" 50 | msgstr "手機" 51 | 52 | #: analytics/models.py:43 53 | msgid "Tablet" 54 | msgstr "平板" 55 | 56 | #: analytics/models.py:44 57 | msgid "Desktop" 58 | msgstr "桌上型電腦" 59 | 60 | #: analytics/models.py:45 61 | msgid "Robot" 62 | msgstr "機器人" 63 | 64 | #: analytics/models.py:46 65 | msgid "Other" 66 | msgstr "其他" 67 | 68 | #: analytics/models.py:49 69 | msgid "Device type" 70 | msgstr "裝置類型" 71 | 72 | #: analytics/models.py:51 73 | msgid "OS" 74 | msgstr "作業系統" 75 | 76 | #: analytics/models.py:52 77 | msgid "IP" 78 | msgstr "IP" 79 | 80 | #: analytics/models.py:55 81 | msgid "Asn" 82 | msgstr "ASN" 83 | 84 | #: analytics/models.py:56 85 | msgid "Country" 86 | msgstr "國家" 87 | 88 | #: analytics/models.py:57 89 | msgid "Longitude" 90 | msgstr "經度" 91 | 92 | #: analytics/models.py:58 93 | msgid "Latitude" 94 | msgstr "緯度" 95 | 96 | #: analytics/models.py:59 97 | msgid "Time zone" 98 | msgstr "時區" 99 | 100 | #: analytics/models.py:61 101 | msgid "Is bounce" 102 | msgstr "是否為跳出" 103 | 104 | #: analytics/models.py:64 analytics/models.py:100 105 | msgid "Session" 106 | msgstr "工作階段" 107 | 108 | #: analytics/models.py:65 109 | msgid "Sessions" 110 | msgstr "工作階段次數" 111 | 112 | #: analytics/models.py:122 113 | msgid "Hit" 114 | msgstr "點選" 115 | 116 | #: analytics/models.py:123 117 | msgid "Hits" 118 | msgstr "點選次數" 119 | -------------------------------------------------------------------------------- /shynet/analytics/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.5 on 2020-04-14 14:40 2 | 3 | import django.db.models.deletion 4 | from django.db import migrations, models 5 | 6 | import analytics.models 7 | 8 | 9 | class Migration(migrations.Migration): 10 | 11 | initial = True 12 | 13 | dependencies = [ 14 | ("core", "0001_initial"), 15 | ] 16 | 17 | operations = [ 18 | migrations.CreateModel( 19 | name="Session", 20 | fields=[ 21 | ( 22 | "uuid", 23 | models.UUIDField( 24 | default=analytics.models._default_uuid, 25 | primary_key=True, 26 | serialize=False, 27 | ), 28 | ), 29 | ("identifier", models.TextField(blank=True, db_index=True)), 30 | ("start_time", models.DateTimeField(auto_now_add=True, db_index=True)), 31 | ("last_seen", models.DateTimeField(auto_now_add=True)), 32 | ("user_agent", models.TextField()), 33 | ("browser", models.TextField()), 34 | ("device", models.TextField()), 35 | ( 36 | "device_type", 37 | models.CharField( 38 | choices=[ 39 | ("PHONE", "Phone"), 40 | ("TABLET", "Tablet"), 41 | ("DESKTOP", "Desktop"), 42 | ("ROBOT", "Robot"), 43 | ("OTHER", "Other"), 44 | ], 45 | default="OTHER", 46 | max_length=7, 47 | ), 48 | ), 49 | ("os", models.TextField()), 50 | ("ip", models.GenericIPAddressField(db_index=True)), 51 | ("asn", models.TextField(blank=True)), 52 | ("country", models.TextField(blank=True)), 53 | ("longitude", models.FloatField(null=True)), 54 | ("latitude", models.FloatField(null=True)), 55 | ("time_zone", models.TextField(blank=True)), 56 | ( 57 | "service", 58 | models.ForeignKey( 59 | on_delete=django.db.models.deletion.CASCADE, to="core.Service" 60 | ), 61 | ), 62 | ], 63 | options={ 64 | "ordering": ["-start_time"], 65 | }, 66 | ), 67 | migrations.CreateModel( 68 | name="Hit", 69 | fields=[ 70 | ( 71 | "id", 72 | models.AutoField( 73 | auto_created=True, 74 | primary_key=True, 75 | serialize=False, 76 | verbose_name="ID", 77 | ), 78 | ), 79 | ("initial", models.BooleanField(db_index=True, default=True)), 80 | ("start_time", models.DateTimeField(auto_now_add=True, db_index=True)), 81 | ("last_seen", models.DateTimeField(auto_now_add=True)), 82 | ("heartbeats", models.IntegerField(default=0)), 83 | ("tracker", models.TextField()), 84 | ("location", models.TextField(blank=True, db_index=True)), 85 | ("referrer", models.TextField(blank=True, db_index=True)), 86 | ("load_time", models.FloatField(null=True)), 87 | ( 88 | "session", 89 | models.ForeignKey( 90 | on_delete=django.db.models.deletion.CASCADE, 91 | to="analytics.Session", 92 | ), 93 | ), 94 | ], 95 | options={ 96 | "ordering": ["-start_time"], 97 | }, 98 | ), 99 | migrations.AddIndex( 100 | model_name="session", 101 | index=models.Index( 102 | fields=["service", "-start_time"], name="analytics_s_service_4b1137_idx" 103 | ), 104 | ), 105 | migrations.AddIndex( 106 | model_name="session", 107 | index=models.Index( 108 | fields=["service", "identifier"], name="analytics_s_service_82ab21_idx" 109 | ), 110 | ), 111 | migrations.AddIndex( 112 | model_name="hit", 113 | index=models.Index( 114 | fields=["session", "-start_time"], name="analytics_h_session_b2667f_idx" 115 | ), 116 | ), 117 | migrations.AddIndex( 118 | model_name="hit", 119 | index=models.Index( 120 | fields=["session", "location"], name="analytics_h_session_775f5a_idx" 121 | ), 122 | ), 123 | migrations.AddIndex( 124 | model_name="hit", 125 | index=models.Index( 126 | fields=["session", "referrer"], name="analytics_h_session_98b8bf_idx" 127 | ), 128 | ), 129 | ] 130 | -------------------------------------------------------------------------------- /shynet/analytics/migrations/0002_auto_20200415_1742.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.5 on 2020-04-15 21:42 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("analytics", "0001_initial"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="hit", 15 | name="tracker", 16 | field=models.TextField( 17 | choices=[("JS", "JavaScript"), ("PIXEL", "Pixel (noscript)")] 18 | ), 19 | ), 20 | ] 21 | -------------------------------------------------------------------------------- /shynet/analytics/migrations/0003_auto_20200502_1227.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.5 on 2020-05-02 16:27 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("analytics", "0002_auto_20200415_1742"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterField( 14 | model_name="session", 15 | name="ip", 16 | field=models.GenericIPAddressField(db_index=True, null=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /shynet/analytics/migrations/0004_auto_20210328_1514.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.7 on 2021-03-28 19:14 2 | 3 | from django.db import migrations, models 4 | import django.utils.timezone 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ("analytics", "0003_auto_20200502_1227"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name="hit", 16 | name="last_seen", 17 | field=models.DateTimeField(default=django.utils.timezone.now), 18 | ), 19 | migrations.AlterField( 20 | model_name="hit", 21 | name="start_time", 22 | field=models.DateTimeField( 23 | db_index=True, default=django.utils.timezone.now 24 | ), 25 | ), 26 | migrations.AlterField( 27 | model_name="session", 28 | name="last_seen", 29 | field=models.DateTimeField( 30 | db_index=True, default=django.utils.timezone.now 31 | ), 32 | ), 33 | migrations.AlterField( 34 | model_name="session", 35 | name="start_time", 36 | field=models.DateTimeField( 37 | db_index=True, default=django.utils.timezone.now 38 | ), 39 | ), 40 | migrations.AddIndex( 41 | model_name="session", 42 | index=models.Index( 43 | fields=["service", "-last_seen"], name="analytics_s_service_10bb96_idx" 44 | ), 45 | ), 46 | ] 47 | -------------------------------------------------------------------------------- /shynet/analytics/migrations/0005_auto_20210328_1518.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.7 on 2021-03-28 19:18 2 | 3 | from django.db import migrations, models 4 | import django.utils.timezone 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ("analytics", "0004_auto_20210328_1514"), 11 | ] 12 | 13 | operations = [ 14 | migrations.AlterField( 15 | model_name="hit", 16 | name="last_seen", 17 | field=models.DateTimeField( 18 | db_index=True, default=django.utils.timezone.now 19 | ), 20 | ), 21 | migrations.AlterField( 22 | model_name="hit", 23 | name="load_time", 24 | field=models.FloatField(db_index=True, null=True), 25 | ), 26 | ] 27 | -------------------------------------------------------------------------------- /shynet/analytics/migrations/0006_hit_service.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.7 on 2021-03-28 19:36 2 | 3 | from ..models import Hit, Session 4 | from django.db import migrations, models 5 | import django.db.models.deletion 6 | from django.db.models import Subquery, OuterRef 7 | 8 | 9 | def add_service_to_hits(_a, _b): 10 | service = Session.objects.filter(pk=OuterRef("session")).values_list("service")[:1] 11 | 12 | Hit.objects.update(service=Subquery(service)) 13 | 14 | 15 | class Migration(migrations.Migration): 16 | 17 | dependencies = [ 18 | ("core", "0008_auto_20200628_1403"), 19 | ("analytics", "0005_auto_20210328_1518"), 20 | ] 21 | 22 | operations = [ 23 | migrations.AddField( 24 | model_name="hit", 25 | name="service", 26 | field=models.ForeignKey( 27 | null=True, 28 | on_delete=django.db.models.deletion.CASCADE, 29 | to="core.service", 30 | ), 31 | ), 32 | migrations.RunPython(add_service_to_hits, lambda: ()), 33 | migrations.AlterField( 34 | model_name="hit", 35 | name="service", 36 | field=models.ForeignKey( 37 | on_delete=django.db.models.deletion.CASCADE, to="core.service" 38 | ), 39 | ), 40 | ] 41 | -------------------------------------------------------------------------------- /shynet/analytics/migrations/0007_auto_20210328_1634.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.7 on 2021-03-28 20:34 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("analytics", "0006_hit_service"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddIndex( 14 | model_name="hit", 15 | index=models.Index( 16 | fields=["service", "-start_time"], name="analytics_h_service_f4f41e_idx" 17 | ), 18 | ), 19 | ] 20 | -------------------------------------------------------------------------------- /shynet/analytics/migrations/0008_session_is_bounce.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.7 on 2021-03-28 21:38 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("analytics", "0007_auto_20210328_1634"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="session", 15 | name="is_bounce", 16 | field=models.BooleanField(db_index=True, default=True), 17 | ) 18 | ] 19 | -------------------------------------------------------------------------------- /shynet/analytics/migrations/0009_auto_20210329_1100.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1.7 on 2021-03-29 15:00 2 | 3 | from django.db import migrations, models 4 | from ..models import Session 5 | 6 | 7 | def update_bounce_stats(_a, _b): 8 | Session.objects.all().annotate(hit_count=models.Count("hit")).filter( 9 | hit_count__gt=1 10 | ).update(is_bounce=False) 11 | 12 | 13 | class Migration(migrations.Migration): 14 | 15 | dependencies = [ 16 | ("analytics", "0008_session_is_bounce"), 17 | ] 18 | 19 | operations = [ 20 | migrations.RunPython(update_bounce_stats, lambda: ()), 21 | ] 22 | -------------------------------------------------------------------------------- /shynet/analytics/migrations/0010_auto_20220624_0744.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.12 on 2022-06-24 11:44 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | import django.utils.timezone 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ('core', '0009_auto_20211117_0217'), 12 | ('analytics', '0009_auto_20210329_1100'), 13 | ] 14 | 15 | operations = [ 16 | migrations.AlterModelOptions( 17 | name='hit', 18 | options={'ordering': ['-start_time'], 'verbose_name': 'Hit', 'verbose_name_plural': 'Hits'}, 19 | ), 20 | migrations.AlterModelOptions( 21 | name='session', 22 | options={'ordering': ['-start_time'], 'verbose_name': 'Session', 'verbose_name_plural': 'Sessions'}, 23 | ), 24 | migrations.AlterField( 25 | model_name='hit', 26 | name='id', 27 | field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), 28 | ), 29 | migrations.AlterField( 30 | model_name='hit', 31 | name='session', 32 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='analytics.session', verbose_name='Session'), 33 | ), 34 | migrations.AlterField( 35 | model_name='session', 36 | name='asn', 37 | field=models.TextField(blank=True, verbose_name='Asn'), 38 | ), 39 | migrations.AlterField( 40 | model_name='session', 41 | name='browser', 42 | field=models.TextField(verbose_name='Browser'), 43 | ), 44 | migrations.AlterField( 45 | model_name='session', 46 | name='country', 47 | field=models.TextField(blank=True, verbose_name='Country'), 48 | ), 49 | migrations.AlterField( 50 | model_name='session', 51 | name='device', 52 | field=models.TextField(verbose_name='Device'), 53 | ), 54 | migrations.AlterField( 55 | model_name='session', 56 | name='device_type', 57 | field=models.CharField(choices=[('PHONE', 'Phone'), ('TABLET', 'Tablet'), ('DESKTOP', 'Desktop'), ('ROBOT', 'Robot'), ('OTHER', 'Other')], default='OTHER', max_length=7, verbose_name='Device type'), 58 | ), 59 | migrations.AlterField( 60 | model_name='session', 61 | name='identifier', 62 | field=models.TextField(blank=True, db_index=True, verbose_name='Identifier'), 63 | ), 64 | migrations.AlterField( 65 | model_name='session', 66 | name='ip', 67 | field=models.GenericIPAddressField(db_index=True, null=True, verbose_name='IP'), 68 | ), 69 | migrations.AlterField( 70 | model_name='session', 71 | name='is_bounce', 72 | field=models.BooleanField(db_index=True, default=True, verbose_name='Is bounce'), 73 | ), 74 | migrations.AlterField( 75 | model_name='session', 76 | name='last_seen', 77 | field=models.DateTimeField(db_index=True, default=django.utils.timezone.now, verbose_name='Last seen'), 78 | ), 79 | migrations.AlterField( 80 | model_name='session', 81 | name='latitude', 82 | field=models.FloatField(null=True, verbose_name='Latitude'), 83 | ), 84 | migrations.AlterField( 85 | model_name='session', 86 | name='longitude', 87 | field=models.FloatField(null=True, verbose_name='Longitude'), 88 | ), 89 | migrations.AlterField( 90 | model_name='session', 91 | name='os', 92 | field=models.TextField(verbose_name='OS'), 93 | ), 94 | migrations.AlterField( 95 | model_name='session', 96 | name='service', 97 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='core.service', verbose_name='Service'), 98 | ), 99 | migrations.AlterField( 100 | model_name='session', 101 | name='start_time', 102 | field=models.DateTimeField(db_index=True, default=django.utils.timezone.now, verbose_name='Start time'), 103 | ), 104 | migrations.AlterField( 105 | model_name='session', 106 | name='time_zone', 107 | field=models.TextField(blank=True, verbose_name='Time zone'), 108 | ), 109 | migrations.AlterField( 110 | model_name='session', 111 | name='user_agent', 112 | field=models.TextField(verbose_name='User agent'), 113 | ), 114 | ] 115 | -------------------------------------------------------------------------------- /shynet/analytics/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/milesmcc/shynet/d07380bb27602d117f07f79b2b2728d43998619d/shynet/analytics/migrations/__init__.py -------------------------------------------------------------------------------- /shynet/analytics/models.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | from django.db import models 4 | from django.shortcuts import reverse 5 | from django.utils import timezone 6 | from django.utils.translation import gettext_lazy as _ 7 | 8 | from core.models import Service, ACTIVE_USER_TIMEDELTA 9 | 10 | 11 | def _default_uuid(): 12 | return str(uuid.uuid4()) 13 | 14 | 15 | class Session(models.Model): 16 | uuid = models.UUIDField(default=_default_uuid, primary_key=True) 17 | service = models.ForeignKey( 18 | Service, verbose_name=_("Service"), on_delete=models.CASCADE, db_index=True 19 | ) 20 | 21 | # Cross-session identification; optional, and provided by the service 22 | identifier = models.TextField( 23 | blank=True, db_index=True, verbose_name=_("Identifier") 24 | ) 25 | 26 | # Time 27 | start_time = models.DateTimeField( 28 | default=timezone.now, db_index=True, verbose_name=_("Start time") 29 | ) 30 | last_seen = models.DateTimeField( 31 | default=timezone.now, db_index=True, verbose_name=_("Last seen") 32 | ) 33 | 34 | # Core request information 35 | user_agent = models.TextField(verbose_name=_("User agent")) 36 | browser = models.TextField(verbose_name=_("Browser")) 37 | device = models.TextField(verbose_name=_("Device")) 38 | device_type = models.CharField( 39 | max_length=7, 40 | choices=[ 41 | ("PHONE", _("Phone")), 42 | ("TABLET", _("Tablet")), 43 | ("DESKTOP", _("Desktop")), 44 | ("ROBOT", _("Robot")), 45 | ("OTHER", _("Other")), 46 | ], 47 | default="OTHER", 48 | verbose_name=_("Device type"), 49 | ) 50 | os = models.TextField(verbose_name=_("OS")) 51 | ip = models.GenericIPAddressField(db_index=True, null=True, verbose_name=_("IP")) 52 | 53 | # GeoIP data 54 | asn = models.TextField(blank=True, verbose_name=_("Asn")) 55 | country = models.TextField(blank=True, verbose_name=_("Country")) 56 | longitude = models.FloatField(null=True, verbose_name=_("Longitude")) 57 | latitude = models.FloatField(null=True, verbose_name=_("Latitude")) 58 | time_zone = models.TextField(blank=True, verbose_name=_("Time zone")) 59 | 60 | is_bounce = models.BooleanField( 61 | default=True, db_index=True, verbose_name=_("Is bounce") 62 | ) 63 | 64 | class Meta: 65 | verbose_name = _("Session") 66 | verbose_name_plural = _("Sessions") 67 | ordering = ["-start_time"] 68 | indexes = [ 69 | models.Index(fields=["service", "-start_time"]), 70 | models.Index(fields=["service", "-last_seen"]), 71 | models.Index(fields=["service", "identifier"]), 72 | ] 73 | 74 | @property 75 | def is_currently_active(self): 76 | return timezone.now() - self.last_seen < ACTIVE_USER_TIMEDELTA 77 | 78 | @property 79 | def duration(self): 80 | return self.last_seen - self.start_time 81 | 82 | def __str__(self): 83 | return f"{self.identifier if self.identifier != '' else 'Anonymous'} @ {self.service.name} [{str(self.uuid)[:6]}]" 84 | 85 | def get_absolute_url(self): 86 | return reverse( 87 | "dashboard:service_session", 88 | kwargs={"pk": self.service.pk, "session_pk": self.uuid}, 89 | ) 90 | 91 | def recalculate_bounce(self): 92 | bounce = self.hit_set.count() == 1 93 | if bounce != self.is_bounce: 94 | self.is_bounce = bounce 95 | self.save() 96 | 97 | 98 | class Hit(models.Model): 99 | session = models.ForeignKey( 100 | Session, on_delete=models.CASCADE, db_index=True, verbose_name=_("Session") 101 | ) 102 | initial = models.BooleanField(default=True, db_index=True) 103 | 104 | # Base request information 105 | start_time = models.DateTimeField(default=timezone.now, db_index=True) 106 | last_seen = models.DateTimeField(default=timezone.now, db_index=True) 107 | heartbeats = models.IntegerField(default=0) 108 | tracker = models.TextField( 109 | choices=[("JS", "JavaScript"), ("PIXEL", "Pixel (noscript)")] 110 | ) # Tracking pixel or JS 111 | 112 | # Advanced page information 113 | location = models.TextField(blank=True, db_index=True) 114 | referrer = models.TextField(blank=True, db_index=True) 115 | load_time = models.FloatField(null=True, db_index=True) 116 | 117 | # While not necessary, we store the root service directly for performance. 118 | # It makes querying much easier; no need for inner joins. 119 | service = models.ForeignKey(Service, on_delete=models.CASCADE, db_index=True) 120 | 121 | class Meta: 122 | verbose_name = _("Hit") 123 | verbose_name_plural = _("Hits") 124 | ordering = ["-start_time"] 125 | indexes = [ 126 | models.Index(fields=["session", "-start_time"]), 127 | models.Index(fields=["service", "-start_time"]), 128 | models.Index(fields=["session", "location"]), 129 | models.Index(fields=["session", "referrer"]), 130 | ] 131 | 132 | @property 133 | def duration(self): 134 | return self.last_seen - self.start_time 135 | 136 | def get_absolute_url(self): 137 | return reverse( 138 | "dashboard:service_session", 139 | kwargs={"pk": self.service.pk, "session_pk": self.session.pk}, 140 | ) 141 | -------------------------------------------------------------------------------- /shynet/analytics/templates/analytics/scripts/page.js: -------------------------------------------------------------------------------- 1 | // This is a lightweight and privacy-friendly analytics script from Shynet, a self-hosted 2 | // analytics tool. To give you full visibility into how your data is being monitored, this 3 | // file is intentionally not minified or obfuscated. To learn more about Shynet (and to view 4 | // its source code), visit . 5 | // 6 | // This script only sends the current URL, the referrer URL, and the page load time. That's it! 7 | 8 | {% if dnt %} 9 | var Shynet = { 10 | dnt: true 11 | }; 12 | {% else %} 13 | var Shynet = { 14 | dnt: false, 15 | idempotency: null, 16 | heartbeatTaskId: null, 17 | skipHeartbeat: false, 18 | sendHeartbeat: function () { 19 | try { 20 | if (document.hidden || Shynet.skipHeartbeat) { 21 | return; 22 | } 23 | 24 | Shynet.skipHeartbeat = true; 25 | var xhr = new XMLHttpRequest(); 26 | xhr.open( 27 | "POST", 28 | "{{protocol}}://{{request.get_host}}{{endpoint}}", 29 | true 30 | ); 31 | xhr.setRequestHeader("Content-Type", "application/json"); 32 | xhr.onload = function () { 33 | Shynet.skipHeartbeat = false; 34 | }; 35 | xhr.onerror = function () { 36 | Shynet.skipHeartbeat = false; 37 | }; 38 | xhr.send( 39 | JSON.stringify({ 40 | idempotency: Shynet.idempotency, 41 | referrer: document.referrer, 42 | location: window.location.href, 43 | loadTime: 44 | window.performance.timing.domContentLoadedEventEnd - 45 | window.performance.timing.navigationStart, 46 | }) 47 | ); 48 | } catch (e) {} 49 | }, 50 | newPageLoad: function () { 51 | if (Shynet.heartbeatTaskId != null) { 52 | clearInterval(Shynet.heartbeatTaskId); 53 | } 54 | Shynet.idempotency = Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15); 55 | Shynet.skipHeartbeat = false; 56 | Shynet.heartbeatTaskId = setInterval(Shynet.sendHeartbeat, parseInt("{{heartbeat_frequency}}")); 57 | Shynet.sendHeartbeat(); 58 | } 59 | }; 60 | 61 | window.addEventListener("load", Shynet.newPageLoad); 62 | {% endif %} 63 | 64 | 65 | {% if script_inject %} 66 | // The following is script is not part of Shynet, and was instead 67 | // provided by this site's administrator. 68 | // 69 | // -- START -- 70 | {{script_inject|safe}} 71 | // -- END -- 72 | {% endif %} 73 | -------------------------------------------------------------------------------- /shynet/analytics/views/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/milesmcc/shynet/d07380bb27602d117f07f79b2b2728d43998619d/shynet/analytics/views/__init__.py -------------------------------------------------------------------------------- /shynet/analytics/views/ingress.py: -------------------------------------------------------------------------------- 1 | import base64 2 | import json 3 | from urllib.parse import urlparse 4 | 5 | from django.conf import settings 6 | from django.core.cache import cache 7 | from django.core.exceptions import ValidationError 8 | from django.http import ( 9 | Http404, 10 | HttpResponse, 11 | HttpResponseBadRequest, 12 | HttpResponseForbidden, 13 | ) 14 | from django.shortcuts import render, reverse 15 | from django.utils import timezone 16 | from django.utils.decorators import method_decorator 17 | from django.views.decorators.csrf import csrf_exempt 18 | from django.views.generic import View 19 | from ipware import get_client_ip 20 | 21 | from core.models import Service 22 | 23 | from ..tasks import ingress_request 24 | 25 | 26 | def ingress(request, service_uuid, identifier, tracker, payload): 27 | time = timezone.now() 28 | client_ip, is_routable = get_client_ip(request) 29 | location = request.META.get("HTTP_REFERER", "").strip() 30 | user_agent = request.META.get("HTTP_USER_AGENT", "").strip() 31 | dnt = request.META.get("HTTP_DNT", "0").strip() == "1" 32 | gpc = request.META.get("HTTP_SEC_GPC", "0").strip() == "1" 33 | if gpc or dnt: 34 | dnt = True 35 | 36 | ingress_request.delay( 37 | service_uuid, 38 | tracker, 39 | time, 40 | payload, 41 | client_ip, 42 | location, 43 | user_agent, 44 | dnt=dnt, 45 | identifier=identifier, 46 | ) 47 | 48 | 49 | class ValidateServiceOriginsMixin: 50 | def dispatch(self, request, *args, **kwargs): 51 | try: 52 | service_uuid = self.kwargs.get("service_uuid") 53 | origins = cache.get(f"service_origins_{service_uuid}") 54 | 55 | if origins is None: 56 | service = Service.objects.get(uuid=service_uuid) 57 | origins = service.origins 58 | cache.set(f"service_origins_{service_uuid}", origins, timeout=3600) 59 | 60 | allow_origin = "*" 61 | 62 | if origins != "*": 63 | remote_origin = request.META.get("HTTP_ORIGIN") 64 | if ( 65 | remote_origin is None 66 | and request.META.get("HTTP_REFERER") is not None 67 | ): 68 | parsed = urlparse(request.META.get("HTTP_REFERER")) 69 | remote_origin = f"{parsed.scheme}://{parsed.netloc}".lower() 70 | origins = [origin.strip().lower() for origin in origins.split(",")] 71 | if remote_origin in origins: 72 | allow_origin = remote_origin 73 | else: 74 | return HttpResponseForbidden() 75 | 76 | resp = super().dispatch(request, *args, **kwargs) 77 | resp["Access-Control-Allow-Origin"] = allow_origin 78 | resp["Access-Control-Allow-Methods"] = "GET,HEAD,OPTIONS,POST" 79 | resp[ 80 | "Access-Control-Allow-Headers" 81 | ] = "Origin, X-Requested-With, Content-Type, Accept, Authorization, Referer" 82 | return resp 83 | except Service.DoesNotExist: 84 | raise Http404() 85 | except ValidationError: 86 | return HttpResponseBadRequest() 87 | 88 | 89 | class PixelView(ValidateServiceOriginsMixin, View): 90 | # Fallback view to serve an unobtrusive 1x1 transparent tracking pixel for browsers with 91 | # JavaScript disabled. 92 | def get(self, *args, **kwargs): 93 | # Extract primary data 94 | ingress( 95 | self.request, 96 | self.kwargs.get("service_uuid"), 97 | self.kwargs.get("identifier", ""), 98 | "PIXEL", 99 | {}, 100 | ) 101 | 102 | data = base64.b64decode( 103 | "R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==" 104 | ) 105 | resp = HttpResponse(data, content_type="image/gif") 106 | resp["Cache-Control"] = "no-cache, no-store, must-revalidate" 107 | resp["Access-Control-Allow-Origin"] = "*" 108 | return resp 109 | 110 | 111 | @method_decorator(csrf_exempt, name="dispatch") 112 | class ScriptView(ValidateServiceOriginsMixin, View): 113 | def get(self, *args, **kwargs): 114 | protocol = "https" if settings.SCRIPT_USE_HTTPS else "http" 115 | endpoint = ( 116 | reverse( 117 | "ingress:endpoint_script", 118 | kwargs={ 119 | "service_uuid": self.kwargs.get("service_uuid"), 120 | }, 121 | ) 122 | if self.kwargs.get("identifier") is None 123 | else reverse( 124 | "ingress:endpoint_script_id", 125 | kwargs={ 126 | "service_uuid": self.kwargs.get("service_uuid"), 127 | "identifier": self.kwargs.get("identifier"), 128 | }, 129 | ) 130 | ) 131 | heartbeat_frequency = settings.SCRIPT_HEARTBEAT_FREQUENCY 132 | dnt = self.request.META.get("HTTP_DNT", "0").strip() == "1" 133 | service_uuid = self.kwargs.get("service_uuid") 134 | service = Service.objects.get(pk=service_uuid, status=Service.ACTIVE) 135 | response = render( 136 | self.request, 137 | "analytics/scripts/page.js", 138 | context=dict( 139 | { 140 | "endpoint": endpoint, 141 | "protocol": protocol, 142 | "heartbeat_frequency": heartbeat_frequency, 143 | "script_inject": self.get_script_inject(), 144 | "dnt": dnt and service.respect_dnt, 145 | } 146 | ), 147 | content_type="application/javascript", 148 | ) 149 | 150 | response["Cache-Control"] = "public, max-age=31536000" # 1 year 151 | return response 152 | 153 | def post(self, *args, **kwargs): 154 | payload = json.loads(self.request.body) 155 | ingress( 156 | self.request, 157 | self.kwargs.get("service_uuid"), 158 | self.kwargs.get("identifier", ""), 159 | "JS", 160 | payload, 161 | ) 162 | return HttpResponse( 163 | json.dumps({"status": "OK"}), content_type="application/json" 164 | ) 165 | 166 | def get_script_inject(self): 167 | service_uuid = self.kwargs.get("service_uuid") 168 | script_inject = cache.get(f"script_inject_{service_uuid}") 169 | if script_inject is None: 170 | service = Service.objects.get(uuid=service_uuid) 171 | script_inject = service.script_inject 172 | cache.set(f"script_inject_{service_uuid}", script_inject, timeout=3600) 173 | return script_inject 174 | -------------------------------------------------------------------------------- /shynet/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/milesmcc/shynet/d07380bb27602d117f07f79b2b2728d43998619d/shynet/api/__init__.py -------------------------------------------------------------------------------- /shynet/api/admin.py: -------------------------------------------------------------------------------- 1 | # from django.contrib import admin 2 | -------------------------------------------------------------------------------- /shynet/api/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class ApiConfig(AppConfig): 5 | default_auto_field = "django.db.models.BigAutoField" 6 | name = "api" 7 | -------------------------------------------------------------------------------- /shynet/api/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/milesmcc/shynet/d07380bb27602d117f07f79b2b2728d43998619d/shynet/api/migrations/__init__.py -------------------------------------------------------------------------------- /shynet/api/mixins.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | 3 | from django.contrib.auth import get_user_model 4 | from django.contrib.auth.models import AnonymousUser 5 | from django.http import JsonResponse 6 | 7 | User = get_user_model() 8 | 9 | 10 | class ApiTokenRequiredMixin: 11 | def _get_user_by_token(self, request): 12 | token = request.headers.get("Authorization") 13 | if not token or not token.startswith("Token "): 14 | return AnonymousUser() 15 | 16 | token = token.split(" ")[1] 17 | user: User = User.objects.filter(api_token=token).first() 18 | return user or AnonymousUser() 19 | 20 | def dispatch(self, request, *args, **kwargs): 21 | request.user = self._get_user_by_token(request) 22 | return ( 23 | super().dispatch(request, *args, **kwargs) 24 | if request.user.is_authenticated 25 | else JsonResponse(data={}, status=HTTPStatus.FORBIDDEN) 26 | ) 27 | -------------------------------------------------------------------------------- /shynet/api/models.py: -------------------------------------------------------------------------------- 1 | # from django.db import models 2 | -------------------------------------------------------------------------------- /shynet/api/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/milesmcc/shynet/d07380bb27602d117f07f79b2b2728d43998619d/shynet/api/tests/__init__.py -------------------------------------------------------------------------------- /shynet/api/tests/test_mixins.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | 3 | from django.test import TestCase, RequestFactory 4 | from django.views import View 5 | 6 | from api.mixins import ApiTokenRequiredMixin 7 | from core.factories import UserFactory 8 | from core.models import _default_api_token, Service 9 | 10 | 11 | class TestApiTokenRequiredMixin(TestCase): 12 | class DummyView(ApiTokenRequiredMixin, View): 13 | model = Service 14 | template_name = "dashboard/pages/service.html" 15 | 16 | def setUp(self): 17 | super().setUp() 18 | self.user = UserFactory() 19 | self.request = RequestFactory().get("/fake-path") 20 | 21 | # Setup request and view. 22 | self.factory = RequestFactory() 23 | self.view = self.DummyView() 24 | 25 | def test_get_user_by_token_without_authorization_token(self): 26 | """ 27 | GIVEN: A request without Authorization header 28 | WHEN: get_user_by_token is called 29 | THEN: It should return AnonymousUser 30 | """ 31 | user = self.view._get_user_by_token(self.request) 32 | 33 | self.assertEqual(user.is_anonymous, True) 34 | 35 | def test_get_user_by_token_with_invalid_authorization_token(self): 36 | """ 37 | GIVEN: A request with invalid Authorization header 38 | WHEN: get_user_by_token is called 39 | THEN: It should return AnonymousUser 40 | """ 41 | self.request.META["HTTP_AUTHORIZATION"] = "Bearer invalid-token" 42 | user = self.view._get_user_by_token(self.request) 43 | 44 | self.assertEqual(user.is_anonymous, True) 45 | 46 | def test_get_user_by_token_with_invalid_token(self): 47 | """ 48 | GIVEN: A request with invalid token 49 | WHEN: get_user_by_token is called 50 | THEN: It should return AnonymousUser 51 | """ 52 | self.request.META["HTTP_AUTHORIZATION"] = f"Token {_default_api_token()}" 53 | user = self.view._get_user_by_token(self.request) 54 | 55 | self.assertEqual(user.is_anonymous, True) 56 | 57 | def test_get_user_by_token_with_valid_token(self): 58 | """ 59 | GIVEN: A request with valid token 60 | WHEN: get_user_by_token is called 61 | THEN: It should return the user 62 | """ 63 | self.request.META["HTTP_AUTHORIZATION"] = f"Token {self.user.api_token}" 64 | user = self.view._get_user_by_token(self.request) 65 | 66 | self.assertEqual(user, self.user) 67 | 68 | def test_dispatch_with_unauthenticated_user(self): 69 | """ 70 | GIVEN: A request with unauthenticated user 71 | WHEN: dispatch is called 72 | THEN: It should return 403 73 | """ 74 | self.request.META["HTTP_AUTHORIZATION"] = f"Token {_default_api_token()}" 75 | response = self.view.dispatch(self.request) 76 | 77 | self.assertEqual(response.status_code, HTTPStatus.FORBIDDEN) 78 | -------------------------------------------------------------------------------- /shynet/api/tests/test_views.py: -------------------------------------------------------------------------------- 1 | import json 2 | from http import HTTPStatus 3 | 4 | from django.contrib.auth import get_user_model 5 | from django.test import TestCase, RequestFactory 6 | from django.urls import reverse 7 | 8 | from api.views import DashboardApiView 9 | from core.factories import UserFactory, ServiceFactory 10 | from core.models import Service 11 | 12 | User = get_user_model() 13 | 14 | 15 | class TestDashboardApiView(TestCase): 16 | def setUp(self) -> None: 17 | super().setUp() 18 | self.user: User = UserFactory() 19 | self.service_1: Service = ServiceFactory(owner=self.user) 20 | self.service_2: Service = ServiceFactory(owner=self.user) 21 | self.url = reverse("api:services") 22 | self.factory = RequestFactory() 23 | 24 | def test_get_with_unauthenticated_user(self): 25 | """ 26 | GIVEN: An unauthenticated user 27 | WHEN: The user makes a GET request to the dashboard API view 28 | THEN: It should return 403 29 | """ 30 | response = self.client.get(self.url) 31 | self.assertEqual(response.status_code, HTTPStatus.FORBIDDEN) 32 | 33 | def test_get_returns_400(self): 34 | """ 35 | GIVEN: An authenticated user 36 | WHEN: The user makes a GET request to the dashboard API view with an invalid date format 37 | THEN: It should return 400 38 | """ 39 | request = self.factory.get(self.url, {"startDate": "01/01/2000"}) 40 | request.META["HTTP_AUTHORIZATION"] = f"Token {self.user.api_token}" 41 | 42 | response = DashboardApiView.as_view()(request) 43 | self.assertEqual(response.status_code, HTTPStatus.BAD_REQUEST) 44 | 45 | data = json.loads(response.content) 46 | self.assertEqual(data["error"], "Invalid date format. Use YYYY-MM-DD.") 47 | 48 | def test_get_with_authenticated_user(self): 49 | """ 50 | GIVEN: An authenticated user 51 | WHEN: The user makes a GET request to the dashboard API view 52 | THEN: It should return 200 53 | """ 54 | request = self.factory.get(self.url) 55 | request.META["HTTP_AUTHORIZATION"] = f"Token {self.user.api_token}" 56 | 57 | response = DashboardApiView.as_view()(request) 58 | self.assertEqual(response.status_code, HTTPStatus.OK) 59 | 60 | data = json.loads(response.content) 61 | self.assertEqual(len(data["services"]), 2) 62 | 63 | def test_get_with_service_uuid(self): 64 | """ 65 | GIVEN: An authenticated user 66 | WHEN: The user makes a GET request to the dashboard API view with a service UUID 67 | THEN: It should return 200 and a single service 68 | """ 69 | request = self.factory.get(self.url, {"uuid": str(self.service_1.uuid)}) 70 | request.META["HTTP_AUTHORIZATION"] = f"Token {self.user.api_token}" 71 | 72 | response = DashboardApiView.as_view()(request) 73 | self.assertEqual(response.status_code, HTTPStatus.OK) 74 | 75 | data = json.loads(response.content) 76 | self.assertEqual(len(data["services"]), 1) 77 | self.assertEqual(data["services"][0]["uuid"], str(self.service_1.uuid)) 78 | self.assertEqual(data["services"][0]["name"], str(self.service_1.name)) 79 | 80 | -------------------------------------------------------------------------------- /shynet/api/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from . import views 4 | 5 | urlpatterns = [ 6 | path("dashboard/", views.DashboardApiView.as_view(), name="services"), 7 | ] 8 | -------------------------------------------------------------------------------- /shynet/api/views.py: -------------------------------------------------------------------------------- 1 | from http import HTTPStatus 2 | 3 | from django.db.models import Q 4 | from django.db.models.query import QuerySet 5 | from django.http import JsonResponse 6 | from django.views.generic import View 7 | 8 | from core.models import Service 9 | from core.utils import is_valid_uuid 10 | from dashboard.mixins import DateRangeMixin 11 | from .mixins import ApiTokenRequiredMixin 12 | 13 | 14 | class DashboardApiView(ApiTokenRequiredMixin, DateRangeMixin, View): 15 | def get(self, request, *args, **kwargs): 16 | services = Service.objects.filter(Q(owner=request.user) | Q(collaborators__in=[request.user])).distinct() 17 | 18 | uuid_ = request.GET.get("uuid") 19 | if uuid_ and is_valid_uuid(uuid_): 20 | services = services.filter(uuid=uuid_) 21 | 22 | try: 23 | start = self.get_start_date() 24 | end = self.get_end_date() 25 | except ValueError: 26 | return JsonResponse(status=HTTPStatus.BAD_REQUEST, data={"error": "Invalid date format. Use YYYY-MM-DD."}) 27 | 28 | service: Service 29 | services_data = [ 30 | { 31 | "name": service.name, 32 | "uuid": service.uuid, 33 | "link": service.link, 34 | "stats": service.get_core_stats(start, end), 35 | } 36 | for service in services 37 | ] 38 | 39 | services_data = self._convert_querysets_to_lists(services_data) 40 | 41 | return JsonResponse(data={"services": services_data}) 42 | 43 | def _convert_querysets_to_lists(self, services_data: list[dict]) -> list[dict]: 44 | for service_data in services_data: 45 | for key, value in service_data["stats"].items(): 46 | if isinstance(value, QuerySet): 47 | service_data["stats"][key] = list(value) 48 | for key, value in service_data["stats"]["compare"].items(): 49 | if isinstance(value, QuerySet): 50 | service_data["stats"]["compare"][key] = list(value) 51 | 52 | return services_data 53 | -------------------------------------------------------------------------------- /shynet/celeryworker.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Start queue worker processes 4 | echo Launching Shynet queue worker... 5 | exec celery -A shynet worker -E --loglevel=INFO --concurrency=3 -------------------------------------------------------------------------------- /shynet/core/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/milesmcc/shynet/d07380bb27602d117f07f79b2b2728d43998619d/shynet/core/__init__.py -------------------------------------------------------------------------------- /shynet/core/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.contrib.auth.admin import UserAdmin as BaseUserAdmin 3 | 4 | from .models import Service, User 5 | 6 | 7 | class UserAdmin(BaseUserAdmin): 8 | fieldsets = BaseUserAdmin.fieldsets + ( 9 | ("Extra fields", {"fields": ("api_token",)}), 10 | ) 11 | 12 | 13 | admin.site.register(User, UserAdmin) 14 | 15 | 16 | class ServiceAdmin(admin.ModelAdmin): 17 | list_display = ("name", "link", "owner", "status") 18 | list_display_links = ("name",) 19 | list_filter = ("status",) 20 | search_fields = ("name", "link", "owner") 21 | 22 | 23 | admin.site.register(Service, ServiceAdmin) 24 | -------------------------------------------------------------------------------- /shynet/core/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class CoreConfig(AppConfig): 5 | name = "core" 6 | 7 | # def ready(self): 8 | # import core.rules 9 | -------------------------------------------------------------------------------- /shynet/core/factories.py: -------------------------------------------------------------------------------- 1 | import factory 2 | from django.contrib.auth import get_user_model 3 | from factory import post_generation 4 | from factory.django import DjangoModelFactory 5 | 6 | from .models import Service 7 | 8 | 9 | class UserFactory(DjangoModelFactory): 10 | username = factory.Faker("user_name") 11 | email = factory.Faker("email") 12 | first_name = factory.Faker("name") 13 | 14 | @post_generation 15 | def password(self, create, extracted, **kwargs): 16 | password = ( 17 | extracted 18 | or factory.Faker( 19 | "password", 20 | length=42, 21 | special_chars=True, 22 | digits=True, 23 | upper_case=True, 24 | lower_case=True, 25 | ).evaluate(None, None, extra={"locale": None}) 26 | ) 27 | 28 | self.set_password(password) 29 | 30 | class Meta: 31 | model = get_user_model() 32 | django_get_or_create = ["username"] 33 | 34 | 35 | class ServiceFactory(DjangoModelFactory): 36 | class Meta: 37 | model = Service 38 | 39 | name = factory.Faker("company") 40 | -------------------------------------------------------------------------------- /shynet/core/locale/de/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2022-06-24 13:20+0200\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 20 | 21 | #: core/models.py:58 22 | msgid "Active" 23 | msgstr "Aktiv" 24 | 25 | #: core/models.py:58 26 | msgid "Archived" 27 | msgstr "Archiviert" 28 | 29 | #: core/models.py:61 30 | msgid "Name" 31 | msgstr "Name" 32 | 33 | #: core/models.py:63 34 | msgid "Owner" 35 | msgstr "Eigentümer" 36 | 37 | #: core/models.py:67 38 | msgid "Collaborators" 39 | msgstr "Mitarbeiter" 40 | 41 | #: core/models.py:70 42 | msgid "created" 43 | msgstr "Erstellt" 44 | 45 | #: core/models.py:71 46 | msgid "link" 47 | msgstr "Verweis" 48 | 49 | #: core/models.py:72 50 | msgid "origins" 51 | msgstr "" 52 | 53 | #: core/models.py:75 54 | msgid "status" 55 | msgstr "Status" 56 | 57 | #: core/models.py:77 58 | msgid "Respect dnt" 59 | msgstr "DNT beachten" 60 | 61 | #: core/models.py:78 62 | msgid "Ignore robots" 63 | msgstr "Robots ignorieren" 64 | 65 | #: core/models.py:79 66 | msgid "Collect ips" 67 | msgstr "IPs erfassen" 68 | 69 | #: core/models.py:82 70 | msgid "Igored ips" 71 | msgstr "IPs ignorieren" 72 | 73 | #: core/models.py:86 74 | msgid "Hide referrer regex" 75 | msgstr "Referrer Regex ausblenden" 76 | 77 | #: core/models.py:88 78 | msgid "Script inject" 79 | msgstr "" 80 | 81 | #: core/models.py:91 82 | msgid "Service" 83 | msgstr "Dienst" 84 | 85 | #: core/models.py:92 86 | msgid "Services" 87 | msgstr "Dienste" 88 | -------------------------------------------------------------------------------- /shynet/core/locale/zh_TW/LC_MESSAGES/django.po: -------------------------------------------------------------------------------- 1 | # SOME DESCRIPTIVE TITLE. 2 | # Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER 3 | # This file is distributed under the same license as the PACKAGE package. 4 | # FIRST AUTHOR , YEAR. 5 | # 6 | #, fuzzy 7 | msgid "" 8 | msgstr "" 9 | "Project-Id-Version: PACKAGE VERSION\n" 10 | "Report-Msgid-Bugs-To: \n" 11 | "POT-Creation-Date: 2022-06-24 13:20+0200\n" 12 | "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" 13 | "Last-Translator: FULL NAME \n" 14 | "Language-Team: LANGUAGE \n" 15 | "Language: \n" 16 | "MIME-Version: 1.0\n" 17 | "Content-Type: text/plain; charset=UTF-8\n" 18 | "Content-Transfer-Encoding: 8bit\n" 19 | "Plural-Forms: nplurals=2; plural=(n != 1);\n" 20 | 21 | #: core/models.py:58 22 | msgid "Active" 23 | msgstr "啟用" 24 | 25 | #: core/models.py:58 26 | msgid "Archived" 27 | msgstr "已封存" 28 | 29 | #: core/models.py:61 30 | msgid "Name" 31 | msgstr "名稱" 32 | 33 | #: core/models.py:63 34 | msgid "Owner" 35 | msgstr "擁有者" 36 | 37 | #: core/models.py:67 38 | msgid "Collaborators" 39 | msgstr "協作者" 40 | 41 | #: core/models.py:70 42 | msgid "created" 43 | msgstr "已建立" 44 | 45 | #: core/models.py:71 46 | msgid "link" 47 | msgstr "連結" 48 | 49 | #: core/models.py:72 50 | msgid "origins" 51 | msgstr "來源" 52 | 53 | #: core/models.py:75 54 | msgid "status" 55 | msgstr "狀態" 56 | 57 | #: core/models.py:77 58 | msgid "Respect dnt" 59 | msgstr "尊重停止追蹤 (Do Not Track) 設定" 60 | 61 | #: core/models.py:78 62 | msgid "Ignore robots" 63 | msgstr "忽略機器人" 64 | 65 | #: core/models.py:79 66 | msgid "Collect ips" 67 | msgstr "收集 IP" 68 | 69 | #: core/models.py:82 70 | msgid "Igored ips" 71 | msgstr "忽略的 IP" 72 | 73 | #: core/models.py:86 74 | msgid "Hide referrer regex" 75 | msgstr "隱藏來源參照正規表達式" 76 | 77 | #: core/models.py:88 78 | msgid "Script inject" 79 | msgstr "插入指令碼" 80 | 81 | #: core/models.py:91 82 | msgid "Service" 83 | msgstr "服務" 84 | 85 | #: core/models.py:92 86 | msgid "Services" 87 | msgstr "服務" 88 | -------------------------------------------------------------------------------- /shynet/core/management/commands/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/milesmcc/shynet/d07380bb27602d117f07f79b2b2728d43998619d/shynet/core/management/commands/__init__.py -------------------------------------------------------------------------------- /shynet/core/management/commands/demo.py: -------------------------------------------------------------------------------- 1 | import traceback 2 | from django.utils.timezone import now 3 | from django.utils.timezone import timedelta 4 | import random 5 | import uuid 6 | 7 | from django.conf import settings 8 | from django.contrib.sites.models import Site 9 | from django.core.management.base import BaseCommand, CommandError 10 | from django.utils.crypto import get_random_string 11 | import user_agents 12 | from logging import info 13 | 14 | from core.models import User, Service 15 | from analytics.models import Session, Hit 16 | from analytics.tasks import ingress_request 17 | 18 | LOCATIONS = [ 19 | "/", 20 | "/post/{rand}", 21 | "/login", 22 | "/me", 23 | ] 24 | 25 | REFERRERS = [ 26 | "https://news.ycombinator.com/item?id=11116274", 27 | "https://news.ycombinator.com/item?id=24872911", 28 | "https://reddit.com", 29 | "https://facebook.com", 30 | "https://twitter.com/milesmccain", 31 | "https://twitter.com", 32 | "https://stanford.edu/~mccain/", 33 | "https://tiktok.com", 34 | "https://io.stanford.edu", 35 | "https://en.wikipedia.org", 36 | "https://stackoverflow.com", 37 | "", 38 | "", 39 | "", 40 | "", 41 | ] 42 | 43 | USER_AGENTS = [ 44 | "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/77.0.3865.90 Safari/537.36", 45 | "Mozilla/5.0 (Macintosh; Intel Mac OS X x.y; rv:42.0) Gecko/20100101 Firefox/43.4", 46 | "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/77.0.3865.90 Safari/537.36", 47 | "Mozilla/5.0 (iPhone; CPU iPhone OS 11_3_1 like Mac OS X) AppleWebKit/603.1.30 (KHTML, like Gecko)", 48 | "Version/10.0 Mobile/14E304 Safari/602.1", 49 | ] 50 | 51 | 52 | class Command(BaseCommand): 53 | help = "Configures a Shynet demo service" 54 | 55 | def add_arguments(self, parser): 56 | parser.add_argument( 57 | "name", 58 | type=str, 59 | ) 60 | parser.add_argument("owner_email", type=str) 61 | parser.add_argument( 62 | "avg", 63 | type=int, 64 | ) 65 | parser.add_argument("deviation", type=float, default=0.4) 66 | parser.add_argument( 67 | "days", 68 | type=int, 69 | ) 70 | parser.add_argument("load_time", type=float, default=1000) 71 | 72 | def handle(self, *args, **options): 73 | owner = User.objects.get(email=options.get("owner_email")) 74 | service = Service.objects.create(name=options.get("name"), owner=owner) 75 | 76 | print( 77 | f"Created demo service `{service.name}` (uuid: `{service.uuid}`, owner: {owner})" 78 | ) 79 | 80 | # Go through each day requested, creating sessions and hits 81 | for days in range(options.get("days")): 82 | day = (now() - timedelta(days=days)).replace(hour=0, minute=0, second=0) 83 | print(f"Populating info for {day}...") 84 | avg = options.get("avg") 85 | deviation = options.get("deviation") 86 | ips = [ 87 | ".".join(map(str, (random.randint(0, 255) for _ in range(4)))) 88 | for _ in range(avg) 89 | ] 90 | 91 | n = avg + random.randrange(-1 * deviation * avg, deviation * avg) 92 | for _ in range(n): 93 | time = day + timedelta( 94 | hours=random.randrange(0, 23), 95 | minutes=random.randrange(0, 59), 96 | seconds=random.randrange(0, 59), 97 | ) 98 | ip = random.choice(ips) 99 | load_time = random.normalvariate(options.get("load_time"), 500) 100 | referrer = random.choice(REFERRERS) 101 | location = "https://example.com" + random.choice(LOCATIONS).replace( 102 | "{rand}", str(random.randint(0, n)) 103 | ) 104 | user_agent = random.choice(USER_AGENTS) 105 | ingress_request( 106 | service.uuid, 107 | "JS", 108 | time, 109 | {"loadTime": load_time, "referrer": referrer}, 110 | ip, 111 | location, 112 | user_agent, 113 | ) 114 | 115 | print(f"Created {n} demo hits on {day}!") 116 | 117 | self.stdout.write(self.style.SUCCESS(f"Successfully created demo data!")) 118 | -------------------------------------------------------------------------------- /shynet/core/management/commands/registeradmin.py: -------------------------------------------------------------------------------- 1 | import traceback 2 | import uuid 3 | 4 | from django.conf import settings 5 | from django.contrib.sites.models import Site 6 | from django.core.management.base import BaseCommand, CommandError 7 | from django.utils.crypto import get_random_string 8 | 9 | from core.models import User 10 | 11 | 12 | class Command(BaseCommand): 13 | help = "Creates an admin user with an auto-generated password" 14 | 15 | def add_arguments(self, parser): 16 | parser.add_argument( 17 | "email", 18 | type=str, 19 | ) 20 | 21 | def handle(self, *args, **options): 22 | email = options.get("email") 23 | password = get_random_string(10) 24 | User.objects.create_superuser(str(uuid.uuid4()), email=email, password=password) 25 | self.stdout.write(self.style.SUCCESS("Successfully created a Shynet superuser")) 26 | self.stdout.write(f"Email address: {email}") 27 | self.stdout.write(f"Password: {password}") 28 | -------------------------------------------------------------------------------- /shynet/core/management/commands/startup_checks.py: -------------------------------------------------------------------------------- 1 | import traceback 2 | import uuid 3 | 4 | from django.conf import settings 5 | from django.contrib.sites.models import Site 6 | from django.core.exceptions import ImproperlyConfigured 7 | from django.core.management.base import BaseCommand, CommandError 8 | from django.db import DEFAULT_DB_ALIAS, connections 9 | from django.db.utils import ConnectionHandler, OperationalError 10 | from django.utils.crypto import get_random_string 11 | 12 | from core.models import User 13 | 14 | 15 | class Command(BaseCommand): 16 | help = "Internal command to perform startup checks." 17 | 18 | def check_migrations(self): 19 | from django.db.migrations.executor import MigrationExecutor 20 | 21 | try: 22 | executor = MigrationExecutor(connections[DEFAULT_DB_ALIAS]) 23 | except OperationalError: 24 | # DB_NAME database not found? 25 | return True 26 | except ImproperlyConfigured: 27 | # No databases are configured (or the dummy one) 28 | return True 29 | 30 | if executor.migration_plan(executor.loader.graph.leaf_nodes()): 31 | return True 32 | 33 | return False 34 | 35 | def handle(self, *args, **options): 36 | migration = self.check_migrations() 37 | 38 | admin, whitelabel = [True] * 2 39 | if not migration: 40 | admin = not User.objects.all().exists() 41 | whitelabel = ( 42 | not Site.objects.filter(name__isnull=False) 43 | .exclude(name__exact="") 44 | .exclude(name__exact="example.com") 45 | .exists() 46 | ) 47 | 48 | self.stdout.write(self.style.SUCCESS(f"{migration} {admin} {whitelabel}")) 49 | -------------------------------------------------------------------------------- /shynet/core/management/commands/whitelabel.py: -------------------------------------------------------------------------------- 1 | import traceback 2 | import uuid 3 | 4 | from django.conf import settings 5 | from django.contrib.sites.models import Site 6 | from django.core.management.base import BaseCommand, CommandError 7 | from django.utils.crypto import get_random_string 8 | 9 | from core.models import User 10 | 11 | 12 | class Command(BaseCommand): 13 | help = "Configures a Shynet whitelabel" 14 | 15 | def add_arguments(self, parser): 16 | parser.add_argument( 17 | "name", 18 | type=str, 19 | ) 20 | 21 | def handle(self, *args, **options): 22 | site = Site.objects.get(pk=settings.SITE_ID) 23 | site.name = options.get("name") 24 | site.save() 25 | self.stdout.write( 26 | self.style.SUCCESS( 27 | f"Successfully set the whitelabel to '{options.get('name')}'" 28 | ) 29 | ) 30 | -------------------------------------------------------------------------------- /shynet/core/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.5 on 2020-04-14 14:40 2 | 3 | import django.contrib.auth.models 4 | import django.db.models.deletion 5 | import django.utils.timezone 6 | from django.conf import settings 7 | from django.db import migrations, models 8 | 9 | import core.models 10 | 11 | 12 | class Migration(migrations.Migration): 13 | 14 | initial = True 15 | 16 | dependencies = [ 17 | ("auth", "0011_update_proxy_permissions"), 18 | ] 19 | 20 | operations = [ 21 | migrations.CreateModel( 22 | name="User", 23 | fields=[ 24 | ( 25 | "id", 26 | models.AutoField( 27 | auto_created=True, 28 | primary_key=True, 29 | serialize=False, 30 | verbose_name="ID", 31 | ), 32 | ), 33 | ("password", models.CharField(max_length=128, verbose_name="password")), 34 | ( 35 | "last_login", 36 | models.DateTimeField( 37 | blank=True, null=True, verbose_name="last login" 38 | ), 39 | ), 40 | ( 41 | "is_superuser", 42 | models.BooleanField( 43 | default=False, 44 | help_text="Designates that this user has all permissions without explicitly assigning them.", 45 | verbose_name="superuser status", 46 | ), 47 | ), 48 | ( 49 | "first_name", 50 | models.CharField( 51 | blank=True, max_length=30, verbose_name="first name" 52 | ), 53 | ), 54 | ( 55 | "last_name", 56 | models.CharField( 57 | blank=True, max_length=150, verbose_name="last name" 58 | ), 59 | ), 60 | ( 61 | "is_staff", 62 | models.BooleanField( 63 | default=False, 64 | help_text="Designates whether the user can log into this admin site.", 65 | verbose_name="staff status", 66 | ), 67 | ), 68 | ( 69 | "is_active", 70 | models.BooleanField( 71 | default=True, 72 | help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.", 73 | verbose_name="active", 74 | ), 75 | ), 76 | ( 77 | "date_joined", 78 | models.DateTimeField( 79 | default=django.utils.timezone.now, verbose_name="date joined" 80 | ), 81 | ), 82 | ( 83 | "username", 84 | models.TextField(default=core.models._default_uuid, unique=True), 85 | ), 86 | ("email", models.EmailField(max_length=254, unique=True)), 87 | ( 88 | "groups", 89 | models.ManyToManyField( 90 | blank=True, 91 | help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.", 92 | related_name="user_set", 93 | related_query_name="user", 94 | to="auth.Group", 95 | verbose_name="groups", 96 | ), 97 | ), 98 | ( 99 | "user_permissions", 100 | models.ManyToManyField( 101 | blank=True, 102 | help_text="Specific permissions for this user.", 103 | related_name="user_set", 104 | related_query_name="user", 105 | to="auth.Permission", 106 | verbose_name="user permissions", 107 | ), 108 | ), 109 | ], 110 | options={ 111 | "verbose_name": "user", 112 | "verbose_name_plural": "users", 113 | "abstract": False, 114 | }, 115 | managers=[ 116 | ("objects", django.contrib.auth.models.UserManager()), 117 | ], 118 | ), 119 | migrations.CreateModel( 120 | name="Service", 121 | fields=[ 122 | ( 123 | "uuid", 124 | models.UUIDField( 125 | default=core.models._default_uuid, 126 | primary_key=True, 127 | serialize=False, 128 | ), 129 | ), 130 | ("name", models.TextField(max_length=64)), 131 | ("created", models.DateTimeField(auto_now_add=True)), 132 | ("link", models.URLField(blank=True)), 133 | ("origins", models.TextField(default="*")), 134 | ( 135 | "status", 136 | models.CharField( 137 | choices=[("AC", "Active"), ("AR", "Archived")], 138 | db_index=True, 139 | default="AC", 140 | max_length=2, 141 | ), 142 | ), 143 | ( 144 | "collaborators", 145 | models.ManyToManyField( 146 | blank=True, 147 | related_name="collaborating_services", 148 | to=settings.AUTH_USER_MODEL, 149 | ), 150 | ), 151 | ( 152 | "owner", 153 | models.ForeignKey( 154 | on_delete=django.db.models.deletion.CASCADE, 155 | related_name="owning_services", 156 | to=settings.AUTH_USER_MODEL, 157 | ), 158 | ), 159 | ], 160 | ), 161 | ] 162 | -------------------------------------------------------------------------------- /shynet/core/migrations/0002_auto_20200415_1742.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.5 on 2020-04-15 21:42 2 | 3 | from django.db import migrations 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("core", "0001_initial"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AlterModelOptions( 14 | name="service", 15 | options={"ordering": ["name", "uuid"]}, 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /shynet/core/migrations/0003_service_respect_dnt.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.5 on 2020-04-22 17:03 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("core", "0002_auto_20200415_1742"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="service", 15 | name="respect_dnt", 16 | field=models.BooleanField(default=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /shynet/core/migrations/0004_service_collect_ips.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.5 on 2020-05-02 16:22 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("core", "0003_service_respect_dnt"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="service", 15 | name="collect_ips", 16 | field=models.BooleanField(default=True), 17 | ), 18 | ] 19 | -------------------------------------------------------------------------------- /shynet/core/migrations/0005_service_ignored_ips.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.6 on 2020-05-07 20:28 2 | 3 | from django.db import migrations, models 4 | 5 | import core.models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ("core", "0004_service_collect_ips"), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name="service", 17 | name="ignored_ips", 18 | field=models.TextField( 19 | blank=True, default="", validators=[core.models._validate_network_list] 20 | ), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /shynet/core/migrations/0006_service_hide_referrer_regex.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.6 on 2020-05-07 21:23 2 | 3 | from django.db import migrations, models 4 | 5 | import core.models 6 | 7 | 8 | class Migration(migrations.Migration): 9 | 10 | dependencies = [ 11 | ("core", "0005_service_ignored_ips"), 12 | ] 13 | 14 | operations = [ 15 | migrations.AddField( 16 | model_name="service", 17 | name="hide_referrer_regex", 18 | field=models.TextField( 19 | blank=True, default="", validators=[core.models._validate_regex] 20 | ), 21 | ), 22 | ] 23 | -------------------------------------------------------------------------------- /shynet/core/migrations/0007_service_ignore_robots.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.0.6 on 2020-06-15 16:16 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("core", "0006_service_hide_referrer_regex"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="service", 15 | name="ignore_robots", 16 | field=models.BooleanField(default=False), 17 | ) 18 | ] 19 | -------------------------------------------------------------------------------- /shynet/core/migrations/0008_auto_20200628_1403.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.1b1 on 2020-06-28 18:03 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | 8 | dependencies = [ 9 | ("core", "0007_service_ignore_robots"), 10 | ] 11 | 12 | operations = [ 13 | migrations.AddField( 14 | model_name="service", 15 | name="script_inject", 16 | field=models.TextField(blank=True, default=""), 17 | ), 18 | migrations.AlterField( 19 | model_name="user", 20 | name="first_name", 21 | field=models.CharField( 22 | blank=True, max_length=150, verbose_name="first name" 23 | ), 24 | ), 25 | ] 26 | -------------------------------------------------------------------------------- /shynet/core/migrations/0009_auto_20211117_0217.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.5 on 2021-11-17 07:17 2 | 3 | import core.models 4 | from django.db import migrations, models 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | dependencies = [ 10 | ('core', '0008_auto_20200628_1403'), 11 | ] 12 | 13 | operations = [ 14 | migrations.AddField( 15 | model_name='user', 16 | name='api_token', 17 | field=models.TextField(null=True, unique=True), 18 | ), 19 | migrations.AlterField( 20 | model_name='user', 21 | name='id', 22 | field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), 23 | ), 24 | ] 25 | -------------------------------------------------------------------------------- /shynet/core/migrations/0010_auto_20220624_0744.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 3.2.12 on 2022-06-24 11:44 2 | 3 | import core.models 4 | from django.conf import settings 5 | from django.db import migrations, models 6 | import django.db.models.deletion 7 | 8 | 9 | class Migration(migrations.Migration): 10 | dependencies = [ 11 | ('core', '0009_auto_20211117_0217'), 12 | ] 13 | 14 | operations = [ 15 | migrations.AlterModelOptions( 16 | name='service', 17 | options={'ordering': ['name', 'uuid'], 'verbose_name': 'Service', 'verbose_name_plural': 'Services'}, 18 | ), 19 | migrations.AlterField( 20 | model_name='service', 21 | name='collaborators', 22 | field=models.ManyToManyField(blank=True, related_name='collaborating_services', to=settings.AUTH_USER_MODEL, verbose_name='Collaborators'), 23 | ), 24 | migrations.AlterField( 25 | model_name='service', 26 | name='collect_ips', 27 | field=models.BooleanField(default=True, verbose_name='Collect ips'), 28 | ), 29 | migrations.AlterField( 30 | model_name='service', 31 | name='created', 32 | field=models.DateTimeField(auto_now_add=True, verbose_name='created'), 33 | ), 34 | migrations.AlterField( 35 | model_name='service', 36 | name='hide_referrer_regex', 37 | field=models.TextField(blank=True, default='', validators=[core.models._validate_regex], verbose_name='Hide referrer regex'), 38 | ), 39 | migrations.AlterField( 40 | model_name='service', 41 | name='ignore_robots', 42 | field=models.BooleanField(default=False, verbose_name='Ignore robots'), 43 | ), 44 | migrations.AlterField( 45 | model_name='service', 46 | name='ignored_ips', 47 | field=models.TextField(blank=True, default='', validators=[core.models._validate_network_list], verbose_name='Igored ips'), 48 | ), 49 | migrations.AlterField( 50 | model_name='service', 51 | name='link', 52 | field=models.URLField(blank=True, verbose_name='link'), 53 | ), 54 | migrations.AlterField( 55 | model_name='service', 56 | name='name', 57 | field=models.TextField(max_length=64, verbose_name='Name'), 58 | ), 59 | migrations.AlterField( 60 | model_name='service', 61 | name='origins', 62 | field=models.TextField(default='*', verbose_name='origins'), 63 | ), 64 | migrations.AlterField( 65 | model_name='service', 66 | name='owner', 67 | field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='owning_services', to=settings.AUTH_USER_MODEL, verbose_name='Owner'), 68 | ), 69 | migrations.AlterField( 70 | model_name='service', 71 | name='respect_dnt', 72 | field=models.BooleanField(default=True, verbose_name='Respect dnt'), 73 | ), 74 | migrations.AlterField( 75 | model_name='service', 76 | name='script_inject', 77 | field=models.TextField(blank=True, default='', verbose_name='Script inject'), 78 | ), 79 | migrations.AlterField( 80 | model_name='service', 81 | name='status', 82 | field=models.CharField(choices=[('AC', 'Active'), ('AR', 'Archived')], db_index=True, default='AC', max_length=2, verbose_name='status'), 83 | ), 84 | migrations.AlterField( 85 | model_name='user', 86 | name='id', 87 | field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'), 88 | ), 89 | ] 90 | -------------------------------------------------------------------------------- /shynet/core/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/milesmcc/shynet/d07380bb27602d117f07f79b2b2728d43998619d/shynet/core/migrations/__init__.py -------------------------------------------------------------------------------- /shynet/core/rules.py: -------------------------------------------------------------------------------- 1 | import rules 2 | from django.conf import settings 3 | 4 | 5 | @rules.predicate 6 | def is_service_creator(user): 7 | if settings.ONLY_SUPERUSERS_CREATE: 8 | return user.is_superuser 9 | return True 10 | 11 | 12 | @rules.predicate 13 | def is_service_owner(user, service): 14 | return service.owner == user 15 | 16 | 17 | @rules.predicate 18 | def is_service_collaborator(user, service): 19 | return service.collaborators.filter(pk=user.pk).exists() 20 | 21 | 22 | rules.add_perm("core.view_service", is_service_owner | is_service_collaborator) 23 | rules.add_perm("core.delete_service", is_service_owner) 24 | rules.add_perm("core.change_service", is_service_owner) 25 | rules.add_perm("core.create_service", is_service_creator) 26 | -------------------------------------------------------------------------------- /shynet/core/tests/__init__.py: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /shynet/core/urls.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | from django.urls import include, path, reverse_lazy 3 | from django.views.generic import RedirectView 4 | 5 | from . import views 6 | 7 | urlpatterns = [ 8 | path( 9 | "", RedirectView.as_view(url=reverse_lazy("dashboard:dashboard")), name="index" 10 | ), 11 | ] 12 | -------------------------------------------------------------------------------- /shynet/core/utils.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | 3 | 4 | def is_valid_uuid(value: str) -> bool: 5 | """Check if a string is a valid UUID.""" 6 | try: 7 | uuid.UUID(value) 8 | return True 9 | except ValueError: 10 | return False 11 | -------------------------------------------------------------------------------- /shynet/core/views.py: -------------------------------------------------------------------------------- 1 | from django.views.generic import TemplateView 2 | 3 | 4 | class IndexView(TemplateView): 5 | template_name = "dashboard/pages/index.html" 6 | -------------------------------------------------------------------------------- /shynet/dashboard/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/milesmcc/shynet/d07380bb27602d117f07f79b2b2728d43998619d/shynet/dashboard/__init__.py -------------------------------------------------------------------------------- /shynet/dashboard/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | from django.conf import settings 3 | 4 | 5 | class DashboardConfig(AppConfig): 6 | name = "dashboard" 7 | 8 | def ready(self): 9 | if not settings.ACCOUNT_SIGNUPS_ENABLED: 10 | # Normally you'd do this in settings.py, but this must be done _after_ apps are enabled 11 | from allauth.account.adapter import DefaultAccountAdapter 12 | 13 | DefaultAccountAdapter.is_open_for_signup = lambda k, v: False 14 | -------------------------------------------------------------------------------- /shynet/dashboard/forms.py: -------------------------------------------------------------------------------- 1 | from allauth.account.admin import EmailAddress 2 | from django import forms 3 | from django.conf import settings 4 | from django.utils.translation import gettext_lazy as _ 5 | 6 | from core.models import Service, User 7 | 8 | 9 | class ServiceForm(forms.ModelForm): 10 | class Meta: 11 | model = Service 12 | fields = [ 13 | "name", 14 | "link", 15 | "respect_dnt", 16 | "collect_ips", 17 | "ignored_ips", 18 | "ignore_robots", 19 | "hide_referrer_regex", 20 | "origins", 21 | "collaborators", 22 | "script_inject", 23 | ] 24 | widgets = { 25 | "name": forms.TextInput(), 26 | "origins": forms.TextInput(), 27 | "ignored_ips": forms.TextInput(), 28 | "respect_dnt": forms.RadioSelect( 29 | choices=[(True, _("Yes")), (False, _("No"))] 30 | ), 31 | "collect_ips": forms.RadioSelect( 32 | choices=[(True, _("Yes")), (False, _("No"))] 33 | ), 34 | "ignore_robots": forms.RadioSelect( 35 | choices=[(True, _("Yes")), (False, _("No"))] 36 | ), 37 | "hide_referrer_regex": forms.TextInput(), 38 | "script_inject": forms.Textarea(attrs={"class": "font-mono", "rows": 5}), 39 | } 40 | labels = { 41 | "origins": _("Allowed origins"), 42 | "respect_dnt": _("Respect DNT"), 43 | "ignored_ips": _("Ignored IP addresses"), 44 | "ignore_robots": _("Ignore robots"), 45 | "hide_referrer_regex": _("Hide specific referrers"), 46 | "script_inject": _("Additional injected JS"), 47 | } 48 | help_texts = { 49 | "name": _("What should the service be called?"), 50 | "link": _("What's the service's primary URL?"), 51 | "origins": _( 52 | "At what origins does the service operate? Use commas to separate multiple values. This sets CORS headers, so use '*' if you're not sure (or don't care)." 53 | ), 54 | "respect_dnt": _( 55 | "Should visitors who have enabled Do Not Track be excluded from all data?" 56 | ), 57 | "ignored_ips": _( 58 | "A comma-separated list of IP addresses or IP ranges (IPv4 and IPv6) to exclude from tracking (e.g., '192.168.0.2, 127.0.0.1/32')." 59 | ), 60 | "ignore_robots": _( 61 | "Should sessions generated by bots be excluded from tracking?" 62 | ), 63 | "hide_referrer_regex": _( 64 | "Any referrers that match this RegEx will not be listed in the referrer summary. Sessions will still be tracked normally. No effect if left blank." 65 | ), 66 | "script_inject": _( 67 | "Optional additional JavaScript to inject at the end of the Shynet script. This code will be injected on every page where this service is installed." 68 | ), 69 | } 70 | 71 | collect_ips = forms.BooleanField( 72 | help_text=_("IP address collection is disabled globally by your administrator.") 73 | if settings.BLOCK_ALL_IPS 74 | else _( 75 | "Should individual IP addresses be collected? IP metadata (location, host, etc) will still be collected." 76 | ), 77 | widget=forms.RadioSelect(choices=[(True, _("Yes")), (False, _("No"))]), 78 | initial=False if settings.BLOCK_ALL_IPS else True, 79 | required=False, 80 | disabled=settings.BLOCK_ALL_IPS, 81 | ) 82 | 83 | def clean_collect_ips(self): 84 | collect_ips = self.cleaned_data["collect_ips"] 85 | # Forces collect IPs to be false if it is disabled globally 86 | return False if settings.BLOCK_ALL_IPS else collect_ips 87 | 88 | collaborators = forms.CharField( 89 | help_text=_( 90 | "Which users on this Shynet instance should have read-only access to this service? (Comma separated list of emails.)" 91 | ), 92 | required=False, 93 | ) 94 | 95 | def clean_collaborators(self): 96 | collaborators = [] 97 | users_to_emails = ( 98 | {} 99 | ) # maps users to the email they are listed under as a collaborator 100 | for collaborator_email in self.cleaned_data["collaborators"].split(","): 101 | email = collaborator_email.strip() 102 | if email == "": 103 | continue 104 | collaborator_email_linked = EmailAddress.objects.filter( 105 | email__iexact=email 106 | ).first() 107 | if collaborator_email_linked is None: 108 | raise forms.ValidationError(f"Email '{email}' is not registered") 109 | user = collaborator_email_linked.user 110 | if user in collaborators: 111 | raise forms.ValidationError( 112 | f"The emails '{email}' and '{users_to_emails[user]}' both correspond to the same user" 113 | ) 114 | users_to_emails[user] = email 115 | collaborators.append(collaborator_email_linked.user) 116 | return collaborators 117 | 118 | def get_initial_for_field(self, field, field_name): 119 | initial = super(ServiceForm, self).get_initial_for_field(field, field_name) 120 | if field_name == "collaborators": 121 | return ", ".join([user.email for user in (initial or [])]) 122 | return initial 123 | -------------------------------------------------------------------------------- /shynet/dashboard/mixins.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, time 2 | 3 | from django.utils import timezone 4 | 5 | 6 | class DateRangeMixin: 7 | def get_start_date(self): 8 | if self.request.GET.get("startDate") is not None: 9 | found_time = timezone.datetime.strptime( 10 | self.request.GET.get("startDate"), "%Y-%m-%d" 11 | ) 12 | return timezone.make_aware(datetime.combine(found_time, time.min)) 13 | else: 14 | return timezone.now() - timezone.timedelta(days=30) 15 | 16 | def get_end_date(self): 17 | if self.request.GET.get("endDate") is not None: 18 | found_time = timezone.datetime.strptime( 19 | self.request.GET.get("endDate"), "%Y-%m-%d" 20 | ) 21 | return timezone.make_aware(datetime.combine(found_time, time.max)) 22 | else: 23 | return timezone.now() 24 | 25 | def get_date_ranges(self): 26 | now = timezone.now() 27 | return [ 28 | { 29 | "name": "Last 3 days", 30 | "start": now - timezone.timedelta(days=2), 31 | "end": now, 32 | }, 33 | { 34 | "name": "Last 30 days", 35 | "start": now - timezone.timedelta(days=29), 36 | "end": now, 37 | }, 38 | { 39 | "name": "Last 90 days", 40 | "start": now - timezone.timedelta(days=89), 41 | "end": now, 42 | }, 43 | { 44 | "name": "This month", 45 | "start": now.replace(day=1), 46 | "end": now, 47 | }, 48 | { 49 | "name": "Last month", 50 | "start": (now.replace(day=1) - timezone.timedelta(days=1)).replace( 51 | day=1 52 | ), 53 | "end": now.replace(day=1) - timezone.timedelta(days=1), 54 | }, 55 | { 56 | "name": "This year", 57 | "start": now.replace(day=1, month=1), 58 | "end": now, 59 | }, 60 | ] 61 | 62 | def get_context_data(self, **kwargs): 63 | data = super().get_context_data(**kwargs) 64 | data["start_date"] = self.get_start_date() 65 | data["end_date"] = self.get_end_date() 66 | data["date_ranges"] = self.get_date_ranges() 67 | 68 | return data 69 | -------------------------------------------------------------------------------- /shynet/dashboard/static/dashboard/css/global.css: -------------------------------------------------------------------------------- 1 | .table tbody tr:nth-child(2n) { 2 | background-color: transparent; 3 | } 4 | 5 | .table { 6 | overflow-x: auto; 7 | } 8 | 9 | .limited-height { 10 | overflow: auto; 11 | max-height: 400px; 12 | } 13 | 14 | .rf { 15 | text-align: right !important; 16 | } 17 | 18 | .chart-card .apexcharts-svg { 19 | border-radius: 0 0 var(--border-radius-lg, 0.5rem) var(--border-radius-lg, 0.5rem); 20 | } 21 | 22 | .max-w-0 { 23 | max-width: 0; 24 | } 25 | 26 | .min-w-48 { 27 | min-width: 48px; 28 | } 29 | 30 | .geo-table { 31 | display: none; 32 | } 33 | 34 | .geo-card--use-table-view .geo-map { 35 | display: none; 36 | } 37 | 38 | .geo-card--use-table-view .geo-table { 39 | display: inline-block; 40 | } 41 | 42 | :root { 43 | --color-neutral-000: white; 44 | --color-neutral-50: #F8FAFC; 45 | --color-neutral-100: #F1F5F9; 46 | --color-neutral-200: #E2E8F0; 47 | --color-neutral-300: #CBD5E1; 48 | --color-neutral-400: #94A3B8; 49 | --color-neutral-500: #64748B; 50 | --color-neutral-600: #475569; 51 | --color-neutral-700: #334155; 52 | --color-neutral-800: #1E293B; 53 | --color-neutral-900: #0F172A; 54 | } -------------------------------------------------------------------------------- /shynet/dashboard/static/dashboard/images/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/milesmcc/shynet/d07380bb27602d117f07f79b2b2728d43998619d/shynet/dashboard/static/dashboard/images/icon.png -------------------------------------------------------------------------------- /shynet/dashboard/tasks.py: -------------------------------------------------------------------------------- 1 | from celery import shared_task 2 | from django.core import mail 3 | from django.conf import settings 4 | import html2text 5 | 6 | 7 | @shared_task 8 | def send_email(to: [str], subject: str, content: str, from_email: str = None): 9 | text_content = html2text.html2text(content) 10 | mail.send_mail( 11 | subject, 12 | text_content, 13 | from_email or settings.DEFAULT_FROM_EMAIL, 14 | to, 15 | html_message=content, 16 | ) 17 | -------------------------------------------------------------------------------- /shynet/dashboard/templates/account/account_inactive.html: -------------------------------------------------------------------------------- 1 | {% extends "account/base.html" %} 2 | 3 | {% load i18n %} 4 | 5 | {% block head_title %}{% trans "Account Inactive" %}{% endblock %} 6 | {% block page_title %}{% trans "Account Inactive" %}{% endblock %} 7 | 8 | {% block card %} 9 |

{% trans "This account is inactive." %}

10 | {% endblock %} 11 | -------------------------------------------------------------------------------- /shynet/dashboard/templates/account/base.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block content %} 4 |
5 |

{% block page_title %}{% endblock %}

6 |
7 |
8 | {% block main %} 9 |
10 | {% block card %} 11 | {% endblock %} 12 |
13 | {% endblock %} 14 | {% endblock %} -------------------------------------------------------------------------------- /shynet/dashboard/templates/account/email.html: -------------------------------------------------------------------------------- 1 | {% extends "account/base.html" %} 2 | 3 | {% load i18n a17t_tags %} 4 | 5 | {% block head_title %}{% trans "Email Addresses" %}{% endblock %} 6 | {% block page_title %}{% trans "Email Addresses" %}{% endblock %} 7 | 8 | {% block main %} 9 |
10 | 11 | {% if user.emailaddress_set.all %} 12 |

{% trans 'These are your known email addresses:' %}

13 | 42 | 43 | {% else %} 44 | 49 | {% endif %} 50 |
51 | 52 |
53 | 54 |
55 | {% csrf_token %} 56 | {{ form|a17t }} 57 | 58 |
59 | 60 | {% endblock %} 61 | 62 | 63 | {% block extra_body %} 64 | 77 | {% endblock %} -------------------------------------------------------------------------------- /shynet/dashboard/templates/account/email/email_confirmation_message.txt: -------------------------------------------------------------------------------- 1 | {% load account %}{% user_display user as user_display %}{% load i18n %}{% autoescape off %}{% blocktrans with site_name=current_site.name site_domain=request.get_host %}Hi there, 2 | 3 | You're receiving this email because {{ user_display }} has listed this email as a valid contact address for their account. 4 | 5 | To confirm this is correct, go to {{ activate_url }} 6 | {% endblocktrans %} 7 | {% blocktrans with site_name=current_site.name site_domain=request.get_host %}Thank you, 8 | {{ site_name }} 9 | {% endblocktrans %} 10 | {% endautoescape %} 11 | -------------------------------------------------------------------------------- /shynet/dashboard/templates/account/email/email_confirmation_signup_message.txt: -------------------------------------------------------------------------------- 1 | {% include "account/email/email_confirmation_message.txt" %} 2 | -------------------------------------------------------------------------------- /shynet/dashboard/templates/account/email/email_confirmation_signup_subject.txt: -------------------------------------------------------------------------------- 1 | {% include "account/email/email_confirmation_subject.txt" %} 2 | -------------------------------------------------------------------------------- /shynet/dashboard/templates/account/email/email_confirmation_subject.txt: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% autoescape off %} 3 | {% blocktrans %}Confirm Email Address{% endblocktrans %} 4 | {% endautoescape %} 5 | -------------------------------------------------------------------------------- /shynet/dashboard/templates/account/email/password_reset_key_message.txt: -------------------------------------------------------------------------------- 1 | {% load i18n %}{% autoescape off %}{% blocktrans with site_name=current_site.name site_domain=request.get_host %}Hi there, 2 | 3 | You're receiving this email because you or someone else has requested a password for your account. 4 | 5 | This message can be safely ignored if you did not request a password reset. Click the link below to reset your password.{% endblocktrans %} 6 | 7 | {{ password_reset_url }} 8 | 9 | {% blocktrans with site_name=current_site.name site_domain=request.get_host %}Thank you, 10 | {{ site_name }} 11 | {% endblocktrans %} 12 | {% endautoescape %} 13 | -------------------------------------------------------------------------------- /shynet/dashboard/templates/account/email/password_reset_key_subject.txt: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% autoescape off %} 3 | {% blocktrans %}Password Reset Email{% endblocktrans %} 4 | {% endautoescape %} 5 | -------------------------------------------------------------------------------- /shynet/dashboard/templates/account/email_confirm.html: -------------------------------------------------------------------------------- 1 | {% extends "account/base.html" %} 2 | 3 | {% load i18n %} 4 | {% load account %} 5 | 6 | {% block head_title %}{% trans "Confirm Email Address" %}{% endblock %} 7 | {% block page_title %}{% trans "Confirm Email Address" %}{% endblock %} 8 | 9 | 10 | {% block card %} 11 | {% if confirmation %} 12 | 13 | {% user_display confirmation.email_address.user as user_display %} 14 | 15 |

{% blocktrans with confirmation.email_address.email as email %}Please confirm that {{ email }} is a valid email where we can reach you.{% endblocktrans %} 17 |

18 | 19 |
20 | {% csrf_token %} 21 | 22 |
23 | 24 | {% else %} 25 | 26 | {% url 'account_email' as email_url %} 27 | 28 |

{% blocktrans %}This email confirmation link expired or is invalid. Please issue a new 29 | email confirmation request.{% endblocktrans %}

30 | 31 | {% endif %} 32 | 33 | {% endblock %} -------------------------------------------------------------------------------- /shynet/dashboard/templates/account/login.html: -------------------------------------------------------------------------------- 1 | {% extends "account/base.html" %} 2 | 3 | {% load i18n a17t_tags %} 4 | {% load account socialaccount %} 5 | 6 | {% block head_title %}{% trans "Sign In" %}{% endblock %} 7 | {% block page_title %}{% trans "Sign In" %}{% endblock %} 8 | 9 | {% block card %} 10 | 23 | {% endblock %} -------------------------------------------------------------------------------- /shynet/dashboard/templates/account/logout.html: -------------------------------------------------------------------------------- 1 | {% extends "account/base.html" %} 2 | 3 | {% load i18n a17t_tags %} 4 | 5 | {% block head_title %}{% trans "Sign Out" %}{% endblock %} 6 | {% block page_title %}{% trans "Sign Out" %}{% endblock %} 7 | 8 | {% block card %} 9 |

{% trans 'Are you sure you want to sign out?' %}

10 | 11 |
12 | {% csrf_token %} 13 | {% if redirect_field_value %} 14 | 15 | {% endif %} 16 | 17 |
18 | {% endblock %} 19 | -------------------------------------------------------------------------------- /shynet/dashboard/templates/account/messages/cannot_delete_primary_email.txt: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% blocktrans %}You cannot remove your primary email address ({{email}}).{% endblocktrans %} 3 | -------------------------------------------------------------------------------- /shynet/dashboard/templates/account/messages/email_confirmation_sent.txt: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% blocktrans %}Confirmation email sent to {{email}}.{% endblocktrans %} 3 | -------------------------------------------------------------------------------- /shynet/dashboard/templates/account/messages/email_confirmed.txt: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% blocktrans %}Confirmed {{email}}.{% endblocktrans %} 3 | -------------------------------------------------------------------------------- /shynet/dashboard/templates/account/messages/email_deleted.txt: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% blocktrans %}Removed email address {{email}}.{% endblocktrans %} 3 | -------------------------------------------------------------------------------- /shynet/dashboard/templates/account/messages/logged_in.txt: -------------------------------------------------------------------------------- 1 | {% load account %} 2 | {% load i18n %} 3 | {% user_display user as name %} 4 | {% blocktrans %}Successfully signed in as {{name}}.{% endblocktrans %} 5 | -------------------------------------------------------------------------------- /shynet/dashboard/templates/account/messages/logged_out.txt: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% blocktrans %}You have signed out.{% endblocktrans %} 3 | -------------------------------------------------------------------------------- /shynet/dashboard/templates/account/messages/password_changed.txt: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% blocktrans %}Password successfully changed.{% endblocktrans %} 3 | -------------------------------------------------------------------------------- /shynet/dashboard/templates/account/messages/password_set.txt: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% blocktrans %}Password successfully set.{% endblocktrans %} 3 | -------------------------------------------------------------------------------- /shynet/dashboard/templates/account/messages/primary_email_set.txt: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% blocktrans %}New primary email address set.{% endblocktrans %} 3 | -------------------------------------------------------------------------------- /shynet/dashboard/templates/account/messages/unverified_primary_email.txt: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% blocktrans %}Your primary email address must be verified.{% endblocktrans %} 3 | -------------------------------------------------------------------------------- /shynet/dashboard/templates/account/password_change.html: -------------------------------------------------------------------------------- 1 | {% extends "account/base.html" %} 2 | 3 | {% load i18n a17t_tags %} 4 | 5 | {% block head_title %}{% trans "Change authentication info" %}{% endblock %} 6 | {% block page_title %}{% trans "Change authentication info" %}{% endblock %} 7 | 8 | {% block card %} 9 |
10 | {% csrf_token %} 11 | {{ form|a17t }} 12 | 13 |
14 |
15 |
16 |

Personal API token

17 |
18 | {% if request.user.api_token %} 19 | {{request.user.api_token}} 20 | {% else %} 21 | Token not generated 22 | {% endif %} 23 |
24 | {% csrf_token %} 25 | 32 |
33 |
34 |

To learn more about the API, see our API guide.

35 |
36 | 37 | {% endblock %} 38 | -------------------------------------------------------------------------------- /shynet/dashboard/templates/account/password_reset.html: -------------------------------------------------------------------------------- 1 | {% extends "account/base.html" %} 2 | 3 | {% load i18n a17t_tags %} 4 | {% load account %} 5 | 6 | {% block head_title %}{% trans "Password Reset" %}{% endblock %} 7 | {% block page_title %}{% trans "Password Reset" %}{% endblock %} 8 | 9 | {% block card %} 10 | {% if user.is_authenticated %} 11 | {% include "account/snippets/already_logged_in.html" %} 12 | {% endif %} 13 | 14 |

15 | {% trans "Forgotten your password? Enter your email address below, and we'll send you an email to reset it." %} 16 |

17 | 18 |
19 | {% csrf_token %} 20 | {{ form|a17t }} 21 | 22 |
23 | {% endblock %} -------------------------------------------------------------------------------- /shynet/dashboard/templates/account/password_reset_done.html: -------------------------------------------------------------------------------- 1 | {% extends "account/base.html" %} 2 | 3 | {% load i18n %} 4 | {% load account %} 5 | 6 | {% block head_title %}{% trans "Password Reset" %}{% endblock %} 7 | {% block page_title %}{% trans "Password Reset" %}{% endblock %} 8 | 9 | {% block card %} 10 | {% if user.is_authenticated %} 11 | {% include "account/snippets/already_logged_in.html" %} 12 | {% endif %} 13 | 14 |

{% blocktrans %}We have sent you an email with a password reset link. Please try again if you do not receive it within a few minutes.{% endblocktrans %}

15 | {% endblock %} 16 | -------------------------------------------------------------------------------- /shynet/dashboard/templates/account/password_reset_from_key.html: -------------------------------------------------------------------------------- 1 | {% extends "account/base.html" %} 2 | 3 | {% load i18n a17t_tags %} 4 | {% block head_title %}{% trans "Change Password" %}{% endblock %} 5 | {% block page_title %}{% trans "Change Password" %}{% endblock %} 6 | 7 | {% block card %} 8 | {% if token_fail %} 9 | {% url 'account_reset_password' as passwd_reset_url %} 10 |

{% blocktrans %}The password reset link was invalid, possibly because it has already been used. Please request a new password reset.{% endblocktrans %}

11 | {% else %} 12 | {% if form %} 13 |
14 | {% csrf_token %} 15 | {{ form|a17t }} 16 | 17 |
18 | {% else %} 19 |

{% trans 'Your password is now changed.' %}

20 | {% endif %} 21 | {% endif %} 22 | {% endblock %} 23 | -------------------------------------------------------------------------------- /shynet/dashboard/templates/account/password_reset_from_key_done.html: -------------------------------------------------------------------------------- 1 | {% extends "account/base.html" %} 2 | 3 | {% load i18n %} 4 | {% block head_title %}{% trans "Change Password" %}{% endblock %} 5 | {% block page_title %}{% trans "Change Password" %}{% endblock %} 6 | 7 | {% block card %} 8 |

{% trans 'Your password is now changed.' %}

9 | Log In 10 | {% endblock %} 11 | -------------------------------------------------------------------------------- /shynet/dashboard/templates/account/password_set.html: -------------------------------------------------------------------------------- 1 | {% extends "account/base.html" %} 2 | 3 | {% load i18n a17t_tags %} 4 | 5 | {% block head_title %}{% trans "Set Password" %}{% endblock %} 6 | {% block page_title %}{% trans "Set Password" %}{% endblock %} 7 | 8 | {% block card %} 9 |
10 | {% csrf_token %} 11 | {{ form|a17t }} 12 | 13 |
14 | {% endblock %} -------------------------------------------------------------------------------- /shynet/dashboard/templates/account/signup.html: -------------------------------------------------------------------------------- 1 | {% extends "account/base.html" %} 2 | 3 | {% load i18n a17t_tags %} 4 | 5 | {% block head_title %}{% trans "Sign Up" %}{% endblock %} 6 | {% block page_title %}{% trans "Sign Up" %}{% endblock %} 7 | 8 | {% block card %} 9 | 10 | 11 | 19 | 20 | {% endblock %} 21 | -------------------------------------------------------------------------------- /shynet/dashboard/templates/account/signup_closed.html: -------------------------------------------------------------------------------- 1 | {% extends "account/base.html" %} 2 | 3 | {% load i18n %} 4 | 5 | {% block head_title %}{% trans "Sign Up Closed" %}{% endblock %} 6 | {% block page_title %}{% trans "Sign Up Closed" %}{% endblock %} 7 | 8 | {% block card %} 9 |

{% trans "Public sign-ups are not allowed at this time." %}

10 | {% endblock %} 11 | -------------------------------------------------------------------------------- /shynet/dashboard/templates/account/snippets/already_logged_in.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% load account %} 3 | 4 | {% user_display user as user_display %} 5 | -------------------------------------------------------------------------------- /shynet/dashboard/templates/account/verification_sent.html: -------------------------------------------------------------------------------- 1 | {% extends "account/base.html" %} 2 | 3 | {% load i18n %} 4 | 5 | {% block head_title %}{% trans "Verify Email Address" %}{% endblock %} 6 | {% block page_title %}{% trans "Verify Email Address" %}{% endblock %} 7 | 8 | {% block card %} 9 |

{% blocktrans %}We have sent an email to you for verification. Follow the link provided to finalize the signup process. Please try to log in again if you do not receive it within a few minutes.{% endblocktrans %}

10 | {% endblock %} 11 | -------------------------------------------------------------------------------- /shynet/dashboard/templates/account/verified_email_required.html: -------------------------------------------------------------------------------- 1 | {% extends "account/base.html" %} 2 | 3 | {% load i18n %} 4 | 5 | {% block head_title %}{% trans "Verify Email Address" %}{% endblock %} 6 | {% block page_title %}{% trans "Verify Email Address" %}{% endblock %} 7 | 8 | {% block card %} 9 | {% url 'account_email' as email_url %} 10 | 11 |

{% blocktrans %}This part of the site requires us to verify that 12 | you are who you claim to be. For this purpose, we require that you 13 | verify ownership of your email address. {% endblocktrans %}

14 | 15 |

{% blocktrans %}We have sent an email to you for 16 | verification. Please click on the link inside this email. Please 17 | try again if you do not receive it within a few minutes.{% endblocktrans %}

18 | 19 | 20 | {% endblock %} 21 | -------------------------------------------------------------------------------- /shynet/dashboard/templates/base.html: -------------------------------------------------------------------------------- 1 | {% load static i18n rules helpers %} 2 | 3 | 4 | 5 | 6 | 7 | {% block head_title %}Privacy-oriented analytics{% endblock %} | {{request.site.name}} 8 | 9 | 10 | {% include 'a17t/includes/head.html' %} 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | {% block extra_head %} 22 | {% endblock %} 23 | 24 | 25 | 26 | {% block body %} 27 | 28 |
29 | 115 |
116 | {% if messages %} 117 |
118 | {% for message in messages %} 119 |
{{message}}
120 | {% endfor %} 121 | 122 |
123 |
124 | {% endif %} 125 |
126 | {% block content %} 127 | {% endblock %} 128 |
129 |
130 |
131 | {% endblock %} 132 | {% block extra_body %} 133 | {% endblock %} 134 | 135 | 136 | 137 | -------------------------------------------------------------------------------- /shynet/dashboard/templates/dashboard/includes/bar.html: -------------------------------------------------------------------------------- 1 | {% load helpers %} 2 |
6 |
7 | -------------------------------------------------------------------------------- /shynet/dashboard/templates/dashboard/includes/date_range.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% get_current_language as LANGUAGE_CODE %} 3 |
4 | 5 | 6 |
7 | 8 | 24 | 62 | -------------------------------------------------------------------------------- /shynet/dashboard/templates/dashboard/includes/map_chart.html: -------------------------------------------------------------------------------- 1 | {% load helpers %} 2 | 3 |
4 | -------------------------------------------------------------------------------- /shynet/dashboard/templates/dashboard/includes/service_form.html: -------------------------------------------------------------------------------- 1 | {% load i18n a17t_tags %} 2 | 3 | {{form.name|a17t}} 4 | {{form.link|a17t}} 5 | {{form.collaborators|a17t}} 6 | 7 |
8 | {% trans 'Advanced settings' %} 9 |
10 | {{form.respect_dnt|a17t}} 11 | {{form.collect_ips|a17t}} 12 | {{form.ignored_ips|a17t}} 13 | {{form.ignore_robots|a17t}} 14 | {{form.hide_referrer_regex|a17t}} 15 | {{form.origins|a17t}} 16 | {{form.script_inject|a17t}} 17 |
-------------------------------------------------------------------------------- /shynet/dashboard/templates/dashboard/includes/service_overview.html: -------------------------------------------------------------------------------- 1 | {% load i18n humanize helpers %} 2 | 3 | 4 | {% with stats=object.stats %} 5 |
6 |
7 |

8 | {{object.link|iconify}} 9 | {{object.name}} 10 |

11 | {% include 'dashboard/includes/stats_status_chip.html' %} 12 |
13 |
14 |
15 |

{% trans 'Sessions' %}

16 |

17 | {{stats.session_count|intcomma}} 18 | {% compare stats.compare.session_count stats.session_count "UP" %} 19 |

20 |
21 |
22 |

{% trans 'Hits' %}

23 |

24 | {{stats.hit_count|intcomma}} 25 | {% compare stats.compare.hit_count stats.hit_count "UP" %} 26 |

27 |
28 |
29 |

{% trans 'Bounce Rate' %}

30 |

31 | {% if stats.bounce_rate_pct != None %} 32 | {{stats.bounce_rate_pct|floatformat:"-1"}}% 33 | {% else %} 34 | ? 35 | {% endif %} 36 | {% compare stats.compare.bounce_rate_pct stats.bounce_rate_pct "DOWN" %} 37 |

38 |
39 |
40 |

{% trans 'Avg. Duration' %}

41 |

42 | {% if stats.avg_session_duration != None %} 43 | {{stats.avg_session_duration|naturaldelta}} 44 | {% else %} 45 | ? 46 | {% endif %} 47 | {% compare stats.compare.avg_session_duration stats.avg_session_duration "UP" %} 48 |

49 |
50 |
51 |
52 |
53 |
54 | {% include 'dashboard/includes/time_chart.html' with data=stats.chart_data sparkline=True height=100 name=object.uuid tooltip_format=stats.chart_tooltip_format granularity=stats.chart_granularity %} 55 |
56 | {% endwith %} 57 |
58 | -------------------------------------------------------------------------------- /shynet/dashboard/templates/dashboard/includes/service_snippet.html: -------------------------------------------------------------------------------- 1 |
{% filter force_escape %} 4 | {% endfilter %} 5 |
6 | -------------------------------------------------------------------------------- /shynet/dashboard/templates/dashboard/includes/session_list.html: -------------------------------------------------------------------------------- 1 | {% load i18n humanize helpers %} 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | {% for session in object_list %} 13 | 14 | 23 | 30 | 31 | 32 | 33 | 34 | {% empty %} 35 | 36 | 37 | 38 | {% endfor %} 39 | 40 |
{% trans 'Session Start' %}{% trans 'Identity' %}{% trans 'Network' %}{% trans 'Duration' %}{% trans 'Hits' %}
15 | 17 | {{ session.start_time|date:"DATETIME_FORMAT"|capfirst }} 18 | {% if session.is_currently_active %} 19 | Online 20 | {% endif %} 21 | 22 | 24 | {% if session.identifier %} 25 | {{session.identifier}} 26 | {% else %} 27 | 28 | {% endif %} 29 | {{session.asn|default:"Unknown"}}{{session.duration|naturaldelta}}{{session.hit_set.count|intcomma}}
{% trans 'No data yet' %}...
-------------------------------------------------------------------------------- /shynet/dashboard/templates/dashboard/includes/sidebar_footer.html: -------------------------------------------------------------------------------- 1 |
2 |

3 | Shynet {{version}} 4 |

5 |
-------------------------------------------------------------------------------- /shynet/dashboard/templates/dashboard/includes/sidebar_portal.html: -------------------------------------------------------------------------------- 1 | {% load i18n helpers %} 2 | 3 | -------------------------------------------------------------------------------- /shynet/dashboard/templates/dashboard/includes/stat_comparison.html: -------------------------------------------------------------------------------- 1 | {% load helpers %} 2 | 3 | 6 | {% percent_change_display start end %} 7 | -------------------------------------------------------------------------------- /shynet/dashboard/templates/dashboard/includes/stats_status_chip.html: -------------------------------------------------------------------------------- 1 | {% load humanize %} 2 | 3 | {% with stats=object.get_daily_stats %} 4 | {% if stats.currently_online > 0 %} 5 | 6 | {{stats.currently_online|intcomma}} online 7 | 8 | {% endif %} 9 | {% endwith %} -------------------------------------------------------------------------------- /shynet/dashboard/templates/dashboard/includes/time_chart.html: -------------------------------------------------------------------------------- 1 |
2 | 93 | -------------------------------------------------------------------------------- /shynet/dashboard/templates/dashboard/pages/dashboard.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% load i18n rules pagination %} 4 | 5 | {% block content %} 6 |
7 | 10 |
11 |
12 | {% include 'dashboard/includes/date_range.html' %} 13 |
14 | {% has_perm "core.create_service" user as can_create %} 15 | {% if can_create %} 16 | + {% trans 'New Service' %} 17 | {% endif %} 18 |
19 |
20 |
21 | {% for object in object_list|dictsortreversed:"stats.session_count" %} 22 | {% include 'dashboard/includes/service_overview.html' %} 23 | {% empty %} 24 |

You don't have any services yet. {% if can_create %}Get started by creating one.{% endif %}

25 | {% endfor %} 26 | 27 | {% if object_list %} 28 | {% pagination page_obj request %} 29 | {% endif %} 30 | 31 | {% endblock %} -------------------------------------------------------------------------------- /shynet/dashboard/templates/dashboard/pages/index.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | {% load i18n %} 3 | 4 | {% block content %} 5 |
6 |

{{request.site.name}} {% trans 'Analytics' %}

7 |

{{request.site.name}} uses Shynet. Eventually, more information about Shynet will be available here.

8 | {% trans 'Log In' %} 9 |
10 | {% endblock %} -------------------------------------------------------------------------------- /shynet/dashboard/templates/dashboard/pages/service_create.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% load i18n a17t_tags %} 4 | 5 | {% block head_title %}{% trans 'Create Service' %}{% endblock %} 6 | 7 | {% block content %} 8 |

{% trans 'Create Service' %}

9 |
10 |
11 | {% csrf_token %} 12 |
13 | {% include 'dashboard/includes/service_form.html' %} 14 |
15 |
16 | 17 | {% trans 'Cancel' %} 18 |
19 |
20 | {% endblock %} -------------------------------------------------------------------------------- /shynet/dashboard/templates/dashboard/pages/service_delete.html: -------------------------------------------------------------------------------- 1 | {% extends "dashboard/service_base.html" %} 2 | 3 | {% load i18n a17t_tags %} 4 | 5 | {% block head_title %}{% trans 'Delete' %} {{object.name}}{% endblock %} 6 | 7 | {% block service_content %} 8 |
9 | {% csrf_token %} 10 |
11 |

12 | {% blocktrans trimmed %} 13 | Are you sure you want to delete this service? All of its 14 | analytics and associated data will be permanently deleted. 15 | {% endblocktrans %} 16 |

17 | {{form|a17t}} 18 |
19 |
20 | 21 | Cancel 22 |
23 |
24 | {% endblock %} -------------------------------------------------------------------------------- /shynet/dashboard/templates/dashboard/pages/service_location_list.html: -------------------------------------------------------------------------------- 1 | {% extends "dashboard/service_base.html" %} 2 | 3 | {% load i18n a17t_tags pagination humanize helpers %} 4 | 5 | {% block head_title %}{{object.name}} {% trans 'Locations' %}{% endblock %} 6 | 7 | {% block service_actions %} 8 |
{% include 'dashboard/includes/date_range.html' %}
9 | {% trans 'Analytics' %} → 10 | {% endblock %} 11 | 12 | {% block service_content %} 13 |
14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | {% for location in object_list %} 23 | 24 | 29 | 37 | 38 | {% empty %} 39 | 40 | 41 | 42 | {% endfor %} 43 | 44 |
{% trans 'Location' %}{% trans 'Hits' %}
25 |
26 | {{location.location|default:"Unknown"|urldisplay}} 27 |
28 |
30 |
31 | {{location.count|intcomma}} 32 | 33 | ({{location.count|percent:hit_count}}) 34 | 35 |
36 |
{% trans 'No data yet...' %}
45 |
46 | {% pagination page_obj request %} 47 | {% endblock %} 48 | -------------------------------------------------------------------------------- /shynet/dashboard/templates/dashboard/pages/service_session.html: -------------------------------------------------------------------------------- 1 | {% extends "dashboard/service_base.html" %} 2 | 3 | {% load i18n a17t_tags pagination humanize helpers %} 4 | 5 | {% block head_title %}{{object.name}} Session{% endblock %} 6 | 7 | {% block service_actions %} 8 | Analytics → 9 | {% endblock %} 10 | 11 | {% block service_content %} 12 |
13 |
14 |
15 |

16 | {{session.identifier|default:"Anonymous"}}, {{session.duration|naturaldelta}} 17 |

18 |
19 |
20 |

{{session.start_time|date:"M j Y, g:i a"}} to 21 | {{session.last_seen|date:"g:i a"}}{% if session.is_currently_active %} Online{% endif %}

22 |
23 |
24 |
25 |
26 |
27 |

{% trans 'Browser' %}

28 |

{{session.browser|default:"Unknown"}}

29 |
30 |
31 |

{% trans 'Device' %}

32 |

{{session.device|default:"Unknown"}}

33 |
34 |
35 | 36 |

{{session.device_type|title}}

37 |
38 |
39 |

{% trans 'OS' %}

40 |

{{session.os|default:"Unknown"}}

41 |
42 |
43 |

{% trans 'Network' %}

44 |

{{session.asn|default:"Unknown"}}

45 |
46 |
47 |

{% trans 'Country' %}

48 |

{{session.country|country_name}}

49 |
50 |
51 |

{% trans 'Location' %}

52 |

53 | {% if session.latitude %} 54 | {% trans 'Open in Maps' %} ↗ 55 | {% else %} 56 | {% trans 'Unknown' %} 57 | {% endif %} 58 |

59 |
60 |
61 |

IP

62 |

{{session.ip|default:"Not Collected"|truncatechars:"16"}}

63 |
64 |
65 |
66 |
67 | {% for hit in session.hit_set.all %} 68 |
69 |
70 |
{{hit.start_time|date:"g:i a"}}
71 |
72 |
73 |
74 |

{{hit.location|default:"Unknown"|urlize}}

75 | {% if hit.referrer %} 76 |

via {{hit.referrer|urlize}}

77 | {% endif %} 78 |

79 |
80 |
81 |

{% trans 'Duration' %}

82 |

{{hit.duration|naturaldelta}}

83 |
84 |
85 |

{% trans 'Load' %}

86 |

{{hit.load_time|floatformat:"0"}}ms

87 |
88 |
89 |

{% trans 'Tracker' %}

90 |

{{hit.tracker}}

91 |
92 |
93 |
94 |
95 | {% endfor %} 96 |
97 | {% endblock %} -------------------------------------------------------------------------------- /shynet/dashboard/templates/dashboard/pages/service_session_list.html: -------------------------------------------------------------------------------- 1 | {% extends "dashboard/service_base.html" %} 2 | 3 | {% load i18n a17t_tags pagination humanize helpers %} 4 | 5 | {% block head_title %}{{object.name}} {% trans 'Sessions' %}{% endblock %} 6 | 7 | {% block service_actions %} 8 |
{% include 'dashboard/includes/date_range.html' %}
9 | {% trans 'Analytics' %} → 10 | {% endblock %} 11 | 12 | {% block service_content %} 13 |
14 | {% include 'dashboard/includes/session_list.html' %} 15 |
16 | {% pagination page_obj request %} 17 | {% endblock %} 18 | -------------------------------------------------------------------------------- /shynet/dashboard/templates/dashboard/pages/service_update.html: -------------------------------------------------------------------------------- 1 | {% extends "dashboard/service_base.html" %} 2 | 3 | {% load i18n a17t_tags %} 4 | 5 | {% block head_title %}{{object.name}} {% trans 'Management' %}{% endblock %} 6 | 7 | {% block service_actions %} 8 | {% trans 'View' %} → 9 | {% endblock %} 10 | 11 | {% block service_content %} 12 |
13 |
{% trans 'Installation' %}
14 |

15 | {% blocktrans trimmed %} 16 | Place the following snippet at the end of the <body> tag on any page you'd like to track. 17 | {% endblocktrans %} 18 |

19 | {% include 'dashboard/includes/service_snippet.html' %} 20 |
21 |
{% trans 'Settings' %}
22 |
23 | {% csrf_token %} 24 |
25 | {% include 'dashboard/includes/service_form.html' %} 26 |
27 |
28 |
29 | 30 | {% trans 'Cancel' %} 31 |
32 | 35 |
36 |
37 |
38 |
API
39 |
40 |

Shynet provides a simple API that you can use to pull data programmatically. You can access this data via this URL:

41 | {{script_protocol}}{{request.get_host}}{% url 'api:services' %}?uuid={{object.uuid}} 42 |

43 | There are 2 optional query parameters: 44 |

    45 |
  • startDate — to set the start date (in format YYYY-MM-DD)
  • 46 |
  • endDate — to set the end date (in format YYYY-MM-DD)
  • 47 |
48 |

49 |

Example using cURL:

50 | curl -H 'Authorization: Token (your API token, available on security page)' '{{script_protocol}}{{request.get_host}}{% url 'api:services' %}?uuid={{object.uuid}}&startDate=2021-01-01&endDate=2050-01-01' 51 |
52 |
53 | {% endblock %} 54 | -------------------------------------------------------------------------------- /shynet/dashboard/templates/dashboard/service_base.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% load humanize helpers %} 4 | 5 | {% block head_title %}{{service.name}}{% endblock %} 6 | 7 | {% block content %} 8 | 23 |
24 | {% block service_content %} 25 | {% endblock %} 26 | {% endblock %} -------------------------------------------------------------------------------- /shynet/dashboard/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/milesmcc/shynet/d07380bb27602d117f07f79b2b2728d43998619d/shynet/dashboard/templatetags/__init__.py -------------------------------------------------------------------------------- /shynet/dashboard/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/milesmcc/shynet/d07380bb27602d117f07f79b2b2728d43998619d/shynet/dashboard/tests/__init__.py -------------------------------------------------------------------------------- /shynet/dashboard/tests/tests_dashboard_views.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase, RequestFactory 2 | from django.conf import settings 3 | from django.urls import reverse 4 | from core.factories import UserFactory 5 | 6 | from dashboard.views import DashboardView 7 | 8 | 9 | class QuestionModelTests(TestCase): 10 | def setUp(self): 11 | # Every test needs access to the request factory. 12 | self.factory = RequestFactory() 13 | self.user = UserFactory() 14 | 15 | def tearDown(self): 16 | pass 17 | 18 | def tests_unauthenticated_dashboard_view(self): 19 | """ 20 | GIVEN: Unauthenticated user 21 | WHEN: Accessing the dashboard view 22 | THEN: It's redirected to login page with NEXT url to dashboard 23 | """ 24 | login_url = settings.LOGIN_URL 25 | response = self.client.get(reverse("dashboard:dashboard")) 26 | 27 | self.assertEqual(response.status_code, 302) 28 | self.assertEqual( 29 | response.url, f"{login_url}?next={reverse('dashboard:dashboard')}" 30 | ) 31 | 32 | def tests_authenticated_dashboard_view(self): 33 | """ 34 | GIVEN: Authenticated user 35 | WHEN: Accessing the dashboard view 36 | THEN: It should respond with 200 and render the view 37 | """ 38 | request = self.factory.get(reverse("dashboard:dashboard")) 39 | request.user = self.user 40 | 41 | # Use this syntax for class-based views. 42 | response = DashboardView.as_view()(request) 43 | self.assertEqual(response.status_code, 200) 44 | -------------------------------------------------------------------------------- /shynet/dashboard/urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import path 2 | 3 | from . import views 4 | 5 | urlpatterns = [ 6 | path("", views.DashboardView.as_view(), name="dashboard"), 7 | path("service/new/", views.ServiceCreateView.as_view(), name="service_create"), 8 | path("service//", views.ServiceView.as_view(), name="service"), 9 | path( 10 | "service//manage/", 11 | views.ServiceUpdateView.as_view(), 12 | name="service_update", 13 | ), 14 | path( 15 | "service//delete/", 16 | views.ServiceDeleteView.as_view(), 17 | name="service_delete", 18 | ), 19 | path( 20 | "service//sessions/", 21 | views.ServiceSessionsListView.as_view(), 22 | name="service_session_list", 23 | ), 24 | path( 25 | "service//sessions//", 26 | views.ServiceSessionView.as_view(), 27 | name="service_session", 28 | ), 29 | path( 30 | "service//locations/", 31 | views.ServiceLocationsListView.as_view(), 32 | name="service_location_list", 33 | ), 34 | path( 35 | "api-token-refresh/", 36 | views.RefreshApiTokenView.as_view(), 37 | name="api_token_refresh", 38 | ), 39 | ] 40 | -------------------------------------------------------------------------------- /shynet/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [[ ! $PERFORM_CHECKS_AND_SETUP == False ]]; then 4 | ./startup_checks.sh && exec ./webserver.sh 5 | else 6 | exec ./webserver.sh 7 | fi -------------------------------------------------------------------------------- /shynet/manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """Django's command-line utility for administrative tasks.""" 3 | import os 4 | import sys 5 | 6 | 7 | def main(): 8 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "shynet.settings") 9 | try: 10 | from django.core.management import execute_from_command_line 11 | except ImportError as exc: 12 | raise ImportError( 13 | "Couldn't import Django. Are you sure it's installed and " 14 | "available on your PYTHONPATH environment variable? Did you " 15 | "forget to activate a virtual environment?" 16 | ) from exc 17 | execute_from_command_line(sys.argv) 18 | 19 | 20 | if __name__ == "__main__": 21 | main() 22 | -------------------------------------------------------------------------------- /shynet/shynet/__init__.py: -------------------------------------------------------------------------------- 1 | from .celery import app as celery_app 2 | 3 | __all__ = ("celery_app",) 4 | -------------------------------------------------------------------------------- /shynet/shynet/celery.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from celery import Celery 4 | 5 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "shynet.settings") 6 | 7 | app = Celery("shynet") 8 | 9 | app.config_from_object("django.conf:settings", namespace="CELERY") 10 | 11 | app.autodiscover_tasks() 12 | -------------------------------------------------------------------------------- /shynet/shynet/urls.py: -------------------------------------------------------------------------------- 1 | """shynet URL Configuration 2 | 3 | The `urlpatterns` list routes URLs to views. For more information please see: 4 | https://docs.djangoproject.com/en/2.2/topics/http/urls/ 5 | Examples: 6 | Function views 7 | 1. Add an import: from my_app import views 8 | 2. Add a URL to urlpatterns: path('', views.home, name='home') 9 | Class-based views 10 | 1. Add an import: from other_app.views import Home 11 | 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') 12 | Including another URLconf 13 | 1. Import the include() function: from django.urls import include, path 14 | 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 15 | """ 16 | from django.contrib import admin 17 | from django.urls import include, path 18 | import debug_toolbar 19 | 20 | urlpatterns = [ 21 | path("__debug__/", include(debug_toolbar.urls)), 22 | path("admin/", admin.site.urls), 23 | path("accounts/", include("allauth.urls")), 24 | path("ingress/", include(("analytics.ingress_urls", "ingress")), name="ingress"), 25 | path("dashboard/", include(("dashboard.urls", "dashboard"), namespace="dashboard")), 26 | path("healthz/", include("health_check.urls")), 27 | path("", include(("core.urls", "core"), namespace="core")), 28 | path("api/v1/", include(("api.urls", "api"), namespace="api")), 29 | ] 30 | -------------------------------------------------------------------------------- /shynet/shynet/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for shynet project. 3 | 4 | It exposes the WSGI callable as a module-level variable named ``application``. 5 | 6 | For more information on this file, see 7 | https://docs.djangoproject.com/en/2.2/howto/deployment/wsgi/ 8 | """ 9 | 10 | import os 11 | 12 | from django.core.wsgi import get_wsgi_application 13 | 14 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "shynet.settings") 15 | 16 | application = get_wsgi_application() 17 | -------------------------------------------------------------------------------- /shynet/ssl.webserver.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Start Gunicorn processes 4 | echo Launching Shynet web server... 5 | exec gunicorn shynet.wsgi:application \ 6 | --bind 0.0.0.0:${PORT:-8080} \ 7 | --workers ${NUM_WORKERS:-1} \ 8 | --timeout 100 \ 9 | --certfile=cert.pem \ 10 | --keyfile=privkey.pem -------------------------------------------------------------------------------- /shynet/startup_checks.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Check if setup is necessary, do setup as needed 3 | echo "Performing startup checks..." 4 | startup_results=( $(./manage.py startup_checks) ) 5 | if [[ ${startup_results[0]} == True ]]; then 6 | echo "Running migrations (setting up DB)..." 7 | { 8 | ./manage.py migrate && echo "Migrations complete!" 9 | } || { 10 | echo "Migrations failed, exiting" && exit 1 11 | } 12 | else 13 | echo "Database is ready to go." 14 | fi 15 | if [[ ${startup_results[1]} == True ]]; then 16 | echo "Warning: no admin user available. Consult docs for instructions." 17 | fi 18 | if [[ ${startup_results[2]} == True ]]; then 19 | echo "Warning: Shynet's whitelabel is not set. Consult docs for instructions." 20 | fi 21 | echo "Startup checks complete!" 22 | -------------------------------------------------------------------------------- /shynet/webserver.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Start Gunicorn processes 3 | echo Launching Shynet web server... 4 | exec gunicorn shynet.wsgi:application \ 5 | --bind 0.0.0.0:${PORT:-8080} \ 6 | --workers ${NUM_WORKERS:-1} \ 7 | --timeout 100 8 | -------------------------------------------------------------------------------- /tests/js.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | JS test 7 | 8 | 9 | 10 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /tests/pixel.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Pixel test 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | --------------------------------------------------------------------------------