OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)

This commit is contained in:
wangruiguo
2026-09-03 18:49:20 +08:00
commit d647428529
3501 changed files with 1988906 additions and 0 deletions

3
.babelrc Normal file
View File

@@ -0,0 +1,3 @@
{
"sourceType": "unambiguous"
}

View File

@@ -0,0 +1,49 @@
You are an expert in prompt engineering, specializing in optimizing AI code assistant instructions. Your task is to analyze and improve the instructions for Claude Code found in u/CLAUDE.md. Follow these steps carefully:
1. Analysis Phase:
Review the chat history in your context window.
Then, examine the current Claude instructions:
<claude_instructions>
u/CLAUDE.md
</claude_instructions>
Analyze the chat history and instructions to identify areas that could be improved. Look for:
- Inconsistencies in Claude's responses
- Misunderstandings of user requests
- Areas where Claude could provide more detailed or accurate information
- Opportunities to enhance Claude's ability to handle specific types of queries or tasks
2. Interaction Phase:
Present your findings and improvement ideas to the human. For each suggestion:
a) Explain the current issue you've identified
b) Propose a specific change or addition to the instructions
c) Describe how this change would improve Claude's performance
Wait for feedback from the human on each suggestion before proceeding. If the human approves a change, move it to the implementation phase. If not, refine your suggestion or move on to the next idea.
3. Implementation Phase:
For each approved change:
a) Clearly state the section of the instructions you're modifying
b) Present the new or modified text for that section
c) Explain how this change addresses the issue identified in the analysis phase
4. Output Format:
Present your final output in the following structure:
<analysis>
[List the issues identified and potential improvements]
</analysis>
<improvements>
[For each approved improvement:
1. Section being modified
2. New or modified instruction text
3. Explanation of how this addresses the identified issue]
</improvements>
<final_instructions>
[Present the complete, updated set of instructions for Claude, incorporating all approved changes]
</final_instructions>
Remember, your goal is to enhance Claude's performance and consistency while maintaining the core functionality and purpose of the AI assistant. Be thorough in your analysis, clear in your explanations, and precise in your implementations.

View File

@@ -0,0 +1,273 @@
# CalDAV Plugin Knowledge Base
Knowledge base for Leantime's CalDAV integration. Use this to answer user questions about calendar synchronization.
## Quick Reference
| Attribute | Value |
|-----------|-------|
| **Feature** | Bidirectional calendar sync |
| **Location** | Settings > CalDAV Integration |
| **Access** | Owner, Admin, Manager, Editor roles |
| **Sync Frequency** | Real-time + every 15 minutes |
---
## Common User Questions
### "How do I sync my calendar with Leantime?"
There are two ways to sync:
**Option 1: Use Leantime as a CalDAV Server (Recommended)**
Connect your phone or desktop calendar app directly to Leantime.
- Server URL: `https://[your-leantime-url]/caldav/server/`
- Username: Your Leantime email
- Password: Your Leantime password
- Works with: Apple Calendar, Thunderbird, DAVx5, Evolution
**Option 2: Push to External Server (Outbound)**
Push Leantime events to a CalDAV server like Nextcloud.
1. Go to Settings > CalDAV Integration
2. Click "Add CalDAV Account"
3. Enter your Nextcloud/ownCloud calendar URL and credentials
4. Click Connect
---
### "Why aren't my events syncing?"
Troubleshooting checklist:
1. **Is the account enabled?**
- Go to Settings > CalDAV Integration
- Verify the account shows "Enabled" (green badge)
2. **Try manual sync**
- Click "Sync Now" button next to the account
- Check if events appear after sync
3. **Check credentials**
- Edit the account
- Re-enter the password
- Save and try again
4. **Verify the calendar URL**
- URLs vary by server type:
- Nextcloud: `https://server.com/remote.php/dav/calendars/USERNAME/CALENDAR/`
- Baikal: `https://server.com/dav.php/calendars/USERNAME/CALENDAR/`
- Radicale: `https://server.com/USERNAME/CALENDAR/`
5. **Check SSL settings**
- If using self-signed certificates, uncheck "Verify SSL" in account settings
---
### "Can I use Apple Calendar with Leantime?"
Yes! Apple Calendar works great with Leantime's CalDAV server.
**Setup on Mac:**
1. Open Calendar > Add Account > Other CalDAV Account
2. Choose "Manual" for account type
3. Enter:
- Username: Your Leantime email
- Password: Your Leantime password
- Server: `https://your-leantime-url/caldav/server/`
4. Click Sign In
**Setup on iPhone/iPad:**
1. Settings > Calendar > Accounts > Add Account
2. Choose Other > Add CalDAV Account
3. Enter same details as above
4. Tap Next
---
### "Can I use Google Calendar with Leantime?"
Google Calendar doesn't support standard CalDAV for third-party apps.
**Workarounds:**
1. Use Nextcloud with Google Calendar integration as a bridge
2. Export events as ICS and import manually
3. Use DAVx5 on Android which can sync to multiple calendars
---
### "What's the difference between Server URL and Calendar URL?"
**Server URL**: The base address of the CalDAV server
- Example: `https://cloud.example.com`
**Calendar URL**: The full path to your specific calendar
- Example: `https://cloud.example.com/remote.php/dav/calendars/john/personal/`
Think of it like:
- Server URL = the building address
- Calendar URL = the specific room in that building
---
### "Is my password secure?"
Yes. CalDAV passwords are:
- Encrypted in the database using AES-256-CBC
- Never stored in plain text
- Decrypted only when making CalDAV requests
- Protected by your Leantime installation's APP_KEY
For the CalDAV server feature, connections should always use HTTPS to protect credentials in transit.
---
### "What gets synced?"
| Content | Direction | Editable |
|---------|-----------|----------|
| Calendar events | Two-way | Yes |
| All-day events | Two-way | Yes |
| Ticket due dates | Read-only | No* |
| Ticket work periods | Read-only | No* |
*To change ticket dates, edit the ticket directly in Leantime.
---
### "How often does sync happen?"
- **Immediately**: When you create, edit, or delete an event in Leantime
- **Every 15 minutes**: Automatic background sync pulls changes from external servers
- **On demand**: Click "Sync Now" to force immediate sync
---
### "Why can't I delete ticket due dates from my calendar app?"
Ticket due dates appear on your calendar but are "read-only" in external apps. They're controlled by the ticket itself.
**To change a ticket's due date:**
1. Open the ticket in Leantime
2. Edit the due date field
3. Save the ticket
4. The calendar will update automatically
---
### "How do I see someone else's calendar?"
CalDAV sync is personal - each user can only see their own calendar events.
**For team visibility:**
- Use project-level calendars in Leantime
- Share calendars through your external CalDAV server
- Use the team calendar view in Leantime
---
### "My external app can't connect to Leantime"
Check these common issues:
1. **URL format**: Make sure you're using `https://your-url/caldav/server/` (with trailing slash)
2. **Credentials**: Use your Leantime email and password
3. **HTTPS**: The CalDAV server requires HTTPS. HTTP connections won't work.
4. **Firewall**: Ensure port 443 is open for inbound connections
5. **App compatibility**: Some apps need specific setup:
- Thunderbird needs TbSync add-on
- Outlook needs CalDav Synchronizer add-on
---
### "Can I sync with multiple calendars?"
**Outbound (to external servers)**: Yes! Add multiple CalDAV accounts in Settings > CalDAV Integration. Each account syncs to a different external calendar.
**Inbound (as CalDAV server)**: Leantime exposes one calendar per user containing all their events.
---
### "Events sync but show wrong times"
This is usually a timezone issue:
1. Check your Leantime timezone setting (User Settings)
2. Check your calendar app's timezone
3. Ensure both are set correctly
Leantime stores all times in UTC internally and converts for display.
---
## Technical Details
### Sync Architecture
```
┌─────────────┐ Real-time ┌──────────────┐
│ Leantime │ ─────────────────> │ External │
│ Calendar │ │ CalDAV │
│ Events │ <───────────────── │ Server │
└─────────────┘ Every 15 min └──────────────┘
┌──────────────┐ ┌─────────────┐
│ External │ ────────────────> │ Leantime │
│ Calendar │ CalDAV │ CalDAV │
│ App │ <──────────────── │ Server │
└──────────────┘ Protocol └─────────────┘
```
### Event Hooks
- `afterCalendarSave` - Triggers sync when event created/edited
- `afterCalendarDelete` - Triggers deletion on external servers
### Database Tables
- `zp_caldav_accounts` - Stores CalDAV connection settings
- `zp_caldav_events` - Maps Leantime events to CalDAV UIDs
### API Endpoints
- `GET /caldav/settings` - Settings page
- `GET /caldav/connect` - Add account form
- `POST /caldav/connect` - Save new account
- `GET /caldav/edit/{id}` - Edit account form
- `POST /caldav/edit/{id}` - Update account
- `GET /caldav/delete/{id}` - Delete account
- `GET /caldav/sync/{id}` - Manual sync trigger
- `/caldav/server/*` - CalDAV protocol endpoint
---
## Related Features
- **Calendar Module**: View and manage events at `/calendar/showMyCalendar`
- **Ticket Due Dates**: Set via ticket edit, appear on calendar
- **Dashboard Widget**: Shows upcoming calendar events
- **Personal Companion**: Can help schedule and manage time
---
## Error Messages
| Error | Meaning | Solution |
|-------|---------|----------|
| "Connection test failed" | Can't reach CalDAV server | Check URL and credentials |
| "Invalid credentials" | Username/password wrong | Re-enter credentials |
| "SSL certificate error" | Certificate validation failed | Uncheck "Verify SSL" for self-signed certs |
| "Calendar not found" | Calendar URL incorrect | Verify calendar URL format |
| "Sync failed" | Error during sync | Check logs, try manual sync |
---
## Plugin Information
- **Plugin Name**: CalDAV Integration
- **Location**: `app/Plugins/CalDAV/`
- **Dependencies**: sabre/dav, sabre/vobject
- **Documentation**: `CalDAV/docs/README.md`

View File

@@ -0,0 +1,974 @@
# Google Calendar Plugin - Technical Specification
## Overview
Native Google Calendar integration for Leantime using OAuth 2.0. Allows users to connect their Google Calendar account and sync events bidirectionally with Leantime.
**Scope:** This is a standalone plugin that does NOT touch OIDC (login) or CalDAV (different protocol).
---
## User Experience
### Connection Flow
1. User goes to Settings → Calendar → Google Calendar
2. Clicks "Connect Google Calendar"
3. Redirected to Google consent screen
4. Grants calendar permissions
5. Redirected back to Leantime
6. Selects which calendars to sync
7. Done - events appear in Leantime calendar
### Ongoing Sync
- Pull: Google events appear in Leantime calendar view
- Push: Leantime events (optionally) sync to Google
- Background sync via cron job
- Manual sync button in settings
---
## Prerequisites
### Google Cloud Console Setup (Manual - Done Once)
1. Go to https://console.cloud.google.com/
2. Create new project or select existing
3. Enable Google Calendar API:
- APIs & Services → Library → Search "Google Calendar API" → Enable
4. Create OAuth credentials:
- APIs & Services → Credentials → Create Credentials → OAuth client ID
- Application type: Web application
- Name: "Leantime Calendar Integration"
- Authorized redirect URIs: `https://your-leantime.com/googlecalendar/callback`
5. Download or copy:
- Client ID
- Client Secret
### Environment Variables
```env
GOOGLE_CALENDAR_CLIENT_ID=your-client-id.apps.googleusercontent.com
GOOGLE_CALENDAR_CLIENT_SECRET=your-client-secret
GOOGLE_CALENDAR_REDIRECT_URI=${APP_URL}/googlecalendar/callback
```
---
## Plugin Structure
```
app/Plugins/GoogleCalendar/
├── Controllers/
│ ├── Connect.php # Initiates OAuth flow
│ ├── Callback.php # Handles OAuth callback
│ ├── Settings.php # Account management UI
│ ├── Sync.php # Manual sync trigger
│ └── Disconnect.php # Remove account
├── Services/
│ ├── GoogleCalendar.php # Main service (API calls, sync logic)
│ ├── GoogleAuth.php # OAuth token management
│ └── EventMapper.php # Maps Google ↔ Leantime events
├── Repositories/
│ └── GoogleCalendarAccounts.php # Database operations
├── Models/
│ ├── GoogleCalendarAccount.php # Account entity
│ └── GoogleCalendarEvent.php # Synced event entity
├── Migrations/
│ └── CreateGoogleCalendarTables.php
├── Templates/
│ ├── settings.blade.php # Settings page
│ ├── connect.blade.php # Pre-connect info
│ └── partials/
│ └── accountCard.blade.php
├── Language/
│ └── en-US.ini
├── Commands/
│ └── SyncCalendars.php # Artisan command for cron
├── register.php # Plugin registration & hooks
├── composer.json # Dependencies
└── index.php # Plugin metadata
```
---
## Database Schema
### Table: `zp_google_calendar_accounts`
```sql
CREATE TABLE `zp_google_calendar_accounts` (
`id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`userId` INT UNSIGNED NOT NULL,
`googleEmail` VARCHAR(255) NOT NULL,
`accessToken` TEXT NOT NULL,
`refreshToken` TEXT NOT NULL,
`tokenExpiresAt` DATETIME NOT NULL,
`selectedCalendars` JSON DEFAULT NULL, -- ["primary", "cal_id_2"]
`syncDirection` ENUM('pull', 'push', 'both') DEFAULT 'pull',
`syncEnabled` TINYINT(1) DEFAULT 1,
`lastSyncAt` DATETIME DEFAULT NULL,
`lastSyncStatus` VARCHAR(50) DEFAULT NULL, -- 'success', 'error', 'partial'
`lastSyncError` TEXT DEFAULT NULL,
`createdAt` DATETIME DEFAULT CURRENT_TIMESTAMP,
`updatedAt` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX `idx_userId` (`userId`),
INDEX `idx_syncEnabled` (`syncEnabled`),
UNIQUE KEY `unique_user_email` (`userId`, `googleEmail`),
FOREIGN KEY (`userId`) REFERENCES `zp_user`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
### Table: `zp_google_calendar_events`
```sql
CREATE TABLE `zp_google_calendar_events` (
`id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`accountId` INT UNSIGNED NOT NULL,
`googleEventId` VARCHAR(255) NOT NULL,
`googleCalendarId` VARCHAR(255) NOT NULL,
`leantimeEventId` INT UNSIGNED DEFAULT NULL,
`title` VARCHAR(500) NOT NULL,
`description` TEXT DEFAULT NULL,
`startDate` DATETIME NOT NULL,
`endDate` DATETIME NOT NULL,
`allDay` TINYINT(1) DEFAULT 0,
`location` VARCHAR(500) DEFAULT NULL,
`googleUpdatedAt` DATETIME NOT NULL,
`syncedAt` DATETIME DEFAULT CURRENT_TIMESTAMP,
`syncDirection` ENUM('from_google', 'to_google') NOT NULL,
INDEX `idx_accountId` (`accountId`),
INDEX `idx_googleEventId` (`googleEventId`),
INDEX `idx_leantimeEventId` (`leantimeEventId`),
UNIQUE KEY `unique_google_event` (`accountId`, `googleEventId`),
FOREIGN KEY (`accountId`) REFERENCES `zp_google_calendar_accounts`(`id`) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
```
---
## Core Files Implementation
### index.php
```php
<?php
return [
'name' => 'GoogleCalendar',
'description' => 'Native Google Calendar integration with OAuth 2.0',
'version' => '1.0.0',
'author' => 'Leantime',
'authorUrl' => 'https://leantime.io',
'license' => 'AGPL-3.0',
'minVersion' => '3.0.0',
'premium' => false,
];
```
### composer.json
```json
{
"name": "leantime/googlecalendar",
"description": "Google Calendar integration for Leantime",
"require": {
"google/apiclient": "^2.15"
},
"autoload": {
"psr-4": {
"Leantime\\Plugins\\GoogleCalendar\\": ""
}
}
}
```
### register.php
```php
<?php
namespace Leantime\Plugins\GoogleCalendar;
use Illuminate\Console\Scheduling\Schedule;
use Leantime\Core\Events;
use Leantime\Domain\Calendar\Services\Calendar;
use Leantime\Plugins\GoogleCalendar\Services\GoogleCalendar;
class Register
{
public function __construct(
private GoogleCalendar $googleCalendarService,
) {}
/**
* Register plugin routes
*/
public function registerRoutes(): void
{
// Routes are auto-discovered from Controllers
}
/**
* Register event listeners
*/
public function registerEvents(): void
{
// Hook into calendar event retrieval
Events::add_filter_listener(
'leantime.domain.calendar.services.calendar.getCalendar.result',
function (array $events, array $params) {
if (!session()->exists('userdata.id')) {
return $events;
}
$userId = session('userdata.id');
$googleEvents = $this->googleCalendarService->getEventsForUser($userId);
return array_merge($events, $googleEvents);
}
);
// Hook into external calendar events
Events::add_filter_listener(
'leantime.domain.calendar.services.calendar.getExternalCalendarEvents.result',
function (array $events, array $params) {
if (!session()->exists('userdata.id')) {
return $events;
}
$userId = session('userdata.id');
$googleEvents = $this->googleCalendarService->getEventsForUser($userId);
return array_merge($events, $googleEvents);
}
);
}
/**
* Register menu items
*/
public function registerMenuItems(): void
{
Events::add_filter_listener(
'leantime.domain.menu.services.menu.getSettingsMenu.result',
function (array $menuItems) {
$menuItems['googlecalendar'] = [
'label' => 'Google Calendar',
'icon' => 'fa-google',
'url' => '/googlecalendar/settings',
'section' => 'integrations',
];
return $menuItems;
}
);
}
/**
* Register scheduled tasks
*/
public function registerScheduledTasks(Schedule $schedule): void
{
// Sync every 15 minutes
$schedule->command('googlecalendar:sync')
->everyFifteenMinutes()
->withoutOverlapping();
}
}
```
---
## OAuth Flow
### Step 1: Connect Controller
```php
<?php
namespace Leantime\Plugins\GoogleCalendar\Controllers;
use Leantime\Core\Controller\Controller;
use Leantime\Plugins\GoogleCalendar\Services\GoogleAuth;
use Symfony\Component\HttpFoundation\Response;
class Connect extends Controller
{
public function __construct(
private GoogleAuth $googleAuth,
) {}
public function get(): Response
{
// Generate OAuth URL and redirect
$authUrl = $this->googleAuth->getAuthUrl();
return $this->tpl->redirect($authUrl);
}
}
```
### Step 2: Callback Controller
```php
<?php
namespace Leantime\Plugins\GoogleCalendar\Controllers;
use Leantime\Core\Controller\Controller;
use Leantime\Plugins\GoogleCalendar\Services\GoogleAuth;
use Leantime\Plugins\GoogleCalendar\Repositories\GoogleCalendarAccounts;
use Symfony\Component\HttpFoundation\Response;
class Callback extends Controller
{
public function __construct(
private GoogleAuth $googleAuth,
private GoogleCalendarAccounts $accountRepo,
) {}
public function get(): Response
{
$code = $_GET['code'] ?? null;
$error = $_GET['error'] ?? null;
if ($error) {
$this->tpl->setNotification('Google Calendar connection was cancelled.', 'error');
return $this->tpl->redirect('/googlecalendar/settings');
}
if (!$code) {
$this->tpl->setNotification('Invalid callback. Please try again.', 'error');
return $this->tpl->redirect('/googlecalendar/settings');
}
try {
// Exchange code for tokens
$tokens = $this->googleAuth->exchangeCode($code);
// Get user info from Google
$googleUser = $this->googleAuth->getUserInfo($tokens['access_token']);
// Store account
$this->accountRepo->createOrUpdate([
'userId' => session('userdata.id'),
'googleEmail' => $googleUser['email'],
'accessToken' => $tokens['access_token'],
'refreshToken' => $tokens['refresh_token'],
'tokenExpiresAt' => date('Y-m-d H:i:s', time() + $tokens['expires_in']),
]);
$this->tpl->setNotification('Google Calendar connected successfully!', 'success');
} catch (\Exception $e) {
$this->tpl->setNotification('Failed to connect: ' . $e->getMessage(), 'error');
}
return $this->tpl->redirect('/googlecalendar/settings');
}
}
```
### Step 3: GoogleAuth Service
```php
<?php
namespace Leantime\Plugins\GoogleCalendar\Services;
use Google\Client;
use Leantime\Core\Configuration\Environment;
class GoogleAuth
{
private Client $client;
public function __construct(
private Environment $config,
) {
$this->client = new Client();
$this->client->setClientId($config->googleCalendarClientId);
$this->client->setClientSecret($config->googleCalendarClientSecret);
$this->client->setRedirectUri($config->googleCalendarRedirectUri);
$this->client->setAccessType('offline'); // Get refresh token
$this->client->setPrompt('consent'); // Force consent to get refresh token
$this->client->addScope([
'https://www.googleapis.com/auth/calendar.readonly',
'https://www.googleapis.com/auth/calendar.events',
'https://www.googleapis.com/auth/userinfo.email',
]);
}
public function getAuthUrl(): string
{
return $this->client->createAuthUrl();
}
public function exchangeCode(string $code): array
{
$token = $this->client->fetchAccessTokenWithAuthCode($code);
if (isset($token['error'])) {
throw new \Exception($token['error_description'] ?? $token['error']);
}
return $token;
}
public function refreshToken(string $refreshToken): array
{
$this->client->fetchAccessTokenWithRefreshToken($refreshToken);
return $this->client->getAccessToken();
}
public function getUserInfo(string $accessToken): array
{
$this->client->setAccessToken($accessToken);
$oauth2 = new \Google\Service\Oauth2($this->client);
$userInfo = $oauth2->userinfo->get();
return [
'email' => $userInfo->getEmail(),
'name' => $userInfo->getName(),
];
}
public function getClient(): Client
{
return $this->client;
}
}
```
---
## Calendar Sync Service
### GoogleCalendar Service
```php
<?php
namespace Leantime\Plugins\GoogleCalendar\Services;
use Google\Service\Calendar;
use Google\Service\Calendar\Event;
use Leantime\Plugins\GoogleCalendar\Repositories\GoogleCalendarAccounts;
use Leantime\Plugins\GoogleCalendar\Services\GoogleAuth;
use Leantime\Plugins\GoogleCalendar\Services\EventMapper;
class GoogleCalendar
{
public function __construct(
private GoogleAuth $googleAuth,
private GoogleCalendarAccounts $accountRepo,
private EventMapper $eventMapper,
) {}
/**
* Get all Google Calendar events for a user (formatted for Leantime)
*/
public function getEventsForUser(int $userId): array
{
$accounts = $this->accountRepo->getByUserId($userId);
$allEvents = [];
foreach ($accounts as $account) {
if (!$account['syncEnabled']) {
continue;
}
try {
$events = $this->fetchEventsFromGoogle($account);
$allEvents = array_merge($allEvents, $events);
} catch (\Exception $e) {
// Log error but continue with other accounts
error_log("Google Calendar sync error for account {$account['id']}: " . $e->getMessage());
}
}
return $allEvents;
}
/**
* Fetch events from Google Calendar API
*/
private function fetchEventsFromGoogle(array $account): array
{
// Refresh token if expired
if (strtotime($account['tokenExpiresAt']) < time()) {
$newTokens = $this->googleAuth->refreshToken($account['refreshToken']);
$this->accountRepo->updateTokens($account['id'], $newTokens);
$account['accessToken'] = $newTokens['access_token'];
}
// Initialize Google Calendar service
$client = $this->googleAuth->getClient();
$client->setAccessToken($account['accessToken']);
$calendarService = new Calendar($client);
// Get selected calendars or default to primary
$calendarIds = json_decode($account['selectedCalendars'] ?? '["primary"]', true);
$events = [];
$timeMin = (new \DateTime('-1 month'))->format(\DateTime::RFC3339);
$timeMax = (new \DateTime('+3 months'))->format(\DateTime::RFC3339);
foreach ($calendarIds as $calendarId) {
try {
$googleEvents = $calendarService->events->listEvents($calendarId, [
'timeMin' => $timeMin,
'timeMax' => $timeMax,
'singleEvents' => true,
'orderBy' => 'startTime',
'maxResults' => 250,
]);
foreach ($googleEvents->getItems() as $googleEvent) {
$events[] = $this->eventMapper->googleToLeantime($googleEvent, $account);
}
} catch (\Exception $e) {
error_log("Error fetching calendar {$calendarId}: " . $e->getMessage());
}
}
return $events;
}
/**
* Sync a Leantime event to Google Calendar
*/
public function pushEventToGoogle(int $userId, array $leantimeEvent): ?string
{
$account = $this->accountRepo->getPrimaryAccount($userId);
if (!$account || $account['syncDirection'] === 'pull') {
return null;
}
// Refresh token if needed
if (strtotime($account['tokenExpiresAt']) < time()) {
$newTokens = $this->googleAuth->refreshToken($account['refreshToken']);
$this->accountRepo->updateTokens($account['id'], $newTokens);
$account['accessToken'] = $newTokens['access_token'];
}
$client = $this->googleAuth->getClient();
$client->setAccessToken($account['accessToken']);
$calendarService = new Calendar($client);
$googleEvent = $this->eventMapper->leantimeToGoogle($leantimeEvent);
$createdEvent = $calendarService->events->insert('primary', $googleEvent);
return $createdEvent->getId();
}
/**
* Get user's available calendars from Google
*/
public function getAvailableCalendars(int $accountId): array
{
$account = $this->accountRepo->getById($accountId);
if (!$account) {
return [];
}
// Refresh token if expired
if (strtotime($account['tokenExpiresAt']) < time()) {
$newTokens = $this->googleAuth->refreshToken($account['refreshToken']);
$this->accountRepo->updateTokens($account['id'], $newTokens);
$account['accessToken'] = $newTokens['access_token'];
}
$client = $this->googleAuth->getClient();
$client->setAccessToken($account['accessToken']);
$calendarService = new Calendar($client);
$calendarList = $calendarService->calendarList->listCalendarList();
$calendars = [];
foreach ($calendarList->getItems() as $calendar) {
$calendars[] = [
'id' => $calendar->getId(),
'summary' => $calendar->getSummary(),
'primary' => $calendar->getPrimary() ?? false,
'backgroundColor' => $calendar->getBackgroundColor(),
];
}
return $calendars;
}
}
```
### EventMapper Service
```php
<?php
namespace Leantime\Plugins\GoogleCalendar\Services;
use Google\Service\Calendar\Event;
use Google\Service\Calendar\EventDateTime;
class EventMapper
{
/**
* Convert Google Calendar event to Leantime format
*/
public function googleToLeantime(Event $googleEvent, array $account): array
{
$start = $googleEvent->getStart();
$end = $googleEvent->getEnd();
// Handle all-day events (date) vs timed events (dateTime)
$allDay = !empty($start->getDate());
if ($allDay) {
$dateFrom = $start->getDate() . ' 00:00:00';
$dateTo = $end->getDate() . ' 23:59:59';
} else {
$dateFrom = date('Y-m-d H:i:s', strtotime($start->getDateTime()));
$dateTo = date('Y-m-d H:i:s', strtotime($end->getDateTime()));
}
return [
'id' => 'google_' . $googleEvent->getId(),
'title' => $googleEvent->getSummary() ?? '(No title)',
'description' => $googleEvent->getDescription() ?? '',
'dateFrom' => $dateFrom,
'dateTo' => $dateTo,
'allDay' => $allDay,
'eventType' => 'external',
'source' => 'Google: ' . $account['googleEmail'],
'color' => '#4285f4', // Google blue
'projectId' => null,
'taskId' => null,
'userId' => $account['userId'],
'editable' => false, // External events not editable in Leantime
];
}
/**
* Convert Leantime event to Google Calendar format
*/
public function leantimeToGoogle(array $leantimeEvent): Event
{
$event = new Event();
$event->setSummary($leantimeEvent['title'] ?? $leantimeEvent['description']);
$event->setDescription($leantimeEvent['description'] ?? '');
$start = new EventDateTime();
$end = new EventDateTime();
if (!empty($leantimeEvent['allDay'])) {
// All-day event
$start->setDate(date('Y-m-d', strtotime($leantimeEvent['dateFrom'])));
$end->setDate(date('Y-m-d', strtotime($leantimeEvent['dateTo'])));
} else {
// Timed event
$start->setDateTime(date('c', strtotime($leantimeEvent['dateFrom'])));
$end->setDateTime(date('c', strtotime($leantimeEvent['dateTo'])));
}
$event->setStart($start);
$event->setEnd($end);
return $event;
}
}
```
---
## Settings UI
### Settings Controller
```php
<?php
namespace Leantime\Plugins\GoogleCalendar\Controllers;
use Leantime\Core\Controller\Controller;
use Leantime\Plugins\GoogleCalendar\Repositories\GoogleCalendarAccounts;
use Leantime\Plugins\GoogleCalendar\Services\GoogleCalendar;
use Symfony\Component\HttpFoundation\Response;
class Settings extends Controller
{
public function __construct(
private GoogleCalendarAccounts $accountRepo,
private GoogleCalendar $googleCalendarService,
) {}
public function get(): Response
{
$userId = session('userdata.id');
$accounts = $this->accountRepo->getByUserId($userId);
// Get available calendars for each account
foreach ($accounts as &$account) {
$account['availableCalendars'] = $this->googleCalendarService->getAvailableCalendars($account['id']);
$account['selectedCalendarsArray'] = json_decode($account['selectedCalendars'] ?? '[]', true);
}
$this->tpl->assign('accounts', $accounts);
return $this->tpl->display('GoogleCalendar::settings');
}
public function post(): Response
{
$accountId = $_POST['accountId'] ?? null;
$selectedCalendars = $_POST['selectedCalendars'] ?? [];
$syncDirection = $_POST['syncDirection'] ?? 'pull';
$syncEnabled = isset($_POST['syncEnabled']) ? 1 : 0;
if ($accountId) {
$this->accountRepo->update($accountId, [
'selectedCalendars' => json_encode($selectedCalendars),
'syncDirection' => $syncDirection,
'syncEnabled' => $syncEnabled,
]);
$this->tpl->setNotification('Settings saved successfully!', 'success');
}
return $this->tpl->redirect('/googlecalendar/settings');
}
}
```
### settings.blade.php Template
```blade
@extends('layout.main')
@section('content')
<div class="maincontent">
<div class="maincontentinner">
<h1>Google Calendar Integration</h1>
<div class="row">
<div class="col-md-8">
@if(empty($accounts))
<div class="alert alert-info">
<h4>Connect Your Google Calendar</h4>
<p>Connect your Google Calendar to see your events alongside your Leantime tasks and meetings.</p>
<a href="/googlecalendar/connect" class="btn btn-primary">
<i class="fa fa-google"></i> Connect Google Calendar
</a>
</div>
@else
@foreach($accounts as $account)
<div class="card mb-4">
<div class="card-header d-flex justify-content-between align-items-center">
<div>
<i class="fa fa-google text-primary"></i>
<strong>{{ $account['googleEmail'] }}</strong>
</div>
<a href="/googlecalendar/disconnect/{{ $account['id'] }}"
class="btn btn-sm btn-outline-danger"
onclick="return confirm('Are you sure you want to disconnect this account?')">
Disconnect
</a>
</div>
<div class="card-body">
<form method="post" action="/googlecalendar/settings">
<input type="hidden" name="accountId" value="{{ $account['id'] }}">
<div class="form-group">
<label><strong>Calendars to Sync</strong></label>
@foreach($account['availableCalendars'] as $calendar)
<div class="form-check">
<input type="checkbox"
class="form-check-input"
name="selectedCalendars[]"
value="{{ $calendar['id'] }}"
id="cal_{{ md5($calendar['id']) }}"
{{ in_array($calendar['id'], $account['selectedCalendarsArray']) ? 'checked' : '' }}>
<label class="form-check-label" for="cal_{{ md5($calendar['id']) }}">
<span style="display: inline-block; width: 12px; height: 12px; background: {{ $calendar['backgroundColor'] }}; border-radius: 2px; margin-right: 5px;"></span>
{{ $calendar['summary'] }}
@if($calendar['primary']) <span class="badge badge-primary">Primary</span> @endif
</label>
</div>
@endforeach
</div>
<div class="form-group">
<label><strong>Sync Direction</strong></label>
<select name="syncDirection" class="form-control">
<option value="pull" {{ $account['syncDirection'] == 'pull' ? 'selected' : '' }}>
Pull only (Google → Leantime)
</option>
<option value="push" {{ $account['syncDirection'] == 'push' ? 'selected' : '' }}>
Push only (Leantime → Google)
</option>
<option value="both" {{ $account['syncDirection'] == 'both' ? 'selected' : '' }}>
Two-way sync
</option>
</select>
</div>
<div class="form-check mb-3">
<input type="checkbox"
class="form-check-input"
name="syncEnabled"
id="syncEnabled_{{ $account['id'] }}"
{{ $account['syncEnabled'] ? 'checked' : '' }}>
<label class="form-check-label" for="syncEnabled_{{ $account['id'] }}">
Enable automatic sync
</label>
</div>
<div class="d-flex justify-content-between align-items-center">
<button type="submit" class="btn btn-primary">Save Settings</button>
<a href="/googlecalendar/sync/{{ $account['id'] }}" class="btn btn-outline-secondary">
<i class="fa fa-refresh"></i> Sync Now
</a>
</div>
@if($account['lastSyncAt'])
<small class="text-muted mt-2 d-block">
Last synced: {{ $account['lastSyncAt'] }}
@if($account['lastSyncStatus'] == 'error')
<span class="text-danger">(Error: {{ $account['lastSyncError'] }})</span>
@endif
</small>
@endif
</form>
</div>
</div>
@endforeach
<hr>
<a href="/googlecalendar/connect" class="btn btn-outline-primary">
<i class="fa fa-plus"></i> Connect Another Google Account
</a>
@endif
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header">
<strong>About Google Calendar Integration</strong>
</div>
<div class="card-body">
<p>This integration allows you to:</p>
<ul>
<li>See Google Calendar events in your Leantime calendar</li>
<li>Optionally push Leantime events to Google Calendar</li>
<li>Connect multiple Google accounts</li>
</ul>
<p class="text-muted small">
Events sync automatically every 15 minutes, or you can trigger a manual sync.
</p>
</div>
</div>
</div>
</div>
</div>
</div>
@endsection
```
---
## Terminal Setup Commands
Run these commands in order to scaffold the plugin:
```bash
# Navigate to plugins directory
cd /Users/gloriafolaron/Herd/leantime/app/Plugins
# Create plugin directory structure
mkdir -p GoogleCalendar/{Controllers,Services,Repositories,Models,Migrations,Templates/partials,Language,Commands}
# Create empty files
touch GoogleCalendar/index.php
touch GoogleCalendar/register.php
touch GoogleCalendar/composer.json
touch GoogleCalendar/Controllers/Connect.php
touch GoogleCalendar/Controllers/Callback.php
touch GoogleCalendar/Controllers/Settings.php
touch GoogleCalendar/Controllers/Sync.php
touch GoogleCalendar/Controllers/Disconnect.php
touch GoogleCalendar/Services/GoogleCalendar.php
touch GoogleCalendar/Services/GoogleAuth.php
touch GoogleCalendar/Services/EventMapper.php
touch GoogleCalendar/Repositories/GoogleCalendarAccounts.php
touch GoogleCalendar/Models/GoogleCalendarAccount.php
touch GoogleCalendar/Models/GoogleCalendarEvent.php
touch GoogleCalendar/Migrations/CreateGoogleCalendarTables.php
touch GoogleCalendar/Templates/settings.blade.php
touch GoogleCalendar/Templates/connect.blade.php
touch GoogleCalendar/Language/en-US.ini
touch GoogleCalendar/Commands/SyncCalendars.php
# Install Google API client (from plugin directory)
cd GoogleCalendar
composer require google/apiclient:^2.15
# Go back to Leantime root and dump autoload
cd /Users/gloriafolaron/Herd/leantime
composer dump-autoload
```
---
## Environment Configuration
Add to `.env`:
```env
# Google Calendar Integration
GOOGLE_CALENDAR_CLIENT_ID=
GOOGLE_CALENDAR_CLIENT_SECRET=
GOOGLE_CALENDAR_REDIRECT_URI=${APP_URL}/googlecalendar/callback
```
Add to `config/configuration.php` (if using config file):
```php
'googleCalendarClientId' => env('GOOGLE_CALENDAR_CLIENT_ID', ''),
'googleCalendarClientSecret' => env('GOOGLE_CALENDAR_CLIENT_SECRET', ''),
'googleCalendarRedirectUri' => env('GOOGLE_CALENDAR_REDIRECT_URI', ''),
```
---
## Cron Job Setup
Add to your server's crontab:
```bash
* * * * * cd /path/to/leantime && php artisan schedule:run >> /dev/null 2>&1
```
Or run manually for testing:
```bash
php artisan googlecalendar:sync
```
---
## Testing Checklist
- [ ] Google Cloud Console project created
- [ ] OAuth credentials generated
- [ ] Environment variables configured
- [ ] Plugin files created
- [ ] Composer dependencies installed
- [ ] Database migration run
- [ ] Can click "Connect Google Calendar"
- [ ] OAuth redirect works
- [ ] Callback stores tokens
- [ ] Settings page shows connected account
- [ ] Calendar list loads
- [ ] Events appear in Leantime calendar
- [ ] Manual sync works
- [ ] Token refresh works (test after 1 hour)
- [ ] Disconnect removes account
---
## Future Enhancements
1. **Outlook Calendar Plugin** - Same pattern, Microsoft Graph API
2. **Webhook support** - Real-time sync instead of polling
3. **Conflict resolution** - Handle overlapping events
4. **Event editing** - Edit Google events from Leantime
5. **Color sync** - Match calendar colors
6. **Recurring events** - Handle recurrence rules

34
.dev/dev-apache-site.conf Normal file
View File

@@ -0,0 +1,34 @@
<VirtualHost *:8080>
ServerAdmin webmaster@localhost
DocumentRoot /var/www/html/public
# Pass the Authorization header through to PHP so $request->bearerToken() works.
# Without this, Apache strips it (it only lands in HTTP_AUTHORIZATION), Laravel's
# Sanctum guard never sees the Bearer token, and the test env silently exercises a
# different auth path than production — which is how the 3.9.x Bearer role regression
# slipped past CI. With it, BearerApiCest goes through the real Sanctum-guard path.
<Directory /var/www/html/public>
CGIPassAuth On
</Directory>
ErrorLog /var/www/html/storage/logs/apacheError.log
CustomLog /var/www/html/storage/logs/access.log combined
</VirtualHost>
<VirtualHost *:443>
ServerAdmin webmaster@localhost
DocumentRoot /var/www/html/public
SSLEngine on
SSLCertificateFile /etc/ssl/certs/cert.pem
SSLCertificateKeyFile /etc/ssl/private/key.pem
SetEnv HTTPS "on"
<Directory /var/www/html/public>
CGIPassAuth On
</Directory>
ErrorLog /var/www/html/storage/logs/apacheError.log
CustomLog /var/www/html/storage/logs/access.log combined
</VirtualHost>

225
.dev/dev-apache2.conf Normal file
View File

@@ -0,0 +1,225 @@
# This is the main Apache server configuration file. It contains the
# configuration directives that give the server its instructions.
# See http://httpd.apache.org/docs/2.4/ for detailed information about
# the directives and /usr/share/doc/apache2/README.Debian about Debian specific
# hints.
#
#
# Summary of how the Apache 2 configuration works in Debian:
# The Apache 2 web server configuration in Debian is quite different to
# upstream's suggested way to configure the web server. This is because Debian's
# default Apache2 installation attempts to make adding and removing modules,
# virtual hosts, and extra configuration directives as flexible as possible, in
# order to make automating the changes and administering the server as easy as
# possible.
# It is split into several files forming the configuration hierarchy outlined
# below, all located in the /etc/apache2/ directory:
#
# /etc/apache2/
# |-- apache2.conf
# | `-- ports.conf
# |-- mods-enabled
# | |-- *.load
# | `-- *.conf
# |-- conf-enabled
# | `-- *.conf
# `-- sites-enabled
# `-- *.conf
#
#
# * apache2.conf is the main configuration file (this file). It puts the pieces
# together by including all remaining configuration files when starting up the
# web server.
#
# * ports.conf is always included from the main configuration file. It is
# supposed to determine listening ports for incoming connections which can be
# customized anytime.
#
# * Configuration files in the mods-enabled/, conf-enabled/ and sites-enabled/
# directories contain particular configuration snippets which manage modules,
# global configuration fragments, or virtual host configurations,
# respectively.
#
# They are activated by symlinking available configuration files from their
# respective *-available/ counterparts. These should be managed by using our
# helpers a2enmod/a2dismod, a2ensite/a2dissite and a2enconf/a2disconf. See
# their respective man pages for detailed information.
#
# * The binary is called apache2. Due to the use of environment variables, in
# the default configuration, apache2 needs to be started/stopped with
# /etc/init.d/apache2 or apache2ctl. Calling /usr/bin/apache2 directly will not
# work with the default configuration.
# Global configuration
#
#
# ServerRoot: The top of the directory tree under which the server's
# configuration, error, and log files are kept.
#
# NOTE! If you intend to place this on an NFS (or otherwise network)
# mounted filesystem then please read the Mutex documentation (available
# at <URL:http://httpd.apache.org/docs/2.4/mod/core.html#mutex>);
# you will save yourself a lot of trouble.
#
# Do NOT add a slash at the end of the directory path.
#
#ServerRoot "/etc/apache2"
#
# The accept serialization lock file MUST BE STORED ON A LOCAL DISK.
#
#Mutex file:${APACHE_LOCK_DIR} default
#
# The directory where shm and other runtime files will be stored.
#
DefaultRuntimeDir ${APACHE_RUN_DIR}
#
# PidFile: The file in which the server should record its process
# identification number when it starts.
# This needs to be set in /etc/apache2/envvars
#
PidFile ${APACHE_PID_FILE}
#
# Timeout: The number of seconds before receives and sends time out.
#
Timeout 300
#
# KeepAlive: Whether or not to allow persistent connections (more than
# one request per connection). Set to "Off" to deactivate.
#
KeepAlive On
#
# MaxKeepAliveRequests: The maximum number of requests to allow
# during a persistent connection. Set to 0 to allow an unlimited amount.
# We recommend you leave this number high, for maximum performance.
#
MaxKeepAliveRequests 100
#
# KeepAliveTimeout: Number of seconds to wait for the next request from the
# same client on the same connection.
#
KeepAliveTimeout 5
# These need to be set in /etc/apache2/envvars
User ${APACHE_RUN_USER}
Group ${APACHE_RUN_GROUP}
#
# HostnameLookups: Log the names of clients or just their IP addresses
# e.g., www.apache.org (on) or 204.62.129.132 (off).
# The default is off because it'd be overall better for the net if people
# had to knowingly turn this feature on, since enabling it means that
# each client request will result in AT LEAST one lookup request to the
# nameserver.
#
HostnameLookups Off
# ErrorLog: The location of the error log file.
# If you do not specify an ErrorLog directive within a <VirtualHost>
# container, error messages relating to that virtual host will be
# logged here. If you *do* define an error logfile for a <VirtualHost>
# container, that host's errors will be logged there and not here.
#
ErrorLog /var/www/html/storage/logs/apacheError.log
#
# LogLevel: Control the severity of messages logged to the error_log.
# Available values: trace8, ..., trace1, debug, info, notice, warn,
# error, crit, alert, emerg.
# It is also possible to configure the log level for particular modules, e.g.
# "LogLevel info ssl:warn"
#
LogLevel warn
# Include module configuration:
IncludeOptional mods-enabled/*.load
IncludeOptional mods-enabled/*.conf
# Include list of ports to listen on
Include ports.conf
# Sets the default security model of the Apache2 HTTPD server. It does
# not allow access to the root filesystem outside of /usr/share and /var/www.
# The former is used by web applications packaged in Debian,
# the latter may be used for local directories served by the web server. If
# your system is serving content from a sub-directory in /srv you must allow
# access here, or in any related virtual host.
<Directory />
Options FollowSymLinks
AllowOverride None
Require all denied
</Directory>
<Directory /usr/share>
AllowOverride None
Require all granted
</Directory>
<Directory /var/www/>
Options Indexes FollowSymLinks
AllowOverride None
Require all granted
</Directory>
#<Directory /srv/>
# Options Indexes FollowSymLinks
# AllowOverride None
# Require all granted
#</Directory>
# AccessFileName: The name of the file to look for in each directory
# for additional configuration directives. See also the AllowOverride
# directive.
#
AccessFileName .htaccess
#
# The following lines prevent .htaccess and .htpasswd files from being
# viewed by Web clients.
#
<FilesMatch "^\.ht">
Require all denied
</FilesMatch>
#
# The following directives define some format nicknames for use with
# a CustomLog directive.
#
# These deviate from the Common Log Format definitions in that they use %O
# (the actual bytes sent including headers) instead of %b (the size of the
# requested file), because the latter makes it impossible to detect partial
# requests.
#
# Note that the use of %{X-Forwarded-For}i instead of %h is not recommended.
# Use mod_remoteip instead.
#
LogFormat "%v:%p %h %l %u %t \"%r\" %>s %O \"%{Referer}i\" \"%{User-Agent}i\"" vhost_combined
LogFormat "%h %l %u %t \"%r\" %>s %O \"%{Referer}i\" \"%{User-Agent}i\"" combined
LogFormat "%h %l %u %t \"%r\" %>s %O" common
LogFormat "%{Referer}i -> %U" referer
LogFormat "%{User-agent}i" agent
# Include of directories ignores editors' and dpkg's backup files,
# see README.Debian for details.
# Include generic snippets of statements
IncludeOptional conf-enabled/*.conf
# Include the virtual host configurations:
IncludeOptional sites-enabled/*.conf
# vim: syntax=apache ts=4 sw=4 sts=4 sr noet

15
.dev/dev-ports.conf Normal file
View File

@@ -0,0 +1,15 @@
# If you just change the port or add more ports here, you will likely also
# have to change the VirtualHost statement in
# /etc/apache2/sites-enabled/000-default.conf
Listen 8080
<IfModule ssl_module>
Listen 443
</IfModule>
<IfModule mod_gnutls.c>
Listen 443
</IfModule>
# vim: syntax=apache ts=4 sw=4 sts=4 sr noet

View File

@@ -0,0 +1,27 @@
#
# This docker compose file is used to build the environment for our testing framework in codeception
# It is not to be used for development
#
services:
leantime-dev:
volumes:
- "../:/var/www/html"
- "./xdebug.ini:/usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini"
- "./error_reporting.ini:/usr/local/etc/php/conf.d/error_reporting.ini"
- "./test.env:/var/www/html/config/.env"
ports:
- "8002:8080"
- "44302:443"
environment:
LEAN_ENV: 'testing'
LEAN_DB_HOST: 'leantime-db'
LEAN_DB_DATABASE: 'leantime_test'
env_file:
- test.env
depends_on:
leantime-db:
condition: service_healthy
leantime-db:
environment:
MYSQL_DATABASE: leantime_test

110
.dev/docker-compose.yaml Normal file
View File

@@ -0,0 +1,110 @@
services:
leantime-dev:
container_name: leantime-dev
build: .
ports:
- "5080:8080" #Using ports that are hopefully available. format is: 80XX
- "5443:443" #Using ports that are hopefully available. format is: 443XX (available range is 4430044399)
extra_hosts:
- "host.docker.internal:host-gateway"
# - "authentik.docker.local:172.28.0.2"
volumes:
- "../:/var/www/html"
- "./xdebug.ini:/usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini"
- "./error_reporting.ini:/usr/local/etc/php/conf.d/error_reporting.ini"
environment:
LEAN_DB_HOST: 'leantime-db'
LEAN_DB_USER: 'leantime'
LEAN_DB_PASSWORD: 'leantime'
LEAN_DB_DATABASE: 'leantime'
LEAN_DB_PORT: '3306'
depends_on:
leantime-db:
condition: service_healthy
networks:
- leantime_internal
- leantime
# - traefik
# labels:
# - "traefik.enable=true"
# - "traefik.priority=2"
# - "traefik.docker.network=traefik"
#
# # HTTP Router
# - "traefik.http.routers.leantime.rule=Host(`leantime.docker.local`)"
# - "traefik.http.services.leantime.loadbalancer.server.port=8080"
# - "traefik.http.routers.leantime.tls=true"
leantime-redis:
container_name: leantime-redis
image: redis:4.0
ports:
- "6379"
networks:
- leantime_internal
leantime-db:
image: mysql:8.4
ports:
- "3306"
environment:
MYSQL_ROOT_PASSWORD: 'leantime'
MYSQL_USER: 'leantime'
MYSQL_PASSWORD: 'leantime'
MYSQL_DATABASE: 'leantime'
networks:
- leantime_internal
volumes:
- leantime-mysql:/var/lib/mysql:rw
healthcheck:
test: "mysql -u$$MYSQL_USER -p$$MYSQL_PASSWORD -e 'SELECT 1'"
interval: 5s
timeout: 5s
retries: 20
selenium:
image: selenium/standalone-chromium
shm_size: 2gb
environment:
- SCREEN_WIDTH=2560
- SCREEN_HEIGHT=1440
ports:
- "4444"
- "7900"
networks:
- leantime_internal
maildev:
image: maildev/maildev
environment:
- MAILDEV_SMTP_PORT=465
- MAILDEV_WEB_PORT=8081
ports:
- "8081"
networks:
- leantime_internal
s3ninja:
image: scireum/s3-ninja
ports:
- "8083"
networks:
- leantime_internal
volumes:
- s3ninja-data:/home/sirius/data
networks:
leantime_internal:
driver: bridge
name: leantime_internal
leantime:
driver: bridge
# traefik:
# external: true
# name: traefik
volumes:
leantime-mysql:
s3ninja-data:

68
.dev/dockerfile Normal file
View File

@@ -0,0 +1,68 @@
FROM php:8.2-apache
COPY ./dev-apache2.conf /etc/apache2/apache2.conf
COPY ./dev-ports.conf /etc/apache2/ports.conf
# Ensure packages are available.
RUN apt-get update
RUN DEBIAN_FRONTEND=noninteractive \
apt install -fqy \
libonig-dev \
libcurl4-openssl-dev \
libxml2-dev \
libxslt1-dev \
libzip-dev \
libjson-c-dev \
libldap-dev \
libargon2-dev \
libfreetype6-dev \
libjpeg62-turbo-dev \
libpng-dev \
libpq-dev \
apt-utils \
vim \
curl \
sqlite3 \
openssl \
default-mysql-client \
iputils-ping \
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
# xdebug (coverage) + redis (cache) are optional for the test/dev image — the test env runs on
# file cache/session (see .dev/test.env). Don't let an intermittent pecl.php.net outage
# ("No releases available for ...") fail the whole image build and block all CI.
RUN pecl install xdebug redis || echo "pecl unavailable — building without xdebug/redis"
RUN docker-php-ext-configure gd --enable-gd --with-jpeg=/usr/include/ --with-freetype --with-jpeg
RUN docker-php-ext-install \
gd \
mysqli \
pdo_mysql \
pdo_pgsql \
mbstring \
exif \
pcntl \
pdo \
bcmath \
opcache \
ldap \
zip
# zip is always installed above; xdebug/redis only if pecl was reachable. Enable each one
# independently and best-effort, so a missing extension (partial pecl outage) doesn't prevent
# enabling the one that IS present.
RUN docker-php-ext-enable zip \
&& (docker-php-ext-enable xdebug || echo "xdebug not installed, skipping enable") \
&& (docker-php-ext-enable redis || echo "redis not installed, skipping enable")
COPY ./dev-apache-site.conf /etc/apache2/sites-enabled/000-default.conf
RUN openssl req -x509 -newkey rsa:4096 -keyout /etc/ssl/private/key.pem -out /etc/ssl/certs/cert.pem -days 365 -nodes -subj "/C=US/ST=NY/L=NY/O=ACME/OU=CD/CN=AcmeWPDeveloper"
RUN a2enmod ssl
RUN a2enmod rewrite
VOLUME /var/www/uploads
RUN mkdir -p /var/www/uploads \
&& chown -R www-data:www-data /var/www/uploads

3
.dev/error_reporting.ini Normal file
View File

@@ -0,0 +1,3 @@
error_reporting=E_ALL
display_errors=On
phar.readonly=Off

View File

@@ -0,0 +1,583 @@
<?php
namespace Leantime\Core\Configuration;
use Leantime\Core\Configuration\Attributes\LaravelConfig;
/**
* Default Configuration Class
*/
class DefaultConfig
{
// General =====================================================================================
/**
* @var string Name of your site, can be changed later
*/
#[LaravelConfig('app.name')]
public string $sitename = 'OneBot';
/**
* @var string Default language
*/
#[LaravelConfig('app.locale')]
public string $language = 'en-US';
/**
* @var string Default logo path, can be changed later
*/
public string $logoPath = '/dist/images/logo.svg';
/**
* @var string Default logo URL use for printing (must be jpg or png format)
*/
public string $printLogoURL = '/dist/images/logo.jpg';
/**
* @var string Base URL, trailing slash not needed
*/
#[LaravelConfig('app.url')]
public string $appUrl = '';
/**
* @var string Base of application withotu trailing slash (used for cookies), e.g, /leantime
*/
public string $appDir = '';
/**
* @var bool Send anonymous data <a href='https://docs.leantime.io/#/using-leantime/company-settings?id=telemetry' target='_blank'>More Info</a>.
* No personally identifiable data will be sent and it will be impossible for us to track individual users.
*/
public bool $allowTelemetry = true;
/**
* @var string Default theme
*/
public string $defaultTheme = 'default';
/**
* @var string Primary Theme color
*/
public string $primarycolor;
/**
* @var string Secondary Theme Color
*/
public string $secondarycolor;
/**
* @var string Default timezone
*/
#[LaravelConfig('app.timezone')]
public string $defaultTimezone = 'America/Los_Angeles';
/**
* @var bool Enable to specifiy menu on a project by project basis
*/
public bool $enableMenuType = false;
/**
* @var bool|int Debug flag
*/
#[LaravelConfig('app.debug')]
public int|bool $debug = 0;
/**
* @var bool When true, a denied #[RequiresPermission] attribute blocks the request
* (403 web / JSON-RPC -32001). When false the central enforcer only LOGS the
* would-be denial (audit mode), so permission coverage can be rolled out and
* observed before being enforced. In-method $this->authorize() calls always
* enforce regardless of this flag.
*/
#[LaravelConfig('permissions.enforce')]
public bool $permissionsEnforce = true;
/**
* @var string editor used for code editing
*/
public string $editor = 'phpstorm';
/**
* @var string Application environment
*/
#[LaravelConfig('app.env')]
public string $env = 'production';
/**
* @var string Log Path
*/
public string $logPath = APP_ROOT.'/storage/logs/error.log';
/**
* @var bool Whether or not to enable the Poor Man's Cron fallback
*/
public bool $poorMansCron = true;
/**
* @var bool Don't show user/pass form on login?
*/
public bool $disableLoginForm = false;
// Database ====================================================================================
/**
* @var string Database host
*/
public string $dbHost = 'localhost';
/**
* @var string Database username
*/
public string $dbUser = '';
/**
* @var string Database password
*/
public string $dbPassword = '';
/**
* @var string Database name
*/
public string $dbDatabase = '';
/**
* @var string Database port
*/
public string $dbPort = '3306';
// Fileupload ==================================================================================
/**
* @var string Local relative path to store uploaded files (if not using S3)
*/
public string $userFilePath = 'userfiles/';
/**
* @var string Local relative path to store backup files, need permission to write
*/
public string $dbBackupPath = 'userfiles/';
// S3 configuration ============================================================================
/**
* @var bool Set to true if you want to use S3 instead of local files
*/
public bool $useS3 = false;
/**
* @var string S3 Key
*/
public string $s3Key = '';
/**
* @var string S3 Secret
*/
public string $s3Secret = '';
/**
* @var string S3 Bucket
*/
public string $s3Bucket = '';
/**
* @var bool false => https://[bucket].[endpoint] ; true => https://[endpoint]/[bucket]
*/
public bool $s3UsePathStyleEndpoint = false;
/**
* @var string S3 Region
*/
public string $s3Region = '';
/**
* @var string S3 Foldername within S3 (can be empty)
*/
public string $s3FolderName = '';
/**
* @var string|null S3 EndPoint S3 Compatible
*
* @see https://sfo2.digitaloceanspaces.com
*/
public ?string $s3EndPoint = null;
// Sessions ====================================================================================
/**
* @var string Salting sessions. Replace with a strong password
*/
#[LaravelConfig('app.key')]
public string $sessionPassword = '3evBlq9zdUEuzKvVJHWWx3QzsQhturBApxwcws2m';
/**
* @var int How many minutes after inactivity should we logout? 480min = 8hours
*/
public int $sessionExpiration = 480;
/**
* @var bool. Sets whether the cookie should only be served via https
*/
#[LaravelConfig('session.secure')]
public bool $sessionSecure = false;
// Email =======================================================================================
/**
* @var string Return email address
*/
public string $email = '';
/**
* @var bool Use SMTP? If set to false, the default php mail() function will be used
*/
public bool $useSMTP = false;
/**
* @var string SMTP host
*/
public string $smtpHosts = '';
/**
* @var bool SMTP use user/password authentication
*/
public bool $smtpAuth = true;
/**
* @var string SMTP username
*/
public string $smtpUsername = '';
/**
* @var string SMTP password
*/
public string $smtpPassword = '';
/**
* @var bool SMTP Enable TLS encryption automatically if a server supports it
*/
public bool $smtpAutoTLS = true;
/**
* @var string SMTP Security protocol (usually one of: TLS, SSL, STARTTLS)
*/
public string $smtpSecure = '';
/**
* @var bool SMTP Allow insecure SSL: Don't verify certificate, accept self-signed, etc.
*/
public bool $smtpSSLNoverify = false;
/**
* @var int SMTP Port (usually one of 25, 465, 587, 2526)
*/
public int $smtpPort = 587;
// ldap default settings (can be changed in company settings) ==================================
/**
* @var bool Set to true if you want to use LDAP
*/
public bool $useLdap = false;
/**
* @var string Select the correct directory type. Currently Supported: OL - OpenLdap, AD - Active Directory
*/
public string $ldapType = 'OL';
/**
* @var string LDAP host (FQDN)
*/
public string $ldapHost = '';
/**
* @var int LDAP port
*/
public int $ldapPort = 389;
/**
* @var string LDAP domain
*/
public string $ldapDomain = '';
/**
* @var string LDAP base DN
*/
public string $ldapUri = '';
/**
* @var string Location of users, example: CN=users,DC=example,DC=com
*/
public string $ldapDn = '';
/**
* @var string Default LDAP keys in your directory. Works for OL
*/
public string $ldapKeys = '{
"username":"uid",
"groups":"memberof",
"email":"mail",
"firstname":"displayname",
"lastname":"",
"phone":"",
"jobTitle":"title",
"jobLevel":"level",
"department":"department"
}';
// For AD use
/*
public $ldapKeys = '{
"username":"cn",
"groups":"memberof",
"email":"mail",
"firstname":"givenname",
"lastname":"sn",
"phone":"telephoneNumber",
"jobTitle":"title",
"jobLevel":"level",
"department":"department"
}';
*/
/**
* @var bool Create users
* Create user if not exists
*/
public bool $ldapCreateUser = false;
/**
* @var string Default role assignments upon first login. (Optional) Can be updated in user settings for each user
*/
public string $ldapLtGroupAssignments = '{
"5": {
"ltRole":"readonly",
"ldapRole":""
},
"10": {
"ltRole":"commenter",
"ldapRole":""
},
"20": {
"ltRole":"editor",
"ldapRole":""
},
"30": {
"ltRole":"manager",
"ldapRole":""
},
"40": {
"ltRole":"admin",
"ldapRole":""
},
"50": {
"ltRole":"owner",
"ldapRole":"administrators"
}
}';
// Default Leantime Role on creation. (set to editor)
/**
* @var int Default Leantime Role on creation. (set to editor)
*/
public int $ldapDefaultRoleKey = 20;
// Plugin Settings ==============================================================================
/**
* @var string Comma separated list of plugins that will always be loaded
*/
public string $plugins = '';
/**
* @var string The Url of the Marketplace
**/
public string $marketplaceUrl = 'https://marketplace.leantime.io/';
// OIDC Settings ================================================================================
/**
* @var bool Set to true if you want to use OIDC
*/
public bool $oidcEnable = false;
/**
* @var string OIDC Provider URL
*/
public string $oidcProviderUrl = '';
/**
* @var string OIDC Client ID
*/
public string $oidcClientId = '';
/**
* @var string OIDC Client Secret
*/
public string $oidcClientSecret = '';
/**
* @var string Custom Auto discover URL
*/
public string $oidcAutoDiscoverUrl = '';
/**
* @var string OIDC Auth URL
*/
public string $oidcAuthUrl = '';
/**
* @var string OIDC Token URL
*/
public string $oidcTokenUrl = '';
/**
* @var string OIDC JWKS URL
*/
public string $oidcJwksUrl = '';
/**
* @var string OIDC User Info URL
*/
public string $oidcUserInfoUrl = '';
/**
* @var string OIDC Certificate String
*/
public string $oidcCertificateString = '';
/**
* @var string OIDC Certificate File
*/
public string $oidcCertificateFile = '';
/**
* @var string OIDC Scopes
*/
public string $oidcScopes = 'openid profile email';
/**
* @var bool create user
*
* Create user if not exists
*/
public bool $oidcCreateUser = false;
/**
* @var int OIDC
*
* Default Role for new users
*/
public int $oidcDefaultRole = 20;
/**
* @var string OIDC Field Email
*/
public string $oidcFieldEmail = 'email';
/**
* @var string OIDC Field First Name
*/
public string $oidcFieldFirstName = 'given_name';
/**
* @var string OIDC Field Last Name
*/
public string $oidcFieldLastName = 'family_name';
/**
* @var string OIDC Field Phone
*/
public string $oidcFieldPhone = '';
/**
* @var string OIDC Field Job Title
*/
public string $oidcFieldJobtitle = '';
/**
* @var string OIDC Field Job Level
*/
public string $oidcFieldJoblevel = '';
/**
* @var string OIDC Field Department
*/
public string $oidcFieldDepartment = '';
// Redis Settings ===============================================================================
/**
* @var bool Set to true if you want to use Redis
*/
public bool $useRedis = false;
/**
* @var bool Set to true if you want to use a redis cluster
*/
public bool $useCluster = false;
/**
* @var string Redis URL
*/
#[LaravelConfig('redis.default.url')]
public string $redisUrl = '';
/**
* @var string Redis Host
*/
#[LaravelConfig('redis.default.host')]
public string $redisHost = '127.0.0.1';
/**
* @var string Redis Port
*/
#[LaravelConfig('redis.default.port')]
public string $redisPort = '6379';
/**
* @var string Redis Password
*/
#[LaravelConfig('redis.default.password')]
public string $redisPassword = '';
/**
* @var string Redis Password
*/
#[LaravelConfig('redis.default.tls')]
public string $redisScheme = 'tls';
// Security/Rate Limiting Settings ===============================================================================
/**
* @var string trusted Proxies
*/
public string $trustedProxies = '127.0.0.1,REMOTE_ADDR';
/**
* @var int rate limit on all requests
*/
public int $ratelimitGeneral = 2000;
/**
* @var int rate limit on API requests (per user+IP per minute). 120 = 2 req/s sustained —
* enough for mobile-app sync bursts and integration polling while still catching
* runaway scripts; in line with comparable tools (GitHub ~83/min, Jira ~100/min).
*/
public int $ratelimitApi = 120;
/**
* @var int rate limit on auth requests
*/
public int $ratelimitAuth = 20;
/**
* @var int rate limit on MCP endpoint requests (per user+IP per minute). Higher than the API
* limit because agentic LLM clients burst many parallel tool calls per turn.
*/
public int $ratelimitMcp = 300;
/**
* @var int rate limit on signup + user-invite POSTs (per IP per minute). These endpoints
* send email and provision resources, so they get a tight budget (invite-spam abuse).
*/
public int $ratelimitSignup = 5;
/**
* @var int maximum user invites per inviting user per hour
*/
public int $ratelimitInvitesUser = 10;
/**
* @var int maximum user invites per installation per day. Raise for legitimate bulk
* onboarding (CSV/directory imports).
*/
public int $ratelimitInvitesTenant = 30;
}

View File

@@ -0,0 +1,30 @@
<?php
namespace Leantime\Views\Composers;
use Leantime\Core\UI\Composer;
use Leantime\Core\UI\Theme;
class Entry extends Composer
{
public static array $views = [
'global::layouts.entry',
];
private Theme $themeCore;
public function init(Theme $themeCore): void
{
$this->themeCore = $themeCore;
}
public function with(): array
{
$this->themeCore->getActive();
$logoUrl = $this->themeCore->getLogoUrl();
return [
'logoPath' => $logoUrl ?: BASE_URL.'/dist/images/logo.svg',
];
}
}

View File

@@ -0,0 +1,328 @@
<?php
namespace Leantime\Core;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Support\Facades\Log;
use Leantime\Core\Configuration\Environment;
use Leantime\Core\Events\DispatchesEvents;
use Leantime\Core\Support\NameSanitizer;
use PHPMailer\PHPMailer\Exception;
use PHPMailer\PHPMailer\PHPMailer;
/**
* Mail class - mails with php mail()
*
* @version 1.0
*
* @license GNU/AGPL-3.0, see license.txt
*/
class Mailer
{
use DispatchesEvents;
public string $cc;
public string $bcc;
public string $text = '';
public string $subject;
public string $context;
private PHPMailer $mailAgent;
/**
* @var string
*/
private mixed $emailDomain;
private Language $language;
private string $logo;
private string $companyColor;
private string $html;
private bool $hideWrapper = false;
public bool $nl2br = true;
/**
* __construct - get configurations
*
* @return void
*/
public function __construct(Environment $config, Language $language)
{
if ($config->email != '') {
$this->emailDomain = $config->email;
} else {
$host = $_SERVER['HTTP_HOST'] ?? 'leantime';
$this->emailDomain = 'no-reply@'.$host;
}
$this->emailDomain = self::dispatch_filter('fromEmail', $this->emailDomain, $this);
// PHPMailer
$this->mailAgent = new PHPMailer(false);
$this->mailAgent->CharSet = 'UTF-8'; // Ensure UTF-8 is used for emails
// Use SMTP or php mail().
if (filter_var($config->useSMTP, FILTER_VALIDATE_BOOLEAN) === true) {
if ($config->debug) {
$this->mailAgent->SMTPDebug = 4; // ensure all aspects (connection, TLS, SMTP, etc) are covered
$this->mailAgent->Debugoutput = function ($str, $level) {
Log::debug($level.' '.$str);
};
} else {
$this->mailAgent->SMTPDebug = 0;
}
$this->mailAgent->Timeout = 20;
$this->mailAgent->isSMTP(); // Set mailer to use SMTP
$this->mailAgent->Host = $config->smtpHosts; // Specify main and backup SMTP servers
if (isset($config->smtpAuth)) {
$this->mailAgent->SMTPAuth = filter_var($config->smtpAuth, FILTER_VALIDATE_BOOLEAN); // Enable SMTP user/password authentication
} else {
$this->mailAgent->SMTPAuth = true;
}
$this->mailAgent->Username = $config->smtpUsername; // SMTP username
$this->mailAgent->Password = $config->smtpPassword; // SMTP password
$this->mailAgent->SMTPAutoTLS = $config->smtpAutoTLS ?? true; // Enable TLS encryption automatically if a server supports it
$this->mailAgent->SMTPSecure = $config->smtpSecure; // Enable TLS encryption, `ssl` also accepted
$this->mailAgent->Port = (int) $config->smtpPort; // TCP port to connect to
if (isset($config->smtpSSLNoverify) && filter_var($config->smtpSSLNoverify, FILTER_VALIDATE_BOOLEAN) === true) { // If enabled, don't verify certifcates: accept self-signed or expired certs.
$this->mailAgent->SMTPOptions = [
'ssl' => [
'verify_peer' => false,
'verify_peer_name' => false,
'allow_self_signed' => true,
],
];
}
} else {
$this->mailAgent->isMail();
}
$this->logo = ! session()->has('companysettings.logoPath') ? '/dist/images/logo_blue.png' : session('companysettings.logoPath');
$this->companyColor = ! session()->has('companysettings.primarycolor') ? '#006c9e' : session('companysettings.primarycolor');
$this->language = $language;
}
/**
* setContext - sets the context for the mailing
* (used for filters & events)
*/
public function setContext($context): void
{
$this->context = $context;
}
/**
* setText - sets the mailtext
*/
public function setText($text): void
{
$this->text = $text;
}
/**
* setHTML - set Mail html (no function yet)
*/
public function setHtml($html, bool $hideWrapper = false): void
{
$this->hideWrapper = $hideWrapper;
$this->html = $html;
}
/**
* setSubject - set mail subject
*/
public function setSubject($subject): void
{
$this->subject = $subject;
}
/**
* dispatchMailerEvent - dispatches a mailer event
*/
private function dispatchMailerEvent($hookname, $payload, array $additional_params = []): void
{
$this->dispatchMailerHook('event', $hookname, $payload, $additional_params);
}
/**
* dispatchMailerFilter - dispatches a mailer filter
*/
private function dispatchMailerFilter($hookname, $payload, array $additional_params = []): mixed
{
return $this->dispatchMailerHook('filter', $hookname, $payload, $additional_params);
}
/**
* dispatchMailerHook - dispatches a mailer hook
*
* @throws BindingResolutionException
*/
private function dispatchMailerHook($type, $hookname, $payload, array $additional_params = []): mixed
{
if ($type !== 'filter' && $type !== 'event') {
return false;
}
$hooks = [$hookname];
if (! empty($this->context)) {
$hooks[] = "$hookname.{$this->context}";
}
$filteredValue = null;
foreach ($hooks as $hook) {
if ($type == 'filter') {
$filteredValue = self::dispatch_filter($hook, $payload, $additional_params);
} elseif ($type == 'event') {
self::dispatch_event($hook, $payload);
}
}
if ($type == 'filter') {
return $filteredValue;
}
return null;
}
/**
* sendMail - send the mail with mail()
*
* @throws Exception
*/
public function sendMail(array $to, $from): void
{
$this->dispatchMailerEvent('beforeSendMail', []);
$to = $this->dispatchMailerFilter('sendMailTo', $to, []);
$from = $this->dispatchMailerFilter('sendMailFrom', $from, []);
$this->mailAgent->isHTML(true); // Set email format to HTML
// The From display name must never carry user-controlled content (invite-spam abuse
// used attacker firstnames here). Callers pass fixed labels; sanitize regardless.
$fromName = NameSanitizer::clean($from);
$fromDisplay = ($fromName === '' || strcasecmp($fromName, 'Leantime') === 0)
? 'Leantime'
: $fromName.' (Leantime)';
$this->mailAgent->setFrom($this->emailDomain, $fromDisplay);
$this->mailAgent->Subject = $this->subject;
if (str_contains($this->logo, 'images/logo.svg')) {
$this->logo = '/dist/images/logo_blue.png';
}
$logoParts = parse_url($this->logo);
if (isset($logoParts['scheme'])) {
// Logo is URL
$inlineLogoContent = $this->logo;
} else {
if (file_exists(ROOT.''.$this->logo) && $this->logo != '' && is_file(ROOT.''.$this->logo)) {
// Logo comes from local file system
$this->mailAgent->addEmbeddedImage(ROOT.''.$this->logo, 'companylogo');
} else {
$this->mailAgent->addEmbeddedImage(ROOT.'/dist/images/logo_blue.png', 'companylogo');
}
$inlineLogoContent = 'cid:companylogo';
}
$mailBody = $this->hideWrapper ? $this->html : app('blade.compiler')::render(
$this->dispatchMailerFilter('bodyTemplate', '<table width="100%" style="background:#fefefe; padding:15px; ">
<tr>
<td align="center" valign="top">
<table width="600" style="width:600px; background-color:#ffffff; border:1px solid #ccc; border-radius:5px;">
<tr>
<td style="padding:20px 10px; text-align:center;">
<img alt="Logo" src="{!! $inlineLogoContent !!}" width="150" style="width:150px;">
</td>
</tr>
<tr>
<td style=\'padding:10px; font-family:"Lato","Helvetica Neue",helvetica,sans-serif; color:#666; font-size:16px; line-height:1.7;\'>
{!! $headline !!}
<br/>
{!! $content !!}
<br/><br/>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td align="center" style=\'padding:10px; font-family:"Lato","Helvetica Neue",helvetica,sans-serif; color:#666; font-size:14px; line-height:1.7;\'>
{!! $unsub_link !!}
</td>
</tr>
</table>'),
$this->dispatchMailerFilter(
'mailBodyParams',
[
'inlineLogoContent' => $inlineLogoContent,
'headline' => $this->language->__('email_notifications.hi'),
'content' => $this->nl2br ? nl2br($this->html) : $this->html,
'unsub_link' => sprintf($this->language->__('email_notifications.unsubscribe'), BASE_URL.'/users/editOwn/'),
]
)
);
$mailBody = $this->dispatchMailerFilter(
'bodyContent',
$mailBody,
[
[
'companyColor' => $this->companyColor,
'logoUrl' => $inlineLogoContent,
'languageHiText' => $this->language->__('email_notifications.hi'),
'emailContentsHtml' => nl2br($this->html),
'unsubLink' => sprintf($this->language->__('email_notifications.unsubscribe'), BASE_URL.'/users/editOwn/'),
],
]
);
$this->mailAgent->Body = $mailBody;
$altBody = $this->dispatchMailerFilter(
'altBody',
$this->text,
[]
);
$this->mailAgent->AltBody = $altBody;
if (is_array($to)) {
$to = array_unique($to);
foreach ($to as $recip) {
try {
$this->mailAgent->addAddress($recip);
$this->mailAgent->send();
} catch (Exception $e) {
Log::error($this->mailAgent->ErrorInfo);
Log::error($e);
}
$this->mailAgent->clearAllRecipients();
}
}
$this->dispatchMailerEvent('afterSendMail', $to);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,67 @@
<!DOCTYPE html>
<html dir="{{ __('language.direction') }}" lang="{{ __('language.code') }}">
<head>
@include('global::sections.header')
@stack('styles')
</head>
<body class="" hx-ext="preload" hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'>
@include('global::sections.appAnnouncement')
<div class="mainwrapper menu{{ session("menuState") ?? "closed" }}">
<div class="header">
<div class="headerinner">
<a class="btnmenu" href="javascript:void(0);"></a>
<a class="barmenu" href="javascript:void(0);">
<span class="fa fa-bars"></span>
</a>
<div class="logo">
<a
href="{{ BASE_URL }}"
style="background-image: url('{{ BASE_URL }}/dist/images/logo.svg')"
>&nbsp;</a>
</div>
@include('menu::headMenu')
</div><!-- headerinner -->
</div><!-- header -->
<div class="overlay" style="position: relative">
<div class="leftpanel">
<div class="leftmenu">
@include('menu::menu')
</div><!-- leftmenu -->
</div>
<div class="rightpanel {{ $section }}">
<div class="primaryContent">
@isset($action, $module)
@include("$module::$action")
@else
@yield('content')
@endisset
<div class="clearfix"></div>
@include('global::sections.footer')
</div>
</div>
</div><!-- rightpanel -->
<div class="menu-backdrop" aria-hidden="true"></div>
</div><!-- mainwrapper -->
@include('global::sections.pageBottom')
@stack('scripts')
@include('help::helpermodal')
</body>
</html>

View File

@@ -0,0 +1,67 @@
<!DOCTYPE html>
<html dir="{{ __('language.direction') }}" lang="{{ __('language.code') }}">
<head>
@include('global::sections.header')
<style>
.leantimeLogo { position: fixed; bottom: 10px; right: 10px; }
</style>
@stack('styles')
</head>
<body class="loginpage" style="height:100%;" hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'>
<div class="header hidden-gt-sm tw-p-[10px]" style="background:var(--header-gradient)">
<a href="{!! BASE_URL !!}" target="_blank">
<img src="{{ BASE_URL }}/dist/images/logo.svg" class="tw-h-full "/>
</a>
</div>
<div class="row" style="min-height:100vh; max-width: 98vw; height: auto;">
<div class="col-md-4 hidden-phone regLeft">
<div class="logo">
<a href="{!! BASE_URL !!}" target="_blank">
<img src="{{ BASE_URL }}/dist/images/logo.svg" />
</a>
</div>
<div class="welcomeContent">
@dispatchFilter('welcomeText', '<h1 class="mainWelcome">'.$language->__("headlines.welcome_back").'</h1>')
</div>
@dispatchFilter('belowWelcomeText', '')
</div>
<div class="col-md-8 col-sm-12 regRight">
<div class="regpanel">
<div class="regpanelinner">
@if($logoPath != '')
<a href="{!! BASE_URL !!}" target="_blank">
@if(!str_ends_with($logoPath, "dist/images/logo.svg" ))
<img src="{{ $logoPath }}" class="tw-h-full "/>
@endif
</a>
@endif
@isset($action, $module)
@include("$module::$action")
@else
@yield('content')
@endisset
</div>
</div>
</div>
<div class="leantimeLogo">
<img style="height: 25px;" src="{!! BASE_URL !!}/dist/images/logo-powered-by-leantime.png">
</div>
</div>
@include('global::sections.pageBottom')
@stack('scripts')
</body>
</html>

View File

@@ -0,0 +1,55 @@
<!DOCTYPE html>
<html dir="{{ __('language.direction') }}" lang="{{ __('language.code') }}">
<head>
@include('global::sections.header')
<style>
.leantimeLogo { position: fixed; bottom: 10px; right: 10px; }
</style>
@stack('styles')
</head>
<body class="loginpage" style="height:100%;" hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'>
<div class="header hidden-gt-sm tw-p-[10px]" style="background:var(--header-gradient)">
<a href="{!! BASE_URL !!}" target="_blank">
<img src="{{ BASE_URL }}/dist/images/logo.svg" class="tw-h-full "/>
</a>
</div>
<div class="row " style="height:100%; width: 99%;">
<div class="col-md-4 hidden-phone regLeft">
<div class="logo">
<a href="{!! BASE_URL !!}" target="_blank"><img src="{{ BASE_URL }}/dist/images/logo.svg" /></a>
</div>
<div class="welcomeContent">
<h1 class="mainWelcome">
Oops, something is off.
</h1>
</div>
</div>
<div class="col-md-8 col-sm-12 regRight">
<div class="regpanel">
<div class="regpanelinner">
@isset($action, $module)
@include("$module::$action")
@else
@yield('content')
@endisset
</div>
</div>
</div>
<div class="leantimeLogo">
<img style="height: 25px;" src="{!! BASE_URL !!}/dist/images/logo-powered-by-leantime.png">
</div>
</div>
@include('global::sections.pageBottom')
@stack('scripts')
</body>
</html>

74
.dev/onebot-bom-i18n.py Normal file
View File

@@ -0,0 +1,74 @@
# -*- coding: utf-8 -*-
"""追加 BOM 模块的语言翻译 keyzh-CN 与 en-US"""
import os
root = r"d:\开发\leantime-master\leantime-master"
zh = '''
# BOM (OneBot)
menu.bom = "BOM"
menu.bom_tooltip = "BOM 物料清单"
text.bom_subtitle = "物料清单BOM管理"
notification.bom_not_found = "BOM 不存在"
label.new_bom = "新建 BOM"
text.no_boms = "暂无 BOM点击“新建 BOM”开始。"
label.bom_no = "BOM编号"
label.product_name = "产品名称"
label.specification = "规格型号"
label.drawing_no = "图号"
label.column_key = "列标识(英文或中文,用于存储)"
label.column_label = "列显示名"
label.add_row = "新增行"
label.add_column = "增加列"
label.teable_sources = "Teable 数据源"
text.no_sources = "未配置数据源"
label.source_name = "表名/别名"
text.teable_import_confirm = "从 Teable 拉取最新数据并导入为明细?"
text.imported = "已导入"
label.rows = ""
text.no_items = "暂无明细"
links.open = "打开"
links.back = "返回"
links.add = "添加"
'''
en = '''
# BOM (OneBot)
menu.bom = "BOM"
menu.bom_tooltip = "Bill of Materials"
text.bom_subtitle = "Bill of Materials (BOM) management"
notification.bom_not_found = "BOM not found"
label.new_bom = "New BOM"
text.no_boms = "No BOM yet. Click \"New BOM\" to start."
label.bom_no = "BOM No."
label.product_name = "Product Name"
label.specification = "Specification"
label.drawing_no = "Drawing No."
label.column_key = "Column key (used for storage)"
label.column_label = "Column label"
label.add_row = "Add Row"
label.add_column = "Add Column"
label.teable_sources = "Teable Sources"
text.no_sources = "No source configured"
label.source_name = "Table name / alias"
text.teable_import_confirm = "Fetch latest data from Teable and import as items?"
text.imported = "Imported"
label.rows = "rows"
text.no_items = "No items"
links.open = "Open"
links.back = "Back"
links.add = "Add"
'''
for code, block in (("zh-CN", zh), ("en-US", en)):
p = os.path.join(root, "app", "Language", code + ".ini")
with open(p, "r", encoding="utf-8") as f:
content = f.read()
if not content.endswith("\n"):
content += "\n"
content += block
with open(p, "w", encoding="utf-8") as f:
f.write(content)
print(code, "appended")

111
.dev/onebot-bom-lint.py Normal file
View File

@@ -0,0 +1,111 @@
# -*- coding: utf-8 -*-
"""对改动过的 PHP/Blade 文件做静态检查(无 php CLI 时的替代):
跳过 /* */ 与 // 与 # 注释,再检查括号/引号平衡。
"""
import os
import re
root = r"d:\开发\leantime-master\leantime-master"
files = [
r"app\Domain\Bom\Repositories\Bom.php",
r"app\Domain\Bom\Services\Bom.php",
r"app\Domain\Bom\Services\Teable.php",
r"app\Domain\Bom\Services\Excel.php",
r"app\Domain\Bom\Permissions\BomPermissions.php",
r"app\Domain\Bom\Controllers\Show.php",
r"app\Domain\Bom\Controllers\Api.php",
r"app\Domain\Bom\routes.php",
r"app\Domain\Bom\Templates\show.blade.php",
r"app\Domain\Bom\Templates\detail.blade.php",
r"app\Domain\Install\Repositories\Install.php",
r"app\Domain\Install\Services\SchemaBuilder.php",
r"app\Domain\Menu\Repositories\Menu.php",
r"app\Views\Templates\components\pdfPreview.blade.php",
r"app\Views\Templates\sections\header.blade.php",
]
def strip_comments(src):
# 去掉 /* ... */ 和 // 行注释(保留换行结构便于定位)
out = []
i = 0
n = len(src)
while i < n:
c = src[i]
nxt = src[i + 1] if i + 1 < n else ''
if c == '/' and nxt == '*':
j = src.find('*/', i + 2)
out.append(' ' * (n if j == -1 else (j + 2 - i)))
i = n if j == -1 else j + 2
elif c == '/' and nxt == '/':
j = src.find('\n', i)
out.append(' ' * (n if j == -1 else (j - i)))
i = n if j == -1 else j
elif c == '#':
j = src.find('\n', i)
out.append(' ' * (n if j == -1 else (j - i)))
i = n if j == -1 else j
else:
out.append(c)
i += 1
return ''.join(out)
def check_balance(s):
pairs = {')': '(', '}': '{', ']': '['}
stack = []
in_s = in_d = False
i = 0
esc = False
while i < len(s):
ch = s[i]
if esc:
esc = False
i += 1
continue
if ch == '\\' and (in_s or in_d):
esc = True
i += 1
continue
if ch == "'" and not in_d:
in_s = not in_s
i += 1
continue
if ch == '"' and not in_s:
in_d = not in_d
i += 1
continue
if not in_s and not in_d:
if ch in '({[':
stack.append(ch)
elif ch in ')}]':
if not stack or stack[-1] != pairs[ch]:
return f"不匹配 '{ch}' 于位置 {i}"
stack.pop()
i += 1
if in_s:
return "单引号未闭合"
if in_d:
return "双引号未闭合"
if stack:
return f"未闭合 {stack[-1]}"
return None
all_ok = True
for f in files:
p = os.path.join(root, f)
if not os.path.exists(p):
print(f"[SKIP] {f} (不存在)")
continue
with open(p, encoding="utf-8") as fh:
src = fh.read()
stripped = strip_comments(src)
bal = check_balance(stripped)
if bal:
print(f"[FAIL] {f} <- {bal}")
all_ok = False
else:
print(f"[OK] {f}")
print("\n总判定:", "PASS" if all_ok else "存在需人工复核的项")

63
.dev/onebot-rebrand.py Normal file
View File

@@ -0,0 +1,63 @@
# -*- coding: utf-8 -*-
"""OneBot rebrand helper: logo 生成 + 语言文件品牌名替换"""
import base64
import re
import glob
import os
import shutil
root = r"d:\开发\leantime-master\leantime-master"
img_dir = os.path.join(root, "public", "assets", "images")
bt = os.path.join(root, "bt.png")
# 1. 生成内嵌 bt.png 的 logo.svg / logo_blue.svg保持现有 .svg 引用不变,最小改动)
with open(bt, "rb") as f:
b64 = base64.b64encode(f.read()).decode()
svg = ('<svg xmlns="http://www.w3.org/2000/svg" width="108" height="98" '
'viewBox="0 0 108 98"><image width="108" height="98" '
'href="data:image/png;base64,{}"/></svg>').format(b64)
for name in ("logo.svg", "logo_blue.svg"):
with open(os.path.join(img_dir, name), "w", encoding="utf-8") as f:
f.write(svg)
# printLogoURL 指向 logo.jpg原本缺失复制 bt.png 补齐
shutil.copyfile(bt, os.path.join(img_dir, "logo.jpg"))
# 2. 语言文件:仅替换 value 中的 Leantime/leantime保留翻译 key 与 *.leantime.io 域名
def repl(m):
s = m.group(0)
return "OneBot" if s[0].isupper() else "onebot"
# 匹配 Leantime不区分大小写排除后面紧跟 .io域名或字母更大单词
pat = re.compile(r"(?i)leantime(?!(\.io|[a-z]))")
changed_files = 0
changed_lines = 0
for ini in glob.glob(os.path.join(root, "app", "Language", "*.ini")):
with open(ini, "r", encoding="utf-8") as f:
lines = f.readlines()
out = []
file_changed = False
for line in lines:
if line.lstrip().startswith(("#", ";")):
out.append(line)
continue
if "=" not in line:
out.append(line)
continue
idx = line.index("=")
key, val = line[:idx], line[idx:]
new_val, n = pat.subn(repl, val)
if n:
file_changed = True
changed_lines += n
out.append(key + new_val)
if file_changed:
with open(ini, "w", encoding="utf-8") as f:
f.writelines(out)
changed_files += 1
print("logo.svg/logo_blue.svg/logo.jpg written")
print("language files changed:", changed_files, "| line replacements:", changed_lines)

View File

@@ -0,0 +1,56 @@
#!/bin/bash
set -e
APP_DIR=/var/www/onebot
SRC_TAR=/home/rui/onebot-src.tar.gz
echo "=== 1. 解压源码 ==="
sudo rm -rf "$APP_DIR"
sudo mkdir -p "$APP_DIR"
sudo tar -xzf "$SRC_TAR" -C "$APP_DIR" --strip-components=1
sudo chown -R "$USER":www-data "$APP_DIR"
echo "解压完成,目录内容:"
ls "$APP_DIR" | head -20
echo "=== 2. 初始化 MySQL 数据库 ==="
DB_NAME=onebot
DB_USER=onebot
DB_PASS='OneBot_Db_7xKpQ9z'
sudo mysql <<SQL
CREATE DATABASE IF NOT EXISTS ${DB_NAME} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER IF NOT EXISTS '${DB_USER}'@'localhost' IDENTIFIED BY '${DB_PASS}';
GRANT ALL PRIVILEGES ON ${DB_NAME}.* TO '${DB_USER}'@'localhost';
FLUSH PRIVILEGES;
SQL
echo "数据库 ${DB_NAME} 创建完成,验证连接:"
mysql -u"$DB_USER" -p"$DB_PASS" -e "SELECT VERSION();" "$DB_NAME" 2>/dev/null && echo "DB_CONNECT_OK"
echo "=== 3. 创建 .env ==="
cat > "$APP_DIR/.env" <<ENV
APP_ENV=production
APP_DEBUG=false
APP_URL=http://119.29.53.45:8081
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=${DB_NAME}
DB_USERNAME=${DB_USER}
DB_PASSWORD=${DB_PASS}
LEAN_DB_HOST=127.0.0.1
LEAN_DB_USER=${DB_USER}
LEAN_DB_PASSWORD=${DB_PASS}
LEAN_DB_DATABASE=${DB_NAME}
LEAN_APP_URL=http://119.29.53.45:8081
LEAN_SITENAME='OneBot'
LEAN_LANGUAGE='zh-CN'
LEAN_SESSION_PASSWORD='OneBot_Session_4tHwLq8z'
ENV
echo ".env 写入完成:"
cat "$APP_DIR/.env"
echo "=== 全部完成 ==="

View File

@@ -0,0 +1,18 @@
#!/bin/bash
set -e
echo "=== 检查 mysql-server 包是否可用 ==="
apt-cache policy mysql-server 2>&1 | head -5
echo "=== 安装 mysql-server ==="
export DEBIAN_FRONTEND=noninteractive
apt-get install -y mysql-server 2>&1 | tail -30
echo "=== 检查安装结果 ==="
which mysql mysqld 2>&1 || echo "MYSQL_NOT_FOUND"
dpkg -l 2>/dev/null | grep -E 'mysql-server|mysql-common' | head
echo "=== 启动 mysql ==="
systemctl start mysql 2>&1 || true
sleep 3
systemctl is-active mysql 2>&1

View File

@@ -0,0 +1,21 @@
#!/bin/bash
set -e
DB_NAME=onebot
DB_USER=onebot
DB_PASS='OneBot_Db_7xKpQ9z'
echo "=== 创建数据库与用户 ==="
sudo mysql <<SQL
CREATE DATABASE IF NOT EXISTS ${DB_NAME} CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER IF NOT EXISTS '${DB_USER}'@'localhost' IDENTIFIED BY '${DB_PASS}';
ALTER USER '${DB_USER}'@'localhost' IDENTIFIED BY '${DB_PASS}';
GRANT ALL PRIVILEGES ON ${DB_NAME}.* TO '${DB_USER}'@'localhost';
FLUSH PRIVILEGES;
SQL
echo "=== 验证连接 ==="
mysql -u"$DB_USER" -p"$DB_PASS" -h127.0.0.1 -e "SELECT VERSION();" "$DB_NAME" && echo "DB_CONNECT_OK"
echo "=== 确认 .env 存在 ==="
ls -la /var/www/onebot/.env

View File

@@ -0,0 +1,48 @@
#!/bin/bash
set -e
APP_DIR=/var/www/onebot
DB_NAME=onebot
DB_USER=onebot
DB_PASS='OneBot_Db_7xKpQ9z'
echo "=== 写入 .env ==="
cat > "$APP_DIR/.env" <<ENV
APP_ENV=production
APP_DEBUG=false
APP_URL=http://119.29.53.45:8081
LEAN_APP_URL=http://119.29.53.45:8081
LEAN_APP_DIR=
LEAN_DEBUG=0
LEAN_DB_HOST=127.0.0.1
LEAN_DB_PORT=3306
LEAN_DB_USER=${DB_USER}
LEAN_DB_PASSWORD=${DB_PASS}
LEAN_DB_DATABASE=${DB_NAME}
LEAN_SESSION_PASSWORD='OneBot_Session_4tHwLq8z'
LEAN_SESSION_EXPIRATION=480
LEAN_SITENAME='OneBot'
LEAN_LANGUAGE='zh-CN'
LEAN_DEFAULT_TIMEZONE='Asia/Shanghai'
LEAN_LOGO_PATH='/dist/images/logo.svg'
LEAN_PRINT_LOGO_URL='/dist/images/logo.png'
LEAN_EMAIL_RETURN=''
LEAN_USE_S3=false
LEAN_USER_FILE_PATH='userfiles/'
LEAN_DB_BACKUP_PATH='backupdb/'
LEAN_USE_REDIS=false
LEAN_NEWS_ENABLED=false
ENV
echo "=== .env 内容 ==="
cat "$APP_DIR/.env"
echo "=== 权限 ==="
sudo chown -R rui:www-data "$APP_DIR"
sudo chmod -R 775 "$APP_DIR/storage" "$APP_DIR/bootstrap/cache" 2>/dev/null || true
echo "DONE"

View File

@@ -0,0 +1,14 @@
#!/bin/bash
set -e
APP_DIR=/var/www/onebot
LOG=/tmp/composer_install.log
echo "=== composer install 开始 ==="
cd "$APP_DIR"
sudo -u rui composer install --no-dev --no-interaction --optimize-autoloader > "$LOG" 2>&1
echo "COMPOSER_EXIT=$?"
echo "=== 日志尾部 ==="
tail -20 "$LOG"
echo "=== vendor 目录大小 ==="
du -sh "$APP_DIR/vendor" 2>/dev/null

View File

@@ -0,0 +1,13 @@
#!/bin/bash
set -e
APP_DIR=/var/www/onebot
LOG=/tmp/npm_install.log
export PATH="/home/rui/.nvm/versions/node/v24.12.0/bin:$PATH"
cd "$APP_DIR"
echo "npm install 开始 $(date)" > "$LOG"
node -v >> "$LOG" 2>&1
npm -v >> "$LOG" 2>&1
npm install --no-audit --no-fund >> "$LOG" 2>&1
echo "NPM_EXIT=$?" >> "$LOG"
echo "完成 $(date)" >> "$LOG"

View File

@@ -0,0 +1,8 @@
#!/bin/bash
set -e
cp /home/rui/onebot-nginx.conf /etc/nginx/sites-available/onebot
ln -sf /etc/nginx/sites-available/onebot /etc/nginx/sites-enabled/onebot
nginx -t
systemctl reload nginx
echo "NGINX_OK"

View File

@@ -0,0 +1,19 @@
#!/bin/bash
set -e
APP_DIR=/var/www/onebot
LOG=/tmp/npm_build.log
export PATH="/home/rui/.nvm/versions/node/v24.12.0/bin:$PATH"
cd "$APP_DIR"
echo "=== 前端构建开始 $(date) ===" > "$LOG"
node -v >> "$LOG" 2>&1
# laravel-mix 生产构建
npx mix --production >> "$LOG" 2>&1
echo "BUILD_EXIT=$?" >> "$LOG"
echo "=== 构建完成 $(date) ===" >> "$LOG"
echo "=== dist 目录 ==="
ls -la "$APP_DIR/public/dist/" 2>/dev/null | head -20
echo "=== dist/images logo ==="
ls -la "$APP_DIR/public/dist/images/" 2>/dev/null | grep -i logo || echo "NO_LOGO_IN_DIST"

View File

@@ -0,0 +1,19 @@
#!/bin/bash
set -e
APP_DIR=/var/www/onebot
echo "=== 重建 storage 目录结构 ==="
mkdir -p "$APP_DIR/storage/app"
mkdir -p "$APP_DIR/storage/framework/cache"
mkdir -p "$APP_DIR/storage/framework/sessions"
mkdir -p "$APP_DIR/storage/framework/views"
mkdir -p "$APP_DIR/storage/logs"
mkdir -p "$APP_DIR/storage/backupdb"
mkdir -p "$APP_DIR/userfiles"
chown -R rui:www-data "$APP_DIR/storage" "$APP_DIR/userfiles"
chmod -R 775 "$APP_DIR/storage" "$APP_DIR/userfiles"
echo "=== storage 目录 ==="
ls -la "$APP_DIR/storage/"
echo "DONE"

View File

@@ -0,0 +1,17 @@
#!/bin/bash
echo "=== HTTP 状态 ==="
curl -s -o /dev/null -w "HTTP=%{http_code}\n" http://127.0.0.1:8081/
echo "=== 页面标题 ==="
curl -s http://127.0.0.1:8081/ | grep -oE "<title>[^<]*</title>" | head -1
echo "=== 检查品牌关键词 ==="
BODY=$(curl -s http://127.0.0.1:8081/)
echo "$BODY" | grep -qi "onebot" && echo "含 OneBot" || echo "无 OneBot"
echo "$BODY" | grep -qi "leantime" && echo "含 Leantime" || echo "无 Leantime"
echo "=== 检查数据库表是否已建 ==="
mysql -uonebot -p'OneBot_Db_7xKpQ9z' -h127.0.0.1 onebot -e "SHOW TABLES LIKE 'zp_bom%';" 2>/dev/null || echo "DB 查询失败"
echo "=== 检查 BOM 相关表 ==="
mysql -uonebot -p'OneBot_Db_7xKpQ9z' -h127.0.0.1 onebot -e "SHOW TABLES;" 2>/dev/null | grep -iE "zp_bom|zp_" | head -30

View File

@@ -0,0 +1,12 @@
#!/bin/bash
echo "=== laravel.log 尾部 ==="
tail -50 /var/www/onebot/storage/logs/laravel.log 2>/dev/null || echo "无 laravel.log"
echo ""
echo "=== .env 是否有 APP_KEY ==="
grep -E "APP_KEY" /var/www/onebot/.env || echo "无 APP_KEY"
echo ""
echo "=== 用 artisan 直接看报错 ==="
cd /var/www/onebot
sudo -u www-data php artisan about 2>&1 | head -40

View File

@@ -0,0 +1,27 @@
#!/bin/bash
echo "=== /var/www/onebot 完整文件列表(根目录)==="
ls -la /var/www/onebot/
echo ""
echo "=== 是否有 artisan ==="
ls -la /var/www/onebot/artisan 2>&1
echo ""
echo "=== index.php 内容 ==="
cat /var/www/onebot/index.php
echo ""
echo "=== config 目录 ==="
ls -la /var/www/onebot/config/
echo ""
echo "=== config/app.php 里 APP_KEY 相关 ==="
grep -n "key\|APP_KEY\|cipher" /var/www/onebot/config/app.php 2>/dev/null | head -20 || echo "无 config/app.php"
echo ""
echo "=== .env 里是否有 APP_KEY ==="
grep -n "APP_KEY" /var/www/onebot/.env || echo "无 APP_KEY"
echo ""
echo "=== bin 目录 ==="
ls -la /var/www/onebot/bin/ 2>&1

View File

@@ -0,0 +1,22 @@
#!/bin/bash
set -e
APP_DIR=/var/www/onebot
# 打开 debug让 Laravel 直接渲染真实错误
sed -i 's/^APP_DEBUG=.*/APP_DEBUG=true/' "$APP_DIR/.env"
sed -i 's/^LEAN_DEBUG=.*/LEAN_DEBUG=1/' "$APP_DIR/.env"
grep -E "APP_DEBUG|LEAN_DEBUG" "$APP_DIR/.env"
# 清空旧日志,重新请求
rm -f "$APP_DIR/storage/logs/laravel.log"
echo "=== 直接 curl 看错误 ==="
curl -s http://127.0.0.1:8081/ | grep -oE "(Fatal error|Parse error|Exception|Error|message)[^<]*" | head -20
echo ""
echo "=== php-fpm 错误日志 ==="
tail -30 /var/log/php8.3-fpm.log 2>/dev/null || echo "无 php-fpm.log"
echo ""
echo "=== laravel.log ==="
tail -40 "$APP_DIR/storage/logs/laravel.log" 2>/dev/null || echo "仍无 laravel.log"

View File

@@ -0,0 +1,17 @@
#!/bin/bash
echo "=== nginx onebot.error.log ==="
tail -50 /var/log/nginx/onebot.error.log 2>/dev/null || echo "无 onebot.error.log"
echo ""
echo "=== nginx error.log (默认) ==="
tail -30 /var/log/nginx/error.log 2>/dev/null || echo "无默认 error.log"
echo ""
echo "=== 直接 CLI 方式执行 index.php 看真实错误 ==="
cd /var/www/onebot/public
sudo -u www-data php -d display_errors=1 -d error_reporting=E_ALL index.php 2>&1 | head -40
echo ""
echo "=== 用 php 直接 require 入口 ==="
cd /var/www/onebot
sudo -u www-data php -r '$_SERVER["REQUEST_URI"]="/"; $_SERVER["REQUEST_METHOD"]="GET"; $_SERVER["SCRIPT_NAME"]="/index.php"; $_SERVER["DOCUMENT_ROOT"]="/var/www/onebot/public"; require "/var/www/onebot/public/index.php";' 2>&1 | head -60

View File

@@ -0,0 +1,20 @@
#!/bin/bash
# 清空 Laravel 缓存,避免旧的语言缓存/配置缓存导致错误
cd /var/www/onebot
rm -rf storage/framework/cache/data/* 2>/dev/null
rm -rf storage/framework/views/* 2>/dev/null
rm -f bootstrap/cache/config.php bootstrap/cache/routes*.php 2>/dev/null
echo "=== HTTP 状态 ==="
curl -s -o /dev/null -w "HTTP=%{http_code}\n" http://127.0.0.1:8081/
echo "=== 页面标题 ==="
curl -s http://127.0.0.1:8081/ | grep -oE "<title>[^<]*</title>" | head -1
echo "=== 品牌关键词 ==="
BODY=$(curl -s http://127.0.0.1:8081/)
echo "$BODY" | grep -qi "onebot" && echo "含 OneBot" || echo "无 OneBot"
echo "$BODY" | grep -qi "leantime" && echo "含 Leantime" || echo "无 Leantime"
echo "=== 数据库表 ==="
mysql -uonebot -p'OneBot_Db_7xKpQ9z' -h127.0.0.1 onebot -e "SHOW TABLES;" 2>/dev/null | grep -iE "zp_bom|zp_" | head -30

View File

@@ -0,0 +1,27 @@
#!/bin/bash
echo "=== 用 PHP 直接测试语言文件解析 ==="
for f in en-US zh-CN; do
echo "--- $f.ini ---"
sudo -u www-data php -r "
\$r = @parse_ini_file('/var/www/onebot/app/Language/$f.ini', false, INI_SCANNER_RAW);
if (\$r === false) {
echo \"PARSE_FAILED\n\";
} else {
echo \"PARSE_OK, keys=\".count(\$r).\"\n\";
}
" 2>&1
done
echo ""
echo "=== 完整错误(不抑制)==="
sudo -u www-data php -d display_errors=1 -d error_reporting=E_ALL -r "
parse_ini_file('/var/www/onebot/app/Language/en-US.ini', false, INI_SCANNER_RAW);
echo 'en-US OK\n';
parse_ini_file('/var/www/onebot/app/Language/zh-CN.ini', false, INI_SCANNER_RAW);
echo 'zh-CN OK\n';
" 2>&1
echo ""
echo "=== installation 缓存位置 ==="
ls -la /var/www/onebot/storage/framework/cache/ 2>/dev/null
find /var/www/onebot/storage/framework/cache -type f 2>/dev/null | head -20

View File

@@ -0,0 +1,24 @@
#!/bin/bash
cd /var/www/onebot
echo "=== 清空所有缓存 ==="
rm -rf storage/framework/cache/* 2>/dev/null
rm -rf storage/framework/views/* 2>/dev/null
rm -f bootstrap/cache/*.php 2>/dev/null
echo "缓存已清空"
echo ""
echo "=== 重新用 PHP 验证语言文件解析 ==="
sudo -u www-data php -r "
\$r = @parse_ini_file('/var/www/onebot/app/Language/en-US.ini', false, INI_SCANNER_RAW);
echo 'en-US: ' . (\$r === false ? 'FAIL' : 'OK('.count(\$r).')') . PHP_EOL;
\$r2 = @parse_ini_file('/var/www/onebot/app/Language/zh-CN.ini', false, INI_SCANNER_RAW);
echo 'zh-CN: ' . (\$r2 === false ? 'FAIL' : 'OK('.count(\$r2).')') . PHP_EOL;
" 2>&1
echo ""
echo "=== HTTP 状态 ==="
curl -s -o /dev/null -w "HTTP=%{http_code}\n" http://127.0.0.1:8081/
echo "=== 页面标题 ==="
curl -s http://127.0.0.1:8081/ | grep -oE "<title>[^<]*</title>" | head -1

View File

@@ -0,0 +1,9 @@
#!/bin/bash
cd /var/www/onebot
echo "=== 可用 CLI 命令 ==="
sudo -u www-data php /var/www/onebot/public/index.php list 2>&1 | head -50
echo ""
echo "=== /install 页面表单 ==="
curl -s http://127.0.0.1:8081/install 2>&1 | grep -oE "(name=\"[^\"]*\"|placeholder=\"[^\"]*\"|公司|Company|Email|First|Last|Password)" | head -40

View File

@@ -0,0 +1,9 @@
#!/bin/bash
cd /var/www/onebot
echo "=== 完整 CLI 命令列表 ==="
sudo -u www-data php /var/www/onebot/public/index.php list 2>&1 | grep -iE "install|db:|migrate|seed"
echo ""
echo "=== /install 表单完整字段(含 type=password==="
curl -s http://127.0.0.1:8081/install 2>&1 | grep -oE "<input[^>]*>" | grep -iE "password|name=" | head -40

View File

@@ -0,0 +1,16 @@
#!/bin/bash
cd /var/www/onebot
echo "=== 提交安装表单 ==="
RESP=$(curl -s -i -X POST http://127.0.0.1:8081/install \
-d "email=admin@onebot.local" \
-d "firstname=Admin" \
-d "lastname=OneBot" \
-d "company=OneBot" \
-d "install=Install")
echo "$RESP" | head -20
echo ""
echo "=== 检查数据库表是否已建 ==="
mysql -uonebot -p'OneBot_Db_7xKpQ9z' -h127.0.0.1 onebot -e "SHOW TABLES;" 2>/dev/null | head -50

View File

@@ -0,0 +1,13 @@
#!/bin/bash
cd /var/www/onebot
echo "=== laravel.log 尾部 60 行 ==="
tail -60 storage/logs/laravel.log 2>/dev/null || echo "无 laravel.log"
echo ""
echo "=== 当前数据库表 ==="
mysql -uonebot -p'OneBot_Db_7xKpQ9z' -h127.0.0.1 onebot -e "SHOW TABLES;" 2>/dev/null
echo ""
echo "=== 是否有 zp_user 表 ==="
mysql -uonebot -p'OneBot_Db_7xKpQ9z' -h127.0.0.1 onebot -e "SELECT COUNT(*) AS cnt FROM zp_user;" 2>/dev/null || echo "无 zp_user 表"

View File

@@ -0,0 +1,26 @@
#!/bin/bash
cd /var/www/onebot
echo "=== storage/logs 权限 ==="
ls -la storage/ storage/logs/ 2>&1
echo ""
echo "=== 检查日志配置 ==="
grep -rE "LOG_CHANNEL|LEAN_LOG|LOG_PATH" .env config/ app/Core/Configuration/ 2>/dev/null | head -20
echo ""
echo "=== 直接 CLI 调用 setupDB 捕获异常 ==="
sudo -u www-data php -d display_errors=1 -d error_reporting=E_ALL -r '
require "/var/www/onebot/vendor/autoload.php";
$app = require "/var/www/onebot/bootstrap/app.php";
\Leantime\Core\Bootloader::getInstance()->boot($app);
$repo = app()->make(\Leantime\Domain\Install\Repositories\Install::class);
try {
$result = $repo->setupDB(["email"=>"admin@onebot.local","firstname"=>"Admin","lastname"=>"OneBot","company"=>"OneBot"]);
echo "setupDB result: " . var_export($result, true) . PHP_EOL;
} catch (\Throwable $e) {
echo "EXCEPTION: " . $e->getMessage() . PHP_EOL;
echo "FILE: " . $e->getFile() . ":" . $e->getLine() . PHP_EOL;
echo $e->getTraceAsString() . PHP_EOL;
}
' 2>&1 | head -60

View File

@@ -0,0 +1,13 @@
#!/bin/bash
cd /var/www/onebot
echo "=== 日志文件列表 ==="
ls -la storage/logs/
echo ""
echo "=== leantime 日志尾部 80 行 ==="
tail -80 storage/logs/leantime-2026-08-31.log 2>/dev/null
echo ""
echo "=== 搜索 Error/Exception 关键词 ==="
grep -nE "Error|Exception|error|failed|SQLSTATE|Access denied" storage/logs/leantime-2026-08-31.log 2>/dev/null | tail -40

View File

@@ -0,0 +1,26 @@
#!/bin/bash
set -e
APP_DIR=/var/www/onebot
echo "=== 移动 .env 到 config/.env ==="
mv "$APP_DIR/.env" "$APP_DIR/config/.env" 2>/dev/null || cp "$APP_DIR/.env" "$APP_DIR/config/.env"
chown rui:www-data "$APP_DIR/config/.env"
chmod 664 "$APP_DIR/config/.env"
echo "=== 确认位置 ==="
ls -la "$APP_DIR/.env" 2>/dev/null && echo "根目录 .env 仍存在(需删除)" || echo "根目录 .env 已移除"
ls -la "$APP_DIR/config/.env" && echo "config/.env 已就位"
echo ""
echo "=== 清缓存 ==="
rm -rf "$APP_DIR/storage/framework/cache/"* 2>/dev/null
rm -f "$APP_DIR/bootstrap/cache/"*.php 2>/dev/null
echo "缓存已清"
echo ""
echo "=== 验证数据库连接配置 ==="
grep -E "LEAN_DB_|DB_" "$APP_DIR/config/.env"
echo ""
echo "=== HTTP 测试 ==="
curl -s -o /dev/null -w "HTTP=%{http_code}\n" http://127.0.0.1:8081/

View File

@@ -0,0 +1,12 @@
#!/bin/bash
echo "=== 总表数 ==="
mysql -uonebot -p'OneBot_Db_7xKpQ9z' -h127.0.0.1 onebot -N -e "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='onebot';" 2>/dev/null
echo "=== BOM 表 ==="
mysql -uonebot -p'OneBot_Db_7xKpQ9z' -h127.0.0.1 onebot -e "SHOW TABLES LIKE 'zp_bom%';" 2>/dev/null
echo "=== zp_user ==="
mysql -uonebot -p'OneBot_Db_7xKpQ9z' -h127.0.0.1 onebot -e "SELECT id, username, status, role FROM zp_user;" 2>/dev/null
echo "=== db-version ==="
mysql -uonebot -p'OneBot_Db_7xKpQ9z' -h127.0.0.1 onebot -e "SELECT * FROM zp_settings WHERE \`key\`='db-version';" 2>/dev/null

View File

@@ -0,0 +1,11 @@
#!/bin/bash
cd /var/www/onebot
echo "=== 生成密码哈希并激活管理员 ==="
HASH=$(php -r "echo password_hash('OneBot@2026', PASSWORD_DEFAULT);")
echo "哈希: ${HASH:0:20}..."
mysql -uonebot -p'OneBot_Db_7xKpQ9z' -h127.0.0.1 onebot -e "UPDATE zp_user SET password='${HASH}', status='a', forcePwReset=0 WHERE id=1;" 2>/dev/null
echo "=== 验证用户状态 ==="
mysql -uonebot -p'OneBot_Db_7xKpQ9z' -h127.0.0.1 onebot -e "SELECT id, username, status, role FROM zp_user WHERE id=1;" 2>/dev/null

View File

@@ -0,0 +1,18 @@
#!/bin/bash
echo "=== 首页 HTTP 状态 ==="
curl -s -o /dev/null -w "HTTP=%{http_code}\n" http://127.0.0.1:8081/
echo "=== 首页标题与品牌 ==="
BODY=$(curl -s http://127.0.0.1:8081/)
echo "$BODY" | grep -oE "<title>[^<]*</title>" | head -1
echo "$BODY" | grep -qi "OneBot" && echo "✅ 含 OneBot 品牌" || echo "❌ 无 OneBot"
echo "$BODY" | grep -qi "leantime" && echo "⚠️ 仍含 leantime" || echo "✅ 无 leantime 残留"
echo "=== logo 文件可达 ==="
curl -s -o /dev/null -w "logo.svg HTTP=%{http_code} size=%{size_download}\n" http://127.0.0.1:8081/dist/images/logo.svg
echo "=== PDF 预览组件挂载 ==="
echo "$BODY" | grep -q "ob-pdf" && echo "✅ pdfPreview 组件已挂载" || echo "⚠️ 未在首页检测到 pdfPreview登录页可能不加载"
echo "=== 登录页面字段 ==="
echo "$BODY" | grep -oE "(name=\"(username|password|email)\")" | sort -u

View File

@@ -0,0 +1,33 @@
#!/bin/bash
cd /var/www/onebot
JAR=/tmp/onebot_cookies.txt
rm -f "$JAR"
echo "=== 登录页品牌 ==="
BODY=$(curl -s -c "$JAR" http://127.0.0.1:8081/auth/login)
echo "$BODY" | grep -oE "<title>[^<]*</title>" | head -1
echo "$BODY" | grep -qi "OneBot" && echo "✅ 登录页含 OneBot" || echo "⚠️ 登录页无 OneBot 文案(可能是标题/翻译)"
echo "$BODY" | grep -qi "leantime" && echo "⚠️ 登录页仍含 leantime" || echo "✅ 登录页无 leantime 残留"
echo ""
echo "=== 提取 CSRF token ==="
CSRF=$(echo "$BODY" | grep -oE 'name="csrf-token" content="[^"]*"' | sed 's/.*content="//;s/"//')
echo "CSRF: ${CSRF:0:20}..."
echo ""
echo "=== 尝试登录 ==="
LOGIN=$(curl -s -i -b "$JAR" -c "$JAR" -X POST http://127.0.0.1:8081/auth/login \
-d "username=admin@onebot.local" \
-d "password=OneBot@2026" \
-d "_token=$CSRF" \
-H "X-CSRF-TOKEN: $CSRF" \
-H "X-Requested-With: XMLHttpRequest")
echo "$LOGIN" | head -10
echo ""
echo "=== 登录后访问首页 ==="
curl -s -b "$JAR" -o /dev/null -w "dashboard HTTP=%{http_code}\n" http://127.0.0.1:8081/dashboard/show
echo ""
echo "=== 访问 BOM 页面 ==="
curl -s -b "$JAR" -o /dev/null -w "bom/show HTTP=%{http_code}\n" http://127.0.0.1:8081/bom/show

View File

@@ -0,0 +1,34 @@
#!/bin/bash
cd /var/www/onebot
JAR=/tmp/onebot_cookies2.txt
rm -f "$JAR"
# 重新登录拿会话
BODY=$(curl -s -c "$JAR" http://127.0.0.1:8081/auth/login)
CSRF=$(echo "$BODY" | grep -oE 'name="csrf-token" content="[^"]*"' | sed 's/.*content="//;s/"//')
curl -s -b "$JAR" -c "$JAR" -X POST http://127.0.0.1:8081/auth/login \
-d "username=admin@onebot.local" -d "password=OneBot@2026" \
-d "_token=$CSRF" -H "X-CSRF-TOKEN: $CSRF" -o /dev/null
echo "=== BOM 页面内容检查 ==="
BOM=$(curl -s -b "$JAR" http://127.0.0.1:8081/bom/show)
echo "$BOM" | grep -oE "<title>[^<]*</title>" | head -1
echo "$BOM" | grep -qi "BOM" && echo "✅ 页面含 BOM 内容" || echo "❌ 无 BOM"
echo "$BOM" | grep -qi "新建 BOM" && echo "✅ 含新建 BOM 按钮" || echo "⚠️ 无新建 BOM语言可能是 en-US"
echo "$BOM" | grep -qi "New BOM" && echo "✅ 含 New BOM 按钮" || echo "⚠️ 无 New BOM"
echo ""
echo "=== 左侧菜单是否含 BOM 项 ==="
echo "$BOM" | grep -oE 'href="[^"]*bom[^"]*"' | head -5
echo "$BOM" | grep -qi "menu.bom\|BOM" && echo "✅ 菜单区含 BOM" || echo "⚠️ 菜单区未见 BOM 文案"
echo ""
echo "=== dashboard 品牌检查 ==="
DASH=$(curl -s -b "$JAR" http://127.0.0.1:8081/dashboard/show)
echo "$DASH" | grep -oE "<title>[^<]*</title>" | head -1
echo "$DASH" | grep -qi "OneBot" && echo "✅ dashboard 含 OneBot" || echo "⚠️ dashboard 无 OneBot 文案"
echo "$DASH" | grep -qi "leantime" && echo "⚠️ dashboard 仍含 leantime检查上下文" || echo "✅ dashboard 无 leantime"
echo ""
echo "=== 登录页 leantime 残留定位 ==="
curl -s http://127.0.0.1:8081/auth/login | grep -oiE ".{40}leantime.{40}" | head -5

View File

@@ -0,0 +1,17 @@
#!/bin/bash
cd /var/www/onebot
JAR=/tmp/onebot_cookies3.txt
rm -f "$JAR"
BODY=$(curl -s -c "$JAR" http://127.0.0.1:8081/auth/login)
CSRF=$(echo "$BODY" | grep -oE 'name="csrf-token" content="[^"]*"' | sed 's/.*content="//;s/"//')
curl -s -b "$JAR" -c "$JAR" -X POST http://127.0.0.1:8081/auth/login \
-d "username=admin@onebot.local" -d "password=OneBot@2026" \
-d "_token=$CSRF" -H "X-CSRF-TOKEN: $CSRF" -o /dev/null
echo "=== dashboard 页面 leantime 出现上下文 ==="
curl -s -b "$JAR" http://127.0.0.1:8081/dashboard/show | grep -oiE ".{30}leantime.{30}" | head -10
echo ""
echo "=== 可见文本(去标签)是否含 Leantime ==="
curl -s -b "$JAR" http://127.0.0.1:8081/dashboard/show | sed 's/<[^>]*>//g' | grep -i "leantime" | head -5 || echo "✅ 可见文本无 Leantime"

View File

@@ -0,0 +1,11 @@
#!/bin/bash
echo "=== 本机网卡 IP ==="
ip -4 addr show 2>/dev/null | grep -E "inet " | awk '{print $2, $NF}'
echo ""
echo "=== 192.168.1.44 是否可达(本机自测)==="
curl -s -o /dev/null -w "HTTP=%{http_code}\n" --connect-timeout 5 http://192.168.1.44:8081/ || echo "8081 从本机 192.168.1.44 访问失败"
echo ""
echo "=== nginx 监听 ==="
ss -tlnp 2>/dev/null | grep -E ":8081|:80"

View File

@@ -0,0 +1,17 @@
#!/bin/bash
set -e
APP_DIR=/var/www/onebot
echo "=== 修改站点 URL 为内网 IP ==="
sed -i 's|http://119.29.53.45:8081|http://192.168.1.44:8081|g' "$APP_DIR/config/.env"
grep -E "APP_URL|LEAN_APP_URL" "$APP_DIR/config/.env"
echo ""
echo "=== 清缓存 ==="
rm -rf "$APP_DIR/storage/framework/cache/"* 2>/dev/null
rm -f "$APP_DIR/bootstrap/cache/"*.php 2>/dev/null
echo "缓存已清"
echo ""
echo "=== 验证重定向地址 ==="
curl -s -i http://192.168.1.44:8081/ | grep -iE "^HTTP|^Location" | head -3

View File

@@ -0,0 +1,15 @@
#!/bin/bash
echo "=== ufw 状态 ==="
sudo ufw status 2>/dev/null || echo "ufw 未启用/未安装"
echo ""
echo "=== ufw 8081 放行 ==="
sudo ufw allow 8081/tcp 2>/dev/null && echo "已放行 8081" || true
echo ""
echo "=== 最终验证:内网 IP 访问 ==="
curl -s -o /dev/null -w "192.168.1.44:8081 HTTP=%{http_code}\n" --connect-timeout 5 http://192.168.1.44:8081/
echo ""
echo "=== 登录页品牌最终确认 ==="
curl -s http://192.168.1.44:8081/auth/login | grep -oE "<title>[^<]*</title>" | head -1

View File

@@ -0,0 +1,12 @@
#!/bin/bash
cd /var/www/onebot
echo "=== 生成新密码哈希 ==="
HASH=$(php -r "echo password_hash('wrg521113..', PASSWORD_DEFAULT);")
echo "哈希: ${HASH:0:20}..."
echo "=== 更新管理员账号 ==="
mysql -uonebot -p'OneBot_Db_7xKpQ9z' -h127.0.0.1 onebot -e "UPDATE zp_user SET username='ruiguo_wang@163.com', password='${HASH}', status='a', forcePwReset=0 WHERE id=1;" 2>/dev/null
echo "=== 验证 ==="
mysql -uonebot -p'OneBot_Db_7xKpQ9z' -h127.0.0.1 onebot -e "SELECT id, username, status, role FROM zp_user WHERE id=1;" 2>/dev/null

View File

@@ -0,0 +1,23 @@
#!/bin/bash
cd /var/www/onebot
JAR=/tmp/onebot_cookies_final.txt
rm -f "$JAR"
BODY=$(curl -s -c "$JAR" http://127.0.0.1:8081/auth/login)
CSRF=$(echo "$BODY" | grep -oE 'name="csrf-token" content="[^"]*"' | sed 's/.*content="//;s/"//')
echo "=== 用新账号登录 ==="
LOGIN=$(curl -s -i -b "$JAR" -c "$JAR" -X POST http://127.0.0.1:8081/auth/login \
-d "username=ruiguo_wang@163.com" \
-d "password=wrg521113.." \
-d "_token=$CSRF" \
-H "X-CSRF-TOKEN: $CSRF")
echo "$LOGIN" | grep -iE "^HTTP|^Location" | head -3
echo ""
echo "=== 登录后 BOM 页面 ==="
curl -s -b "$JAR" -o /dev/null -w "bom/show HTTP=%{http_code}\n" http://127.0.0.1:8081/bom/show
echo ""
echo "=== dashboard 标题 ==="
curl -s -b "$JAR" http://127.0.0.1:8081/dashboard/show | grep -oE "<title>[^<]*</title>" | head -1

View File

@@ -0,0 +1,85 @@
#!/bin/bash
set -u
cd /var/www/onebot
echo "=== PHP 语法检查 ==="
for f in app/Domain/Bom/Services/Teable.php app/Domain/Bom/Services/Bom.php app/Domain/Bom/Controllers/Api.php app/Domain/Bom/routes.php; do
php -l "$f" 2>&1
done
echo ""
echo "=== 清缓存 + 重载 fpm ==="
rm -rf storage/framework/views/* storage/framework/cache/* bootstrap/cache/*.php 2>/dev/null
echo "wrg521113.." | sudo -S systemctl reload php8.3-fpm >/dev/null 2>&1
echo "done"
echo ""
echo "=== 登录 ==="
JAR=/tmp/onebot_paste_cookies.txt
rm -f "$JAR"
BODY=$(curl -s -c "$JAR" http://127.0.0.1:8081/auth/login)
CSRF=$(echo "$BODY" | grep -oE 'name="csrf-token" content="[^"]*"' | sed 's/.*content="//;s/"//')
curl -s -b "$JAR" -c "$JAR" -X POST http://127.0.0.1:8081/auth/login \
-d "username=ruiguo_wang@163.com" \
-d "password=wrg521113.." \
-d "_token=$CSRF" \
-H "X-CSRF-TOKEN: $CSRF" -o /dev/null -w "login HTTP=%{http_code}\n"
echo ""
echo "=== 粘贴解析接口测试 ==="
cat > /tmp/paste_sample.txt <<'PASTEEOF'
# Table: UAGP350B
## API Operations
### 1. Read Records (GET)
curl -X GET "https://table.universal-onebot.com/api/table/tblGHwQc8eAGFZcZ71A/record?fieldKeyType=name" \
-H "Authorization: Bearer teable_accaWtlglDjnwkieJXE_KaafZue9411p5G+QmP0r4zRsC6GDpUTuRDVfh2f8eco="
PASTEEOF
php -r '$t=file_get_contents("/tmp/paste_sample.txt"); echo json_encode(["text"=>$t], JSON_UNESCAPED_SLASHES);' > /tmp/paste_payload.json
curl -s -b "$JAR" -X POST http://127.0.0.1:8081/bom/api/teable/parse \
-H "Content-Type: application/json" \
-H "X-CSRF-TOKEN: $CSRF" \
--data-binary @/tmp/paste_payload.json
echo ""
echo ""
echo "=== Teable API 连通性(远程拉 1 条记录) ==="
curl -s -m 20 "https://table.universal-onebot.com/api/table/tblGHwQc8eAGFZcZ71A/record?fieldKeyType=name&take=1" \
-H "Authorization: Bearer teable_accaWtlglDjnwkieJXE_KaafZue9411p5G+QmP0r4zRsC6GDpUTuRDVfh2f8eco=" | head -c 400
echo ""
echo ""
echo "=== 端到端:新建测试 BOM -> 粘贴导入 -> 验证 -> 删除 ==="
PROJECT_ID=$(mysql -uonebot -p'OneBot_Db_7xKpQ9z' -h127.0.0.1 onebot -N -e "SELECT id FROM zp_projects LIMIT 1;" 2>/dev/null)
echo "projectId=$PROJECT_ID"
CREATE_RESP=$(curl -s -b "$JAR" -X POST http://127.0.0.1:8081/bom/api \
-H "Content-Type: application/json" \
-H "X-CSRF-TOKEN: $CSRF" \
-d "{\"bomNo\":\"TEST-PASTE-1\",\"productName\":\"UAGP350B paste test\",\"projectId\":$PROJECT_ID}")
echo "create: $CREATE_RESP"
BOM_ID=$(echo "$CREATE_RESP" | grep -oE '"id":[0-9]+' | head -1 | sed 's/"id"://')
echo "bomId=$BOM_ID"
if [ -n "$BOM_ID" ] && [ "$BOM_ID" -gt 0 ] 2>/dev/null; then
echo ""
echo "--- 粘贴导入(真实拉取 Teable 数据) ---"
curl -s -b "$JAR" -X POST "http://127.0.0.1:8081/bom/api/$BOM_ID/import/teable-paste" \
-H "Content-Type: application/json" \
-H "X-CSRF-TOKEN: $CSRF" \
--data-binary @/tmp/paste_payload.json
echo ""
echo "--- 验证行数 ---"
mysql -uonebot -p'OneBot_Db_7xKpQ9z' -h127.0.0.1 onebot -N -e "SELECT COUNT(*) FROM zp_bom_item WHERE bomId=$BOM_ID;" 2>/dev/null
echo "--- 清理测试 BOM ---"
curl -s -b "$JAR" -X DELETE "http://127.0.0.1:8081/bom/api/$BOM_ID" \
-H "X-CSRF-TOKEN: $CSRF" -w "delete HTTP=%{http_code}\n"
fi
echo ""
echo "=== 全部完成 ==="

View File

@@ -0,0 +1,33 @@
#!/bin/bash
set -u
cd /var/www/onebot
echo "=== 1. BOM 列表 ==="
mysql -uonebot -p'OneBot_Db_7xKpQ9z' -h127.0.0.1 onebot -e "SELECT id, bomNo, productName, projectId FROM zp_bom ORDER BY id DESC LIMIT 5;" 2>/dev/null
echo ""
echo "=== 2. 每个 BOM 的行数 ==="
mysql -uonebot -p'OneBot_Db_7xKpQ9z' -h127.0.0.1 onebot -e "SELECT bomId, COUNT(*) cnt FROM zp_bom_item GROUP BY bomId;" 2>/dev/null
echo ""
echo "=== 3. 动态列 ==="
mysql -uonebot -p'OneBot_Db_7xKpQ9z' -h127.0.0.1 onebot -e "SELECT id, bomId, colKey, label FROM zp_bom_column ORDER BY bomId, sortOrder LIMIT 40;" 2>/dev/null
echo ""
echo "=== 4. 最新 BOM 的一行明细extra 原始 JSON ==="
LATEST=$(mysql -uonebot -p'OneBot_Db_7xKpQ9z' -h127.0.0.1 onebot -N -e "SELECT MAX(bomId) FROM zp_bom_item;" 2>/dev/null)
echo "latest bomId with items = $LATEST"
mysql -uonebot -p'OneBot_Db_7xKpQ9z' -h127.0.0.1 onebot -e "SELECT id, seq, partNo, partName, extra FROM zp_bom_item WHERE bomId=$LATEST LIMIT 2\G" 2>/dev/null | head -60
echo ""
echo "=== 5. Teable field 接口原始返回(前 3 个字段对象) ==="
curl -s -m 20 "https://table.universal-onebot.com/api/table/tblGHwQc8eAGFZcZ71A/field" \
-H "Authorization: Bearer teable_accaWtlglDjnwkieJXE_KaafZue9411p5G+QmP0r4zRsC6GDpUTuRDVfh2f8eco=" \
| php -r '$d=json_decode(stream_get_contents(STDIN),true); echo json_encode(array_slice($d,0,3), JSON_UNESCAPED_UNICODE|JSON_PRETTY_PRINT);'
echo ""
echo "=== 6. Teable 记录原始返回(第 1 条) ==="
curl -s -m 20 "https://table.universal-onebot.com/api/table/tblGHwQc8eAGFZcZ71A/record?fieldKeyType=name&take=1" \
-H "Authorization: Bearer teable_accaWtlglDjnwkieJXE_KaafZue9411p5G+QmP0r4zRsC6GDpUTuRDVfh2f8eco=" \
| head -c 1200
echo ""

View File

@@ -0,0 +1,39 @@
#!/bin/bash
set -u
cd /var/www/onebot
echo "=== 1. zp_bom_column 全部行(正确列名) ==="
mysql -uonebot -p'OneBot_Db_7xKpQ9z' -h127.0.0.1 onebot -e "SELECT id, bomId, \`key\`, label, sortOrder FROM zp_bom_column ORDER BY bomId, sortOrder;" 2>&1 | grep -v "Warning"
echo ""
echo "=== 2. zp_bom_source 全部行 ==="
mysql -uonebot -p'OneBot_Db_7xKpQ9z' -h127.0.0.1 onebot -e "SELECT id, bomId, name, tableId FROM zp_bom_source;" 2>&1 | grep -v "Warning"
echo ""
echo "=== 3. 远程文件 md5 ==="
md5sum app/Domain/Bom/Services/Bom.php app/Domain/Bom/Services/Teable.php app/Domain/Bom/Templates/detail.blade.php
echo ""
echo "=== 4. 远程 Bom.php importSource 字段循环395-425 行) ==="
sed -n '395,425p' app/Domain/Bom/Services/Bom.php
echo ""
echo "=== 5. BOM 3 详情页渲染检查 ==="
JAR=/tmp/ob38.txt
rm -f $JAR
BODY=$(curl -s -c $JAR http://127.0.0.1:8081/auth/login)
CSRF=$(echo "$BODY" | grep -oE 'name="csrf-token" content="[^"]*"' | sed 's/.*content="//;s/"//')
curl -s -b $JAR -c $JAR -X POST http://127.0.0.1:8081/auth/login \
-d "username=ruiguo_wang@163.com" -d "password=wrg521113.." -d "_token=$CSRF" \
-H "X-CSRF-TOKEN: $CSRF" -o /dev/null
curl -s -b $JAR "http://127.0.0.1:8081/bom/show/3" -o /tmp/ob38_detail.html -w "detail HTTP=%{http_code}\n"
echo "--- 页面 th 数量及 data-key ---"
grep -oE '<th data-key="[^"]*"' /tmp/ob38_detail.html | sed 's/<th data-key="//;s/"//' | tr '\n' ' '
echo ""
echo "--- 页面里的动态列标签th 里的中文) ---"
grep -oE '<th[^>]*>[^<]*' /tmp/ob38_detail.html | head -40
echo ""
echo "=== 6. BOM 3 明细接口 JSON 前 300 字符 ==="
curl -s -b $JAR "http://127.0.0.1:8081/bom/api/3" | head -c 300
echo ""

View File

@@ -0,0 +1,40 @@
#!/bin/bash
cd /var/www/onebot
echo "=== BOM1 前2行 extra ==="
mysql -uonebot -p'OneBot_Db_7xKpQ9z' -h127.0.0.1 onebot -e "SELECT id, seq, partNo, LEFT(extra,200) e FROM zp_bom_item WHERE bomId=1 ORDER BY id LIMIT 2\G" 2>/dev/null
echo ""
echo "=== zp_bom_item 表结构 ==="
mysql -uonebot -p'OneBot_Db_7xKpQ9z' -h127.0.0.1 onebot -e "SHOW CREATE TABLE zp_bom_item\G" 2>/dev/null | grep -E "CREATE|\`seq\`|\`partNo\`|\`extra\`"
echo ""
echo "=== 登录并拉取两个 BOM 的详情页 ==="
JAR=/tmp/ob38b.txt
rm -f $JAR
BODY=$(curl -s -c $JAR http://127.0.0.1:8081/auth/login)
CSRF=$(echo "$BODY" | grep -oE 'name="csrf-token" content="[^"]*"' | sed 's/.*content="//;s/"//')
curl -s -b $JAR -c $JAR -X POST http://127.0.0.1:8081/auth/login \
-d "username=ruiguo_wang@163.com" -d "password=wrg521113.." -d "_token=$CSRF" \
-H "X-CSRF-TOKEN: $CSRF" -o /dev/null
curl -s -b $JAR http://127.0.0.1:8081/bom/show/1 -o /tmp/ob38b_1.html
curl -s -b $JAR http://127.0.0.1:8081/bom/show/3 -o /tmp/ob38b_3.html
cat > /tmp/check_row.php <<'PHPEOF'
<?php
foreach ([1, 3] as $bid) {
$h = file_get_contents("/tmp/ob38b_$bid.html");
echo "=== BOM $bid 页面渲染的第一行非空单元格 ===\n";
if (! preg_match('/<tbody>(.*?)<\/tbody>/s', $h, $m)) { echo "no tbody\n"; continue; }
if (! preg_match_all('/<tr data-item-id="(\d+)"(.*?)<\/tr>/s', $m[1], $rows)) { echo "no rows\n"; continue; }
$n = 0;
foreach ($rows[2] as $i => $body) {
preg_match_all('/data-key="([^"]*)"[^>]*value="([^"]*)"/', $body, $mm, PREG_SET_ORDER);
$filled = [];
foreach ($mm as $x) { if ($x[2] !== '') { $filled[] = $x[1].'='.mb_substr($x[2], 0, 24); } }
echo "row ".$rows[1][$i].": ".implode(' | ', $filled)."\n";
if (++$n >= 2) break;
}
}
PHPEOF
php /tmp/check_row.php

View File

@@ -0,0 +1,37 @@
#!/bin/bash
set -u
cd /var/www/onebot
echo "=== 1. PHP 语法检查 ==="
for f in \
app/Domain/Bom/Services/Teable.php \
app/Domain/Bom/Services/Bom.php \
app/Domain/Bom/Services/Excel.php \
app/Domain/Bom/Controllers/Api.php \
app/Domain/Bom/routes.php \
app/Domain/Bom/Repositories/Bom.php \
app/Domain/Install/Repositories/Install.php \
app/Domain/Install/Services/SchemaBuilder.php \
app/Core/Configuration/AppSettings.php ; do
php -l "$f" 2>&1 | grep -v "No syntax errors" || true
done
echo "语法检查完成"
echo ""
echo "=== 2. 清缓存 ==="
rm -rf storage/framework/views/* storage/framework/cache/* bootstrap/cache/*.php 2>/dev/null
echo "done"
echo ""
echo "=== 3. 执行数据库迁移updateDB ==="
php public/index.php db:update 2>&1 | tail -30 || echo "db:update 命令可能不存在,尝试其他方式"
echo ""
echo "=== 4. 检查 db-version 和 hiddenColumns 列 ==="
mysql -uonebot -p'OneBot_Db_7xKpQ9z' -h127.0.0.1 onebot -e "SELECT \`key\`,\`value\` FROM zp_settings WHERE \`key\`='db-version';" 2>/dev/null
mysql -uonebot -p'OneBot_Db_7xKpQ9z' -h127.0.0.1 onebot -e "SHOW COLUMNS FROM zp_bom LIKE 'hiddenColumns';" 2>/dev/null
echo ""
echo "=== 5. 重载 fpm ==="
echo "wrg521113.." | sudo -S systemctl reload php8.3-fpm >/dev/null 2>&1
echo "done"

View File

@@ -0,0 +1,57 @@
#!/bin/bash
set -u
cd /var/www/onebot
JAR=/tmp/ob40.txt
rm -f $JAR
echo "=== 登录 ==="
BODY=$(curl -s -c $JAR http://127.0.0.1:8081/auth/login)
CSRF=$(echo "$BODY" | grep -oE 'name="csrf-token" content="[^"]*"' | sed 's/.*content="//;s/"//')
curl -s -b $JAR -c $JAR -X POST http://127.0.0.1:8081/auth/login \
-d "username=ruiguo_wang@163.com" -d "password=wrg521113.." -d "_token=$CSRF" \
-H "X-CSRF-TOKEN: $CSRF" -o /dev/null -w "login HTTP=%{http_code}\n"
echo ""
echo "=== 详情页 HTTP 状态 ==="
curl -s -b $JAR -o /tmp/ob40_detail.html -w "bom/show/3 HTTP=%{http_code}\n" http://127.0.0.1:8081/bom/show/3
echo ""
echo "=== 页面关键元素检查 ==="
echo -n "横向滚动容器: "; grep -c 'overflow-x:auto' /tmp/ob40_detail.html
echo -n "列设置按钮: "; grep -c 'btnColumnSettings' /tmp/ob40_detail.html
echo -n "Teable下拉: "; grep -c 'btnTeablePaste' /tmp/ob40_detail.html
echo -n "Excel模板导出: "; grep -c 'btnExcelTemplate' /tmp/ob40_detail.html
echo -n "Excel导出xlsx: "; grep -c 'btnExcelExport' /tmp/ob40_detail.html
echo -n "粘贴手动补齐输入框(pasteBaseUrl): "; grep -c 'pasteBaseUrl' /tmp/ob40_detail.html
echo -n "缩略图列显示友好(非JSON, 看是否有image.png): "; grep -c 'image.png' /tmp/ob40_detail.html
echo ""
echo "=== 列显隐 API 测试(隐藏 seq + 项目) ==="
curl -s -b $JAR -X POST http://127.0.0.1:8081/bom/api/3/column-visibility \
-H "Content-Type: application/json" \
-H "X-CSRF-TOKEN: $CSRF" \
-d '{"keys":["seq","项目"]}' -w "\nvisibility HTTP=%{http_code}\n"
echo ""
echo "=== 验证隐藏列已持久化 ==="
mysql -uonebot -p'OneBot_Db_7xKpQ9z' -h127.0.0.1 onebot -e "SELECT hiddenColumns FROM zp_bom WHERE id=3;" 2>/dev/null
echo ""
echo "=== 导出 xlsx ==="
curl -s -b $JAR -o /tmp/ob40_export.xlsx -w "export xlsx HTTP=%{http_code} size=%{size_download}\n" "http://127.0.0.1:8081/bom/api/3/export?format=xlsx"
echo -n "xlsx 文件头(应为 PK): "; head -c 2 /tmp/ob40_export.xlsx; echo ""
echo ""
echo "=== 导出模板 ==="
curl -s -b $JAR -o /tmp/ob40_tpl.xlsx -w "export-template HTTP=%{http_code} size=%{size_download}\n" "http://127.0.0.1:8081/bom/api/3/export-template"
echo ""
echo "=== 恢复列显隐(清空) ==="
curl -s -b $JAR -X POST http://127.0.0.1:8081/bom/api/3/column-visibility \
-H "Content-Type: application/json" \
-H "X-CSRF-TOKEN: $CSRF" \
-d '{"keys":[]}' -w "reset HTTP=%{http_code}\n"
echo ""
echo "=== 全部完成 ==="

View File

@@ -0,0 +1,26 @@
#!/bin/bash
cd /var/www/onebot
echo "=== 1. 取一个附件字段原始值 ==="
curl -s -m 20 "https://table.universal-onebot.com/api/table/tblGHwQc8eAGFZcZ71A/record?fieldKeyType=name&take=1" \
-H "Authorization: Bearer teable_accaWtlglDjnwkieJXE_KaafZue9411p5G+QmP0r4zRsC6GDpUTuRDVfh2f8eco=" \
| php -r '$d=json_decode(stream_get_contents(STDIN),true); $f=$d["records"][0]["fields"]["缩略图"][0] ?? []; echo json_encode($f, JSON_UNESCAPED_UNICODE|JSON_PRETTY_PRINT);'
echo ""
echo "=== 2. 提取 smThumbnailUrl 并测试直连(不带 Bearer ==="
URL=$(curl -s -m 20 "https://table.universal-onebot.com/api/table/tblGHwQc8eAGFZcZ71A/record?fieldKeyType=name&take=1" \
-H "Authorization: Bearer teable_accaWtlglDjnwkieJXE_KaafZue9411p5G+QmP0r4zRsC6GDpUTuRDVfh2f8eco=" \
| php -r '$d=json_decode(stream_get_contents(STDIN),true); $f=$d["records"][0]["fields"]["缩略图"][0] ?? []; echo $f["smThumbnailUrl"] ?? "";')
echo "smThumbnailUrl=$URL"
if [ -n "$URL" ]; then
FULL="https://table.universal-onebot.com$URL"
echo "--- 直连(无 Bearer---"
curl -s -m 15 -o /dev/null -w "HTTP=%{http_code} type=%{content_type} size=%{size_download}\n" "$FULL"
echo "--- 带 Bearer ---"
curl -s -m 15 -o /dev/null -w "HTTP=%{http_code} type=%{content_type} size=%{size_download}\n" -H "Authorization: Bearer teable_accaWtlglDjnwkieJXE_KaafZue9411p5G+QmP0r4zRsC6GDpUTuRDVfh2f8eco=" "$FULL"
fi
echo ""
echo "=== 3. CDN 可达性测试Handsontable ==="
curl -s -m 10 -o /dev/null -w "jsdelivr HTTP=%{http_code}\n" "https://cdn.jsdelivr.net/npm/handsontable/dist/handsontable.full.min.js" 2>&1 || echo "jsdelivr 不可达"
curl -s -m 10 -o /dev/null -w "unpkg HTTP=%{http_code}\n" "https://unpkg.com/handsontable/dist/handsontable.full.min.js" 2>&1 || echo "unpkg 不可达"

View File

@@ -0,0 +1,20 @@
#!/bin/bash
set -u
cd /var/www/onebot
DIR=public/dist/handsontable
mkdir -p "$DIR"
BASE="https://cdn.jsdelivr.net/npm/handsontable@14.6.2/dist"
echo "=== 下载 Handsontable 14.6.2 ==="
curl -s -m 60 -o "$DIR/handsontable.full.min.js" "$BASE/handsontable.full.min.js" && echo "js ok $(wc -c < "$DIR/handsontable.full.min.js") bytes"
curl -s -m 60 -o "$DIR/handsontable.full.min.css" "$BASE/handsontable.full.min.css" && echo "css ok $(wc -c < "$DIR/handsontable.full.min.css") bytes"
echo ""
echo "=== 校验js 应有 Handsontable 关键字css 应有 .handsontable ==="
head -c 60 "$DIR/handsontable.full.min.js"; echo ""
grep -c "handsontable" "$DIR/handsontable.full.min.css" 2>/dev/null || echo "css 校验失败"
echo ""
echo "=== 通过 web 验证可访问 ==="
curl -s -o /dev/null -w "js HTTP=%{http_code}\n" "http://127.0.0.1:8081/dist/handsontable/handsontable.full.min.js"
curl -s -o /dev/null -w "css HTTP=%{http_code}\n" "http://127.0.0.1:8081/dist/handsontable/handsontable.full.min.css"

View File

@@ -0,0 +1,49 @@
#!/bin/bash
set -u
cd /var/www/onebot
JAR=/tmp/ob43.txt
rm -f $JAR
echo "=== 登录 ==="
BODY=$(curl -s -c $JAR http://127.0.0.1:8081/auth/login)
CSRF=$(echo "$BODY" | grep -oE 'name="csrf-token" content="[^"]*"' | sed 's/.*content="//;s/"//')
curl -s -b $JAR -c $JAR -X POST http://127.0.0.1:8081/auth/login \
-d "username=ruiguo_wang@163.com" -d "password=wrg521113.." -d "_token=$CSRF" \
-H "X-CSRF-TOKEN: $CSRF" -o /dev/null -w "login HTTP=%{http_code}\n"
echo ""
echo "=== 详情页渲染 ==="
curl -s -b $JAR -o /tmp/ob43.html -w "bom/show/3 HTTP=%{http_code}\n" http://127.0.0.1:8081/bom/show/3
echo ""
echo "=== 关键元素检查 ==="
echo -n "Handsontable js 引入: "; grep -c 'handsontable.full.min.js' /tmp/ob43.html
echo -n "Handsontable css 引入: "; grep -c 'handsontable.full.min.css' /tmp/ob43.html
echo -n "bomHot 容器: "; grep -c 'id="bomHot"' /tmp/ob43.html
echo -n "imageRenderer: "; grep -c 'imageRenderer' /tmp/ob43.html
echo -n "fileRenderer: "; grep -c 'fileRenderer' /tmp/ob43.html
echo -n "new Handsontable: "; grep -c 'new Handsontable' /tmp/ob43.html
echo -n "附件 URL (attachments/read): "; grep -c 'attachments/read' /tmp/ob43.html
echo -n "图片缩略图 img 标签: "; grep -c '<img' /tmp/ob43.html
echo ""
echo "=== 明细 API 返回的附件字段(应为 JSON 数组含 url ==="
curl -s -b $JAR "http://127.0.0.1:8081/bom/api/3" | php -r '
$d=json_decode(stream_get_contents(STDIN),true);
$cols=$d["data"]["columns"] ?? [];
$img=$d["data"]["imageColumns"] ?? [];
$file=$d["data"]["fileColumns"] ?? [];
echo "imageColumns: ".json_encode($img,JSON_UNESCAPED_UNICODE)."\n";
echo "fileColumns: ".json_encode($file,JSON_UNESCAPED_UNICODE)."\n";
$it=$d["data"]["items"][0] ?? [];
foreach($img as $k){ echo "首行 $k = ".substr((string)($it[$k]??""),0,120)."\n"; }
'
echo ""
echo "=== Handsontable 静态资源可访问 ==="
curl -s -o /dev/null -w "js HTTP=%{http_code}\n" http://127.0.0.1:8081/dist/handsontable/handsontable.full.min.js
curl -s -o /dev/null -w "css HTTP=%{http_code}\n" http://127.0.0.1:8081/dist/handsontable/handsontable.full.min.css
echo ""
echo "=== 完成 ==="

View File

@@ -0,0 +1,22 @@
#!/bin/bash
set -u
cd /var/www/onebot
JAR=/tmp/ob44.txt
rm -f $JAR
BODY=$(curl -s -c $JAR http://127.0.0.1:8081/auth/login)
CSRF=$(echo "$BODY" | grep -oE 'name="csrf-token" content="[^"]*"' | sed 's/.*content="//;s/"//')
curl -s -b $JAR -c $JAR -X POST http://127.0.0.1:8081/auth/login \
-d "username=ruiguo_wang@163.com" -d "password=wrg521113.." -d "_token=$CSRF" \
-H "X-CSRF-TOKEN: $CSRF" -o /dev/null
curl -s -b $JAR -o /tmp/ob44.html -w "detail HTTP=%{http_code}\n" http://127.0.0.1:8081/bom/show/3
echo "=== 关键改动检查 ==="
echo -n "filters 筛选插件: "; grep -c 'filters: true' /tmp/ob44.html
echo -n "dropdownMenu 列头下拉: "; grep -c 'dropdownMenu: true' /tmp/ob44.html
echo -n "search 搜索插件: "; grep -c 'search: true' /tmp/ob44.html
echo -n "stretchH 铺满: "; grep -c "stretchH: 'all'" /tmp/ob44.html
echo -n "calcHeight 动态高度: "; grep -c 'function calcHeight' /tmp/ob44.html
echo -n "window resize 重算: "; grep -c "'resize'" /tmp/ob44.html
echo -n "数据源管理按钮: "; grep -c 'btnSourceManager' /tmp/ob44.html
echo -n "数据源管理弹窗: "; grep -c 'sourceManagerModal' /tmp/ob44.html
echo -n "顶部源配置面板已移除(应为0): "; grep -c 'Teable 数据源</div>' /tmp/ob44.html

View File

@@ -0,0 +1,59 @@
#!/bin/bash
set -u
cd /var/www/onebot
JAR=/tmp/ob45.txt
rm -f $JAR
BODY=$(curl -s -c $JAR http://127.0.0.1:8081/auth/login)
CSRF=$(echo "$BODY" | grep -oE 'name="csrf-token" content="[^"]*"' | sed 's/.*content="//;s/"//')
curl -s -b $JAR -c $JAR -X POST http://127.0.0.1:8081/auth/login \
-d "username=ruiguo_wang@163.com" -d "password=wrg521113.." -d "_token=$CSRF" \
-H "X-CSRF-TOKEN: $CSRF" -o /dev/null -w "login HTTP=%{http_code}\n"
MASTER_ID=$(mysql -uonebot -p'OneBot_Db_7xKpQ9z' -h127.0.0.1 onebot -N -e "SELECT id FROM zp_bom WHERE bomNo='1' ORDER BY id DESC LIMIT 1;" 2>/dev/null)
echo "masterId=$MASTER_ID"
echo ""
echo "=== 1. 项目关联主数据 ==="
LINK=$(curl -s -b $JAR -X POST http://127.0.0.1:8081/bom/api/ref/link \
-H "Content-Type: application/json" -H "X-CSRF-TOKEN: $CSRF" \
-d "{\"projectId\":1,\"masterId\":$MASTER_ID}")
echo "$LINK"
REF_ID=$(echo "$LINK" | grep -oE '"refId":[0-9]+' | head -1 | sed 's/"refId"://')
echo "refId=$REF_ID"
echo ""
echo "=== 2. 项目级追加列(采购数量) ==="
COL=$(curl -s -b $JAR -X POST "http://127.0.0.1:8081/bom/api/ref/$REF_ID/column" \
-H "Content-Type: application/json" -H "X-CSRF-TOKEN: $CSRF" \
-d '{"key":"采购数量","label":"采购数量"}')
echo "$COL"
COL_ID=$(echo "$COL" | grep -oE '"columnId":[0-9]+' | head -1 | sed 's/"columnId"://')
echo "columnId=$COL_ID"
echo ""
echo "=== 3. 项目级存值itemId 用第一条明细) ==="
ITEM_ID=$(mysql -uonebot -p'OneBot_Db_7xKpQ9z' -h127.0.0.1 onebot -N -e "SELECT id FROM zp_bom_item WHERE bomId=$MASTER_ID ORDER BY id LIMIT 1;" 2>/dev/null)
echo "itemId=$ITEM_ID"
curl -s -b $JAR -X POST "http://127.0.0.1:8081/bom/api/ref/$REF_ID/value" \
-H "Content-Type: application/json" -H "X-CSRF-TOKEN: $CSRF" \
-d "{\"columnId\":$COL_ID,\"itemId\":$ITEM_ID,\"value\":\"999\"}" -w " save HTTP=%{http_code}\n"
echo ""
echo "=== 4. 验证:项目级值已写入引用层 ==="
mysql -uonebot -p'OneBot_Db_7xKpQ9z' -h127.0.0.1 onebot -e "SELECT * FROM zp_master_ref_value WHERE refId=$REF_ID;" 2>/dev/null
echo ""
echo "=== 5. 验证全局主数据行未被修改extra 不含采购数量) ==="
mysql -uonebot -p'OneBot_Db_7xKpQ9z' -h127.0.0.1 onebot -e "SELECT id, extra LIKE '%采购数量%' AS has_col FROM zp_bom_item WHERE id=$ITEM_ID;" 2>/dev/null
echo ""
echo "=== 6. 引用详情接口 ==="
curl -s -b $JAR "http://127.0.0.1:8081/bom/api/ref/$REF_ID" | head -c 400
echo ""
echo ""
echo "=== 7. 清理测试数据 ==="
curl -s -b $JAR -X DELETE "http://127.0.0.1:8081/bom/api/ref/$REF_ID" \
-H "X-CSRF-TOKEN: $CSRF" -w "unlink HTTP=%{http_code}\n"
echo ""
echo "=== 完成 ==="

View File

@@ -0,0 +1,41 @@
#!/bin/bash
set -u
cd /var/www/onebot
JAR=/tmp/ob48.txt
rm -f $JAR
BODY=$(curl -s -c $JAR http://127.0.0.1:8081/auth/login)
CSRF=$(echo "$BODY" | grep -oE 'name="csrf-token" content="[^"]*"' | sed 's/.*content="//;s/"//')
curl -s -b $JAR -c $JAR -X POST http://127.0.0.1:8081/auth/login \
-d "username=ruiguo_wang@163.com" -d "password=wrg521113.." -d "_token=$CSRF" \
-H "X-CSRF-TOKEN: $CSRF" -o /dev/null -w "login HTTP=%{http_code}\n"
echo ""
echo "=== 1. 引用列表页 /bom/refs ==="
curl -s -b $JAR -o /tmp/ob48_refs.html -w "refs HTTP=%{http_code}\n" http://127.0.0.1:8081/bom/refs
grep -oE "项目引用的主数据|尚未关联" /tmp/ob48_refs.html | head -1
echo ""
echo "=== 2. 先关联一个主数据 ==="
MASTER_ID=$(mysql -uonebot -p'OneBot_Db_7xKpQ9z' -h127.0.0.1 onebot -N -e "SELECT id FROM zp_bom WHERE bomNo='1' ORDER BY id DESC LIMIT 1;" 2>/dev/null)
echo "masterId=$MASTER_ID"
LINK=$(curl -s -b $JAR -X POST http://127.0.0.1:8081/bom/api/ref/link \
-H "Content-Type: application/json" -H "X-CSRF-TOKEN: $CSRF" \
-d "{\"projectId\":1,\"masterId\":$MASTER_ID}")
echo "$LINK"
REF_ID=$(echo "$LINK" | grep -oE '"refId":[0-9]+' | head -1 | sed 's/"refId"://')
echo "refId=$REF_ID"
echo ""
echo "=== 3. 引用详情页 /bom/refs/$REF_ID ==="
curl -s -b $JAR -o /tmp/ob48_refdetail.html -w "refDetail HTTP=%{http_code}\n" "http://127.0.0.1:8081/bom/refs/$REF_ID"
echo -n "标题含'引用视图': "; grep -c "引用视图" /tmp/ob48_refdetail.html
echo -n "增加项目列按钮: "; grep -c "btnAddRefColumn" /tmp/ob48_refdetail.html
echo -n "refVal 输入框: "; grep -c "refVal" /tmp/ob48_refdetail.html
echo ""
echo "=== 4. 清理测试引用 ==="
curl -s -b $JAR -X DELETE "http://127.0.0.1:8081/bom/api/ref/$REF_ID" \
-H "X-CSRF-TOKEN: $CSRF" -w "unlink HTTP=%{http_code}\n"
echo ""
echo "=== 完成 ==="

View File

@@ -0,0 +1,32 @@
#!/bin/bash
set -u
cd /var/www/onebot
JAR=/tmp/ob51.txt
rm -f $JAR
BODY=$(curl -s -c $JAR http://127.0.0.1:8081/auth/login)
CSRF=$(echo "$BODY" | grep -oE 'name="csrf-token" content="[^"]*"' | sed 's/.*content="//;s/"//')
curl -s -b $JAR -c $JAR -X POST http://127.0.0.1:8081/auth/login \
-d "username=ruiguo_wang@163.com" -d "password=wrg521113.." -d "_token=$CSRF" \
-H "X-CSRF-TOKEN: $CSRF" -o /dev/null -w "login HTTP=%{http_code}\n"
echo ""
echo "=== 1. 主数据列表(全局,与项目无关) ==="
curl -s -b $JAR "http://127.0.0.1:8081/bom/show?type=bom" -o /tmp/ob51_bom.html -w "bom list HTTP=%{http_code}\n"
echo -n "列表含 PRUAGP215: "; grep -c "PRUAGP215" /tmp/ob51_bom.html
echo -n "列表含 PR215: "; grep -c "PR215" /tmp/ob51_bom.html
echo ""
echo "=== 2. 主数据详情(全局 projectId=0鉴权用全局角色 ==="
curl -s -b $JAR "http://127.0.0.1:8081/bom/api/1" -o /tmp/ob51_detail.json -w "detail HTTP=%{http_code}\n"
echo -n "详情返回 projectId=0: "; grep -oE '"projectId":0' /tmp/ob51_detail.json | head -1
echo ""
echo "=== 3. 数据室菜单含主数据 + 图标 ==="
curl -s -b $JAR "http://127.0.0.1:8081/dashboard/show" -o /tmp/ob51_dash.html
echo -n "菜单含 BOM 图标 fa-list-check: "; grep -c "fa-list-check" /tmp/ob51_dash.html
echo -n "菜单含工艺文件: "; grep -c "工艺文件" /tmp/ob51_dash.html
echo -n "菜单含工具清单: "; grep -c "工具清单" /tmp/ob51_dash.html
echo -n "make分组不再含BOM(应为0): "; grep -c 'id="submenu-materialize"' /tmp/ob51_dash.html
echo ""
echo "=== 完成 ==="

View File

@@ -0,0 +1,33 @@
#!/bin/bash
cd /var/www/onebot
JAR=/tmp/ob64.txt
rm -f $JAR
BODY=$(curl -s -c $JAR http://127.0.0.1:8081/auth/login)
CSRF=$(echo "$BODY" | grep -oE 'name="csrf-token" content="[^"]*"' | sed 's/.*content="//;s/"//')
curl -s -b $JAR -c $JAR -X POST http://127.0.0.1:8081/auth/login \
-d "username=ruiguo_wang@163.com" -d "password=wrg521113.." -d "_token=$CSRF" \
-H "X-CSRF-TOKEN: $CSRF" -o /dev/null
curl -s -b $JAR "http://127.0.0.1:8081/bom/show/1" -o /tmp/ob64.html
php -r '
$h = file_get_contents("/tmp/ob64.html");
if (preg_match("/var RAW_ITEMS = (\[.*?\]);/s", $h, $m)) {
$items = json_decode($m[1], true);
echo "RAW_ITEMS 元素数: " . (is_array($items) ? count($items) : "解析失败") . "\n";
if (is_array($items) && isset($items[0])) {
echo "第1行完整: " . json_encode($items[0], JSON_UNESCAPED_UNICODE) . "\n";
echo "第1行字段数: " . count($items[0]) . "\n";
}
} else {
echo "未匹配 RAW_ITEMS\n";
}
if (preg_match("/var COLUMNS = (\[.*?\]);/s", $h, $m)) {
$cols = json_decode($m[1], true);
echo "COLUMNS 数: " . (is_array($cols) ? count($cols) : "解析失败") . "\n";
if (is_array($cols)) echo "COLUMNS keys: " . implode(",", array_column($cols, "key")) . "\n";
}
if (preg_match("/var FIXED = (\[.*?\]);/s", $h, $m)) {
$fixed = json_decode($m[1], true);
echo "FIXED keys: " . implode(",", $fixed) . "\n";
}
'

View File

@@ -0,0 +1,8 @@
#!/bin/bash
cd /var/www/onebot
echo "=== BOM3 第一条缩略图完整值 ==="
mysql -uonebot -p'OneBot_Db_7xKpQ9z' -h127.0.0.1 onebot -N -e "SELECT extra FROM zp_bom_item WHERE bomId=3 ORDER BY id LIMIT 1;" 2>/dev/null > /tmp/extra3.txt
php -r '$d=json_decode(file_get_contents("/tmp/extra3.txt"),true); echo "缩略图原始值: ".$d["缩略图"]."\n\n"; $t=json_decode($d["缩略图"]??"[]",true); if(is_array($t)&&isset($t[0])){ echo "=== 第一项字段 ===\n"; foreach($t[0] as $k=>$v){ if(is_string($v)&&strlen($v)>80){ echo $k.": ".substr($v,0,80)."... (len ".strlen($v).")\n"; } else { echo $k.": ".var_export($v,true)."\n"; } } }'
echo ""
echo "=== BOM3 数据源 baseUrl ==="
mysql -uonebot -p'OneBot_Db_7xKpQ9z' -h127.0.0.1 onebot -e "SELECT id, name, baseUrl, tableId FROM zp_bom_source WHERE bomId=3;" 2>/dev/null

View File

@@ -0,0 +1,21 @@
#!/bin/bash
cd /var/www/onebot
JAR=/tmp/ob80.txt
rm -f $JAR
BODY=$(curl -s -c $JAR http://127.0.0.1:8081/auth/login)
CSRF=$(echo "$BODY" | grep -oE 'name="csrf-token" content="[^"]*"' | sed 's/.*content="//;s/"//')
curl -s -b $JAR -c $JAR -X POST http://127.0.0.1:8081/auth/login \
-d "username=ruiguo_wang@163.com" -d "password=wrg521113.." -d "_token=$CSRF" \
-H "X-CSRF-TOKEN: $CSRF" -o /dev/null
echo "=== 测试保存 BOM1 第一条明细itemId=251改 partNo 为测试值) ==="
curl -s -b $JAR -X POST http://127.0.0.1:8081/bom/api/1/item \
-H "Content-Type: application/json" -H "X-CSRF-TOKEN: $CSRF" \
-d '{"itemId":251,"seq":0,"partNo":"TEST-SAVE-1","partName":"测试保存","material":"","spec":"","qtyPerUnit":"","unit":"","process":"","remark":"","项目":"1","零件代号":"PR4391-"}'
echo ""
echo "=== 验证数据库是否写入 ==="
mysql -uonebot -p'OneBot_Db_7xKpQ9z' -h127.0.0.1 onebot -e "SELECT id, partNo, partName FROM zp_bom_item WHERE id=251;" 2>/dev/null
echo "=== 恢复原值 ==="
mysql -uonebot -p'OneBot_Db_7xKpQ9z' -h127.0.0.1 onebot -e "UPDATE zp_bom_item SET partNo='', partName='' WHERE id=251;" 2>/dev/null

View File

@@ -0,0 +1,52 @@
server {
listen 8081;
listen [::]:8081;
server_name _;
set $base /var/www/onebot;
root $base/public;
access_log /var/log/nginx/onebot.access.log;
error_log /var/log/nginx/onebot.error.log warn;
index index.php;
client_max_body_size 64M;
location / {
try_files $uri $uri/ /index.php?$args;
}
location ~ \.php$ {
try_files $fastcgi_script_name =404;
include fastcgi_params;
fastcgi_pass unix:/var/run/php/php8.3-fpm.sock;
fastcgi_index index.php;
fastcgi_buffers 8 16k;
fastcgi_buffer_size 32k;
fastcgi_param DOCUMENT_ROOT $realpath_root;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
}
location = /favicon.ico {
log_not_found off;
access_log off;
}
location = /robots.txt {
log_not_found off;
access_log off;
}
location ^~ /dist/ {
expires 7d;
access_log off;
}
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied expired no-cache no-store private auth;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml application/javascript;
gzip_disable "MSIE [1-6]\.";
}

142
.dev/test.env Normal file
View File

@@ -0,0 +1,142 @@
#
# This environment file is used for our testing framework
# It is not to be used for development
#
## Minimum Configuration, these are required for installation
APP_ENV=testing
APP_DEBUG=true
APP_KEY=base64:testing123testing123testing123testing123=
APP_URL=https://leantime-dev
LEAN_APP_URL = 'https://leantime-dev' # Base URL, only needed for subfolder installation
LEAN_APP_DIR = '' # Base of application without trailing slash (used for cookies), e.g, /leantime
LEAN_DEBUG = true # Debug flag
# Database
LEAN_DB_HOST=leantime-db
LEAN_DB_USER=root
LEAN_DB_PASSWORD=leantime
LEAN_DB_DATABASE=leantime_test
LEAN_DB_PORT=3306
## Optional Configuraiton, you may ommit these from your .env file
## Default Settings
LEAN_SITENAME = 'Leantime' # Name of your site, can be changed later
LEAN_LANGUAGE = 'en-US' # Default language
LEAN_DEFAULT_TIMEZONE = 'America/Los_Angeles' # Set default timezone
LEAN_ENABLE_MENU_TYPE = false # Enable to specifiy menu on aproject by project basis
LEAN_SESSION_PASSWORD = '3evBlq9zdUEuzKvVJHWWx3QzsQhturBApxwcws2m' #Salting sessions. Replace with a strong password
LEAN_SESSION_EXPIRATION = 28800 # How many seconds after inactivity should we logout? 28800seconds = 8hours
LEAN_LOG_PATH = '' # Default Log Path (including filename), if not set /logs/error.log will be used
## Look & Feel, these settings are available in the UI and can be overwritten there.
LEAN_LOGO_PATH = '/dist/images/logo.svg' # Default logo path, can be changed later
LEAN_PRINT_LOGO_URL = '/dist/images/logo.jpg' # Default logo URL use for printing (must be jpg or png format)
LEAN_DEFAULT_THEME = 'default' # Default theme
LEAN_PRIMARY_COLOR = '#006d9f' # Primary Theme color
LEAN_SECONDARY_COLOR = '#81B1A8' # Secondary Theme Color
LEAN_KEEP_THEME = true # Keep theme and language from previous user for login screen
## Fileuploads
# Local File Uploads
LEAN_USER_FILE_PATH = 'userfiles/' # Local relative path to store uploaded files (if not using S3)
LEAN_DB_BACKUP_PATH = 'backupdb/' # Local relative path to store backup files, need permission to write
# S3 File Uploads
LEAN_USE_S3 = false # Set to true if you want to use S3 instead of local files
LEAN_S3_KEY = 'AKIAIOSFODNN7EXAMPLE' # S3 Key, hardcoded in s3ninja
LEAN_S3_SECRET = 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY' # S3 Secret, hardcoded in s3ninja
LEAN_S3_BUCKET = 'leantime' # Your S3 bucket
LEAN_S3_USE_PATH_STYLE_ENDPOINT = true # Sets the endpoint style: false => https://[bucket].[endpoint] ; true => https://[endpoint]/[bucket]
LEAN_S3_REGION = 'eu-west-1' # S3 region
LEAN_S3_FOLDER_NAME = '' # Foldername within S3 (can be emtpy)
LEAN_S3_END_POINT = "http://s3ninja:9000" # S3 EndPoint S3 Compatible (https://sfo2.digitaloceanspaces.com)
## Email
LEAN_EMAIL_RETURN = 'leantime@test.local' # Return email address, needs to be valid email address format
LEAN_EMAIL_USE_SMTP = true # Use SMTP? If set to false, the default php mail() function will be used
LEAN_EMAIL_SMTP_HOSTS = 'maildev' # SMTP host
LEAN_EMAIL_SMTP_AUTH = false # SMTP authentication required
LEAN_EMAIL_SMTP_USERNAME = '' # SMTP username
LEAN_EMAIL_SMTP_PASSWORD = '' # SMTP password
LEAN_EMAIL_SMTP_AUTO_TLS = true # SMTP Enable TLS encryption automatically if a server supports it
LEAN_EMAIL_SMTP_SECURE = '' # SMTP Security protocol (usually one of: TLS, SSL, STARTTLS)
LEAN_EMAIL_SMTP_SSLNOVERIFY = true # SMTP Allow insecure SSL: Don't verify certificate, accept self-signed, etc.
LEAN_EMAIL_SMTP_PORT = '465' # Port (usually one of 25, 465, 587, 2526)
## Ldap
LEAN_LDAP_USE_LDAP = false # Set to true if you want to use LDAP
LEAN_LDAP_LDAP_TYPE = 'OL' # Select the correct directory type. Currently Supported: OL - OpenLdap, AD - Active Directory
LEAN_LDAP_HOST = '' # FQDN
LEAN_LDAP_PORT = 389 # Default Port
LEAN_LDAP_DN = '' # Location of users, example: CN=users,DC=example,DC=com
# Leantime->Ldap attribute mapping
LEAN_LDAP_KEYS = "{\"username\":\"uid\",\"groups\":\"memberOf\",\"email\":\"mail\",\"firstname\":\"displayname\",\"lastname\":\"\",\"phonenumber\":\"telephoneNumber\"}"
# For AD use these default attributes
# LEAN_LDAP_KEYS = "{
# \"username\":\"cn\",
# \"groups\":\"memberOf\",
# \"email\":\"mail\",
# \"firstname\":\"givenName\",
# \"lastname\":\"sn\",
# \"phonenumber\":\"telephoneNumber\"
# }"
LEAN_LDAP_DEFAULT_ROLE_KEY = 20; # Default Leantime Role on creation. (set to editor)
# Default role assignments upon first login.
# (Optional) Can be updated later in user settings for each user
LEAN_LDAP_GROUP_ASSIGNMENT = "{\"5\": {\"ltRole\":\"readonly\",\"ldapRole\":\"readonly\"},\"10\": {\"ltRole\":\"commenter\",\"ldapRole\":\"commenter\"},\"20\": {\"ltRole\":\"editor\",\"ldapRole\":\"editor\"},\"30\": {\"ltRole\":\"manager\",\"ldapRole\":\"manager\"},\"40\": {\"ltRole\":\"admin\",\"ldapRole\":\"administrators\"},\"50\": {\"ltRole\":\"owner\",\"ldapRole\":\"administrators\"}}"
## OpenID Connect
# required
LEAN_OIDC_ENABLE = true
LEAN_OIDC_CLIEND_ID =
LEAN_OIDC_CLIEND_SECRET =
# required - the url for your provider (examples down below)
#LEAN_OIDC_PROVIDER_URL =
# optional - these will be read from the well-known configuration if possible
#LEAN_OIDC_AUTH_URL_OVERRIDE =
#LEAN_OIDC_TOKEN_URL_OVERRIDE =
#LEAN_OIDC_JWKS_URL_OVERRIDE =
#LEAN_OIDC_USERINFO_URL_OVERRIDE =
# optional - override the public key for RSA validation
#LEAN_OIDC_CERTIFICATE_STRING =
#LEAN_OIDC_CERTIFICATE_FILE =
# optional - override the requested scopes
#LEAN_OIDC_SCOPES =
# optional - override the keys used for these fields
#LEAN_OIDC_FIELD_EMAIL =
#LEAN_OIDC_FIELD_FIRSTNAME =
#LEAN_OIDC_FIELD_LASTNAME =
## OpenID Connect setting for github
#LEAN_OIDC_PROVIDER_URL = https://token.actions.githubusercontent.com/
#LEAN_OIDC_AUTH_URL_OVERRIDE = https://github.com/login/oauth/authorize
#LEAN_OIDC_TOKEN_URL_OVERRIDE = https://github.com/login/oauth/access_token
#LEAN_OIDC_USERINFO_URL_OVERRIDE = https://api.github.com/user,https://api.github.com/user/emails
#LEAN_OIDC_SCOPES = user:email
#LEAN_OIDC_FIELD_EMAIL = 0.email
LEAN_RATELIMIT_GENERAL = 50000
LEAN_RATELIMIT_AUTH = 50000
LEAN_RATELIMIT_API = 50000
# Cache Configuration for testing (use file cache instead of Redis)
LEAN_USE_REDIS=false
CACHE_DRIVER=file
SESSION_DRIVER=file
QUEUE_CONNECTION=sync

10
.dev/xdebug.ini Normal file
View File

@@ -0,0 +1,10 @@
zend_extension=xdebug
[xdebug]
xdebug.mode=debug
xdebug.discover_client_host=1
xdebug.client_host=host.docker.internal; do not touch
xdebug.start_with_request=yes
xdebug.client_port=9003
xdebug.idekey="PHPSTORM"
xdebug.log=/var/www/html/storage/logs/xdebug.log

131
.docker/Dockerfile Normal file
View File

@@ -0,0 +1,131 @@
# Build stage
FROM --platform=$TARGETPLATFORM php:8.3-fpm-alpine AS builder
# Add QEMU for cross-platform builds
COPY --from=tonistiigi/binfmt:latest /usr/bin/qemu-* /usr/bin/
# Install build dependencies
RUN apk add --no-cache --virtual .build-deps \
$PHPIZE_DEPS \
gcc \
g++ \
openssl-dev \
make \
libxml2-dev \
oniguruma-dev \
openldap-dev \
zstd-dev \
libzip-dev \
freetype-dev \
libpng-dev \
libjpeg-turbo-dev \
postgresql-dev
# Set cross-compilation flags if needed
ARG TARGETPLATFORM
RUN case "${TARGETPLATFORM}" in \
linux/arm64*) export CFLAGS='-march=armv8-a' CXXFLAGS='-march=armv8-a' ;; \
esac
# Install and configure PHP extensions
RUN set -ex; \
# Configure extensions
docker-php-ext-configure gd --with-freetype --with-jpeg; \
# Install extensions one by one to prevent memory issues
docker-php-ext-install mysqli && \
docker-php-ext-install pdo_mysql && \
docker-php-ext-install pdo_pgsql && \
docker-php-ext-install bcmath && \
docker-php-ext-install mbstring && \
docker-php-ext-install exif && \
docker-php-ext-install pcntl && \
docker-php-ext-install opcache && \
docker-php-ext-install ldap && \
docker-php-ext-install zip && \
pecl install redis && docker-php-ext-enable redis && \
docker-php-ext-install gd && \
rm -rf /tmp/* /var/cache/apk/*
# Production stage
FROM --platform=$TARGETPLATFORM php:8.3-fpm-alpine
# Add production dependencies
RUN apk add --no-cache \
tini \
nginx \
mysql-client \
openssl \
supervisor \
freetype \
libpng \
zstd-libs \
libjpeg-turbo \
libzip \
openldap \
libpq \
icu-libs && \
rm -rf /var/cache/apk/* /tmp/*
# Copy built extensions from builder
COPY --from=builder /usr/local/lib/php/extensions/ /usr/local/lib/php/extensions/
COPY --from=builder /usr/local/etc/php/conf.d/ /usr/local/etc/php/conf.d/
# Add non-root user
ARG PUID=1000
ARG PGID=1000
# Set working directory
WORKDIR /var/www/html
# Install Leantime
ARG LEAN_VERSION
RUN set -ex; \
curl -fsSL --retry 3 https://github.com/Leantime/leantime/releases/download/v${LEAN_VERSION}/Leantime-v${LEAN_VERSION}.tar.gz -o leantime.tar.gz && \
tar xzf leantime.tar.gz --strip-components 1 && \
rm leantime.tar.gz && \
chown -R www-data:www-data .
# Set Permissions
RUN set -ex; \
# Modify existing www-data user/group
deluser www-data; \
addgroup -g ${PGID} www-data; \
adduser -u ${PUID} -G www-data -h /home/www-data -s /bin/sh -D www-data; \
# Create required directories
mkdir -p /var/www/html/userfiles \
/var/www/html/public/userfiles \
/var/www/html/bootstrap/cache \
/var/www/html/storage/logs \
/var/www/html/storage/framework/cache \
/var/www/html/storage/framework/sessions \
/var/www/html/storage/framework/views \
/var/www/html/app/Plugins \
/run /var/log/nginx /var/lib/nginx; \
chown -R www-data:www-data /var/www/html /run /var/log/nginx /var/lib/nginx && \
chmod 775 /var/www/html/userfiles \
/var/www/html/public/userfiles \
/var/www/html/bootstrap/cache \
/var/www/html/storage/logs \
/var/www/html/storage/framework/cache \
/var/www/html/storage/framework/sessions \
/var/www/html/storage/framework/views \
/var/www/html/app/Plugins;
# Copy configuration files
COPY config/custom.ini /usr/local/etc/php/conf.d/
COPY config/nginx.conf /etc/nginx/nginx.conf
COPY config/php-fpm.conf /usr/local/etc/php-fpm.d/www.conf
COPY config/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
COPY --chmod=0755 start.sh /start.sh
# Switch to non-root user
USER www-data
# Add healthcheck
HEALTHCHECK --interval=30s --timeout=10s --retries=3 \
CMD curl -f http://localhost:8080 || exit 1
EXPOSE 8080
ENTRYPOINT ["/sbin/tini", "--", "/start.sh"]

23
.docker/config/custom.ini Normal file
View File

@@ -0,0 +1,23 @@
memory_limit = 1G
max_execution_time = 300
opcache.enable = 1
opcache.jit=1255
opcache.jit_buffer_size=128M
; Optimized opcache settings
opcache.memory_consumption=128
opcache.interned_strings_buffer=8
opcache.max_accelerated_files=4000
opcache.revalidate_freq=60
opcache.fast_shutdown=1
; Upload settings
upload_max_filesize = 100M
post_max_size = 100M
; Session handling
session.gc_maxlifetime = 28800
; Error Handline
display_errors = off
log_errors = on

86
.docker/config/nginx.conf Normal file
View File

@@ -0,0 +1,86 @@
user www-data www-data;
worker_processes auto;
pid /run/nginx.pid;
error_log /dev/stderr info;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
access_log /dev/stdout;
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
client_max_body_size 100M;
# SSL
ssl_protocols TLSv1.2 TLSv1.3;
ssl_prefer_server_ciphers on;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384;
# Security headers
add_header X-Frame-Options "DENY" always;
add_header X-XSS-Protection "1; mode=block; report=/xss-report" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer-when-downgrade" always;
add_header Content-Security-Policy "default-src 'self' http: https: data: blob: 'unsafe-inline'" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
server {
listen 8080;
server_name _;
root /var/www/html/public;
index index.php;
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_pass 127.0.0.1:9000;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info;
fastcgi_read_timeout 300;
}
location ~ /\.ht {
deny all;
}
# additional config
# favicon.ico
location = /favicon.ico {
log_not_found off;
access_log off;
}
# robots.txt
location = /robots.txt {
log_not_found off;
access_log off;
}
location ^~ /dist/ {
expires 7d;
access_log off;
}
# Enable gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied expired no-cache no-store private auth;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml application/javascript;
gzip_disable "MSIE [1-6]\.";
}
}

View File

@@ -0,0 +1,28 @@
[global]
error_log = /proc/self/fd/2
daemonize = no
[www]
user = www-data
group = www-data
listen = 127.0.0.1:9000
listen.owner = www-data
listen.group = www-data
listen.mode = 0660
pm = dynamic
pm.max_children = 50
pm.start_servers = 5
pm.min_spare_servers = 5
pm.max_spare_servers = 35
pm.max_requests = 500
clear_env = no
catch_workers_output = yes
decorate_workers_output = no
chdir = /var/www/html
php_admin_value[error_log] = /proc/self/fd/2
php_admin_flag[log_errors] = on

View File

@@ -0,0 +1,32 @@
[supervisord]
nodaemon=true
logfile=/dev/stdout
loglevel=info
logfile_maxbytes=0
pidfile=/run/supervisord.pid
[program:php-fpm]
command=php-fpm -F
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
autostart=true
autorestart=true
priority=5
startretries=10
[program:nginx]
command=nginx -g 'daemon off;'
autostart=true
autorestart=true
priority=10
[program:scheduler]
command=php /var/www/html/bin/leantime schedule:work
autostart=true
autorestart=true
user=www-data
stdout_logfile=/dev/fd/1
stdout_logfile_maxbytes=0
redirect_stderr=true

View File

@@ -0,0 +1,64 @@
version: '3.3'
services:
leantime_db:
image: mysql:8.4
container_name: mysql_leantime
volumes:
- db_data:/var/lib/mysql
restart: unless-stopped
env_file: ./.env # Environment file with settings
networks:
- leantime-net
command: --character-set-server=UTF8MB4 --collation-server=UTF8MB4_unicode_ci
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
interval: 30s
timeout: 10s
retries: 3
leantime:
image: leantime/leantime:3.4.12
restart: unless-stopped
env_file: ./.env # Environment file with settings
# Add security options
security_opt:
- no-new-privileges:true
# Add capabilities
cap_add:
# - CAP_NET_BIND_SERVICE
- CAP_CHOWN
- CAP_SETGID
- CAP_SETUID
ports:
- "${LEAN_PORT:-8080}:8080"
networks:
- leantime-net
volumes:
- public_userfiles:/var/www/html/public/userfiles # Volume to store public files, logo etc
- userfiles:/var/www/html/userfiles # Original volume name for compatibility
- plugins:/var/www/html/app/Plugins # Plugin storage
- logs:/var/www/html/storage/logs # Log storage
depends_on:
leantime_db:
condition: service_healthy
# Add a helper container for volume permissions
# Run via docker compose --profile mysql_helper up -d
mysql_helper:
image: mysql:8.4
command: chown -R mysql:mysql /var/lib/mysql
volumes:
- db_data:/var/lib/mysql
user: root
profiles: [ "helper" ]
volumes:
db_data:
userfiles: # New volume for public files
public_userfiles:
plugins:
logs:
networks:
leantime-net:

51
.docker/start.sh Normal file
View File

@@ -0,0 +1,51 @@
#!/bin/sh
if [[ -n "${LEAN_DB_PASSWORD_FILE}" ]]; then
LEAN_DB_PASSWORD=$(cat "${LEAN_DB_PASSWORD_FILE}")
export LEAN_DB_PASSWORD
fi
if [[ -n "${LEAN_EMAIL_SMTP_PASSWORD_FILE}" ]]; then
LEAN_EMAIL_SMTP_PASSWORD=$(cat "${LEAN_EMAIL_SMTP_PASSWORD_FILE}")
export LEAN_EMAIL_SMTP_PASSWORD
fi
if [[ -n "${LEAN_S3_SECRET_FILE}" ]]; then
LEAN_S3_SECRET=$(cat "${LEAN_S3_SECRET_FILE}")
export LEAN_S3_SECRET
fi
if [[ -n "${LEAN_SESSION_PASSWORD_FILE}" ]]; then
LEAN_SESSION_PASSWORD=$(cat "${LEAN_SESSION_PASSWORD_FILE}")
export LEAN_SESSION_PASSWORD
fi
if [[ -n "${LEAN_REDIS_PASSWORD_FILE}" ]]; then
LEAN_REDIS_PASSWORD=$(cat "${LEAN_REDIS_PASSWORD_FILE}")
export LEAN_REDIS_PASSWORD
fi
if [[ -n "${LEAN_DB_HOST_FILE}" ]]; then
LEAN_DB_HOST=$(cat "${LEAN_DB_HOST_FILE}")
export LEAN_DB_HOST
fi
if [[ -n "${LEAN_DB_DATABASE_FILE}" ]]; then
LEAN_DB_DATABASE=$(cat "${LEAN_DB_DATABASE_FILE}")
export LEAN_DB_DATABASE
fi
if [[ -n "${LEAN_DB_USER_FILE}" ]]; then
LEAN_DB_USER=$(cat "${LEAN_DB_USER_FILE}")
export LEAN_DB_USER
fi
if [[ -n "${LEAN_EMAIL_SMTP_USERNAME_FILE}" ]]; then
LEAN_EMAIL_SMTP_USERNAME=$(cat "${LEAN_EMAIL_SMTP_USERNAME_FILE}")
export LEAN_EMAIL_SMTP_USERNAME
fi
# Ensure supervisord can write its pid file
mkdir -p /run
/usr/bin/supervisord -c /etc/supervisor/conf.d/supervisord.conf

20
.editorconfig Normal file
View File

@@ -0,0 +1,20 @@
# everyfile
[*]
charset = utf-8
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
# 4 space indenting
[*.{php,xml,html,json}]
indent_size = 4
[*.md]
trim_trailing_whitespace = false
[*.yml]
indent_size = 2
[makefile]
indent_style = tab

62
.eslintrc Normal file
View File

@@ -0,0 +1,62 @@
{
"parser": "@babel/eslint-parser",
"env": {
"browser": true,
"jquery": true,
"mocha": true
},
"globals": {
"jQuery": true
},
"rules": {
"no-bitwise": 0, //Update to 2
"camelcase": 0,
"curly": 0,
"eqeqeq": 0,
"guard-for-in": 0, //Update to 2
"no-extend-native": 0, //Update to 2
"wrap-iife": 0,
"no-use-before-define": 0,
"new-cap": 0,
"no-caller": 2,
"no-empty": 0,
"no-irregular-whitespace": 2,
"no-new": 0,
"no-plusplus": 0,
"quotes": 0,
"no-undef": 0,
"no-unused-vars": 0, //Update to 2
"strict": 0,
"max-params": 0,
"max-depth": 0,
"max-statements": 0,
"complexity": 0,
"max-len": 0,
"no-var": 0,
"semi": 0,
"no-cond-assign": 0,
"no-debugger": 0,
"no-eq-null": 0, //Update to 2
"no-eval": 0,
"no-unused-expressions": 0,
"block-scoped-var": 0,
"no-iterator": 0,
"linebreak-style": 0,
"no-delete-var": 0,
"comma-style": [
2,
"last"
],
"no-loop-func": 0,
"no-multi-str": 0,
"require-yield": 0,
"valid-typeof": 0,
"no-proto": 0,
"no-script-url": 0,
"no-shadow": 0, //Update to 2
"dot-notation": 2,
"no-new-func": 0,
"no-new-wrappers": 0,
"no-invalid-this": 0
}
}

1
.github/CODEOWNERS vendored Normal file
View File

@@ -0,0 +1 @@
* @leantime/developers

4
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,4 @@
# These are supported funding model platforms
github: ['Leantime']
custom: ['https://marketplace.leantime.io']

52
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View File

@@ -0,0 +1,52 @@
name: Bug Report
description: File a bug report.
title: "Enter a title of your bug"
type: "Bug"
assignees:
- marcelfolaron
body:
- type: markdown
attributes:
value: "Thank you for taking the time to submit a bug report. If you have problems with the installation or other questions please use the [discussion forum](https://github.com/Leantime/leantime/discussions) or our [discord server](https://discord.gg/4zMzJtAq9z)"
- type: dropdown
attributes:
label: What is your set up?
options:
- Cloud Hosted
- Self Hosted Server
- Self Hosted Docker
validations:
required: true
- type: input
id: version
attributes:
label: Version
description: "Which Leantime version are you using (see footer bottom right)"
placeholder: "3.x.x"
validations:
required: true
- type: textarea
attributes:
label: Describe the issue
placeholder: A clear and concise description of what the bug is.
validations:
required: true
- type: textarea
id: repro
attributes:
label: Reproduction steps
description: "How do you trigger this bug? Please walk us through it step by step."
value: |
1.
2.
3.
...
validations:
required: true
- type: textarea
id: context
attributes:
label: Error Logs (LEANTIMEFOLDER/storage/logs)
description: "Please past the contents of your error logs and any other logs you may have."
validations:
required: false

View File

@@ -0,0 +1,36 @@
name: Feature request
description: Suggest an idea for this project.
title: "Enter title of your feature suggestion"
type: "Feature"
assignees:
- marcelfolaron
body:
- type: markdown
attributes:
value: "Thank you for taking the time to submit a feature request. If you have problems with the installation or other questions please use the [discussion forum](https://github.com/Leantime/leantime/discussions) or our [discord server](https://discord.gg/4zMzJtAq9z)"
- type: dropdown
attributes:
label: What is your set up?
options:
- Cloud Hosted
- Self Hosted Server
- Self Hosted Docker
validations:
required: true
- type: textarea
attributes:
label: Is your feature request related to a problem? Please describe
description: A clear and concise description of what the problem is.
placeholder: Ex. I'm always frustrated when [...]
- type: textarea
attributes:
label: Describe the solution you'd like
description: A clear and concise description of what you want to happen.
placeholder:
validations:
required: true
- type: textarea
attributes:
label: Additional context
description: "Add any other context or screenshots about the feature request here."
validations:

17
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View File

@@ -0,0 +1,17 @@
### Description
*Please include a short description of the suggested change and the reasoning behind the approach you have chosen.*
### Link to ticket
*Please add a link to the GitHub issue being addressed by this change.*
### Type
- [ ] Fix
- [ ] Feature
- [ ] Cleanup
### Screenshot of the result
*If your change affects the user interface, you should include a screenshot of the result with the pull request.*

31
.github/changelogConfig.yml vendored Normal file
View File

@@ -0,0 +1,31 @@
changelog_type: 'commit_message'
header_prefix: 'Version:'
commit_changelog: true
comment_changelog: true
include_unlabeled_changes: true
unlabeled_group_title: 'Unlabeled Changes'
pull_request_title_regex: '^Release'
version_regex: 'v?([0-9]{1,2})+[.]+([0-9]{1,2})+[.]+([0-9]{1,2})\s\(\d{1,2}-\d{1,2}-\d{4}\)'
exclude_labels:
- bot
- dependabot
- ci
group_config:
- title: Bug Fixes
labels:
- bug
- bugfix
- fix
- title: Code Improvements
labels:
- improvements
- enhancement
- cleanup
- title: New Features
labels:
- feature
- title: Documentation Updates
labels:
- docs
- documentation
- doc

17
.github/release.yml vendored Normal file
View File

@@ -0,0 +1,17 @@
changelog:
exclude:
labels:
- ignore-for-release
categories:
- title: 🛠 Breaking Changes
labels:
- Breaking-Change
- title: 🎉 New Features
labels:
- Feature
- title: 🐞 Bug Fixes
labels:
- Bug
- title: Other Changes
labels:
- "*"

30
.github/workflows/acceptancetests.yml vendored Normal file
View File

@@ -0,0 +1,30 @@
name: Acceptance Tests (Selenium)
on:
workflow_dispatch:
push:
branches: [ "master", "*.*-dev" ]
pull_request:
branches: [ "master", "*.*-dev" ]
jobs:
acceptance:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: redis, gd, ldap, mbstring, pdo_mysql, zip, bcmath, exif, pcntl
- name: Run Acceptance Tests
run: make acceptance-test-ci
- name: Store screenshots
uses: actions/upload-artifact@v4
if: failure()
with:
name: acceptance-test
path: tests/_output

36
.github/workflows/api-bearer-tests.yml vendored Normal file
View File

@@ -0,0 +1,36 @@
name: API Tests (Bearer auth)
# Sibling to acceptancetests.yml. Exists because the JSON-RPC endpoint
# accepts three auth modes (session, x-api-key, Bearer) and ApiCest only
# exercises x-api-key. Bearer is the mode mobile + AdvancedAuth integrators
# hit, and a 2026-06 permission-engine deploy silently broke it for every
# gated read with no CI signal. This workflow closes that gap.
on:
workflow_dispatch:
push:
branches: [ "master", "*.*-dev" ]
pull_request:
branches: [ "master", "*.*-dev" ]
jobs:
bearer-api:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: redis, gd, ldap, mbstring, pdo_mysql, zip, bcmath, exif, pcntl
- name: Run Bearer API Tests
run: make bearer-api-test-ci
- name: Store screenshots
uses: actions/upload-artifact@v4
if: failure()
with:
name: bearer-api-test
path: tests/_output

28
.github/workflows/codeStyleAnalysis.yml vendored Normal file
View File

@@ -0,0 +1,28 @@
name: Code Style Analysis
on:
workflow_dispatch:
push:
branches: [ "master", "*.*-dev" ]
pull_request:
branches: [ "master", "*.*-dev" ]
jobs:
pint:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: redis, gd, ldap, mbstring, pdo_mysql, zip, bcmath, exif, pcntl
- name: Install Dependencies
run: make build-dev
- name: Run Laravel Pint Analysis
run: make test-code-style

28
.github/workflows/makefile.yml vendored Normal file
View File

@@ -0,0 +1,28 @@
name: Makefile CI
on:
workflow_dispatch:
push:
branches: [ "master", "*.*-dev" ]
pull_request:
branches: [ "master", "*.*-dev" ]
jobs:
build:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: redis, gd, ldap, mbstring, pdo_mysql, zip, bcmath, exif, pcntl
- name: Install dependencies
run: make build
- name: Run distcheck
run: make clean

174
.github/workflows/release-prepare.yml vendored Normal file
View File

@@ -0,0 +1,174 @@
name: Prepare Release
# Dispatch with a version number to open a "Release vX.Y.Z" PR containing the
# version bump (package.json, package-lock.json, AppSettings.php) and an
# AI-drafted CHANGELOG.md section. The regular CI suite runs on the PR as the
# final test gate; merging the PR triggers the Create Release workflow which
# tags, packages, and publishes.
#
# Requires two repo secrets:
# - RELEASE_TOKEN: fine-grained PAT with Contents + Pull requests read/write.
# PRs opened with the default GITHUB_TOKEN would not trigger CI workflows.
# - ANTHROPIC_API_KEY: Claude API key used to draft the changelog section.
on:
workflow_dispatch:
inputs:
version:
description: 'New version number (e.g. 3.8.1)'
required: true
type: string
# Least privilege: branch push + PR creation use the RELEASE_TOKEN PAT, so the
# default token only needs read access (checkout, changelog).
permissions:
contents: read
pull-requests: read
jobs:
prepare:
runs-on: ubuntu-latest
env:
VERSION: ${{ inputs.version }}
steps:
- name: Checkout
uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Validate version
run: |
if ! [[ "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "::error::Version '$VERSION' is not a valid X.Y.Z version number"
exit 1
fi
CURRENT=$(make get-version)
if [ "$VERSION" = "$CURRENT" ]; then
echo "::error::Version $VERSION is already the current version"
exit 1
fi
if git rev-parse -q --verify "refs/tags/v$VERSION" > /dev/null; then
echo "::error::Tag v$VERSION already exists"
exit 1
fi
- name: Bump version files
run: |
npm version "$VERSION" --no-git-tag-version
sed -i "s/appVersion = '[^']*'/appVersion = '$VERSION'/" app/Core/Configuration/AppSettings.php
echo "Bumped to $(make get-version)"
- name: Collect history since last release
run: |
LAST_TAG=$(git describe --tags --abbrev=0 --match 'v*')
echo "Generating changelog for $LAST_TAG..HEAD"
git log "$LAST_TAG"..HEAD --no-merges --pretty='- %s' > "$RUNNER_TEMP/commits.md"
wc -l "$RUNNER_TEMP/commits.md"
- name: Build changelog prompt
run: |
# Most recent CHANGELOG section doubles as the style example
awk '/^# Version:/{n++} n==1' CHANGELOG.md > "$RUNNER_TEMP/style-example.md"
cat > "$RUNNER_TEMP/system-prompt.md" << 'SYSEOF'
You are the release manager for Leantime, an open source project management
system. You write the CHANGELOG.md section for a new release based on the
git commit subjects since the last release.
Format rules:
- Start with exactly: # Version: <version>
- Use these sections, in this order, and ONLY if they have content:
## Highlights, ## New Features, ## Bug Fixes, ## Improvements, ## Security, ## Localization, ## Dependency Updates
- Highlights: at most 1-3 entries, only for genuinely notable user-facing
features (### subheading + a short paragraph). Most releases have none -
omit the section rather than inflate minor work.
- All other entries are single bullets: **Short Title** - one-sentence
user-facing description ending with the PR reference(s), e.g. (#1234)
- Rewrite commit subjects into user-facing language; never copy
conventional-commit prefixes like fix(scope): into the output.
- Merge multiple commits about the same change into one bullet with all
PR references.
- Group dependency bumps into ## Dependency Updates as terse bullets.
- Skip pure chores with no user impact (CI tweaks, code style, review
follow-up commits that have no PR reference).
- Output raw markdown only - no surrounding code fences, no commentary.
Here is the previous release's section as a style example:
SYSEOF
cat "$RUNNER_TEMP/style-example.md" >> "$RUNNER_TEMP/system-prompt.md"
{
echo "Write the CHANGELOG section for version $VERSION based on these commits:"
echo
cat "$RUNNER_TEMP/commits.md"
} > "$RUNNER_TEMP/prompt.md"
- name: Generate changelog section
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
run: |
if [ -z "$ANTHROPIC_API_KEY" ]; then
echo "::error::ANTHROPIC_API_KEY secret is not set"
exit 1
fi
jq -n \
--rawfile system "$RUNNER_TEMP/system-prompt.md" \
--rawfile prompt "$RUNNER_TEMP/prompt.md" \
'{
model: "claude-opus-4-8",
max_tokens: 8000,
thinking: {type: "adaptive"},
system: $system,
messages: [{role: "user", content: $prompt}]
}' > "$RUNNER_TEMP/request.json"
if ! curl -sS --fail-with-body --max-time 600 https://api.anthropic.com/v1/messages \
-H "x-api-key: $ANTHROPIC_API_KEY" \
-H "anthropic-version: 2023-06-01" \
-H "content-type: application/json" \
-d @"$RUNNER_TEMP/request.json" > "$RUNNER_TEMP/response.json"; then
echo "::error::Anthropic API request failed"
cat "$RUNNER_TEMP/response.json"
exit 1
fi
# Adaptive thinking responses interleave thinking blocks - keep text blocks only
jq -r '[.content[] | select(.type == "text") | .text] | join("\n")' \
"$RUNNER_TEMP/response.json" > "$RUNNER_TEMP/ai-response.md"
if ! [ -s "$RUNNER_TEMP/ai-response.md" ]; then
echo "::error::Empty changelog from the API (stop_reason: $(jq -r '.stop_reason' "$RUNNER_TEMP/response.json"))"
exit 1
fi
- name: Prepend section to CHANGELOG.md
run: |
sed '/^```/d' "$RUNNER_TEMP/ai-response.md" > "$RUNNER_TEMP/new-section.md"
if ! head -1 "$RUNNER_TEMP/new-section.md" | grep -q "^# Version: $VERSION"; then
printf '# Version: %s\n\n' "$VERSION" | cat - "$RUNNER_TEMP/new-section.md" > "$RUNNER_TEMP/new-section.tmp"
mv "$RUNNER_TEMP/new-section.tmp" "$RUNNER_TEMP/new-section.md"
fi
{ cat "$RUNNER_TEMP/new-section.md"; echo; echo '---'; echo; cat CHANGELOG.md; } > "$RUNNER_TEMP/changelog.md"
mv "$RUNNER_TEMP/changelog.md" CHANGELOG.md
{
echo "Automated release PR. Review and edit the changelog below (it ships as CHANGELOG.md and as the GitHub release notes), wait for CI, then merge - the release publishes automatically."
echo
echo '---'
echo
cat "$RUNNER_TEMP/new-section.md"
} > "$RUNNER_TEMP/pr-body.md"
- name: Open release PR
uses: peter-evans/create-pull-request@v7
with:
token: ${{ secrets.RELEASE_TOKEN }}
commit-message: 'release: bump version to ${{ inputs.version }} + changelog'
branch: release/v${{ inputs.version }}
delete-branch: true
base: master
title: 'Release v${{ inputs.version }}'
labels: release
body-path: ${{ runner.temp }}/pr-body.md

81
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,81 @@
name: Create Release
# Runs automatically when a release/vX.Y.Z PR (opened by the Prepare Release
# workflow) is merged into master. Manual escape hatches: dispatch directly or
# push a version tag - both release whatever version AppSettings.php declares.
on:
workflow_dispatch:
push:
tags:
- '*'
pull_request:
types: [closed]
branches:
- master
permissions:
contents: write
jobs:
build_release:
name: build_release
if: github.event_name != 'pull_request' || (github.event.pull_request.merged == true && startsWith(github.event.pull_request.head.ref, 'release/'))
runs-on: ubuntu-24.04
steps:
# On pull_request events github.sha points at a transient PR merge ref,
# not the commit that landed on master - build and tag merge_commit_sha.
- name: checkout
uses: actions/checkout@v5
with:
fetch-depth: 0
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.merge_commit_sha || github.ref }}
# Same PHP setup as Makefile CI - the runner's stock ext-redis (5.3.7)
# conflicts with symfony/cache >= 7.4, which requires ext-redis >= 6.1.
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: redis, gd, ldap, mbstring, pdo_mysql, zip, bcmath, exif, pcntl
- name: version
run: echo "version=$(make get-version)" >> $GITHUB_OUTPUT
id: version
# COMPOSER_AUTH prevents GitHub API rate-limiting; without it composer silently
# falls back to source (git clone) installs, shipping .git dirs and package build/
# test artifacts that ballooned release archives (#3330).
- name: build artifacts
run: make package
env:
COMPOSER_AUTH: '{"github-oauth": {"github.com": "${{ github.token }}"}}'
- name: Extract changelog section
run: |
awk '/^# Version:/{n++} n==1' CHANGELOG.md > "$RUNNER_TEMP/release-body.md"
if ! grep -q "Version: ${{ steps.version.outputs.version }}" "$RUNNER_TEMP/release-body.md"; then
echo "::warning::Top CHANGELOG.md section does not match v${{ steps.version.outputs.version }}; falling back to auto-generated notes only"
: > "$RUNNER_TEMP/release-body.md"
fi
- name: release
uses: ncipollo/release-action@v1
id: create_release
with:
token: ${{ github.token }}
draft: false
prerelease: false
name: Leantime v${{ steps.version.outputs.version }}
tag: v${{ steps.version.outputs.version }}
commit: ${{ github.event_name == 'pull_request' && github.event.pull_request.merge_commit_sha || github.sha }}
generateReleaseNotes: true
makeLatest: true
bodyFile: ${{ runner.temp }}/release-body.md
artifacts: './target/Leantime-v${{ steps.version.outputs.version }}.zip,./target/Leantime-v${{ steps.version.outputs.version }}.tar.gz'
- name: Run latest-tag
uses: EndBug/latest-tag@latest
with:
ref: latest
description: Latest Release of Leantime.

63
.github/workflows/security.yml vendored Normal file
View File

@@ -0,0 +1,63 @@
name: Security Audit
on:
workflow_dispatch:
push:
branches: [ "master", "*.*-dev" ]
pull_request:
branches: [ "master", "*.*-dev" ]
schedule:
- cron: '0 8 * * 1' # Weekly on Monday at 8am UTC
jobs:
composer-audit:
name: Composer Security Audit
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: redis, gd, ldap, mbstring, pdo_mysql, zip, bcmath, exif, pcntl
- name: Install Composer Dependencies
run: composer install --no-dev --no-interaction --prefer-dist
- name: Run Composer Audit
run: composer audit --format=summary
continue-on-error: true
npm-audit:
name: NPM Security Audit
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install NPM Dependencies
run: npm ci
- name: Run NPM Audit
run: npm audit --audit-level=high
codeql:
name: CodeQL Analysis
runs-on: ubuntu-24.04
permissions:
security-events: write
steps:
- uses: actions/checkout@v4
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: javascript
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3

26
.github/workflows/staticAnalysis.yml vendored Normal file
View File

@@ -0,0 +1,26 @@
name: Static Analysis
on:
workflow_dispatch:
push:
branches: [ "master", "*.*-dev" ]
pull_request:
branches: [ "master", "*.*-dev" ]
jobs:
phpstan:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: redis, gd, ldap, mbstring, pdo_mysql, zip, bcmath, exif, pcntl
- name: Install Dependencies
run: make build-dev
- name: Run PHPStan Analysis
run: make phpstan

30
.github/workflows/unittests.yml vendored Normal file
View File

@@ -0,0 +1,30 @@
name: Unit Tests
on:
workflow_dispatch:
push:
branches: [ "master", "*.*-dev" ]
pull_request:
branches: [ "master", "*.*-dev" ]
jobs:
unit:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: '8.3'
extensions: redis, gd, ldap, mbstring, pdo_mysql, zip, bcmath, exif, pcntl
- name: Run Unit Tests
run: make unit-test
- name: Store screenshots
uses: actions/upload-artifact@v4
if: failure()
with:
name: unit-test
path: tests/_output

Some files were not shown because too many files have changed in this diff Show More