OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)
This commit is contained in:
49
.claude/commands/conversation-analysis-system.md
Normal file
49
.claude/commands/conversation-analysis-system.md
Normal 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.
|
||||||
273
.claude/docs/caldav-plugin.md
Normal file
273
.claude/docs/caldav-plugin.md
Normal 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`
|
||||||
974
.claude/docs/google-calendar-plugin.md
Normal file
974
.claude/docs/google-calendar-plugin.md
Normal 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
34
.dev/dev-apache-site.conf
Normal 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
225
.dev/dev-apache2.conf
Normal 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
15
.dev/dev-ports.conf
Normal 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
|
||||||
27
.dev/docker-compose.tests.yaml
Normal file
27
.dev/docker-compose.tests.yaml
Normal 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
110
.dev/docker-compose.yaml
Normal 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 44300–44399)
|
||||||
|
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
68
.dev/dockerfile
Normal 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
3
.dev/error_reporting.ini
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
error_reporting=E_ALL
|
||||||
|
display_errors=On
|
||||||
|
phar.readonly=Off
|
||||||
583
.dev/onebot-bak/DefaultConfig.php.bak
Normal file
583
.dev/onebot-bak/DefaultConfig.php.bak
Normal 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;
|
||||||
|
}
|
||||||
30
.dev/onebot-bak/Entry.php.bak
Normal file
30
.dev/onebot-bak/Entry.php.bak
Normal 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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
328
.dev/onebot-bak/Mailer.php.bak
Normal file
328
.dev/onebot-bak/Mailer.php.bak
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
1104
.dev/onebot-bak/Theme.php.bak
Normal file
1104
.dev/onebot-bak/Theme.php.bak
Normal file
File diff suppressed because it is too large
Load Diff
67
.dev/onebot-bak/app.blade.php.bak
Normal file
67
.dev/onebot-bak/app.blade.php.bak
Normal 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')"
|
||||||
|
> </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>
|
||||||
67
.dev/onebot-bak/entry.blade.php.bak
Normal file
67
.dev/onebot-bak/entry.blade.php.bak
Normal 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>
|
||||||
55
.dev/onebot-bak/error.blade.php.bak
Normal file
55
.dev/onebot-bak/error.blade.php.bak
Normal 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
74
.dev/onebot-bom-i18n.py
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""追加 BOM 模块的语言翻译 key(zh-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
111
.dev/onebot-bom-lint.py
Normal 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
63
.dev/onebot-rebrand.py
Normal 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)
|
||||||
56
.dev/remote-deploy/01_unpack_db.sh
Normal file
56
.dev/remote-deploy/01_unpack_db.sh
Normal 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 "=== 全部完成 ==="
|
||||||
18
.dev/remote-deploy/02_install_mysql.sh
Normal file
18
.dev/remote-deploy/02_install_mysql.sh
Normal 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
|
||||||
21
.dev/remote-deploy/03_init_db.sh
Normal file
21
.dev/remote-deploy/03_init_db.sh
Normal 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
|
||||||
48
.dev/remote-deploy/04_write_env.sh
Normal file
48
.dev/remote-deploy/04_write_env.sh
Normal 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"
|
||||||
14
.dev/remote-deploy/05_composer.sh
Normal file
14
.dev/remote-deploy/05_composer.sh
Normal 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
|
||||||
13
.dev/remote-deploy/06_npm_install.sh
Normal file
13
.dev/remote-deploy/06_npm_install.sh
Normal 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"
|
||||||
8
.dev/remote-deploy/07_nginx.sh
Normal file
8
.dev/remote-deploy/07_nginx.sh
Normal 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"
|
||||||
19
.dev/remote-deploy/08_npm_build.sh
Normal file
19
.dev/remote-deploy/08_npm_build.sh
Normal 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"
|
||||||
19
.dev/remote-deploy/09_storage.sh
Normal file
19
.dev/remote-deploy/09_storage.sh
Normal 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"
|
||||||
17
.dev/remote-deploy/10_verify.sh
Normal file
17
.dev/remote-deploy/10_verify.sh
Normal 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
|
||||||
12
.dev/remote-deploy/11_debug.sh
Normal file
12
.dev/remote-deploy/11_debug.sh
Normal 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
|
||||||
27
.dev/remote-deploy/12_diag.sh
Normal file
27
.dev/remote-deploy/12_diag.sh
Normal 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
|
||||||
22
.dev/remote-deploy/13_debug_on.sh
Normal file
22
.dev/remote-deploy/13_debug_on.sh
Normal 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"
|
||||||
17
.dev/remote-deploy/14_realdbg.sh
Normal file
17
.dev/remote-deploy/14_realdbg.sh
Normal 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
|
||||||
20
.dev/remote-deploy/15_verify2.sh
Normal file
20
.dev/remote-deploy/15_verify2.sh
Normal 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
|
||||||
27
.dev/remote-deploy/16_parse_test.sh
Normal file
27
.dev/remote-deploy/16_parse_test.sh
Normal 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
|
||||||
24
.dev/remote-deploy/17_verify3.sh
Normal file
24
.dev/remote-deploy/17_verify3.sh
Normal 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
|
||||||
9
.dev/remote-deploy/18_install_probe.sh
Normal file
9
.dev/remote-deploy/18_install_probe.sh
Normal 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
|
||||||
9
.dev/remote-deploy/19_install_form.sh
Normal file
9
.dev/remote-deploy/19_install_form.sh
Normal 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
|
||||||
16
.dev/remote-deploy/20_install.sh
Normal file
16
.dev/remote-deploy/20_install.sh
Normal 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
|
||||||
13
.dev/remote-deploy/21_error_log.sh
Normal file
13
.dev/remote-deploy/21_error_log.sh
Normal 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 表"
|
||||||
26
.dev/remote-deploy/22_cli_setupdb.sh
Normal file
26
.dev/remote-deploy/22_cli_setupdb.sh
Normal 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
|
||||||
13
.dev/remote-deploy/23_readlog.sh
Normal file
13
.dev/remote-deploy/23_readlog.sh
Normal 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
|
||||||
26
.dev/remote-deploy/24_fix_env.sh
Normal file
26
.dev/remote-deploy/24_fix_env.sh
Normal 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/
|
||||||
12
.dev/remote-deploy/25_check_tables.sh
Normal file
12
.dev/remote-deploy/25_check_tables.sh
Normal 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
|
||||||
11
.dev/remote-deploy/26_activate_admin.sh
Normal file
11
.dev/remote-deploy/26_activate_admin.sh
Normal 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
|
||||||
18
.dev/remote-deploy/27_verify_site.sh
Normal file
18
.dev/remote-deploy/27_verify_site.sh
Normal 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
|
||||||
33
.dev/remote-deploy/28_login_test.sh
Normal file
33
.dev/remote-deploy/28_login_test.sh
Normal 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
|
||||||
34
.dev/remote-deploy/29_final_check.sh
Normal file
34
.dev/remote-deploy/29_final_check.sh
Normal 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
|
||||||
17
.dev/remote-deploy/30_leantime_context.sh
Normal file
17
.dev/remote-deploy/30_leantime_context.sh
Normal 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"
|
||||||
11
.dev/remote-deploy/31_check_ip.sh
Normal file
11
.dev/remote-deploy/31_check_ip.sh
Normal 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"
|
||||||
17
.dev/remote-deploy/32_fix_url.sh
Normal file
17
.dev/remote-deploy/32_fix_url.sh
Normal 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
|
||||||
15
.dev/remote-deploy/33_firewall.sh
Normal file
15
.dev/remote-deploy/33_firewall.sh
Normal 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
|
||||||
12
.dev/remote-deploy/34_update_admin.sh
Normal file
12
.dev/remote-deploy/34_update_admin.sh
Normal 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
|
||||||
23
.dev/remote-deploy/35_login_final.sh
Normal file
23
.dev/remote-deploy/35_login_final.sh
Normal 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
|
||||||
85
.dev/remote-deploy/36_teable_paste.sh
Normal file
85
.dev/remote-deploy/36_teable_paste.sh
Normal 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 "=== 全部完成 ==="
|
||||||
33
.dev/remote-deploy/37_diag.sh
Normal file
33
.dev/remote-deploy/37_diag.sh
Normal 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 ""
|
||||||
39
.dev/remote-deploy/38_diag2.sh
Normal file
39
.dev/remote-deploy/38_diag2.sh
Normal 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 ""
|
||||||
40
.dev/remote-deploy/38b_diag3.sh
Normal file
40
.dev/remote-deploy/38b_diag3.sh
Normal 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
|
||||||
37
.dev/remote-deploy/39_deploy_v2.sh
Normal file
37
.dev/remote-deploy/39_deploy_v2.sh
Normal 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"
|
||||||
57
.dev/remote-deploy/40_verify_v2.sh
Normal file
57
.dev/remote-deploy/40_verify_v2.sh
Normal 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 "=== 全部完成 ==="
|
||||||
26
.dev/remote-deploy/41_test_attach.sh
Normal file
26
.dev/remote-deploy/41_test_attach.sh
Normal 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 不可达"
|
||||||
20
.dev/remote-deploy/42_dl_handsontable.sh
Normal file
20
.dev/remote-deploy/42_dl_handsontable.sh
Normal 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"
|
||||||
49
.dev/remote-deploy/43_verify_hot.sh
Normal file
49
.dev/remote-deploy/43_verify_hot.sh
Normal 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 "=== 完成 ==="
|
||||||
22
.dev/remote-deploy/44_verify_fullscreen.sh
Normal file
22
.dev/remote-deploy/44_verify_fullscreen.sh
Normal 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
|
||||||
59
.dev/remote-deploy/45_verify_ref.sh
Normal file
59
.dev/remote-deploy/45_verify_ref.sh
Normal 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 "=== 完成 ==="
|
||||||
41
.dev/remote-deploy/48_verify_refs_view.sh
Normal file
41
.dev/remote-deploy/48_verify_refs_view.sh
Normal 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 "=== 完成 ==="
|
||||||
32
.dev/remote-deploy/51_verify_global.sh
Normal file
32
.dev/remote-deploy/51_verify_global.sh
Normal 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 "=== 完成 ==="
|
||||||
33
.dev/remote-deploy/64_diag_data.sh
Normal file
33
.dev/remote-deploy/64_diag_data.sh
Normal 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";
|
||||||
|
}
|
||||||
|
'
|
||||||
8
.dev/remote-deploy/70_diag_attach.sh
Normal file
8
.dev/remote-deploy/70_diag_attach.sh
Normal 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
|
||||||
21
.dev/remote-deploy/80_test_save.sh
Normal file
21
.dev/remote-deploy/80_test_save.sh
Normal 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
|
||||||
52
.dev/remote-deploy/onebot-nginx.conf
Normal file
52
.dev/remote-deploy/onebot-nginx.conf
Normal 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
142
.dev/test.env
Normal 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
10
.dev/xdebug.ini
Normal 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
131
.docker/Dockerfile
Normal 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
23
.docker/config/custom.ini
Normal 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
86
.docker/config/nginx.conf
Normal 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]\.";
|
||||||
|
}
|
||||||
|
}
|
||||||
28
.docker/config/php-fpm.conf
Normal file
28
.docker/config/php-fpm.conf
Normal 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
|
||||||
32
.docker/config/supervisord.conf
Normal file
32
.docker/config/supervisord.conf
Normal 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
|
||||||
64
.docker/docker-compose.yml
Normal file
64
.docker/docker-compose.yml
Normal 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
51
.docker/start.sh
Normal 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
20
.editorconfig
Normal 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
62
.eslintrc
Normal 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
1
.github/CODEOWNERS
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
* @leantime/developers
|
||||||
4
.github/FUNDING.yml
vendored
Normal file
4
.github/FUNDING.yml
vendored
Normal 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
52
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal 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
|
||||||
36
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
36
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal 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
17
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal 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
31
.github/changelogConfig.yml
vendored
Normal 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
17
.github/release.yml
vendored
Normal 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
30
.github/workflows/acceptancetests.yml
vendored
Normal 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
36
.github/workflows/api-bearer-tests.yml
vendored
Normal 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
28
.github/workflows/codeStyleAnalysis.yml
vendored
Normal 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
28
.github/workflows/makefile.yml
vendored
Normal 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
174
.github/workflows/release-prepare.yml
vendored
Normal 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
81
.github/workflows/release.yml
vendored
Normal 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
63
.github/workflows/security.yml
vendored
Normal 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
26
.github/workflows/staticAnalysis.yml
vendored
Normal 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
30
.github/workflows/unittests.yml
vendored
Normal 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
Reference in New Issue
Block a user