Compare commits
313 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e48e2dcf5 | ||
|
|
b286c80754 | ||
|
|
c23f44d7b7 | ||
|
|
b7f2e9cfe6 | ||
|
|
77cb1fb37c | ||
|
|
d9bbeea892 | ||
|
|
ca97453c3e | ||
|
|
b87b158aab | ||
|
|
4a6af18765 | ||
|
|
6d84f63130 | ||
|
|
ba91ed561d | ||
|
|
2aaadfe81c | ||
|
|
7f60b3abff | ||
|
|
069b218828 | ||
|
|
80647d960a | ||
|
|
364ef115c9 | ||
|
|
71ec196ec4 | ||
|
|
4834c5722f | ||
|
|
4b4c8f207e | ||
|
|
aed88b7c9a | ||
|
|
bcf94147c9 | ||
|
|
66b841fd86 | ||
|
|
d809ec82d9 | ||
|
|
e577aa4997 | ||
|
|
5966ea2f84 | ||
|
|
a7248cd54b | ||
|
|
1dec03c724 | ||
|
|
ff6933b4de | ||
|
|
1fd46b019c | ||
|
|
e534269c77 | ||
|
|
0d64ef33b0 | ||
|
|
56c82e7d23 | ||
|
|
c71d934c67 | ||
|
|
85ae56fcdb | ||
|
|
cd422ffd71 | ||
|
|
060a9b2a96 | ||
|
|
8d13ccd0fd | ||
|
|
0d46e6d1f4 | ||
|
|
81ae84efb3 | ||
|
|
8302aedaa7 | ||
|
|
e2d438134a | ||
|
|
787ce1775f | ||
|
|
ea5f58fbd3 | ||
|
|
4079a8494a | ||
|
|
780b71083a | ||
|
|
62fbb014e7 | ||
|
|
d62d48c7b4 | ||
|
|
2f8891a843 | ||
|
|
a963694fd0 | ||
|
|
90b2896ded | ||
|
|
bec4b19366 | ||
|
|
32adb64dc0 | ||
|
|
53bc690435 | ||
|
|
04120323a6 | ||
|
|
03ced00f63 | ||
|
|
8d04ed5c1f | ||
|
|
f2879775ef | ||
|
|
c980567fee | ||
|
|
57c8695bcc | ||
|
|
31ffa47fd3 | ||
|
|
73f3513dfe | ||
|
|
b2e9d50d78 | ||
|
|
de235c02a7 | ||
|
|
31cb616242 | ||
|
|
2d5fbae279 | ||
|
|
0153b1f847 | ||
|
|
473ad93081 | ||
|
|
1225ad90e8 | ||
|
|
e43718f596 | ||
|
|
d9623a9905 | ||
|
|
011f1f13c8 | ||
|
|
9832de0c19 | ||
|
|
83b20643d2 | ||
|
|
ab44ba8318 | ||
|
|
fcea6d3be9 | ||
|
|
f3a89bff78 | ||
|
|
3c9bc9f3c9 | ||
|
|
2f5d0ba7e5 | ||
|
|
1c866209c9 | ||
|
|
a4785b1a0c | ||
|
|
2928e663db | ||
|
|
ff97a46fd9 | ||
|
|
afb78dc499 | ||
|
|
a4eaef0117 | ||
|
|
7891866214 | ||
|
|
eedcbc4e85 | ||
|
|
0d006620dd | ||
|
|
0b78f6df72 | ||
|
|
74ddef1670 | ||
|
|
9d9d4a7b1e | ||
|
|
d66f683104 | ||
|
|
b44642e023 | ||
|
|
c12a7e9e71 | ||
|
|
0294d31ea4 | ||
|
|
40cb5afbad | ||
|
|
073bd94112 | ||
|
|
3a01fefcff | ||
|
|
14a7ec68f3 | ||
|
|
fdf2ab719b | ||
|
|
737eeb5df4 | ||
|
|
cb4855e4fc | ||
|
|
f4127cf9b1 | ||
|
|
159015de1c | ||
|
|
a7548d7eba | ||
|
|
da87ddb18f | ||
|
|
4a76ab32fc | ||
|
|
4afeced7d3 | ||
|
|
2a6efe1b7f | ||
|
|
07f3926a9c | ||
|
|
14ed0b7979 | ||
|
|
ab51089647 | ||
|
|
86695dbcc4 | ||
|
|
4e4cfe081b | ||
|
|
f54b67ef0f | ||
|
|
43f339e32b | ||
|
|
b144efaa9b | ||
|
|
c06b7a094a | ||
|
|
f13745f15e | ||
|
|
5c782ddb7d | ||
|
|
a6a508899a | ||
|
|
2b003b8fa9 | ||
|
|
023e0fde15 | ||
|
|
a1a083a403 | ||
|
|
8b167b2c74 | ||
|
|
4cd0c4735d | ||
|
|
d9e1ffddb1 | ||
|
|
9fb875f749 | ||
|
|
f6e502dfbd | ||
|
|
7c69b0bd81 | ||
|
|
78bea501a8 | ||
|
|
c2daf3a5a5 | ||
|
|
df6786e037 | ||
|
|
6621625d90 | ||
|
|
32ae0aa5f3 | ||
|
|
2221a99662 | ||
|
|
69ec37331a | ||
|
|
03f88af03c | ||
|
|
87b7ce2edc | ||
|
|
26c1ae2bce | ||
|
|
36d72508e6 | ||
|
|
68945df17d | ||
|
|
fef531efa9 | ||
|
|
46176b19fc | ||
|
|
94c53d2ab5 | ||
|
|
ea893b2322 | ||
|
|
71431fcaa6 | ||
|
|
e9536f1816 | ||
|
|
6f835a4f27 | ||
|
|
faf4f48e75 | ||
|
|
278306daa4 | ||
|
|
2c0fafefea | ||
|
|
6eb41e016a | ||
|
|
369f4d8d6b | ||
|
|
3d43f223eb | ||
|
|
351efff147 | ||
|
|
6867cbd282 | ||
|
|
c03ef52ba8 | ||
|
|
9cb030ecbd | ||
|
|
8bab14cc8a | ||
|
|
fe8e766670 | ||
|
|
b63863e283 | ||
|
|
516f9fb951 | ||
|
|
c2234ec647 | ||
|
|
02cbee5c8c | ||
|
|
518436ffd2 | ||
|
|
311aa2b1ac | ||
|
|
8ad44ddc23 | ||
|
|
874aad87a8 | ||
|
|
f2e875d03d | ||
|
|
45fd32c8ca | ||
|
|
08b36ba69f | ||
|
|
d5cfe577a0 | ||
|
|
c131cfef27 | ||
|
|
526d4cd133 | ||
|
|
8e09871b44 | ||
|
|
6aa3ce0b32 | ||
|
|
23ea8e493e | ||
|
|
22d996bed7 | ||
|
|
9df864787c | ||
|
|
b7a6ac9ec0 | ||
|
|
38d8d416e1 | ||
|
|
592613a99a | ||
|
|
e9f43c6a53 | ||
|
|
89c6800913 | ||
|
|
db9c807289 | ||
|
|
6e48a3eac7 | ||
|
|
ba9a716913 | ||
|
|
6d7292a60a | ||
|
|
c0d02732e7 | ||
|
|
d071a91917 | ||
|
|
d67e14b08f | ||
|
|
174a386f54 | ||
|
|
ce23cfc5b5 | ||
|
|
8be690c417 | ||
|
|
2f778dc4b4 | ||
|
|
e0c165313b | ||
|
|
c86192d301 | ||
|
|
775c105d1d | ||
|
|
be85c0a560 | ||
|
|
70e1af15cc | ||
|
|
6afea91c5f | ||
|
|
7a4c892804 | ||
|
|
9b50b1ea42 | ||
|
|
52a18d21f1 | ||
|
|
8aaf312c67 | ||
|
|
ede06900e5 | ||
|
|
a42455c9dc | ||
|
|
547a84f2fc | ||
|
|
f56ea99dc2 | ||
|
|
4cea5d2310 | ||
|
|
e4f09b4e68 | ||
|
|
cc094fe04e | ||
|
|
ac5c743390 | ||
|
|
963db18642 | ||
|
|
748fb76eaf | ||
|
|
d93a698e87 | ||
|
|
ca9ee2f1f5 | ||
|
|
9146c889ac | ||
|
|
8b1034ebb0 | ||
|
|
a8fd263855 | ||
|
|
31fa3d55d5 | ||
|
|
13229f64aa | ||
|
|
101d26d356 | ||
|
|
c524325f0a | ||
|
|
4a07ab80ce | ||
|
|
c8dead4457 | ||
|
|
4a06357137 | ||
|
|
29ac82a91b | ||
|
|
fecea17a9d | ||
|
|
03062e3de5 | ||
|
|
6652acdf14 | ||
|
|
1dfbec06e1 | ||
|
|
3e315f06ed | ||
|
|
2d42674e1a | ||
|
|
e4deab2072 | ||
|
|
c5ed5ef0e7 | ||
|
|
7268a4ea84 | ||
|
|
2cbc5ac441 | ||
|
|
058601d669 | ||
|
|
213c44a45a | ||
|
|
8b98cf2277 | ||
|
|
4c53b94588 | ||
|
|
a70e07be05 | ||
|
|
0195c4595b | ||
|
|
a54d9e6840 | ||
|
|
a4245eb733 | ||
|
|
7e0584b5d2 | ||
|
|
37396cde63 | ||
|
|
a1e4bef08f | ||
|
|
c3510278e3 | ||
|
|
da61b9b400 | ||
|
|
98187a39f8 | ||
|
|
3d27efba8b | ||
|
|
80c66ceb8e | ||
|
|
a2776e64f6 | ||
|
|
c73f96525a | ||
|
|
510df192d8 | ||
|
|
2e7620f1eb | ||
|
|
93d4ee5241 | ||
|
|
1a7594be93 | ||
|
|
f464a7ee67 | ||
|
|
a1cd3d4609 | ||
|
|
358fb234a7 | ||
|
|
94fed58de3 | ||
|
|
49f452d9f2 | ||
|
|
40d07fe159 | ||
|
|
e150e6bede | ||
|
|
87a411f42d | ||
|
|
88f25b6743 | ||
|
|
bb0dc2e90f | ||
|
|
4a8939796e | ||
|
|
ba795ccd5c | ||
|
|
c9b5a677d3 | ||
|
|
affcb893fa | ||
|
|
e030807acb | ||
|
|
4ced1365d4 | ||
|
|
dcdbb7cd45 | ||
|
|
b3102f5f32 | ||
|
|
2a61cf1b51 | ||
|
|
0d7c9c4c33 | ||
|
|
6649aeaaf0 | ||
|
|
cb11dc0c4e | ||
|
|
4a4f2645df | ||
|
|
81a836df53 | ||
|
|
919ca52ca1 | ||
|
|
f7ecb88659 | ||
|
|
1a0dcf7579 | ||
|
|
0f3037b315 | ||
|
|
b234ef2917 | ||
|
|
1b344fb90c | ||
|
|
d164306f8b | ||
|
|
c61d23caf1 | ||
|
|
fcfbbe8809 | ||
|
|
1bb4aac32f | ||
|
|
d895eac14d | ||
|
|
5cce890ff6 | ||
|
|
387c1e375d | ||
|
|
4e13842334 | ||
|
|
62c3a87cda | ||
|
|
cac6d44166 | ||
|
|
1a5f68e353 | ||
|
|
4569744726 | ||
|
|
6978bbd03e | ||
|
|
d88f61b281 | ||
|
|
c84dac6b01 | ||
|
|
abe37800ec | ||
|
|
8aef1f0dc7 | ||
|
|
1c01c27326 | ||
|
|
a766c1eaa2 | ||
|
|
a457c2be7b | ||
|
|
6a5ce6ddb9 | ||
|
|
bd88617dc5 | ||
|
|
77f1fbc2cc |
4
.github/release-drafter.yml
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
template: |
|
||||||
|
## What’s Changed
|
||||||
|
|
||||||
|
$CHANGES
|
||||||
48
.github/workflows/build-docker-edge.yml
vendored
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
name: Build edge Docker images
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish_to_docker_hub:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Set swap space
|
||||||
|
uses: pierotofy/set-swap-space@master
|
||||||
|
with:
|
||||||
|
swap-size-gb: 5
|
||||||
|
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
|
||||||
|
- name: Prepare tags
|
||||||
|
id: prep
|
||||||
|
run: |
|
||||||
|
DOCKER_IMAGE=milesmcc/shynet
|
||||||
|
TAGS="${DOCKER_IMAGE}:edge"
|
||||||
|
echo ::set-output name=tags::${TAGS}
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v1
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
id: buildx
|
||||||
|
uses: docker/setup-buildx-action@v1
|
||||||
|
|
||||||
|
- name: Login to DockerHub
|
||||||
|
uses: docker/login-action@v1
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_HUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push advanced image
|
||||||
|
id: docker_build
|
||||||
|
uses: docker/build-push-action@v2
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.prep.outputs.tags }}
|
||||||
48
.github/workflows/build-docker-manual.yml
vendored
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
name: Build manual Docker images
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
tag:
|
||||||
|
description: 'Docker image tag'
|
||||||
|
jobs:
|
||||||
|
publish_to_docker_hub:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Set swap space
|
||||||
|
uses: pierotofy/set-swap-space@master
|
||||||
|
with:
|
||||||
|
swap-size-gb: 5
|
||||||
|
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
|
||||||
|
- name: Prepare tags
|
||||||
|
id: prep
|
||||||
|
run: |
|
||||||
|
DOCKER_IMAGE=milesmcc/shynet
|
||||||
|
TAGS="${DOCKER_IMAGE}:${{ github.event.inputs.tag }}"
|
||||||
|
echo ::set-output name=tags::${TAGS}
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v1
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
id: buildx
|
||||||
|
uses: docker/setup-buildx-action@v1
|
||||||
|
|
||||||
|
- name: Login to DockerHub
|
||||||
|
uses: docker/login-action@v1
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_HUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push advanced image
|
||||||
|
id: docker_build
|
||||||
|
uses: docker/build-push-action@v2
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.prep.outputs.tags }}
|
||||||
49
.github/workflows/build-docker-release.yml
vendored
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
name: Build release Docker images
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- "*"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
publish_to_docker_hub:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Set swap space
|
||||||
|
uses: pierotofy/set-swap-space@master
|
||||||
|
with:
|
||||||
|
swap-size-gb: 5
|
||||||
|
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
|
||||||
|
- name: Prepare tags
|
||||||
|
id: prep
|
||||||
|
run: |
|
||||||
|
DOCKER_IMAGE=milesmcc/shynet
|
||||||
|
VERSION=${GITHUB_REF#refs/tags/}
|
||||||
|
TAGS="${DOCKER_IMAGE}:${VERSION},${DOCKER_IMAGE}:latest"
|
||||||
|
echo ::set-output name=tags::${TAGS}
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v1
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
id: buildx
|
||||||
|
uses: docker/setup-buildx-action@v1
|
||||||
|
|
||||||
|
- name: Login to DockerHub
|
||||||
|
uses: docker/login-action@v1
|
||||||
|
with:
|
||||||
|
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||||
|
password: ${{ secrets.DOCKER_HUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build and push advanced image
|
||||||
|
id: docker_build
|
||||||
|
uses: docker/build-push-action@v2
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./Dockerfile
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
push: true
|
||||||
|
tags: ${{ steps.prep.outputs.tags }}
|
||||||
14
.github/workflows/draft.yml
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
name: Release Drafter
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
update_release_draft:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: release-drafter/release-drafter@v5.11.0
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
37
.github/workflows/run-tests.yml
vendored
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
name: Run tests
|
||||||
|
|
||||||
|
on: [push, pull_request]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
services:
|
||||||
|
db:
|
||||||
|
image: postgres:12.3-alpine
|
||||||
|
env:
|
||||||
|
POSTGRES_USER: shynet_db_user
|
||||||
|
POSTGRES_PASSWORD: shynet_db_user_password
|
||||||
|
POSTGRES_DB: shynet_db
|
||||||
|
ports:
|
||||||
|
- 5432:5432
|
||||||
|
strategy:
|
||||||
|
max-parallel: 4
|
||||||
|
matrix:
|
||||||
|
python-version: [3.9]
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v2
|
||||||
|
- uses: actions/setup-python@v2
|
||||||
|
with:
|
||||||
|
python-version: ${{ matrix.python-version }}
|
||||||
|
- name: Run image
|
||||||
|
uses: abatilo/actions-poetry@v2.0.0
|
||||||
|
with:
|
||||||
|
poetry-version: 1.1.6
|
||||||
|
- name: Install dependencies
|
||||||
|
run: poetry install
|
||||||
|
|
||||||
|
- name: Django Testing project
|
||||||
|
run: |
|
||||||
|
cp TEMPLATE.env .env
|
||||||
|
poetry run ./shynet/manage.py test
|
||||||
11
.gitignore
vendored
@@ -3,6 +3,9 @@ __pycache__/
|
|||||||
*.py[cod]
|
*.py[cod]
|
||||||
*$py.class
|
*$py.class
|
||||||
|
|
||||||
|
# JavaScript packages
|
||||||
|
node_modules/
|
||||||
|
|
||||||
# C extensions
|
# C extensions
|
||||||
*.so
|
*.so
|
||||||
|
|
||||||
@@ -109,6 +112,9 @@ venv/
|
|||||||
ENV/
|
ENV/
|
||||||
env.bak/
|
env.bak/
|
||||||
venv.bak/
|
venv.bak/
|
||||||
|
Vagrantfile
|
||||||
|
.vagrant
|
||||||
|
ubuntu-xenial-16.04-cloudimg-console.log
|
||||||
|
|
||||||
# Spyder project settings
|
# Spyder project settings
|
||||||
.spyderproject
|
.spyderproject
|
||||||
@@ -132,4 +138,7 @@ dmypy.json
|
|||||||
secrets.yml
|
secrets.yml
|
||||||
.vscode
|
.vscode
|
||||||
.DS_Store
|
.DS_Store
|
||||||
compiledstatic/
|
compiledstatic/
|
||||||
|
|
||||||
|
# Pycharm
|
||||||
|
.idea
|
||||||
|
|||||||
15
CONTRIBUTING.md
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
# Contributing
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Setting up your development environment
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
To begin, clone the Shynet repository to your computer, and ensure that you have Docker and Docker Compose installed.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
_Did you have to perform additional steps to setup your environment? Document them here and submit a pull request!_
|
||||||
40
Dockerfile
@@ -1,24 +1,39 @@
|
|||||||
FROM python:3-alpine
|
FROM python:alpine3.14
|
||||||
|
|
||||||
# Getting things ready
|
# Getting things ready
|
||||||
WORKDIR /usr/src/shynet
|
WORKDIR /usr/src/shynet
|
||||||
COPY Pipfile.lock Pipfile ./
|
|
||||||
|
|
||||||
# Install dependencies & configure machine
|
# Install dependencies & configure machine
|
||||||
ARG GF_UID="500"
|
ARG GF_UID="500"
|
||||||
ARG GF_GID="500"
|
ARG GF_GID="500"
|
||||||
RUN apk update && \
|
RUN apk update && \
|
||||||
apk add gettext curl bash && \
|
apk add gettext curl bash npm libffi-dev rust cargo
|
||||||
# URL from https://github.com/shlinkio/shlink/issues/596 :)
|
# libffi-dev and rust are used for the cryptography package,
|
||||||
curl "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-ASN&license_key=G4Lm0C60yJsnkdPi&suffix=tar.gz" | tar -xvz -C /tmp && \
|
# which we indirectly rely on. Necessary for aarch64 support.
|
||||||
curl "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-City&license_key=G4Lm0C60yJsnkdPi&suffix=tar.gz" | tar -xvz -C /tmp && \
|
|
||||||
|
# Collect GeoIP Database
|
||||||
|
RUN curl -m 180 "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-ASN&license_key=kKG1ebhL3iWVd0iv&suffix=tar.gz" | tar -xvz -C /tmp && \
|
||||||
|
curl -m 180 "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-City&license_key=kKG1ebhL3iWVd0iv&suffix=tar.gz" | tar -xvz -C /tmp && \
|
||||||
mv /tmp/GeoLite2*/*.mmdb /etc && \
|
mv /tmp/GeoLite2*/*.mmdb /etc && \
|
||||||
apk del curl && \
|
apk del curl
|
||||||
apk add --no-cache postgresql-libs && \
|
|
||||||
|
# Move dependency files
|
||||||
|
COPY poetry.lock pyproject.toml ./
|
||||||
|
COPY package.json package-lock.json ../
|
||||||
|
# Django expects node_modules to be in its parent directory.
|
||||||
|
|
||||||
|
# Install more dependencies
|
||||||
|
RUN apk add --no-cache postgresql-libs && \
|
||||||
apk add --no-cache --virtual .build-deps gcc musl-dev postgresql-dev && \
|
apk add --no-cache --virtual .build-deps gcc musl-dev postgresql-dev && \
|
||||||
pip install pipenv && \
|
npm i -P --prefix .. && \
|
||||||
pipenv install --system --deploy && \
|
pip install poetry==1.1.7
|
||||||
apk --purge del .build-deps && \
|
|
||||||
|
# Install Python dependencies
|
||||||
|
RUN poetry config virtualenvs.create false && \
|
||||||
|
poetry install --no-dev --no-interaction --no-ansi
|
||||||
|
|
||||||
|
# Cleanup dependencies & setup user group
|
||||||
|
RUN apk --purge del .build-deps && \
|
||||||
rm -rf /var/lib/apt/lists/* && \
|
rm -rf /var/lib/apt/lists/* && \
|
||||||
rm /var/cache/apk/* && \
|
rm /var/cache/apk/* && \
|
||||||
addgroup --system -g $GF_GID appgroup && \
|
addgroup --system -g $GF_GID appgroup && \
|
||||||
@@ -32,4 +47,5 @@ RUN python manage.py collectstatic --noinput && \
|
|||||||
# Launch
|
# Launch
|
||||||
USER appuser
|
USER appuser
|
||||||
EXPOSE 8080
|
EXPOSE 8080
|
||||||
ENTRYPOINT [ "./entrypoint.sh" ]
|
HEALTHCHECK CMD bash -c 'wget -o /dev/null -O /dev/null --header "Host: ${ALLOWED_HOSTS%%,*}" "http://127.0.0.1:$PORT/healthz/?format=json"'
|
||||||
|
CMD [ "./entrypoint.sh" ]
|
||||||
|
|||||||
169
GUIDE.md
@@ -3,19 +3,26 @@
|
|||||||
## Table of Contents
|
## Table of Contents
|
||||||
|
|
||||||
- [Installation](#installation)
|
- [Installation](#installation)
|
||||||
|
- [Heroku](#heroku)
|
||||||
|
- [Render](#render)
|
||||||
- [Updating Your Configuration](#updating-your-configuration)
|
- [Updating Your Configuration](#updating-your-configuration)
|
||||||
- [Enhancements](#enhancements)
|
- [Advanced Usage](#advanced-usage)
|
||||||
* [Installation with SSL](#installation-with-ssl)
|
|
||||||
* [Configuring a Reverse Proxy](#configuring-a-reverse-proxy)
|
* [Configuring a Reverse Proxy](#configuring-a-reverse-proxy)
|
||||||
+ [Cloudflare](#cloudflare)
|
+ [Cloudflare](#cloudflare)
|
||||||
+ [Nginx](#nginx)
|
+ [Nginx](#nginx)
|
||||||
|
* [Health Checks](#health-checks)
|
||||||
|
* [Primary Key Integration](#primary-key-integration)
|
||||||
|
* [Usage with Single-Page Applications](#usage-with-single-page-applications)
|
||||||
+ [Troubleshooting](#troubleshooting)
|
+ [Troubleshooting](#troubleshooting)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Staying Updated
|
||||||
|
|
||||||
|
**If you install Shynet, you should strongly consider enabling notifications when new versions are released.** You can do this under the "Watch" tab on GitHub (above). This will ensure that you are notified when new versions are available, some of which may be security updates. (Shynet will never automatically update itself.)
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
Installation of Shynet is easy! Follow the [Basic Installation](#basic-installation) guide below if you'd like to run Shynet over HTTP or if you are going to be running it over HTTPS through a reverse proxy. If you'd like to run Shynet over HTTPS without a reverse proxy, skip ahead to [Installation with SSL](#installation-with-ssl) instead.
|
Installation of Shynet is easy! Follow the [Basic Installation](#basic-installation) guide or the [Basic Installation with Docker Compose](#basic-installation-with-docker-compose) below for a minimal installation, or if you are going to be running Shynet over HTTPS through a reverse proxy.
|
||||||
|
|
||||||
> **These commands assume Ubuntu.** If you're installing Shynet on a different platform, the process will be different.
|
> **These commands assume Ubuntu.** If you're installing Shynet on a different platform, the process will be different.
|
||||||
|
|
||||||
@@ -27,64 +34,66 @@ Before continuing, please be sure to have the latest version of Docker installed
|
|||||||
|
|
||||||
2. Have a PostgreSQL server ready to go. This can be on the same machine as the deployment, or elsewhere. You'll just need a username, password, host, and port. (For info on how to setup a PostgreSQL server on Ubuntu, follow [this guide](https://www.digitalocean.com/community/tutorials/how-to-install-and-use-postgresql-on-ubuntu-18-04)).
|
2. Have a PostgreSQL server ready to go. This can be on the same machine as the deployment, or elsewhere. You'll just need a username, password, host, and port. (For info on how to setup a PostgreSQL server on Ubuntu, follow [this guide](https://www.digitalocean.com/community/tutorials/how-to-install-and-use-postgresql-on-ubuntu-18-04)).
|
||||||
|
|
||||||
3. Configure an environment file for Shynet, using [this file](/TEMPLATE.env) as a template. (This file is typically named `.env`.) Make sure you set the database settings, or Shynet won't be able to run.
|
3. Configure an environment file for Shynet, using [this file](/TEMPLATE.env) as a template. (This file is typically named `.env`.) Make sure you set the database settings, or Shynet won't be able to run. Also consider setting `ALLOWED_HOSTS` inside the environment file to your deployment's domain for better security.
|
||||||
|
|
||||||
4. Launch the Shynet server by running `docker run --env-file=<your env file> milesmcc/shynet:latest`. Watch the output of the script; if it's the first run, you'll see a temporary password printed that you can use to log in. You may need to bind Docker's port 8080 (where Shynet runs) to your local port 80 (http); this can be done using the flag `-p 80:8080` after `run`.
|
4. Launch the Shynet server for the first time by running `docker run --env-file=<your env file> milesmcc/shynet:latest`. Provided you're using the default environment information (i.e., `PERFORM_CHECKS_AND_SETUP` is `True`), you'll see a few warnings about not having an admin user or host setup; these are normal. Don't worry — we'll do this in the next step. You only need to stop if you see a stacktrace about being unable to connect to the database.
|
||||||
|
|
||||||
5. Visit your service's homepage, and verify everything looks right! You should see a login prompt. Log in with the credentials from step 4. You'll probably be prompted to "confirm your email"—if you haven't set up an email server, the confirmation email will be printed to the console instead.
|
5. Create an admin user by running `docker run --env-file=<your env file> milesmcc/shynet:latest ./manage.py registeradmin <your email>`. A temporary password will be printed to the console.
|
||||||
|
|
||||||
6. Create a service by clicking "+ Create Service" in the top right hand corner. Fill out the options as appropriate. Once you're done, press "create" and you'll be redirected to your new service's analytics page.
|
6. Set the whitelabel of your Shynet instance by running `docker run --env-file=<your env file> milesmcc/shynet:latest ./manage.py whitelabel <whitelabel>`. While this setting doesn't affect any core operations of Shynet, it lets you rename Shynet to whatever you want. (Example whitelabels: `"My Shynet Instance"` or `"Acme Analytics"`.)
|
||||||
|
|
||||||
7. Finally, click on "Manage" in the top right of the service's page to get the tracking script code. Inject this script on all pages you'd like the service to track.
|
7. Launch your webserver by running `docker run --env-file=<your env file> milesmcc/shynet:latest`. You may need to bind Docker's port 8080 (where Shynet runs) to your local port 80 (http); this can be done using the flag `-p 80:8080` after `run`. Visit your service's homepage, and verify everything looks right! You should see a login prompt. Log in with the credentials from step 5. You'll probably be prompted to "confirm your email"—if you haven't set up an email server, the confirmation email will be printed to the console instead.
|
||||||
|
|
||||||
## Updating Your Configuration
|
8. Create a service by clicking "+ Create Service" in the top right hand corner. Fill out the options as appropriate. Once you're done, press "create" and you'll be redirected to your new service's analytics page.
|
||||||
|
|
||||||
When you first setup Shynet, you set a number of environment variables that determine first-run initialization settings (these variables start with `SHYNET_`). Once they're first set, though, changing them won't have any effect. Be sure to run the following commands in the same way that you deploy Shynet (i.e., linked to the same database).
|
9. Finally, click on "Manage" in the top right of the service's page to get the tracking script code. Inject this script on all pages you'd like the service to track.
|
||||||
|
|
||||||
* Create an admin account by running `docker run --env-file=<your env file> milesmcc/shynet:latest python manage.py registeradmin <your email>`. The command will print a temporary password that you'll be able to use to log in.
|
|
||||||
|
|
||||||
* Configure Shynet's hostname (e.g. `shynet.example.com` or `localhost:8000`) by running `docker run --env-file=<your env file> milesmcc/shynet:latest python manage.py hostname "<your hostname>"`. This doesn't affect Shynet's bind port; instead, it determines what hostname to inject into the tracking script. (So you'll want to use the "user-facing" hostname here.)
|
### Basic Installation with Docker Compose
|
||||||
|
|
||||||
* Name your Shynet instance by running `docker run --env-file=<your env file> milesmcc/shynet:latest python manage.py whitelabel "<your instance name>"`. This could be something like "My Shynet Server" or "Acme Analytics"—whatever suits you.
|
> Make sure you have `docker-compose` installed. If not, [install it](https://docs.docker.com/compose/install/)
|
||||||
|
|
||||||
|
1. Clone the repository.
|
||||||
|
|
||||||
|
2. Using [TEMPLATE.env](/TEMPLATE.env) as a template, configure the environment for your Shynet instance and place the modified config in a file called `.env` in the root of the repository. Do _not_ change the port number at the end; you can set the public facing port in the next step.
|
||||||
|
|
||||||
|
3. On line 2 of the `nginx.conf` file located in the root of the repository, replace `example.com` with your hostname. Then, in the `docker-compose.yml` file, set the port number by replacing `8080` in line 38 ( `- 8080:80` ) with whatever local port you want to bind it to. For example, set the port number to `- 80:80` if you want your site will be available via HTTP (port 80) at `http://<your hostname>`.
|
||||||
|
|
||||||
|
4. Launch the Shynet server for the first time by running `docker-compose up -d`. If you get an error like "permission denied" or "Couldn't connect to Docker daemon", either prefix the command with `sudo` or add your user to the `docker` group.
|
||||||
|
|
||||||
|
5. Create an admin user by running `docker exec -it shynet_main ./manage.py registeradmin <your email>`. A temporary password will be printed to the console.
|
||||||
|
|
||||||
|
6. Set the whitelabel of your Shynet instance by running `docker exec -it shynet_main ./manage.py whitelabel <whitelabel>`. While this setting doesn't affect any core operations of Shynet, it lets you rename Shynet to whatever you want. (Example whitelabels: "My Shynet Instance" or "Acme Analytics".)
|
||||||
|
|
||||||
|
Your site should now be accessible at `http://hostname:port`. Now you can follow steps 9-10 of the [Basic Installation](#basic-installation) guide above to get Shynet integrated on your sites.
|
||||||
|
|
||||||
|
## Heroku
|
||||||
|
|
||||||
|
You may wish to deploy Shynet on Heroku. Note that Heroku's free offerings (namely the free Postgres addon) are unlikely to support running any Shynet instance that records more than a few hundred requests per day — the database will quickly fill up. In most cases, the more cost-effective option for running Shynet is renting a VPS from a full cloud service provider. However, if you're sure Heroku is the right option for you, or you just want to try Shynet out, you can use the Quick Deploy button then follow the steps below.
|
||||||
|
|
||||||
|
[](https://heroku.com/deploy?template=https://github.com/milesmcc/shynet/tree/master)
|
||||||
|
|
||||||
|
Once you deploy, you'll need to setup an admin user and whitelabel before you can use Shynet. Do that with the following commands:
|
||||||
|
|
||||||
|
1. `heroku run --app=<your app> ./manage.py registeradmin <your email>`
|
||||||
|
2. `heroku run --app=<your app> ./manage.py whitelabel "<your Shynet instance's name>"`
|
||||||
|
|
||||||
|
## Render
|
||||||
|
|
||||||
|
[Render](https://render.com) is a modern cloud platform to build and run all your apps and websites with free SSL, a global CDN, private networks and auto deploys from Git. To deploy Shynet, click the `Deploy to Render` button and follow the steps below.
|
||||||
|
|
||||||
|
[](https://render.com/deploy?repo=https://github.com/render-examples/shynet)
|
||||||
|
|
||||||
|
Once your deploy has completed, use the **Render Shell** to configure your app:
|
||||||
|
|
||||||
|
1. Set your email: `./manage.py registeradmin your-email@example.com`
|
||||||
|
2. Set your whitelabel: `./manage.py whitelabel "Your Shynet Instance Name"`
|
||||||
|
|
||||||
|
See the [Render docs](https://render.com/docs/deploy-shynet) for more information on deploying your application on Render.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Enhancements
|
## Advanced Usage
|
||||||
|
|
||||||
|
|
||||||
### Installation with SSL
|
|
||||||
|
|
||||||
If you are going to be running Shynet through a reverse proxy, please see [Configuring a Reverse Proxy](#configuring-a-reverse-proxy) instead.
|
|
||||||
|
|
||||||
0. We'll be cloning this into the home directory to make this installation easier, so run `cd ~/` if you need to.
|
|
||||||
|
|
||||||
1. Instead of pulling from Docker, we will be pulling from GitHub and building using Docker in order to easily add SSL certificates. You will want to run `git clone https://github.com/milesmcc/shynet.git` to clone the GitHub repo to your current working directory.
|
|
||||||
|
|
||||||
2. To install `certbot` follow [the guide here](https://certbot.eff.org/instructions) or follow along below
|
|
||||||
* Ubuntu 18.04
|
|
||||||
* `sudo apt-get update`
|
|
||||||
* `sudo apt-get install software-properties-common`
|
|
||||||
* `sudo add-apt-repository universe`
|
|
||||||
* `sudo add-apt-repository ppa:certbot/certbot`
|
|
||||||
* `sudo apt-get update`
|
|
||||||
* `sudo apt-get install certbot`
|
|
||||||
|
|
||||||
3. Run `sudo certbot certonly --standalone` and follow the instructions to generate your SSL certificate.
|
|
||||||
* If you registering the certificate to a domain name like `example.com`, please be sure to point your DNS records to your current server before running `certbot`.
|
|
||||||
|
|
||||||
4. We are going to move the SSL certificates to the Shynet repo with with command below. Replace `<domain>` with the domain name you used in step 3.
|
|
||||||
* `cp /etc/letsencrypt/live/<domain>/{cert,privkey}.pem ~/shynet/shynet/`
|
|
||||||
|
|
||||||
5. With that, we are going to replace the `webserver.sh` with `ssl.webserver.sh` to enable the use of SSL certificates. The original `webserver.sh` will be backed up to `backup.webserver.sh`
|
|
||||||
* `mv ~/shynet/shynet/webserver.sh ~/shynet/shynet/backup.webserver.sh`
|
|
||||||
* `mv ~/shynet/shynet/ssl.webserver.sh ~/shynet/shynet/webserver.sh`
|
|
||||||
|
|
||||||
6. Now we build the image!
|
|
||||||
* `docker image build shynet -t shynet-ssl:latest`
|
|
||||||
|
|
||||||
7. Have a PostgreSQL server ready to go. This can be on the same machine as the deployment, or elsewhere. You'll just need a username, password, host, and port (default is `5432`). (For info on how to setup a PostgreSQL server on Ubuntu, follow [this guide](https://www.digitalocean.com/community/tutorials/how-to-install-and-use-postgresql-on-ubuntu-18-04)).
|
|
||||||
|
|
||||||
8. Follow the [Basic Installation](#basic-installation) guide with just one modification: in step #4, change the local bind port from `80` to `443`, and use `shynet-ssl:latest` as your Docker image instead of `milesmcc/shynet:latest`.
|
|
||||||
|
|
||||||
### Configuring a Reverse Proxy
|
### Configuring a Reverse Proxy
|
||||||
|
|
||||||
@@ -114,8 +123,6 @@ A reverse proxy has many benefits. It can be used for DDoS protection, caching f
|
|||||||
|
|
||||||
Nginx is a self hosted, highly configurable webserver. Nginx can be configured to run as a reverse proxy on either the same machine or a remote machine.
|
Nginx is a self hosted, highly configurable webserver. Nginx can be configured to run as a reverse proxy on either the same machine or a remote machine.
|
||||||
|
|
||||||
##### Set up
|
|
||||||
|
|
||||||
> **These commands assume Ubuntu.** If you're installing Nginx on a different platform, the process will be different.
|
> **These commands assume Ubuntu.** If you're installing Nginx on a different platform, the process will be different.
|
||||||
|
|
||||||
0. Before starting, shut down your Docker containers (if any are running)
|
0. Before starting, shut down your Docker containers (if any are running)
|
||||||
@@ -140,6 +147,7 @@ Nginx is a self hosted, highly configurable webserver. Nginx can be configured t
|
|||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
location / {
|
location / {
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_pass http://127.0.0.1:8080;
|
proxy_pass http://127.0.0.1:8080;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -166,6 +174,56 @@ Nginx is a self hosted, highly configurable webserver. Nginx can be configured t
|
|||||||
* [How to add SSL/HTTPS to Nginx (Ubuntu 16.04)](https://www.digitalocean.com/community/tutorials/how-to-secure-nginx-with-let-s-encrypt-on-ubuntu-16-04)
|
* [How to add SSL/HTTPS to Nginx (Ubuntu 16.04)](https://www.digitalocean.com/community/tutorials/how-to-secure-nginx-with-let-s-encrypt-on-ubuntu-16-04)
|
||||||
* [Nginx Documentation](https://nginx.org/en/docs/)
|
* [Nginx Documentation](https://nginx.org/en/docs/)
|
||||||
|
|
||||||
|
### Health Checks
|
||||||
|
|
||||||
|
By default, Shynet includes a default health check endpoint at `/healthz/`. If the instance is running normally, this endpoint will return an HTTP status code of 200; if something is wrong, it will have a non-200 status code. To view the health data as JSON, send your request to `/healthz/?format=json`.
|
||||||
|
|
||||||
|
This feature is helpful when running Shynet with Kubernetes, as it allows you to setup [startup readiness probes](https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/) that prevent traffic from being sent to your Shynet instances before they are ready.
|
||||||
|
|
||||||
|
### Primary-Key Integration
|
||||||
|
|
||||||
|
In some cases, it is useful to associate particular users on your platform with their sessions in Shynet. In Shynet, this is called _primary key integration_, and is done by adding an additional element to the Shynet script url for each particular user.
|
||||||
|
|
||||||
|
If the Shynet script location (for either the pixel or the script) is, for example, `//shynet.example.com/ingress/your_service_uuid/pixel.gif` and `//shynet.example.com/ingress/your_service_uuid/script.js`, the URLs for primary-key enabled users would be `//shynet.example.com/ingress/your_service_uuid/USER_PRIMARY_KEY/pixel.gif` and `//shynet.example.com/ingress/your_service_uuid/USER_PRIMARY_KEY/script.js`.
|
||||||
|
|
||||||
|
Adding this path can be done easily using server-side rendering. For example, here is a Django template that adds users' primary keys to the Shynet tracking script:
|
||||||
|
|
||||||
|
```html
|
||||||
|
{% if request.user.is_authenticated %}
|
||||||
|
<noscript>
|
||||||
|
<img src="//shynet.example.com/ingress/service-uuid/{{request.user.email|urlencode:""}}/pixel.gif">
|
||||||
|
</noscript>
|
||||||
|
<script src="//shynet.example.com/ingress/service-uuid/{{request.user.email|urlencode:""}}/script.js"></script>
|
||||||
|
{% else %}
|
||||||
|
<noscript>
|
||||||
|
<img src="//shynet.example.com/ingress/service-uuid/pixel.gif">
|
||||||
|
</noscript>
|
||||||
|
<script src="//shynet.example.com/ingress/service-uuid/script.js"></script>
|
||||||
|
{% endif %}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Usage with Single-Page Applications
|
||||||
|
|
||||||
|
In a single-page application, the page never reloads. (That's the entire point of single-page applications, after all!) Unfortunately, this also means that Shynet will not automatically recognize and track when the user navigates between pages _within_ your application.
|
||||||
|
|
||||||
|
Fortunately, Shynet offers a simple method you can call from anywhere within your JavaScript to indicate that a new page has been loaded: `Shynet.newPageLoad()`. Add this method call to the code that handles routing in your app, and you'll be ready to go.
|
||||||
|
|
||||||
|
|
||||||
|
### API
|
||||||
|
|
||||||
|
All the information displayed on the dashboard can be obtained via API on url ```//shynet.example.com/api/v1/dashboard/```. By default this endpoint will return the full data from all services over the last last 30 days. The `Authentication` header should be set to use user's parsonal API token (```'Authorization: Token <user API token>'```).
|
||||||
|
|
||||||
|
There are 3 optional query parameters:
|
||||||
|
* `uuid` - to get data only from one service
|
||||||
|
* `startDate` - to set start date in format YYYY-MM-DD
|
||||||
|
* `endDate` - to set end date in format YYYY-MM-DD
|
||||||
|
|
||||||
|
Example in HTTPie:
|
||||||
|
```http get '//shynet.example.com/api/v1/dashboard/?uuid={{service_uuid}}&startDate=2021-01-01&endDate=2050-01-01' 'Authorization:Token {{user_api_token}}'```
|
||||||
|
|
||||||
|
Example in cURL:
|
||||||
|
```curl -H 'Authorization:Token {{user_api_token}}' '//shynet.example.com/api/v1/dashboard/?uuid={{service_uuid}}&startDate=2021-01-01&endDate=2050-01-01'```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Troubleshooting
|
## Troubleshooting
|
||||||
@@ -181,7 +239,12 @@ Here are solutions for some common issues. If your situation isn't described her
|
|||||||
#### Shynet isn't linking different pageviews from the same visitor into a single session!
|
#### Shynet isn't linking different pageviews from the same visitor into a single session!
|
||||||
|
|
||||||
* Verify that your cache is properly configured. (See #2 above.) In multi-instance deployments, it's critical that all webservers are using the _same_ cache—so make sure you configure a Redis cache if you're using a non-default installation.
|
* Verify that your cache is properly configured. (See #2 above.) In multi-instance deployments, it's critical that all webservers are using the _same_ cache—so make sure you configure a Redis cache if you're using a non-default installation.
|
||||||
|
* This can happen between Shynet restarts if you're not using an external cache provider (like Redis).
|
||||||
|
|
||||||
#### I changed the `SHYNET_WHITELABEL`/`SHYNET_HOST` environment variable, but nothing happened!
|
#### I changed the `SHYNET_WHITELABEL`/`SHYNET_HOST` environment variable, but nothing happened!
|
||||||
|
|
||||||
* Those values only affect how your Shynet instance is setup on first run; once it's configured, they have no effect. See [updating your configuration](#updating-your-configuration) for help on how to update your configuration.
|
* Those values only affect how your Shynet instance is setup on first run; once it's configured, they have no effect. See [updating your configuration](#updating-your-configuration) for help on how to update your configuration. (Note: these environment variables are not present in newer Shynet versions; they have been removed from the guide.)
|
||||||
|
|
||||||
|
#### Shynet can't connect to my database running on `localhost`/`127.0.0.1`
|
||||||
|
|
||||||
|
* The problem is likely that to Shynet, `localhost` points to the local network in the container itself, not on the host machine. Try adding the `--network='host'` option when you run Docker.
|
||||||
|
|||||||
28
Pipfile
@@ -1,28 +0,0 @@
|
|||||||
[[source]]
|
|
||||||
name = "pypi"
|
|
||||||
url = "https://pypi.org/simple"
|
|
||||||
verify_ssl = true
|
|
||||||
|
|
||||||
[dev-packages]
|
|
||||||
black = "*"
|
|
||||||
|
|
||||||
[packages]
|
|
||||||
django = "*"
|
|
||||||
django-allauth = "*"
|
|
||||||
geoip2 = "*"
|
|
||||||
whitenoise = "*"
|
|
||||||
celery = "*"
|
|
||||||
django-ipware = "*"
|
|
||||||
pyyaml = "*"
|
|
||||||
ua-parser = "*"
|
|
||||||
user-agents = "*"
|
|
||||||
emoji-country-flag = "*"
|
|
||||||
rules = "*"
|
|
||||||
gunicorn = "*"
|
|
||||||
psycopg2-binary = "*"
|
|
||||||
redis = "*"
|
|
||||||
django-redis-cache = "*"
|
|
||||||
pycountry = "*"
|
|
||||||
|
|
||||||
[pipenv]
|
|
||||||
allow_prereleases = true
|
|
||||||
401
Pipfile.lock
generated
@@ -1,401 +0,0 @@
|
|||||||
{
|
|
||||||
"_meta": {
|
|
||||||
"hash": {
|
|
||||||
"sha256": "2cd3e33ea333a40476d2030b6be66826e93c3a4de67032655061725835c92f09"
|
|
||||||
},
|
|
||||||
"pipfile-spec": 6,
|
|
||||||
"requires": {},
|
|
||||||
"sources": [
|
|
||||||
{
|
|
||||||
"name": "pypi",
|
|
||||||
"url": "https://pypi.org/simple",
|
|
||||||
"verify_ssl": true
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"default": {
|
|
||||||
"amqp": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:6e649ca13a7df3faacdc8bbb280aa9a6602d22fd9d545336077e573a1f4ff3b8",
|
|
||||||
"sha256:77f1aef9410698d20eaeac5b73a87817365f457a507d82edf292e12cbb83b08d"
|
|
||||||
],
|
|
||||||
"version": "==2.5.2"
|
|
||||||
},
|
|
||||||
"asgiref": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:8036f90603c54e93521e5777b2b9a39ba1bad05773fcf2d208f0299d1df58ce5",
|
|
||||||
"sha256:9ca8b952a0a9afa61d30aa6d3d9b570bb3fd6bafcf7ec9e6bed43b936133db1c"
|
|
||||||
],
|
|
||||||
"version": "==3.2.7"
|
|
||||||
},
|
|
||||||
"billiard": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:bff575450859a6e0fbc2f9877d9b715b0bbc07c3565bb7ed2280526a0cdf5ede",
|
|
||||||
"sha256:d91725ce6425f33a97dfa72fb6bfef0e47d4652acd98a032bd1a7fbf06d5fa6a"
|
|
||||||
],
|
|
||||||
"version": "==3.6.3.0"
|
|
||||||
},
|
|
||||||
"celery": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:108a0bf9018a871620936c33a3ee9f6336a89f8ef0a0f567a9001f4aa361415f",
|
|
||||||
"sha256:5b4b37e276033fe47575107a2775469f0b721646a08c96ec2c61531e4fe45f2a"
|
|
||||||
],
|
|
||||||
"index": "pypi",
|
|
||||||
"version": "==4.4.2"
|
|
||||||
},
|
|
||||||
"certifi": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:1d987a998c75633c40847cc966fcf5904906c920a7f17ef374f5aa4282abd304",
|
|
||||||
"sha256:51fcb31174be6e6664c5f69e3e1691a2d72a1a12e90f872cbdb1567eb47b6519"
|
|
||||||
],
|
|
||||||
"version": "==2020.4.5.1"
|
|
||||||
},
|
|
||||||
"chardet": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:84ab92ed1c4d4f16916e05906b6b75a6c0fb5db821cc65e70cbd64a3e2a5eaae",
|
|
||||||
"sha256:fc323ffcaeaed0e0a02bf4d117757b98aed530d9ed4531e3e15460124c106691"
|
|
||||||
],
|
|
||||||
"version": "==3.0.4"
|
|
||||||
},
|
|
||||||
"defusedxml": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:6687150770438374ab581bb7a1b327a847dd9c5749e396102de3fad4e8a3ef93",
|
|
||||||
"sha256:f684034d135af4c6cbb949b8a4d2ed61634515257a67299e5f940fbaa34377f5"
|
|
||||||
],
|
|
||||||
"version": "==0.6.0"
|
|
||||||
},
|
|
||||||
"django": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:642d8eceab321ca743ae71e0f985ff8fdca59f07aab3a9fb362c617d23e33a76",
|
|
||||||
"sha256:d4666c2edefa38c5ede0ec1655424c56dc47ceb04b6d8d62a7eac09db89545c1"
|
|
||||||
],
|
|
||||||
"index": "pypi",
|
|
||||||
"version": "==3.0.5"
|
|
||||||
},
|
|
||||||
"django-allauth": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:7ab91485b80d231da191d5c7999ba93170ef1bf14ab6487d886598a1ad03e1d8"
|
|
||||||
],
|
|
||||||
"index": "pypi",
|
|
||||||
"version": "==0.41.0"
|
|
||||||
},
|
|
||||||
"django-ipware": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:a7c7a8fd019dbdc9c357e6e582f65034e897572fc79a7e467674efa8aef9d00b"
|
|
||||||
],
|
|
||||||
"index": "pypi",
|
|
||||||
"version": "==2.1.0"
|
|
||||||
},
|
|
||||||
"django-redis-cache": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:06d4e48545243883f88dc9263dda6c8a0012cb7d0cee2d8758d8917eca92cece",
|
|
||||||
"sha256:b19ee6654cc2f2c89078c99255e07e19dc2dba8792351d76ba7ea899d465fbb0"
|
|
||||||
],
|
|
||||||
"index": "pypi",
|
|
||||||
"version": "==2.1.1"
|
|
||||||
},
|
|
||||||
"emoji-country-flag": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:67c0cb6a3765fb53f31b34160d6b1c8a5f44b297bc278d1835c6f2e5b0a9a592",
|
|
||||||
"sha256:ae7edb38077b0840210fa9e37673f481f2b9c032446e13ad6dab2b1108cd7ad6"
|
|
||||||
],
|
|
||||||
"index": "pypi",
|
|
||||||
"version": "==1.2.1"
|
|
||||||
},
|
|
||||||
"geoip2": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:5869e987bc54c0d707264fec4710661332cc38d2dca5a7f9bb5362d0308e2ce0",
|
|
||||||
"sha256:99ec12d2f1271a73a0a4a2b663fe6ce25fd02289c0a6bef05c0a1c3b30ee95a4"
|
|
||||||
],
|
|
||||||
"index": "pypi",
|
|
||||||
"version": "==3.0.0"
|
|
||||||
},
|
|
||||||
"gunicorn": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:1904bb2b8a43658807108d59c3f3d56c2b6121a701161de0ddf9ad140073c626",
|
|
||||||
"sha256:cd4a810dd51bf497552cf3f863b575dabd73d6ad6a91075b65936b151cbf4f9c"
|
|
||||||
],
|
|
||||||
"index": "pypi",
|
|
||||||
"version": "==20.0.4"
|
|
||||||
},
|
|
||||||
"idna": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:7588d1c14ae4c77d74036e8c22ff447b26d0fde8f007354fd48a7814db15b7cb",
|
|
||||||
"sha256:a068a21ceac8a4d63dbfd964670474107f541babbd2250d61922f029858365fa"
|
|
||||||
],
|
|
||||||
"version": "==2.9"
|
|
||||||
},
|
|
||||||
"kombu": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:2d1cda774126a044d91a7ff5fa6d09edf99f46924ab332a810760fe6740e9b76",
|
|
||||||
"sha256:598e7e749d6ab54f646b74b2d2df67755dee13894f73ab02a2a9feb8870c7cb2"
|
|
||||||
],
|
|
||||||
"version": "==4.6.8"
|
|
||||||
},
|
|
||||||
"maxminddb": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:d0ce131d901eb11669996b49a59f410efd3da2c6dbe2c0094fe2fef8d85b6336"
|
|
||||||
],
|
|
||||||
"version": "==1.5.2"
|
|
||||||
},
|
|
||||||
"oauthlib": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:bee41cc35fcca6e988463cacc3bcb8a96224f470ca547e697b604cc697b2f889",
|
|
||||||
"sha256:df884cd6cbe20e32633f1db1072e9356f53638e4361bef4e8b03c9127c9328ea"
|
|
||||||
],
|
|
||||||
"version": "==3.1.0"
|
|
||||||
},
|
|
||||||
"psycopg2-binary": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:008da3ab51adc70a5f1cfbbe5db3a22607ab030eb44bcecf517ad11a0c2b3cac",
|
|
||||||
"sha256:07cf82c870ec2d2ce94d18e70c13323c89f2f2a2628cbf1feee700630be2519a",
|
|
||||||
"sha256:08507efbe532029adee21b8d4c999170a83760d38249936038bd0602327029b5",
|
|
||||||
"sha256:107d9be3b614e52a192719c6bf32e8813030020ea1d1215daa86ded9a24d8b04",
|
|
||||||
"sha256:17a0ea0b0eabf07035e5e0d520dabc7950aeb15a17c6d36128ba99b2721b25b1",
|
|
||||||
"sha256:3286541b9d85a340ee4ed42732d15fc1bb441dc500c97243a768154ab8505bb5",
|
|
||||||
"sha256:3939cf75fc89c5e9ed836e228c4a63604dff95ad19aed2bbf71d5d04c15ed5ce",
|
|
||||||
"sha256:40abc319f7f26c042a11658bf3dd3b0b3bceccf883ec1c565d5c909a90204434",
|
|
||||||
"sha256:51f7823f1b087d2020d8e8c9e6687473d3d239ba9afc162d9b2ab6e80b53f9f9",
|
|
||||||
"sha256:6bb2dd006a46a4a4ce95201f836194eb6a1e863f69ee5bab506673e0ca767057",
|
|
||||||
"sha256:702f09d8f77dc4794651f650828791af82f7c2efd8c91ae79e3d9fe4bb7d4c98",
|
|
||||||
"sha256:7036ccf715925251fac969f4da9ad37e4b7e211b1e920860148a10c0de963522",
|
|
||||||
"sha256:7b832d76cc65c092abd9505cc670c4e3421fd136fb6ea5b94efbe4c146572505",
|
|
||||||
"sha256:8f74e631b67482d504d7e9cf364071fc5d54c28e79a093ff402d5f8f81e23bfa",
|
|
||||||
"sha256:930315ac53dc65cbf52ab6b6d27422611f5fb461d763c531db229c7e1af6c0b3",
|
|
||||||
"sha256:96d3038f5bd061401996614f65d27a4ecb62d843eb4f48e212e6d129171a721f",
|
|
||||||
"sha256:a20299ee0ea2f9cca494396ac472d6e636745652a64a418b39522c120fd0a0a4",
|
|
||||||
"sha256:a34826d6465c2e2bbe9d0605f944f19d2480589f89863ed5f091943be27c9de4",
|
|
||||||
"sha256:a69970ee896e21db4c57e398646af9edc71c003bc52a3cc77fb150240fefd266",
|
|
||||||
"sha256:b9a8b391c2b0321e0cd7ec6b4cfcc3dd6349347bd1207d48bcb752aa6c553a66",
|
|
||||||
"sha256:ba13346ff6d3eb2dca0b6fa0d8a9d999eff3dcd9b55f3a890f12b0b6362b2b38",
|
|
||||||
"sha256:bb0608694a91db1e230b4a314e8ed00ad07ed0c518f9a69b83af2717e31291a3",
|
|
||||||
"sha256:c8830b7d5f16fd79d39b21e3d94f247219036b29b30c8270314c46bf8b732389",
|
|
||||||
"sha256:cac918cd7c4c498a60f5d2a61d4f0a6091c2c9490d81bc805c963444032d0dab",
|
|
||||||
"sha256:cc30cb900f42c8a246e2cb76539d9726f407330bc244ca7729c41a44e8d807fb",
|
|
||||||
"sha256:ccdc6a87f32b491129ada4b87a43b1895cf2c20fdb7f98ad979647506ffc41b6",
|
|
||||||
"sha256:d1a8b01f6a964fec702d6b6dac1f91f2b9f9fe41b310cbb16c7ef1fac82df06d",
|
|
||||||
"sha256:e004db88e5a75e5fdab1620fb9f90c9598c2a195a594225ac4ed2a6f1c23e162",
|
|
||||||
"sha256:eb2f43ae3037f1ef5e19339c41cf56947021ac892f668765cd65f8ab9814192e",
|
|
||||||
"sha256:fa466306fcf6b39b8a61d003123d442b23707d635a5cb05ac4e1b62cc79105cd"
|
|
||||||
],
|
|
||||||
"index": "pypi",
|
|
||||||
"version": "==2.8.5"
|
|
||||||
},
|
|
||||||
"pycountry": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:3c57aa40adcf293d59bebaffbe60d8c39976fba78d846a018dc0c2ec9c6cb3cb"
|
|
||||||
],
|
|
||||||
"index": "pypi",
|
|
||||||
"version": "==19.8.18"
|
|
||||||
},
|
|
||||||
"python3-openid": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:0086da6b6ef3161cfe50fb1ee5cceaf2cda1700019fda03c2c5c440ca6abe4fa",
|
|
||||||
"sha256:628d365d687e12da12d02c6691170f4451db28d6d68d050007e4a40065868502"
|
|
||||||
],
|
|
||||||
"version": "==3.1.0"
|
|
||||||
},
|
|
||||||
"pytz": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:1c557d7d0e871de1f5ccd5833f60fb2550652da6be2693c1e02300743d21500d",
|
|
||||||
"sha256:b02c06db6cf09c12dd25137e563b31700d3b80fcc4ad23abb7a315f2789819be"
|
|
||||||
],
|
|
||||||
"version": "==2019.3"
|
|
||||||
},
|
|
||||||
"pyyaml": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:06a0d7ba600ce0b2d2fe2e78453a470b5a6e000a985dd4a4e54e436cc36b0e97",
|
|
||||||
"sha256:240097ff019d7c70a4922b6869d8a86407758333f02203e0fc6ff79c5dcede76",
|
|
||||||
"sha256:4f4b913ca1a7319b33cfb1369e91e50354d6f07a135f3b901aca02aa95940bd2",
|
|
||||||
"sha256:69f00dca373f240f842b2931fb2c7e14ddbacd1397d57157a9b005a6a9942648",
|
|
||||||
"sha256:73f099454b799e05e5ab51423c7bcf361c58d3206fa7b0d555426b1f4d9a3eaf",
|
|
||||||
"sha256:74809a57b329d6cc0fdccee6318f44b9b8649961fa73144a98735b0aaf029f1f",
|
|
||||||
"sha256:7739fc0fa8205b3ee8808aea45e968bc90082c10aef6ea95e855e10abf4a37b2",
|
|
||||||
"sha256:95f71d2af0ff4227885f7a6605c37fd53d3a106fcab511b8860ecca9fcf400ee",
|
|
||||||
"sha256:b8eac752c5e14d3eca0e6dd9199cd627518cb5ec06add0de9d32baeee6fe645d",
|
|
||||||
"sha256:cc8955cfbfc7a115fa81d85284ee61147059a753344bc51098f3ccd69b0d7e0c",
|
|
||||||
"sha256:d13155f591e6fcc1ec3b30685d50bf0711574e2c0dfffd7644babf8b5102ca1a"
|
|
||||||
],
|
|
||||||
"index": "pypi",
|
|
||||||
"version": "==5.3.1"
|
|
||||||
},
|
|
||||||
"redis": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:0dcfb335921b88a850d461dc255ff4708294943322bd55de6cfd68972490ca1f",
|
|
||||||
"sha256:b205cffd05ebfd0a468db74f0eedbff8df1a7bfc47521516ade4692991bb0833"
|
|
||||||
],
|
|
||||||
"index": "pypi",
|
|
||||||
"version": "==3.4.1"
|
|
||||||
},
|
|
||||||
"requests": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:43999036bfa82904b6af1d99e4882b560e5e2c68e5c4b0aa03b655f3d7d73fee",
|
|
||||||
"sha256:b3f43d496c6daba4493e7c431722aeb7dbc6288f52a6e04e7b6023b0247817e6"
|
|
||||||
],
|
|
||||||
"version": "==2.23.0"
|
|
||||||
},
|
|
||||||
"requests-oauthlib": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:7f71572defaecd16372f9006f33c2ec8c077c3cfa6f5911a9a90202beb513f3d",
|
|
||||||
"sha256:b4261601a71fd721a8bd6d7aa1cc1d6a8a93b4a9f5e96626f8e4d91e8beeaa6a"
|
|
||||||
],
|
|
||||||
"version": "==1.3.0"
|
|
||||||
},
|
|
||||||
"rules": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:9bae429f9d4f91a375402990da1541f9e093b0ac077221d57124d06eeeca4405"
|
|
||||||
],
|
|
||||||
"index": "pypi",
|
|
||||||
"version": "==2.2"
|
|
||||||
},
|
|
||||||
"six": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:236bdbdce46e6e6a3d61a337c0f8b763ca1e8717c03b369e87a7ec7ce1319c0a",
|
|
||||||
"sha256:8f3cd2e254d8f793e7f3d6d9df77b92252b52637291d0f0da013c76ea2724b6c"
|
|
||||||
],
|
|
||||||
"version": "==1.14.0"
|
|
||||||
},
|
|
||||||
"sqlparse": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:022fb9c87b524d1f7862b3037e541f68597a730a8843245c349fc93e1643dc4e",
|
|
||||||
"sha256:e162203737712307dfe78860cc56c8da8a852ab2ee33750e33aeadf38d12c548"
|
|
||||||
],
|
|
||||||
"version": "==0.3.1"
|
|
||||||
},
|
|
||||||
"ua-parser": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:46ab2e383c01dbd2ab284991b87d624a26a08f72da4d7d413f5bfab8b9036f8a",
|
|
||||||
"sha256:47b1782ed130d890018d983fac37c2a80799d9e0b9c532e734c67cf70f185033"
|
|
||||||
],
|
|
||||||
"index": "pypi",
|
|
||||||
"version": "==0.10.0"
|
|
||||||
},
|
|
||||||
"urllib3": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:3018294ebefce6572a474f0604c2021e33b3fd8006ecd11d62107a5d2a963527",
|
|
||||||
"sha256:88206b0eb87e6d677d424843ac5209e3fb9d0190d0ee169599165ec25e9d9115"
|
|
||||||
],
|
|
||||||
"version": "==1.25.9"
|
|
||||||
},
|
|
||||||
"user-agents": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:da54371d856c35d8ead0622da24ad5ef6d667eda3629a750e3373a3e847a054b",
|
|
||||||
"sha256:e727ab6f169e829bc25d41dbd25b9ff679b4631bd81959bcf7de1e246da67194"
|
|
||||||
],
|
|
||||||
"index": "pypi",
|
|
||||||
"version": "==2.1"
|
|
||||||
},
|
|
||||||
"vine": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:133ee6d7a9016f177ddeaf191c1f58421a1dcc6ee9a42c58b34bed40e1d2cd87",
|
|
||||||
"sha256:ea4947cc56d1fd6f2095c8d543ee25dad966f78692528e68b4fada11ba3f98af"
|
|
||||||
],
|
|
||||||
"version": "==1.3.0"
|
|
||||||
},
|
|
||||||
"whitenoise": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:0f9137f74bd95fa54329ace88d8dc695fbe895369a632e35f7a136e003e41d73",
|
|
||||||
"sha256:62556265ec1011bd87113fb81b7516f52688887b7a010ee899ff1fd18fd22700"
|
|
||||||
],
|
|
||||||
"index": "pypi",
|
|
||||||
"version": "==5.0.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"develop": {
|
|
||||||
"appdirs": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:9e5896d1372858f8dd3344faf4e5014d21849c756c8d5701f78f8a103b372d92",
|
|
||||||
"sha256:d8b24664561d0d34ddfaec54636d502d7cea6e29c3eaf68f3df6180863e2166e"
|
|
||||||
],
|
|
||||||
"version": "==1.4.3"
|
|
||||||
},
|
|
||||||
"attrs": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c",
|
|
||||||
"sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72"
|
|
||||||
],
|
|
||||||
"version": "==19.3.0"
|
|
||||||
},
|
|
||||||
"black": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:1b30e59be925fafc1ee4565e5e08abef6b03fe455102883820fe5ee2e4734e0b",
|
|
||||||
"sha256:c2edb73a08e9e0e6f65a0e6af18b059b8b1cdd5bef997d7a0b181df93dc81539"
|
|
||||||
],
|
|
||||||
"index": "pypi",
|
|
||||||
"version": "==19.10b0"
|
|
||||||
},
|
|
||||||
"click": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:8a18b4ea89d8820c5d0c7da8a64b2c324b4dabb695804dbfea19b9be9d88c0cc",
|
|
||||||
"sha256:e345d143d80bf5ee7534056164e5e112ea5e22716bbb1ce727941f4c8b471b9a"
|
|
||||||
],
|
|
||||||
"version": "==7.1.1"
|
|
||||||
},
|
|
||||||
"pathspec": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:7d91249d21749788d07a2d0f94147accd8f845507400749ea19c1ec9054a12b0",
|
|
||||||
"sha256:da45173eb3a6f2a5a487efba21f050af2b41948be6ab52b6a1e3ff22bb8b7061"
|
|
||||||
],
|
|
||||||
"version": "==0.8.0"
|
|
||||||
},
|
|
||||||
"regex": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:08119f707f0ebf2da60d2f24c2f39ca616277bb67ef6c92b72cbf90cbe3a556b",
|
|
||||||
"sha256:0ce9537396d8f556bcfc317c65b6a0705320701e5ce511f05fc04421ba05b8a8",
|
|
||||||
"sha256:1cbe0fa0b7f673400eb29e9ef41d4f53638f65f9a2143854de6b1ce2899185c3",
|
|
||||||
"sha256:2294f8b70e058a2553cd009df003a20802ef75b3c629506be20687df0908177e",
|
|
||||||
"sha256:23069d9c07e115537f37270d1d5faea3e0bdded8279081c4d4d607a2ad393683",
|
|
||||||
"sha256:24f4f4062eb16c5bbfff6a22312e8eab92c2c99c51a02e39b4eae54ce8255cd1",
|
|
||||||
"sha256:295badf61a51add2d428a46b8580309c520d8b26e769868b922750cf3ce67142",
|
|
||||||
"sha256:2a3bf8b48f8e37c3a40bb3f854bf0121c194e69a650b209628d951190b862de3",
|
|
||||||
"sha256:4385f12aa289d79419fede43f979e372f527892ac44a541b5446617e4406c468",
|
|
||||||
"sha256:5635cd1ed0a12b4c42cce18a8d2fb53ff13ff537f09de5fd791e97de27b6400e",
|
|
||||||
"sha256:5bfed051dbff32fd8945eccca70f5e22b55e4148d2a8a45141a3b053d6455ae3",
|
|
||||||
"sha256:7e1037073b1b7053ee74c3c6c0ada80f3501ec29d5f46e42669378eae6d4405a",
|
|
||||||
"sha256:90742c6ff121a9c5b261b9b215cb476eea97df98ea82037ec8ac95d1be7a034f",
|
|
||||||
"sha256:a58dd45cb865be0ce1d5ecc4cfc85cd8c6867bea66733623e54bd95131f473b6",
|
|
||||||
"sha256:c087bff162158536387c53647411db09b6ee3f9603c334c90943e97b1052a156",
|
|
||||||
"sha256:c162a21e0da33eb3d31a3ac17a51db5e634fc347f650d271f0305d96601dc15b",
|
|
||||||
"sha256:c9423a150d3a4fc0f3f2aae897a59919acd293f4cb397429b120a5fcd96ea3db",
|
|
||||||
"sha256:ccccdd84912875e34c5ad2d06e1989d890d43af6c2242c6fcfa51556997af6cd",
|
|
||||||
"sha256:e91ba11da11cf770f389e47c3f5c30473e6d85e06d7fd9dcba0017d2867aab4a",
|
|
||||||
"sha256:ea4adf02d23b437684cd388d557bf76e3afa72f7fed5bbc013482cc00c816948",
|
|
||||||
"sha256:fb95debbd1a824b2c4376932f2216cc186912e389bdb0e27147778cf6acb3f89"
|
|
||||||
],
|
|
||||||
"version": "==2020.4.4"
|
|
||||||
},
|
|
||||||
"toml": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:229f81c57791a41d65e399fc06bf0848bab550a9dfd5ed66df18ce5f05e73d5c",
|
|
||||||
"sha256:235682dd292d5899d361a811df37e04a8828a5b1da3115886b73cf81ebc9100e"
|
|
||||||
],
|
|
||||||
"version": "==0.10.0"
|
|
||||||
},
|
|
||||||
"typed-ast": {
|
|
||||||
"hashes": [
|
|
||||||
"sha256:0666aa36131496aed8f7be0410ff974562ab7eeac11ef351def9ea6fa28f6355",
|
|
||||||
"sha256:0c2c07682d61a629b68433afb159376e24e5b2fd4641d35424e462169c0a7919",
|
|
||||||
"sha256:249862707802d40f7f29f6e1aad8d84b5aa9e44552d2cc17384b209f091276aa",
|
|
||||||
"sha256:24995c843eb0ad11a4527b026b4dde3da70e1f2d8806c99b7b4a7cf491612652",
|
|
||||||
"sha256:269151951236b0f9a6f04015a9004084a5ab0d5f19b57de779f908621e7d8b75",
|
|
||||||
"sha256:4083861b0aa07990b619bd7ddc365eb7fa4b817e99cf5f8d9cf21a42780f6e01",
|
|
||||||
"sha256:498b0f36cc7054c1fead3d7fc59d2150f4d5c6c56ba7fb150c013fbc683a8d2d",
|
|
||||||
"sha256:4e3e5da80ccbebfff202a67bf900d081906c358ccc3d5e3c8aea42fdfdfd51c1",
|
|
||||||
"sha256:6daac9731f172c2a22ade6ed0c00197ee7cc1221aa84cfdf9c31defeb059a907",
|
|
||||||
"sha256:715ff2f2df46121071622063fc7543d9b1fd19ebfc4f5c8895af64a77a8c852c",
|
|
||||||
"sha256:73d785a950fc82dd2a25897d525d003f6378d1cb23ab305578394694202a58c3",
|
|
||||||
"sha256:8c8aaad94455178e3187ab22c8b01a3837f8ee50e09cf31f1ba129eb293ec30b",
|
|
||||||
"sha256:8ce678dbaf790dbdb3eba24056d5364fb45944f33553dd5869b7580cdbb83614",
|
|
||||||
"sha256:aaee9905aee35ba5905cfb3c62f3e83b3bec7b39413f0a7f19be4e547ea01ebb",
|
|
||||||
"sha256:bcd3b13b56ea479b3650b82cabd6b5343a625b0ced5429e4ccad28a8973f301b",
|
|
||||||
"sha256:c9e348e02e4d2b4a8b2eedb48210430658df6951fa484e59de33ff773fbd4b41",
|
|
||||||
"sha256:d205b1b46085271b4e15f670058ce182bd1199e56b317bf2ec004b6a44f911f6",
|
|
||||||
"sha256:d43943ef777f9a1c42bf4e552ba23ac77a6351de620aa9acf64ad54933ad4d34",
|
|
||||||
"sha256:d5d33e9e7af3b34a40dc05f498939f0ebf187f07c385fd58d591c533ad8562fe",
|
|
||||||
"sha256:fc0fea399acb12edbf8a628ba8d2312f583bdbdb3335635db062fa98cf71fca4",
|
|
||||||
"sha256:fe460b922ec15dd205595c9b5b99e2f056fd98ae8f9f56b888e7a17dc2b757e7"
|
|
||||||
],
|
|
||||||
"version": "==1.4.1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
<br>
|
<br>
|
||||||
<strong><a href="#installation">Getting started »</a></strong>
|
<strong><a href="#installation">Getting started »</a></strong>
|
||||||
</p>
|
</p>
|
||||||
<p align="center"><a href="#screenshots">Screenshots</a> • <a href="#features">Features</a> • <a href="https://github.com/milesmcc/a17t">Design</a></p>
|
<p align="center"><a href="#screenshots">Screenshots</a> • <a href="#features">Features</a> • <a href="https://miles.land/officehours/">Office Hours</a></p>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<br>
|
<br>
|
||||||
@@ -79,7 +79,7 @@ Here's the information Shynet can give you about your visitors:
|
|||||||
|
|
||||||
## Recommendations
|
## Recommendations
|
||||||
|
|
||||||
Shynet isn't for everyone. It's great for personal projects and small to medium size websites, but hasn't been tested with ultra-high traffic sites. It's also requires a fair amount of technical know-how to deploy and maintain, so if you need a one-click solution, you're best served with other tools.
|
Shynet isn't for everyone. It's great for personal projects and small to medium size websites, but hasn't been tested with ultra-high traffic sites. It also requires a fair amount of technical know-how to deploy and maintain, so if you need a one-click solution, you're best served with other tools.
|
||||||
|
|
||||||
## Concepts
|
## Concepts
|
||||||
|
|
||||||
@@ -93,8 +93,7 @@ Shynet is pretty simple, but there are a few key terms you need to know in order
|
|||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
You can find intructions on getting started and usage in the [Usage Guide](GUIDE.md#installation). Out of the box, we support deploying via a simple
|
You can find intructions on getting started and usage in the [Usage Guide](GUIDE.md#installation). Out of the box, we support deploying via a simple Docker container, docker-compose, Heroku, or Kubernetes (see [kubernetes](/kubernetes)).
|
||||||
Docker container, docker-compose, or Kubernetes (see [kubernetes](/kubernetes)).
|
|
||||||
|
|
||||||
## FAQ
|
## FAQ
|
||||||
|
|
||||||
|
|||||||
63
TEMPLATE.env
@@ -13,7 +13,11 @@ DB_PORT=5432
|
|||||||
EMAIL_HOST_USER=example
|
EMAIL_HOST_USER=example
|
||||||
EMAIL_HOST_PASSWORD=example_password
|
EMAIL_HOST_PASSWORD=example_password
|
||||||
EMAIL_HOST=smtp.example.com
|
EMAIL_HOST=smtp.example.com
|
||||||
SERVER_EMAIL=<Shynet> noreply@shynet.example.com
|
EMAIL_PORT=465
|
||||||
|
EMAIL_USE_SSL=True
|
||||||
|
# Comment out EMAIL_USE_SSL & uncomment EMAIL_USE_TLS if your SMTP server uses TLS.
|
||||||
|
# EMAIL_USE_TLS=True
|
||||||
|
SERVER_EMAIL=Shynet <noreply@shynet.example.com>
|
||||||
|
|
||||||
# General Django settings
|
# General Django settings
|
||||||
DJANGO_SECRET_KEY=random_string
|
DJANGO_SECRET_KEY=random_string
|
||||||
@@ -22,9 +26,15 @@ DJANGO_SECRET_KEY=random_string
|
|||||||
ALLOWED_HOSTS=*
|
ALLOWED_HOSTS=*
|
||||||
|
|
||||||
# Set to True (capitalized) if you want people to be able to sign up for your Shynet instance (not recommended)
|
# Set to True (capitalized) if you want people to be able to sign up for your Shynet instance (not recommended)
|
||||||
SIGNUPS_ENABLED=False
|
ACCOUNT_SIGNUPS_ENABLED=False
|
||||||
|
|
||||||
|
# Should user email addresses be verified? Only set this to `required` if you've setup the email settings and allow
|
||||||
|
# public sign-ups; otherwise, it's unnecessary.
|
||||||
|
ACCOUNT_EMAIL_VERIFICATION=none
|
||||||
|
|
||||||
# The timezone of the admin panel. Affects how dates are displayed.
|
# The timezone of the admin panel. Affects how dates are displayed.
|
||||||
|
# This must match a value from the IANA's tz database.
|
||||||
|
# Wikipedia has a list of valid strings: https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
|
||||||
TIME_ZONE=America/New_York
|
TIME_ZONE=America/New_York
|
||||||
|
|
||||||
# Set to "False" if you will not be serving content over HTTPS
|
# Set to "False" if you will not be serving content over HTTPS
|
||||||
@@ -47,20 +57,47 @@ ONLY_SUPERUSERS_CREATE=True
|
|||||||
# Will skip only if value is False.
|
# Will skip only if value is False.
|
||||||
PERFORM_CHECKS_AND_SETUP=True
|
PERFORM_CHECKS_AND_SETUP=True
|
||||||
|
|
||||||
# Your admin user's email. A temporary password will be printed
|
# The port that Shynet should bind to. Don't set this if you're deploying on Heroku.
|
||||||
# to the console on first run.
|
PORT=8080
|
||||||
SHYNET_ADMIN_EMAIL=you@example.com
|
|
||||||
|
|
||||||
# The domain on which you'll be hosting Shynet.
|
# Set to "False" if you do not want the version to be displayed on the frontend.
|
||||||
SHYNET_HOST=shynet.example.com
|
SHOW_SHYNET_VERSION=True
|
||||||
|
|
||||||
# What you'd like to call your Shynet instance.
|
# Redis, queue, and parellization settings; not necessary for single-instance deployments.
|
||||||
SHYNET_WHITELABEL=My Shynet Instance
|
|
||||||
|
|
||||||
# Redis and queue settings; not necessary for single-instance deployments.
|
|
||||||
# Don't uncomment these unless you know what you are doing!
|
# Don't uncomment these unless you know what you are doing!
|
||||||
# REDIS_CACHE_LOCATION=redis://redis.default.svc.cluster.local/0
|
# NUM_WORKERS=1
|
||||||
|
# Make sure you set a REDIS_CACHE_LOCATION if you have more than one frontend worker/instance.
|
||||||
|
# REDIS_CACHE_LOCATION=redis://redis.default.svc.cluster.local/0
|
||||||
# If CELERY_BROKER_URL is set, make sure CELERY_TASK_ALWAYS_EAGER is False and
|
# If CELERY_BROKER_URL is set, make sure CELERY_TASK_ALWAYS_EAGER is False and
|
||||||
# that you have a separate queue consumer running somewhere via `celeryworker.sh`.
|
# that you have a separate queue consumer running somewhere via `celeryworker.sh`.
|
||||||
# CELERY_TASK_ALWAYS_EAGER=False
|
# CELERY_TASK_ALWAYS_EAGER=False
|
||||||
# CELERY_BROKER_URL=redis://redis.default.svc.cluster.local/1
|
# CELERY_BROKER_URL=redis://redis.default.svc.cluster.local/1
|
||||||
|
|
||||||
|
# Should Shynet show third-party icons in the dashboard?
|
||||||
|
SHOW_THIRD_PARTY_ICONS=True
|
||||||
|
|
||||||
|
# Should Shynet block collection of IP addresses globally?
|
||||||
|
BLOCK_ALL_IPS=False
|
||||||
|
|
||||||
|
# Should Shynet include the date and site ID when hashing users?
|
||||||
|
# This will prevent any possibility of cross-site tracking provided
|
||||||
|
# that IP collection is also disabled, and external keys (primary
|
||||||
|
# keys) aren't supplied. It will also prevent sessions from spanning
|
||||||
|
# one day to another.
|
||||||
|
AGGRESSIVE_HASH_SALTING=True
|
||||||
|
|
||||||
|
# Custom location url to link to in frontend.
|
||||||
|
# $LATITUDE will get replaced by the latitude, $LONGITUDE will get
|
||||||
|
# replaced by the longitude.
|
||||||
|
# Examples:
|
||||||
|
# - https://www.openstreetmap.org/?mlat=$LATITUDE&mlon=$LONGITUDE (default)
|
||||||
|
# - https://www.google.com/maps/search/?api=1&query=$LATITUDE,$LONGITUDE
|
||||||
|
# - https://www.mapquest.com/near-$LATITUDE,$LONGITUDE
|
||||||
|
LOCATION_URL=https://www.openstreetmap.org/?mlat=$LATITUDE&mlon=$LONGITUDE
|
||||||
|
|
||||||
|
# How many services should be displayed on dashboard page?
|
||||||
|
# Set to big number if you don't want pagination at all.
|
||||||
|
DASHBOARD_PAGE_SIZE=5
|
||||||
|
|
||||||
|
# Should background bars be scaled to full width?
|
||||||
|
USE_RELATIVE_MAX_IN_BAR_VISUALIZATION=True
|
||||||
|
|||||||
142
app.json
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
{
|
||||||
|
"name": "Shynet",
|
||||||
|
"description": "Modern, privacy-friendly, and detailed web analytics that works without cookies or JS.",
|
||||||
|
"keywords": [
|
||||||
|
"app.json",
|
||||||
|
"shynet",
|
||||||
|
"heroku",
|
||||||
|
"analytics",
|
||||||
|
"privacy",
|
||||||
|
"friendly"
|
||||||
|
],
|
||||||
|
"website": "https://github.com/milesmcc/shynet",
|
||||||
|
"repository": "https://github.com/milesmcc/shynet",
|
||||||
|
"logo": "https://github.com/milesmcc/shynet/raw/master/images/slogo.png",
|
||||||
|
"success_url": "/",
|
||||||
|
"stack": "container",
|
||||||
|
"addons": [
|
||||||
|
"heroku-postgresql:hobby-dev"
|
||||||
|
],
|
||||||
|
"formation": {
|
||||||
|
"web": {
|
||||||
|
"quantity": 1,
|
||||||
|
"size": "free"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"env": {
|
||||||
|
"DB_NAME": {
|
||||||
|
"description": "Postgres database name (not required if using Postgres addon)",
|
||||||
|
"value": "shynet",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"DB_USER": {
|
||||||
|
"description": "Postgres database username (not required if using Postgres addon)",
|
||||||
|
"value": "",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"DB_PASSWORD": {
|
||||||
|
"description": "Postgres database password (not required if using Postgres addon)",
|
||||||
|
"value": "",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"DB_HOST": {
|
||||||
|
"description": "Postgres database hostname (not required if using Postgres addon)",
|
||||||
|
"value": "",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"DB_PORT": {
|
||||||
|
"description": "Postgres database port (not required if using Postgres addon)",
|
||||||
|
"value": "5432",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"EMAIL_HOST": {
|
||||||
|
"description": "SMTP server hostname (for sending emails)",
|
||||||
|
"value": "smtp.gmail.com",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"EMAIL_PORT": {
|
||||||
|
"description": "SMTP server port (for sending emails)",
|
||||||
|
"value": "465",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"EMAIL_HOST_USER": {
|
||||||
|
"description": "SMTP server username (for sending emails)",
|
||||||
|
"value": "",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"EMAIL_HOST_PASSWORD": {
|
||||||
|
"description": "SMTP server password (for sending emails)",
|
||||||
|
"value": "",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"SERVER_EMAIL": {
|
||||||
|
"description": "Email address (for sending emails)",
|
||||||
|
"value": "<Shynet> noreply@shynet.example.com",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"DJANGO_SECRET_KEY": {
|
||||||
|
"description": "Django secret key",
|
||||||
|
"generator": "secret"
|
||||||
|
},
|
||||||
|
"ALLOWED_HOSTS": {
|
||||||
|
"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.",
|
||||||
|
"value": "*",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"ACCOUNT_SIGNUPS_ENABLED": {
|
||||||
|
"description": "Set to True (capitalized) if you want people to be able to sign up for your Shynet instance (not recommended).",
|
||||||
|
"value": "False",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"TIME_ZONE": {
|
||||||
|
"description": "The timezone of the admin panel. Affects how dates are displayed.",
|
||||||
|
"value": "America/New_York",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"SCRIPT_USE_HTTPS": {
|
||||||
|
"description": "Set to 'False' if you will not be serving Shynet over HTTPS.",
|
||||||
|
"value": "True",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"SCRIPT_HEARTBEAT_FREQUENCY": {
|
||||||
|
"description": "How frequently should the monitoring script 'phone home' (in ms)?",
|
||||||
|
"value": "5000",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"SESSION_MEMORY_TIMEOUT": {
|
||||||
|
"description": "How much time can elapse between requests from the same user before a new session is created, in seconds?",
|
||||||
|
"value": "1800",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"ONLY_SUPERUSERS_CREATE": {
|
||||||
|
"description": "Should only superusers (admins) be able to create tracked services?",
|
||||||
|
"value": "True",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"PERFORM_CHECKS_AND_SETUP": {
|
||||||
|
"description": "Whether to perform checks and setup at startup. Recommended value is 'True' for Heroku users.",
|
||||||
|
"value": "True",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"SHOW_SHYNET_VERSION": {
|
||||||
|
"description": "Set to 'False' if you do not want the version to be displayed on the frontend.",
|
||||||
|
"value": "True",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"LOCATION_URL": {
|
||||||
|
"description": "Custom location url to link to in frontend.",
|
||||||
|
"value": "https://www.openstreetmap.org/?mlat=$LATITUDE&mlon=$LONGITUDE",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"DASHBOARD_PAGE_SIZE": {
|
||||||
|
"description": "How many services should be displayed on dashboard page?",
|
||||||
|
"value": "5",
|
||||||
|
"required": false
|
||||||
|
},
|
||||||
|
"USE_RELATIVE_MAX_IN_BAR_VISUALIZATION": {
|
||||||
|
"description": "Should background bars be scaled to full width?",
|
||||||
|
"value": "True",
|
||||||
|
"required": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
version: '3'
|
version: '3'
|
||||||
services:
|
services:
|
||||||
shynet:
|
shynet:
|
||||||
|
container_name: shynet_main
|
||||||
image: milesmcc/shynet:latest
|
image: milesmcc/shynet:latest
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
expose:
|
expose:
|
||||||
@@ -16,6 +17,7 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
db:
|
db:
|
||||||
|
container_name: shynet_database
|
||||||
image: postgres
|
image: postgres
|
||||||
restart: always
|
restart: always
|
||||||
environment:
|
environment:
|
||||||
@@ -26,6 +28,18 @@ services:
|
|||||||
- shynet_db:/var/lib/postgresql/data
|
- shynet_db:/var/lib/postgresql/data
|
||||||
networks:
|
networks:
|
||||||
- internal
|
- internal
|
||||||
|
webserver:
|
||||||
|
container_name: shynet_webserver
|
||||||
|
image: nginx
|
||||||
|
restart: always
|
||||||
|
volumes:
|
||||||
|
- ./nginx.conf:/etc/nginx/conf.d/default.conf
|
||||||
|
ports:
|
||||||
|
- 8080:80
|
||||||
|
depends_on:
|
||||||
|
- shynet
|
||||||
|
networks:
|
||||||
|
- internal
|
||||||
volumes:
|
volumes:
|
||||||
shynet_db:
|
shynet_db:
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
3
heroku.yml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
build:
|
||||||
|
docker:
|
||||||
|
web: Dockerfile
|
||||||
|
Before Width: | Height: | Size: 308 KiB After Width: | Height: | Size: 263 KiB |
BIN
images/logo.png
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 8.6 KiB |
|
Before Width: | Height: | Size: 589 KiB After Width: | Height: | Size: 240 KiB |
BIN
images/slogo.png
Normal file
|
After Width: | Height: | Size: 931 B |
@@ -16,12 +16,12 @@ spec:
|
|||||||
app: "shynet-webserver"
|
app: "shynet-webserver"
|
||||||
spec:
|
spec:
|
||||||
containers:
|
containers:
|
||||||
- name: "covideo-webserver"
|
- name: "shynet-webserver"
|
||||||
image: "milesmcc/shynet:latest"
|
image: "milesmcc/shynet:edge" # Change to the version appropriate for you (e.g., :latest)
|
||||||
imagePullPolicy: Always
|
imagePullPolicy: Always
|
||||||
envFrom:
|
envFrom:
|
||||||
- secretRef:
|
- secretRef:
|
||||||
name: django-settings
|
name: shynet-settings
|
||||||
---
|
---
|
||||||
apiVersion: "apps/v1"
|
apiVersion: "apps/v1"
|
||||||
kind: "Deployment"
|
kind: "Deployment"
|
||||||
@@ -41,45 +41,79 @@ spec:
|
|||||||
app: "shynet-celeryworker"
|
app: "shynet-celeryworker"
|
||||||
spec:
|
spec:
|
||||||
containers:
|
containers:
|
||||||
- name: "covideo-celeryworker"
|
- name: "shynet-celeryworker"
|
||||||
image: "milesmcc/shynet:latest"
|
image: "milesmcc/shynet:edge" # Change to the version appropriate for you (e.g., :latest)
|
||||||
command: ["./celeryworker.sh"]
|
command: ["./celeryworker.sh"]
|
||||||
imagePullPolicy: Always
|
imagePullPolicy: Always
|
||||||
envFrom:
|
envFrom:
|
||||||
- secretRef:
|
- secretRef:
|
||||||
name: django-settings
|
name: shynet-settings
|
||||||
---
|
---
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Service
|
kind: Service
|
||||||
metadata:
|
metadata:
|
||||||
name: redis
|
name: shynet-redis
|
||||||
spec:
|
spec:
|
||||||
ports:
|
ports:
|
||||||
- port: 6379
|
- port: 6379
|
||||||
name: redis
|
name: redis
|
||||||
clusterIP: None
|
clusterIP: None
|
||||||
selector:
|
selector:
|
||||||
app: redis
|
app: shynet-redis
|
||||||
---
|
---
|
||||||
apiVersion: apps/v1beta2
|
apiVersion: apps/v1
|
||||||
kind: StatefulSet
|
kind: StatefulSet
|
||||||
metadata:
|
metadata:
|
||||||
name: redis
|
name: shynet-redis
|
||||||
spec:
|
spec:
|
||||||
selector:
|
selector:
|
||||||
matchLabels:
|
matchLabels:
|
||||||
app: redis
|
app: shynet-redis
|
||||||
serviceName: redis
|
serviceName: shynet-redis
|
||||||
replicas: 1
|
replicas: 1
|
||||||
template:
|
template:
|
||||||
metadata:
|
metadata:
|
||||||
labels:
|
labels:
|
||||||
app: redis
|
app: shynet-redis
|
||||||
spec:
|
spec:
|
||||||
containers:
|
containers:
|
||||||
- name: redis
|
- name: shynet-redis
|
||||||
image: redis:latest
|
image: redis:latest
|
||||||
imagePullPolicy: Always
|
imagePullPolicy: Always
|
||||||
ports:
|
ports:
|
||||||
- containerPort: 6379
|
- containerPort: 6379
|
||||||
name: redis
|
name: redis
|
||||||
|
---
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: shynet-webserver-service
|
||||||
|
spec:
|
||||||
|
type: ClusterIP
|
||||||
|
ports:
|
||||||
|
- port: 8080
|
||||||
|
selector:
|
||||||
|
app: shynet-webserver
|
||||||
|
---
|
||||||
|
apiVersion: networking.k8s.io/v1
|
||||||
|
kind: Ingress
|
||||||
|
metadata:
|
||||||
|
name: shynet-webserver-ingress
|
||||||
|
annotations:
|
||||||
|
kubernetes.io/ingress.class: addon-http-application-routing
|
||||||
|
spec:
|
||||||
|
rules:
|
||||||
|
- host: shynet.rmrm.io
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- backend:
|
||||||
|
serviceName: shynet-webserver-service
|
||||||
|
servicePort: 8080
|
||||||
|
path: /
|
||||||
|
- host: shynet-beta.rmrm.io
|
||||||
|
http:
|
||||||
|
paths:
|
||||||
|
- backend:
|
||||||
|
serviceName: shynet-webserver-service
|
||||||
|
servicePort: 8080
|
||||||
|
path: /
|
||||||
@@ -1,19 +1,19 @@
|
|||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Secret
|
kind: Secret
|
||||||
metadata:
|
metadata:
|
||||||
name: django-settings
|
name: shynet-settings
|
||||||
type: Opaque
|
type: Opaque
|
||||||
stringData:
|
stringData:
|
||||||
# Django settings
|
# Django settings
|
||||||
DEBUG: "False"
|
DEBUG: "False"
|
||||||
ALLOWED_HOSTS: "*" # For better security, set this to your deployment's domain. Comma separated.
|
ALLOWED_HOSTS: "*" # For better security, set this to your deployment's domain. Comma separated.
|
||||||
DJANGO_SECRET_KEY: ""
|
DJANGO_SECRET_KEY: ""
|
||||||
SIGNUPS_ENABLED: "False"
|
ACCOUNT_SIGNUPS_ENABLED: "False"
|
||||||
TIME_ZONE: "America/New_York"
|
TIME_ZONE: "America/New_York"
|
||||||
|
|
||||||
# Redis configuration (if you use the default Kubernetes config, this will work)
|
# Redis configuration (if you use the default Kubernetes config, this will work)
|
||||||
REDIS_CACHE_LOCATION: "redis://redis.default.svc.cluster.local/0"
|
REDIS_CACHE_LOCATION: "redis://shynet-redis.default.svc.cluster.local/0"
|
||||||
CELERY_BROKER_URL: "redis://redis.default.svc.cluster.local/1"
|
CELERY_BROKER_URL: "redis://shynet-redis.default.svc.cluster.local/1"
|
||||||
|
|
||||||
# PostgreSQL settings
|
# PostgreSQL settings
|
||||||
DB_NAME: ""
|
DB_NAME: ""
|
||||||
|
|||||||
19
nginx.conf
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
server {
|
||||||
|
server_name example.com;
|
||||||
|
access_log /var/log/nginx/bin.access.log;
|
||||||
|
error_log /var/log/nginx/bin.error.log error;
|
||||||
|
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://shynet:8080;
|
||||||
|
proxy_redirect off;
|
||||||
|
proxy_set_header Host $http_host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header X-Forwarded-Protocol $scheme;
|
||||||
|
proxy_set_header X-Url-Scheme $scheme;
|
||||||
|
}
|
||||||
|
listen 80;
|
||||||
|
|
||||||
|
}
|
||||||
1261
package-lock.json
generated
Normal file
28
package.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"name": "shynet",
|
||||||
|
"description": "Modern, privacy-friendly, and cookie-free web analytics.",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/milesmcc/shynet.git"
|
||||||
|
},
|
||||||
|
"keywords": [
|
||||||
|
"privacy",
|
||||||
|
"analytics",
|
||||||
|
"self-host"
|
||||||
|
],
|
||||||
|
"author": "R. Miles McCain <shynet@sendmiles.email>",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/milesmcc/shynet/issues"
|
||||||
|
},
|
||||||
|
"homepage": "https://github.com/milesmcc/shynet#readme",
|
||||||
|
"dependencies": {
|
||||||
|
"@fortawesome/fontawesome-free": "^5.15.1",
|
||||||
|
"a17t": "^0.5.1",
|
||||||
|
"apexcharts": "^3.24.0",
|
||||||
|
"datamaps": "^0.5.9",
|
||||||
|
"flag-icon-css": "^3.5.0",
|
||||||
|
"inter-ui": "^3.15.0",
|
||||||
|
"litepicker": "^2.0.11"
|
||||||
|
}
|
||||||
|
}
|
||||||
1756
poetry.lock
generated
Normal file
41
pyproject.toml
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
[tool.poetry]
|
||||||
|
name = "shynet"
|
||||||
|
version = "0.10.0"
|
||||||
|
description = "Modern, privacy-friendly, and cookie-free web analytics."
|
||||||
|
authors = ["R. Miles McCain <github@sendmiles.email>"]
|
||||||
|
license = "Apache-2.0"
|
||||||
|
|
||||||
|
[tool.poetry.dependencies]
|
||||||
|
python = "^3.8"
|
||||||
|
Django = "^3.2.5"
|
||||||
|
django-allauth = "^0.45.0"
|
||||||
|
geoip2 = "^4.2.0"
|
||||||
|
whitenoise = "^5.3.0"
|
||||||
|
celery = "^5.1.2"
|
||||||
|
django-ipware = "^3.0.2"
|
||||||
|
PyYAML = "^5.4.1"
|
||||||
|
user-agents = "^2.2.0"
|
||||||
|
rules = "^3.0"
|
||||||
|
gunicorn = "^20.1.0"
|
||||||
|
psycopg2-binary = "^2.9.2"
|
||||||
|
redis = "^3.5.3"
|
||||||
|
django-redis-cache = "^3.0.0"
|
||||||
|
pycountry = "^20.7.3"
|
||||||
|
html2text = "^2020.1.16"
|
||||||
|
django-health-check = "^3.16.4"
|
||||||
|
django-npm = "^1.0.0"
|
||||||
|
python-dotenv = "^0.18.0"
|
||||||
|
django-debug-toolbar = "^3.2.1"
|
||||||
|
django-cors-headers = "^3.11.0"
|
||||||
|
|
||||||
|
[tool.poetry.dev-dependencies]
|
||||||
|
pytest-sugar = "^0.9.4"
|
||||||
|
factory-boy = "^3.2.0"
|
||||||
|
pytest-django = "^4.4.0"
|
||||||
|
django-coverage-plugin = "^2.0.0"
|
||||||
|
django-stubs = "^1.8.0"
|
||||||
|
mypy = "^0.910"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["poetry-core>=1.0.0"]
|
||||||
|
build-backend = "poetry.core.masonry.api"
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/a17t@0.1.3/dist/a17t.css">
|
|
||||||
<script async src="https://use.fontawesome.com/releases/v5.3.1/js/all.js"></script>
|
|
||||||
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@^1.0/dist/tailwind.min.css" rel="stylesheet">
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--family-primary: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
|
||||||
--family-secondary: var(--family-primary);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
12
shynet/a17t/templates/a17t/includes/head.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{% load static %}
|
||||||
|
|
||||||
|
<link rel="stylesheet" href="{% static 'a17t/dist/a17t.css' %}">
|
||||||
|
<script async src="{% static '@fortawesome/fontawesome-free/js/all.min.js' %}" data-mutate-approach="sync"></script>
|
||||||
|
<link href="{% static 'a17t/dist/tailwind.css' %}" rel="stylesheet">
|
||||||
|
<link href="{% static 'inter-ui/Inter (web)/inter.css' %}" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--family-primary: "Inter", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||||
|
--family-secondary: var(--family-primary);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,23 +1,23 @@
|
|||||||
<nav class="flex w-full flex-wrap items-center justify-between" role="navigation" aria-label="pagination">
|
<nav class="flex w-full flex-wrap items-center justify-between" role="navigation" aria-label="pagination">
|
||||||
<div class="w-full md:w-auto mb-2">
|
<div class="w-full md:w-auto mb-2">
|
||||||
{% if page.has_previous %}
|
{% if page.has_previous %}
|
||||||
<a href="?page={{ page.previous_page_number }}{{url_parameters}}" class="button field w-auto mr-1">Previous</a>
|
<a href="?page={{ page.previous_page_number }}&{{url_parameters}}" class="button field !low bg-neutral-000 w-auto mr-1">Previous</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a class="button field w-auto mr-1" disabled>Previous</a>
|
<a class="button field !low bg-neutral-000 w-auto mr-1" disabled>Previous</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if page.has_next %}
|
{% if page.has_next %}
|
||||||
<a href="?page={{ page.next_page_number }}{{url_parameters}}" class="button field w-auto">Next</a>
|
<a href="?page={{ page.next_page_number }}&{{url_parameters}}" class="button field !low bg-neutral-000 w-auto">Next</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a class="button field w-auto" disabled>Next</a>
|
<a class="button field !low bg-neutral-000 w-auto" disabled>Next</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul class="pagination-list w-full md:w-auto mb-2 flex">
|
<ul class="pagination-list w-full md:w-auto mb-2 flex">
|
||||||
{% for pnum in begin %}
|
{% for pnum in begin %}
|
||||||
{% ifequal page.number pnum %}
|
{% ifequal page.number pnum %}
|
||||||
<li><a class="button field w-auto mx-1 text-white bg-gray-700">{{ pnum }}</a></li>
|
<li><a class="button field !low w-auto mx-1 text-white bg-neutral-700">{{ pnum }}</a></li>
|
||||||
{% else %}
|
{% else %}
|
||||||
<li><a class="button field w-auto mx-1" href="?page={{ pnum }}{{url_parameters}}">{{ pnum }}</a></li>
|
<li><a class="button field !low bg-neutral-000 w-auto mx-1" href="?page={{ pnum }}&{{url_parameters}}">{{ pnum }}</a></li>
|
||||||
{% endifequal %}
|
{% endifequal %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
@@ -25,9 +25,9 @@
|
|||||||
<li><span class="pagination-ellipsis">…</span></li>
|
<li><span class="pagination-ellipsis">…</span></li>
|
||||||
{% for pnum in middle %}
|
{% for pnum in middle %}
|
||||||
{% ifequal page.number pnum %}
|
{% ifequal page.number pnum %}
|
||||||
<li><a class="button field w-auto mx-1 text-white bg-gray-700">{{ pnum }}</a></li>
|
<li><a class="button field !low w-auto mx-1 text-white bg-neutral-700">{{ pnum }}</a></li>
|
||||||
{% else %}
|
{% else %}
|
||||||
<li><a class="button field w-auto mx-1" href="?page={{ pnum }}{{url_parameters}}">{{ pnum }}</a></li>
|
<li><a class="button field !low bg-neutral-000 w-auto mx-1" href="?page={{ pnum }}&{{url_parameters}}">{{ pnum }}</a></li>
|
||||||
{% endifequal %}
|
{% endifequal %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -36,9 +36,9 @@
|
|||||||
<li><span class="pagination-ellipsis">…</span></li>
|
<li><span class="pagination-ellipsis">…</span></li>
|
||||||
{% for pnum in end %}
|
{% for pnum in end %}
|
||||||
{% ifequal page.number pnum %}
|
{% ifequal page.number pnum %}
|
||||||
<li><a class="button field w-auto mx-1 text-white bg-gray-700">{{ pnum }}</a></li>
|
<li><a class="button field !low w-auto mx-1 text-white bg-neutral-700">{{ pnum }}</a></li>
|
||||||
{% else %}
|
{% else %}
|
||||||
<li><a class="button field w-auto mx-1" href="?page={{ pnum }}{{url_parameters}}">{{ pnum }}</a></li>
|
<li><a class="button field !low bg-neutral-000 w-auto mx-1" href="?page={{ pnum }}&{{url_parameters}}">{{ pnum }}</a></li>
|
||||||
{% endifequal %}
|
{% endifequal %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -92,4 +92,6 @@ def is_file(field):
|
|||||||
def add_class(field, css_class):
|
def add_class(field, css_class):
|
||||||
if len(field.errors) > 0:
|
if len(field.errors) > 0:
|
||||||
css_class += " ~critical"
|
css_class += " ~critical"
|
||||||
|
if field.field.widget.attrs.get("class") != None:
|
||||||
|
css_class += " " + field.field.widget.attrs["class"]
|
||||||
return field.as_widget(attrs={"class": field.css_classes(extra_classes=css_class)})
|
return field.as_widget(attrs={"class": field.css_classes(extra_classes=css_class)})
|
||||||
|
|||||||
@@ -15,12 +15,8 @@ def pagination(
|
|||||||
before_current_pages=4,
|
before_current_pages=4,
|
||||||
after_current_pages=4,
|
after_current_pages=4,
|
||||||
):
|
):
|
||||||
url_parameters = "".join(
|
url_parameters = urlencode(
|
||||||
[
|
[(key, value) for key, value in request.GET.items() if key != "page"]
|
||||||
f"&{urlencode(key)}={urlencode(value)}"
|
|
||||||
for key, value in request.GET.items()
|
|
||||||
if key != "page"
|
|
||||||
]
|
|
||||||
)
|
)
|
||||||
|
|
||||||
before = max(page.number - before_current_pages - 1, 0)
|
before = max(page.number - before_current_pages - 1, 0)
|
||||||
|
|||||||
@@ -60,7 +60,9 @@ class Migration(migrations.Migration):
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
options={"ordering": ["-start_time"],},
|
options={
|
||||||
|
"ordering": ["-start_time"],
|
||||||
|
},
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name="Hit",
|
name="Hit",
|
||||||
@@ -90,7 +92,9 @@ class Migration(migrations.Migration):
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
options={"ordering": ["-start_time"],},
|
options={
|
||||||
|
"ordering": ["-start_time"],
|
||||||
|
},
|
||||||
),
|
),
|
||||||
migrations.AddIndex(
|
migrations.AddIndex(
|
||||||
model_name="session",
|
model_name="session",
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ from django.db import migrations, models
|
|||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('analytics', '0002_auto_20200415_1742'),
|
("analytics", "0002_auto_20200415_1742"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='session',
|
model_name="session",
|
||||||
name='ip',
|
name="ip",
|
||||||
field=models.GenericIPAddressField(db_index=True, null=True),
|
field=models.GenericIPAddressField(db_index=True, null=True),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
46
shynet/analytics/migrations/0004_auto_20210328_1514.py
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
# Generated by Django 3.1.7 on 2021-03-28 19:14
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.utils.timezone
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("analytics", "0003_auto_20200502_1227"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="hit",
|
||||||
|
name="last_seen",
|
||||||
|
field=models.DateTimeField(default=django.utils.timezone.now),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="hit",
|
||||||
|
name="start_time",
|
||||||
|
field=models.DateTimeField(
|
||||||
|
db_index=True, default=django.utils.timezone.now
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="session",
|
||||||
|
name="last_seen",
|
||||||
|
field=models.DateTimeField(
|
||||||
|
db_index=True, default=django.utils.timezone.now
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="session",
|
||||||
|
name="start_time",
|
||||||
|
field=models.DateTimeField(
|
||||||
|
db_index=True, default=django.utils.timezone.now
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="session",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["service", "-last_seen"], name="analytics_s_service_10bb96_idx"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
26
shynet/analytics/migrations/0005_auto_20210328_1518.py
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# Generated by Django 3.1.7 on 2021-03-28 19:18
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.utils.timezone
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("analytics", "0004_auto_20210328_1514"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="hit",
|
||||||
|
name="last_seen",
|
||||||
|
field=models.DateTimeField(
|
||||||
|
db_index=True, default=django.utils.timezone.now
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="hit",
|
||||||
|
name="load_time",
|
||||||
|
field=models.FloatField(db_index=True, null=True),
|
||||||
|
),
|
||||||
|
]
|
||||||
40
shynet/analytics/migrations/0006_hit_service.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# Generated by Django 3.1.7 on 2021-03-28 19:36
|
||||||
|
|
||||||
|
from ..models import Hit, Session
|
||||||
|
from django.db import migrations, models
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db.models import Subquery, OuterRef
|
||||||
|
|
||||||
|
|
||||||
|
def add_service_to_hits(_a, _b):
|
||||||
|
service = Session.objects.filter(pk=OuterRef("session")).values_list("service")[:1]
|
||||||
|
|
||||||
|
Hit.objects.update(service=Subquery(service))
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("core", "0008_auto_20200628_1403"),
|
||||||
|
("analytics", "0005_auto_20210328_1518"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="hit",
|
||||||
|
name="service",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
null=True,
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="core.service",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
migrations.RunPython(add_service_to_hits, lambda: ()),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="hit",
|
||||||
|
name="service",
|
||||||
|
field=models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE, to="core.service"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
19
shynet/analytics/migrations/0007_auto_20210328_1634.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Generated by Django 3.1.7 on 2021-03-28 20:34
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("analytics", "0006_hit_service"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name="hit",
|
||||||
|
index=models.Index(
|
||||||
|
fields=["service", "-start_time"], name="analytics_h_service_f4f41e_idx"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
shynet/analytics/migrations/0008_session_is_bounce.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 3.1.7 on 2021-03-28 21:38
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("analytics", "0007_auto_20210328_1634"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="session",
|
||||||
|
name="is_bounce",
|
||||||
|
field=models.BooleanField(db_index=True, default=True),
|
||||||
|
)
|
||||||
|
]
|
||||||
21
shynet/analytics/migrations/0009_auto_20210329_1100.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
# Generated by Django 3.1.7 on 2021-03-29 15:00
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
from ..models import Session
|
||||||
|
|
||||||
|
|
||||||
|
def update_bounce_stats(_a, _b):
|
||||||
|
Session.objects.all().annotate(hit_count=models.Count("hit")).filter(
|
||||||
|
hit_count__gt=1
|
||||||
|
).update(is_bounce=False)
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("analytics", "0008_session_is_bounce"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.RunPython(update_bounce_stats, lambda: ()),
|
||||||
|
]
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
import json
|
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.shortcuts import reverse
|
from django.shortcuts import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from core.models import Service
|
from core.models import Service, ACTIVE_USER_TIMEDELTA
|
||||||
|
|
||||||
|
|
||||||
def _default_uuid():
|
def _default_uuid():
|
||||||
@@ -20,8 +19,8 @@ class Session(models.Model):
|
|||||||
identifier = models.TextField(blank=True, db_index=True)
|
identifier = models.TextField(blank=True, db_index=True)
|
||||||
|
|
||||||
# Time
|
# Time
|
||||||
start_time = models.DateTimeField(auto_now_add=True, db_index=True)
|
start_time = models.DateTimeField(default=timezone.now, db_index=True)
|
||||||
last_seen = models.DateTimeField(auto_now_add=True)
|
last_seen = models.DateTimeField(default=timezone.now, db_index=True)
|
||||||
|
|
||||||
# Core request information
|
# Core request information
|
||||||
user_agent = models.TextField()
|
user_agent = models.TextField()
|
||||||
@@ -48,16 +47,19 @@ class Session(models.Model):
|
|||||||
latitude = models.FloatField(null=True)
|
latitude = models.FloatField(null=True)
|
||||||
time_zone = models.TextField(blank=True)
|
time_zone = models.TextField(blank=True)
|
||||||
|
|
||||||
|
is_bounce = models.BooleanField(default=True, db_index=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ["-start_time"]
|
ordering = ["-start_time"]
|
||||||
indexes = [
|
indexes = [
|
||||||
models.Index(fields=["service", "-start_time"]),
|
models.Index(fields=["service", "-start_time"]),
|
||||||
|
models.Index(fields=["service", "-last_seen"]),
|
||||||
models.Index(fields=["service", "identifier"]),
|
models.Index(fields=["service", "identifier"]),
|
||||||
]
|
]
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_currently_active(self):
|
def is_currently_active(self):
|
||||||
return timezone.now() - self.last_seen < timezone.timedelta(seconds=10)
|
return timezone.now() - self.last_seen < ACTIVE_USER_TIMEDELTA
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def duration(self):
|
def duration(self):
|
||||||
@@ -72,14 +74,20 @@ class Session(models.Model):
|
|||||||
kwargs={"pk": self.service.pk, "session_pk": self.uuid},
|
kwargs={"pk": self.service.pk, "session_pk": self.uuid},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def recalculate_bounce(self):
|
||||||
|
bounce = self.hit_set.count() == 1
|
||||||
|
if bounce != self.is_bounce:
|
||||||
|
self.is_bounce = bounce
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
|
||||||
class Hit(models.Model):
|
class Hit(models.Model):
|
||||||
session = models.ForeignKey(Session, on_delete=models.CASCADE, db_index=True)
|
session = models.ForeignKey(Session, on_delete=models.CASCADE, db_index=True)
|
||||||
initial = models.BooleanField(default=True, db_index=True)
|
initial = models.BooleanField(default=True, db_index=True)
|
||||||
|
|
||||||
# Base request information
|
# Base request information
|
||||||
start_time = models.DateTimeField(auto_now_add=True, db_index=True)
|
start_time = models.DateTimeField(default=timezone.now, db_index=True)
|
||||||
last_seen = models.DateTimeField(auto_now_add=True)
|
last_seen = models.DateTimeField(default=timezone.now, db_index=True)
|
||||||
heartbeats = models.IntegerField(default=0)
|
heartbeats = models.IntegerField(default=0)
|
||||||
tracker = models.TextField(
|
tracker = models.TextField(
|
||||||
choices=[("JS", "JavaScript"), ("PIXEL", "Pixel (noscript)")]
|
choices=[("JS", "JavaScript"), ("PIXEL", "Pixel (noscript)")]
|
||||||
@@ -88,12 +96,17 @@ class Hit(models.Model):
|
|||||||
# Advanced page information
|
# Advanced page information
|
||||||
location = models.TextField(blank=True, db_index=True)
|
location = models.TextField(blank=True, db_index=True)
|
||||||
referrer = models.TextField(blank=True, db_index=True)
|
referrer = models.TextField(blank=True, db_index=True)
|
||||||
load_time = models.FloatField(null=True)
|
load_time = models.FloatField(null=True, db_index=True)
|
||||||
|
|
||||||
|
# While not necessary, we store the root service directly for performance.
|
||||||
|
# It makes querying much easier; no need for inner joins.
|
||||||
|
service = models.ForeignKey(Service, on_delete=models.CASCADE, db_index=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ["-start_time"]
|
ordering = ["-start_time"]
|
||||||
indexes = [
|
indexes = [
|
||||||
models.Index(fields=["session", "-start_time"]),
|
models.Index(fields=["session", "-start_time"]),
|
||||||
|
models.Index(fields=["service", "-start_time"]),
|
||||||
models.Index(fields=["session", "location"]),
|
models.Index(fields=["session", "location"]),
|
||||||
models.Index(fields=["session", "referrer"]),
|
models.Index(fields=["session", "referrer"]),
|
||||||
]
|
]
|
||||||
@@ -105,5 +118,5 @@ class Hit(models.Model):
|
|||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse(
|
return reverse(
|
||||||
"dashboard:service_session",
|
"dashboard:service_session",
|
||||||
kwargs={"pk": self.session.service.pk, "session_pk": self.session.pk},
|
kwargs={"pk": self.service.pk, "session_pk": self.session.pk},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import json
|
import ipaddress
|
||||||
import logging
|
import logging
|
||||||
|
from hashlib import sha256
|
||||||
|
|
||||||
import geoip2.database
|
import geoip2.database
|
||||||
import user_agents
|
import user_agents
|
||||||
from hashlib import sha1
|
|
||||||
from celery import shared_task
|
from celery import shared_task
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
@@ -39,6 +39,9 @@ def _geoip2_lookup(ip):
|
|||||||
}
|
}
|
||||||
except geoip2.errors.AddressNotFoundError:
|
except geoip2.errors.AddressNotFoundError:
|
||||||
return {}
|
return {}
|
||||||
|
except FileNotFoundError as e:
|
||||||
|
log.exception("Unable to perform GeoIP lookup: %s", e)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
@shared_task
|
@shared_task
|
||||||
@@ -58,15 +61,33 @@ def ingress_request(
|
|||||||
log.debug(f"Linked to service {service}")
|
log.debug(f"Linked to service {service}")
|
||||||
|
|
||||||
if dnt and service.respect_dnt:
|
if dnt and service.respect_dnt:
|
||||||
|
log.debug("Ignoring because of DNT")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
remote_ip = ipaddress.ip_network(ip)
|
||||||
|
for ignored_network in service.get_ignored_networks():
|
||||||
|
if (
|
||||||
|
ignored_network.version == remote_ip.version
|
||||||
|
and ignored_network.supernet_of(remote_ip)
|
||||||
|
):
|
||||||
|
log.debug("Ignoring because of ignored IP")
|
||||||
|
return
|
||||||
|
except ValueError as e:
|
||||||
|
log.exception(e)
|
||||||
|
|
||||||
# Validate payload
|
# Validate payload
|
||||||
if payload.get("loadTime", 1) <= 0:
|
if payload.get("loadTime", 1) <= 0:
|
||||||
payload["loadTime"] = None
|
payload["loadTime"] = None
|
||||||
|
|
||||||
association_id_hash = sha1()
|
association_id_hash = sha256()
|
||||||
association_id_hash.update(str(ip).encode("utf-8"))
|
association_id_hash.update(str(ip).encode("utf-8"))
|
||||||
association_id_hash.update(str(user_agent).encode("utf-8"))
|
association_id_hash.update(str(user_agent).encode("utf-8"))
|
||||||
|
if settings.AGGRESSIVE_HASH_SALTING:
|
||||||
|
association_id_hash.update(str(service.pk).encode("utf-8"))
|
||||||
|
association_id_hash.update(
|
||||||
|
str(timezone.now().date().isoformat()).encode("utf-8")
|
||||||
|
)
|
||||||
session_cache_path = (
|
session_cache_path = (
|
||||||
f"session_association_{service.pk}_{association_id_hash.hexdigest()}"
|
f"session_association_{service.pk}_{association_id_hash.hexdigest()}"
|
||||||
)
|
)
|
||||||
@@ -101,20 +122,24 @@ def ingress_request(
|
|||||||
device_type = "TABLET"
|
device_type = "TABLET"
|
||||||
elif ua.is_pc:
|
elif ua.is_pc:
|
||||||
device_type = "DESKTOP"
|
device_type = "DESKTOP"
|
||||||
|
if device_type == "ROBOT" and service.ignore_robots:
|
||||||
|
return
|
||||||
session = Session.objects.create(
|
session = Session.objects.create(
|
||||||
service=service,
|
service=service,
|
||||||
ip=ip if service.collect_ips else None,
|
ip=ip if service.collect_ips and not settings.BLOCK_ALL_IPS else None,
|
||||||
user_agent=user_agent,
|
user_agent=user_agent,
|
||||||
identifier=identifier.strip(),
|
identifier=identifier.strip(),
|
||||||
browser=ua.browser.family or "",
|
browser=ua.browser.family or "",
|
||||||
device=ua.device.family or ua.device.model or "",
|
device=ua.device.family or ua.device.model or "",
|
||||||
device_type=device_type,
|
device_type=device_type,
|
||||||
|
start_time=time,
|
||||||
|
last_seen=time,
|
||||||
os=ua.os.family or "",
|
os=ua.os.family or "",
|
||||||
asn=ip_data.get("asn", ""),
|
asn=ip_data.get("asn") or "",
|
||||||
country=ip_data.get("country", ""),
|
country=ip_data.get("country") or "",
|
||||||
longitude=ip_data.get("longitude"),
|
longitude=ip_data.get("longitude"),
|
||||||
latitude=ip_data.get("latitude"),
|
latitude=ip_data.get("latitude"),
|
||||||
time_zone=ip_data.get("time_zone", ""),
|
time_zone=ip_data.get("time_zone") or "",
|
||||||
)
|
)
|
||||||
cache.set(
|
cache.set(
|
||||||
session_cache_path, session.pk, timeout=settings.SESSION_MEMORY_TIMEOUT
|
session_cache_path, session.pk, timeout=settings.SESSION_MEMORY_TIMEOUT
|
||||||
@@ -125,7 +150,7 @@ def ingress_request(
|
|||||||
log.debug("Updating old session with new data...")
|
log.debug("Updating old session with new data...")
|
||||||
|
|
||||||
# Update last seen time
|
# Update last seen time
|
||||||
session.last_seen = timezone.now()
|
session.last_seen = time
|
||||||
if session.identifier == "" and identifier.strip() != "":
|
if session.identifier == "" and identifier.strip() != "":
|
||||||
session.identifier = identifier.strip()
|
session.identifier = identifier.strip()
|
||||||
session.save()
|
session.save()
|
||||||
@@ -146,7 +171,7 @@ def ingress_request(
|
|||||||
# this is a heartbeat.
|
# this is a heartbeat.
|
||||||
log.debug("Hit is a heartbeat; updating old hit with new data...")
|
log.debug("Hit is a heartbeat; updating old hit with new data...")
|
||||||
hit.heartbeats += 1
|
hit.heartbeats += 1
|
||||||
hit.last_seen = timezone.now()
|
hit.last_seen = time
|
||||||
hit.save()
|
hit.save()
|
||||||
|
|
||||||
if hit is None:
|
if hit is None:
|
||||||
@@ -162,7 +187,14 @@ def ingress_request(
|
|||||||
location=payload.get("location", location),
|
location=payload.get("location", location),
|
||||||
referrer=payload.get("referrer", ""),
|
referrer=payload.get("referrer", ""),
|
||||||
load_time=payload.get("loadTime"),
|
load_time=payload.get("loadTime"),
|
||||||
|
start_time=time,
|
||||||
|
last_seen=time,
|
||||||
|
service=service,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Recalculate whether the session is a bounce
|
||||||
|
session.recalculate_bounce()
|
||||||
|
|
||||||
# Set idempotency (if applicable)
|
# Set idempotency (if applicable)
|
||||||
if idempotency is not None:
|
if idempotency is not None:
|
||||||
cache.set(
|
cache.set(
|
||||||
@@ -170,4 +202,5 @@ def ingress_request(
|
|||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
log.exception(e)
|
log.exception(e)
|
||||||
|
print(e)
|
||||||
raise e
|
raise e
|
||||||
|
|||||||
@@ -1,22 +1,37 @@
|
|||||||
window.onload = function () {
|
// This is a lightweight and privacy-friendly analytics script from Shynet, a self-hosted
|
||||||
var idempotency =
|
// analytics tool. To give you full visibility into how your data is being monitored, this
|
||||||
Math.random().toString(36).substring(2, 15) +
|
// file is intentionally not minified or obfuscated. To learn more about Shynet (and to view
|
||||||
Math.random().toString(36).substring(2, 15);
|
// its source code), visit <https://github.com/milesmcc/shynet>.
|
||||||
function sendUpdate() {
|
//
|
||||||
|
// This script only sends the current URL, the referrer URL, and the page load time. That's it!
|
||||||
|
|
||||||
|
var Shynet = {
|
||||||
|
idempotency: null,
|
||||||
|
heartbeatTaskId: null,
|
||||||
|
skipHeartbeat: false,
|
||||||
|
sendHeartbeat: function () {
|
||||||
try {
|
try {
|
||||||
if (document.hidden) {
|
if (document.hidden || Shynet.skipHeartbeat) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Shynet.skipHeartbeat = true;
|
||||||
var xhr = new XMLHttpRequest();
|
var xhr = new XMLHttpRequest();
|
||||||
xhr.open(
|
xhr.open(
|
||||||
"POST",
|
"POST",
|
||||||
"{{protocol}}://{{request.site.domain|default:request.META.HTTP_HOST}}{{endpoint}}",
|
"{{protocol}}://{{request.get_host}}{{endpoint}}",
|
||||||
true
|
true
|
||||||
);
|
);
|
||||||
xhr.setRequestHeader("Content-Type", "application/json");
|
xhr.setRequestHeader("Content-Type", "application/json");
|
||||||
|
xhr.onload = function () {
|
||||||
|
Shynet.skipHeartbeat = false;
|
||||||
|
};
|
||||||
|
xhr.onerror = function () {
|
||||||
|
Shynet.skipHeartbeat = false;
|
||||||
|
};
|
||||||
xhr.send(
|
xhr.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
idempotency: idempotency,
|
idempotency: Shynet.idempotency,
|
||||||
referrer: document.referrer,
|
referrer: document.referrer,
|
||||||
location: window.location.href,
|
location: window.location.href,
|
||||||
loadTime:
|
loadTime:
|
||||||
@@ -24,8 +39,26 @@ window.onload = function () {
|
|||||||
window.performance.timing.navigationStart,
|
window.performance.timing.navigationStart,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
} catch { }
|
} catch (e) {}
|
||||||
|
},
|
||||||
|
newPageLoad: function () {
|
||||||
|
if (Shynet.heartbeatTaskId != null) {
|
||||||
|
clearInterval(Shynet.heartbeatTaskId);
|
||||||
|
}
|
||||||
|
Shynet.idempotency = Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
|
||||||
|
Shynet.skipHeartbeat = false;
|
||||||
|
Shynet.heartbeatTaskId = setInterval(Shynet.sendHeartbeat, parseInt("{{heartbeat_frequency}}"));
|
||||||
|
Shynet.sendHeartbeat();
|
||||||
}
|
}
|
||||||
setInterval(sendUpdate, parseInt("{{heartbeat_frequency}}"));
|
|
||||||
sendUpdate();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
window.addEventListener("load", Shynet.newPageLoad);
|
||||||
|
|
||||||
|
{% if script_inject %}
|
||||||
|
// The following is script is not part of Shynet, and was instead
|
||||||
|
// provided by this site's administrator.
|
||||||
|
//
|
||||||
|
// -- START --
|
||||||
|
{{script_inject|safe}}
|
||||||
|
// -- END --
|
||||||
|
{% endif %}
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.core.cache import cache
|
from django.core.cache import cache
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.http import HttpResponse, Http404, HttpResponseBadRequest
|
from django.http import (
|
||||||
|
Http404,
|
||||||
|
HttpResponse,
|
||||||
|
HttpResponseBadRequest,
|
||||||
|
HttpResponseForbidden,
|
||||||
|
)
|
||||||
from django.shortcuts import render, reverse
|
from django.shortcuts import render, reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.decorators import method_decorator
|
from django.utils.decorators import method_decorator
|
||||||
@@ -36,6 +42,7 @@ def ingress(request, service_uuid, identifier, tracker, payload):
|
|||||||
identifier=identifier,
|
identifier=identifier,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class ValidateServiceOriginsMixin:
|
class ValidateServiceOriginsMixin:
|
||||||
def dispatch(self, request, *args, **kwargs):
|
def dispatch(self, request, *args, **kwargs):
|
||||||
try:
|
try:
|
||||||
@@ -47,8 +54,24 @@ class ValidateServiceOriginsMixin:
|
|||||||
origins = service.origins
|
origins = service.origins
|
||||||
cache.set(f"service_origins_{service_uuid}", origins, timeout=3600)
|
cache.set(f"service_origins_{service_uuid}", origins, timeout=3600)
|
||||||
|
|
||||||
|
allow_origin = "*"
|
||||||
|
|
||||||
|
if origins != "*":
|
||||||
|
remote_origin = request.META.get("HTTP_ORIGIN")
|
||||||
|
if (
|
||||||
|
remote_origin is None
|
||||||
|
and request.META.get("HTTP_REFERER") is not None
|
||||||
|
):
|
||||||
|
parsed = urlparse(request.META.get("HTTP_REFERER"))
|
||||||
|
remote_origin = f"{parsed.scheme}://{parsed.netloc}".lower()
|
||||||
|
origins = [origin.strip().lower() for origin in origins.split(",")]
|
||||||
|
if remote_origin in origins:
|
||||||
|
allow_origin = remote_origin
|
||||||
|
else:
|
||||||
|
return HttpResponseForbidden()
|
||||||
|
|
||||||
resp = super().dispatch(request, *args, **kwargs)
|
resp = super().dispatch(request, *args, **kwargs)
|
||||||
resp["Access-Control-Allow-Origin"] = origins
|
resp["Access-Control-Allow-Origin"] = allow_origin
|
||||||
resp["Access-Control-Allow-Methods"] = "GET,HEAD,OPTIONS,POST"
|
resp["Access-Control-Allow-Methods"] = "GET,HEAD,OPTIONS,POST"
|
||||||
resp[
|
resp[
|
||||||
"Access-Control-Allow-Headers"
|
"Access-Control-Allow-Headers"
|
||||||
@@ -77,7 +100,7 @@ class PixelView(ValidateServiceOriginsMixin, View):
|
|||||||
"R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=="
|
"R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw=="
|
||||||
)
|
)
|
||||||
resp = HttpResponse(data, content_type="image/gif")
|
resp = HttpResponse(data, content_type="image/gif")
|
||||||
resp["Cache-Control"] = "no-cache"
|
resp["Cache-Control"] = "no-cache, no-store, must-revalidate"
|
||||||
resp["Access-Control-Allow-Origin"] = "*"
|
resp["Access-Control-Allow-Origin"] = "*"
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
@@ -89,7 +112,9 @@ class ScriptView(ValidateServiceOriginsMixin, View):
|
|||||||
endpoint = (
|
endpoint = (
|
||||||
reverse(
|
reverse(
|
||||||
"ingress:endpoint_script",
|
"ingress:endpoint_script",
|
||||||
kwargs={"service_uuid": self.kwargs.get("service_uuid"),},
|
kwargs={
|
||||||
|
"service_uuid": self.kwargs.get("service_uuid"),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
if self.kwargs.get("identifier") == None
|
if self.kwargs.get("identifier") == None
|
||||||
else reverse(
|
else reverse(
|
||||||
@@ -104,11 +129,14 @@ class ScriptView(ValidateServiceOriginsMixin, View):
|
|||||||
return render(
|
return render(
|
||||||
self.request,
|
self.request,
|
||||||
"analytics/scripts/page.js",
|
"analytics/scripts/page.js",
|
||||||
context={
|
context=dict(
|
||||||
"endpoint": endpoint,
|
{
|
||||||
"protocol": protocol,
|
"endpoint": endpoint,
|
||||||
"heartbeat_frequency": heartbeat_frequency,
|
"protocol": protocol,
|
||||||
},
|
"heartbeat_frequency": heartbeat_frequency,
|
||||||
|
"script_inject": self.get_script_inject(),
|
||||||
|
}
|
||||||
|
),
|
||||||
content_type="application/javascript",
|
content_type="application/javascript",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -124,3 +152,12 @@ class ScriptView(ValidateServiceOriginsMixin, View):
|
|||||||
return HttpResponse(
|
return HttpResponse(
|
||||||
json.dumps({"status": "OK"}), content_type="application/json"
|
json.dumps({"status": "OK"}), content_type="application/json"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def get_script_inject(self):
|
||||||
|
service_uuid = self.kwargs.get("service_uuid")
|
||||||
|
script_inject = cache.get(f"script_inject_{service_uuid}")
|
||||||
|
if script_inject == None:
|
||||||
|
service = Service.objects.get(uuid=service_uuid)
|
||||||
|
script_inject = service.script_inject
|
||||||
|
cache.set(f"script_inject_{service_uuid}", script_inject, timeout=3600)
|
||||||
|
return script_inject
|
||||||
|
|||||||
0
shynet/api/__init__.py
Normal file
1
shynet/api/admin.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# from django.contrib import admin
|
||||||
6
shynet/api/apps.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
|
class ApiConfig(AppConfig):
|
||||||
|
default_auto_field = 'django.db.models.BigAutoField'
|
||||||
|
name = 'api'
|
||||||
0
shynet/api/migrations/__init__.py
Normal file
23
shynet/api/mixins.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
from django.http import JsonResponse
|
||||||
|
from django.contrib.auth.models import AnonymousUser
|
||||||
|
|
||||||
|
from core.models import User
|
||||||
|
|
||||||
|
|
||||||
|
class ApiTokenRequiredMixin:
|
||||||
|
def _get_user_by_token(self, request):
|
||||||
|
token = request.headers.get('Authorization')
|
||||||
|
if not token or not token.startswith('Token '):
|
||||||
|
return AnonymousUser()
|
||||||
|
|
||||||
|
token = token.split(' ')[1]
|
||||||
|
user = User.objects.filter(api_token=token).first()
|
||||||
|
|
||||||
|
return user if user else AnonymousUser()
|
||||||
|
|
||||||
|
def dispatch(self, request, *args, **kwargs):
|
||||||
|
request.user = self._get_user_by_token(request)
|
||||||
|
if not request.user.is_authenticated:
|
||||||
|
return JsonResponse(data={}, status=403)
|
||||||
|
|
||||||
|
return super().dispatch(request, *args, **kwargs)
|
||||||
1
shynet/api/models.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
# from django.db import models
|
||||||
3
shynet/api/tests.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
# Create your tests here.
|
||||||
7
shynet/api/urls.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
from django.urls import path
|
||||||
|
|
||||||
|
from . import views
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path("dashboard/", views.DashboardApiView.as_view(), name="services"),
|
||||||
|
]
|
||||||
60
shynet/api/views.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import uuid
|
||||||
|
from django.http import JsonResponse
|
||||||
|
from django.db.models import Q
|
||||||
|
from django.db.models.query import QuerySet
|
||||||
|
from django.views.generic import View
|
||||||
|
|
||||||
|
from dashboard.mixins import DateRangeMixin
|
||||||
|
from core.models import Service
|
||||||
|
|
||||||
|
from .mixins import ApiTokenRequiredMixin
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_uuid(value):
|
||||||
|
try:
|
||||||
|
uuid.UUID(value)
|
||||||
|
return True
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardApiView(ApiTokenRequiredMixin, DateRangeMixin, View):
|
||||||
|
def get(self, request, *args, **kwargs):
|
||||||
|
services = Service.objects.filter(
|
||||||
|
Q(owner=request.user) | Q(collaborators__in=[request.user])
|
||||||
|
).distinct()
|
||||||
|
|
||||||
|
uuid = request.GET.get('uuid')
|
||||||
|
if uuid and is_valid_uuid(uuid):
|
||||||
|
services = services.filter(uuid=uuid)
|
||||||
|
|
||||||
|
try:
|
||||||
|
start = self.get_start_date()
|
||||||
|
end = self.get_end_date()
|
||||||
|
except ValueError:
|
||||||
|
return JsonResponse(status=400, data={'error': 'Invalid date format'})
|
||||||
|
|
||||||
|
services_data = [
|
||||||
|
{
|
||||||
|
'name': s.name,
|
||||||
|
'uuid': s.uuid,
|
||||||
|
'link': s.link,
|
||||||
|
'stats': s.get_core_stats(start, end),
|
||||||
|
}
|
||||||
|
for s in services
|
||||||
|
]
|
||||||
|
|
||||||
|
services_data = self._convert_querysets_to_lists(services_data)
|
||||||
|
|
||||||
|
return JsonResponse(data={'services': services_data})
|
||||||
|
|
||||||
|
def _convert_querysets_to_lists(self, services_data):
|
||||||
|
for service_data in services_data:
|
||||||
|
for key, value in service_data['stats'].items():
|
||||||
|
if isinstance(value, QuerySet):
|
||||||
|
service_data['stats'][key] = list(value)
|
||||||
|
for key, value in service_data['stats']['compare'].items():
|
||||||
|
if isinstance(value, QuerySet):
|
||||||
|
service_data['stats']['compare'][key] = list(value)
|
||||||
|
|
||||||
|
return services_data
|
||||||
37
shynet/core/factories.py
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
import factory
|
||||||
|
from factory.django import DjangoModelFactory
|
||||||
|
from .models import Service
|
||||||
|
|
||||||
|
|
||||||
|
class UserFactory(DjangoModelFactory):
|
||||||
|
username = factory.Faker("user_name")
|
||||||
|
email = factory.Faker("email")
|
||||||
|
name = factory.Faker("name")
|
||||||
|
|
||||||
|
@post_generation
|
||||||
|
def password(self, create, extracted, **kwargs):
|
||||||
|
password = (
|
||||||
|
extracted
|
||||||
|
if extracted
|
||||||
|
else factory.Faker(
|
||||||
|
"password",
|
||||||
|
length=42,
|
||||||
|
special_chars=True,
|
||||||
|
digits=True,
|
||||||
|
upper_case=True,
|
||||||
|
lower_case=True,
|
||||||
|
).evaluate(None, None, extra={"locale": None})
|
||||||
|
)
|
||||||
|
self.set_password(password)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = get_user_model()
|
||||||
|
django_get_or_create = ["username"]
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceFactory(DjangoModelFactory):
|
||||||
|
class Meta:
|
||||||
|
model = Service
|
||||||
|
|
||||||
|
name = factory.Faker("company")
|
||||||
117
shynet/core/management/commands/demo.py
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import traceback
|
||||||
|
from django.utils.timezone import now
|
||||||
|
from django.utils.timezone import timedelta
|
||||||
|
import random
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.contrib.sites.models import Site
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
from django.utils.crypto import get_random_string
|
||||||
|
import user_agents
|
||||||
|
from logging import info
|
||||||
|
|
||||||
|
from core.models import User, Service
|
||||||
|
from analytics.models import Session, Hit
|
||||||
|
from analytics.tasks import ingress_request
|
||||||
|
|
||||||
|
LOCATIONS = [
|
||||||
|
"/",
|
||||||
|
"/post/{rand}",
|
||||||
|
"/login",
|
||||||
|
"/me",
|
||||||
|
]
|
||||||
|
|
||||||
|
REFERRERS = [
|
||||||
|
"https://news.ycombinator.com/item?id=11116274",
|
||||||
|
"https://news.ycombinator.com/item?id=24872911",
|
||||||
|
"https://reddit.com",
|
||||||
|
"https://facebook.com",
|
||||||
|
"https://twitter.com/milesmccain",
|
||||||
|
"https://twitter.com",
|
||||||
|
"https://stanford.edu/~mccain/",
|
||||||
|
"https://tiktok.com",
|
||||||
|
"https://io.stanford.edu",
|
||||||
|
"https://en.wikipedia.org",
|
||||||
|
"https://stackoverflow.com",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
|
||||||
|
USER_AGENTS = [
|
||||||
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/77.0.3865.90 Safari/537.36",
|
||||||
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X x.y; rv:42.0) Gecko/20100101 Firefox/43.4",
|
||||||
|
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/77.0.3865.90 Safari/537.36",
|
||||||
|
"Mozilla/5.0 (iPhone; CPU iPhone OS 11_3_1 like Mac OS X) AppleWebKit/603.1.30 (KHTML, like Gecko)",
|
||||||
|
"Version/10.0 Mobile/14E304 Safari/602.1",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "Configures a Shynet demo service"
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
"name",
|
||||||
|
type=str,
|
||||||
|
)
|
||||||
|
parser.add_argument("owner_email", type=str)
|
||||||
|
parser.add_argument(
|
||||||
|
"avg",
|
||||||
|
type=int,
|
||||||
|
)
|
||||||
|
parser.add_argument("deviation", type=float, default=0.4)
|
||||||
|
parser.add_argument(
|
||||||
|
"days",
|
||||||
|
type=int,
|
||||||
|
)
|
||||||
|
parser.add_argument("load_time", type=float, default=1000)
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
owner = User.objects.get(email=options.get("owner_email"))
|
||||||
|
service = Service.objects.create(name=options.get("name"), owner=owner)
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"Created demo service `{service.name}` (uuid: `{service.uuid}`, owner: {owner})"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Go through each day requested, creating sessions and hits
|
||||||
|
for days in range(options.get("days")):
|
||||||
|
day = (now() - timedelta(days=days)).replace(hour=0, minute=0, second=0)
|
||||||
|
print(f"Populating info for {day}...")
|
||||||
|
avg = options.get("avg")
|
||||||
|
deviation = options.get("deviation")
|
||||||
|
ips = [
|
||||||
|
".".join(map(str, (random.randint(0, 255) for _ in range(4))))
|
||||||
|
for _ in range(avg)
|
||||||
|
]
|
||||||
|
|
||||||
|
n = avg + random.randrange(-1 * deviation * avg, deviation * avg)
|
||||||
|
for _ in range(n):
|
||||||
|
time = day + timedelta(
|
||||||
|
hours=random.randrange(0, 23),
|
||||||
|
minutes=random.randrange(0, 59),
|
||||||
|
seconds=random.randrange(0, 59),
|
||||||
|
)
|
||||||
|
ip = random.choice(ips)
|
||||||
|
load_time = random.normalvariate(options.get("load_time"), 500)
|
||||||
|
referrer = random.choice(REFERRERS)
|
||||||
|
location = "https://example.com" + random.choice(LOCATIONS).replace(
|
||||||
|
"{rand}", str(random.randint(0, n))
|
||||||
|
)
|
||||||
|
user_agent = random.choice(USER_AGENTS)
|
||||||
|
ingress_request(
|
||||||
|
service.uuid,
|
||||||
|
"JS",
|
||||||
|
time,
|
||||||
|
{"loadTime": load_time, "referrer": referrer},
|
||||||
|
ip,
|
||||||
|
location,
|
||||||
|
user_agent,
|
||||||
|
)
|
||||||
|
|
||||||
|
print(f"Created {n} demo hits on {day}!")
|
||||||
|
|
||||||
|
self.stdout.write(self.style.SUCCESS(f"Successfully created demo data!"))
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import traceback
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from django.conf import settings
|
|
||||||
from django.contrib.sites.models import Site
|
|
||||||
from django.core.management.base import BaseCommand, CommandError
|
|
||||||
from django.utils.crypto import get_random_string
|
|
||||||
|
|
||||||
from core.models import User
|
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
|
||||||
help = "Configures the Shynet hostname"
|
|
||||||
|
|
||||||
def add_arguments(self, parser):
|
|
||||||
parser.add_argument(
|
|
||||||
"hostname", type=str,
|
|
||||||
)
|
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
|
||||||
site = Site.objects.get(pk=settings.SITE_ID)
|
|
||||||
site.domain = options.get("hostname")
|
|
||||||
site.save()
|
|
||||||
self.stdout.write(
|
|
||||||
self.style.SUCCESS(
|
|
||||||
f"Successfully set the hostname to '{options.get('hostname')}'"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
@@ -14,7 +14,8 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
def add_arguments(self, parser):
|
def add_arguments(self, parser):
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"email", type=str,
|
"email",
|
||||||
|
type=str,
|
||||||
)
|
)
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
|
|||||||
@@ -3,21 +3,21 @@ import uuid
|
|||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.sites.models import Site
|
from django.contrib.sites.models import Site
|
||||||
from django.core.management.base import BaseCommand, CommandError
|
|
||||||
from django.utils.crypto import get_random_string
|
|
||||||
|
|
||||||
from django.db import DEFAULT_DB_ALIAS, connections
|
|
||||||
from django.db.utils import OperationalError, ConnectionHandler
|
|
||||||
from django.core.exceptions import ImproperlyConfigured
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
from django.db import DEFAULT_DB_ALIAS, connections
|
||||||
|
from django.db.utils import ConnectionHandler, OperationalError
|
||||||
|
from django.utils.crypto import get_random_string
|
||||||
|
|
||||||
from core.models import User
|
from core.models import User
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
help = "Internal command to perform startup sanity checks."
|
help = "Internal command to perform startup checks."
|
||||||
|
|
||||||
def check_migrations(self):
|
def check_migrations(self):
|
||||||
from django.db.migrations.executor import MigrationExecutor
|
from django.db.migrations.executor import MigrationExecutor
|
||||||
|
|
||||||
try:
|
try:
|
||||||
executor = MigrationExecutor(connections[DEFAULT_DB_ALIAS])
|
executor = MigrationExecutor(connections[DEFAULT_DB_ALIAS])
|
||||||
except OperationalError:
|
except OperationalError:
|
||||||
@@ -26,24 +26,23 @@ class Command(BaseCommand):
|
|||||||
except ImproperlyConfigured:
|
except ImproperlyConfigured:
|
||||||
# No databases are configured (or the dummy one)
|
# No databases are configured (or the dummy one)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
if executor.migration_plan(executor.loader.graph.leaf_nodes()):
|
if executor.migration_plan(executor.loader.graph.leaf_nodes()):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
migration = self.check_migrations()
|
migration = self.check_migrations()
|
||||||
|
|
||||||
admin, hostname, whitelabel = [True] * 3
|
admin, whitelabel = [True] * 2
|
||||||
if not migration:
|
if not migration:
|
||||||
admin = not User.objects.all().exists()
|
admin = not User.objects.all().exists()
|
||||||
hostname = not Site.objects.filter(domain__isnull=False).exclude(domain__exact="").exclude(domain__exact="example.com").exists()
|
whitelabel = (
|
||||||
whitelabel = not Site.objects.filter(name__isnull=False).exclude(name__exact="").exclude(name__exact="example.com").exists()
|
not Site.objects.filter(name__isnull=False)
|
||||||
|
.exclude(name__exact="")
|
||||||
self.stdout.write(
|
.exclude(name__exact="example.com")
|
||||||
self.style.SUCCESS(
|
.exists()
|
||||||
f"{migration} {admin} {hostname} {whitelabel}"
|
|
||||||
)
|
)
|
||||||
)
|
|
||||||
|
self.stdout.write(self.style.SUCCESS(f"{migration} {admin} {whitelabel}"))
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
def add_arguments(self, parser):
|
def add_arguments(self, parser):
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"name", type=str,
|
"name",
|
||||||
|
type=str,
|
||||||
)
|
)
|
||||||
|
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
|
|||||||
@@ -112,7 +112,9 @@ class Migration(migrations.Migration):
|
|||||||
"verbose_name_plural": "users",
|
"verbose_name_plural": "users",
|
||||||
"abstract": False,
|
"abstract": False,
|
||||||
},
|
},
|
||||||
managers=[("objects", django.contrib.auth.models.UserManager()),],
|
managers=[
|
||||||
|
("objects", django.contrib.auth.models.UserManager()),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name="Service",
|
name="Service",
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ class Migration(migrations.Migration):
|
|||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.AlterModelOptions(
|
migrations.AlterModelOptions(
|
||||||
name="service", options={"ordering": ["name", "uuid"]},
|
name="service",
|
||||||
|
options={"ordering": ["name", "uuid"]},
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ from django.db import migrations, models
|
|||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('core', '0003_service_respect_dnt'),
|
("core", "0003_service_respect_dnt"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.AddField(
|
migrations.AddField(
|
||||||
model_name='service',
|
model_name="service",
|
||||||
name='collect_ips',
|
name="collect_ips",
|
||||||
field=models.BooleanField(default=True),
|
field=models.BooleanField(default=True),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
22
shynet/core/migrations/0005_service_ignored_ips.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Generated by Django 3.0.6 on 2020-05-07 20:28
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
import core.models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("core", "0004_service_collect_ips"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="service",
|
||||||
|
name="ignored_ips",
|
||||||
|
field=models.TextField(
|
||||||
|
blank=True, default="", validators=[core.models._validate_network_list]
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
22
shynet/core/migrations/0006_service_hide_referrer_regex.py
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# Generated by Django 3.0.6 on 2020-05-07 21:23
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
import core.models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("core", "0005_service_ignored_ips"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="service",
|
||||||
|
name="hide_referrer_regex",
|
||||||
|
field=models.TextField(
|
||||||
|
blank=True, default="", validators=[core.models._validate_regex]
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
18
shynet/core/migrations/0007_service_ignore_robots.py
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
# Generated by Django 3.0.6 on 2020-06-15 16:16
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("core", "0006_service_hide_referrer_regex"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="service",
|
||||||
|
name="ignore_robots",
|
||||||
|
field=models.BooleanField(default=False),
|
||||||
|
)
|
||||||
|
]
|
||||||
25
shynet/core/migrations/0008_auto_20200628_1403.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Generated by Django 3.1b1 on 2020-06-28 18:03
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
("core", "0007_service_ignore_robots"),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name="service",
|
||||||
|
name="script_inject",
|
||||||
|
field=models.TextField(blank=True, default=""),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name="user",
|
||||||
|
name="first_name",
|
||||||
|
field=models.CharField(
|
||||||
|
blank=True, max_length=150, verbose_name="first name"
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
24
shynet/core/migrations/0009_auto_20211117_0217.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Generated by Django 3.2.5 on 2021-11-17 07:17
|
||||||
|
|
||||||
|
import core.models
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('core', '0008_auto_20200628_1403'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AddField(
|
||||||
|
model_name='user',
|
||||||
|
name='api_token',
|
||||||
|
field=models.TextField(default=core.models._default_api_token, unique=True),
|
||||||
|
),
|
||||||
|
migrations.AlterField(
|
||||||
|
model_name='user',
|
||||||
|
name='id',
|
||||||
|
field=models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,22 +1,57 @@
|
|||||||
import json
|
import ipaddress
|
||||||
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
|
from secrets import token_urlsafe
|
||||||
|
|
||||||
from django.apps import apps
|
from django.apps import apps
|
||||||
|
from django.conf import settings
|
||||||
from django.contrib.auth.models import AbstractUser
|
from django.contrib.auth.models import AbstractUser
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.db.models.functions import TruncDate
|
from django.db.models.functions import TruncDate, TruncHour
|
||||||
from django.db.utils import NotSupportedError
|
from django.db.utils import NotSupportedError
|
||||||
from django.shortcuts import reverse
|
from django.shortcuts import reverse
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
|
# How long a session a needs to go without an update to no longer be considered 'active' (i.e., currently online)
|
||||||
|
ACTIVE_USER_TIMEDELTA = timezone.timedelta(
|
||||||
|
milliseconds=settings.SCRIPT_HEARTBEAT_FREQUENCY * 2
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _default_uuid():
|
def _default_uuid():
|
||||||
return str(uuid.uuid4())
|
return str(uuid.uuid4())
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_network_list(networks: str):
|
||||||
|
try:
|
||||||
|
_parse_network_list(networks)
|
||||||
|
except ValueError as e:
|
||||||
|
raise ValidationError(str(e))
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_regex(regex: str):
|
||||||
|
try:
|
||||||
|
re.compile(regex)
|
||||||
|
except re.error:
|
||||||
|
raise ValidationError(f"'{regex}' is not valid RegEx")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_network_list(networks: str):
|
||||||
|
if len(networks.strip()) == 0:
|
||||||
|
return []
|
||||||
|
return [ipaddress.ip_network(network.strip()) for network in networks.split(",")]
|
||||||
|
|
||||||
|
|
||||||
|
def _default_api_token():
|
||||||
|
return token_urlsafe(32)
|
||||||
|
|
||||||
|
|
||||||
class User(AbstractUser):
|
class User(AbstractUser):
|
||||||
username = models.TextField(default=_default_uuid, unique=True)
|
username = models.TextField(default=_default_uuid, unique=True)
|
||||||
email = models.EmailField(unique=True)
|
email = models.EmailField(unique=True)
|
||||||
|
api_token = models.TextField(default=_default_api_token, unique=True)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.email
|
return self.email
|
||||||
@@ -42,7 +77,15 @@ class Service(models.Model):
|
|||||||
max_length=2, choices=SERVICE_STATUSES, default=ACTIVE, db_index=True
|
max_length=2, choices=SERVICE_STATUSES, default=ACTIVE, db_index=True
|
||||||
)
|
)
|
||||||
respect_dnt = models.BooleanField(default=True)
|
respect_dnt = models.BooleanField(default=True)
|
||||||
|
ignore_robots = models.BooleanField(default=False)
|
||||||
collect_ips = models.BooleanField(default=True)
|
collect_ips = models.BooleanField(default=True)
|
||||||
|
ignored_ips = models.TextField(
|
||||||
|
default="", blank=True, validators=[_validate_network_list]
|
||||||
|
)
|
||||||
|
hide_referrer_regex = models.TextField(
|
||||||
|
default="", blank=True, validators=[_validate_regex]
|
||||||
|
)
|
||||||
|
script_inject = models.TextField(default="", blank=True)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
ordering = ["name", "uuid"]
|
ordering = ["name", "uuid"]
|
||||||
@@ -50,6 +93,21 @@ class Service(models.Model):
|
|||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
||||||
|
def get_ignored_networks(self):
|
||||||
|
return _parse_network_list(self.ignored_ips)
|
||||||
|
|
||||||
|
def get_ignored_referrer_regex(self):
|
||||||
|
if len(self.hide_referrer_regex.strip()) == 0:
|
||||||
|
return re.compile(r".^") # matches nothing
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
return re.compile(self.hide_referrer_regex)
|
||||||
|
except re.error:
|
||||||
|
# Regexes are validated in the form, but this is an important
|
||||||
|
# fallback to prevent form validation and malformed source
|
||||||
|
# data from causing all service pages to error
|
||||||
|
return re.compile(r".^")
|
||||||
|
|
||||||
def get_daily_stats(self):
|
def get_daily_stats(self):
|
||||||
return self.get_core_stats(
|
return self.get_core_stats(
|
||||||
start_time=timezone.now() - timezone.timedelta(days=1)
|
start_time=timezone.now() - timezone.timedelta(days=1)
|
||||||
@@ -73,8 +131,10 @@ class Service(models.Model):
|
|||||||
Session = apps.get_model("analytics", "Session")
|
Session = apps.get_model("analytics", "Session")
|
||||||
Hit = apps.get_model("analytics", "Hit")
|
Hit = apps.get_model("analytics", "Hit")
|
||||||
|
|
||||||
|
tz_now = timezone.now()
|
||||||
|
|
||||||
currently_online = Session.objects.filter(
|
currently_online = Session.objects.filter(
|
||||||
service=self, last_seen__gt=timezone.now() - timezone.timedelta(seconds=10)
|
service=self, last_seen__gt=tz_now - ACTIVE_USER_TIMEDELTA
|
||||||
).count()
|
).count()
|
||||||
|
|
||||||
sessions = Session.objects.filter(
|
sessions = Session.objects.filter(
|
||||||
@@ -83,11 +143,13 @@ class Service(models.Model):
|
|||||||
session_count = sessions.count()
|
session_count = sessions.count()
|
||||||
|
|
||||||
hits = Hit.objects.filter(
|
hits = Hit.objects.filter(
|
||||||
session__service=self, start_time__lt=end_time, start_time__gt=start_time
|
service=self, start_time__lt=end_time, start_time__gt=start_time
|
||||||
)
|
)
|
||||||
hit_count = hits.count()
|
hit_count = hits.count()
|
||||||
|
|
||||||
bounces = sessions.annotate(hit_count=models.Count("hit")).filter(hit_count=1)
|
has_hits = Hit.objects.filter(service=self).exists()
|
||||||
|
|
||||||
|
bounces = sessions.filter(is_bounce=True)
|
||||||
bounce_count = bounces.count()
|
bounce_count = bounces.count()
|
||||||
|
|
||||||
locations = (
|
locations = (
|
||||||
@@ -96,12 +158,17 @@ class Service(models.Model):
|
|||||||
.order_by("-count")
|
.order_by("-count")
|
||||||
)
|
)
|
||||||
|
|
||||||
referrers = (
|
referrer_ignore = self.get_ignored_referrer_regex()
|
||||||
hits.filter(initial=True)
|
referrers = [
|
||||||
.values("referrer")
|
referrer
|
||||||
.annotate(count=models.Count("referrer"))
|
for referrer in (
|
||||||
.order_by("-count")
|
hits.filter(initial=True)
|
||||||
)
|
.values("referrer")
|
||||||
|
.annotate(count=models.Count("referrer"))
|
||||||
|
.order_by("-count")
|
||||||
|
)
|
||||||
|
if not referrer_ignore.match(referrer["referrer"])
|
||||||
|
]
|
||||||
|
|
||||||
countries = (
|
countries = (
|
||||||
sessions.values("country")
|
sessions.values("country")
|
||||||
@@ -131,18 +198,43 @@ class Service(models.Model):
|
|||||||
.order_by("-count")
|
.order_by("-count")
|
||||||
)
|
)
|
||||||
|
|
||||||
device_types = (
|
|
||||||
sessions.values("device_type")
|
|
||||||
.annotate(count=models.Count("device_type"))
|
|
||||||
.order_by("-count")
|
|
||||||
)
|
|
||||||
|
|
||||||
avg_load_time = hits.aggregate(load_time__avg=models.Avg("load_time"))[
|
avg_load_time = hits.aggregate(load_time__avg=models.Avg("load_time"))[
|
||||||
"load_time__avg"
|
"load_time__avg"
|
||||||
]
|
]
|
||||||
|
|
||||||
avg_hits_per_session = hit_count / session_count if session_count > 0 else None
|
avg_hits_per_session = hit_count / session_count if session_count > 0 else None
|
||||||
|
|
||||||
|
avg_session_duration = self._get_avg_session_duration(sessions, session_count)
|
||||||
|
|
||||||
|
chart_data, chart_tooltip_format, chart_granularity = self._get_chart_data(
|
||||||
|
sessions, hits, start_time, end_time, tz_now
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"currently_online": currently_online,
|
||||||
|
"session_count": session_count,
|
||||||
|
"hit_count": hit_count,
|
||||||
|
"has_hits": has_hits,
|
||||||
|
"bounce_rate_pct": bounce_count * 100 / session_count
|
||||||
|
if session_count > 0
|
||||||
|
else None,
|
||||||
|
"avg_session_duration": avg_session_duration,
|
||||||
|
"avg_load_time": avg_load_time,
|
||||||
|
"avg_hits_per_session": avg_hits_per_session,
|
||||||
|
"locations": locations,
|
||||||
|
"referrers": referrers,
|
||||||
|
"countries": countries,
|
||||||
|
"operating_systems": operating_systems,
|
||||||
|
"browsers": browsers,
|
||||||
|
"devices": devices,
|
||||||
|
"device_types": device_types,
|
||||||
|
"chart_data": chart_data,
|
||||||
|
"chart_tooltip_format": chart_tooltip_format,
|
||||||
|
"chart_granularity": chart_granularity,
|
||||||
|
"online": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
def _get_avg_session_duration(self, sessions, session_count):
|
||||||
try:
|
try:
|
||||||
avg_session_duration = sessions.annotate(
|
avg_session_duration = sessions.annotate(
|
||||||
duration=models.F("last_seen") - models.F("start_time")
|
duration=models.F("last_seen") - models.F("start_time")
|
||||||
@@ -157,44 +249,77 @@ class Service(models.Model):
|
|||||||
if session_count == 0:
|
if session_count == 0:
|
||||||
avg_session_duration = None
|
avg_session_duration = None
|
||||||
|
|
||||||
session_chart_data = {
|
return avg_session_duration
|
||||||
k["date"]: k["count"]
|
|
||||||
for k in sessions.annotate(date=TruncDate("start_time"))
|
|
||||||
.values("date")
|
|
||||||
.annotate(count=models.Count("uuid"))
|
|
||||||
.order_by("date")
|
|
||||||
}
|
|
||||||
for day_offset in range((end_time - start_time).days + 1):
|
|
||||||
day = (start_time + timezone.timedelta(days=day_offset)).date()
|
|
||||||
if day not in session_chart_data:
|
|
||||||
session_chart_data[day] = 0
|
|
||||||
|
|
||||||
return {
|
def _get_chart_data(self, sessions, hits, start_time, end_time, tz_now):
|
||||||
"currently_online": currently_online,
|
# Show hourly chart for date ranges of 3 days or less, otherwise daily chart
|
||||||
"session_count": session_count,
|
if (end_time - start_time).days < 3:
|
||||||
"hit_count": hit_count,
|
chart_tooltip_format = "MM/dd HH:mm"
|
||||||
"avg_hits_per_session": hit_count / (max(session_count, 1)),
|
chart_granularity = "hourly"
|
||||||
"bounce_rate_pct": bounce_count * 100 / session_count
|
sessions_per_hour = (
|
||||||
if session_count > 0
|
sessions.annotate(hour=TruncHour("start_time"))
|
||||||
else None,
|
.values("hour")
|
||||||
"avg_session_duration": avg_session_duration,
|
.annotate(count=models.Count("uuid"))
|
||||||
"avg_load_time": avg_load_time,
|
.order_by("hour")
|
||||||
"avg_hits_per_session": avg_hits_per_session,
|
)
|
||||||
"locations": locations,
|
chart_data = {
|
||||||
"referrers": referrers,
|
k["hour"]: {"sessions": k["count"]} for k in sessions_per_hour
|
||||||
"countries": countries,
|
}
|
||||||
"operating_systems": operating_systems,
|
hits_per_hour = (
|
||||||
"browsers": browsers,
|
hits.annotate(hour=TruncHour("start_time"))
|
||||||
"devices": devices,
|
.values("hour")
|
||||||
"device_types": device_types,
|
.annotate(count=models.Count("id"))
|
||||||
"session_chart_data": json.dumps(
|
.order_by("hour")
|
||||||
[
|
)
|
||||||
{"x": str(key), "y": value}
|
for k in hits_per_hour:
|
||||||
for key, value in session_chart_data.items()
|
if k["hour"] not in chart_data:
|
||||||
]
|
chart_data[k["hour"]] = {"hits": k["count"], "sessions": 0}
|
||||||
),
|
else:
|
||||||
"online": True,
|
chart_data[k["hour"]]["hits"] = k["count"]
|
||||||
|
|
||||||
|
hours_range = range(int((end_time - start_time).total_seconds() / 3600) + 1)
|
||||||
|
for hour_offset in hours_range:
|
||||||
|
hour = start_time + timezone.timedelta(hours=hour_offset)
|
||||||
|
if hour not in chart_data and hour <= tz_now:
|
||||||
|
chart_data[hour] = {"sessions": 0, "hits": 0}
|
||||||
|
else:
|
||||||
|
chart_tooltip_format = "MMM d"
|
||||||
|
chart_granularity = "daily"
|
||||||
|
sessions_per_day = (
|
||||||
|
sessions.annotate(date=TruncDate("start_time"))
|
||||||
|
.values("date")
|
||||||
|
.annotate(count=models.Count("uuid"))
|
||||||
|
.order_by("date")
|
||||||
|
)
|
||||||
|
chart_data = {k["date"]: {"sessions": k["count"]} for k in sessions_per_day}
|
||||||
|
hits_per_day = (
|
||||||
|
hits.annotate(date=TruncDate("start_time"))
|
||||||
|
.values("date")
|
||||||
|
.annotate(count=models.Count("id"))
|
||||||
|
.order_by("date")
|
||||||
|
)
|
||||||
|
for k in hits_per_day:
|
||||||
|
if k["date"] not in chart_data:
|
||||||
|
chart_data[k["date"]] = {"hits": k["count"], "sessions": 0}
|
||||||
|
else:
|
||||||
|
chart_data[k["date"]]["hits"] = k["count"]
|
||||||
|
|
||||||
|
for day_offset in range((end_time - start_time).days + 1):
|
||||||
|
day = (start_time + timezone.timedelta(days=day_offset)).date()
|
||||||
|
if day not in chart_data and day <= tz_now.date():
|
||||||
|
chart_data[day] = {"sessions": 0, "hits": 0}
|
||||||
|
|
||||||
|
chart_data = sorted(chart_data.items(), key=lambda k: k[0])
|
||||||
|
chart_data = {
|
||||||
|
"sessions": [v["sessions"] for k, v in chart_data],
|
||||||
|
"hits": [v["hits"] for k, v in chart_data],
|
||||||
|
"labels": [str(k) for k, v in chart_data],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return chart_data, chart_tooltip_format, chart_granularity
|
||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return reverse("dashboard:service", kwargs={"pk": self.pk},)
|
return reverse(
|
||||||
|
"dashboard:service",
|
||||||
|
kwargs={"pk": self.pk},
|
||||||
|
)
|
||||||
|
|||||||
1
shynet/core/tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
from allauth.account.admin import EmailAddress
|
from allauth.account.admin import EmailAddress
|
||||||
from django import forms
|
from django import forms
|
||||||
|
from django.conf import settings
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
from core.models import Service, User
|
from core.models import Service, User
|
||||||
@@ -8,35 +9,74 @@ from core.models import Service, User
|
|||||||
class ServiceForm(forms.ModelForm):
|
class ServiceForm(forms.ModelForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Service
|
model = Service
|
||||||
fields = ["name", "link", "respect_dnt", "collect_ips", "origins", "collaborators"]
|
fields = [
|
||||||
|
"name",
|
||||||
|
"link",
|
||||||
|
"respect_dnt",
|
||||||
|
"collect_ips",
|
||||||
|
"ignored_ips",
|
||||||
|
"ignore_robots",
|
||||||
|
"hide_referrer_regex",
|
||||||
|
"origins",
|
||||||
|
"collaborators",
|
||||||
|
"script_inject",
|
||||||
|
]
|
||||||
widgets = {
|
widgets = {
|
||||||
"name": forms.TextInput(),
|
"name": forms.TextInput(),
|
||||||
"origins": forms.TextInput(),
|
"origins": forms.TextInput(),
|
||||||
|
"ignored_ips": forms.TextInput(),
|
||||||
"respect_dnt": forms.RadioSelect(choices=[(True, "Yes"), (False, "No")]),
|
"respect_dnt": forms.RadioSelect(choices=[(True, "Yes"), (False, "No")]),
|
||||||
"collect_ips": forms.RadioSelect(choices=[(True, "Yes"), (False, "No")]),
|
"collect_ips": forms.RadioSelect(choices=[(True, "Yes"), (False, "No")]),
|
||||||
|
"ignore_robots": forms.RadioSelect(choices=[(True, "Yes"), (False, "No")]),
|
||||||
|
"hide_referrer_regex": forms.TextInput(),
|
||||||
|
"script_inject": forms.Textarea(attrs={"class": "font-mono", "rows": 5}),
|
||||||
}
|
}
|
||||||
labels = {
|
labels = {
|
||||||
"origins": "Allowed Hostnames",
|
"origins": "Allowed origins",
|
||||||
"respect_dnt": "Respect DNT",
|
"respect_dnt": "Respect DNT",
|
||||||
"collect_ips": "Collect IP addresses"
|
"ignored_ips": "Ignored IP addresses",
|
||||||
|
"ignore_robots": "Ignore robots",
|
||||||
|
"hide_referrer_regex": "Hide specific referrers",
|
||||||
|
"script_inject": "Additional injected JS",
|
||||||
}
|
}
|
||||||
help_texts = {
|
help_texts = {
|
||||||
"name": _("What should the service be called?"),
|
"name": _("What should the service be called?"),
|
||||||
"link": _("What's the service's primary URL?"),
|
"link": _("What's the service's primary URL?"),
|
||||||
"origins": _(
|
"origins": _(
|
||||||
"At what hostnames does the service operate? This sets CORS headers, so use '*' if you're not sure (or don't care)."
|
"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)."
|
||||||
),
|
),
|
||||||
"respect_dnt": "Should visitors who have enabled <a href='https://en.wikipedia.org/wiki/Do_Not_Track'>Do Not Track</a> be excluded from all data?",
|
"respect_dnt": "Should visitors who have enabled <a href='https://en.wikipedia.org/wiki/Do_Not_Track'>Do Not Track</a> be excluded from all data?",
|
||||||
"collect_ips": "Should individual IP addresses be collected? IP metadata (location, host, etc) will still be collected."
|
"ignored_ips": "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').",
|
||||||
|
"ignore_robots": "Should sessions generated by bots be excluded from tracking?",
|
||||||
|
"hide_referrer_regex": "Any referrers that match this <a href='https://regexr.com/'>RegEx</a> will not be listed in the referrer summary. Sessions will still be tracked normally. No effect if left blank.",
|
||||||
|
"script_inject": "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.",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
collect_ips = forms.BooleanField(
|
||||||
|
help_text="IP address collection is disabled globally by your administrator."
|
||||||
|
if settings.BLOCK_ALL_IPS
|
||||||
|
else "Should individual IP addresses be collected? IP metadata (location, host, etc) will still be collected.",
|
||||||
|
widget=forms.RadioSelect(choices=[(True, "Yes"), (False, "No")]),
|
||||||
|
initial=False if settings.BLOCK_ALL_IPS else True,
|
||||||
|
required=False,
|
||||||
|
disabled=settings.BLOCK_ALL_IPS,
|
||||||
|
)
|
||||||
|
|
||||||
|
def clean_collect_ips(self):
|
||||||
|
collect_ips = self.cleaned_data["collect_ips"]
|
||||||
|
# Forces collect IPs to be false if it is disabled globally
|
||||||
|
return False if settings.BLOCK_ALL_IPS else collect_ips
|
||||||
|
|
||||||
collaborators = forms.CharField(
|
collaborators = forms.CharField(
|
||||||
help_text="Which users should have read-only access to this service? (Comma separated list of emails.)",
|
help_text="Which users on this Shynet instance should have read-only access to this service? (Comma separated list of emails.)",
|
||||||
required=False,
|
required=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
def clean_collaborators(self):
|
def clean_collaborators(self):
|
||||||
collaborators = []
|
collaborators = []
|
||||||
|
users_to_emails = (
|
||||||
|
{}
|
||||||
|
) # maps users to the email they are listed under as a collaborator
|
||||||
for collaborator_email in self.cleaned_data["collaborators"].split(","):
|
for collaborator_email in self.cleaned_data["collaborators"].split(","):
|
||||||
email = collaborator_email.strip()
|
email = collaborator_email.strip()
|
||||||
if email == "":
|
if email == "":
|
||||||
@@ -46,6 +86,12 @@ class ServiceForm(forms.ModelForm):
|
|||||||
).first()
|
).first()
|
||||||
if collaborator_email_linked is None:
|
if collaborator_email_linked is None:
|
||||||
raise forms.ValidationError(f"Email '{email}' is not registered")
|
raise forms.ValidationError(f"Email '{email}' is not registered")
|
||||||
|
user = collaborator_email_linked.user
|
||||||
|
if user in collaborators:
|
||||||
|
raise forms.ValidationError(
|
||||||
|
f"The emails '{email}' and '{users_to_emails[user]}' both correspond to the same user"
|
||||||
|
)
|
||||||
|
users_to_emails[user] = email
|
||||||
collaborators.append(collaborator_email_linked.user)
|
collaborators.append(collaborator_email_linked.user)
|
||||||
return collaborators
|
return collaborators
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
from datetime import datetime, time
|
from datetime import datetime, time
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
|
|
||||||
class DateRangeMixin:
|
class DateRangeMixin:
|
||||||
def get_start_date(self):
|
def get_start_date(self):
|
||||||
if self.request.GET.get("startDate") != None:
|
if self.request.GET.get("startDate") is not None:
|
||||||
found_time = timezone.datetime.strptime(
|
found_time = timezone.datetime.strptime(
|
||||||
self.request.GET.get("startDate"), "%Y-%m-%d"
|
self.request.GET.get("startDate"), "%Y-%m-%d"
|
||||||
)
|
)
|
||||||
@@ -15,7 +14,7 @@ class DateRangeMixin:
|
|||||||
return timezone.now() - timezone.timedelta(days=30)
|
return timezone.now() - timezone.timedelta(days=30)
|
||||||
|
|
||||||
def get_end_date(self):
|
def get_end_date(self):
|
||||||
if self.request.GET.get("endDate") != None:
|
if self.request.GET.get("endDate") is not None:
|
||||||
found_time = timezone.datetime.strptime(
|
found_time = timezone.datetime.strptime(
|
||||||
self.request.GET.get("endDate"), "%Y-%m-%d"
|
self.request.GET.get("endDate"), "%Y-%m-%d"
|
||||||
)
|
)
|
||||||
@@ -23,8 +22,40 @@ class DateRangeMixin:
|
|||||||
else:
|
else:
|
||||||
return timezone.now()
|
return timezone.now()
|
||||||
|
|
||||||
|
def get_date_ranges(self):
|
||||||
|
now = timezone.now()
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"name": "Last 3 days",
|
||||||
|
"start": now - timezone.timedelta(days=2),
|
||||||
|
"end": now,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Last 30 days",
|
||||||
|
"start": now - timezone.timedelta(days=29),
|
||||||
|
"end": now,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Last 90 days",
|
||||||
|
"start": now - timezone.timedelta(days=89),
|
||||||
|
"end": now,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "This month",
|
||||||
|
"start": now.replace(day=1),
|
||||||
|
"end": now,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "This year",
|
||||||
|
"start": now.replace(day=1, month=1),
|
||||||
|
"end": now,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
def get_context_data(self, **kwargs):
|
def get_context_data(self, **kwargs):
|
||||||
data = super().get_context_data(**kwargs)
|
data = super().get_context_data(**kwargs)
|
||||||
data["start_date"] = self.get_start_date()
|
data["start_date"] = self.get_start_date()
|
||||||
data["end_date"] = self.get_end_date()
|
data["end_date"] = self.get_end_date()
|
||||||
|
data["date_ranges"] = self.get_date_ranges()
|
||||||
|
|
||||||
return data
|
return data
|
||||||
|
|||||||
@@ -13,4 +13,42 @@
|
|||||||
|
|
||||||
.rf {
|
.rf {
|
||||||
text-align: right !important;
|
text-align: right !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-card .apexcharts-svg {
|
||||||
|
border-radius: 0 0 var(--border-radius-lg, 0.5rem) var(--border-radius-lg, 0.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.max-w-0 {
|
||||||
|
max-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.min-w-48 {
|
||||||
|
min-width: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.geo-table {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.geo-card--use-table-view .geo-map {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.geo-card--use-table-view .geo-table {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--color-neutral-000: white;
|
||||||
|
--color-neutral-50: #F8FAFC;
|
||||||
|
--color-neutral-100: #F1F5F9;
|
||||||
|
--color-neutral-200: #E2E8F0;
|
||||||
|
--color-neutral-300: #CBD5E1;
|
||||||
|
--color-neutral-400: #94A3B8;
|
||||||
|
--color-neutral-500: #64748B;
|
||||||
|
--color-neutral-600: #475569;
|
||||||
|
--color-neutral-700: #334155;
|
||||||
|
--color-neutral-800: #1E293B;
|
||||||
|
--color-neutral-900: #0F172A;
|
||||||
}
|
}
|
||||||
BIN
shynet/dashboard/static/dashboard/images/icon.png
Normal file
|
After Width: | Height: | Size: 4.2 KiB |
0
shynet/dashboard/static/dashboard/js/base.js
Normal file
16
shynet/dashboard/tasks.py
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
from celery import shared_task
|
||||||
|
from django.core import mail
|
||||||
|
from django.conf import settings
|
||||||
|
import html2text
|
||||||
|
|
||||||
|
|
||||||
|
@shared_task
|
||||||
|
def send_email(to: [str], subject: str, content: str, from_email: str = None):
|
||||||
|
text_content = html2text.html2text(content)
|
||||||
|
mail.send_mail(
|
||||||
|
subject,
|
||||||
|
text_content,
|
||||||
|
from_email or settings.DEFAULT_FROM_EMAIL,
|
||||||
|
to,
|
||||||
|
html_message=content,
|
||||||
|
)
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div>
|
<div class="flex-1 truncate">
|
||||||
<h4 class="heading">{% block page_title %}{% endblock %}</h4>
|
<h4 class="heading truncate">{% block page_title %}{% endblock %}</h4>
|
||||||
</div>
|
</div>
|
||||||
<hr class="sep">
|
<hr class="sep">
|
||||||
{% block main %}
|
{% block main %}
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
{% load account %}{% user_display user as user_display %}{% load i18n %}{% autoescape off %}{% blocktrans with site_name=current_site.name site_domain=current_site.domain %}Hi there,
|
{% 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,
|
||||||
|
|
||||||
You're receiving this email because {{ user_display }} has listed this email as a valid contact address for their account.
|
You're receiving this email because {{ user_display }} has listed this email as a valid contact address for their account.
|
||||||
|
|
||||||
To confirm this is correct, go to {{ activate_url }}
|
To confirm this is correct, go to {{ activate_url }}
|
||||||
{% endblocktrans %}
|
{% endblocktrans %}
|
||||||
{% blocktrans with site_name=current_site.name site_domain=current_site.domain %}Thank you,
|
{% blocktrans with site_name=current_site.name site_domain=request.get_host %}Thank you,
|
||||||
{{ site_name }}
|
{{ site_name }}
|
||||||
{% endblocktrans %}
|
{% endblocktrans %}
|
||||||
{% endautoescape %}
|
{% endautoescape %}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
{% load i18n %}{% autoescape off %}{% blocktrans with site_name=current_site.name site_domain=current_site.domain %}Hi there,
|
{% load i18n %}{% autoescape off %}{% blocktrans with site_name=current_site.name site_domain=request.get_host %}Hi there,
|
||||||
|
|
||||||
You're receiving this email because you or someone else has requested a password for your account.
|
You're receiving this email because you or someone else has requested a password for your account.
|
||||||
|
|
||||||
@@ -6,7 +6,7 @@ This message can be safely ignored if you did not request a password reset. Clic
|
|||||||
|
|
||||||
{{ password_reset_url }}
|
{{ password_reset_url }}
|
||||||
|
|
||||||
{% blocktrans with site_name=current_site.name site_domain=current_site.domain %}Thank you,
|
{% blocktrans with site_name=current_site.name site_domain=request.get_host %}Thank you,
|
||||||
{{ site_name }}
|
{{ site_name }}
|
||||||
{% endblocktrans %}
|
{% endblocktrans %}
|
||||||
{% endautoescape %}
|
{% endautoescape %}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
{% load i18n a17t_tags %}
|
{% load i18n a17t_tags %}
|
||||||
|
|
||||||
{% block head_title %}{% trans "Change Password" %}{% endblock %}
|
{% block head_title %}{% trans "Change authentication info" %}{% endblock %}
|
||||||
{% block page_title %}{% trans "Change Password" %}{% endblock %}
|
{% block page_title %}{% trans "Change authentication info" %}{% endblock %}
|
||||||
|
|
||||||
{% block card %}
|
{% block card %}
|
||||||
<form method="POST" action="{% url 'account_change_password' %}" class="password_change max-w-lg">
|
<form method="POST" action="{% url 'account_change_password' %}" class="password_change max-w-lg">
|
||||||
@@ -11,4 +11,17 @@
|
|||||||
{{ form|a17t }}
|
{{ form|a17t }}
|
||||||
<button type="submit" name="action" class="button ~urge !high">{% trans "Change Password" %}</button>
|
<button type="submit" name="action" class="button ~urge !high">{% trans "Change Password" %}</button>
|
||||||
</form>
|
</form>
|
||||||
|
<hr class="sep">
|
||||||
|
<div>
|
||||||
|
<p class="label mb-1">Personal API token</p>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class='chip ~info !normal'>{{request.user.api_token}}</span>
|
||||||
|
<form method="POST" action="{% url 'dashboard:api_token_refresh' %}">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" name="action" class="button ~neutral @high">{% trans "Refresh token" %}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<p class="support mt-1">To learn more about the API, see our <a href="https://github.com/milesmcc/shynet/blob/master/GUIDE.md#api">API guide</a>.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -7,41 +7,48 @@
|
|||||||
<title>{% block head_title %}Privacy-oriented analytics{% endblock %} | {{request.site.name}}</title>
|
<title>{% block head_title %}Privacy-oriented analytics{% endblock %} | {{request.site.name}}</title>
|
||||||
<meta name="robots" content="noindex">
|
<meta name="robots" content="noindex">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
{% include 'a17t/head.html' %}
|
{% include 'a17t/includes/head.html' %}
|
||||||
<script src="https://cdn.jsdelivr.net/npm/litepicker@1.2.0/dist/js/main.js"
|
<link rel="icon" type="image/png" href="{% static 'dashboard/images/icon.png' %}">
|
||||||
integrity="sha256-mOlCEHUNWZPYIrc5OFL4Ab2rsJGzIPld3cy1ok7Cfx0=" crossorigin="anonymous"></script>
|
<script src="{% static 'apexcharts/dist/apexcharts.min.js'%}"></script>
|
||||||
<script src="https://cdn.jsdelivr.net/npm/apexcharts@3.18.1/dist/apexcharts.min.js"
|
<script src="{% static 'litepicker/dist/nocss/litepicker.js' %}"></script>
|
||||||
integrity="sha256-RalQXBZdisB04aaBsm+6YZ0b/iRYjX1MZn90m19AnCY=" crossorigin="anonymous"></script>
|
<script src="{% static 'litepicker/dist/plugins/ranges.js' %}"></script>
|
||||||
|
<script src="{% static 'd3/d3.min.js' %}"></script>
|
||||||
|
<script src="{% static 'topojson/build/topojson.min.js' %}"></script>
|
||||||
|
<script src="{% static 'datamaps/dist/datamaps.world.min.js' %}"></script>
|
||||||
|
<script src="{% static 'dashboard/js/base.js' %}"></script>
|
||||||
<link rel="stylesheet" href="{% static 'dashboard/css/global.css' %}">
|
<link rel="stylesheet" href="{% static 'dashboard/css/global.css' %}">
|
||||||
|
<link rel="stylesheet" href="{% static 'flag-icon-css/css/flag-icon.min.css' %}">
|
||||||
|
<link rel="stylesheet" href="{% static 'litepicker/dist/css/litepicker.css' %}">
|
||||||
{% block extra_head %}
|
{% block extra_head %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="bg-gray-200 min-h-full">
|
<body class="bg-neutral-100 min-h-full overflow-x-hidden">
|
||||||
{% block body %}
|
{% block body %}
|
||||||
|
|
||||||
<section class="max-w-screen-xl mx-auto px-4 py-4 md:py-12 md:flex">
|
<section class="max-w-screen-xl mx-auto px-4 py-4 md:py-12 md:flex">
|
||||||
<aside class="mb-8 md:w-2/12 md:pr-6 relative flex flex-wrap md:block justify-between items-center overflow-x-hidden">
|
<aside
|
||||||
|
class="mb-2 md:w-2/12 md:pr-6 relative flex flex-wrap md:block justify-between items-center overflow-x-hidden">
|
||||||
<a class="icon ~urge ml-2 md:ml-6 md:mb-8 md:mt-3" href="{% url 'dashboard:dashboard' %}">
|
<a class="icon ~urge ml-2 md:ml-6 md:mb-8 md:mt-3" href="{% url 'dashboard:dashboard' %}">
|
||||||
<i class="fas fa-binoculars fa-3x text-purple-600 hidden md:block"></i>
|
<i class="fas fa-binoculars fa-3x text-urge-600 hidden md:block"></i>
|
||||||
<i class="fas fa-binoculars fa-2x text-purple-600 md:hidden"></i>
|
<i class="fas fa-binoculars fa-2x text-urge-600 md:hidden"></i>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<button class="button ~neutral !low md:hidden"
|
<a tabindex="0" role="button" class="button ~neutral !low md:hidden"
|
||||||
onclick="document.getElementById('navMenuExpanded').classList.toggle('hidden')">
|
onclick="document.getElementById('navMenuExpanded').classList.toggle('hidden')">
|
||||||
<span class="icon">
|
<span class="icon">
|
||||||
<i class="fas fa-bars"></i>
|
<i class="fas fa-bars"></i>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</a>
|
||||||
<hr class="sep h-4 md:h-8 w-full">
|
<hr class="sep h-4 md:h-8 w-full">
|
||||||
<div id="navMenuExpanded"
|
<div id="navMenuExpanded"
|
||||||
class="bg-white shadow-lg md:shadow-none p-4 hidden rounded-lg md:block md:bg-transparent md:border-none md:p-0 w-full">
|
class="bg-neutral-000 shadow-lg md:shadow-none p-4 hidden rounded-lg md:block md:bg-transparent md:border-none md:p-0 w-full">
|
||||||
{% if user.owning_services.all %}
|
{% if user.owning_services.all %}
|
||||||
<p class="ml-2 mb-1 supra font-medium text-gray-500 pointer-events-none">Services</p>
|
<p class="ml-2 mb-1 supra font-medium text-gray-500 pointer-events-none">Services</p>
|
||||||
|
|
||||||
{% for service in user.owning_services.all %}
|
{% for service in user.owning_services.all %}
|
||||||
{% url 'dashboard:service' service.uuid as url %}
|
{% contextual_url 'dashboard:service' service.uuid as url %}
|
||||||
{% include 'dashboard/includes/sidebar_portal.html' with label=service.name|truncatechars:20 url=url %}
|
{% include 'dashboard/includes/sidebar_portal.html' with label=service.name url=url icon=service.link|iconify %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -59,8 +66,8 @@
|
|||||||
<p class="ml-2 mb-1 supra font-medium text-gray-500 pointer-events-none">Collaborations</p>
|
<p class="ml-2 mb-1 supra font-medium text-gray-500 pointer-events-none">Collaborations</p>
|
||||||
|
|
||||||
{% for service in user.collaborating_services.all %}
|
{% for service in user.collaborating_services.all %}
|
||||||
{% url 'dashboard:service' service.uuid as url %}
|
{% contextual_url 'dashboard:service' service.uuid as url %}
|
||||||
{% include 'dashboard/includes/sidebar_portal.html' with label=service.name|truncatechars:20 url=url %}
|
{% include 'dashboard/includes/sidebar_portal.html' with label=service.name url=url %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
<hr class="sep h-8">
|
<hr class="sep h-8">
|
||||||
@@ -72,7 +79,7 @@
|
|||||||
|
|
||||||
{% if user.is_superuser %}
|
{% if user.is_superuser %}
|
||||||
{% url 'admin:index' as url %}
|
{% url 'admin:index' as url %}
|
||||||
{% include 'dashboard/includes/sidebar_portal.html' with label="Admin" url=url %}
|
{% include 'dashboard/includes/sidebar_portal.html' with label="Admin" disable_turbolinks=True url=url %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% url 'account_email' as url %}
|
{% url 'account_email' as url %}
|
||||||
@@ -120,4 +127,4 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
6
shynet/dashboard/templates/dashboard/includes/bar.html
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{% load helpers %}
|
||||||
|
<div
|
||||||
|
class="absolute h-6 rounded-md"
|
||||||
|
style="width: {% bar_width count max total %}; top: 6px; left: 0px; height: calc(100% - 12px); background-color: var(--color-urge-100-fallback)"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
@@ -2,32 +2,48 @@
|
|||||||
<input type="hidden" name="startDate" value="{{start_date.isoformat}}" id="startDate">
|
<input type="hidden" name="startDate" value="{{start_date.isoformat}}" id="startDate">
|
||||||
<input type="hidden" name="endDate" value="{{end_date.isoformat}}" id="endDate">
|
<input type="hidden" name="endDate" value="{{end_date.isoformat}}" id="endDate">
|
||||||
</form>
|
</form>
|
||||||
<input type="input" id="rangePicker" placeholder="Date range" class="input ~neutral cursor-pointer" readonly>
|
<input type="input" id="rangePicker" placeholder="Date range" class="input ~neutral !low bg-neutral-000 cursor-pointer" style="max-width: 200px;" readonly>
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--litepickerMonthButtonHover: var(--color-urge);
|
--litepicker-button-prev-month-color-hover: var(--color-urge);
|
||||||
--litepickerDayColorHover: var(--color-urge);
|
--litepicker-button-next-month-color-hover: var(--color-urge);
|
||||||
--litepickerDayIsTodayColor: var(--color-urge);
|
--litepicker-day-color-hover: var(--color-urge);
|
||||||
--litepickerDayIsInRange: var(--color-urge-normal-fill);
|
--litepicker-is-today-color: var(--color-urge);
|
||||||
--litepickerDayIsStartBg: var(--color-urge);
|
--litepicker-is-in-range-color: var(--color-urge-normal-fill);
|
||||||
--litepickerDayIsEndBg: var(--color-urge);
|
--litepicker-is-start-color-bg: var(--color-urge);
|
||||||
--litepickerButtonApplyBg: var(--color-urge);
|
--litepicker-is-end-color-bg: var(--color-urge);
|
||||||
|
--litepicker-button-apply-color-bg: var(--color-urge);
|
||||||
|
}
|
||||||
|
|
||||||
|
.litepicker .container__predefined-ranges, .litepicker .container__months {
|
||||||
|
box-shadow: var(--fallback-box-shadow-normal) !important;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
<script>
|
<script>
|
||||||
var picker = new Litepicker({
|
var picker = new Litepicker({
|
||||||
element: document.getElementById('rangePicker'),
|
element: document.getElementById('rangePicker'),
|
||||||
|
plugins: ['ranges'],
|
||||||
singleMode: false,
|
singleMode: false,
|
||||||
format: 'MMM D, YYYY',
|
format: "MMM DD 'YY",
|
||||||
maxDate: new Date(),
|
maxDate: new Date(),
|
||||||
startDate: Date.parse(document.getElementById("startDate").getAttribute("value")),
|
startDate: Date.parse(document.getElementById("startDate").getAttribute("value")),
|
||||||
endDate: Date.parse(document.getElementById("endDate").getAttribute("value")),
|
endDate: Date.parse(document.getElementById("endDate").getAttribute("value")),
|
||||||
onSelect: function (startDate, endDate) {
|
ranges: {
|
||||||
document.getElementById("startDate").setAttribute("value", startDate.getFullYear() +
|
customRanges: {
|
||||||
"-" + (startDate.getMonth() + 1) + "-" + startDate.getDate());
|
{% for date_range in date_ranges %}
|
||||||
document.getElementById("endDate").setAttribute("value", endDate.getFullYear() + "-" +
|
'{{ date_range.name }}': [
|
||||||
(endDate.getMonth() + 1) + "-" + endDate.getDate());
|
new Date('{{ date_range.start.isoformat }}'),
|
||||||
document.getElementById("datePicker").submit();
|
new Date('{{ date_range.end.isoformat }}')
|
||||||
|
],
|
||||||
|
{% endfor %}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
picker.on('selected', (startDate, endDate) => {
|
||||||
|
document.getElementById("startDate").setAttribute("value", startDate.getFullYear() +
|
||||||
|
"-" + (startDate.getMonth() + 1) + "-" + startDate.getDate());
|
||||||
|
document.getElementById("endDate").setAttribute("value", endDate.getFullYear() + "-" +
|
||||||
|
(endDate.getMonth() + 1) + "-" + endDate.getDate());
|
||||||
|
document.getElementById("datePicker").submit();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|||||||
66
shynet/dashboard/templates/dashboard/includes/map_chart.html
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
{% load helpers %}
|
||||||
|
|
||||||
|
<div id="map-chart" class="relative"></div>
|
||||||
|
<script>
|
||||||
|
// Colors
|
||||||
|
const lightBlue = "#C4B5FD";
|
||||||
|
const highlightBlue = "#8B5CF6";
|
||||||
|
const white = "#ffffff";
|
||||||
|
|
||||||
|
// Data maps
|
||||||
|
const countryMapData = {};
|
||||||
|
const countryMapColors = {};
|
||||||
|
const countryMap = {
|
||||||
|
{% for country in countries %}"{{country.country|safe|datamap_id}}": {{country.count}},
|
||||||
|
{% endfor %}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Max session count will be full opacity
|
||||||
|
const maxSessionCount = Math.max(...Object.values(countryMap));
|
||||||
|
|
||||||
|
// Color scale starts from opacity 0.1 - 1.0, 0 sessions gets opacity 0
|
||||||
|
const minPercentage = 0.1
|
||||||
|
|
||||||
|
// Loop over country map and transform data for Datamaps use
|
||||||
|
const keys = Object.keys(countryMap);
|
||||||
|
const length = keys.length;
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
countryMapData[keys[i]] = {
|
||||||
|
sessionCount: countryMap[keys[i]],
|
||||||
|
color: `rgba(124, 58, 237, ${countryMap[keys[i]] === 0 ? 0 : minPercentage + (countryMap[keys[i]] / maxSessionCount * (1 - minPercentage))})`
|
||||||
|
};
|
||||||
|
countryMapColors[keys[i]] = countryMapData[keys[i]].color;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create datamap
|
||||||
|
var geoMap = new Datamap({
|
||||||
|
element: document.getElementById('map-chart'),
|
||||||
|
projection: 'mercator',
|
||||||
|
responsive: true,
|
||||||
|
geographyConfig: {
|
||||||
|
borderColor: lightBlue,
|
||||||
|
highlightBorderColor: highlightBlue,
|
||||||
|
highlightBorderWidth: 1.5,
|
||||||
|
highlightFillColor: (geography) => geography.color || white,
|
||||||
|
highlightFillOpacity: 0.9,
|
||||||
|
popupTemplate: (geography, data) => '<div class="hoverinfo"><strong>' + geography.properties.name + '</strong>: ' + data.sessionCount + ' sessions</div>'
|
||||||
|
|
||||||
|
},
|
||||||
|
fills: {
|
||||||
|
defaultFill: white
|
||||||
|
},
|
||||||
|
data: countryMapData,
|
||||||
|
aspectRatio: 0.68
|
||||||
|
});
|
||||||
|
geoMap.updateChoropleth(countryMapColors);
|
||||||
|
|
||||||
|
let debounceTimeout
|
||||||
|
const debounce = (func, debounce) => {
|
||||||
|
return function(event){
|
||||||
|
if(debounceTimeout) clearTimeout(debounceTimeout);
|
||||||
|
debounceTimeout = setTimeout(func,debounce,event);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("resize",debounce(() => geoMap.resize(), 100))
|
||||||
|
</script>
|
||||||
@@ -4,10 +4,14 @@
|
|||||||
{{form.link|a17t}}
|
{{form.link|a17t}}
|
||||||
{{form.collaborators|a17t}}
|
{{form.collaborators|a17t}}
|
||||||
|
|
||||||
<details class="p-4 border rounded">
|
<details {% if form.errors %}open{% endif %}>
|
||||||
<summary class="cursor-pointer text-sm">Advanced settings</summary>
|
<summary class="cursor-pointer text-sm">Advanced settings</summary>
|
||||||
<hr class="sep h-4">
|
<hr class="sep h-4">
|
||||||
{{form.respect_dnt|a17t}}
|
{{form.respect_dnt|a17t}}
|
||||||
{{form.collect_ips|a17t}}
|
{{form.collect_ips|a17t}}
|
||||||
|
{{form.ignored_ips|a17t}}
|
||||||
|
{{form.ignore_robots|a17t}}
|
||||||
|
{{form.hide_referrer_regex|a17t}}
|
||||||
{{form.origins|a17t}}
|
{{form.origins|a17t}}
|
||||||
|
{{form.script_inject|a17t}}
|
||||||
</details>
|
</details>
|
||||||
@@ -1,15 +1,16 @@
|
|||||||
{% load humanize helpers %}
|
{% load humanize helpers %}
|
||||||
|
|
||||||
<a class="card ~neutral !low service mb-6 p-0" href="{% url 'dashboard:service' object.uuid %}">
|
<a class="card chart-card overflow-visible ~neutral !low service mb-6 p-0" href="{% contextual_url 'dashboard:service' object.uuid %}">
|
||||||
{% with stats=object.stats %}
|
{% with stats=object.stats %}
|
||||||
<div class="p-4 md:flex justify-between">
|
<div class="p-4 md:flex justify-between overflow-none">
|
||||||
<div class="flex items-center mb-4 md:mb-0">
|
<div class="flex items-center mb-4 md:mb-0 md:flex-1 md:min-w-0 truncate pr-0 md:pr-2">
|
||||||
<h3 class="heading text-xl md:text-2xl mr-2 mb-1 text-purple-600">
|
<h3 class="heading text-xl md:text-2xl mr-2 mb-1 text-urge-600 flex items-center truncate" title="{{object.name}}">
|
||||||
{{object.name}}
|
{{object.link|iconify}}
|
||||||
|
<span class="truncate">{{object.name}}</span>
|
||||||
</h3>
|
</h3>
|
||||||
{% include 'dashboard/includes/stats_status_chip.html' %}
|
{% include 'dashboard/includes/stats_status_chip.html' %}
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-6">
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-6 md:gap-3 lg:gap-6 md:flex-none">
|
||||||
<div>
|
<div>
|
||||||
<p>Sessions</p>
|
<p>Sessions</p>
|
||||||
<p class="label">
|
<p class="label">
|
||||||
@@ -50,7 +51,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<hr class="sep h-4">
|
<hr class="sep h-4">
|
||||||
<div style="bottom: -1px;">
|
<div style="bottom: -1px;">
|
||||||
{% include 'dashboard/includes/time_chart.html' with data=stats.session_chart_data sparkline=True height=100 name=object.uuid %}
|
{% 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 %}
|
||||||
</div>
|
</div>
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<div class="card ~neutral !high font-mono text-sm whitespace-pre-wrap break-all">{% filter force_escape %}<noscript>
|
||||||
|
<img src="{{script_protocol}}{{request.get_host}}{% url 'ingress:endpoint_pixel' object.uuid %}">
|
||||||
|
</noscript>
|
||||||
|
<script defer src="{{script_protocol}}{{request.get_host}}{% url 'ingress:endpoint_script' object.uuid %}"></script>{% endfilter %}
|
||||||
|
</div>
|
||||||
@@ -12,8 +12,8 @@
|
|||||||
{% for session in object_list %}
|
{% for session in object_list %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
<a href="{% url 'dashboard:service_session' object.pk session.pk %}"
|
<a href="{% contextual_url 'dashboard:service_session' object.pk session.pk %}"
|
||||||
class="font-medium text-purple-700">
|
class="font-medium text-urge-700">
|
||||||
{{session.start_time|date:"M j Y, g:i a"|capfirst}}
|
{{session.start_time|date:"M j Y, g:i a"|capfirst}}
|
||||||
{% if session.is_currently_active %}
|
{% if session.is_currently_active %}
|
||||||
<span class="badge ~positive">Online</span>
|
<span class="badge ~positive">Online</span>
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
<span class="text-gray-600">—</span>
|
<span class="text-gray-600">—</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>{{session.country|flag_emoji}} {{session.asn|default:"Unknown"}}</td>
|
<td><span class="{{session.country|flag_class}}"></span>{{session.asn|default:"Unknown"}}</td>
|
||||||
<td class="rf">{{session.duration|naturaldelta}}</td>
|
<td class="rf">{{session.duration|naturaldelta}}</td>
|
||||||
<td class="rf">{{session.hit_set.count|intcomma}}</td>
|
<td class="rf">{{session.hit_set.count|intcomma}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{% load helpers %}
|
{% load helpers %}
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<a class="portal !low {% if request.get_full_path|startswith:url %}~urge active bg-gray-100{% endif %}"
|
<a class="portal !low {% if request.get_full_path|startswith:url %}~urge active bg-neutral-100{% endif %} flex items-center" title="{{label}}"
|
||||||
href="{{url}}">{{label}}</a>
|
{% if disable_turbolinks %}data-turbolinks="false"{% endif %} href="{{url}}">{{icon}} <span class="truncate">{{label}}</span></a>
|
||||||
</div>
|
</div>
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
{% with stats=object.get_daily_stats %}
|
{% with stats=object.get_daily_stats %}
|
||||||
{% if stats.currently_online > 0 %}
|
{% if stats.currently_online > 0 %}
|
||||||
<span class="chip ~positive !high">
|
<span class="chip ~positive !high whitespace-nowrap">
|
||||||
{{stats.currently_online|intcomma}} online
|
{{stats.currently_online|intcomma}} online
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -5,9 +5,14 @@
|
|||||||
enabled: false
|
enabled: false
|
||||||
},
|
},
|
||||||
tooltip: {
|
tooltip: {
|
||||||
shared: false,
|
shared: true,
|
||||||
|
x: {
|
||||||
|
format: '{{tooltip_format|default:"MMM d"}}',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
show: false,
|
||||||
},
|
},
|
||||||
colors: ["#805AD5"],
|
|
||||||
chart: {
|
chart: {
|
||||||
zoom: {
|
zoom: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
@@ -15,7 +20,7 @@
|
|||||||
toolbar: {
|
toolbar: {
|
||||||
show: false,
|
show: false,
|
||||||
},
|
},
|
||||||
type: 'area',
|
type: 'line',
|
||||||
height: {{height|default:"200"}},
|
height: {{height|default:"200"}},
|
||||||
offsetY: -1,
|
offsetY: -1,
|
||||||
animations: {
|
animations: {
|
||||||
@@ -24,16 +29,14 @@
|
|||||||
sparkline: {
|
sparkline: {
|
||||||
enabled: {% if sparkline %}true{% else %}false{% endif %},
|
enabled: {% if sparkline %}true{% else %}false{% endif %},
|
||||||
},
|
},
|
||||||
fill: {
|
{% if granularity == "daily" and click_zoom %}
|
||||||
type: 'gradient',
|
events: {
|
||||||
gradient: {
|
markerClick: function(event, chartContext, { seriesIndex, dataPointIndex, w: {config}}) {
|
||||||
shadeIntensity: 1,
|
const day = config.labels[dataPointIndex]
|
||||||
inverseColors: false,
|
window.location.href = `?startDate=${day}&endDate=${day}`
|
||||||
opacityFrom: 0.8,
|
|
||||||
opacityTo: 0,
|
|
||||||
stops: [0, 75, 100]
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{% endif %}
|
||||||
},
|
},
|
||||||
grid: {
|
grid: {
|
||||||
padding: {
|
padding: {
|
||||||
@@ -63,15 +66,27 @@
|
|||||||
},
|
},
|
||||||
xaxis: {
|
xaxis: {
|
||||||
type: "datetime",
|
type: "datetime",
|
||||||
|
labels: {
|
||||||
|
datetimeUTC: false
|
||||||
|
},
|
||||||
},
|
},
|
||||||
stroke: {
|
stroke: {
|
||||||
width: 1.5,
|
width: 2,
|
||||||
|
curve: 'smooth',
|
||||||
},
|
},
|
||||||
series: [{
|
series: [{
|
||||||
name: "{{unit|default:'Sessions'}}",
|
name: "Hits",
|
||||||
data: {{data|safe}}
|
type: 'area',
|
||||||
}]
|
color: "#ddd6fe",
|
||||||
|
data: {{data.hits|safe}}
|
||||||
|
}, {
|
||||||
|
name: "Sessions",
|
||||||
|
type: 'line',
|
||||||
|
color: "#805AD5",
|
||||||
|
data: {{data.sessions|safe}}
|
||||||
|
}],
|
||||||
|
labels: {{data.labels|safe}}
|
||||||
};
|
};
|
||||||
var triggerMatchesChart = new ApexCharts(document.querySelector("#chart{{name|default:'Main'}}"), triggerMatchesChartOptions);
|
var triggerMatchesChart = new ApexCharts(document.querySelector("#chart{{name|default:'Main'}}"), triggerMatchesChartOptions);
|
||||||
triggerMatchesChart.render();
|
triggerMatchesChart.render();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
{% extends "base.html" %}
|
{% extends "base.html" %}
|
||||||
|
|
||||||
{% load rules %}
|
{% load rules pagination %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="md:flex justify-between items-center">
|
<div class="md:flex justify-between items-center">
|
||||||
<div>
|
<div class="flex-1 truncate display-none md:display-block mr-4 md:mb-0 mb-4" title="{{request.site.name|default:"Dashboard"}}">
|
||||||
<h4 class="heading">{{request.site.name|default:"Dashboard"}}</h4>
|
<h4 class="heading truncate">{{request.site.name|default:"Dashboard"}}</h4>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center">
|
||||||
<div class="mr-1">
|
<div class="mr-1">
|
||||||
@@ -13,14 +13,19 @@
|
|||||||
</div>
|
</div>
|
||||||
{% has_perm "core.create_service" user as can_create %}
|
{% has_perm "core.create_service" user as can_create %}
|
||||||
{% if can_create %}
|
{% if can_create %}
|
||||||
<a href="{% url 'dashboard:service_create' %}" class="button field w-auto">+ New Service</a>
|
<a href="{% url 'dashboard:service_create' %}" class="button field !low bg-neutral-000 w-auto">+ New Service</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<hr class="sep">
|
<hr class="sep h-8 md:h-12">
|
||||||
{% for object in services|dictsortreversed:"stats.session_count" %}
|
{% for object in object_list|dictsortreversed:"stats.session_count" %}
|
||||||
{% include 'dashboard/includes/service_overview.html' %}
|
{% include 'dashboard/includes/service_overview.html' %}
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<p>You don't have any services on {{request.site.name|default:"Shynet"}} yet.</p>
|
<p class="aside ~urge !high">You don't have any services yet. {% if can_create %}Get started by <a href="{% url 'dashboard:service_create' %}" class="underline">creating one</a>.{% endif %}</p>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
|
{% if object_list %}
|
||||||
|
{% pagination page_obj request %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -6,29 +6,37 @@
|
|||||||
<div class="mr-2">{% include 'dashboard/includes/date_range.html' %}</div>
|
<div class="mr-2">{% include 'dashboard/includes/date_range.html' %}</div>
|
||||||
{% has_perm 'core.change_service' user object as can_update %}
|
{% has_perm 'core.change_service' user object as can_update %}
|
||||||
{% if can_update %}
|
{% if can_update %}
|
||||||
<a href="{% url 'dashboard:service_update' service.uuid %}" class="button field ~neutral w-auto">Manage →</a>
|
<a href="{% contextual_url 'dashboard:service_update' service.uuid %}" class="button field !low bg-neutral-000 w-auto">Manage →</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block service_content %}
|
{% block service_content %}
|
||||||
|
{% if not stats.has_hits %}
|
||||||
|
<div class="content mb-6">
|
||||||
|
<p>
|
||||||
|
This service hasn't collected any data yet. To get started, place the following code snippet at the end of the <code><body></code> tag on any page you'd like to track.
|
||||||
|
</p>
|
||||||
|
{% include 'dashboard/includes/service_snippet.html' %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
<div class="grid grid-cols-2 gap-6 md:flex justify-between mb-6 card ~neutral !high px-6" id="stats">
|
<div class="grid grid-cols-2 gap-6 md:flex justify-between mb-6 card ~neutral !high px-6" id="stats">
|
||||||
{% with classes="text-sm font-semibold" good_classes="text-green-400" bad_classes="text-red-400" neutral_classes="text-gray-400" %}
|
{% with classes="text-sm font-semibold" good_classes="text-positive-400" bad_classes="text-critical-400" neutral_classes="text-gray-400" %}
|
||||||
<article class="">
|
<article class="">
|
||||||
<p class="label text-gray-400">Sessions</p>
|
<p class="label text-gray-400">Sessions</p>
|
||||||
<p class="heading">
|
<p class="heading">
|
||||||
{{stats.session_count|intcomma}}
|
{{stats.session_count|intcomma}}
|
||||||
<div>
|
<div>
|
||||||
{% compare stats.compare.session_count stats.session_count "UP" classes=classes good_classes=good_classes bad_classes=bad_classes neutral_classes=neutral_classes %}
|
{% compare stats.compare.session_count stats.session_count "UP" classes=classes good_classes=good_classes bad_classes=bad_classes neutral_classes=neutral_classes %}
|
||||||
</div>
|
</div>
|
||||||
</p>
|
</p>
|
||||||
</article>
|
</article>
|
||||||
<article class="">
|
<article class="">
|
||||||
<p class="label text-gray-400">Hits</p>
|
<p class="label text-gray-400">Hits</p>
|
||||||
<p class="heading">
|
<p class="heading">
|
||||||
{{stats.hit_count|intcomma}}
|
{{stats.hit_count|intcomma}}
|
||||||
<div>
|
<div>
|
||||||
{% compare stats.compare.hit_count stats.hit_count "UP" classes=classes good_classes=good_classes bad_classes=bad_classes neutral_classes=neutral_classes %}
|
{% compare stats.compare.hit_count stats.hit_count "UP" classes=classes good_classes=good_classes bad_classes=bad_classes neutral_classes=neutral_classes %}
|
||||||
</div>
|
</div>
|
||||||
</p>
|
</p>
|
||||||
</article>
|
</article>
|
||||||
<article class="">
|
<article class="">
|
||||||
@@ -39,9 +47,9 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
?
|
?
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div>
|
<div>
|
||||||
{% compare stats.compare.avg_load_time stats.avg_load_time "DOWN" classes=classes good_classes=good_classes bad_classes=bad_classes neutral_classes=neutral_classes %}
|
{% compare stats.compare.avg_load_time stats.avg_load_time "DOWN" classes=classes good_classes=good_classes bad_classes=bad_classes neutral_classes=neutral_classes %}
|
||||||
</div>
|
</div>
|
||||||
</p>
|
</p>
|
||||||
</article>
|
</article>
|
||||||
<article class="">
|
<article class="">
|
||||||
@@ -52,9 +60,9 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
?
|
?
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div>
|
<div>
|
||||||
{% compare stats.compare.bounce_rate_pct stats.bounce_rate_pct "DOWN" classes=classes good_classes=good_classes bad_classes=bad_classes neutral_classes=neutral_classes %}
|
{% compare stats.compare.bounce_rate_pct stats.bounce_rate_pct "DOWN" classes=classes good_classes=good_classes bad_classes=bad_classes neutral_classes=neutral_classes %}
|
||||||
</div>
|
</div>
|
||||||
</p>
|
</p>
|
||||||
</article>
|
</article>
|
||||||
<article class="">
|
<article class="">
|
||||||
@@ -65,9 +73,9 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
?
|
?
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div>
|
<div>
|
||||||
{% compare stats.compare.avg_session_duration stats.avg_session_duration "UP" classes=classes good_classes=good_classes bad_classes=bad_classes neutral_classes=neutral_classes %}
|
{% compare stats.compare.avg_session_duration stats.avg_session_duration "UP" classes=classes good_classes=good_classes bad_classes=bad_classes neutral_classes=neutral_classes %}
|
||||||
</div>
|
</div>
|
||||||
</p>
|
</p>
|
||||||
</article>
|
</article>
|
||||||
<article class="">
|
<article class="">
|
||||||
@@ -78,17 +86,18 @@
|
|||||||
{% else %}
|
{% else %}
|
||||||
?
|
?
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div>
|
<div>
|
||||||
{% compare stats.compare.avg_hits_per_session stats.avg_hits_per_session "UP" classes=classes good_classes=good_classes bad_classes=bad_classes neutral_classes=neutral_classes %}
|
{% compare stats.compare.avg_hits_per_session stats.avg_hits_per_session "UP" classes=classes good_classes=good_classes bad_classes=bad_classes neutral_classes=neutral_classes %}
|
||||||
</div>
|
</div>
|
||||||
</p>
|
</p>
|
||||||
</article>
|
</article>
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
</div>
|
</div>
|
||||||
<div class="card ~neutral !low py-0 mb-6">
|
<div class="card overflow-visible ~neutral !low py-0 mb-6">
|
||||||
{% include 'dashboard/includes/time_chart.html' with data=stats.session_chart_data %}
|
{% include 'dashboard/includes/time_chart.html' with data=stats.chart_data tooltip_format=stats.chart_tooltip_format granularity=stats.chart_granularity click_zoom=True %}
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
{% endif %}
|
||||||
|
<div id="card-grid" class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||||
<div class="card ~neutral !low limited-height py-2">
|
<div class="card ~neutral !low limited-height py-2">
|
||||||
<table class="table">
|
<table class="table">
|
||||||
<thead class="text-sm">
|
<thead class="text-sm">
|
||||||
@@ -100,8 +109,68 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% for location in stats.locations %}
|
{% for location in stats.locations %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{location.location|default:"Unknown"|urldisplay}}</td>
|
<td class="truncate w-full max-w-0 relative">
|
||||||
<td class="rf">{{location.count|intcomma}}</td>
|
{% include 'dashboard/includes/bar.html' with count=location.count max=stats.locations.0.count total=stats.hit_count %}
|
||||||
|
<div class="relative flex items-center">
|
||||||
|
{{location.location|default:"Unknown"|urldisplay}}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="flex justify-end items-center">
|
||||||
|
{{location.count|intcomma}}
|
||||||
|
<span class="text-xs rf min-w-48">
|
||||||
|
({{location.count|percent:stats.hit_count}})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% empty %}
|
||||||
|
<tr>
|
||||||
|
<td><span class="text-gray-600">No data yet...</span></td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="geo-map card ~neutral !low py-2 overflow-y-hidden">
|
||||||
|
<p class="text-sm font-semibold p-2 border-b mb-2" style="color: var(--color-title)">
|
||||||
|
Sessions by Geography  
|
||||||
|
<button onclick="document.getElementById('card-grid').classList.add('geo-card--use-table-view')" class="text-xs select-none p-0 button ~urge !low">
|
||||||
|
(view table)
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
{% include 'dashboard/includes/map_chart.html' with countries=stats.countries %}
|
||||||
|
</div>
|
||||||
|
<div class="geo-table card ~neutral !low limited-height py-2">
|
||||||
|
<table class="table">
|
||||||
|
<thead class="text-sm">
|
||||||
|
<tr>
|
||||||
|
<th>
|
||||||
|
Country  
|
||||||
|
<button onclick="document.getElementById('card-grid').classList.remove('geo-card--use-table-view'); geoMap.resize()" class="text-xs select-none p-0 button ~urge !low">
|
||||||
|
(view map)
|
||||||
|
</button>
|
||||||
|
</th>
|
||||||
|
<th class="rf">Sessions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for country in stats.countries %}
|
||||||
|
<tr>
|
||||||
|
<td class="truncate w-full max-w-0 relative" title="{{country.country|country_name}}">
|
||||||
|
{% include 'dashboard/includes/bar.html' with count=country.count max=stats.countries.0.count total=stats.session_count %}
|
||||||
|
<div class="relative flex items-center">
|
||||||
|
<span class="flex-none {{country.country|flag_class}}"></span> <span class="truncate">{{country.country|country_name}}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="flex justify-end items-center">
|
||||||
|
{{country.count|intcomma}}
|
||||||
|
<span class="text-xs rf min-w-48">
|
||||||
|
({{country.count|percent:stats.session_count}})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<tr>
|
<tr>
|
||||||
@@ -122,30 +191,20 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% for referrer in stats.referrers %}
|
{% for referrer in stats.referrers %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{referrer.referrer|default:"Direct"|urldisplay}}</td>
|
<td class="truncate w-full max-w-0 relative">
|
||||||
<td class="rf">{{referrer.count|intcomma}}</td>
|
{% include 'dashboard/includes/bar.html' with count=referrer.count max=stats.referrers.0.count total=stats.session_count %}
|
||||||
</tr>
|
<div class="relative flex items-center">
|
||||||
{% empty %}
|
{{referrer.referrer|default:"Direct"|urldisplay}}
|
||||||
<tr>
|
</div>
|
||||||
<td><span class="text-gray-600">No data yet...</span></td>
|
</td>
|
||||||
</tr>
|
<td>
|
||||||
{% endfor %}
|
<div class="flex justify-end items-center">
|
||||||
</tbody>
|
{{referrer.count|intcomma}}
|
||||||
</table>
|
<span class="text-xs rf min-w-48">
|
||||||
</div>
|
({{referrer.count|percent:stats.session_count}})
|
||||||
<div class="card ~neutral !low limited-height py-2">
|
</span>
|
||||||
<table class="table">
|
</div>
|
||||||
<thead class="text-sm">
|
</td>
|
||||||
<tr>
|
|
||||||
<th>Country</th>
|
|
||||||
<th class="rf">Sessions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{% for country in stats.countries %}
|
|
||||||
<tr>
|
|
||||||
<td>{{country.country|flag_emoji}} {{country.country|country_name}}</td>
|
|
||||||
<td class="rf">{{country.count|intcomma}}</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<tr>
|
<tr>
|
||||||
@@ -166,8 +225,20 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% for os in stats.operating_systems %}
|
{% for os in stats.operating_systems %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{os.os|default:"Unknown"}}</td>
|
<td class="flex items-center truncate w-full max-w-0 relative" title="{{os.os|default:'Unknown'}}">
|
||||||
<td class="rf">{{os.count|intcomma}}</td>
|
{% include 'dashboard/includes/bar.html' with count=os.count max=stats.operating_systems.0.count total=stats.session_count %}
|
||||||
|
<div class="relative flex items-center">
|
||||||
|
{{os.os|iconify}}<span class="truncate">{{os.os|default:"Unknown"}}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="flex justify-end items-center">
|
||||||
|
{{os.count|intcomma}}
|
||||||
|
<span class="text-xs rf min-w-48">
|
||||||
|
({{os.count|percent:stats.session_count}})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<tr>
|
<tr>
|
||||||
@@ -188,8 +259,21 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% for browser in stats.browsers %}
|
{% for browser in stats.browsers %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{browser.browser|default:"Unknown"}}</td>
|
<td class="flex items-center truncate w-full max-w-0 relative" title="{{browser.browser|default:'Unknown'}}">
|
||||||
<td class="rf">{{browser.count|intcomma}}</td>
|
{% include 'dashboard/includes/bar.html' with count=browser.count max=stats.browsers.0.count total=stats.session_count %}
|
||||||
|
</div>
|
||||||
|
<div class="relative flex items-center">
|
||||||
|
{{browser.browser|iconify}}<span class="truncate">{{browser.browser|default:"Unknown"}}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="flex justify-end items-center">
|
||||||
|
{{browser.count|intcomma}}
|
||||||
|
<span class="text-xs rf min-w-48">
|
||||||
|
({{browser.count|percent:stats.session_count}})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<tr>
|
<tr>
|
||||||
@@ -210,8 +294,20 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% for device_type in stats.device_types %}
|
{% for device_type in stats.device_types %}
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{device_type.device_type|default:"Unknown"|title}}</td>
|
<td class="truncate w-full max-w-0 relative">
|
||||||
<td class="rf">{{device_type.count|intcomma}}</td>
|
{% include 'dashboard/includes/bar.html' with count=device_type.count max=stats.device_types.0.count total=stats.session_count %}
|
||||||
|
<div class="relative flex items-center">
|
||||||
|
<span class="truncate">{{device_type.device_type|default:"Unknown"|title}}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div class="flex justify-end items-center">
|
||||||
|
{{device_type.count|intcomma}}
|
||||||
|
<span class="text-xs rf min-w-48">
|
||||||
|
({{device_type.count|percent:stats.session_count}})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% empty %}
|
{% empty %}
|
||||||
<tr>
|
<tr>
|
||||||
@@ -222,10 +318,11 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card ~neutral !low limited-height py-2">
|
<div class="card ~neutral !low py-2 overflow-auto">
|
||||||
{% include 'dashboard/includes/session_list.html' %}
|
{% include 'dashboard/includes/session_list.html' %}
|
||||||
<hr class="sep h-8">
|
<hr class="sep h-8 md:h-12">
|
||||||
<a href="{% url 'dashboard:service_session_list' service.uuid %}" class="button ~neutral w-auto mb-2">View more sessions
|
<a href="{% contextual_url 'dashboard:service_session_list' service.uuid %}" class="button ~neutral w-auto mb-2">View more
|
||||||
|
sessions
|
||||||
→</a>
|
→</a>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
{% block head_title %}{{object.name}} Session{% endblock %}
|
{% block head_title %}{{object.name}} Session{% endblock %}
|
||||||
|
|
||||||
{% block service_actions %}
|
{% block service_actions %}
|
||||||
<a href="{% url 'dashboard:service' object.uuid %}" class="button field ~neutral w-auto">Analytics →</a>
|
<a href="{% contextual_url 'dashboard:service' object.uuid %}" class="button field !low bg-neutral-000 w-auto">Analytics →</a>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block service_content %}
|
{% block service_content %}
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
{{session.last_seen|date:"g:i a"}}{% if session.is_currently_active %} <span class="chip ~positive !high text-base">Online</span>{% endif %}</p>
|
{{session.last_seen|date:"g:i a"}}{% if session.is_currently_active %} <span class="chip ~positive !high text-base">Online</span>{% endif %}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<hr class="sep h-8">
|
<hr class="sep h-8 md:h-12">
|
||||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-gray-400 font-medium">
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-gray-400 font-medium">
|
||||||
<div>
|
<div>
|
||||||
<p>Browser</p>
|
<p>Browser</p>
|
||||||
@@ -45,13 +45,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p>Country</p>
|
<p>Country</p>
|
||||||
<p class="label">{{session.country|flag_emoji}} {{session.country|country_name}}</p>
|
<p class="label"><span class="{{session.country|flag_class}}"></span>{{session.country|country_name}}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p>Location</p>
|
<p>Location</p>
|
||||||
<p class="label">
|
<p class="label">
|
||||||
{% if session.latitude %}
|
{% if session.latitude %}
|
||||||
<a href="https://www.google.com/maps/search/?api=1&query={{session.latitude}},{{session.longitude}}">Open
|
<a href="{{session|location_url}}" target="_blank">Open
|
||||||
in Maps ↗</a>
|
in Maps ↗</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
Unknown
|
Unknown
|
||||||
@@ -72,7 +72,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="md:flex card ~neutral !low flex-grow justify-between">
|
<div class="md:flex card ~neutral !low flex-grow justify-between">
|
||||||
<div class="mb-4 md:mb-0 md:w-1/2">
|
<div class="mb-4 md:mb-0 md:w-1/2">
|
||||||
<p class="label font-medium text-lg">{{hit.location|default:"Unknown"|urlize}}</p>
|
<p class="label font-medium text-lg truncate">{{hit.location|default:"Unknown"|urlize}}</p>
|
||||||
{% if hit.referrer %}
|
{% if hit.referrer %}
|
||||||
<p>via {{hit.referrer|urlize}}<p>
|
<p>via {{hit.referrer|urlize}}<p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -6,11 +6,11 @@
|
|||||||
|
|
||||||
{% block service_actions %}
|
{% block service_actions %}
|
||||||
<div class="mr-2">{% include 'dashboard/includes/date_range.html' %}</div>
|
<div class="mr-2">{% include 'dashboard/includes/date_range.html' %}</div>
|
||||||
<a href="{% url 'dashboard:service' object.uuid %}" class="button field ~neutral w-auto">Analytics →</a>
|
<a href="{% contextual_url 'dashboard:service' object.uuid %}" class="button field ~neutral !low bg-neutral-000 w-auto">Analytics →</a>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block service_content %}
|
{% block service_content %}
|
||||||
<div class="card ~neutral !low mb-8 pt-2 max-w-full overflow-x-scroll">
|
<div class="card ~neutral !low mb-8 pt-2 max-w-full overflow-x-auto">
|
||||||
{% include 'dashboard/includes/session_list.html' %}
|
{% include 'dashboard/includes/session_list.html' %}
|
||||||
</div>
|
</div>
|
||||||
{% pagination page_obj request %}
|
{% pagination page_obj request %}
|
||||||
|
|||||||
@@ -5,19 +5,14 @@
|
|||||||
{% block head_title %}{{object.name}} Management{% endblock %}
|
{% block head_title %}{{object.name}} Management{% endblock %}
|
||||||
|
|
||||||
{% block service_actions %}
|
{% block service_actions %}
|
||||||
<a href="{% url 'dashboard:service' object.uuid %}" class="button field ~neutral w-auto">View →</a>
|
<a href="{% url 'dashboard:service' object.uuid %}" class="button field !low bg-neutral-000 w-auto">View →</a>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block service_content %}
|
{% block service_content %}
|
||||||
<div class="max-w-xl content">
|
<div class="max-w-xl content">
|
||||||
<h5>Installation</h5>
|
<h5>Installation</h5>
|
||||||
<p>Place the following snippet at the end of the <code><body></code> tag on any page you'd like to track.</p>
|
<p>Place the following snippet at the end of the <code><body></code> tag on any page you'd like to track.</p>
|
||||||
<div class="card ~neutral !high font-mono text-sm">
|
{% include 'dashboard/includes/service_snippet.html' %}
|
||||||
{% filter force_escape %}<noscript><img
|
|
||||||
src="//{{request.site.domain}}{% url 'ingress:endpoint_pixel' object.uuid %}"></noscript>
|
|
||||||
<script src="//{{request.site.domain}}{% url 'ingress:endpoint_script' object.uuid %}"></script>
|
|
||||||
{% endfilter %}
|
|
||||||
</div>
|
|
||||||
<hr class="sep h-4">
|
<hr class="sep h-4">
|
||||||
<h5>Settings</h5>
|
<h5>Settings</h5>
|
||||||
<form class="card ~neutral !low p-0" method="POST">
|
<form class="card ~neutral !low p-0" method="POST">
|
||||||
@@ -35,5 +30,20 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
<hr class="sep h-4">
|
||||||
|
<h5>API</h5>
|
||||||
|
<div class="card ~neutral !low content">
|
||||||
|
<p>Shynet provides a simple API that you can use to pull data programmatically. You can access this data via this URL:</p>
|
||||||
|
<code class="text-sm">{{script_protocol}}{{request.get_host}}{% url 'api:services' %}?uuid={{object.uuid}}</code>
|
||||||
|
<p>
|
||||||
|
There are 2 optional query parameters:
|
||||||
|
<ul>
|
||||||
|
<li><code class="text-sm">startDate</code> — to set the start date (in format YYYY-MM-DD)</li>
|
||||||
|
<li><code class="text-sm">endDate</code> — to set the end date (in format YYYY-MM-DD)</li>
|
||||||
|
</ul>
|
||||||
|
</p>
|
||||||
|
<p>Example using cURL:</p>
|
||||||
|
<code class="text-sm">curl -H 'Authorization: Token {{request.user.api_token}}' '{{script_protocol}}{{request.get_host}}{% url 'api:services' %}?uuid={{object.uuid}}&startDate=2021-01-01&endDate=2050-01-01'</code>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -6,20 +6,21 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="md:flex justify-between items-center" id="heading">
|
<div class="md:flex justify-between items-center" id="heading">
|
||||||
<a class="flex items-center mb-4 md:mb-0" href="{% url 'dashboard:service' object.uuid %}">
|
<a class="flex items-center mb-4 md:mb-0 truncate" href="{% contextual_url 'dashboard:service' object.uuid %}">
|
||||||
<h3 class="heading leading-none mr-4">
|
<h3 class="heading items-center mr-4 md:mr-2 flex truncate">
|
||||||
{{object.name}}
|
{{object.link|iconify}}
|
||||||
|
<span class="flex-1 truncate ml-2" title="{{object.name}}">{{object.name}}</span>
|
||||||
</h3>
|
</h3>
|
||||||
<div class='text-3xl'>
|
<div class="text-3xl md:mr-2">
|
||||||
{% include 'dashboard/includes/stats_status_chip.html' %}
|
{% include 'dashboard/includes/stats_status_chip.html' %}
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<div class="flex items-center">
|
<div class="flex items-center flex-none">
|
||||||
{% block service_actions %}
|
{% block service_actions %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<hr class="sep h-8">
|
<hr class="sep h-8 md:h-12">
|
||||||
{% block service_content %}
|
{% block service_content %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
|
from datetime import timedelta
|
||||||
from urllib.parse import urlparse
|
from urllib.parse import urlparse
|
||||||
|
import urllib
|
||||||
|
|
||||||
import flag
|
|
||||||
import pycountry
|
import pycountry
|
||||||
from django import template
|
from django import template
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
from django.utils.html import escape
|
from django.utils.html import escape
|
||||||
from django.utils.safestring import SafeString
|
from django.utils.safestring import SafeString
|
||||||
|
from django.template.defaulttags import url as url_tag
|
||||||
|
|
||||||
register = template.Library()
|
register = template.Library()
|
||||||
|
|
||||||
@@ -26,11 +28,11 @@ def naturaldelta(timedelta):
|
|||||||
|
|
||||||
|
|
||||||
@register.filter
|
@register.filter
|
||||||
def flag_emoji(isocode):
|
def flag_class(isocode):
|
||||||
try:
|
if isocode:
|
||||||
return flag.flag(isocode)
|
return "mr-1 flag-icon flag-icon-" + isocode.lower()
|
||||||
except:
|
else:
|
||||||
return ""
|
return "hidden"
|
||||||
|
|
||||||
|
|
||||||
@register.filter
|
@register.filter
|
||||||
@@ -41,9 +43,22 @@ def country_name(isocode):
|
|||||||
return "Unknown"
|
return "Unknown"
|
||||||
|
|
||||||
|
|
||||||
|
@register.filter
|
||||||
|
def datamap_id(isocode):
|
||||||
|
try:
|
||||||
|
return pycountry.countries.get(alpha_2=isocode).alpha_3
|
||||||
|
except:
|
||||||
|
return "UNKNOWN"
|
||||||
|
|
||||||
|
|
||||||
@register.simple_tag
|
@register.simple_tag
|
||||||
def relative_stat_tone(
|
def relative_stat_tone(
|
||||||
start, end, good="UP", good_classes=None, bad_classes=None, neutral_classes=None,
|
start,
|
||||||
|
end,
|
||||||
|
good="UP",
|
||||||
|
good_classes=None,
|
||||||
|
bad_classes=None,
|
||||||
|
neutral_classes=None,
|
||||||
):
|
):
|
||||||
good_classes = good_classes or "~positive"
|
good_classes = good_classes or "~positive"
|
||||||
bad_classes = bad_classes or "~critical"
|
bad_classes = bad_classes or "~critical"
|
||||||
@@ -73,7 +88,7 @@ def percent_change_display(start, end):
|
|||||||
elif start == 0:
|
elif start == 0:
|
||||||
pct_change = "0%"
|
pct_change = "0%"
|
||||||
else:
|
else:
|
||||||
change = int(round(100 * abs(end - start) / start))
|
change = int(round(100 * abs(end - start) / max(start, 1)))
|
||||||
if change > 999:
|
if change > 999:
|
||||||
return "> 999%"
|
return "> 999%"
|
||||||
else:
|
else:
|
||||||
@@ -81,11 +96,11 @@ def percent_change_display(start, end):
|
|||||||
|
|
||||||
return SafeString(direction + pct_change)
|
return SafeString(direction + pct_change)
|
||||||
|
|
||||||
|
|
||||||
@register.inclusion_tag("dashboard/includes/sidebar_footer.html")
|
@register.inclusion_tag("dashboard/includes/sidebar_footer.html")
|
||||||
def sidebar_footer():
|
def sidebar_footer():
|
||||||
return {
|
return {"version": settings.VERSION if settings.SHOW_SHYNET_VERSION else ""}
|
||||||
"version": settings.VERSION
|
|
||||||
}
|
|
||||||
|
|
||||||
@register.inclusion_tag("dashboard/includes/stat_comparison.html")
|
@register.inclusion_tag("dashboard/includes/stat_comparison.html")
|
||||||
def compare(
|
def compare(
|
||||||
@@ -97,6 +112,12 @@ def compare(
|
|||||||
bad_classes=None,
|
bad_classes=None,
|
||||||
neutral_classes=None,
|
neutral_classes=None,
|
||||||
):
|
):
|
||||||
|
if isinstance(start, timedelta):
|
||||||
|
start = start.seconds
|
||||||
|
|
||||||
|
if isinstance(end, timedelta):
|
||||||
|
end = end.seconds
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"start": start,
|
"start": start,
|
||||||
"end": end,
|
"end": end,
|
||||||
@@ -115,12 +136,140 @@ def startswith(text, starts):
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@register.filter
|
||||||
|
def iconify(text):
|
||||||
|
if not settings.SHOW_THIRD_PARTY_ICONS:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
text = text.lower()
|
||||||
|
icons = {
|
||||||
|
"chrome": "chrome.com",
|
||||||
|
"safari": "www.apple.com",
|
||||||
|
"windows": "windows.com",
|
||||||
|
"edge": "microsoft.com",
|
||||||
|
"firefox": "firefox.com",
|
||||||
|
"opera": "opera.com",
|
||||||
|
"unknown": "example.com",
|
||||||
|
"linux": "kernel.org",
|
||||||
|
"ios": "www.apple.com",
|
||||||
|
"mac": "www.apple.com",
|
||||||
|
"macos": "www.apple.com",
|
||||||
|
"mac os x": "www.apple.com",
|
||||||
|
"android": "android.com",
|
||||||
|
"chrome os": "chrome.com",
|
||||||
|
"ubuntu": "ubuntu.com",
|
||||||
|
"fedora": "getfedora.org",
|
||||||
|
"mobile safari": "www.apple.com",
|
||||||
|
"chrome mobile ios": "chrome.com",
|
||||||
|
"chrome mobile": "chrome.com",
|
||||||
|
"samsung internet": "samsung.com",
|
||||||
|
"google": "google.com",
|
||||||
|
"chrome mobile webview": "chrome.com",
|
||||||
|
"firefox mobile": "firefox.com",
|
||||||
|
"edge mobile": "microsoft.com",
|
||||||
|
"chromium": "chromium.org",
|
||||||
|
}
|
||||||
|
|
||||||
|
domain = None
|
||||||
|
if text.startswith("http"):
|
||||||
|
domain = urlparse(text).netloc
|
||||||
|
elif text in icons:
|
||||||
|
domain = icons[text]
|
||||||
|
else:
|
||||||
|
# This fallback works better than you'd think!
|
||||||
|
domain = text + ".com"
|
||||||
|
|
||||||
|
return SafeString(
|
||||||
|
f'<span class="icon mr-1 flex-none"><img src="https://icons.duckduckgo.com/ip3/{domain}.ico"></span>'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@register.filter
|
@register.filter
|
||||||
def urldisplay(url):
|
def urldisplay(url):
|
||||||
if url.startswith("http"):
|
if url.startswith("http"):
|
||||||
display_url = url.replace("http://", "").replace("https://", "")
|
display_url = url.replace("http://", "").replace("https://", "")
|
||||||
return SafeString(
|
return SafeString(
|
||||||
f"<a href='{url}' title='{url}' rel='nofollow'>{escape(display_url if len(display_url) < 40 else display_url[:40] + '...')}</a>"
|
f"<a href='{url}' title='{url}' rel='nofollow' class='flex items-center mr-1 truncate'>{iconify(url)}<span class='truncate'>{escape(display_url)}</span></a>"
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
return url
|
return url
|
||||||
|
|
||||||
|
|
||||||
|
class ContextualURLNode(template.Node):
|
||||||
|
"""Extension of the Django URLNode to support including contextual parameters in URL outputs. In other words, URLs generated will keep the start and end date parameters."""
|
||||||
|
|
||||||
|
CONTEXT_PARAMS = ["startDate", "endDate"]
|
||||||
|
|
||||||
|
def __init__(self, urlnode):
|
||||||
|
self.urlnode = urlnode
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return self.urlnode.__repr__()
|
||||||
|
|
||||||
|
def render(self, context):
|
||||||
|
url = self.urlnode.render(context)
|
||||||
|
if self.urlnode.asvar:
|
||||||
|
url = context[self.urlnode.asvar]
|
||||||
|
|
||||||
|
url_parts = list(urlparse(url))
|
||||||
|
query = dict(urllib.parse.parse_qsl(url_parts[4]))
|
||||||
|
|
||||||
|
query.update(
|
||||||
|
{
|
||||||
|
param: context.request.GET.get(param)
|
||||||
|
for param in self.CONTEXT_PARAMS
|
||||||
|
if param in context.request.GET and param not in query
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
url_parts[4] = urllib.parse.urlencode(query)
|
||||||
|
|
||||||
|
url_final = urllib.parse.urlunparse(url_parts)
|
||||||
|
|
||||||
|
if self.urlnode.asvar:
|
||||||
|
context[self.urlnode.asvar] = url_final
|
||||||
|
return ""
|
||||||
|
else:
|
||||||
|
return url_final
|
||||||
|
|
||||||
|
|
||||||
|
@register.tag
|
||||||
|
def contextual_url(*args, **kwargs):
|
||||||
|
urlnode = url_tag(*args, **kwargs)
|
||||||
|
return ContextualURLNode(urlnode)
|
||||||
|
|
||||||
|
|
||||||
|
@register.filter
|
||||||
|
def location_url(session):
|
||||||
|
return settings.LOCATION_URL.replace("$LATITUDE", str(session.latitude)).replace(
|
||||||
|
"$LONGITUDE", str(session.longitude)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@register.filter
|
||||||
|
def percent(value, total):
|
||||||
|
if total == 0:
|
||||||
|
return "N/A"
|
||||||
|
|
||||||
|
percent = value / total
|
||||||
|
|
||||||
|
if percent < 0.001:
|
||||||
|
return "<0.1%"
|
||||||
|
|
||||||
|
return f"{percent:.1%}"
|
||||||
|
|
||||||
|
|
||||||
|
@register.simple_tag
|
||||||
|
def bar_width(count, max, total):
|
||||||
|
if total == 0 or max == 0:
|
||||||
|
return "0"
|
||||||
|
|
||||||
|
if settings.USE_RELATIVE_MAX_IN_BAR_VISUALIZATION:
|
||||||
|
percent = count / max
|
||||||
|
else:
|
||||||
|
percent = count / total
|
||||||
|
|
||||||
|
if percent < 0.001:
|
||||||
|
return "0"
|
||||||
|
|
||||||
|
return f"{percent:.1%}"
|
||||||
|
|||||||
0
shynet/dashboard/tests/__init__.py
Normal file
43
shynet/dashboard/tests/tests_dashboard_views.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
from django.test import TestCase, RequestFactory
|
||||||
|
from django.conf import settings
|
||||||
|
from django.urls import reverse
|
||||||
|
from core.factories import UserFactory
|
||||||
|
|
||||||
|
from dashboard.views import DashboardView
|
||||||
|
|
||||||
|
|
||||||
|
class QuestionModelTests(TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
# Every test needs access to the request factory.
|
||||||
|
self.factory = RequestFactory()
|
||||||
|
self.user = UserFactory()
|
||||||
|
|
||||||
|
def tearDown(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def tests_unauthenticated_dashboard_view(self):
|
||||||
|
"""
|
||||||
|
GIVEN: Unauthenticated user
|
||||||
|
WHEN: Accessing the dashboard view
|
||||||
|
THEN: It's redirected to login page with NEXT url to dashboard
|
||||||
|
"""
|
||||||
|
login_url = settings.LOGIN_URL
|
||||||
|
response = self.client.get(reverse("dashboard:dashboard"))
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertEqual(
|
||||||
|
response.url, f"{login_url}?next={reverse('dashboard:dashboard')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def tests_authenticated_dashboard_view(self):
|
||||||
|
"""
|
||||||
|
GIVEN: Authenticated user
|
||||||
|
WHEN: Accessing the dashboard view
|
||||||
|
THEN: It should respond with 200 and render the view
|
||||||
|
"""
|
||||||
|
request = self.factory.get(reverse("dashboard:dashboard"))
|
||||||
|
request.user = self.user
|
||||||
|
|
||||||
|
# Use this syntax for class-based views.
|
||||||
|
response = DashboardView.as_view()(request)
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||