diff --git a/.devcontainer/Dockerfile.dev b/.devcontainer/Dockerfile.dev
new file mode 100644
index 000000000..1bc7183e3
--- /dev/null
+++ b/.devcontainer/Dockerfile.dev
@@ -0,0 +1 @@
+FROM ghcr.io/universityradioyork/myradio/dev-base:latest
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
new file mode 100644
index 000000000..d715f5cdc
--- /dev/null
+++ b/.devcontainer/devcontainer.json
@@ -0,0 +1,59 @@
+// For format details, see https://aka.ms/devcontainer.json. For config options, see the README at:
+// https://github.com/microsoft/vscode-dev-containers/tree/v0.203.0/containers/docker-existing-docker-compose
+// If you want to run as a non-root user in the container, see .devcontainer/docker-compose.yml.
+{
+ "name": "MyRadio + Postgres, Memcached, Mailhog",
+
+ // Update the 'dockerComposeFile' list if you have more compose files or use different names.
+ // The .devcontainer/docker-compose.yml file contains any overrides you need/want to make.
+ "dockerComposeFile": [
+ "../docker-compose.yml",
+ "docker-compose.yml"
+ ],
+
+ "service": "myradio",
+
+ // The optional 'workspaceFolder' property is the path VS Code should open by default when
+ // connected. This is typically a file mount in .devcontainer/docker-compose.yml
+ "workspaceFolder": "/var/www/myradio",
+
+ // Set *default* container specific settings.json values on container create.
+ "settings": {
+ "sqltools.connections": [{
+ "name": "Container database",
+ "driver": "PostgreSQL",
+ "server": "postgres",
+ "previewLimit": 50,
+ "port": 5432,
+ "database": "myradio",
+ "username": "myradio",
+ "password": "myradio"
+ }]
+ },
+
+ // Add the IDs of extensions you want installed when the container is created.
+ "extensions": [
+ "bmewburn.vscode-intelephense-client",
+ "felixfbecker.php-debug",
+ "mtxr.sqltools",
+ "mtxr.sqltools-driver-pg",
+ "whatwedo.twig"
+ ],
+
+ // Use 'forwardPorts' to make a list of ports inside the container available locally.
+ "forwardPorts": [7080, 8025],
+
+ // "initializeCommand": ""
+
+ // Uncomment the next line if you want start specific services in your Docker Compose config.
+ // "runServices": [],
+
+ // Uncomment the next line if you want to keep your containers running after VS Code shuts down.
+ // "shutdownAction": "none",
+
+ // Uncomment the next line to run commands after the container is created - for example installing curl.
+ "postCreateCommand": "COMPOSER_VENDOR_DIR=/workspaces/MyRadio/src/vendor composer install && apachectl restart",
+
+ // Uncomment to connect as a non-root user if you've added one. See https://aka.ms/vscode-remote/containers/non-root.
+ // "remoteUser": "vscode"
+}
diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml
new file mode 100644
index 000000000..dd03ef687
--- /dev/null
+++ b/.devcontainer/docker-compose.yml
@@ -0,0 +1,25 @@
+version: '3'
+services:
+ # Update this to the name of the service you want to work with in your docker-compose.yml file
+ myradio:
+ # Use a pre-built base image to speed up startup
+ build:
+ context: .
+ dockerfile: .devcontainer/Dockerfile.dev
+
+ # If you want add a non-root user to your Dockerfile, you can use the "remoteUser"
+ # property in devcontainer.json to cause VS Code its sub-processes (terminals, tasks,
+ # debugging) to execute as the user. Uncomment the next line if you want the entire
+ # container to run as this user instead. Note that, on Linux, you may need to
+ # ensure the UID and GID of the container user you create matches your local user.
+ # See https://aka.ms/vscode-remote/containers/non-root for details.
+ #
+ # user: vscode
+
+ init: true
+
+ volumes:
+ # Update this to wherever you want VS Code to mount the folder of your project
+ - .:/workspaces/MyRadio:cached
+ - ./sample_configs/codespaces-server-name.conf:/etc/apache2/conf-enabled/server-name.conf
+ - ./sample_configs/codespaces-apache.conf:/etc/apache2/sites-available/myradio.conf
diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 000000000..2bb1c8ce5
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,2 @@
+src/vendor/
+node_modules/
\ No newline at end of file
diff --git a/.eslintignore b/.eslintignore
new file mode 100644
index 000000000..15a3ecc1c
--- /dev/null
+++ b/.eslintignore
@@ -0,0 +1,4 @@
+*.min.js
+src/vendor/*
+src/Public/js/vendor/**/*.js
+src/PublicAPI/rtfm/*
diff --git a/.eslintrc.json b/.eslintrc.json
new file mode 100644
index 000000000..84b83b270
--- /dev/null
+++ b/.eslintrc.json
@@ -0,0 +1,16 @@
+{
+ "root": true,
+ "env": {
+ "browser": true,
+ "es6": true,
+ "jquery": true
+ },
+ "extends": "eslint:recommended",
+ "rules": {
+ "indent": ["error", 2],
+ "linebreak-style": ["error", "unix"],
+ "quotes": ["error", "double"],
+ "semi": ["error", "always"],
+ "no-console": "off"
+ }
+}
diff --git a/.github/workflows/dev-base-image.yml b/.github/workflows/dev-base-image.yml
new file mode 100644
index 000000000..51994fa02
--- /dev/null
+++ b/.github/workflows/dev-base-image.yml
@@ -0,0 +1,50 @@
+name: Update development base image
+
+on:
+ push:
+ branches: [ master ]
+ paths:
+ - sample_configs/*
+ - composer.*
+ - .github/*
+ - .devcontainer/*
+ workflow_dispatch:
+ inputs:
+ ref:
+ description: Branch, tag, or commit SHA to build against
+ required: false
+ default: master
+ docker-tag:
+ description: Tag to add to the Docker image
+ required: false
+ default: latest
+
+jobs:
+
+ build:
+
+ runs-on: ubuntu-latest
+
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ ref: ${{ github.event.inputs.ref || 'master' }}
+ - name: Set up QEMU
+ uses: docker/setup-qemu-action@v3
+
+ - name: Set up Docker Buildx
+ uses: docker/setup-buildx-action@v3
+
+ - name: Docker Login
+ uses: docker/login-action@v1.10.0
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Build and push
+ uses: docker/build-push-action@v6
+ with:
+ platforms: linux/amd64,linux/arm64
+ push: true
+ tags: ghcr.io/universityradioyork/myradio/dev-base:${{ github.event.inputs.docker-tag || 'latest' }}
diff --git a/.gitignore b/.gitignore
index e3b49c40d..4c6a974d7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,5 +1,18 @@
-/nbproject
*.LCK*
-MyRadio_Config.local.php
+MyRadio_Config.local.php*
+util/checkpassword.conf
*.sw[a-z]
sftp-*.json
+.sync-config.cson
+.tags*
+Thumbs.db
+cache.properties
+build/
+.phpintel
+.vagrant
+src/vendor
+composer.lock
+node_modules/
+src/Public/img/stats_training_*.svg
+.idea/
+.DS_Store
\ No newline at end of file
diff --git a/.travis.yml b/.travis.yml
new file mode 100644
index 000000000..e669bd5d1
--- /dev/null
+++ b/.travis.yml
@@ -0,0 +1,66 @@
+sudo: required
+dist: trusty # Use notancient unbuntu
+language: php
+php:
+ - '7.1'
+ - '7.2'
+ - '7.3'
+ - '7.4'
+ - 'nightly' # why not
+
+matrix:
+ allow_failures:
+ - php: 'nightly'
+
+cache:
+ directories:
+ - src/vendor
+ - $HOME/.cache/composer/files
+ - node_modules/
+
+addons:
+ postgresql: '9.5'
+services:
+ - postgresql
+before_install:
+ - nvm install --lts # Updated Node required for eslint
+install:
+ - composer install
+ - npm install eslint
+ - sudo apt-get update
+ - sudo apt-get install apache2 libapache2-mod-fastcgi
+before_script:
+ # missing read perms on $HOME, which apache24 requires
+ - chmod 755 $HOME
+ # enable php-fpm
+ - phpenv config-add sample_configs/travis-php.ini
+ - sudo cp ~/.phpenv/versions/$(phpenv version-name)/etc/php-fpm.conf{.default,}
+ - sudo cp ~/.phpenv/versions/$(phpenv version-name)/etc/php-fpm.d/www.conf{.default,}
+ - sudo a2enmod rewrite actions fastcgi alias
+ - echo "cgi.fix_pathinfo = 1" >> ~/.phpenv/versions/$(phpenv version-name)/etc/php.ini
+ - ~/.phpenv/versions/$(phpenv version-name)/sbin/php-fpm
+ # configure apache virtual hosts
+ - sudo cp -f sample_configs/travis-fpm.conf /etc/apache2/sites-available/myradio-travis.conf
+ - sudo sed -e "s?%TRAVIS_BUILD_DIR%?$(pwd)?g" --in-place /etc/apache2/sites-available/myradio-travis.conf
+ - sudo a2dissite 000-default
+ - sudo a2ensite myradio-travis
+ - sudo service apache2 restart
+ - sudo apachectl -S
+ - sudo cat /etc/apache2/sites-enabled/*
+ # setup postgres
+ - psql -U postgres < sample_configs/postgres.sql
+ - PGPASSWORD=myradio psql -U myradio myradio < schema/base.sql
+ - for f in schema/patches/*.sql; do PGPASSWORD=myradio psql -U myradio myradio < $f; done
+ - PGPASSWORD=myradio psql -U myradio myradio < sample_configs/travis-auth.sql
+ # configure MyRadio
+ - cp sample_configs/travis-config.php src/MyRadio_Config.local.php
+script:
+ - src/vendor/bin/phpcs --standard=PSR2 --ignore="*.min.*,*/vendor/*,*/PublicAPI/rtfm/*,*/Public/js/*" --exclude=Squiz.Classes.ValidClassName,PSR1.Files.SideEffects src/ -p -s
+ - node_modules/.bin/eslint .
+ - curl -v http://localhost/api/v2/config/publicconfig?api_key=travis-test-key
+ - src/vendor/bin/codecept run --debug --env travis
+after_script:
+ - sudo cat $(pwd)/apache-access.log
+ - sudo cat $(pwd)/apache-error.log
+ - sudo cat ~/.phpenv/versions/$(phpenv version-name)/var/log/php-fpm.log
+ - sudo ls -l ~/.phpenv/versions/$(phpenv version-name)/var/log/
diff --git a/.vscode/launch.json b/.vscode/launch.json
new file mode 100644
index 000000000..542060d33
--- /dev/null
+++ b/.vscode/launch.json
@@ -0,0 +1,14 @@
+{
+ // 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": "Listen for Xdebug",
+ "type": "php",
+ "request": "launch",
+ "port": 9003
+ }
+ ]
+}
\ No newline at end of file
diff --git a/Dockerfile b/Dockerfile
new file mode 100644
index 000000000..835f4167f
--- /dev/null
+++ b/Dockerfile
@@ -0,0 +1,59 @@
+FROM php:8.2-apache
+
+RUN apt-get update && apt-get install -y libpq-dev libpng-dev libjpeg-dev libldap-dev unzip \
+ libcurl4-openssl-dev libxslt-dev git libz-dev libzip-dev libmemcached-dev \
+ postgresql-client jq msmtp-mta ffmpeg
+
+RUN docker-php-ext-install pgsql pdo_pgsql gd ldap curl xsl zip
+
+RUN pecl install memcached && \
+ echo extension=memcached.so >> /usr/local/etc/php/conf.d/memcached.ini
+
+RUN pecl install xdebug-3.3.1 && docker-php-ext-enable xdebug \
+ && echo 'zend_extension="/usr/local/lib/php/extensions/no-debug-non-zts-20220829/xdebug.so"' >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini \
+ && echo 'xdebug.client_port=9003' >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini \
+ && echo 'xdebug.mode=develop,debug' >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini \
+ && echo 'xdebug.start_with_request=yes' >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini \
+ && echo 'xdebug.client_host=localhost' >> /usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini
+
+RUN echo 'error_reporting=E_ALL' >> /usr/local/etc/php/conf.d/error-reporting.ini
+
+RUN echo "memory_limit=512M" >> /usr/local/etc/php/conf.d/uploads.ini
+RUN echo "upload_max_filesize=512M" >> /usr/local/etc/php/conf.d/uploads.ini
+RUN echo "post_max_size=512M" >> /usr/local/etc/php/conf.d/uploads.ini
+
+RUN echo sendmail_path = "/usr/bin/msmtp -t --host mail --port 1025 --from myradio@ury.dev" > /usr/local/etc/php/conf.d/sendmail.ini
+
+# Self-signed certificate
+RUN openssl req -nodes -new -subj "/C=GB/ST=North Yorkshire/L=York/O=University Radio York/OU=Localhost/CN=localhost" > myradio.csr && \
+ openssl rsa -in privkey.pem -out myradio.key && \
+ openssl x509 -in myradio.csr -out myradio.crt -req -signkey myradio.key -days 999 && \
+ cp myradio.crt /etc/apache2/myradio.crt && \
+ cp myradio.key /etc/apache2/myradio.key
+
+RUN a2enmod rewrite ssl
+
+# Fixes for running on ARM
+RUN echo "Mutex posixsem" >> /etc/apache2/apache2.conf
+
+RUN echo "127.0.0.0/8" >> /etc/apache2/trusted-proxies.txt
+
+COPY sample_configs/apache.conf /etc/apache2/sites-available/myradio.conf
+RUN a2enmod remoteip && a2dissite 000-default && a2ensite myradio && apachectl -S
+
+COPY --from=composer:latest /usr/bin/composer /usr/local/bin/composer
+
+RUN mkdir -p /var/www/myradio && chown -R www-data:www-data /var/www/myradio && \
+ mkdir -p /var/log/myradio && chown -R www-data:www-data /var/log/myradio
+
+WORKDIR /var/www/myradio
+COPY composer.* /var/www/myradio/
+RUN COMPOSER_VENDOR_DIR=/var/www/myradio/src/vendor composer install --no-security-blocking
+
+COPY schema schema
+COPY src src
+
+COPY sample_configs/docker-config.php src/MyRadio_Config.local.php
+RUN chown www-data:www-data /var/www/myradio/src/MyRadio_Config.local.php && chmod 664 /var/www/myradio/src/MyRadio_Config.local.php
+
+CMD ["apache2-foreground"]
diff --git a/Dockerfile.prod b/Dockerfile.prod
new file mode 100644
index 000000000..83af47e75
--- /dev/null
+++ b/Dockerfile.prod
@@ -0,0 +1,50 @@
+FROM php:8.2-apache
+
+RUN apt-get update && apt-get install -y libpq-dev libpng-dev libjpeg-dev libldap-dev unzip \
+ libcurl4-openssl-dev libxslt-dev git libz-dev libzip-dev libmemcached-dev \
+ postgresql-client jq msmtp-mta ffmpeg
+
+RUN docker-php-ext-install pgsql pdo_pgsql gd ldap curl xsl zip
+
+RUN pecl install memcached && \
+ echo extension=memcached.so >> /usr/local/etc/php/conf.d/memcached.ini
+
+RUN echo 'error_reporting=E_ALL' >> /usr/local/etc/php/conf.d/error-reporting.ini
+
+RUN echo "memory_limit=512M" >> /usr/local/etc/php/conf.d/uploads.ini
+RUN echo "upload_max_filesize=512M" >> /usr/local/etc/php/conf.d/uploads.ini
+RUN echo "post_max_size=512M" >> /usr/local/etc/php/conf.d/uploads.ini
+
+RUN echo sendmail_path = "/usr/bin/msmtp -t --host ury.york.ac.uk --port 587 --from noreply@ury.org.uk" > /usr/local/etc/php/conf.d/sendmail.ini
+
+# Self-signed certificate
+RUN openssl req -nodes -new -subj "/C=GB/ST=North Yorkshire/L=York/O=University Radio York/OU=Localhost/CN=localhost" > myradio.csr && \
+ openssl rsa -in privkey.pem -out myradio.key && \
+ openssl x509 -in myradio.csr -out myradio.crt -req -signkey myradio.key -days 999 && \
+ cp myradio.crt /etc/apache2/myradio.crt && \
+ cp myradio.key /etc/apache2/myradio.key
+
+RUN a2enmod rewrite ssl
+
+RUN echo "144.32.64.160/27" >> /etc/apache2/trusted-proxies.txt
+
+COPY sample_configs/apache.conf /etc/apache2/sites-available/myradio.conf
+RUN a2enmod remoteip && a2dissite 000-default && a2ensite myradio && apachectl -S
+
+COPY --from=composer:latest /usr/bin/composer /usr/local/bin/composer
+
+RUN mkdir -p /var/www/myradio && chown -R www-data:www-data /var/www/myradio && \
+ mkdir -p /var/log/myradio && chown -R www-data:www-data /var/log/myradio && \
+ ln -s /dev/stderr /var/log/myradio/errors.log
+
+WORKDIR /var/www/myradio
+COPY composer.* /var/www/myradio/
+RUN COMPOSER_VENDOR_DIR=/var/www/myradio/src/vendor composer install --no-security-blocking
+
+COPY schema schema
+COPY src src
+
+COPY sample_configs/docker-config.php src/MyRadio_Config.local.php
+RUN chown www-data:www-data /var/www/myradio/src/MyRadio_Config.local.php && chmod 664 /var/www/myradio/src/MyRadio_Config.local.php
+
+CMD ["apache2-foreground"]
diff --git a/README.md b/README.md
index 206a101fd..1d3135208 100644
--- a/README.md
+++ b/README.md
@@ -8,7 +8,9 @@ their own community or student radio venture.
MyRadio is part of a suite of upcoming public projects, including:
- iTones, our liquidsoap sustainer system
-- loggerng, our python audio logging and retriving system
+- loggerng, our python audio logging and retrieving system
- Bootstrapping scripts for setting up and configuring all the dependencies
-MyRadio uses Git-Flow as a development workflow: https://github.com/nvie/gitflow/
+To install myradio locally, see the [Install Guide](docs/install.md)
+
+MyRadio uses [GitHub Flow](https://guides.github.com/overviews/flow/) as a development workflow:
diff --git a/Vagrantfile b/Vagrantfile
new file mode 100644
index 000000000..c2d2116b2
--- /dev/null
+++ b/Vagrantfile
@@ -0,0 +1,78 @@
+# -*- mode: ruby -*-
+# vi: set ft=ruby :
+
+# All Vagrant configuration is done below. The "2" in Vagrant.configure
+# configures the configuration version (we support older styles for
+# backwards compatibility). Please don't change it unless you know what
+# you're doing.
+Vagrant.configure(2) do |config|
+ # The most common configuration options are documented and commented below.
+ # For a complete reference, please see the online documentation at
+ # https://docs.vagrantup.com.
+
+ # Every Vagrant development environment requires a box. You can search for
+ # boxes at https://atlas.hashicorp.com/search.
+ config.vm.box = "bento/ubuntu-20.04"
+
+ # Disable automatic box update checking. If you disable this, then
+ # boxes will only be checked for updates when the user runs
+ # `vagrant box outdated`. This is not recommended.
+ # config.vm.box_check_update = false
+
+ # Create a forwarded port mapping which allows access to a specific port
+ # within the machine from a port on the host machine. In the example below,
+ # accessing "localhost:8080" will access port 80 on the guest machine.
+ config.vm.network "forwarded_port", guest: 443, host: 4443
+ config.vm.network "forwarded_port", guest: 80, host: 7080
+
+ # Create a private network, which allows host-only access to the machine
+ # using a specific IP.
+ # config.vm.network "private_network", ip: "192.168.33.10"
+
+ # Create a public network, which generally matched to bridged network.
+ # Bridged networks make the machine appear as another physical device on
+ # your network.
+ # config.vm.network "public_network"
+
+ # Share an additional folder to the guest VM. The first argument is
+ # the path on the host to the actual folder. The second argument is
+ # the path on the guest to mount the folder. And the optional third
+ # argument is a set of non-required options.
+ # config.vm.synced_folder "./", "/vagrant_data"
+
+ # Set www-data so MyRadio/Apache2 is happy.
+ config.vm.synced_folder ".", "/vagrant", :mount_options => ['dmode=775', 'fmode=775']
+
+ # Provider-specific configuration so you can fine-tune various
+ # backing providers for Vagrant. These expose provider-specific options.
+ # Example for VirtualBox:
+ #
+ # config.vm.provider "virtualbox" do |vb|
+ # # Display the VirtualBox GUI when booting the machine
+ # vb.gui = true
+ #
+ # # Customize the amount of memory on the VM:
+ # vb.memory = "1024"
+ # end
+ #
+ # View the documentation for the provider you are using for more
+ # information on available options.
+
+ # Define a Vagrant Push strategy for pushing to Atlas. Other push strategies
+ # such as FTP and Heroku are also available. See the documentation at
+ # https://docs.vagrantup.com/v2/push/atlas.html for more information.
+ # config.push.define "atlas" do |push|
+ # push.app = "YOUR_ATLAS_USERNAME/YOUR_APPLICATION_NAME"
+ # end
+
+ # Enable provisioning with a shell script. Additional provisioners such as
+ # Puppet, Chef, Ansible, Salt, and Docker are also available. Please see the
+ # documentation for more information about their specific syntax and use.
+ config.vm.provision :shell, :inline => "sed -i 's#http://us.archive.ubuntu.com/ubuntu/#mirror://mirrors.ubuntu.com/mirrors.txt#g' /etc/apt/sources.list"
+ config.vm.provision :shell, path: "scripts/bootstrap.sh"
+ config.trigger.after :up do |trigger|
+ trigger.name = "Apache2 Restart"
+ trigger.info = "Restarting Apache2, it doesn't seem to start correctly."
+ trigger.run_remote = {inline: "service apache2 reload"}
+ end
+end
diff --git a/build.xml b/build.xml
index 79b6bb8f2..76f433fd4 100644
--- a/build.xml
+++ b/build.xml
@@ -1,29 +1,14 @@
This category is for all playlists that don''t fit the others. They will not be played by Jukebox or Campus Playout. This category is for all playlists that should be played by Jukebox.
In one week all of your personally identifiable data that is not required for webstudio or our public facing websites to function as advertised will be deleted.
+If you wish to avoid this you can opt out of deletion by logging into your myradio account or by contact the ury computing team.
+If you are happy for your personal data to be deleted feel free to ignore this eamil.
+--Thanks for showing an interest in URY, your official student radio station.
- -My name's Al, and I'm the Station Manager here at URY. It's my job to make it as -easy as possible to get on the air or join any of our other teams.
- -If you're interested in getting involved in any of our teams (there's 11 of -them!), then reply to this email and I'll sort you out, or email the address -listed for that team on our website. -
- -For more information about these, and everything else we do, you can: -
Finally, URY has a lot of online -resources that are useful for all sorts of things, so you'll need your login - details:
-Username: #USER
-Password: #PASS
If you have any questions, feel free to ask by visiting us at our station in -Vanbrugh, or emailing training@ury.org.uk.
- -Hope to see you soon. -| Number of Plays | Title | Total Playtime | Playlist Membership |
|---|---|---|---|
| '.$row['num_plays'].' | '.$row['title'].' | '.$row['total_playtime'].' | ' - . $row['in_playlists'] .' |
| Number of Plays | Title | Total Playtime | Playlist Membership |
|---|---|---|---|
| '.$row['num_plays'].' | '.$row['title'].' | ' + .$row['total_playtime'].' | '.$row['in_playlists'].' |
| '.$totalplays.' | '.$totaltracks + .' | '.CoreUtils::intToTime($totaltime).' |
| Type | Information | Location | Time |
|---|---|---|---|
| " . $row["type"] + . " | " . $row["info"] + . " | " . $row["location"] + . " | " . $row["time"] + . " |
' . $message . '
'; + echo ''.$message.'
'; } } /** - * Returns the ID of a Module, creating it if necessary + * Returns the ID of a Module, creating it if necessary. * * This method first caches all module IDs, if they aren't already available. It then checks * if the given module exists, and if not it creates one, generating an ID. - * - * @param String $module + * + * @param string $module + * * @return int */ - public static function getModuleId($module) { + public static function getModuleId($module) + { if (empty(self::$module_ids)) { - $result = Database::getInstance()->fetch_all('SELECT name, moduleid FROM myury.modules'); + $result = Database::getInstance()->fetchAll('SELECT name, moduleid FROM myury.modules'); foreach ($result as $row) { self::$module_ids[$row['name']] = $row['moduleid']; } @@ -501,140 +358,69 @@ public static function getModuleId($module) { if (empty(self::$module_ids[$module])) { //The module needs creating - $result = Database::getInstance()->fetch_column('INSERT INTO myury.modules (serviceid, name) - VALUES ($1, $2) RETURNING moduleid', array(Config::$service_id, $module)); - self::$module_ids[$module] = $result[0]; + $result = Database::getInstance()->fetchColumn( + 'INSERT INTO myury.modules (serviceid, name) + VALUES ($1, $2) RETURNING moduleid', + [Config::$service_id, $module] + ); + if ($result) { + self::$module_ids[$module] = $result[0]; + } else { + return; + } } + return self::$module_ids[$module]; } /** - * Returns the ID of a Service/Module/Action request, creating it if necessary - * @param int $module - * @param String $action + * Returns the ID of a Service/Module/Action request, creating it if necessary. + * + * @param int $module + * @param string $action + * * @return int */ - public static function getActionId($module, $action) { + public static function getActionId($module, $action) + { if (empty(self::$action_ids)) { - $result = Database::getInstance()->fetch_all('SELECT name, moduleid, actionid FROM myury.actions'); + $result = Database::getInstance()->fetchAll('SELECT name, moduleid, actionid FROM myury.actions'); foreach ($result as $row) { - self::$action_ids[$row['name'] . '-' . $row['moduleid']] = $row['actionid']; + self::$action_ids[$row['name'].'-'.$row['moduleid']] = $row['actionid']; } } - if (empty(self::$action_ids[$action . '-' . $module])) { + if (empty(self::$action_ids[$action.'-'.$module])) { //The action needs creating - $result = Database::getInstance()->fetch_column('INSERT INTO myury.actions (moduleid, name) - VALUES ($1, $2) RETURNING actionid', array($module, $action)); - self::$action_ids[$action . '-' . $module] = $result[0]; - } - return self::$action_ids[$action . '-' . $module]; - } - - /** - * Assigns a permission to a command - * @todo Document - * @param type $module - * @param type $action - * @param type $permission - */ - public static function addActionPermission($module, $action, $permission) { - $db = Database::getInstance(); - $db->query('INSERT INTO myury.act_permission (serviceid, moduleid, actionid, typeid) - VALUES ($1, $2, $3, $4)', array(Config::$service_id, $module, $action, $permission)); - } - - /** - * Returns the service version allocated to the given user. - * - * @param MyRadio_User $user If given this is the user to check. By default, - * it uses the currently logged-in user. - * - * If there is no user logged in, then the default version is returned. - */ - public static function getServiceVersionForUser(MyRadio_User $user = null) { - if ($user === null) { - if (!isset($_SESSION['memberid'])) { - return self::getDefaultServiceVersion(); - } - $user = MyRadio_User::getInstance(); - } - $serviceid = Config::$service_id; - $key = $serviceid . '-' . $user->getID(); - - if ($user->getID() === MyRadio_User::getInstance()->getID()) { - //It's the current user. If they have an override defined in their session, use that. - if (isset($_SESSION['myury_svc_version_' . $serviceid])) { - return array( - 'version' => $_SESSION['myury_svc_version_' . $serviceid], - 'path' => $_SESSION['myury_svc_version_' . $serviceid . '_path'], - 'proxy_static' => $_SESSION['myury_svc_version_' . $serviceid . '_proxy_static'] - ); - } - } - - if (!isset(self::$svc_version_cache[$key])) { - $db = Database::getInstance(); - - $result = $db->fetch_one('SELECT version, path, proxy_static - FROM myury.services_versions - WHERE serviceid IN (SELECT serviceid FROM myury.services_versions_member - WHERE memberid=$2 AND serviceversionid IN (SELECT serviceversionid FROM myury.services_versions - WHERE serviceid=$1) - )', array($serviceid, $user->getID())); - - if (empty($result)) { - self::$svc_version_cache[$key] = self::getDefaultServiceVersion(); + $result = Database::getInstance()->fetchColumn( + 'INSERT INTO myury.actions (moduleid, name) + VALUES ($1, $2) RETURNING actionid', + [$module, $action] + ); + if ($result) { + self::$action_ids[$action.'-'.$module] = $result[0]; } else { - $result['proxy_static'] = $result['proxy_static'] === 't'; - self::$svc_version_cache[$key] = $result; + return; } } - //If it's the current user, store the data in session. - if ($user->getID() === MyRadio_User::getInstance()->getID()) { - $_SESSION['myury_svc_version_' . $serviceid] = self::$svc_version_cache[$key]['version']; - $_SESSION['myury_svc_version_' . $serviceid . '_path'] = self::$svc_version_cache[$key]['path']; - $_SESSION['myury_svc_version_' . $serviceid . '_proxy_static'] = self::$svc_version_cache[$key]['proxy_static']; - } - - return self::$svc_version_cache[$key]; + return self::$action_ids[$action.'-'.$module]; } /** - * - */ - private static function getDefaultServiceVersion() { - $db = Database::getInstance(); - - $r = $db->fetch_one('SELECT version, path, proxy_static FROM myury.services_versions WHERE serviceid=$1 - AND is_default=true LIMIT 1', array(Config::$service_id)); - $r['proxy_static'] = $r['proxy_static'] === 't'; - - return $r; - } - - /** - * @todo Document this. - * @return boolean - */ - public static function getServiceVersions() { - $db = Database::getInstance(); - - return $db->fetch_all('SELECT version, path, proxy_static FROM myury.services_versions WHERE serviceid=$1', array(Config::$service_id)); - } - - /** - * Parses an object or array into client array datasource + * Parses an object or array into client array datasource. + * * @param mixed $data + * * @return array */ - public static function dataSourceParser($data, $full = true) { - if (is_object($data) && $data instanceof MyRadio_DataSource) { - return $data->toDataSource($full); + public static function dataSourceParser($data, $mixins = []) + { + if (is_object($data) && $data instanceof ServiceAPI) { + return $data->toDataSource($mixins); } elseif (is_array($data)) { foreach ($data as $k => $v) { - $data[$k] = self::dataSourceParser($v, $full); + $data[$k] = self::dataSourceParser($v, $mixins); } return $data; } else { @@ -642,25 +428,54 @@ public static function dataSourceParser($data, $full = true) { } } + /** + * Iteratively calls the toDataSource method on all of the objects in the given array, returning the results as + * a new array. + * @param array $array + * @param array $mixins Mixins + * @return array, unless it wasn't passed an array to begin with, in which case just return $array. + * @throws MyRadioException Throws an Exception if a provided object is not a DataSource + */ + public static function setToDataSource($array, $mixins = []) + { + if (!is_array($array)) { + return $array; + } + $result = []; + foreach ($array as $element) { + //It must implement the toDataSource method! + if (!method_exists($element, 'toDataSource')) { + throw new MyRadioException( + 'Attempted to convert '.get_class($element).' to a DataSource but it not a valid Data Object!', + 500 + ); + } + $result[] = $element->toDataSource($mixins); + } + return $result; + } + //from http://www.php.net/manual/en/function.xml-parse-into-struct.php#109032 - public static function xml2array($xml) { - $opened = array(); + public static function xml2array($xml) + { + $opened = []; $opened[1] = 0; $xml_parser = xml_parser_create(); xml_parse_into_struct($xml_parser, $xml, $xmlarray); $array = array_shift($xmlarray); - unset($array["level"]); - unset($array["type"]); + unset($array['level']); + unset($array['type']); $arrsize = sizeof($xmlarray); - for ($j = 0; $j < $arrsize; $j++) { + for ($j = 0; $j < $arrsize; ++$j) { $val = $xmlarray[$j]; - switch ($val["type"]) { - case "open": - $opened[$val["level"]] = 0; - case "complete": - $index = ""; - for ($i = 1; $i < ($val["level"]); $i++) { - $index .= "[" . $opened[$i] . "]"; + switch ($val['type']) { + case 'open': + $opened[$val['level']] = 0; + /* Fall through */ + case 'complete': + $index = ''; + for ($i = 1; $i < ($val['level']); ++$i) { + $index .= '['.$opened[$i].']'; } $path = explode('][', substr($index, 1, -1)); $value = &$array; @@ -668,42 +483,42 @@ public static function xml2array($xml) { $value = &$value[$segment]; } $value = $val; - unset($value["level"]); - unset($value["type"]); - if ($val["type"] == "complete") { - $opened[$val["level"] - 1] ++; + unset($value['level']); + unset($value['type']); + if ($val['type'] == 'complete') { + ++$opened[$val['level'] - 1]; } break; - case "close": - $opened[$val["level"] - 1] ++; - unset($opened[$val["level"]]); + case 'close': + $opened[$val['level'] - 1]++; + unset($opened[$val['level']]); break; } } + return $array; } - public static function requireTimeslot() { + public static function requireTimeslot() + { if (!isset($_SESSION['timeslotid'])) { - header('Location: ' . CoreUtils::makeURL('MyRadio', 'timeslot', ['next' => $_SERVER['REQUEST_URI']])); + URLUtils::redirect('MyRadio', 'timeslot', ['next' => $_SERVER['REQUEST_URI']]); exit; } } - public static function backWithMessage($message) { - header('Location: ' . $_SERVER['HTTP_REFERER'] . (strstr($_SERVER['HTTP_REFERER'], '?') !== false ? '&' : '?') . 'message=' . base64_encode($message)); - } - /** * Returns a randomly selected item from the list, in a biased manner - * Weighted should be an integer - how many times to put the item into the bag - * @param Array $data 2D of Format [['item' => mixed, 'weight' => n], ...] + * Weighted should be an integer - how many times to put the item into the bag. + * + * @param array $data 2D of Format [['item' => mixed, 'weight' => n], ...] */ - public static function biased_random($data) { - $bag = array(); + public static function biasedRandom($data) + { + $bag = []; foreach ($data as $ball) { - for (; $ball['weight'] > 0; $ball['weight'] --) { + for (; $ball['weight'] > 0; --$ball['weight']) { $bag[] = $ball['item']; } } @@ -712,7 +527,8 @@ public static function biased_random($data) { } //Reports some things - public static function shutdown() { + public static function shutdown() + { session_write_close(); //It doesn't seem to do this itself sometimes. try { $db = Database::getInstance(); @@ -724,122 +540,79 @@ public static function shutdown() { flush(); } - $errors = MyRadioError::getErrorCount(); - $exceptions = MyRadioException::getExceptionCount(); - $queries = $db->getCounter(); - $host = gethostbyname(gethostname()); - - $db->query('INSERT INTO myury.error_rate (server_ip, error_count, exception_count, queries) - VALUES ($1, $2, $3, $4)', array($host, $errors, $exceptions, $queries)); + //Discard any in-progress transactions + if ($db->getInTransaction()) { + $db->query('ROLLBACK'); + } } /** - * Ring YUSU's API and ask how it's doing - * + * Ring YUSU's API and ask how it's doing. + * * Currently, ListMembers is the only function available. Dan Bishop has plans for more at a later date. - * - * @return Array JSON Response, forced to assoc array - */ - public static function callYUSU($function = 'ListMembers') { - return json_decode(file_get_contents('https://www.yusu.org/api/api.php?apikey=' . Config::$yusu_api_key . '&function=' . $function), true); - /** - * @todo php5-curl not installed, pkg broken (20130716) - */ - $ch = curl_init(); - $timeout = 5; - curl_setopt($ch, CURLOPT_URL, 'https://www.yusu.org/api/api.php?apikey=' . Config::$yusu_api_key . '&function=' . $function); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, 1); - curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, $timeout); - curl_setopt($ch, CURLOPT_USERAGENT, 'Mozilla/5.0 (Unix; en-GB) MyRadio/2013.07.16 (cURL)'); - $data = curl_exec($ch); - curl_close($ch); - return json_decode($data, true); - } - - public static function getErrorStats($since = null) { - if ($since === null) { - $since = time() - 86400; - } - $result = Database::getInstance()->fetch_all('SELECT - round(extract(\'epoch\' from timestamp) / 600) * 600 as timestamp, - SUM(error_count)/COUNT(error_count) AS errors, SUM(exception_count)/COUNT(exception_count) AS exceptions, - SUM(queries)/COUNT(queries) AS queries - FROM myury.error_rate WHERE timestamp>=$1 GROUP BY round(extract(\'epoch\' from timestamp) / 600) - ORDER BY timestamp ASC', array(self::getTimestamp($since))); - - $return = array(); - $return[] = array('Timestamp', 'Errors per request', 'Exceptions per request', 'Queries per request'); - foreach ($result as $row) { - $return[] = array(date('H:i', $row['timestamp']), (int) $row['errors'], (int) $row['exceptions'], (int) $row['queries']); - } - return $return; - } - - public static function getSafeHTML($dirty_html) { - require_once 'Classes/Vendor/htmlpurifier/HTMLPurifier.auto.php'; - $config = HTMLPurifier_Config::createDefault(); - $purifier = new HTMLPurifier($config); + * + * @return array JSON Response, forced to assoc array + */ + public static function callYUSU($function = 'ListMembers') + { + $options = [ + 'http' => [ + 'method' => 'GET', + 'header' => "User-Agent: MyRadio\r\n", + ], + ]; + $context = stream_context_create($options); + + return json_decode( + file_get_contents( + Config::$yusu_api_website + .'?apikey=' + .Config::$yusu_api_key + .'&function=' + .$function, + false, + $context + ), + true + ); + } + + public static function getSafeHTML($dirty_html) + { + $config = \HTMLPurifier_Config::createDefault(); + $purifier = new \HTMLPurifier($config); + return $purifier->purify($dirty_html); } /** - * Tests whether the given username or password are valid against a provider - * (and the right provider if needed). - * - * This is a far more basic version of the full Controllers/login.php system, - * not verifying if the user needs to take an action first. - * It does, however, update the User's last login time.. - * You MUST use POST with this - otherwise the credentials will turn up in - * access logs. - * - * @param String $user - * @param String $pass - * @return MyRadio_User|false - * @api POST + * Returns lookup values for Status for a select box. + * + * @return array */ - public static function testCredentials($user, $pass) { - //Make a best guess at the user account - //This way we can skip authenticators if they have one set - $u = MyRadio_User::findByEmail($user); - if ($u instanceof MyRadio_User && $u->getAuthProvider() !== null) { - $authenticators = [$u->getAuthProvider()]; - } else { - $authenticators = Config::$authenticators; - } - - //Iterate over each authenticator - foreach ($authenticators as $authenticator) { - $a = new $authenticator(); - $result = $a->validateCredentials($user, $pass); - if ($result instanceof MyRadio_User) { - if (Config::$single_authenticator && - $result->getAuthProvider() !== null && - $result->getAuthProvider() !== $authenticator) { - //This is the wrong authenticator for the user - continue; - } else { - $result->updateLastLogin(); - return $result; - } - } - } - return false; + public static function getStatusLookup() + { + return Database::getInstance()->fetchAll( + 'SELECT statusid AS value, descr AS text FROM public.l_status + ORDER BY descr ASC' + ); } /** * Returns information about the $_REQUEST array. - * + * * This *MUST* be used instead of print_r($_REQUEST) or var_dump($_REQUEST) * in debug output. - * - * @return String var_dump output + * + * @return string var_dump output */ - public static function getRequestInfo() { + public static function getRequestInfo() + { ob_start(); - if (isset($_REQUEST['redact'])) { - $info = array(); + if (isset($_REQUEST['redact']) || isset($_REQUEST['pass']) || isset($_REQUEST['password'])) { + $info = []; foreach ($_REQUEST as $k => $v) { - if (!in_array($k, $_REQUEST['redact'])) { + if (!in_array($k, $_REQUEST['redact']) && $k !== 'pass' && $k !== 'password') { $info[$k] = $v; } else { $info[$k] = '**REDACTED**'; @@ -849,44 +622,140 @@ public static function getRequestInfo() { } else { var_dump($_REQUEST); } + + return ob_get_clean(); + } + + /** + * Returns information about the $_SERVER array. + * + * @return string var_dump output + */ + public static function getServerInfo() + { + ob_start(); + var_dump($_SERVER); return ob_get_clean(); } /** * Generates a completely pseudorandom string, aimed for Salt purposes. + * * @param int $pwdLen The length of the string to generate - * @return String a random string of length $pwdLen + * + * @return string a random string of length $pwdLen */ - public static function randomString($pwdLen = 8) { + public static function randomString($pwdLen = 8) + { $result = ''; $pwdSource = '1234567890abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'; srand((double) microtime() * 1000000); while ($pwdLen) { $result .= substr($pwdSource, rand(0, strlen($pwdSource) - 1), 1); - $pwdLen--; + --$pwdLen; + } + + return $result; + } + + /** + * I'm becoming a Python person who just expects this to be a thing. + * + * Copypasta from http://stackoverflow.com/questions/834303/startswith-and-endswith-functions-in-php + */ + public static function startsWith($haystack, $needle) + { + // search backwards starting from haystack length characters from the end + return $needle === '' || strrpos($haystack, $needle, -strlen($haystack)) !== false; + } + + /** + * Explode tags from string to array. + * We want to handle the case when people delimit with commas, spaces, or commas and + * spaces, as well as handling extended spaces. + * + * @param string $tags A tags string, comma separated. + * + * @return array The exploded tags. + * + * @throws MyRadioException when tags are longer than 24 characters. + **/ + public static function explodeTags($tags) + { + $tags = preg_split('/[, ] */', $tags, null, PREG_SPLIT_NO_EMPTY); + $exploded_tags = []; + foreach ($tags as $tag) { + if (empty($tag)) { + continue; + } + if (strlen($tag) > 24) { + throw new MyRadioException( + "Sorry, individual tags longer than 24 characters aren't allowed. Please try again.", + 400 + ); + } + // Add the valid tag to the returned array. + $exploded_tags[] = trim($tag); } - return( $result ); + return $exploded_tags; } - private function __construct() { - + public static function checkUploadPostSize() + { + // Check that any files don't go over the PHP post_max_size + // Otherwise, sometimes PHP won't return an error, causing an empty $_POST. + // This would cause confusing errors relating to empty fields. + // https://stackoverflow.com/questions/2133652/how-to-gracefully-handle-files-that-exceed-phps-post-max-size + $post_size = trim(ini_get('post_max_size')); + if ($post_size != '') { + $last = strtolower(substr($post_size, -1)); + } else { + $last = ''; + } + $post_size = intval($post_size); // Convert to int (strips any suffix letters) + switch ($last) { + // The 'G' modifier is available since PHP 5.1.0 + case 'g': + $post_size *= 1024; + // fall through + case 'm': + $post_size *= 1024; + // fall through + case 'k': + $post_size *= 1024; + // fall through + } + if ($_SERVER['CONTENT_LENGTH'] > $post_size) { + throw new MyRadioException( + "The content uploaded in this form was too large for the server's configuration.", + 500 + ); + } + } + + private function __construct() + { } /** - * Generates a new password consisting of two words and a two-digit number + * Generates a new password consisting of two words and a two-digit number. + * * @todo Make this crypto secure random? - * @return String + * + * @return string */ - public static function newPassword() { - return self::$words[array_rand(self::$words)] . rand(10, 99) - . self::$words[array_rand(self::$words)]; + public static function newPassword() + { + return self::$words[array_rand(self::$words)].rand(10, 99) + .self::$words[array_rand(self::$words)]; } /** - * Words used by CoreUtils::newPassword - * @var String[] + * Words used by CoreUtils::newPassword. + * + * @var string[] */ - private static $words = array( + private static $words = [ 'Radio', 'Microphone', 'Studio', @@ -922,7 +791,85 @@ public static function newPassword() { 'Frequency', 'Modulation', 'Vinyl', - 'Broadcasting' - ); + 'Broadcasting', + ]; + /** + * This whole thing is a bodge that needs to die. + * + * Duplicate the name-match-based subtype identification logic from 2016-site, until it is configured to + * properly use the [show, season]Subtype field from MyRadio, and MyRadio has a proper GUI for setting them. + * + * It sucks, but that's the cost of progress. + * @param $show_name string + * @return string + */ + public static function getSubtypeForShow($show_name) + { + $blockMatches = [ + ["ury: early morning", "primetime"], + ["ury breakfast", "primetime"], + ["ury lunch", "primetime"], + ["ury brunch", "primetime"], + ["URY Brunch", "primetime"], + ["URY Afternoon Tea:", "primetime"], + ["URY:PM", "primetime"], + ["Alumni Takeover:", "primetime"], + + ["ury news", "news"], + ["ury sports", "news"], + ["ury football", "news"], + ["york sport report", "news"], + ["university radio talk", "news"], + ["candidate interview night", "news"], + ["election results night", "news"], + ["yusu election", "news"], + ["The Second Half With Josh Kerr", "news"], + ["URY SPORT", "news"], + ["URY News & Sport:", "news"], + ["URY N&S:", "news"], + + ["ury speech", "speech"], + ["yorworld", "speech"], + ["in the stalls", "speech"], + ["screen", "speech"], + ["stage", "speech"], + ["game breaking", "speech"], + ["radio drama", "speech"], + ["Book Corner", "speech"], + ["Saturated Facts", "speech"], + ["URWatch", "speech"], + ["Society Challenge", "speech"], + ["Speech Showcase", "speech"], + ["URY Speech:", "speech"], + + ["URY Music:", "music"], + + ["roses live 20", "event"], + ["roses 20", "event"], + ["freshers 20", "event"], + ["woodstock", "event"], + ["movember", "event"], + ["panto", "event"], + ["101:", "event"], + ["Vanbrugh Chair Debate", "event"], + ["URY Does RAG Courtyard Takeover", "event"], + ["URY Presents", "event"], + ["URYOnTour", "event"], + ["URY On Tour", "event"], + + ["YSTV", "collab"], + ["Nouse", "collab"], + ["York Politics Digest", "collab"], + ["Breakz", "collab"], + ]; + + $name = strtolower($show_name); + foreach ($blockMatches as $match) { + if (strpos($name, strtolower($match[0])) !== false /* bloody PHP */) { + return $match[1]; + } + } + return 'regular'; + } } diff --git a/src/Classes/MyRadio/GraphQLContext.php b/src/Classes/MyRadio/GraphQLContext.php new file mode 100644 index 000000000..a2c170f56 --- /dev/null +++ b/src/Classes/MyRadio/GraphQLContext.php @@ -0,0 +1,19 @@ +warnings[] = $warning; + } + + public function getWarnings() + { + return $this->warnings; + } +} diff --git a/src/Classes/MyRadio/GraphQLUtils.php b/src/Classes/MyRadio/GraphQLUtils.php new file mode 100644 index 000000000..f15ec924d --- /dev/null +++ b/src/Classes/MyRadio/GraphQLUtils.php @@ -0,0 +1,330 @@ + [ + 'c' => 'Current', + 'h' => 'Historic' + ], + 'OfficerType' => [ + 'h' => 'HeadOfTeam', + 'a' => 'AssistantHeadOfTeam', + 'm' => 'TeamMember', + 'o' => 'Other' + ] + ]; + + /** + * @param ResolveInfo $info + * @param string $name + * @return DirectiveNode|null + */ + public static function getDirectiveByName(ResolveInfo $info, string $name) + { + $fieldNode = $info->parentType->getField($info->fieldName)->astNode; + return self::getDirectiveByNameOnAstNode($fieldNode, $name); + } + + /** + * @param ObjectTypeDefinitionNode|FieldDefinitionNode $node + * @param string $name + * @return DirectiveNode|null + */ + private static function getDirectiveByNameOnAstNode($node, string $name) + { + /** @var NodeList $directives */ + $directives = $node->directives; + if ($directives) { + /** @var DirectiveNode[] $directives */ + foreach ($directives as $directive) { + if ($directive->name->value === $name) { + return $directive; + } + } + } + return null; + } + + /** + * @param DirectiveNode $directive + * @return ValueNode[] + */ + public static function getDirectiveArguments(DirectiveNode $directive) + { + $args = []; + foreach ($directive->arguments as $arg) { + $args[$arg->name->value] = $arg->value; + } + return $args; + } + + /** + * Er, take a wild guess? + * @param ResolveInfo $info + */ + public static function returnNullOrThrowForbiddenException(ResolveInfo $info) + { + if ($info->returnType instanceof NonNull) { + throw new MyRadioException('Caller cannot access this field', 403); + } else { + return null; + } + } + + /** + * Tests if the current caller is authorised to access the given GraphQL field + * @param ResolveInfo $info + * @param string|null $resolvedClass + * @param string|null $resolvedMethod + * @param mixed|null $resolvedObject + * @return bool + */ + public static function isAuthorisedToAccess( + ResolveInfo $info, + $resolvedClass, + $resolvedMethod, + $resolvedObject = null + ) { + $caller = MyRadio_Swagger2::getAPICaller(); + if ($caller === null) { + throw new MyRadioException('No valid authentication data provided', 401); + } + if ($caller->hasAuth(AUTH_APISUDO)) { + // I am become sudo, doer of API calls + return true; + } + // First, check if we have an @auth directive. If so, it overrides. + $authDirective = self::getDirectiveByName($info, 'auth'); + if ($authDirective !== null) { + return self::processAuthDirective( + self::getDirectiveArguments($authDirective), + $resolvedClass, + $resolvedMethod, + $resolvedObject + ); + } else { + // Check if there's an @auth on the parent object + $authDirective = self::getDirectiveByNameOnAstNode($info->parentType->astNode, "auth"); + if ($authDirective !== null) { + return self::processAuthDirective( + self::getDirectiveArguments($authDirective), + $resolvedClass, + $resolvedMethod, + $resolvedObject + ); + } else { + // Object-scalar rule: if we're dealing with an object, use API v2 rules + // If we're dealing with a scalar, assume it's okay, as it must have come from an object + if ($info->returnType instanceof WrappingType) { + $type = $info->returnType->getWrappedType(true); + } else { + $type = $info->returnType; + } + if ($type instanceof ScalarType || $type instanceof EnumType) { + return true; + } elseif ($resolvedClass === null && $resolvedMethod === null) { + return true; + } else { + return $caller->canCall($resolvedClass, $resolvedMethod); + } + } + } + } + + private static function processAuthDirective( + array $args, + $resolvedClass, + $resolvedMethod, + $resolvedObject = null + ) { + if (isset($args['constants'])) { + $constants = $args['constants']->values; + // No constants => public access + if (count($constants) === 0) { + return true; + } + foreach ($constants as $constant) { + if (AuthUtils::hasPermission(constant($constant))) { + return true; + } + } + } + /** @var EnumValueNode $hookVal */ + $hook = $args['hook']; + if (isset($hook)) { + $hookName = $hook->value; + switch ($hookName) { + case 'ViewShow': + /** @var MyRadio_Show|MyRadio_Season|MyRadio_Timeslot $show */ + $show = $resolvedObject; + if (AuthUtils::hasPermission(AUTH_VIEWMEMBERSHOWS)) { + return true; + } + return $show->isCurrentUserAnOwner(); + case 'ViewMember': + case 'ViewOfficer': + /** @var MyRadio_User $member */ + $member = $resolvedObject; + if (AuthUtils::hasPermission(AUTH_VIEWOTHERMEMBERS)) { + return true; + } + if ($hookName === 'viewOfficer' && $member->isOfficer()) { + return true; + } + return $member->getID() === (MyRadio_User::getCurrentOrSystemUser()->getID()); + default: + throw new MyRadioException("Unknown auth hook $hookName"); + } + } + return false; + } + + /** + * Given a resolved value, converts it into a scalar type if appropriate. + * + * For example, given a number or date string on a Date/Time/DateTime type field, + * this method will format it accordingly. + * @param ResolveInfo $info + * @param mixed $value + * @return mixed + */ + public static function processScalarIfNecessary(ResolveInfo $info, $value) + { + // Resolve enums if necessary + if ($info->returnType instanceof EnumType) { + if (isset(self::$ENUM_MAPPINGS[$info->returnType->name])) { + return self::$ENUM_MAPPINGS[$info->returnType->name][$value]; + } + } + // If the field is not a scalar, don't touch it. + if ($info->returnType instanceof ScalarType) { + $type = $info->returnType->name; + } elseif ($info->returnType instanceof WrappingType + && $info->returnType->getWrappedType(true) instanceof ScalarType + ) { + $type = $info->returnType->getWrappedType(true)->name; + } else { + return $value; + } + // If it has a @coerce directive, do as it says + $coerceDirective = self::getDirectiveByName($info, 'coerce'); + if ($coerceDirective !== null) { + foreach (self::getDirectiveArguments($coerceDirective)['hooks']->values as $hook) { + switch ($hook->value) { + case "FalseToNull": + if ($value === false) { + $value = null; + } + break; + default: + $val = $hook->value; + throw new MyRadioException("Unknown coerce hook $val"); + } + } + } + switch ($type) { + case "Int": + case "Float": + case "String": + case "Boolean": + case "ID": + case "HTMLString": + // Passed through directly + return $value; + case "Date": + case "Time": + case "DateTime": + // If the value is a number, assume it's a UNIX timestamp. If not, try and parse it. + if ($value === null) { + return $value; + } elseif (is_numeric($value)) { + $val_unix = (float) $value; + } else { + $val_unix = strtotime($value); + if ($val_unix === false) { + throw new MyRadioException("Failed to parse datetime $value"); + } + } + switch ($type) { + case "Date": + return date("Y-m-d", $val_unix); + case "Time": + return date("H:i:sP", $val_unix); + case "DateTime": + return date("Y-m-d\TH:i:sP", $val_unix); + } + break; + case "Duration": + // If it's a number, assume it's seconds. + if ($value === null) { + return $value; + } elseif (is_numeric($value)) { + $interval = new \DateInterval("PT${value}S"); + } else { + $data = date_parse($value); + $interval = new \DateInterval( + sprintf( + "P%02dY%02dM%02dDT%02dH%02dM%02dS", + $data['year'], + $data['month'], + $data['day'], + $data['hour'], + $data['minute'], + $data['second'] + ) + ); + } + return $interval->format("%H:%M:%S"); + default: + throw new MyRadioException("Unknown scalar type $type!"); + } + } + + public static function invokeNamed(\ReflectionMethod $meth, $object = null, $args = []) + { + $methArgs = $meth->getParameters(); + // If it has no arguments, we can just invoke it directly. + if (count($methArgs) === 0) { + return $meth->invoke($object); + } else { + // Overwrite them in the parameters array to ensure we call them in the correct order + foreach ($methArgs as &$param) { + $name = $param->getName(); + if (isset($args[$name])) { + $param = $args[$name]; + } else { + try { + $param = $param->getDefaultValue(); + } catch (\ReflectionException $e) { + $methName = $meth->getName(); + $obj = $meth->getDeclaringClass()->getName(); + throw new MyRadioException("Missing parameter $name in call to $obj::$methName"); + } + } + } + return $meth->invokeArgs($object, $methArgs); + } + } +} diff --git a/src/Classes/MyRadio/MyRadioAuthenticator.php b/src/Classes/MyRadio/MyRadioAuthenticator.php deleted file mode 100644 index 2073088ee..000000000 --- a/src/Classes/MyRadio/MyRadioAuthenticator.php +++ /dev/null @@ -1,49 +0,0 @@ - - */ -interface MyRadioAuthenticator { - /** - * @param String $user The username (a full email address, or the prefix - * if it matches Config::$eduroam_domain). - * @param String $password The provided password. - * @return MyRadio_User|false Map the credentials to a MyRadio User on success, or - * return false on failure. - */ - public function validateCredentials($user, $password); - - /** - * @param String $user The username (a full email address, or the prefix - * if it matches Config::$eduroam_domain). - * @return Array A list of IDs for the permission flags this user should be - * granted. - */ - public function getPermissions($user); - - /** - * @param String $user The username (a full email address, or the prefix - * if it matches Config::$eduroam_domain). - * @return boolean Whether the reset has happened or not. MyRadio will stop - * attempting resets once one Authenticator has return true. - */ - public function resetAccount($user); - - /** - * A friendly name to explain to users what the login method is - */ - public function getFriendlyName(); - - /** - * A friendly description to explain to users what selecting this authenticator does - */ - public function getDescription(); - - /** - * A friendly message to display on the "I've forgotten my password" page - */ - public function getResetFormMessage(); -} diff --git a/src/Classes/MyRadio/MyRadioDefaultAuthenticator.php b/src/Classes/MyRadio/MyRadioDefaultAuthenticator.php index 38fbc208d..f79dd31df 100644 --- a/src/Classes/MyRadio/MyRadioDefaultAuthenticator.php +++ b/src/Classes/MyRadio/MyRadioDefaultAuthenticator.php @@ -1,19 +1,35 @@ */ -class MyRadioDefaultAuthenticator extends Database implements MyRadioAuthenticator { - +class MyRadioDefaultAuthenticator extends \MyRadio\Database implements \MyRadio\Iface\MyRadioAuthenticator +{ /** - * Sets up the DB connection + * Sets up the DB connection. */ - public function __construct() { - $this->db = pg_connect('host=' . Config::$db_hostname . ' port=5432 dbname=' . Config::$db_name . ' - user=' . Config::$auth_db_user . ' password=' . Config::$auth_db_pass); + public function __construct() + { + if (empty(Config::$auth_db_user)) { + $this->db = pg_connect( + 'host='.Config::$db_hostname.' port=5432 dbname='.Config::$db_name + .' user='.Config::$db_user.' password='.Config::$db_pass + ); + } else { + $this->db = pg_connect( + 'host='.Config::$db_hostname.' port=5432 dbname='.Config::$db_name + .' user='.Config::$auth_db_user.' password='.Config::$auth_db_pass + ); + } if (!$this->db) { //Database isn't working. Throw an EVERYTHING IS BROKEN Exception throw new MyRadioException('Database Connection Failed!', MyRadioException::FATAL); @@ -21,23 +37,29 @@ public function __construct() { } /** - * Tears down the DB connection + * Tears down the DB connection. */ - public function __destruct() { - pg_close($this->db); + public function __destruct() + { + if (!empty(Config::$auth_db_user)) { + pg_close($this->db); + } } /** - * @param String $user The username (a full email address, or the prefix - * if it matches Config::$eduroam_domain). - * @param String $password The provided password. + * @param string $user The username (a full email address, or the prefix if it matches Config::$eduroam_domain). + * if it matches Config::$eduroam_domain). + * @param string $password The provided password. + * * @return MyRadio_User|false Map the credentials to a MyRadio User on success, or - * return false on failure. + * return false on failure. + * * @todo Require change password * @todo Account lock * @todo Make timing consistent */ - public function validateCredentials($user, $password) { + public function validateCredentials($user, $password) + { //If local passwords are disabled, don't even try. if (!Config::$enable_local_passwords) { return false; @@ -47,8 +69,11 @@ public function validateCredentials($user, $password) { if (!$user) { return false; } else { - $r = $this->fetch_column('SELECT password FROM ' - . 'public.member_pass WHERE memberid=$1', [$user->getID()]); + $r = $this->fetchColumn( + 'SELECT password FROM ' + .'public.member_pass WHERE memberid=$1', + [$user->getID()] + ); if (empty($r)) { return false; } else { @@ -58,10 +83,13 @@ public function validateCredentials($user, $password) { if (substr($r[0], 0, 3) === '$1$') { //Upgrade password $new_password = $this->encrypt($password); - $this->query('UPDATE member_pass SET password=$1 WHERE memberid=$2', - [$new_password, $user->getID()]); + $this->query( + 'UPDATE member_pass SET password=$1 WHERE memberid=$2', + [$new_password, $user->getID()] + ); } unset($password, $new_password, $r); //Just to be safe. + return $user; } else { return false; @@ -73,27 +101,32 @@ public function validateCredentials($user, $password) { /** * This authenticator does not add any extra permissions to the ones already * defined internally. - * - * @param String $user The username (a full email address, or the prefix - * if it matches Config::$eduroam_domain). - * @return Array A list of IDs for the permission flags this user should be - * granted. These are in addition to the ones computed by MyRadio - * internally. + * + * @param string $user The username (a full email address, or the prefix + * if it matches Config::$eduroam_domain). + * + * @return array A list of IDs for the permission flags this user should be + * granted. These are in addition to the ones computed by MyRadio + * internally. */ - public function getPermissions($user) { + public function getPermissions($user) + { return []; } /** * This authenticator will reset the password in the MyRadio database. - * - * @param String $user The username (a full email address, or the prefix - * if it matches Config::$eduroam_domain). - * @return boolean Whether the reset has happened or not. MyRadio will stop - * attempting resets once one Authenticator has return true. - * @todo implement password resets + * + * @param string $user The username (a full email address, or the prefix if it matches Config::$eduroam_domain). + * if it matches Config::$eduroam_domain). + * + * @return bool Whether the reset has happened or not. MyRadio will stop + * attempting resets once one Authenticator has return true. + * + * @todo implement password resets */ - public function resetAccount($user) { + public function resetAccount($user) + { $result = MyRadio_User::findByEmail($user); if (!$result) { return false; @@ -104,22 +137,25 @@ public function resetAccount($user) { //Create a reset token do { $token = CoreUtils::randomString(64); - } while ($db->num_rows( - $db->query('SELECT * FROM myury.password_reset_token ' - . 'WHERE token=$1', [$token])) > 0); + } while ($db->numRows($db->query('SELECT * FROM myury.password_reset_token WHERE token=$1', [$token])) > 0); //Add the reset token to the database (expires in 48h) $expires = CoreUtils::getTimestamp(time() + 86400 * 2); - $db->query('INSERT INTO myury.password_reset_token ' - . '(token, memberid, expires) VALUES ($1, $2, $3)', [$token, $result->getID(), $expires]); + $db->query( + 'INSERT INTO myury.password_reset_token (token, memberid, expires) VALUES ($1, $2, $3)', + [$token, $result->getID(), $expires] + ); //Email the user - MyRadioEmail::sendEmailToUser($result, 'Password reset', 'Hello,' - . 'A password reset has been requested for the ' . Config::$short_name - . ' account associated with this email address. If you did not request' - . ' this email, please ignore it.
' - . 'Click here to finish resetting your password.
' + MyRadioEmail::sendEmailToUser( + $result, + 'Password reset', + 'Hello,' + .'A password reset has been requested for the '.Config::$short_name + .' account associated with this email address. If you did not request' + .' this email, please ignore it.
' + .''.URLUtils::makeURL('MyRadio', 'pwChange', ['token' => $token]).'
' + .'Copy that link and paste it into your searchbar to finish resetting your password.
' ); return true; @@ -127,77 +163,92 @@ public function resetAccount($user) { /** * Encrypts a password using MyRadio's prefered technique. - * - * @param String $string The string to be encrypted - * @return String The encrypted string + * + * @param string $string The string to be encrypted + * + * @return string The encrypted string */ - private function encrypt($string) { - return crypt($string, '$6$rounds=4567$' . $this->randomString()); + private function encrypt($string) + { + return crypt($string, '$6$rounds=4567$'.$this->randomString()); } /** * Generates a cryptographically secure pseudorandom string, for Salt purposes. + * * @param int $pwdLen The length of the string to generate - * @return String a random string of length $pwdLen + * + * @return string a random string of length $pwdLen */ - private function randomString($pwdLen = 32) { + private function randomString($pwdLen = 32) + { return base64_encode(openssl_random_pseudo_bytes($pwdLen)); } - public function getFriendlyName() { - return Config::$short_name . '-only'; + public function getFriendlyName() + { + return Config::$short_name.'-only'; } - public function getDescription() { + public function getDescription() + { return 'By choosing this option, we will always use your unique ' - . $this->getFriendlyName() . ' username and password to log you in. ' - . 'This password is completely seperate to any other details ' - . 'you may have.'; + .$this->getFriendlyName() + .' username and password to log you in. ' + .'This password is completely seperate to any other details ' + .'you may have.'; } - public function removePassword($memberid) { + public function removePassword($memberid) + { $this->query('UPDATE member_pass SET password=NULL WHERE memberid=$1', [$memberid]); } /** * Sets a User's password. - * - * @param User $user - * @param String $password + * + * @param User $user + * @param string $password */ - public function setPassword(MyRadio_User $user, $password) { + public function setPassword(MyRadio_User $user, $password) + { $password = $this->encrypt($password); //Insert or Update - $result = $this->query('UPDATE member_pass SET password=$1 WHERE memberid=$2', - [$password, $user->getID()]); - - //Set require_password_change to false - $user->setRequirePasswordChange(false); - + $result = $this->query( + 'UPDATE member_pass SET password=$1 WHERE memberid=$2', + [$password, $user->getID()] + ); + if (pg_affected_rows($result) === 0) { - $this->query('INSERT INTO member_pass (memberid, password) VALUES ($1, $2)', - [$user->getID(), $password]); + $this->query( + 'INSERT INTO member_pass (memberid, password) VALUES ($1, $2)', + [$user->getID(), $password] + ); } + + //Set require_password_change to false + $user->setRequirePasswordChange(false); } - public function getResetFormMessage() { + public function getResetFormMessage() + { //If this is not the only authenticator, mention this will create a //MyRadio specific login. if (sizeof(Config::$authenticators) > 1) { $others = ''; foreach (Config::$authenticators as $auth) { if ($auth !== __CLASS__) { - $a = new $auth; - $others .= (empty($others) ? '' : ', ') . $a->getFriendlyName(); + $a = new $auth(); + $others .= (empty($others) ? '' : ', ').$a->getFriendlyName(); } } - return 'If you do not currently have a ' . Config::$short_name . + + return 'If you do not currently have a '.Config::$short_name. ' password, this will enable you to set one up which is' - . ' seperate to your ' . $others . ' password.'; + .' seperate to your '.$others.' password.'; } else { - return 'If you\'ve forgotten your ' . Config::$short_name . ' password, you' - . ' can fill in this form to have a reset email sent to you.'; + return 'If you\'ve forgotten your '.Config::$short_name.' password, you' + .' can fill in this form to have a reset email sent to you.'; } } - } diff --git a/src/Classes/MyRadio/MyRadioForm.php b/src/Classes/MyRadio/MyRadioForm.php index 573770506..a80d3602b 100644 --- a/src/Classes/MyRadio/MyRadioForm.php +++ b/src/Classes/MyRadio/MyRadioForm.php @@ -1,124 +1,148 @@ - * @version 20140102 - * @package MyRadio_Core */ -class MyRadioForm { - +class MyRadioForm +{ /** - * The name of the form - * @var String + * The name of the form. + * + * @var string */ private $name = 'autofrm'; /** * The module that it will submit to - * Best practice is this should be the current module - * @var String + * Best practice is this should be the current module. + * + * @var string */ private $module; /** - * The action that it will submit to - * @var String + * The action that it will submit to. + * + * @var string */ private $action; /** - * Whether to enable detailed output of what is happening (or isn't) + * Whether to enable detailed output of what is happening (or isn't). + * * @var bool */ private $debug = false; /** - * Additional classes to add to the base form element - * @var Array + * Additional classes to add to the base form element. + * + * @var array */ - private $classes = array(); + private $classes = []; /** - * Whether to enable Form Validation + * Whether to enable Form Validation. + * * @var bool */ private $validate = true; /** - * Whether to use GET instead of POST + * Whether to use GET instead of POST. + * * @var bool */ private $get = false; /** * The Twig template to use for the form. Must be form.twig or a child. - * @var String + * + * @var string */ private $template = 'form.twig'; /** - * The form fields in the form (an array of MyRadioFormField objects) - * @var Array + * The form fields in the form (an array of MyRadioFormField objects). + * + * @var array */ - private $fields = array(); + private $fields = []; /** - * The title of the page (the human readable name) - * @var String + * The title of the page (the human readable name). + * + * @var string */ private $title = null; /** - * Logging output - * @var Array + * The subtitle of the page (a smaller human readable description). + * + * @var string */ - private $debug_log = array(); - + private $subtitle = null; + /** - * Enable recaptcha requirement - * @var boolean + * Logging output. + * + * @var array + */ + private $debug_log = []; + + /** + * Enable recaptcha requirement. + * + * @var bool */ private $captcha = false; /** - * Fields that cannot be edited by params - * @var Array + * Fields that cannot be edited by params. + * + * @var array */ - private $restricted_fields = array('name', 'module', 'action', 'fields', 'restricted_fields', 'debug_log'); + private $restricted_fields = ['name', 'module', 'action', 'fields', 'restricted_fields', 'debug_log']; /** - * Creates a new MyRadioForm object with the given parameters - * @param string $name The name/id of the form + * Creates a new MyRadioForm object with the given parameters. + * + * @param string $name The name/id of the form * @param string $module The module the form submits to * @param string $action The action the form submits to - * @param array $params One or more of the following additional settings"; // carriage return + newline ob_start(); - debug_print_backtrace(); + //debug_print_backtrace(); Sometimes these have passwords. $trace = str_replace("\n", $rtnl, ob_get_clean()); - $message = $errstr . $rtnl . $rtnl . $trace; + $message = $errstr.$rtnl.$rtnl.$trace; if (class_exists('MyRadioEmail') && class_exists('Config')) { $sent = MyRadioEmail::sendEmailToList( - MyRadio_List::getByName(Config::$error_report_email), - 'MyRadio error alert', $message); + MyRadio_List::getByName(Config::$error_report_email), + 'MyRadio error alert', + $message + ); if (!$sent) { error_log('FAIL: mail failed to send error alert email.'); // Good chance that if the mail command failed, // then error_log will also fail to send mail, // but we have to try. - error_log(__FUNCTION__ . ' failed! Check server logs!'); + error_log(__FUNCTION__.' failed! Check server logs!'); throw new MyRadioException('Failed to send email error alert.', 500); } } } } } - - /** - * Returns the number of errors encountered during execution. - * @return int - */ - public static function getErrorCount() { - return self::$count; - } - - public static function resetErrorCount() { - self::$count = 0; - } - -} \ No newline at end of file +} diff --git a/src/Classes/MyRadioException.php b/src/Classes/MyRadioException.php index e3b4e294c..bcc1bf8e9 100644 --- a/src/Classes/MyRadioException.php +++ b/src/Classes/MyRadioException.php @@ -1,129 +1,215 @@ - * @version 20130711 - * @package MyRadio_Core + * and logging. */ -class MyRadioException extends RuntimeException { - - const FATAL = -1; - - private static $count = 0; - - /** - * Extends the default session by enabling useful output - * @param String $message A nice message explaining what is going on - * @param int $code A number representing the problem. -1 Indicates fatal. - * @param \Exception $previous - */ - public function __construct($message, $code = 500, Exception $previous = null) { - self::$count++; - if (self::$count > Config::$exception_limit) { - trigger_error('Exception limit exceeded. Futher exceptions will not be reported.'); - return; - } - - $trace = $this->getTrace(); - $traceStr = $this->getTraceAsString(); - if ($previous) { - $trace = array_merge($trace, $previous->getTrace()); - $traceStr .= "\n\n".$this->getTraceAsString(); - } - - parent::__construct((string) $message, (int) $code, $previous); +class MyRadioException extends \RuntimeException implements ClientAware +{ + const FATAL = -1; - if (defined('SILENT_EXCEPTIONS') && SILENT_EXCEPTIONS) { - return; + private static $count = 0; + private $error; + private $trace; + private $traceStr; + + public function getCodeName() + { + switch ($this->code) { + case 400: + return 'Bad Request'; + case 401: + return 'Authentication Required'; + case 403: + return 'Unauthorized'; + case 404: + return 'File Not Found'; + case 405: + return 'Method Not Allowed'; + case 418: + return 'I\'m a Teapot'; + case 500: + return 'Internal Server Error'; + } } - //Set up the Exception - $error = "
MyRadio has encountered a problem processing this request.
-| Message | {$this->getMessage()} |
| Location | {$this->getFile()}:{$this->getLine()} |
| Trace | " . nl2br($traceStr) . " |
A fatal error has occured that has prevented MyRadio from performing the action you requested. ' - . 'The computing team have been notified.
I'm sorry, but you don't have permission to access this page.
+{$this->getMessage()}
"; } else { - echo $error; + $this->error = "MyRadio has encountered a problem processing this request.
+| Message: | {$this->getMessage()} |
| Location: | {$this->getFile()}:{$this->getLine()} |
| Trace: | " . nl2br($this->traceStr) . ' |
Sorry, we encountered an error and are unable to continue. Please try again later.
' + .''.$this->message.'
' + .'Computing Team have been notified.
' + .'' . $warning . '
'; + } else { + $prefix = ''; + } + + $source = $_SERVER['REMOTE_ADDR']; + + self::$db->query( + 'INSERT INTO sis2.messages (timeslotid, commtypeid, sender, subject, content, statusid, comm_source) + VALUES ($1, $2, $3, $4, $5, $6, $7)', + [ + $this->getID(), // timeslot + 3, // commtypeid : website + 'MyRadio', // sender + substr($message, 0, 144), // subject : trancated message + $prefix . $message, // content : message with prefix + $junk ? 4 : 1, // statusid : junk or unread + $source, // comm_source : IP + ] + ); + + return $this; + } + + /** + * Signs the given user into the timeslot to say they were + * on air at this time, if they haven't been signed in already. + * * @param MyRadio_User $member + * @param int|string $locationid */ - public function signIn(MyRadio_User $member) { - self::$db->query('INSERT INTO sis2.member_signin' - . ' (show_season_timeslot_id, memberid, signerid)' - . ' VALUES ($1, $2, $3)', [$this->getID(), $member->getID(), MyRadio_User::getInstance()->getID()]); + public function signIn(MyRadio_User $member, $locationid) + { + // If member already signed in for whatever reason, don't bother trying again. + $signedIn = !empty(self::$db->fetchOne( + 'SELECT * FROM sis2.member_signin + WHERE show_season_timeslot_id=$1 AND memberid=$2', + [$this->getID(), $member->getID()] + )); + if (!$signedIn) { + self::$db->query( + 'INSERT INTO sis2.member_signin (show_season_timeslot_id, memberid, signerid, location) + VALUES ($1, $2, $3, $4)', + [$this->getID(), $member->getID(), MyRadio_User::getInstance()->getID(), $locationid] + ); + } + } + + public function signInGuests(string $guestInfo, $locationid) + { + self::$db->query( + 'INSERT INTO sis2.guest_signin (show_season_timeslot_id, signerid, location, guest_info) + VALUES ($1, $2, $3, $4)', + [ + $this->getID(), + MyRadio_User::getInstance()->getID(), + $locationid, + htmlspecialchars($guestInfo, ENT_QUOTES) + ] + ); + } + + public function toIcalEvent(): Event + { + return Event::create($this->getMeta('title')) + ->startsAt((new DateTime())->setTimestamp($this->getStartTime())) + ->endsAt((new DateTime())->setTimestamp($this->getEndTime())) + ->description(html_entity_decode(strip_tags($this->getMeta('description')))); } + public static function getCancelForm() + { + return (new MyRadioForm( + 'sched_cancel', + 'Scheduler', + 'cancelEpisode', + [ + 'debug' => false, + 'title' => 'Cancel Episode', + ] + ) + )->addField( + new MyRadioFormField( + 'reason', + MyRadioFormField::TYPE_BLOCKTEXT, + ['label' => 'Please explain why this Episode should be removed from the Schedule'] + ) + )->addField( + new MyRadioFormField( + 'show_season_timeslot_id', + MyRadioFormField::TYPE_HIDDEN, + ['value' => $_REQUEST['show_season_timeslot_id']] + ) + ); + } + + public function getMoveForm() + { + $title = $this->getMeta('title') . ' - ' . CoreUtils::happyTime($this->getStartTime()); + return (new MyRadioForm( + 'sched_move', + 'Scheduler', + 'moveEpisode', + [ + 'debug' => false, + 'title' => 'Move Episode', + 'subtitle' => "Moving $title" + ] + ))->addField(new MyRadioFormField( + 'grp_info', + MyRadioFormField::TYPE_SECTION, + [ + 'label' => 'New Time', + 'explanation' => 'Enter the new time to move the episode to. Take care with the end time.' + ] + ))->addField(new MyRadioFormField( + 'new_start_time', + MyRadioFormField::TYPE_DATETIME, + [ + 'label' => 'New Start Time', + 'value' => date('d/m/Y H:i', $this->getStartTime()) + ] + ))->addField(new MyRadioFormField( + 'new_end_time', + MyRadioFormField::TYPE_DATETIME, + [ + 'label' => 'New End Time', + 'value' => date('d/m/Y H:i', $this->getEndTime()) + ] + ))->addField(new MyRadioFormField( + 'grp_info_close', + MyRadioFormField::TYPE_SECTION_CLOSE, + [] + ))->addField(new MyRadioFormField( + 'show_season_timeslot_id', + MyRadioFormField::TYPE_HIDDEN, + ['value' => $this->getID()] + )); + } + + public static function getGraphQLTypeName() + { + return 'Timeslot'; + } } diff --git a/src/Classes/ServiceAPI/MyRadio_Track.php b/src/Classes/ServiceAPI/MyRadio_Track.php index 5a33cb6cc..12d2a4dc6 100644 --- a/src/Classes/ServiceAPI/MyRadio_Track.php +++ b/src/Classes/ServiceAPI/MyRadio_Track.php @@ -1,322 +1,803 @@ - * @package MyRadio_Core - * @uses \Database - * @todo Cache this + * The MyRadio_Track class provides and stores information about a Track. + * + * @uses \Database */ -class MyRadio_Track extends ServiceAPI { +class MyRadio_Track extends ServiceAPI +{ /** - * The number of the Track on a Record + * The number of the Track on a Record. + * * @var int */ private $number; /** - * The title of the Track - * @var String + * The title of the Track. + * + * @var string */ private $title; /** - * The Artist of the Track + * The Artist of the Track. + * * @var int */ private $artist; /** - * The length of the Track, in seconds + * The length of the Track, in seconds. + * * @var int */ private $length; /** * Don't use this. + * * @deprecated - * @var String + * + * @var string */ private $duration; /** - * The genreid of the Track + * The genreid of the Track. + * * @var char */ private $genre; /** - * How long the intro (non-vocal) part of the track is, in seconds + * How long the intro (non-vocal) part of the track is, in seconds. + * * @var int */ private $intro; + /** + * The start time of an ending segment to the track, in seconds. + * + * @var int + */ + private $outro; + /** * Whether the track is clean:sname, fname
- * college: The name of the member's college (not the ID!)
- * paid: How much the member has paid this year
- */
- public static function getAllMembers() {
- //Return the object if it is cached
- self::$allMembers = self::$cache->get('MyRadioProfile_allMembers');
- if (self::$allMembers === false) {
- self::wakeup();
- self::$allMembers =
- self::$db->fetch_all('SELECT member.memberid, sname || \', \' || fname AS name, l_college.descr AS college, paid
- FROM member LEFT JOIN (SELECT * FROM member_year WHERE year = $1) AS member_year
- ON ( member.memberid = member_year.memberid ), l_college
- WHERE member.college = l_college.collegeid
- ORDER BY sname ASC', array(CoreUtils::getAcademicYear()));
- self::$cache->set('MyRadioProfile_allMembers', self::$allMembers);
+class Profile extends ServiceAPI
+{
+ /**
+ * Stores an Array representation of the current officers from the getCurrentOfficers function when it is first
+ * called. This is also cached using a CacheProvider.
+ *
+ * @var array
+ */
+ private static $currentOfficers = null;
+ /**
+ * Stores an Array representation of the current officerships and members holding them from the getOfficers function
+ * when it is first called This is also cached using a CacheProvider.
+ *
+ * @var array
+ */
+ private static $officers = null;
+
+ /**
+ * Clears (well, deletes) the cache objects used in Profile.
+ */
+ public static function clearCache()
+ {
+ self::$cache->delete('MyRadioProfile_allMembers');
+ self::$cache->delete('MyRadioProfile_currentOfficers');
+ self::$cache->delete('MyRadioProfile_officers');
}
-
- return self::$allMembers;
- }
-
- /**
- * Returns an Array representation of this year's URY Members. On first run, this is cached locally in the class, and
- * shared in the CacheProvider until the Cache is cleared
- *
- * @return Array A two-dimensional Array, each element in the first dimension container the following details about
- * a member, sorted by their name:
- *
- * memberid: The user's unique memberid
- * name: The user's last and first names formatted as sname, fname
- * college: The name of the member's college (not the ID!)
- * paid: How much the member has paid this year
- */
- public static function getThisYearsMembers() {
- self::wakeup();
- return self::$db->fetch_all('SELECT member.memberid, sname || \', \' || fname AS name, l_college.descr AS college, paid
- FROM member INNER JOIN (SELECT * FROM member_year WHERE year = $1) AS member_year
- ON ( member.memberid = member_year.memberid ), l_college
- WHERE member.college = l_college.collegeid
- ORDER BY sname ASC', array(CoreUtils::getAcademicYear()));
- }
- /**
- * Returns an Array representation of the current URY Officers. On first run, this is cached locally in the class, and
- * shared in the CacheProvider until the Cache is cleared
- *
- * @return Array A two-dimensional Array, each element in the first dimension container the following details about
- * officer, sorted by their officer ordering:
- *
- * team: The team the officer is in
- * officership: The current position held
- * name: The user's last and first names formatted as sname, fname
- * memberid: The user's unique memberid
- */
- public static function getCurrentOfficers() {
- //Return the object if it is cached
- self::$currentOfficers = self::$cache->get('MyRadioProfile_currentOfficers');
- if (self::$currentOfficers === false) {
- self::wakeup();
- self::$currentOfficers =
- self::$db->fetch_all('SELECT team.team_name AS team, officer.officer_name AS officership, sname || \', \' || fname AS name, member.memberid
- FROM member, officer, member_officer, team
- WHERE member_officer.memberid = member.memberid AND officer.officerid = member_officer.officerid AND officer.teamid = team.teamid AND member_officer.till_date IS NULL
- ORDER BY team.ordering, officer.ordering, sname');
- self::$cache->set('MyRadioProfile_currentOfficers', self::$currentOfficers);
+ /**
+ * Returns an Array representation of this year's URY Members.
+ *
+ * @return array A two-dimensional Array, each element in the first dimension container the following details about
+ * a member, sorted by their name:
+ *
+ * memberid: The user's unique memberid
+ * name: The user's last and first names formatted as sname, fname
+ * college: The name of the member's college (not the ID!)
+ * paid: How much the member has paid this year
+ */
+ public static function getThisYearsMembers()
+ {
+ return self::getMembersForYear(CoreUtils::getAcademicYear());
+ }
+
+ /**
+ * Returns an Array representation of the given year's URY Members.
+ *
+ * @return array A two-dimensional Array, each element in the first dimension container the following details about
+ * a member, sorted by their name:
+ *
+ * memberid: The user's unique memberid
+ * name: The user's last and first names formatted as sname, fname
+ * college: The name of the member's college (not the ID!)
+ * paid: How much the member has paid this year
+ */
+ public static function getMembersForYear($year)
+ {
+ self::wakeup();
+
+ return self::$db->fetchAll(
+ 'SELECT member.memberid, sname || \', \' || fname AS name, l_college.descr AS college, paid, email, eduroam
+ FROM member INNER JOIN (SELECT * FROM member_year WHERE year = $1) AS member_year
+ ON ( member.memberid = member_year.memberid ), l_college
+ WHERE member.college = l_college.collegeid
+ ORDER BY sname ASC',
+ [$year]
+ );
}
-
- return self::$currentOfficers;
- }
-
-
- /**
- * Returns an Array representation of the current URY officerships and the member holding them. On first run, this is cached locally in the class, and
- * shared in the CacheProvider until the Cache is cleared
- *
- * @return Array A two-dimensional Array, each element in the first dimension container the following details about
- * a member, sorted by their name:
- *
- * team: The team the officer is in
- * officership: The current position held
- * name: The user's last and first names formatted as fname sname (If officership is filled else NULL)
- * memberid: The user's unique memberid (If officership is filled else NULL)
- */
- public static function getOfficers() {
- //Return the object if it is cached
- self::$officers = self::$cache->get('MyRadioProfile_officers');
- if (self::$officers === false) {
- self::wakeup();
- self::$officers =
- self::$db->fetch_all('SELECT team.team_name AS team, officer.type, officer.officer_name AS officership,
- fname || \' \' || sname AS name, member.memberid, officer.officerid
- FROM team
- LEFT JOIN officer ON team.teamid = officer.teamid AND officer.status = \'c\'
- LEFT JOIN member_officer ON officer.officerid = member_officer.officerid AND member_officer.till_date IS NULL
- LEFT JOIN member ON member_officer.memberid = member.memberid
- WHERE team.status = \'c\' AND officer.type != \'m\'
- ORDER BY team.ordering, officer.ordering, sname');
- self::$cache->set('MyRadioProfile_officers', self::$officers);
+
+ /**
+ * Returns an Array representation of the current URY Officers. On first run, this is cached locally in the class,
+ * and shared in the CacheProvider until the Cache is cleared.
+ *
+ * @return array A two-dimensional Array, each element in the first dimension container the following details about
+ * officer, sorted by their officer ordering:
+ *
+ * team: The team the officer is in
+ * officership: The current position held
+ * name: The user's last and first names formatted as sname, fname
+ * memberid: The user's unique memberid
+ */
+ public static function getCurrentOfficers()
+ {
+ //Return the object if it is cached
+ self::$currentOfficers = self::$cache->get('MyRadioProfile_currentOfficers');
+ if (self::$currentOfficers === false) {
+ self::wakeup();
+ self::$currentOfficers = self::$db->fetchAll(
+ 'SELECT team.team_name AS team, officer.officer_name AS officership,
+ sname || \', \' || fname AS name, member.memberid
+ FROM member, officer, member_officer, team
+ WHERE member_officer.memberid = member.memberid
+ AND officer.officerid = member_officer.officerid
+ AND officer.teamid = team.teamid
+ AND member_officer.till_date IS NULL
+ ORDER BY team.ordering, officer.ordering, sname'
+ );
+ self::$cache->set('MyRadioProfile_currentOfficers', self::$currentOfficers);
+ }
+
+ return self::$currentOfficers;
+ }
+
+ /**
+ * Returns an Array representation of the current URY officerships and the member holding them. On first run, this
+ * is cached locally in the class, and shared in the CacheProvider until the Cache is cleared.
+ *
+ * @return array A two-dimensional Array, each element in the first dimension container the following details about
+ * a member, sorted by their name:
+ *
+ * team: The team the officer is in
+ * officership: The current position held
+ * name: The user's last and first names formatted as fname sname (If officership is filled else NULL)
+ * memberid: The user's unique memberid (If officership is filled else NULL)
+ */
+ public static function getOfficers()
+ {
+ //Return the object if it is cached
+ self::$officers = self::$cache->get('MyRadioProfile_officers');
+ if (self::$officers === false) {
+ self::wakeup();
+ self::$officers = self::$db->fetchAll(
+ 'SELECT team.team_name AS team, officer.type, officer.officer_name AS officership,
+ fname || \' \' || sname AS name, member.memberid, officer.officerid
+ FROM team
+ LEFT JOIN officer ON team.teamid = officer.teamid AND officer.status = \'c\'
+ LEFT JOIN member_officer ON officer.officerid = member_officer.officerid
+ AND member_officer.till_date IS NULL
+ LEFT JOIN member ON member_officer.memberid = member.memberid
+ WHERE team.status = \'c\' AND officer.type != \'m\'
+ ORDER BY team.ordering, officer.ordering, sname'
+ );
+ self::$cache->set('MyRadioProfile_officers', self::$officers);
+ }
+
+ return self::$officers;
}
-
- return self::$officers;
- }
-}
\ No newline at end of file
+}
diff --git a/src/Classes/ServiceAPI/ServiceAPI.php b/src/Classes/ServiceAPI/ServiceAPI.php
index 654a1b341..e2d2209ed 100644
--- a/src/Classes/ServiceAPI/ServiceAPI.php
+++ b/src/Classes/ServiceAPI/ServiceAPI.php
@@ -1,156 +1,215 @@
- * @version 20130707
- * @package MyRadio_Core
- * @uses \Database
- * @uses \CacheProvider
+ * @uses \Database
+ * @uses \CacheProvider
*/
-abstract class ServiceAPI implements IServiceAPI, MyRadio_DataSource {
-
- /**
- * All ServiceAPI subclasses will contain a reference to the Database Singleton
- * @var \Database
- */
- protected static $db = null;
- /**
- * All ServiceAPI subclasses will contain a reference to the CacheProvider Singleton
- * @var \CacheProvider
- */
- protected static $cache = null;
-
- /**
- * Start up the connection to the Database
- */
- protected static function initDB() {
- if (!self::$db) {
- self::$db = Database::getInstance();
- }
- }
-
- /**
- * Start up the connection to the CacheProvider
- */
- protected static function initCache() {
- if (!self::$cache) {
- $cache = Config::$cache_provider;
- self::$cache = $cache::getInstance();
- }
- }
-
- /**
- * A magic function that will reload the Database and CacheProvider after the object has been loaded from Cache
- */
- public function __wakeup() {
- self::wakeup();
- }
-
- public static function wakeup() {
- self::initDB();
- self::initCache();
- }
-
- public static function getInstance($itemid) {
- self::initCache();
- self::initDB();
-
- $class = get_called_class();
- $key = $class::getCacheKey($itemid);
- $cache = self::$cache->get($key);
- if (!$cache) {
- $cache = new $class($itemid);
- self::$cache->set($key, $cache, 86400);
- }
-
- return $cache;
- }
-
- public function toDataSource($full = false) {
- throw new MyRadioException(get_called_class() . ' has not had a DataSource Conversion Method Defined!', 500);
- }
-
- /**
- * Iteratively calls the toDataSource method on all of the objects in the given array, returning the results as
- * a new array.
- * @param Array $array
- * @param bool $full If true, will return expanded data if available.
- * @return Array
- * @throws MyRadioException Throws an Exception if a provided object is not a DataSource
- */
- public static function setToDataSource($array, $full = false) {
- if (!is_array($array)) {
- return $array;
- }
- $result = array();
- foreach ($array as $element) {
- //It must implement the toDataSource method!
- if (!method_exists($element, 'toDataSource')) {
- throw new MyRadioException('Attempted to convert '.get_class($element).' to a DataSource but it not a valid Data Object!', 500);
- } else {
- $result[] = $element->toDataSource($full);
- }
- }
- return $result;
- }
-
- public function __toString() {
- return get_called_class().'-'.$this->getID();
- }
-
- /**
- * Takes an array of IDs, and creates an array of the relevant objects
- * @param int[] $ids
- * @return ServiceAPI[]
- */
- public static function resultSetToObjArray($ids) {
- $response = array();
- $child = get_called_class();
- if (!is_array($ids) or empty($ids)) {
- return [];
- }
- foreach ($ids as $id) {
- $response[] = $child::getInstance($id);
- }
-
- return $response;
- }
-
- protected function __construct() {}
-
- /**
- * Generates the Key string for caching services
- *
- * @param int $id The ID of the object to get the cache key for
- * @return String
- */
- public static function getCacheKey($id) {
- return get_called_class() . '-' . $id;
- }
-
- /**
- * Sets the cache for this object to be the current object state.
- *
- * This should always be called after a setSomething.
- */
- protected function updateCacheObject() {
- self::$cache->set(self::getCacheKey($this->getID()), $this, 3600);
- }
-
- /**
- * Removes singleton instance. Used for memory optimisation for very large
- * requests.
- * @deprecated
- */
- public function removeInstance() {
- return true;
- unset(self::$singletons[self::getCacheKey($this->getID())]);
- }
+abstract class ServiceAPI
+{
+ /**
+ * All ServiceAPI subclasses will contain a reference to the Database Singleton.
+ *
+ * @var Database
+ */
+ protected static $db = null;
+ /**
+ * All ServiceAPI subclasses will contain a reference to the CacheProvider Singleton.
+ *
+ * @var CacheProvider
+ */
+ protected static $cache = null;
+
+ protected $change = false;
+
+ /**
+ * Start up the connection to the Database.
+ */
+ protected static function initDB()
+ {
+ if (!self::$db) {
+ self::$db = Database::getInstance();
+ }
+ }
+
+ /**
+ * Start up the connection to the CacheProvider.
+ */
+ protected static function initCache()
+ {
+ if (!self::$cache) {
+ $cache = Config::$cache_provider;
+ self::$cache = $cache::getInstance();
+ }
+ }
+
+ /**
+ * A magic function that will reload the Database and CacheProvider after the object has been loaded from Cache.
+ */
+ public function __wakeup()
+ {
+ self::wakeup();
+ }
+
+ public static function wakeup()
+ {
+ self::initDB();
+ self::initCache();
+ }
+
+ /**
+ * Loads an instance of this object from either the cache or the DB.
+ * @param $itemid
+ * @return static
+ */
+ public static function getInstance($itemid)
+ {
+ self::initCache();
+ self::initDB();
+
+ $class = get_called_class();
+ $key = self::getCacheKey($itemid);
+ $cache = self::$cache->get($key);
+ if (!$cache) {
+ $cache = $class::factory($itemid);
+ self::$cache->set($key, $cache);
+ }
+
+ return $cache;
+ }
+
+ protected static function factory($itemid)
+ {
+ return new static($itemid);
+ }
+
+ protected function addMixins(&$data, $mixins, $mixin_funcs, $strict = true)
+ {
+ foreach ($mixins as $mixin) {
+ if (array_key_exists($mixin, $mixin_funcs)) {
+ $mixin_funcs[$mixin]($data);
+ } else {
+ throw new MyRadioException('Unsupported mixin '.$mixin, 400);
+ }
+ }
+ }
+
+ /**
+ * Base method for serialising the class.
+ * @param array $mixins Mixins
+ * @return array
+ */
+ public function toDataSource($mixins = [])
+ {
+ throw new MyRadioException(
+ get_called_class() . ' has not had a DataSource Conversion Method Defined!',
+ 500
+ );
+ }
+
+ public function __toString()
+ {
+ return get_called_class().'-'.$this->getID();
+ }
+
+ /**
+ * Takes an array of IDs, and creates an array of the relevant objects.
+ *
+ * @param int[] $ids
+ *
+ * @return static[]
+ */
+ public static function resultSetToObjArray($ids): array
+ {
+ $response = [];
+ $child = get_called_class();
+ if (!is_array($ids) or empty($ids)) {
+ return [];
+ }
+ foreach ($ids as $id) {
+ $response[] = $child::getInstance($id);
+ }
+
+ return $response;
+ }
+
+ /**
+ * Get the name of this ServiceAPI in the GraphQL schema.
+ * @return string
+ */
+ public static function getGraphQLTypeName()
+ {
+ throw new MyRadioException('Tried to call getGraphQLTypeName on a type that it shouldn\'t be called on!');
+ }
+
+ protected function __construct()
+ {
+ }
+
+ public function __destruct()
+ {
+ if ($this->change) {
+ $this->write();
+ }
+ }
+
+ /**
+ * Generates the Key string for caching services.
+ *
+ * @param int $id The ID of the object to get the cache key for
+ *
+ * @return string
+ */
+ public static function getCacheKey($id)
+ {
+ return get_called_class().'-'.$id;
+ }
+
+ /**
+ * Sets the cache for this object to be the current object state.
+ *
+ * This should always be called after a setSomething.
+ * @param bool $forceWrite whether to immediately write to the cache, or wait until the object is destroyed
+ */
+ protected function updateCacheObject(bool $forceWrite = false)
+ {
+ if ($forceWrite) {
+ $this->write();
+ } else {
+ $this->change = true;
+ }
+ }
+
+ /**
+ * Writes this object to the cache.
+ */
+ private function write(): void
+ {
+ $this->change = false;
+ self::$cache->set(self::getCacheKey($this->getID()), $this);
+ }
+
+ /**
+ * Removes singleton instance. Used for memory optimisation for very large
+ * requests.
+ *
+ * @deprecated
+ */
+ public function removeInstance()
+ {
+ return true;
+ unset(self::$singletons[self::getCacheKey($this->getID())]);
+ }
+
}
diff --git a/src/Classes/Vendor/getid3/extension.cache.dbm.php b/src/Classes/Vendor/getid3/extension.cache.dbm.php
deleted file mode 100755
index 7ac4fce90..000000000
--- a/src/Classes/Vendor/getid3/extension.cache.dbm.php
+++ /dev/null
@@ -1,208 +0,0 @@
- //
-// available at http://getid3.sourceforge.net //
-// or http://www.getid3.org //
-/////////////////////////////////////////////////////////////////
-// //
-// extension.cache.dbm.php - part of getID3() //
-// Please see readme.txt for more information //
-// ///
-/////////////////////////////////////////////////////////////////
-// //
-// This extension written by Allan Hansen