diff --git a/.docker/Dockerfile.dev b/.docker/Dockerfile.dev new file mode 100644 index 0000000..ce4e77c --- /dev/null +++ b/.docker/Dockerfile.dev @@ -0,0 +1,35 @@ +# rust:1.80.1-alpine3.20 +FROM rust@sha256:1f5aff501e02c1384ec61bb47f89e3eebf60e287e6ed5d1c598077afc82e83d5 AS dev + +ARG INIT_PATH=/usr/local/bin/dumb-init +ARG INIT_URL=https://github.com/Yelp/dumb-init/releases/download/v1.2.5/dumb-init_1.2.5_x86_64 +ARG USER=rust +ARG USER_UID=1000 +ARG USER_GID=${USER_UID} +ARG WORK_DIR=/usr/src/app + +ENV TZ=America/Sao_Paulo TERM=xterm-256color LANG=C.UTF-8 LC_ALL=C.UTF-8 + +RUN set -euxo pipefail; \ + apk add --no-cache nano sudo build-base tzdata curl cmake g++ pcre-dev openssl-dev make gmp-dev git ca-certificates wget zip unzip busybox; \ + curl --fail --silent --show-error --location ${INIT_URL} --output ${INIT_PATH}; \ + chmod +x ${INIT_PATH}; \ + cargo install cargo-watch; + +RUN set -euxo pipefail; \ + addgroup -g ${USER_GID} ${USER}; \ + adduser -u ${USER_UID} -G ${USER} -h /home/${USER} -D ${USER}; \ + echo "${USER} ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/${USER}; \ + chmod 0440 /etc/sudoers.d/${USER}; + +USER ${USER} + +ENV CARGO_HOME="/home/${USER}/.cargo" + +WORKDIR ${WORK_DIR} + +EXPOSE 3000 + +ENTRYPOINT [ "/usr/local/bin/dumb-init", "--" ] + +CMD [ "cargo", "watch", "-w", "src", "-x", "run" ] diff --git a/.docker/Dockerfile.prod b/.docker/Dockerfile.prod new file mode 100644 index 0000000..13f0dac --- /dev/null +++ b/.docker/Dockerfile.prod @@ -0,0 +1,66 @@ +ARG INIT_PATH=/usr/local/bin/dumb-init +ARG INIT_URL=https://github.com/Yelp/dumb-init/releases/download/v1.2.5/dumb-init_1.2.5_x86_64 +ARG USER=rust +ARG USER_UID=1000 +ARG USER_GID=${USER_UID} +ARG WORK_DIR=/usr/src/app + +# rust:1.80.1-alpine3.20 +FROM rust@sha256:1f5aff501e02c1384ec61bb47f89e3eebf60e287e6ed5d1c598077afc82e83d5 AS builder + +ARG INIT_PATH +ARG INIT_URL +ARG USER +ARG USER_UID +ARG USER_GID +ARG WORK_DIR + +ENV CI=true LANG=C.UTF-8 LC_ALL=C.UTF-8 + +WORKDIR ${WORK_DIR} + +RUN set -euxo pipefail; \ + apk add --no-cache build-base cmake g++ pcre-dev openssl-dev gmp-dev curl ca-certificates; \ + curl --fail --silent --show-error --location ${INIT_URL} --output ${INIT_PATH}; \ + chmod +x ${INIT_PATH}; + +COPY Cargo.toml Cargo.lock Makefile ${WORK_DIR}/ +COPY src ${WORK_DIR}/src + +RUN set -euxo pipefail; \ + make clean; \ + make release; + +# alpine:3.20 +FROM alpine@sha256:0a4eaa0eecf5f8c050e5bba433f58c052be7587ee8af3e8b3910ef9ab5fbe9f5 AS main + +ARG INIT_PATH +ARG INIT_URL +ARG USER +ARG USER_UID +ARG USER_GID +ARG WORK_DIR + +ENV LANG=C.UTF-8 LC_ALL=C.UTF-8 + +RUN set -euxo pipefail; \ + addgroup -g ${USER_GID} ${USER}; \ + adduser -u ${USER_UID} -G ${USER} -D ${USER}; \ + apk update --no-cache; \ + apk upgrade --no-cache; + +COPY --from=builder --chown=${USER}:${USER} ${INIT_PATH} ${INIT_PATH} + +WORKDIR ${WORK_DIR} + +COPY --from=builder --chown=${USER}:${USER} ${WORK_DIR}/target/release/twitch-extension-api ${WORK_DIR}/twitch-extension-api + +COPY --chown=${USER}:${USER} static ${WORK_DIR}/static + +USER ${USER} + +EXPOSE 3000 + +ENTRYPOINT [ "/usr/local/bin/dumb-init", "--" ] + +CMD [ "/usr/src/app/twitch-extension-api" ] diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0179457 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,23 @@ +# Exclude version control and IDE files +.git +.github +.idea + +# Exclude build artifacts +target + +# Exclude environment files +.env* + +# Exclude Docker development files +.docker* + +# Exclude certificate files +cert.pem +key.pem + +# Exclude unnecessary documentation and configuration +README.md + +# Exclute test files +tests diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..5c570d9 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,29 @@ +# EditorConfig helps developers define and maintain consistent +# coding styles between different editors and IDEs +# editorconfig.org +# Adapted from rust-lang/rust repository + +root = true + +[*] +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true +indent_style = space +indent_size = 2 + +[*.rs] +max_line_length = 100 + +[*.md] +# double whitespace at end of line +# denotes a line break in Markdown +trim_trailing_whitespace = false + +[*.yml] +indent_size = 2 + +[Makefile] +indent_style = tab +indent_size = 4 diff --git a/.env b/.env deleted file mode 100644 index 31fcb3b..0000000 --- a/.env +++ /dev/null @@ -1,15 +0,0 @@ -# App Config -APP_NAME="Gaming Leaderboard" -APP_VERSION="0.0.4" -APP_URL="0.0.0.0" -APP_PORT="8000" -APP_TLS_ENABLED="false" -APP_TLS_CERT="/home/danielhe4rt/dev/scylladb/browser-extension/api/cert.pem" -APP_TLS_KEY="/home/danielhe4rt/dev/scylladb/browser-extension/api/key.pem" - -# Database Config -SCYLLA_NODES="localhost:9042" -SCYLLA_USERNAME="scylla" -SCYLLA_PASSWORD="" -SCYLLA_CACHED_QUERIES="15" -SCYLLA_KEYSPACE="twitch" diff --git a/.env.docker b/.env.docker new file mode 100644 index 0000000..ead101f --- /dev/null +++ b/.env.docker @@ -0,0 +1,24 @@ +# Rust +RUST_LOG=twitch_extension_api=debug +RUST_BACKTRACE=1 + +# Http +MAX_WORKERS=2 + +# App Config +APP_NAME="Twitch Better Chat API" +APP_VERSION=0.1.0 +APP_URL=0.0.0.0 +APP_PORT=3000 +APP_ENV=dev +APP_TLS_ENABLED=false +APP_TLS_CERT=/your/path/to/cert.pem +APP_TLS_KEY=/your/path/to/key.pem +APP_PLATFORM_SECRET=secret + +# Database Config +SCYLLA_NODES=scylla-1:9042 +SCYLLA_USERNAME= +SCYLLA_PASSWORD= +SCYLLA_CACHED_QUERIES=15 +SCYLLA_KEYSPACE=twitch diff --git a/.env.example b/.env.example index 0afa425..8ccc8a7 100644 --- a/.env.example +++ b/.env.example @@ -1,14 +1,24 @@ +# Rust +RUST_LOG=twitch_extension_api=info +RUST_BACKTRACE=1 + +# Http +MAX_WORKERS=4 + # App Config -APP_NAME="Gaming Leaderboard" -APP_VERSION="0.0.1" -APP_URL="0.0.0.0" -APP_PORT="8000" -APP_AUTH_CERT="/home/danielhe4rt/dev/scylladb/browser-extension/api/cert.pem" -APP_AUTH_KEY="/home/danielhe4rt/dev/scylladb/browser-extension/api/key.pem" +APP_NAME='Twitch Better Chat API' +APP_VERSION=0.1.0 +APP_URL=0.0.0.0 +APP_PORT=3000 +APP_ENV=dev +APP_TLS_ENABLED=false +APP_TLS_CERT=/your/path/to/cert.pem +APP_TLS_KEY=/your/path/to/key.pem +APP_PLATFORM_SECRET=secret # Database Config -SCYLLA_NODES="localhost:9042" -SCYLLA_USERNAME="scylla" -SCYLLA_PASSWORD="" -SCYLLA_CACHED_QUERIES="15" -SCYLLA_KEYSPACE="twitch" +SCYLLA_NODES=localhost:9042 +SCYLLA_USERNAME= +SCYLLA_PASSWORD= +SCYLLA_CACHED_QUERIES=15 +SCYLLA_KEYSPACE=twitch diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..34884cb --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,13 @@ +version: 2 + +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + target-branch: "develop" + - package-ecosystem: "cargo" + directory: "/" + schedule: + interval: "weekly" + target-branch: "develop" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e17b417 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,53 @@ +name: CI + +on: + push: + branches: + - main + - develop + pull_request: + branches: + - main + - develop + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + setup: + name: Setup rust + runs-on: ubuntu-24.04 + steps: + - name: Checkout code + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + - name: Setup rust + uses: actions-rust-lang/setup-rust-toolchain@1fbea72663f6d4c03efaab13560c8a24cfd2a7cc # v1.9.0 + with: + toolchain: stable + target: x86_64-unknown-linux-gnu + components: clippy, rustfmt + - name: Build project + run: make build + lint: + name: Run lint and format + runs-on: ubuntu-24.04 + env: + RUSTFLAGS: "-Dwarnings" + needs: + - setup + steps: + - name: Checkout code + uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4.1.7 + - name: Setup rust + uses: actions-rust-lang/setup-rust-toolchain@1fbea72663f6d4c03efaab13560c8a24cfd2a7cc # v1.9.0 + with: + toolchain: stable + target: x86_64-unknown-linux-gnu + components: clippy, rustfmt + - name: Run formatter + uses: actions-rust-lang/rustfmt@2d1d4e9f72379428552fa1def0b898733fb8472d # v1.1.0 + - name: Run linter + uses: clechasseur/rs-clippy-check@a2a93bdcf05de7909aabd62eca762179ad3dbe50 # v3.0.5 + with: + args: --all-features diff --git a/.gitignore b/.gitignore index c00a6b4..0e423cd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,212 @@ -/target -/.idea -.gitignore +# Created by https://www.toptal.com/developers/gitignore/api/rust,linux,macos,windows,jetbrains+all,visualstudiocode,dotenv +# Edit at https://www.toptal.com/developers/gitignore?templates=rust,linux,macos,windows,jetbrains+all,visualstudiocode,dotenv + +### dotenv ### +.env +.env.* + +# Explicitly track these specific .env files +!.env.example +!.env.docker + +# Ignore charybdis-migrate current_schema.json +current_schema.json + +### JetBrains+all ### +# 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 + +### JetBrains+all Patch ### +# Ignore everything but code style settings and run configurations +# that are supposed to be shared within teams. + +.idea/* + +!.idea/codeStyles +!.idea/runConfigurations + +### Linux ### +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### macOS Patch ### +# iCloud generated files +*.icloud + +### Rust ### +# Generated by Cargo +# will have compiled files and executables +debug/ +target/ + +# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries +# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html +Cargo.lock + +# These are backup files generated by rustfmt +**/*.rs.bk + +# MSVC Windows builds of rustc generate these, which store debugging information +*.pdb + +### VisualStudioCode ### +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +!.vscode/*.code-snippets + +# Local History for Visual Studio Code +.history/ + +# Built Visual Studio Code Extensions +*.vsix + +### VisualStudioCode Patch ### +# Ignore all local history of files +.history +.ionide + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +# End of https://www.toptal.com/developers/gitignore/api/rust,linux,macos,windows,jetbrains+all,visualstudiocode,dotenv diff --git a/Cargo.lock b/Cargo.lock index 97dcf44..678b775 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3459,7 +3459,7 @@ dependencies = [ [[package]] name = "twitch-extension-api" -version = "0.0.7" +version = "0.1.0" dependencies = [ "actix-cors", "actix-files", diff --git a/Cargo.toml b/Cargo.toml index 643c58d..01dfecb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,7 +9,6 @@ actix-files = "0.6.6" actix-test = "0.1.5" actix-web = { version = "4.8.0", features = ["rustls-0_23"] } anyhow = "1.0.86" -cargo-watch = "8.5.2" charybdis = "0.7.2" chrono = { version = "0.4.38", features = ["serde"] } colog = "1.3.0" @@ -27,3 +26,4 @@ uuid = "1.10.0" [dev-dependencies] charybdis-migrate = "0.7.4" scylladb-dev-toolkit = "0.1.0" +cargo-watch = "8.5.2" diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c208497 --- /dev/null +++ b/Makefile @@ -0,0 +1,70 @@ +CARGO := $(shell command -v cargo 2> /dev/null) + +ifndef CARGO +$(error "Cannot find cargo. Please install and try again!") +endif + +all: help ## Default target: shows the help message with available commands. + +.PHONY: clean +clean: ## Cleans up the project by removing the target directory. + @$(CARGO) clean + +.PHONY: lint +lint: ## Runs Clippy to lint the codebase. + @$(CARGO) clippy --no-deps + +.PHONY: format +format: ## Formats the codebase using rustfmt. + @$(CARGO) fmt + +.PHONY: check +check: format lint ## Formats the codebase and then lints it. + +.PHONY: build +build: ## Compiles the project. + @$(CARGO) build + +.PHONY: run +run: ## Compiles and runs the project. + @$(CARGO) run + +.PHONY: release +release: clean ## Cleans up the project and compiles it for release. + @$(CARGO) build --release + +.PHONY: test +test: ## Runs the test suite. + @$(CARGO) test + +# Load .env file +ifneq (,$(wildcard ./.env)) + include .env + export +endif + +.PHONY: print-env +print-env: ## Prints the loaded environment variables from the .env file. + $(foreach v, $(.VARIABLES), $(info $(v)=$($(v)))) + +.PHONY: migrate +migrate: ## Runs database migrations + @migrate --host=$(SCYLLA_NODES) --keyspace=$(SCYLLA_KEYSPACE) $(if $(SCYLLA_USERNAME),--user=$(SCYLLA_USERNAME)) $(if $(SCYLLA_PASSWORD),--password=$(SCYLLA_PASSWORD)) + +.PHONY: keyspace +keyspace: ## Configures the keyspace in the ScyllaDB + @toolkit keyspace --host=$(SCYLLA_NODES) --keyspace=$(SCYLLA_KEYSPACE) --replication-factor="1" $(if $(SCYLLA_USERNAME),--user=$(SCYLLA_USERNAME)) $(if $(SCYLLA_PASSWORD),--password=$(SCYLLA_PASSWORD)) + +.PHONY: watch +watch: ## Watches for changes in the source files and runs the project on each change. + @$(CARGO) watch -w src -x run + +.PHONY: dev +dev: ## Starts the development environment using Docker Compose. + @docker compose --file docker-compose.yml up + +.PHONY: help +help: ## Shows the help message with available commands. + @echo "Available commands:" + @grep -E '^[^[:space:]]+:[^:]*?## .*$$' $(MAKEFILE_LIST) | \ + awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-30s\033[0m %s\n", $$1, $$2}' diff --git a/current_schema.json b/current_schema.json deleted file mode 100644 index d3aa5f2..0000000 --- a/current_schema.json +++ /dev/null @@ -1,275 +0,0 @@ -{ - "tables": { - "settings": { - "fields": [ - [ - "locale", - "text", - false - ], - [ - "occupation", - "text", - false - ], - [ - "pronouns", - "text", - false - ], - [ - "timezone", - "text", - false - ], - [ - "updated_at", - "timestamp", - false - ], - [ - "user_id", - "int", - false - ], - [ - "username", - "text", - false - ] - ], - "field_names": [ - "locale", - "updated_at", - "username", - "occupation", - "timezone", - "pronouns", - "user_id" - ], - "types_by_name": { - "user_id": "int", - "updated_at": "timestamp", - "username": "text", - "pronouns": "text", - "locale": "text", - "timezone": "text", - "occupation": "text" - }, - "type_name": "", - "table_name": "", - "base_table": "", - "partition_keys": [ - "user_id" - ], - "clustering_keys": [ - "updated_at" - ], - "static_columns": [], - "global_secondary_indexes": [], - "local_secondary_indexes": [], - "table_options": null - }, - "messages": { - "fields": [ - [ - "channel_id", - "int", - false - ], - [ - "color", - "text", - false - ], - [ - "content", - "text", - false - ], - [ - "message_id", - "uuid", - false - ], - [ - "sent_at", - "timestamp", - false - ], - [ - "user_id", - "int", - false - ], - [ - "username", - "text", - false - ] - ], - "field_names": [ - "user_id", - "message_id", - "content", - "username", - "color", - "channel_id", - "sent_at" - ], - "types_by_name": { - "user_id": "int", - "username": "text", - "message_id": "uuid", - "channel_id": "int", - "color": "text", - "content": "text", - "sent_at": "timestamp" - }, - "type_name": "", - "table_name": "", - "base_table": "", - "partition_keys": [ - "channel_id" - ], - "clustering_keys": [ - "sent_at" - ], - "static_columns": [], - "global_secondary_indexes": [ - [ - "messages_username_idx", - "username" - ] - ], - "local_secondary_indexes": [], - "table_options": null - } - }, - "udts": {}, - "materialized_views": { - "settings_by_username": { - "fields": [ - [ - "locale", - "text", - false - ], - [ - "occupation", - "text", - false - ], - [ - "pronouns", - "text", - false - ], - [ - "timezone", - "text", - false - ], - [ - "updated_at", - "timestamp", - false - ], - [ - "user_id", - "int", - false - ], - [ - "username", - "text", - false - ] - ], - "field_names": [ - "locale", - "occupation", - "timezone", - "user_id", - "username", - "pronouns", - "updated_at" - ], - "types_by_name": { - "locale": "text", - "updated_at": "timestamp", - "occupation": "text", - "timezone": "text", - "user_id": "int", - "pronouns": "text", - "username": "text" - }, - "type_name": "", - "table_name": "", - "base_table": "", - "partition_keys": [ - "username" - ], - "clustering_keys": [ - "updated_at", - "user_id" - ], - "static_columns": [], - "global_secondary_indexes": [], - "local_secondary_indexes": [], - "table_options": null - }, - "messages_username_idx_index": { - "fields": [ - [ - "channel_id", - "int", - false - ], - [ - "idx_token", - "bigint", - false - ], - [ - "sent_at", - "timestamp", - false - ], - [ - "username", - "text", - false - ] - ], - "field_names": [ - "idx_token", - "sent_at", - "channel_id", - "username" - ], - "types_by_name": { - "channel_id": "int", - "username": "text", - "sent_at": "timestamp", - "idx_token": "bigint" - }, - "type_name": "", - "table_name": "", - "base_table": "", - "partition_keys": [ - "username" - ], - "clustering_keys": [ - "channel_id", - "idx_token", - "sent_at" - ], - "static_columns": [], - "global_secondary_indexes": [], - "local_secondary_indexes": [], - "table_options": null - } - }, - "keyspace_name": "twitch" -} \ No newline at end of file diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..989d11f --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,23 @@ +services: + tbp-server: + build: + context: . + dockerfile: .docker/Dockerfile.prod + image: tbp-consumer-api:${APP_VERSION} + hostname: prod + restart: on-failure + ports: + - "3001-3003:3000" + deploy: + replicas: 3 + resources: + limits: + cpus: '0.3' + memory: '300MB' + networks: + - tbp-consumer-api + stop_signal: SIGTERM +networks: + tbp-consumer-api: + name: tbp-consumer-api + driver: bridge diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d1edb55 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,22 @@ +services: + tbp-server: + build: + context: . + dockerfile: .docker/Dockerfile.dev + tty: true + stdin_open: true + image: tbp-consumer-api:dev + container_name: tbp-server + hostname: dev + ports: + - "3000:3000" + env_file: + - .env.docker + volumes: + - .:/usr/src/app:rw + networks: + - tbp-consumer-api +networks: + tbp-consumer-api: + name: tbp-consumer-api + driver: bridge diff --git a/src/config/app.rs b/src/config/app.rs index 7a3d9d0..a972512 100644 --- a/src/config/app.rs +++ b/src/config/app.rs @@ -1,5 +1,4 @@ use crate::config::Config; -use dotenvy::dotenv; use scylla::{CachingSession, Session, SessionBuilder}; use std::sync::Arc; use std::time::Duration; @@ -12,8 +11,6 @@ pub struct AppState { impl AppState { pub async fn new() -> Self { - dotenv().expect(".env file not found"); - let config = Config::new(); let session: Session = SessionBuilder::new() .known_nodes(config.database.nodes) diff --git a/src/config/mod.rs b/src/config/mod.rs index 6ed08cc..453b6d3 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,14 +1,17 @@ pub mod app; use serde::{Deserialize, Serialize}; -#[derive(Clone, Debug, Serialize, Deserialize)] +use std::thread; +#[derive(Clone, Debug, Serialize, Deserialize)] pub struct App { pub name: String, pub version: String, pub url: String, pub port: u16, + pub platform_secret: String, } + #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Database { pub nodes: Vec, @@ -25,11 +28,17 @@ pub struct Tls { pub key: String, } +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Http { + pub workers: usize, +} + #[derive(Clone, Debug, Serialize, Deserialize)] pub struct Config { pub app: App, pub tls: Tls, pub database: Database, + pub http: Http, } impl Config { @@ -40,12 +49,26 @@ impl Config { version: dotenvy::var("APP_VERSION").unwrap(), url: dotenvy::var("APP_URL").unwrap(), port: dotenvy::var("APP_PORT").unwrap().parse::().unwrap(), + platform_secret: dotenvy::var("APP_PLATFORM_SECRET") + .unwrap() + .parse::() + .unwrap(), }, tls: Tls { enabled: dotenvy::var("APP_TLS_ENABLED").unwrap() == "true", cert: dotenvy::var("APP_TLS_CERT").unwrap(), key: dotenvy::var("APP_TLS_KEY").unwrap(), }, + http: Http { + workers: dotenvy::var("MAX_WORKERS") + .ok() + .and_then(|s| s.parse::().ok()) + .unwrap_or_else(|| { + thread::available_parallelism() + .map(|n| n.get()) + .unwrap_or(1) + }), + }, database: Database { nodes: dotenvy::var("SCYLLA_NODES") .unwrap() diff --git a/src/http/messages_controller.rs b/src/http/messages_controller.rs deleted file mode 100644 index e5a043d..0000000 --- a/src/http/messages_controller.rs +++ /dev/null @@ -1,26 +0,0 @@ -use crate::config::app::AppState; -use crate::http::SomeError; -use crate::models::message::Message; -use actix_web::{post, web, HttpResponse, Responder}; -use charybdis::operations::Insert; -use log::debug; -use serde_json::json; - -#[post("/messages")] -pub async fn post_submission( - data: web::Data, - message: web::Json, -) -> Result { - let message = message.into_inner(); - message.insert().execute(&data.database).await?; - - debug!( - "[{:?}] ({}) {}: {}", - message.sent_at, - message.channel_id.to_string(), - message.username.to_string(), - message.content.to_string() - ); - - Ok(HttpResponse::Ok().json(json!(message))) -} diff --git a/src/http/mod.rs b/src/http/mod.rs index 379d1c9..e451f35 100644 --- a/src/http/mod.rs +++ b/src/http/mod.rs @@ -1,5 +1,5 @@ -pub mod messages_controller; -pub mod settings_controller; +pub mod v0; +pub mod v1; use actix_web::{get, HttpResponse, ResponseError}; use charybdis::errors::CharybdisError; diff --git a/src/http/v0/mod.rs b/src/http/v0/mod.rs new file mode 100644 index 0000000..18c9e3a --- /dev/null +++ b/src/http/v0/mod.rs @@ -0,0 +1 @@ +pub mod settings_controller; diff --git a/src/http/settings_controller.rs b/src/http/v0/settings_controller.rs similarity index 93% rename from src/http/settings_controller.rs rename to src/http/v0/settings_controller.rs index 78a94ec..60d7731 100644 --- a/src/http/settings_controller.rs +++ b/src/http/v0/settings_controller.rs @@ -6,8 +6,8 @@ use web::Json; use crate::config::app::AppState; use crate::http::SomeError; -use crate::models::materialized_views::settings_by_username::SettingsByUsername; -use crate::models::settings::Settings; +use crate::models::v0::settings::Settings; +use crate::models::v0::settings_by_username::SettingsByUsername; static AVAILABLE_PRONOUNS: &[&str] = &[ "n/d", diff --git a/src/http/v1/auth_controller.rs b/src/http/v1/auth_controller.rs new file mode 100644 index 0000000..23d6641 --- /dev/null +++ b/src/http/v1/auth_controller.rs @@ -0,0 +1,34 @@ +use crate::config::app::AppState; +use crate::http::v1::validate_token; +use crate::http::SomeError; +use crate::models::v1::users::UserToken; +use actix_web::{post, web, HttpResponse, Responder}; +use charybdis::operations::Insert; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +#[derive(Deserialize, Serialize, Debug)] +pub struct AuthenticateDTO { + pub user_id: i32, + pub token: String, +} +#[post("/api/v1/authenticate")] +pub async fn post_user_authentication( + data: web::Data, + payload: web::Json, + req: actix_web::HttpRequest, +) -> Result { + if let Some(value) = validate_token(req, data.config.app.platform_secret.clone()) { + return Ok(HttpResponse::Unauthorized().json(json!({ + "error": "Unauthorized", + "message": value + }))); + } + + UserToken::new(payload.into_inner()) + .insert() + .execute(&data.database) + .await?; + + Ok(HttpResponse::Created().finish()) +} diff --git a/src/http/v1/metrics_controller.rs b/src/http/v1/metrics_controller.rs new file mode 100644 index 0000000..6d894df --- /dev/null +++ b/src/http/v1/metrics_controller.rs @@ -0,0 +1,210 @@ +use crate::config::app::AppState; +use crate::http::v1::is_authenticated; +use crate::http::SomeError; +use crate::models::v1::metrics::{ + delete_user_most_watched_category_leaderboard, delete_user_most_watched_channels_leaderboard, + UserMetrics, UserMetricsByCategory, UserMetricsByStream, UserMostWatchedCategoryLeaderboard, + UserMostWatchedChannelsLeaderboard, +}; +use crate::models::v1::throttle::Throttle; +use actix_web::{get, post, web, HttpResponse, Responder}; +use charybdis::operations::{Find, Insert}; +use charybdis::types::Text; +use chrono::Utc; +use scylla::statement::Consistency; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +#[derive(Deserialize, Serialize, Debug)] +struct UserMetricsDTO { + pub channel_id: Text, + pub category_id: Text, +} + +#[get("/api/v1/metrics/by-user")] +pub async fn get_user_metrics( + data: web::Data, + req: actix_web::HttpRequest, +) -> Result { + let authenticated_user = is_authenticated(&data.database, req).await; + + if authenticated_user.is_none() { + return Ok(HttpResponse::Unauthorized().finish()); + } + + let user_id = authenticated_user.unwrap().user_id.unwrap(); + let main_metrics = UserMetrics { + user_id, + ..Default::default() + } + .maybe_find_by_primary_key() + .execute(&data.database) + .await?; + + if main_metrics.is_none() { + return Ok(HttpResponse::Ok().json(json!({ + "main_metrics": [], + "user_metrics_by_channel": [], + "user_metrics_by_category": [], + }))); + } + + let user_metrics_by_channel = UserMostWatchedChannelsLeaderboard { + user_id, + ..Default::default() + }; + let user_metrics_by_category = UserMostWatchedCategoryLeaderboard { + user_id, + ..Default::default() + }; + + let user_metrics_by_channel = user_metrics_by_channel + .find_by_partition_key() + .consistency(Consistency::LocalOne) + .page_size(5) + .execute(&data.database) + .await? + .try_collect() + .await + .unwrap(); + + let user_metrics_by_category = user_metrics_by_category + .find_by_partition_key() + .consistency(Consistency::LocalOne) + .execute(&data.database) + .await? + .try_collect() + .await + .unwrap(); + + Ok(HttpResponse::Ok().json(json!({ + "main_metrics": main_metrics, + "user_metrics_by_channel": user_metrics_by_channel, + "user_metrics_by_category": user_metrics_by_category, + }))) +} + +#[post("/api/v1/metrics/heartbeat")] +pub async fn post_heartbeat( + data: web::Data, + payload: web::Json, + req: actix_web::HttpRequest, +) -> Result { + let payload = payload.into_inner(); + let is_authenticated = is_authenticated(&data.database, req).await; + + if is_authenticated.is_none() { + return Ok(HttpResponse::Unauthorized().finish()); + } + let user_id = is_authenticated.unwrap().user_id.unwrap(); + + let throttle = Throttle { + uri: "/api/v1/metrics/heartbeat".to_string(), + user_id, + content: format!("channel={}", payload.channel_id.clone()), + updated_at: Utc::now(), + }; + + let throttle_verification = throttle + .find_by_partition_key() + .consistency(Consistency::LocalOne) + .execute(&data.database) + .await? + .try_collect() + .await?; + + if !throttle_verification.is_empty() { + return Ok(HttpResponse::TooManyRequests().finish()); + } + + throttle.insert_throttle(&data.database, 60).await.unwrap(); + + let main_metrics = UserMetrics { + user_id, + ..Default::default() + }; + let user_metrics_by_channel = UserMetricsByStream { + user_id, + channel_id: payload.channel_id.clone(), + ..Default::default() + }; + let user_metrics_by_category = UserMetricsByCategory { + user_id, + category_id: payload.category_id.clone(), + ..Default::default() + }; + + main_metrics + .increment_minutes_watched(1) + .consistency(Consistency::LocalOne) + .execute(&data.database) + .await?; + user_metrics_by_channel + .increment_minutes_watched(1) + .consistency(Consistency::LocalOne) + .execute(&data.database) + .await?; + user_metrics_by_category + .increment_minutes_watched(1) + .consistency(Consistency::LocalOne) + .execute(&data.database) + .await?; + + let user_metrics_by_category = user_metrics_by_category + .find_by_primary_key() + .consistency(Consistency::LocalOne) + .execute(&data.database) + .await?; + let user_metrics_by_channel = user_metrics_by_channel + .find_by_primary_key() + .consistency(Consistency::LocalOne) + .execute(&data.database) + .await?; + + let current_minutes_by_category = user_metrics_by_category.minutes_watched.unwrap().0 as i32; + let current_minutes_by_channel = user_metrics_by_channel.minutes_watched.unwrap().0 as i32; + + let user_category_leaderboard = UserMostWatchedCategoryLeaderboard { + user_id, + minutes_watched: current_minutes_by_category, + category_id: payload.category_id.clone(), + }; + + let user_channels_leaderboard = UserMostWatchedChannelsLeaderboard { + user_id, + minutes_watched: current_minutes_by_channel, + channel_id: payload.channel_id.clone(), + }; + + delete_user_most_watched_category_leaderboard!( + "user_id = ? AND category_id = ? AND minutes_watched = ?", + ( + user_id, + payload.category_id.clone(), + current_minutes_by_category - 1 + ) + ) + .execute(&data.database) + .await?; + + delete_user_most_watched_channels_leaderboard!( + "user_id = ? AND channel_id = ? AND minutes_watched = ?", + (user_id, payload.channel_id, current_minutes_by_channel - 1) + ) + .consistency(Consistency::LocalOne) + .execute(&data.database) + .await?; + + user_category_leaderboard + .insert() + .consistency(Consistency::LocalOne) + .execute(&data.database) + .await?; + user_channels_leaderboard + .insert() + .consistency(Consistency::LocalOne) + .execute(&data.database) + .await?; + + Ok(HttpResponse::Ok().finish()) +} diff --git a/src/http/v1/mod.rs b/src/http/v1/mod.rs new file mode 100644 index 0000000..6a4a419 --- /dev/null +++ b/src/http/v1/mod.rs @@ -0,0 +1,52 @@ +use std::sync::Arc; + +use actix_web::HttpRequest; +use charybdis::operations::Find; +use scylla::CachingSession; + +use crate::models::v1::users::UserToken; + +pub mod auth_controller; +pub mod metrics_controller; +pub mod settings_controller; + +pub fn validate_token(req: HttpRequest, platform_secret: String) -> Option { + let authorization_token = req.headers().get("X-Authorization"); + + if authorization_token.is_none() { + return Some(String::from( + "You must be logged in to update your settings", + )); + } + + let authorization_token = authorization_token.unwrap().to_str().unwrap(); + if authorization_token != platform_secret { + return Some(String::from("Invalid token")); + } + None +} + +pub async fn is_authenticated( + session: &Arc, + req: HttpRequest, +) -> Option { + let header = req.headers().get("Authorization"); + + let header = header?.to_str(); + + if header.is_err() { + return None; + } + + let response = UserToken { + access_token: header.unwrap().to_string(), + ..Default::default() + } + .maybe_find_by_primary_key() + .execute(session) + .await + .unwrap(); + + response.as_ref()?; + Some(response.unwrap()) +} diff --git a/src/http/v1/settings_controller.rs b/src/http/v1/settings_controller.rs new file mode 100644 index 0000000..49cc660 --- /dev/null +++ b/src/http/v1/settings_controller.rs @@ -0,0 +1,107 @@ +use actix_web::{get, put, web, HttpResponse, Responder}; +use charybdis::operations::{Find, Insert}; +use charybdis::options::Consistency; +use log::info; +use serde::Deserialize; +use serde_json::json; +use web::Json; + +use crate::config::app::AppState; +use crate::http::SomeError; +use crate::models::v1::settings::{Settings, SettingsByUsername}; + +#[put("/api/v1/settings")] +pub async fn put_settings( + data: web::Data, + message: Json, + req: actix_web::HttpRequest, +) -> anyhow::Result { + let authorization_token = req.headers().get("X-Authorization"); + + if authorization_token.is_none() { + return Ok(HttpResponse::Unauthorized().json(json!({ + "error": "Unauthorized", + "message": "You must be logged in to update your settings" + }))); + } + + let authorization_token = authorization_token.unwrap().to_str().unwrap(); + if authorization_token != data.config.app.platform_secret { + return Ok(HttpResponse::Unauthorized().json(json!({ + "error": "Unauthorized", + "message": "Invalid token" + }))); + } + + let settings = message.into_inner(); + info!( + "[PUT Settings] -> Channel/User -> {} / {}", + settings.channel_id, settings.username + ); + + settings.insert().execute(&data.database).await?; + + Ok(HttpResponse::Ok().json(json!(settings))) +} + +#[derive(Deserialize)] +struct SettingsQuery { + channel_id: Option, +} + +#[get("/api/v1/settings/{username}")] +pub async fn get_settings( + data: web::Data, + username: web::Path, + channel_id: web::Query, +) -> Result { + let username = username.into_inner(); + let channel_id = channel_id + .into_inner() + .channel_id + .unwrap_or("global".to_string()); + + info!( + "[GET Settings] -> Channel/User -> {} / {}", + channel_id, username + ); + + let mut settings = SettingsByUsername { + username: username.clone(), + enabled: true, + channel_id, + ..Default::default() + }; + + // Query the user settings with the given username and channel_id + let settings_model = settings + .find_by_partition_key() + .consistency(Consistency::LocalOne) + .execute(&data.database) + .await? + .try_collect() + .await + .unwrap(); + + if !settings_model.is_empty() { + return Ok(HttpResponse::Ok().json(json!(settings_model.first()))); + } + + settings.channel_id = "global".to_string(); + + let settings_model = settings + .find_by_partition_key() + .consistency(Consistency::LocalOne) + .execute(&data.database) + .await? + .try_collect() + .await + .unwrap(); + + let result = match settings_model.is_empty() { + true => HttpResponse::NotFound().finish(), + false => HttpResponse::Ok().json(json!(settings_model.first())), + }; + + Ok(result) +} diff --git a/src/main.rs b/src/main.rs index c656af4..98c935a 100644 --- a/src/main.rs +++ b/src/main.rs @@ -4,9 +4,12 @@ use std::io::BufReader; use actix_cors::Cors; use actix_web::web::Data; use actix_web::{App, HttpServer}; +use dotenvy::dotenv; use log::debug; +use self::http::v1; use crate::config::app::AppState; +use crate::http::v0; mod config; mod http; @@ -14,38 +17,46 @@ mod models; #[actix_web::main] async fn main() -> std::io::Result<()> { - dotenvy::dotenv().expect(".env file not found"); + dotenv().ok(); colog::init(); let app_data = AppState::new().await; let addr = (app_data.config.app.url.clone(), app_data.config.app.port); let tls_enabled = app_data.config.tls.enabled; + let max_workers = app_data.config.http.workers; + debug!("Web Server Online!"); - // TLS setup. - rustls::crypto::aws_lc_rs::default_provider() - .install_default() - .unwrap(); + let tls_config = if tls_enabled { + // TLS setup. + rustls::crypto::aws_lc_rs::default_provider() + .install_default() + .unwrap(); - let mut certs_file = BufReader::new(File::open(app_data.config.tls.cert.clone()).unwrap()); - let mut key_file = BufReader::new(File::open(app_data.config.tls.key.clone()).unwrap()); + let mut certs_file = BufReader::new(File::open(app_data.config.tls.cert.clone()).unwrap()); + let mut key_file = BufReader::new(File::open(app_data.config.tls.key.clone()).unwrap()); - // load TLS certs and key - // to create a self-signed temporary cert for testing: - // `openssl req -x509 -newkey rsa:4096 -nodes -keyout key.pem -out cert.pem -days 365 -subj '/CN=localhost'` - let tls_certs = rustls_pemfile::certs(&mut certs_file) - .collect::, _>>() - .unwrap(); - let tls_key = rustls_pemfile::pkcs8_private_keys(&mut key_file) - .next() - .unwrap() - .unwrap(); + // load TLS certs and key + // to create a self-signed temporary cert for testing: + // `openssl req -x509 -newkey rsa:4096 -nodes -keyout key.pem -out cert.pem -days 365 -subj '/CN=localhost'` + let tls_certs = rustls_pemfile::certs(&mut certs_file) + .collect::, _>>() + .unwrap(); + let tls_key = rustls_pemfile::pkcs8_private_keys(&mut key_file) + .next() + .unwrap() + .unwrap(); - // set up TLS config options - let tls_config = rustls::ServerConfig::builder() - .with_no_client_auth() - .with_single_cert(tls_certs, rustls::pki_types::PrivateKeyDer::Pkcs8(tls_key)) - .unwrap(); + // set up TLS config options + Some( + rustls::ServerConfig::builder() + .with_no_client_auth() + .with_single_cert(tls_certs, rustls::pki_types::PrivateKeyDer::Pkcs8(tls_key)) + .unwrap(), + ) + } else { + None + }; let server = HttpServer::new(move || { let cors = Cors::default() @@ -59,13 +70,23 @@ async fn main() -> std::io::Result<()> { .app_data(Data::new(app_data.clone())) .service(actix_files::Files::new("/static", "./static").use_last_modified(true)) .service(http::welcome) - .service(http::messages_controller::post_submission) - .service(http::settings_controller::put_settings) - .service(http::settings_controller::get_settings) - }); + .service(v0::settings_controller::put_settings) + .service(v0::settings_controller::get_settings) + .service(v1::settings_controller::put_settings) + .service(v1::settings_controller::get_settings) + .service(v1::metrics_controller::post_heartbeat) + .service(v1::metrics_controller::get_user_metrics) + .service(v1::auth_controller::post_user_authentication) + }) + .workers(max_workers); match tls_enabled { - true => server.bind_rustls_0_23(addr, tls_config)?.run().await, + true => { + server + .bind_rustls_0_23(addr, tls_config.unwrap())? + .run() + .await + } false => server.bind(addr)?.run().await, } } diff --git a/src/models/message.rs b/src/models/message.rs deleted file mode 100644 index 50f78b0..0000000 --- a/src/models/message.rs +++ /dev/null @@ -1,66 +0,0 @@ -use charybdis::macros::charybdis_model; -use charybdis::types::{Int, Text, Timestamp, Uuid}; -use chrono::{DateTime, TimeZone, Utc}; -use serde::de::Visitor; -use serde::{de, Deserialize, Deserializer, Serialize}; -use std::fmt; - -#[charybdis_model( - table_name = messages, - partition_keys = [channel_id], - clustering_keys = [sent_at], - global_secondary_indexes = [username], - local_secondary_indexes = [], - static_columns = [] -)] -#[derive(Debug, Serialize, Deserialize)] -pub struct Message { - pub channel_id: Int, - pub user_id: Int, - pub username: Text, - pub message_id: Uuid, - pub content: Text, - pub color: Text, - #[serde(deserialize_with = "deserialize_epoch")] - pub sent_at: Timestamp, -} - -// Custom deserializer function -fn deserialize_epoch<'de, D>(deserializer: D) -> Result, D::Error> -where - D: Deserializer<'de>, -{ - struct EpochVisitor; - - impl<'de> Visitor<'de> for EpochVisitor { - type Value = DateTime; - - fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { - formatter.write_str("an integer timestamp") - } - - fn visit_i64(self, value: i64) -> Result, E> - where - E: de::Error, - { - let value = value / 1000; - Utc - .timestamp_opt(value, 0) - .single() - .ok_or_else(|| de::Error::custom("invalid timestamp")) - } - - fn visit_u64(self, value: u64) -> Result, E> - where - E: de::Error, - { - let value = value / 1000; - Utc - .timestamp_opt(value as i64, 0) - .single() - .ok_or_else(|| de::Error::custom("invalid timestamp")) - } - } - - deserializer.deserialize_any(EpochVisitor) -} diff --git a/src/models/mod.rs b/src/models/mod.rs index 42faafb..621eac8 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1,3 +1,2 @@ -pub mod materialized_views; -pub mod message; -pub mod settings; +pub mod v0; +pub mod v1; diff --git a/src/models/materialized_views/mod.rs b/src/models/v0/mod.rs similarity index 62% rename from src/models/materialized_views/mod.rs rename to src/models/v0/mod.rs index ab2718b..849a75c 100644 --- a/src/models/materialized_views/mod.rs +++ b/src/models/v0/mod.rs @@ -1 +1,2 @@ +pub mod settings; pub mod settings_by_username; diff --git a/src/models/settings.rs b/src/models/v0/settings.rs similarity index 100% rename from src/models/settings.rs rename to src/models/v0/settings.rs diff --git a/src/models/materialized_views/settings_by_username.rs b/src/models/v0/settings_by_username.rs similarity index 78% rename from src/models/materialized_views/settings_by_username.rs rename to src/models/v0/settings_by_username.rs index 88f6617..c6bd041 100644 --- a/src/models/materialized_views/settings_by_username.rs +++ b/src/models/v0/settings_by_username.rs @@ -3,10 +3,10 @@ use charybdis::types::{Int, Text, Timestamp}; use serde::{Deserialize, Serialize}; #[charybdis_view_model( - table_name=settings_by_username, - base_table=settings, - partition_keys=[username], - clustering_keys=[user_id, updated_at], + table_name = settings_by_username, + base_table = settings, + partition_keys = [username], + clustering_keys = [user_id, updated_at], table_options = " CLUSTERING ORDER BY (updated_at DESC, user_id DESC) " diff --git a/src/models/v1/metrics.rs b/src/models/v1/metrics.rs new file mode 100644 index 0000000..0309c05 --- /dev/null +++ b/src/models/v1/metrics.rs @@ -0,0 +1,86 @@ +use charybdis::macros::charybdis_model; +use charybdis::types::{Counter, Int, Text}; +use serde::{Deserialize, Serialize}; + +#[charybdis_model( + table_name = user_metrics_v1, + partition_keys = [user_id], + clustering_keys = [], + global_secondary_indexes = [], + local_secondary_indexes = [], + static_columns = [], +)] +#[derive(Debug, Serialize, Deserialize, Default, Clone)] +pub struct UserMetrics { + pub user_id: Int, + pub minutes_watched: Option, + pub messages_count: Option, +} + +#[charybdis_model( + table_name = user_metrics_by_channel_v1, + partition_keys = [user_id, channel_id], + clustering_keys = [], + global_secondary_indexes = [], + local_secondary_indexes = [], + static_columns = [], +)] +#[derive(Debug, Serialize, Deserialize, Default, Clone)] +pub struct UserMetricsByStream { + pub user_id: Int, + pub channel_id: Text, + pub minutes_watched: Option, + pub messages_count: Option, +} + +#[charybdis_model( + table_name = user_metrics_by_category_v1, + partition_keys = [user_id, category_id], + clustering_keys = [], + global_secondary_indexes = [], + local_secondary_indexes = [], + static_columns = [], +)] +#[derive(Debug, Serialize, Deserialize, Default, Clone)] +pub struct UserMetricsByCategory { + pub user_id: Int, + pub category_id: Text, + pub minutes_watched: Option, + pub messages_count: Option, +} + +#[charybdis_model( + table_name = user_most_watched_category_leaderboard_v1, + partition_keys = [user_id], + clustering_keys = [minutes_watched, category_id], + global_secondary_indexes = [], + local_secondary_indexes = [], + static_columns = [], + table_options = " + CLUSTERING ORDER BY (minutes_watched DESC, category_id ASC) + " +)] +#[derive(Debug, Serialize, Deserialize, Default, Clone)] +pub struct UserMostWatchedCategoryLeaderboard { + pub user_id: Int, + pub category_id: Text, + pub minutes_watched: Int, +} + +#[charybdis_model( + table_name = user_most_watched_channels_leaderboard_v1, + partition_keys = [user_id], + clustering_keys = [minutes_watched, channel_id], + global_secondary_indexes = [], + local_secondary_indexes = [], + static_columns = [], + table_options = " + CLUSTERING ORDER BY (minutes_watched DESC, channel_id ASC) + " +)] +#[derive(Debug, Serialize, Deserialize, Default, Clone)] +pub struct UserMostWatchedChannelsLeaderboard { + pub user_id: Int, + pub channel_id: Text, + pub minutes_watched: Int, +} diff --git a/src/models/v1/mod.rs b/src/models/v1/mod.rs new file mode 100644 index 0000000..0307c38 --- /dev/null +++ b/src/models/v1/mod.rs @@ -0,0 +1,4 @@ +pub mod metrics; +pub mod settings; +pub mod throttle; +pub mod users; diff --git a/src/models/v1/settings.rs b/src/models/v1/settings.rs new file mode 100644 index 0000000..8f87c39 --- /dev/null +++ b/src/models/v1/settings.rs @@ -0,0 +1,82 @@ +use charybdis::macros::{charybdis_model, charybdis_udt_model, charybdis_view_model}; +use charybdis::types::{Boolean, Frozen, Int, Text, Timestamp}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[charybdis_udt_model(type_name = settingoptions)] +#[derive(Debug, Default, Clone, Deserialize, Serialize)] +pub struct SettingOptions { + pub name: Text, + pub slug: Text, + pub translation_key: Text, +} + +#[charybdis_udt_model(type_name = coloroption)] +#[derive(Debug, Default, Clone, Deserialize, Serialize)] +pub struct ColorOption { + pub name: Text, + pub slug: Text, + pub translation_key: Text, + pub hex: Option, +} + +#[charybdis_udt_model(type_name = effectoption)] +#[derive(Debug, Default, Clone, Deserialize, Serialize)] +pub struct EffectOption { + pub name: Text, + pub slug: Text, + pub translation_key: Text, + pub class_name: Text, + pub hex: Option, +} + +#[charybdis_model( + table_name = settings_v1, + partition_keys = [user_id, channel_id], + clustering_keys = [username], + global_secondary_indexes = [], + local_secondary_indexes = [], + static_columns = [], +)] +#[derive(Debug, Serialize, Deserialize, Default, Clone)] +pub struct Settings { + pub user_id: Int, + pub username: Text, + pub channel_id: Text, + pub enabled: Boolean, + pub locale: Option, + pub timezone: Option, + pub occupation: Option>, + pub pronouns: Option>, + pub color: Option>, + pub effect: Option>, + pub is_developer: Option, + #[serde(default = "default_updated_at")] + pub updated_at: Timestamp, +} + +#[charybdis_view_model( + table_name = settings_by_username_v1, + base_table = settings_v1, + partition_keys = [username, enabled, channel_id], + clustering_keys = [user_id], +)] +#[derive(Debug, Serialize, Deserialize, Default, Clone)] +pub struct SettingsByUsername { + pub user_id: Int, + pub username: Text, + pub channel_id: Text, + pub enabled: Boolean, + pub locale: Option, + pub timezone: Option, + pub occupation: Option>, + pub pronouns: Option>, + pub color: Option>, + pub effect: Option>, + pub is_developer: Option, + pub updated_at: Option, +} + +pub fn default_updated_at() -> DateTime { + Utc::now() +} diff --git a/src/models/v1/throttle.rs b/src/models/v1/throttle.rs new file mode 100644 index 0000000..1ddea29 --- /dev/null +++ b/src/models/v1/throttle.rs @@ -0,0 +1,46 @@ +use charybdis::macros::charybdis_model; +use charybdis::types::{Int, Text, Timestamp}; +use scylla::query::Query; +use scylla::CachingSession; +use serde::{Deserialize, Serialize}; + +static INSERT_THROTTLE_WITH_TTL: &str = + "INSERT INTO throttle_v1 (uri, user_id, content, updated_at) VALUES (?, ?, ?, ?) USING TTL ?"; + +#[charybdis_model( + table_name = throttle_v1, + partition_keys = [uri,user_id,content], + clustering_keys = [updated_at], + global_secondary_indexes = [], + local_secondary_indexes = [], + static_columns = [], +)] +#[derive(Debug, Serialize, Deserialize, Default, Clone)] +pub struct Throttle { + pub uri: Text, + pub user_id: Int, + pub content: Text, + pub updated_at: Timestamp, +} + +impl Throttle { + pub async fn insert_throttle(&self, connection: &CachingSession, ttl: i32) -> anyhow::Result<()> { + let query = Query::new(INSERT_THROTTLE_WITH_TTL); + connection.add_prepared_statement(&query).await?; + + connection + .execute( + query, + ( + &self.uri, + &self.user_id, + &self.content, + &self.updated_at, + ttl, + ), + ) + .await?; + + Ok(()) + } +} diff --git a/src/models/v1/users.rs b/src/models/v1/users.rs new file mode 100644 index 0000000..694d417 --- /dev/null +++ b/src/models/v1/users.rs @@ -0,0 +1,27 @@ +use crate::http::v1::auth_controller::AuthenticateDTO; +use charybdis::macros::charybdis_model; +use charybdis::types::{Int, Text}; +use serde::{Deserialize, Serialize}; + +#[charybdis_model( + table_name = user_tokens_v1, + partition_keys = [access_token], + clustering_keys = [], + table_options = r" + default_time_to_live = 604800 + " +)] +#[derive(Debug, Serialize, Deserialize, Default, Clone)] +pub struct UserToken { + pub access_token: Text, + pub user_id: Option, +} + +impl UserToken { + pub fn new(dto: AuthenticateDTO) -> Self { + Self { + access_token: dto.token, + user_id: Some(dto.user_id), + } + } +} diff --git a/static/icons/backend-engineer.png b/static/icons/backend-engineer.png new file mode 100644 index 0000000..bab8e12 Binary files /dev/null and b/static/icons/backend-engineer.png differ diff --git a/static/icons/civil-engineer.png b/static/icons/civil-engineer.png new file mode 100644 index 0000000..1c9fcd7 Binary files /dev/null and b/static/icons/civil-engineer.png differ diff --git a/static/icons/doctor.png b/static/icons/doctor.png new file mode 100644 index 0000000..57dbf4d Binary files /dev/null and b/static/icons/doctor.png differ diff --git a/static/icons/frontend-developer.png b/static/icons/frontend-developer.png new file mode 100644 index 0000000..2523ec4 Binary files /dev/null and b/static/icons/frontend-developer.png differ diff --git a/static/icons/fullstack-developer.png b/static/icons/fullstack-developer.png new file mode 100644 index 0000000..e337bde Binary files /dev/null and b/static/icons/fullstack-developer.png differ diff --git a/static/icons/lawyer.png b/static/icons/lawyer.png new file mode 100644 index 0000000..ee03684 Binary files /dev/null and b/static/icons/lawyer.png differ diff --git a/static/icons/mod.png b/static/icons/mod.png index 87c97cf..8b229a5 100644 Binary files a/static/icons/mod.png and b/static/icons/mod.png differ diff --git a/static/icons/mod1.png b/static/icons/mod1.png deleted file mode 100644 index 44cbb5a..0000000 Binary files a/static/icons/mod1.png and /dev/null differ diff --git a/static/icons/mod2.png b/static/icons/mod2.png deleted file mode 100644 index b52732e..0000000 Binary files a/static/icons/mod2.png and /dev/null differ diff --git a/static/icons/none.png b/static/icons/none.png new file mode 100644 index 0000000..8b229a5 Binary files /dev/null and b/static/icons/none.png differ diff --git a/static/icons/sre-engineer.png b/static/icons/sre-engineer.png new file mode 100644 index 0000000..89ea66a Binary files /dev/null and b/static/icons/sre-engineer.png differ diff --git a/static/icons/student.png b/static/icons/student.png new file mode 100644 index 0000000..b1089e2 Binary files /dev/null and b/static/icons/student.png differ diff --git a/static/icons/uxui-designer.png b/static/icons/uxui-designer.png new file mode 100644 index 0000000..46abe08 Binary files /dev/null and b/static/icons/uxui-designer.png differ diff --git a/tests/http/settings_controller_test.rs b/tests/http/settings_controller_test.rs index 3133c40..c1af363 100644 --- a/tests/http/settings_controller_test.rs +++ b/tests/http/settings_controller_test.rs @@ -6,8 +6,9 @@ mod tests { use actix_web::App; use charybdis::operations::{Delete, Insert}; use twitch_extension_api::config::app::AppState; - use twitch_extension_api::http::settings_controller::{get_settings, put_settings}; - use twitch_extension_api::models::settings::Settings; + use twitch_extension_api::http::v1::settings_controller::{get_settings, put_settings}; + use twitch_extension_api::models::v1::settings::{SettingOptions, Settings}; + use twitch_extension_api::models::v1::settings_by_username::SettingsByUsername; #[actix_web::test] async fn test_get_settings() { @@ -23,14 +24,14 @@ mod tests { let settings = Settings { user_id: 123, - username: Some("danielhe4rt".to_string()), + username: "danielhe4rt".to_string(), ..Default::default() }; settings.insert().execute(&database).await.unwrap(); // Act - let uri = format!("/settings/{}", settings.username.clone().unwrap()); + let uri = format!("/api/v1/settings/{}", settings.username.clone()); let req = server.get(uri); let mut res = req.send().await.unwrap(); let parsed_response: Settings = res.json().await.unwrap(); @@ -47,6 +48,7 @@ mod tests { async fn test_put_settings() { // Arrange let app_data = AppState::new().await; + let secret = app_data.config.app.platform_secret.clone(); let database = Arc::clone(&app_data.database); let server = actix_test::start(move || { @@ -57,62 +59,35 @@ mod tests { let mut settings = Settings { user_id: 123, - username: Some("danielhe4rt".to_string()), - pronouns: Some("she/her".to_string()), + username: "danielhe4rt".to_string(), + pronouns: SettingOptions { + slug: "she-her".to_string(), + name: "He/Him".to_string(), + translation_key: "HeHim".to_string(), + }, ..Default::default() }; settings.insert().execute(&database).await.unwrap(); - settings.pronouns = Some("he/him".to_string()); + settings.pronouns = SettingOptions { + slug: "he-him".to_string(), + name: "He/Him".to_string(), + translation_key: "HeHim".to_string(), + }; // Act - let uri = "/settings".to_string(); - let req = server.put(uri); + let uri = "/api/v1/settings".to_string(); + let req = server.put(uri).insert_header(("X-Authorization", secret)); let mut res = req.send_json(&settings).await.unwrap(); - let parsed_response: Settings = res.json().await.unwrap(); + let parsed_response: SettingsByUsername = res.json().await.unwrap(); // Assert assert_eq!(res.status().as_u16(), 200); assert_eq!(parsed_response.username, settings.username); - assert_eq!(parsed_response.pronouns, settings.pronouns); - - settings.delete().execute(&database).await.unwrap(); - } - - #[actix_web::test] - async fn test_should_put_settings_in_right_format() { - // Arrange - let app_data = AppState::new().await; - let database = Arc::clone(&app_data.database); - - let server = actix_test::start(move || { - App::new() - .app_data(Data::new(app_data.clone())) - .service(put_settings) - }); - - let mut settings = Settings { - user_id: 123, - username: Some("danielhe4rt".to_string()), - pronouns: Some("she/her".to_string()), - ..Default::default() - }; - settings.insert().execute(&database).await.unwrap(); - - settings.pronouns = Some("he/hims".to_string()); - - // Act - let uri = "/settings".to_string(); - let req = server.put(uri); - - let res = req.send_json(&settings).await.unwrap(); - - // Assert - - assert_eq!(res.status().as_u16(), 422); + assert_eq!(parsed_response.pronouns.slug, settings.pronouns.slug); settings.delete().execute(&database).await.unwrap(); }