Initial commit

This commit is contained in:
DavidCamejo 2025-11-04 21:44:35 -04:00
commit 6e3d1c0d67
24 changed files with 1503 additions and 0 deletions

187
README.md Normal file
View File

@ -0,0 +1,187 @@
# GlobalQuota - Nextcloud App v1.2.1
Define and monitor a global storage quota, compatible with S3 and external storage.
**NEW v1.2.1**: Enhanced UI reliability with fallback text display and improved debugging.
**NEW v1.2.0**: Adaptive visualization with ServerInfo integration or standalone Chart.js donut.
**NEW v1.1.0**: Blocks uploads when global quota is exceeded.
---
## Features
* ✅ **Global quota definition and monitoring**
* ✅ **Compatible with S3 and external storage backends**
* ✅ **Upload blocking when quota exceeded** (PSR-14 events)
* ✅ **OCC commands** for management
* ✅ **REST API** for external integration
* ✅ **Server Info dashboard integration**
* ✅ **Adaptive visualization (v1.2.0)**:
+ Integrates seamlessly into ServerInfo if supported
+ Shows its own donut chart in Admin Settings otherwise
* ✅ **Enhanced reliability (v1.2.1)**:
+ Always displays quota values in text format
+ Robust error handling and debugging logs
+ Chart.js fallback mechanisms
---
### Admin Settings Panel
The app displays quota information with both visual chart and text values:
- **Used storage**: Shows current usage
- **Free storage**: Available space remaining
- **Total storage**: Global quota limit
- **Usage percentage**: Visual indicator with color coding
---
## Installation
1. **Download** or clone this repository
2. **Copy** the `globalquota` folder to your Nextcloud `apps/` directory
3. **Enable** the app in Nextcloud Admin Settings → Apps
4. **Configure** the global quota in Admin Settings → Additional Settings → Global Quota
---
## Configuration
Add to `config/config.php`:
```php
'globalquota' => [
'enabled' => true,
'quota_bytes' => 500 * 1024 * 1024 * 1024, // 500 GB
],
```
---
## OCC Commands
```bash
# Set quota (in bytes)
occ globalquota:set 2147483648 # 2 GB
# Check status
occ globalquota:status
# Force recalculation
occ globalquota:recalc
```
---
## API Endpoints
```bash
# Get current status (new endpoint for admin chart)
curl -H "Authorization: Bearer TOKEN" \
https://instance.com/apps/globalquota/status
# Legacy API v1 endpoint
curl -H "Authorization: Bearer TOKEN" \
https://instance.com/apps/globalquota/api/v1/status
# Update quota
curl -X PUT \
-H "Authorization: Bearer TOKEN" \
-H "Content-Type: application/json" \
-d '{"quota_bytes": 10737418240}' \
https://instance.com/apps/globalquota/api/v1/quota
```
---
## Upload Blocking
When the global quota is exceeded, the app will:
* ❌ **Block new file uploads**
* ❌ **Block file updates that increase size**
* 🚫 **Return HTTP 507 "Insufficient Storage" error**
* 📱 **Work with all clients** (web, desktop, mobile, WebDAV)
---
## Visualization Modes
### 🔗 **With ServerInfo Integration**
If ServerInfo supports events (`LoadAdditionalDataEvent`), GlobalQuota **replaces the Disk chart** with GlobalQuota data.
### 📊 **Standalone Mode**
If ServerInfo does not support events, GlobalQuota shows its **own donut chart** in the Admin Settings section.
### 📝 **Text Fallback (v1.2.1)**
Even if the chart fails to load, quota values are **always displayed in text format** for reliability.
**Both modes display:**
- Usage percentage with color coding
- Used / Free / Total values (formatted)
- Auto-refresh capability via "Refresh" button
- Detailed console logging for debugging
---
## Troubleshooting
### Chart not displaying?
1. Check browser console for errors (`F12` → Console)
2. Look for `GlobalQuota:` log messages
3. Verify Chart.js is loading (CDN or local)
4. Text values should still display regardless
### API errors?
1. Verify app is enabled and configured
2. Check Nextcloud logs for backend errors
3. Test endpoints directly with curl
---
## Requirements
* **Nextcloud 25+**
* **Admin privileges** for configuration
* **Optional**: ServerInfo app (for dashboard integration)
* **Browser**: Modern browser with JavaScript enabled
---
## Changelog
* **v1.2.1** *(Latest)*
+ Enhanced UI reliability with always-visible text values
+ Improved error handling and debugging logs
+ Chart.js fallback mechanisms
+ Better console logging for troubleshooting
* **v1.2.0**
+ Adaptive visualization (ServerInfo integration or standalone Chart.js donut)
+ New `/apps/globalquota/status` endpoint
+ Improved admin settings panel
* **v1.1.0**
+ Upload blocking functionality with PSR-14 events
+ OCC commands and REST API integration
* **v1.0.0**
+ Initial release with basic quota management and S3 compatibility
---
## Contributing
1. **Fork** this repository
2. **Create** a feature branch
3. **Make** your changes
4. **Test** thoroughly
5. **Submit** a pull request
### Reporting Issues
Please report bugs and feature requests on [GitHub Issues](https://github.com/DavidCamejo/globalquota/issues).
---
**Author:** David Camejo
**License:** AGPL-3.0
**Repository:** https://github.com/DavidCamejo/globalquota

7
appinfo/app.php Normal file
View File

@ -0,0 +1,7 @@
<?php
/**
* Load application class
*/
namespace OCA\GlobalQuota\AppInfo;
$app = new Application();

25
appinfo/info.xml Normal file
View File

@ -0,0 +1,25 @@
<?xml version="1.0"?>
<info xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://apps.nextcloud.com/schema/apps/info.xsd">
<id>globalquota</id>
<name>Global Quota</name>
<summary>Manage global storage quota with adaptive visualization</summary>
<description>Global Quota allows administrators to set and monitor a global storage limit across all users. Integrates with ServerInfo if available, or shows its own chart.</description>
<version>1.2.0</version>
<licence>agpl</licence>
<author>David Camejo</author>
<namespace>GlobalQuota</namespace>
<category>tools</category>
<dependencies>
<nextcloud min-version="25" max-version="30"/>
</dependencies>
<commands>
<command>OCA\GlobalQuota\Command\StatusCommand</command>
<command>OCA\GlobalQuota\Command\SetQuotaCommand</command>
<command>OCA\GlobalQuota\Command\RecalcCommand</command>
</commands>
<settings>
<admin>OCA\GlobalQuota\Settings\Admin\Settings</admin>
<admin-section>OCA\GlobalQuota\Settings\Admin\Section</admin-section>
</settings>
</info>

12
appinfo/routes.php Normal file
View File

@ -0,0 +1,12 @@
<?php
return [
'routes' => [
['name' => 'quota#status', 'url' => '/status', 'verb' => 'GET'],
['name' => 'quota#apiStatus', 'url' => '/api/v1/status', 'verb' => 'GET'],
['name' => 'quota#updateQuota', 'url' => '/api/v1/quota', 'verb' => 'PUT'],
['name' => 'quota#setQuota', 'url' => '/set', 'verb' => 'POST'],
['name' => 'quota#getQuota', 'url' => '/quota', 'verb' => 'GET'],
['name' => 'quota#recalc', 'url' => '/recalc', 'verb' => 'GET'],
]
];

51
css/admin-globalquota.css Normal file
View File

@ -0,0 +1,51 @@
#globalquota.section {
margin-top: 20px;
}
.globalquota-container {
display: flex;
flex-wrap: wrap;
gap: 20px;
align-items: stretch;
}
.globalquota-chart-wrapper {
width: 280px;
min-height: 260px;
position: relative;
padding: 10px;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-main-background);
display: flex;
align-items: center;
justify-content: center;
}
.globalquota-stats {
flex: 1;
min-width: 260px;
padding: 16px;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-main-background);
}
.globalquota-stats h3 {
margin-top: 0;
}
.globalquota-list {
list-style: none;
padding: 0;
margin: 0 0 12px 0;
}
.globalquota-list li {
margin: 6px 0;
}
.globalquota-error {
color: #b71c1c;
margin-top: 8px;
}

58
css/admin-settings.css Normal file
View File

@ -0,0 +1,58 @@
/**
* GlobalQuota Admin Settings Styles
*/
#globalquota-settings {
margin-bottom: 30px;
}
#globalquota-settings h2 {
margin-bottom: 20px;
font-weight: bold;
}
.globalquota-chart-container {
background: var(--color-main-background);
border: 1px solid var(--color-border);
border-radius: var(--border-radius-large);
padding: 20px;
margin: 20px 0;
}
.globalquota-chart-wrapper {
display: flex;
align-items: center;
gap: 30px;
margin: 20px 0;
}
#globalquota-chart {
max-width: 280px;
max-height: 280px;
}
.globalquota-stats {
flex: 1;
min-width: 200px;
}
.stat-item {
display: flex;
justify-content: space-between;
border-bottom: 1px solid var(--color-border-dark);
padding: 6px 0;
font-family: monospace;
}
.stat-item:last-child {
border-bottom: none;
}
.globalquota-actions {
margin-top: 10px;
}
.globalquota-actions .button {
padding: 6px 14px;
margin-right: 8px;
}

4
img/app-dark.svg Normal file
View File

@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<circle cx="32" cy="32" r="30" stroke="black" stroke-width="4" fill="#0078d7"/>
<text x="32" y="37" font-size="20" text-anchor="middle" fill="white">GQ</text>
</svg>

After

Width:  |  Height:  |  Size: 231 B

359
js/admin-settings.js Normal file
View File

@ -0,0 +1,359 @@
/**
* GlobalQuota Admin Settings JavaScript
* Maneja el gráfico donut y las acciones de administración
*/
(function() {
'use strict';
// Validate Nextcloud environment
if (typeof OC === 'undefined' || typeof t === 'undefined') {
console.error('GlobalQuota: Nextcloud environment not available');
return;
}
console.log('GlobalQuota admin JS loaded ✅');
let quotaChart = null;
let chartInitialized = false;
let dataLoaded = false;
function initChart() {
const canvas = document.getElementById('globalquota-chart');
if (!canvas) {
console.warn('GlobalQuota: canvas #globalquota-chart no encontrado');
return;
}
if (typeof Chart === 'undefined') {
console.warn('GlobalQuota: Chart.js no está disponible');
return;
}
try {
const ctx = canvas.getContext('2d');
quotaChart = new Chart(ctx, {
type: 'doughnut',
data: {
labels: [t('globalquota', 'Used'), t('globalquota', 'Free')],
datasets: [{
data: [0, 100],
backgroundColor: ['#e74c3c', '#ecf0f1'],
borderWidth: 0,
cutout: '70%'
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom',
labels: {
padding: 20,
usePointStyle: true,
font: {
size: 12
}
}
},
tooltip: {
callbacks: {
label: function(context) {
const label = context.label || '';
const value = formatBytes(context.raw);
const total = context.dataset.data.reduce((a, b) => a + b, 0);
const percentage = total > 0 ? ((context.raw / total) * 100).toFixed(1) : '0.0';
return `${label}: ${value} (${percentage}%)`;
}
}
}
}
}
});
chartInitialized = true;
console.log('GlobalQuota: Gráfico inicializado correctamente');
} catch (error) {
console.error('GlobalQuota: Error al inicializar el gráfico:', error);
quotaChart = null;
chartInitialized = false;
}
}
function loadQuotaData() {
// Prevent multiple simultaneous requests
if (dataLoaded) {
console.log('GlobalQuota: Data already loading, skipping...');
return;
}
dataLoaded = true;
console.log('GlobalQuota: Cargando datos...');
// Clear any previous errors
const errorEl = document.getElementById('quota-error');
if (errorEl) {
errorEl.style.display = 'none';
errorEl.textContent = '';
}
// Try the new /status endpoint first
fetch(OC.generateUrl('/apps/globalquota/status'))
.then(async response => {
console.log('GlobalQuota: Respuesta del endpoint /status:', response.status);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const text = await response.text();
if (!text) {
throw new Error('Empty response');
}
try {
const data = JSON.parse(text);
console.log('GlobalQuota: Datos recibidos de /status:', data);
return data;
} catch (parseError) {
console.warn('GlobalQuota: respuesta no-JSON', text);
throw new Error('Invalid JSON response');
}
})
.then(data => {
if (data && typeof data.used !== 'undefined' && typeof data.total !== 'undefined') {
processQuotaData(data);
} else {
throw new Error('Invalid data structure');
}
})
.catch(error => {
console.log('GlobalQuota: /status failed, trying /quota endpoint...', error.message);
// Fallback to legacy /quota endpoint
return fetch(OC.generateUrl('/apps/globalquota/quota'))
.then(response => {
console.log('GlobalQuota: Respuesta del endpoint /quota:', response.status);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
return response.json();
})
.then(data => {
console.log('GlobalQuota: Datos recibidos de /quota:', data);
if (data && typeof data.used !== 'undefined' && typeof data.total !== 'undefined') {
processQuotaData(data);
} else {
throw new Error('Invalid quota data structure');
}
})
.catch(fallbackError => {
console.error('GlobalQuota: Both endpoints failed:', fallbackError);
showError('Failed to load quota data from both endpoints');
});
})
.finally(() => {
dataLoaded = false;
});
}
function processQuotaData(data) {
// Unified calculation logic for both chart and stats
const used = Number(data.used) || 0;
const total = Number(data.total) || 0;
const available = Number(data.available || data.free || 0);
// Calculate free space with unified logic
let free;
if (total > 0) {
// If we have total, calculate free as total - used
free = Math.max(0, total - used);
} else if (available > 0) {
// If no total but we have available, use that
free = available;
} else {
// Fallback: assume no free space if we can't calculate
free = 0;
}
// Calculate percentage
const percentage = total > 0 ? ((used / total) * 100) : 0;
// Create normalized data object
const normalizedData = {
used: used,
free: free,
total: total > 0 ? total : (used + free),
percentage: percentage,
formatted: data.formatted || {}
};
console.log('GlobalQuota: Normalized data:', normalizedData);
// Update both chart and stats with the same data
updateChart(normalizedData);
updateStats(normalizedData);
}
function updateChart(data) {
if (!quotaChart || !chartInitialized) {
console.warn('GlobalQuota: Chart not initialized, skipping update');
return;
}
console.log('GlobalQuota: Updating chart with:', {
used: data.used,
free: data.free,
total: data.total
});
quotaChart.data.datasets[0].data = [data.used, data.free];
// Update colors based on usage percentage
let usedColor = '#2ecc71'; // Green
if (data.percentage > 90) {
usedColor = '#e74c3c'; // Red
} else if (data.percentage > 75) {
usedColor = '#f39c12'; // Orange
} else if (data.percentage > 50) {
usedColor = '#f1c40f'; // Yellow
}
quotaChart.data.datasets[0].backgroundColor = [usedColor, '#ecf0f1'];
quotaChart.update('active');
}
function updateStats(data) {
// Use the same normalized data for stats
const elems = {
'quota-used': data.formatted?.used || formatBytes(data.used),
'quota-free': data.formatted?.free || data.formatted?.available || formatBytes(data.free),
'quota-total': data.formatted?.total || formatBytes(data.total),
'quota-percentage': data.percentage.toFixed(1) + '%'
};
console.log('GlobalQuota: Updating stats with:', elems);
Object.entries(elems).forEach(([id, val]) => {
const el = document.getElementById(id);
if (el) {
el.textContent = val;
} else {
console.warn(`GlobalQuota: Element #${id} not found`);
}
});
}
function formatBytes(bytes) {
if (!bytes || isNaN(bytes) || bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB', 'PB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
const idx = Math.max(0, Math.min(i, sizes.length - 1));
const num = bytes / Math.pow(k, idx);
return num.toFixed(2) + ' ' + sizes[idx];
}
function showError(msg) {
console.error('GlobalQuota:', msg);
// Try to show error in dedicated error element
const errorEl = document.getElementById('quota-error');
if (errorEl) {
errorEl.style.display = 'block';
errorEl.textContent = msg;
}
// Also try Nextcloud notification system
try {
if (OC && OC.Notification) {
OC.Notification.showTemporary(msg, { type: 'error' });
}
} catch (e) {
console.warn('GlobalQuota: Could not show notification:', e);
}
}
function loadChartJS() {
return new Promise((resolve, reject) => {
if (typeof Chart !== 'undefined') {
console.log('GlobalQuota: Chart.js already available');
resolve();
return;
}
console.log('GlobalQuota: Loading Chart.js...');
// Try to load from CDN first
const cdnScript = document.createElement('script');
cdnScript.src = 'https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.js';
cdnScript.onload = function() {
console.log('GlobalQuota: Chart.js loaded from CDN');
resolve();
};
cdnScript.onerror = function() {
console.warn('GlobalQuota: CDN failed, trying local Chart.js...');
// Fallback to local file
const localScript = document.createElement('script');
localScript.src = OC.filePath('globalquota', 'js', 'chart.min.js');
localScript.onload = function() {
console.log('GlobalQuota: Chart.js loaded locally');
resolve();
};
localScript.onerror = function() {
console.error('GlobalQuota: Failed to load Chart.js from both CDN and local');
reject(new Error('Chart.js not available'));
};
document.head.appendChild(localScript);
};
document.head.appendChild(cdnScript);
});
}
// Initialize when DOM is ready
document.addEventListener('DOMContentLoaded', function() {
console.log('GlobalQuota: DOM loaded, iniciando...');
// Initialize chart if canvas exists
const canvas = document.getElementById('globalquota-chart');
if (canvas) {
console.log('GlobalQuota: Canvas found, loading Chart.js...');
loadChartJS()
.then(() => {
initChart();
// Load data only after chart is ready
loadQuotaData();
})
.catch(error => {
console.error('GlobalQuota: Failed to initialize chart:', error);
showError('Chart visualization not available');
// Still load data for stats even if chart fails
loadQuotaData();
});
} else {
console.warn('GlobalQuota: Canvas #globalquota-chart not found');
// Load data for stats only
loadQuotaData();
}
// Set up refresh button
const refreshBtn = document.getElementById('refresh-quota');
if (refreshBtn) {
refreshBtn.addEventListener('click', function() {
console.log('GlobalQuota: Refresh button clicked');
loadQuotaData();
});
}
});
})();

14
js/chart.js Normal file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace OCA\GlobalQuota\AppInfo;
use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext;
use OCP\AppFramework\Bootstrap\IBootstrap;
use OCP\AppFramework\Bootstrap\IRegistrationContext;
use OCP\EventDispatcher\IEventDispatcher;
use OCA\GlobalQuota\Listener\BeforeFileWrittenListener;
use OCA\GlobalQuota\Listener\OverrideServerInfoListener;
use OCP\Files\Events\Node\BeforeNodeWrittenEvent;
class Application extends App implements IBootstrap {
public const APP_ID = 'globalquota';
public function __construct(array $urlParams = []) {
parent::__construct(self::APP_ID, $urlParams);
}
public function register(IRegistrationContext $context): void {
// Registro del listener para bloqueo de uploads
$context->registerEventListener(
BeforeNodeWrittenEvent::class,
BeforeFileWrittenListener::class
);
// 🚀 DETECCIÓN AUTOMÁTICA: ServerInfo vs Panel Propio
if (class_exists('\OCA\ServerInfo\Events\LoadAdditionalDataEvent')) {
// Caso 1: ServerInfo soporta eventos → nos integramos
$context->registerEventListener(
\OCA\ServerInfo\Events\LoadAdditionalDataEvent::class,
OverrideServerInfoListener::class
);
} else {
// Caso 2: ServerInfo no soporta eventos → panel propio en Admin Settings
$context->registerService('GlobalQuotaAdminSettings', function() {
return new \OCA\GlobalQuota\Settings\Admin\Settings();
});
}
}
public function boot(IBootContext $context): void {
// Boot logic if needed
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace OCA\GlobalQuota\Command;
use OCA\GlobalQuota\Service\QuotaService;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class RecalcCommand extends Command {
private $quotaService;
public function __construct(QuotaService $quotaService) {
parent::__construct();
$this->quotaService = $quotaService;
}
protected function configure() {
$this->setName('globalquota:recalc')
->setDescription('Recalculate global usage');
}
protected function execute(InputInterface $input, OutputInterface $output) {
$status = $this->quotaService->getStatus(true);
$output->writeln("Recalculated usage: {$status['used_bytes']} bytes");
return 0;
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace OCA\GlobalQuota\Command;
use OCA\GlobalQuota\Service\QuotaService;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class SetQuotaCommand extends Command {
private $quotaService;
public function __construct(QuotaService $quotaService) {
parent::__construct();
$this->quotaService = $quotaService;
}
protected function configure() {
$this->setName('globalquota:set')
->setDescription('Set global quota in bytes')
->addArgument('bytes', InputArgument::REQUIRED, 'Quota in bytes');
}
protected function execute(InputInterface $input, OutputInterface $output) {
$bytes = (int)$input->getArgument('bytes');
$this->quotaService->setQuota($bytes);
$output->writeln("Global quota set to $bytes bytes");
return 0;
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace OCA\GlobalQuota\Command;
use OCA\GlobalQuota\Service\QuotaService;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;
class StatusCommand extends Command {
private $quotaService;
public function __construct(QuotaService $quotaService) {
parent::__construct();
$this->quotaService = $quotaService;
}
protected function configure() {
$this->setName('globalquota:status')
->setDescription('Show global quota and usage');
}
protected function execute(InputInterface $input, OutputInterface $output) {
$status = $this->quotaService->getStatus();
$output->writeln("Quota: {$status['quota_bytes']} bytes");
$output->writeln("Used: {$status['used_bytes']} bytes");
$output->writeln("Free: {$status['free_bytes']} bytes");
$output->writeln("Usage: {$status['usage_percentage']}%");
return 0;
}
}

View File

@ -0,0 +1,38 @@
<?php
namespace OCA\GlobalQuota\Controller;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http\DataResponse;
use OCA\GlobalQuota\Service\QuotaService;
use OCP\IRequest;
class ApiController extends Controller {
private $quotaService;
public function __construct($AppName, IRequest $request, QuotaService $quotaService) {
parent::__construct($AppName, $request);
$this->quotaService = $quotaService;
}
/**
* @NoCSRFRequired
* @AdminRequired
*/
public function status(): DataResponse {
return new DataResponse($this->quotaService->getStatus());
}
/**
* @NoCSRFRequired
* @AdminRequired
*/
public function setQuota(): DataResponse {
$data = $this->request->getParams();
if (!isset($data['quota_bytes'])) {
return new DataResponse(['error' => 'Missing quota_bytes'], 400);
}
$this->quotaService->setQuota((int)$data['quota_bytes']);
return new DataResponse(['status' => 'ok']);
}
}

View File

@ -0,0 +1,189 @@
<?php
declare(strict_types=1);
namespace OCA\GlobalQuota\Controller;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http\JSONResponse;
use OCP\IRequest;
use OCA\GlobalQuota\Service\QuotaService;
class QuotaController extends Controller {
private QuotaService $quotaService;
public function __construct(
string $appName,
IRequest $request,
QuotaService $quotaService
) {
parent::__construct($appName, $request);
$this->quotaService = $quotaService;
}
/** @NoAdminRequired @NoCSRFRequired */
public function status(): JSONResponse {
try {
$status = $this->quotaService->getStatus();
return new JSONResponse([
'success' => true,
'data' => [
'used' => $status['used_bytes'],
'total' => $status['quota_bytes'],
'free' => $status['free_bytes'],
'percentage' => $status['usage_percentage'],
'formatted' => [
'used' => $this->formatBytes($status['used_bytes']),
'total' => $this->formatBytes($status['quota_bytes']),
'free' => $this->formatBytes($status['free_bytes'])
]
]
]);
} catch (\Exception $e) {
return new JSONResponse(['success' => false, 'error' => $e->getMessage()], 500);
}
}
/** @NoAdminRequired @NoCSRFRequired */
public function apiStatus(): JSONResponse {
return $this->status();
}
/**
* @NoAdminRequired
* @NoCSRFRequired
*
* Endpoint for frontend quota display - returns simplified format
*/
public function getQuota(): JSONResponse {
try {
$status = $this->quotaService->getStatus();
return new JSONResponse([
'used' => $status['used_bytes'],
'available' => $status['free_bytes'],
'total' => $status['quota_bytes'],
'percentage' => $status['usage_percentage'],
'formatted' => [
'used' => $this->formatBytes($status['used_bytes']),
'available' => $this->formatBytes($status['free_bytes']),
'total' => $this->formatBytes($status['quota_bytes'])
]
]);
} catch (\Exception $e) {
return new JSONResponse(['error' => $e->getMessage()], 500);
}
}
/**
* @AdminRequired
* @NoCSRFRequired
*
* Recalculate quota usage
*/
public function recalc(): JSONResponse {
try {
// Force recalculation of quota usage
$status = $this->quotaService->getStatus(true);
return new JSONResponse([
'status' => 'success',
'message' => 'Quota recalculated successfully',
'data' => [
'used' => $status['used_bytes'],
'total' => $status['quota_bytes'],
'free' => $status['free_bytes'],
'percentage' => $status['usage_percentage'],
'formatted' => [
'used' => $this->formatBytes($status['used_bytes']),
'total' => $this->formatBytes($status['quota_bytes']),
'free' => $this->formatBytes($status['free_bytes'])
]
]
]);
} catch (\Exception $e) {
return new JSONResponse([
'status' => 'error',
'message' => 'Failed to recalculate quota: ' . $e->getMessage()
], 500);
}
}
/** @AdminRequired @NoCSRFRequired */
public function updateQuota(): JSONResponse {
$quotaBytes = $this->request->getParam('quota_bytes');
if (!is_numeric($quotaBytes) || $quotaBytes < 0) {
return new JSONResponse(['success' => false, 'error' => 'Invalid quota value'], 400);
}
try {
$this->quotaService->setQuota((int)$quotaBytes);
return new JSONResponse([
'success' => true,
'message' => 'Quota updated successfully',
'data' => $this->quotaService->getStatus()
]);
} catch (\Exception $e) {
return new JSONResponse(['success' => false, 'error' => $e->getMessage()], 500);
}
}
/**
* @AdminRequired
* @NoCSRFRequired
*
* Set quota endpoint for REST API compatibility
* Accepts both 'bytes' and 'quota_bytes' parameters
*/
public function setQuota(): JSONResponse {
// Accept both 'bytes' and 'quota_bytes' for compatibility
$quotaBytes = $this->request->getParam('bytes') ?? $this->request->getParam('quota_bytes');
if (!is_numeric($quotaBytes) || $quotaBytes < 0) {
return new JSONResponse([
'success' => false,
'error' => 'Invalid quota value. Please provide a valid number in bytes parameter.'
], 400);
}
try {
$this->quotaService->setQuota((int)$quotaBytes);
$status = $this->quotaService->getStatus();
return new JSONResponse([
'success' => true,
'message' => 'Global quota set successfully',
'data' => [
'quota_set' => (int)$quotaBytes,
'quota_formatted' => $this->formatBytes((int)$quotaBytes),
'current_status' => [
'used' => $status['used_bytes'],
'total' => $status['quota_bytes'],
'free' => $status['free_bytes'],
'percentage' => $status['usage_percentage'],
'formatted' => [
'used' => $this->formatBytes($status['used_bytes']),
'total' => $this->formatBytes($status['quota_bytes']),
'free' => $this->formatBytes($status['free_bytes'])
]
]
]
]);
} catch (\Exception $e) {
return new JSONResponse([
'success' => false,
'error' => 'Failed to set quota: ' . $e->getMessage()
], 500);
}
}
private function formatBytes(int $bytes): string {
$units = ['B', 'KiB', 'MiB', 'GiB', 'TiB'];
$bytes = max($bytes, 0);
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
$pow = min($pow, count($units) - 1);
$bytes /= (1 << (10 * $pow));
return round($bytes, 2) . ' ' . $units[$pow];
}
}

View File

@ -0,0 +1,62 @@
<?php
namespace OCA\GlobalQuota\Listener;
use OCP\EventDispatcher\IEventListener;
use OCP\Files\Events\Node\BeforeFileCreatedEvent;
use OCP\Files\Events\Node\BeforeFileUpdatedEvent;
use OCP\Files\ForbiddenException;
use OCA\GlobalQuota\Service\QuotaService;
class GlobalQuotaListener implements IEventListener {
private $quotaService;
public function __construct(QuotaService $quotaService) {
$this->quotaService = $quotaService;
}
/**
* Maneja eventos de creación/actualización de archivos
* Bloquea uploads si exceden la cuota global
*/
public function handle($event): void {
if ($event instanceof BeforeFileCreatedEvent || $event instanceof BeforeFileUpdatedEvent) {
$quota = $this->quotaService->getQuota();
// Si no hay cuota global definida → no bloquea
if ($quota === null) {
return;
}
// Tamaño actual del sistema
$used = $this->quotaService->getUsage();
// Tamaño esperado del nuevo archivo
$newSize = 0;
if (method_exists($event, 'getSize')) {
$newSize = $event->getSize();
}
// Si el archivo ya existía (update), restar su tamaño actual
$currentSize = 0;
if ($event instanceof BeforeFileUpdatedEvent) {
try {
$node = $event->getNode();
if ($node && method_exists($node, 'getSize')) {
$currentSize = $node->getSize();
}
} catch (\Exception $e) {
$currentSize = 0;
}
}
// Diferencia neta que sumará al uso total
$delta = max(0, $newSize - $currentSize);
// Verificar si excede la cuota
if (($used + $delta) > $quota) {
throw new ForbiddenException("Global quota exceeded. Upload blocked.");
}
}
}
}

View File

@ -0,0 +1,48 @@
<?php
declare(strict_types=1);
namespace OCA\GlobalQuota\Listener;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCA\GlobalQuota\Service\QuotaService;
use OCA\ServerInfo\Events\LoadAdditionalDataEvent;
class OverrideServerInfoListener implements IEventListener {
private QuotaService $quotaService;
public function __construct(QuotaService $quotaService) {
$this->quotaService = $quotaService;
}
public function handle(Event $event): void {
if (!($event instanceof LoadAdditionalDataEvent)) {
return;
}
try {
$status = $this->quotaService->getStatus();
$event->addData('disk', [
'used' => $status['used_bytes'],
'available' => $status['free_bytes'],
'total' => $status['quota_bytes'],
'percent' => $status['percentage_used'],
'mount' => 'GlobalQuota',
'filesystem' => 'Global Storage'
]);
$event->addData('quota_used', $status['used_bytes']);
$event->addData('quota_total', $status['quota_bytes']);
$event->addData('quota_free', $status['free_bytes']);
$event->addData('quota_percentage', $status['percentage_used']);
} catch (\Exception $e) {
\OC::$server->getLogger()->error(
'GlobalQuota: Error al obtener datos para ServerInfo: ' . $e->getMessage(),
['app' => 'globalquota']
);
}
}
}

View File

@ -0,0 +1,105 @@
<?php
namespace OCA\GlobalQuota\Service;
use OCP\IConfig;
use OCP\IUserManager;
use OCP\Files\IRootFolder;
use OCP\AppFramework\Utility\ITimeFactory;
class QuotaService {
private $config;
private $userManager;
private $rootFolder;
private $timeFactory;
private $cacheKey = 'globalquota_usage';
private $cacheTTL = 300; // 5 minutos
public function __construct(
IConfig $config,
IUserManager $userManager,
IRootFolder $rootFolder,
ITimeFactory $timeFactory
) {
$this->config = $config;
$this->userManager = $userManager;
$this->rootFolder = $rootFolder;
$this->timeFactory = $timeFactory;
}
public function getQuota(): ?int {
$val = $this->config->getSystemValue('globalquota', []);
return $val['quota_bytes'] ?? null;
}
public function setQuota(int $bytes): void {
$this->config->setSystemValue('globalquota', [
'quota_bytes' => $bytes
]);
$this->config->deleteAppValue('globalquota', $this->cacheKey);
}
public function getUsage(bool $forceRecalc = false): int {
if (!$forceRecalc) {
$cached = $this->getCache();
if ($cached !== null) {
return $cached['used'];
}
}
$totalUsed = 0;
foreach ($this->userManager->search('') as $user) {
try {
$folder = $this->rootFolder->getUserFolder($user->getUID());
// Usar getSize() en lugar de getCache()->getUsedSpace()
$totalUsed += $folder->getSize();
} catch (\Exception $e) {
// Skip user if there's an error accessing their folder
continue;
}
}
$this->setCache($totalUsed);
return $totalUsed;
}
public function getStatus(bool $forceRecalc = false): array {
$quota = $this->getQuota();
$used = $this->getUsage($forceRecalc);
return [
'quota_bytes' => $quota,
'used_bytes' => $used,
'free_bytes' => $quota - $used,
'usage_percentage' => $quota > 0 ? round(($used / $quota) * 100, 2) : 0
];
}
public function recalculateUsage(): array {
// Fuerza el recálculo (sin cache)
$status = $this->getStatus(true);
// Borra el cache anterior y guarda el nuevo
$this->config->deleteAppValue('globalquota', $this->cacheKey);
$this->setCache($status['used_bytes']);
return $status;
}
private function getCache(): ?array {
$raw = $this->config->getAppValue('globalquota', $this->cacheKey, null);
if ($raw === null) return null;
$data = json_decode($raw, true);
if (($this->timeFactory->getTime() - $data['timestamp']) > $this->cacheTTL) {
return null;
}
return $data;
}
private function setCache(int $used): void {
$data = [
'used' => $used,
'timestamp' => $this->timeFactory->getTime()
];
$this->config->setAppValue('globalquota', $this->cacheKey, json_encode($data));
}
}

41
lib/Settings/Admin.php Normal file
View File

@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
namespace OCA\GlobalQuota\Settings;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\IConfig;
use OCP\IL10N;
use OCP\Settings\ISettings;
use OCP\Util;
class Admin implements ISettings {
/** @var IL10N */
private $l10n;
/** @var IConfig */
private $config;
public function __construct(IL10N $l10n, IConfig $config) {
$this->l10n = $l10n;
$this->config = $config;
}
public function getForm(): TemplateResponse {
Util::addScript('globalquota', 'admin-globalquota');
Util::addStyle('globalquota', 'admin-globalquota');
return new TemplateResponse('globalquota', 'admin', [
// aquí puedes pasar valores iniciales si hiciera falta
], 'blank');
}
public function getSection(): string {
return 'server';
}
public function getPriority(): int {
return 50;
}
}

View File

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace OCA\GlobalQuota\Settings\Admin;
use OCP\IL10N;
use OCP\IURLGenerator;
use OCP\Settings\IIconSection;
class Section implements IIconSection {
private IL10N $l;
private IURLGenerator $urlGenerator;
public function __construct(IL10N $l, IURLGenerator $urlGenerator) {
$this->l = $l;
$this->urlGenerator = $urlGenerator;
}
public function getID(): string {
return 'globalquota';
}
public function getName(): string {
return $this->l->t('Global Quota');
}
public function getPriority(): int {
return 75;
}
public function getIcon(): string {
return $this->urlGenerator->imagePath('globalquota', 'app-dark.svg');
}
}

View File

@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
namespace OCA\GlobalQuota\Settings\Admin;
use OCP\AppFramework\Http\TemplateResponse;
use OCP\IConfig;
use OCP\IL10N;
use OCP\Settings\ISettings;
use OCA\GlobalQuota\Service\QuotaService;
class Settings implements ISettings {
private IConfig $config;
private IL10N $l;
private QuotaService $quotaService;
public function __construct(IConfig $config, IL10N $l, QuotaService $quotaService) {
$this->config = $config;
$this->l = $l;
$this->quotaService = $quotaService;
}
public function getForm(): TemplateResponse {
$showOwnChart = !class_exists('\OCA\ServerInfo\Events\LoadAdditionalDataEvent');
try {
$status = $this->quotaService->getStatus();
} catch (\Exception $e) {
$status = ['used_bytes' => 0,'quota_bytes' => 0,'free_bytes' => 0,'percentage_used' => 0];
}
return new TemplateResponse('globalquota','admin-settings',[
'showChart' => $showOwnChart,
'quotaStatus' => $status,
'serverInfoIntegration' => !$showOwnChart
]);
}
public function getSection(): string {
return 'globalquota';
}
public function getPriority(): int {
return 50;
}
}

View File

@ -0,0 +1,18 @@
<?php
namespace OCA\GlobalQuota\SystemInfo;
use OCA\GlobalQuota\Service\QuotaService;
use OCP\System\ISystemInfo;
class GlobalQuotaSystemInfo implements ISystemInfo {
private $quotaService;
public function __construct(QuotaService $quotaService) {
$this->quotaService = $quotaService;
}
public function getDiskInfo(): array {
return $this->quotaService->getStatus();
}
}

View File

@ -0,0 +1,34 @@
<?php
script('globalquota', 'admin-settings');
style('globalquota', 'admin-settings');
?>
<div id="globalquota-settings" class="section">
<h2 class="inlineblock"><?php p($l->t('Global Quota')); ?></h2>
<?php if ($_['serverInfoIntegration']): ?>
<div class="globalquota-info">
<p><?php p($l->t('GlobalQuota is integrated with Server Info.')); ?></p>
</div>
<?php else: ?>
<div class="globalquota-chart-container">
<h3><?php p($l->t('Storage Usage')); ?></h3>
<!-- Error display element -->
<div id="quota-error" style="display: none; color: #e74c3c; background: #fdf2f2; padding: 10px; border-radius: 4px; margin: 10px 0; border: 1px solid #fecaca;"></div>
<div class="globalquota-chart-wrapper">
<canvas id="globalquota-chart" width="300" height="300"></canvas>
<div class="globalquota-stats">
<div class="stat-item"><span><?php p($l->t('Used:')); ?></span><span id="quota-used">-</span></div>
<div class="stat-item"><span><?php p($l->t('Free:')); ?></span><span id="quota-free">-</span></div>
<div class="stat-item"><span><?php p($l->t('Total:')); ?></span><span id="quota-total">-</span></div>
<div class="stat-item"><span><?php p($l->t('Usage:')); ?></span><span id="quota-percentage">-</span></div>
</div>
</div>
<div class="globalquota-actions">
<button id="refresh-quota" class="button"><?php p($l->t('Refresh')); ?></button>
</div>
</div>
<?php endif; ?>
</div>

42
templates/admin.php Normal file
View File

@ -0,0 +1,42 @@
<?php
script('globalquota', 'admin-globalquota'); // registra js/admin-globalquota.js
style('globalquota', 'admin-globalquota'); // registra css/admin-globalquota.css
?>
<div id="globalquota" class="section">
<h2><?php p($l->t('Global Quota')); ?></h2>
<div class="globalquota-container">
<div class="globalquota-chart-wrapper">
<canvas id="globalquota-chart" width="240" height="240" aria-label="<?php p($l->t('Global quota donut chart')); ?>"></canvas>
</div>
<div class="globalquota-stats">
<h3><?php p($l->t('Storage Usage')); ?></h3>
<ul class="globalquota-list">
<li>
<strong><?php p($l->t('Used')); ?>:</strong>
<span id="quota-used">-</span>
</li>
<li>
<strong><?php p($l->t('Free')); ?>:</strong>
<span id="quota-free">-</span>
</li>
<li>
<strong><?php p($l->t('Total')); ?>:</strong>
<span id="quota-total">-</span>
</li>
<li>
<strong><?php p($l->t('Usage')); ?>:</strong>
<span id="quota-percentage">-</span>
</li>
</ul>
<button id="refresh-quota" class="button"><?php p($l->t('Refresh')); ?></button>
<div id="quota-error" class="globalquota-error" style="display:none;"></div>
</div>
</div>
</div>
<!-- Cargar Chart.js por CDN para asegurar disponibilidad -->
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>