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

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

View File

@@ -0,0 +1,85 @@
<?php
namespace Leantime\Domain\Files\Controllers;
use Leantime\Core\Auth\Permissions\RequiresPermission;
use Leantime\Core\Controller\Controller;
use Leantime\Core\Controller\Frontcontroller;
use Leantime\Domain\Files\Permissions\FilesPermissions;
use Leantime\Domain\Files\Services\Files as FileService;
use Symfony\Component\HttpFoundation\Response;
class Browse extends Controller
{
private FileService $filesService;
/**
* Initializes dependencies.
*/
public function init(
FileService $filesService
): void {
$this->filesService = $filesService;
}
/**
* Displays the file browser.
*
* @param array $params Request parameters
*
* @throws \Exception
*/
#[RequiresPermission(FilesPermissions::VIEW)]
public function get(array $params): Response
{
$this->assignTemplateVars();
return $this->tpl->display('files.browse');
}
/**
* Handles file uploads and deletions via POST.
*
* @param array $params Request parameters
*
* @throws \Exception
*/
#[RequiresPermission(FilesPermissions::VIEW)]
public function post(array $params): Response
{
$result = $this->filesService->handleFileAction($_POST, $_FILES, 'project', session('currentProject'));
if ($result['action'] === 'delete') {
if ($result['success'] === true) {
$this->tpl->setNotification($this->language->__('notifications.file_deleted'), 'success', 'file_deleted');
return Frontcontroller::redirect(BASE_URL.'/files/showAll'.(($_GET['modalPopUp'] ?? '') ? '?modalPopUp=true' : ''));
}
$this->tpl->setNotification($this->language->__('notifications.file_deleted_error'), 'error');
}
if ($result['action'] === 'upload') {
if ($result['success'] === true) {
$this->tpl->setNotification('notifications.file_upload_success', 'success', 'file_created');
} else {
$this->tpl->setNotification('notifications.file_upload_error', 'error');
}
}
$this->assignTemplateVars();
return $this->tpl->display('files.browse');
}
/**
* Assigns common template variables.
*/
private function assignTemplateVars(): void
{
$this->tpl->assign('currentModule', session('currentProject'));
$this->tpl->assign('modules', $this->filesService->getModules(session('userdata.id')));
$this->tpl->assign('imgExtensions', $this->filesService->getImageExtensions());
$this->tpl->assign('files', $this->filesService->getFilesByModule('project', session('currentProject')));
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace Leantime\Domain\Files\Controllers;
use Leantime\Core\Controller\Controller;
use Leantime\Domain\Files\Services\Files as FileService;
use Symfony\Component\HttpFoundation\Response;
class Get extends Controller
{
private FileService $filesService;
/**
* Initializes the controller with required dependencies.
*
* @param FileService $filesService The file service for retrieval and authorization.
*/
public function init(FileService $filesService): void
{
$this->filesService = $filesService;
}
/**
* Handles GET requests to download/view a file.
*
* Validates that the current user has access to the project the file belongs to
* before serving the file content.
*
* @return Response The file content response, 403 if unauthorized, or 404 if not found.
*
* @throws \Exception
*/
public function get(): Response
{
$rawEncName = $_GET['encName'] ?? '';
$encName = preg_replace('/[^a-zA-Z0-9]+/', '', $rawEncName);
if (empty($encName)) {
return new Response('Bad request', 400);
}
return $this->filesService->getFileForUser($encName, (int) session('userdata.id'));
}
}

View File

@@ -0,0 +1,90 @@
<?php
namespace Leantime\Domain\Files\Controllers;
use Illuminate\Contracts\Container\BindingResolutionException;
use Leantime\Core\Auth\Permissions\RequiresPermission;
use Leantime\Core\Controller\Controller;
use Leantime\Core\Controller\Frontcontroller;
use Leantime\Domain\Files\Permissions\FilesPermissions;
use Leantime\Domain\Files\Services\Files as FileService;
use Symfony\Component\HttpFoundation\Response;
class ShowAll extends Controller
{
private FileService $filesService;
/**
* Initializes dependencies.
*/
public function init(
FileService $filesService
): void {
$this->filesService = $filesService;
}
/**
* Displays all project files.
*
* @param array $params Request parameters
*
* @throws BindingResolutionException
*/
#[RequiresPermission(FilesPermissions::VIEW)]
public function get(array $params): Response
{
$currentModule = $params['id'] ?? $_GET['id'] ?? '';
$this->assignTemplateVars($currentModule);
return $this->tpl->displayPartial('files.showAll');
}
/**
* Handles file uploads and deletions via POST.
*
* @param array $params Request parameters
*
* @throws BindingResolutionException
*/
#[RequiresPermission(FilesPermissions::VIEW)]
public function post(array $params): Response
{
$currentModule = $params['id'] ?? $_GET['id'] ?? '';
$result = $this->filesService->handleFileAction($_POST, $_FILES, 'project', session('currentProject'));
if ($result['action'] === 'delete') {
if ($result['success'] === true) {
$this->tpl->setNotification($this->language->__('notifications.file_deleted'), 'success', 'file_deleted');
return Frontcontroller::redirect(BASE_URL.'/files/showAll'.(($_GET['modalPopUp'] ?? '') ? '?modalPopUp=true' : ''));
}
$this->tpl->setNotification($this->language->__('notifications.file_deleted_error'), 'error');
}
if ($result['action'] === 'upload') {
if ($result['success'] === true) {
$this->tpl->setNotification('notifications.file_upload_success', 'success', 'file_uploaded');
} else {
$this->tpl->setNotification('notifications.file_upload_error', 'error');
}
}
$this->assignTemplateVars($currentModule);
return $this->tpl->displayPartial('files.showAll');
}
/**
* Assigns common template variables.
*/
private function assignTemplateVars(string $currentModule): void
{
$this->tpl->assign('currentModule', $currentModule);
$this->tpl->assign('modules', $this->filesService->getModules(session('userdata.id')));
$this->tpl->assign('imgExtensions', $this->filesService->getImageExtensions());
$this->tpl->assign('files', $this->filesService->getFilesByModule('project', session('currentProject'), session('userdata.id')));
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace Leantime\Domain\Files\Controllers;
use Illuminate\Http\Request;
use Leantime\Domain\Files\Services\Files as FileService;
use Symfony\Component\HttpFoundation\Response;
/**
* Handles multipart file uploads (editor image paste/drop, Uppy file manager).
*
* A native Laravel controller (constructor DI, route-bound action). Relocated from the
* retired Api\Controllers\Files. Bound in Files/routes.php at the canonical /files/upload
* plus the backward-compatible /api/files alias used by Tiptap and Uppy. The dead
* paste-fallback branch, the unrelated PATCH (user-settings) handler and the 501 stubs
* from the old controller are intentionally not carried over.
*
* The success response MUST stay the raw upload() metadata array — Tiptap reads
* data.module/encName/extension/realName and Uppy reads the same off response.body.
*/
class Upload
{
public function __construct(private FileService $fileService) {}
/**
* POST — store an uploaded file against a module/moduleId (both from the query string;
* the file is the multipart field "file"). Returns the upload() metadata array as JSON.
*/
public function post(Request $request): Response
{
$module = $request->query('module');
$moduleId = $request->query('moduleId');
// Missing required parts is a client error, not a server fault.
if (! isset($_FILES['file']) || $module === null || $moduleId === null) {
return response()->json(['status' => 'error', 'message' => 'Missing file, module or moduleId'], 400);
}
$module = htmlentities($module);
$id = (int) $moduleId;
// The legacy endpoint had no project gate: a logged-in user could attach files to
// any module/moduleId by tampering with the query string. Authorize against the
// target's project (admins/owners bypass; modules with no project mapping fall back
// to the read-path behaviour in Files::getFileForUser()).
if (! $this->fileService->userCanUploadToModule($module, $id)) {
return response()->json(['status' => 'unauthorized'], 403);
}
$result = $this->fileService->upload($_FILES, $module, $id);
if (is_string($result)) {
return response()->json(['status' => 'error', 'message' => $result], 500);
}
return response()->json($result);
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace Leantime\Domain\Files\Events;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Broadcasting\PrivateChannel;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class FileUploaded
{
use Dispatchable, InteractsWithSockets, SerializesModels;
/**
* Create a new event instance.
*/
public function __construct()
{
//
}
/**
* Get the channels the event should broadcast on.
*
* @return array<int, \Illuminate\Broadcasting\Channel>
*/
public function broadcastOn(): array
{
return [
new PrivateChannel('channel-name'),
];
}
}

View File

@@ -0,0 +1,51 @@
<?php
namespace Leantime\Domain\Files\Permissions;
use Leantime\Core\Auth\Permissions\Permission;
use Leantime\Core\Auth\Permissions\ProvidesPermissions;
/**
* The Files permission vocabulary — standard project-scoped capabilities.
*
* A file's authority is the user's role *in the file's owning project*: project-module files
* use their moduleId as the projectId directly, ticket-module files resolve through the owning
* ticket. Both are resolved fail-closed by {@see \Leantime\Domain\Files\Repositories\Files::getProjectIdForFile()}
* (null when the id is missing or the module has no project context).
*
* All three verbs are standard, so they auto-grant through the project-scoped matrix rules in
* {@see \Leantime\Core\Auth\Permissions\DefaultRolePermissions} with NO matrix edit:
* - view → readonly+ (any project member may see/download a project's files)
* - upload → commenter+ (the standard `upload` verb, same as ticket/comment attachments)
* - delete → editor+ (manager+ via the project wildcard; admin/owner via scope:any)
*
* Two checks live in the service rather than the vocabulary:
* - Ownership: a file's uploader may always delete their own file regardless of role.
* - Owner-restricted modules (private/user/lead/export) have no project context — access is
* gated by the uploader check instead of a project permission.
*/
final class FilesPermissions implements ProvidesPermissions
{
/** View / download files attached to a project or project entity (ticket). Readonly+. */
public const VIEW = 'files.view';
/** Upload files to a project or project entity. Commenter+. */
public const UPLOAD = 'files.upload';
/** Delete another user's file in a project (the uploader may always delete their own). Editor+. */
public const DELETE = 'files.delete';
public function domain(): string
{
return 'files';
}
public function permissions(): array
{
return [
new Permission(self::VIEW, 'View and download project files', true),
new Permission(self::UPLOAD, 'Upload files to projects', true),
new Permission(self::DELETE, 'Delete project files', true),
];
}
}

View File

@@ -0,0 +1,324 @@
<?php
namespace Leantime\Domain\Files\Repositories;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Database\ConnectionInterface;
use Leantime\Core\Db\Db as DbCore;
use Leantime\Core\Files\Contracts\FileManagerInterface;
use Symfony\Component\HttpFoundation\File\UploadedFile;
class Files
{
public array $adminModules = ['project' => 'Projects', 'ticket' => 'Tickets', 'client' => 'Clients', 'lead' => 'Lead', 'private' => 'General']; // 'user'=>'Users',
public array $userModules = ['project' => 'Projects', 'ticket' => 'Tickets', 'private' => 'General'];
private ConnectionInterface $db;
private FileManagerInterface $fileManager;
public function __construct(DbCore $db, FileManagerInterface $fileManager)
{
$this->db = $db->getConnection();
$this->fileManager = $fileManager;
}
public function addFile(array $values, string $module): false|string
{
$id = $this->db->table('zp_file')->insertGetId([
'encName' => $values['encName'],
'realName' => $values['realName'],
'extension' => $values['extension'],
'module' => $module,
'moduleId' => $values['moduleId'],
'userId' => $values['userId'],
'date' => now(),
]);
return (string) $id;
}
public function getFile(int $id): array|false
{
$result = $this->db->table('zp_file as file')
->select(
'file.id',
'file.extension',
'file.realName',
'file.encName',
'file.date',
'file.module',
'file.moduleId',
'file.userId',
'user.firstname',
'user.lastname'
)
->join('zp_user as user', 'file.userId', '=', 'user.id')
->where('file.id', $id)
->first();
return $result ? (array) $result : false;
}
/**
* Retrieves a file record by its encoded name.
*
* @param string $encName The encoded (hashed) filename without extension.
* @return array|false The file record as an associative array, or false if not found.
*/
public function getFileByEncName(string $encName): array|false
{
$result = $this->db->table('zp_file as file')
->select(
'file.id',
'file.extension',
'file.realName',
'file.encName',
'file.date',
'file.module',
'file.moduleId',
'file.userId'
)
->where('file.encName', $encName)
->first();
return $result ? (array) $result : false;
}
/**
* Resolves the owning project id for a file record based on its module type.
*
* For 'project' module files the moduleId is the project id directly.
* For 'ticket' module files the owning ticket is looked up to find its project.
* All other module types have no project context and return null.
*
* @param array $fileRecord The file record as returned by getFileByEncName().
* @return int|null The owning project id, or null when no project context applies.
*/
public function getProjectIdForFile(array $fileRecord): ?int
{
$module = $fileRecord['module'] ?? '';
$moduleId = (int) ($fileRecord['moduleId'] ?? 0);
if ($moduleId <= 0) {
return null;
}
if ($module === 'project') {
return $moduleId;
}
if ($module === 'ticket') {
$ticket = $this->db->table('zp_tickets')
->select('projectId')
->where('id', $moduleId)
->first();
if ($ticket) {
return (int) $ticket->projectId;
}
}
return null;
}
public function getFiles(int $userId = 0): false|array
{
$query = $this->db->table('zp_file as file')
->select(
'file.id',
'file.moduleId',
'file.extension',
'file.realName',
'file.encName',
'file.date',
'file.module',
'user.firstname',
'user.lastname'
)
->join('zp_user as user', 'file.userId', '=', 'user.id');
if ($userId > 0) {
$query->where('file.userId', $userId);
}
$results = $query->orderBy('file.module')
->orderBy('file.moduleId')
->get();
return array_map(fn ($item) => (array) $item, $results->toArray());
}
public function getFolders(string $module): array
{
$folders = [];
$files = $this->getFiles(session('userdata.id'));
$table = match ($module) {
'ticket' => 'zp_tickets',
'client' => 'zp_clients',
'project' => 'zp_projects',
'lead' => 'zp_lead',
default => 'zp_tickets',
};
$titleColumn = match ($module) {
'ticket' => 'headline',
'client' => 'name',
'project' => 'name',
'lead' => 'name',
default => 'headline',
};
$ids = [];
foreach ($files as $file) {
if (! isset($ids[$file['moduleId']])) {
$result = $this->db->table($table)
->select("{$titleColumn} as title", 'id')
->where('id', $file['moduleId'])
->limit(1)
->first();
if ($result) {
$folders[] = (array) $result;
}
$ids[$file['moduleId']] = true;
}
}
return $folders;
}
public function getFilesByModule(string $module = '', ?int $moduleId = null, ?int $userId = 0): false|array
{
$query = $this->db->table('zp_file as file')
->select(
'file.id',
'file.extension',
'file.realName',
'file.encName',
'file.date',
'file.module',
'file.moduleId',
'user.firstname',
'user.lastname',
'user.id AS userId'
)
->addSelect('file.date AS rawDate')
->join('zp_user as user', 'file.userId', '=', 'user.id');
if ($module !== '') {
$query->where('file.module', $module);
} else {
$query->where('file.module', '<>', '');
}
if ($moduleId !== null) {
$query->where('file.moduleId', $moduleId);
}
if ($userId > 0) {
$query->where('file.userId', $userId);
}
$results = $query->get();
return array_map(fn ($item) => (array) $item, $results->toArray());
}
public function deleteFile(int $id): bool
{
$result = $this->db->table('zp_file')
->select('encName', 'extension')
->where('id', $id)
->first();
if ($result && isset($result->encName) && isset($result->extension)) {
// Use FileManager to delete the file
$fileName = $result->encName.'.'.$result->extension;
// Delete file from default storage
$this->fileManager->deleteFile($fileName, 'default');
}
return $this->db->table('zp_file')
->where('id', $id)
->delete() > 0;
}
/**
* @return array|false
*
* @throws BindingResolutionException
*/
public function upload(array $file, string $module, int $moduleId): false|string|array
{
// Clean module mess
if ($module === 'projects') {
$module = 'project';
}
if ($module === 'tickets') {
$module = 'ticket';
}
try {
$uploadedFile = $file['file'];
$path = $uploadedFile['name'];
$ext = pathinfo($path, PATHINFO_EXTENSION);
$realName = str_replace('.'.$ext, '', $uploadedFile['name']);
// Just something unique to avoid collision in s3 (each customer has their own folder)
$newname = md5(session('userdata.id').time());
// Create a UploadedFile instance
$symfonyFile = new UploadedFile(
$uploadedFile['tmp_name'],
$uploadedFile['name'],
$uploadedFile['type'],
$uploadedFile['error'],
true
);
// Use FileManager to upload the file.
// FIXME(phpstan-l2): the old call passed ($symfonyFile, $newname, false) but
// upload() is (UploadedFile $file, $disk = 'default') — so $newname was being used
// as the DISK name (would fail disk lookup) and `false` was ignored. upload() now
// computes the stored name itself; the $values below still use the caller's own
// $newname/$realName/$ext, which may not match the actually-stored file. Review.
$result = $this->fileManager->upload($symfonyFile);
if ($result !== false) {
$values = [
'encName' => $newname,
'realName' => $realName,
'extension' => $ext,
'moduleId' => $moduleId,
'userId' => session('userdata.id'),
'module' => $module,
'fileId' => '',
];
$fileAddResults = $this->addFile($values, $module);
if ($fileAddResults) {
$values['fileId'] = $fileAddResults;
return $values;
}
}
return false;
} catch (\Exception $e) {
report($e);
return $e->getMessage();
}
}
public function uploadCloud(string $name, string $url, string $module, int $moduleId): void
{
// Add cloud stuff here.
}
}

View File

@@ -0,0 +1,464 @@
<?php
namespace Leantime\Domain\Files\Services;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Support\Facades\Log;
use Leantime\Core\Domains\BaseService;
use Leantime\Core\Exceptions\AuthorizationException;
use Leantime\Core\Files\Exceptions\FileValidationException;
use Leantime\Core\Files\FileManager;
use Leantime\Core\Language as LanguageCore;
use Leantime\Domain\Auth\Models\Roles;
use Leantime\Domain\Auth\Services\Auth;
use Leantime\Domain\Files\Permissions\FilesPermissions;
use Leantime\Domain\Files\Repositories\Files as FileRepository;
use Symfony\Component\Filesystem\Exception\FileNotFoundException;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\Response;
/**
* @api
*/
class Files extends BaseService
{
/**
* Image file extensions treated as previewable images across the file UI.
*
* @var array<int, string>
*/
private const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'psd', 'bmp', 'tif', 'thm', 'yuv', 'webp'];
/**
* Module types whose files are restricted to the uploading user.
*
* @var array<int, string>
*/
private const OWNER_RESTRICTED_MODULES = ['private', 'user', 'lead', 'export'];
/**
* Module types whose files belong to a project (resolved by getProjectIdForFile). For these,
* an unresolvable project id — an invalid or deleted entity — must FAIL CLOSED rather than
* fall through to the non-project "allow upload" / "serve file" path.
*
* @var array<int, string>
*/
private const PROJECT_SCOPED_MODULES = ['project', 'ticket'];
public function __construct(
protected FileRepository $fileRepository,
protected FileManager $fileManager,
protected LanguageCore $language,
) {}
/**
* Lists files for a module/entity, fail-closed against the entity's owning project.
*
* Without this gate the @api method let any authenticated caller enumerate every project's
* files by guessing ids over JSON-RPC. We resolve the target's real project and require
* files.view in it (readonly+); owner-restricted listings (private/user/lead/export) are
* limited to the owner; an empty/unknown module returns [] rather than dumping the whole
* table. The 'client' module has no project mapping and stays a Clients-domain concern
* (ShowClient is admin-gated) — tracked as a follow-up with the Clients rollout; it requires
* a specific client id here so the @api method can't be called with no id to dump every
* client's files.
*
* @api
*/
public function getFilesByModule(string $module = '', $entityId = null, $userId = null): false|array
{
$projectId = $this->resolveProjectId(['module' => $module, 'moduleId' => $entityId]);
if ($projectId !== null) {
if (! $this->can(FilesPermissions::VIEW, $projectId)) {
return [];
}
} elseif (in_array($module, self::OWNER_RESTRICTED_MODULES, true)) {
// Owner-restricted listing: only the owner may enumerate their own files.
if ((int) $entityId !== $this->currentUserId()) {
return [];
}
} elseif ($module === 'client' && (int) $entityId > 0) {
// Client files have no project mapping; their authz is a Clients-domain concern
// (ShowClient is admin-gated) tracked as a follow-up. Require a SPECIFIC client id so
// this @api method can't be called with no id to dump every client's files at once.
} else {
// No project context (empty/unknown module, or 'client' with no id): refuse rather
// than dump rows.
return [];
}
return $this->fileRepository->getFilesByModule($module, $entityId, $userId);
}
/**
* @throws BindingResolutionException
*
* @api
*/
public function upload($file, $module, $moduleId, $entity = null, $disk = 'default'): array|string|false
{
try {
// Validate input parameters
if (empty($module) || empty($moduleId)) {
Log::warning('Upload attempted with missing module or moduleId', [
'module' => $module,
'moduleId' => $moduleId,
]);
throw new FileValidationException('Missing module or moduleId', FileValidationException::VALIDATION_ERROR);
}
if (! isset($file['file']) || ! is_array($file['file'])) {
throw new FileNotFoundException('File not included in request or has invalid format');
}
} catch (FileValidationException $e) {
Log::warning('File validation failed: '.$e->getMessage());
return $e->getUserMessage();
}
// Normalize module names for consistency
if ($module === 'projects') {
$module = 'project';
}
if ($module === 'tickets') {
$module = 'ticket';
}
// Authorize against the target's owning project before writing anything (commenter+;
// admin/owner bypass). This guards the JSON-RPC path, which reaches the @api upload()
// directly, without the Upload controller's userCanUploadToModule pre-check.
$targetProjectId = $this->resolveProjectId(['module' => $module, 'moduleId' => $moduleId]);
if (in_array($module, self::PROJECT_SCOPED_MODULES, true)) {
// Project-scoped target: an unresolvable project (invalid/deleted entity) fails closed,
// so a bogus id can't create an orphan file that bypasses files.upload.
if ($targetProjectId === null) {
throw new AuthorizationException;
}
$this->authorize(FilesPermissions::UPLOAD, $targetProjectId);
}
// Non-project modules (user avatar, private, ...) have no project context and preserve prior
// behavior; their flows pin moduleId server-side (e.g. ProfileImage forces the session user's id).
try {
// Validate file type with the enhanced validator
$symfonyFile = new UploadedFile(
$file['file']['tmp_name'],
$file['file']['name'],
$file['file']['type'],
$file['file']['error'],
true
);
// Validate file size before processing
if ($file['file']['size'] > FileManager::getMaximumFileUploadSize()) {
throw new FileValidationException('File exceeds maximum allowed size', FileValidationException::FILE_TOO_LARGE);
}
} catch (FileValidationException $e) {
Log::warning('File validation failed: '.$e->getMessage());
return $e->getUserMessage();
}
try {
// Create a UploadedFile instance
$symfonyFile = new UploadedFile(
$file['file']['tmp_name'],
$file['file']['name'],
$file['file']['type'],
$file['file']['error'],
(bool) config('app.debug')
);
$leantimeFile = $this->fileManager->upload($symfonyFile, $disk);
} catch (\Exception $e) {
return 'Error uploading file: '.$e->getMessage();
}
if ($leantimeFile) {
$leantimeFile['module'] = $module;
$leantimeFile['moduleId'] = $moduleId;
$fileAddResults = $this->fileRepository->addFile($leantimeFile, $module);
if ($fileAddResults) {
$leantimeFile['fileId'] = $fileAddResults;
return $leantimeFile;
}
}
return false;
}
public function getModules($id): array
{
$modules = $this->fileRepository->userModules;
if (Auth::userIsAtLeast(Roles::$admin)) {
$modules = $this->fileRepository->adminModules;
}
return $modules;
}
/**
* Delete a file. The uploader may always delete their own file; otherwise the caller needs
* files.delete (editor+) IN THE FILE'S OWN PROJECT.
*
* The old check allowed any manager+ to delete ANY file globally (no project scope) — an IDOR
* across projects. We now resolve the file's real project and require files.delete there
* (admin/owner still bypass project membership). Owner-restricted/no-project files (private,
* user avatar, ...) can only be deleted by their uploader. Denials soft-deny (return false) to
* preserve the prior contract and keep handleFileAction's success flag meaningful.
*
* @api
*/
public function deleteFile($fileId): bool
{
$file = $this->fileRepository->getFile((int) $fileId);
if (! $file) {
return false;
}
// The uploader may always delete their own file, regardless of role.
if ((int) $file['userId'] === $this->currentUserId()) {
return $this->fileRepository->deleteFile((int) $fileId);
}
$projectId = $this->resolveProjectId($file);
// Non-owner delete of a project-scoped file requires files.delete in THAT project.
if ($projectId !== null) {
if (! $this->can(FilesPermissions::DELETE, $projectId)) {
return false;
}
return $this->fileRepository->deleteFile((int) $fileId);
}
// Owner-restricted / no-project file and the caller is not the uploader: deny.
return false;
}
public function getFilePathById($fileId): false|string
{
$dbReference = $this->fileRepository->getFile($fileId);
if ($dbReference) {
return $this->fileManager->getFileUrl($dbReference['encName'].'.'.$dbReference['extension']);
}
return false;
}
public function getFileById($fileId): false|Response
{
$dbReference = $this->fileRepository->getFile($fileId);
if ($dbReference) {
return $this->fileManager->getFile($dbReference['encName'].'.'.$dbReference['extension'], $dbReference['realName']);
}
return false;
}
/**
* Returns the list of image file extensions treated as previewable images.
*
* @return array<int, string> The whitelisted image extensions.
*
* @api
*/
public function getImageExtensions(): array
{
return self::IMAGE_EXTENSIONS;
}
/**
* Determines whether a file's module type restricts access to the file owner.
*
* Files in 'private', 'user', 'lead' and 'export' modules are only accessible
* to the user who uploaded them (unless the caller is admin/owner).
*
* @param array $fileRecord The file record from the database.
* @return bool True if the module restricts access to the file owner.
*/
public function isOwnerRestrictedModule(array $fileRecord): bool
{
return in_array($fileRecord['module'] ?? '', self::OWNER_RESTRICTED_MODULES, true);
}
/**
* Resolves the owning project id for a file record based on its module type.
*
* For 'project' module files the moduleId is the project id directly.
* For 'ticket' module files the owning ticket is looked up to find its project.
* All other module types return null (handled by the owner-restriction check).
*
* @param array $fileRecord The file record from the database.
* @return int|null The owning project id, or null when no project context applies.
*/
public function resolveProjectId(array $fileRecord): ?int
{
return $this->fileRepository->getProjectIdForFile($fileRecord);
}
/**
* Authorizes the current user to upload a file against a target module/moduleId.
*
* The /api/files (now /files/upload) endpoint takes module + moduleId straight from
* the request, and Files::upload() does no access control — so without this gate a
* logged-in user could attach files to another project/ticket by tampering with the
* query string. Mirrors the read-path model in getFileForUser(): admins/owners bypass,
* project-scoped targets (project/ticket) require access to the owning project, and
* targets with no project mapping fall back to that path's (unrestricted) behaviour.
*
* Not @api: internal authorization helper for the upload controller, not a JSON-RPC method.
* Returns the same verdict the @api upload() enforces in-body, so the controller can render a
* clean 403 before invoking it.
*
* @param string $module The target module (e.g. project, ticket, wiki)
* @param int $moduleId The target entity id within that module
* @return bool True if the current user may upload to the target
*/
public function userCanUploadToModule(string $module, int $moduleId): bool
{
$projectId = $this->resolveProjectId(['module' => $module, 'moduleId' => $moduleId]);
if (in_array($module, self::PROJECT_SCOPED_MODULES, true)) {
// Project-scoped: needs files.upload (commenter+) in the owning project; admin/owner
// bypass membership. An unresolvable id (invalid/deleted entity) fails closed.
return $projectId !== null && $this->can(FilesPermissions::UPLOAD, $projectId);
}
// Non-project modules (user avatar, private, ...) keep prior behavior; their flows pin
// moduleId server-side.
return true;
}
/**
* Resolves a file by its encoded name, authorizes the CURRENT (session) user, and returns
* the file response.
*
* Project-scoped files require files.view in the owning project (readonly+; admin/owner bypass
* project membership — equivalent to the prior admin-bypass + isUserAssignedToProject check).
* Owner-restricted modules require the caller to be the uploader. Authorization always uses the
* session user, never the $userId argument: this @api method previously trusted the passed id,
* so a JSON-RPC caller could read any owner-restricted file by claiming to be its uploader.
*
* @param string $encName The encoded (hashed) filename without extension.
* @param int $userId Retained for signature/RPC compatibility; not used for authorization.
* @return Response The file content response, 403 if unauthorized, or 404 if not found.
*
* @throws \Exception
*
* @api
*/
public function getFileForUser(string $encName, int $userId): Response
{
$fileRecord = $this->fileRepository->getFileByEncName($encName);
if ($fileRecord === false) {
return new Response('File not found', 404);
}
// Use DB values instead of user-supplied params to prevent parameter tampering
$realName = $fileRecord['realName'];
$ext = $fileRecord['extension'];
$currentUserId = $this->currentUserId();
$projectId = $this->resolveProjectId($fileRecord);
if ($projectId !== null) {
if (! $this->can(FilesPermissions::VIEW, $projectId)) {
Log::warning('Unauthorized file access attempt', [
'userId' => $currentUserId,
'fileId' => $fileRecord['id'],
'projectId' => $projectId,
]);
return new Response('', 403);
}
} elseif (in_array($fileRecord['module'] ?? '', self::PROJECT_SCOPED_MODULES, true)) {
// Project-scoped file whose project can't be resolved (e.g. a deleted ticket) → deny,
// rather than fall through to the non-project serve path (fail closed).
Log::warning('Unauthorized file access attempt on orphaned project file', [
'userId' => $currentUserId,
'fileId' => $fileRecord['id'],
'module' => $fileRecord['module'],
]);
return new Response('', 403);
} elseif ($this->isOwnerRestrictedModule($fileRecord)) {
// For private/user files, only the file owner can access.
if ((int) ($fileRecord['userId'] ?? 0) !== $currentUserId) {
Log::warning('Unauthorized file access attempt on private file', [
'userId' => $currentUserId,
'fileId' => $fileRecord['id'],
'module' => $fileRecord['module'] ?? '',
]);
return new Response('', 403);
}
}
// Construct the file name from trusted DB values
$fileName = $encName.'.'.$ext;
$response = $this->fileManager->getFile($fileName, $realName);
if ($response === false) {
return new Response('File not found', 404);
}
return $response;
}
/**
* Handles the upload/delete POST action for the file browser controllers.
*
* Dispatches based on the submitted POST/FILES payload and returns a structured
* result describing the action taken and its outcome so the controller can render
* the appropriate notification (and redirect after a successful delete).
*
* @param array $post The POST payload (expects 'delFile' and/or 'upload').
* @param array $files The FILES payload (expects 'file').
* @param string $module The module the upload belongs to (e.g. 'project').
* @param int|string|null $moduleId The module entity id the upload belongs to.
* @return array{action: string|null, success: bool} The action ('delete'|'upload'|null) and whether it succeeded.
*
* @throws BindingResolutionException
*
* Not @api: an internal controller helper shaped around $_POST/$_FILES, called in-process by the
* Browse/ShowAll controllers. It is deliberately NOT JSON-RPC reachable — its delegates
* (deleteFile/upload) self-authorize, but the request-array signature is not an API surface.
*/
public function handleFileAction(array $post, array $files, string $module, int|string|null $moduleId): array
{
if (isset($post['delFile'])) {
return [
'action' => 'delete',
'success' => $this->deleteFile($post['delFile']),
];
}
if (isset($post['upload']) || isset($files['file'])) {
if (isset($files['file'])) {
try {
$result = $this->upload($files, $module, $moduleId);
// @phpstan-ignore-next-line catch.neverThrown — upload() throws AuthorizationException (Files.php:136); PHPStan can't track it through the call.
} catch (AuthorizationException) {
// A denied upload becomes a clean "upload failed" notification, not a 403 page.
return ['action' => 'upload', 'success' => false];
}
// upload() returns the file metadata array on success, or a string/false on failure.
return ['action' => 'upload', 'success' => is_array($result)];
}
return ['action' => 'upload', 'success' => false];
}
return ['action' => null, 'success' => false];
}
}

View File

@@ -0,0 +1,307 @@
@extends($layout)
@section('content')
@php
$maxSize = \Leantime\Core\Files\FileManager::getMaximumFileUploadSize();
$moduleId = session('currentProject');
@endphp
<div class="pageheader">
<div class="pageicon"><span class="fa fa-fw fa-file"></span></div>
<div class="pagetitle">
<h5>{{ session('currentProjectName') }}</h5>
<h1>{!! __('headlines.files') !!}</h1>
</div>
</div><!--pageheader-->
<div class="maincontent">
<div id="fileManager">
<div class="maincontentinner">
{!! $tpl->displayNotification() !!}
<h5 class="subtitle">{!! __('headline.browse_files_headline') !!}</h5>
@if ($login::userIsAtLeast($roles::$editor))
<div class="uploadWrapper">
<x-global::forms.button tag="a" contentRole="default" id="cancelLink" link="javascript:void(0);" style="display:none;">{!! __('links.cancel') !!}</x-global::forms.button>
<div class="extra" style="margin-top:5px;"></div>
<div class="fileUploadDrop">
<p><i>{!! __('text.drop_files') !!}</i></p>
<div class="file-upload-input" style="margin:auto; display:inline-block"></div>
<a href="javascript:void(0);" id="webcamClick">{!! __('label.webcam') !!}</a>
<a href="javascript:void(0);" id="screencaptureLink">{!! __('label.screen_recording') !!}</a>
</div>
<!-- Progress bar #1 -->
<div class="input-progress"></div>
<div class="input-error"></div>
<form id="upload-form"></form>
</div>
@endif
</div>
<div class="maincontentinner">
<div class='mediamgr'>
<div class="mediamgr_content">
<ul id='medialist' class='listfile'>
@foreach ($files as $file)
<li class="file-module-{{ $file['moduleId'] }}">
<div class="inlineDropDownContainer dropright" style="float:right;">
<a href="javascript:void(0);" class="dropdown-toggle ticketDropDown" data-toggle="dropdown">
<i class="fa fa-ellipsis-v" aria-hidden="true"></i>
</a>
<ul class="dropdown-menu">
<li class="nav-header">{!! __('subtitles.file') !!}</li>
<li><a target="_blank" href="{{ BASE_URL }}/files/get?module={{ $file['module'] }}&encName={{ $file['encName'] }}&ext={{ $file['extension'] }}&realName={{ $file['realName'] }}">{!! __('links.download') !!}</a></li>
@if ($login::userIsAtLeast($roles::$editor))
<li>
<form method="post" action="{{ BASE_URL }}/files/browse" class="deleteFile" onsubmit="return confirm('{{ __('text.confirm_delete') }}')">
@csrf
<input type="hidden" name="delFile" value="{{ $file['id'] }}" />
<button type="submit" class="delete" style="background:none;border:none;cursor:pointer;padding:3px 20px;width:100%;text-align:left;"><i class="fa fa-trash"></i> {!! __('links.delete') !!}</button>
</form>
</li>
@endif
</ul>
</div>
<a class="imageLink" data-ext="{{ $file['extension'] }}" href="{{ BASE_URL }}/files/get?module={{ $file['module'] }}&encName={{ $file['encName'] }}&ext={{ $file['extension'] }}&realName={{ $file['realName'] }}">
@if (in_array(strtolower($file['extension']), $imgExtensions))
<img style='max-height: 50px; max-width: 70px;' src="{{ BASE_URL }}/files/get?module={{ $file['module'] }}&encName={{ $file['encName'] }}&ext={{ $file['extension'] }}&realName={{ $file['realName'] }}" alt="" />
@else
<img style='max-height: 50px; max-width: 70px;' src='{{ BASE_URL }}/dist/images/doc.png' />
@endif
<span class="filename" title="{{ $file['realName'] }}.{{ $file['extension'] }}">{{ $file['realName'] }}.{{ $file['extension'] }}</span>
</a>
</li>
@endforeach
<br class="clearall" />
</ul>
<br class="clearall" />
</div><!--mediamgr_content-->
<br class="clearall" />
</div><!--mediamgr-->
</div>
</div>
</div>
@once
@push('scripts')
<script type='text/javascript'>
jQuery(document).ready(function(){
jQuery('#widgetAction').click(function(){
jQuery('.widgetList').toggle();
});
jQuery('#widgetAction2').click(function(){
jQuery('.widgetList2').toggle();
});
});
</script>
<script type="text/javascript">
jQuery(document).ready(function(){
let modalTypes = ["jpg", "jpeg", "png", "gif", "apng", "webp", "avif"];
jQuery(".imageLink").each(function(i) {
let ext = jQuery(this).attr("data-ext");
if(modalTypes.includes(ext)) {
jQuery(this).nyroModal();
}
});
//Replaces data-rel attribute to rel.
//We use data-rel because of w3c validation issue
jQuery('a[data-rel]').each(function() {
jQuery(this).attr('rel', jQuery(this).data('rel'));
});
//jQuery("#medialist a").colorbox();
@if (isset($_GET['modalPopUp']))
jQuery('#medialist a.imageLink').on("click", function(event){
event.preventDefault();
event.stopImmediatePropagation();
var url = jQuery(this).attr("href");
//File picker upload callback from editor
window.filePickerCallback(url, {text: "file"});
jQuery.nmTop().close();
});
jQuery(".deleteFile").nyroModal();
@endif
// Media Filter
jQuery('#mediafilter a').on("click", function(){
var filter = (jQuery(this).attr('href') != 'all')? '.'+jQuery(this).attr('href') : '*';
jQuery('#medialist').isotope({ filter: filter });
jQuery('#mediafilter li').removeClass('current');
jQuery(this).parent().addClass('current');
return false;
});
});
</script>
<script>
if (typeof uppy === 'undefined') {
const uppy = new Uppy.Uppy({
debug: false,
autoProceed: true,
restrictions: {
maxFileSize: {{ $maxSize }}
}
});
uppy.use(Uppy.DropTarget, { target: '#fileManager' });
uppy.use(Uppy.FileInput, {
target: '.file-upload-input',
pretty: true,
locale: {
strings: {
chooseFiles: ' Browse',
}
}
});
uppy.use(Uppy.XHRUpload, {
endpoint: '{{ BASE_URL }}/api/files?module=project&moduleId={{ $moduleId }}',
formData: true,
fieldName: 'file',
});
uppy.use(Uppy.StatusBar, {
target: '.input-progress',
hideUploadButton: true,
hideAfterFinish: false,
});
uppy.use(Uppy.Form, { target: '#upload-form' });
uppy.use(Uppy.Compressor);
// Upload
uppy.on("restriction-failed", (file, error) => {
jQuery(".input-error").html("<span class='label-important'>"+error+"</span>");
return false
});
uppy.on('upload-success', (file, response) => {
jQuery(".input-error").text('');
response = response.body;
if(response.hasOwnProperty("moduleId")){
let html = '<li class="file-module-'+response.moduleId+'">' +
'<div class="inlineDropDownContainer dropright" style="float:right;">' +
'<a href="javascript:void(0);" class="dropdown-toggle ticketDropDown" data-toggle="dropdown">' +
'<i class="fa fa-ellipsis-v" aria-hidden="true"></i>' +
'</a>' +
'<ul class="dropdown-menu">' +
'<li class="nav-header">{!! __("subtitles.file") !!}</li>' +
'<li><a target="_blank" href="{{ BASE_URL }}/files/get?module='+ response.module +'&encName='+ response.encName +'&ext='+ response.extension +'&realName='+ response.realName +'">{!! str_replace("'", '"', __("links.download")) !!}</a></li>'+
@if ($login::userIsAtLeast($roles::$editor))
'<li><form method="post" action="{{ BASE_URL }}/files/browse" class="deleteFile" onsubmit="return confirm(\'{{ __("text.confirm_delete") }}\')"><input type="hidden" name="_token" value="'+ jQuery('meta[name=csrf-token]').attr('content') +'" /><input type="hidden" name="delFile" value="'+ response.fileId +'" /><button type="submit" class="delete" style="background:none;border:none;cursor:pointer;padding:3px 20px;width:100%;text-align:left;"><i class="fa fa-trash"></i> {!! str_replace("'", '"', __("links.delete")) !!}</button></form></li>'+
@endif
'</ul>'+
'</div>'+
'<a class="imageLink" href="{{ BASE_URL }}/files/get?module='+ response.module +'&encName='+ response.encName +'&ext='+ response.extension +'&realName='+ response.realName +'">'+
'<img style="max-height: 50px; max-width: 70px;" src="{{ BASE_URL }}/files/get?module='+ response.module +'&encName='+ response.encName +'&ext='+ response.extension +'&realName='+ response.realName +'" alt="" />'+
'<span class="filename" title="'+response.realName+'.'+response.extension+'">'+response.realName+'.'+response.extension+'</span>'+
'</a>'+
'</li>';
jQuery("#medialist").append(html);
}
});
jQuery("#webcamClick").click(function(){
jQuery(".uploadWrapper .extra").css("display", "flex");
uppy.use(Uppy.Webcam, { target: '.extra' });
jQuery("#cancelLink").show();
});
jQuery("#screencaptureLink").click(function(){
jQuery(".uploadWrapper .extra").css("display", "flex");
uppy.use(Uppy.ScreenCapture,
{
displayMediaConstraints: {
video: {
width: 1280,
height: 720,
frameRate: {
ideal: 3,
max: 5,
},
cursor: 'motion',
displaySurface: 'window',
},
},
target: '.extra'
});
jQuery("#cancelLink").show();
});
jQuery("#cancelLink").click(function(){
const instance = uppy.getPlugin('Webcam');
if(instance) {
uppy.removePlugin(instance);
}
const instance2 = uppy.getPlugin('ScreenCapture');
if(instance2) {
uppy.removePlugin(instance2);
}
jQuery("#cancelLink").hide();
jQuery(".uploadWrapper .extra").css("display", "none");
});
}
</script>
@endpush
@endonce
@endsection

View File

@@ -0,0 +1,140 @@
@extends($layout)
@section('content')
<div id="fileManager">
<div>
{!! $tpl->displayNotification() !!}
<div class='mediamgr'>
<div class='mediamgr_left'>
<div class="mediamgr_category">
<form action='{{ BASE_URL }}/files/showAll{{ isset($_GET['modalPopUp']) ? '?modalPopUp=true' : '' }}' method='post' enctype="multipart/form-data" class="fileModal" >
<div class="par f-left" style="margin-right: 15px;">
<div class='fileupload fileupload-new' data-provides='fileupload'>
<input type="hidden" />
<div class="input-append">
<div class="uneditable-input span3">
<i class="fa-file fileupload-exists"></i><span class="fileupload-preview"></span>
</div>
<span class="btn btn-file">
<span class="fileupload-new">Select file</span>
<span class='fileupload-exists'>Change</span>
<input type='file' name='file' />
</span>
<a href='#' class='btn fileupload-exists' data-dismiss='fileupload'>Remove</a>
</div>
</div>
</div>
<x-global::forms.button tag="input" inputType="submit" contentRole="primary" :labelText="__('UPLOAD')" name="upload" />
</form>
</div>
<div class="mediamgr_content">
<ul id='medialist' class='listfile'>
@foreach ($files as $file)
<li class="{{ $file['moduleId'] }}">
<div class="inlineDropDownContainer dropright" style="float:right;">
<a href="javascript:void(0);" class="dropdown-toggle ticketDropDown" data-toggle="dropdown">
<i class="fa fa-ellipsis-v" aria-hidden="true"></i>
</a>
<ul class="dropdown-menu">
<li class="nav-header">{!! __('subtitles.file') !!}</li>
<li><a target="_blank" href="{{ BASE_URL }}/files/get?module={{ $file['module'] }}&encName={{ $file['encName'] }}&ext={{ $file['extension'] }}&realName={{ $file['realName'] }}">{!! __('links.download') !!}</a></li>
@if ($login::userIsAtLeast($roles::$editor))
<li>
<form method="post" action="{{ BASE_URL }}/files/showAll" class="deleteFile" onsubmit="return confirm('{{ __('text.confirm_delete') }}')">
@csrf
<input type="hidden" name="delFile" value="{{ $file['id'] }}" />
<button type="submit" class="delete" style="background:none;border:none;cursor:pointer;padding:3px 20px;width:100%;text-align:left;"><i class="fa fa-trash"></i> {!! __('links.delete') !!}</button>
</form>
</li>
@endif
</ul>
</div>
<a class="imageLink" href="{{ BASE_URL }}/files/get?module={{ $file['module'] }}&encName={{ $file['encName'] }}&ext={{ $file['extension'] }}&realName={{ $file['realName'] }}">
@if (in_array(strtolower($file['extension']), $imgExtensions))
<img style='max-height: 50px; max-width: 70px;' src="{{ BASE_URL }}/files/get?module={{ $file['module'] }}&encName={{ $file['encName'] }}&ext={{ $file['extension'] }}&realName={{ $file['realName'] }}" alt="" />
@else
<img style='max-height: 50px; max-width: 70px;' src='{{ BASE_URL }}/dist/images/thumbs/doc.png' />
@endif
<span class="filename" title="{{ $file['realName'] }}.{{ $file['extension'] }}">{{ $file['realName'] }}.{{ $file['extension'] }}</span>
</a>
</li>
@endforeach
<br class="clearall" />
</ul>
<br class="clearall" />
</div><!--mediamgr_content-->
</div><!--mediamgr_left -->
<br class="clearall" />
</div><!--mediamgr-->
</div>
</div>
@once
@push('scripts')
<script type="text/javascript">
jQuery(document).ready(function(){
//Replaces data-rel attribute to rel.
//We use data-rel because of w3c validation issue
jQuery('a[data-rel]').each(function() {
jQuery(this).attr('rel', jQuery(this).data('rel'));
});
//jQuery("#medialist a").colorbox();
@if (isset($_GET['modalPopUp']))
jQuery('#medialist a.imageLink').on("click", function(event){
event.preventDefault();
event.stopImmediatePropagation();
var url = jQuery(this).attr("href");
//File picker upload callback from editor
window.filePickerCallback(url, {text: "file"});
jQuery.nmTop().close();
});
@endif
// Media Filter
jQuery('#mediafilter a').on("click", function(){
var filter = (jQuery(this).attr('href') != 'all')? '.'+jQuery(this).attr('href') : '*';
jQuery('#medialist').isotope({ filter: filter });
jQuery('#mediafilter li').removeClass('current');
jQuery(this).parent().addClass('current');
return false;
});
jQuery(".deleteFile").nyroModal();
});
</script>
@endpush
@endonce
@endsection

View File

@@ -0,0 +1,306 @@
@php
$module = \Leantime\Core\Controller\Frontcontroller::getModuleName('');
$maxSize = \Leantime\Core\Files\FileManager::getMaximumFileUploadSize();
$moduleId = $_GET['id'] ?? '';
@endphp
<div id="fileManager">
{!! $tpl->displayNotification() !!}
<div class="uploadWrapper">
<x-global::forms.button tag="a" contentRole="default" id="cancelLink" link="javascript:void(0);" style="display:none;">{!! __('links.cancel') !!}</x-global::forms.button>
<div class="extra" style="margin-top:5px;"></div>
<div class="fileUploadDrop">
<p><i>{!! __('text.drop_files') !!}</i></p>
<div class="file-upload-input" style="margin:auto; display:inline-block"></div>
<a href="javascript:void(0);" id="webcamClick">{!! __('label.webcam') !!}</a>
<a href="javascript:void(0);" id="screencaptureLink">{!! __('label.screen_recording') !!}</a>
</div>
<!-- Progress bar #1 -->
<div class="input-progress"></div>
<div class="input-error"></div>
<form id="upload-form"></form>
</div>
<div class='mediamgr'>
<div class="mediamgr_content">
<ul id='medialist' class='listfile'>
@foreach ($files as $file)
<li class="file-module-{{ $file['moduleId'] }}">
<div class="inlineDropDownContainer dropright" style="float:right;">
<a href="javascript:void(0);" class="dropdown-toggle ticketDropDown" data-toggle="dropdown">
<i class="fa fa-ellipsis-v" aria-hidden="true"></i>
</a>
<ul class="dropdown-menu">
<li class="nav-header">{!! __('subtitles.file') !!}</li>
<li><a target="_blank" href="{{ BASE_URL }}/files/get?module={{ $file['module'] }}&encName={{ $file['encName'] }}&ext={{ $file['extension'] }}&realName={{ $file['realName'] }}">{!! __('links.download') !!}</a></li>
@if ($login::userIsAtLeast($roles::$editor))
<li>
<form method="post" action="{{ BASE_URL }}/files/showAll" class="deleteFile" onsubmit="return confirm('{{ __('text.confirm_delete') }}')">
@csrf
<input type="hidden" name="delFile" value="{{ $file['id'] }}" />
<button type="submit" class="delete" style="background:none;border:none;cursor:pointer;padding:3px 20px;width:100%;text-align:left;"><i class="fa fa-trash"></i> {!! __('links.delete') !!}</button>
</form>
</li>
@endif
</ul>
</div>
<a class="imageLink" data-ext="{{ $file['extension'] }}" href="{{ BASE_URL }}/files/get?module={{ $file['module'] }}&encName={{ $file['encName'] }}&ext={{ $file['extension'] }}&realName={{ $file['realName'] }}">
@if (in_array(strtolower($file['extension']), $imgExtensions ?? []))
<img style='max-height: 50px; max-width: 70px;' src="{{ BASE_URL }}/files/get?module={{ $file['module'] }}&encName={{ $file['encName'] }}&ext={{ $file['extension'] }}&realName={{ $file['realName'] }}" alt="" />
@else
<img style='max-height: 50px; max-width: 70px;' src='{{ BASE_URL }}/dist/images/doc.png' />
@endif
<span class="filename" title="{{ $file['realName'] }}.{{ $file['extension'] }}">{{ $file['realName'] }}.{{ $file['extension'] }}</span>
</a>
</li>
@endforeach
<br class="clearall" />
</ul>
<br class="clearall" />
</div><!--mediamgr_content-->
<br class="clearall" />
</div><!--mediamgr-->
</div>
<script type='text/javascript'>
jQuery(document).ready(function(){
jQuery('#widgetAction').click(function(){
jQuery('.widgetList').toggle();
});
jQuery('#widgetAction2').click(function(){
jQuery('.widgetList2').toggle();
});
});
</script>
<script type="text/javascript">
jQuery(document).ready(function(){
let modalTypes = ["jpg", "jpeg", "png", "gif", "apng", "webp", "avif"];
jQuery(".imageLink").each(function(i) {
let ext = jQuery(this).attr("data-ext");
if(modalTypes.includes(ext)) {
jQuery(this).nyroModal();
}
});
//Replaces data-rel attribute to rel.
//We use data-rel because of w3c validation issue
jQuery('a[data-rel]').each(function() {
jQuery(this).attr('rel', jQuery(this).data('rel'));
});
//jQuery("#medialist a").colorbox();
@if (isset($_GET['modalPopUp']))
jQuery('#medialist a.imageLink').on("click", function(event){
event.preventDefault();
event.stopImmediatePropagation();
var url = jQuery(this).attr("href");
//File picker upload callback from editor
window.filePickerCallback(url, {text: "file"});
jQuery.nmTop().close();
});
@endif
// Media Filter
jQuery('#mediafilter a').on("click", function(){
var filter = (jQuery(this).attr('href') != 'all')? '.'+jQuery(this).attr('href') : '*';
jQuery('#medialist').isotope({ filter: filter });
jQuery('#mediafilter li').removeClass('current');
jQuery(this).parent().addClass('current');
return false;
});
jQuery(".deleteFile").nyroModal();
});
</script>
<script>
if (typeof uppy === 'undefined') {
const uppy = new Uppy.Uppy({
debug: false,
autoProceed: true,
restrictions: {
maxFileSize: {{ $maxSize }}
}
});
uppy.use(Uppy.DropTarget, { target: '#fileManager' });
uppy.use(Uppy.FileInput, {
target: '.file-upload-input',
pretty: true,
locale: {
strings: {
chooseFiles: ' Browse',
}
}
});
uppy.use(Uppy.XHRUpload, {
endpoint: '{{ BASE_URL }}/api/files?module={{ $module }}&moduleId={{ $moduleId }}',
formData: true,
});
uppy.use(Uppy.StatusBar, {
target: '.input-progress',
hideUploadButton: true,
hideAfterFinish: false,
});
//uppy.use(Uppy.Webcam, { target: '.extra' });
//uppy.use(Uppy.ProgressBar, { target: '.input-progress', hideAfterFinish: true });
//uppy.use(Uppy.Audio, { target: '.extra', showRecordingLength: true });
//uppy.use(Uppy.ScreenCapture, { target: '.extra' });
uppy.use(Uppy.Form, { target: '#upload-form' });
//uppy.use(Uppy.ImageEditor, { target: '.extra' });
// Allow dropping files on any element or the whole document
// Optimize images
uppy.use(Uppy.Compressor);
/*
uppy.use(Uppy.ThumbnailGenerator, {
id: 'ThumbnailGenerator',
thumbnailWidth: 200,
thumbnailHeight: 200,
thumbnailType: 'image/jpeg',
waitForThumbnailsBeforeUpload: false,
});
uppy.on('thumbnail:generated', (file, preview) => {
const img = document.createElement('img')
img.src = preview;
img.width = 100;
document.body.appendChild(img);
});*/
// Upload
uppy.on("restriction-failed", (file, error) => {
jQuery(".input-error").html("<span class='label-important'>"+error+"</span>");
return false
});
uppy.on('upload-success', (file, response) => {
jQuery(".input-error").text('');
response = response.body;
if(response.hasOwnProperty("moduleId")){
/*
//window.location.hash = "files";
//window.location.reload();*/
let html = '<li class="file-module-'+response.moduleId+'">' +
'<div class="inlineDropDownContainer dropright" style="float:right;">' +
'<a href="javascript:void(0);" class="dropdown-toggle ticketDropDown" data-toggle="dropdown">' +
'<i class="fa fa-ellipsis-v" aria-hidden="true"></i>' +
'</a>' +
'<ul class="dropdown-menu">' +
'<li class="nav-header">{!! __('subtitles.file') !!}</li>' +
'<li><a target="_blank" href="{{ BASE_URL }}/files/get?module='+ response.module +'&encName='+ response.encName +'&ext='+ response.extension +'&realName='+ response.realName +'">{!! str_replace("'", '"', __('links.download')) !!}</a></li>'+
@if ($login::userIsAtLeast($roles::$editor))
'<li><form method="post" action="{{ BASE_URL }}/files/showAll" class="deleteFile" onsubmit="return confirm(\'{{ __("text.confirm_delete") }}\')"><input type="hidden" name="_token" value="'+ jQuery('meta[name=csrf-token]').attr('content') +'" /><input type="hidden" name="delFile" value="'+ response.fileId +'" /><button type="submit" class="delete" style="background:none;border:none;cursor:pointer;padding:3px 20px;width:100%;text-align:left;"><i class="fa fa-trash"></i> {!! str_replace("'", '"', __("links.delete")) !!}</button></form></li>'+
@endif
'</ul>'+
'</div>'+
'<a class="imageLink" href="{{ BASE_URL }}/files/get?module='+ response.module +'&encName='+ response.encName +'&ext='+ response.extension +'&realName='+ response.realName +'">'+
'<img style="max-height: 50px; max-width: 70px;" src="{{ BASE_URL }}/files/get?module='+ response.module +'&encName='+ response.encName +'&ext='+ response.extension +'&realName='+ response.realName +'" alt="" />'+
'<span class="filename" title="'+response.realName+'.'+response.extension+'">'+response.realName+'.'+response.extension+'</span>'+
'</a>'+
'</li>';
jQuery("#medialist").append(html);
}
});
jQuery("#webcamClick").click(function(){
jQuery(".uploadWrapper .extra").css("display", "flex");
uppy.use(Uppy.Webcam, { target: '.extra' });
jQuery("#cancelLink").show();
});
jQuery("#screencaptureLink").click(function(){
jQuery(".uploadWrapper .extra").css("display", "flex");
uppy.use(Uppy.ScreenCapture,
{
displayMediaConstraints: {
video: {
width: 1280,
height: 720,
frameRate: {
ideal: 3,
max: 5,
},
cursor: 'motion',
displaySurface: 'window',
},
},
target: '.extra'
});
jQuery("#cancelLink").show();
});
jQuery("#cancelLink").click(function(){
const instance = uppy.getPlugin('Webcam');
if(instance) {
uppy.removePlugin(instance);
}
const instance2 = uppy.getPlugin('ScreenCapture');
if(instance2) {
uppy.removePlugin(instance2);
}
jQuery("#cancelLink").hide();
jQuery(".uploadWrapper .extra").css("display", "none");
});
}
</script>

View File

@@ -0,0 +1,34 @@
<?php
use Illuminate\Support\Facades\Route;
use Leantime\Domain\Files\Controllers\Upload;
/*
|--------------------------------------------------------------------------
| Files Domain Routes
|--------------------------------------------------------------------------
|
| Backwards-compatibility redirect: legacy /download.php URLs were embedded
| in ticket descriptions by the old TinyMCE editor. The file was removed in
| the storage refactor but the URLs live on in the database. This route
| preserves those links permanently by forwarding all query parameters to
| the active /files/get endpoint.
|
*/
Route::get('/download.php', function () {
$qs = request()->getQueryString();
return redirect('/files/get'.($qs ? '?'.$qs : ''), 301);
});
/*
| File uploads were relocated here from the retired Api\Controllers\Files.
| The canonical route is /files/upload. The /api/files alias is kept so the Tiptap
| editor and Uppy file manager keep working without a JS change; both read the raw
| upload() metadata array off the JSON response, which Files\Controllers\Upload preserves.
*/
Route::post('/files/upload', [Upload::class, 'post'])->name('files.upload');
Route::post('/api/files', [Upload::class, 'post'])->name('files.upload.legacy');