Merge branch 'main' into develop
commit
0def46149f
|
@ -0,0 +1,52 @@
|
|||
name: Docker
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
tags: [ 'v*.*' ]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Log into registry ${{ env.REGISTRY }}
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# https://github.com/docker/metadata-action
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v3
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
|
||||
# https://github.com/docker/build-push-action
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
file: docker/Dockerfile
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
"COMPOSER_VERSION=2.1.5"
|
||||
"PHP_VERSION=7.4"
|
||||
"DEBIAN_RELEASE=buster"
|
|
@ -6,3 +6,4 @@ tmp
|
|||
vendor
|
||||
webroot/theme/node_modules
|
||||
.vscode
|
||||
docker/run/
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
## Requirements
|
||||
|
||||
An Ubuntu server (18.04/20.04 should both work fine) - though other linux installations should work too.
|
||||
- apache2, mysql/mariadb, sqlite need to be installed and running
|
||||
- apache2 (or nginx), mysql/mariadb, sqlite need to be installed and running
|
||||
- php extensions for intl, mysql, sqlite3, mbstring, xml need to be installed and running
|
||||
- php extention for curl (not required but makes composer run a little faster)
|
||||
- composer
|
||||
|
||||
## Network requirements
|
||||
|
@ -17,8 +18,16 @@ Cerebrate communicates via HTTPS so in order to be able to connect to other cere
|
|||
## Cerebrate installation instructions
|
||||
|
||||
It should be sufficient to issue the following command to install the dependencies:
|
||||
|
||||
- for apache
|
||||
|
||||
```bash
|
||||
sudo apt install apache2 mariadb-server git composer php-intl php-mbstring php-dom php-xml unzip php-ldap php-sqlite3 sqlite libapache2-mod-php php-mysql
|
||||
sudo apt install apache2 mariadb-server git composer php-intl php-mbstring php-dom php-xml unzip php-ldap php-sqlite3 php-curl sqlite libapache2-mod-php php-mysql
|
||||
```
|
||||
|
||||
- for nginx
|
||||
```bash
|
||||
sudo apt install nginx mariadb-server git composer php-intl php-mbstring php-dom php-xml unzip php-ldap php-sqlite3 sqlite php-fpm php-curl php-mysql
|
||||
```
|
||||
|
||||
Clone this repository (for example into /var/www/cerebrate)
|
||||
|
@ -32,12 +41,19 @@ sudo -u www-data git clone https://github.com/cerebrate-project/cerebrate.git /v
|
|||
Run composer
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /var/www/.composer
|
||||
sudo chown www-data:www-data /var/www/.composer
|
||||
cd /var/www/cerebrate
|
||||
sudo -u www-data composer install
|
||||
sudo -H -u www-data composer install
|
||||
```
|
||||
|
||||
Create a database for cerebrate
|
||||
|
||||
With a fresh install of Ubuntu sudo to the (system) root user before logging in as the mysql root
|
||||
```Bash
|
||||
sudo -i mysql -u root
|
||||
```
|
||||
|
||||
From SQL shell:
|
||||
```mysql
|
||||
mysql
|
||||
|
@ -46,6 +62,7 @@ CREATE USER 'cerebrate'@'localhost' IDENTIFIED BY 'YOUR_PASSWORD';
|
|||
GRANT USAGE ON *.* to cerebrate@localhost;
|
||||
GRANT ALL PRIVILEGES ON cerebrate.* to cerebrate@localhost;
|
||||
FLUSH PRIVILEGES;
|
||||
QUIT;
|
||||
```
|
||||
|
||||
Or from Bash:
|
||||
|
@ -71,7 +88,7 @@ sudo -u www-data cp -a /var/www/cerebrate/config/config.example.json /var/www/ce
|
|||
sudo -u www-data vim /var/www/cerebrate/config/app_local.php
|
||||
```
|
||||
|
||||
mod_rewrite needs to be enabled:
|
||||
mod_rewrite needs to be enabled if __using apache__:
|
||||
|
||||
```bash
|
||||
sudo a2enmod rewrite
|
||||
|
@ -91,9 +108,9 @@ This would be, when following the steps above:
|
|||
|
||||
Run the database schema migrations
|
||||
```bash
|
||||
/var/www/cerebrate/bin/cake migrations migrate
|
||||
/var/www/cerebrate/bin/cake migrations migrate -p tags
|
||||
/var/www/cerebrate/bin/cake migrations migrate -p ADmad/SocialAuth
|
||||
sudo -u www-data /var/www/cerebrate/bin/cake migrations migrate
|
||||
sudo -u www-data /var/www/cerebrate/bin/cake migrations migrate -p tags
|
||||
sudo -u www-data /var/www/cerebrate/bin/cake migrations migrate -p ADmad/SocialAuth
|
||||
```
|
||||
|
||||
Clean cakephp caches
|
||||
|
@ -104,16 +121,31 @@ sudo rm /var/www/cerebrate/tmp/cache/persistent/*
|
|||
|
||||
Create an apache config file for cerebrate / ssh key and point the document root to /var/www/cerebrate/webroot and you're good to go
|
||||
|
||||
For development installs the following can be done:
|
||||
For development installs the following can be done for either apache or nginx:
|
||||
|
||||
```bash
|
||||
# Apache
|
||||
# This configuration is purely meant for local installations for development / testing
|
||||
# Using HTTP on an unhardened apache is by no means meant to be used in any production environment
|
||||
sudo cp /var/www/cerebrate/INSTALL/cerebrate_dev.conf /etc/apache2/sites-available/
|
||||
sudo ln -s /etc/apache2/sites-available/cerebrate_dev.conf /etc/apache2/sites-enabled/
|
||||
sudo cp /var/www/cerebrate/INSTALL/cerebrate_apache_dev.conf /etc/apache2/sites-available/
|
||||
sudo ln -s /etc/apache2/sites-available/cerebrate_apache_dev.conf /etc/apache2/sites-enabled/
|
||||
sudo service apache2 restart
|
||||
```
|
||||
|
||||
OR
|
||||
|
||||
```bash
|
||||
# NGINX
|
||||
# This configuration is purely meant for local installations for development / testing
|
||||
# Using HTTP on an unhardened apache is by no means meant to be used in any production environment
|
||||
sudo cp /var/www/cerebrate/INSTALL/cerebrate_nginx.conf /etc/nginx/sites-available/
|
||||
sudo ln -s /etc/nginx/sites-available/cerebrate_nginx.conf /etc/nginx/sites-enabled/
|
||||
sudo systemctl disable apache2 # may be required if apache is using port
|
||||
sudo service nginx restart
|
||||
sudo systemctl enable nginx
|
||||
|
||||
```
|
||||
|
||||
Now you can point your browser to: http://localhost:8000
|
||||
|
||||
To log in use the default credentials below:
|
||||
|
|
|
@ -0,0 +1,37 @@
|
|||
## Cerebrate Nginx Web Server Configuration
|
||||
server {
|
||||
listen 8000;
|
||||
# listen 443 ssl;
|
||||
|
||||
root /var/www/cerebrate/webroot;
|
||||
error_log /var/log/nginx/cerebrate_error.log;
|
||||
access_log /var/log/nginx/cerebrate_access.log;
|
||||
|
||||
# Add index.php to the list if you are using PHP
|
||||
index index.html index.htm index.nginx-debian.html index.php;
|
||||
|
||||
server_name _;
|
||||
|
||||
# Configure Crypto Keys/Certificates/DH
|
||||
# If enabling this setting change port above, should also set the server name
|
||||
# ssl_certificate /path/to/ssl/cert;
|
||||
# ssl_certificate_key /path/to/ssl/cert;
|
||||
|
||||
# enable HSTS
|
||||
# add_header Strict-Transport-Security "max-age=15768000; includeSubdomains";
|
||||
# add_header X-Frame-Options SAMEORIGIN;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.php?$args;
|
||||
}
|
||||
|
||||
location ~ \.php$ {
|
||||
try_files $uri =404;
|
||||
fastcgi_split_path_info ^(.+\.php)(/.+)$;
|
||||
fastcgi_pass unix:/var/run/php/php7.4-fpm.sock;
|
||||
fastcgi_index index.php;
|
||||
include fastcgi_params;
|
||||
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||
fastcgi_param PATH_INFO $fastcgi_path_info;
|
||||
}
|
||||
}
|
|
@ -390,6 +390,27 @@ CREATE TABLE `meta_template_fields` (
|
|||
KEY `type` (`type`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `audit_logs` (
|
||||
`id` int(10) unsigned NOT NULL AUTO_INCREMENT,
|
||||
`created` datetime NOT NULL,
|
||||
`user_id` int(10) unsigned DEFAULT NULL,
|
||||
`authkey_id` int(10) unsigned DEFAULT NULL,
|
||||
`request_ip` varbinary(16) DEFAULT NULL,
|
||||
`request_type` tinyint NOT NULL,
|
||||
`request_id` varchar(191) DEFAULT NULL,
|
||||
`request_action` varchar(20) NOT NULL,
|
||||
`model` varchar(80) NOT NULL,
|
||||
`model_id` int(10) unsigned DEFAULT NULL,
|
||||
`model_title` text DEFAULT NULL,
|
||||
`change` blob,
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `user_id` (`user_id`),
|
||||
KEY `ip` (`ip`),
|
||||
KEY `model` (`model`),
|
||||
KEY `action` (`action`),
|
||||
KEY `model_id` (`model_id`)
|
||||
KEY `created` (`created`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
/*!40101 SET SQL_MODE=@OLD_SQL_MODE */;
|
||||
/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */;
|
||||
|
|
30
README.md
30
README.md
|
@ -1,24 +1,31 @@
|
|||
# cerebrate
|
||||
|
||||
The Cerebrate Sync Platform core software. Cerebrate is an open-source platform meant to act as a trusted contact information provider and interconnection orchestrator for other security tools.
|
||||
Cerebrate is an [open-source platform](https://github.com/cerebrate-project) meant to act as a trusted contact information provider and interconnection orchestrator for other security tools (such as [MISP](https://www.misp-project.org/)).
|
||||
|
||||
It is currently being built under the MeliCERTes v2 project and is heavily work in progress.
|
||||
# Features
|
||||
|
||||
# Current features
|
||||
- Advanced repository to manage individuals and organisations;
|
||||
- Key store for public encryption and signing cryptographic keys (e.g. PGP);
|
||||
- Distributed synchronisation model where multiple Cerebrate instances can be interconnected amongst organisations and/or departments;
|
||||
- Management of individuals and their affiliations to each organisations;
|
||||
- Advanced API and CLI to integrate with existing tools (e.g. importing existing directory information);
|
||||
- Dynamic model for creating new organisational structures;
|
||||
- Support existing organisational structures such as [FIRST.org](https://www.first.org/) directory, EU [CSIRTs network](https://csirtsnetwork.eu/);
|
||||
- Local tooling interconnection to easily connect existing tools with their native protocols;
|
||||
|
||||
- Repository of organisations and individuals
|
||||
- Maintain signing and encryption keys
|
||||
- Maintain affiliations between organisations and individuals
|
||||
Cerebrate is developed in the scope of the MeliCERTes v2 project.
|
||||
|
||||
## Screenshots
|
||||
|
||||
![Dashboard](https://www.cerebrate-project.org/assets/images/screenshots/Screenshot%20from%202021-10-19%2016-31-56.png)
|
||||
|
||||
List of individuals along with their affiliations
|
||||
|
||||
![List of individuals](/documentation/images/individuals.png)
|
||||
![List of individuals](https://www.cerebrate-project.org/assets/images/screenshots/Screenshot%20from%202021-10-19%2016-32-35.png)
|
||||
|
||||
Adding organisations
|
||||
|
||||
![Adding an organisation](/documentation/images/add_org.png)
|
||||
![Adding an organisation](https://www.cerebrate-project.org/assets/images/screenshots/Screenshot%20from%202021-10-19%2016-33-04.png)
|
||||
|
||||
Everything is available via the API, here an example of a search query for all international organisations in the DB.
|
||||
|
||||
|
@ -28,6 +35,10 @@ Managing public keys and assigning them to users both for communication and vali
|
|||
|
||||
![Encryption key management](/documentation/images/add_encryption_key.png)
|
||||
|
||||
Dynamic model for creating new organisation structre
|
||||
|
||||
![Meta Field Templates](https://www.cerebrate-project.org/assets/images/screenshots/Screenshot%20from%202021-10-19%2016-38-21.png)
|
||||
|
||||
# Requirements and installation
|
||||
|
||||
The platform is built on CakePHP 4 along with Bootstrap 4 and shares parts of the code-base with [MISP](https://www.github.com/MISP).
|
||||
|
@ -45,6 +56,7 @@ For installation via docker, refer to the [cerebrate-docker](https://github.com/
|
|||
~~~~
|
||||
The software is released under the AGPLv3.
|
||||
|
||||
Copyright (C) 2019, 2020 Andras Iklody
|
||||
Copyright (C) 2019, 2021 Andras Iklody
|
||||
Copyright (C) 2020-2021 Sami Mokaddem
|
||||
Copyright (C) CIRCL - Computer Incident Response Center Luxembourg
|
||||
~~~~
|
||||
|
|
|
@ -0,0 +1,96 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
use Migrations\AbstractMigration;
|
||||
|
||||
final class AuditLogs extends AbstractMigration
|
||||
{
|
||||
/**
|
||||
* Change Method.
|
||||
*
|
||||
* Write your reversible migrations using this method.
|
||||
*
|
||||
* More information on writing migrations is available here:
|
||||
* https://book.cakephp.org/phinx/0/en/migrations.html#the-change-method
|
||||
*
|
||||
* Remember to call "create()" or "update()" and NOT "save()" when working
|
||||
* with the Table class.
|
||||
*/
|
||||
|
||||
public $autoId = false; // turn off automatic `id` column create. We want it to be `int(10) unsigned`
|
||||
|
||||
public function change(): void
|
||||
{
|
||||
$exists = $this->hasTable('audit_logs');
|
||||
if (!$exists) {
|
||||
$table = $this->table('audit_logs', [
|
||||
'signed' => false,
|
||||
'collation' => 'utf8mb4_unicode_ci'
|
||||
]);
|
||||
$table
|
||||
->addColumn('id', 'integer', [
|
||||
'autoIncrement' => true,
|
||||
'limit' => 10,
|
||||
'signed' => false,
|
||||
])
|
||||
->addPrimaryKey('id')
|
||||
->addColumn('user_id', 'integer', [
|
||||
'default' => null,
|
||||
'null' => true,
|
||||
'signed' => false,
|
||||
'length' => 10
|
||||
])
|
||||
->addColumn('authkey_id', 'integer', [
|
||||
'default' => null,
|
||||
'null' => true,
|
||||
'signed' => false,
|
||||
'length' => 10
|
||||
])
|
||||
->addColumn('request_ip', 'varbinary', [
|
||||
'default' => null,
|
||||
'null' => true,
|
||||
'length' => 16
|
||||
])
|
||||
->addColumn('request_type', 'boolean', [
|
||||
'null' => false
|
||||
])
|
||||
->addColumn('request_id', 'integer', [
|
||||
'default' => null,
|
||||
'null' => true,
|
||||
'signed' => false,
|
||||
'length' => 10
|
||||
])
|
||||
->addColumn('request_action', 'string', [
|
||||
'null' => false,
|
||||
'length' => 20
|
||||
])
|
||||
->addColumn('model', 'string', [
|
||||
'null' => false,
|
||||
'length' => 80
|
||||
])
|
||||
->addColumn('model_id', 'integer', [
|
||||
'default' => null,
|
||||
'null' => true,
|
||||
'signed' => false,
|
||||
'length' => 10
|
||||
])
|
||||
->addColumn('model_title', 'text', [
|
||||
'default' => null,
|
||||
'null' => true
|
||||
])
|
||||
->addColumn('change', 'blob', [
|
||||
])
|
||||
->addColumn('created', 'datetime', [
|
||||
'default' => null,
|
||||
'null' => false,
|
||||
])
|
||||
->addIndex('user_id')
|
||||
->addIndex('request_ip')
|
||||
->addIndex('model')
|
||||
->addIndex('model_id')
|
||||
->addIndex('request_action')
|
||||
->addIndex('created');
|
||||
$table->create();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -5,6 +5,16 @@
|
|||
* Note: It is not recommended to commit files with credentials such as app_local.php
|
||||
* into source code version control.
|
||||
*/
|
||||
|
||||
// set the baseurl here if you want to set it manually
|
||||
$baseurl = env('CEREBRATE_BASEURL', false);
|
||||
|
||||
|
||||
// Do not modify the this block
|
||||
$temp = parse_url($baseurl);
|
||||
$base = empty($temp['path']) ? false : $temp['path'];
|
||||
// end of block
|
||||
|
||||
return [
|
||||
/*
|
||||
* Debug Level:
|
||||
|
@ -89,4 +99,8 @@ return [
|
|||
'url' => env('EMAIL_TRANSPORT_DEFAULT_URL', null),
|
||||
],
|
||||
],
|
||||
'Cerebrate' => [
|
||||
'open' => [],
|
||||
'dark' => 0
|
||||
]
|
||||
];
|
||||
|
|
|
@ -0,0 +1,80 @@
|
|||
ARG COMPOSER_VERSION
|
||||
ARG PHP_VERSION
|
||||
ARG DEBIAN_RELEASE
|
||||
|
||||
FROM php:${PHP_VERSION}-apache-${DEBIAN_RELEASE}
|
||||
|
||||
# we need some extra libs to be installed in the runtime
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends curl git zip unzip && \
|
||||
apt-get install -y --no-install-recommends libicu-dev libxml2-dev && \
|
||||
docker-php-ext-install intl pdo pdo_mysql mysqli simplexml && \
|
||||
apt-get remove -y --purge libicu-dev libxml2-dev && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY composer.json composer.json
|
||||
|
||||
# install composer as root
|
||||
ARG COMPOSER_VERSION
|
||||
RUN curl -sL https://getcomposer.org/installer | \
|
||||
php -- --install-dir=/usr/bin/ --filename=composer --version=${COMPOSER_VERSION}
|
||||
|
||||
# switch back to unprivileged user for composer install
|
||||
USER www-data
|
||||
|
||||
RUN composer install \
|
||||
--ignore-platform-reqs \
|
||||
--no-interaction \
|
||||
--no-plugins \
|
||||
--no-scripts \
|
||||
--prefer-dist
|
||||
|
||||
# web server configuration
|
||||
USER root
|
||||
|
||||
# allow .htaccess overrides and push them
|
||||
RUN a2enmod rewrite
|
||||
RUN sed -i -r '/DocumentRoot/a \\t<Directory /var/www/html/>\n\t\tAllowOverride all\n\t</Directory>' /etc/apache2/sites-available/000-default.conf
|
||||
COPY --chown=www-data docker/etc/DocumentRoot.htaccess /var/www/html/.htaccess
|
||||
COPY --chown=www-data docker/etc/webroot.htaccess /var/www/html/webroot/.htaccess
|
||||
|
||||
# passing environment variables through apache
|
||||
RUN a2enmod env
|
||||
RUN echo 'PassEnv CEREBRATE_DB_HOST' >> /etc/apache2/conf-enabled/environment.conf
|
||||
RUN echo 'PassEnv CEREBRATE_DB_NAME' >> /etc/apache2/conf-enabled/environment.conf
|
||||
RUN echo 'PassEnv CEREBRATE_DB_PASSWORD' >> /etc/apache2/conf-enabled/environment.conf
|
||||
RUN echo 'PassEnv CEREBRATE_DB_PORT' >> /etc/apache2/conf-enabled/environment.conf
|
||||
RUN echo 'PassEnv CEREBRATE_DB_SCHEMA' >> /etc/apache2/conf-enabled/environment.conf
|
||||
RUN echo 'PassEnv CEREBRATE_DB_USERNAME' >> /etc/apache2/conf-enabled/environment.conf
|
||||
RUN echo 'PassEnv CEREBRATE_EMAIL_HOST' >> /etc/apache2/conf-enabled/environment.conf
|
||||
RUN echo 'PassEnv CEREBRATE_EMAIL_PASSWORD' >> /etc/apache2/conf-enabled/environment.conf
|
||||
RUN echo 'PassEnv CEREBRATE_EMAIL_PORT' >> /etc/apache2/conf-enabled/environment.conf
|
||||
RUN echo 'PassEnv CEREBRATE_EMAIL_TLS' >> /etc/apache2/conf-enabled/environment.conf
|
||||
RUN echo 'PassEnv CEREBRATE_EMAIL_USERNAME' >> /etc/apache2/conf-enabled/environment.conf
|
||||
RUN echo 'PassEnv CEREBRATE_SECURITY_SALT' >> /etc/apache2/conf-enabled/environment.conf
|
||||
|
||||
# entrypoint
|
||||
COPY docker/entrypoint.sh /entrypoint.sh
|
||||
RUN chmod 755 /entrypoint.sh
|
||||
|
||||
# copy actual codebase
|
||||
COPY --chown=www-data . /var/www/html
|
||||
|
||||
# last checks with unprivileged user
|
||||
USER www-data
|
||||
|
||||
# CakePHP seems to not handle very well externally installed components
|
||||
# this will chown/chmod/symlink all in place for its own good
|
||||
RUN composer install --no-interaction
|
||||
|
||||
# app config override making use of environment variables
|
||||
COPY --chown=www-data docker/etc/app_local.php /var/www/html/config/app_local.php
|
||||
# version 1.0 addition requires a config/config.json file
|
||||
# can still be overriden by a docker volume
|
||||
RUN cp -a /var/www/html/config/config.example.json /var/www/html/config/config.json
|
||||
|
||||
# also can be overridin by a docker volume
|
||||
RUN mkdir -p /var/www/html/logs
|
||||
|
||||
ENTRYPOINT [ "/entrypoint.sh" ]
|
|
@ -0,0 +1,47 @@
|
|||
# Database init
|
||||
|
||||
For the `docker-compose` setup to work you must initialize database with
|
||||
what is in `../INSTALL/mysql.sql`
|
||||
|
||||
```
|
||||
mkdir -p run/dbinit/
|
||||
cp ../INSTALL/mysql.sql run/dbinit/
|
||||
```
|
||||
|
||||
The MariaDB container has a volume mounted as follow
|
||||
`- ./run/dbinit:/docker-entrypoint-initdb.d/:ro`
|
||||
|
||||
So that on startup the container will source files in this directory to seed
|
||||
the database. Once it's done the container will run normally and Cerebrate will
|
||||
be able to roll its database migration scripts
|
||||
|
||||
# Actual data and volumes
|
||||
|
||||
The actual database will be located in `./run/database` exposed with the
|
||||
following volume `- ./run/database:/var/lib/mysql`
|
||||
|
||||
Application logs (CakePHP / Cerebrate) will be stored in `./run/logs`,
|
||||
volume `- ./run/logs:/var/www/html/logs`
|
||||
|
||||
You're free to change those parameters if you're using Swarm, Kubernetes or
|
||||
your favorite config management tool to deploy this stack
|
||||
|
||||
# Building yourself
|
||||
|
||||
You can create the following Makefile in basedir of this repository
|
||||
and issue `make image`
|
||||
|
||||
```
|
||||
COMPOSER_VERSION?=2.1.5
|
||||
PHP_VERSION?=7.4
|
||||
DEBIAN_RELEASE?=buster
|
||||
IMAGE_NAME?=cerebrate:latest
|
||||
|
||||
image:
|
||||
docker build -t $(IMAGE_NAME) \
|
||||
-f docker/Dockerfile \
|
||||
--build-arg COMPOSER_VERSION=$(COMPOSER_VERSION) \
|
||||
--build-arg PHP_VERSION=$(PHP_VERSION) \
|
||||
--build-arg DEBIAN_RELEASE=$(DEBIAN_RELEASE) \
|
||||
.
|
||||
```
|
|
@ -0,0 +1,28 @@
|
|||
version: "3"
|
||||
services:
|
||||
database:
|
||||
image: mariadb:10.6
|
||||
restart: always
|
||||
volumes:
|
||||
- ./run/database:/var/lib/mysql
|
||||
- ./run/dbinit:/docker-entrypoint-initdb.d/:ro
|
||||
environment:
|
||||
MARIADB_RANDOM_ROOT_PASSWORD: "yes"
|
||||
MYSQL_DATABASE: "cerebrate"
|
||||
MYSQL_USER: "cerebrate"
|
||||
MYSQL_PASSWORD: "etarberec"
|
||||
www:
|
||||
image: ghcr.io/cerebrate-project/cerebrate:main
|
||||
ports:
|
||||
- "8080:80"
|
||||
volumes:
|
||||
- ./run/logs:/var/www/html/logs
|
||||
environment:
|
||||
DEBUG: "true"
|
||||
CEREBRATE_DB_USERNAME: "cerebrate"
|
||||
CEREBRATE_DB_PASSWORD: "etarberec"
|
||||
CEREBRATE_DB_NAME: "cerebrate"
|
||||
CEREBRATE_DB_HOST: database
|
||||
CEREBRATE_SECURITY_SALT: supersecret
|
||||
depends_on:
|
||||
- database
|
|
@ -0,0 +1,20 @@
|
|||
#!/bin/sh
|
||||
|
||||
set -e
|
||||
|
||||
|
||||
run_all_migrations() {
|
||||
./bin/cake migrations migrate
|
||||
./bin/cake migrations migrate -p tags
|
||||
./bin/cake migrations migrate -p ADmad/SocialAuth
|
||||
}
|
||||
|
||||
# waiting for DB to come up
|
||||
for try in 1 2 3 4 5 6; do
|
||||
echo >&2 "migration - attempt $try"
|
||||
run_all_migrations && break || true
|
||||
sleep 5
|
||||
[ "$try" = "6" ] && exit 1
|
||||
done
|
||||
|
||||
exec /usr/local/bin/apache2-foreground "$@"
|
|
@ -0,0 +1,3 @@
|
|||
RewriteEngine on
|
||||
RewriteRule ^$ webroot/ [L]
|
||||
RewriteRule (.*) webroot/$1 [L]
|
|
@ -0,0 +1,44 @@
|
|||
<?php
|
||||
$db = [
|
||||
'username' => env('CEREBRATE_DB_USERNAME', 'cerebrate'),
|
||||
'password' => env('CEREBRATE_DB_PASSWORD', ''),
|
||||
'host' => env('CEREBRATE_DB_HOST', 'localhost'),
|
||||
'database' => env('CEREBRATE_DB_NAME', 'cerebrate'),
|
||||
];
|
||||
|
||||
// non-default port can be set on demand - otherwise the DB driver will choose the default
|
||||
if (!empty(env('CEREBRATE_DB_PORT'))) {
|
||||
$db['port'] = env('CEREBRATE_DB_PORT');
|
||||
}
|
||||
|
||||
// If not using the default 'public' schema with the PostgreSQL driver set it here.
|
||||
if (!empty(env('CEREBRATE_DB_SCHEMA'))) {
|
||||
$db['schema'] = env('CEREBRATE_DB_SCHEMA');
|
||||
}
|
||||
|
||||
return [
|
||||
'debug' => filter_var(env('DEBUG', false), FILTER_VALIDATE_BOOLEAN),
|
||||
|
||||
'Security' => [
|
||||
'salt' => env('CEREBRATE_SECURITY_SALT'),
|
||||
],
|
||||
|
||||
'Datasources' => [
|
||||
'default' => $db,
|
||||
],
|
||||
|
||||
'EmailTransport' => [
|
||||
'default' => [
|
||||
// host could be ssl://smtp.gmail.com then set port to 465
|
||||
'host' => env('CEREBRATE_EMAIL_HOST', 'localhost'),
|
||||
'port' => env('CEREBRATE_EMAIL_PORT', 25),
|
||||
'username' => env('CEREBRATE_EMAIL_USERNAME', null),
|
||||
'password' => env('CEREBRATE_EMAIL_PASSWORD', null),
|
||||
'tls' => env('CEREBRATE_EMAIL_TLS', null)
|
||||
],
|
||||
],
|
||||
'Cerebrate' => [
|
||||
'open' => [],
|
||||
'dark' => 0
|
||||
]
|
||||
];
|
|
@ -0,0 +1,3 @@
|
|||
RewriteEngine On
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteRule ^ index.php [L]
|
|
@ -0,0 +1 @@
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
/**
|
||||
* Generic importer to feed data to cerebrate from JSON or CSV.
|
||||
*
|
||||
*
|
||||
* - JSON configuration file must have the `format` key which can either take the value `json` or `csv`
|
||||
* - If `csv` is provided, the file must contains the header.
|
||||
* - If `json` is provided, a `mapping` key on how to reach each fields using the cakephp4's Hash syntax must be provided.
|
||||
|
@ -10,7 +10,7 @@
|
|||
* - The key is the field name
|
||||
* - The value
|
||||
* - Can either be the string representing the path from which to get the value
|
||||
* - Or a JSON containg the `path`, the optional `override` parameter specifying if the existing data should be overriden
|
||||
* - Or a JSON containg the `path`, the optional `override` parameter specifying if the existing data should be overriden
|
||||
* and an optional `massage` function able to alter the data.
|
||||
* - Example
|
||||
* {
|
||||
|
@ -22,7 +22,7 @@
|
|||
* },
|
||||
*
|
||||
* - The optional primary key argument provides a way to make import replayable. It can typically be used when an ID or UUID is not provided in the source file but can be replaced by something else (e.g. team-name or other type of unique data).
|
||||
*
|
||||
*
|
||||
*/
|
||||
|
||||
namespace App\Command;
|
||||
|
@ -165,7 +165,7 @@ class ImporterCommand extends Command
|
|||
'valueField' => 'id'
|
||||
])->where(['meta_template_id' => $metaTemplate->id])->toArray();
|
||||
} else {
|
||||
$this->io->error("Unkown template for UUID {$config['metaTemplateUUID']}");
|
||||
$this->io->error("Unknown template for UUID {$config['metaTemplateUUID']}");
|
||||
die(1);
|
||||
}
|
||||
}
|
||||
|
@ -196,7 +196,7 @@ class ImporterCommand extends Command
|
|||
$metaEntity->meta_template_field_id = $metaTemplateFieldsMapping[$fieldName];
|
||||
} else {
|
||||
$hasErrors = true;
|
||||
$this->io->error("Field $fieldName is unkown for template {$metaTemplate->name}");
|
||||
$this->io->error("Field $fieldName is unknown for template {$metaTemplate->name}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
@ -529,4 +529,4 @@ class ImporterCommand extends Command
|
|||
{
|
||||
return is_null($value) ? '' : $value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -111,6 +111,7 @@ class AppController extends Controller
|
|||
}
|
||||
unset($user['password']);
|
||||
$this->ACL->setUser($user);
|
||||
$this->request->getSession()->write('authUser', $user);
|
||||
$this->isAdmin = $user['role']['perm_admin'];
|
||||
$this->set('menu', $this->ACL->getMenu());
|
||||
$this->set('loggedUser', $this->ACL->getUser());
|
||||
|
@ -147,13 +148,31 @@ class AppController extends Controller
|
|||
{
|
||||
if (!empty($_SERVER['HTTP_AUTHORIZATION']) && strlen($_SERVER['HTTP_AUTHORIZATION'])) {
|
||||
$this->loadModel('AuthKeys');
|
||||
$logModel = $this->Users->auditLogs();
|
||||
$authKey = $this->AuthKeys->checkKey($_SERVER['HTTP_AUTHORIZATION']);
|
||||
if (!empty($authKey)) {
|
||||
$this->loadModel('Users');
|
||||
$user = $this->Users->get($authKey['user_id']);
|
||||
$user = $logModel->userInfo();
|
||||
$logModel->insert([
|
||||
'action' => 'login',
|
||||
'model' => 'Users',
|
||||
'model_id' => $user['id'],
|
||||
'model_title' => $user['name'],
|
||||
'change' => []
|
||||
]);
|
||||
if (!empty($user)) {
|
||||
$this->Authentication->setIdentity($user);
|
||||
}
|
||||
} else {
|
||||
$user = $logModel->userInfo();
|
||||
$logModel->insert([
|
||||
'action' => 'login',
|
||||
'model' => 'Users',
|
||||
'model_id' => $user['id'],
|
||||
'model_title' => $user['name'],
|
||||
'change' => []
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,36 @@
|
|||
<?php
|
||||
namespace App\Controller;
|
||||
|
||||
use App\Controller\AppController;
|
||||
use Cake\Utility\Hash;
|
||||
use Cake\Utility\Text;
|
||||
use Cake\ORM\TableRegistry;
|
||||
use \Cake\Database\Expression\QueryExpression;
|
||||
use Cake\Http\Exception\UnauthorizedException;
|
||||
use Cake\Core\Configure;
|
||||
|
||||
class AuditLogsController extends AppController
|
||||
{
|
||||
public $filterFields = ['model_id', 'model', 'action', 'user_id', 'title'];
|
||||
public $quickFilterFields = ['model', 'action', 'title'];
|
||||
public $containFields = ['Users'];
|
||||
|
||||
public function index()
|
||||
{
|
||||
$this->CRUD->index([
|
||||
'contain' => $this->containFields,
|
||||
'filters' => $this->filterFields,
|
||||
'quickFilters' => $this->quickFilterFields,
|
||||
'afterFind' => function($data) {
|
||||
$data['request_ip'] = inet_ntop(stream_get_contents($data['request_ip']));
|
||||
$data['change'] = stream_get_contents($data['change']);
|
||||
return $data;
|
||||
}
|
||||
]);
|
||||
$responsePayload = $this->CRUD->getResponsePayload();
|
||||
if (!empty($responsePayload)) {
|
||||
return $responsePayload;
|
||||
}
|
||||
$this->set('metaGroup', 'Administration');
|
||||
}
|
||||
}
|
|
@ -61,11 +61,25 @@ class CRUDComponent extends Component
|
|||
}
|
||||
if ($this->Controller->ParamHandler->isRest()) {
|
||||
$data = $query->all();
|
||||
if (isset($options['hidden'])) {
|
||||
$data->each(function($value, $key) use ($options) {
|
||||
$hidden = is_array($options['hidden']) ? $options['hidden'] : [$options['hidden']];
|
||||
$value->setHidden($hidden);
|
||||
return $value;
|
||||
});
|
||||
}
|
||||
if (isset($options['afterFind'])) {
|
||||
$function = $options['afterFind'];
|
||||
if (is_callable($options['afterFind'])) {
|
||||
$data = $options['afterFind']($data);
|
||||
$function = $options['afterFind'];
|
||||
$data->each(function($value, $key) use ($function) {
|
||||
return $function($value);
|
||||
});
|
||||
} else {
|
||||
$data = $this->Table->{$options['afterFind']}($data);
|
||||
$t = $this->Table;
|
||||
$data->each(function($value, $key) use ($t, $function) {
|
||||
return $t->$function($value);
|
||||
});
|
||||
}
|
||||
}
|
||||
$this->Controller->restResponsePayload = $this->RestResponse->viewData($data, 'json');
|
||||
|
@ -73,10 +87,17 @@ class CRUDComponent extends Component
|
|||
$this->Controller->loadComponent('Paginator');
|
||||
$data = $this->Controller->Paginator->paginate($query);
|
||||
if (isset($options['afterFind'])) {
|
||||
$function = $options['afterFind'];
|
||||
if (is_callable($options['afterFind'])) {
|
||||
$data = $options['afterFind']($data);
|
||||
$function = $options['afterFind'];
|
||||
$data->each(function($value, $key) use ($function) {
|
||||
return $function($value);
|
||||
});
|
||||
} else {
|
||||
$data = $this->Table->{$options['afterFind']}($data);
|
||||
$t = $this->Table;
|
||||
$data->each(function($value, $key) use ($t, $function) {
|
||||
return $t->$function($value);
|
||||
});
|
||||
}
|
||||
}
|
||||
$this->setFilteringContext($options['contextFilters'] ?? [], $params);
|
||||
|
|
|
@ -113,6 +113,11 @@ class Sidemenu {
|
|||
'url' => '/instance/migrationIndex',
|
||||
'icon' => 'database',
|
||||
],
|
||||
'AuditLogs' => [
|
||||
'label' => __('Audit Logs'),
|
||||
'url' => '/auditLogs/index',
|
||||
'icon' => 'history',
|
||||
],
|
||||
]
|
||||
],
|
||||
],
|
||||
|
@ -144,4 +149,4 @@ class Sidemenu {
|
|||
]
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,6 +6,7 @@ use App\Controller\AppController;
|
|||
use Cake\Utility\Hash;
|
||||
use Cake\Utility\Text;
|
||||
use \Cake\Database\Expression\QueryExpression;
|
||||
use Cake\Http\Exception\BadRequestException;
|
||||
use Cake\Http\Exception\NotFoundException;
|
||||
use Cake\Http\Exception\MethodNotAllowedException;
|
||||
use Cake\Http\Exception\ForbiddenException;
|
||||
|
|
|
@ -130,11 +130,27 @@ class UsersController extends AppController
|
|||
{
|
||||
$result = $this->Authentication->getResult();
|
||||
// If the user is logged in send them away.
|
||||
$logModel = $this->Users->auditLogs();
|
||||
if ($result->isValid()) {
|
||||
$user = $logModel->userInfo();
|
||||
$logModel->insert([
|
||||
'request_action' => 'login',
|
||||
'model' => 'Users',
|
||||
'model_id' => $user['id'],
|
||||
'model_title' => $user['name'],
|
||||
'change' => []
|
||||
]);
|
||||
$target = $this->Authentication->getLoginRedirect() ?? '/instance/home';
|
||||
return $this->redirect($target);
|
||||
}
|
||||
if ($this->request->is('post') && !$result->isValid()) {
|
||||
$logModel->insert([
|
||||
'request_action' => 'login_fail',
|
||||
'model' => 'Users',
|
||||
'model_id' => 0,
|
||||
'model_title' => 'unknown_user',
|
||||
'change' => []
|
||||
]);
|
||||
$this->Flash->error(__('Invalid username or password'));
|
||||
}
|
||||
$this->viewBuilder()->setLayout('login');
|
||||
|
@ -144,6 +160,15 @@ class UsersController extends AppController
|
|||
{
|
||||
$result = $this->Authentication->getResult();
|
||||
if ($result->isValid()) {
|
||||
$logModel = $this->Users->auditLogs();
|
||||
$user = $logModel->userInfo();
|
||||
$logModel->insert([
|
||||
'request_action' => 'logout',
|
||||
'model' => 'Users',
|
||||
'model_id' => $user['id'],
|
||||
'model_title' => $user['name'],
|
||||
'change' => []
|
||||
]);
|
||||
$this->Authentication->logout();
|
||||
$this->Flash->success(__('Goodbye.'));
|
||||
return $this->redirect(\Cake\Routing\Router::url('/users/login'));
|
||||
|
|
|
@ -0,0 +1,214 @@
|
|||
<?php
|
||||
namespace App\Model\Behavior;
|
||||
|
||||
use ArrayObject;
|
||||
use Cake\Datasource\EntityInterface;
|
||||
use Cake\Event\EventInterface;
|
||||
use Cake\ORM\Behavior;
|
||||
use Cake\ORM\Entity;
|
||||
use Cake\ORM\Query;
|
||||
use Cake\Utility\Text;
|
||||
use Cake\Utility\Security;
|
||||
use \Cake\Http\Session;
|
||||
use Cake\Core\Configure;
|
||||
use Cake\ORM\TableRegistry;
|
||||
use App\Model\Table\AuditLogTable;
|
||||
|
||||
class AuditLogBehavior extends Behavior
|
||||
{
|
||||
/** @var array */
|
||||
private $config;
|
||||
|
||||
/** @var array|null */
|
||||
private $old;
|
||||
|
||||
/** @var AuditLog|null */
|
||||
private $AuditLogs;
|
||||
|
||||
// Hash is faster that in_array
|
||||
private $skipFields = [
|
||||
'id' => true,
|
||||
'lastpushedid' => true,
|
||||
'timestamp' => true,
|
||||
'revision' => true,
|
||||
'modified' => true,
|
||||
'date_modified' => true, // User
|
||||
'current_login' => true, // User
|
||||
'last_login' => true, // User
|
||||
'newsread' => true, // User
|
||||
'proposal_email_lock' => true, // Event
|
||||
];
|
||||
|
||||
public function initialize(array $config): void
|
||||
{
|
||||
$this->config = $config;
|
||||
}
|
||||
|
||||
public function beforeSave(EventInterface $event, EntityInterface $entity, ArrayObject $options)
|
||||
{
|
||||
$fields = $entity->extract($entity->getVisible(), true);
|
||||
$skipFields = $this->skipFields;
|
||||
$fieldsToFetch = array_filter($fields, function($key) use ($skipFields) {
|
||||
return strpos($key, '_') !== 0 && !isset($skipFields[$key]);
|
||||
}, ARRAY_FILTER_USE_KEY);
|
||||
// Do not fetch old version when just few fields will be fetched
|
||||
$fieldToFetch = [];
|
||||
if (!empty($options['fieldList'])) {
|
||||
foreach ($options['fieldList'] as $field) {
|
||||
if (!isset($this->skipFields[$field])) {
|
||||
$fieldToFetch[] = $field;
|
||||
}
|
||||
}
|
||||
if (empty($fieldToFetch)) {
|
||||
$this->old = null;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if ($entity->id) {
|
||||
$this->old = $this->_table->find()->where(['id' => $entity->id])->contain($fieldToFetch)->first();
|
||||
} else {
|
||||
$this->old = null;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public function afterSave(EventInterface $event, EntityInterface $entity, ArrayObject $options)
|
||||
{
|
||||
if ($entity->id) {
|
||||
$id = $entity->id;
|
||||
} else {
|
||||
$id = null;
|
||||
}
|
||||
|
||||
if ($entity->isNew()) {
|
||||
$action = $entity->getConstant('ACTION_ADD');
|
||||
} else {
|
||||
$action = $entity->getConstant('ACTION_EDIT');
|
||||
if (isset($entity['deleted'])) {
|
||||
if ($entity['deleted']) {
|
||||
$action = $entity->getConstant('ACTION_SOFT_DELETE');
|
||||
} else if (!$entity['deleted'] && $this->old['deleted']) {
|
||||
$action = $entity->getConstant('ACTION_UNDELETE');
|
||||
}
|
||||
}
|
||||
}
|
||||
$changedFields = $this->changedFields($entity, isset($options['fieldList']) ? $options['fieldList'] : null);
|
||||
if (empty($changedFields)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$modelTitleField = $this->_table->getDisplayField();
|
||||
if (is_callable($modelTitleField)) {
|
||||
$modelTitle = $modelTitleField($entity, isset($this->old) ? $this->old : []);
|
||||
} else if (isset($entity[$modelTitleField])) {
|
||||
$modelTitle = $entity[$modelTitleField];
|
||||
} else if ($this->old[$modelTitleField]) {
|
||||
$modelTitle = $this->old[$modelTitleField];
|
||||
}
|
||||
$this->auditLogs()->insert([
|
||||
'request_action' => $action,
|
||||
'model' => $entity->getSource(),
|
||||
'model_id' => $id,
|
||||
'model_title' => $modelTitle,
|
||||
'change' => $changedFields
|
||||
]);
|
||||
}
|
||||
|
||||
public function beforeDelete(EventInterface $event, EntityInterface $entity, ArrayObject $options)
|
||||
{
|
||||
$this->old = $this->_table->find()->where(['id' => $entity->id])->first();
|
||||
return true;
|
||||
}
|
||||
|
||||
public function afterDelete(EventInterface $event, EntityInterface $entity, ArrayObject $options)
|
||||
{
|
||||
$modelTitleField = $this->_table->getDisplayField();
|
||||
if (is_callable($modelTitleField)) {
|
||||
$modelTitle = $modelTitleField($entity, []);
|
||||
} else if (isset($entity[$modelTitleField])) {
|
||||
$modelTitle = $entity[$modelTitleField];
|
||||
}
|
||||
|
||||
$logEntity = $this->auditLogs()->newEntity([
|
||||
'request_action' => $entity->getConstant('ACTION_DELETE'),
|
||||
'model' => $entity->getSource(),
|
||||
'model_id' => $this->old->id,
|
||||
'model_title' => $modelTitle,
|
||||
'change' => $this->changedFields($entity)
|
||||
]);
|
||||
$logEntity->save();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Model $model
|
||||
* @param array|null $fieldsToSave
|
||||
* @return array
|
||||
*/
|
||||
private function changedFields(EntityInterface $entity, $fieldsToSave = null)
|
||||
{
|
||||
$dbFields = $this->_table->getSchema()->typeMap();
|
||||
$changedFields = [];
|
||||
foreach ($entity->extract($entity->getVisible()) as $key => $value) {
|
||||
if (isset($this->skipFields[$key])) {
|
||||
continue;
|
||||
}
|
||||
if (!isset($dbFields[$key])) {
|
||||
continue;
|
||||
}
|
||||
if ($fieldsToSave && !in_array($key, $fieldsToSave, true)) {
|
||||
continue;
|
||||
}
|
||||
if (isset($entity[$key]) && isset($this->old[$key])) {
|
||||
$old = $this->old[$key];
|
||||
} else {
|
||||
$old = null;
|
||||
}
|
||||
// Normalize
|
||||
if (is_bool($old)) {
|
||||
$old = $old ? 1 : 0;
|
||||
}
|
||||
if (is_bool($value)) {
|
||||
$value = $value ? 1 : 0;
|
||||
}
|
||||
$dbType = $dbFields[$key];
|
||||
if ($dbType === 'integer' || $dbType === 'tinyinteger' || $dbType === 'biginteger' || $dbType === 'boolean') {
|
||||
$value = (int)$value;
|
||||
if ($old !== null) {
|
||||
$old = (int)$old;
|
||||
}
|
||||
}
|
||||
if ($value == $old) {
|
||||
continue;
|
||||
}
|
||||
if ($key === 'password' || $key === 'authkey') {
|
||||
$value = '*****';
|
||||
if ($old !== null) {
|
||||
$old = $value;
|
||||
}
|
||||
}
|
||||
|
||||
if ($old === null) {
|
||||
$changedFields[$key] = $value;
|
||||
} else {
|
||||
$changedFields[$key] = [$old, $value];
|
||||
}
|
||||
}
|
||||
return $changedFields;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return AuditLogs
|
||||
*/
|
||||
public function auditLogs()
|
||||
{
|
||||
if ($this->AuditLogs === null) {
|
||||
$this->AuditLogs = TableRegistry::getTableLocator()->get('AuditLogs');
|
||||
}
|
||||
return $this->AuditLogs;
|
||||
}
|
||||
|
||||
public function log()
|
||||
{
|
||||
|
||||
}
|
||||
}
|
|
@ -6,6 +6,28 @@ use Cake\ORM\Entity;
|
|||
|
||||
class AppModel extends Entity
|
||||
{
|
||||
const BROTLI_HEADER = "\xce\xb2\xcf\x81";
|
||||
const BROTLI_MIN_LENGTH = 200;
|
||||
|
||||
const ACTION_ADD = 'add',
|
||||
ACTION_EDIT = 'edit',
|
||||
ACTION_SOFT_DELETE = 'soft_delete',
|
||||
ACTION_DELETE = 'delete',
|
||||
ACTION_UNDELETE = 'undelete',
|
||||
ACTION_TAG = 'tag',
|
||||
ACTION_TAG_LOCAL = 'tag_local',
|
||||
ACTION_REMOVE_TAG = 'remove_tag',
|
||||
ACTION_REMOVE_TAG_LOCAL = 'remove_local_tag',
|
||||
ACTION_LOGIN = 'login',
|
||||
ACTION_LOGIN_FAIL = 'login_fail',
|
||||
ACTION_LOGOUT = 'logout';
|
||||
|
||||
|
||||
public function getConstant($name)
|
||||
{
|
||||
return constant('self::' . $name);
|
||||
}
|
||||
|
||||
public function getAccessibleFieldForNew(): array
|
||||
{
|
||||
return $this->_accessibleOnNew ?? [];
|
||||
|
|
|
@ -0,0 +1,68 @@
|
|||
<?php
|
||||
|
||||
namespace App\Model\Entity;
|
||||
|
||||
use App\Model\Entity\AppModel;
|
||||
use Cake\ORM\Entity;
|
||||
use Cake\Core\Configure;
|
||||
|
||||
class AuditLog extends AppModel
|
||||
{
|
||||
private $compressionEnabled = false;
|
||||
|
||||
public function __construct(array $properties = [], array $options = [])
|
||||
{
|
||||
$this->compressionEnabled = Configure::read('Cerebrate.log_compress') && function_exists('brotli_compress');
|
||||
parent::__construct($properties, $options);
|
||||
}
|
||||
|
||||
protected function _getTitle(): String
|
||||
{
|
||||
return $this->generateUserFriendlyTitle($this);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $change
|
||||
* @return array|string
|
||||
* @throws JsonException
|
||||
*/
|
||||
private function decodeChange($change)
|
||||
{
|
||||
if (substr($change, 0, 4) === self::BROTLI_HEADER) {
|
||||
if (function_exists('brotli_uncompress')) {
|
||||
$change = brotli_uncompress(substr($change, 4));
|
||||
if ($change === false) {
|
||||
return 'Compressed';
|
||||
}
|
||||
} else {
|
||||
return 'Compressed';
|
||||
}
|
||||
}
|
||||
return json_decode($change, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $auditLog
|
||||
* @return string
|
||||
*/
|
||||
public function generateUserFriendlyTitle($auditLog)
|
||||
{
|
||||
if (in_array($auditLog['request_action'], [self::ACTION_TAG, self::ACTION_TAG_LOCAL, self::ACTION_REMOVE_TAG, self::ACTION_REMOVE_TAG_LOCAL], true)) {
|
||||
$attached = ($auditLog['request_action'] === self::ACTION_TAG || $auditLog['request_action'] === self::ACTION_TAG_LOCAL);
|
||||
$local = ($auditLog['request_action'] === self::ACTION_TAG_LOCAL || $auditLog['request_action'] === self::ACTION_REMOVE_TAG_LOCAL) ? __('local') : __('global');
|
||||
if ($attached) {
|
||||
return __('Attached %s tag "%s" to %s #%s', $local, $auditLog['model_title'], strtolower($auditLog['model']), $auditLog['model_id']);
|
||||
} else {
|
||||
return __('Detached %s tag "%s" from %s #%s', $local, $auditLog['model_title'], strtolower($auditLog['model']), $auditLog['model_id']);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
$title = "{$auditLog['model']} #{$auditLog['model_id']}";
|
||||
|
||||
if (isset($auditLog['model_title']) && $auditLog['model_title']) {
|
||||
$title .= ": {$auditLog['model_title']}";
|
||||
}
|
||||
return $title;
|
||||
}
|
||||
}
|
|
@ -12,6 +12,7 @@ class AlignmentsTable extends AppTable
|
|||
{
|
||||
parent::initialize($config);
|
||||
$this->belongsTo('Individuals');
|
||||
$this->addBehavior('AuditLog');
|
||||
$this->belongsTo('Organisations');
|
||||
$this->addBehavior('Timestamp');
|
||||
}
|
||||
|
|
|
@ -0,0 +1,250 @@
|
|||
<?php
|
||||
namespace App\Model\Table;
|
||||
|
||||
use App\Model\Table\AppTable;
|
||||
use Cake\ORM\Table;
|
||||
use Cake\Validation\Validator;
|
||||
use Cake\Datasource\EntityInterface;
|
||||
use Cake\Event\Event;
|
||||
use Cake\Event\EventInterface;
|
||||
use Cake\Auth\DefaultPasswordHasher;
|
||||
use Cake\Utility\Security;
|
||||
use Cake\Core\Configure;
|
||||
use Cake\Routing\Router;
|
||||
use Cake\Http\Exception\MethodNotAllowedException;
|
||||
use ArrayObject;
|
||||
|
||||
/**
|
||||
* @property Event $Event
|
||||
* @property User $User
|
||||
* @property Organisation $Organisation
|
||||
*/
|
||||
class AuditLogsTable extends AppTable
|
||||
{
|
||||
const BROTLI_HEADER = "\xce\xb2\xcf\x81";
|
||||
const BROTLI_MIN_LENGTH = 200;
|
||||
|
||||
const REQUEST_TYPE_DEFAULT = 0,
|
||||
REQUEST_TYPE_API = 1,
|
||||
REQUEST_TYPE_CLI = 2;
|
||||
|
||||
/** @var array|null */
|
||||
private $user = null;
|
||||
|
||||
/** @var bool */
|
||||
private $compressionEnabled;
|
||||
|
||||
/**
|
||||
* Null when not defined, false when not enabled
|
||||
* @var Syslog|null|false
|
||||
*/
|
||||
private $syslog;
|
||||
|
||||
public function initialize(array $config): void
|
||||
{
|
||||
parent::initialize($config);
|
||||
$this->addBehavior('UUID');
|
||||
$this->addBehavior('Timestamp', [
|
||||
'Model.beoreSave' => [
|
||||
'created_at' => 'new'
|
||||
]
|
||||
]);
|
||||
$this->belongsTo('Users');
|
||||
$this->setDisplayField('info');
|
||||
$this->compressionEnabled = Configure::read('Cerebrate.log_new_audit_compress') && function_exists('brotli_compress');
|
||||
}
|
||||
|
||||
public function beforeMarshal(EventInterface $event, ArrayObject $data, ArrayObject $options)
|
||||
{
|
||||
if (!isset($data['request_ip'])) {
|
||||
$ipHeader = 'REMOTE_ADDR';
|
||||
if (isset($_SERVER[$ipHeader])) {
|
||||
$data['request_ip'] = $_SERVER[$ipHeader];
|
||||
} else {
|
||||
$data['request_ip'] = '127.0.0.1';
|
||||
}
|
||||
}
|
||||
foreach (['user_id', 'request_type', 'authkey_id'] as $field) {
|
||||
if (!isset($data[$field])) {
|
||||
if (!isset($userInfo)) {
|
||||
$userInfo = $this->userInfo();
|
||||
}
|
||||
if (!empty($userInfo[$field])) {
|
||||
$data[$field] = $userInfo[$field];
|
||||
} else {
|
||||
$data[$field] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isset($data['request_id'] ) && isset($_SERVER['HTTP_X_REQUEST_ID'])) {
|
||||
$data['request_id'] = $_SERVER['HTTP_X_REQUEST_ID'];
|
||||
}
|
||||
|
||||
// Truncate request_id
|
||||
if (isset($data['request_id']) && strlen($data['request_id']) > 255) {
|
||||
$data['request_id'] = substr($data['request_id'], 0, 255);
|
||||
}
|
||||
|
||||
// Truncate model title
|
||||
if (isset($data['model_title']) && mb_strlen($data['model_title']) > 255) {
|
||||
$data['model_title'] = mb_substr($data['model_title'], 0, 252) . '...';
|
||||
}
|
||||
|
||||
if (isset($data['change'])) {
|
||||
$change = json_encode($data['change'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
if ($this->compressionEnabled && strlen($change) >= self::BROTLI_MIN_LENGTH) {
|
||||
$change = self::BROTLI_HEADER . brotli_compress($change, 4, BROTLI_TEXT);
|
||||
}
|
||||
$data['change'] = $change;
|
||||
}
|
||||
}
|
||||
|
||||
public function beforeSave(EventInterface $event, EntityInterface $entity, ArrayObject $options)
|
||||
{
|
||||
$entity->request_ip = inet_pton($entity->request_ip);
|
||||
$this->logData($entity);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $data
|
||||
* @return bool
|
||||
*/
|
||||
private function logData(EntityInterface $entity)
|
||||
{
|
||||
if (Configure::read('Plugin.ZeroMQ_enable') && Configure::read('Plugin.ZeroMQ_audit_notifications_enable')) {
|
||||
$pubSubTool = $this->getPubSubTool();
|
||||
$pubSubTool->publish($data, 'audit', 'log');
|
||||
}
|
||||
|
||||
//$this->publishKafkaNotification('audit', $data, 'log');
|
||||
|
||||
if (Configure::read('Plugin.ElasticSearch_logging_enable')) {
|
||||
// send off our logs to distributed /dev/null
|
||||
$logIndex = Configure::read("Plugin.ElasticSearch_log_index");
|
||||
$elasticSearchClient = $this->getElasticSearchTool();
|
||||
$elasticSearchClient->pushDocument($logIndex, "log", $data);
|
||||
}
|
||||
|
||||
// write to syslogd as well if enabled
|
||||
if ($this->syslog === null) {
|
||||
if (Configure::read('Security.syslog')) {
|
||||
$options = [];
|
||||
$syslogToStdErr = Configure::read('Security.syslog_to_stderr');
|
||||
if ($syslogToStdErr !== null) {
|
||||
$options['to_stderr'] = $syslogToStdErr;
|
||||
}
|
||||
$syslogIdent = Configure::read('Security.syslog_ident');
|
||||
if ($syslogIdent) {
|
||||
$options['ident'] = $syslogIdent;
|
||||
}
|
||||
$this->syslog = new SysLog($options);
|
||||
} else {
|
||||
$this->syslog = false;
|
||||
}
|
||||
}
|
||||
if ($this->syslog) {
|
||||
$entry = $data['request_action'];
|
||||
$title = $entity->generateUserFriendlyTitle();
|
||||
if ($title) {
|
||||
$entry .= " -- $title";
|
||||
}
|
||||
$this->syslog->write('info', $entry);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array
|
||||
*/
|
||||
public function userInfo()
|
||||
{
|
||||
if ($this->user !== null) {
|
||||
return $this->user;
|
||||
}
|
||||
|
||||
$this->user = ['id' => 0, /*'org_id' => 0, */'authkey_id' => 0, 'request_type' => self::REQUEST_TYPE_DEFAULT];
|
||||
|
||||
$isShell = (php_sapi_name() === 'cli');
|
||||
if ($isShell) {
|
||||
// do not start session for shell commands and fetch user info from configuration
|
||||
$this->user['request_type'] = self::REQUEST_TYPE_CLI;
|
||||
$currentUserId = Configure::read('CurrentUserId');
|
||||
if (!empty($currentUserId)) {
|
||||
$this->user['id'] = $currentUserId;
|
||||
$userFromDb = $this->Users->find()->where(['id' => $currentUserId])->first();
|
||||
$this->user['name'] = $userFromDb['name'];
|
||||
$this->user['org_id'] = $userFromDb['org_id'];
|
||||
}
|
||||
} else {
|
||||
$authUser = Router::getRequest()->getSession()->read('authUser');
|
||||
if (!empty($authUser)) {
|
||||
$this->user['id'] = $authUser['id'];
|
||||
$this->user['user_id'] = $authUser['id'];
|
||||
$this->user['name'] = $authUser['name'];
|
||||
//$this->user['org_id'] = $authUser['org_id'];
|
||||
if (isset($authUser['logged_by_authkey']) && $authUser['logged_by_authkey']) {
|
||||
$this->user['request_type'] = self::REQUEST_TYPE_API;
|
||||
}
|
||||
if (isset($authUser['authkey_id'])) {
|
||||
$this->user['authkey_id'] = $authUser['authkey_id'];
|
||||
}
|
||||
}
|
||||
}
|
||||
return $this->user;
|
||||
}
|
||||
|
||||
public function insert(array $data)
|
||||
{
|
||||
$logEntity = $this->newEntity($data);
|
||||
if ($logEntity->getErrors()) {
|
||||
throw new Exception($logEntity->getErrors());
|
||||
} else {
|
||||
$this->save($logEntity);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string|int $org
|
||||
* @return array
|
||||
*/
|
||||
public function returnDates($org = 'all')
|
||||
{
|
||||
$conditions = [];
|
||||
if ($org !== 'all') {
|
||||
$org = $this->Organisation->fetchOrg($org);
|
||||
if (empty($org)) {
|
||||
throw new NotFoundException('Invalid organisation.');
|
||||
}
|
||||
$conditions['org_id'] = $org['id'];
|
||||
}
|
||||
|
||||
$dataSource = ConnectionManager::getDataSource('default')->config['datasource'];
|
||||
if ($dataSource === 'Database/Mysql' || $dataSource === 'Database/MysqlObserver') {
|
||||
$validDates = $this->find('all', [
|
||||
'recursive' => -1,
|
||||
'fields' => ['DISTINCT UNIX_TIMESTAMP(DATE(created)) AS Date', 'count(id) AS count'],
|
||||
'conditions' => $conditions,
|
||||
'group' => ['Date'],
|
||||
'order' => ['Date'],
|
||||
]);
|
||||
} elseif ($dataSource === 'Database/Postgres') {
|
||||
if (!empty($conditions['org_id'])) {
|
||||
$condOrg = sprintf('WHERE org_id = %s', intval($conditions['org_id']));
|
||||
} else {
|
||||
$condOrg = '';
|
||||
}
|
||||
$sql = 'SELECT DISTINCT EXTRACT(EPOCH FROM CAST(created AS DATE)) AS "Date", COUNT(id) AS count
|
||||
FROM audit_logs
|
||||
' . $condOrg . '
|
||||
GROUP BY "Date" ORDER BY "Date"';
|
||||
$validDates = $this->query($sql);
|
||||
}
|
||||
$data = [];
|
||||
foreach ($validDates as $date) {
|
||||
$data[(int)$date[0]['Date']] = (int)$date[0]['count'];
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
}
|
|
@ -19,10 +19,11 @@ class AuthKeysTable extends AppTable
|
|||
{
|
||||
parent::initialize($config);
|
||||
$this->addBehavior('UUID');
|
||||
$this->addBehavior('AuditLog');
|
||||
$this->belongsTo(
|
||||
'Users'
|
||||
);
|
||||
$this->setDisplayField('authkey');
|
||||
$this->setDisplayField('comment');
|
||||
}
|
||||
|
||||
public function beforeMarshal(EventInterface $event, ArrayObject $data, ArrayObject $options)
|
||||
|
|
|
@ -19,6 +19,7 @@ class BroodsTable extends AppTable
|
|||
parent::initialize($config);
|
||||
$this->addBehavior('UUID');
|
||||
$this->addBehavior('Timestamp');
|
||||
$this->addBehavior('AuditLog');
|
||||
$this->BelongsTo(
|
||||
'Organisations'
|
||||
);
|
||||
|
@ -278,7 +279,7 @@ class BroodsTable extends AppTable
|
|||
}
|
||||
return $jsonReply;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* handleSendingFailed - Handle the case if the request could not be sent or if the remote rejected the connection request
|
||||
*
|
||||
|
@ -302,7 +303,7 @@ class BroodsTable extends AppTable
|
|||
];
|
||||
return $creationResult;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* handleMessageNotCreated - Handle the case if the request was sent but the remote brood did not save the message in the inbox
|
||||
*
|
||||
|
|
|
@ -14,6 +14,7 @@ class EncryptionKeysTable extends AppTable
|
|||
{
|
||||
parent::initialize($config);
|
||||
$this->addBehavior('UUID');
|
||||
$this->addBehavior('AuditLog');
|
||||
$this->addBehavior('Timestamp');
|
||||
$this->belongsTo(
|
||||
'Individuals',
|
||||
|
|
|
@ -19,7 +19,7 @@ class InboxTable extends AppTable
|
|||
parent::initialize($config);
|
||||
$this->addBehavior('UUID');
|
||||
$this->addBehavior('Timestamp');
|
||||
|
||||
$this->addBehavior('AuditLog');
|
||||
$this->belongsTo('Users');
|
||||
$this->setDisplayField('title');
|
||||
}
|
||||
|
@ -68,7 +68,7 @@ class InboxTable extends AppTable
|
|||
if (empty($brood)) {
|
||||
$errors[] = __('Unkown brood `{0}`', $entryData['data']['cerebrateURL']);
|
||||
}
|
||||
|
||||
|
||||
// $found = false;
|
||||
// foreach ($user->individual->organisations as $organisations) {
|
||||
// if ($organisations->id == $brood->organisation_id) {
|
||||
|
|
|
@ -16,6 +16,7 @@ class IndividualsTable extends AppTable
|
|||
$this->addBehavior('UUID');
|
||||
$this->addBehavior('Timestamp');
|
||||
$this->addBehavior('Tags.Tag');
|
||||
$this->addBehavior('AuditLog');
|
||||
$this->hasMany(
|
||||
'Alignments',
|
||||
[
|
||||
|
|
|
@ -18,6 +18,8 @@ class InstanceTable extends AppTable
|
|||
public function initialize(array $config): void
|
||||
{
|
||||
parent::initialize($config);
|
||||
$this->addBehavior('AuditLog');
|
||||
$this->setDisplayField('name');
|
||||
}
|
||||
|
||||
public function validationDefault(Validator $validator): Validator
|
||||
|
@ -54,7 +56,7 @@ class InstanceTable extends AppTable
|
|||
$timeline[$entry->date]['count'] = $entry->count;
|
||||
}
|
||||
$statistics[$model]['timeline'] = array_values($timeline);
|
||||
|
||||
|
||||
$startCount = $table->find()->where(['modified <' => new \DateTime("-{$days} days")])->all()->count();
|
||||
$endCount = $statistics[$model]['amount'];
|
||||
$statistics[$model]['variation'] = $endCount - $startCount;
|
||||
|
|
|
@ -30,6 +30,7 @@ class LocalToolsTable extends AppTable
|
|||
public function initialize(array $config): void
|
||||
{
|
||||
parent::initialize($config);
|
||||
$this->addBehavior('AuditLog');
|
||||
$this->addBehavior('Timestamp');
|
||||
}
|
||||
|
||||
|
|
|
@ -12,6 +12,7 @@ class MetaFieldsTable extends AppTable
|
|||
{
|
||||
parent::initialize($config);
|
||||
$this->addBehavior('UUID');
|
||||
$this->addBehavior('AuditLog');
|
||||
$this->setDisplayField('field');
|
||||
$this->belongsTo('MetaTemplates');
|
||||
$this->belongsTo('MetaTemplateFields');
|
||||
|
|
|
@ -20,6 +20,7 @@ class OrganisationsTable extends AppTable
|
|||
parent::initialize($config);
|
||||
$this->addBehavior('Timestamp');
|
||||
$this->addBehavior('Tags.Tag');
|
||||
$this->addBehavior('AuditLog');
|
||||
$this->hasMany(
|
||||
'Alignments',
|
||||
[
|
||||
|
|
|
@ -28,6 +28,7 @@ class OutboxProcessorsTable extends AppTable
|
|||
if (empty($this->outboxProcessors)) {
|
||||
$this->loadProcessors();
|
||||
}
|
||||
$this->addBehavior('AuditLog');
|
||||
}
|
||||
|
||||
public function getProcessor($scope, $action=null)
|
||||
|
@ -87,7 +88,7 @@ class OutboxProcessorsTable extends AppTable
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* getProcessorClass
|
||||
*
|
||||
|
@ -112,7 +113,7 @@ class OutboxProcessorsTable extends AppTable
|
|||
return $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* createOutboxEntry
|
||||
*
|
||||
|
|
|
@ -19,8 +19,8 @@ class OutboxTable extends AppTable
|
|||
parent::initialize($config);
|
||||
$this->addBehavior('UUID');
|
||||
$this->addBehavior('Timestamp');
|
||||
|
||||
$this->belongsTo('Users');
|
||||
$this->addBehavior('AuditLog');
|
||||
$this->setDisplayField('title');
|
||||
}
|
||||
|
||||
|
|
|
@ -18,6 +18,7 @@ class RemoteToolConnectionsTable extends AppTable
|
|||
'LocalTools'
|
||||
);
|
||||
$this->setDisplayField('id');
|
||||
$this->addBehavior('AuditLog');
|
||||
}
|
||||
|
||||
public function validationDefault(Validator $validator): Validator
|
||||
|
|
|
@ -12,6 +12,7 @@ class RolesTable extends AppTable
|
|||
{
|
||||
parent::initialize($config);
|
||||
$this->addBehavior('UUID');
|
||||
$this->addBehavior('AuditLog');
|
||||
$this->hasMany(
|
||||
'Users',
|
||||
[
|
||||
|
|
|
@ -26,6 +26,7 @@ class SettingsTable extends AppTable
|
|||
parent::initialize($config);
|
||||
$this->setTable(false);
|
||||
$this->SettingsProvider = new CerebrateSettingsProvider();
|
||||
$this->addBehavior('AuditLog');
|
||||
}
|
||||
|
||||
public function getSettings($full=false): array
|
||||
|
|
|
@ -15,6 +15,7 @@ class SharingGroupsTable extends AppTable
|
|||
parent::initialize($config);
|
||||
$this->addBehavior('UUID');
|
||||
$this->addBehavior('Timestamp');
|
||||
$this->addBehavior('AuditLog');
|
||||
$this->belongsTo(
|
||||
'Users'
|
||||
);
|
||||
|
|
|
@ -20,6 +20,7 @@ class UsersTable extends AppTable
|
|||
parent::initialize($config);
|
||||
$this->addBehavior('Timestamp');
|
||||
$this->addBehavior('UUID');
|
||||
$this->addBehavior('AuditLog');
|
||||
$this->initAuthBehaviors();
|
||||
$this->belongsTo(
|
||||
'Individuals',
|
||||
|
|
|
@ -0,0 +1,65 @@
|
|||
<?php
|
||||
echo $this->element('genericElements/IndexTable/index_table', [
|
||||
'data' => [
|
||||
'data' => $data,
|
||||
'top_bar' => [
|
||||
'children' => [
|
||||
[
|
||||
'type' => 'search',
|
||||
'button' => __('Filter'),
|
||||
'placeholder' => __('Enter value to search'),
|
||||
'data' => '',
|
||||
'searchKey' => 'value'
|
||||
]
|
||||
]
|
||||
],
|
||||
'fields' => [
|
||||
[
|
||||
'name' => '#',
|
||||
'sort' => 'id',
|
||||
'data_path' => 'id',
|
||||
],
|
||||
[
|
||||
'name' => __('IP'),
|
||||
'sort' => 'request_ip',
|
||||
'data_path' => 'request_ip',
|
||||
],
|
||||
[
|
||||
'name' => __('Username'),
|
||||
'sort' => 'user.username',
|
||||
'data_path' => 'user.username',
|
||||
],
|
||||
[
|
||||
'name' => __('Title'),
|
||||
'data_path' => 'title',
|
||||
],
|
||||
[
|
||||
'name' => __('Model'),
|
||||
'sort' => 'model',
|
||||
'data_path' => 'model',
|
||||
],
|
||||
[
|
||||
'name' => __('Model ID'),
|
||||
'sort' => 'model',
|
||||
'data_path' => 'model_id',
|
||||
],
|
||||
[
|
||||
'name' => __('Action'),
|
||||
'sort' => 'action',
|
||||
'data_path' => 'action',
|
||||
],
|
||||
[
|
||||
'name' => __('Change'),
|
||||
'sort' => 'change',
|
||||
'data_path' => 'change',
|
||||
'element' => 'json'
|
||||
],
|
||||
],
|
||||
'title' => __('Logs'),
|
||||
'description' => null,
|
||||
'pull' => 'right',
|
||||
'actions' => []
|
||||
]
|
||||
]);
|
||||
echo '</div>';
|
||||
?>
|
|
@ -1,9 +1,12 @@
|
|||
<?php
|
||||
$data = h($this->Hash->extract($row, $field['data_path']));
|
||||
$data = $this->Hash->extract($row, $field['data_path']);
|
||||
// I feed dirty for this...
|
||||
if (is_array($data) && count($data) === 1 && isset($data[0])) {
|
||||
$data = $data[0];
|
||||
}
|
||||
if (!is_array($data)) {
|
||||
$data = json_decode($data, true);
|
||||
}
|
||||
echo sprintf(
|
||||
'<div class="json_container_%s"></div>',
|
||||
h($k)
|
||||
|
|
Loading…
Reference in New Issue