Contributing
This section is for developers who want to contribute code to the tirreno project. If you only want to customize tirreno for your own use (custom rules, pattern lists), see the Risk rules & customization section above.
Notice: Submissions using generative AI will be rejected. Submissions from AI chatbots will result in the account being banned.
Source code
The source code is maintained at: https://github.com/tirrenotechnologies/tirreno
Before you start
Most issues in the tirreno issue tracker are ideas and bugs that the team would like to implement or solve. However, this is not always the case — the team may no longer be interested in some issues even though they remain open.
Before you spend time working on a bug or feature (and risk it not being merged), it is highly recommended that you first leave a comment on the issue explaining that you are interested in contributing. In your comment, also explain how you plan to solve the bug or implement the new feature, and ask for a quick validation of your approach.
This gives the tirreno team the opportunity to review your proposal, confirm whether they want to see it added, and provide early guidance. The team will reply in the issue, and once they confirm, you can confidently work towards opening a Pull Request.
If no existing issue matches your idea, create a new issue first and wait for team feedback before starting development.
Contributor license agreement (CLA)
Before your contributions can be accepted, you must sign the tirreno Contributor License Agreement (CLA). All contributed code is dual-licensed: AGPL-3.0 for open source use and a separate enterprise license for commercial use. Contact team@tirreno.com for the CLA document.
Git workflow
- Fork the repository on GitHub
- Clone your fork:
git clone https://github.com/YOUR_USERNAME/tirreno.git
- Create a branch:
git checkout -b feature/your-feature
- Make changes following coding standards
- Commit, push, and open a Pull Request
Local development setup
Prerequisites
- PHP 8.0 to 8.3 with extensions: PDO_PGSQL, cURL, mbstring
- PostgreSQL 12 or greater
- Apache with mod_rewrite
- Composer
- Git
Local setup
# 1. Fork and clone
git clone https://github.com/YOUR_USERNAME/tirreno.git
cd tirreno
# 2. Install dependencies
composer install
# 3. Create PostgreSQL database
createdb tirreno_dev
# 4. Configure database
# Edit config/ files with your database credentials
# 5. Run web installer
# Point Apache to project root, visit: http://localhost/install/
# 6. Delete install directory (important!)
rm -rf install/
# 7. Setup cron job
crontab -e
# Add: */10 * * * * /usr/bin/php /absolute/path/to/tirreno/index.php /cron
# 8. Create admin account at /signup/ |
Docker setup
One line:
curl -sL tirreno.com/t.yml | docker compose -f - up -d
tirreno uses the following tools for code quality:
- PHP_CodeSniffer (
phpcs.xml) for PHP style enforcement
- PHPStan for static analysis
- ESLint (
eslint.config.js) for JavaScript
# PHP CodeSniffer - check style
./vendor/bin/phpcs --standard=phpcs.xml app/
# PHP CodeSniffer - auto-fix
./vendor/bin/phpcbf --standard=phpcs.xml app/
# PHPStan - static analysis
./vendor/bin/phpstan analyse
# ESLint - JavaScript
npx eslint ui/js/
npx eslint ui/js/ --fix |
PHP coding standards
Class structure
Follow the tirreno Model pattern:
<?php
declare(strict_types=1);
namespace Tirreno\Models;
class Device extends \Tirreno\Models\BaseSql {
protected $DB_TABLE_NAME = 'event_device';
public function getFullDeviceInfoById(int $deviceId, int $apiKey): array {
// ...
}
} |
Use fully-qualified class names and type declarations for all parameters and return types.
Naming conventions
| Element | Convention | Example |
| Classes/Namespaces | PascalCase | Device, BaseSql |
| Methods/Variables | camelCase | getDeviceInfo(), $apiKey |
| Constants | UPPER_SNAKE_CASE | DB_TABLE_NAME |
| Tables/Columns | snake_case | event_device, api_key |
| Query params | :snake_case | :api_key, :device_id |
Query string style
Use parentheses for multiline SQL queries:
$query = (
'SELECT id, lang, created
FROM event_device
WHERE key = :api_key'
); |
SQL security
Always use named PDO parameters:
// Good
$params = [':api_key' => $apiKey, ':device_id' => $subjectId];
$query = ('SELECT id FROM event_device WHERE id = :device_id AND key = :api_key');
$results = $this->execQuery($query, $params);
// Bad - never concatenate user input
$query = "SELECT * FROM event_device WHERE id = $deviceId"; |
Database best practices
Extend \Models\BaseSql, use execQuery(). Never raw PDO.
XSS prevention
Templates auto-escape with {{ @var }}. Use htmlspecialchars() at output time in PHP:
echo htmlspecialchars($userInput, ENT_QUOTES, 'UTF-8'); |
Template syntax
tirreno uses the Fat-Free Framework's template engine with includes, variables, and inline PHP:
<include href="templates/parts/headerAdmin.html" />
<div id="wrap">
<include href="templates/parts/panel/eventPanel.html" />
<include href="templates/parts/panel/devicePanel.html" />
<include href="templates/parts/leftMenu.html" />
<div class="main">
<include href="templates/parts/forms/globalSearchForm.html" />
<include href="templates/parts/systemNotification.html" />
<include href="templates/parts/notification.html" />
{~
$country = ['iso' => $IP['country_iso']];
$subtitle = array();
if(isset($IP['name']) && !empty($IP['name'])) {
$subtitle[] = $IP['name'];
}
$subtitle = join(', ', $subtitle);
~}
<include href="templates/parts/infoHeader.html" with="title={{@IP.ip}}, country={{@country}}, id={{@IP.id}}"/>
<include href="templates/parts/widgets/ip.html" />
<include href="templates/parts/tables/users.html" />
<include href="templates/parts/tables/events.html" with="showChart=1"/>
</div>
</div>
<include href="templates/parts/footerAdmin.html" /> |
Template conventions:
| Syntax | Purpose | Example |
{{ @var }} | Output escaped variable | {{ @IP.ip }} |
{{ @var | raw }} | Output unescaped (careful!) | {{ @htmlContent | raw }} |
{{ @arr.key }} | Access array element | {{ @IP.country_iso }} |
{~ ... ~} | Inline PHP code block | {~ $x = 1 + 2; ~} |
<include href="..." /> | Include template file | <include href="templates/parts/header.html" /> |
<include ... with="..." /> | Include with parameters | <include href="..." with="title={{@IP.ip}}, id={{@IP.id}}"/> |
{<b> ... </b>} | Template comment (not rendered) | {<b><include href="..." /></b>} |
Template directory structure:
ui/templates/
├── layout.html # Base layout
├── pages/ # Page templates
│ ├── admin/ # Admin page templates
│ │ ├── events.html
│ │ ├── ip.html
│ │ ├── users.html
│ │ └── ...
│ ├── login.html
│ ├── signup.html
│ └── ...
├── parts/ # Reusable components
│ ├── headerAdmin.html # Common header
│ ├── footerAdmin.html # Common footer
│ ├── leftMenu.html # Navigation menu
│ ├── notification.html # Alert messages
│ ├── forms/ # Form components
│ ├── panel/ # Side panels
│ ├── tables/ # Data tables
│ ├── widgets/ # Dashboard widgets
│ └── choices/ # Filter dropdowns
└── snippets/ # Code snippets
├── php.html
├── python.html
└── nodejs.html |
Key patterns:
- Use
<include> for reusable components (DRY principle)
- Pass data with
with="param1={{@var1}}, param2={{@var2}}"
- Use
{~ ... ~} for template logic (preprocessing data before display)
- Comment out unused includes with
{<b> ... </b>}
- Access nested array data with dot notation:
@IP.country_iso
Internationalization (i18n)
tirreno uses the framework's built-in internationalization support. Language strings are stored in dictionary files under app/Dictionary/.
Using translations in templates:
<h1>{{ @DICT.dashboard_title }}</h1>
<button>{{ @DICT.save_button }}</button> |
Using translations in PHP:
$f3 = \Base::instance();
// Get translated string
$message = $f3->get('DICT.welcome_message');
// With variables
$greeting = sprintf($f3->get('DICT.hello_user'), $userName); |
Best practices:
- Never hardcode user-visible strings, use dictionary keys
- Keep dictionary keys descriptive:
dashboard_title, not dt1
- Group related strings with prefixes:
error_invalid_email, error_login_failed
- Don't concatenate translated strings, word order varies by language
JavaScript coding standards
Follow the ESLint configuration in eslint.config.js:
// Use const/let, not var
const API_ENDPOINT = '/sensor/';
let eventCount = 0;
// Use arrow functions
const trackEvent = async (userId, eventType) => {
const response = await fetch(API_ENDPOINT, {
method: 'POST',
body: new URLSearchParams({ userName: userId, eventType }),
});
return response.ok;
};
// Use template literals
const message = `User ${userId} logged in at ${timestamp}`; |
Page architecture
tirreno uses ES6 modules with a class-based page structure:
import {BasePage} from './Base.js';
import {DatesFilter} from '../parts/DatesFilter.js?v=2';
import {SearchFilter} from '../parts/SearchFilter.js?v=2';
import {IpTypeFilter} from '../parts/choices/IpTypeFilter.js?v=2';
import {IpsChart} from '../parts/chart/Ips.js?v=2';
import {IpsGrid} from '../parts/grid/Ips.js?v=2';
export class IpsPage extends BasePage {
constructor() {
super('ips');
this.initUi();
}
initUi() {
const datesFilter = new DatesFilter();
const searchFilter = new SearchFilter();
const ipTypeFilter = new IpTypeFilter();
this.filters = {
dateRange: datesFilter,
searchValue: searchFilter,
ipTypeIds: ipTypeFilter,
};
const gridParams = {
url: `${window.app_base}/admin/loadIps`,
tileId: 'totalIps',
tableId: 'ips-table',
dateRangeGrid: true,
calculateTotals: true,
totals: {
type: 'ip',
columns: ['total_visit'],
},
isSortable: true,
orderByLastseen: false,
choicesFilterEvents: [ipTypeFilter.getEventType()],
getParams: this.getParamsSection,
};
const chartParams = this.getChartParams(datesFilter, searchFilter);
new IpsChart(chartParams);
new IpsGrid(gridParams);
}
} |
JavaScript conventions:
| Pattern | Description | Example |
| ES6 modules | Use import/export | import {BasePage} from './Base.js'; |
| Class inheritance | Pages extend BasePage | class IpsPage extends BasePage |
| Version cache-busting | Append ?v=N to imports | '../parts/DatesFilter.js?v=2' |
| Constructor pattern | Call super(), then initUi() | super('ips'); this.initUi(); |
| Filters object | Store filter instances | this.filters = { dateRange, searchValue } |
| Global app base | Use window.app_base for URLs | <code contenteditable> </code>${window.app_base}/admin/loadIps `` |
JavaScript directory structure:
ui/js/
├── endpoints/ # Page entry points
│ ├── admin_ips.js
│ ├── admin_events.js
│ └── ...
├── pages/ # Page controllers
│ ├── Base.js # Base page class
│ ├── Ips.js # IPs page (IpsPage)
│ ├── Events.js # Events page
│ └── ...
├── parts/ # Reusable components
│ ├── DatesFilter.js # Date range filter
│ ├── SearchFilter.js # Search input filter
│ ├── DataRenderers.js # Column rendering functions
│ ├── choices/ # Dropdown filters (Choices.js)
│ │ └── IpTypeFilter.js
│ ├── chart/ # Chart components (uPlot)
│ │ └── Ips.js
│ ├── grid/ # Data grid components (DataTables)
│ │ └── Ips.js
│ ├── panel/ # Detail panels
│ └── utils/ # Utility modules
│ ├── Constants.js
│ ├── String.js
│ └── Date.js
└── vendor/ # Third-party libraries |
- Indentation: 4 spaces (no tabs), check
.editorconfig if present
- Line endings: Unix (LF)
- File encoding: UTF-8
- Trailing newline: Yes
tirreno uses a minimal documentation style. Write self-documenting code with descriptive names and type declarations. Add comments only to explain "why", not "what":
// Good - explains why
// Skip devices that haven't been updated since last sync
if ($device->lastseen < $lastSync) {
continue;
}
// Bad - states the obvious
// Check if lastseen is less than lastSync
if ($device->lastseen < $lastSync) {
continue;
} |
Commit messages
Write good commit messages. Follow these guidelines:
Format: <type>: <subject>. Types: Add, Fix, Update, Remove, Refactor, Docs
Add: user session timeout configuration
Allow admins to configure timeout. Default 30 min.
Closes #123 |
Line endings
All text files should use Unix-style line endings (LF, not CRLF). Windows developers should configure Git: git config --global core.autocrlf input
Testing
Before submitting a pull request:
- Test your changes on Chrome and Firefox
- Run code quality checks: phpcs, phpstan, eslint
- Verify database changes work with PostgreSQL 12+
________________________________________________________________________________