diff --git a/.code-workspace b/.code-workspace new file mode 100644 index 000000000..45bcddf7f --- /dev/null +++ b/.code-workspace @@ -0,0 +1,32 @@ +{ + "folders": [ + { + "name": "Frontend", + "path": "frontend" + }, + { + "name": "Documentation", + "path": "docs" + }, + { + "name": "Backend", + "path": "backend" + }, + { + "name": "Other", + "path": "./" + } + ], + "launch": { + "version": "0.2.0", + "compounds": [ + { + "name": "Run All", + "configurations": [ + "Run Backend: dev", + "Run Frontend: dev" + ] + } + ] + } + } \ No newline at end of file diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..27899c6ba --- /dev/null +++ b/.dockerignore @@ -0,0 +1,24 @@ +venv/ +.env +.DS_Store +.gitignore +.git +.github +.idea +.buildHelper.txt +node_modules +__pycache__ + +# All hidden files +.* + +Dockerfile +README.md +babel.cfg +unraid.xml + +screenshots/ +docs/ +testing/ +database/ +delete/ \ No newline at end of file diff --git a/.github/workflows/beta-release.yml b/.github/workflows/beta-release.yml new file mode 100644 index 000000000..fd863bc5e --- /dev/null +++ b/.github/workflows/beta-release.yml @@ -0,0 +1,66 @@ +name: Create Pre-Release on Version Change + +on: + push: + branches: + - v3-beta + +jobs: + check-version-and-create-release: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v2 + + - name: Set up Node.js + uses: actions/setup-node@v2 + with: + node-version: "14" + + - name: Install dependencies + run: npm install + + - name: Get previous package version + id: get_prev_version + run: echo "::set-output name=prev_version::$(git show HEAD^:package.json | jq -r '.version')" + + - name: Get current package version + id: get_current_version + run: echo "::set-output name=current_version::$(jq -r '.version' package.json)" + + - name: Compare versions + id: compare_versions + run: | + if [[ "${{ steps.get_prev_version.outputs.prev_version }}" != "${{ steps.get_current_version.outputs.current_version }}" ]]; then + echo "Version changed. Creating Pre-Release." + echo "New version: ${{ steps.get_current_version.outputs.current_version }}, Previous version: ${{ steps.get_prev_version.outputs.prev_version }}" + echo "::set-output name=version_changed::true" + else + echo "Version unchanged. No Pre-Release needed." + echo "::set-output name=version_changed::false" + fi + + - name: Generate Release Notes + id: generate_release_notes + run: | + echo "::set-output name=release_notes::$(git log --pretty=format:"- %s%n" $(git rev-list ${{ github.event.before }}..${{ github.sha }}))" + + - name: Create Pre-Release + if: steps.compare_versions.outputs.version_changed == 'true' + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + # Create a new Pre-Release using GitHub API + # Replace ":owner", ":repo", and other placeholders with actual values + # You can use curl or other tools to interact with the API + curl -X POST \ + -H "Authorization: token $GITHUB_TOKEN" \ + -d '{ + "tag_name": "'${{ steps.get_current_version.outputs.current_version }}'", + "target_commitish": "v3-beta", + "name": "Pre-Release V'${{ steps.get_current_version.outputs.current_version }}'", + "body": "${{ steps.generate_release_notes.outputs.release_notes }}", + "prerelease": true + }' \ + "https://api.github.com/repos/wizarrrr/wizarr/releases" diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml new file mode 100644 index 000000000..bfffe357e --- /dev/null +++ b/.github/workflows/docker-build.yml @@ -0,0 +1,46 @@ +name: Docker Build and Push + +on: + push: + branches: + - v3-beta + +permissions: + packages: write + +env: + REGISTRY: ghcr.io + IMAGE_NAME: wizarrrr/wizarr + IMAGE_TAG: v3-beta + +jobs: + build: + runs-on: ubuntu-latest + steps: + # Checkout the repo + - name: Checkout + uses: actions/checkout@v2 + + # Set up Docker Buildx + - name: Set up Docker Buildx + id: buildx + uses: docker/setup-buildx-action@v3 + + # Login to GHCR + - name: Login to GHCR + uses: docker/login-action@v1 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Build and push the image + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + file: ./Dockerfile + push: true + tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.IMAGE_TAG }} + platforms: linux/amd64,linux/arm64,linux/arm64/v7,linux/arm64/v8 + provenance: false diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..5a71bf71b --- /dev/null +++ b/.gitignore @@ -0,0 +1,392 @@ +venv/ +app/__pycache__/ +__pycache__/ +.env +.DS_Store +.buildHelper.txt +data/ +/backend/database +/database +**/node_modules/ +node_modules/ +testing/cypress/screenshots +testing/cypress/videos +docker/ +delete/ +*.backup +swagger.json +dev-dist + +# Created by https://www.toptal.com/developers/gitignore/api/pycharm,flask,python +# Edit at https://www.toptal.com/developers/gitignore?templates=pycharm,flask,python + +### Flask ### +instance/* +!instance/.gitignore +.webassets-cache +.env + +### Flask.Python Stack ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +### PyCharm ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### PyCharm Patch ### +# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721 + +# *.iml +# modules.xml +# .idea/misc.xml +# *.ipr + +# Sonarlint plugin +# https://plugins.jetbrains.com/plugin/7973-sonarlint +.idea/**/sonarlint/ + +# SonarQube Plugin +# https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin +.idea/**/sonarIssues.xml + +# Markdown Navigator plugin +# https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced +.idea/**/markdown-navigator.xml +.idea/**/markdown-navigator-enh.xml +.idea/**/markdown-navigator/ + +# Cache file creation bug +# See https://youtrack.jetbrains.com/issue/JBR-2257 +.idea/$CACHE_FILE$ + +# CodeStream plugin +# https://plugins.jetbrains.com/plugin/12206-codestream +.idea/codestream.xml + +# Azure Toolkit for IntelliJ plugin +# https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij +.idea/**/azureSettings.xml + +### Python ### +# Byte-compiled / optimized / DLL files + +# C extensions + +# Distribution / packaging + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. + +# Installer logs + +# Unit test / coverage reports + +# Translations + +# Django stuff: + +# Flask stuff: + +# Scrapy stuff: + +# Sphinx documentation + +# PyBuilder + +# Jupyter Notebook + +# IPython + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm + +# Celery stuff + +# SageMath parsed files + +# Environments + +# Spyder project settings + +# Rope project settings + +# mkdocs documentation + +# mypy + +# Pyre type checker + +# pytype static type analyzer + +# Cython debug symbols + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. + +### Python Patch ### +# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration +poetry.toml + +# ruff +.ruff_cache/ + +# End of https://www.toptal.com/developers/gitignore/api/pycharm,flask,python +testing/core diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..f0d263014 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,7 @@ +{ + "files.exclude": { + "backend/": true, + "frontend/": true, + "venv/": true, + } +} \ No newline at end of file diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 000000000..18c914718 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..c06be2ee1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,63 @@ +# Build Stage +FROM --platform=$BUILDPLATFORM python:3.12.0-alpine + +####################### +# Backend Build Stage # +####################### + +# Copy only the necessary files for building +WORKDIR /data/backend +COPY ./backend ./ + +# Install build dependencies +RUN apk add --no-cache libffi-dev g++ nmap tzdata nginx + +# Upgrade pip and install Python dependencies +RUN pip install --upgrade pip && pip install --no-cache-dir -r requirements.txt + + +######################## +# Frontend Build Stage # +######################## + +# Copy only the necessary files for building +WORKDIR /data/frontend +COPY ./frontend/ ./ + +# Install build dependencies +RUN apk add --no-cache nodejs npm + +# Node.js and Frontend build +RUN npm install --verbose +RUN npm run build + + +################# +# Runtime Stage # +################# + +WORKDIR /data + +# Copy Nginx configuration +COPY nginx.conf /etc/nginx/http.d/default.conf + +# Setup timezone +RUN cp /usr/share/zoneinfo/UTC /etc/localtime \ + && echo UTC > /etc/timezone + +# Set environment variables +ENV TZ=Etc/UTC + +# Get the version from package.json in the ./ directory +COPY ./package.json ./version.json + +LABEL maintainer="Ashley Bailey " +LABEL description="Wizarr is an advanced user invitation and management system for Jellyfin, Plex, Emby etc." + + +# Expose ports +EXPOSE 5690 +WORKDIR /data/backend + +# Start Nginx in the background and Gunicorn in the foreground +CMD [ "sh", "-c", "nginx && gunicorn --worker-class geventwebsocket.gunicorn.workers.GeventWebSocketWorker --bind 0.0.0.0:5000 -m 007 run:app" ] diff --git a/README.md b/README.md new file mode 100644 index 000000000..b1207f9b3 --- /dev/null +++ b/README.md @@ -0,0 +1,107 @@ +

Wizarr Beta

+

The Free Media Invitation System

+ +--- + +

+ +
+
+ +GPL 2.0 License + + +Current Release + + + + + +Donate + + +Submit Feature Requests + + +Chat on Discord + + +Join our Subreddit + + +Github Issue + + +Github Build + +

+ +--- + +Wizarr is a automatic user invitation system for Plex and Jellyfin. Create a unique link and share it to a user and they will be invited to your Media Server after they complete there signup proccess! They can even be guided to download the clients and read instructions on how to use your media software! + +## Major Features Include + +- Automatic Invitation to your Media Server (Plex, Jellyfin) +- Support for Passkey authentication for Admin Users +- Create multiple invitations with different configurations +- Make invitations and users expire after a certain amount of time +- Automatically add users to your Request System (Ombi, Jellyseerr, Overseerr) +- Add users to your Discord Server +- Create a custom HTML page +- Multi-Language Support +- Scheduled Tasks to keep Wizarr updated with your Media Server +- Live logs directly from the Wizarr Web UI +- Multiple Admin Users with different permissions +- Notification System +- API for Developers with Swagger UI +- Light and Dark Mode Support +- Session Management for Admin Users + +## Whats to come + +- Added API Endpoints +- Multi-Server Support +- Mass Emailing to Client Users +- OAuth Support with custom providers +- Use your own Database +- 2FA Support for Admin Users +- Built in Update System +- Full Wizard Customization with Drag and Drop Template Editor +- Jellyfin and Plex user permissions management tool +- Invite Request System for users to request invite +- and much more! + +## Getting Started + +``` +docker run -d \ + --name wizarr \ + -p 5690:5690 \ + -v ./wizarr/database:/data/database \ + ghcr.io/wizarrrr/wizarr:v3-beta +``` + +``` +--- +version: "3.5" +services: + wizarr: + container_name: wizarr + image: ghcr.io/wizarrrr/wizarr:v3-beta + ports: + - 5690:5690 + volumes: + - ./wizarr/database:/data/database +``` + +Check out our documentation for instructions on how to install and run Wizarr! +https://github.com/Wizarrrr/wizarr/tree/v3-beta/docs/setup + +## Thank you + +A big thank you ❤️ to these amazing people for contributing to this project! + + + + diff --git a/TODO.md b/TODO.md new file mode 100644 index 000000000..fb845e8d3 --- /dev/null +++ b/TODO.md @@ -0,0 +1,22 @@ +# Todo List + +This page contains a list of tasks that need to be completed. If you complete a task, check the box next to it. If you have started a task but haven't finished it yet, check the box next to it and add a note about what you have done so far. + +- [x] Create a TODO list markdown file +- [x] Fix Dropdown menu when on mobile +- [ ] Add Edit, Delete, Scan Users, Add User and Delete all functionality to Users page +- [ ] Add View Libraries, Delete, Edit, Create Invite and Delete all functionality to Libraries page +- [ ] Finish of functionality for the Quick Invite page +- [ ] Add aria-current="page" to the accounts page +- [ ] Create Account and Password pages for the accounts page +- [ ] Start work on Invite Create User page +- [ ] Start work on /setup page (rename route to /help) +- [ ] Finish off setup onboarding page +- [ ] Allow for seperate server_url to allow for IP address +- [ ] Rework plex invite intergration +- [ ] Add Report Bug button to the bottom of the page (screenshot and send to discord) +- [ ] +- [ ] +- [ ] +- [ ] Rewrite Wizarr to allow for multiple servers + diff --git a/backend/.editorconfig b/backend/.editorconfig new file mode 100644 index 000000000..bb53136e5 --- /dev/null +++ b/backend/.editorconfig @@ -0,0 +1,12 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +[*] +indent_style = space +indent_size = 4 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true \ No newline at end of file diff --git a/backend/.pylintrc b/backend/.pylintrc new file mode 100644 index 000000000..3f2c14c09 --- /dev/null +++ b/backend/.pylintrc @@ -0,0 +1,435 @@ +# This Pylint rcfile contains a best-effort configuration to uphold the +# best-practices and style described in the Google Python style guide: +# https://google.github.io/styleguide/pyguide.html +# +# Its canonical open-source location is: +# https://google.github.io/styleguide/pylintrc + +[MASTER] + +# Files or directories to be skipped. They should be base names, not paths. +ignore=third_party + +# Files or directories matching the regex patterns are skipped. The regex +# matches against base names, not paths. +ignore-patterns= + +# Pickle collected data for later comparisons. +persistent=no + +# List of plugins (as comma separated values of python modules names) to load, +# usually to register additional checkers. +load-plugins= + +# Use multiple processes to speed up Pylint. +jobs=4 + +# Allow loading of arbitrary C extensions. Extensions are imported into the +# active Python interpreter and may run arbitrary code. +unsafe-load-any-extension=no + + +[MESSAGES CONTROL] + +# Only show warnings with the listed confidence levels. Leave empty to show +# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED +confidence= + +# Enable the message, report, category or checker with the given id(s). You can +# either give multiple identifier separated by comma (,) or put this option +# multiple time (only on the command line, not in the configuration file where +# it should appear only once). See also the "--disable" option for examples. +#enable= + +# Disable the message, report, category or checker with the given id(s). You +# can either give multiple identifiers separated by comma (,) or put this +# option multiple times (only on the command line, not in the configuration +# file where it should appear only once).You can also use "--disable=all" to +# disable everything first and then reenable specific checks. For example, if +# you want to run only the similarities checker, you can use "--disable=all +# --enable=similarities". If you want to run only the classes checker, but have +# no Warning level messages displayed, use"--disable=all --enable=classes +# --disable=W" +disable=abstract-method, + apply-builtin, + arguments-differ, + attribute-defined-outside-init, + backtick, + bad-option-value, + basestring-builtin, + buffer-builtin, + c-extension-no-member, + consider-using-enumerate, + cmp-builtin, + cmp-method, + coerce-builtin, + coerce-method, + delslice-method, + div-method, + duplicate-code, + eq-without-hash, + execfile-builtin, + file-builtin, + filter-builtin-not-iterating, + fixme, + getslice-method, + global-statement, + hex-method, + idiv-method, + implicit-str-concat, + import-error, + import-self, + import-star-module-level, + inconsistent-return-statements, + input-builtin, + intern-builtin, + invalid-str-codec, + locally-disabled, + long-builtin, + long-suffix, + map-builtin-not-iterating, + misplaced-comparison-constant, + missing-function-docstring, + metaclass-assignment, + next-method-called, + next-method-defined, + no-absolute-import, + no-else-break, + no-else-continue, + no-else-raise, + no-else-return, + no-init, # added + no-member, + no-name-in-module, + no-self-use, + nonzero-method, + oct-method, + old-division, + old-ne-operator, + old-octal-literal, + old-raise-syntax, + parameter-unpacking, + print-statement, + raising-string, + range-builtin-not-iterating, + raw_input-builtin, + rdiv-method, + reduce-builtin, + relative-import, + reload-builtin, + round-builtin, + setslice-method, + signature-differs, + standarderror-builtin, + suppressed-message, + sys-max-int, + too-few-public-methods, + too-many-ancestors, + too-many-arguments, + too-many-boolean-expressions, + too-many-branches, + too-many-instance-attributes, + too-many-locals, + too-many-nested-blocks, + too-many-public-methods, + too-many-return-statements, + too-many-statements, + trailing-newlines, + unichr-builtin, + unicode-builtin, + unnecessary-pass, + unpacking-in-except, + useless-else-on-loop, + useless-object-inheritance, + useless-suppression, + using-cmp-argument, + wrong-import-order, + xrange-builtin, + zip-builtin-not-iterating, + missing-module-docstring, + not-an-iterable, + import-outside-toplevel, + wrong-import-position, + broad-exception-caught, + multiple-statements, + unnecessary-lambda-assignment, + +[REPORTS] + +# Set the output format. Available formats are text, parseable, colorized, msvs +# (visual studio) and html. You can also give a reporter class, eg +# mypackage.mymodule.MyReporterClass. +output-format=text + +# Tells whether to display a full report or only the messages +reports=no + +# Python expression which should return a note less than 10 (10 is the highest +# note). You have access to the variables errors warning, statement which +# respectively contain the number of errors / warnings messages and the total +# number of statements analyzed. This is used by the global evaluation report +# (RP0004). +evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) + +# Template used to display messages. This is a python new-style format string +# used to format the message information. See doc for all details +#msg-template= + + +[BASIC] + +# Good variable names which should always be accepted, separated by a comma +good-names=main,_ + +# Bad variable names which should always be refused, separated by a comma +bad-names= + +# Colon-delimited sets of names that determine each other's naming style when +# the name regexes allow several styles. +name-group= + +# Include a hint for the correct naming format with invalid-name +include-naming-hint=no + +# List of decorators that produce properties, such as abc.abstractproperty. Add +# to this list to register other decorators that produce valid properties. +property-classes=abc.abstractproperty,cached_property.cached_property,cached_property.threaded_cached_property,cached_property.cached_property_with_ttl,cached_property.threaded_cached_property_with_ttl + +# Regular expression matching correct function names +function-rgx=^(?:(?PsetUp|tearDown|setUpModule|tearDownModule)|(?P_?[A-Z][a-zA-Z0-9]*)|(?P_?[a-z][a-z0-9_]*))$ + +# Regular expression matching correct variable names +variable-rgx=^[a-z][a-z0-9_]*$ + +# Regular expression matching correct constant names +const-rgx=^(_?[A-Z][A-Z0-9_]*|__[a-z0-9_]+__|_?[a-z][a-z0-9_]*)$ + +# Regular expression matching correct attribute names +attr-rgx=^_{0,2}[a-z][a-z0-9_]*$ + +# Regular expression matching correct argument names +argument-rgx=^[a-z][a-z0-9_]*$ + +# Regular expression matching correct class attribute names +class-attribute-rgx=^(_?[A-Z][A-Z0-9_]*|__[a-z0-9_]+__|_?[a-z][a-z0-9_]*)$ + +# Regular expression matching correct inline iteration names +inlinevar-rgx=^[a-z][a-z0-9_]*$ + +# Regular expression matching correct class names +class-rgx=^_?[A-Z][a-zA-Z0-9]*$ + +# Regular expression matching correct module names +module-rgx=^(_?[a-z][a-z0-9_]*|__init__)$ + +# Regular expression matching correct method names +method-rgx=(?x)^(?:(?P_[a-z0-9_]+__|runTest|setUp|tearDown|setUpTestCase|tearDownTestCase|setupSelf|tearDownClass|setUpClass|(test|assert)_*[A-Z0-9][a-zA-Z0-9_]*|next)|(?P_{0,2}[A-Z][a-zA-Z0-9_]*)|(?P_{0,2}[a-z][a-z0-9_]*))$ + +# Regular expression which should only match function or class names that do +# not require a docstring. +no-docstring-rgx=(__.*__|main|test.*|.*test|.*Test)$ + +# Minimum line length for functions/classes that require docstrings, shorter +# ones are exempt. +docstring-min-length=10 + + +[TYPECHECK] + +# List of decorators that produce context managers, such as +# contextlib.contextmanager. Add to this list to register other decorators that +# produce valid context managers. +contextmanager-decorators=contextlib.contextmanager,contextlib2.contextmanager + +# Tells whether missing members accessed in mixin class should be ignored. A +# mixin class is detected if its name ends with "mixin" (case insensitive). +ignore-mixin-members=yes + +# List of module names for which member attributes should not be checked +# (useful for modules/projects where namespaces are manipulated during runtime +# and thus existing member attributes cannot be deduced by static analysis. It +# supports qualified module names, as well as Unix pattern matching. +ignored-modules= + +# List of class names for which member attributes should not be checked (useful +# for classes with dynamically set attributes). This supports the use of +# qualified names. +ignored-classes=optparse.Values,thread._local,_thread._local + +# List of members which are set dynamically and missed by pylint inference +# system, and so shouldn't trigger E1101 when accessed. Python regular +# expressions are accepted. +generated-members= + + +[FORMAT] + +# Maximum number of characters on a single line. +max-line-length=1080 + +# TODO(https://github.com/PyCQA/pylint/issues/3352): Direct pylint to exempt +# lines made too long by directives to pytype. + +# Regexp for a line that is allowed to be longer than the limit. +ignore-long-lines=(?x)( + ^\s*(\#\ )??$| + ^\s*(from\s+\S+\s+)?import\s+.+$) + +# Allow the body of an if to be on the same line as the test if there is no +# else. +single-line-if-stmt=yes + +# Maximum number of lines in a module +max-module-lines=99999 + +# String used as indentation unit. The internal Google style guide mandates 2 +# spaces. Google's externaly-published style guide says 4, consistent with +# PEP 8. Here, we use 2 spaces, for conformity with many open-sourced Google +# projects (like TensorFlow). +indent-string=' ' + +# Number of spaces of indent required inside a hanging or continued line. +indent-after-paren=4 + +# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. +expected-line-ending-format= + + +[MISCELLANEOUS] + +# List of note tags to take in consideration, separated by a comma. +notes=TODO + + +[STRING] + +# This flag controls whether inconsistent-quotes generates a warning when the +# character used as a quote delimiter is used inconsistently within a module. +check-quote-consistency=yes + + +[VARIABLES] + +# Tells whether we should check for unused import in __init__ files. +init-import=no + +# A regular expression matching the name of dummy variables (i.e. expectedly +# not used). +dummy-variables-rgx=^\*{0,2}(_$|unused_|dummy_) + +# List of additional names supposed to be defined in builtins. Remember that +# you should avoid to define new builtins when possible. +additional-builtins= + +# List of strings which can identify a callback function by name. A callback +# name must start or end with one of those strings. +callbacks=cb_,_cb + +# List of qualified module names which can have objects that can redefine +# builtins. +redefining-builtins-modules=six,six.moves,past.builtins,future.builtins,functools + + +[LOGGING] + +# Logging modules to check that the string format arguments are in logging +# function parameter format +logging-modules=logging,absl.logging,tensorflow.io.logging + + +[SIMILARITIES] + +# Minimum lines number of a similarity. +min-similarity-lines=4 + +# Ignore comments when computing similarities. +ignore-comments=yes + +# Ignore docstrings when computing similarities. +ignore-docstrings=yes + +# Ignore imports when computing similarities. +ignore-imports=no + + +[SPELLING] + +# Spelling dictionary name. Available dictionaries: none. To make it working +# install python-enchant package. +spelling-dict= + +# List of comma separated words that should not be checked. +spelling-ignore-words= + +# A path to a file that contains private dictionary; one word per line. +spelling-private-dict-file= + +# Tells whether to store unknown words to indicated private dictionary in +# --spelling-private-dict-file option instead of raising a message. +spelling-store-unknown-words=no + + +[IMPORTS] + +# Deprecated modules which should not be used, separated by a comma +deprecated-modules=regsub, + TERMIOS, + Bastion, + rexec, + sets + +# Create a graph of every (i.e. internal and external) dependencies in the +# given file (report RP0402 must not be disabled) +import-graph= + +# Create a graph of external dependencies in the given file (report RP0402 must +# not be disabled) +ext-import-graph= + +# Create a graph of internal dependencies in the given file (report RP0402 must +# not be disabled) +int-import-graph= + +# Force import order to recognize a module as part of the standard +# compatibility libraries. +known-standard-library= + +# Force import order to recognize a module as part of a third party library. +known-third-party=enchant, absl + +# Analyse import fallback blocks. This can be used to support both Python 2 and +# 3 compatible code, which means that the block might have code that exists +# only in one or another interpreter, leading to false positives when analysed. +analyse-fallback-blocks=no + + +[CLASSES] + +# List of method names used to declare (i.e. assign) instance attributes. +defining-attr-methods=__init__, + __new__, + setUp + +# List of member names, which should be excluded from the protected access +# warning. +exclude-protected=_asdict, + _fields, + _replace, + _source, + _make + +# List of valid names for the first argument in a class method. +valid-classmethod-first-arg=cls, + class_ + +# List of valid names for the first argument in a metaclass class method. +valid-metaclass-classmethod-first-arg=mcs + + +[EXCEPTIONS] + +# Exceptions that will emit a warning when being caught. Defaults to +# "Exception" +overgeneral-exceptions=builtins.StandardError, + builtins.Exception, + builtins.BaseException \ No newline at end of file diff --git a/backend/.vscode/launch.json b/backend/.vscode/launch.json new file mode 100644 index 000000000..c1eecc0a4 --- /dev/null +++ b/backend/.vscode/launch.json @@ -0,0 +1,23 @@ +{ + // Use IntelliSense to learn about possible attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Run Backend: dev", + "type": "python", + "request": "launch", + "module": "flask", + "env": { + "FLASK_APP": "app.py" + }, + "args": [ + "run", + "--debug" + ], + "jinja": true, + "justMyCode": true + } + ] +} diff --git a/backend/.vscode/settings.json b/backend/.vscode/settings.json new file mode 100644 index 000000000..70f54405b --- /dev/null +++ b/backend/.vscode/settings.json @@ -0,0 +1,12 @@ +{ + "files.exclude": { + "**/.git": true, + "**/.svn": true, + "**/.hg": true, + "**/CVS": true, + "**/.DS_Store": true, + "**/Thumbs.db": true, + "**/__pycache__": true, + "**/venv": true, + } +} \ No newline at end of file diff --git a/backend/api/__init__.py b/backend/api/__init__.py new file mode 100644 index 000000000..93f5223a0 --- /dev/null +++ b/backend/api/__init__.py @@ -0,0 +1,2 @@ +from api.routes import * +from api.models import * \ No newline at end of file diff --git a/backend/api/models/__init__.py b/backend/api/models/__init__.py new file mode 100644 index 000000000..0eb86e8e8 --- /dev/null +++ b/backend/api/models/__init__.py @@ -0,0 +1,2 @@ +from .accounts import * +from .authentication import * diff --git a/backend/api/models/accounts.py b/backend/api/models/accounts.py new file mode 100644 index 000000000..d01827f6c --- /dev/null +++ b/backend/api/models/accounts.py @@ -0,0 +1,17 @@ +from app.extensions import api +from flask_restx import fields + +AccountsGET = api.model("AccountsGET", { + "id": fields.Integer(required=True, description="The account's ID"), + "username": fields.String(required=True, description="The account's username"), + "email": fields.String(required=False, description="The account's email"), + "last_login": fields.DateTime(required=False, description="The account's last login"), + "created": fields.DateTime(required=False, description="The date the account was created"), +}) + +AccountsPOST = api.model("AccountsPOST", { + "username": fields.String(required=True, description="The account's username"), + "email": fields.String(required=False, description="The account's email"), + "password": fields.String(required=True, description="The account's password"), + "confirm_password": fields.String(required=False, description="The account's password confirmation"), +}) diff --git a/backend/api/models/authentication.py b/backend/api/models/authentication.py new file mode 100644 index 000000000..b418ad524 --- /dev/null +++ b/backend/api/models/authentication.py @@ -0,0 +1,8 @@ +from app.extensions import api +from flask_restx import fields + +LoginPOST = api.model("LoginPOST", { + "username": fields.String(required=True, description="The account's username"), + "password": fields.String(required=True, description="The account's password"), + "remember": fields.Boolean(required=False, description="Keep JWT valid indefinitely", default=False) +}) diff --git a/backend/api/routes/__init__.py b/backend/api/routes/__init__.py new file mode 100644 index 000000000..216f4d80e --- /dev/null +++ b/backend/api/routes/__init__.py @@ -0,0 +1,140 @@ +from peewee import IntegrityError +from schematics.exceptions import ValidationError, DataError +from werkzeug.exceptions import UnsupportedMediaType +from flask_jwt_extended.exceptions import RevokedTokenError +from jwt.exceptions import InvalidSignatureError +from flask import jsonify + +from app.exceptions import AuthenticationError +from app.extensions import api + +from .accounts_api import api as accounts_api # REVIEW - This is almost completed +from .apikeys_api import api as apikeys_api +from .authentication_api import api as authentication_api # REVIEW - This is almost completed +from .backup_api import api as backup_api +from .discord_api import api as discord_api +from .invitations_api import api as invitations_api # REVIEW - This is almost completed +from .libraries_api import api as libraries_api +from .notifications_api import api as notifications_api +from .plex_api import api as plex_api +from .requests_api import api as requests_api +from .scan_libraries_api import api as scan_libraries_api +from .sessions_api import api as sessions_api +from .settings_api import api as settings_api +from .setup_api import api as setup_api +from .tasks_api import api as tasks_api +from .users_api import api as users_api +from .webhooks_api import api as webhooks_api +from .logging_api import api as logging_api +from .oauth_api import api as oauth_api +from .mfa_api import api as mfa_api +from .utilities_api import api as utilities_api +from .jellyfin_api import api as jellyfin_api +from .healthcheck_api import api as healthcheck_api +from .server_api import api as server_api + +authorizations = { + "jsonWebToken": { + "type": "apiKey", + "in": "header", + "name": "Authorization", + "description": "JWT Authorization header using the Bearer scheme. Example: \"Authorization: Bearer {token}\"" + }, + "cookieAuth": { + "type": "apiKey", + "in": "cookie", + "name": "session" + } +} + +# pylint: disable=protected-access +api.title = "Wizarr API" +api.description = "Wizarr API" +api.prefix = "/api" +api.authorizations = authorizations +api._doc = "/api/docs" + +def error_handler(exception, code, json=False): + error_object = { + "errors": { + "message": str(exception), + "type": type(exception).__name__ + }, + } + + if json: + error_object = {**error_object, **exception.json()} + else: + error_object = {**error_object, "message": str(exception)} + + + return error_object, code + +@api.errorhandler(InvalidSignatureError) +def handle_invalid_signature_error(error: InvalidSignatureError): + return error_handler(error, 401) + +@api.errorhandler(ValidationError) +def handle_validation_error(error: ValidationError): + return { "errors": error.to_primitive() }, 400 + +@api.errorhandler(DataError) +def handle_data_error(error: DataError): + return { "errors": error.to_primitive() }, 400 + +@api.errorhandler(ValueError) +def handle_value_error(error): + return error_handler(error, 400) + +@api.errorhandler(IntegrityError) +def handle_integrity_error(error): + return error_handler(error, 400) + +@api.errorhandler(UnsupportedMediaType) +def handle_unsupported_media_type(error): + return error_handler(error, 415) + +@api.errorhandler(AuthenticationError) +def handle_authentication_error(error): + return error_handler(error, 401) + +@api.errorhandler(RevokedTokenError) +def handle_authentication_error(error): + return error_handler(error, 401) + +@api.errorhandler(Exception) +def handle_request_exception(error): + return error_handler(error, 500) + +# Ordered Alphabetically for easier viewing in Swagger UI +api.add_namespace(accounts_api) +api.add_namespace(apikeys_api) +api.add_namespace(authentication_api) +api.add_namespace(backup_api) +api.add_namespace(discord_api) +api.add_namespace(healthcheck_api) +api.add_namespace(invitations_api) +api.add_namespace(jellyfin_api) +api.add_namespace(libraries_api) +api.add_namespace(logging_api) +api.add_namespace(mfa_api) +api.add_namespace(notifications_api) +api.add_namespace(oauth_api) +api.add_namespace(plex_api) +api.add_namespace(requests_api) +api.add_namespace(scan_libraries_api) +api.add_namespace(server_api) +api.add_namespace(setup_api) +api.add_namespace(sessions_api) +api.add_namespace(settings_api) +api.add_namespace(tasks_api) +api.add_namespace(users_api) +api.add_namespace(utilities_api) +api.add_namespace(webhooks_api) + +# Potentially remove this if it becomes unstable +# api.add_namespace(live_notifications_api) + +# TODO: Tasks API +# TODO: API API +# TODO: HTML API diff --git a/backend/api/routes/accounts_api.py b/backend/api/routes/accounts_api.py new file mode 100644 index 000000000..ba4ffb083 --- /dev/null +++ b/backend/api/routes/accounts_api.py @@ -0,0 +1,91 @@ +from flask import request +from flask_jwt_extended import jwt_required, current_user +from flask_restx import Namespace, Resource + +from helpers.accounts import create_account, get_accounts, get_account_by_id, delete_account, update_account + +from api.models.accounts import AccountsGET, AccountsPOST + + +api = Namespace("Accounts", description="Accounts related operations", path="/accounts") + +api.add_model("AccountsGET", AccountsGET) +api.add_model("AccountsPOST", AccountsPOST) + + +@api.route("") +@api.route("/", doc=False) +@api.doc(security=["jsonWebToken", "cookieAuth"]) +class AccountsListAPI(Resource): + """API resource for all accounts""" + + method_decorators = [jwt_required()] + + @api.marshal_list_with(AccountsGET) + @api.doc(description="Get all accounts") + @api.response(200, "Successfully retrieved all accounts") + @api.response(500, "Internal server error") + def get(self): + """Get all accounts from the database""" + return get_accounts(), 200 + + + @api.expect(AccountsPOST) + @api.marshal_with(AccountsGET) + @api.doc(description="Create a new account") + @api.response(200, "Successfully created a new account") + @api.response(400, "Invalid account") + @api.response(409, "Account already exists") + @api.response(500, "Internal server error") + def post(self): + """Create a new account""" + return create_account(**request.form), 200 + + + @api.doc(description="Update account") + @api.response(200, "Successfully updated account") + @api.response(500, "Internal server error") + def put(self): + """Update account""" + return update_account(current_user['id'], **request.form), 200 + + +@api.route("/") +@api.route("//", doc=False) +@api.doc(security=["jsonWebToken", "cookieAuth"]) +class AccountsAPI(Resource): + """API resource for a single account""" + + method_decorators = [jwt_required()] + + @api.marshal_with(AccountsGET) + @api.doc(description="Get an account") + @api.response(200, "Successfully retrieved an account") + @api.response(404, "Account not found") + @api.response(500, "Internal server error") + def get(self, account_id: str): + """Get an account""" + return get_account_by_id(account_id), 200 + + + @api.expect(AccountsPOST) + @api.marshal_with(AccountsGET) + @api.doc(description="Update an account") + @api.response(200, "Successfully updated an account") + @api.response(400, "Invalid account") + @api.response(404, "Account not found") + @api.response(500, "Internal server error") + def put(self, account_id: int): + """Update an account""" + response = update_account(account_id, **request.form) + return response, 200 + + + @api.doc(description="Delete an account") + @api.response(200, "Successfully deleted an account") + @api.response(404, "Account not found") + @api.response(500, "Internal server error") + def delete(self, account_id: str) -> tuple[dict[str, str], int]: + """Delete an account""" + delete_account(account_id) + return {"message": "Account deleted"}, 200 diff --git a/backend/api/routes/apikeys_api.py b/backend/api/routes/apikeys_api.py new file mode 100644 index 000000000..4d30cadfc --- /dev/null +++ b/backend/api/routes/apikeys_api.py @@ -0,0 +1,62 @@ +from flask import request +from flask_jwt_extended import jwt_required, current_user, create_access_token, get_jti +from flask_restx import Namespace, Resource +from app.models.database.api_keys import APIKeys +from json import loads, dumps +from playhouse.shortcuts import model_to_dict +from datetime import datetime + +api = Namespace('API Keys', description='API Keys related operations', path='/apikeys') + +@api.route("") +class APIKeysListAPI(Resource): + + method_decorators = [jwt_required()] + + @api.doc(description="Get all API Keys") + @api.response(200, "Successfully retrieved all API Keys") + def get(self): + """Get all API Keys""" + response = list(APIKeys.select().where(APIKeys.user_id == current_user['id']).dicts()) + return loads(dumps(response, indent=4, sort_keys=True, default=str)), 200 + + @api.doc(description="Create an API Key") + @api.response(200, "Successfully created an API Key") + def post(self): + """Create an API Key""" + token = create_access_token(fresh=False, identity=current_user['id'], expires_delta=False) + jti = get_jti(encoded_token=token) + + api_key = APIKeys.create( + name=str(request.form.get("name")), + key=str(token), + jti=str(jti), + user_id=str(current_user['id']) + ) + + response = model_to_dict(APIKeys.get(APIKeys.id == api_key)) + return loads(dumps(response, indent=4, sort_keys=True, default=str)), 200 + +@api.route("/") +class APIKeysAPI(Resource): + + method_decorators = [jwt_required()] + + @api.doc(description="Get a single API Key") + @api.response(200, "Successfully retrieved API Key") + def get(self, api_key_id): + """Get a single API Key""" + api_key = APIKeys.get_or_none(APIKeys.id == api_key_id and APIKeys.user_id == current_user['id']) + if not api_key: + return {"message": "API Key not found"}, 404 + return loads(dumps(model_to_dict(api_key), indent=4, sort_keys=True, default=str)), 200 + + @api.doc(description="Delete a single API Key") + @api.response(200, "Successfully deleted API Key") + def delete(self, api_key_id): + """Delete a single API Key""" + api_key = APIKeys.get_or_none(APIKeys.id == api_key_id and APIKeys.user_id == current_user['id']) + if not api_key: + return {"message": "API Key not found"}, 404 + api_key.delete_instance() + return {"message": "API Key deleted"}, 200 diff --git a/backend/api/routes/authentication_api.py b/backend/api/routes/authentication_api.py new file mode 100644 index 000000000..d3bbad42b --- /dev/null +++ b/backend/api/routes/authentication_api.py @@ -0,0 +1,54 @@ +from flask import request +from flask_restx import Namespace, Resource + +from flask_jwt_extended import jwt_required, get_jwt_identity +from app.models.wizarr.authentication import AuthenticationModel +from api.models.authentication import LoginPOST + +api = Namespace(name="Authentication", description="Authentication related operations", path="/auth") + +api.add_model("LoginPOST", LoginPOST) + +@api.route("/login") +@api.route("/login/", doc=False) +class Login(Resource): + """API resource for logging in""" + + @api.expect(LoginPOST) + @api.doc(description="Login to the application") + @api.response(200, "Login successful") + @api.response(401, "Invalid Username or Password") + @api.response(500, "Internal server error") + def post(self): + """Login to the application""" + auth = AuthenticationModel(request.form) + return auth.login_user() + +@api.route("/refresh") +@api.route("/refresh/", doc=False) +class Refresh(Resource): + """API resource for refreshing the JWT token""" + + method_decorators = [jwt_required(refresh=True)] + + @api.doc(description="Refresh the JWT token") + @api.response(200, "Token refreshed") + @api.response(401, "Invalid refresh token") + @api.response(500, "Internal server error") + def post(self): + """Refresh the JWT token""" + return AuthenticationModel.refresh_token() + +@api.route("/logout") +@api.route("/logout/", doc=False) +class Logout(Resource): + """API resource for logging out""" + + method_decorators = [jwt_required()] + + @api.doc(description="Logout the currently logged in user") + @api.response(200, "Logout successful") + @api.response(500, "Internal server error") + def post(self): + """Logout the currently logged in user""" + return AuthenticationModel.logout_user() diff --git a/backend/api/routes/backup_api.py b/backend/api/routes/backup_api.py new file mode 100644 index 000000000..96233abed --- /dev/null +++ b/backend/api/routes/backup_api.py @@ -0,0 +1,164 @@ +from datetime import datetime + +from cryptography.fernet import InvalidToken +from flask import make_response, request +from flask_jwt_extended import jwt_required +from flask_restx import Namespace, Resource +from json import loads +from app.security import is_setup_required + +from app.utils.backup import backup_database, encrypt_backup, generate_key, decrypt_backup, restore_database + +api = Namespace("Backup", description="Backup related operations", path="/backup") + +@api.route("/download") +@api.route("/download/", doc=False) +class BackupDownload(Resource): + """Backup related operations""" + + method_decorators = [jwt_required()] + + @api.doc(security="jwt") + def post(self): + # Get the password from the request + password = request.form.get("password", None) + + # If the password is None, return an error + if password is None: + raise ValueError("Password is required") + + try: + # Backup the database + backup_unencrypted = backup_database() + backup_encrypted = encrypt_backup(backup_unencrypted, generate_key(password)) + except InvalidToken: + return { "message": "Invalid password" }, 400 + + if backup_encrypted is None: + return { "message": "An unknown error occurred" }, 400 + + # Make a response as a download file + file_name = f"wizarr-{datetime.now().strftime('%Y-%m-%d-%H-%M-%S')}.backup" + + response = make_response(backup_encrypted) + response.headers["Content-Disposition"] = f"attachment; filename={file_name}" + response.headers["Content-Type"] = "application/octet-stream" + + # Return the response + return response + + +@api.route("/restore") +@api.route("/restore/", doc=False) +class BackupUpload(Resource): + """Backup related operations""" + + method_decorators = [] if is_setup_required() else [jwt_required()] + + @api.doc(security="jwt") + def post(self): + # Get the posted file and password + backup_file = request.files["backup"] + password = request.form.get("password", None) + + print(password) + + # Check if the file exists + if not backup_file: + raise FileNotFoundError("File not found") + + # If the password is None, return an error + if password is None: + raise ValueError("Password is required") + + try: + # Decrypt the backup + data = decrypt_backup(backup_file.read(), generate_key(password)) + except InvalidToken: + return { "message": "Invalid password" }, 400 + + # Restore the backup + if data is None or not restore_database(data): + return { "message": "An unknown error occurred" }, 400 + + # Return the response + return { "message": "Backup restored successfully" } + + +@api.route("/decrypt") +@api.route("/decrypt/", doc=False) +class BackupDecrypt(Resource): + """Backup related operations""" + + method_decorators = [jwt_required()] + + @api.doc(security="jwt") + def post(self): + # Get the posted file and password + backup_file = request.files["backup"] + password = request.form.get("password", None) + + # Check if the file exists + if not backup_file: + raise FileNotFoundError("File not found") + + # If the password is None, return an error + if password is None: + raise ValueError("Password is required") + + try: + backup_decrypted = decrypt_backup(backup_file.read(), generate_key(password)) + except InvalidToken: + return { "error": "Invalid password" }, 400 + + if backup_decrypted is None: + return { "error": "An unknown error occurred" } + + # Create file name based on input file name + file_name = backup_file.filename.replace(".backup", ".json") + + response = make_response(backup_decrypted) + response.headers["Content-Disposition"] = f"attachment; filename={file_name}" + response.headers["Content-Type"] = "application/json" + + # Return the response + return response + + +@api.route("/encrypt") +@api.route("/encrypt/", doc=False) +class BackupEncrypt(Resource): + """Backup related operations""" + + method_decorators = [jwt_required()] + + @api.doc(security="jwt") + def post(self): + # Get the posted file and password + backup_file = request.files["backup"] + password = request.form.get("password", None) + + # Check if the file exists + if not backup_file: + raise FileNotFoundError("File not found") + + # If the password is None, return an error + if password is None: + raise ValueError("Password is required") + + # Encrypt the backup + backup_decrypted = loads(backup_file.read()) + backup_encrypted = encrypt_backup(backup_decrypted, generate_key(password)) + + if backup_encrypted is None: + return { "error": "An unknown error occurred" } + + # Create file name based on input file name + file_name = backup_file.filename.replace(".json", ".backup") + + response = make_response(backup_encrypted) + response.headers["Content-Disposition"] = f"attachment; filename={file_name}" + response.headers["Content-Type"] = "application/octet-stream" + + # Return the response + return response diff --git a/backend/api/routes/discord_api.py b/backend/api/routes/discord_api.py new file mode 100644 index 000000000..cc4434298 --- /dev/null +++ b/backend/api/routes/discord_api.py @@ -0,0 +1,45 @@ +from flask import request +from flask_jwt_extended import jwt_required, current_user +from flask_restx import Namespace, Resource +from playhouse.shortcuts import model_to_dict + +from app.models.database.discord import Discord as DiscordDB + +api = Namespace("Discord", description="OAuth related operations", path="/discord") + +@api.route("/bot") +@api.route("/bot/", doc=False) +class Discord(Resource): + """Discord related operations""" + + method_decorators = [jwt_required()] + + @api.doc(security="jwt") + def get(self): + """Get discord info""" + data = DiscordDB.get() + + return model_to_dict(data), 200 + + @api.doc(security="jwt") + def post(self): + """Update discord info""" + # Get the discord token from the request + token = request.form.get("token") + enabled = request.form.get("enabled") + + # Check if the token is valid + if not token or not enabled: + return { "message": "Invalid data" }, 400 + + # If first db entry, create it otherwise update it + discord_bot, _ = DiscordDB.get_or_create(id=1) + + # Update the token + discord_bot.token = token + discord_bot.enabled = enabled in ["true", "True", "1"] + + # Save the entry + discord_bot.save() + + return { "message": "Discord settings updated", "data": model_to_dict(discord_bot) }, 200 diff --git a/backend/api/routes/healthcheck_api.py b/backend/api/routes/healthcheck_api.py new file mode 100644 index 000000000..351f38115 --- /dev/null +++ b/backend/api/routes/healthcheck_api.py @@ -0,0 +1,33 @@ +from flask_jwt_extended import current_user +from flask_restx import Namespace, Resource + +from psutil import boot_time +from app.utils.software_lifecycle import get_current_version, need_update +from app.extensions import cache + + +api = Namespace("Healthcheck", description="Healthcheck related operations", path="/health") + +@api.route("") +@api.route("/", doc=False) +class Healthcheck(Resource): + """Healthcheck related operations""" + + @cache.cached(timeout=3600) + def get_need_update(self): + return need_update() + + def get(self): + """Get the health of the application""" + from app import app + from app.security import is_setup_required + + resp = { + "uptime": str(boot_time()), + "status": "OK", + "version": str(get_current_version()), + "update_available": self.get_need_update(), + "debug": True if app.debug else False + } + + return resp, 200 diff --git a/backend/api/routes/invitations_api.py b/backend/api/routes/invitations_api.py new file mode 100644 index 000000000..179a4c032 --- /dev/null +++ b/backend/api/routes/invitations_api.py @@ -0,0 +1,114 @@ +from flask import request +from flask_restx import Namespace, Resource +from flask_jwt_extended import jwt_required +from playhouse.shortcuts import model_to_dict +from json import loads, dumps +from datetime import datetime + +from app.models.database.invitations import Invitations +from app.models.wizarr.invitations import InvitationsModel + +from helpers.webhooks import run_webhook + +api = Namespace("Invitations", description="Invites related operations", path="/invitations") + + +@api.route("") +class InvitationsListAPI(Resource): + """API resource for all invites""" + + method_decorators = [jwt_required()] + + @api.doc(description="Get all invites") + @api.response(500, "Internal server error") + def get(self): + response = list(Invitations.select().dicts()) + + # Convert the specific_libraries and used_by fields to lists + for invite in response: + if invite["specific_libraries"] is not None: + invite["specific_libraries"] = invite["specific_libraries"].split(",") + + if invite["used_by"] is not None: + invite["used_by"] = invite["used_by"].split(",") + + return loads(dumps(response, indent=4, sort_keys=True, default=str)), 200 + + @api.doc(description="Create an invite") + @api.response(500, "Internal server error") + def post(self): + # Initialize the invite + invite = InvitationsModel(request.form) + + # Validate the invite + invite.validate() + + # Create the invite + new_invite = invite.create_invitation() + + # Send the webhook + run_webhook("invitation_created", new_invite) + + # Create the invite + return new_invite, 200 + + +@api.route("/") +class InvitationsAPI(Resource): + """API resource for a single invite""" + + method_decorators = [jwt_required()] + + @api.doc(description="Get a single invite") + @api.response(404, "Invite not found") + @api.response(500, "Internal server error") + def get(self, invite_id): + # Select the invite from the database + invite = Invitations.get_or_none(Invitations.id == invite_id) + + # Check if the invite exists + if not invite: + return {"message": "Invite not found"}, 404 + + return model_to_dict(invite, exclude=[Invitations.created, Invitations.expires]), 200 + + @api.doc(description="Delete a single invite") + @api.response(404, "Invite not found") + @api.response(500, "Internal server error") + def delete(self, invite_id): + # Select the invite from the database + invite = Invitations.get_or_none(Invitations.id == invite_id) + + # Check if the invite exists + if not invite: + return {"message": "Invite not found"}, 404 + + # Delete the invite + invite.delete_instance() + + # Send the webhook + run_webhook("invitation_deleted", model_to_dict(invite)) + + return {"message": "Invite deleted successfully"}, 200 + +@api.route("//verify") +class InvitationsVerifyAPI(Resource): + """API resource for verifying an invite""" + + @api.doc(description="Verify an invite") + @api.response(404, "Invite not found") + @api.response(500, "Internal server error") + def get(self, invite_code): + # Select the invite from the database + invitation: Invitations = Invitations.get_or_none(Invitations.code == invite_code) + + if not invitation: + return {"message": "Invitation not found"}, 404 + + if invitation.used is True and invitation.unlimited is not True: + return {"message": "Invitation has already been used"}, 400 + + if invitation.expires and invitation.expires <= datetime.utcnow(): + return {"message": "Invitation has expired"}, 400 + + return {"message": "Invitation is valid"}, 200 diff --git a/backend/api/routes/jellyfin_api.py b/backend/api/routes/jellyfin_api.py new file mode 100644 index 000000000..752d13bb3 --- /dev/null +++ b/backend/api/routes/jellyfin_api.py @@ -0,0 +1,41 @@ +from flask import request +from flask_restx import Namespace, Resource + +from flask_socketio import join_room +from app.extensions import socketio +from os import urandom +from helpers.universal import global_invite_user_to_media_server + +api = Namespace("Jellyfin", description="Jellyfin related operations", path="/jellyfin") + +@socketio.on("connect", namespace="/jellyfin") +def connect(): + print("Client connected") + +@api.route("") +@api.route("/", doc=False) +class JellyfinStream(Resource): + """Listen for Jellyfin authentication events""" + + def post(self): + # Get the username, password, code, and socket ID from the request + username = request.form.get("username", None) + email = request.form.get("email", None) + password = request.form.get("password", None) + code = request.form.get("code", None) + socket_id = request.form.get("socket_id", None) + + # Check for missing Socket ID + if socket_id is None: + return { "message": "Missing socket ID" }, 400 + + # Create a room with a random ID + random_id = urandom(16).hex() + join_room(random_id, sid=socket_id, namespace="/jellyfin") + + # Send a message to the user and start a background task + socketio.emit("message", f"Hello {username}!", namespace="/jellyfin", to=socket_id) + socketio.start_background_task(target=global_invite_user_to_media_server, username=username, email=email, password=password, code=code, socket_id=socket_id) + + # Return the room ID and the username + return { "room": random_id, "username": username }, 200 diff --git a/backend/api/routes/libraries_api.py b/backend/api/routes/libraries_api.py new file mode 100644 index 000000000..b0f196b2b --- /dev/null +++ b/backend/api/routes/libraries_api.py @@ -0,0 +1,42 @@ + +from json import loads +from logging import info +from typing import Optional + +from flask import request +from flask_jwt_extended import jwt_required +from flask_restx import Namespace, Resource + +from helpers.libraries import get_libraries +from app.models.wizarr.libraries import LibrariesModel +from app.security import is_setup_required + +api = Namespace('Libraries', description='Libraries related operations', path="/libraries") + +@api.route('') +class LibrariesListAPI(Resource): + """API resource for all libraries""" + + method_decorators = [] if is_setup_required() else [jwt_required()] + + @api.doc(description="Get all libraries") + @api.response(200, "Successfully retrieved all libraries") + @api.response(500, "Internal server error") + def get(self): + # Get all libraries + return get_libraries(), 200 + + + @api.doc(description="") + @api.response(500, "Internal server error") + def post(self): + # Get the libraries from the request + libraries = LibrariesModel(request.form) + + # Update the libraries in the database + libraries.update_libraries() + + # Create the response + response = { "message": "Libraries updated", "libraries": libraries.libraries } + + return response, 200 diff --git a/backend/api/routes/live_notifications_api.py b/backend/api/routes/live_notifications_api.py new file mode 100644 index 000000000..3c2a84dfc --- /dev/null +++ b/backend/api/routes/live_notifications_api.py @@ -0,0 +1,33 @@ +from flask import redirect, request +from flask_jwt_extended import current_user, jwt_required +from flask_restx import Model, Namespace, Resource, fields + +api = Namespace('Stream', description='Create a live notification stream', path="/stream") + +@api.route('') +class StreamAPI(Resource): + + method_decorators = [jwt_required()] + + def get(self): + from app import sse + + code = current_user.get('username').encode("utf-8").hex() + sse.announcer(code) + return redirect(f"/api/stream/{code}") + + +@api.route('/') +class StreamGetAPI(Resource): + + method_decorators = [jwt_required()] + + def get(self, stream_id): + from app import sse + + user_id = bytes.fromhex(stream_id).decode("utf-8") + + if user_id != current_user.get('username'): + return "Unauthorized", 401 + + return sse.response(stream_id) diff --git a/backend/api/routes/logging_api.py b/backend/api/routes/logging_api.py new file mode 100644 index 000000000..056786103 --- /dev/null +++ b/backend/api/routes/logging_api.py @@ -0,0 +1,47 @@ +from os import path, stat, linesep +from flask import send_file +from flask_jwt_extended import jwt_required, current_user +from flask_restx import Namespace, Resource +from io import BytesIO +from app.extensions import socketio +from termcolor import colored +from time import sleep + +api = Namespace("Logging", description="Logging related operations", path="/logging") +log_file = path.abspath(path.join(path.dirname(__file__), "../", "../", "../", "database", "logs.log")) +task = None + +def watch_log_file(file_path): + last_position = 0 + while True: + with open(file_path, 'r', encoding='utf-8') as file: + file.seek(last_position) + new_lines = file.readlines() + if new_lines: + last_position = file.tell() + for line in new_lines: + socketio.emit("log", line, namespace="/logging") + sleep(0.1) + +@socketio.on("connect", namespace="/logging") +@jwt_required() +def connect(): + socketio.emit("log", f"User {current_user['username']} connected to log stream.", namespace="/logging") + socketio.start_background_task(watch_log_file, file_path=log_file) + +@api.route("/text") +@api.route("/text/", doc=False) +@api.doc(security=["jsonWebToken", "cookieAuth"]) +class LogAPI(Resource): + """API resource for downloading logs""" + + method_decorators = [jwt_required()] + + @staticmethod + def get(): + with open(log_file, "rb") as f: + log_data = f.read() + + log_data_io = BytesIO(log_data) + + return send_file(log_data_io, mimetype="text/plain") diff --git a/backend/api/routes/mfa_api.py b/backend/api/routes/mfa_api.py new file mode 100644 index 000000000..17ac38d34 --- /dev/null +++ b/backend/api/routes/mfa_api.py @@ -0,0 +1,467 @@ +from base64 import urlsafe_b64decode, urlsafe_b64encode +from logging import info +from secrets import token_hex +from json import loads, dumps + +from flask import jsonify, make_response, request, session +from flask_jwt_extended import current_user, jwt_required +from flask_restx import Namespace, Resource +from playhouse.shortcuts import model_to_dict + +from webauthn import generate_authentication_options +from webauthn import generate_registration_options +from webauthn import options_to_json +from webauthn import verify_authentication_response +from webauthn import verify_registration_response + +from webauthn.helpers.structs import AuthenticationCredential +from webauthn.helpers.structs import AuthenticatorSelectionCriteria +from webauthn.helpers.structs import COSEAlgorithmIdentifier +from webauthn.helpers.structs import PublicKeyCredentialCreationOptions +from webauthn.helpers.structs import PublicKeyCredentialRequestOptions +from webauthn.helpers.structs import RegistrationCredential +from webauthn.helpers.structs import UserVerificationRequirement + +from app.models.database.accounts import Accounts +from app.models.database.sessions import Sessions +from app.models.database.mfa import MFA +from app.models.wizarr.accounts import AccountsModel +from app.models.wizarr.authentication import AuthenticationModel + +api = Namespace("MFA", description="MFA related operations", path="/mfa") + + +@api.route("") +@api.route("/", doc=False) +class MFAListAPI(Resource): + """ + Get all MFA devices + """ + + @jwt_required() + def get(self): + + # Check if there is a current user + if not current_user: + return {"message": "No user found"}, 401 + + response = list(MFA.select().where(MFA.user_id == current_user["id"]).dicts()) + return loads(dumps(response, indent=4, sort_keys=True, default=str)), 200 + + +@api.route("/") +class MFAAPI(Resource): + """ + Manage a specific MFA device + """ + + @jwt_required() + def get(self, mfa_id: int): + """ + Get a specific MFA device + """ + + # Check if there is a current user + if not current_user: + return {"message": "No user found"}, 401 + + # Get the MFA + mfa: MFA = MFA.get_or_none(MFA.id == mfa_id) + + # Check if the MFA exists + if not mfa: + return {"message": "MFA not found"}, 404 + + # Check if the MFA belongs to the current user + if int(mfa.user_id) != int(current_user["id"]): + return {"message": "MFA not found"}, 404 + + # Return the response + return jsonify(model_to_dict(mfa)), 200 + + + @jwt_required() + def put(self, mfa_id: int): + """ + Update a specific MFA device + """ + + # Check if there is a current user + if not current_user: + return {"message": "No user found"}, 401 + + # Get the MFA + mfa: MFA = MFA.get_or_none(MFA.id == mfa_id) + + # Check if the MFA exists + if not mfa: + return {"message": "MFA not found"}, 404 + + # Check if the MFA belongs to the current user + if int(mfa.user_id) != int(current_user["id"]): + return {"message": "MFA not found"}, 404 + + # Get the request data + data = request.get_json() + + # Check if the request data is valid + if not data: + return {"message": "Invalid request data"}, 400 + + # Check if the request data has the required fields + if "name" not in data: + return {"message": "Invalid request data"}, 400 + + # Update the MFA + mfa.name = data["name"] + mfa.save() + + # Return the response + return {"message": "MFA name updated"}, 200 + + + @jwt_required() + def delete(self, mfa_id: int): + """ + Delete a specific MFA device + """ + + # Check if there is a current user + if not current_user: + return {"message": "No user found"}, 401 + + # Get the MFA and MFA session + mfa: MFA = MFA.get_or_none(MFA.id == mfa_id) + mfa_session: Sessions = Sessions.get_or_none(Sessions.mfa_id == mfa.id) + + # Check if the MFA exists + if not mfa: + return {"message": "MFA not found"}, 404 + + # Check if the MFA belongs to the current user + if int(mfa.user_id) != int(current_user["id"]): + return {"message": "MFA not found"}, 404 + + # Check if the MFA session exists + if mfa_session: + # Delete the MFA session + mfa_session.delete_instance() + + # Delete the MFA + mfa.delete_instance() + + # Return the response + return {"message": "MFA deleted"}, 200 + + +@api.route("/registration") +class MFARegisterAPI(Resource): + """ + Register a new MFA device + """ + + @jwt_required() + @api.doc(body=False) + def get(self): + """ + Register a new MFA device + """ + + # Check if there is a current user + if not current_user: + return {"message": "No user found"}, 401 + + # Get host and protocol forwarded from nginx + host = request.headers.get("X-Forwarded-Host", request.host) + + # Remove port from host if it exists + if ":" in host: + host = host.split(":")[0] + + # Get the MFAs for the current user + user_mfas: list[MFA] = MFA.select().where(MFA.user_id == current_user["id"]) + + # Create the registration variables + rp_id = host + rp_name = "Wizarr" + user_id = str(current_user["id"]) + user_name = str(current_user["username"]) + + authenticator_selection = AuthenticatorSelectionCriteria( + user_verification = UserVerificationRequirement.REQUIRED + ) + + supported_pub_key_algs = [ + COSEAlgorithmIdentifier.ECDSA_SHA_256, + COSEAlgorithmIdentifier.RSASSA_PKCS1_v1_5_SHA_256, + ] + + exclude_credentials = [ + { "id": urlsafe_b64decode(cred.credential_id), "transports": cred.transports.split(","), "type": "public-key" } + for cred in user_mfas + ] + + # Generate the registration options + registration_options = generate_registration_options( + rp_id = rp_id, + rp_name = rp_name, + user_id = user_id, + user_name = user_name, + authenticator_selection = authenticator_selection, + supported_pub_key_algs = supported_pub_key_algs, + exclude_credentials = exclude_credentials + ) + + # Store the options in the session + session["registration_options"] = registration_options + + # Return the response + response = make_response(options_to_json(registration_options), 200) + response.headers["Content-Type"] = "application/json" + return response + + + @jwt_required() + def post(self): + """ + Verify the MFA registration + """ + + # Check if there is a current user + if not current_user: + return {"message": "No user found"}, 401 + + # Get the registration options from the session + registration_options: PublicKeyCredentialCreationOptions = session.get("registration_options", None) + + # Check if the registration options are valid + if not registration_options: + return {"message": "No registration options found"}, 400 + + + # Get the registration response + registration = request.get_json() + + credential = registration.get("registration") + name = registration.get("name", None) + transports = loads(credential).get("response", "{}").get("transports", "[]") + + # Check if the registration response is valid + if not registration.get("registration", None): + return {"message": "No registration found"}, 400 + + if not registration.get("origin", None): + return {"message": "No origin found"}, 400 + + + # Verify the registration response + verified_credential = verify_registration_response( + credential=RegistrationCredential.parse_raw(credential), + expected_challenge=registration_options.challenge, + expected_rp_id = registration_options.rp.id, + expected_origin = registration.get("origin") + ) + + # Get the credential ID and public key + credential_id = urlsafe_b64encode(verified_credential.credential_id) + public_key = urlsafe_b64encode(verified_credential.credential_public_key) + sign_count = verified_credential.sign_count + attestation = urlsafe_b64encode(verified_credential.attestation_object) + + # Add the credential to the users MFA credentials + mfa_id = MFA.create( + name = name if name else "MFA Device: " + token_hex(6), + user_id = current_user["id"], + aaguid = verified_credential.aaguid, + credential_id = credential_id, + public_key = public_key, + sign_count = sign_count, + attestation = attestation, + transports = ",".join(transports) + ) + + # Get the MFA details + mfa = model_to_dict(MFA.get_or_none(MFA.id == mfa_id)) + return loads(dumps(mfa, indent=4, sort_keys=True, default=str)), 200 + + +@api.route("/authentication") +class MFAAuthenticateAPI(Resource): + """ + Authenticate with an MFA device + """ + + @api.doc(body=False) + def get(self): + """ + Authenticate with an MFA device + """ + + # Get host and protocol + host = request.headers.get("X-Forwarded-Host", request.host) + + # Remove port from host if it exists + if ":" in host: + host = host.split(":")[0] + + # Create the authentication variables + rp_id = host + + # Get the username from the request + username = request.args.get("username", None) + allow_credentials = None + + # If the username is provided, allow only the credentials for that user + if username: + + # Get the user from the database + user = Accounts.get_or_none(Accounts.username == username) + + # Check if the user exists + if not user: + return {"message": "User not found"}, 404 + + # Get the MFAs for the current user + user_mfas: list[MFA] = MFA.select().where(MFA.user_id == user.id) + + allow_credentials = [ + { "id": urlsafe_b64decode(cred.credential_id), "transports": cred.transports.split(","), "type": "public-key" } + for cred in user_mfas + ] + + + # Generate the authentication options + authentication_options = generate_authentication_options( + rp_id = rp_id, + user_verification = UserVerificationRequirement.REQUIRED, + allow_credentials = allow_credentials + ) + + # Store the options in the session + session["authentication_options"] = authentication_options + + # Return the response + response = make_response(options_to_json(authentication_options), 200) + response.headers["Content-Type"] = "application/json" + return response + + + def post(self): + """ + Verify the MFA authentication + """ + + # Get host and protocol + host = request.headers.get("X-Forwarded-Host", request.host) + + # Remove port from host if it exists + if ":" in host: + host = host.split(":")[0] + + # Create the authentication variables + rp_id = host + + # Get the authentication response + authentication = request.get_json() + + # Check if the authentication response is valid + if not authentication.get("assertion", None) or not authentication.get("origin", None): + return {"message": "Invalid authentication response"}, 400 + + # Get the variables from the authentication response + assertion = AuthenticationCredential.parse_raw(authentication.get("assertion", None)) + origin = authentication.get("origin", None) + + # Get the MFA credentials for the user based on credential ID + mfa_credentials: MFA = MFA.get_or_none(MFA.credential_id == urlsafe_b64encode(assertion.raw_id)) + + # Check if the user has any MFA credentials + if not mfa_credentials: + return {"message": "MFA not found"}, 404 + + + # Get the authentication options from the session + authentication_options: PublicKeyCredentialRequestOptions = session.get("authentication_options", None) + + # Check if the authentication options are valid + if not authentication_options: + return {"message": "No authentication options found"}, 400 + + # Get variables from the mfa credentials + public_key = urlsafe_b64decode(mfa_credentials.public_key) + sign_count = mfa_credentials.sign_count + + # Verify the authentication response + verified_credential = verify_authentication_response( + credential=assertion, + expected_challenge=authentication_options.challenge, + expected_rp_id=rp_id, + expected_origin=origin, + credential_public_key=public_key, + credential_current_sign_count=sign_count, + require_user_verification=True + ) + + # Update the sign count + mfa_credentials.sign_count = verified_credential.new_sign_count + mfa_credentials.save() + + # Log the user in + auth = AuthenticationModel({}, mfa=True, user_id=mfa_credentials.user_id, mfa_id=mfa_credentials.id) + + # Get token for user + access_token = auth.get_access_token() + refresh_token = auth.get_refresh_token(access_token) + + # Get the admin user + user = auth.get_admin() + + # Create a response object + resp = jsonify({ + "message": "Successfully authenticated with MFA device", + "auth": { + "token": access_token, + "refresh_token": refresh_token, + "user": AccountsModel(model_to_dict(user, exclude=[Accounts.password])).to_primitive(), + } + }) + + # Log message and return response + info(f"Account {user.username} successfully logged in") + + return resp + + + @api.route("/available") + class MFAAvailableAPI(Resource): + """ + Check if MFA is available for specific user + """ + + def post(self): + """ + Check if MFA is available for specific user + """ + + # Get username from request body + username = request.get_json()["username"] + + # Check if the username is valid + if not username: + return {"message": "No username found"}, 400 + + # Get the user from the database + user = Accounts.get_or_none(Accounts.username == username.lower()) + + # Check if the user exists + if not user: + return {"message": "User does not exist"}, 404 + + # Get the MFA credentials for the user + mfa_credentials: MFA = MFA.select().where(MFA.user_id == user.id) + + # Count the number of MFA credentials + mfa_credentials_count = mfa_credentials.count() + + # Return the response + return {"mfa_available": mfa_credentials_count > 0}, 200 diff --git a/backend/api/routes/notifications_api.py b/backend/api/routes/notifications_api.py new file mode 100644 index 000000000..4f7f15a35 --- /dev/null +++ b/backend/api/routes/notifications_api.py @@ -0,0 +1,130 @@ +from logging import info + +from flask import request +from flask_jwt_extended import jwt_required, current_user +from flask_restx import Namespace, Resource + +from app.notifications.exceptions import InvalidNotificationAgent +from app.models.notifications import NotificationsGetModel, NotificationsPostModel +from app.models.database.notifications import Notifications +from app.notifications.builder import get_web_resources, validate_resource + +api = Namespace('Notifications', description='Notifications related operations', path="/notifications") + +# api.add_model("NotificationsPostModel", NotificationsPostModel) +# api.add_model("NotificationsGetModel", NotificationsGetModel) + +@api.route('') +@api.route('/', doc=False) +@api.doc(security=["jsonWebToken", "cookieAuth"]) +class NotificationsListAPI(Resource): + """Notifications related operations""" + + method_decorators = [jwt_required()] + + def get(self, query=None) -> tuple[list[Notifications], int]: + # Get all notifications from the database for the current user + user_id = current_user['id'] + + # Get all notifications for the current user + notifications = Notifications.select().where(Notifications.user_id == user_id) + responses = [] + + # Loop through each notification + for notification in notifications: + # Validate the notification resource + resource = validate_resource(notification.resource, notification.data) + + # Add the notification to the response + response = resource.to_primitive() + response['id'] = notification.id + + # Respond to query parameters + query = request.args.get('query') or query + + # If query has , in it, split it into a list + if query is not None and ',' in query: + query = query.split(',') + + def get_query(query, resource, response): + if not hasattr(resource, query): return + + # Get the attribute from the resource and add it to the response + response[query] = getattr(resource, query) + + # If the attribute is a function, call it + if callable(response[query]): + response[query] = response[query]() + + # If query is a list, loop through each query + if query is not None and isinstance(query, list): + for q in query: + get_query(q, resource, response) + elif query is not None and isinstance(query, str): + get_query(query, resource, response) + + # Add the response to the responses list + responses.append(response) + + # Respond with all notifications for the current user + return responses, 200 + + def post(self) -> tuple[dict[str, str], int]: + # Get the request body + body = request.form + + # Make sure we have a resource + if 'resource' not in body: + return {"message": "Missing resource"}, 400 + + # Get the notification resource + resource = validate_resource( + body.get('resource'), + body + ) + + # Save the notification in the database + resource.save() + + # Respond with the notification + return resource.to_primitive(), 200 + + +@api.route('/') +@api.route('//', doc=False) +@api.doc(security=["jsonWebToken", "cookieAuth"]) +class NotificationsAPI(Resource): + + method_decorators = [jwt_required()] + + def delete(self, notification_id: int) -> tuple[dict[str, str], int]: + # Get the notification from the database + notification = Notifications.get_or_none(Notifications.id == notification_id) + + # If the notification does not exist, respond with a 404 + if notification is None: + return {"message": "Notification does not exist"}, 404 + + # If the notification does not belong to the current user, respond with a 403 + if str(notification.user_id) != str(current_user['id']): + return {"message": "Notification does not belong to the current user"}, 403 + + # Delete the notification from the database + notification.delete_instance() + + # Respond with a 200 + return {"message": "Notification deleted successfully"}, 200 + + +@api.route('/resources') +@api.route('/resources/', doc=False) +@api.doc(security=["jsonWebToken", "cookieAuth"]) +class NotificationsResourcesAPI(Resource): + """Get all notification resources""" + + def get(self) -> tuple[dict[str, str], int]: + # Get all notification resources + resources = get_web_resources() + + # Respond with all notification resources + return resources, 200 diff --git a/backend/api/routes/oauth_api.py b/backend/api/routes/oauth_api.py new file mode 100644 index 000000000..530ff0f25 --- /dev/null +++ b/backend/api/routes/oauth_api.py @@ -0,0 +1,17 @@ +from flask import send_file +from flask_jwt_extended import jwt_required, current_user +from flask_restx import Namespace, Resource + + +api = Namespace("OAuth", description="OAuth related operations", path="/oauth") + +@api.route("") +@api.route("/", doc=False) +class OAuth(Resource): + """OAuth related operations""" + + @api.doc(security="jwt") + @jwt_required() + def post(self): + """Create a new OAuth handler""" + return { "message": "Hello World!" }, 200 \ No newline at end of file diff --git a/backend/api/routes/plex_api.py b/backend/api/routes/plex_api.py new file mode 100644 index 000000000..ae43ae4f2 --- /dev/null +++ b/backend/api/routes/plex_api.py @@ -0,0 +1,46 @@ +from flask import request +from flask_restx import Namespace, Resource +from plexapi.myplex import MyPlexAccount, Unauthorized + +from flask_socketio import join_room +from app.extensions import socketio +from os import urandom +from helpers.universal import global_invite_user_to_media_server + +api = Namespace("Plex", description="Plex related operations", path="/plex") + +@socketio.on("connect", namespace="/plex") +def connect(): + print("Client connected") + +@api.route("") +@api.route("/", doc=False) +class PlexStream(Resource): + """Listen for Plex authentication events""" + + def post(self): + # Get the token from the request + token = request.form.get("token", None) + code = request.form.get("code", None) + socket_id = request.form.get("socket_id", None) + + # Check both the token and the socket ID + if token is None or socket_id is None: + return { "message": "Missing token or socket ID" }, 400 + + # Check if the token is valid + try: + username = MyPlexAccount(token=token).username + except Unauthorized: + return { "message": "Invalid token" }, 400 + + # Create a room with a random ID + random_id = urandom(16).hex() + join_room(random_id, sid=socket_id, namespace="/plex") + + # Send a message to the user and start a background task + socketio.emit("message", f"Hello {username}!", namespace="/plex", to=socket_id) + socketio.start_background_task(target=global_invite_user_to_media_server, token=token, code=code, socket_id=socket_id) + + # Return the room ID and the username + return { "room": random_id, "username": username }, 200 diff --git a/backend/api/routes/requests_api.py b/backend/api/routes/requests_api.py new file mode 100644 index 000000000..14a1dad6f --- /dev/null +++ b/backend/api/routes/requests_api.py @@ -0,0 +1,57 @@ +from json import dumps, loads + +from flask import request +from flask_jwt_extended import current_user, get_jti, get_jwt, jwt_required +from flask_restx import Namespace, Resource +from playhouse.shortcuts import model_to_dict +from datetime import datetime + +from app.models.database.requests import Requests + +api = Namespace("Requests", description="Requests related operations", path="/requests") + +@api.route('') +@api.doc(security=["jsonWebToken", "cookieAuth"]) +class RequestsListAPI(Resource): + + method_decorators = [jwt_required()] + + def get(self) -> tuple[list[dict[str, str]], int]: + # Select all requests from the database + requests = list(Requests.select().dicts()) + + # Return the requests + return loads(dumps(requests, indent=4, sort_keys=True, default=str)), 200 + + def post(self) -> tuple[dict[str, str], int]: + # Create the request + request_db = Requests.create(**request.form) + request_db.created = datetime.utcnow() + + # Return the request + return loads(dumps(model_to_dict(request_db), indent=4, sort_keys=True, default=str)), 200 + + +@api.route('/') +@api.doc(security=["jsonWebToken", "cookieAuth"]) +class RequestsAPI(Resource): + + method_decorators = [jwt_required()] + + def get(self, requests_id: str) -> tuple[dict[str, str], int]: + # Select the request from the database + request = Requests.get(Requests.id == requests_id) + + return loads(dumps(request, indent=4, sort_keys=True, default=str)), 200 + + def delete(self, requests_id: str) -> tuple[dict[str, str], int]: + # Select the request from the database + request = Requests.get(Requests.id == requests_id) + + # Delete the request + request.delete_instance() + + # Responnse + response = { "message": f"Request { requests_id } has been deleted" } + + return response, 200 diff --git a/backend/api/routes/scan_libraries_api.py b/backend/api/routes/scan_libraries_api.py new file mode 100644 index 000000000..cac59e8fa --- /dev/null +++ b/backend/api/routes/scan_libraries_api.py @@ -0,0 +1,103 @@ +from flask import request +from flask_jwt_extended import jwt_required +from flask_restx import Namespace, Resource +from requests import RequestException + +from app.models.wizarr.libraries import ScanLibrariesModel +from app.security import is_setup_required + +api = Namespace( + "Scan Libraries", description=" related operations", path="/scan-libraries" +) + + +@api.route("") +class ScanLibrariesListAPI(Resource): + """Scan Libraries related operations""" + + method_decorators = [] if is_setup_required() else [jwt_required()] + + @api.doc(description="") + @api.response(500, "Internal server error") + def get(self): + # Import from helpers + from helpers import scan_jellyfin_libraries, scan_plex_libraries + from app.models.database import Settings + + # Get the server settings + settings = { + setting.key: setting.value + for setting in Settings.select() + } + + # Validate the data and initialize the object + server_settings = ScanLibrariesModel({ + "server_type": settings["server_type"], + "server_url": settings["server_url"], + "server_api_key": settings["server_api_key"] + }) + + server_settings.validate() + server_type, server_url, server_api_key = server_settings.values() + + + # Check if the server type is Jellyfin + if server_type == "jellyfin": + # Get the libraries + libraries = scan_jellyfin_libraries(server_api_key, server_url) + + # Format the libraries as [name]: [id] + libraries = {library["Name"]: library["Id"] for library in libraries} + + # Return the libraries + return {"libraries": libraries}, 200 + + # Check if the server type is Plex + if server_type == "plex": + # Get the libraries + libraries = scan_plex_libraries(server_api_key, server_url) + + # Format the libraries as [name]: [id] + libraries = {library.title: library.uuid for library in libraries} + + # Return the libraries + return {"libraries": libraries}, 200 + + raise RequestException("Invalid server type") + + @api.doc(description="") + @api.response(500, "Internal server error") + def post(self): + # Import from helpers + from helpers import scan_jellyfin_libraries, scan_plex_libraries + + # Validate the data and initialize the object + server_settings = ScanLibrariesModel(request.form) + server_settings.validate() + + server_type, server_url, server_api_key = server_settings.values() + + + # Check if the server type is Jellyfin + if server_type == "jellyfin": + # Get the libraries + libraries = scan_jellyfin_libraries(server_api_key, server_url) + + # Format the libraries as [name]: [id] + libraries = {library["Name"]: library["Id"] for library in libraries} + + # Return the libraries + return {"libraries": libraries}, 200 + + # Check if the server type is Plex + if server_type == "plex": + # Get the libraries + libraries = scan_plex_libraries(server_api_key, server_url) + + # Format the libraries as [name]: [id] + libraries = {library.title: library.uuid for library in libraries} + + # Return the libraries + return {"libraries": libraries}, 200 + + raise RequestException("Invalid server type") diff --git a/backend/api/routes/server_api.py b/backend/api/routes/server_api.py new file mode 100644 index 000000000..013a5db5e --- /dev/null +++ b/backend/api/routes/server_api.py @@ -0,0 +1,34 @@ +from flask_jwt_extended import current_user +from flask_restx import Namespace, Resource +from playhouse.shortcuts import model_to_dict + +from app.utils.software_lifecycle import get_current_version, need_update +from app.extensions import cache + + +api = Namespace("Server", description="Server related operations", path="/server") + +@api.route("") +@api.route("/", doc=False) +class Server(Resource): + """Server related operations""" + + @cache.cached(timeout=3600) + def get_need_update(self): + return need_update() + + def get(self): + """Get the details of the server""" + from app import app + from app.security import is_setup_required + from helpers.settings import get_settings + + resp = { + "settings": get_settings(disallowed=["server_api_key"]), + "version": str(get_current_version()), + "update_available": self.get_need_update(), + "debug": True if app.debug else False, + "setup_required": is_setup_required() + } + + return resp, 200 diff --git a/backend/api/routes/sessions_api.py b/backend/api/routes/sessions_api.py new file mode 100644 index 000000000..4022033fa --- /dev/null +++ b/backend/api/routes/sessions_api.py @@ -0,0 +1,52 @@ + +from json import dumps, loads + +from flask import request +from flask_jwt_extended import current_user, get_jti, jwt_required, get_jwt +from flask_restx import Namespace, Resource +from playhouse.shortcuts import model_to_dict + +from app.models.database import Sessions + +api = Namespace("Sessions", description="Sessions related operations", path="/sessions") + +@api.route('') +@api.doc(security=["jsonWebToken", "cookieAuth"]) +class SessionsListAPI(Resource): + + method_decorators = [jwt_required()] + + def get(self) -> tuple[list[dict[str, str]], int]: + # Select all sessions from the database + sessions = list(Sessions.select().where(Sessions.user == current_user["id"]).dicts()) + + # Return the sessions + return loads(dumps(sessions, indent=4, sort_keys=True, default=str)), 200 + +@api.route('/') +@api.doc(security=["jsonWebToken", "cookieAuth"]) +class SessionsAPI(Resource): + + method_decorators = [jwt_required()] + + def get(self, sessions_id: str) -> tuple[dict[str, str], int]: + # Select the session from the database + session = Sessions.get(Sessions.id == sessions_id) + + return model_to_dict(session), 200 + + def delete(self, sessions_id: str) -> tuple[dict[str, str], int]: + # Select the session from the database + session = Sessions.get(Sessions.id == sessions_id) + + # If this session is the current session, return a 301 status code + token = get_jwt() + status = 401 if session.access_jti == token["jti"] else 200 + + # Delete the session + session.delete_instance() + + # Responnse + response = { "message": f"Session { sessions_id } has been deleted" } + + return response, status diff --git a/backend/api/routes/settings_api.py b/backend/api/routes/settings_api.py new file mode 100644 index 000000000..7e836c42d --- /dev/null +++ b/backend/api/routes/settings_api.py @@ -0,0 +1,150 @@ +from flask import request +from flask_jwt_extended import jwt_required +from flask_restx import Namespace, Resource +from peewee import Case, IntegrityError, fn + +from app.models.settings import SettingsGetModel, SettingsModel, SettingsPostModel +from app.models.database.settings import Settings +from app.security import is_setup_required + +from app.models.database.libraries import Libraries +from app.models.database.users import Users +from app.models.database.invitations import Invitations +from app.models.database.requests import Requests + + +api = Namespace("Settings", description="Settings related operations", path="/settings") + +api.add_model("SettingsPostModel", SettingsPostModel) +api.add_model("SettingsGetModel", SettingsGetModel) + +@api.route('') +@api.doc(security=["jsonWebToken", "cookieAuth"]) +class SettingsListAPI(Resource): + + method_decorators = [] if is_setup_required() else [jwt_required()] + + @api.marshal_with(SettingsGetModel) + @api.doc(description="Get all settings") + @api.response(200, "Successfully retrieved all settings") + @api.response(500, "Internal server error") + def get(self): + result = Settings.select() + + response = { setting.key: setting.value for setting in result } + + return response, 200 + + + @api.expect(SettingsPostModel) + @api.marshal_with(SettingsGetModel) + @api.doc(description="Update all settings") + @api.response(200, "Successfully updated all settings") + @api.response(400, "Invalid settings") + @api.response(500, "Internal server error") + def post(self): + # Form data is stored in request.form + form = request.form.to_dict() + + # Verify that the form data is valid + data = SettingsModel(**form) + + # Insert the settings into the database as key-value pairs + settings = data.model_dump() + + for key, value in settings.items(): + Settings.update(key=key, value=value) + + response = { key: value for key, value in settings.items() } + + return response, 200 + + @api.expect(SettingsPostModel) + @api.marshal_with(SettingsGetModel) + @api.doc(description="Update all settings") + @api.response(200, "Successfully updated settings") + @api.response(400, "Invalid settings") + @api.response(500, "Internal server error") + def put(self): + # Form data is stored in request.form + form = request.form.to_dict() + + # Verify that the form data is valid + data = SettingsModel(**form) + + # Extract the data from the model to a dictionary + response = data.model_dump() + + # FIXME: This will send many queries to the database + # Insert the settings into the database + for key, value in response.items(): + setting = Settings.get_or_none(Settings.key == key) + if not setting: + Settings.create(key=key, value=value) + else: + Settings.update(value=value).where(Settings.key == key).execute() + + if key == "server_type": + Libraries.delete().execute() + Users.delete().execute() + Invitations.delete().execute() + + if value == "plex": + Requests.delete().where(Requests.service == "jellyseerr").execute() + elif value == "jellyfin": + Requests.delete().where(Requests.service == "overseerr").execute() + + + return response, 200 + + + +@api.route('/') +@api.doc(security=["jsonWebToken", "cookieAuth"]) +class SettingsAPI(Resource): + + method_decorators = [jwt_required()] + + @api.marshal_with(SettingsGetModel) + @api.doc(description="Get a setting by ID") + @api.response(200, "Successfully retrieved the setting") + @api.response(400, "Invalid setting ID") + @api.response(404, "Setting not found") + @api.response(500, "Internal server error") + def get(self, setting_id: str): + result = Settings.get_or_none(Settings.key == setting_id) + + if not result: + raise IntegrityError(f"Setting {setting_id} not found") # Raise IntegrityError if setting is not found + + response = { result.key: result.value } + + return response, 200 + + + @api.expect(SettingsPostModel) + @api.marshal_with(SettingsGetModel) + @api.doc(description="Update a setting by ID") + @api.response(200, "Successfully updated the setting") + @api.response(400, "Invalid setting ID") + @api.response(404, "Setting not found") + @api.response(500, "Internal server error") + def put(self, setting_id: str): + Settings.update(value=request.data).where(Settings.key == setting_id).execute() + + response = SettingsAPI.get(self, setting_id) + + return response, 200 + + + @api.doc(description="Delete a setting by ID") + @api.response(200, "Successfully deleted the setting") + @api.response(400, "Invalid setting ID") + @api.response(404, "Setting not found") + @api.response(500, "Internal server error") + def delete(self, setting_id: str): + Settings.delete().where(Settings.key == setting_id).execute() + + response = { "msg": f"Setting {setting_id} deleted successfully" } + + return response, 200 diff --git a/backend/api/routes/setup_api.py b/backend/api/routes/setup_api.py new file mode 100644 index 000000000..9a5621d22 --- /dev/null +++ b/backend/api/routes/setup_api.py @@ -0,0 +1,101 @@ +from flask import request +from flask_restx import Namespace, Resource +from flask_jwt_extended import jwt_required + +from app.security import is_setup_required +from helpers.accounts import create_account + +from app.models.database.accounts import Accounts +from app.models.database.settings import Settings + +api = Namespace('Setup API', description='Setup related operations', path="/setup") + +@api.route('/status', doc=False) +@api.route('/status/', doc=False) +class Setup(Resource): + """Setup related operations""" + + method_decorators = [] if is_setup_required() else [jwt_required()] + + @api.doc(description="Get the setup status") + @api.response(200, "Successfully retrieved the setup status") + @api.response(500, "Internal server error") + def get(self): + # Get counts for stages + # pylint: disable=no-value-for-parameter + accounts = Accounts.select() + account_count = accounts.count() or 0 + + # Get data from the database for settings + server_url = Settings.get_or_none(Settings.key == "server_url") + server_type = Settings.get_or_none(Settings.key == "server_type") + server_api_key = Settings.get_or_none(Settings.key == "server_api_key") + + # Check if the server settings are set + settings_set = server_url is not None and server_type is not None and server_api_key is not None + + # Create the response object + response = { + "setup_required": is_setup_required(), + "setup_stage": { + "accounts": account_count > 0, + "settings": settings_set + } + } + + # Return the setup status + return response, 200 + +@api.route('/accounts', doc=False) +@api.route('/accounts/', doc=False) +class SetupAccounts(Resource): + """Setup related operations""" + + method_decorators = [] if is_setup_required() else [jwt_required()] + + @api.doc(description="Create a new account") + @api.response(200, "Successfully created a new account") + @api.response(500, "Internal server error") + def post(self): + # Create the account + user = create_account( + display_name=request.form.get("display_name"), + username=request.form.get("username").lower(), + email=request.form.get("email"), + password=request.form.get("password"), + confirm_password=request.form.get("confirm_password"), + role="admin" + ) + + return { "message": "Account created", "user": user }, 200 + +@api.route('/exit', doc=False) +@api.route('/exit/', doc=False) +class SetupExit(Resource): + """Setup related operations""" + + method_decorators = [] if is_setup_required() else [jwt_required()] + + @api.doc(description="Exit the setup") + @api.response(200, "Successfully exited the setup") + @api.response(500, "Internal server error") + def get(self): + # Get count for accounts + # pylint: disable=no-value-for-parameter + account_set = Accounts.select().count() > 0 + + # Get data from the database for settings + server_url = Settings.get_or_none(Settings.key == "server_url") + server_type = Settings.get_or_none(Settings.key == "server_type") + server_api_key = Settings.get_or_none(Settings.key == "server_api_key") + + # Check if the server settings are set + settings_set = server_url is not None and server_type is not None and server_api_key is not None + + # If the accounts and settings are set, exit the setup by setting server_verified to True + if account_set and settings_set: + server_verified = Settings.get_or_create(key="server_verified") + server_verified[0].value = "True" + server_verified[0].save() + + return { "message": "Setup exited" }, 200 diff --git a/backend/api/routes/tasks_api.py b/backend/api/routes/tasks_api.py new file mode 100644 index 000000000..774a2eff4 --- /dev/null +++ b/backend/api/routes/tasks_api.py @@ -0,0 +1,45 @@ +from flask import Response, make_response +from flask_jwt_extended import jwt_required +from flask_restx import Namespace, Resource + +api = Namespace("Tasks", description="Tasks related operations", path="/tasks") + +@api.route('') +@api.doc(security=["jsonWebToken", "cookieAuth"]) +class TasksListAPI(Resource): + + method_decorators = [jwt_required()] + + def get(self) -> Response: + # Import required modules here to avoid circular imports + from app.scheduler import get_schedule + + return make_response(get_schedule(), 200) + + +@api.route('/') +@api.doc(security=["jsonWebToken", "cookieAuth"]) +class TasksAPI(Resource): + + method_decorators = [jwt_required()] + + def get(self, task_id: str) -> Response: + # Import required modules here to avoid circular imports + from app.scheduler import get_task + + return make_response(get_task(task_id), 200) + +@api.route('//run') +@api.doc(security=["jsonWebToken", "cookieAuth"]) +class TasksRunAPI(Resource): + + method_decorators = [jwt_required()] + + def get(self, task_id: str) -> Response: + # Import required modules here to avoid circular imports + from app.scheduler import run_task + + # Run the task + run_task(task_id) + + return make_response({ "msg": f"Task {task_id} has been run" }, 200) diff --git a/backend/api/routes/users_api.py b/backend/api/routes/users_api.py new file mode 100644 index 000000000..d9037eb0b --- /dev/null +++ b/backend/api/routes/users_api.py @@ -0,0 +1,65 @@ +from flask import send_file +from flask_jwt_extended import jwt_required +from flask_restx import Namespace, Resource +from json import loads, dumps + +from helpers.universal import global_sync_users_to_media_server, global_delete_user_from_media_server, global_get_user_profile_picture +from app.models.database.users import Users + +from app.extensions import cache + +api = Namespace("Users", description="Users related operations", path="/users") + +@api.route("") +class UsersListAPI(Resource): + + method_decorators = [jwt_required()] + + @api.doc(description="Get all users in the database") + @api.response(500, "Internal server error") + def get(self): + # Select all users from the database + response = list(Users.select().dicts()) + return loads(dumps(response, indent=4, sort_keys=True, default=str)), 200 + + + @api.doc(description="") + @api.response(500, "Internal server error") + def post(self): + return + + +@api.route("/") +class UsersAPI(Resource): + + method_decorators = [jwt_required()] + + @api.doc(description="Delete a user from the database and media server") + @api.response(500, "Internal server error") + def delete(self, user_id): + return global_delete_user_from_media_server(user_id), 200 + + +@api.route("//profile-picture") +class UsersProfilePictureAPI(Resource): + + method_decorators = [jwt_required()] + + @cache.cached(timeout=3600) + @api.doc(description="Get a user profile picture") + @api.response(500, "Internal server error") + def get(self, user_id): + picture = global_get_user_profile_picture(user_id) + return send_file(picture, mimetype="image/jpeg") + + +@api.route("/scan") +class UsersScanAPI(Resource): + + method_decorators = [jwt_required()] + + @api.doc(description="Scan for new users") + @api.response(500, "Internal server error") + def get(self): + return global_sync_users_to_media_server(), 200 + diff --git a/backend/api/routes/utilities_api.py b/backend/api/routes/utilities_api.py new file mode 100644 index 000000000..e0b2902d9 --- /dev/null +++ b/backend/api/routes/utilities_api.py @@ -0,0 +1,45 @@ +from flask import redirect, request +from flask_jwt_extended import current_user, jwt_required +from flask_restx import Model, Namespace, Resource, fields +from app.security import is_setup_required + +api = Namespace('Utilities', description='Utility functions', path="/utilities") + +@api.route('/detect-server') +class DetectServerAPI(Resource): + """Detect server type""" + + method_decorators = [] if is_setup_required() else [jwt_required()] + + def get(self): + """Detect server type""" + from app.utils.media_server import detect_server + return detect_server(request.args.get('server_url')) + +@api.route('/verify-server') +class VerifyServerAPI(Resource): + """Verify server connection credentials""" + + method_decorators = [] if is_setup_required() else [jwt_required()] + + def get(self): + """Verify server connection credentials""" + from app.utils.media_server import verify_server + return verify_server(request.args.get('server_url'), request.args.get('api_key')) + +@api.route('/scan-servers') +class ScanServersAPI(Resource): + """Scan for Media Servers on the network""" + + method_decorators = [] if is_setup_required() else [jwt_required()] + + def get(self): + """Scan for Media Servers on the network""" + from app.utils.media_server import scan_network, get_subnet_from_ip + + subnet = request.args.get('subnet', None) + ip = request.args.get('ip', None) + + target = str(subnet) if subnet else str(get_subnet_from_ip(str(ip))) if ip else None + + return scan_network(target=target) diff --git a/backend/api/routes/webhooks_api.py b/backend/api/routes/webhooks_api.py new file mode 100644 index 000000000..e08b32b65 --- /dev/null +++ b/backend/api/routes/webhooks_api.py @@ -0,0 +1,57 @@ +from flask import request +from flask_jwt_extended import jwt_required +from flask_restx import Namespace, Resource +from app.models.database.webhooks import Webhooks +from json import loads, dumps +from playhouse.shortcuts import model_to_dict +from datetime import datetime + +api = Namespace('Webhooks', description='Webhooks related operations', path='/webhooks') + +@api.route("") +class WebhooksListAPI(Resource): + + method_decorators = [jwt_required()] + + @api.doc(description="Get all webhooks") + @api.response(200, "Successfully retrieved all webhooks") + def get(self): + """Get all webhooks""" + response = list(Webhooks.select().dicts()) + return loads(dumps(response, indent=4, sort_keys=True, default=str)), 200 + + @api.doc(description="Create a webhook") + @api.response(200, "Successfully created a webhook") + def post(self): + """Create a webhook""" + + # Create the webhook + webhook = Webhooks.create(**request.form) + webhook.created = datetime.utcnow() + + # Return the webhook + return loads(dumps(model_to_dict(webhook), indent=4, sort_keys=True, default=str)), 200 + +@api.route("/") +class WebhooksAPI(Resource): + + method_decorators = [jwt_required()] + + @api.doc(description="Get a single webhook") + @api.response(200, "Successfully retrieved webhook") + def get(self, webhook_id): + """Get a single webhook""" + webhook = Webhooks.get_or_none(Webhooks.id == webhook_id) + if not webhook: + return {"message": "Webhook not found"}, 404 + return loads(dumps(webhook, indent=4, sort_keys=True, default=str)), 200 + + @api.doc(description="Delete a single webhook") + @api.response(200, "Successfully deleted webhook") + def delete(self, webhook_id): + """Delete a single webhook""" + webhook = Webhooks.get_or_none(Webhooks.id == webhook_id) + if not webhook: + return {"message": "Webhook not found"}, 404 + webhook.delete_instance() + return {"message": "Webhook deleted"}, 200 diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 000000000..0f80f0f64 --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1,46 @@ +from dotenv import load_dotenv +from flask import Flask + +from api import * + +from .config import create_config +from .extensions import * +from .models.database import * +from .security import * +from .utils.clear_logs import clear_logs + +BASE_DIR = path.abspath(path.dirname(__file__)) + +# Load environment variables +load_dotenv() + +# Create the app +app = Flask(__name__) + +app.config.update(**create_config(app)) +schedule.authenticate(lambda auth: auth is not None) + +# Initialize App Extensions +sess.init_app(app) +jwt.init_app(app) +cache.init_app(app) +api.init_app(app) +schedule.init_app(app) +socketio.init_app(app, async_mode="gevent" if app.config["GUNICORN"] else "threading", cors_allowed_origins="*", async_handlers=True) +# oauth.init_app(app) + +# Clear cache on startup +with app.app_context(): + cache.clear() + clear_logs() + +# Register Flask JWT callbacks +jwt.token_in_blocklist_loader(check_if_token_revoked) +jwt.user_identity_loader(user_identity_lookup) +jwt.user_lookup_loader(user_lookup_callback) + +from .logging import * +from .scheduler import * + +if __name__ == "__main__": + socketio.run(app) diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 000000000..fd5c863c0 --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,37 @@ +from datetime import timedelta +from os import environ, getenv, path + +from flask import Flask + +from app.security import secret_key, SchedulerAuth + +def create_config(app: Flask): + config = {} + base_dir = path.abspath(path.join(path.dirname(__file__), "../")) + + config["SESSION_TYPE"] = "filesystem" + config["SESSION_FILE_DIR"] = path.join(base_dir, "../", "database", "sessions") + config["PERMANENT_SESSION_LIFETIME"] = timedelta(hours=5) + config["UPLOAD_FOLDER"] = path.join(base_dir, "../", "database", "uploads") + config["SWAGGER_UI_DOC_EXPANSION"] = "list" + config["SERVER_NAME"] = "127.0.0.1:5000" + config["APPLICATION_ROOT"] = "/" + config["JWT_SECRET_KEY"] = secret_key() + config["JWT_BLACKLIST_ENABLED"] = True + config["JWT_TOKEN_LOCATION"] = ["headers", "json", "query_string"] + config["JWT_ACCESS_TOKEN_EXPIRES"] = timedelta(hours=1) + config["JWT_REFRESH_TOKEN_EXPIRES"] = timedelta(days=31) + config["JWT_COOKIE_CSRF_PROTECT"] = True + config["JWT_COOKIE_SECURE"] = False + config["DEBUG"] = app.debug + config["CACHE_TYPE"] = "SimpleCache" + config["CACHE_DEFAULT_TIMEOUT"] = 300 + config["PROPAGATE_EXCEPTIONS"] = app.debug + config["GUNICORN"] = "gunicorn" in environ.get("SERVER_SOFTWARE", "") + config['JSONIFY_PRETTYPRINT_REGULAR'] = True + config["SCHEDULER_TIMEZONE"] = "Europe/London" + config["SCHEDULER_API_ENABLED"] = True + config["SCHEDULER_API_PREFIX"] = "/api/scheduler" + config["SCHEDULER_AUTH"] = SchedulerAuth() + + return config diff --git a/backend/app/exceptions.py b/backend/app/exceptions.py new file mode 100644 index 000000000..500c68bb2 --- /dev/null +++ b/backend/app/exceptions.py @@ -0,0 +1,11 @@ +class AuthenticationError(Exception): + pass + +class AuthorizationError(Exception): + pass + +class InvalidUsage(Exception): + pass + +class MigrationError(Exception): + pass diff --git a/backend/app/extensions.py b/backend/app/extensions.py new file mode 100644 index 000000000..26995b275 --- /dev/null +++ b/backend/app/extensions.py @@ -0,0 +1,16 @@ +from flask_session import Session +from flask_jwt_extended import JWTManager +from flask_caching import Cache +from flask_restx import Api +from flask_apscheduler import APScheduler +from flask_socketio import SocketIO + +from apscheduler.schedulers.background import BackgroundScheduler +from pytz import utc + +sess = Session() +jwt = JWTManager() +cache = Cache() +api = Api() +schedule = APScheduler(scheduler=BackgroundScheduler(timezone=utc)) +socketio = SocketIO(log_output=False) diff --git a/backend/app/globals.py b/backend/app/globals.py new file mode 100644 index 000000000..e3d03a600 --- /dev/null +++ b/backend/app/globals.py @@ -0,0 +1,23 @@ +from os import getenv, path +from flask import Flask +from app.utils.software_lifecycle import is_stable, need_update, get_current_version + +def create_globals(app: Flask): + globals_config = {} + base_dir = path.abspath(path.join(path.dirname(__file__), "../")) + + globals_config["APP_NAME"] = "Wizarr" + globals_config["APP_VERSION"] = get_current_version() + globals_config["APP_GITHUB_URL"] = "https://github.com/Wizarrrr/wizarr" + globals_config["GITHUB_SHEBANG"] = "wizarrrr/wizarr" + globals_config["DOCS_URL"] = "https://docs.wizarr.dev" + globals_config["DISCORD_INVITE"] = "wsSTsHGsqu" + globals_config["APP_RELEASED"] = is_stable() + globals_config["APP_LANG"] = "en" + globals_config["TIMEZONE"] = getenv("TZ", "UTC") + globals_config["DATA_DIRECTORY"] = path.abspath(path.join(base_dir, "../", "database")) + globals_config["APP_UPDATE"] = need_update() + globals_config["DISABLE_BUILTIN_AUTH"] = bool(str(getenv("DISABLE_BUILTIN_AUTH", "False")).lower() == "true") + globals_config["LANGUAGES"] = app.config["LANGUAGES"] + + return globals_config diff --git a/backend/app/logging.py b/backend/app/logging.py new file mode 100644 index 000000000..72ba593e5 --- /dev/null +++ b/backend/app/logging.py @@ -0,0 +1,49 @@ +import logging +import coloredlogs +from os import path +from logging.handlers import WatchedFileHandler + +# Configure the root logger +logging.basicConfig(level=logging.DEBUG, format="%(asctime)s %(levelname)s %(message)s", datefmt="%H:%M:%S") + +# Add a null handler to the root logger to prevent log messages from being discarded +logging.getLogger().addHandler(logging.NullHandler()) + +# Exclude certain messages from the logger with a filter +class ExcludeFilter(logging.Filter): + def __init__(self, exclude_str): + super().__init__() + self.exclude_str = exclude_str + + def filter(self, record): + return self.exclude_str not in record.getMessage() + +class ExcludeLoggerFilter(logging.Filter): + def __init__(self, exclude_logger): + super().__init__() + self.exclude_logger = exclude_logger + + def filter(self, record): + return record.name != self.exclude_logger + +# Get the root logger +logger = logging.getLogger() +werkzeug = logging.getLogger("werkzeug") + +# Create a file handler for logging to a file +file_log_handler = WatchedFileHandler(path.join(path.dirname(path.abspath(__file__)), "../", "../", "database", "logs.log"), mode="a", encoding="utf-8") +file_log_handler.addFilter(ExcludeLoggerFilter("peewee")) +file_log_handler.addFilter(ExcludeFilter("socket.io")) +file_log_handler.setFormatter(coloredlogs.ColoredFormatter("%(asctime)s %(levelname)s %(message)s", datefmt="%H:%M:%S")) + +# Add the file handler to the root logger +logger.addHandler(file_log_handler) + +# Configure colored logging +coloredlogs.install(level=None, fmt="%(asctime)s %(levelname)s %(message)s", datefmt="%H:%M:%S") +coloredlogs.install(level=None, logger=werkzeug, fmt="%(asctime)s %(levelname)s %(message)s", datefmt="%H:%M:%S") + +# Get a dictionary of all the loggers +loggers = logging.Logger.manager.loggerDict +# print(tabulate([[key, value] for key, value in loggers.items()], headers=["Logger", "Level"])) +logging.getLogger("socketio").setLevel(logging.ERROR) diff --git a/backend/app/models/base.py b/backend/app/models/base.py new file mode 100644 index 000000000..858092d61 --- /dev/null +++ b/backend/app/models/base.py @@ -0,0 +1,8 @@ +from peewee import Model, SqliteDatabase + +db = SqliteDatabase("./database/database.db") + +class BaseModel(Model): + class Meta: + database = db + \ No newline at end of file diff --git a/backend/app/models/database/__init__.py b/backend/app/models/database/__init__.py new file mode 100644 index 000000000..88374bd95 --- /dev/null +++ b/backend/app/models/database/__init__.py @@ -0,0 +1,24 @@ +from logging import error + +from app.models.database.accounts import Accounts +from app.models.database.api_keys import APIKeys +from app.models.database.base import db +from app.models.database.invitations import Invitations +from app.models.database.libraries import Libraries +from app.models.database.notifications import Notifications +from app.models.database.sessions import Sessions +from app.models.database.settings import Settings +from app.models.database.users import Users +from app.models.database.oauth_clients import OAuthClients +from app.models.database.mfa import MFA +from app.models.database.discord import Discord +from app.models.database.webhooks import Webhooks +from app.models.database.requests import Requests + + +all_models = [Accounts, APIKeys, Invitations, Libraries, Notifications, Settings, Users, Sessions, OAuthClients, MFA, Discord, Webhooks, Requests] + +try: + db.create_tables(all_models, safe=True) +except Exception as e: + error(f"Failed to create tables: {e}") diff --git a/backend/app/models/database/accounts.py b/backend/app/models/database/accounts.py new file mode 100644 index 000000000..1e5e7e047 --- /dev/null +++ b/backend/app/models/database/accounts.py @@ -0,0 +1,18 @@ +from peewee import SQL, CharField, DateTimeField, IntegerField +from app.models.database.base import BaseModel + +class Accounts(BaseModel): + id = IntegerField(primary_key=True) + avatar = CharField(null=True, default=None) + display_name = CharField(null=True, default=None) + username = CharField(unique=True) + password = CharField() + email = CharField(null=True, unique=True, default=None) + role = CharField(default="user") + last_login = DateTimeField(null=True, default=None) + created = DateTimeField(constraints=[SQL("DEFAULT (datetime('now'))")]) + + def save(self, *args, **kwargs): + if self.role not in ["admin", "moderator", "user"]: + raise ValueError("Invalid role value") + super().save(*args, **kwargs) diff --git a/backend/app/models/database/api_keys.py b/backend/app/models/database/api_keys.py new file mode 100644 index 000000000..e3909fe9f --- /dev/null +++ b/backend/app/models/database/api_keys.py @@ -0,0 +1,12 @@ +from peewee import SQL, CharField, DateTimeField, IntegerField + +from app.models.database.base import BaseModel +from app.models.database.users import Users + +class APIKeys(BaseModel): + id = IntegerField(primary_key=True) + name = CharField() + key = CharField() + jti = CharField() + user_id = CharField() + created = DateTimeField(constraints=[SQL("DEFAULT (datetime('now'))")]) diff --git a/backend/app/models/database/base.py b/backend/app/models/database/base.py new file mode 100644 index 000000000..361bd5bbf --- /dev/null +++ b/backend/app/models/database/base.py @@ -0,0 +1,14 @@ +from peewee import Model, SqliteDatabase +from os import path + +current_dir = path.dirname(path.realpath(__file__)) +base_dir = path.abspath(path.join(current_dir, "../", "../", "../", "../")) +db_dir = path.join(base_dir, "database") +db_file = path.join(db_dir, "database.db") + +db = SqliteDatabase(db_file) + +class BaseModel(Model): + class Meta: + database = db + diff --git a/backend/app/models/database/discord.py b/backend/app/models/database/discord.py new file mode 100644 index 000000000..a18b158d9 --- /dev/null +++ b/backend/app/models/database/discord.py @@ -0,0 +1,8 @@ +from peewee import SQL, BooleanField, CharField, DateTimeField +from app.models.database.base import BaseModel + + +class Discord(BaseModel): + token = CharField(null=True, default=None) + enabled = BooleanField(default=False) + guild_id = CharField(null=True, default=None) diff --git a/backend/app/models/database/invitations.py b/backend/app/models/database/invitations.py new file mode 100644 index 000000000..ac2a2eaa4 --- /dev/null +++ b/backend/app/models/database/invitations.py @@ -0,0 +1,16 @@ +from peewee import SQL, BooleanField, CharField, DateTimeField +from app.models.database.base import BaseModel + + +class Invitations(BaseModel): + code = CharField(unique=True) + used = BooleanField(default=False) + used_at = DateTimeField(null=True, default=None) + used_by = CharField(null=True, default=None) + created = DateTimeField(constraints=[SQL("DEFAULT (datetime('now'))")]) + expires = DateTimeField(null=True, default=None) # How long the invite is valid for + unlimited = BooleanField(null=True, default=None) + duration = CharField(null=True, default=None) # How long the membership is kept for + specific_libraries = CharField(default=None, null=True) + plex_allow_sync = BooleanField(null=True, default=None) + plex_home = BooleanField(null=True, default=None) diff --git a/backend/app/models/database/libraries.py b/backend/app/models/database/libraries.py new file mode 100644 index 000000000..818914e17 --- /dev/null +++ b/backend/app/models/database/libraries.py @@ -0,0 +1,8 @@ +from peewee import SQL, CharField, DateTimeField +from app.models.database.base import BaseModel + + +class Libraries(BaseModel): + id = CharField(unique=True) + name = CharField() + created = DateTimeField(constraints=[SQL("DEFAULT (datetime('now'))")]) diff --git a/backend/app/models/database/mfa.py b/backend/app/models/database/mfa.py new file mode 100644 index 000000000..e927774cc --- /dev/null +++ b/backend/app/models/database/mfa.py @@ -0,0 +1,14 @@ +from peewee import SQL, CharField, DateTimeField, IntegerField +from app.models.database.base import BaseModel + + +class MFA(BaseModel): + id = IntegerField(primary_key=True) + name = CharField() + user_id = CharField() + credential_id = CharField(unique=True) + public_key = CharField(unique=True) + sign_count = IntegerField() + attestation = CharField(unique=True) + transports = CharField() + created = DateTimeField(constraints=[SQL("DEFAULT (datetime('now'))")]) diff --git a/backend/app/models/database/notifications.py b/backend/app/models/database/notifications.py new file mode 100644 index 000000000..a4e6dea67 --- /dev/null +++ b/backend/app/models/database/notifications.py @@ -0,0 +1,10 @@ +from peewee import SQL, CharField, DateTimeField, IntegerField, ForeignKeyField +from app.models.database.base import BaseModel +from app.models.database.users import Users + +class Notifications(BaseModel): + id = IntegerField(primary_key=True) + user_id = ForeignKeyField(Users, backref='notifications', on_delete='CASCADE') + resource = CharField() + data = CharField() + created = DateTimeField(constraints=[SQL("DEFAULT (datetime('now'))")]) diff --git a/backend/app/models/database/oauth_clients.py b/backend/app/models/database/oauth_clients.py new file mode 100644 index 000000000..1da45392f --- /dev/null +++ b/backend/app/models/database/oauth_clients.py @@ -0,0 +1,17 @@ +from peewee import SQL, CharField, DateTimeField, IntegerField +from app.models.database.base import BaseModel + + +class OAuthClients(BaseModel): + id = IntegerField(primary_key=True) + name = CharField(null=True, default=None) + issuer = CharField(unique=True) + consumer_key = CharField(null=True, default=None) + consumer_secret = CharField(null=True, default=None) + request_token_params = CharField(null=True, default=None) + request_token_url = CharField(null=True, default=None) + access_token_method = CharField(null=True, default=None) + access_token_url = CharField(null=True, default=None) + authorize_url = CharField(null=True, default=None) + userinfo_endpoint = CharField(null=True, default=None) + created = DateTimeField(constraints=[SQL("DEFAULT (datetime('now'))")]) diff --git a/backend/app/models/database/requests.py b/backend/app/models/database/requests.py new file mode 100644 index 000000000..f0f8ff994 --- /dev/null +++ b/backend/app/models/database/requests.py @@ -0,0 +1,11 @@ +from peewee import CharField, DateTimeField, IntegerField, SQL +from app.models.database.base import BaseModel + +class Requests(BaseModel): + id = IntegerField(primary_key=True) + name = CharField(unique=True) + server_id = CharField(default=0) # Future support for multiple servers + service = CharField() + url = CharField(unique=True) + api_key = CharField() + created = DateTimeField(constraints=[SQL("DEFAULT (datetime('now'))")]) diff --git a/backend/app/models/database/sessions.py b/backend/app/models/database/sessions.py new file mode 100644 index 000000000..77dd6da3e --- /dev/null +++ b/backend/app/models/database/sessions.py @@ -0,0 +1,16 @@ +from peewee import SQL, CharField, DateTimeField, ForeignKeyField, BooleanField + +from app.models.database.base import BaseModel +from app.models.database.users import Users + + +class Sessions(BaseModel): + access_jti = CharField(unique=True) + refresh_jti = CharField(unique=True, null=True, default=None) + user = ForeignKeyField(Users, backref='sessions', on_delete='CASCADE') + user_agent = CharField() + ip = CharField() + expires = DateTimeField(null=True, default=None) + mfa_id = CharField(null=True, default=None) + revoked = BooleanField(default=False) + created = DateTimeField(constraints=[SQL("DEFAULT (datetime('now'))")]) diff --git a/backend/app/models/database/settings.py b/backend/app/models/database/settings.py new file mode 100644 index 000000000..e7d134fa4 --- /dev/null +++ b/backend/app/models/database/settings.py @@ -0,0 +1,8 @@ +from peewee import CharField + +from app.models.database.base import BaseModel + + +class Settings(BaseModel): + key = CharField() + value = CharField(null=True) diff --git a/backend/app/models/database/users.py b/backend/app/models/database/users.py new file mode 100644 index 000000000..1ab0e3b30 --- /dev/null +++ b/backend/app/models/database/users.py @@ -0,0 +1,13 @@ +from peewee import SQL, CharField, DateTimeField, IntegerField +from app.models.database.base import BaseModel + + +class Users(BaseModel): + id = IntegerField(primary_key=True) + token = CharField() # Plex ID or Jellyfin ID + username = CharField() + email = CharField(null=True, default=None) + code = CharField(null=True, default=None) + expires = DateTimeField(null=True, default=None) + auth = CharField(null=True, default=None) + created = DateTimeField(constraints=[SQL("DEFAULT (datetime('now'))")]) diff --git a/backend/app/models/database/webhooks.py b/backend/app/models/database/webhooks.py new file mode 100644 index 000000000..ee72b7f1a --- /dev/null +++ b/backend/app/models/database/webhooks.py @@ -0,0 +1,8 @@ +from peewee import SQL, CharField, DateTimeField, IntegerField +from app.models.database.base import BaseModel + +class Webhooks(BaseModel): + id = IntegerField(primary_key=True) + name = CharField() + url = CharField() + created = DateTimeField(constraints=[SQL("DEFAULT (datetime('now'))")]) diff --git a/backend/app/models/jellyfin/library.py b/backend/app/models/jellyfin/library.py new file mode 100644 index 000000000..7fc197a83 --- /dev/null +++ b/backend/app/models/jellyfin/library.py @@ -0,0 +1,318 @@ +# pylint: disable=missing-module-docstring, missing-class-docstring, missing-function-docstring + +from uuid import UUID +from datetime import datetime + +class AlbumArtist: + name: str + id: UUID + + +class Chapter: + start_position_ticks: int + name: str + image_path: str + image_date_modified: datetime + image_tag: str + +class ExternalURL: + name: str + url: str + +class ImageTags: + additional_prop1: str + additional_prop2: str + additional_prop3: str + +class MediaAttachment: + codec: str + codec_tag: str + comment: str + index: int + file_name: str + mime_type: str + delivery_url: str + +class MediaStream: + codec: str + codec_tag: str + language: str + color_range: str + color_space: str + color_transfer: str + color_primaries: str + dv_version_major: int + dv_version_minor: int + dv_profile: int + dv_level: int + rpu_present_flag: int + el_present_flag: int + bl_present_flag: int + dv_bl_signal_compatibility_id: int + comment: str + time_base: str + codec_time_base: str + title: str + video_range: str + video_range_type: str + video_do_vi_title: str + localized_undefined: str + localized_default: str + localized_forced: str + localized_external: str + display_title: str + nal_length_size: str + is_interlaced: bool + is_avc: bool + channel_layout: str + bit_rate: int + bit_depth: int + ref_frames: int + packet_length: int + channels: int + sample_rate: int + is_default: bool + is_forced: bool + height: int + width: int + average_frame_rate: int + real_frame_rate: int + profile: str + type: str + aspect_ratio: str + index: int + score: int + is_external: bool + delivery_method: str + delivery_url: str + is_external_url: bool + is_text_subtitle_stream: bool + supports_external_stream: bool + path: str + pixel_format: str + level: int + is_anamorphic: bool + +class MediaSource: + protocol: str + id: str + path: str + encoder_path: str + encoder_protocol: str + type: str + container: str + size: int + name: str + is_remote: bool + e_tag: str + run_time_ticks: int + read_at_native_framerate: bool + ignore_dts: bool + ignore_index: bool + gen_pts_input: bool + supports_transcoding: bool + supports_direct_stream: bool + supports_direct_play: bool + is_infinite_stream: bool + requires_opening: bool + open_token: str + requires_closing: bool + live_stream_id: str + buffer_ms: int + requires_looping: bool + supports_probing: bool + video_type: str + iso_type: str + video3_d_format: str + media_streams: list[MediaStream] + media_attachments: list[MediaAttachment] + formats: list[str] + bitrate: int + timestamp: str + required_http_headers: ImageTags + transcoding_url: str + transcoding_sub_protocol: str + transcoding_container: str + analyze_duration_ms: int + default_audio_stream_index: int + default_subtitle_stream_index: int + +class Person: + name: str + id: UUID + role: str + type: str + primary_image_tag: str + image_blur_hashes: dict[str, ImageTags] + +class UserData: + rating: int + played_percentage: int + unplayed_item_count: int + playback_position_ticks: int + play_count: int + is_favorite: bool + likes: bool + last_played_date: datetime + played: bool + key: str + item_id: str + + +class JellyfinLibraryItem: + name: str + original_title: str + server_id: str + id: UUID + etag: str + source_type: str + playlist_item_id: str + date_created: datetime + date_last_media_added: datetime + extra_type: str + airs_before_season_number: int + airs_after_season_number: int + airs_before_episode_number: int + can_delete: bool + can_download: bool + has_subtitles: bool + preferred_metadata_language: str + preferred_metadata_country_code: str + supports_sync: bool + container: str + sort_name: str + forced_sort_name: str + video3_d_format: str + premiere_date: datetime + external_urls: list[ExternalURL] + media_sources: list[MediaSource] + critic_rating: int + production_locations: list[str] + path: str + enable_media_source_display: bool + official_rating: str + custom_rating: str + channel_id: UUID + channel_name: str + overview: str + taglines: list[str] + genres: list[str] + community_rating: int + cumulative_run_time_ticks: int + run_time_ticks: int + play_access: str + aspect_ratio: str + production_year: int + is_place_holder: bool + number: str + channel_number: str + index_number: int + index_number_end: int + parent_index_number: int + remote_trailers: list[ExternalURL] + provider_ids: ImageTags + is_hd: bool + is_folder: bool + parent_id: UUID + type: str + people: list[Person] + studios: list[AlbumArtist] + genre_items: list[AlbumArtist] + parent_logo_item_id: UUID + parent_backdrop_item_id: UUID + parent_backdrop_image_tags: list[str] + local_trailer_count: int + user_data: UserData + recursive_item_count: int + child_count: int + series_name: str + series_id: UUID + season_id: UUID + special_feature_count: int + display_preferences_id: str + status: str + air_time: str + air_days: list[str] + tags: list[str] + primary_image_aspect_ratio: int + artists: list[str] + artist_items: list[AlbumArtist] + album: str + collection_type: str + display_order: str + album_id: UUID + album_primary_image_tag: str + series_primary_image_tag: str + album_artist: str + album_artists: list[AlbumArtist] + season_name: str + media_streams: list[MediaStream] + video_type: str + part_count: int + media_source_count: int + image_tags: ImageTags + backdrop_image_tags: list[str] + screenshot_image_tags: list[str] + parent_logo_image_tag: str + parent_art_item_id: UUID + parent_art_image_tag: str + series_thumb_image_tag: str + image_blur_hashes: dict[str, ImageTags] + series_studio: str + parent_thumb_item_id: UUID + parent_thumb_image_tag: str + parent_primary_image_item_id: str + parent_primary_image_tag: str + chapters: list[Chapter] + location_type: str + iso_type: str + media_type: str + end_date: datetime + locked_fields: list[str] + trailer_count: int + movie_count: int + series_count: int + program_count: int + episode_count: int + song_count: int + album_count: int + artist_count: int + music_video_count: int + lock_data: bool + width: int + height: int + camera_make: str + camera_model: str + software: str + exposure_time: int + focal_length: int + image_orientation: str + aperture: int + shutter_speed: int + latitude: int + longitude: int + altitude: int + iso_speed_rating: int + series_timer_id: str + program_id: str + channel_primary_image_tag: str + start_date: datetime + completion_percentage: int + is_repeat: bool + episode_title: str + channel_type: str + audio: str + is_movie: bool + is_sports: bool + is_series: bool + is_live: bool + is_news: bool + is_kids: bool + is_premiere: bool + timer_id: str + current_program: str + +class JellyfinLibrary: + items: list[JellyfinLibraryItem] + total_record_count: int + start_index: int diff --git a/backend/app/models/jellyfin/user.py b/backend/app/models/jellyfin/user.py new file mode 100644 index 000000000..92c45b276 --- /dev/null +++ b/backend/app/models/jellyfin/user.py @@ -0,0 +1,88 @@ +# pylint: disable=missing-module-docstring, missing-class-docstring, missing-function-docstring + +from uuid import UUID +from datetime import datetime + + +class Configuration: + audio_language_preference: str + play_default_audio_track: bool + subtitle_language_preference: str + display_missing_episodes: bool + grouped_folders: list[str] + subtitle_mode: str + display_collections_view: bool + enable_local_password: bool + ordered_views: list[str] + latest_items_excludes: list[str] + my_media_excludes: list[str] + hide_played_in_latest: bool + remember_audio_selections: bool + remember_subtitle_selections: bool + enable_next_episode_auto_play: bool + +class AccessSchedule: + id: int + user_id: UUID + day_of_week: str + start_hour: int + end_hour: int + + +class Policy: + is_administrator: bool + is_hidden: bool + is_disabled: bool + max_parental_rating: int + blocked_tags: list[str] + enable_user_preference_access: bool + access_schedules: list[AccessSchedule] + block_unrated_items: list[str] + enable_remote_control_of_other_users: bool + enable_shared_device_control: bool + enable_remote_access: bool + enable_live_tv_management: bool + enable_live_tv_access: bool + enable_media_playback: bool + enable_audio_playback_transcoding: bool + enable_video_playback_transcoding: bool + enable_playback_remuxing: bool + force_remote_source_transcoding: bool + enable_content_deletion: bool + enable_content_deletion_from_folders: list[str] + enable_content_downloading: bool + enable_sync_transcoding: bool + enable_media_conversion: bool + enabled_devices: list[str] + enable_all_devices: bool + enabled_channels: list[UUID] + enable_all_channels: bool + enabled_folders: list[UUID] + enable_all_folders: bool + invalid_login_attempt_count: int + login_attempts_before_lockout: int + max_active_sessions: int + enable_public_sharing: bool + blocked_media_folders: list[UUID] + blocked_channels: list[UUID] + remote_client_bitrate_limit: int + authentication_provider_id: str + password_reset_provider_id: str + sync_play_access: str + + +class JellyfinUser: + name: str + server_id: str + server_name: str + id: UUID + primary_image_tag: str + has_password: bool + has_configured_password: bool + has_configured_easy_password: bool + enable_auto_login: bool + last_login_date: datetime + last_activity_date: datetime + configuration: Configuration + policy: Policy + primary_image_aspect_ratio: int diff --git a/backend/app/models/jellyfin/user_policy.py b/backend/app/models/jellyfin/user_policy.py new file mode 100644 index 000000000..4f055f7dc --- /dev/null +++ b/backend/app/models/jellyfin/user_policy.py @@ -0,0 +1,12 @@ +# pylint: disable=missing-module-docstring, missing-class-docstring, missing-function-docstring + + +class JellyfinUserPolicy: + type: str + title: str + status: int + detail: str + instance: str + additional_prop1: str + additional_prop2: str + additional_prop3: str diff --git a/backend/app/models/notifications.py b/backend/app/models/notifications.py new file mode 100644 index 000000000..b38e09ac9 --- /dev/null +++ b/backend/app/models/notifications.py @@ -0,0 +1,20 @@ +from flask_restx import Model, fields +from peewee import SQL, CharField, DateTimeField, IntegerField + +NotificationsPostModel = Model('NotificationsPostModel', { + "name": fields.String(required=True, description="The name of the notification"), + "type": fields.String(required=True, description="The type of the notification"), + "url": fields.String(required=True, description="The URL of the notification"), + "username": fields.String(required=False, description="The username of the notification"), + "password": fields.String(required=False, description="The password of the notification") +}) + +NotificationsGetModel = Model('NotificationsGetModel', { + "id": fields.Integer(required=True, description="The ID of the notification"), + "name": fields.String(required=True, description="The name of the notification"), + "type": fields.String(required=True, description="The type of the notification"), + "url": fields.String(required=True, description="The URL of the notification"), + "username": fields.String(required=False, description="The username of the notification"), + "password": fields.String(required=False, description="The password of the notification"), + "created": fields.DateTime(required=True, description="The date the notification was created") +}) diff --git a/backend/app/models/settings.py b/backend/app/models/settings.py new file mode 100644 index 000000000..e4889b74d --- /dev/null +++ b/backend/app/models/settings.py @@ -0,0 +1,56 @@ +from typing import Optional +from flask_restx import Model, fields + +class SettingsModel: + server_type: Optional[str] + server_verified: Optional[bool] + server_url: Optional[str] + server_name: Optional[str] + discord_id: Optional[str] + request_type: Optional[str] + request_url: Optional[str] + request_api_key: Optional[str] + server_api_key: Optional[str] + discord_widget: Optional[str] + custom_html: Optional[str] + + def __init__(self, **kwargs) -> None: + for key, value in kwargs.items(): + setattr(self, key, value) + + def model_dump(self): + model = {} + + for key, value in self.__dict__.items(): + if key != "_state": + model[key] = value + + return model + +SettingsPostModel = Model('SettingsPostModel', { + "server_type": fields.String(required=False, description="The type of server"), + "server_verified": fields.String(required=False, description="Whether the server has been verified"), + "server_url": fields.String(required=False, description="The URL of the server"), + "server_name": fields.String(required=False, description="The name of the server"), + "discord_id": fields.String(required=False, description="The Discord ID of the server"), + "request_type": fields.String(required=False, description="The type of request server"), + "request_url": fields.String(required=False, description="The URL of the request server"), + "request_api_key": fields.String(required=False, description="The API key of the request server"), + "server_api_key": fields.String(required=False, description="The API key of the server"), + "discord_widget": fields.String(required=False, description="Whether the Discord widget is enabled"), + "custom_html": fields.String(required=False, description="Custom HTML to be displayed on the homepage") +}) + +SettingsGetModel = Model('SettingsGetModel', { + "server_type": fields.String(required=False, description="The type of server"), + "server_verified": fields.String(required=False, description="Whether the server has been verified"), + "server_url": fields.String(required=False, description="The URL of the server"), + "server_name": fields.String(required=False, description="The name of the server"), + "discord_id": fields.String(required=False, description="The Discord ID of the server"), + "request_type": fields.String(required=False, description="The type of request server"), + "request_url": fields.String(required=False, description="The URL of the request server"), + "request_api_key": fields.String(required=False, description="The API key of the request server"), + "server_api_key": fields.String(required=False, description="The API key of the server"), + "discord_widget": fields.String(required=False, description="Whether the Discord widget is enabled"), + "custom_html": fields.String(required=False, description="Custom HTML to be displayed on the homepage") +}) \ No newline at end of file diff --git a/backend/app/models/users.py b/backend/app/models/users.py new file mode 100644 index 000000000..1b629570c --- /dev/null +++ b/backend/app/models/users.py @@ -0,0 +1,23 @@ +from typing import Optional +from datetime import datetime + +class UsersModel: + token: str + username: str + email: Optional[str] + code: Optional[str] + expires: datetime + auth: Optional[str] + + def __init__(self, **kwargs) -> None: + for key, value in kwargs.items(): + setattr(self, key, value) + + def model_dump(self): + model = {} + + for key, value in self.__dict__.items(): + if key != "_state": + model[key] = value + + return model diff --git a/backend/app/models/wizarr/__init__.py b/backend/app/models/wizarr/__init__.py new file mode 100644 index 000000000..02eb8e926 --- /dev/null +++ b/backend/app/models/wizarr/__init__.py @@ -0,0 +1,4 @@ +from app.models.wizarr.accounts import * +from app.models.wizarr.authentication import * +from app.models.wizarr.invitations import * +from app.models.wizarr.libraries import * diff --git a/backend/app/models/wizarr/accounts.py b/backend/app/models/wizarr/accounts.py new file mode 100644 index 000000000..3beb358c8 --- /dev/null +++ b/backend/app/models/wizarr/accounts.py @@ -0,0 +1,109 @@ +from os import getenv + +from password_strength import PasswordPolicy +from playhouse.shortcuts import model_to_dict +from schematics.exceptions import DataError, ValidationError +from schematics.models import Model +from schematics.types import DateTimeType, EmailType, StringType +from werkzeug.security import generate_password_hash + +from app.models.database.accounts import Accounts + +min_password_length = int(getenv("MIN_PASSWORD_LENGTH", "8")) +min_password_uppercase = int(getenv("MIN_PASSWORD_UPPERCASE", "1")) +min_password_numbers = int(getenv("MIN_PASSWORD_NUMBERS", "1")) +min_password_special = int(getenv("MIN_PASSWORD_SPECIAL", "0")) + +class AccountsModel(Model): + """Account Account Model""" + + # ANCHOR - Account Account Model + id = StringType(required=False) + avatar = StringType(required=False) + display_name = StringType(required=False, default="") + username = StringType(required=True) + email = EmailType(required=False) + password = StringType(required=True) + confirm_password = StringType(required=False) + hashed_password = StringType(required=False) + role = StringType(required=False, default="user") + last_login = DateTimeType(required=False, convert_tz=True) + created = DateTimeType(required=False, convert_tz=True) + + + # ANCHOR - Validate Password + def validate_password(self, _, value): + # Create password policy based on environment variables or defaults + policy = PasswordPolicy.from_names(length=min_password_length, uppercase=min_password_uppercase, numbers=min_password_numbers, special=min_password_special) + + # Check if the password is strong enough + if len(policy.test(value)) > 0: + raise ValidationError("Password is not strong enough") + + + # ANCHOR - Validate Confirm Password + def validate_confirm_password(self, values, value): + if value and value != values["password"]: + raise ValidationError("Passwords do not match") + + + # ANCHOR - Validate Role + def validate_role(self, _, value): + if value not in ["admin", "moderator", "user"]: + raise ValidationError("Invalid role value") + + + # ANCHOR - Validate Username + def check_username_exists(self, account_id: int = None): + if account_id and Accounts.get_or_none(Accounts.username == self.username, Accounts.id != account_id) is not None: + raise DataError({"username": ["Account with that username already exists"]}) + elif not account_id and Accounts.get_or_none(Accounts.username == self.username) is not None: + raise DataError({"username": ["Username is already taken"]}) + + + # ANCHOR - Validate Email + def check_email_exists(self, account_id: int = None): + if account_id and Accounts.get_or_none(Accounts.email == self.email, Accounts.id != account_id) is not None: + raise DataError({"email": ["Account with that email already exists"]}) + elif not account_id and Accounts.get_or_none(Accounts.email == self.email) is not None: + raise DataError({"email": ["Email is already taken"]}) + + + # ANCHOR - Hash Password + def hash_password(self): + self.hashed_password = generate_password_hash(self.password, method="scrypt") + return self.hashed_password + + # ANCHOR - Update Account + def update_account(self, account: Accounts): + # Check if the account exists + if account is None: + raise DataError({"account_id": ["Account does not exist"]}) + + # If password exists, check if confirm_password exists and if they match + if self.password: + if self.confirm_password and self.password != self.confirm_password: + raise DataError({"confirm_password": ["Passwords do not match"]}) + + # Hash the password and set it to the account + self.hash_password() + setattr(account, "password", self.hashed_password) + + # Check if username and email exist + if self.username: + self.check_username_exists(account.id) + + if self.email: + self.check_email_exists(account.id) + + # Set the attributes of the model to the account + for key, value in self.to_primitive().items(): + if value is not None: + setattr(account, key, value) + + # Save the account + account.save() + + # Set the attributes of the updated account to the model + for key, value in model_to_dict(account).items(): + setattr(self, key, value) diff --git a/backend/app/models/wizarr/authentication.py b/backend/app/models/wizarr/authentication.py new file mode 100644 index 000000000..668008e4c --- /dev/null +++ b/backend/app/models/wizarr/authentication.py @@ -0,0 +1,252 @@ +from datetime import datetime, timedelta +from logging import info + +from flask import current_app, jsonify, request +from flask_jwt_extended import (create_access_token, create_refresh_token, + decode_token, get_jti, get_jwt, + get_jwt_identity) +from flask_jwt_extended import set_access_cookies as set_access_cookies_jwt +from flask_jwt_extended import unset_access_cookies as unset_access_cookies_jwt +from flask_jwt_extended import verify_jwt_in_request +from playhouse.shortcuts import model_to_dict +from schematics.exceptions import DataError, ValidationError +from schematics.models import Model +from schematics.types import BooleanType, StringType +from werkzeug.security import check_password_hash, generate_password_hash + +from app.models.database.accounts import Accounts +from app.models.database.sessions import Sessions +from app.models.wizarr.accounts import AccountsModel + + +class AuthenticationModel(Model): + """Authentication Model""" + + # Private Variables + _user: Accounts | None = None + _mfa: bool = False + _user_id: int | None = None + _mfa_id: str | None = None + + + # ANCHOR - Authentication Model + username = StringType(required=True) + password = StringType(required=True) + remember = BooleanType(required=False, default=True) + + + # ANCHOR - Initialize + def __init__(self, *args, **kwargs): + # Get the mfa value from the kwargs and remove it + mfa = kwargs.get("mfa", False) + kwargs.pop("mfa", None) + + # Set the mfa value to the class + self._mfa = mfa + + # Get the user_id value from the kwargs and remove it + self._user_id = kwargs.get("user_id", None) + kwargs.pop("user_id", None) + + # Get the mfa_id value from the kwargs and remove it + self._mfa_id = kwargs.get("mfa_id", None) + kwargs.pop("mfa_id", None) + + super().__init__(*args, **kwargs) + + # Get the user from the database + self._get_user() + + # Skip the rest if mfa is passed + if (mfa): return + + # Migrate old passwords if needed + self._migrate_password() + + # Validate unless partial is set + self.validate() + + + # ANCHOR - Get User + def _get_user(self) -> Accounts: + # Create admin variable + admin: Accounts | None = None + + # Get the user from the database + if not self._mfa and self.username: + admin = Accounts.get_or_none(Accounts.username == self.username.lower()) + elif self._mfa and self._user_id: + admin = Accounts.get_or_none(Accounts.id == self._user_id) + + # Check if the user exists + if admin is None: + raise DataError({"username": ["User does not exist"]}) + + # Set the user + self._user = admin + + + # ANCHOR - Validate Password + def validate_password(self, _, value): + # Check if the password is correct + if not check_password_hash(self._user.password, value): + raise ValidationError("Invalid Username or Password") + + + # ANCHOR - Perform migration of old passwords + def _migrate_password(self): + # Migrate to scrypt from sha 256 + if self._user.password.startswith("sha256"): + # Generate the new hash + new_hash = generate_password_hash(self.password, method='scrypt') + + # Update the password in the database + Accounts.update(password=new_hash).where(Accounts.username == self._user.username.lower()).execute() + + # Log the migration + info("Migrated password for user: " + self._user.username) + + + # ANCHOR - Get ip_address from request + def _get_ip_address(self): + return request.headers.get("X-Forwarded-For", request.remote_addr) + + + # ANCHOR - Get user_agent from request + def _get_user_agent(self): + return request.user_agent.string + + + # ANCHOR - Create JWT Token for user + def get_access_token(self): + # Check if the current_app is set + if not current_app: + raise RuntimeError("Must be called from within a flask route") + + # Generate a jwt token + token = create_access_token(identity=self._user.id, fresh=True) + + # Get JTI from token + jti = get_jti(token) + + # Decode the token to get the expiry + expiry = datetime.fromtimestamp(decode_token(token)["exp"]) + + # Get IP address and User Agent from request + ip_addr = self._get_ip_address() + user_agent = self._get_user_agent() + + # Store the admin key in the database + Sessions.create(access_jti=jti, user=self._user.id, ip=ip_addr, user_agent=user_agent, expires=expiry, mfa_id=self._mfa_id, created=datetime.utcnow()) + + # Return the token + return token + + # ANCHOR - Get JWT Refresh Token for user + def get_refresh_token(self, access_token: str = None): + # Check if the current_app is set + if not current_app: + raise RuntimeError("Must be called from within a flask route") + + # Generate a jwt token + token = create_refresh_token(identity=self._user.id) + + # Update the session with the refresh token + if access_token is not None: + session = Sessions.get(Sessions.access_jti == get_jti(access_token)) + session.refresh_jti = get_jti(token) + session.save() + + # Return the token + return token + + # ANCHOR - Set access cookies + @staticmethod + def set_access_cookies(response, token): + return set_access_cookies_jwt(response, token) + + + # ANCHOR - Unset access cookies + @staticmethod + def unset_access_cookies(response): + return unset_access_cookies_jwt(response) + + + # ANCHOR - Get User + def get_admin(self) -> Accounts: + # Return the user object + return self._user + + + # ANCHOR - Destroy Session + @staticmethod + def destroy_session(): + # Get the token + token = get_jwt() + jti = token["jti"] + + # Delete the session from the database + session = Sessions.get_or_none((Sessions.access_jti == jti) | (Sessions.refresh_jti == jti)) + + # Delete the session + session.delete_instance() + + # ANCHOR - Refresh Token + @staticmethod + def refresh_token(): + # Check if the current_app is set + if not current_app: + raise RuntimeError("Must be called from within a flask route") + + # Get the identity of the user + identity = get_jwt_identity() + jti = get_jwt()["jti"] + + # Find the session in the database where the access_jti or refresh_jti matches the jti + session = Sessions.get_or_none((Sessions.access_jti == jti) | (Sessions.refresh_jti == jti)) + + # Exchange the refresh token for a new access token + access_token = create_access_token(identity=identity) + + # Update the access token in the database + session.access_jti = get_jti(access_token) + session.save() + + # Return the new access token + return jsonify(access_token=access_token) + + + # ANCHOR - Login User + def login_user(self): + # Get Tokens and User + access_token = self.get_access_token() + refresh_token = self.get_refresh_token(access_token) + + # Create a response object + resp = jsonify({ + "message": "Login successful", + "auth": { + "user": AccountsModel(model_to_dict(self._user, exclude=[Accounts.password])).to_primitive(), + "token": access_token, + "refresh_token": refresh_token + } + }) + + # Log message and return response + info(f"Account {self._user.username} successfully logged in") + return resp + + # ANCHOR - Logout User + @staticmethod + def logout_user(): + # Create a response object + response = jsonify({ "message": "Successfully logged out" }) + + # Delete the jwt token from the cookie + auth = AuthenticationModel + + # Destroy the session + auth.destroy_session() + + info("Successfully logged out") + return response diff --git a/backend/app/models/wizarr/invitations.py b/backend/app/models/wizarr/invitations.py new file mode 100644 index 000000000..44bd7da43 --- /dev/null +++ b/backend/app/models/wizarr/invitations.py @@ -0,0 +1,140 @@ +from datetime import datetime, timedelta +from json import JSONDecodeError, loads, dumps +from secrets import token_hex + +from playhouse.shortcuts import model_to_dict +from schematics.exceptions import ValidationError +from schematics.models import Model +from schematics.types import (BaseType, BooleanType, DateTimeType, IntType, + StringType) + +from app.models.database.invitations import Invitations +from app.models.database.libraries import Libraries + +# Custom specific_libraries type that converts a string to a list if needed +class SpecificLibrariesType(BaseType): + """Specific Libraries Type""" + + def to_native(self, value, _): + if isinstance(value, str): + try: + return loads(value) + except JSONDecodeError as e: + raise ValidationError("Invalid libraries") from e + + return value + + +class InvitationsModel(Model): + """Invitations Model""" + + # ANCHOR - Invitations Model + code = StringType(required=False, default=None) + used = BooleanType(required=False, default=False) + expires = IntType(required=False, default=None) + used_by = StringType(required=False, default=None) + unlimited = BooleanType(required=False, default=False) + duration = IntType(required=False, default=None) + specific_libraries = SpecificLibrariesType(required=False, default=[]) + plex_allow_sync = BooleanType(required=False, default=False) + plex_home = BooleanType(required=False, default=False) + used_at = DateTimeType(required=False, default=None, convert_tz=True) + created = DateTimeType(required=False, default=datetime.utcnow(), convert_tz=True) + + + # ANCHOR - Validate Code + def validate_code(self, _, value: str): + # If the code is None ignore further validation + if value is None: + return + + # Check that the code is a 6 character string of only letters and numbers + if not isinstance(value, str) or len(value) != 6 or not value.isalnum(): + raise ValidationError("Invalid code") + + # Check that the code has not been used + if Invitations.get_or_none(Invitations.code == self.code) is not None: + raise ValidationError("Code already exists") + + + # ANCHOR - Validate expires + def validate_expires(self, _, value: datetime): + # Check that the expires is in the future, ignore milliseconds + if value and (datetime.utcnow() + timedelta(minutes=int(str(value)))) < datetime.utcnow(): + raise ValidationError("Expires must be in the future") + + # ANCHOR - Validate duration + def validate_duration(self, _, value: datetime): + # Check that the duration is in the future, ignore milliseconds + if value and (datetime.utcnow() + timedelta(minutes=int(str(value)))) < datetime.utcnow(): + raise ValidationError("Duration must be in the future") + + # ANCHOR - Validate specific_libraries + def validate_specific_libraries(self, _, value): + # Check that the value is a list + if not isinstance(value, list): + raise ValidationError("Invalid libraries") + + # Check that the libraries are valid + for library_id in value: + + # Check that the library is a string + if not isinstance(library_id, str): + raise ValidationError("Invalid library id") + + # Check that the library exists in the database + if not Libraries.get_or_none(Libraries.id == library_id): + raise ValidationError(f"Invalid library {library_id}") + + + # ANCHOR - Create Invitation in the Database + def create_invitation(self) -> Invitations: + # Create the invitation + invitation = self.to_native() + + # Infinite function that will create a new code until it is unique in the database, only run 10 times + def create_code(): + for _ in range(10): + code = token_hex(3).upper() + if not Invitations.get_or_none(Invitations.code == code): + return code + raise ValidationError("Unable to generate a unique code") + + # If code is None, generate a new code + if not invitation["code"] or len(invitation["code"]) != 6 or not str(invitation["code"]).isalnum(): + invitation["code"] = create_code() + + # Upper case the code + invitation["code"] = str(invitation["code"]).upper() + + # If specific_libraries is empty, set it to all libraries + if len(invitation["specific_libraries"]) == 0: + invitation["specific_libraries"] = [library.id for library in Libraries.select()] + + # If specific_libraries is a list, convert it to a string of comma separated values + if isinstance(invitation["specific_libraries"], list): + invitation["specific_libraries"] = ",".join(invitation["specific_libraries"]) + + + # invitation["expires"] = None + # invitation["duration"] = None + + # If expires is a string or int, convert it to a utc datetime plus the total minutes + if invitation["expires"] and isinstance(invitation["expires"], (str, int)): + invitation["expires"] = datetime.utcnow() + timedelta(minutes=int(str(invitation["expires"]))) + + # If duration is a string or int, convert it to a utc datetime plus the total minutes + if invitation["duration"] and isinstance(invitation["duration"], (str, int)): + invitation["duration"] = datetime.utcnow() + timedelta(minutes=int(str(invitation["duration"]))) + + # Create the invitation in the database + invite: Invitations = Invitations.create(**invitation) + + # Convert specific_libraries back to a list + invite.specific_libraries = invite.specific_libraries.split(",") + + # Set the attributes of the invite to model + for key, value in model_to_dict(invite).items(): + setattr(self, key, value) + + return loads(dumps(model_to_dict(invite), indent=4, sort_keys=True, default=str)) diff --git a/backend/app/models/wizarr/libraries.py b/backend/app/models/wizarr/libraries.py new file mode 100644 index 000000000..3dbebfc8e --- /dev/null +++ b/backend/app/models/wizarr/libraries.py @@ -0,0 +1,153 @@ +from schematics.models import Model +from schematics.types import StringType, DateTimeType, BaseType, URLType +from schematics.exceptions import DataError, ValidationError + +from json import loads, JSONDecodeError +from app.models.database.libraries import Libraries +from app.models.database.settings import Settings +from logging import info + +class SpecificLibrariesType(BaseType): + """Converts a string to a list if needed""" + + def to_native(self, value, _): + if isinstance(value, str): + try: + return loads(value) + except JSONDecodeError as e: + raise ValidationError("Invalid libraries") from e + + return value + + +class LibraryModel(Model): + """Libraries List Model""" + + id = StringType(required=True) + name = StringType(required=True) + created = DateTimeType(required=False, convert_tz=True) + + +class ScanLibrariesModel(Model): + """Scan Libraries Model""" + + server_type = StringType(required=False, choices=["plex", "jellyfin"]) + server_url = URLType(fqdn=False, required=False) + server_api_key = StringType(required=False) + + +class LibrariesModel(Model): + """Libraries Model""" + + # ANCHOR - Libraries Model + libraries: list[str] = SpecificLibrariesType(required=False, default=[]) + + + # ANCHOR - Validate libraries + def validate_libraries(self, _, value): + # Check that the value is a list + if not isinstance(value, list): + raise ValidationError("Invalid libraries") + + # Check that the libraries are valid + for library_id in value: + + # Check that the library is a string + if not isinstance(library_id, str): + raise ValidationError("Invalid library id") + + # Check that the library exists in the database + if not Libraries.get_or_none(Libraries.id == library_id): + raise ValidationError(f"Invalid library {library_id}") + + + # ANCHOR - Get Plex Libraries + def get_plex_libraries(self, server_url: str, server_api_key: str): + from helpers.plex import scan_plex_libraries + plex_libraries = scan_plex_libraries(server_api_key, server_url) + return [{"id": str(library.uuid), "name": library.title} for library in plex_libraries] + + + # ANCHOR - Get Jellyfin Libraries + def get_jellyfin_libraries(self, server_url: str, server_api_key: str): + from helpers.jellyfin import scan_jellyfin_libraries + jellyfin_libraries = scan_jellyfin_libraries(server_api_key, server_url) + return [{"id": library["Id"], "name": library["Name"]} for library in jellyfin_libraries] + + + # ANCHOR - Compare Libraries + def compare_libraries(self, server_libraries: list[dict]): + # pylint: disable=unsupported-membership-test + return [library for library in server_libraries if library["id"] in self.libraries] + + + # ANCHOR - Delete Libraries + def delete_libraries(self, libraries: list[dict]) -> int: + return Libraries.delete().where(Libraries.id.not_in([library["id"] for library in libraries])).execute() + + + # ANCHOR - Update Libraries to Database + def update_libraries(self): + # Get server_type, server_url, and server_api_key from the database + settings = { + settings.key: settings.value + for settings in Settings.select().where( + (Settings.key == "server_type") | (Settings.key == "server_url") | (Settings.key == "server_api_key") + ) + } + + # Place the settings into variables + server_type, server_url, server_api_key = settings["server_type"], settings["server_url"], settings["server_api_key"] + + # Check variables are not None + if server_type is None or server_url is None or server_api_key is None: + raise DataError("Invalid server settings") + + + # Functions to get libraries from the server + server_libraries_func = { + "plex": self.get_plex_libraries, + "jellyfin": self.get_jellyfin_libraries + } + + # Get the libraries from the server + server_libraries = server_libraries_func[server_type](server_url, server_api_key) + + # Check that the libraries are not None + if server_libraries is None: + raise DataError("Invalid Libraries") + + # Compare the libraries from the server to the libraries from the client + libraries = self.compare_libraries(server_libraries) + + # Delete all libraries THAT ARE NOT in the list of libraries + deleted_count = self.delete_libraries(libraries) + + # Log the deleted libraries + info(f"Deleted {deleted_count} libraries") + + # Loop through new libraries + for library in libraries: + + # Attempt to get the library from the database + db_library = Libraries.get_or_none(Libraries.id == library["id"]) + + # Create the library if it does not exist + if db_library is None: + Libraries.create(id=library["id"], name=library["name"]) + info(f"Library {library['name']} created") + continue + + # Update the library if it exists + if db_library.name != library["name"]: + db_library.name = library["name"] + db_library.save() + info(f"Library {library['name']} updated") + continue + + # Log that the library already exists + info(f"Library {library['name']} already exists") + + + # Set libraries to the libraries from the database + setattr(self, "libraries", [{"id": library.id, "name": library.name} for library in Libraries.select()]) diff --git a/backend/app/notifications/__init__.py b/backend/app/notifications/__init__.py new file mode 100644 index 000000000..71536a173 --- /dev/null +++ b/backend/app/notifications/__init__.py @@ -0,0 +1,5 @@ +import app.notifications.providers as notification_providers +import app.notifications.sender as notification_sender +import app.notifications.builder as notification_builder +import app.notifications.exceptions as notification_exceptions +import app.notifications.model as notification_model diff --git a/backend/app/notifications/builder.py b/backend/app/notifications/builder.py new file mode 100644 index 000000000..e3841a4ef --- /dev/null +++ b/backend/app/notifications/builder.py @@ -0,0 +1,114 @@ +from inspect import getmembers, isclass + +from app.notifications import providers +from app.notifications.model import Model + + +def build_web_resource(resource: type(providers)): + # Store metadata for each field in the resource + metadata = [] + + # Loop through each field in the resource + for field in resource.items(): + # Get the metadata for the field in the resource class + field_metadata = resource.fields[field[0]].metadata + + # Get the default value for the field in the resource class + field_default = resource.fields[field[0]].default + + # Get if the field is required in the resource class + field_required = resource.fields[field[0]].required + + # Get the field name in the resource class + field_name = field[0] + + # Get the type of the field in the resource class + field_type = resource.fields[field[0]].primitive_type.__name__ + + # Create a dictionary for the fields metadata + data = {} + + # Add the field name to the data dictionary + if field_metadata: + data["name"] = field_metadata["name"] + data["metadata"] = field_metadata + + # Add the field default value to the data dictionary + if field_default: + data["default"] = field_default + + # Add the field required value to the data dictionary + if field_required: + data["required"] = field_required + + # Add the field name to the data dictionary + if field_name: + data["field_name"] = field_name + + # Add the field type to the data dictionary + if field_type: + data["type"] = field_type + + # Add to the metadata dictionary + metadata.append(data) + + # Return the metadata dictionary + return metadata + + +def get_web_resources(): + # Store all resources + resources = [] + + # Get all classes from app.notifications.providers + classes = getmembers(providers, isclass) + + # Filter the classes based on their name + resource_classes = [cls for cls in classes if cls[0].endswith('Resource')] + + # Loop through each resource class and build the web resource + for cls in resource_classes: + web_resource = build_web_resource(cls[1]()) + resource_name = web_resource[0]["name"] + + resources.append({ + "name": resource_name, + "class": cls[1].__name__, + "resource": web_resource, + }) + + return resources + + +def validate_resource(resource: str, data: dict or str) -> Model: + # Make sure the resource name is valid and ends with Resource + if not resource.endswith("Resource"): + raise ValueError("Invalid resource name") + + # Get the resource class from the providers module by name + resource_class = getattr(providers, resource) + + try: + # Validate the data against the resource class + resource_model: Model = resource_class(validate=True, strict=False) + + # Load the data into the model + if isinstance(data, str): + resource_model.from_json(data) + elif isinstance(data, dict): + resource_model.import_data(data) + else: + raise ValueError("Invalid data passed to resource") + + except Exception as e: + raise ValueError(f"Invalid data passed to {resource}") from e + + # Add the resource name to the model + resource_model.resource = resource + resource_model.resource_class = resource_class + + # Add metadata to the model, but only run build_web_resource if access is attempted + resource_model.metadata = lambda: build_web_resource(resource_model) + + # Return the validated data + return resource_model diff --git a/backend/app/notifications/exceptions.py b/backend/app/notifications/exceptions.py new file mode 100644 index 000000000..b10eef2e9 --- /dev/null +++ b/backend/app/notifications/exceptions.py @@ -0,0 +1,8 @@ +class NotificationSendError(Exception): + pass + +class NotificationStatusError(Exception): + pass + +class InvalidNotificationAgent(Exception): + pass \ No newline at end of file diff --git a/backend/app/notifications/model.py b/backend/app/notifications/model.py new file mode 100644 index 000000000..466e4cbd4 --- /dev/null +++ b/backend/app/notifications/model.py @@ -0,0 +1,36 @@ +from json import dumps, loads +from typing import Optional + +from schematics.models import Model as SchematicsModel + +from app.models.database.notifications import Notifications + + +class Model(SchematicsModel): + """Extend the Schematics Model class to add additional functionality""" + + def save(self, user_id: Optional[int | str] = None): + # Get the user id from the current user if no user id is provided + if user_id is None: + from flask_jwt_extended import current_user + user_id = current_user['id'] + + # # Create a new notification in the database + new_notification = Notifications.create( + user_id=user_id, + resource=self.resource, + data=self.to_json() + ) + + # # Save the notification in the database + new_notification.save() + + def to_json(self, role=None, app_data=None, **kwargs): + data = super().to_primitive(role, app_data, **kwargs) + return dumps(data) + + def from_json(self, raw_data, recursive=False, **kwargs): + data = loads(raw_data) + return super().import_data(data, recursive, **kwargs) + + diff --git a/backend/app/notifications/providers/__init__.py b/backend/app/notifications/providers/__init__.py new file mode 100644 index 000000000..424007286 --- /dev/null +++ b/backend/app/notifications/providers/__init__.py @@ -0,0 +1,2 @@ +from .pushover import Pushover, PushoverResource +from .smtp import SMTP, SMTPResource diff --git a/backend/app/notifications/providers/pushover.py b/backend/app/notifications/providers/pushover.py new file mode 100644 index 000000000..09eea578c --- /dev/null +++ b/backend/app/notifications/providers/pushover.py @@ -0,0 +1,63 @@ +from requests import post +from schematics.types import StringType + +from app.notifications.exceptions import NotificationSendError, NotificationStatusError +from app.notifications.model import Model + + +class PushoverResource(Model): + name = StringType(default="Pushover", metadata={"name": "Pushover", "icon": "bell", "description": 'e.g. "Pushover"'}) + base_url = StringType(default="https://api.pushover.net/1/", metadata={"name": "Base URL", "description": 'e.g. "https://api.pushover.net/1/"'}) + token = StringType(required=True, metadata={"name": "API Token", "description": 'e.g. "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5"'}) + user = StringType(required=True, metadata={"name": "User Key", "description": 'e.g. "a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5"'}) + device = StringType(required=False, default=None, metadata={"name": "Device Name", "description": 'e.g. "iPhone"'}) + + template = { + "name": "Pushover", + "logo": "https://pushover.net/images/pushover-logo.svg", + "description": "Pushover is a simple push notification service to instantly send alerts to Android and iOS devices." + } + + +class Pushover(PushoverResource): + """ + A pushover notification + + :param token: The pushover API token + :param user: The pushover user key + :param device: The pushover device name + + :raises ResourceError: If the pushover API returns an error + """ + + def send(self, **kwargs): + """ + Send a pushover notification + + :param title: The title of the notification + :param message: The message of the notification + + :return: The response from the pushover API + """ + + payload = { + "token": self.token, + "user": self.user, + "message": kwargs.get("message"), + } + + if kwargs.get("title"): + payload.update({"title": kwargs.get("title")}) + + if self.device: + payload.update({"device": self.device}) + + try: + response = post(f"{self.base_url}messages.json", data=payload, timeout=10) + except Exception as e: + raise NotificationSendError(f"Pushover error: {e}") from e + + if response.status_code != 200: + raise NotificationStatusError(f"Pushover error: {response.text}") + + return response.json() diff --git a/backend/app/notifications/providers/smtp.py b/backend/app/notifications/providers/smtp.py new file mode 100644 index 000000000..7df3afb6a --- /dev/null +++ b/backend/app/notifications/providers/smtp.py @@ -0,0 +1,65 @@ +from smtplib import SMTP as SMTPClient +from ssl import create_default_context + +from schematics.types import BooleanType, IntType, StringType + +from app.notifications.exceptions import NotificationSendError +from app.notifications.model import Model + + +class SMTPResource(Model): + name = StringType(default="SMTP", metadata={"name": "SMTP", "hidden": True, "icon": "envelope", "description": 'e.g. "SMTP"'}) + smtp_server = StringType(required=True, default="", metadata={"name": "SMTP Server", "type": "url", "description": 'e.g. "smtp.wizarr.dev"'}) + port = IntType(required=True, default=25, metadata={"name": "Port", "description": 'e.g. "25"'}) + username = StringType(required=True, default="", metadata={"name": "Username", "description": 'e.g. "wizarr@wizarr.dev'}) + password = StringType(required=True, default="", metadata={"name": "Password", "type": "password", "description": 'e.g. "password"'}) + receiver = StringType(required=True, default="", metadata={"name": "Receiver", "type": "email", "description": 'e.g. "admin@wizarr.dev'}) + starttls = BooleanType(required=False, default="false", metadata={"name": "StartTLS", "type": "checkbox", "description": 'e.g. "False"'}) + + template = { + "name": "SMTP", + "icon": "fa-envelope", + "description": "SMTP is a simple email service to instantly send alerts to email addresses." + } + + +class SMTP(SMTPResource): + """ + A smtp notification + + :param smtp_server: The smtp server url + :param port: The smtp server port + :param username: The smtp server username + :param password: The smtp server password + + :raises ResourceError: If the smtp API returns an error + """ + + def send(self, **kwargs): + """ + Send a smtp notification + + :param title: The title of the notification + :param message: The message of the notification + + :return: The response from the smtp API + """ + + message = f"Subject: {kwargs.get('title')}\n\n{kwargs.get('message')}" + + # Send the notification + try: + client = SMTPClient(self.smtp_server, self.port) + + if self.starttls: + context = create_default_context() + client.starttls(context=context) + + client.login(self.username, self.password) + client.sendmail(self.username, self.receiver, message) + client.quit() + except Exception as e: + raise NotificationSendError(str(e)) from e + + # Return the response + return True diff --git a/backend/app/notifications/sender.py b/backend/app/notifications/sender.py new file mode 100644 index 000000000..0e5967291 --- /dev/null +++ b/backend/app/notifications/sender.py @@ -0,0 +1,40 @@ +import app.notifications.providers as notification_providers + +# Take an array of class and send a notification to each one +def send_notifications(providers: [notification_providers], **kwargs): + """ + Send a notification to a list of providers + + :param providers: A list of providers to send a notification to + :param kwargs: The arguments to send to the provider + + :return: A dictionary of responses from the providers + """ + + # Response object + response = {} + + # Loop through each provider and send a notification + for provider in providers: + response[provider.name] = provider.send(**kwargs) + + # Return the response + return response + + +# Send a notification to a provider +def send_notification(provider: notification_providers, **kwargs): + """ + Send a notification to a provider + + :param provider: The provider to send a notification to + :param kwargs: The arguments to send to the provider + + :return: The response from the provider + """ + + # Send the notification + response = provider.send(**kwargs) + + # Return the response + return response diff --git a/backend/app/scheduler.py b/backend/app/scheduler.py new file mode 100644 index 000000000..43eb19515 --- /dev/null +++ b/backend/app/scheduler.py @@ -0,0 +1,109 @@ +from datetime import datetime +from logging import info + +from app.extensions import schedule +from app.security import server_verified + +schedule.start() + +# Scheduled tasks +@schedule.task("interval", id="checkExpiringUsers", minutes=30, misfire_grace_time=900) +def check_expiring_users(): + # Check if the server is verified + if not server_verified(): return + + # Import the function here to avoid circular imports + from helpers.universal import global_delete_user_from_media_server + from helpers.users import get_users_by_expiring + + # Log message to console + info("Checking for expiring users") + + # Get all users that have an expiration date set and are expired + expiring = get_users_by_expiring() + + print(expiring) + + # Delete all expired users + for user in expiring: + global_delete_user_from_media_server(user.id) + info(f"Deleting user { user.email if user.email else user.username } due to expired invite.") + + +@schedule.task("interval", id="clearRevokedSessions", hours=1, misfire_grace_time=900) +def clear_revoked_sessions(): + # Check if the server is verified + if not server_verified(): return + + # Import the function here to avoid circular imports + from app.models.database import Sessions + + info("Checking for expired sessions") + # Get all sessions where expires is less than now in utc and delete them + sessions = Sessions.select().where(Sessions.revoked) + + # Delete all expired sessions + for session in sessions: + session.delete_instance() + info(f"Deleting session { session.id } due to being revoked.") + + +@schedule.task("interval", id="syncUsers", hours=3, misfire_grace_time=900) +def scan_users(): + # Check if the server is verified + if not server_verified(): return + + # Import the function here to avoid circular imports + from helpers.universal import global_sync_users_to_media_server + + info("Scanning for new users") + global_sync_users_to_media_server() + +@schedule.task("interval", id="checkForUpdates", hours=1, misfire_grace_time=900) +def check_for_updates(): + # Import the function here to avoid circular imports + from app.utils.software_lifecycle import need_update + from app import app + + info("Checking for updates") + + # Update jinja global variable + app.jinja_env.globals.update(APP_UPDATE=need_update()) + + + +# Ignore these helper functions they need to be moved to a different file +def get_schedule(): + job_store = schedule.get_jobs() + + schedule_list = [] + + for job in job_store: + # Replace underscores with spaces and capitalize first letter of each word + name = job.name.replace("_", " ").title() + + schedule_info = { + "id": job.id, + "name": name, + "trigger": str(job.trigger), + "next_run_time": str(job.next_run_time) + } + + schedule_list.append(schedule_info) + + return schedule_list + +def get_task(job_id): + job_store = schedule.get_job(id=job_id) + + schedule_info = { + "id": job_store.id, + "name": job_store.name, + "trigger": str(job_store.trigger), + "next_run_time": str(job_store.next_run_time) + } + + return schedule_info + +def run_task(job_id): + return schedule.modify_job(id=job_id, jobstore=None, next_run_time=datetime.now()) diff --git a/backend/app/security.py b/backend/app/security.py new file mode 100644 index 000000000..0131588db --- /dev/null +++ b/backend/app/security.py @@ -0,0 +1,79 @@ +from os import mkdir, path +from secrets import token_hex +from flask import request +from flask_jwt_extended import verify_jwt_in_request +from playhouse.shortcuts import model_to_dict + +from app.models.database import Sessions, Settings, Accounts, APIKeys + +# Yh this code looks messy but it works so ill tidy it up later +database_dir = path.abspath(path.join(__file__, "../", "../", "../", "database")) +database_file = path.join(database_dir, "database.db") +secret_key_file = path.join(database_dir, "secret.key") + +# Class to handle authentication for the scheduler +class SchedulerAuth: + def get_authorization(self): + return verify_jwt_in_request() + + def get_authenticate_header(self): + return request.cookies.get("access_token_cookie") + +def server_verified(): + verified = Settings.get_or_none(Settings.key == "server_verified") + if verified: return bool(verified.value) + return bool(verified) + +# Generate a secret key, and store it in root/database/secret.key if it doesn't exist, return the secret key +def secret_key(length: int = 32) -> str: + + # Check if the database directory exists + if not path.exists(database_dir): + mkdir(database_dir) + + # Check if the secret key file exists + if not path.exists(secret_key_file): + # Generate a secret key and write it to the secret key file + with open(path.join(secret_key_file), "w", encoding="utf-8") as f: + secret = token_hex(length) + f.write(secret) + return secret + + # Read the secret key from the secret key file + with open(path.join(secret_key_file), "r", encoding="utf-8") as f: + secret = f.read() + + return secret + +# def refresh_expiring_jwts(response): +# try: +# jwt = get_jwt() +# if datetime.timestamp(datetime.now(timezone.utc) + timedelta(minutes=30)) > jwt["exp"]: +# access_token = create_access_token(identity=get_jwt_identity()) +# Sessions.update(session=get_jti(access_token), expires=datetime.utcnow() + timedelta(days=1)).where(Sessions.session == jwt["jti"]).execute() +# set_access_cookies(response, access_token) +# info(f"Refreshed JWT for {get_jwt_identity()}") +# return response +# except (RuntimeError, KeyError): +# return response + + +def check_if_token_revoked(_, jwt_payload: dict) -> bool: + jti = jwt_payload["jti"] + session = Sessions.get_or_none((Sessions.access_jti == jti) | (Sessions.refresh_jti == jti)) + api = not APIKeys.select().where(APIKeys.jti == jti).exists() + return session.revoked if session else api + +def user_identity_lookup(user): + return user + +def user_lookup_callback(_, jwt_data): + identity = jwt_data["sub"] + try: + user = Accounts.get_by_id(identity) + return model_to_dict(user, recurse=True, backrefs=True, exclude=[Accounts.password]) + except Exception: + return None + +def is_setup_required(): + return not server_verified() diff --git a/backend/app/utils/backup.py b/backend/app/utils/backup.py new file mode 100644 index 000000000..418a69113 --- /dev/null +++ b/backend/app/utils/backup.py @@ -0,0 +1,101 @@ +from app.models.database.base import db, db_file, db_dir +from cryptography.fernet import Fernet +from base64 import urlsafe_b64encode +from json import dumps, loads +from datetime import datetime +from os import system, path + +def backup_database(): + # Backup dictionary + backup = {} + + # Get all tables + db_tables = db.get_tables() + + # Get all rows in the table + for table in db_tables: + + # Add the table to the backup dictionary + backup[table] = [] + + # Get all rows in the table + db_rows = db.execute_sql(f"SELECT * FROM {table}") + + # Get all columns in the table + db_columns = [column[0] for column in db_rows.description] + + # Add all rows to the backup dictionary + for row in db_rows: + backup[table].append(dict(zip(db_columns, row))) + + # Remove apscheduler_jobs table + backup.pop("apscheduler_jobs", None) + + return backup + + +def restore_database(backup: dict): + # Create a backup of the database file before restoring + backup_filename = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + backup_location = path.join(db_dir, "backups") + system(f"cp {db_file} {path.join(backup_location, backup_filename)}") + + # Loop through all tables in the backup dictionary and restore them + for table in backup: + # Delete all rows in the table + db.execute_sql(f"DELETE FROM {table}") + + # Loop through all rows in the table + for row in backup[table]: + # Get all columns in the table + columns = list(row.keys()) + + # Get all values in the table + values = list(row.values()) + + # Create the query + query = f"INSERT INTO {table} ({', '.join(columns)}) VALUES ({', '.join(['?'] * len(columns))})" + + # Execute the query + db.execute_sql(query, values) + + return True + + +def generate_key(text): + # Convert the text to bytes + text = text.encode() + + # Pad the text to 32 bytes + text = text + b"=" * (32 - len(text) % 32) + + # Encode the text to base64 + text = urlsafe_b64encode(text).decode() + + # Return the base64 encoded text + return text + + +def encrypt_backup(backup: dict, key: str): + # Create the encryption key + key = Fernet(key) + + def encrypt_string(string: str): + return key.encrypt(string.encode()).decode() + + backup_string = dumps(backup) + encrypted_backup = encrypt_string(backup_string) + + return encrypted_backup + + +def decrypt_backup(backup: str, key: str): + # Create the encryption key + key = Fernet(key) + + def decrypt_string(string: str): + return key.decrypt(string).decode() + + backup_string = decrypt_string(backup) + + return loads(backup_string) diff --git a/backend/app/utils/clear_logs.py b/backend/app/utils/clear_logs.py new file mode 100644 index 000000000..bc6b7736a --- /dev/null +++ b/backend/app/utils/clear_logs.py @@ -0,0 +1,12 @@ +from flask_restx import Api +from json import dumps +from os import path + +def clear_logs(): + """Clear the logs.log file on startup""" + base_dir = path.dirname(path.dirname(path.dirname(path.abspath(__file__)))) + + # Clear log file contents on startup + if path.exists(path.join(base_dir, "../", "database", "logs.log")): + with open(path.join(base_dir, "../", "database", "logs.log"), "w", encoding="utf-8") as f: + f.write("") diff --git a/backend/app/utils/media_server.py b/backend/app/utils/media_server.py new file mode 100644 index 000000000..32b848889 --- /dev/null +++ b/backend/app/utils/media_server.py @@ -0,0 +1,231 @@ +from ipaddress import ip_interface +from logging import error +from socket import gethostbyname +from typing import Optional + +from nmap import PortScanner +from requests import get +from schematics.exceptions import ValidationError +from schematics.types import URLType + +def get_host_ip_from_container(): + """ + Get the IP address of the Docker host from inside the container + :return: str | None + """ + + # Retrieve the IP address of the Docker host from inside the container + try: + return gethostbyname("host-gateway") + except Exception: + return None + +def get_internal_ip_from_container(): + """ + Get the internal IP address of the container + :return: str | None + """ + + # Retrieve the IP address of the Docker host from inside the container + try: + return gethostbyname("host.docker.internal") + except Exception: + return None + +def get_subnet_from_ip(host_ip: str): + """ + Get the subnet of the given IP address + :return: str + """ + + # Calculate the subnet of the container"s host IP address + subnet_ip = ip_interface(f"{host_ip}/24") + subnet = subnet_ip.network + + # Return the subnet + return subnet + +def detect_server(server_url: str): + """ + Detect what type of media server is running at the given url + + :param server_url: The url of the media server + :type server_url: str + + :return: object + """ + + # Create URLType object + url_validator = URLType(fqdn=False) + + # Create a url object + url = url_validator.valid_url(server_url) + + # Check if the url is valid + if not url: + raise ValidationError("Invalid url, malformed input") + + # If the url has path, query, or fragment, raise an exception + if url["query"] or url["frag"]: + raise ValidationError("Invalid url, must be a base url") + + # Get host from url + host = url["hostn"] or url["host4"] or url["host6"] + + # Construct the url from the server url + server_url = f"{url['scheme']}://{host}" + + # Add the port if it exists + if url["port"]: + server_url += f":{url['port']}" + + # Add the path if it exists + if url["path"] and url["path"] != "/": + server_url += url["path"] + + # Map endpoints to server types + endpoints = { + "plex": "/identity", + "jellyfin": "/System/Info/Public" + } + + # Loop through the endpoints to find the server type + for server_type, endpoint in endpoints.items(): + # Make the request, don't allow redirects, and set the timeout to 30 seconds + try: + response = get(f"{server_url}{endpoint}", allow_redirects=False, timeout=30) + except Exception as e: + error(e) + continue + + # Check if the response is valid + if response.status_code == 200: + return { + "server_type": server_type, + "server_url": server_url + } + + # Raise an exception if the server type is not found + raise ConnectionError("Media Server could not be contacted") + +def verify_server(server_url: str, server_api_key: str): + """ + Verify that the api credentials are valid for the media server + + :param server_url: The url of the media server + :type server_url: str + + :param server_api_key: The api key of the media server + :type server_api_key: str + + :return: object + """ + + # Get the server type + server = detect_server(server_url) + server_type = server["server_type"] + server_url = server["server_url"] + + # Map endpoints for verifying the server + endpoints = { + "plex": f"/connections?X-Plex-Token={server_api_key}", + "jellyfin": f"/System/Info?api_key={server_api_key}" + } + + # Build the url for the server + server_url += endpoints[server_type] + + # Make the request, don't allow redirects, and set the timeout to 30 seconds + try: + response = get(server_url, allow_redirects=False, timeout=30) + except ConnectionError as e: + raise ConnectionError("Unable to connect to server") from e + + # Check if the response is valid + if response.status_code == 200: + return { + "server_type": server_type, + "server_url": server["server_url"] + } + + # Raise an exception if the server type is not found + raise ConnectionError("Unable to verify server") + +def scan_network(ports: Optional[int | str | list[int | str]] = None, target: Optional[str | list[str]] = None): + """ + Scan the network for media servers + :param ports: The ports to scan + :param target: The target to scan + + :return: list + """ + + # Create a PortScanner object + nmap = PortScanner() + media_servers = [] + + # Set default ports if not provided + if ports is None: + ports = [8096, 32400] + + # Set default target if not provided + if target is None: + # Get the dockers internal and external IP addresses + external_address = get_host_ip_from_container() + internal_address = get_internal_ip_from_container() + + # Create a list of targets + targets = [] + + # Add the external ip address to the list of targets if it exists + if external_address: + targets.append(f"{get_subnet_from_ip(external_address)}") + + # Add the internal ip address to the list of targets if it exists + if internal_address: + targets.append(f"{get_subnet_from_ip(internal_address)}") + + # Convert the list of targets to a string + target = f"{' '.join(targets)}" + + # If target is a list, convert it to a string + if isinstance(target, list): + target = " ".join(target) + + # Ensure ports is a list + if isinstance(ports, (int, str)): + ports = [str(ports)] + + # Convert ports to strings + ports = [str(port) for port in ports] + + # Convert ports list to a string + port_str = ",".join(ports) + + # Scan the network + nmap.scan(hosts=target, arguments=f"-p {port_str} --open") + + # Iterate through each host + for host in nmap.all_hosts(): + # Check open ports and determine server type + for port in nmap[host]["tcp"].keys(): + # Check if the port is open + if nmap[host]["tcp"][port]["state"] == "open": + try: + # Attempt to detect the server type + server_info = detect_server(f"http://{host}:{port}") + server_type = server_info.get("server_type", None) + + # If the server type is None, skip it + if server_type is None: + continue + + # Add server information to the list + media_servers.append({"host": host, "port": port, "server_type": server_type }) + + except Exception: + # If an exception occurs, pass to the next port + pass + + # Return the list of media servers + return media_servers diff --git a/backend/app/utils/software_lifecycle.py b/backend/app/utils/software_lifecycle.py new file mode 100644 index 000000000..01dde8570 --- /dev/null +++ b/backend/app/utils/software_lifecycle.py @@ -0,0 +1,66 @@ +from requests import get +from packaging.version import parse +from os import path +from json import load + +def get_latest_version(): + url = "https://raw.githubusercontent.com/Wizarrrr/wizarr/master/.github/latest" + response = get(url, timeout=10) + if response.status_code != 200: + return None + return parse(response.content.decode("utf-8")) if response.content else None + +def get_latest_beta_version(): + try: + url = "https://api.github.com/repos/wizarrrr/wizarr/releases" + response = get(url, timeout=5) + if response.status_code != 200: + return None + releases = response.json() + latest_beta = [release["tag_name"] for release in releases if release["prerelease"]][0] + return parse(latest_beta) + except Exception: + return None + +def get_current_version(): + package = path.abspath(path.join(path.dirname(__file__), "../", "../", "../", "package.json")) + with open(package, "r", encoding="utf-8") as f: + return parse(load(f)["version"]) + + +def is_beta(): + current_version = get_current_version() + latest_version = get_latest_version() + beta = False + + try: + beta = bool(current_version > latest_version) + except Exception: + pass + + return beta + +def is_stable(): + current_version = get_current_version() + latest_version = get_latest_version() + stable = False + + try: + stable = bool(current_version < latest_version) + except Exception: + pass + + return stable + +# cache +def need_update(): + current_version = get_current_version() + latest_version = is_beta() and get_latest_beta_version() or get_latest_version() + update = False + + try: + update = bool(current_version < latest_version) + except Exception: + update = False + + return update diff --git a/backend/babel.cfg b/backend/babel.cfg new file mode 100644 index 000000000..4e6750c1c --- /dev/null +++ b/backend/babel.cfg @@ -0,0 +1,5 @@ +[python: app/**.py] +[jinja2: app/templates/**.html] +[jinja2: app/views/**.html] +[jinja2: app/configs/**] +;extensions=jinja2.ext.autoescape,jinja2.ext.with_ \ No newline at end of file diff --git a/backend/helpers/__init__.py b/backend/helpers/__init__.py new file mode 100644 index 000000000..14c5a5ae1 --- /dev/null +++ b/backend/helpers/__init__.py @@ -0,0 +1,5 @@ +from .jellyfin import * +from .plex import * +from .settings import * +from .accounts import * +from .universal import * diff --git a/backend/helpers/accounts.py b/backend/helpers/accounts.py new file mode 100644 index 000000000..42a0a3349 --- /dev/null +++ b/backend/helpers/accounts.py @@ -0,0 +1,195 @@ +from playhouse.shortcuts import model_to_dict +from schematics.exceptions import DataError + +from app.models.database.accounts import Accounts +from app.models.wizarr.accounts import AccountsModel + +# INDEX OF FUNCTIONS +# - Get Accounts +# - Get Account by ID +# - Get Account by Username +# - Create Account User +# - Update Account User +# - Delete Account User + + +# ANCHOR - Get Accounts +def get_accounts(password: bool = True) -> list[Accounts]: + """Get all accounts from the database + :param password: Whether or not to include the password in the response + :type password: bool + + :return: A list of accounts + """ + + # Get all accounts from the database + accounts: list[Accounts] = Accounts.select() + exclude = [] + + # Remove the password from the account if password is False + if password is False: + exclude.append("password") + + # Convert the accounts to a list of dictionaries + accounts = [AccountsModel(model_to_dict(account, exclude=exclude)).to_primitive() for account in accounts] + + # Return a list of dicts + return accounts + + +# ANCHOR - Get Account by ID +def get_account_by_id(account_id: int, verify: bool = True, password: bool = False) -> Accounts or None: + """Get an account by id + :param id: The id of the account + :type id: int + + :param verify: Whether or not to verify the account exists + :type verify: bool + + :return: An account + """ + + # Get the account by id + account = Accounts.get_or_none(Accounts.id == account_id) + exclude = [] + + # Check if the account exists + if account is None and verify: + raise ValueError("Account does not exist") + + # Remove the password from the account if password is False + if password is False: + exclude.append("password") + + # Return the account + return AccountsModel(model_to_dict(account, exclude=exclude)).to_primitive() + + +# ANCHOR - Get Account by Username +def get_account_by_username(username: str, verify: bool = True, password: bool = False) -> Accounts or None: + """Get an account by username + :param username: The username of the account + :type username: str + + :param verify: Whether or not to verify the account exists + :type verify: bool + + :return: An account or None + """ + + # Get the account by username + account = Accounts.get_or_none(Accounts.username == username) + exclude = [] + + # Check if the account exists + if account is None and verify: + raise ValueError("Account does not exist") + + # Remove the password from the account if password is False + if password is False: + exclude.append("password") + + # Return the account + return AccountsModel(model_to_dict(account, exclude=exclude)).to_primitive() + + +# ANCHOR - Create Account User +def create_account(**kwargs) -> Accounts: + """Create an account user + :param username: The username of the account + :type username: str + + :param email: The email of the account + :type email: str + + :param password: The password of the account + :type password: str + + :param confirm_password: The password of the account + :type confirm_password: Optional[str] + + :return: An account + """ + + # Create the account user + account = AccountsModel(kwargs) + + # Validate the account user + account.validate() + + # Validate username and email do not exist + account.check_username_exists() + account.check_email_exists() + + # Hash the password + account.hash_password() + + # Create the account in the database + new_account: Accounts = Accounts.create( + display_name=account.display_name, + username=account.username, + password=account.hashed_password, + email=account.email, + role=account.role + ) + + # Return the user + return get_account_by_id(new_account.id, password=False) + + +# ANCHOR - Update Account User +def update_account(account_id: int, **kwargs) -> Accounts: + """Update an account user + :param id: The id of the account + :type id: int + + :param username: The username of the account + :type username: str + + :param email: The email of the account + :type email: str + + :param password: The password of the account + :type password: str + + :param confirm_password: The password of the account + :type confirm_password: Optional[str] + + :return: An account + """ + + # Get the account by id + db_account = get_account_by_id(account_id, verify=False, password=True) + + # Check if the account exists + if db_account is None: + raise DataError({"account_id": ["Account does not exist"]}) + + # Create the account user + account = AccountsModel(kwargs) + + # Update the account in the database + account.update_account(db_account) + + # Return the account + return account.to_primitive() + + +# ANCHOR - Delete Account User +def delete_account(account_id: int) -> None: + """Delete an account user + :param id: The id of the account + :type id: int + + :return: An account + """ + + # Get the account by id + account = get_account_by_id(account_id, False) + + # Check if the account exists + if account is None: + raise ValueError("Account does not exist") + + # Delete the account + account.delete_instance() diff --git a/backend/helpers/jellyfin.py b/backend/helpers/jellyfin.py new file mode 100644 index 000000000..0b8cbc4f3 --- /dev/null +++ b/backend/helpers/jellyfin.py @@ -0,0 +1,428 @@ +from typing import Optional + +from requests import RequestException, get, post, delete +from logging import info +from io import BytesIO + +from app.models.database import Invitations + +from .libraries import get_libraries_ids +from .settings import get_media_settings +from .users import get_users, create_user, get_user_by_token + +from app.models.jellyfin.user import JellyfinUser +from app.models.jellyfin.user_policy import JellyfinUserPolicy +from app.models.jellyfin.library import JellyfinLibraryItem + +# INDEX OF FUNCTIONS +# - Jellyfin Get Request +# - Jellyfin Post Request +# - Jellyfin Delete Request +# - Jellyfin Scan Libraries +# - Jellyfin Get Policy +# - Jellyfin Invite User +# - Jellyfin Get Users +# - Jellyfin Get User +# - Jellyfin Delete User +# - Jellyfin Sync Users + + +# ANCHOR - Jellyfin Get Request +def get_jellyfin(api_path: str, as_json: Optional[bool] = True, server_api_key: Optional[str] = None, server_url: Optional[str] = None): + """Get data from Jellyfin. + :param api_path: API path to get data from + :type api_path: str + + :param server_api_key: Jellyfin API key + :type server_api_key: Optional[str] - If not provided, will get from database. + + :param server_url: Jellyfin URL + :type server_url: Optional[str] - If not provided, will get from database. + + :return: Jellyfin API response + """ + + # Get required settings + if not server_api_key or not server_url: + settings = get_media_settings() + server_url = server_url or settings.get("server_url", None) + server_api_key = server_api_key or settings.get("server_api_key", None) + + # If server_url does not end with a slash, add one + if not server_url.endswith("/"): + server_url = server_url + "/" + + # If api_path starts with a slash, remove it + if api_path.startswith("/"): + api_path = api_path[1:] + + # Add api_path to Jellyfin URL + api_url = str(server_url) + api_path + + # Set headers for Jellyfin API + headers = { + "X-Emby-Token": server_api_key, + "Accept": "application/json, profile=\"PascalCase\"" + } + + # Get data from Jellyfin + response = get(url=api_url, headers=headers, timeout=30) + + # Raise exception if Jellyfin API returns non-2** status code + if not response.ok: + raise RequestException( + f"Jellyfin API returned {response.status_code} status code." + ) + + # Return response + if as_json: + return response.json() if response.content else None + else: + return response if response.content else None + + +# ANCHOR - Jellyfin Post Request +def post_jellyfin(api_path: str, server_api_key: Optional[str] = None, server_url: Optional[str] = None, json: Optional[dict] = None, data: Optional[any] = None): + """Post data to Jellyfin. + :param api_path: API path to post data to + :type api_path: str + + :param server_api_key: Jellyfin API key + :type server_api_key: Optional[str] - If not provided, will get from database. + + :param server_url: Jellyfin URL + :type server_url: Optional[str] - If not provided, will get from database. + + :param data: Data to post to Jellyfin + :type data: Optional[dict] + + :return: Jellyfin API response + """ + + # Get required settings + if not server_api_key or not server_url: + settings = get_media_settings() + server_url = server_url or settings.get("server_url", None) + server_api_key = server_api_key or settings.get("server_api_key", None) + + # Add api_path to Jellyfin URL + api_url = str(server_url) + api_path + + # Set headers for Jellyfin API + headers = { + "X-Emby-Token": server_api_key, + "Accept": "application/json" + } + + # Post data to Jellyfin + response = post(url=api_url, headers=headers, data=data, json=json, timeout=30) + + # Raise exception if Jellyfin API returns non-2** status code + if not response.ok: + raise RequestException( + f"Jellyfin API returned {response.status_code} status code." + ) + + response.raise_for_status() + + return response.json() if response.content else None + + +# ANCHOR - Jellyfin Delete Request +def delete_jellyfin(api_path: str, server_api_key: Optional[str] = None, server_url: Optional[str] = None) -> None: + """Delete data from Jellyfin. + :param api_path: API path to delete data from + :type api_path: str + + :param server_api_key: Jellyfin API key + :type server_api_key: Optional[str] - If not provided, will get from database. + + :param server_url: Jellyfin URL + :type server_url: Optional[str] - If not provided, will get from database. + + :return: None + """ + + # Get required settings + if not server_api_key or not server_url: + settings = get_media_settings() + server_url = server_url or settings.get("server_url", None) + server_api_key = server_api_key or settings.get("server_api_key", None) + + # Add api_path to Jellyfin URL + api_url = str(server_url) + api_path + + # Set headers for Jellyfin API + headers = { + "X-Emby-Token": server_api_key, + "Accept": "application/json, profile=\"PascalCase\"" + } + + # Delete data from Jellyfin + response = delete(url=api_url, headers=headers, timeout=30) + + # Raise exception if Jellyfin API returns non-2** status code + if not response.ok: + raise RequestException( + f"Jellyfin API returned {response.status_code} status code." + ) + + return response.json() if response.content else None + + +# ANCHOR - Jellyfin Scan Libraries +def scan_jellyfin_libraries(server_api_key: Optional[str], server_url: Optional[str]) -> list[JellyfinLibraryItem]: + """Scan Jellyfin libraries and return list of libraries. + :param server_api_key: Jellyfin API key + :type server_api_key: Optional[str] - If not provided, will get from database. + + :param server_url: Jellyfin URL + :type server_url: Optional[str] - If not provided, will get from database. + + :return: List of libraries + """ + + # Get libraries from Jellyfin + response = get_jellyfin( + api_path="/Library/MediaFolders", server_api_key=server_api_key, server_url=server_url + ) + + # Check if items exist + if response["Items"] is None: + raise ValueError("No libraries found.") + + # Return list of libraries + return response["Items"] + + +# ANCHOR - Jellyfin Get Policy +def get_jellyfin_policy(user_id: str, server_api_key: Optional[str], server_url: Optional[str]) -> JellyfinUserPolicy: + """Get policy from Jellyfin. + + :param user_id: ID of the user to get policy for + :type user_id: str + + :param server_api_key: Jellyfin API key + :type server_api_key: Optional[str] - If not provided, will get from database. + + :param server_url: Jellyfin URL + :type server_url: Optional[str] - If not provided, will get from database. + + :return: Jellyfin API response + """ + + # Get user from Jellyfin + response = get_jellyfin( + api_path=f"/Users/{user_id}", server_api_key=server_api_key, server_url=server_url + ) + + # Check if user has a policy + if response["Policy"] is None: + raise ValueError("User does not have a policy.") + + return response["Policy"] + + +# ANCHOR - Jellyfin Invite User +def invite_jellyfin_user(username: str, password: str, code: str, server_api_key: Optional[str] = None, server_url: Optional[str] = None) -> JellyfinUser: + """Invite user to Jellyfin. + + :param username: Username of the user to invite + :type username: str + + :param password: Password of the user to invite + :type password: str + + :param code: Invitation code + :type code: str + + :param server_api_key: Jellyfin API key + :type server_api_key: Optional[str] - If not provided, will get from database. + + :param server_url: Jellyfin URL + :type server_url: Optional[str] - If not provided, will get from database. + + :return: Jellyfin API response + """ + + # Get Invitation from Database + invitation = Invitations.get_or_none(Invitations.code == code) + + # Get libraries from invitation + sections = ( + get_libraries_ids() + if invitation.specific_libraries is None + else invitation.specific_libraries.split(",") + ) + + # Create user object + new_user = { "Name": str(username), "Password": str(password) } + + # Create user in Jellyfin + user_response = post_jellyfin(api_path="/Users/New", json=new_user, server_api_key=server_api_key, server_url=server_url) + + # Create policy object + new_policy = { "EnableAllFolders": False, "EnabledFolders": sections } + old_policy = user_response["Policy"] + + # Merge policy with user policy don't overwrite + new_policy = {**old_policy, **new_policy} + + # API path fpr user policy + api_path = f"/Users/{user_response['Id']}/Policy" + + # Update user policy + post_jellyfin(api_path=api_path, json=new_policy, server_api_key=server_api_key, server_url=server_url) + + # Return response + return user_response + + +# ANCHOR - Jellyfin Get Users +def get_jellyfin_users(server_api_key: Optional[str] = None, server_url: Optional[str] = None) -> list[JellyfinUser]: + """Get users from Jellyfin. + + :param server_api_key: Jellyfin API key + :type server_api_key: Optional[str] - If not provided, will get from database. + + :param server_url: Jellyfin URL + :type server_url: Optional[str] - If not provided, will get from database. + + :return: Jellyfin API response + """ + + # Get users from Jellyfin + response = get_jellyfin(api_path="/Users", server_api_key=server_api_key, server_url=server_url) + + # Return users + return response + + +# ANCHOR - Jellyfin Get User +def get_jellyfin_user(user_id: str, server_api_key: Optional[str] = None, server_url: Optional[str] = None) -> JellyfinUser: + """Get user from Jellyfin. + + :param user_id: ID of the user to get + :type user_id: str + + :param server_api_key: Jellyfin API key + :type server_api_key: Optional[str] - If not provided, will get from database. + + :param server_url: Jellyfin URL + :type server_url: Optional[str] - If not provided, will get from database. + + :return: Jellyfin API response + """ + + # Get user from Jellyfin + response = get_jellyfin(api_path=f"/Users/{user_id}", server_api_key=server_api_key, server_url=server_url) + + # Return user + return response + + +# ANCHOR - Jellyfin Delete User +def delete_jellyfin_user(user_id: str, server_api_key: Optional[str] = None, server_url: Optional[str] = None) -> None: + """Delete user from Jellyfin. + :param user_id: ID of the user to delete + :type user_id: str + + :param server_api_key: Jellyfin API key + :type server_api_key: Optional[str] - If not provided, will get from database. + + :param server_url: Jellyfin URL + :type server_url: Optional[str] - If not provided, will get from database. + + :return: None + """ + + # Delete user from Jellyfin + delete_jellyfin(api_path=f"/Users/{user_id}", server_api_key=server_api_key, server_url=server_url) + + +# ANCHOR - Jellyfin Sync Users +def sync_jellyfin_users(server_api_key: Optional[str] = None, server_url: Optional[str] = None) -> list[JellyfinUser]: + """Sync users from Jellyfin to database. + + :param server_api_key: Jellyfin API key + :type server_api_key: Optional[str] - If not provided, will get from database. + + :param server_url: Jellyfin URL + :type server_url: Optional[str] - If not provided, will get from database. + + :return: None + """ + + # Get users from Jellyfin + jellyfin_users = get_jellyfin_users(server_api_key=server_api_key, server_url=server_url) + + + # Get users from database + database_users = get_users(False) + + # If jellyfin_users.id not in database_users.token, add to database + for jellyfin_user in jellyfin_users: + if str(jellyfin_user["Id"]) not in [str(database_user.token) for database_user in database_users]: + create_user(username=jellyfin_user["Name"], token=jellyfin_user["Id"]) + info(f"User {jellyfin_user['Name']} successfully imported to database.") + + # If database_users.token not in jellyfin_users.id, delete from database + for database_user in database_users: + if str(database_user.token) not in [str(jellyfin_user["Id"]) for jellyfin_user in jellyfin_users]: + database_user.delete_instance() + info(f"User {database_user.username} successfully deleted from database.") + + + +# ANCHOR - Jellyfin Get Profile Picture +def get_jellyfin_profile_picture(user_id: str, max_height: Optional[int] = 150, max_width: Optional[int] = 150, quality: Optional[int] = 30, server_api_key: Optional[str] = None, server_url: Optional[str] = None): + """Get profile picture from Jellyfin. + + :param user_id: ID of the user to get profile picture for + :type user_id: str + + :param username: Username for backup profile picture using ui-avatars.com + :type username: str + + :param max_height: Maximum height of profile picture + :type max_height: Optional[int] - Default: 150 + + :param max_width: Maximum width of profile picture + :type max_width: Optional[int] - Default: 150 + + :param quality: Quality of profile picture + :type quality: Optional[int] - Default: 30 + + :param server_api_key: Jellyfin API key + :type server_api_key: Optional[str] - If not provided, will get from database. + + :param server_url: Jellyfin URL + :type server_url: Optional[str] - If not provided, will get from database. + + :return: Jellyfin API response + """ + + # Response object + response = None + + try: + # Get profile picture from Jellyfin + response = get_jellyfin(api_path=f"/Users/{user_id}/Images/Primary?maxHeight={max_height}&maxWidth={max_width}&quality={quality}", as_json=False, server_api_key=server_api_key, server_url=server_url) + except RequestException: + # Backup profile picture using ui-avatars.com if Jellyfin fails + user = get_user_by_token(user_id, verify=False) + username = f"{user.username}&length=1" if user else "ERROR&length=60&font-size=0.28" + response = get(url=f"https://ui-avatars.com/api/?uppercase=true&name={username}", timeout=30) + + # Raise exception if either Jellyfin or ui-avatars.com fails + if response.status_code != 200: + raise RequestException("Failed to get profile picture.") + + # Extract image from response + image = response.content + + # Convert image bytes to read image + image = BytesIO(image) + + # Return profile picture + return image diff --git a/backend/helpers/jellyseerr.py b/backend/helpers/jellyseerr.py new file mode 100644 index 000000000..10536c982 --- /dev/null +++ b/backend/helpers/jellyseerr.py @@ -0,0 +1,212 @@ +from requests import RequestException, get, post, delete +from typing import Optional +from app.models.database.users import Users + +# ANCHOR - Jellyseerr Get Request +def jellyseerr_get_request(api_path: str, api_url: str, api_key: str): + """Make a GET request to the Jellyseerr API + :param api_path: The API path to make the request to + :param api_key: The API key to use + + :returns: The response from the API + """ + + # If the api_url starts with a /, remove it + if api_path.startswith("/"): + api_path = api_path[1:] + + # If the api_url ends with a /, remove it + if api_path.endswith("/"): + api_path = api_path[:-1] + + # Add the api_path to the api_url + api_url = f"{api_url}/{api_path}" + + # Set the headers for Jellyseerr API + headers = { + "X-Api-Key": api_key, + "Content-Type": "application/json" + } + + # Get the response from the API + response = get(api_url, headers=headers, timeout=30) + + # Raise an exception if the request fails with a none 2** status code + if not response.ok: + raise RequestException( + f"Request to {api_url} failed with status code {response.status_code}" + ) + + # Return the response + return response.json() + + +# ANCHOR - Jellyseerr Post Request +def jellyseerr_post_request(api_path: str, api_url: str, api_key: str, json: Optional[dict] = None, data: Optional[any] = None): + """Make a POST request to the Jellyseerr API + :param api_path: The API path to make the request to + :param api_key: The API key to use + :param data: The data to send to the API + + :returns: The response from the API + """ + + # If the api_url starts with a /, remove it + if api_path.startswith("/"): + api_path = api_path[1:] + + # If the api_url ends with a /, remove it + if api_path.endswith("/"): + api_path = api_path[:-1] + + # Add the api_path to the api_url + api_url = f"{api_url}/{api_path}" + + # Set the headers for Jellyseerr API + headers = { + "X-Api-Key": api_key, + "Content-Type": "application/json" + } + + # Get the response from the API + response = post(api_url, headers=headers, data=data, json=json, timeout=30) + + # Raise an exception if the request fails with a none 2** status code + if not response.ok: + raise RequestException( + f"Request to {api_url} failed with status code {response.status_code}" + ) + + # Return the response + return response.json() + + +# ANCHOR - Jellyseerr Delete Request +def jellyseerr_delete_request(api_path: str, api_url: str, api_key: str): + """Make a DELETE request to the Jellyseerr API + :param api_path: The API path to make the request to + :param api_key: The API key to use + + :returns: The response from the API + """ + + # If the api_url starts with a /, remove it + if api_path.startswith("/"): + api_path = api_path[1:] + + # If the api_url ends with a /, remove it + if api_path.endswith("/"): + api_path = api_path[:-1] + + # Add the api_path to the api_url + api_url = f"{api_url}/{api_path}" + + # Set the headers for Jellyseerr API + headers = { + "X-Api-Key": api_key + } + + # Get the response from the API + response = delete(api_url, headers=headers, timeout=30) + + # Raise an exception if the request fails with a none 2** status code + if not response.ok: + raise RequestException( + f"Request to {api_url} failed with status code {response.status_code}" + ) + + # Return the response + return response.json() + + +# ANCHOR - Jellyseerr Import User +def jellyseerr_import_user(api_url: str, api_key: str, user_token: str): + """Import a user into Jellyseerr + :param api_url: The API url to use + :param api_key: The API key to use + :param user_token: The Jellyfin user id to import + """ + + # Set the data to send to the API + data = { + "jellyfinUserIds": [str(user_token)] + } + + # Make the request to the API + jellyseerr_post_request("/api/v1/user/import-from-jellyfin", api_url, api_key, json=data) + + +# ANCHOR - Jellyseerr Import Users +def jellyseerr_import_users(api_url: str, api_key: str, user_tokens: list[str]): + """Import a user into Jellyseerr + :param api_url: The API url to use + :param api_key: The API key to use + :param user_tokens: List of Jellyfin users id to import + """ + + # Set the data to send to the API + data = { + "jellyfinUserIds": [str(token) for token in user_tokens] + } + + # Make the request to the API + jellyseerr_post_request("/api/v1/user/import-from-jellyfin", api_url, api_key, json=data) + + +# ANCHOR - Jellyseerr Id from Jellyfin Id +def jellyseerr_id_from_jellyfin_id(api_url: str, api_key: str, user_token: str): + """Get a Jellyseerr user id from a Jellyfin user id + :param api_url: The API url to use + :param api_key: The API key to use + :param user_token: The user id to get the Jellyseerr id from + """ + + # Get users from Jellyseerr using pagination + def get_users(page: int = 1, pageSize: int = 10): + return jellyseerr_get_request("/api/v1/user?take={}&skip={}".format(pageSize, (page - 1) * pageSize), api_url, api_key) + + # Check if the user is the user we are looking for + def check_user(user): + if str(user["jellyfinUserId"]) == str(user_token): + return user["id"] + + # Define variable to store the Jellyseerr user id + jellyseerr_user_id = None + + # Get the first page of users + response = get_users() + + # Check if the user is in the first page + for user in response["results"]: + jellyseerr_user_id = check_user(user) + if jellyseerr_user_id: + break + + # If the user is not in the first page, get the rest of the pages + if not jellyseerr_user_id: + for page in range(2, response["pageInfo"]["pages"] + 1): + response = get_users(page) + for user in response["results"]: + jellyseerr_user_id = check_user(user) + if jellyseerr_user_id: + break + + if jellyseerr_user_id is None: + raise ValueError("Unable to get Jellyseerr user id from Jellyfin user id") + + return jellyseerr_user_id + + +# ANCHOR - Jellyseerr Delete User +def jellyseerr_delete_user(api_url: str, api_key: str, user_token: str): + """Delete a user from Jellyseerr + :param api_url: The API url to use + :param api_key: The API key to use + :param user_token: The user id to delete + """ + + # Get the Jellyseerr user id from the Jellyfin user id + jellyseerr_user_id = jellyseerr_id_from_jellyfin_id(api_url, api_key, user_token) + + # Delete the user from Jellyseerr + jellyseerr_delete_request(f"/api/v1/user/{jellyseerr_user_id}", api_url, api_key) diff --git a/backend/helpers/libraries.py b/backend/helpers/libraries.py new file mode 100644 index 000000000..6ae1f22bd --- /dev/null +++ b/backend/helpers/libraries.py @@ -0,0 +1,133 @@ +from app.models.database import Libraries +from app.models.wizarr.libraries import LibraryModel +from playhouse.shortcuts import model_to_dict + +# INDEX OF FUNCTIONS +# - Get Libraries +# - Get Library by ID +# - Get Library by Name +# - Get Libraries IDs +# - Get Libraries Names + +# ANCHOR - Get Libraries +def get_libraries() -> list[Libraries]: + """Get all libraries from the database + + :return: A list of libraries + """ + + # Get all libraries from the database + libraries: list[Libraries] = Libraries.select() + + # Convert the libraries to a list of dictionaries + libraries = [LibraryModel(model_to_dict(library)).to_primitive() for library in libraries] + + # Return a list of libraries + return libraries + + +# ANCHOR - Get Library by ID +def get_library_by_id(library_id: int, verify: bool = True) -> Libraries or None: + """Get a library by id + :param library_id: The id of the library + :type library_id: int + + :param verify: Whether or not to verify the library exists + :type verify: bool + + :return: A library + """ + + # Get the library by id + library = Libraries.get_or_none(Libraries.id == library_id) + + # Check if the library exists + if library is None and verify: + raise ValueError("Library does not exist") + + # Return the library + return library + + +# ANCHOR - Get Library by Name +def get_library_by_name(library_name: str, verify: bool = True) -> Libraries or None: + """Get a library by name + + :param library_name: The name of the library + :type library_name: str + + :param verify: Whether or not to verify the library exists + :type verify: bool + + :return: A library + """ + + # Get the library by name + library = Libraries.get_or_none(Libraries.name == library_name) + + # Check if the library exists + if library is None and verify: + raise ValueError("Library does not exist") + + # Return the library + return library + + +# ANCHOR - Get Libraries IDs +def get_libraries_ids() -> list[str]: + """Get all libraries from the database with only the ID + + :return: A list of str of libraries IDs + """ + + # Get all libraries from the database with only the ID into a list[str] + libraries = Libraries.select() + + # Convert the libraries to a list of dictionaries with only the ID + libraries = [model_to_dict(library, only=[Libraries.id]) for library in libraries] + + # Return all libraries + return libraries + + +# ANCHOR - Get Libraries Names +def get_libraries_name(): + """Get all libraries from the database with only the name + + :return: A list of str of libraries names + """ + + # Get all libraries from the database with only the name into a list[str] + libraries = Libraries.select() + + # Convert the libraries to a list of dictionaries with only the name + libraries = [model_to_dict(library, only=[Libraries.name]) for library in libraries] + + # Return all libraries + return libraries + + +# ANCHOR - Create Library +# TODO: Create Create Library + +# ANCHOR - Update Library +# TODO: Create Update Library + +# ANCHOR - Delete Library +def delete_library(library_id: int) -> None: + """Delete a library by id + :param library_id: The id of the library + :type library_id: int + + :return: None + """ + + # Get the library by id + library = get_library_by_id(library_id, False) + + # Check if the library exists + if library is None: + raise ValueError("Library does not exist") + + # Delete the library + library.delete_instance() diff --git a/backend/helpers/ombi.py b/backend/helpers/ombi.py new file mode 100644 index 000000000..426a57e9b --- /dev/null +++ b/backend/helpers/ombi.py @@ -0,0 +1,199 @@ +from requests import RequestException, get, post, delete +from typing import Optional + +from app.models.database.users import Users +from app.models.database.settings import Settings + +# ANCHOR - Ombi Get Request +def ombi_get_request(api_path: str, api_url: str, api_key: str): + """Make a GET request to the Ombi API + :param api_path: The API path to make the request to + :param api_key: The API key to use + + :returns: The response from the API + """ + + # If the api_url starts with a /, remove it + if api_path.startswith("/"): + api_path = api_path[1:] + + # If the api_url ends with a /, remove it + if api_path.endswith("/"): + api_path = api_path[:-1] + + # Add the api_path to the api_url + api_url = f"{api_url}/{api_path}" + + # Set the headers for Ombi API + headers = { + "ApiKey": api_key, + "Content-Type": "application/json" + } + + # Get the response from the API + response = get(api_url, headers=headers, timeout=30) + + # Raise an exception if the request fails with a none 2** status code + if not response.ok: + raise RequestException( + f"Request to {api_url} failed with status code {response.status_code}" + ) + + # Return the response + return response.json() + + +# ANCHOR - Ombi Post Request +def ombi_post_request(api_path: str, api_url: str, api_key: str, json: Optional[dict] = None, data: Optional[any] = None): + """Make a POST request to the Ombi API + :param api_path: The API path to make the request to + :param api_key: The API key to use + :param data: The data to send to the API + + :returns: The response from the API + """ + + # If the api_url starts with a /, remove it + if api_path.startswith("/"): + api_path = api_path[1:] + + # If the api_url ends with a /, remove it + if api_path.endswith("/"): + api_path = api_path[:-1] + + # Add the api_path to the api_url + api_url = f"{api_url}/{api_path}" + + # Set the headers for Ombi API + headers = { + "ApiKey": api_key, + "Content-Type": "application/json" + } + + # Get the response from the API + response = post(api_url, headers=headers, data=data, json=json, timeout=30) + + # Raise an exception if the request fails with a none 2** status code + if not response.ok: + raise RequestException( + f"Request to {api_url} failed with status code {response.status_code}" + ) + + # Return the response + return response.json() + + +# ANCHOR - Ombi Delete Request +def ombi_delete_request(api_path: str, api_url: str, api_key: str): + """Make a DELETE request to the Ombi API + :param api_path: The API path to make the request to + :param api_key: The API key to use + + :returns: The response from the API + """ + + # If the api_url starts with a /, remove it + if api_path.startswith("/"): + api_path = api_path[1:] + + # If the api_url ends with a /, remove it + if api_path.endswith("/"): + api_path = api_path[:-1] + + # Add the api_path to the api_url + api_url = f"{api_url}/{api_path}" + + # Set the headers for Ombi API + headers = { + "ApiKey": api_key + } + + # Get the response from the API + response = delete(api_url, headers=headers, timeout=30) + + # Raise an exception if the request fails with a none 2** status code + if not response.ok: + raise RequestException( + f"Request to {api_url} failed with status code {response.status_code}" + ) + + # Return the response + return response.json() + + +# ANCHOR - Ombi Import User +def ombi_import_user(api_url: str, api_key: str): + """Import a user into Ombi + :param api_url: The API url to use + :param api_key: The API key to use + :param user_token: The Plex user id to import + """ + + # Get the server type from the database + server_type = Settings.get_or_none(Settings.key == "server_type").value + + # Make the request to the API + ombi_post_request(f"/api/v1/Job/{server_type}userimporter", api_url, api_key) + + +# ANCHOR - Ombi Import Users +def ombi_import_users(api_url: str, api_key: str): + """Import a user into Ombi + :param api_url: The API url to use + :param api_key: The API key to use + :param user_token: List of Plex users id to import + """ + + # Just run omby_import_user + ombi_import_user(api_url, api_key) + + +# ANCHOR - Ombi Id from Jellyfin Id +def ombi_id_from_media_server_id(api_url: str, api_key: str, user_token: str): + """Get a Ombi user id from a Plex user id + :param api_url: The API url to use + :param api_key: The API key to use + :param user_token: The user token to get the Ombi id from + """ + + # PATCH: Get the user username from the database with the user_token + # There needs to be a fix on Omby's side where they store the Media Server user id + user = Users.get_or_none(Users.token == user_token) + username = user.username + + # Get users from Ombi + response = ombi_get_request("/api/v1/Identity/Users", api_url, api_key) + + # Check if the user is the user we are looking for + def check_user(user): + if str(user["userName"]) == str(username): + return user["id"] + + # Define variable to store the Ombi user id + ombi_user_id = None + + # Check if the user is in the first page + for user in response: + ombi_user_id = check_user(user) + if ombi_user_id: + break + + if ombi_user_id is None: + raise ValueError("Unable to get Ombi user id from Plex user id") + + return ombi_user_id + + +# ANCHOR - Ombi Delete User +def ombi_delete_user(api_url: str, api_key: str, user_token: str): + """Delete a user from Ombi + :param api_url: The API url to use + :param api_key: The API key to use + :param user_token: The user token to delete + """ + + # Get the Ombi user id from the Jellyfin user id + ombi_user_id = ombi_id_from_media_server_id(api_url, api_key, user_token) + + # Delete the user from Ombi + ombi_delete_request(f"/api/v1/Identity/{ombi_user_id}", api_url, api_key) diff --git a/backend/helpers/overseerr.py b/backend/helpers/overseerr.py new file mode 100644 index 000000000..2881d624e --- /dev/null +++ b/backend/helpers/overseerr.py @@ -0,0 +1,212 @@ +from requests import RequestException, get, post, delete +from typing import Optional +from app.models.database.users import Users + +# ANCHOR - Overseerr Get Request +def overseerr_get_request(api_path: str, api_url: str, api_key: str): + """Make a GET request to the Overseerr API + :param api_path: The API path to make the request to + :param api_key: The API key to use + + :returns: The response from the API + """ + + # If the api_url starts with a /, remove it + if api_path.startswith("/"): + api_path = api_path[1:] + + # If the api_url ends with a /, remove it + if api_path.endswith("/"): + api_path = api_path[:-1] + + # Add the api_path to the api_url + api_url = f"{api_url}/{api_path}" + + # Set the headers for Overseerr API + headers = { + "X-Api-Key": api_key, + "Content-Type": "application/json" + } + + # Get the response from the API + response = get(api_url, headers=headers, timeout=30) + + # Raise an exception if the request fails with a none 2** status code + if not response.ok: + raise RequestException( + f"Request to {api_url} failed with status code {response.status_code}" + ) + + # Return the response + return response.json() + + +# ANCHOR - Overseerr Post Request +def overseerr_post_request(api_path: str, api_url: str, api_key: str, json: Optional[dict] = None, data: Optional[any] = None): + """Make a POST request to the Overseerr API + :param api_path: The API path to make the request to + :param api_key: The API key to use + :param data: The data to send to the API + + :returns: The response from the API + """ + + # If the api_url starts with a /, remove it + if api_path.startswith("/"): + api_path = api_path[1:] + + # If the api_url ends with a /, remove it + if api_path.endswith("/"): + api_path = api_path[:-1] + + # Add the api_path to the api_url + api_url = f"{api_url}/{api_path}" + + # Set the headers for Overseerr API + headers = { + "X-Api-Key": api_key, + "Content-Type": "application/json" + } + + # Get the response from the API + response = post(api_url, headers=headers, data=data, json=json, timeout=30) + + # Raise an exception if the request fails with a none 2** status code + if not response.ok: + raise RequestException( + f"Request to {api_url} failed with status code {response.status_code}" + ) + + # Return the response + return response.json() + + +# ANCHOR - Overseerr Delete Request +def overseerr_delete_request(api_path: str, api_url: str, api_key: str): + """Make a DELETE request to the Overseerr API + :param api_path: The API path to make the request to + :param api_key: The API key to use + + :returns: The response from the API + """ + + # If the api_url starts with a /, remove it + if api_path.startswith("/"): + api_path = api_path[1:] + + # If the api_url ends with a /, remove it + if api_path.endswith("/"): + api_path = api_path[:-1] + + # Add the api_path to the api_url + api_url = f"{api_url}/{api_path}" + + # Set the headers for Overseerr API + headers = { + "X-Api-Key": api_key + } + + # Get the response from the API + response = delete(api_url, headers=headers, timeout=30) + + # Raise an exception if the request fails with a none 2** status code + if not response.ok: + raise RequestException( + f"Request to {api_url} failed with status code {response.status_code}" + ) + + # Return the response + return response.json() + + +# ANCHOR - Overseerr Import User +def overseerr_import_user(api_url: str, api_key: str, user_token: str): + """Import a user into Overseerr + :param api_url: The API url to use + :param api_key: The API key to use + :param user_token: The Plex user id to import + """ + + # Set the data to send to the API + data = { + "plexIds": [str(user_token)] + } + + # Make the request to the API + overseerr_post_request("/api/v1/user/import-from-plex", api_url, api_key, json=data) + + +# ANCHOR - Overseerr Import Users +def overseerr_import_users(api_url: str, api_key: str, user_token: list[str]): + """Import a user into Overseerr + :param api_url: The API url to use + :param api_key: The API key to use + :param user_token: List of Plex users id to import + """ + + # Set the data to send to the API + data = { + "plexIds": [str(token) for token in user_token] + } + + # Make the request to the API + overseerr_post_request("/api/v1/user/import-from-plex", api_url, api_key, json=data) + + +# ANCHOR - Overseerr Id from Jellyfin Id +def overseerr_id_from_plex_id(api_url: str, api_key: str, user_token: str): + """Get a Overseerr user id from a Plex user id + :param api_url: The API url to use + :param api_key: The API key to use + :param user_token: The user token to get the Overseerr id from + """ + + # Get users from Overseerr using pagination + def get_users(page: int = 1, pageSize: int = 10): + return overseerr_get_request("/api/v1/user?take={}&skip={}".format(pageSize, (page - 1) * pageSize), api_url, api_key) + + # Check if the user is the user we are looking for + def check_user(user): + if str(user["plexId"]) == str(user_token): + return user["id"] + + # Define variable to store the Overseerr user id + overseerr_user_id = None + + # Get the first page of users + response = get_users() + + # Check if the user is in the first page + for user in response["results"]: + overseerr_user_id = check_user(user) + if overseerr_user_id: + break + + # If the user is not in the first page, get the rest of the pages + if not overseerr_user_id: + for page in range(2, response["pageInfo"]["pages"] + 1): + response = get_users(page) + for user in response["results"]: + overseerr_user_id = check_user(user) + if overseerr_user_id: + break + + if overseerr_user_id is None: + raise ValueError("Unable to get Overseerr user id from Plex user id") + + return overseerr_user_id + + +# ANCHOR - Overseerr Delete User +def overseerr_delete_user(api_url: str, api_key: str, user_token: str): + """Delete a user from Overseerr + :param api_url: The API url to use + :param api_key: The API key to use + :param user_token: The user token to delete + """ + + # Get the Overseerr user id from the Jellyfin user id + overseerr_user_id = overseerr_id_from_plex_id(api_url, api_key, user_token) + + # Delete the user from Overseerr + overseerr_delete_request(f"/api/v1/user/{overseerr_user_id}", api_url, api_key) diff --git a/backend/helpers/plex.py b/backend/helpers/plex.py new file mode 100644 index 000000000..bfff9d457 --- /dev/null +++ b/backend/helpers/plex.py @@ -0,0 +1,342 @@ +from typing import Optional + +from requests import RequestException, get +from io import BytesIO + +from plexapi.myplex import PlexServer, LibrarySection, MyPlexUser, MyPlexAccount, NotFound +from logging import info + +from app.models.database import Invitations + +from .libraries import get_libraries_name +from .settings import get_media_settings +from .users import get_users, create_user + +from app.models.database.libraries import Libraries + +# INDEX OF FUNCTIONS +# - Plex Get Server +# - Plex San Libraries +# - Plex Invite User +# - Plex Get Users +# - Plex Get User +# - Plex Delete User +# - Plex Sync Users +# - Plex Get Profile Picture + +# ANCHOR - Get Plex Server +def get_plex_server(server_api_key: Optional[str] = None, server_url: Optional[str] = None) -> PlexServer: + """Get a PlexServer object + :param server_api_key: The API key of the Plex server + :type server_api_key: Optional[str] - If not provided, will get from database. + + :param server_url: The URL of the Plex server + :type server_url: Optional[str] - If not provided, will get from database. + + :return: A PlexServer object + """ + + # Get required settings + if not server_api_key or not server_url: + settings = get_media_settings() + server_url = server_url or settings.get("server_url", None) + server_api_key = server_api_key or settings.get("server_api_key", None) + + # If server_url does not end with a slash, add one + if not server_url.endswith("/"): + server_url = server_url + "/" + + # Create PlexServer object + plex_server = PlexServer(server_url, server_api_key) + + # Return PlexServer object + return plex_server + + +# ANCHOR - Plex Scan Libraries +def scan_plex_libraries(server_api_key: Optional[str] = None, server_url: Optional[str] = None) -> list[LibrarySection]: + """Scan all Plex libraries + :param server_api_key: The API key of the Plex server + :type server_api_key: Optional[str] - If not provided, will get from database. + + :param server_url: The URL of the Plex server + :type server_url: Optional[str] - If not provided, will get from database. + + :return: list[dict] - A list of all libraries + """ + + # Get the PlexServer object + plex = get_plex_server(server_api_key=server_api_key, server_url=server_url) + + # Get the raw libraries + response: list[LibrarySection] = plex.library.sections() + + # Raise exception if raw_libraries is not a list + if not isinstance(response, list): + raise TypeError("Plex API returned invalid data.") + + # Return the libraries + return response + + +# ANCHOR - Plex Invite User +def invite_plex_user(code: str, token: str, server_api_key: Optional[str] = None, server_url: Optional[str] = None): + """Invite a user to the Plex server + + :param code: The code of the invitation + :type code: str + + :param email: The email of the user to invite + :type email: str + + :param server_api_key: The API key of the Plex server + :type server_api_key: Optional[str] - If not provided, will get from database. + + :param server_url: The URL of the Plex server + :type server_url: Optional[str] - If not provided, will get from database. + + :return: Plex Invite + """ + + # Get the PlexServer object + plex = get_plex_server(server_api_key=server_api_key, server_url=server_url) + + # Get Invitation from Database + invitation = Invitations.get_or_none(Invitations.code == code) + + # Get libraries from invitation + sections = ( + get_libraries_name() + if invitation.specific_libraries is None + else invitation.specific_libraries.split(",") + ) + + # If specific_libraries is None, convert sections ids to names + if invitation.specific_libraries: + sections = [library.name for library in Libraries.filter(Libraries.id.in_(sections))] + + # Get allow_sync and plex_home from invitation + allow_sync = invitation.plex_allow_sync + plex_home = invitation.plex_home + + # Get my account from Plex + my_account = plex.myPlexAccount() + + # Get the user from the token + plex_account = MyPlexAccount(token=token) + + # Select invitation method + invite_method = my_account.createHomeUser if plex_home else my_account.inviteFriend + + # Invite the user + invite = invite_method( + user=plex_account.email, + server=plex, + sections=sections, + allowSync=allow_sync + ) + + # If the invite is none raise an error + if invite is None: + raise ValueError("Failed to invite user.") + + # Return the invite + return plex_account + + +# ANCHOR - Plex Accept Invitation +def accept_plex_invitation(token: str, server_api_key: Optional[str] = None, server_url: Optional[str] = None): + """Accept a Plex invitation + + :param token: The token of the invitation + :type token: str + + :param server_api_key: The API key of the Plex server + :type server_api_key: Optional[str] - If not provided, will get from database. + + :param server_url: The URL of the Plex server + :type server_url: Optional[str] - If not provided, will get from database. + + :return: None + """ + + # Get the PlexServer object + plex = get_plex_server(server_api_key=server_api_key, server_url=server_url) + + # Get my account from Plex and email + my_account = plex.myPlexAccount() + + # Get plex account for the user + plex_account = MyPlexAccount(token=token) + + # Accept the invitation and enable sync + plex_account.acceptInvite(my_account.email) + plex_account.enableViewStateSync() + + +# ANCHOR - Plex Get Users +def get_plex_users(server_api_key: Optional[str] = None, server_url: Optional[str] = None) -> list[MyPlexUser]: + """Get all Plex users + :param server_api_key: The API key of the Plex server + :type server_api_key: Optional[str] - If not provided, will get from database. + + :param server_url: The URL of the Plex server + :type server_url: Optional[str] - If not provided, will get from database. + + :return: list[dict] - A list of all users + """ + + # Get the PlexServer object + plex = get_plex_server(server_api_key=server_api_key, server_url=server_url) + + # Get the raw users + response: list[MyPlexUser] = plex.myPlexAccount().users() + + # Raise exception if raw_users is not a list + if not isinstance(response, list): + raise TypeError("Plex API returned invalid data.") + + # Return the users + return response + + +# ANCHOR - Plex Get User +def get_plex_user(user_id: str, server_api_key: Optional[str] = None, server_url: Optional[str] = None) -> MyPlexUser: + """Get a Plex user + :param user_id: The id of the user + :type user_id: str - [usernames, email, id] + + :param server_api_key: The API key of the Plex server + :type server_api_key: Optional[str] - If not provided, will get from database. + + :param server_url: The URL of the Plex server + :type server_url: Optional[str] - If not provided, will get from database. + + :return: dict - A user + """ + + # Get the PlexServer object + plex = get_plex_server(server_api_key=server_api_key, server_url=server_url) + + # Get the raw user + response: MyPlexUser = plex.myPlexAccount().user(user_id) + + # Raise exception if raw_user is not a dict + if not isinstance(response, MyPlexUser): + raise TypeError("Plex API returned invalid data.") + + # Return the user + return response + + +# ANCHOR - Delete Plex User +def delete_plex_user(user_id: str, server_api_key: Optional[str] = None, server_url: Optional[str] = None): + """Delete a Plex user + + :param user_id: The id of the user + :type user_id: str - [usernames, email, id] + + :param server_api_key: The API key of the Plex server + :type server_api_key: Optional[str] - If not provided, will get from database. + + :param server_url: The URL of the Plex server + :type server_url: Optional[str] - If not provided, will get from database. + + :return: None + """ + + # Get the PlexServer object + plex = get_plex_server(server_api_key=server_api_key, server_url=server_url) + + # Plex account + plex_account = plex.myPlexAccount() + + # Delete the user + try: + plex_account.removeFriend(user_id) + except NotFound as e: + print("NOT IMPORTANT: ", e) + print("The above error is not important, it just means that the user is not a Plex Friend.") + + try: + plex_account.removeHomeUser(user_id) + except NotFound as e: + print("NOT IMPORTANT: ", e) + print("The above error is not important, it just means that the user is not a Plex Home User.") + + +# ANCHOR - Plex Sync Users +def sync_plex_users(server_api_key: Optional[str] = None, server_url: Optional[str] = None) -> list[MyPlexUser]: + """Sync Plex users + :param server_api_key: The API key of the Plex server + :type server_api_key: Optional[str] - If not provided, will get from database. + + :param server_url: The URL of the Plex server + :type server_url: Optional[str] - If not provided, will get from database. + + :return: list[dict] - A list of all users + """ + + # Get users from plex + plex_users = get_plex_users(server_api_key=server_api_key, server_url=server_url) + + # Get users from database + database_users = get_users(as_dict=False) + + # If plex_users.id is not in database_users.token, add user to database + for plex_user in plex_users: + if str(plex_user.id) not in [str(database_user.token) for database_user in database_users]: + create_user(username=plex_user.username, token=plex_user.id, email=plex_user.email) + info(f"User {plex_user.username} successfully imported to database") + + + # If database_users.token is not in plex_users.id, remove user from database + for database_user in database_users: + if str(database_user.token) not in [str(plex_user.id) for plex_user in plex_users]: + database_user.delete_instance() + info(f"User {database_user.username} successfully removed from database") + + +# ANCHOR - Plex Get Profile Picture +def get_plex_profile_picture(user_id: str, server_api_key: Optional[str] = None, server_url: Optional[str] = None) -> str: + """Get a Plex user's profile picture + + :param user_id: The id of the user + :type user_id: str - [usernames, email, id] + + :param server_api_key: The API key of the Plex server + :type server_api_key: Optional[str] - If not provided, will get from database. + + :param server_url: The URL of the Plex server + :type server_url: Optional[str] - If not provided, will get from database. + + :return: str - The url of the profile picture + """ + + # Response object + response = None + + # Get the user + user = get_plex_user(user_id=user_id, server_api_key=server_api_key, server_url=server_url) + + try: + # Get the profile picture from Plex + url = user.thumb + response = get(url=url, timeout=30) + except RequestException: + # Backup profile picture using ui-avatars.com if Jellyfin fails + username = f"{user.username}&length=1" if user else "ERROR&length=60&font-size=0.28" + response = get(url=f"https://ui-avatars.com/api/?uppercase=true&name={username}", timeout=30) + + # Raise exception if either Jellyfin or ui-avatars.com fails + if response.status_code != 200: + raise RequestException("Failed to get profile picture.") + + # Extract image from response + image = response.content + + # Convert image bytes to read image + image = BytesIO(image) + + # Return profile picture + return image diff --git a/backend/helpers/settings.py b/backend/helpers/settings.py new file mode 100644 index 000000000..9d5868ebf --- /dev/null +++ b/backend/helpers/settings.py @@ -0,0 +1,70 @@ +from app.models.database import Settings + +def get_media_settings(): + # Get the media settings + Settings.select().where(Settings.key.in_(["server_url", "server_api_key"])) + + # Return the media settings + return {setting.key: setting.value for setting in Settings.select().where(Settings.key.in_(["server_url", "server_api_key"]))} + + +def get_settings(settings: list[str] = None, defaults: str = None, disallowed: list[str] = None): + # Create the response and query variables + response = {} + + # Create the query functions for the settings + query_func = { + "all": lambda: Settings.select(), + "settings": lambda: Settings.select().where(Settings.key.in_(settings)), + "disallowed": lambda: Settings.select().where(Settings.key.not_in(disallowed)) if disallowed is not None else None + } + + # Perform the query based on the settings or disallowed + query = query_func["all"] if settings is None and disallowed is None else query_func["settings"] if settings is not None else query_func["disallowed"]() + + # If settings and defaults are not None, fill the response with the default values + if settings is not None and defaults is not None: + missing = [setting for setting in settings if setting not in query] + response = {**{setting: defaults[setting] for setting in missing}, **response} + + # Return all settings + return {**response, **{setting.key: setting.value for setting in query}} + +def get_setting(setting: str, default: str = None): + # Get the setting from the database + setting = Settings.get_or_none(Settings.key == setting) + + # Return the value if the setting exists, otherwise return the default value + return setting.value if setting else default + +def create_settings(settings: dict[str, str], allowed_settings: list[str] = None): + # Validate settings + if allowed_settings and not all(key in allowed_settings for key in settings.keys()): + raise ValueError("Invalid setting") + + [Settings.get_or_create(key=key, value=value) for key, value in settings.items()] + + # Return all settings for the keys that were created + response = get_settings([key for key in settings.keys()]) + + return response + +def update_settings(settings: dict[str, str], allowed_settings: list[str] = None): + # Validate settings + if allowed_settings and not all(key in allowed_settings for key in settings.keys()): + raise ValueError("Invalid setting") + + # Update the settings + [Settings.update(value=value).where(Settings.key == key).execute() for key, value in settings.items()] + + # Return all settings for the keys that were updated + response = get_settings([key for key in settings.keys()]) + + return response + +def update_setting(setting: str, value: str): + # Update the setting + Settings.update(value=value).where(Settings.key == setting).execute() + + # Return the setting + return get_setting(setting) diff --git a/backend/helpers/universal.py b/backend/helpers/universal.py new file mode 100644 index 000000000..831516aa3 --- /dev/null +++ b/backend/helpers/universal.py @@ -0,0 +1,387 @@ +from plexapi.myplex import MyPlexUser, NotFound, BadRequest +from requests.exceptions import RequestException +from app.models.jellyfin.user import JellyfinUser +from app.extensions import socketio +from datetime import datetime + +from .plex import get_plex_users, get_plex_user, sync_plex_users, delete_plex_user, get_plex_profile_picture, invite_plex_user, accept_plex_invitation +from .jellyfin import get_jellyfin_users, get_jellyfin_user, sync_jellyfin_users, delete_jellyfin_user, get_jellyfin_profile_picture, invite_jellyfin_user + +from .jellyseerr import jellyseerr_import_user, jellyseerr_delete_user +from .overseerr import overseerr_import_user, overseerr_delete_user +from .ombi import ombi_import_user, ombi_delete_user + +from app.models.database.users import Users +from app.models.database.invitations import Invitations +from app.models.database.requests import Requests +from app.models.database.settings import Settings + +from helpers.webhooks import run_webhook +from playhouse.shortcuts import model_to_dict + +# ANCHOR - Get Server Type +def get_server_type() -> str: + """Get the server type from the settings + + return: str - [plex, jellyfin] + """ + + # Get the server type from the settings + server_type = Settings.get_or_none(Settings.key == "server_type").value + + # Raise an error if the server type is not set + if server_type is None: + raise ValueError("Server type not set") + + # Return the server type + return server_type + +# ANCHOR - Global Delete User From Request Server +def global_delete_user_from_request_server(user_token: str) -> dict[str]: + """Delete a user from the request server + :param user_id: The id of the user + """ + + # Get the requests from the database + requests = Requests.select() + + # Request Server Map + universal_delete_user = { + "jellyseerr": lambda **kwargs: jellyseerr_delete_user(**kwargs), + "overseerr": lambda **kwargs: overseerr_delete_user(**kwargs), + "ombi": lambda **kwargs: ombi_delete_user(**kwargs) + } + + # Loop through the requests server and delete the user + for request in requests: + try: + universal_delete_user.get(request.service)(api_url=request.url, api_key=request.api_key, user_token=user_token) + except Exception as e: + import traceback + traceback.print_exc() + print(e) + + # Return response + return { "message": "User deleted from request server" } + + +# ANCHOR - Global Invite User To Request Server +def global_invite_user_to_request_server(user_token: str) -> dict[str]: + """Invite a user to the request server + :param user_id: The id of the user + """ + + # Get the requests from the database + requests = Requests.select() + + # Request Server Map + universal_add_user = { + "jellyseerr": lambda **kwargs: jellyseerr_import_user(**kwargs), + "overseerr": lambda **kwargs: overseerr_import_user(**kwargs), + "ombi": lambda **kwargs: ombi_import_user(**kwargs) + } + + # Loop through the requests server and invite the user + for request in requests: + try: + universal_add_user.get(request.service)(api_url=request.url, api_key=request.api_key, user_token=user_token) + except Exception as e: + print(e) + + # Return response + return { "message": "User invited to request server" } + + +# ANCHOR - Global Get Users +def global_get_users_from_media_server() -> dict[MyPlexUser or JellyfinUser]: + """Get all users from the media server + + :return: A list of users + """ + + # Get the server type and set the users variable + server_type = get_server_type() + users = None + + # Get the users from the media server + if server_type == "plex": + users = get_plex_users() + elif server_type == "jellyfin": + users = get_jellyfin_users() + + # Raise an error if the users are None + if users is None: + raise ValueError("Unable to get users") + + # Return the users + return users + + +# ANCHOR - Global Get User +def global_get_user_from_media_server(user_id: str) -> MyPlexUser or JellyfinUser: + """Get a user from the media server + :param user_id: The id of the user + :type user_id: str + + :return: A user + """ + + # Get the server type and set the user variable + server_type = get_server_type() + user = None + + # Get the user from the media server + if server_type == "plex": + user = get_plex_user(user_id) + elif server_type == "jellyfin": + user = get_jellyfin_user(user_id) + + # Raise an error if the user is None + if user is None: + raise ValueError("Unable to get user") + + # Return the user + return user + + +# ANCHOR - Global Delete User +def global_delete_user_from_media_server(user_id: str) -> dict[str]: + """Delete a user from the media server + :param user_id: The id of the user + :type user_id: str + + :return: None + """ + + # Get the server type + server_type = get_server_type() + + # Get the user from the database where the id is equal to the user_id provided + user = Users.get_or_none(Users.id == user_id) + + # Delete the user from the media server + if server_type == "plex": + delete_plex_user(user.token) + elif server_type == "jellyfin": + delete_jellyfin_user(user.token) + + try: + # Get the invite from the database where the code is equal to the code provided + invite: Invitations = Invitations.get_or_none(Invitations.code == user.code) + + # Append the user id to the invite used_by field + used_by = invite.used_by.split(",") if invite.used_by else [] + + # Remove the user id from the used_by field + used_by.remove(str(user.id)) + + # Set the used_by field to the used_by list + invite.used_by = ",".join(used_by) if len(used_by) > 0 else None + + # Save the invite + invite.save() + except Exception as e: + print(e) + + # Delete the user from the request server + try: + global_delete_user_from_request_server(user.token) + except Exception as e: + print(e) + + # Send webhook event + run_webhook("user_deleted", model_to_dict(user)) + + # Delete the user from the database + user.delete_instance() + + # Return response + return { "message": "User deleted" } + + +# ANCHOR - Global Sync Users +def global_sync_users_to_media_server() -> dict[str]: + """Sync users from the media server to the database + + :return: None + """ + + # Get the server type + server_type = get_server_type() + + # Sync users from the media server to the database + if server_type == "plex": + sync_plex_users() + elif server_type == "jellyfin": + sync_jellyfin_users() + + # Return response + return { "message": "Users synced" } + + +# ANCHOR - Global Get User Profile Picture +def global_get_user_profile_picture(user_id: str) -> str: + """Get a user"s profile picture from the media server + + :param user_id: The id of the user + :type user_id: str + + :return: The url of the user"s profile picture + """ + + # Get the server type + server_type = get_server_type() + + # Get the user"s profile picture from the media server + if server_type == "plex": + return get_plex_profile_picture(user_id) + elif server_type == "jellyfin": + return get_jellyfin_profile_picture(user_id) + + # Raise an error if the user"s profile picture is None + raise ValueError("Unable to get user's profile picture") + + +# ANCHOR - Global Invite User To Media Server +def global_invite_user_to_media_server(**kwargs) -> dict[str]: + """Invite a user to the media server + + :param token: The token of the user if Plex + :type token: str + + :param username: The username of the user if Jellyfin + :type username: str + + :param email: The email of the user if Jellyfin + :type email: str + + :param password: The password of the user if Jellyfin + :type password: str + + :param code: The invite code required for Plex and Jellyfin + :type code: str + + :return: None + """ + + # Get the server type + server_type = get_server_type() + user = None + + # Get the invite from the database where the code is equal to the code provided + invite: Invitations = Invitations.get_or_none(Invitations.code == kwargs.get("code")) + + # Make sure the invite exists + if invite is None: + raise BadRequest("Invalid invite code") + + # Make sure the invite is not expired + if invite.expires and invite.expires < datetime.utcnow(): + raise BadRequest("Invite code expired") + + # Make sure the invite is not used + if invite.used and not invite.unlimited: + raise BadRequest("Invite code already used") + + # Map user creation to there respective functions + universal_invite_user = { + "plex": lambda token, code, **kwargs: invite_plex_user(token=token, code=code), + "jellyfin": lambda username, password, code, **kwargs: invite_jellyfin_user(username=username, password=password, code=code) + } + + # Create a socketio emit function that will emit to the socket_id if it is not None + socketio_emit = lambda event, data: socketio.emit(event, data, namespace=f"/{server_type}", to=kwargs.get("socket_id")) if kwargs.get("socket_id") else None + + # Emit step 1 + socketio_emit("step", 1) + + # Invite the user to the media server + try: + user = universal_invite_user.get(server_type)(**kwargs) + except BadRequest as e: + socketio_emit("error", "We were unable to join you to the media server, please try again later.") + return { "message": "We were unable to join you to the media server, please try again later." } + except RequestException as e: + socketio_emit("error", "We were unable to join you to the media server, you may already be a member.") + return { "message": "We were unable to join you to the media server, you may already be a member." } + except Exception as e: + socketio_emit("error", str(e) or "There was issue during the account creation") + raise BadRequest("There was issue during the account creation") from e + + # Emit step 2 + socketio_emit("step", 2) + + try: + if server_type == "plex": accept_plex_invitation(token=kwargs.get("token")) + except NotFound as e: + socketio_emit("error", "We were unable to accept the Plex Invite on your behalf, please accept the invite manually through Plex or your email.") + return { "message": "We were unable to accept the Plex Invite on your behalf, please accept the invite manually through Plex or your email." } + except Exception as e: + socketio_emit("error", str(e) or "There was issue during the account invitation") + raise BadRequest("There was issue during the account invitation") from e + + # Emit step 3 + socketio_emit("step", 3) + + # Raise an error if the invite is None + if not user: + socketio_emit("error", "We were unable to locate your Plex account, please try again later.") + return { "message": "We were unable to locate your Plex account, please try again later." } + + try: + # Create the user in the database + db_user = Users.insert({ + Users.token: user.id if server_type == "plex" else user["Id"], + Users.username: user.username if server_type == "plex" else user["Name"], + Users.code: invite.code, + Users.expires: invite.duration, + Users.auth: kwargs.get("token", None), + Users.email: user.email if server_type == "plex" else kwargs.get("email", None), + }) + + # Add the user to the database + # pylint: disable=no-value-for-parameter + user_id = db_user.execute() + except Exception as e: + socketio_emit("error", str(e) or "There was issue during local account creation") + raise BadRequest("There was issue during local account creation") from e + + + try: + # Send webhook event + run_webhook("user_invited", model_to_dict( + Users.get_or_none(Users.id == user_id) + )) + except Exception as e: + print(e) + + try: + global_invite_user_to_request_server(user_token=user.id if server_type == "plex" else user["Id"]) + except Exception as e: + print(e) + + try: + # Set the invite to used + invite.used = True + invite.used_at = datetime.now() + + # Append the user id to the invite used_by field + used_by = invite.used_by.split(",") if invite.used_by else [] + + # Append the user id to the used_by field if it is not already in there + if str(user_id) not in used_by: + used_by.append(str(user_id)) + + # Set the used_by field to the used_by list + invite.used_by = ",".join(used_by) if len(used_by) > 0 else None + + # Save the invite + invite.save() + except Exception as e: + print(e) + + # Emit done + socketio_emit("done", None) + + # Return response + return { "message": "User invited to media server", "invite": invite, "user": db_user } diff --git a/backend/helpers/users.py b/backend/helpers/users.py new file mode 100644 index 000000000..0d4dc25a5 --- /dev/null +++ b/backend/helpers/users.py @@ -0,0 +1,165 @@ +from app.models.database.users import Users +from app.models.users import UsersModel +from playhouse.shortcuts import model_to_dict +from datetime import datetime + +# ANCHOR - Get Users +def get_users(as_dict: bool = True) -> list[Users]: + """Get all users from the database + :param dict: Whether or not to return as list of dicts + + :return: A list of users + """ + + # Get all users from the database + users: list[Users] = Users.select() + + # Convert to a list of dicts + if as_dict: + users = [model_to_dict(user) for user in users] + + # Return all users + return users + + +# ANCHOR - Get User by ID +def get_user_by_id(user_id: int, verify: bool = True) -> Users or None: + """Get a user by id + :param user_id: The id of the user + :type user_id: int + + :param verify: Whether or not to verify the user exists + :type verify: bool + + :return: A user + """ + + # Get the user by id + user = Users.get_or_none(Users.id == user_id) + + # Check if the user exists + if user is None and verify: + raise ValueError("User does not exist") + + # Return the user + return user + + +# ANCHOR - Get User by Username +def get_user_by_username(username: str, verify: bool = True) -> Users or None: + """Get a user by username + + :param username: The username of the user + :type username: str + + :param verify: Whether or not to verify the user exists + :type verify: bool + + :return: A user + """ + + # Get the user by username + user = Users.get_or_none(Users.username == username) + + # Check if the user exists + if user is None and verify: + raise ValueError("User does not exist") + + # Return the user + return user + + +# ANCHOR - Get User by Email +def get_user_by_email(email: str, verify: bool = True) -> Users or None: + """Get a user by email + + :param email: The email of the user + :type email: str + + :param verify: Whether or not to verify the user exists + :type verify: bool + + :return: A user + """ + + # Get the user by email + user = Users.get_or_none(Users.email == email) + + # Check if the user exists + if user is None and verify: + raise ValueError("User does not exist") + + # Return the user + return user + +# ANCHOR - Get User by Token +def get_user_by_token(token: str, verify: bool = True) -> Users or None: + """Get a user by token + + :param token: The token of the user + :type token: str + + :param verify: Whether or not to verify the user exists + :type verify: bool + + :return: A user + """ + + # Get the user by token + user = Users.get_or_none(Users.token == token) + + # Check if the user exists + if user is None and verify: + raise ValueError("User does not exist") + + # Return the user + return user + + +# ANCHOR - Get Users by Expiring +def get_users_by_expiring() -> list[Users]: + """Get all users by expiring + + :return: A list of users + """ + + # Get all users by expiring + users: list[Users] = Users.select().where(Users.expires <= datetime.utcnow()) + + return users + + +# ANCHOR - Create User +def create_user(**kwargs) -> Users: + """Create a user + + :param token: The token of the user + :type token: str + + :param username: The username of the user + :type username: str + + :param email: The email of the user + :type email: str + + :param code: The code of the user + :type code: str + + :param expires: The expiration date of the user + :type expires: datetime + + :return: A user + """ + + # Validate user input + form = UsersModel(**kwargs) + user_model = form.model_dump() + + # If user already exists raise error (maybe change this to update user) + if get_user_by_username(form.username, verify=False) is not None: + user: Users = Users.update(**user_model).where(Users.username == form.username) + else: + user: Users = Users.create(**user_model) + + # Return the user + return user diff --git a/backend/helpers/webhooks.py b/backend/helpers/webhooks.py new file mode 100644 index 000000000..e98d66051 --- /dev/null +++ b/backend/helpers/webhooks.py @@ -0,0 +1,11 @@ +from app.models.database.webhooks import Webhooks +from requests import post +from json import loads, dumps + +def run_webhook(event: str, data: dict): + webhooks = Webhooks.select() + for webhook in webhooks: + try: + post(webhook.url, json={"event": event, "data": loads(dumps(data, indent=4, sort_keys=True, default=str))}, verify=False, timeout=10) + except Exception: + pass diff --git a/backend/manage.py b/backend/manage.py new file mode 100644 index 000000000..d808941fc --- /dev/null +++ b/backend/manage.py @@ -0,0 +1,292 @@ +from sys import argv, exit as sys_exit +from time import sleep +from termcolor import colored +from tabulate import tabulate +from werkzeug.security import generate_password_hash +from threading import Thread + +spinner_running = True + +# Please wait message with spinner +def please_wait(): + print(colored("Please wait", "yellow"), end="") + i = 0 + while spinner_running: + print(colored(".", "yellow"), end="", flush=True) + sleep(0.5) + i += 1 + if i == 3: + print("\b\b\b \b\b\b", end="", flush=True) + i = 0 + +def start_spinner(): + global spinner_running + spinner_running = True + Thread(target=please_wait).start() + +def message(msg: str, color: str = "white"): + global spinner_running + spinner_running = False + print("\r" + " " * 30 + "\r", end="", flush=True) + print(colored(msg, color), flush=True) + + +# Declared functions +def help_message(): + # Usage message + print(colored("Usage: python manage.py [args]", "green")) + print() + + # Print table of commands, args, and descriptions + print(tabulate([[command, " ".join([f"--{arg_name} " for arg_name in args["args"]]) if "args" in args else "", args["description"]] for command, args in VALID_COMMANDS.items()], headers=["Command", "Args", "Description"])) + + print() + sys_exit(0) + + + +def reset_password(username: str, password: str): + # Start the spinner + start_spinner() + + # Import the Accounts model + from app.models.database.accounts import Accounts + + # Get the user + user = Accounts.get_or_none(username=username) + + # Check if the user exists + if not user: + message(f"User `{username}` does not exist", "red") + sys_exit(1) + + # Set the new password + user.password = generate_password_hash(password, method="scrypt") + + # Save the user + user.save() + + # Print the user + message(f"Successfully reset password for user `{username}`", "green") + sys_exit(0) + + + +def create_user(username: str, password: str, email: str): + # Start the spinner + start_spinner() + + # Import the Accounts model + from app.models.database.accounts import Accounts + + # Check if the user exists + if Accounts.get_or_none(username=username): + message(f"User `{username}` already exists", "red") + sys_exit(1) + + # Check if the email is valid and not already in use + if email: + if not "@" in email: + message(f"Email `{email}` is not valid", "red") + sys_exit(1) + if Accounts.get_or_none(email=email): + message(f"Email `{email}` is already in use", "red") + sys_exit(1) + + # Create the user + user = Accounts.create( + username=username, + password=generate_password_hash(password, method="scrypt"), + email=email, + role="admin" + ) + + # Print the user + message(f"Successfully created user `{user.username}`", "green") + + +def check_user(username: str): + # Start the spinner + start_spinner() + + # Import the Accounts model + from app.models.database.accounts import Accounts + + # Get the user + user = Accounts.get_or_none(username=username) + + # Check if the user exists + if not user: + message(f"User `{username}` does not exist", "red") + sys_exit(1) + + # Print the user + message(f"User `{user.username}` exists", "green") + sys_exit(0) + + +def delete_user(username: str, y: bool = False): + # Ask for confirmation + if not y: + if input(colored(f"Are you sure you want to delete user `{username}`? (y/n) ", "yellow")).lower() != "y": + sys_exit(1) + + # Start the spinner + start_spinner() + + # Import the Accounts model + from app.models.database.accounts import Accounts + + # Get the user + user = Accounts.get_or_none(username=username) + + # Check if the user exists + if not user: + message(f"User `{username}` does not exist", "red") + sys_exit(1) + + # Delete the user + user.delete_instance() + + # Print the user + message(f"Successfully deleted user `{username}`", "green") + sys_exit(0) + + +# Declared variables +VALID_COMMANDS = { + "--help": { + "description": "Shows this help message", + "function": help_message, + }, + "reset_password": { + "description": "Resets the password of the specified user", + "args": { + "username": { + "description": "The username of the user to reset the password of", + "required": True, + }, + "password": { + "description": "The new password of the user", + "required": True, + }, + }, + "function": reset_password, + }, + "create_user": { + "description": "Creates a new user", + "args": { + "username": { + "description": "The username of the user to create", + "required": True, + }, + "password": { + "description": "The password of the user to create", + "required": True, + }, + "email": { + "description": "The email of the user to create", + "required": True, + }, + }, + "function": create_user, + }, + "delete_user": { + "description": "Deletes the specified user", + "args": { + "username": { + "description": "The username of the user to delete", + "required": True, + }, + "y": { + "description": "Skips the confirmation prompt", + "required": False, + }, + }, + "function": delete_user, + }, + "check_user": { + "description": "Checks if the specified user exists", + "args": { + "username": { + "description": "The username of the user to check", + "required": True, + }, + }, + "function": check_user, + }, +} + +# Check if argv[1] exists +if len(argv) < 2: + print(colored("Please specify a command like `python manage.py reset_password`", "red")) + print("See `python manage.py --help` for a list of valid commands") + sys_exit(1) + + +# Check if the command is valid +if argv[1] not in VALID_COMMANDS: + print(colored(f"Invalid command `{argv[1]}`", "red")) + print("See `python manage.py --help` for a list of valid commands") + sys_exit(1) + +# Check if the command has a function +if "function" not in VALID_COMMANDS[argv[1]]: + print(colored(f"Command `{argv[1]}` does not have a function", "red")) + sys_exit(1) + + +# Check if the command has args +if "args" in VALID_COMMANDS[argv[1]]: + try: + # if the last arg is --help after the command print the commands description and args descriptions + if len(argv) == 3 and argv[2] == "--help": + print(colored(f"{argv[1]} - {VALID_COMMANDS[argv[1]]['description']}", "green")) + print() + print(tabulate([[arg_name, arg["description"]] for arg_name, arg in VALID_COMMANDS[argv[1]]["args"].items()], headers=["Arg", "Description"])) + print() + sys_exit(0) + + # Convert args to key value pairs + kwargs = {argv[i].replace("--", ""): argv[i + 1] for i in range(2, len(argv), 2)} + + # Check if the command has invalid args + if any([arg_name not in VALID_COMMANDS[argv[1]]["args"] for arg_name in kwargs]): + print(colored(f"Command `{argv[1]}` has invalid args", "red")) + print(f"python manage.py {argv[1]}", " ".join([f"--{arg_name} " for arg_name in VALID_COMMANDS[argv[1]]["args"]])) + print() + sys_exit(1) + + # Check that all required args are present + if any([arg_name not in kwargs for arg_name, arg in VALID_COMMANDS[argv[1]]["args"].items() if arg["required"]]): + print(colored(f"Command `{argv[1]}` is missing required args", "red")) + print(f"python manage.py {argv[1]}", " ".join([f"--{arg_name} " for arg_name, arg in VALID_COMMANDS[argv[1]]["args"].items() if arg["required"]])) + print() + sys_exit(1) + + # Run the command + VALID_COMMANDS[argv[1]]["function"](**kwargs) + + except IndexError: + print(colored(f"Command `{argv[1]}` requires args", "red")) + print(f"python manage.py {argv[1]}", " ".join([f"--{arg_name} " for arg_name in VALID_COMMANDS[argv[1]]["args"]])) + print() + sys_exit(1) + + except Exception as e: + print(colored(f"Something went wrong while parsing args for command `{argv[1]}`", "red")) + print(e) + print() + sys_exit(1) +else: + try: + # Run the command + VALID_COMMANDS[argv[1]]["function"]() + except Exception as e: + print(colored(f"Something went wrong while running command `{argv[1]}`", "red")) + print(e) + print() + sys_exit(1) + + +sys_exit(0) diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 000000000..9efff5200 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,30 @@ +apscheduler +coloredlogs +cryptography +python-dotenv +flask +flask-apscheduler +flask-caching +flask-jwt_extended +flask-oauthlib +flask-restx +flask-session +flask-socketio +python-nmap +packaging +password-strength +peewee +plexapi +psutil +pytz +requests +schematics +tabulate +termcolor +webauthn +werkzeug +sentry-sdk[flask] +gunicorn +gevent +gevent-websocket +pydantic==1.10.12 diff --git a/backend/run.py b/backend/run.py new file mode 100644 index 000000000..df4c8040f --- /dev/null +++ b/backend/run.py @@ -0,0 +1,4 @@ +from app import socketio, app + +if __name__ == '__main__': + socketio.run(app) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..0ab91bc1c --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,12 @@ +--- +version: "3.5" +services: + wizarr: + container_name: wizarr + build: + context: . + dockerfile: Dockerfile + ports: + - 5690:5690 + volumes: + - ./database:/data/backend/database # Change this to your data directory \ No newline at end of file diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md new file mode 100644 index 000000000..227840bcc --- /dev/null +++ b/docs/SUMMARY.md @@ -0,0 +1,22 @@ +# Table of contents + +* [Introduction](README.md) + +## 💾 Getting-Started + +* [Installation](getting-started/installation.md) +* [Reverse Proxy](getting-started/reverse-proxy.md) + +## 💭 Using Wizarr + +* [Custom HTML](using-wizarr/custom-html.md) +* [Requests Integration](using-wizarr/requests-integration.md) + +## ⛑ Support + +* [Discord](support/discord.md) + +## Contribute + +* [Translate](contribute/translate.md) +* [Development](contribute/development.md) diff --git a/docs/contribute/api.md b/docs/contribute/api.md new file mode 100644 index 000000000..af3bb36e2 --- /dev/null +++ b/docs/contribute/api.md @@ -0,0 +1,223 @@ +# Wizarr API +Wizarr API + +## Version: 1.0 + +--- +## Accounts +Accounts related operations + +### /accounts/ + +#### POST +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +#### GET +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /accounts/{username} + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| username | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +#### PUT +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| username | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +--- +## Authentication +Authentication related operations + +### /auth/login + +#### POST +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /auth/logout + +#### POST +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +--- +## Notifications +Notifications related operations + +### /notifications/ + +#### POST +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| payload | body | | Yes | [NotificationsPostModel](#notificationspostmodel) | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 201 | Notification created successfully | +| 400 | Invalid notification agent | +| 409 | Notification agent already exists | +| 500 | Internal server error | + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| X-Fields | header | An optional fields mask | No | string (mask) | + +##### Responses + +| Code | Description | Schema | +| ---- | ----------- | ------ | +| 200 | Success | [ [NotificationsGetModel](#notificationsgetmodel) ] | + +### /notifications/{notification_id} + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| notification_id | path | | Yes | integer | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +#### DELETE +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| notification_id | path | | Yes | integer | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +--- +## Settings +Settings related operations + +### /settings/ + +#### POST +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +#### GET +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +### /settings/{setting_id} + +#### GET +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| setting_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +#### PUT +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| setting_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +#### DELETE +##### Parameters + +| Name | Located in | Description | Required | Schema | +| ---- | ---------- | ----------- | -------- | ------ | +| setting_id | path | | Yes | string | + +##### Responses + +| Code | Description | +| ---- | ----------- | +| 200 | Success | + +--- +### Models + +#### NotificationsPostModel + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| name | string | The name of the notification | Yes | +| type | string | The type of the notification | Yes | +| url | string | The URL of the notification | Yes | +| username | string | The username of the notification | No | +| password | string | The password of the notification | No | + +#### NotificationsGetModel + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| id | integer | The ID of the notification | Yes | +| name | string | The name of the notification | Yes | +| type | string | The type of the notification | Yes | +| url | string | The URL of the notification | Yes | +| username | string | The username of the notification | No | +| password | string | The password of the notification | No | +| created | dateTime | The date the notification was created | Yes | diff --git a/docs/contribute/development.md b/docs/contribute/development.md new file mode 100644 index 000000000..7d1cf9c1e --- /dev/null +++ b/docs/contribute/development.md @@ -0,0 +1,15 @@ +# Development + +If you want to contribute to Wizarr, here is how + +### Prerequisites + +* Python3.11+ + +### Development Environment + +1. Clone the repository with `git clone git@github.com:Wizarrrr/wizarr.git` +2. Move into the directory `cd wizarr` +3. Please use VSCode and open the `wizarr.code-workspace` file under File->Open Worspace from File +4. Then inside the Run and Debug panel of VSCode run the `Run All (workspace)` +5. Then you can visit http://localhost:5173 diff --git a/docs/contribute/files.md b/docs/contribute/files.md new file mode 100644 index 000000000..6dae41c5a --- /dev/null +++ b/docs/contribute/files.md @@ -0,0 +1,84 @@ +## Files Guide +This outlines the structure of the files in the project and how each part of structure is utilized. This is a guide for developers who want to contribute to the project. + +### File Structure +The file structure is as follows: + +- `api/` - Contains each API endpoint, organized `api/_api.py` +- `helpers/` - Contains helper functions for the API, organized `helpers/.py` +- `models/` - Contains multiple folders based usage: + - `database/` - Contains the database models, organized `models/database/.py` + - `api/` - Contains the mashalling models for the API, organized `models/api/.py` + - `wizarr/` - Contains the models used for schematics data validation, organized `models/wizarr/.py` + +- `tests/` - Contains frontend tests using cypress +- `docs/` - Contains the documentation for the project +- `migrations/` - Contains the database migration scripts +- `app/` - Contains multiple folders used for the main execution of the program + - `templates/` - Contains the templates for the frontend + - See more about the frontend in the [frontend guide](frontend.md) + - `static/` - Contains the static files for the frontend + + + + +## Components + +#### Table of Contents +
Click to expand +- [Accounts](#accounts) +- [Authentication](#authentication) +
+ +### Accounts +Accounts are what admin users use to login to the system. They use JWT tokens to authenticate themselfs to the system. The accounts are stored in the database and are hashed. The following files all relate to account based operations: + +- `api/accounts_api.py` - Contains the API endpoints for account based operations + - `/accounts` - `GET` - Returns all accounts + - `/accounts` - `POST` - Creates a new account + - `/accounts/` - `GET` - Returns a specific account + - `/accounts/` - `PUT` - Updates a specific account + - `/accounts/` - `DELETE` - Deletes a specific account + +- `helpers/accounts.py` - Contains helper functions for account based operations + - `get_accounts()` - Returns all accounts + - `get_account_by_id()` - Returns a specific account by id + - `get_account_by_username()` - Returns a specific account by username + - `create_account()` - Creates a new account + - `update_account()` - Updates a specific account + - `delete_account()` - Deletes a specific account + +- `models/database/account.py` - Contains the database model for accounts +- `models/api/account.py` - Contains the mashalling model for accounts + +- `models/wizarr/account.py` - Contains the schematics model for accounts + - `AccountsModel` - Class for validating account data + - `validate()` - Validates the data provided to the class during initialization + - `check_username_exists()` - Checks if the username provided already exists in the database + - `check_email_exists()` - Checks if the email provided already exists in the database + - `hash_password()` - Hashes the password provided + - `update_account()` - Updates the account provided + + +### Authentication +Authentication is the process of verifying that a user under accounts is who they say they are. This is done by using JWT tokens. The following files all relate to authentication based operations: + +- `api/authenticate_api.py` - Contains the API endpoints for authentication based operations + - `/login` - `POST` - Logs in a user based on the username and password provided + - `/logout` - `POST` - Logs out a user using JWT in cookies + +- `helpers/authenticate.py` - Contains helper functions for authentication based operations + - `login_to_account()` - Logs in a user + - `logout_of_account()` - Logs out a user + +- `models/api/authenticate.py` - Contains the mashalling model for authentication + +- `models/wizarr/authenticate.py` - Contains the schematics model for authentication + - `AuthenticateModel` - Class for validating authentication data + - `get_token()` - Returns a JWT token for the user provided + - `set_access_cookies()` - Sets the JWT token in cookies + - `unset_access_cookies()` - Unsets the JWT token in cookies + - `get_admin()` - Returns the admin user + - `get_token_from_cookie()` - Returns the JWT token from cookies + - `destroy_session()` - Destroys the session for the user + diff --git a/docs/contribute/style-guide/buttons.md b/docs/contribute/style-guide/buttons.md new file mode 100644 index 000000000..1ee22bbbc --- /dev/null +++ b/docs/contribute/style-guide/buttons.md @@ -0,0 +1,93 @@ +## Button Styles + +This buttons style guide provides guidelines for using the following styles in the project: + +- Default Button +- Secondary Button +- Delete Button +- Default Link + +### Default Button + +Use the following classes to style a default button: + +``` +.bg-primary +.hover:bg-primary_hover +.focus:outline-none +.text-white +.font-medium +.rounded +.px-5 +.py-2.5 +.text-sm +.dark:bg-primary +.dark:hover:bg-primary_hover +``` + +Copy/Paste +``` +bg-primary hover:bg-primary_hover focus:outline-none text-white font-medium rounded px-5 py-2.5 text-sm dark:bg-primary dark:hover:bg-primary_hover +``` + +### Secondary Button + +Use the following classes to style a secondary button: + +``` +.bg-secondary +.hover:bg-secondary_hover +.focus:outline-none +.text-white +.font-medium +.rounded +.px-5 +.py-2.5 +.text-sm +.dark:bg-secondary +.dark:hover:bg-secondary_hover +``` + +Copy/Paste +``` +bg-secondary hover:bg-secondary_hover focus:outline-none text-white font-medium rounded px-5 py-2.5 text-sm dark:bg-secondary dark:hover:bg-secondary_hover +``` + +### Delete Button + +Use the following classes to style a delete button: + +``` +.bg-red-600 +.hover:bg-primary_hover +.focus:outline-none +.text-white +.font-medium +.rounded +.px-5 +.py-2.5 +.text-sm +.dark:bg-red-600 +.dark:hover:bg-primary_hover +``` + +Copy/Paste +``` +bg-red-600 hover:bg-primary_hover focus:outline-none text-white font-medium rounded px-5 py-2.5 text-sm dark:bg-red-600 dark:hover:bg-primary_hover +``` + +### Default Link + +Use the following classes to style a default link: + +``` +.block +.font-medium +.text-secondary +.dark:text-primary +``` + +Copy/Paste +``` +block font-medium text-secondary dark:text-primary +``` \ No newline at end of file diff --git a/docs/contribute/translate.md b/docs/contribute/translate.md new file mode 100644 index 000000000..fb23b4e91 --- /dev/null +++ b/docs/contribute/translate.md @@ -0,0 +1,44 @@ +# Translate + +{% hint style="warning" %} +TRANSLATIONS CURRENTLY NOT BEING ACCEPTED +{% endhint %} + +Thanks for your interest in contributing to Wizarr! + +### Weblate + +We use Weblate to help translate Wizarr! + +{% embed url="https://hosted.weblate.org/engage/wizarr/" %} + +### Testing Translations + +After you have saved a translation, it will be pushed to the `master` branch directly. The `dev` docker image will then be automatically compiled shortly thereafter. + +To test it out, simply add the `dev` label to the Docker Image, and you can use the `FORCE_LANGUAGE` environment variable to force a language to Wizarr. + +{% tabs %} +{% tab title="Docker Compose" %} +```yaml +--- +version: "3.8" +services: + wizarr: + container_name: wizarr + image: ghcr.io/wizarrrr/wizarr:dev + [...] + environment: + - FORCE_LANGUAGE=en +``` +{% endtab %} + +{% tab title="Docker CLI" %} +``` +docker run -d \ + -e FORCE_LANGUAGE=en + [...] + ghcr.io/wizarrrr/wizarr:dev +``` +{% endtab %} +{% endtabs %} diff --git a/docs/contribute/webpack.md b/docs/contribute/webpack.md new file mode 100644 index 000000000..c7ff0b566 --- /dev/null +++ b/docs/contribute/webpack.md @@ -0,0 +1,29 @@ +# How to Add TypeScript Code + +To add your own TypeScript code to Wizarr in ```/static/src/ts/``` to be accessed in the project, you will need to do the following: + +1. Create a new file in ```/static/src/ts/``` with your code. + +2. Import your function from your module file in ```/static/src/ts/AddToDom.ts```. + +Example: +``` +import ScanLibraries from "./ScanLibraries"; +``` + +3. Add your function to the ```modules``` array in ```/static/src/ts/AddToDom.ts```. + +Example: +``` +onst modules = [ScanLibraries, ...]; + +``` + +4. You can now call your function inside Wizarr templates by referencing the ```window``` object. + +TIP: You can use the below code to access your function after the page has loaded. Your function won't be available on the ```window``` object until the page has loaded. +``` +document.addEventListener("DOMContentLoaded", function(event) { + window.ScanLibraries(); +}); +``` \ No newline at end of file diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md new file mode 100644 index 000000000..abfa03e9e --- /dev/null +++ b/docs/getting-started/installation.md @@ -0,0 +1,98 @@ +--- +description: Install Wizarr using Docker or Compose +--- + +# Installation + +{% hint style="info" %} +We have recently updated from V2 to V3, this has many improvements and features however V2 will always be available, however we won't be able to provide updates and support for V2, to use V2 visit it below. + +[View Documentation](https://github.com/Wizarrrr/wizarr/tree/v2) +{% endhint %} + +### Docker + +{% hint style="warning" %} +Be sure to replace`/path/to/appdata/config` in the below examples with a valid host directory path. If this volume mount is not configured correctly, your Wizarr settings/data will not be persisted when the container is recreated (e.g., when updating the image or rebooting your machine). + +The `TZ` environment variable value should also be set to the [TZ database name](https://en.wikipedia.org/wiki/List\_of\_tz\_database\_time\_zones) of your time zone! +{% endhint %} + +{% tabs %} +{% tab title="Docker Compose (recommended)" %} +**Installation:** + +Define the `wizarr` service in your `docker-compose.yml` as follows: + +```yaml +--- +version: "3.8" +services: + wizarr: + container_name: wizarr + image: ghcr.io/wizarrrr/wizarr + ports: + - 5690:5690 + volumes: + - /path/to/appdata/config:/data/database +``` + +Then, start all services defined in the Compose file: + +`docker compose up -d` **or** `docker-compose up -d` + +**Updating** + +Pull the latest image: + +`docker compose pull wizarr` or `docker-compose pull wizarr` + +Then, restart all services defined in the Compose file: + +`docker compose up -d` or `docker-compose up -d` +{% endtab %} + +{% tab title="Docker CLI" %} +**Installation** + +
docker run -d \
+  --name wizarr \
+  -p 5690:5690 \
+  -v /path/to/appdata/config:/data/database \
+  --restart unless-stopped \
+  ghcr.io/wizarrrr/wizarr
+
+ +**Updating** + +Stop and remove the existing container: + +```bash +docker stop wizarr && docker rm wizarr +``` + +Pull the latest image: + +```bash +docker pull ghcr.io/wizarrrr/wizarr +``` + +Finally, run the container with the same parameters originally used to create the container: + +```bash +docker run -d ... +``` +{% endtab %} +{% endtabs %} + +## Unraid + +{% hint style="warning" %} +NOT IMPLEMENTED YET +{% endhint %} + +1. Ensure you have the **Community Applications** plugin installed. +2. Inside the **Community Applications** app store, search for **Wizarr**. +3. Click the **Install Button**. +4. On the following **Add Container** screen, make changes to the **Host Port** and **Host Path 1**(Appdata) as needed, as well as the environment variables. +5. Click apply and access "Wizarr" at your `` in a web browser. diff --git a/docs/getting-started/reverse-proxy.md b/docs/getting-started/reverse-proxy.md new file mode 100644 index 000000000..7eb35bb7c --- /dev/null +++ b/docs/getting-started/reverse-proxy.md @@ -0,0 +1,155 @@ +# Reverse Proxy + +## Nginx + +{% tabs %} +{% tab title="SWAG" %} +Create a new file `wizarr.subdomain.conf` in `proxy-confs` with the following configuration: + +```nginx +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + + server_name wizarr.*; + + include /config/nginx/ssl.conf; + + client_max_body_size 0; + + location / { + include /config/nginx/proxy.conf; + resolver 127.0.0.11 valid=30s; + set $upstream_app wizarr; + set $upstream_port 5690; + set $upstream_proto http; + proxy_pass $upstream_proto://$upstream_app:$upstream_port; + } +} +``` +{% endtab %} + +{% tab title="Nginx Proxy Manager" %} +Add a new proxy host with the following settings: + +**Details** + +* **Domain Names:** Your desired external wizarr hostname; e.g., `wizarr.example.com` +* **Scheme:** `http` +* **Forward Hostname / IP:** Internal wizarr hostname or IP +* **Forward Port:** `5690` +* **Cache Assets:** yes +* **Block Common Exploits:** yes +* **Websocket Support:** yes + +**SSL** + +* **SSL Certificate:** Select one of the options; if you are not sure, pick “Request a new SSL Certificate” +* **Force SSL:** yes +* **HTTP/2 Support:** yes +{% endtab %} + +{% tab title="Subdomain" %} +Add the following configuration to a new file `/etc/nginx/sites-available/wizarr.example.com.conf`: + +```nginx +server { + listen 80; + server_name wizarr.example.com; + return 301 https://$server_name$request_uri; +} + +server { + listen 443 ssl http2; + server_name wizarr.example.com; + + ssl_certificate /etc/letsencrypt/live/wizarr.example.com/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/wizarr.example.com/privkey.pem; + + proxy_set_header Referer $http_referer; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Real-Port $remote_port; + proxy_set_header X-Forwarded-Host $host:$remote_port; + proxy_set_header X-Forwarded-Server $host; + proxy_set_header X-Forwarded-Port $remote_port; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Ssl on; + + location / { + proxy_pass http://127.0.0.1:5690; + } +} +``` + +Then, create a symlink to `/etc/nginx/sites-enabled`: + +```bash +sudo ln -s /etc/nginx/sites-available/wizarr.example.com.conf /etc/nginx/sites-enabled/wizarr.example.com.conf +``` +{% endtab %} +{% endtabs %} + +## Traefik (v2) + +Add the following labels to the wizarr service in your `docker-compose.yml` file: + +``` +labels: + - "traefik.enable=true" + ## HTTP Routers + - "traefik.http.routers.wizarr-rtr.entrypoints=https" + - "traefik.http.routers.wizarr-rtr.rule=Host(`wizarr.domain.com`)" + - "traefik.http.routers.wizarr-rtr.tls=true" + ## HTTP Services + - "traefik.http.routers.wizarr-rtr.service=wizarr-svc" + - "traefik.http.services.wizarr-svc.loadbalancer.server.port=5690" +``` + +For more information, please refer to the [Traefik documentation](https://doc.traefik.io/traefik/user-guides/docker-compose/basic-example/). + +## Caddy + +{% tabs %} +{% tab title="Subdomain" %} +Add the following site block to your Caddyfile: + +``` +wizarr.example.com { + reverse_proxy http://127.0.0.1:5690 +} +``` +{% endtab %} + +{% tab title="Path" %} +You need the [response replacement](https://github.com/caddyserver/replace-response) module to use this config. + +Add the following site block to your Caddyfile: + +``` +plex.example.com { + redir /wizarr /wizarr/admin + + handle_path /wizarr/* { + replace { + "href=\"/" "href=\"/wizarr/" + "action=\"/" "action=\"/wizarr/" + "\"/static" "\"/wizarr/static" + "hx-post=\"/" "hx-post=\"/wizarr/" + "hx-get=\"/" "hx-get=\"/wizarr/" + "scan=\"/" "href=\"/wizarr/" + "/scan" "/wizarr/scan" + # include in join code path copy + "navigator.clipboard.writeText(url + \"/j/\" + invite_code);" "navigator.clipboard.writeText(url + \"/wizarr/j/\" + invite_code);" + } + + # Your wizarr backend + reverse_proxy http://127.0.0.1:5690 + } + # Your main service that you want at / + reverse_proxy http://127.0.0.1:5055 +} +``` +{% endtab %} +{% endtabs %} diff --git a/docs/setup/README.md b/docs/setup/README.md new file mode 100644 index 000000000..834f77735 --- /dev/null +++ b/docs/setup/README.md @@ -0,0 +1,98 @@ +# Setup Wizarr V3 +#### This document will help you with setting up Wizarr V3, please read thoroughly + +## Welcome Page +The refresh icon will wipe everything you have done and restart you back to the beginning. +*Restart Button is currently not working, still in development* + +![Screenshot 2023-08-17 at 3 36 31 pm](https://github.com/Wizarrrr/wizarr/assets/16636012/495ee8cb-ece6-4d85-806d-538a87489eb7) + +
+ +## Database Setup +Database setup is not currently required but will be a future update to allow for custom database connection. + +![Screenshot 2023-08-17 at 3 36 37 pm](https://github.com/Wizarrrr/wizarr/assets/16636012/c2eb3765-1546-48fe-8e47-b11b2a344cfd) + +
+ +## Account Setup +This is where you will setup your first Wizarr Admin account, your welcome to use the username `admin`, please ensure you use a real email address and also a strong password, something with special characters, numbers and uppercase and lowercase letters. + +![Screenshot 2023-08-17 at 3 36 44 pm](https://github.com/Wizarrrr/wizarr/assets/16636012/b518e742-6a29-46ed-8ae4-c76af2edd5b7) + +
+ +## Media Server Setup +Here we need to configure the media server, this can either be `Jellyfin` or `Plex`, what you would use for Server URL will depend on your setup. + +
+ +### Public Hosted Media Server +If your media server is hosted at a public facing domain, for example http://plex.wizarr.dev or https://plex.wizarr.dev then you may use this address to point to your media server. + +DO NOT LEAVE A TRAILING SLASH ON YOUR URL
+Example: https://plex.wizarr.dev/ + +![Screenshot 2023-08-17 at 3 40 01 pm](https://github.com/Wizarrrr/wizarr/assets/16636012/5d3304e2-2963-4519-b7a5-656b0fcf31de) + +
+ +### Media Server Hosted in Docker on same machine +If your media server is hosted inside Docker on the same machine that Wizarr is running on then you can take advantage of Dockers internal DNS routing. + +Docker will use your media servers container name as a host address, if you look in the below screenshot you will see that I ran a `docker ps` command and in the last column of the result you can see the container names. For my plex container it's named `plex`. + +![Screenshot 2023-08-17 at 3 39 31 pm 1](https://github.com/Wizarrrr/wizarr/assets/16636012/ad1829c2-f2dd-425b-9eb8-1319cb714603) + +So we can use this address to point to our media server, you will see in the below screenshot I have set the Server URL to `http://plex:32400`. +1. `http://` - We want to use an unencrypted connection to Plex, this is secure because we are on a sub network inside of Docker. +2. `plex` - This is our docker container name for the chosen media server, this could be any name that you chose when creating your docker container. +3. `:32400` - The port that Plex is running at, Jellyfin would use `8096` for the port number, so if your Jellyfin container name was `jellyfin` you could use the Server URL `http://jellyfin:8096` + +DO NOT LEAVE A TRAILING SLASH ON YOUR URL
+Example: http://plex:32400/ + +![Screenshot 2023-08-17 at 3 37 35 pm](https://github.com/Wizarrrr/wizarr/assets/16636012/5d85d773-9329-427e-bad4-2a55ea70f7f7) + +
+ +### IP Addresses +*THERE IS CURRENTLY AN UNRESOLVED BUG IN WIZARR V3 BETA THAT DOES NOT ALLOW YOU TO USE IP ADDRESSES. THIS SHOULD BE FIXED SOON. + +CURRENT WORKAROUND +If you need to use an IP Address then please use the below workaround, you will need to add the below setting to your Wizarr V3's Docker Compose file. + +```` +extra_hosts: + - "mediaserver:your-ip-address" +```` + +KEEP `mediaserver` the same, but replace `your-ip-address` with the IP address of your Media Server you are attempting to point to. + +Now save the file and restart Wizarr V3 with the updated changes, you can now set Server URL to `http://mediaserver:32400`, remember `32400` is the port for Plex, if you are using Jellyfin then you would use the port number `8096` + +DO NOT USE THE ABOVE `EXTRA_HOSTS` method if your Plex or Jellyfin server is running in Docker on the same machine as Wizarr, instead please refer to using the `Media Server Hosted in Docker on same machine` method. + +DO NOT LEAVE A TRAILING SLASH ON YOUR URL
+Example: http://mediaserver:32400/ + +
+ +### Libraries Setup +If your Media Server is successfully detected then you will see a button show called `Configure Libraries`, click this to select which Libraries by default Wizarr will allow invited users to be apart of. + +![Screenshot 2023-08-17 at 3 40 29 pm](https://github.com/Wizarrrr/wizarr/assets/16636012/0e5fbac5-c11f-4be2-87c6-ec3a086ed385) + +For example, if you create an Invite to your Jellyfin server but you do not wish under any circumstance invited users to have access to your `Home Movies` Library then you would select all Libraries EXCEPT the `Home Movies` Library. + +![Screenshot 2023-08-17 at 3 40 35 pm](https://github.com/Wizarrrr/wizarr/assets/16636012/31bed2bf-deb9-42d8-8058-3ebbf9a9bf28) + +
+ +### All Done +After saving your Libraries (if you chose to configure it now, can be configured later on if you wish), you can click `Save` to be brought to the last step. + +Just click `Go to Login` and you will be ready to Login to Wizarr. + +![Screenshot 2023-08-17 at 3 40 49 pm](https://github.com/Wizarrrr/wizarr/assets/16636012/3116622d-1dec-499a-a2d3-c5dce9af74c4) diff --git a/docs/setup/Screenshot 2023-08-17 at 3.36.31 pm.png b/docs/setup/Screenshot 2023-08-17 at 3.36.31 pm.png new file mode 100644 index 000000000..905c70a04 Binary files /dev/null and b/docs/setup/Screenshot 2023-08-17 at 3.36.31 pm.png differ diff --git a/docs/setup/Screenshot 2023-08-17 at 3.36.37 pm.png b/docs/setup/Screenshot 2023-08-17 at 3.36.37 pm.png new file mode 100644 index 000000000..c445db36c Binary files /dev/null and b/docs/setup/Screenshot 2023-08-17 at 3.36.37 pm.png differ diff --git a/docs/setup/Screenshot 2023-08-17 at 3.36.44 pm.png b/docs/setup/Screenshot 2023-08-17 at 3.36.44 pm.png new file mode 100644 index 000000000..c374109a8 Binary files /dev/null and b/docs/setup/Screenshot 2023-08-17 at 3.36.44 pm.png differ diff --git a/docs/setup/Screenshot 2023-08-17 at 3.37.05 pm.png b/docs/setup/Screenshot 2023-08-17 at 3.37.05 pm.png new file mode 100644 index 000000000..da99082b3 Binary files /dev/null and b/docs/setup/Screenshot 2023-08-17 at 3.37.05 pm.png differ diff --git a/docs/setup/Screenshot 2023-08-17 at 3.37.35 pm.png b/docs/setup/Screenshot 2023-08-17 at 3.37.35 pm.png new file mode 100644 index 000000000..51ffe4631 Binary files /dev/null and b/docs/setup/Screenshot 2023-08-17 at 3.37.35 pm.png differ diff --git a/docs/setup/Screenshot 2023-08-17 at 3.39.31 pm 1.png b/docs/setup/Screenshot 2023-08-17 at 3.39.31 pm 1.png new file mode 100644 index 000000000..b026b6108 Binary files /dev/null and b/docs/setup/Screenshot 2023-08-17 at 3.39.31 pm 1.png differ diff --git a/docs/setup/Screenshot 2023-08-17 at 3.39.31 pm.png b/docs/setup/Screenshot 2023-08-17 at 3.39.31 pm.png new file mode 100644 index 000000000..e6ed469e3 Binary files /dev/null and b/docs/setup/Screenshot 2023-08-17 at 3.39.31 pm.png differ diff --git a/docs/setup/Screenshot 2023-08-17 at 3.40.01 pm.png b/docs/setup/Screenshot 2023-08-17 at 3.40.01 pm.png new file mode 100644 index 000000000..241e5937a Binary files /dev/null and b/docs/setup/Screenshot 2023-08-17 at 3.40.01 pm.png differ diff --git a/docs/setup/Screenshot 2023-08-17 at 3.40.21 pm.png b/docs/setup/Screenshot 2023-08-17 at 3.40.21 pm.png new file mode 100644 index 000000000..e51c3a76e Binary files /dev/null and b/docs/setup/Screenshot 2023-08-17 at 3.40.21 pm.png differ diff --git a/docs/setup/Screenshot 2023-08-17 at 3.40.29 pm.png b/docs/setup/Screenshot 2023-08-17 at 3.40.29 pm.png new file mode 100644 index 000000000..1f2c26e18 Binary files /dev/null and b/docs/setup/Screenshot 2023-08-17 at 3.40.29 pm.png differ diff --git a/docs/setup/Screenshot 2023-08-17 at 3.40.35 pm.png b/docs/setup/Screenshot 2023-08-17 at 3.40.35 pm.png new file mode 100644 index 000000000..97b97a1c1 Binary files /dev/null and b/docs/setup/Screenshot 2023-08-17 at 3.40.35 pm.png differ diff --git a/docs/setup/Screenshot 2023-08-17 at 3.40.49 pm.png b/docs/setup/Screenshot 2023-08-17 at 3.40.49 pm.png new file mode 100644 index 000000000..1a4093613 Binary files /dev/null and b/docs/setup/Screenshot 2023-08-17 at 3.40.49 pm.png differ diff --git a/docs/using-wizarr/custom-html.md b/docs/using-wizarr/custom-html.md new file mode 100644 index 000000000..a138496a5 --- /dev/null +++ b/docs/using-wizarr/custom-html.md @@ -0,0 +1,24 @@ +# Custom HTML + +{% hint style="warning" %} +NOT YET IMPLEMENTED +{% endhint %} + +With the ability to add a custom HTML section to the setup wizard for Plex and Jellyfin, you can create a custom page that will be automatically centred and placed in a div with a background, making it a seamless addition to your setup process + +### Adding Custom HTML + +Adding custom HTML to your setup wizard is a simple process. All you need to do is follow these steps: + +1. Go to the Settings page and navigate to the HTML section +2. Paste in your custom HTML code + +That's it! Your custom HTML section will be automatically added to the setup wizard, centered and styled with a background, thanks TailwindCSS. + +### Using TailwindCSS + +TailwindCSS is the underlying framework of Wizarr. As a result, users are free to use TailwindCSS syntax in their HTML code for the custom section. This allows for greater flexibility in designing and customizing the appearance of the section. + +{% hint style="danger" %} +Javascript will not function inside Custom HTML, for more advanced functionality we advise you fork your own copy of Wizarr. This is to prevent XSS vulnerabilities being introduced. +{% endhint %} diff --git a/docs/using-wizarr/requests-integration.md b/docs/using-wizarr/requests-integration.md new file mode 100644 index 000000000..21a439a26 --- /dev/null +++ b/docs/using-wizarr/requests-integration.md @@ -0,0 +1,36 @@ +# Requests Integration + +{% hint style="warning" %} +OLD DOCUMENTATION +{% endhint %} + +### Setup + +**Jellyseerr** + +1. Go to your Jellyseerr settings and copy the API key. +2. Go to your Wizarr settings then Requests. +3. Select Jellyseerr from the dropdown. +4. Input your Jellyseerr base URL. +5. Input your Jellyseerr API key. + +**Overseerr** + +1. Go to your Overseerr settings and copy the API key. +2. Go to your Wizarr settings then Requests. +3. Select Overseerr from the dropdown. +4. Input your Overseerr base URL. +5. Input your Overseerr API key. + +**Ombi** + +1. Go to your Ombi settings, then click "Configuration" and select "General" from the dropdown. +2. Copy the API key from the "API Key" field. +3. Go to your Wizarr settings then Requests. +4. Select Ombi from the dropdown. +5. Input your Ombi base URL. +6. Input your Ombi API key. + +### Usage + +Now when a user is invited to your media server, they will be automatically added to your request software. They will also be guided on how to request Movies and TV Shows. When a user expires or is deleted from Wizarr, they will also be removed from your request software. diff --git a/frontend/.editorconfig b/frontend/.editorconfig new file mode 100644 index 000000000..c1322dc7b --- /dev/null +++ b/frontend/.editorconfig @@ -0,0 +1,12 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +[*] +indent_style = space +indent_size = 4 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = false +insert_final_newline = false \ No newline at end of file diff --git a/frontend/.eslintrc.js b/frontend/.eslintrc.js new file mode 100644 index 000000000..ebde2344e --- /dev/null +++ b/frontend/.eslintrc.js @@ -0,0 +1,11 @@ +module.exports = { + env: { + node: true, + }, + parser: "babel-eslint", + extends: ["eslint:recommended", "plugin:vue/vue3-recommended", "plugin:@typescript-eslint/recommended", "prettier", "@vue/eslint-config-prettier"], + rules: { + // override/add rules settings here, such as: + // 'vue/no-unused-vars': 'error' + }, +} diff --git a/frontend/.prettierrc.js b/frontend/.prettierrc.js new file mode 100644 index 000000000..b56822c52 --- /dev/null +++ b/frontend/.prettierrc.js @@ -0,0 +1,9 @@ +module.exports = { + printWidth: 10000, + tabWidth: 4, + useTabs: false, + semi: true, + singleQuote: false, + doubleQuote: true, + singleAttributePerLine: false, +} diff --git a/frontend/.vscode/launch.json b/frontend/.vscode/launch.json new file mode 100644 index 000000000..0aace8a17 --- /dev/null +++ b/frontend/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "configurations": [ + { + "type": "node-terminal", + "name": "Run Frontend: dev", + "request": "launch", + "command": "npm run dev", + "cwd": "${workspaceFolder}" + } + ] +} diff --git a/frontend/.vscode/settings.json b/frontend/.vscode/settings.json new file mode 100644 index 000000000..7322d9e19 --- /dev/null +++ b/frontend/.vscode/settings.json @@ -0,0 +1,47 @@ +{ + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnPaste": false, + "editor.formatOnType": false, + "editor.formatOnSave": true, + "[vue]": { + "editor.formatOnType": true, + "editor.formatOnSave": true, + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "[javascript]": { + "editor.defaultFormatter": "esbenp.prettier-vscode" + }, + "editor.codeActionsOnSave": { + "source.fixAll": false, + "source.fixAll.eslint": true, + "source.organizeImports": false + }, + "eslint.validate": [ + "javascript", + "javascriptreact", + "typescript", + "typescriptreact", + "vue" + ], + "[nunjucks]": { + "editor.defaultFormatter": "okitavera.vscode-nunjucks-formatter" + }, + "[html]": { + "editor.defaultFormatter": "vscode.html-language-features" + }, + "css.customData": [ + ".vscode/tailwind.json" + ], + "scss.lint.unknownAtRules": "ignore", + "prettier.configPath": ".prettierrc.js", + "editor.wordWrap": "off", + "[xml]": { + "editor.defaultFormatter": "fabianlauer.vs-code-xml-format" + }, + // hide node_modules folder + "files.exclude": { + "**/.git": true, + "**/.DS_Store": true, + "**/node_modules": true, + } +} \ No newline at end of file diff --git a/frontend/.vscode/tailwind.json b/frontend/.vscode/tailwind.json new file mode 100644 index 000000000..4c40326fc --- /dev/null +++ b/frontend/.vscode/tailwind.json @@ -0,0 +1,55 @@ +{ + "version": 1.1, + "atDirectives": [ + { + "name": "@tailwind", + "description": "Use the `@tailwind` directive to insert Tailwind's `base`, `components`, `utilities` and `screens` styles into your CSS.", + "references": [ + { + "name": "Tailwind Documentation", + "url": "https://tailwindcss.com/docs/functions-and-directives#tailwind" + } + ] + }, + { + "name": "@apply", + "description": "Use the `@apply` directive to inline any existing utility classes into your own custom CSS. This is useful when you find a common utility pattern in your HTML that you’d like to extract to a new component.", + "references": [ + { + "name": "Tailwind Documentation", + "url": "https://tailwindcss.com/docs/functions-and-directives#apply" + } + ] + }, + { + "name": "@responsive", + "description": "You can generate responsive variants of your own classes by wrapping their definitions in the `@responsive` directive:\n```css\n@responsive {\n .alert {\n background-color: #E53E3E;\n }\n}\n```\n", + "references": [ + { + "name": "Tailwind Documentation", + "url": "https://tailwindcss.com/docs/functions-and-directives#responsive" + } + ] + }, + { + "name": "@screen", + "description": "The `@screen` directive allows you to create media queries that reference your breakpoints by **name** instead of duplicating their values in your own CSS:\n```css\n@screen sm {\n /* ... */\n}\n```\n…gets transformed into this:\n```css\n@media (min-width: 640px) {\n /* ... */\n}\n```\n", + "references": [ + { + "name": "Tailwind Documentation", + "url": "https://tailwindcss.com/docs/functions-and-directives#screen" + } + ] + }, + { + "name": "@variants", + "description": "Generate `hover`, `focus`, `active` and other **variants** of your own utilities by wrapping their definitions in the `@variants` directive:\n```css\n@variants hover, focus {\n .btn-brand {\n background-color: #3182CE;\n }\n}\n```\n", + "references": [ + { + "name": "Tailwind Documentation", + "url": "https://tailwindcss.com/docs/functions-and-directives#variants" + } + ] + } + ] +} \ No newline at end of file diff --git a/frontend/config/VitePWA.config.ts b/frontend/config/VitePWA.config.ts new file mode 100644 index 000000000..31e7b4c46 --- /dev/null +++ b/frontend/config/VitePWA.config.ts @@ -0,0 +1,61 @@ +import type { VitePWAOptions } from "vite-plugin-pwa"; + +const VitePWAConfig: Partial = { + mode: "production", + base: "/", + includeAssets: ["favicon.ico"], + selfDestroying: false, + srcDir: "src/service", + filename: "sw.ts", + registerType: "autoUpdate", + strategies: "injectManifest", + workbox: { + runtimeCaching: [ + { + handler: "NetworkOnly", + urlPattern: /\/api\/.*\/*.json/, + method: "POST", + options: { + backgroundSync: { + name: "apiQueue", + options: { + maxRetentionTime: 24 * 60, + }, + }, + }, + }, + ], + }, + manifest: { + name: "Wizarr", + short_name: "Wizarr", + start_url: "/admin", + theme_color: "#fe4155", + icons: [ + { + src: "pwa-192x192.png", + sizes: "192x192", + type: "image/png", + }, + { + src: "/pwa-512x512.png", + sizes: "512x512", + type: "image/png", + }, + { + src: "pwa-512x512.png", + sizes: "512x512", + type: "image/png", + purpose: "any maskable", + }, + ], + }, + devOptions: { + enabled: true, + type: "module", + navigateFallback: "index.html", + suppressWarnings: false, + }, +}; + +export default VitePWAConfig; diff --git a/frontend/config/ViteSSR.config.ts b/frontend/config/ViteSSR.config.ts new file mode 100644 index 000000000..d3403f1b8 --- /dev/null +++ b/frontend/config/ViteSSR.config.ts @@ -0,0 +1,7 @@ +import type { UserConfig } from "vite-plugin-ssr/plugin"; + +const config: UserConfig = { + prerender: false, +}; + +export default config; diff --git a/frontend/cypress.config.ts b/frontend/cypress.config.ts new file mode 100644 index 000000000..0f66080fd --- /dev/null +++ b/frontend/cypress.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'cypress' + +export default defineConfig({ + e2e: { + specPattern: 'cypress/e2e/**/*.{cy,spec}.{js,jsx,ts,tsx}', + baseUrl: 'http://localhost:4173' + } +}) diff --git a/frontend/cypress/e2e/example.cy.ts b/frontend/cypress/e2e/example.cy.ts new file mode 100644 index 000000000..7554c35d8 --- /dev/null +++ b/frontend/cypress/e2e/example.cy.ts @@ -0,0 +1,8 @@ +// https://on.cypress.io/api + +describe('My First Test', () => { + it('visits the app root url', () => { + cy.visit('/') + cy.contains('h1', 'You did it!') + }) +}) diff --git a/frontend/cypress/e2e/tsconfig.json b/frontend/cypress/e2e/tsconfig.json new file mode 100644 index 000000000..37748feb7 --- /dev/null +++ b/frontend/cypress/e2e/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "@vue/tsconfig/tsconfig.dom.json", + "include": ["./**/*", "../support/**/*"], + "compilerOptions": { + "isolatedModules": false, + "target": "es5", + "lib": ["es5", "dom"], + "types": ["cypress"] + } +} diff --git a/frontend/cypress/fixtures/example.json b/frontend/cypress/fixtures/example.json new file mode 100644 index 000000000..02e425437 --- /dev/null +++ b/frontend/cypress/fixtures/example.json @@ -0,0 +1,5 @@ +{ + "name": "Using fixtures to represent data", + "email": "hello@cypress.io", + "body": "Fixtures are a great way to mock data for responses to routes" +} diff --git a/frontend/cypress/support/commands.ts b/frontend/cypress/support/commands.ts new file mode 100644 index 000000000..9b7bb8e25 --- /dev/null +++ b/frontend/cypress/support/commands.ts @@ -0,0 +1,39 @@ +/// +// *********************************************** +// This example commands.ts shows you how to +// create various custom commands and overwrite +// existing commands. +// +// For more comprehensive examples of custom +// commands please read more here: +// https://on.cypress.io/custom-commands +// *********************************************** +// +// +// -- This is a parent command -- +// Cypress.Commands.add('login', (email, password) => { ... }) +// +// +// -- This is a child command -- +// Cypress.Commands.add('drag', { prevSubject: 'element'}, (subject, options) => { ... }) +// +// +// -- This is a dual command -- +// Cypress.Commands.add('dismiss', { prevSubject: 'optional'}, (subject, options) => { ... }) +// +// +// -- This will overwrite an existing command -- +// Cypress.Commands.overwrite('visit', (originalFn, url, options) => { ... }) +// +// declare global { +// namespace Cypress { +// interface Chainable { +// login(email: string, password: string): Chainable +// drag(subject: string, options?: Partial): Chainable +// dismiss(subject: string, options?: Partial): Chainable +// visit(originalFn: CommandOriginalFn, url: string, options: Partial): Chainable +// } +// } +// } + +export {} diff --git a/frontend/cypress/support/e2e.ts b/frontend/cypress/support/e2e.ts new file mode 100644 index 000000000..d68db96df --- /dev/null +++ b/frontend/cypress/support/e2e.ts @@ -0,0 +1,20 @@ +// *********************************************************** +// This example support/index.js is processed and +// loaded automatically before your test files. +// +// This is a great place to put global configuration and +// behavior that modifies Cypress. +// +// You can change the location of this file or turn off +// automatically serving support files with the +// 'supportFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/configuration +// *********************************************************** + +// Import commands.js using ES2015 syntax: +import './commands' + +// Alternatively you can use CommonJS syntax: +// require('./commands') diff --git a/frontend/env.d.ts b/frontend/env.d.ts new file mode 100644 index 000000000..11f02fe2a --- /dev/null +++ b/frontend/env.d.ts @@ -0,0 +1 @@ +/// diff --git a/frontend/gettext.config.js b/frontend/gettext.config.js new file mode 100644 index 000000000..f23abf1f8 --- /dev/null +++ b/frontend/gettext.config.js @@ -0,0 +1,44 @@ +module.exports = { + input: { + path: "./src", // only files in this directory are considered for extraction + include: ["**/*.js", "**/*.ts", "**/*.vue"], // glob patterns to select files for extraction + exclude: [], // glob patterns to exclude files from extraction + jsExtractorOpts: [ + // custom extractor keyword. default empty. + { + keyword: "__", // only extractor default keyword such as $gettext,use keyword to custom + options: { + // see https://github.com/lukasgeiter/gettext-extractor + content: { + replaceNewLines: "\n", + }, + arguments: { + text: 0, + }, + }, + }, + { + keyword: "_n", // $ngettext + options: { + content: { + replaceNewLines: "\n", + }, + arguments: { + text: 0, + textPlural: 1, + }, + }, + }, + ], + compileTemplate: false, // do not compile