mirror of
https://github.com/kuaifan/dootask.git
synced 2026-09-11 14:38:41 +00:00
feat(file): add WebDAV support
This commit is contained in:
parent
9408777d00
commit
9028061638
@ -22,10 +22,10 @@ class DocApiMap extends Command
|
||||
|
||||
$total = 0;
|
||||
$sections = [];
|
||||
foreach ($controllers as $prefix => $class) {
|
||||
$rows = $this->collectMethods($prefix, $class);
|
||||
foreach ($controllers as $prefix => $controller) {
|
||||
$rows = $this->collectMethods($prefix, $controller['class'], $controller['fixed_method']);
|
||||
$total += count($rows);
|
||||
$sections[] = $this->renderSection($prefix, $class, $rows);
|
||||
$sections[] = $this->renderSection($prefix, $controller['class'], $rows);
|
||||
}
|
||||
|
||||
$path = base_path('routes/api-map.md');
|
||||
@ -38,19 +38,29 @@ class DocApiMap extends Command
|
||||
/**
|
||||
* 从已注册路由中收集 api 前缀与控制器的映射
|
||||
* 匹配 routes/web.php 中的动态路由:api/{prefix}/{method}
|
||||
* @return array [prefix => 控制器类名]
|
||||
* 同时支持固定 method 的三级路由,如 api/file/dav/{action}。
|
||||
* @return array [prefix => ['class' => 控制器类名, 'fixed_method' => 固定方法名|null]]
|
||||
*/
|
||||
private function collectControllers(): array
|
||||
{
|
||||
$controllers = [];
|
||||
foreach (Route::getRoutes() as $route) {
|
||||
if (!preg_match('/^api\/(\w+)\/\{method}$/', $route->uri())) {
|
||||
$uri = $route->uri();
|
||||
$fixedMethod = null;
|
||||
if (preg_match('/^api\/(.+)\/\{method}$/', $uri, $match)) {
|
||||
$prefix = $match[1];
|
||||
} elseif (preg_match('/^api\/(.+)\/\{action}$/', $uri, $match) && isset($route->defaults['method'])) {
|
||||
$prefix = $match[1];
|
||||
$fixedMethod = (string) $route->defaults['method'];
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
preg_match('/^api\/(\w+)\/\{method}$/', $route->uri(), $match);
|
||||
$class = $route->getAction('controller');
|
||||
if ($class && class_exists($class)) {
|
||||
$controllers[$match[1]] = $class;
|
||||
$controllers[$prefix] = [
|
||||
'class' => $class,
|
||||
'fixed_method' => $fixedMethod,
|
||||
];
|
||||
}
|
||||
}
|
||||
return $controllers;
|
||||
@ -62,7 +72,7 @@ class DocApiMap extends Command
|
||||
* @param string $class 控制器类名
|
||||
* @return array [['url' => ..., 'method' => ..., 'http' => ..., 'title' => ...], ...]
|
||||
*/
|
||||
private function collectMethods(string $prefix, string $class): array
|
||||
private function collectMethods(string $prefix, string $class, ?string $fixedMethod): array
|
||||
{
|
||||
$rows = [];
|
||||
$reflection = new ReflectionClass($class);
|
||||
@ -73,10 +83,20 @@ class DocApiMap extends Command
|
||||
|| str_starts_with($method->getName(), '__')) {
|
||||
continue;
|
||||
}
|
||||
$methodName = $method->getName();
|
||||
if ($fixedMethod !== null) {
|
||||
$fixedPrefix = $fixedMethod . '__';
|
||||
if (!str_starts_with($methodName, $fixedPrefix)) {
|
||||
continue;
|
||||
}
|
||||
$urlMethod = substr($methodName, strlen($fixedPrefix));
|
||||
} else {
|
||||
$urlMethod = str_replace('__', '/', $methodName);
|
||||
}
|
||||
[$http, $title] = $this->parseApiDoc($method);
|
||||
$rows[] = [
|
||||
'url' => "api/{$prefix}/" . str_replace('__', '/', $method->getName()),
|
||||
'method' => $method->getName() . '()',
|
||||
'url' => "api/{$prefix}/{$urlMethod}",
|
||||
'method' => $methodName . '()',
|
||||
'http' => $http,
|
||||
'title' => $title,
|
||||
];
|
||||
|
||||
276
app/Http/Controllers/Api/FileDavController.php
Normal file
276
app/Http/Controllers/Api/FileDavController.php
Normal file
@ -0,0 +1,276 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Exceptions\ApiException;
|
||||
use App\Models\User;
|
||||
use App\Models\WebDavCredential;
|
||||
use App\Models\WebDavLock;
|
||||
use App\Module\Base;
|
||||
use App\Services\WebDav\WebDavConfig;
|
||||
use App\Services\WebDav\WebDavConflictService;
|
||||
use Request;
|
||||
|
||||
/**
|
||||
* @apiDefine fileDav
|
||||
*
|
||||
* WebDAV 管理
|
||||
*/
|
||||
class FileDavController extends AbstractController
|
||||
{
|
||||
/**
|
||||
* Laravel passes the dynamic action before the route default for this fixed-method route.
|
||||
*/
|
||||
public function __invoke($action, $method = 'dav')
|
||||
{
|
||||
return parent::__invoke($method, $action);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/file/dav/status 获取 WebDAV 状态
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup fileDav
|
||||
* @apiName dav__status
|
||||
*/
|
||||
public function dav__status()
|
||||
{
|
||||
$user = User::auth();
|
||||
$config = WebDavConfig::get();
|
||||
return Base::retSuccess('success', [
|
||||
'enabled' => $config['enabled'],
|
||||
'allowed' => WebDavConfig::isAllowed($user, $config),
|
||||
'https' => Request::secure(),
|
||||
'url' => WebDavConfig::url(),
|
||||
'max_credentials' => $config['max_credentials'],
|
||||
'active_credentials' => WebDavCredential::whereUserid($user->userid)
|
||||
->whereNull('revoked_at')
|
||||
->where(function ($query) {
|
||||
$query->whereNull('expires_at')->orWhere('expires_at', '>', now());
|
||||
})->count(),
|
||||
'default_expire_days' => $config['default_expire_days'],
|
||||
'max_expire_days' => $config['max_expire_days'],
|
||||
'max_file_bytes' => $config['max_file_bytes'],
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/file/dav/credentials 获取 WebDAV 凭据
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup fileDav
|
||||
* @apiName dav__credentials
|
||||
*/
|
||||
public function dav__credentials()
|
||||
{
|
||||
$user = User::auth();
|
||||
$list = WebDavCredential::whereUserid($user->userid)->orderByDesc('id')->get();
|
||||
$data = $list->map(function (WebDavCredential $credential) {
|
||||
return [
|
||||
'id' => $credential->id,
|
||||
'public_id' => $credential->public_id,
|
||||
'name' => $credential->name,
|
||||
'password_suffix' => $credential->password_suffix,
|
||||
'expires_at' => $credential->expires_at?->toDateTimeString(),
|
||||
'last_used_at' => $credential->last_used_at?->toDateTimeString(),
|
||||
'last_used_ip' => $credential->last_used_ip,
|
||||
'last_user_agent' => $credential->last_user_agent,
|
||||
'created_at' => $credential->created_at?->toDateTimeString(),
|
||||
'revoked_at' => $credential->revoked_at?->toDateTimeString(),
|
||||
'status' => $credential->status(),
|
||||
];
|
||||
})->values();
|
||||
return Base::retSuccess('success', $data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/file/dav/create 创建 WebDAV 应用密码
|
||||
* @apiDescription 需要token身份,密码只返回一次
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup fileDav
|
||||
* @apiName dav__create
|
||||
*/
|
||||
public function dav__create()
|
||||
{
|
||||
$user = User::auth();
|
||||
$config = WebDavConfig::get();
|
||||
if (!WebDavConfig::isAllowed($user, $config)) {
|
||||
throw new ApiException('WebDAV 未启用或你没有使用权限');
|
||||
}
|
||||
if (!Request::secure() && !app()->environment(['local', 'testing'])) {
|
||||
throw new ApiException('WebDAV 必须通过 HTTPS 使用');
|
||||
}
|
||||
$name = mb_substr(trim((string) Request::input('name')), 0, 100);
|
||||
if ($name === '') {
|
||||
throw new ApiException('请输入设备名称');
|
||||
}
|
||||
$expireDays = intval(Request::input('expire_days', $config['default_expire_days']));
|
||||
if ($expireDays < 1 || $expireDays > $config['max_expire_days']) {
|
||||
throw new ApiException('有效期超出允许范围');
|
||||
}
|
||||
$activeCount = WebDavCredential::whereUserid($user->userid)
|
||||
->whereNull('revoked_at')
|
||||
->where(function ($query) {
|
||||
$query->whereNull('expires_at')->orWhere('expires_at', '>', now());
|
||||
})->count();
|
||||
if ($activeCount >= $config['max_credentials']) {
|
||||
throw new ApiException('WebDAV 应用密码数量已达上限');
|
||||
}
|
||||
|
||||
[$credential, $secret] = WebDavCredential::issue($user, $name, $expireDays);
|
||||
return Base::retSuccess('创建成功', [
|
||||
'id' => $credential->id,
|
||||
'public_id' => $credential->public_id,
|
||||
'password' => $secret,
|
||||
'password_suffix' => $credential->password_suffix,
|
||||
'url' => WebDavConfig::url(),
|
||||
'expires_at' => $credential->expires_at?->toDateTimeString(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/file/dav/revoke 撤销 WebDAV 应用密码
|
||||
* @apiDescription 需要token身份
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup fileDav
|
||||
* @apiName dav__revoke
|
||||
*/
|
||||
public function dav__revoke()
|
||||
{
|
||||
$user = User::auth();
|
||||
$credential = WebDavCredential::whereUserid($user->userid)
|
||||
->whereId(intval(Request::input('id')))
|
||||
->first();
|
||||
if (!$credential) {
|
||||
throw new ApiException('WebDAV 应用密码不存在');
|
||||
}
|
||||
$credential->revoke();
|
||||
return Base::retSuccess('撤销成功', [
|
||||
'id' => $credential->id,
|
||||
'revoked_at' => $credential->revoked_at?->toDateTimeString(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/file/dav/adminsetting 获取或保存 WebDAV 设置
|
||||
* @apiDescription 需要管理员身份
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup fileDav
|
||||
* @apiName dav__adminsetting
|
||||
*/
|
||||
public function dav__adminsetting()
|
||||
{
|
||||
User::auth('admin');
|
||||
if (Request::input('type') === 'save') {
|
||||
if (config('dootask.system_setting') === 'disabled') {
|
||||
throw new ApiException('当前环境禁止修改');
|
||||
}
|
||||
$normalized = WebDavConfig::normalizeAdminInput(Request::input());
|
||||
if ($normalized['webdav_enabled'] === 'open' && WebDavConfig::pathConflictCount() > 0) {
|
||||
throw new ApiException('存在文件路径冲突,请先处理后再启用 WebDAV');
|
||||
}
|
||||
$current = Base::setting('fileSetting');
|
||||
$setting = array_merge($current, $normalized);
|
||||
Base::setting('fileSetting', $setting);
|
||||
}
|
||||
return Base::retSuccess(Request::input('type') === 'save' ? '保存成功' : 'success', WebDavConfig::adminForm());
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/file/dav/adminstatus 获取 WebDAV 运行状态
|
||||
* @apiDescription 需要管理员身份
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup fileDav
|
||||
* @apiName dav__adminstatus
|
||||
*/
|
||||
public function dav__adminstatus()
|
||||
{
|
||||
User::auth('admin');
|
||||
return Base::retSuccess('success', [
|
||||
'config' => WebDavConfig::get(),
|
||||
'active_credentials' => WebDavCredential::whereNull('revoked_at')
|
||||
->where(function ($query) {
|
||||
$query->whereNull('expires_at')->orWhere('expires_at', '>', now());
|
||||
})->count(),
|
||||
'active_locks' => WebDavLock::where('timeout_at', '>', now())->count(),
|
||||
'path_conflicts' => WebDavConfig::pathConflictCount(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {get} api/file/dav/conflicts 获取 WebDAV 路径冲突明细
|
||||
* @apiDescription 需要管理员身份
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup fileDav
|
||||
* @apiName dav__conflicts
|
||||
*
|
||||
* @apiParam {Number} [page=1] 页码
|
||||
* @apiParam {Number} [pagesize=20] 每页数量,最大 100
|
||||
* @apiSuccess {Boolean} data.data.files.can_open_location 当前管理员能否按现有文件权限打开所在位置
|
||||
* @apiSuccess {String} data.data.files.location_board 定位板块:mine 或 shared
|
||||
* @apiSuccess {Number} data.data.files.location_parent_id 定位目录 ID,共享根文件为 0
|
||||
*/
|
||||
public function dav__conflicts()
|
||||
{
|
||||
$user = User::auth('admin');
|
||||
return Base::retSuccess('success', WebDavConfig::pathConflicts(
|
||||
intval(Request::input('page', 1)),
|
||||
intval(Request::input('pagesize', 20)),
|
||||
$user
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/file/dav/conflictrename 管理员重命名 WebDAV 冲突文件
|
||||
* @apiDescription 需要管理员身份,目标必须仍属于路径冲突组
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup fileDav
|
||||
* @apiName dav__conflictrename
|
||||
*
|
||||
* @apiParam {Number} id 文件 ID
|
||||
* @apiParam {String} name 新的完整名称
|
||||
*/
|
||||
public function dav__conflictrename()
|
||||
{
|
||||
$user = User::auth('admin');
|
||||
$name = trim((string) Request::input('name'));
|
||||
$file = (new WebDavConflictService())->renameAsAdmin(
|
||||
$user,
|
||||
intval(Request::input('id')),
|
||||
$name,
|
||||
Request::header('X-Request-Id'),
|
||||
Request::ip(),
|
||||
Request::userAgent()
|
||||
);
|
||||
return Base::retSuccess('重命名成功', [
|
||||
'id' => intval($file->id),
|
||||
'name' => $file->getNameAndExt(),
|
||||
'path_conflicts' => WebDavConfig::pathConflictCount(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api {post} api/file/dav/userrevoke 撤销用户全部 WebDAV 应用密码
|
||||
* @apiDescription 需要管理员身份
|
||||
* @apiVersion 1.0.0
|
||||
* @apiGroup fileDav
|
||||
* @apiName dav__userrevoke
|
||||
*/
|
||||
public function dav__userrevoke()
|
||||
{
|
||||
User::auth('admin');
|
||||
$userid = intval(Request::input('userid'));
|
||||
if ($userid <= 0 || !User::whereUserid($userid)->exists()) {
|
||||
throw new ApiException('用户不存在');
|
||||
}
|
||||
$credentials = WebDavCredential::whereUserid($userid)->whereNull('revoked_at')->get();
|
||||
foreach ($credentials as $credential) {
|
||||
$credential->revoke();
|
||||
}
|
||||
return Base::retSuccess('撤销成功', [
|
||||
'userid' => $userid,
|
||||
'revoked_count' => $credentials->count(),
|
||||
'revoked_at' => now()->toDateTimeString(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
114
app/Http/Controllers/WebDavProtocolController.php
Normal file
114
app/Http/Controllers/WebDavProtocolController.php
Normal file
@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers;
|
||||
|
||||
use App\Models\WebDavOperationLog;
|
||||
use App\Services\RequestContext;
|
||||
use App\Services\WebDav\WebDavAuthenticator;
|
||||
use App\Services\WebDav\WebDavConfig;
|
||||
use App\Services\WebDav\WebDavServerFactory;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Sabre\HTTP\Request as SabreRequest;
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\HttpFoundation\StreamedResponse;
|
||||
|
||||
class WebDavProtocolController extends Controller
|
||||
{
|
||||
public function __invoke(Request $request, string $path = ''): Response
|
||||
{
|
||||
$startedAt = microtime(true);
|
||||
RequestContext::set('start_time', $startedAt);
|
||||
$requestId = RequestContext::getCurrentRequestId();
|
||||
$user = null;
|
||||
$credential = null;
|
||||
$status = 500;
|
||||
$bytes = 0;
|
||||
|
||||
try {
|
||||
$config = WebDavConfig::get();
|
||||
if (!$config['enabled']) {
|
||||
$status = 503;
|
||||
return response('WebDAV is disabled.', $status);
|
||||
}
|
||||
if (!$request->secure() && !app()->environment(['local', 'testing'])) {
|
||||
$status = 403;
|
||||
return response('HTTPS is required.', $status);
|
||||
}
|
||||
|
||||
$authenticator = new WebDavAuthenticator();
|
||||
$auth = $authenticator->authenticate($request);
|
||||
if (!$auth) {
|
||||
$status = $authenticator->wasRateLimited() ? 429 : 401;
|
||||
$headers = $status === 401
|
||||
? ['WWW-Authenticate' => 'Basic realm="DooTask WebDAV", charset="UTF-8"']
|
||||
: ['Retry-After' => '60'];
|
||||
return response('', $status, $headers);
|
||||
}
|
||||
[$user, $credential] = $auth;
|
||||
RequestContext::setMultiple([
|
||||
'webdav_user' => $user,
|
||||
'webdav_credential' => $credential,
|
||||
]);
|
||||
|
||||
$server = (new WebDavServerFactory())->make($user, $credential);
|
||||
$sabreRequest = new SabreRequest(
|
||||
$request->getMethod(),
|
||||
$request->getRequestUri(),
|
||||
$request->headers->all(),
|
||||
$request->getContent(true)
|
||||
);
|
||||
$sabreRequest->setAbsoluteUrl($request->getUri());
|
||||
$server->httpRequest = $sabreRequest;
|
||||
$server->start();
|
||||
|
||||
$sabreResponse = $server->httpResponse;
|
||||
$status = $sabreResponse->getStatus();
|
||||
$bytes = intval($sabreResponse->getHeader('Content-Length') ?? 0);
|
||||
$response = $this->toSymfonyResponse($sabreResponse);
|
||||
$response->headers->set('X-Request-Id', $requestId);
|
||||
return $response;
|
||||
} finally {
|
||||
try {
|
||||
WebDavOperationLog::createInstance([
|
||||
'request_id' => $requestId,
|
||||
'userid' => $user?->userid,
|
||||
'credential_id' => $credential?->id,
|
||||
'method' => mb_substr($request->getMethod(), 0, 20),
|
||||
'uri' => mb_substr('/dav/' . ltrim($path, '/'), 0, 1000),
|
||||
'status' => $status,
|
||||
'result' => $status >= 400 ? 'failed' : 'success',
|
||||
'bytes' => $bytes,
|
||||
'ip' => mb_substr((string) $request->ip(), 0, 45),
|
||||
'user_agent' => mb_substr((string) $request->userAgent(), 0, 255),
|
||||
'duration_ms' => max(0, intval((microtime(true) - $startedAt) * 1000)),
|
||||
])->save();
|
||||
} catch (\Throwable $exception) {
|
||||
Log::warning('WebDAV audit log failed', ['exception' => $exception->getMessage()]);
|
||||
}
|
||||
RequestContext::clean($requestId);
|
||||
}
|
||||
}
|
||||
|
||||
private function toSymfonyResponse(\Sabre\HTTP\Response $sabreResponse): Response
|
||||
{
|
||||
$headers = [];
|
||||
foreach ($sabreResponse->getHeaders() as $name => $values) {
|
||||
$headers[$name] = implode(', ', $values);
|
||||
}
|
||||
$body = $sabreResponse->getBody();
|
||||
if (!is_resource($body) && !is_callable($body)) {
|
||||
return new Response(is_string($body) ? $body : '', $sabreResponse->getStatus(), $headers);
|
||||
}
|
||||
return new StreamedResponse(function () use ($body) {
|
||||
if (is_callable($body)) {
|
||||
$body();
|
||||
return;
|
||||
}
|
||||
$output = fopen('php://output', 'wb');
|
||||
stream_copy_to_stream($body, $output);
|
||||
fclose($output);
|
||||
if (is_resource($body)) fclose($body);
|
||||
}, $sabreResponse->getStatus(), $headers);
|
||||
}
|
||||
}
|
||||
77
app/Models/WebDavCredential.php
Normal file
77
app/Models/WebDavCredential.php
Normal file
@ -0,0 +1,77 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Support\Facades\Hash;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* @property int $id
|
||||
* @property string $public_id
|
||||
* @property int $userid
|
||||
* @property string $name
|
||||
* @property string $password_hash
|
||||
* @property string $password_suffix
|
||||
* @property \Illuminate\Support\Carbon|null $expires_at
|
||||
* @property \Illuminate\Support\Carbon|null $last_used_at
|
||||
* @property \Illuminate\Support\Carbon|null $revoked_at
|
||||
*/
|
||||
class WebDavCredential extends AbstractModel
|
||||
{
|
||||
protected $table = 'webdav_credentials';
|
||||
|
||||
protected $hidden = ['password_hash'];
|
||||
|
||||
protected $casts = [
|
||||
'expires_at' => 'datetime',
|
||||
'last_used_at' => 'datetime',
|
||||
'revoked_at' => 'datetime',
|
||||
];
|
||||
|
||||
public static function issue(User $user, string $name, ?int $expireDays): array
|
||||
{
|
||||
$secret = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');
|
||||
$credential = self::createInstance([
|
||||
'public_id' => 'dtw_' . strtolower((string) Str::ulid()),
|
||||
'userid' => $user->userid,
|
||||
'name' => $name,
|
||||
'password_hash' => Hash::make($secret),
|
||||
'password_suffix' => substr($secret, -4),
|
||||
'expires_at' => $expireDays ? now()->addDays($expireDays) : null,
|
||||
]);
|
||||
$credential->save();
|
||||
|
||||
return [$credential, $secret];
|
||||
}
|
||||
|
||||
public function isActive(): bool
|
||||
{
|
||||
return $this->revoked_at === null
|
||||
&& ($this->expires_at === null || $this->expires_at->isFuture());
|
||||
}
|
||||
|
||||
public function verify(string $secret): bool
|
||||
{
|
||||
return $this->isActive() && Hash::check($secret, $this->password_hash);
|
||||
}
|
||||
|
||||
public function revoke(): void
|
||||
{
|
||||
if ($this->revoked_at === null) {
|
||||
$this->revoked_at = now();
|
||||
$this->save();
|
||||
}
|
||||
WebDavLock::whereCredentialId($this->id)->delete();
|
||||
}
|
||||
|
||||
public function status(): string
|
||||
{
|
||||
if ($this->revoked_at !== null) {
|
||||
return 'revoked';
|
||||
}
|
||||
if ($this->expires_at !== null && $this->expires_at->isPast()) {
|
||||
return 'expired';
|
||||
}
|
||||
return 'active';
|
||||
}
|
||||
}
|
||||
12
app/Models/WebDavLock.php
Normal file
12
app/Models/WebDavLock.php
Normal file
@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
class WebDavLock extends AbstractModel
|
||||
{
|
||||
protected $table = 'webdav_locks';
|
||||
|
||||
protected $casts = [
|
||||
'timeout_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
10
app/Models/WebDavOperationLog.php
Normal file
10
app/Models/WebDavOperationLog.php
Normal file
@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
class WebDavOperationLog extends AbstractModel
|
||||
{
|
||||
protected $table = 'webdav_operation_logs';
|
||||
|
||||
public const UPDATED_AT = null;
|
||||
}
|
||||
12
app/Models/WebDavProperty.php
Normal file
12
app/Models/WebDavProperty.php
Normal file
@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
class WebDavProperty extends AbstractModel
|
||||
{
|
||||
protected $table = 'webdav_properties';
|
||||
|
||||
protected $casts = [
|
||||
'value_type' => 'integer',
|
||||
];
|
||||
}
|
||||
434
app/Services/FileSystem/FileSystemService.php
Normal file
434
app/Services/FileSystem/FileSystemService.php
Normal file
@ -0,0 +1,434 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\FileSystem;
|
||||
|
||||
use App\Exceptions\ApiException;
|
||||
use App\Models\AbstractModel;
|
||||
use App\Models\File;
|
||||
use App\Models\FileContent;
|
||||
use App\Models\User;
|
||||
use App\Module\Base;
|
||||
use App\Services\WebDav\WebDavConfig;
|
||||
|
||||
class FileSystemService
|
||||
{
|
||||
public function children(User $actor, ?File $parent, string $scope = 'files')
|
||||
{
|
||||
if ($parent) {
|
||||
File::permissionFind($parent->id, $actor, 0);
|
||||
return File::wherePid($parent->id)->orderBy('type')->orderBy('name')->get();
|
||||
}
|
||||
if ($scope === 'files') {
|
||||
return File::wherePid(0)->whereUserid($actor->userid)->orderBy('type')->orderBy('name')->get();
|
||||
}
|
||||
|
||||
$userids = $actor->isTemp() ? [$actor->userid] : [0, $actor->userid];
|
||||
$received = File::select('files.*')
|
||||
->join('file_users', 'files.id', '=', 'file_users.file_id')
|
||||
->where('files.userid', '!=', $actor->userid)
|
||||
->whereIn('file_users.userid', $userids)
|
||||
->where('files.share', 1)
|
||||
->distinct()
|
||||
->orderBy('files.name')
|
||||
->get();
|
||||
$owned = File::wherePid(0)
|
||||
->whereUserid($actor->userid)
|
||||
->whereShare(1)
|
||||
->orderBy('name')
|
||||
->get();
|
||||
return $received->concat($owned)->unique('id')->values();
|
||||
}
|
||||
|
||||
public function child(User $actor, ?File $parent, string $fullName, string $scope = 'files'): ?File
|
||||
{
|
||||
if ($parent) {
|
||||
File::permissionFind($parent->id, $actor, 0);
|
||||
return $this->findByFullName(File::wherePid($parent->id), $fullName);
|
||||
}
|
||||
if ($scope === 'files') {
|
||||
return $this->findByFullName(
|
||||
File::wherePid(0)->whereUserid($actor->userid),
|
||||
$fullName
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public function createDirectory(User $actor, ?File $parent, string $name, string $scope = 'files'): File
|
||||
{
|
||||
$this->validateName($name);
|
||||
[$pid, $userid] = $this->target($actor, $parent, $scope);
|
||||
$this->assertCapacity($pid, $userid);
|
||||
if ($this->findByFullName(File::wherePid($pid)->whereUserid($userid), $name)) {
|
||||
throw new ApiException('文件已存在');
|
||||
}
|
||||
|
||||
$file = File::createInstance([
|
||||
'pid' => $pid,
|
||||
'name' => $name,
|
||||
'type' => 'folder',
|
||||
'ext' => '',
|
||||
'userid' => $userid,
|
||||
'created_id' => $actor->userid,
|
||||
]);
|
||||
$file->saveBeforePP();
|
||||
$this->push($file, 'add', $file);
|
||||
return $file;
|
||||
}
|
||||
|
||||
public function createFile(User $actor, ?File $parent, string $fullName, $data, string $scope = 'files'): File
|
||||
{
|
||||
[$name, $ext] = $this->splitName($fullName);
|
||||
$this->validateName($fullName);
|
||||
[$pid, $userid] = $this->target($actor, $parent, $scope);
|
||||
$this->assertCapacity($pid, $userid);
|
||||
if ($this->findByFullName(File::wherePid($pid)->whereUserid($userid), $fullName)) {
|
||||
throw new ApiException('文件已存在');
|
||||
}
|
||||
|
||||
$temp = $this->writeTemp($data);
|
||||
$finalPath = null;
|
||||
try {
|
||||
$file = AbstractModel::transaction(function () use ($actor, $pid, $userid, $name, $ext, $temp, &$finalPath) {
|
||||
$file = File::createInstance([
|
||||
'pid' => $pid,
|
||||
'name' => $name,
|
||||
'type' => $this->typeFromExtension($ext),
|
||||
'ext' => $ext,
|
||||
'size' => filesize($temp),
|
||||
'hash' => md5_file($temp),
|
||||
'userid' => $userid,
|
||||
'created_id' => $actor->userid,
|
||||
]);
|
||||
$file->saveBeforePP();
|
||||
$finalPath = $this->moveToContentPath($temp, $file);
|
||||
$this->createContent($file, $actor, $finalPath);
|
||||
return $file->fresh();
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
$this->cleanup($temp, $finalPath);
|
||||
throw $e;
|
||||
}
|
||||
$this->push($file, 'add', $file);
|
||||
return $file;
|
||||
}
|
||||
|
||||
public function replaceFile(User $actor, File $file, $data): File
|
||||
{
|
||||
File::permissionFind($file->id, $actor, 1);
|
||||
if ($file->type === 'folder') {
|
||||
throw new ApiException('文件夹不能写入内容');
|
||||
}
|
||||
$temp = $this->writeTemp($data);
|
||||
$finalPath = null;
|
||||
try {
|
||||
$file = AbstractModel::transaction(function () use ($actor, $file, $temp, &$finalPath) {
|
||||
$locked = File::whereId($file->id)->lockForUpdate()->first();
|
||||
if (!$locked) {
|
||||
throw new ApiException('文件不存在或已被删除');
|
||||
}
|
||||
File::permissionFind($locked->id, $actor, 1);
|
||||
$locked->size = filesize($temp);
|
||||
$locked->hash = md5_file($temp);
|
||||
$locked->updated_at = now();
|
||||
$locked->save();
|
||||
$finalPath = $this->moveToContentPath($temp, $locked);
|
||||
$this->createContent($locked, $actor, $finalPath);
|
||||
return $locked->fresh();
|
||||
});
|
||||
} catch (\Throwable $e) {
|
||||
$this->cleanup($temp, $finalPath);
|
||||
throw $e;
|
||||
}
|
||||
$this->push($file, 'content');
|
||||
return $file;
|
||||
}
|
||||
|
||||
public function overwriteByMove(User $actor, File $source, File $destination): File
|
||||
{
|
||||
File::permissionFind($source->id, $actor, 1000);
|
||||
$destination = $this->overwriteByCopy($actor, $source, $destination);
|
||||
$this->delete($actor, $source);
|
||||
return $destination;
|
||||
}
|
||||
|
||||
public function overwriteByCopy(User $actor, File $source, File $destination): File
|
||||
{
|
||||
File::permissionFind($source->id, $actor, 0);
|
||||
File::permissionFind($destination->id, $actor, 1);
|
||||
if ($source->type === 'folder' || $destination->type === 'folder') {
|
||||
throw new ApiException('文件夹不能写入内容');
|
||||
}
|
||||
|
||||
$stream = $this->open($source);
|
||||
try {
|
||||
$destination = $this->replaceFile($actor, $destination, $stream);
|
||||
} finally {
|
||||
if (is_resource($stream)) {
|
||||
fclose($stream);
|
||||
}
|
||||
}
|
||||
return $destination;
|
||||
}
|
||||
|
||||
public function rename(User $actor, File $file, string $fullName): File
|
||||
{
|
||||
File::permissionFind($file->id, $actor, 1);
|
||||
return $this->renameFile($file, $fullName);
|
||||
}
|
||||
|
||||
public function renameConflictAsAdmin(User $actor, File $file, string $fullName): File
|
||||
{
|
||||
if (!$actor->isAdmin()) {
|
||||
throw new ApiException('仅限管理员操作');
|
||||
}
|
||||
return $this->renameFile($file, $fullName);
|
||||
}
|
||||
|
||||
private function renameFile(File $file, string $fullName): File
|
||||
{
|
||||
$this->validateName($fullName);
|
||||
[$name, $ext] = $file->type === 'folder' ? [$fullName, ''] : $this->splitName($fullName);
|
||||
$exists = File::wherePid($file->pid)->whereUserid($file->userid)
|
||||
->whereName($name)->whereExt($ext)->where('id', '!=', $file->id)->exists();
|
||||
if ($exists) {
|
||||
throw new ApiException('文件已存在');
|
||||
}
|
||||
$file->name = $name;
|
||||
$file->ext = $ext;
|
||||
if ($file->type !== 'folder') {
|
||||
$file->type = $this->typeFromExtension($ext);
|
||||
}
|
||||
$file->save();
|
||||
$this->push($file, 'update', $file);
|
||||
return $file;
|
||||
}
|
||||
|
||||
public function move(User $actor, File $file, ?File $targetParent, string $targetName, string $scope): File
|
||||
{
|
||||
File::permissionFind($file->id, $actor, 1000);
|
||||
[$pid, $userid] = $this->target($actor, $targetParent, $scope);
|
||||
$this->assertCapacity($pid, $userid, $file->pid === $pid ? 1 : 0);
|
||||
if ($file->type === 'folder' && ($pid === $file->id || str_contains((string) $targetParent?->pids, ",{$file->id},"))) {
|
||||
throw new ApiException('移动位置错误');
|
||||
}
|
||||
$file->pid = $pid;
|
||||
if ($file->userid !== $userid) {
|
||||
$file->userid = $userid;
|
||||
$file->updateChildFilesUserid($userid);
|
||||
}
|
||||
$this->rename($actor, $file, $targetName);
|
||||
$file->saveBeforePP();
|
||||
return $file->fresh();
|
||||
}
|
||||
|
||||
public function delete(User $actor, File $file): void
|
||||
{
|
||||
File::permissionFind($file->id, $actor, 1000);
|
||||
$file->deleteFile();
|
||||
}
|
||||
|
||||
public function open(File $file)
|
||||
{
|
||||
$content = FileContent::whereFid($file->id)->orderByDesc('id')->first();
|
||||
if (!$content) {
|
||||
return fopen('php://temp', 'r+');
|
||||
}
|
||||
$data = Base::json2array($content->content ?: []);
|
||||
$relative = $data['url'] ?? '';
|
||||
$path = public_path($relative);
|
||||
if (!str_starts_with($relative, 'uploads/') || !is_file($path)) {
|
||||
throw new ApiException('文件内容不存在');
|
||||
}
|
||||
$stream = fopen($path, 'rb');
|
||||
if (!$stream) {
|
||||
throw new ApiException('文件内容读取失败');
|
||||
}
|
||||
return $stream;
|
||||
}
|
||||
|
||||
public function etag(File $file): string
|
||||
{
|
||||
$version = intval(FileContent::whereFid($file->id)->max('id'));
|
||||
return '"f-' . $file->id . '-v-' . $version . '"';
|
||||
}
|
||||
|
||||
public function mime(File $file): string
|
||||
{
|
||||
$map = [
|
||||
'md' => 'text/markdown', 'txt' => 'text/plain', 'json' => 'application/json',
|
||||
'pdf' => 'application/pdf', 'jpg' => 'image/jpeg', 'jpeg' => 'image/jpeg',
|
||||
'png' => 'image/png', 'gif' => 'image/gif', 'svg' => 'image/svg+xml',
|
||||
'docx' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'xlsx' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'pptx' => 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
];
|
||||
return $map[strtolower((string) $file->ext)] ?? 'application/octet-stream';
|
||||
}
|
||||
|
||||
public function fullName(File $file): string
|
||||
{
|
||||
return $file->getNameAndExt();
|
||||
}
|
||||
|
||||
private function target(User $actor, ?File $parent, string $scope): array
|
||||
{
|
||||
if ($parent) {
|
||||
$row = File::permissionFind($parent->id, $actor, 1);
|
||||
if ($row->type !== 'folder') {
|
||||
throw new ApiException('目标不是文件夹');
|
||||
}
|
||||
return [$row->id, intval($row->userid)];
|
||||
}
|
||||
if ($scope !== 'files') {
|
||||
throw new ApiException('共享根目录不可写');
|
||||
}
|
||||
return [0, intval($actor->userid)];
|
||||
}
|
||||
|
||||
private function assertCapacity(int $pid, int $userid, int $offset = 0): void
|
||||
{
|
||||
$query = File::wherePid($pid);
|
||||
if ($pid === 0) {
|
||||
$query->whereUserid($userid);
|
||||
}
|
||||
if ($query->count() - $offset >= 300) {
|
||||
throw new ApiException('每个文件夹里最多只能创建300个文件或文件夹');
|
||||
}
|
||||
}
|
||||
|
||||
private function validateName(string $name): void
|
||||
{
|
||||
if (mb_strlen($name) < 1 || mb_strlen($name) > 200) {
|
||||
throw new ApiException('文件名称长度必须为1至200个字符');
|
||||
}
|
||||
if (preg_match('/[\\\\\/:*?"<>|\x00-\x1F]/u', $name)) {
|
||||
throw new ApiException('文件名称包含非法字符');
|
||||
}
|
||||
if ($name === '.' || $name === '..') {
|
||||
throw new ApiException('文件名称错误');
|
||||
}
|
||||
}
|
||||
|
||||
private function splitName(string $fullName): array
|
||||
{
|
||||
$position = mb_strrpos($fullName, '.');
|
||||
if ($position === false || $position === 0 || $position === mb_strlen($fullName) - 1) {
|
||||
return [$fullName, ''];
|
||||
}
|
||||
return [mb_substr($fullName, 0, $position), strtolower(mb_substr($fullName, $position + 1))];
|
||||
}
|
||||
|
||||
private function findByFullName($query, string $fullName): ?File
|
||||
{
|
||||
[$name, $ext] = $this->splitName($fullName);
|
||||
$file = (clone $query)->whereName($name)->whereExt($ext)->first();
|
||||
if (!$file && $ext !== '') {
|
||||
$file = (clone $query)->whereType('folder')->whereName($fullName)->first();
|
||||
}
|
||||
return $file;
|
||||
}
|
||||
|
||||
private function typeFromExtension(string $ext): string
|
||||
{
|
||||
if (in_array($ext, ['md', 'markdown', 'text'], true)) return 'document';
|
||||
if ($ext === 'drawio') return 'drawio';
|
||||
if ($ext === 'mind') return 'mind';
|
||||
if (in_array($ext, ['doc', 'docx', 'dot', 'dotx', 'odt', 'ott', 'rtf'], true)) return 'word';
|
||||
if (in_array($ext, ['xls', 'xlsx', 'xlsm', 'xlt', 'xltx', 'ods', 'ots', 'csv', 'tsv'], true)) return 'excel';
|
||||
if (in_array($ext, ['ppt', 'pptx', 'pps', 'ppsx', 'pot', 'potx', 'odp', 'otp'], true)) return 'ppt';
|
||||
if (in_array($ext, File::imageExt, true) || $ext === 'svg') return 'picture';
|
||||
if (in_array($ext, File::codeExt, true)) return 'code';
|
||||
if (in_array($ext, ['rar', 'zip', 'jar', '7-zip', 'tar', 'gzip', '7z', 'gz'], true)) return 'archive';
|
||||
if (in_array($ext, ['mp3', 'wav', 'mp4', 'flv', 'avi', 'mov', 'wmv', 'mkv'], true)) return 'media';
|
||||
return match ($ext) {
|
||||
'pdf' => 'pdf', 'txt' => 'txt', 'xmind' => 'xmind', 'ofd' => 'ofd',
|
||||
'dwg', 'dxf' => 'cad', 'tif', 'tiff' => 'tif', 'wps' => 'wps',
|
||||
default => '',
|
||||
};
|
||||
}
|
||||
|
||||
private function writeTemp($data): string
|
||||
{
|
||||
$dir = storage_path('app/webdav/tmp');
|
||||
Base::makeDir($dir);
|
||||
$path = $dir . '/' . bin2hex(random_bytes(16));
|
||||
$output = fopen($path, 'wb');
|
||||
if (!$output) throw new ApiException('临时文件创建失败');
|
||||
$input = is_resource($data) ? $data : null;
|
||||
$max = WebDavConfig::get()['max_file_bytes'] ?? intval(config('dootask.webdav.max_file_bytes'));
|
||||
if ($input) {
|
||||
$written = 0;
|
||||
while (!feof($input)) {
|
||||
$chunk = fread($input, 1024 * 1024);
|
||||
if ($chunk === false) {
|
||||
fclose($output);
|
||||
@unlink($path);
|
||||
throw new ApiException('文件读取失败');
|
||||
}
|
||||
$written += strlen($chunk);
|
||||
if ($written > $max) {
|
||||
fclose($output);
|
||||
@unlink($path);
|
||||
throw new ApiException('文件大小超过限制');
|
||||
}
|
||||
fwrite($output, $chunk);
|
||||
}
|
||||
} else {
|
||||
$content = (string) $data;
|
||||
if (strlen($content) > $max) {
|
||||
fclose($output);
|
||||
@unlink($path);
|
||||
throw new ApiException('文件大小超过限制');
|
||||
}
|
||||
fwrite($output, $content);
|
||||
}
|
||||
fclose($output);
|
||||
return $path;
|
||||
}
|
||||
|
||||
private function moveToContentPath(string $temp, File $file): string
|
||||
{
|
||||
$dir = 'uploads/file/' . ($file->type ?: 'other') . '/' . date('Ym') . '/' . $file->id . '/';
|
||||
Base::makeDir(public_path($dir));
|
||||
$relative = $dir . hash_file('sha256', $temp) . '-' . bin2hex(random_bytes(4));
|
||||
if (!rename($temp, public_path($relative))) {
|
||||
throw new ApiException('文件保存失败');
|
||||
}
|
||||
return $relative;
|
||||
}
|
||||
|
||||
private function createContent(File $file, User $actor, string $relative): FileContent
|
||||
{
|
||||
$meta = ['from' => '', 'type' => $file->type, 'ext' => $file->ext, 'url' => $relative];
|
||||
if ($file->type === 'picture' && $size = @getimagesize(public_path($relative))) {
|
||||
$meta['width'] = $size[0];
|
||||
$meta['height'] = $size[1];
|
||||
}
|
||||
$content = FileContent::createInstance([
|
||||
'fid' => $file->id,
|
||||
'content' => $meta,
|
||||
'text' => '',
|
||||
'size' => $file->size,
|
||||
'userid' => $actor->userid,
|
||||
]);
|
||||
$content->save();
|
||||
return $content;
|
||||
}
|
||||
|
||||
private function cleanup(?string ...$paths): void
|
||||
{
|
||||
foreach ($paths as $path) {
|
||||
if (!$path) continue;
|
||||
$absolute = str_starts_with($path, 'uploads/') ? public_path($path) : $path;
|
||||
if (is_file($absolute)) @unlink($absolute);
|
||||
}
|
||||
}
|
||||
|
||||
private function push(File $file, string $action, $data = null): void
|
||||
{
|
||||
if (app()->bound('swoole')) {
|
||||
$file->pushMsg($action, $data);
|
||||
}
|
||||
}
|
||||
}
|
||||
74
app/Services/WebDav/Nodes/AbstractFileNode.php
Normal file
74
app/Services/WebDav/Nodes/AbstractFileNode.php
Normal file
@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\WebDav\Nodes;
|
||||
|
||||
use App\Exceptions\ApiException;
|
||||
use App\Models\File;
|
||||
use App\Models\User;
|
||||
use App\Services\FileSystem\FileSystemService;
|
||||
use App\Services\WebDav\WebDavExceptionMapper;
|
||||
|
||||
trait AbstractFileNode
|
||||
{
|
||||
protected FileSystemService $files;
|
||||
protected User $user;
|
||||
protected File $file;
|
||||
protected string $davName;
|
||||
protected string $scope;
|
||||
|
||||
protected function initializeNode(
|
||||
FileSystemService $files,
|
||||
User $user,
|
||||
File $file,
|
||||
?string $davName = null,
|
||||
string $scope = 'files'
|
||||
): void {
|
||||
$this->files = $files;
|
||||
$this->user = $user;
|
||||
$this->file = $file;
|
||||
$this->davName = $davName ?? $files->fullName($file);
|
||||
$this->scope = $scope;
|
||||
}
|
||||
|
||||
public function getName()
|
||||
{
|
||||
return $this->davName;
|
||||
}
|
||||
|
||||
public function getLastModified()
|
||||
{
|
||||
return $this->file->updated_at?->timestamp;
|
||||
}
|
||||
|
||||
public function setName($name)
|
||||
{
|
||||
if ($this->scope === 'shared' && intval($this->file->pshare) === intval($this->file->id)) {
|
||||
throw WebDavExceptionMapper::map(new ApiException('共享根目录不可写'));
|
||||
}
|
||||
try {
|
||||
$this->file = $this->files->rename($this->user, $this->file, $name);
|
||||
$this->davName = $name;
|
||||
} catch (ApiException $e) {
|
||||
throw WebDavExceptionMapper::map($e);
|
||||
}
|
||||
}
|
||||
|
||||
public function delete()
|
||||
{
|
||||
try {
|
||||
$this->files->delete($this->user, $this->file);
|
||||
} catch (ApiException $e) {
|
||||
throw WebDavExceptionMapper::map($e);
|
||||
}
|
||||
}
|
||||
|
||||
public function getFileModel(): File
|
||||
{
|
||||
return $this->file;
|
||||
}
|
||||
|
||||
public function getUser(): User
|
||||
{
|
||||
return $this->user;
|
||||
}
|
||||
}
|
||||
99
app/Services/WebDav/Nodes/DavDirectory.php
Normal file
99
app/Services/WebDav/Nodes/DavDirectory.php
Normal file
@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\WebDav\Nodes;
|
||||
|
||||
use App\Exceptions\ApiException;
|
||||
use App\Models\File as FileModel;
|
||||
use App\Models\User;
|
||||
use App\Services\FileSystem\FileSystemService;
|
||||
use Sabre\DAV\Collection;
|
||||
use App\Services\WebDav\WebDavExceptionMapper;
|
||||
use Sabre\DAV\Exception\NotFound;
|
||||
use Sabre\DAV\IMoveTarget;
|
||||
use Sabre\DAV\INode;
|
||||
|
||||
class DavDirectory extends Collection implements IMoveTarget
|
||||
{
|
||||
use AbstractFileNode;
|
||||
|
||||
public function __construct(
|
||||
FileSystemService $files,
|
||||
User $user,
|
||||
FileModel $file,
|
||||
?string $davName = null,
|
||||
string $scope = 'files'
|
||||
) {
|
||||
$this->initializeNode($files, $user, $file, $davName, $scope);
|
||||
}
|
||||
|
||||
public function getChildren()
|
||||
{
|
||||
try {
|
||||
return $this->files->children($this->user, $this->file)
|
||||
->map(fn(FileModel $file) => NodeFactory::make($this->files, $this->user, $file, null, $this->scope))
|
||||
->all();
|
||||
} catch (ApiException $e) {
|
||||
throw WebDavExceptionMapper::map($e);
|
||||
}
|
||||
}
|
||||
|
||||
public function getChild($name)
|
||||
{
|
||||
try {
|
||||
$file = $this->files->child($this->user, $this->file, $name);
|
||||
} catch (ApiException $e) {
|
||||
throw WebDavExceptionMapper::map($e);
|
||||
}
|
||||
if (!$file) {
|
||||
throw new NotFound('File not found');
|
||||
}
|
||||
return NodeFactory::make($this->files, $this->user, $file, null, $this->scope);
|
||||
}
|
||||
|
||||
public function childExists($name)
|
||||
{
|
||||
try {
|
||||
return $this->files->child($this->user, $this->file, $name) !== null;
|
||||
} catch (ApiException) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function createDirectory($name)
|
||||
{
|
||||
try {
|
||||
$this->files->createDirectory($this->user, $this->file, $name, $this->scope);
|
||||
} catch (ApiException $e) {
|
||||
throw WebDavExceptionMapper::map($e);
|
||||
}
|
||||
}
|
||||
|
||||
public function createFile($name, $data = null)
|
||||
{
|
||||
try {
|
||||
$file = $this->files->createFile($this->user, $this->file, $name, $data, $this->scope);
|
||||
return $this->files->etag($file);
|
||||
} catch (ApiException $e) {
|
||||
throw WebDavExceptionMapper::map($e);
|
||||
}
|
||||
}
|
||||
|
||||
public function moveInto($targetName, $sourcePath, INode $sourceNode)
|
||||
{
|
||||
if (!method_exists($sourceNode, 'getFileModel')) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
$this->files->move(
|
||||
$this->user,
|
||||
$sourceNode->getFileModel(),
|
||||
$this->file,
|
||||
$targetName,
|
||||
$this->scope
|
||||
);
|
||||
return true;
|
||||
} catch (ApiException $e) {
|
||||
throw WebDavExceptionMapper::map($e);
|
||||
}
|
||||
}
|
||||
}
|
||||
60
app/Services/WebDav/Nodes/DavFile.php
Normal file
60
app/Services/WebDav/Nodes/DavFile.php
Normal file
@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\WebDav\Nodes;
|
||||
|
||||
use App\Exceptions\ApiException;
|
||||
use App\Models\File as FileModel;
|
||||
use App\Models\User;
|
||||
use App\Services\FileSystem\FileSystemService;
|
||||
use App\Services\WebDav\WebDavExceptionMapper;
|
||||
use Sabre\DAV\File;
|
||||
|
||||
class DavFile extends File
|
||||
{
|
||||
use AbstractFileNode;
|
||||
|
||||
public function __construct(
|
||||
FileSystemService $files,
|
||||
User $user,
|
||||
FileModel $file,
|
||||
?string $davName = null,
|
||||
string $scope = 'files'
|
||||
) {
|
||||
$this->initializeNode($files, $user, $file, $davName, $scope);
|
||||
}
|
||||
|
||||
public function put($data)
|
||||
{
|
||||
try {
|
||||
$this->file = $this->files->replaceFile($this->user, $this->file, $data);
|
||||
return $this->getETag();
|
||||
} catch (ApiException $e) {
|
||||
throw WebDavExceptionMapper::map($e);
|
||||
}
|
||||
}
|
||||
|
||||
public function get()
|
||||
{
|
||||
try {
|
||||
FileModel::permissionFind($this->file->id, $this->user, 0);
|
||||
return $this->files->open($this->file);
|
||||
} catch (ApiException $e) {
|
||||
throw WebDavExceptionMapper::map($e);
|
||||
}
|
||||
}
|
||||
|
||||
public function getSize()
|
||||
{
|
||||
return intval($this->file->size);
|
||||
}
|
||||
|
||||
public function getETag()
|
||||
{
|
||||
return $this->files->etag($this->file);
|
||||
}
|
||||
|
||||
public function getContentType()
|
||||
{
|
||||
return $this->files->mime($this->file);
|
||||
}
|
||||
}
|
||||
18
app/Services/WebDav/Nodes/DavRoot.php
Normal file
18
app/Services/WebDav/Nodes/DavRoot.php
Normal file
@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\WebDav\Nodes;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Services\FileSystem\FileSystemService;
|
||||
use Sabre\DAV\SimpleCollection;
|
||||
|
||||
class DavRoot extends SimpleCollection
|
||||
{
|
||||
public function __construct(FileSystemService $files, User $user)
|
||||
{
|
||||
parent::__construct('root', [
|
||||
new FilesRoot($files, $user),
|
||||
new SharedRoot($files, $user),
|
||||
]);
|
||||
}
|
||||
}
|
||||
84
app/Services/WebDav/Nodes/FilesRoot.php
Normal file
84
app/Services/WebDav/Nodes/FilesRoot.php
Normal file
@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\WebDav\Nodes;
|
||||
|
||||
use App\Exceptions\ApiException;
|
||||
use App\Models\File;
|
||||
use App\Models\User;
|
||||
use App\Services\FileSystem\FileSystemService;
|
||||
use Sabre\DAV\Collection;
|
||||
use App\Services\WebDav\WebDavExceptionMapper;
|
||||
use Sabre\DAV\Exception\NotFound;
|
||||
use Sabre\DAV\IMoveTarget;
|
||||
use Sabre\DAV\INode;
|
||||
|
||||
class FilesRoot extends Collection implements IMoveTarget
|
||||
{
|
||||
public function __construct(private FileSystemService $files, private User $user)
|
||||
{
|
||||
}
|
||||
|
||||
public function getName() { return 'files'; }
|
||||
public function getLastModified() { return null; }
|
||||
|
||||
public function getChildren()
|
||||
{
|
||||
try {
|
||||
return $this->files->children($this->user, null, 'files')
|
||||
->map(fn(File $file) => NodeFactory::make($this->files, $this->user, $file))
|
||||
->all();
|
||||
} catch (ApiException $e) {
|
||||
throw WebDavExceptionMapper::map($e);
|
||||
}
|
||||
}
|
||||
|
||||
public function getChild($name)
|
||||
{
|
||||
try {
|
||||
$file = $this->files->child($this->user, null, $name, 'files');
|
||||
} catch (ApiException $e) {
|
||||
throw WebDavExceptionMapper::map($e);
|
||||
}
|
||||
if (!$file) throw new NotFound('File not found');
|
||||
return NodeFactory::make($this->files, $this->user, $file);
|
||||
}
|
||||
|
||||
public function childExists($name)
|
||||
{
|
||||
try {
|
||||
return $this->files->child($this->user, null, $name, 'files') !== null;
|
||||
} catch (ApiException) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
public function createDirectory($name)
|
||||
{
|
||||
try {
|
||||
$this->files->createDirectory($this->user, null, $name, 'files');
|
||||
} catch (ApiException $e) {
|
||||
throw WebDavExceptionMapper::map($e);
|
||||
}
|
||||
}
|
||||
|
||||
public function createFile($name, $data = null)
|
||||
{
|
||||
try {
|
||||
$file = $this->files->createFile($this->user, null, $name, $data, 'files');
|
||||
return $this->files->etag($file);
|
||||
} catch (ApiException $e) {
|
||||
throw WebDavExceptionMapper::map($e);
|
||||
}
|
||||
}
|
||||
|
||||
public function moveInto($targetName, $sourcePath, INode $sourceNode)
|
||||
{
|
||||
if (!method_exists($sourceNode, 'getFileModel')) return false;
|
||||
try {
|
||||
$this->files->move($this->user, $sourceNode->getFileModel(), null, $targetName, 'files');
|
||||
return true;
|
||||
} catch (ApiException $e) {
|
||||
throw WebDavExceptionMapper::map($e);
|
||||
}
|
||||
}
|
||||
}
|
||||
22
app/Services/WebDav/Nodes/NodeFactory.php
Normal file
22
app/Services/WebDav/Nodes/NodeFactory.php
Normal file
@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\WebDav\Nodes;
|
||||
|
||||
use App\Models\File;
|
||||
use App\Models\User;
|
||||
use App\Services\FileSystem\FileSystemService;
|
||||
|
||||
class NodeFactory
|
||||
{
|
||||
public static function make(
|
||||
FileSystemService $files,
|
||||
User $user,
|
||||
File $file,
|
||||
?string $davName = null,
|
||||
string $scope = 'files'
|
||||
) {
|
||||
return $file->type === 'folder'
|
||||
? new DavDirectory($files, $user, $file, $davName, $scope)
|
||||
: new DavFile($files, $user, $file, $davName, $scope);
|
||||
}
|
||||
}
|
||||
61
app/Services/WebDav/Nodes/SharedRoot.php
Normal file
61
app/Services/WebDav/Nodes/SharedRoot.php
Normal file
@ -0,0 +1,61 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\WebDav\Nodes;
|
||||
|
||||
use App\Models\File;
|
||||
use App\Models\User;
|
||||
use App\Services\FileSystem\FileSystemService;
|
||||
use Sabre\DAV\Collection;
|
||||
use Sabre\DAV\Exception\NotFound;
|
||||
|
||||
class SharedRoot extends Collection
|
||||
{
|
||||
public function __construct(private FileSystemService $files, private User $user)
|
||||
{
|
||||
}
|
||||
|
||||
public function getName() { return 'shared'; }
|
||||
public function getLastModified() { return null; }
|
||||
|
||||
public function getChildren()
|
||||
{
|
||||
return $this->files->children($this->user, null, 'shared')
|
||||
->map(fn(File $file) => NodeFactory::make(
|
||||
$this->files,
|
||||
$this->user,
|
||||
$file,
|
||||
$this->sharedName($file),
|
||||
'shared'
|
||||
))->all();
|
||||
}
|
||||
|
||||
public function getChild($name)
|
||||
{
|
||||
if (!preg_match('/ \[#(\d+)\](\.[^.]*)?$/u', $name, $matches)) {
|
||||
throw new NotFound('File not found');
|
||||
}
|
||||
$id = intval($matches[1]);
|
||||
$file = $this->files->children($this->user, null, 'shared')->firstWhere('id', $id);
|
||||
if (!$file || $this->sharedName($file) !== $name) {
|
||||
throw new NotFound('File not found');
|
||||
}
|
||||
return NodeFactory::make($this->files, $this->user, $file, $name, 'shared');
|
||||
}
|
||||
|
||||
public function childExists($name)
|
||||
{
|
||||
try {
|
||||
$this->getChild($name);
|
||||
return true;
|
||||
} catch (NotFound) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function sharedName(File $file): string
|
||||
{
|
||||
return $file->ext
|
||||
? $file->name . ' [#' . $file->id . '].' . $file->ext
|
||||
: $file->name . ' [#' . $file->id . ']';
|
||||
}
|
||||
}
|
||||
52
app/Services/WebDav/WebDavAuthenticator.php
Normal file
52
app/Services/WebDav/WebDavAuthenticator.php
Normal file
@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\WebDav;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\WebDavCredential;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
|
||||
class WebDavAuthenticator
|
||||
{
|
||||
private bool $rateLimited = false;
|
||||
|
||||
public function authenticate(Request $request): ?array
|
||||
{
|
||||
$header = (string) $request->header('Authorization');
|
||||
if (!str_starts_with($header, 'Basic ')) {
|
||||
return null;
|
||||
}
|
||||
$decoded = base64_decode(substr($header, 6), true);
|
||||
if ($decoded === false || !str_contains($decoded, ':')) {
|
||||
return null;
|
||||
}
|
||||
[$publicId, $secret] = explode(':', $decoded, 2);
|
||||
$key = 'webdav-auth:' . sha1($request->ip() . '|' . $publicId);
|
||||
if (RateLimiter::tooManyAttempts($key, 10)) {
|
||||
$this->rateLimited = true;
|
||||
return null;
|
||||
}
|
||||
|
||||
$credential = WebDavCredential::wherePublicId($publicId)->first();
|
||||
$user = $credential ? User::whereUserid($credential->userid)->first() : null;
|
||||
if (!$credential || !$user || !$credential->verify($secret) || !WebDavConfig::isAllowed($user)) {
|
||||
RateLimiter::hit($key, 60);
|
||||
return null;
|
||||
}
|
||||
RateLimiter::clear($key);
|
||||
|
||||
if (!$credential->last_used_at || $credential->last_used_at->lt(now()->subMinutes(5))) {
|
||||
$credential->last_used_at = now();
|
||||
$credential->last_used_ip = mb_substr((string) $request->ip(), 0, 45);
|
||||
$credential->last_user_agent = mb_substr((string) $request->userAgent(), 0, 255);
|
||||
$credential->save();
|
||||
}
|
||||
return [$user, $credential];
|
||||
}
|
||||
|
||||
public function wasRateLimited(): bool
|
||||
{
|
||||
return $this->rateLimited;
|
||||
}
|
||||
}
|
||||
219
app/Services/WebDav/WebDavConfig.php
Normal file
219
app/Services/WebDav/WebDavConfig.php
Normal file
@ -0,0 +1,219 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\WebDav;
|
||||
|
||||
use App\Models\File;
|
||||
use App\Models\User;
|
||||
use App\Module\Base;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class WebDavConfig
|
||||
{
|
||||
public static function get(): array
|
||||
{
|
||||
$setting = Base::setting('fileSetting');
|
||||
$permissionType = $setting['webdav_permission_type'] ?? 'all';
|
||||
if (!in_array($permissionType, ['all', 'appoint'], true)) {
|
||||
$permissionType = 'all';
|
||||
}
|
||||
|
||||
$maxExpireDays = min(3650, max(1, intval($setting['webdav_max_expire_days'] ?? 365)));
|
||||
return [
|
||||
'enabled' => ($setting['webdav_enabled'] ?? 'close') === 'open',
|
||||
'permission_type' => $permissionType,
|
||||
'permission_userids' => self::normalizeUserIds($setting['webdav_permission_userids'] ?? []),
|
||||
'max_credentials' => min(20, max(1, intval($setting['webdav_max_credentials'] ?? 5))),
|
||||
'default_expire_days' => min($maxExpireDays, max(1, intval($setting['webdav_default_expire_days'] ?? 90))),
|
||||
'max_expire_days' => $maxExpireDays,
|
||||
'max_file_bytes' => min(
|
||||
intval(config('dootask.webdav.max_file_bytes', 1024 * 1024 * 1024)),
|
||||
max(1024 * 1024, intval($setting['webdav_max_file_bytes'] ?? 1024 * 1024 * 1024))
|
||||
),
|
||||
'copy_max_nodes' => min(10000, max(1, intval($setting['webdav_copy_max_nodes'] ?? 1000))),
|
||||
'audit_retention_days' => min(365, max(7, intval($setting['webdav_audit_retention_days'] ?? 90))),
|
||||
];
|
||||
}
|
||||
|
||||
public static function isAllowed(User $user, ?array $config = null): bool
|
||||
{
|
||||
$config ??= self::get();
|
||||
if (!$config['enabled'] || $user->isDisable(true)) {
|
||||
return false;
|
||||
}
|
||||
return $config['permission_type'] === 'all'
|
||||
|| in_array(intval($user->userid), $config['permission_userids'], true);
|
||||
}
|
||||
|
||||
public static function url(): string
|
||||
{
|
||||
return rtrim(request()->getSchemeAndHttpHost(), '/') . '/dav/';
|
||||
}
|
||||
|
||||
public static function normalizeAdminInput(array $input): array
|
||||
{
|
||||
$enabled = ($input['webdav_enabled'] ?? 'close') === 'open' ? 'open' : 'close';
|
||||
$permissionType = ($input['webdav_permission_type'] ?? 'all') === 'appoint' ? 'appoint' : 'all';
|
||||
$maxExpireDays = min(3650, max(1, intval($input['webdav_max_expire_days'] ?? 365)));
|
||||
return [
|
||||
'webdav_enabled' => $enabled,
|
||||
'webdav_permission_type' => $permissionType,
|
||||
'webdav_permission_userids' => self::normalizeUserIds($input['webdav_permission_userids'] ?? []),
|
||||
'webdav_max_credentials' => min(20, max(1, intval($input['webdav_max_credentials'] ?? 5))),
|
||||
'webdav_default_expire_days' => min($maxExpireDays, max(1, intval($input['webdav_default_expire_days'] ?? 90))),
|
||||
'webdav_max_expire_days' => $maxExpireDays,
|
||||
'webdav_max_file_bytes' => min(
|
||||
intval(config('dootask.webdav.max_file_bytes', 1024 * 1024 * 1024)),
|
||||
max(1024 * 1024, intval($input['webdav_max_file_bytes'] ?? 1024 * 1024 * 1024))
|
||||
),
|
||||
'webdav_copy_max_nodes' => min(10000, max(1, intval($input['webdav_copy_max_nodes'] ?? 1000))),
|
||||
'webdav_audit_retention_days' => min(365, max(7, intval($input['webdav_audit_retention_days'] ?? 90))),
|
||||
];
|
||||
}
|
||||
|
||||
public static function adminForm(): array
|
||||
{
|
||||
$config = self::get();
|
||||
return [
|
||||
'webdav_enabled' => $config['enabled'] ? 'open' : 'close',
|
||||
'webdav_permission_type' => $config['permission_type'],
|
||||
'webdav_permission_userids' => $config['permission_userids'],
|
||||
'webdav_max_credentials' => $config['max_credentials'],
|
||||
'webdav_default_expire_days' => $config['default_expire_days'],
|
||||
'webdav_max_expire_days' => $config['max_expire_days'],
|
||||
'webdav_max_file_bytes' => $config['max_file_bytes'],
|
||||
'webdav_copy_max_nodes' => $config['copy_max_nodes'],
|
||||
'webdav_audit_retention_days' => $config['audit_retention_days'],
|
||||
];
|
||||
}
|
||||
|
||||
public static function pathConflictCount(): int
|
||||
{
|
||||
return DB::query()->fromSub(function ($query) {
|
||||
$query->from('files')
|
||||
->select(['pid', 'userid', 'name', 'ext'])
|
||||
->selectRaw('COUNT(*) AS aggregate')
|
||||
->whereNull('deleted_at')
|
||||
->groupBy(['pid', 'userid', 'name', 'ext'])
|
||||
->havingRaw('COUNT(*) > 1');
|
||||
}, 'duplicates')->count();
|
||||
}
|
||||
|
||||
public static function pathConflicts(int $page = 1, int $pageSize = 20, ?User $viewer = null): array
|
||||
{
|
||||
$page = max(1, $page);
|
||||
$pageSize = min(100, max(1, $pageSize));
|
||||
$total = self::pathConflictCount();
|
||||
$groups = DB::table('files')
|
||||
->select(['pid', 'userid', 'name', 'ext'])
|
||||
->selectRaw('COUNT(*) AS file_count')
|
||||
->whereNull('deleted_at')
|
||||
->groupBy(['pid', 'userid', 'name', 'ext'])
|
||||
->havingRaw('COUNT(*) > 1')
|
||||
->orderBy('userid')
|
||||
->orderBy('pid')
|
||||
->orderBy('name')
|
||||
->offset(($page - 1) * $pageSize)
|
||||
->limit($pageSize)
|
||||
->get();
|
||||
|
||||
$userIds = $groups->pluck('userid')->map('intval')->unique()->values();
|
||||
$users = User::whereIn('userid', $userIds)->get(['userid', 'nickname', 'email'])->keyBy('userid');
|
||||
$data = $groups->map(function ($group) use ($users, $viewer) {
|
||||
$files = File::wherePid($group->pid)
|
||||
->whereUserid($group->userid)
|
||||
->whereName($group->name)
|
||||
->whereExt($group->ext)
|
||||
->orderBy('id')
|
||||
->get(['id', 'pid', 'pids', 'name', 'ext', 'type', 'userid', 'created_id', 'share', 'pshare', 'updated_at']);
|
||||
$ancestorIds = $files->flatMap(fn(File $file) => self::pathIds($file->pids))->unique()->values();
|
||||
$ancestors = File::withTrashed()->whereIn('id', $ancestorIds)
|
||||
->get(['id', 'name', 'ext', 'type', 'deleted_at'])->keyBy('id');
|
||||
$first = $files->first();
|
||||
$parentNames = $first ? collect(self::pathIds($first->pids))->map(function (int $id) use ($ancestors) {
|
||||
/** @var File|null $ancestor */
|
||||
$ancestor = $ancestors->get($id);
|
||||
return $ancestor ? $ancestor->getNameAndExt() : "#{$id}";
|
||||
})->all() : [];
|
||||
$fullName = $first?->getNameAndExt() ?? (string) $group->name;
|
||||
$user = $users->get(intval($group->userid));
|
||||
|
||||
return [
|
||||
'owner' => [
|
||||
'userid' => intval($group->userid),
|
||||
'nickname' => $user?->nickname ?: '',
|
||||
'email' => $user?->email ?: '',
|
||||
],
|
||||
'parent_id' => intval($group->pid),
|
||||
'parent_path' => '/' . implode('/', $parentNames),
|
||||
'path' => '/' . implode('/', array_merge($parentNames, [$fullName])),
|
||||
'file_count' => intval($group->file_count),
|
||||
'files' => $files->map(function (File $file) use ($viewer) {
|
||||
$canOpenLocation = false;
|
||||
$locationBoard = null;
|
||||
$locationParentId = null;
|
||||
if ($viewer) {
|
||||
$isOwner = intval($file->userid) === intval($viewer->userid);
|
||||
$permission = $file->getPermission($viewer->isTemp() ? [$viewer->userid] : [0, $viewer->userid]);
|
||||
$isShared = intval($file->pshare) > 0;
|
||||
$canOpenLocation = $isOwner || ($isShared && $permission >= 0);
|
||||
if ($canOpenLocation) {
|
||||
$locationBoard = $isOwner ? 'mine' : 'shared';
|
||||
$locationParentId = !$isOwner && intval($file->pshare) === intval($file->id)
|
||||
? 0
|
||||
: intval($file->pid);
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => intval($file->id),
|
||||
'type' => $file->type,
|
||||
'full_name' => $file->getNameAndExt(),
|
||||
'updated_at' => $file->updated_at?->toDateTimeString(),
|
||||
'can_open_location' => $canOpenLocation,
|
||||
'location_board' => $locationBoard,
|
||||
'location_parent_id' => $locationParentId,
|
||||
];
|
||||
})->values()->all(),
|
||||
];
|
||||
})->values()->all();
|
||||
|
||||
return [
|
||||
'current_page' => $page,
|
||||
'per_page' => $pageSize,
|
||||
'total' => $total,
|
||||
'data' => $data,
|
||||
];
|
||||
}
|
||||
|
||||
public static function filePath(File $file): string
|
||||
{
|
||||
$ancestorIds = self::pathIds($file->pids);
|
||||
$ancestors = File::withTrashed()->whereIn('id', $ancestorIds)
|
||||
->get(['id', 'name', 'ext'])->keyBy('id');
|
||||
$names = collect($ancestorIds)->map(function (int $id) use ($ancestors) {
|
||||
/** @var File|null $ancestor */
|
||||
$ancestor = $ancestors->get($id);
|
||||
return $ancestor ? $ancestor->getNameAndExt() : "#{$id}";
|
||||
})->all();
|
||||
return '/' . implode('/', array_merge($names, [$file->getNameAndExt()]));
|
||||
}
|
||||
|
||||
private static function pathIds(?string $pids): array
|
||||
{
|
||||
if (!$pids) {
|
||||
return [];
|
||||
}
|
||||
return array_values(array_filter(array_map('intval', explode(',', trim($pids, ',')))));
|
||||
}
|
||||
|
||||
private static function normalizeUserIds(mixed $userIds): array
|
||||
{
|
||||
if (!is_array($userIds)) {
|
||||
return [];
|
||||
}
|
||||
return array_values(array_unique(array_filter(
|
||||
array_map('intval', $userIds),
|
||||
fn(int $userId) => $userId > 0
|
||||
)));
|
||||
}
|
||||
}
|
||||
62
app/Services/WebDav/WebDavConflictService.php
Normal file
62
app/Services/WebDav/WebDavConflictService.php
Normal file
@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\WebDav;
|
||||
|
||||
use App\Exceptions\ApiException;
|
||||
use App\Models\AbstractModel;
|
||||
use App\Models\File;
|
||||
use App\Models\User;
|
||||
use App\Models\WebDavOperationLog;
|
||||
use App\Services\FileSystem\FileSystemService;
|
||||
|
||||
class WebDavConflictService
|
||||
{
|
||||
public function renameAsAdmin(
|
||||
User $admin,
|
||||
int $fileId,
|
||||
string $name,
|
||||
?string $requestId,
|
||||
?string $ip,
|
||||
?string $userAgent
|
||||
): File {
|
||||
if (!$admin->isAdmin()) {
|
||||
throw new ApiException('仅限管理员操作');
|
||||
}
|
||||
if ($name === '') {
|
||||
throw new ApiException('文件名不能为空');
|
||||
}
|
||||
|
||||
return AbstractModel::transaction(function () use ($admin, $fileId, $name, $requestId, $ip, $userAgent) {
|
||||
$file = File::whereId($fileId)->lockForUpdate()->first();
|
||||
if (!$file) {
|
||||
throw new ApiException('文件不存在或已被删除');
|
||||
}
|
||||
$conflictCount = File::wherePid($file->pid)
|
||||
->whereUserid($file->userid)
|
||||
->whereName($file->name)
|
||||
->whereExt($file->ext)
|
||||
->lockForUpdate()
|
||||
->count();
|
||||
if ($conflictCount < 2) {
|
||||
throw new ApiException('文件已不在冲突组中');
|
||||
}
|
||||
|
||||
$oldName = $file->getNameAndExt();
|
||||
$oldPath = WebDavConfig::filePath($file);
|
||||
$ownerId = intval($file->userid);
|
||||
$file = (new FileSystemService())->renameConflictAsAdmin($admin, $file, $name);
|
||||
WebDavOperationLog::createInstance([
|
||||
'request_id' => mb_substr((string) $requestId, 0, 100),
|
||||
'userid' => intval($admin->userid),
|
||||
'method' => 'ADMIN_RENAME',
|
||||
'uri' => mb_substr($oldPath, 0, 1000),
|
||||
'file_id' => intval($file->id),
|
||||
'status' => 200,
|
||||
'result' => mb_substr("owner={$ownerId}; {$oldName} -> {$file->getNameAndExt()}", 0, 255),
|
||||
'ip' => mb_substr((string) $ip, 0, 45),
|
||||
'user_agent' => mb_substr((string) $userAgent, 0, 255),
|
||||
])->save();
|
||||
return $file;
|
||||
});
|
||||
}
|
||||
}
|
||||
44
app/Services/WebDav/WebDavCopyGuardPlugin.php
Normal file
44
app/Services/WebDav/WebDavCopyGuardPlugin.php
Normal file
@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\WebDav;
|
||||
|
||||
use Sabre\DAV\ICollection;
|
||||
use Sabre\DAV\INode;
|
||||
use Sabre\DAV\Server;
|
||||
use Sabre\DAV\ServerPlugin;
|
||||
use Sabre\DAV\Exception\InsufficientStorage;
|
||||
|
||||
class WebDavCopyGuardPlugin extends ServerPlugin
|
||||
{
|
||||
private Server $server;
|
||||
|
||||
public function initialize(Server $server)
|
||||
{
|
||||
$this->server = $server;
|
||||
$server->on('beforeCopy', [$this, 'beforeCopy'], 50);
|
||||
}
|
||||
|
||||
public function getPluginName()
|
||||
{
|
||||
return 'dootask-copy-guard';
|
||||
}
|
||||
|
||||
public function beforeCopy(string $sourcePath): void
|
||||
{
|
||||
$remaining = WebDavConfig::get()['copy_max_nodes'];
|
||||
$this->countNode($this->server->tree->getNodeForPath($sourcePath), $remaining);
|
||||
}
|
||||
|
||||
private function countNode(INode $node, int &$remaining): void
|
||||
{
|
||||
if (--$remaining < 0) {
|
||||
throw new InsufficientStorage('复制的文件和文件夹数量超过限制');
|
||||
}
|
||||
if (!$node instanceof ICollection) {
|
||||
return;
|
||||
}
|
||||
foreach ($node->getChildren() as $child) {
|
||||
$this->countNode($child, $remaining);
|
||||
}
|
||||
}
|
||||
}
|
||||
44
app/Services/WebDav/WebDavExceptionMapper.php
Normal file
44
app/Services/WebDav/WebDavExceptionMapper.php
Normal file
@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\WebDav;
|
||||
|
||||
use App\Exceptions\ApiException;
|
||||
use Sabre\DAV\Exception;
|
||||
use Sabre\DAV\Exception\Conflict;
|
||||
use Sabre\DAV\Exception\Forbidden;
|
||||
use Sabre\DAV\Exception\InsufficientStorage;
|
||||
use Sabre\DAV\Exception\NotFound;
|
||||
|
||||
class WebDavExceptionMapper
|
||||
{
|
||||
public static function map(ApiException $exception): Exception
|
||||
{
|
||||
$message = $exception->getMessage();
|
||||
|
||||
if (str_contains($message, '不存在') || str_contains($message, '已被删除')) {
|
||||
return new NotFound($message);
|
||||
}
|
||||
if (
|
||||
str_contains($message, '已存在')
|
||||
|| str_contains($message, '位置错误')
|
||||
|| str_contains($message, '名称')
|
||||
|| str_contains($message, '不是文件夹')
|
||||
|| str_contains($message, '文件夹不能写入')
|
||||
) {
|
||||
return new Conflict($message);
|
||||
}
|
||||
if (str_contains($message, '大小超过限制')) {
|
||||
return new WebDavPayloadTooLarge($message);
|
||||
}
|
||||
if (
|
||||
str_contains($message, '最多只能创建')
|
||||
|| str_contains($message, '保存失败')
|
||||
|| str_contains($message, '临时文件创建失败')
|
||||
|| str_contains($message, '读取失败')
|
||||
) {
|
||||
return new InsufficientStorage($message);
|
||||
}
|
||||
|
||||
return new Forbidden($message);
|
||||
}
|
||||
}
|
||||
94
app/Services/WebDav/WebDavLockBackend.php
Normal file
94
app/Services/WebDav/WebDavLockBackend.php
Normal file
@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\WebDav;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\WebDavCredential;
|
||||
use App\Models\WebDavLock;
|
||||
use Sabre\DAV\Locks\Backend\AbstractBackend;
|
||||
use Sabre\DAV\Locks\LockInfo;
|
||||
use Sabre\DAV\Server;
|
||||
|
||||
class WebDavLockBackend extends AbstractBackend
|
||||
{
|
||||
public function __construct(private User $user, private WebDavCredential $credential)
|
||||
{
|
||||
}
|
||||
|
||||
public function getLocks($uri, $returnChildLocks)
|
||||
{
|
||||
$prefix = $this->namespacePrefix($uri);
|
||||
$rows = WebDavLock::where('timeout_at', '>', now())
|
||||
->where('uri', 'like', $prefix . '%')
|
||||
->get();
|
||||
$result = [];
|
||||
foreach ($rows as $row) {
|
||||
$lockUri = substr($row->uri, strlen($prefix));
|
||||
$isExact = $lockUri === $uri;
|
||||
$isParent = $row->depth === 'infinity' && str_starts_with($uri, rtrim($lockUri, '/') . '/');
|
||||
$isChild = $returnChildLocks && str_starts_with($lockUri, rtrim($uri, '/') . '/');
|
||||
if (!$isExact && !$isParent && !$isChild) continue;
|
||||
|
||||
$lock = new LockInfo();
|
||||
$lock->owner = $row->owner;
|
||||
$lock->token = $row->token;
|
||||
$lock->timeout = max(1, $row->timeout_at->timestamp - time());
|
||||
$lock->created = $row->created_at?->timestamp ?? time();
|
||||
$lock->scope = $row->scope === 'shared' ? LockInfo::SHARED : LockInfo::EXCLUSIVE;
|
||||
$lock->depth = $row->depth === 'infinity' ? Server::DEPTH_INFINITY : 0;
|
||||
$lock->uri = $lockUri;
|
||||
$result[] = $lock;
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function lock($uri, LockInfo $lockInfo)
|
||||
{
|
||||
$storageUri = $this->storageUri($uri);
|
||||
$timeout = intval($lockInfo->timeout);
|
||||
if ($timeout <= 0 || $timeout === LockInfo::TIMEOUT_INFINITE) {
|
||||
$timeout = intval(config('dootask.webdav.lock_timeout_seconds', 1800));
|
||||
}
|
||||
$timeout = min($timeout, intval(config('dootask.webdav.lock_max_timeout_seconds', 7200)));
|
||||
$row = WebDavLock::whereToken($lockInfo->token)->first();
|
||||
$params = [
|
||||
'userid' => $this->user->userid,
|
||||
'credential_id' => $this->credential->id,
|
||||
'uri' => $storageUri,
|
||||
'uri_hash' => hash('sha256', $storageUri),
|
||||
'owner' => mb_substr((string) $lockInfo->owner, 0, 255),
|
||||
'scope' => $lockInfo->scope === LockInfo::SHARED ? 'shared' : 'exclusive',
|
||||
'depth' => $lockInfo->depth === Server::DEPTH_INFINITY ? 'infinity' : '0',
|
||||
'timeout_at' => now()->addSeconds($timeout),
|
||||
];
|
||||
if ($row) {
|
||||
$row->updateInstance($params);
|
||||
} else {
|
||||
$row = WebDavLock::createInstance(array_merge($params, ['token' => $lockInfo->token]));
|
||||
}
|
||||
$row->save();
|
||||
return true;
|
||||
}
|
||||
|
||||
public function unlock($uri, LockInfo $lockInfo)
|
||||
{
|
||||
$storageUri = $this->storageUri($uri);
|
||||
return WebDavLock::whereUriHash(hash('sha256', $storageUri))
|
||||
->whereToken($lockInfo->token)
|
||||
->whereUserid($this->user->userid)
|
||||
->delete() > 0;
|
||||
}
|
||||
|
||||
private function storageUri(string $uri): string
|
||||
{
|
||||
return $this->namespacePrefix($uri) . $uri;
|
||||
}
|
||||
|
||||
private function namespacePrefix(string $uri): string
|
||||
{
|
||||
$path = ltrim($uri, '/');
|
||||
return ($path === 'shared' || str_starts_with($path, 'shared/'))
|
||||
? 'shared:'
|
||||
: 'user:' . $this->user->userid . ':';
|
||||
}
|
||||
}
|
||||
111
app/Services/WebDav/WebDavMovePlugin.php
Normal file
111
app/Services/WebDav/WebDavMovePlugin.php
Normal file
@ -0,0 +1,111 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\WebDav;
|
||||
|
||||
use App\Exceptions\ApiException;
|
||||
use App\Services\FileSystem\FileSystemService;
|
||||
use App\Services\WebDav\Nodes\DavFile;
|
||||
use Sabre\DAV\Server;
|
||||
use Sabre\DAV\ServerPlugin;
|
||||
use Sabre\HTTP\RequestInterface;
|
||||
use Sabre\HTTP\ResponseInterface;
|
||||
|
||||
class WebDavMovePlugin extends ServerPlugin
|
||||
{
|
||||
private Server $server;
|
||||
|
||||
public function __construct(private FileSystemService $files)
|
||||
{
|
||||
}
|
||||
|
||||
public function initialize(Server $server)
|
||||
{
|
||||
$this->server = $server;
|
||||
$server->on('method:MOVE', [$this, 'httpMove'], 50);
|
||||
$server->on('method:COPY', [$this, 'httpCopy'], 50);
|
||||
}
|
||||
|
||||
public function getPluginName()
|
||||
{
|
||||
return 'dootask-move';
|
||||
}
|
||||
|
||||
public function httpMove(RequestInterface $request, ResponseInterface $response): ?bool
|
||||
{
|
||||
$move = $this->server->getCopyAndMoveInfo($request);
|
||||
if (!$move['destinationExists']) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$sourcePath = $request->getPath();
|
||||
$source = $this->server->tree->getNodeForPath($sourcePath);
|
||||
$destination = $move['destinationNode'];
|
||||
if (!$source instanceof DavFile || !$destination instanceof DavFile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!$this->server->emit('beforeUnbind', [$sourcePath])) {
|
||||
return false;
|
||||
}
|
||||
if (!$this->server->emit('beforeBind', [$move['destination']])) {
|
||||
return false;
|
||||
}
|
||||
if (!$this->server->emit('beforeMove', [$sourcePath, $move['destination']])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->files->overwriteByMove(
|
||||
$source->getUser(),
|
||||
$source->getFileModel(),
|
||||
$destination->getFileModel()
|
||||
);
|
||||
} catch (ApiException $exception) {
|
||||
throw WebDavExceptionMapper::map($exception);
|
||||
}
|
||||
|
||||
$this->server->emit('afterUnbind', [$sourcePath]);
|
||||
$this->server->emit('afterBind', [$move['destination']]);
|
||||
$response->setHeader('Content-Length', '0');
|
||||
$response->setStatus(204);
|
||||
return false;
|
||||
}
|
||||
|
||||
public function httpCopy(RequestInterface $request, ResponseInterface $response): ?bool
|
||||
{
|
||||
$copy = $this->server->getCopyAndMoveInfo($request);
|
||||
if (!$copy['destinationExists']) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$sourcePath = $request->getPath();
|
||||
$source = $this->server->tree->getNodeForPath($sourcePath);
|
||||
$destination = $copy['destinationNode'];
|
||||
if (!$source instanceof DavFile || !$destination instanceof DavFile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!$this->server->emit('beforeBind', [$copy['destination']])) {
|
||||
return false;
|
||||
}
|
||||
if (!$this->server->emit('beforeCopy', [$sourcePath, $copy['destination'], $copy['depth']])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->files->overwriteByCopy(
|
||||
$source->getUser(),
|
||||
$source->getFileModel(),
|
||||
$destination->getFileModel()
|
||||
);
|
||||
} catch (ApiException $exception) {
|
||||
throw WebDavExceptionMapper::map($exception);
|
||||
}
|
||||
|
||||
$this->server->emit('afterCopy', [$sourcePath, $copy['destination'], $copy['depth']]);
|
||||
$this->server->emit('afterBind', [$copy['destination']]);
|
||||
$response->setHeader('Content-Length', '0');
|
||||
$response->setStatus(204);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
13
app/Services/WebDav/WebDavPayloadTooLarge.php
Normal file
13
app/Services/WebDav/WebDavPayloadTooLarge.php
Normal file
@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\WebDav;
|
||||
|
||||
use Sabre\DAV\Exception;
|
||||
|
||||
class WebDavPayloadTooLarge extends Exception
|
||||
{
|
||||
public function getHTTPCode()
|
||||
{
|
||||
return 413;
|
||||
}
|
||||
}
|
||||
86
app/Services/WebDav/WebDavPropertyBackend.php
Normal file
86
app/Services/WebDav/WebDavPropertyBackend.php
Normal file
@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\WebDav;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\WebDavProperty;
|
||||
use Sabre\DAV\PropFind;
|
||||
use Sabre\DAV\PropPatch;
|
||||
use Sabre\DAV\PropertyStorage\Backend\BackendInterface;
|
||||
use Sabre\DAV\Xml\Property\Complex;
|
||||
|
||||
class WebDavPropertyBackend implements BackendInterface
|
||||
{
|
||||
public function __construct(private User $user)
|
||||
{
|
||||
}
|
||||
|
||||
public function propFind($path, PropFind $propFind)
|
||||
{
|
||||
if (!$propFind->isAllProps() && count($propFind->get404Properties()) === 0) return;
|
||||
$rows = WebDavProperty::whereUserid($this->user->userid)
|
||||
->wherePathHash(hash('sha256', $path))->get();
|
||||
foreach ($rows as $row) {
|
||||
$value = $row->value_type === 2 ? new Complex($row->value) : $row->value;
|
||||
$propFind->set($row->name, $value);
|
||||
}
|
||||
}
|
||||
|
||||
public function propPatch($path, PropPatch $propPatch)
|
||||
{
|
||||
$propPatch->handleRemaining(function (array $properties) use ($path) {
|
||||
foreach ($properties as $value) {
|
||||
if ($value !== null && !$value instanceof Complex && !is_scalar($value)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
foreach ($properties as $name => $value) {
|
||||
$where = [
|
||||
'userid' => $this->user->userid,
|
||||
'path_hash' => hash('sha256', $path),
|
||||
'property_hash' => hash('sha256', $name),
|
||||
];
|
||||
if ($value === null) {
|
||||
WebDavProperty::where($where)->delete();
|
||||
continue;
|
||||
}
|
||||
if ($value instanceof Complex) {
|
||||
$valueType = 2;
|
||||
$value = $value->getXml();
|
||||
} else {
|
||||
$valueType = 1;
|
||||
$value = (string) $value;
|
||||
}
|
||||
WebDavProperty::updateInsert($where, [
|
||||
'path' => $path,
|
||||
'name' => $name,
|
||||
'value_type' => $valueType,
|
||||
'value' => $value,
|
||||
]);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
public function delete($path)
|
||||
{
|
||||
$rows = WebDavProperty::whereUserid($this->user->userid)->get();
|
||||
foreach ($rows as $row) {
|
||||
if ($row->path === $path || str_starts_with($row->path, rtrim($path, '/') . '/')) {
|
||||
$row->delete();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function move($source, $destination)
|
||||
{
|
||||
$rows = WebDavProperty::whereUserid($this->user->userid)->get();
|
||||
foreach ($rows as $row) {
|
||||
if ($row->path !== $source && !str_starts_with($row->path, rtrim($source, '/') . '/')) continue;
|
||||
$suffix = substr($row->path, strlen($source));
|
||||
$row->path = $destination . $suffix;
|
||||
$row->path_hash = hash('sha256', $row->path);
|
||||
$row->save();
|
||||
}
|
||||
}
|
||||
}
|
||||
14
app/Services/WebDav/WebDavSapi.php
Normal file
14
app/Services/WebDav/WebDavSapi.php
Normal file
@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\WebDav;
|
||||
|
||||
use Sabre\HTTP\ResponseInterface;
|
||||
use Sabre\HTTP\Sapi;
|
||||
|
||||
class WebDavSapi extends Sapi
|
||||
{
|
||||
public static function sendResponse(ResponseInterface $response)
|
||||
{
|
||||
// Symfony/Laravel owns the actual response emission.
|
||||
}
|
||||
}
|
||||
32
app/Services/WebDav/WebDavServerFactory.php
Normal file
32
app/Services/WebDav/WebDavServerFactory.php
Normal file
@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\WebDav;
|
||||
|
||||
use App\Models\User;
|
||||
use App\Models\WebDavCredential;
|
||||
use App\Services\FileSystem\FileSystemService;
|
||||
use App\Services\WebDav\Nodes\DavRoot;
|
||||
use Sabre\DAV\Locks\Plugin as LocksPlugin;
|
||||
use Sabre\DAV\PropertyStorage\Plugin as PropertyStoragePlugin;
|
||||
use Sabre\DAV\Server;
|
||||
|
||||
class WebDavServerFactory
|
||||
{
|
||||
public function make(User $user, WebDavCredential $credential): Server
|
||||
{
|
||||
$files = new FileSystemService();
|
||||
$server = new Server(
|
||||
new DavRoot($files, $user),
|
||||
new WebDavSapi()
|
||||
);
|
||||
$server->setBaseUri('/dav/');
|
||||
$server->debugExceptions = false;
|
||||
Server::$exposeVersion = false;
|
||||
$server->enablePropfindDepthInfinity = false;
|
||||
$server->addPlugin(new LocksPlugin(new WebDavLockBackend($user, $credential)));
|
||||
$server->addPlugin(new PropertyStoragePlugin(new WebDavPropertyBackend($user)));
|
||||
$server->addPlugin(new WebDavMovePlugin($files));
|
||||
$server->addPlugin(new WebDavCopyGuardPlugin());
|
||||
return $server;
|
||||
}
|
||||
}
|
||||
@ -33,6 +33,8 @@ return Application::configure(basePath: $_ENV['APP_BASE_PATH'] ?? dirname(__DIR_
|
||||
$middleware->validateCsrfTokens(except: [
|
||||
// 接口部分
|
||||
'api/*',
|
||||
'dav',
|
||||
'dav/*',
|
||||
|
||||
// 发布桌面端
|
||||
'desktop/publish/',
|
||||
|
||||
@ -37,6 +37,7 @@
|
||||
"phpoffice/phppresentation": "^1.2",
|
||||
"phpoffice/phpword": "^1.4",
|
||||
"predis/predis": "^2.3",
|
||||
"sabre/dav": "^4.7",
|
||||
"smalot/pdfparser": "^2.11",
|
||||
"symfony/console": "^7.4",
|
||||
"symfony/yaml": "^7.4"
|
||||
|
||||
447
composer.lock
generated
447
composer.lock
generated
@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "394ccedcb5eb9fcf3ebadc4c65689688",
|
||||
"content-hash": "9563bf2796d1179ba8740789be1ef49b",
|
||||
"packages": [
|
||||
{
|
||||
"name": "brick/math",
|
||||
@ -5215,6 +5215,451 @@
|
||||
},
|
||||
"time": "2025-12-14T04:43:48+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sabre/dav",
|
||||
"version": "4.7.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/sabre-io/dav.git",
|
||||
"reference": "f36f002dce082e1d425c4a0dc8c71a6b176c3b07"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/sabre-io/dav/zipball/f36f002dce082e1d425c4a0dc8c71a6b176c3b07",
|
||||
"reference": "f36f002dce082e1d425c4a0dc8c71a6b176c3b07",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-ctype": "*",
|
||||
"ext-date": "*",
|
||||
"ext-dom": "*",
|
||||
"ext-iconv": "*",
|
||||
"ext-json": "*",
|
||||
"ext-mbstring": "*",
|
||||
"ext-pcre": "*",
|
||||
"ext-simplexml": "*",
|
||||
"ext-spl": "*",
|
||||
"lib-libxml": ">=2.7.0",
|
||||
"php": "^7.1.0 || ^8.0",
|
||||
"psr/log": "^1.0 || ^2.0 || ^3.0",
|
||||
"sabre/event": "^5.0",
|
||||
"sabre/http": "^5.0.5",
|
||||
"sabre/uri": "^2.0",
|
||||
"sabre/vobject": "^4.2.1",
|
||||
"sabre/xml": "^2.0.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"friendsofphp/php-cs-fixer": "^2.19",
|
||||
"monolog/monolog": "^1.27 || ^2.0",
|
||||
"phpstan/phpstan": "^0.12 || ^1.0",
|
||||
"phpstan/phpstan-phpunit": "^1.0",
|
||||
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.6"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-curl": "*",
|
||||
"ext-imap": "*",
|
||||
"ext-pdo": "*"
|
||||
},
|
||||
"bin": [
|
||||
"bin/sabredav",
|
||||
"bin/naturalselection"
|
||||
],
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Sabre\\": "lib/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"BSD-3-Clause"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Evert Pot",
|
||||
"email": "me@evertpot.com",
|
||||
"homepage": "http://evertpot.com/",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "WebDAV Framework for PHP",
|
||||
"homepage": "http://sabre.io/",
|
||||
"keywords": [
|
||||
"CalDAV",
|
||||
"CardDAV",
|
||||
"WebDAV",
|
||||
"framework",
|
||||
"iCalendar"
|
||||
],
|
||||
"support": {
|
||||
"forum": "https://groups.google.com/group/sabredav-discuss",
|
||||
"issues": "https://github.com/sabre-io/dav/issues",
|
||||
"source": "https://github.com/fruux/sabre-dav"
|
||||
},
|
||||
"time": "2026-07-07T08:39:09+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sabre/event",
|
||||
"version": "5.1.9",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/sabre-io/event.git",
|
||||
"reference": "743f1d04811fd5b89f67878d002f6a273ccb089f"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/sabre-io/event/zipball/743f1d04811fd5b89f67878d002f6a273ccb089f",
|
||||
"reference": "743f1d04811fd5b89f67878d002f6a273ccb089f",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.1 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"friendsofphp/php-cs-fixer": "~2.17.1||^3.95",
|
||||
"phpstan/phpstan": "^0.12",
|
||||
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.6"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"files": [
|
||||
"lib/coroutine.php",
|
||||
"lib/Loop/functions.php",
|
||||
"lib/Promise/functions.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Sabre\\Event\\": "lib/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"BSD-3-Clause"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Evert Pot",
|
||||
"email": "me@evertpot.com",
|
||||
"homepage": "http://evertpot.com/",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "sabre/event is a library for lightweight event-based programming",
|
||||
"homepage": "http://sabre.io/event/",
|
||||
"keywords": [
|
||||
"EventEmitter",
|
||||
"async",
|
||||
"coroutine",
|
||||
"eventloop",
|
||||
"events",
|
||||
"hooks",
|
||||
"plugin",
|
||||
"promise",
|
||||
"reactor",
|
||||
"signal"
|
||||
],
|
||||
"support": {
|
||||
"forum": "https://groups.google.com/group/sabredav-discuss",
|
||||
"issues": "https://github.com/sabre-io/event/issues",
|
||||
"source": "https://github.com/fruux/sabre-event"
|
||||
},
|
||||
"time": "2026-07-07T09:13:04+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sabre/http",
|
||||
"version": "5.1.13",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/sabre-io/http.git",
|
||||
"reference": "7c2a14097d1a0de2347dcbdc91a02f38e338f4db"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/sabre-io/http/zipball/7c2a14097d1a0de2347dcbdc91a02f38e338f4db",
|
||||
"reference": "7c2a14097d1a0de2347dcbdc91a02f38e338f4db",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-ctype": "*",
|
||||
"ext-curl": "*",
|
||||
"ext-mbstring": "*",
|
||||
"php": "^7.1 || ^8.0",
|
||||
"sabre/event": ">=4.0 <6.0",
|
||||
"sabre/uri": "^2.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"friendsofphp/php-cs-fixer": "~2.17.1||3.63.2",
|
||||
"phpstan/phpstan": "^0.12",
|
||||
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.6"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-curl": " to make http requests with the Client class"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"files": [
|
||||
"lib/functions.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Sabre\\HTTP\\": "lib/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"BSD-3-Clause"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Evert Pot",
|
||||
"email": "me@evertpot.com",
|
||||
"homepage": "http://evertpot.com/",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "The sabre/http library provides utilities for dealing with http requests and responses. ",
|
||||
"homepage": "https://github.com/fruux/sabre-http",
|
||||
"keywords": [
|
||||
"http"
|
||||
],
|
||||
"support": {
|
||||
"forum": "https://groups.google.com/group/sabredav-discuss",
|
||||
"issues": "https://github.com/sabre-io/http/issues",
|
||||
"source": "https://github.com/fruux/sabre-http"
|
||||
},
|
||||
"time": "2025-09-09T10:21:47+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sabre/uri",
|
||||
"version": "2.3.4",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/sabre-io/uri.git",
|
||||
"reference": "b76524c22de90d80ca73143680a8e77b1266c291"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/sabre-io/uri/zipball/b76524c22de90d80ca73143680a8e77b1266c291",
|
||||
"reference": "b76524c22de90d80ca73143680a8e77b1266c291",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.4 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"friendsofphp/php-cs-fixer": "^3.63",
|
||||
"phpstan/extension-installer": "^1.4",
|
||||
"phpstan/phpstan": "^1.12",
|
||||
"phpstan/phpstan-phpunit": "^1.4",
|
||||
"phpstan/phpstan-strict-rules": "^1.6",
|
||||
"phpunit/phpunit": "^9.6"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"files": [
|
||||
"lib/functions.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Sabre\\Uri\\": "lib/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"BSD-3-Clause"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Evert Pot",
|
||||
"email": "me@evertpot.com",
|
||||
"homepage": "http://evertpot.com/",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "Functions for making sense out of URIs.",
|
||||
"homepage": "http://sabre.io/uri/",
|
||||
"keywords": [
|
||||
"rfc3986",
|
||||
"uri",
|
||||
"url"
|
||||
],
|
||||
"support": {
|
||||
"forum": "https://groups.google.com/group/sabredav-discuss",
|
||||
"issues": "https://github.com/sabre-io/uri/issues",
|
||||
"source": "https://github.com/fruux/sabre-uri"
|
||||
},
|
||||
"time": "2024-08-27T12:18:16+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sabre/vobject",
|
||||
"version": "4.6.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/sabre-io/vobject.git",
|
||||
"reference": "63613f6c53a0a2bddfe22caba0d052e6c59f7d0e"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/sabre-io/vobject/zipball/63613f6c53a0a2bddfe22caba0d052e6c59f7d0e",
|
||||
"reference": "63613f6c53a0a2bddfe22caba0d052e6c59f7d0e",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-mbstring": "*",
|
||||
"php": "^7.1 || ^8.0",
|
||||
"sabre/xml": "^2.1 || ^3.0 || ^4.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"friendsofphp/php-cs-fixer": "~2.17.1",
|
||||
"phpstan/phpstan": "^0.12 || ^1.12 || ^2.0",
|
||||
"phpunit/php-invoker": "^2.0 || ^3.1",
|
||||
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.6"
|
||||
},
|
||||
"suggest": {
|
||||
"hoa/bench": "If you would like to run the benchmark scripts"
|
||||
},
|
||||
"bin": [
|
||||
"bin/vobject",
|
||||
"bin/generate_vcards"
|
||||
],
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "4.0.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Sabre\\VObject\\": "lib/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"BSD-3-Clause"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Evert Pot",
|
||||
"email": "me@evertpot.com",
|
||||
"homepage": "http://evertpot.com/",
|
||||
"role": "Developer"
|
||||
},
|
||||
{
|
||||
"name": "Dominik Tobschall",
|
||||
"email": "dominik@fruux.com",
|
||||
"homepage": "http://tobschall.de/",
|
||||
"role": "Developer"
|
||||
},
|
||||
{
|
||||
"name": "Ivan Enderlin",
|
||||
"email": "ivan.enderlin@hoa-project.net",
|
||||
"homepage": "http://mnt.io/",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "The VObject library for PHP allows you to easily parse and manipulate iCalendar and vCard objects",
|
||||
"homepage": "http://sabre.io/vobject/",
|
||||
"keywords": [
|
||||
"availability",
|
||||
"freebusy",
|
||||
"iCalendar",
|
||||
"ical",
|
||||
"ics",
|
||||
"jCal",
|
||||
"jCard",
|
||||
"recurrence",
|
||||
"rfc2425",
|
||||
"rfc2426",
|
||||
"rfc2739",
|
||||
"rfc4770",
|
||||
"rfc5545",
|
||||
"rfc5546",
|
||||
"rfc6321",
|
||||
"rfc6350",
|
||||
"rfc6351",
|
||||
"rfc6474",
|
||||
"rfc6638",
|
||||
"rfc6715",
|
||||
"rfc6868",
|
||||
"vCalendar",
|
||||
"vCard",
|
||||
"vcf",
|
||||
"xCal",
|
||||
"xCard"
|
||||
],
|
||||
"support": {
|
||||
"forum": "https://groups.google.com/group/sabredav-discuss",
|
||||
"issues": "https://github.com/sabre-io/vobject/issues",
|
||||
"source": "https://github.com/fruux/sabre-vobject"
|
||||
},
|
||||
"time": "2026-07-07T03:20:17+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sabre/xml",
|
||||
"version": "2.2.11",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/sabre-io/xml.git",
|
||||
"reference": "01a7927842abf3e10df3d9c2d9b0cc9d813a3fcc"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/sabre-io/xml/zipball/01a7927842abf3e10df3d9c2d9b0cc9d813a3fcc",
|
||||
"reference": "01a7927842abf3e10df3d9c2d9b0cc9d813a3fcc",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-dom": "*",
|
||||
"ext-xmlreader": "*",
|
||||
"ext-xmlwriter": "*",
|
||||
"lib-libxml": ">=2.6.20",
|
||||
"php": "^7.1 || ^8.0",
|
||||
"sabre/uri": ">=1.0,<3.0.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"friendsofphp/php-cs-fixer": "~2.17.1||3.63.2",
|
||||
"phpstan/phpstan": "^0.12",
|
||||
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.6"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"files": [
|
||||
"lib/Deserializer/functions.php",
|
||||
"lib/Serializer/functions.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Sabre\\Xml\\": "lib/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"BSD-3-Clause"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Evert Pot",
|
||||
"email": "me@evertpot.com",
|
||||
"homepage": "http://evertpot.com/",
|
||||
"role": "Developer"
|
||||
},
|
||||
{
|
||||
"name": "Markus Staab",
|
||||
"email": "markus.staab@redaxo.de",
|
||||
"role": "Developer"
|
||||
}
|
||||
],
|
||||
"description": "sabre/xml is an XML library that you may not hate.",
|
||||
"homepage": "https://sabre.io/xml/",
|
||||
"keywords": [
|
||||
"XMLReader",
|
||||
"XMLWriter",
|
||||
"dom",
|
||||
"xml"
|
||||
],
|
||||
"support": {
|
||||
"forum": "https://groups.google.com/group/sabredav-discuss",
|
||||
"issues": "https://github.com/sabre-io/xml/issues",
|
||||
"source": "https://github.com/fruux/sabre-xml"
|
||||
},
|
||||
"time": "2024-09-06T07:37:46+00:00"
|
||||
},
|
||||
{
|
||||
"name": "smalot/pdfparser",
|
||||
"version": "v2.12.5",
|
||||
|
||||
@ -38,6 +38,13 @@ return [
|
||||
// 临时文件自动清理天数(DeleteTmpTask)
|
||||
'auto_empty_temp_file' => env('AUTO_EMPTY_TEMP_FILE', 30),
|
||||
|
||||
// WebDAV 协议硬限制;用户可配置值不能超过这里的上限
|
||||
'webdav' => [
|
||||
'max_file_bytes' => 1024 * 1024 * 1024,
|
||||
'lock_timeout_seconds' => 1800,
|
||||
'lock_max_timeout_seconds' => 7200,
|
||||
],
|
||||
|
||||
// 在线授权:appstore 授权中心地址(OnlineLicense;默认中央,测试可指向 dev appstore)
|
||||
// [调试中] 临时指向本地 dev appstore,发版前改回 'https://appstore.dootask.com'
|
||||
'online_license_appstore_url' => env('ONLINE_LICENSE_APPSTORE_URL', 'https://appstore.dootask.com'),
|
||||
|
||||
@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('webdav_credentials', function (Blueprint $table) {
|
||||
$table->bigIncrements('id');
|
||||
$table->string('public_id', 40)->unique();
|
||||
$table->bigInteger('userid')->index();
|
||||
$table->string('name', 100);
|
||||
$table->string('password_hash', 255);
|
||||
$table->string('password_suffix', 4);
|
||||
$table->timestamp('expires_at')->nullable()->index();
|
||||
$table->timestamp('last_used_at')->nullable();
|
||||
$table->string('last_used_ip', 45)->nullable();
|
||||
$table->string('last_user_agent', 255)->nullable();
|
||||
$table->timestamp('revoked_at')->nullable()->index();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('webdav_locks', function (Blueprint $table) {
|
||||
$table->bigIncrements('id');
|
||||
$table->string('token', 100)->unique();
|
||||
$table->bigInteger('userid')->index();
|
||||
$table->bigInteger('credential_id')->index();
|
||||
$table->bigInteger('file_id')->nullable()->index();
|
||||
$table->string('uri', 1000);
|
||||
$table->char('uri_hash', 64)->index();
|
||||
$table->string('owner', 255)->nullable();
|
||||
$table->string('scope', 20)->default('exclusive');
|
||||
$table->string('depth', 20)->default('infinity');
|
||||
$table->timestamp('timeout_at')->index();
|
||||
$table->timestamps();
|
||||
});
|
||||
|
||||
Schema::create('webdav_properties', function (Blueprint $table) {
|
||||
$table->bigIncrements('id');
|
||||
$table->bigInteger('userid')->index();
|
||||
$table->string('path', 1000);
|
||||
$table->char('path_hash', 64)->index();
|
||||
$table->string('name', 255);
|
||||
$table->char('property_hash', 64);
|
||||
$table->unsignedTinyInteger('value_type')->default(1);
|
||||
$table->longText('value')->nullable();
|
||||
$table->timestamps();
|
||||
$table->unique(
|
||||
['userid', 'path_hash', 'property_hash'],
|
||||
'webdav_properties_user_path_property_unique'
|
||||
);
|
||||
});
|
||||
|
||||
Schema::create('webdav_operation_logs', function (Blueprint $table) {
|
||||
$table->bigIncrements('id');
|
||||
$table->string('request_id', 100)->nullable()->index();
|
||||
$table->bigInteger('userid')->nullable()->index();
|
||||
$table->bigInteger('credential_id')->nullable()->index();
|
||||
$table->string('method', 20);
|
||||
$table->string('uri', 1000)->nullable();
|
||||
$table->bigInteger('file_id')->nullable()->index();
|
||||
$table->smallInteger('status')->default(0)->index();
|
||||
$table->string('result', 255)->nullable();
|
||||
$table->bigInteger('bytes')->default(0);
|
||||
$table->string('ip', 45)->nullable();
|
||||
$table->string('user_agent', 255)->nullable();
|
||||
$table->unsignedInteger('duration_ms')->default(0);
|
||||
$table->timestamp('created_at')->useCurrent()->index();
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('webdav_operation_logs');
|
||||
Schema::dropIfExists('webdav_properties');
|
||||
Schema::dropIfExists('webdav_locks');
|
||||
Schema::dropIfExists('webdav_credentials');
|
||||
}
|
||||
};
|
||||
853
docs/webdav-design.md
Normal file
853
docs/webdav-design.md
Normal file
@ -0,0 +1,853 @@
|
||||
# DooTask WebDAV 技术设计方案
|
||||
|
||||
> 状态:设计稿
|
||||
> 适用主程序版本:1.8.89
|
||||
> 范围:DooTask「我的文件」和「共享文件」
|
||||
> 默认策略:管理员全局启用,用户使用独立 WebDAV 应用密码连接
|
||||
|
||||
## 1. 目标与结论
|
||||
|
||||
为 DooTask 文件系统提供标准 WebDAV 访问,使用户可以通过 Windows、macOS、Linux 和支持 WebDAV 的办公软件访问文件,同时保持以下行为与网页端一致:
|
||||
|
||||
- 文件和目录权限一致。
|
||||
- 写入生成文件历史版本。
|
||||
- 移动、重命名、删除触发现有消息推送和搜索同步。
|
||||
- 共享文件保持现有所有者、创建者、只读、读写权限语义。
|
||||
- 凭据可以独立创建、过期和撤销,不影响主账号登录。
|
||||
- 并发写入遵循 WebDAV 锁和 HTTP 条件请求,避免静默覆盖。
|
||||
|
||||
WebDAV 暴露的是由 `files`、`file_contents`、`file_users` 组成的虚拟文件系统,不直接暴露 `public/uploads` 物理目录。
|
||||
|
||||
## 2. 范围边界
|
||||
|
||||
### 2.1 首版包含
|
||||
|
||||
- 根目录固定包含 `files/` 和 `shared/` 两个虚拟集合。
|
||||
- `files/`:当前用户拥有的根文件及全部子级。
|
||||
- `shared/`:其他用户明确共享给当前用户的顶层共享项及全部子级。
|
||||
- 文件和目录的查询、下载、上传、创建、复制、移动、重命名、删除。
|
||||
- WebDAV 排他写锁、ETag、条件请求和 Range 下载。
|
||||
- 管理员开关、用户应用密码、撤销、审计和运维指标。
|
||||
- 文件历史、WebSocket 通知、Manticore 搜索同步及回收站行为。
|
||||
|
||||
### 2.2 首版不包含
|
||||
|
||||
- 聊天附件、任务附件、项目协作文件聚合视图。
|
||||
- 匿名链接和游客访问。
|
||||
- CalDAV、CardDAV。
|
||||
- 将 DooTask 作为外部 WebDAV 的客户端或存储后端。
|
||||
- Windows 文件扩展属性、NTFS ACL 和 POSIX 权限的完全映射。
|
||||
- 离线同步客户端;系统只提供服务端协议。
|
||||
|
||||
### 2.3 不允许的实现
|
||||
|
||||
- 不允许将 `public/uploads/file` 直接配置为 Nginx WebDAV 目录。
|
||||
- 不允许通过 URL 参数携带主登录 token。
|
||||
- 不允许直接使用用户账号密码作为 WebDAV 密码。
|
||||
- 不允许 DAV 控制器复制一套文件业务逻辑。
|
||||
- 不允许在静态属性、单例或全局变量中保存当前 DAV 用户、锁或请求路径。
|
||||
|
||||
## 3. 产品闭环
|
||||
|
||||
### 3.1 管理员流程
|
||||
|
||||
1. 管理员进入「系统设置 > 文件设置 > WebDAV」。
|
||||
2. 开启 WebDAV,并选择允许范围:全员或指定成员。
|
||||
3. 配置凭据数量、有效期、单文件上限、复制上限和审计保留天数。
|
||||
4. 保存后,状态接口返回服务地址和当前运行能力。
|
||||
5. 管理员可以查看连接用户、最近失败和写操作审计,并可停用某个用户的全部 WebDAV 凭据。
|
||||
6. 全局关闭时,所有 DAV 请求立即返回 `503 Service Unavailable`,凭据保留,以便重新启用;管理员可另行执行凭据全部撤销。
|
||||
|
||||
### 3.2 用户开通流程
|
||||
|
||||
1. 用户进入文件页面,在右上角加号右侧点击圆形“更多”图标,在菜单中选择「WebDAV」。
|
||||
2. 页面打开 WebDAV 管理弹窗,展示管理员是否启用、服务器地址、支持范围和已有凭据。
|
||||
3. 用户点击「创建应用密码」,填写设备名称并选择有效期。
|
||||
4. 服务端返回一次性的用户名和应用密码;应用密码此后不可再次读取。
|
||||
5. 页面提供服务器地址、用户名和密码字段及复制按钮,同时提示必须使用 HTTPS。
|
||||
6. 用户在客户端连接后,页面更新最后使用时间、IP 和客户端名称。
|
||||
7. 用户可以撤销单个凭据;撤销后新请求立即失败,已有锁同步失效。
|
||||
|
||||
### 3.3 文件操作闭环
|
||||
|
||||
每个写请求必须按以下顺序完成:
|
||||
|
||||
1. 认证应用密码并检查全局、用户和凭据状态。
|
||||
2. 规范化并解析 DAV 路径,确认路径不能越过虚拟根和共享边界。
|
||||
3. 校验资源权限、锁 token、ETag 和目标冲突。
|
||||
4. 将请求体流式写入临时文件,并在写入时计算大小和 SHA-256。
|
||||
5. 调用统一文件领域服务执行数据库与物理文件操作。
|
||||
6. 写入新 `FileContent` 版本并更新 `File` 元数据。
|
||||
7. 触发现有 WebSocket 消息、Observer 和 Manticore 同步。
|
||||
8. 写入 DAV 审计日志并返回标准 DAV 状态码。
|
||||
9. 无论成功失败都关闭流并清理临时文件;异常遗留由定时清理兜底。
|
||||
|
||||
## 4. URL 与目录模型
|
||||
|
||||
### 4.1 服务地址
|
||||
|
||||
```text
|
||||
https://{host}/dav/
|
||||
```
|
||||
|
||||
根目录使用固定、不随语言变化的 URI 段:
|
||||
|
||||
```text
|
||||
/dav/
|
||||
├── files/
|
||||
└── shared/
|
||||
```
|
||||
|
||||
固定 ASCII URI 可以避免用户切换语言后挂载路径失效。前端说明可将其翻译为「我的文件」和「共享文件」。
|
||||
|
||||
### 4.2 `files/` 映射
|
||||
|
||||
- `/dav/files/` 映射当前用户 `pid = 0 AND userid = 当前用户` 的资源。
|
||||
- 后续每一段按 `pid + 完整文件名` 解析。
|
||||
- 完整文件名为 `name`,文件有扩展名时为 `name.ext`。
|
||||
- 文件夹不得带文件扩展名语义,按 `type = folder` 判断。
|
||||
|
||||
### 4.3 `shared/` 映射
|
||||
|
||||
共享根的每个顶层项使用以下稳定且无冲突的 DAV 名称:
|
||||
|
||||
```text
|
||||
{原完整名称} [#{共享根文件ID}]
|
||||
```
|
||||
|
||||
示例:
|
||||
|
||||
```text
|
||||
/dav/shared/产品资料 [#128]/设计/首页.fig
|
||||
/dav/shared/预算表 [#356].xlsx
|
||||
```
|
||||
|
||||
- `[#ID]` 只用于共享顶层 URI,子级保持原名称。
|
||||
- `displayname` 属性返回原完整名称,不包含 `[#ID]`。
|
||||
- 顶层 ID 防止不同所有者共享同名资源时产生歧义。
|
||||
- 共享根重命名后 URI 名称变化,但 ID 保持不变;解析时必须同时验证 ID 和当前名称。旧路径返回 `404`,不做永久重定向,避免 DAV 客户端缓存错误。
|
||||
- 用户自己共享出去的文件仍位于 `files/`,不在 `shared/` 重复展示。
|
||||
|
||||
### 4.4 路径规范化
|
||||
|
||||
- URL 路径按 UTF-8 解码,每段只解码一次。
|
||||
- 拒绝非法 UTF-8、NUL、控制字符、`.`、`..`、空段和编码后的路径分隔符。
|
||||
- 使用 Unicode NFC 作为比较前的规范形式,但数据库保存用户原始显示形式。
|
||||
- 文件名继续禁止 `\\ / : * ? " < > |`。
|
||||
- 新增 DAV 写入允许 1 至 200 个字符;现有网页端“至少 2 个字”的限制应同步改为至少 1 个字符,否则两种入口行为不一致。
|
||||
- 路径比较遵循数据库当前排序规则;不得仅在 PHP 中做大小写敏感判断。
|
||||
- 路径解析结果只可缓存于当前 `RequestContext`,不得跨请求缓存权限结果。
|
||||
|
||||
## 5. 协议能力
|
||||
|
||||
### 5.1 方法矩阵
|
||||
|
||||
| 方法 | 作用 | 首版行为 |
|
||||
| --- | --- | --- |
|
||||
| `OPTIONS` | 能力发现 | 返回 `DAV: 1, 2`、允许方法和 MS DAV 扩展头 |
|
||||
| `PROPFIND` | 查询资源属性 | 支持 `Depth: 0/1`,对 `infinity` 返回 `403`,避免全树扫描 |
|
||||
| `PROPPATCH` | 设置死属性 | 支持非保护属性,系统属性返回 `403` |
|
||||
| `HEAD` | 文件元数据 | 与 GET 同头部,不返回内容 |
|
||||
| `GET` | 下载文件 | 支持 Range、ETag、Last-Modified 和条件读取 |
|
||||
| `PUT` | 新建或覆盖文件 | 流式写入;覆盖创建历史版本 |
|
||||
| `MKCOL` | 创建文件夹 | 请求体非空返回 `415` |
|
||||
| `COPY` | 复制资源 | 文件及目录;遵循 `Depth`、`Destination`、`Overwrite` |
|
||||
| `MOVE` | 移动或重命名 | 同一 DAV 服务内;跨 `files/shared` 边界按权限判断 |
|
||||
| `DELETE` | 删除资源 | 进入现有文件回收站;递归删除目录 |
|
||||
| `LOCK` | 创建或刷新写锁 | 支持排他写锁和 lock-null 资源 |
|
||||
| `UNLOCK` | 释放写锁 | 校验 `Lock-Token` 和凭据所属用户 |
|
||||
|
||||
不支持的方法返回 `405 Method Not Allowed`,并带 `Allow` 响应头。
|
||||
|
||||
### 5.2 属性
|
||||
|
||||
至少实现:
|
||||
|
||||
- `{DAV:}displayname`
|
||||
- `{DAV:}resourcetype`
|
||||
- `{DAV:}getcontentlength`
|
||||
- `{DAV:}getcontenttype`
|
||||
- `{DAV:}getetag`
|
||||
- `{DAV:}getlastmodified`
|
||||
- `{DAV:}creationdate`
|
||||
- `{DAV:}supportedlock`
|
||||
- `{DAV:}lockdiscovery`
|
||||
|
||||
不声明配额属性,直到项目存在真实的用户容量配额。文件夹大小不得在 `PROPFIND` 中递归计算。
|
||||
|
||||
### 5.3 ETag 与时间
|
||||
|
||||
- 文件强 ETag:`"f-{file_id}-v-{latest_file_content_id}"`。
|
||||
- 空文件强 ETag:`"f-{file_id}-v-0"`。
|
||||
- 文件夹弱 ETag:`W/"d-{file_id}-{updated_at_timestamp}"`。
|
||||
- 虚拟根 ETag 包含用户 ID 和可见共享列表的最大更新时间。
|
||||
- `Last-Modified` 使用 `files.updated_at`,统一输出 GMT。
|
||||
- `PUT`、`MOVE`、`COPY`、`DELETE` 必须处理 `If-Match`、`If-None-Match` 和 DAV `If` 头。
|
||||
- 条件不满足返回 `412 Precondition Failed`,不得继续写入。
|
||||
|
||||
### 5.4 内容读取与写入
|
||||
|
||||
- GET 只读取最新未删除 `FileContent`。
|
||||
- 物理文件通过鉴权后的响应流输出,不返回 `uploads/...` 地址。
|
||||
- 空文件返回长度为 0 的正常文件,不沿用网页预览接口的空 Office 模板。
|
||||
- PUT 使用 `php://input` 对应的请求流分块写入临时文件,禁止 `getContent()` 整体载入内存。
|
||||
- 当前 LaravelS/Swoole 的 `package_max_length` 虽为 1 GB,但该配置只是允许请求大小,不证明原始 PUT body 在进入 Laravel 前不会被 Swoole 聚合到内存。实现阶段必须先通过 RSS 压测验证请求入口;未通过时必须启用 9.6 节的独立 DAV 入口。
|
||||
- 超过配置大小时尽早返回 `413 Content Too Large`,未知长度请求在流式累计超限时中断。
|
||||
- 覆盖现有文件时保留 `files.id`,新增一条 `file_contents`,保证分享链接、历史记录和最近访问仍指向原文件。
|
||||
- 新文件扩展名和 `type` 使用统一类型映射服务,不在 DAV 层复制 `match` 列表。
|
||||
|
||||
### 5.5 原子保存兼容
|
||||
|
||||
桌面客户端常使用“上传临时文件,再 MOVE 覆盖目标”的方式保存。服务端必须特殊处理:
|
||||
|
||||
1. 当前用户在目标目录创建临时文件。
|
||||
2. MOVE 的目标已存在且 `Overwrite: T`。
|
||||
3. 用户对目标有写权限,并持有需要的锁。
|
||||
4. 服务端将临时文件最新内容作为目标文件的新版本,保留目标 `files.id`。
|
||||
5. 删除临时文件记录,返回 `204 No Content`。
|
||||
|
||||
这样共享读写用户无需拥有目标文件的删除权限,也能完成 Office 原子保存。
|
||||
|
||||
## 6. 权限模型
|
||||
|
||||
### 6.1 权限级别映射
|
||||
|
||||
| DooTask 权限 | DAV 能力 |
|
||||
| --- | --- |
|
||||
| `-1` 无权限 | 统一表现为 `404`,避免泄露资源存在性 |
|
||||
| `0` 只读 | PROPFIND、HEAD、GET、作为 COPY 来源 |
|
||||
| `1` 读写 | 只读能力 + PUT、MKCOL、PROPPATCH、LOCK;可修改/重命名资源,不能删除或移走他人资源 |
|
||||
| `1000` 所有者或创建者 | 全部能力,包括 DELETE、MOVE 和共享边界管理允许的操作 |
|
||||
|
||||
### 6.2 共享目录规则
|
||||
|
||||
- `shared/` 虚拟根永远不可写。
|
||||
- 只读共享项内任何写方法返回 `403 Forbidden`。
|
||||
- 读写共享项允许创建子项和更新已有内容。
|
||||
- 用户创建的子项因 `created_id` 是当前用户,可由该用户移动和删除。
|
||||
- 用户不得删除或移走共享所有者创建的资源。
|
||||
- 对已有资源的同目录重命名按写权限处理,与现有 `add(id)` 行为一致。
|
||||
- 原子覆盖按照 5.5 节处理,不把覆盖解释为删除目标。
|
||||
- 不允许把 `shared/` 顶层项 MOVE 到 `files/`,也不允许改变共享关系。
|
||||
- 从共享目录 COPY 到 `files/`:来源需可读,目标需可写,新副本归当前用户所有。
|
||||
- 从 `files/` COPY 到共享目录:目标共享目录需读写,新副本所有者沿用共享根所有者,创建者为当前用户。
|
||||
|
||||
### 6.3 权限变化
|
||||
|
||||
- 每个请求实时读取当前共享权限,不依赖凭据创建时权限。
|
||||
- 共享撤销后,后续请求立即变为 `404`。
|
||||
- 对已锁资源撤销共享时,相关 DAV 锁同步删除。
|
||||
- 用户停用、删除或被移出 WebDAV 允许范围时,所有凭据立即不可用并清理锁。
|
||||
|
||||
## 7. 认证与凭据
|
||||
|
||||
### 7.1 认证协议
|
||||
|
||||
- 使用 HTTPS 上的 HTTP Basic Authentication。
|
||||
- Basic 用户名使用服务端生成的公开标识,例如 `dtw_01J...`。
|
||||
- 密码使用 32 字节加密随机数生成的 base64url 字符串。
|
||||
- 数据库只保存 Laravel `Hash::make()` 结果和密码末四位,不保存明文或可逆密文。
|
||||
- 创建响应只返回一次完整密码。
|
||||
- 不支持主账号密码、登录 token、URL token 和匿名访问。
|
||||
|
||||
选择独立公开用户名而不是邮箱,原因是:凭据可以独立撤销;无需处理 LDAP/SSO 密码;认证查询可以命中唯一索引;不会泄露登录邮箱。
|
||||
|
||||
### 7.2 凭据状态
|
||||
|
||||
凭据可处于:
|
||||
|
||||
- `active`:可正常认证。
|
||||
- `expired`:超过 `expires_at`。
|
||||
- `revoked`:用户或管理员撤销。
|
||||
- `disabled`:全局开关、允许范围或用户状态导致不可用,不改变凭据记录。
|
||||
|
||||
认证成功后异步或限频更新 `last_used_at`、`last_used_ip`、`last_user_agent`,同一凭据最多每 5 分钟写库一次。
|
||||
|
||||
### 7.3 防护
|
||||
|
||||
- 按 IP 和公开用户名组合限流,建议失败 10 次/分钟后返回 `429`。
|
||||
- 使用 `hash_equals` 或 Laravel Hash 校验,错误响应不区分用户名不存在、密码错误和凭据已撤销。
|
||||
- `401` 必须返回 `WWW-Authenticate: Basic realm="DooTask WebDAV", charset="UTF-8"`。
|
||||
- 管理界面创建和撤销凭据使用现有登录 token,并记录安全审计。
|
||||
- 生产环境不是 HTTPS 时禁止创建凭据;已有 DAV 请求返回配置错误。
|
||||
|
||||
## 8. 数据模型
|
||||
|
||||
### 8.1 `webdav_credentials`
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `id` | bigint PK | 主键 |
|
||||
| `public_id` | varchar(40) unique | Basic 用户名,不含秘密 |
|
||||
| `userid` | bigint index | 所属用户 |
|
||||
| `name` | varchar(100) | 用户填写的设备名称 |
|
||||
| `password_hash` | varchar(255) | 应用密码哈希 |
|
||||
| `password_suffix` | varchar(4) | 展示末四位 |
|
||||
| `expires_at` | timestamp nullable | 过期时间 |
|
||||
| `last_used_at` | timestamp nullable | 最近使用 |
|
||||
| `last_used_ip` | varchar(45) nullable | 最近 IP |
|
||||
| `last_user_agent` | varchar(255) nullable | 最近客户端 |
|
||||
| `revoked_at` | timestamp nullable | 撤销时间 |
|
||||
| `created_at/updated_at` | timestamps | 时间 |
|
||||
|
||||
不使用软删除,撤销记录保留到审计保留期结束。默认每用户最多 5 个有效凭据。
|
||||
|
||||
### 8.2 `webdav_locks`
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `id` | bigint PK | 主键 |
|
||||
| `token` | varchar(100) unique | `opaquelocktoken:{uuid}` |
|
||||
| `userid` | bigint index | 锁所有者 |
|
||||
| `credential_id` | bigint index | 创建锁的凭据 |
|
||||
| `file_id` | bigint nullable index | 已存在资源 ID;lock-null 时为空 |
|
||||
| `uri` | varchar(1000) index prefix | 规范化 DAV URI |
|
||||
| `uri_hash` | char(64) index | URI SHA-256,精确查询 |
|
||||
| `owner` | varchar(255) nullable | 客户端 owner |
|
||||
| `scope` | varchar(20) | 首版固定 exclusive |
|
||||
| `depth` | varchar(20) | `0` 或 `infinity` |
|
||||
| `timeout_at` | timestamp index | 过期时间 |
|
||||
| `created_at/updated_at` | timestamps | 时间 |
|
||||
|
||||
- 默认锁 30 分钟,允许客户端请求 1 分钟至 2 小时。
|
||||
- 通过定时任务清理过期锁。
|
||||
- MOVE/重命名目录时,在同一事务内更新该资源及子资源锁 URI。
|
||||
- 删除、撤销凭据或权限时删除相关锁。
|
||||
|
||||
### 8.3 `webdav_properties`
|
||||
|
||||
存储客户端通过 PROPPATCH 设置的死属性:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `id` | bigint PK | 主键 |
|
||||
| `file_id` | bigint index | 资源 ID |
|
||||
| `namespace` | varchar(255) | XML namespace |
|
||||
| `name` | varchar(255) | 属性名 |
|
||||
| `value` | longtext | 安全序列化后的 XML 值 |
|
||||
| `created_at/updated_at` | timestamps | 时间 |
|
||||
|
||||
唯一键为 `file_id + namespace_hash + name_hash`。删除文件时一并删除;复制时复制死属性,移动时无需变化。
|
||||
|
||||
### 8.4 `webdav_operation_logs`
|
||||
|
||||
记录认证结果和写操作,GET/PROPFIND 只进入结构化访问日志与指标,避免数据库日志量失控。
|
||||
|
||||
字段至少包括:`request_id`、`userid`、`credential_id`、`method`、`uri`、`file_id`、`status`、`result`、`bytes`、`ip`、`user_agent`、`duration_ms`、`created_at`。
|
||||
|
||||
URI 可能包含敏感文件名,管理员页面默认只显示末级文件名,日志导出需要管理员权限。默认保留 90 天,由定时任务分批清理。
|
||||
|
||||
### 8.5 文件路径唯一性
|
||||
|
||||
WebDAV 要求同一集合内 URI 唯一,必须完成以下治理:
|
||||
|
||||
1. 增加只读审计命令,检测同一有效父目录下完整名称冲突。
|
||||
2. 存在冲突时禁止管理员启用 WebDAV,并列出待处理文件 ID。
|
||||
3. 所有网页 API 和 DAV 写入统一经过文件领域服务,并对父目录加分布式锁和数据库行锁。
|
||||
4. 冲突检查使用 `pid + userid + name + ext + deleted_at IS NULL` 的现有数据库排序语义。
|
||||
5. 不直接增加包含 `deleted_at` 的普通唯一索引,因为 MySQL 对 NULL 唯一值的行为不能保证软删除资源唯一;后续可通过生成列 `active_path_key` 增强约束。
|
||||
|
||||
## 9. 代码架构
|
||||
|
||||
### 9.1 依赖
|
||||
|
||||
在生产依赖中增加兼容当前 PHP 版本的 `sabre/dav` 稳定版本,并锁定小版本范围。引入前执行许可证、PHP 8.4 和 LaravelS 兼容验证。
|
||||
|
||||
不得使用 Sabre 的 SAPI 直接输出或 `exit`。需要将 Illuminate Request 桥接为 Sabre HTTP Request,再将 Sabre Response 转换为 Symfony Response/StreamedResponse。
|
||||
|
||||
### 9.2 新增模块建议
|
||||
|
||||
```text
|
||||
app/
|
||||
├── Http/
|
||||
│ ├── Controllers/
|
||||
│ │ ├── Api/FileDavController.php
|
||||
│ │ └── WebDavProtocolController.php
|
||||
│ └── Middleware/WebDavRequest.php
|
||||
├── Models/
|
||||
│ ├── WebDavCredential.php
|
||||
│ ├── WebDavLock.php
|
||||
│ ├── WebDavProperty.php
|
||||
│ └── WebDavOperationLog.php
|
||||
├── Services/WebDav/
|
||||
│ ├── WebDavServerFactory.php
|
||||
│ ├── WebDavAuthBackend.php
|
||||
│ ├── WebDavTree.php
|
||||
│ ├── WebDavDirectory.php
|
||||
│ ├── WebDavFile.php
|
||||
│ ├── WebDavLockBackend.php
|
||||
│ ├── WebDavPropertyBackend.php
|
||||
│ ├── WebDavPathResolver.php
|
||||
│ └── WebDavAuditService.php
|
||||
└── Services/FileSystem/
|
||||
├── FileSystemService.php
|
||||
├── FileContentStorage.php
|
||||
├── FileName.php
|
||||
├── FileTypeResolver.php
|
||||
└── FileOperationResult.php
|
||||
```
|
||||
|
||||
### 9.3 `FileSystemService` 边界
|
||||
|
||||
服务方法接收明确的 `User` 和结构化参数,不读取全局 `Request`,不返回 HTTP 响应:
|
||||
|
||||
```php
|
||||
list(User $actor, int $parentId, string $scope): Collection
|
||||
resolveChild(User $actor, int $parentId, string $fullName): File
|
||||
createDirectory(User $actor, int $parentId, string $name): FileOperationResult
|
||||
putFromStream(User $actor, int $parentId, string $fullName, $stream, PutOptions $options): FileOperationResult
|
||||
rename(User $actor, File $file, string $newName): FileOperationResult
|
||||
move(User $actor, File $file, int $targetParentId, MoveOptions $options): FileOperationResult
|
||||
copy(User $actor, File $file, int $targetParentId, CopyOptions $options): FileOperationResult
|
||||
delete(User $actor, File $file): FileOperationResult
|
||||
read(User $actor, File $file, ?int $versionId = null): FileReadHandle
|
||||
```
|
||||
|
||||
现有 `FileController` 的 add、copy、move、remove、content save/upload 逐步改为调用此服务,保持 API 响应不变。这样 DAV 和网页端共享同一事务、权限和副作用。
|
||||
|
||||
### 9.4 内容存储
|
||||
|
||||
`FileContentStorage` 负责:
|
||||
|
||||
- 临时流落盘、大小限制和哈希计算。
|
||||
- 将临时文件原子移动到 `uploads/file/{type}/{Ym}/{fileId}/{contentKey}`。
|
||||
- 打开最新内容只读流。
|
||||
- 复制内容时创建独立物理文件,避免两个 `FileContent` URL 引用同一路径后其中一个清理导致另一个损坏。
|
||||
- 删除物理文件前检查是否仍有其他 `FileContent` 引用同一 URL,兼容历史复制数据。
|
||||
- DB 失败时清理已移动文件;进程异常时由孤儿文件扫描任务兜底。
|
||||
|
||||
### 9.5 请求生命周期
|
||||
|
||||
每次 DAV 请求创建新的 Sabre Server、树、认证 backend 和响应对象。当前认证用户写入 `RequestContext`,请求结束由 WebDAV middleware 清理。
|
||||
|
||||
禁止将以下对象注册为保存请求状态的单例:
|
||||
|
||||
- Sabre Server
|
||||
- 当前 User
|
||||
- 当前 Credential
|
||||
- PathResolver 的节点缓存
|
||||
- 请求/响应流
|
||||
|
||||
### 9.6 大文件请求入口
|
||||
|
||||
完整实现需要支持现有系统允许的最大文件,同时不能让单个 PUT 占用等量 Worker 内存。采用两级决策:
|
||||
|
||||
1. 首先在当前 LaravelS 入口分别上传 100 MB、500 MB、1 GB 文件,记录 Nginx、Swoole Worker 和容器 RSS 峰值。
|
||||
2. 只有 RSS 增量保持在固定缓冲上限内,才允许 `/dav` 继续复用 LaravelS。
|
||||
3. 如果 RSS 随文件大小线性增长,则生产架构增加独立 `webdav` PHP-FPM 容器;Nginx 仅将 `/dav/` 转发给该容器,普通 API 和 WebSocket 仍走 LaravelS。
|
||||
4. 独立入口复用同一份 Laravel 代码、数据库、Redis 和项目文件卷,但每请求启动独立应用生命周期,通过 `php://input` 流式读取。
|
||||
5. PHP-FPM 方案仍需验证 Nginx/FastCGI 是否落临时文件或流式传递,并统一临时目录容量、超时和请求大小。
|
||||
|
||||
不得采用以下降级方式规避问题:把 1 GB body 放入 Redis、由 Swoole Worker 整体读取后再分块、或仅依靠提高容器内存。若独立入口尚未完成,管理员页面必须把 WebDAV 单文件上限限制为已压测证明安全的值。
|
||||
|
||||
## 10. 路由与 API
|
||||
|
||||
### 10.1 DAV 协议路由
|
||||
|
||||
在 SPA 兜底路由之前注册:
|
||||
|
||||
```text
|
||||
OPTIONS /dav/{path?}
|
||||
PROPFIND /dav/{path?}
|
||||
PROPPATCH /dav/{path?}
|
||||
HEAD /dav/{path?}
|
||||
GET /dav/{path?}
|
||||
PUT /dav/{path?}
|
||||
MKCOL /dav/{path?}
|
||||
COPY /dav/{path?}
|
||||
MOVE /dav/{path?}
|
||||
DELETE /dav/{path?}
|
||||
LOCK /dav/{path?}
|
||||
UNLOCK /dav/{path?}
|
||||
```
|
||||
|
||||
`path` 使用 `.*` 约束。`/dav/*` 加入 CSRF 排除,但仍由 WebDAV Basic 认证保护。Nginx 明确增加优先于 SPA 的 `/dav/` location。该 location 按 9.6 节验证结果转发到 LaravelS 或独立 PHP-FPM DAV 入口;请求缓冲、临时目录和超时以实测的恒定内存为验收标准。
|
||||
|
||||
### 10.2 管理 API
|
||||
|
||||
管理接口使用 `api/file/dav/xxx` 命名,但由独立 `Api\FileDavController` 承载,不向冻结的巨型 `FileController` 新增方法:
|
||||
|
||||
| API | 方法 | 权限 | 用途 |
|
||||
| --- | --- | --- | --- |
|
||||
| `api/file/dav/adminsetting` | GET/POST | admin | 获取/保存全局配置 |
|
||||
| `api/file/dav/adminstatus` | GET | admin | 运行状态、冲突审计和近期失败 |
|
||||
| `api/file/dav/userrevoke` | POST | admin | 撤销用户全部凭据 |
|
||||
| `api/file/dav/status` | GET | 登录用户 | 当前可用性、URL、策略 |
|
||||
| `api/file/dav/credentials` | GET | 登录用户 | 凭据列表,不返回哈希 |
|
||||
| `api/file/dav/create` | POST | 登录用户 | 创建并一次性返回密码 |
|
||||
| `api/file/dav/revoke` | POST | 登录用户 | 撤销凭据 |
|
||||
|
||||
这些 URL 保持 `file/{method}/{action}` 的两段动态路由限制,控制器方法分别为 `dav__adminsetting`、`dav__adminstatus`、`dav__userrevoke`、`dav__status`、`dav__credentials`、`dav__create`、`dav__revoke`。
|
||||
|
||||
路由中先将 `method = dav` 明确分派到 `FileDavController`,再让其他 `file/{method}/{action}` 进入现有 `FileController`;现有 FileController 路由应增加排除 `dav` 的约束,避免相同 URI 模式产生不确定匹配。新增控制器和路由后运行 `./cmd artisan doc:api-map`。
|
||||
|
||||
这里的 `api/file/dav/xxx` 只承载网页使用的 JSON 管理接口。WebDAV 客户端仍连接 `/dav/{path?}` 协议路由,因为它需要任意深度路径、自定义 HTTP 方法、XML 多状态响应和独立异常处理。
|
||||
|
||||
### 10.3 API 契约
|
||||
|
||||
所有管理 API 继续使用 `Base::retSuccess()` / `Base::retError()`,不返回 WebDAV XML。核心载荷如下:
|
||||
|
||||
```text
|
||||
POST api/file/dav/create
|
||||
request: { name: string, expire_days: int }
|
||||
response: { id, public_id, password, password_suffix, url, expires_at }
|
||||
|
||||
POST api/file/dav/revoke
|
||||
request: { id: int }
|
||||
response: { id, revoked_at }
|
||||
|
||||
GET api/file/dav/credentials
|
||||
response: [{ id, public_id, name, password_suffix, expires_at,
|
||||
last_used_at, last_used_ip, last_user_agent, status }]
|
||||
|
||||
GET api/file/dav/status
|
||||
response: { enabled, allowed, https, url, max_credentials,
|
||||
active_credentials, default_expire_days, max_expire_days,
|
||||
max_file_bytes }
|
||||
|
||||
POST api/file/dav/userrevoke
|
||||
request: { userid: int }
|
||||
response: { userid, revoked_count, revoked_at }
|
||||
```
|
||||
|
||||
- `password` 只存在于创建成功响应,列表和日志不得出现。
|
||||
- `expire_days` 必须在管理员策略范围内;`0` 仅在管理员允许永不过期时有效。
|
||||
- 撤销接口幂等,重复撤销返回成功和原 `revoked_at`。
|
||||
- `status.enabled` 表示全局开关,`allowed` 表示当前用户是否在允许范围,两者不能混用。
|
||||
- 管理设置保存采用字段白名单和完整归一化,前端未提交的敏感策略不得被空值覆盖。
|
||||
|
||||
### 10.4 配置
|
||||
|
||||
用户可配置策略存储在 `fileSetting`:
|
||||
|
||||
```text
|
||||
webdav_enabled
|
||||
webdav_permission_type all / appoint
|
||||
webdav_permission_userids
|
||||
webdav_max_credentials
|
||||
webdav_default_expire_days
|
||||
webdav_max_expire_days
|
||||
webdav_max_file_bytes
|
||||
webdav_copy_max_nodes
|
||||
webdav_audit_retention_days
|
||||
```
|
||||
|
||||
协议硬限制和默认值放在 `config/dootask.php`,业务代码不直接读取 `env()`。配置/路由变更部署后需要重启 LaravelS。
|
||||
|
||||
## 11. 并发、事务与锁
|
||||
|
||||
### 11.1 两类锁
|
||||
|
||||
- 协议锁:`webdav_locks`,对客户端可见,实现 DAV `LOCK/UNLOCK`。
|
||||
- 服务端互斥锁:复用 `App\Module\Lock`,保护同一父目录的命名空间和同一文件版本写入。
|
||||
|
||||
两者不可相互替代。即使客户端未主动 LOCK,服务端仍必须使用短期互斥锁保证事务一致性。
|
||||
|
||||
### 11.2 加锁顺序
|
||||
|
||||
为避免死锁,统一按以下顺序:
|
||||
|
||||
1. 规范化资源路径。
|
||||
2. 检查 DAV 锁 token。
|
||||
3. 获取按数字 ID 排序后的父目录分布式锁。
|
||||
4. 开启数据库事务。
|
||||
5. 按 ID 升序 `lockForUpdate` 锁父目录、源资源、目标资源。
|
||||
6. 再次检查权限、名称冲突和条件请求。
|
||||
7. 写数据库并提交。
|
||||
8. 事务外投递可重试的通知;现有必须同步的副作用保持原行为。
|
||||
|
||||
### 11.3 失败恢复
|
||||
|
||||
- 请求体接收失败:删除临时文件,不创建 File/FileContent。
|
||||
- 物理文件移动失败:回滚数据库。
|
||||
- 数据库失败:删除本次新物理文件;删除失败记入孤儿清理队列。
|
||||
- 消息或搜索异步投递失败:主文件操作成功,记录失败并走现有重试机制。
|
||||
- 客户端断开:检测连接状态并停止继续读取,finally 清理临时文件。
|
||||
- MOVE/COPY 多资源失败:不得留下半棵可见目录;先在事务中完成元数据,超出同步上限直接在执行前拒绝。
|
||||
|
||||
## 12. 状态码与错误映射
|
||||
|
||||
| 场景 | 状态码 |
|
||||
| --- | --- |
|
||||
| 未提供或无效凭据 | `401 Unauthorized` |
|
||||
| 全局关闭或维护中 | `503 Service Unavailable` |
|
||||
| 无查看权限或资源不存在 | `404 Not Found` |
|
||||
| 有查看权限但无写权限 | `403 Forbidden` |
|
||||
| 同名目标且不允许覆盖 | `412 Precondition Failed` |
|
||||
| ETag 或 DAV If 条件失败 | `412 Precondition Failed` |
|
||||
| 资源被其他锁占用 | `423 Locked` |
|
||||
| 父目录不存在 | `409 Conflict` |
|
||||
| 文件夹达到 300 项 | `507 Insufficient Storage` |
|
||||
| 文件或复制规模超限 | `413 Content Too Large` 或 `507` |
|
||||
| 不支持的方法 | `405 Method Not Allowed` |
|
||||
| PROPFIND/PROPPATCH 多状态 | `207 Multi-Status` |
|
||||
| PUT 新建成功 | `201 Created` |
|
||||
| PUT 覆盖成功 | `204 No Content` |
|
||||
| MOVE/COPY 成功 | `201` 或 `204` |
|
||||
| DELETE 成功 | `204 No Content` |
|
||||
|
||||
DAV 路由的异常必须由 DAV 专用异常渲染器转换为 XML 或空响应,不能落入全局 `ApiException` JSON 响应。
|
||||
|
||||
## 13. 安全设计
|
||||
|
||||
- 只允许 HTTPS;反向代理场景使用可信的 `X-Forwarded-Proto` 判断。
|
||||
- XML 使用禁用外部实体和网络访问的解析器,限制 XML 体积和节点数量,防止 XXE 与 XML bomb。
|
||||
- 拒绝双重编码、路径穿越、编码斜杠、超长路径和控制字符。
|
||||
- `Destination` 必须属于当前 Host 和 `/dav/` 基础路径,拒绝跨服务 COPY/MOVE。
|
||||
- 响应不暴露物理路径、SQL、文件所有者邮箱和权限查询细节。
|
||||
- 下载设置 `Content-Disposition`、正确 MIME、`X-Content-Type-Options: nosniff`。
|
||||
- 凭据明文只出现于创建响应;前端不得写入 localStorage、日志或埋点。
|
||||
- 操作审计覆盖凭据创建、撤销、认证失败以及全部 DAV 写方法。
|
||||
- 管理员允许名单变更、用户停用和密码策略变化不需要重发 WebDAV 密码,但必须实时影响访问状态。
|
||||
|
||||
## 14. 性能与容量
|
||||
|
||||
- PROPFIND `Depth: 1` 一次批量查询子节点和最新内容 ID,避免 N+1。
|
||||
- 当前每目录最多 300 项,可在单次响应内返回;仍应使用游标式内部查询和固定字段选择。
|
||||
- 共享根一次查询全部可见共享根,结果只在当前请求缓存。
|
||||
- GET/PUT 采用 1 MB 左右分块流式处理,实际块大小通过压测确定。
|
||||
- Nginx、LaravelS、PHP 临时目录和应用限制必须统一,避免某一层提前截断。
|
||||
- `package_max_length = 1 GB` 只代表 Swoole 接受上限,不作为流式能力证明;入口进程 RSS 是强制验收指标。
|
||||
- COPY 目录默认最多 10,000 个节点;执行前先计数,超限不启动复制。
|
||||
- 大文件和长请求设置独立的 Nginx 超时,不影响普通 API。
|
||||
- 审计写入可通过 Swoole Task 异步投递,但认证失败和安全事件必须保证记录或进入结构化日志。
|
||||
|
||||
## 15. 前端设计
|
||||
|
||||
### 15.1 管理端
|
||||
|
||||
在现有「文件设置」增加 WebDAV 区域:
|
||||
|
||||
- 启用开关。
|
||||
- 允许使用范围:全员/指定成员。
|
||||
- 每用户凭据数、默认有效期、最大有效期。
|
||||
- 单文件和目录复制限制。
|
||||
- 当前服务 URL 与 HTTPS 状态。
|
||||
- 路径冲突审计状态;存在冲突时禁用开启按钮并提供文件 ID 列表。
|
||||
- 最近 24 小时认证失败数和写入失败数。
|
||||
|
||||
### 15.2 用户端
|
||||
|
||||
在文件页面右上角现有加号按钮右侧增加圆形“更多”按钮:
|
||||
|
||||
- 使用现有图标库的 `ios-more`,按钮尺寸、圆形样式和固定占位与加号保持一致。
|
||||
- 点击后打开下拉菜单,首版包含「WebDAV」入口,后续文件级全局能力可以继续放入该菜单。
|
||||
- 入口在“我的文件”和“共享文件”板块显示;“协作文件”不属于 DAV 范围,不显示该入口。
|
||||
- 选择「WebDAV」后打开独立管理弹窗,不跳转到系统设置或个人安全页面。
|
||||
- 弹窗展示可用状态、服务地址和凭据列表:设备名、末四位、创建时间、过期时间、最后使用时间和客户端。
|
||||
- 弹窗内可以创建应用密码;创建成功后一次性展示连接信息。
|
||||
- 弹窗内可以撤销单个凭据并二次确认。
|
||||
- 管理员关闭时弹窗只展示不可用状态,不能创建新凭据。
|
||||
- 移动端空间不足时保留加号和更多两个固定尺寸图标,搜索框优先收缩,按钮不得换行或覆盖。
|
||||
|
||||
所有新增可见中文同步登记到 `language/original-web.txt` 和 `language/original-api.txt`,并更新相关 ai-kb 功能 chunk。
|
||||
|
||||
## 16. 可观测性与运维
|
||||
|
||||
### 16.1 指标
|
||||
|
||||
至少统计:
|
||||
|
||||
- 按方法和状态码的请求数。
|
||||
- 认证成功、失败、限流次数。
|
||||
- 活跃用户和活跃凭据数。
|
||||
- GET/PUT 字节数及耗时分布。
|
||||
- 锁创建、冲突、超时数。
|
||||
- ETag 冲突和覆盖次数。
|
||||
- 临时文件、孤儿文件数量及清理失败数。
|
||||
- DAV 写入后的消息/搜索同步失败数。
|
||||
|
||||
### 16.2 日志关联
|
||||
|
||||
- 每个请求生成 `request_id` 并加入响应头 `X-Request-Id`。
|
||||
- DAV 操作日志、应用日志和 Nginx 日志都记录该 ID。
|
||||
- 密码、Authorization、Lock-Token 不得进入日志。
|
||||
- URI 记录前去除认证信息并限制长度。
|
||||
|
||||
### 16.3 定时维护
|
||||
|
||||
新增任务:
|
||||
|
||||
- 每分钟或按需清理过期 DAV 锁。
|
||||
- 每日清理过期/撤销且超过保留期的凭据记录。
|
||||
- 每日分批清理过期操作日志。
|
||||
- 复用临时文件清理任务清理超时 DAV 上传目录。
|
||||
- 定期扫描无 FileContent 引用的物理孤儿文件,只报告;自动删除需另行评审。
|
||||
|
||||
## 17. 测试方案
|
||||
|
||||
### 17.1 单元测试
|
||||
|
||||
- 路径编码、NFC、非法字符、穿越和双重解码。
|
||||
- 完整文件名与 `name/ext/type` 转换。
|
||||
- `files/shared` 节点解析和共享顶层 `[#ID]`。
|
||||
- 权限矩阵的每个方法。
|
||||
- ETag 和全部条件请求组合。
|
||||
- 锁创建、刷新、继承、冲突、过期和撤销。
|
||||
- WebDAV 状态码与 DooTask 异常映射。
|
||||
- 应用密码生成、哈希校验、过期和撤销。
|
||||
|
||||
### 17.2 Feature 测试
|
||||
|
||||
- OPTIONS 和 Basic challenge。
|
||||
- PROPFIND Depth 0/1 的 XML 响应。
|
||||
- PUT 新建、覆盖及历史版本。
|
||||
- MKCOL、COPY、MOVE、DELETE 全流程。
|
||||
- 临时文件 MOVE 覆盖目标且保留目标 ID。
|
||||
- 共享只读、共享读写、创建者删除和越权访问。
|
||||
- Range GET、HEAD、空文件和大文件流。
|
||||
- 两客户端并发 PUT、锁冲突和 ETag 冲突。
|
||||
- 凭据撤销、用户停用、全局关闭立即生效。
|
||||
- 写操作后 WebSocket 推送和 Manticore Task 被正确投递。
|
||||
- 数据库/物理写入异常时无可见半成品。
|
||||
|
||||
### 17.3 协议与客户端测试
|
||||
|
||||
- 使用 WebDAV Litmus 测试套件作为协议基线。
|
||||
- `curl` 覆盖所有方法和条件头。
|
||||
- Windows 11 文件资源管理器:挂载、Office 保存、重命名、覆盖、删除。
|
||||
- macOS Finder:连接、复制目录、锁定编辑、断线重连。
|
||||
- Linux `davfs2`:挂载和并发文件操作。
|
||||
- Microsoft Office/LibreOffice:临时文件原子覆盖、锁刷新和冲突提示。
|
||||
- 中文、空格、`#`、`%`、emoji、超长名称和大小写冲突文件。
|
||||
|
||||
客户端测试记录环境、步骤、状态码和结果截图到 `tests/playwright-results/` 或新增的 DAV 测试结果目录;协议测试不伪装为 Playwright 自动化结果。
|
||||
|
||||
### 17.4 质量门禁
|
||||
|
||||
实现完成后执行:
|
||||
|
||||
```text
|
||||
./cmd composer stan
|
||||
npm run lint
|
||||
npm run check:lang
|
||||
./cmd artisan doc:api-map
|
||||
```
|
||||
|
||||
不主动运行 `./cmd dev`、`./cmd prod` 或 `./cmd build`。
|
||||
|
||||
## 18. 实施阶段与验收
|
||||
|
||||
### 阶段 0:领域服务收敛
|
||||
|
||||
- 先完成 9.6 节请求体内存验证,并确定 LaravelS 或独立 PHP-FPM 入口;该结论记录到测试结果。
|
||||
- 建立 FileSystemService、类型解析和内容存储。
|
||||
- 现有文件 API 迁入服务,接口行为保持兼容。
|
||||
- 修复复制内容物理引用和路径并发问题。
|
||||
- 增加路径冲突审计命令。
|
||||
|
||||
验收:大文件入口架构已经用 RSS 数据确定;原文件页面全部操作通过;现有 API 响应无回归;新增并发测试通过。
|
||||
|
||||
### 阶段 1:开关与凭据
|
||||
|
||||
- 增加迁移、模型、管理员配置和用户凭据界面。
|
||||
- 完成 Basic backend、限流、撤销和安全审计。
|
||||
- 功能开关默认关闭。
|
||||
|
||||
验收:凭据只展示一次;撤销和全局关闭立即生效;日志无秘密信息。
|
||||
|
||||
### 阶段 2:只读协议
|
||||
|
||||
- 完成 OPTIONS、PROPFIND、HEAD、GET、ETag、Range。
|
||||
- 完成 `files/shared` 虚拟树和权限隐藏。
|
||||
- 接入 Litmus 与三个操作系统的只读验证。
|
||||
|
||||
验收:大文件恒定内存;无权限资源不泄露;共享列表无重复和歧义。
|
||||
|
||||
### 阶段 3:写协议
|
||||
|
||||
- 完成 PUT、MKCOL、COPY、MOVE、DELETE、PROPPATCH。
|
||||
- 完成统一事务、副作用、临时文件和原子覆盖。
|
||||
- 完成共享权限矩阵。
|
||||
|
||||
验收:网页端和 DAV 互相实时可见;覆盖产生历史;异常不留半成品。
|
||||
|
||||
### 阶段 4:锁与兼容
|
||||
|
||||
- 完成 LOCK/UNLOCK、DAV If 头和锁清理。
|
||||
- 完成 Windows/macOS/Office 兼容修正和性能压测。
|
||||
- 完成运维仪表和告警。
|
||||
|
||||
验收:Litmus 目标用例通过;Office 原子保存稳定;并发编辑不静默丢失数据。
|
||||
|
||||
### 阶段 5:灰度上线
|
||||
|
||||
- 先对指定内部用户启用。
|
||||
- 观察至少一个完整凭据和锁超时周期。
|
||||
- 检查错误率、孤儿文件、同步失败和数据库慢查询。
|
||||
- 再逐步扩大允许范围,最后由管理员决定是否全员开放。
|
||||
|
||||
### 工作包与依赖
|
||||
|
||||
| 工作包 | 内容 | 前置依赖 | 交付判定 |
|
||||
| --- | --- | --- | --- |
|
||||
| W0 | LaravelS/PHP-FPM 大文件入口验证 | 无 | 形成 RSS 数据和确定的部署拓扑 |
|
||||
| W1 | FileSystemService、类型解析、内容存储 | 无 | 原网页文件 API 全部复用服务且行为无回归 |
|
||||
| W2 | 凭据、锁、属性、审计迁移与模型 | 无 | 迁移和模型单测通过,不修改现有文件数据 |
|
||||
| W3 | 管理配置、用户凭据 API 与前端 | W2 | 开启、创建、一次展示、撤销、停用形成闭环 |
|
||||
| W4 | Sabre 请求桥、Basic backend、DAV 中间件 | W0、W2 | OPTIONS 和认证挑战符合协议,异常不返回 JSON |
|
||||
| W5 | 虚拟树、路径解析、PROPFIND/HEAD/GET | W1、W4 | 我的文件和共享文件只读客户端验证通过 |
|
||||
| W6 | PUT/MKCOL/COPY/MOVE/DELETE | W1、W5 | 写入历史、权限、副作用和失败补偿测试通过 |
|
||||
| W7 | LOCK/UNLOCK、PROPPATCH、条件请求 | W2、W5、W6 | 并发编辑返回正确 412/423,无静默覆盖 |
|
||||
| W8 | 审计、指标、清理任务和管理状态 | W2、W4 | 可定位失败请求,过期数据自动分批收敛 |
|
||||
| W9 | Litmus、系统客户端、Office 和压测 | W5、W6、W7、W8 | 目标兼容矩阵和性能门禁全部有记录 |
|
||||
| W10 | API map、语言、ai-kb、部署和运维文档 | W3 至 W9 | 文档与最终行为一致,版本号完成复核 |
|
||||
|
||||
W0、W1、W2 可以并行;W4 不得在 W0 未定结论时固化部署实现;W6 不得绕过 W1 直接写模型。每个工作包都应包含对应自动化测试,避免把测试集中到 W9 才补。
|
||||
|
||||
## 19. 发布、回滚与数据安全
|
||||
|
||||
### 19.1 发布前
|
||||
|
||||
- 数据库备份。
|
||||
- 执行路径冲突审计,存在冲突则停止启用。
|
||||
- 验证 HTTPS、代理头、大文件限制和临时目录容量。
|
||||
- 安装依赖并完成 PHP 8.4/LaravelS 冒烟测试。
|
||||
- 迁移只新增表和索引,不删除现有数据。
|
||||
|
||||
### 19.2 回滚
|
||||
|
||||
1. 首先关闭 `webdav_enabled`,立即阻断协议流量。
|
||||
2. 保留凭据、锁和审计表,便于调查和再次启用。
|
||||
3. 回滚协议路由和代码不影响已有 `files/file_contents` 数据。
|
||||
4. 不自动删除 DAV 创建的文件,因为它们已经是正常 DooTask 文件。
|
||||
5. 如需卸载表结构,必须另行确认并先导出审计;不作为常规代码回滚步骤。
|
||||
|
||||
### 19.3 兼容承诺
|
||||
|
||||
- DAV 创建的文件必须能在网页端正常预览、下载、移动和恢复历史。
|
||||
- 网页端修改必须在下一次 DAV 请求立即可见。
|
||||
- 禁用 WebDAV 不改变任何文件、共享关系或历史版本。
|
||||
- 后续升级不得改变 `files/`、`shared/` URI 名称和共享顶层 ID 规则。
|
||||
|
||||
## 20. 风险与决策记录
|
||||
|
||||
| 风险 | 处理决策 |
|
||||
| --- | --- |
|
||||
| 现有控制器含业务逻辑 | 先收敛到 FileSystemService,再接 DAV |
|
||||
| 应用密码被窃取 | 强制 HTTPS、只存哈希、可撤销、限流、审计 |
|
||||
| Swoole 请求状态串联 | 每请求建 Server,用户和缓存放 RequestContext |
|
||||
| 客户端静默覆盖 | ETag + DAV If + LOCK,失败返回 412/423 |
|
||||
| 共享写权限与删除权限不同 | 保留现有语义,原子覆盖不解释为删除目标 |
|
||||
| 同名共享根冲突 | 共享顶层 URI 固定附加 `[#file_id]` |
|
||||
| 大文件耗尽内存 | 全链路流式、统一上限、临时目录监控 |
|
||||
| Swoole 在 Laravel 前聚合 PUT body | RSS 压测作为门禁;不满足时使用独立 PHP-FPM DAV 入口 |
|
||||
| 复制内容共享物理 URL | 新复制创建独立内容,旧数据删除前查引用 |
|
||||
| 异常留下物理孤儿 | 补偿清理 + 孤儿扫描报告 |
|
||||
| DAV 错误落成 JSON | 独立中间件和异常响应转换 |
|
||||
| 路径冲突导致 URI 不唯一 | 启用前审计、父目录锁、统一服务写入 |
|
||||
|
||||
## 21. 完成定义
|
||||
|
||||
只有同时满足以下条件,WebDAV 才算功能闭环:
|
||||
|
||||
- 管理员可以启用、限制、观测和关闭服务。
|
||||
- 用户可以创建、使用、查看状态和撤销应用密码。
|
||||
- `files/shared` 的读写与网页权限一致。
|
||||
- 所有协议方法返回标准状态码和 XML。
|
||||
- 写入保留历史并触发现有通知、搜索和回收站行为。
|
||||
- 锁、ETag 和条件请求可以防止并发静默覆盖。
|
||||
- 大文件不会整体进入 PHP 内存,失败会清理临时文件。
|
||||
- Windows、macOS、Linux 和办公客户端有可追溯的验证结果。
|
||||
- 功能默认关闭,可灰度,可即时停用,停用不破坏文件数据。
|
||||
- API 对照表、语言文件、ai-kb、运维文档和测试在同一次功能交付中同步更新。
|
||||
@ -883,6 +883,7 @@ URL格式不正确
|
||||
清理完成
|
||||
|
||||
重命名成功
|
||||
文件已不在冲突组中
|
||||
请输入会话名称
|
||||
复制任务
|
||||
调整模板排序
|
||||
@ -1045,3 +1046,25 @@ upload_id 不能为空
|
||||
仅项目负责人或任务相关成员删除
|
||||
未开启部门负责人视角功能
|
||||
没有可查看的部门数据
|
||||
WebDAV 应用密码不存在
|
||||
WebDAV 应用密码数量已达上限
|
||||
WebDAV 必须通过 HTTPS 使用
|
||||
WebDAV 未启用或你没有使用权限
|
||||
临时文件创建失败
|
||||
共享根目录不可写
|
||||
存在文件路径冲突,请先处理后再启用 WebDAV
|
||||
撤销成功
|
||||
文件保存失败
|
||||
文件内容不存在
|
||||
文件内容读取失败
|
||||
文件名称包含非法字符
|
||||
文件名称错误
|
||||
文件名称长度必须为1至200个字符
|
||||
文件大小超过限制
|
||||
文件夹不能写入内容
|
||||
文件已存在
|
||||
文件读取失败
|
||||
有效期超出允许范围
|
||||
目标不是文件夹
|
||||
请输入设备名称
|
||||
复制的文件和文件夹数量超过限制
|
||||
|
||||
@ -2691,3 +2691,41 @@ AI 助手设置
|
||||
协作文件
|
||||
搜索名称、会话、项目或任务
|
||||
聊天图片 (*)
|
||||
[credential_status].有效
|
||||
WebDAV
|
||||
你没有 WebDAV 使用权限
|
||||
WebDAV 未启用
|
||||
当前页面不是 HTTPS,无法创建应用密码
|
||||
连接地址
|
||||
应用密码已创建,请立即保存,关闭后无法再次查看。
|
||||
用户名
|
||||
应用密码
|
||||
设备名称
|
||||
例如:办公室电脑
|
||||
有效期
|
||||
有效期至
|
||||
最近使用
|
||||
暂无应用密码
|
||||
存在文件路径冲突,处理后才能启用 WebDAV
|
||||
发现(*)组同路径文件,处理后才能启用 WebDAV
|
||||
查看冲突文件
|
||||
WebDAV 路径冲突
|
||||
同一拥有者的同一目录中存在完整名称相同的文件,WebDAV 无法确定应访问哪一条。
|
||||
请让对应拥有者在文件页面保留其中一条,并重命名或删除其余文件,然后刷新检测。
|
||||
刷新检测
|
||||
拥有者
|
||||
冲突路径
|
||||
冲突记录
|
||||
打开位置
|
||||
请输入新名称
|
||||
重命名其他成员的文件
|
||||
你正在重命名(*)的私人文件,此操作会直接修改文件名称。
|
||||
启用 WebDAV
|
||||
允许范围
|
||||
每人应用密码上限
|
||||
默认有效期
|
||||
最长有效期
|
||||
用户可在文件页面右上角的更多菜单中管理 WebDAV 应用密码
|
||||
请输入设备名称
|
||||
撤销应用密码
|
||||
撤销后,使用此应用密码连接的设备将立即断开。
|
||||
|
||||
@ -38758,5 +38758,701 @@
|
||||
"fr": "Image de discussion (%T1)",
|
||||
"id": "Gambar obrolan (%T1)",
|
||||
"ru": "Изображение из чата (%T1)"
|
||||
},
|
||||
{
|
||||
"key": "[credential_status].有效",
|
||||
"zh": "有效",
|
||||
"zh-CHT": "有效",
|
||||
"en": "Active",
|
||||
"ko": "활성",
|
||||
"ja": "有効",
|
||||
"de": "Aktiv",
|
||||
"fr": "Actif",
|
||||
"id": "Aktif",
|
||||
"ru": "Активен"
|
||||
},
|
||||
{
|
||||
"key": "WebDAV",
|
||||
"zh": "",
|
||||
"zh-CHT": "WebDAV",
|
||||
"en": "WebDAV",
|
||||
"ko": "WebDAV",
|
||||
"ja": "WebDAV",
|
||||
"de": "WebDAV",
|
||||
"fr": "WebDAV",
|
||||
"id": "WebDAV",
|
||||
"ru": "WebDAV"
|
||||
},
|
||||
{
|
||||
"key": "你没有 WebDAV 使用权限",
|
||||
"zh": "",
|
||||
"zh-CHT": "你沒有 WebDAV 使用權限",
|
||||
"en": "You do not have permission to use WebDAV",
|
||||
"ko": "WebDAV 사용 권한이 없습니다",
|
||||
"ja": "WebDAV を使用する権限がありません",
|
||||
"de": "Sie sind nicht zur Nutzung von WebDAV berechtigt",
|
||||
"fr": "Vous n’avez pas l’autorisation d’utiliser WebDAV",
|
||||
"id": "Anda tidak memiliki izin untuk menggunakan WebDAV",
|
||||
"ru": "У вас нет разрешения на использование WebDAV"
|
||||
},
|
||||
{
|
||||
"key": "WebDAV 未启用",
|
||||
"zh": "",
|
||||
"zh-CHT": "WebDAV 未啟用",
|
||||
"en": "WebDAV is not enabled",
|
||||
"ko": "WebDAV가 활성화되지 않았습니다",
|
||||
"ja": "WebDAV は有効になっていません",
|
||||
"de": "WebDAV ist nicht aktiviert",
|
||||
"fr": "WebDAV n’est pas activé",
|
||||
"id": "WebDAV belum diaktifkan",
|
||||
"ru": "WebDAV не включен"
|
||||
},
|
||||
{
|
||||
"key": "当前页面不是 HTTPS,无法创建应用密码",
|
||||
"zh": "",
|
||||
"zh-CHT": "目前頁面不是 HTTPS,無法建立應用密碼",
|
||||
"en": "This page is not using HTTPS, so an app password cannot be created",
|
||||
"ko": "현재 페이지가 HTTPS가 아니므로 앱 비밀번호를 만들 수 없습니다",
|
||||
"ja": "現在のページは HTTPS ではないため、アプリパスワードを作成できません",
|
||||
"de": "Diese Seite verwendet kein HTTPS. Ein App-Passwort kann nicht erstellt werden",
|
||||
"fr": "Cette page n’utilise pas HTTPS, le mot de passe d’application ne peut pas être créé",
|
||||
"id": "Halaman ini tidak menggunakan HTTPS, sehingga kata sandi aplikasi tidak dapat dibuat",
|
||||
"ru": "Эта страница не использует HTTPS, поэтому создать пароль приложения нельзя"
|
||||
},
|
||||
{
|
||||
"key": "连接地址",
|
||||
"zh": "",
|
||||
"zh-CHT": "連線位址",
|
||||
"en": "Server address",
|
||||
"ko": "연결 주소",
|
||||
"ja": "接続先アドレス",
|
||||
"de": "Serveradresse",
|
||||
"fr": "Adresse du serveur",
|
||||
"id": "Alamat server",
|
||||
"ru": "Адрес сервера"
|
||||
},
|
||||
{
|
||||
"key": "应用密码已创建,请立即保存,关闭后无法再次查看。",
|
||||
"zh": "",
|
||||
"zh-CHT": "應用密碼已建立,請立即儲存,關閉後無法再次查看。",
|
||||
"en": "The app password has been created. Save it now; it cannot be viewed again after closing.",
|
||||
"ko": "앱 비밀번호가 생성되었습니다. 지금 저장하세요. 닫은 후에는 다시 볼 수 없습니다.",
|
||||
"ja": "アプリパスワードを作成しました。閉じると再表示できないため、今すぐ保存してください。",
|
||||
"de": "Das App-Passwort wurde erstellt. Speichern Sie es jetzt; nach dem Schließen kann es nicht erneut angezeigt werden.",
|
||||
"fr": "Le mot de passe d’application a été créé. Enregistrez-le maintenant ; il ne pourra plus être affiché après la fermeture.",
|
||||
"id": "Kata sandi aplikasi telah dibuat. Simpan sekarang; kata sandi tidak dapat dilihat lagi setelah ditutup.",
|
||||
"ru": "Пароль приложения создан. Сохраните его сейчас: после закрытия его нельзя будет посмотреть снова."
|
||||
},
|
||||
{
|
||||
"key": "用户名",
|
||||
"zh": "",
|
||||
"zh-CHT": "使用者名稱",
|
||||
"en": "Username",
|
||||
"ko": "사용자 이름",
|
||||
"ja": "ユーザー名",
|
||||
"de": "Benutzername",
|
||||
"fr": "Nom d’utilisateur",
|
||||
"id": "Nama pengguna",
|
||||
"ru": "Имя пользователя"
|
||||
},
|
||||
{
|
||||
"key": "应用密码",
|
||||
"zh": "",
|
||||
"zh-CHT": "應用密碼",
|
||||
"en": "App password",
|
||||
"ko": "앱 비밀번호",
|
||||
"ja": "アプリパスワード",
|
||||
"de": "App-Passwort",
|
||||
"fr": "Mot de passe d’application",
|
||||
"id": "Kata sandi aplikasi",
|
||||
"ru": "Пароль приложения"
|
||||
},
|
||||
{
|
||||
"key": "设备名称",
|
||||
"zh": "",
|
||||
"zh-CHT": "裝置名稱",
|
||||
"en": "Device name",
|
||||
"ko": "기기 이름",
|
||||
"ja": "デバイス名",
|
||||
"de": "Gerätename",
|
||||
"fr": "Nom de l’appareil",
|
||||
"id": "Nama perangkat",
|
||||
"ru": "Имя устройства"
|
||||
},
|
||||
{
|
||||
"key": "例如:办公室电脑",
|
||||
"zh": "",
|
||||
"zh-CHT": "例如:辦公室電腦",
|
||||
"en": "For example: Office computer",
|
||||
"ko": "예: 사무실 컴퓨터",
|
||||
"ja": "例:オフィスのパソコン",
|
||||
"de": "Zum Beispiel: Bürocomputer",
|
||||
"fr": "Par exemple : ordinateur du bureau",
|
||||
"id": "Contoh: Komputer kantor",
|
||||
"ru": "Например: рабочий компьютер"
|
||||
},
|
||||
{
|
||||
"key": "有效期",
|
||||
"zh": "",
|
||||
"zh-CHT": "有效期限",
|
||||
"en": "Validity period",
|
||||
"ko": "유효 기간",
|
||||
"ja": "有効期間",
|
||||
"de": "Gültigkeitsdauer",
|
||||
"fr": "Durée de validité",
|
||||
"id": "Masa berlaku",
|
||||
"ru": "Срок действия"
|
||||
},
|
||||
{
|
||||
"key": "有效期至",
|
||||
"zh": "",
|
||||
"zh-CHT": "有效期限至",
|
||||
"en": "Valid until",
|
||||
"ko": "유효 기한",
|
||||
"ja": "有効期限",
|
||||
"de": "Gültig bis",
|
||||
"fr": "Valide jusqu’au",
|
||||
"id": "Berlaku hingga",
|
||||
"ru": "Действует до"
|
||||
},
|
||||
{
|
||||
"key": "最近使用",
|
||||
"zh": "",
|
||||
"zh-CHT": "最近使用",
|
||||
"en": "Last used",
|
||||
"ko": "최근 사용",
|
||||
"ja": "最終使用",
|
||||
"de": "Zuletzt verwendet",
|
||||
"fr": "Dernière utilisation",
|
||||
"id": "Terakhir digunakan",
|
||||
"ru": "Последнее использование"
|
||||
},
|
||||
{
|
||||
"key": "暂无应用密码",
|
||||
"zh": "",
|
||||
"zh-CHT": "暫無應用密碼",
|
||||
"en": "No app passwords",
|
||||
"ko": "앱 비밀번호가 없습니다",
|
||||
"ja": "アプリパスワードはありません",
|
||||
"de": "Keine App-Passwörter",
|
||||
"fr": "Aucun mot de passe d’application",
|
||||
"id": "Belum ada kata sandi aplikasi",
|
||||
"ru": "Нет паролей приложений"
|
||||
},
|
||||
{
|
||||
"key": "存在文件路径冲突,处理后才能启用 WebDAV",
|
||||
"zh": "",
|
||||
"zh-CHT": "存在檔案路徑衝突,處理後才能啟用 WebDAV",
|
||||
"en": "File path conflicts must be resolved before WebDAV can be enabled",
|
||||
"ko": "WebDAV를 활성화하려면 먼저 파일 경로 충돌을 해결해야 합니다",
|
||||
"ja": "WebDAV を有効にする前にファイルパスの競合を解決してください",
|
||||
"de": "Dateipfadkonflikte müssen behoben werden, bevor WebDAV aktiviert werden kann",
|
||||
"fr": "Les conflits de chemins de fichiers doivent être résolus avant d’activer WebDAV",
|
||||
"id": "Konflik jalur file harus diselesaikan sebelum WebDAV dapat diaktifkan",
|
||||
"ru": "Перед включением WebDAV необходимо устранить конфликты путей файлов"
|
||||
},
|
||||
{
|
||||
"key": "启用 WebDAV",
|
||||
"zh": "",
|
||||
"zh-CHT": "啟用 WebDAV",
|
||||
"en": "Enable WebDAV",
|
||||
"ko": "WebDAV 활성화",
|
||||
"ja": "WebDAV を有効にする",
|
||||
"de": "WebDAV aktivieren",
|
||||
"fr": "Activer WebDAV",
|
||||
"id": "Aktifkan WebDAV",
|
||||
"ru": "Включить WebDAV"
|
||||
},
|
||||
{
|
||||
"key": "允许范围",
|
||||
"zh": "",
|
||||
"zh-CHT": "允許範圍",
|
||||
"en": "Access scope",
|
||||
"ko": "허용 범위",
|
||||
"ja": "許可範囲",
|
||||
"de": "Zugriffsbereich",
|
||||
"fr": "Périmètre d’accès",
|
||||
"id": "Cakupan akses",
|
||||
"ru": "Область доступа"
|
||||
},
|
||||
{
|
||||
"key": "每人应用密码上限",
|
||||
"zh": "",
|
||||
"zh-CHT": "每人應用密碼上限",
|
||||
"en": "App password limit per user",
|
||||
"ko": "사용자별 앱 비밀번호 한도",
|
||||
"ja": "ユーザーごとのアプリパスワード上限",
|
||||
"de": "App-Passwort-Limit pro Benutzer",
|
||||
"fr": "Limite de mots de passe d’application par utilisateur",
|
||||
"id": "Batas kata sandi aplikasi per pengguna",
|
||||
"ru": "Лимит паролей приложений на пользователя"
|
||||
},
|
||||
{
|
||||
"key": "默认有效期",
|
||||
"zh": "",
|
||||
"zh-CHT": "預設有效期限",
|
||||
"en": "Default validity period",
|
||||
"ko": "기본 유효 기간",
|
||||
"ja": "デフォルトの有効期間",
|
||||
"de": "Standardgültigkeitsdauer",
|
||||
"fr": "Durée de validité par défaut",
|
||||
"id": "Masa berlaku default",
|
||||
"ru": "Срок действия по умолчанию"
|
||||
},
|
||||
{
|
||||
"key": "最长有效期",
|
||||
"zh": "",
|
||||
"zh-CHT": "最長有效期限",
|
||||
"en": "Maximum validity period",
|
||||
"ko": "최대 유효 기간",
|
||||
"ja": "最長有効期間",
|
||||
"de": "Maximale Gültigkeitsdauer",
|
||||
"fr": "Durée de validité maximale",
|
||||
"id": "Masa berlaku maksimum",
|
||||
"ru": "Максимальный срок действия"
|
||||
},
|
||||
{
|
||||
"key": "用户可在文件页面右上角的更多菜单中管理 WebDAV 应用密码",
|
||||
"zh": "",
|
||||
"zh-CHT": "使用者可在檔案頁面右上角的更多選單中管理 WebDAV 應用密碼",
|
||||
"en": "Users can manage WebDAV app passwords from the More menu in the upper-right corner of the Files page",
|
||||
"ko": "사용자는 파일 페이지 오른쪽 위의 더보기 메뉴에서 WebDAV 앱 비밀번호를 관리할 수 있습니다",
|
||||
"ja": "ユーザーはファイルページ右上のその他メニューから WebDAV アプリパスワードを管理できます",
|
||||
"de": "Benutzer können WebDAV-App-Passwörter über das Mehr-Menü oben rechts auf der Dateiseite verwalten",
|
||||
"fr": "Les utilisateurs peuvent gérer les mots de passe d’application WebDAV dans le menu Plus en haut à droite de la page Fichiers",
|
||||
"id": "Pengguna dapat mengelola kata sandi aplikasi WebDAV dari menu Lainnya di kanan atas halaman File",
|
||||
"ru": "Пользователи могут управлять паролями приложений WebDAV через меню «Еще» в правом верхнем углу страницы файлов"
|
||||
},
|
||||
{
|
||||
"key": "请输入设备名称",
|
||||
"zh": "",
|
||||
"zh-CHT": "請輸入裝置名稱",
|
||||
"en": "Enter a device name",
|
||||
"ko": "기기 이름을 입력하세요",
|
||||
"ja": "デバイス名を入力してください",
|
||||
"de": "Geben Sie einen Gerätenamen ein",
|
||||
"fr": "Saisissez un nom d’appareil",
|
||||
"id": "Masukkan nama perangkat",
|
||||
"ru": "Введите имя устройства"
|
||||
},
|
||||
{
|
||||
"key": "撤销应用密码",
|
||||
"zh": "",
|
||||
"zh-CHT": "撤銷應用密碼",
|
||||
"en": "Revoke app password",
|
||||
"ko": "앱 비밀번호 취소",
|
||||
"ja": "アプリパスワードを取り消す",
|
||||
"de": "App-Passwort widerrufen",
|
||||
"fr": "Révoquer le mot de passe d’application",
|
||||
"id": "Cabut kata sandi aplikasi",
|
||||
"ru": "Отозвать пароль приложения"
|
||||
},
|
||||
{
|
||||
"key": "撤销后,使用此应用密码连接的设备将立即断开。",
|
||||
"zh": "",
|
||||
"zh-CHT": "撤銷後,使用此應用密碼連線的裝置將立即中斷。",
|
||||
"en": "After revocation, devices connected with this app password will be disconnected immediately.",
|
||||
"ko": "취소하면 이 앱 비밀번호로 연결된 기기의 연결이 즉시 끊어집니다.",
|
||||
"ja": "取り消すと、このアプリパスワードで接続しているデバイスは直ちに切断されます。",
|
||||
"de": "Nach dem Widerruf werden Geräte, die dieses App-Passwort verwenden, sofort getrennt.",
|
||||
"fr": "Après la révocation, les appareils connectés avec ce mot de passe d’application seront immédiatement déconnectés.",
|
||||
"id": "Setelah dicabut, perangkat yang terhubung dengan kata sandi aplikasi ini akan segera terputus.",
|
||||
"ru": "После отзыва устройства, подключенные с этим паролем приложения, будут немедленно отключены."
|
||||
},
|
||||
{
|
||||
"key": "WebDAV 应用密码不存在",
|
||||
"zh": "",
|
||||
"zh-CHT": "WebDAV 應用密碼不存在",
|
||||
"en": "The WebDAV app password does not exist",
|
||||
"ko": "WebDAV 앱 비밀번호가 없습니다",
|
||||
"ja": "WebDAV アプリパスワードが存在しません",
|
||||
"de": "Das WebDAV-App-Passwort ist nicht vorhanden",
|
||||
"fr": "Le mot de passe d’application WebDAV n’existe pas",
|
||||
"id": "Kata sandi aplikasi WebDAV tidak ada",
|
||||
"ru": "Пароль приложения WebDAV не существует"
|
||||
},
|
||||
{
|
||||
"key": "WebDAV 应用密码数量已达上限",
|
||||
"zh": "",
|
||||
"zh-CHT": "WebDAV 應用密碼數量已達上限",
|
||||
"en": "The WebDAV app password limit has been reached",
|
||||
"ko": "WebDAV 앱 비밀번호 한도에 도달했습니다",
|
||||
"ja": "WebDAV アプリパスワードの上限に達しました",
|
||||
"de": "Das Limit für WebDAV-App-Passwörter wurde erreicht",
|
||||
"fr": "La limite de mots de passe d’application WebDAV est atteinte",
|
||||
"id": "Batas kata sandi aplikasi WebDAV telah tercapai",
|
||||
"ru": "Достигнут лимит паролей приложений WebDAV"
|
||||
},
|
||||
{
|
||||
"key": "WebDAV 必须通过 HTTPS 使用",
|
||||
"zh": "",
|
||||
"zh-CHT": "WebDAV 必須透過 HTTPS 使用",
|
||||
"en": "WebDAV must be used over HTTPS",
|
||||
"ko": "WebDAV는 HTTPS를 통해 사용해야 합니다",
|
||||
"ja": "WebDAV は HTTPS 経由で使用する必要があります",
|
||||
"de": "WebDAV muss über HTTPS verwendet werden",
|
||||
"fr": "WebDAV doit être utilisé via HTTPS",
|
||||
"id": "WebDAV harus digunakan melalui HTTPS",
|
||||
"ru": "WebDAV необходимо использовать через HTTPS"
|
||||
},
|
||||
{
|
||||
"key": "WebDAV 未启用或你没有使用权限",
|
||||
"zh": "",
|
||||
"zh-CHT": "WebDAV 未啟用或你沒有使用權限",
|
||||
"en": "WebDAV is not enabled or you do not have permission to use it",
|
||||
"ko": "WebDAV가 활성화되지 않았거나 사용 권한이 없습니다",
|
||||
"ja": "WebDAV が有効になっていないか、使用権限がありません",
|
||||
"de": "WebDAV ist nicht aktiviert oder Sie sind nicht zur Nutzung berechtigt",
|
||||
"fr": "WebDAV n’est pas activé ou vous n’avez pas l’autorisation de l’utiliser",
|
||||
"id": "WebDAV belum diaktifkan atau Anda tidak memiliki izin untuk menggunakannya",
|
||||
"ru": "WebDAV не включен или у вас нет разрешения на его использование"
|
||||
},
|
||||
{
|
||||
"key": "临时文件创建失败",
|
||||
"zh": "",
|
||||
"zh-CHT": "建立暫存檔失敗",
|
||||
"en": "Failed to create the temporary file",
|
||||
"ko": "임시 파일을 만들지 못했습니다",
|
||||
"ja": "一時ファイルを作成できませんでした",
|
||||
"de": "Die temporäre Datei konnte nicht erstellt werden",
|
||||
"fr": "Impossible de créer le fichier temporaire",
|
||||
"id": "Gagal membuat file sementara",
|
||||
"ru": "Не удалось создать временный файл"
|
||||
},
|
||||
{
|
||||
"key": "共享根目录不可写",
|
||||
"zh": "",
|
||||
"zh-CHT": "共享根目錄不可寫入",
|
||||
"en": "The shared root directory is not writable",
|
||||
"ko": "공유 루트 디렉터리에 쓸 수 없습니다",
|
||||
"ja": "共有ルートディレクトリには書き込めません",
|
||||
"de": "Das freigegebene Stammverzeichnis ist nicht beschreibbar",
|
||||
"fr": "Le répertoire racine partagé n’est pas accessible en écriture",
|
||||
"id": "Direktori akar bersama tidak dapat ditulisi",
|
||||
"ru": "Корневой каталог общих файлов недоступен для записи"
|
||||
},
|
||||
{
|
||||
"key": "存在文件路径冲突,请先处理后再启用 WebDAV",
|
||||
"zh": "",
|
||||
"zh-CHT": "存在檔案路徑衝突,請先處理後再啟用 WebDAV",
|
||||
"en": "File path conflicts exist. Resolve them before enabling WebDAV",
|
||||
"ko": "파일 경로 충돌이 있습니다. 해결한 후 WebDAV를 활성화하세요",
|
||||
"ja": "ファイルパスの競合があります。解決してから WebDAV を有効にしてください",
|
||||
"de": "Es bestehen Dateipfadkonflikte. Beheben Sie diese, bevor Sie WebDAV aktivieren",
|
||||
"fr": "Des conflits de chemins de fichiers existent. Résolvez-les avant d’activer WebDAV",
|
||||
"id": "Terdapat konflik jalur file. Selesaikan sebelum mengaktifkan WebDAV",
|
||||
"ru": "Обнаружены конфликты путей файлов. Устраните их перед включением WebDAV"
|
||||
},
|
||||
{
|
||||
"key": "撤销成功",
|
||||
"zh": "",
|
||||
"zh-CHT": "撤銷成功",
|
||||
"en": "Revoked successfully",
|
||||
"ko": "취소되었습니다",
|
||||
"ja": "取り消しました",
|
||||
"de": "Erfolgreich widerrufen",
|
||||
"fr": "Révocation réussie",
|
||||
"id": "Berhasil dicabut",
|
||||
"ru": "Успешно отозвано"
|
||||
},
|
||||
{
|
||||
"key": "文件保存失败",
|
||||
"zh": "",
|
||||
"zh-CHT": "檔案儲存失敗",
|
||||
"en": "Failed to save the file",
|
||||
"ko": "파일을 저장하지 못했습니다",
|
||||
"ja": "ファイルを保存できませんでした",
|
||||
"de": "Die Datei konnte nicht gespeichert werden",
|
||||
"fr": "Impossible d’enregistrer le fichier",
|
||||
"id": "Gagal menyimpan file",
|
||||
"ru": "Не удалось сохранить файл"
|
||||
},
|
||||
{
|
||||
"key": "文件内容不存在",
|
||||
"zh": "",
|
||||
"zh-CHT": "檔案內容不存在",
|
||||
"en": "The file content does not exist",
|
||||
"ko": "파일 내용이 없습니다",
|
||||
"ja": "ファイルの内容が存在しません",
|
||||
"de": "Der Dateiinhalt ist nicht vorhanden",
|
||||
"fr": "Le contenu du fichier n’existe pas",
|
||||
"id": "Konten file tidak ada",
|
||||
"ru": "Содержимое файла не существует"
|
||||
},
|
||||
{
|
||||
"key": "文件内容读取失败",
|
||||
"zh": "",
|
||||
"zh-CHT": "讀取檔案內容失敗",
|
||||
"en": "Failed to read the file content",
|
||||
"ko": "파일 내용을 읽지 못했습니다",
|
||||
"ja": "ファイルの内容を読み取れませんでした",
|
||||
"de": "Der Dateiinhalt konnte nicht gelesen werden",
|
||||
"fr": "Impossible de lire le contenu du fichier",
|
||||
"id": "Gagal membaca konten file",
|
||||
"ru": "Не удалось прочитать содержимое файла"
|
||||
},
|
||||
{
|
||||
"key": "文件名称包含非法字符",
|
||||
"zh": "",
|
||||
"zh-CHT": "檔案名稱包含非法字元",
|
||||
"en": "The file name contains invalid characters",
|
||||
"ko": "파일 이름에 잘못된 문자가 포함되어 있습니다",
|
||||
"ja": "ファイル名に使用できない文字が含まれています",
|
||||
"de": "Der Dateiname enthält ungültige Zeichen",
|
||||
"fr": "Le nom du fichier contient des caractères non valides",
|
||||
"id": "Nama file berisi karakter yang tidak valid",
|
||||
"ru": "Имя файла содержит недопустимые символы"
|
||||
},
|
||||
{
|
||||
"key": "文件名称错误",
|
||||
"zh": "",
|
||||
"zh-CHT": "檔案名稱錯誤",
|
||||
"en": "Invalid file name",
|
||||
"ko": "잘못된 파일 이름입니다",
|
||||
"ja": "ファイル名が正しくありません",
|
||||
"de": "Ungültiger Dateiname",
|
||||
"fr": "Nom de fichier non valide",
|
||||
"id": "Nama file tidak valid",
|
||||
"ru": "Недопустимое имя файла"
|
||||
},
|
||||
{
|
||||
"key": "文件名称长度必须为1至200个字符",
|
||||
"zh": "",
|
||||
"zh-CHT": "檔案名稱長度必須為1至200個字元",
|
||||
"en": "The file name must be between 1 and 200 characters",
|
||||
"ko": "파일 이름은 1자에서 200자 사이여야 합니다",
|
||||
"ja": "ファイル名は 1~200 文字で指定してください",
|
||||
"de": "Der Dateiname muss zwischen 1 und 200 Zeichen lang sein",
|
||||
"fr": "Le nom du fichier doit contenir entre 1 et 200 caractères",
|
||||
"id": "Nama file harus terdiri dari 1 hingga 200 karakter",
|
||||
"ru": "Длина имени файла должна составлять от 1 до 200 символов"
|
||||
},
|
||||
{
|
||||
"key": "文件大小超过限制",
|
||||
"zh": "",
|
||||
"zh-CHT": "檔案大小超過限制",
|
||||
"en": "The file size exceeds the limit",
|
||||
"ko": "파일 크기가 제한을 초과했습니다",
|
||||
"ja": "ファイルサイズが上限を超えています",
|
||||
"de": "Die Dateigröße überschreitet das Limit",
|
||||
"fr": "La taille du fichier dépasse la limite",
|
||||
"id": "Ukuran file melebihi batas",
|
||||
"ru": "Размер файла превышает ограничение"
|
||||
},
|
||||
{
|
||||
"key": "文件夹不能写入内容",
|
||||
"zh": "",
|
||||
"zh-CHT": "資料夾不能寫入內容",
|
||||
"en": "Content cannot be written to a folder",
|
||||
"ko": "폴더에 내용을 쓸 수 없습니다",
|
||||
"ja": "フォルダーに内容を書き込むことはできません",
|
||||
"de": "In einen Ordner können keine Inhalte geschrieben werden",
|
||||
"fr": "Impossible d’écrire du contenu dans un dossier",
|
||||
"id": "Konten tidak dapat ditulis ke folder",
|
||||
"ru": "Нельзя записать содержимое в папку"
|
||||
},
|
||||
{
|
||||
"key": "有效期超出允许范围",
|
||||
"zh": "",
|
||||
"zh-CHT": "有效期限超出允許範圍",
|
||||
"en": "The validity period is outside the allowed range",
|
||||
"ko": "유효 기간이 허용 범위를 벗어났습니다",
|
||||
"ja": "有効期間が許可範囲外です",
|
||||
"de": "Die Gültigkeitsdauer liegt außerhalb des zulässigen Bereichs",
|
||||
"fr": "La durée de validité est hors de la plage autorisée",
|
||||
"id": "Masa berlaku berada di luar rentang yang diizinkan",
|
||||
"ru": "Срок действия выходит за допустимый диапазон"
|
||||
},
|
||||
{
|
||||
"key": "目标不是文件夹",
|
||||
"zh": "",
|
||||
"zh-CHT": "目標不是資料夾",
|
||||
"en": "The destination is not a folder",
|
||||
"ko": "대상이 폴더가 아닙니다",
|
||||
"ja": "移動先はフォルダーではありません",
|
||||
"de": "Das Ziel ist kein Ordner",
|
||||
"fr": "La destination n’est pas un dossier",
|
||||
"id": "Tujuan bukan folder",
|
||||
"ru": "Назначение не является папкой"
|
||||
},
|
||||
{
|
||||
"key": "复制的文件和文件夹数量超过限制",
|
||||
"zh": "",
|
||||
"zh-CHT": "複製的檔案和資料夾數量超過限制",
|
||||
"en": "The number of files and folders being copied exceeds the limit",
|
||||
"ko": "복사할 파일과 폴더 수가 제한을 초과했습니다",
|
||||
"ja": "コピーするファイルとフォルダーの数が上限を超えています",
|
||||
"de": "Die Anzahl der zu kopierenden Dateien und Ordner überschreitet das Limit",
|
||||
"fr": "Le nombre de fichiers et de dossiers à copier dépasse la limite",
|
||||
"id": "Jumlah file dan folder yang disalin melebihi batas",
|
||||
"ru": "Количество копируемых файлов и папок превышает ограничение"
|
||||
},
|
||||
{
|
||||
"key": "发现(%T1)组同路径文件,处理后才能启用 WebDAV",
|
||||
"zh": "",
|
||||
"zh-CHT": "發現 (%T1) 組同路徑檔案,處理後才能啟用 WebDAV",
|
||||
"en": "Found (%T1) groups of files with conflicting paths. Resolve them before enabling WebDAV.",
|
||||
"ko": "경로가 충돌하는 파일 그룹 (%T1)개를 발견했습니다. WebDAV를 활성화하기 전에 해결하세요.",
|
||||
"ja": "同じパスのファイルが (%T1) グループ見つかりました。解決してから WebDAV を有効にしてください。",
|
||||
"de": "Es wurden (%T1) Gruppen von Dateien mit kollidierenden Pfaden gefunden. Beheben Sie diese, bevor Sie WebDAV aktivieren.",
|
||||
"fr": "(%T1) groupes de fichiers ayant des chemins en conflit ont été détectés. Corrigez-les avant d'activer WebDAV.",
|
||||
"id": "Ditemukan (%T1) grup file dengan jalur yang berkonflik. Selesaikan sebelum mengaktifkan WebDAV.",
|
||||
"ru": "Обнаружено групп файлов с конфликтующими путями: (%T1). Устраните конфликты перед включением WebDAV."
|
||||
},
|
||||
{
|
||||
"key": "查看冲突文件",
|
||||
"zh": "",
|
||||
"zh-CHT": "查看衝突檔案",
|
||||
"en": "View conflicting files",
|
||||
"ko": "충돌 파일 보기",
|
||||
"ja": "競合ファイルを表示",
|
||||
"de": "Konfliktdateien anzeigen",
|
||||
"fr": "Voir les fichiers en conflit",
|
||||
"id": "Lihat file konflik",
|
||||
"ru": "Просмотреть конфликтующие файлы"
|
||||
},
|
||||
{
|
||||
"key": "WebDAV 路径冲突",
|
||||
"zh": "",
|
||||
"zh-CHT": "WebDAV 路徑衝突",
|
||||
"en": "WebDAV path conflicts",
|
||||
"ko": "WebDAV 경로 충돌",
|
||||
"ja": "WebDAV パスの競合",
|
||||
"de": "WebDAV-Pfadkonflikte",
|
||||
"fr": "Conflits de chemins WebDAV",
|
||||
"id": "Konflik jalur WebDAV",
|
||||
"ru": "Конфликты путей WebDAV"
|
||||
},
|
||||
{
|
||||
"key": "同一拥有者的同一目录中存在完整名称相同的文件,WebDAV 无法确定应访问哪一条。",
|
||||
"zh": "",
|
||||
"zh-CHT": "同一擁有者的同一目錄中存在完整名稱相同的檔案,WebDAV 無法確定應存取哪一個。",
|
||||
"en": "Files with identical full names exist in the same folder for the same owner, so WebDAV cannot determine which one to access.",
|
||||
"ko": "동일한 소유자의 같은 폴더에 전체 이름이 같은 파일이 있어 WebDAV가 접근할 파일을 결정할 수 없습니다.",
|
||||
"ja": "同じ所有者の同じフォルダーに完全名が同一のファイルがあるため、WebDAV はアクセス対象を特定できません。",
|
||||
"de": "Im selben Ordner desselben Eigentümers befinden sich Dateien mit identischen vollständigen Namen. WebDAV kann daher nicht bestimmen, auf welche Datei zugegriffen werden soll.",
|
||||
"fr": "Des fichiers portant exactement le même nom se trouvent dans le même dossier pour le même propriétaire. WebDAV ne peut donc pas déterminer lequel utiliser.",
|
||||
"id": "Terdapat file dengan nama lengkap yang sama dalam folder dan pemilik yang sama, sehingga WebDAV tidak dapat menentukan file yang harus diakses.",
|
||||
"ru": "В одной папке одного владельца есть файлы с одинаковыми полными именами, поэтому WebDAV не может определить, к какому из них обращаться."
|
||||
},
|
||||
{
|
||||
"key": "请让对应拥有者在文件页面保留其中一条,并重命名或删除其余文件,然后刷新检测。",
|
||||
"zh": "",
|
||||
"zh-CHT": "請讓對應擁有者在檔案頁面保留其中一個,並重新命名或刪除其餘檔案,然後重新整理檢測。",
|
||||
"en": "Ask the owner to keep one file on the Files page and rename or delete the others, then refresh the check.",
|
||||
"ko": "해당 소유자가 파일 페이지에서 하나를 유지하고 나머지를 이름 변경하거나 삭제한 후 검사를 새로 고치도록 하세요.",
|
||||
"ja": "該当する所有者にファイルページで1件を残し、残りを名前変更または削除してもらった後、再検出してください。",
|
||||
"de": "Bitten Sie den jeweiligen Eigentümer, auf der Dateiseite eine Datei zu behalten und die übrigen umzubenennen oder zu löschen. Aktualisieren Sie anschließend die Prüfung.",
|
||||
"fr": "Demandez au propriétaire concerné de conserver un fichier sur la page Fichiers et de renommer ou supprimer les autres, puis actualisez la vérification.",
|
||||
"id": "Minta pemilik terkait menyimpan satu file di halaman File dan mengganti nama atau menghapus file lainnya, lalu segarkan pemeriksaan.",
|
||||
"ru": "Попросите соответствующего владельца оставить один файл на странице файлов, а остальные переименовать или удалить, затем обновите проверку."
|
||||
},
|
||||
{
|
||||
"key": "刷新检测",
|
||||
"zh": "",
|
||||
"zh-CHT": "重新整理檢測",
|
||||
"en": "Refresh check",
|
||||
"ko": "검사 새로 고침",
|
||||
"ja": "再検出",
|
||||
"de": "Prüfung aktualisieren",
|
||||
"fr": "Actualiser la vérification",
|
||||
"id": "Segarkan pemeriksaan",
|
||||
"ru": "Обновить проверку"
|
||||
},
|
||||
{
|
||||
"key": "拥有者",
|
||||
"zh": "",
|
||||
"zh-CHT": "擁有者",
|
||||
"en": "Owner",
|
||||
"ko": "소유자",
|
||||
"ja": "所有者",
|
||||
"de": "Eigentümer",
|
||||
"fr": "Propriétaire",
|
||||
"id": "Pemilik",
|
||||
"ru": "Владелец"
|
||||
},
|
||||
{
|
||||
"key": "冲突路径",
|
||||
"zh": "",
|
||||
"zh-CHT": "衝突路徑",
|
||||
"en": "Conflicting path",
|
||||
"ko": "충돌 경로",
|
||||
"ja": "競合パス",
|
||||
"de": "Konfliktpfad",
|
||||
"fr": "Chemin en conflit",
|
||||
"id": "Jalur konflik",
|
||||
"ru": "Конфликтующий путь"
|
||||
},
|
||||
{
|
||||
"key": "冲突记录",
|
||||
"zh": "",
|
||||
"zh-CHT": "衝突記錄",
|
||||
"en": "Conflicting records",
|
||||
"ko": "충돌 레코드",
|
||||
"ja": "競合レコード",
|
||||
"de": "Konfliktdatensätze",
|
||||
"fr": "Enregistrements en conflit",
|
||||
"id": "Catatan konflik",
|
||||
"ru": "Конфликтующие записи"
|
||||
},
|
||||
{
|
||||
"key": "打开位置",
|
||||
"zh": "",
|
||||
"zh-CHT": "開啟位置",
|
||||
"en": "Open location",
|
||||
"ko": "위치 열기",
|
||||
"ja": "場所を開く",
|
||||
"de": "Speicherort öffnen",
|
||||
"fr": "Ouvrir l'emplacement",
|
||||
"id": "Buka lokasi",
|
||||
"ru": "Открыть расположение"
|
||||
},
|
||||
{
|
||||
"key": "请输入新名称",
|
||||
"zh": "",
|
||||
"zh-CHT": "請輸入新名稱",
|
||||
"en": "Enter a new name",
|
||||
"ko": "새 이름을 입력하세요",
|
||||
"ja": "新しい名前を入力してください",
|
||||
"de": "Neuen Namen eingeben",
|
||||
"fr": "Saisissez un nouveau nom",
|
||||
"id": "Masukkan nama baru",
|
||||
"ru": "Введите новое имя"
|
||||
},
|
||||
{
|
||||
"key": "文件已不在冲突组中",
|
||||
"zh": "",
|
||||
"zh-CHT": "檔案已不在衝突群組中",
|
||||
"en": "The file is no longer in a conflict group",
|
||||
"ko": "파일이 더 이상 충돌 그룹에 없습니다",
|
||||
"ja": "ファイルは競合グループに含まれていません",
|
||||
"de": "Die Datei befindet sich nicht mehr in einer Konfliktgruppe",
|
||||
"fr": "Le fichier ne fait plus partie d'un groupe en conflit",
|
||||
"id": "File tidak lagi berada dalam grup konflik",
|
||||
"ru": "Файл больше не входит в конфликтующую группу"
|
||||
},
|
||||
{
|
||||
"key": "重命名其他成员的文件",
|
||||
"zh": "",
|
||||
"zh-CHT": "重新命名其他成員的檔案",
|
||||
"en": "Rename another member's file",
|
||||
"ko": "다른 구성원의 파일 이름 변경",
|
||||
"ja": "他のメンバーのファイル名を変更",
|
||||
"de": "Datei eines anderen Mitglieds umbenennen",
|
||||
"fr": "Renommer le fichier d'un autre membre",
|
||||
"id": "Ganti nama file anggota lain",
|
||||
"ru": "Переименовать файл другого участника"
|
||||
},
|
||||
{
|
||||
"key": "你正在重命名(%T1)的私人文件,此操作会直接修改文件名称。",
|
||||
"zh": "",
|
||||
"zh-CHT": "你正在重新命名 (%T1) 的私人檔案,此操作會直接修改檔案名稱。",
|
||||
"en": "You are renaming a private file owned by (%T1). This action will directly change the file name.",
|
||||
"ko": "(%T1)님의 비공개 파일 이름을 변경하려고 합니다. 이 작업은 파일 이름을 직접 수정합니다.",
|
||||
"ja": "(%T1) の非公開ファイル名を変更しようとしています。この操作によりファイル名が直接変更されます。",
|
||||
"de": "Sie benennen eine private Datei von (%T1) um. Dadurch wird der Dateiname direkt geändert.",
|
||||
"fr": "Vous renommez un fichier privé appartenant à (%T1). Cette action modifiera directement le nom du fichier.",
|
||||
"id": "Anda akan mengganti nama file pribadi milik (%T1). Tindakan ini akan langsung mengubah nama file.",
|
||||
"ru": "Вы переименовываете личный файл пользователя (%T1). Это действие напрямую изменит имя файла."
|
||||
}
|
||||
]
|
||||
]
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -1 +1 @@
|
||||
["","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","",""]
|
||||
["","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","","",""]
|
||||
2
public/language/web/de.js
vendored
2
public/language/web/de.js
vendored
File diff suppressed because one or more lines are too long
2
public/language/web/en.js
vendored
2
public/language/web/en.js
vendored
File diff suppressed because one or more lines are too long
2
public/language/web/fr.js
vendored
2
public/language/web/fr.js
vendored
File diff suppressed because one or more lines are too long
2
public/language/web/id.js
vendored
2
public/language/web/id.js
vendored
File diff suppressed because one or more lines are too long
2
public/language/web/ja.js
vendored
2
public/language/web/ja.js
vendored
File diff suppressed because one or more lines are too long
2
public/language/web/key.js
vendored
2
public/language/web/key.js
vendored
File diff suppressed because one or more lines are too long
2
public/language/web/ko.js
vendored
2
public/language/web/ko.js
vendored
File diff suppressed because one or more lines are too long
2
public/language/web/ru.js
vendored
2
public/language/web/ru.js
vendored
File diff suppressed because one or more lines are too long
2
public/language/web/zh-CHT.js
vendored
2
public/language/web/zh-CHT.js
vendored
File diff suppressed because one or more lines are too long
2
public/language/web/zh.js
vendored
2
public/language/web/zh.js
vendored
File diff suppressed because one or more lines are too long
@ -124,6 +124,7 @@ features:
|
||||
- file.search.howto
|
||||
- file.collaboration.howto
|
||||
- file.version.concept
|
||||
- file.webdav.howto
|
||||
|
||||
- id: application
|
||||
name: 应用中心(导航入口)
|
||||
@ -580,6 +581,7 @@ features:
|
||||
- system-setting.auto-archive.howto
|
||||
- system-setting.task-ai-analyze.howto
|
||||
- system-setting.file-upload-limit.howto
|
||||
- system-setting.webdav.howto
|
||||
- system-setting.video-process.howto
|
||||
- system-setting.task-visibility.howto
|
||||
- system-setting.chat-mute.howto
|
||||
|
||||
48
resources/ai-kb/zh/howto/file/webdav.md
Normal file
48
resources/ai-kb/zh/howto/file/webdav.md
Normal file
@ -0,0 +1,48 @@
|
||||
---
|
||||
id: file.webdav.howto
|
||||
title: 通过 WebDAV 访问我的文件和共享文件
|
||||
type: howto
|
||||
feature: file
|
||||
scope: end-user
|
||||
locale: zh
|
||||
aliases:
|
||||
- WebDAV 怎么连接
|
||||
- 挂载 DooTask 文件
|
||||
- WebDAV 应用密码
|
||||
- 用 Finder 访问文件
|
||||
- 用资源管理器访问文件
|
||||
related_tools: []
|
||||
related_pages: [file]
|
||||
prerequisites:
|
||||
- 管理员已启用 WebDAV,并允许当前用户使用
|
||||
- 使用 HTTPS 地址访问 DooTask
|
||||
negative:
|
||||
- 不能使用 DooTask 登录密码连接,必须单独创建应用密码
|
||||
- WebDAV 只提供「我的文件」和「共享文件」,不包含「协作文件」
|
||||
- 应用密码关闭创建结果后不能再次查看,只能撤销后重建
|
||||
last_verified: v1.8.89
|
||||
---
|
||||
|
||||
# 通过 WebDAV 访问文件
|
||||
|
||||
## 创建连接凭据
|
||||
1. 打开左侧栏「文件」,停留在「我的文件」或「共享文件」。
|
||||
2. 点击页面右上角加号右侧的圆形「···」,选择「WebDAV」。
|
||||
3. 点击「新建」,填写用于识别客户端的设备名称和有效期。
|
||||
4. 创建后立即保存弹窗中的连接地址、用户名和应用密码。应用密码只显示一次。
|
||||
|
||||
## 连接后的目录
|
||||
- `files/`:当前用户的「我的文件」,允许按现有文件权限新建、读取、修改、移动和删除。
|
||||
- `shared/`:别人共享给当前用户的文件。共享顶层名称带有 `[#文件ID]`,用于避免重名,不要手工删掉该标识。
|
||||
- 共享权限为「只读」时只能浏览和下载;权限为「读写」时可以保存修改,也可以在共享文件夹中创建内容。
|
||||
|
||||
在操作系统或 WebDAV 客户端中添加网络位置时,服务器地址、用户名和密码必须使用弹窗给出的三项。连接采用 HTTP Basic 应用密码认证,但服务端只允许通过 HTTPS 传输。
|
||||
|
||||
## 管理设备
|
||||
再次进入「文件」右上角「···」→「WebDAV」,可查看设备名称、凭据尾号、有效期和最近使用时间。点击凭据右侧的删除图标并确认即可撤销;撤销后,使用该密码的设备会立即认证失败,未过期锁也会被清理。
|
||||
|
||||
## 常见问题
|
||||
- 看不到入口或提示未启用:管理员尚未开启,或当前用户不在指定成员范围内。
|
||||
- 无法创建密码:确认当前页面是 HTTPS,并检查是否达到每人应用密码数量上限。
|
||||
- 密码忘记:应用密码不可找回,撤销旧密码并新建一个。
|
||||
- 文件保存失败:检查共享权限、文件是否被其他客户端锁定、名称是否合法以及文件大小限制。
|
||||
52
resources/ai-kb/zh/howto/system-setting/webdav.md
Normal file
52
resources/ai-kb/zh/howto/system-setting/webdav.md
Normal file
@ -0,0 +1,52 @@
|
||||
---
|
||||
id: system-setting.webdav.howto
|
||||
title: 管理员启用和配置 WebDAV
|
||||
type: howto
|
||||
feature: system-setting
|
||||
scope: admin
|
||||
locale: zh
|
||||
aliases:
|
||||
- 开启 WebDAV
|
||||
- WebDAV 权限设置
|
||||
- 限制 WebDAV 用户
|
||||
- WebDAV 密码上限
|
||||
- WebDAV 路径冲突
|
||||
related_tools: []
|
||||
related_pages: []
|
||||
prerequisites:
|
||||
- 需要系统管理员权限
|
||||
- 部署环境变量 SYSTEM_SETTING 不为 disabled
|
||||
- 对外访问地址已配置 HTTPS
|
||||
negative:
|
||||
- 不能为用户查看或恢复已创建的应用密码
|
||||
- 不能把「协作文件」通过 WebDAV 暴露
|
||||
- 存在有效文件路径冲突时不能启用 WebDAV
|
||||
last_verified: v1.8.89
|
||||
---
|
||||
|
||||
# 管理员启用和配置 WebDAV
|
||||
|
||||
## 入口
|
||||
桌面端:左上角头像 →「系统设置」→「文件」→「WebDAV」。
|
||||
|
||||
## 配置项
|
||||
- **启用 WebDAV**:全局协议开关。关闭后 `/dav/` 立即停止提供服务,已有应用密码不会被展示为登录密码。
|
||||
- **允许范围**:可选「所有人」或「指定成员」。被停用账号始终不能使用。
|
||||
- **每人应用密码上限**:1 至 20 个有效凭据。
|
||||
- **默认有效期**:用户新建凭据时的默认天数,不能超过最长有效期。
|
||||
- **最长有效期**:管理员允许用户选择的上限,最大 3650 天。
|
||||
|
||||
保存设置使用管理接口 `api/file/dav/adminsetting`。运行状态接口 `api/file/dav/adminstatus` 提供有效凭据数、活动锁数和路径冲突数;冲突明细接口 `api/file/dav/conflicts` 分页返回冲突路径、拥有者、文件 ID,以及当前管理员基于现有文件权限能否打开文件位置。
|
||||
|
||||
## 启用流程
|
||||
1. 确认 DooTask 的外部地址使用 HTTPS;生产环境的 DAV 协议入口拒绝明文 HTTP。
|
||||
2. 打开「文件」设置页并检查路径冲突提示。
|
||||
3. 如存在冲突,点击「查看冲突文件」,可复制完整冲突路径,并根据拥有者和文件 ID 找到记录。管理员可直接重命名任意冲突文件;重命名其他成员的私人文件前会二次确认并记录审计,但不会开放文件内容、移动或删除权限。管理员本人拥有目标文件,或目标文件已通过现有共享权限向管理员开放时,列表才显示「打开位置」,并自动定位到「我的文件」或「共享文件」;没有查看权限时不会显示该入口。保留其中一条并重命名或删除同组内其余文件后,点击「刷新检测」确认冲突清零。服务端会硬性拒绝在冲突未处理时开启。
|
||||
4. 选择允许范围与凭据限制,开启 WebDAV 后点击「提交」。
|
||||
5. 通知获准用户从「文件」页右上角「···」创建自己的应用密码,管理员不代替用户生成或查看密码。
|
||||
|
||||
## 安全和运维
|
||||
- 用户应用密码以不可逆哈希保存,仅创建时返回明文;撤销某个凭据后,其锁记录同步失效。
|
||||
- DAV 请求具有独立操作审计,记录请求 ID、用户、凭据、方法、路径、状态、流量、来源 IP 和耗时。
|
||||
- 用户访问被限制在 `files/` 与 `shared/` 两个虚拟根,实际读写仍逐项执行现有文件权限。
|
||||
- 单文件大小、锁超时和复制节点数还受服务端硬限制;提高界面配置不能突破部署侧限制。
|
||||
276
resources/assets/js/pages/manage/components/WebDavManager.vue
Normal file
276
resources/assets/js/pages/manage/components/WebDavManager.vue
Normal file
@ -0,0 +1,276 @@
|
||||
<template>
|
||||
<Modal
|
||||
v-model="visible"
|
||||
:title="$L('WebDAV')"
|
||||
:mask-closable="false"
|
||||
width="640">
|
||||
<div class="webdav-manager">
|
||||
<Spin v-if="loading > 0" fix/>
|
||||
|
||||
<Alert v-if="status && (!status.enabled || !status.allowed)" type="warning" show-icon>
|
||||
{{status.enabled ? $L('你没有 WebDAV 使用权限') : $L('WebDAV 未启用')}}
|
||||
</Alert>
|
||||
|
||||
<template v-else-if="status">
|
||||
<Alert v-if="!status.https" type="warning" show-icon>
|
||||
{{$L('当前页面不是 HTTPS,无法创建应用密码')}}
|
||||
</Alert>
|
||||
<div class="webdav-section">
|
||||
<div class="webdav-section-title">{{$L('连接地址')}}</div>
|
||||
<div class="webdav-copy-row">
|
||||
<Input :value="status.url" readonly/>
|
||||
<Button icon="ios-copy-outline" @click="copyText(status.url)">{{$L('复制')}}</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Alert v-if="created.password" type="success" show-icon class="webdav-secret">
|
||||
{{$L('应用密码已创建,请立即保存,关闭后无法再次查看。')}}
|
||||
<div slot="desc" class="webdav-secret-fields">
|
||||
<div>
|
||||
<span>{{$L('用户名')}}</span>
|
||||
<div class="webdav-copy-row">
|
||||
<Input :value="created.public_id" readonly/>
|
||||
<Button icon="ios-copy-outline" @click="copyText(created.public_id)">{{$L('复制')}}</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span>{{$L('应用密码')}}</span>
|
||||
<div class="webdav-copy-row">
|
||||
<Input :value="created.password" readonly/>
|
||||
<Button icon="ios-copy-outline" @click="copyText(created.password)">{{$L('复制')}}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Alert>
|
||||
|
||||
<div class="webdav-section">
|
||||
<div class="webdav-section-head">
|
||||
<div class="webdav-section-title">{{$L('应用密码')}}</div>
|
||||
<Button
|
||||
v-if="!createVisible"
|
||||
type="primary"
|
||||
icon="md-add"
|
||||
:disabled="!status.https || credentials.length >= status.max_credentials"
|
||||
@click="createVisible=true">
|
||||
{{$L('新建')}}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Form v-if="createVisible" class="webdav-create" @submit.native.prevent>
|
||||
<FormItem :label="$L('设备名称')">
|
||||
<Input v-model="createForm.name" :maxlength="100" :placeholder="$L('例如:办公室电脑')"/>
|
||||
</FormItem>
|
||||
<FormItem :label="$L('有效期')">
|
||||
<InputNumber
|
||||
v-model="createForm.expire_days"
|
||||
:min="1"
|
||||
:max="status.max_expire_days"/>
|
||||
<span class="webdav-days">{{$L('[day_unit].天')}}</span>
|
||||
</FormItem>
|
||||
<div class="webdav-create-actions">
|
||||
<Button @click="createVisible=false">{{$L('取消')}}</Button>
|
||||
<Button type="primary" :loading="creating" @click="createCredential">{{$L('创建')}}</Button>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
<div v-if="credentials.length" class="webdav-credentials">
|
||||
<div v-for="item in credentials" :key="item.id" class="webdav-credential">
|
||||
<div class="webdav-credential-main">
|
||||
<strong>{{item.name}}</strong>
|
||||
<span>{{item.public_id}} · ****{{item.password_suffix}}</span>
|
||||
<span>
|
||||
{{$L('有效期至')}}:{{item.expires_at || $L('永久')}}
|
||||
<template v-if="item.last_used_at"> · {{$L('最近使用')}}:{{item.last_used_at}}</template>
|
||||
</span>
|
||||
</div>
|
||||
<Tag :color="item.status === 'active' ? 'green' : 'default'">{{statusText(item.status)}}</Tag>
|
||||
<Button
|
||||
v-if="item.status === 'active'"
|
||||
type="text"
|
||||
icon="ios-trash-outline"
|
||||
class="webdav-revoke"
|
||||
@click="revokeCredential(item)"/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="!createVisible" class="webdav-empty">{{$L('暂无应用密码')}}</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
<div slot="footer">
|
||||
<Button @click="visible=false">{{$L('关闭')}}</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'WebDavManager',
|
||||
props: {
|
||||
value: Boolean,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
visible: false,
|
||||
loading: 0,
|
||||
creating: false,
|
||||
status: null,
|
||||
credentials: [],
|
||||
created: {},
|
||||
createVisible: false,
|
||||
createForm: {
|
||||
name: '',
|
||||
expire_days: 90,
|
||||
},
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
value: {
|
||||
immediate: true,
|
||||
handler(value) {
|
||||
this.visible = value;
|
||||
if (value) this.load();
|
||||
},
|
||||
},
|
||||
visible(value) {
|
||||
this.$emit('input', value);
|
||||
if (!value) {
|
||||
this.created = {};
|
||||
this.createVisible = false;
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
load() {
|
||||
this.loading++;
|
||||
Promise.all([
|
||||
this.$store.dispatch('call', {url: 'file/dav/status'}),
|
||||
this.$store.dispatch('call', {url: 'file/dav/credentials'}),
|
||||
]).then(([status, credentials]) => {
|
||||
this.status = status.data;
|
||||
this.credentials = credentials.data || [];
|
||||
this.createForm.expire_days = this.status.default_expire_days;
|
||||
}).catch(({msg}) => {
|
||||
$A.modalError(msg);
|
||||
}).finally(() => {
|
||||
this.loading--;
|
||||
});
|
||||
},
|
||||
createCredential() {
|
||||
if (!this.createForm.name.trim()) {
|
||||
$A.messageWarning('请输入设备名称');
|
||||
return;
|
||||
}
|
||||
this.creating = true;
|
||||
this.$store.dispatch('call', {
|
||||
url: 'file/dav/create',
|
||||
method: 'post',
|
||||
data: this.createForm,
|
||||
}).then(({data}) => {
|
||||
this.created = data;
|
||||
this.createVisible = false;
|
||||
this.createForm.name = '';
|
||||
return this.$store.dispatch('call', {url: 'file/dav/credentials'});
|
||||
}).then(({data}) => {
|
||||
this.credentials = data || [];
|
||||
}).catch(({msg}) => {
|
||||
$A.modalError(msg);
|
||||
}).finally(() => {
|
||||
this.creating = false;
|
||||
});
|
||||
},
|
||||
revokeCredential(item) {
|
||||
$A.modalConfirm({
|
||||
title: '撤销应用密码',
|
||||
content: '撤销后,使用此应用密码连接的设备将立即断开。',
|
||||
onOk: () => this.$store.dispatch('call', {
|
||||
url: 'file/dav/revoke',
|
||||
method: 'post',
|
||||
data: {id: item.id},
|
||||
}).then(() => this.load()),
|
||||
});
|
||||
},
|
||||
statusText(status) {
|
||||
if (status === 'active') return $L('[credential_status].有效');
|
||||
if (status === 'expired') return $L('已过期');
|
||||
if (status === 'revoked') return $L('已撤销');
|
||||
return status;
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.webdav-manager {
|
||||
min-height: 180px;
|
||||
position: relative;
|
||||
}
|
||||
.webdav-section + .webdav-section,
|
||||
.webdav-secret {
|
||||
margin-top: 20px;
|
||||
}
|
||||
.webdav-section-title {
|
||||
color: #17233d;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.webdav-section-head,
|
||||
.webdav-copy-row,
|
||||
.webdav-credential {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.webdav-section-head {
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.webdav-copy-row {
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.webdav-copy-row .ivu-input-wrapper {
|
||||
min-width: 0;
|
||||
}
|
||||
.webdav-secret-fields > div {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.webdav-create {
|
||||
padding: 14px 0;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
.webdav-days {
|
||||
margin-left: 8px;
|
||||
}
|
||||
.webdav-create-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
.webdav-credential {
|
||||
min-height: 70px;
|
||||
gap: 12px;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
.webdav-credential-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.webdav-credential-main strong,
|
||||
.webdav-credential-main span {
|
||||
display: block;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.webdav-credential-main span {
|
||||
color: #808695;
|
||||
font-size: 12px;
|
||||
margin-top: 3px;
|
||||
}
|
||||
.webdav-revoke {
|
||||
color: #ed4014;
|
||||
}
|
||||
.webdav-empty {
|
||||
color: #808695;
|
||||
padding: 24px 0;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@ -33,6 +33,12 @@
|
||||
<div v-if="board !== 'collaboration'" class="file-add">
|
||||
<Button shape="circle" icon="md-add" @click.stop="handleRightClick($event, null, true)"></Button>
|
||||
</div>
|
||||
<Dropdown v-if="board !== 'collaboration'" placement="bottom-end" trigger="click" transfer @on-click="webDavShow=true">
|
||||
<Button shape="circle" icon="ios-more"></Button>
|
||||
<DropdownMenu slot="list">
|
||||
<DropdownItem name="webdav">{{$L('WebDAV')}}</DropdownItem>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -481,6 +487,8 @@
|
||||
<FileContent v-else ref="fileContent" v-model="fileShow" :file="fileInfo"/>
|
||||
</DrawerOverlay>
|
||||
|
||||
<WebDavManager v-model="webDavShow"/>
|
||||
|
||||
<!--拖动上传提示-->
|
||||
<Modal
|
||||
v-model="pasteShow"
|
||||
@ -509,6 +517,7 @@ import UserSelect from "../../components/UserSelect.vue";
|
||||
import UserAvatarTip from "../../components/UserAvatar/tip.vue";
|
||||
import Forwarder from "./components/Forwarder/index.vue";
|
||||
import CollaborationFileList from "./components/CollaborationFileList.vue";
|
||||
import WebDavManager from "./components/WebDavManager.vue";
|
||||
import {chunkedUpload, CHUNK_THRESHOLD} from "../../store/chunkedUpload";
|
||||
|
||||
const FilePreview = () => import('./components/FilePreview');
|
||||
@ -516,10 +525,11 @@ const FileContent = () => import('./components/FileContent');
|
||||
const FileObject = {sort: null, mode: null, board: null};
|
||||
|
||||
export default {
|
||||
components: {CollaborationFileList, Forwarder, UserAvatarTip, UserSelect, FilePreview, DrawerOverlay, FileContent},
|
||||
components: {WebDavManager, CollaborationFileList, Forwarder, UserAvatarTip, UserSelect, FilePreview, DrawerOverlay, FileContent},
|
||||
directives: {longpress},
|
||||
data() {
|
||||
return {
|
||||
webDavShow: false,
|
||||
packList: [],
|
||||
packShow: false,
|
||||
|
||||
@ -641,7 +651,9 @@ export default {
|
||||
async beforeRouteEnter(to, from, next) {
|
||||
FileObject.sort = await $A.IDBJson("cacheFileSort")
|
||||
FileObject.mode = await $A.IDBString("fileTableMode")
|
||||
FileObject.board = await $A.IDBString("fileBoard")
|
||||
FileObject.board = ['mine', 'shared'].includes(to.query.board)
|
||||
? to.query.board
|
||||
: await $A.IDBString("fileBoard")
|
||||
next()
|
||||
},
|
||||
|
||||
@ -843,6 +855,9 @@ export default {
|
||||
},
|
||||
|
||||
activated() {
|
||||
if (['mine', 'shared'].includes(this.$route.query.board)) {
|
||||
this.board = this.$route.query.board;
|
||||
}
|
||||
this.getFileList();
|
||||
},
|
||||
|
||||
|
||||
@ -27,11 +27,72 @@
|
||||
</FormItem>
|
||||
</div>
|
||||
</div>
|
||||
<div class="block-setting-box">
|
||||
<h3>{{$L('WebDAV')}}</h3>
|
||||
<div class="form-box">
|
||||
<Alert v-if="webDavStatus.path_conflicts > 0" type="error">
|
||||
{{$L('发现(*)组同路径文件,处理后才能启用 WebDAV', webDavStatus.path_conflicts)}}
|
||||
<Button slot="desc" @click="openWebDavConflicts" style="margin-top:8px;">{{$L('查看冲突文件')}}</Button>
|
||||
</Alert>
|
||||
<FormItem :label="$L('启用 WebDAV')">
|
||||
<i-switch
|
||||
v-model="formData.webdav_enabled"
|
||||
true-value="open"
|
||||
false-value="close"
|
||||
:disabled="webDavStatus.path_conflicts > 0 && formData.webdav_enabled !== 'open'"/>
|
||||
</FormItem>
|
||||
<template v-if="formData.webdav_enabled === 'open'">
|
||||
<FormItem :label="$L('允许范围')">
|
||||
<RadioGroup v-model="formData.webdav_permission_type">
|
||||
<Radio label="all">{{$L('所有人')}}</Radio>
|
||||
<Radio label="appoint">{{$L('指定成员')}}</Radio>
|
||||
</RadioGroup>
|
||||
</FormItem>
|
||||
<FormItem v-if="formData.webdav_permission_type === 'appoint'" :label="$L('指定人员')">
|
||||
<UserSelect
|
||||
v-model="formData.webdav_permission_userids"
|
||||
:multiple-max="200"
|
||||
avatar-name
|
||||
show-disable
|
||||
:title="$L('请选择指定人员')"/>
|
||||
</FormItem>
|
||||
<FormItem :label="$L('每人应用密码上限')">
|
||||
<InputNumber v-model="formData.webdav_max_credentials" :min="1" :max="20"/>
|
||||
</FormItem>
|
||||
<FormItem :label="$L('默认有效期')">
|
||||
<InputNumber v-model="formData.webdav_default_expire_days" :min="1" :max="365"/>
|
||||
<span style="margin-left:8px">{{$L('[day_unit].天')}}</span>
|
||||
</FormItem>
|
||||
<FormItem :label="$L('最长有效期')">
|
||||
<InputNumber v-model="formData.webdav_max_expire_days" :min="1" :max="3650"/>
|
||||
<span style="margin-left:8px">{{$L('[day_unit].天')}}</span>
|
||||
</FormItem>
|
||||
<div class="form-tip">{{$L('用户可在文件页面右上角的更多菜单中管理 WebDAV 应用密码')}}</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
<div class="setting-footer">
|
||||
<Button :loading="loadIng > 0" type="primary" @click="submitForm">{{ $L('提交') }}</Button>
|
||||
<Button :loading="loadIng > 0" @click="resetForm">{{ $L('重置') }}</Button>
|
||||
</div>
|
||||
<Modal v-model="conflictShow" :title="$L('WebDAV 路径冲突')" width="800" class-name="webdav-conflict-modal" footer-hide>
|
||||
<Alert type="warning">
|
||||
{{$L('同一拥有者的同一目录中存在完整名称相同的文件,WebDAV 无法确定应访问哪一条。')}}
|
||||
<div slot="desc">{{$L('请让对应拥有者在文件页面保留其中一条,并重命名或删除其余文件,然后刷新检测。')}}</div>
|
||||
</Alert>
|
||||
<div class="webdav-conflict-actions">
|
||||
<Button icon="md-refresh" :loading="conflictLoading > 0" @click="loadWebDavConflicts(conflictPage)">{{$L('刷新检测')}}</Button>
|
||||
</div>
|
||||
<Table class="webdav-conflict-table" :columns="conflictColumns" :data="conflictList" :loading="conflictLoading > 0"/>
|
||||
<Page
|
||||
v-if="conflictTotal > conflictPageSize"
|
||||
:total="conflictTotal"
|
||||
:current="conflictPage"
|
||||
:page-size="conflictPageSize"
|
||||
show-total
|
||||
@on-change="loadWebDavConflicts"/>
|
||||
</Modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@ -45,6 +106,67 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
loadIng: 0,
|
||||
webDavStatus: {},
|
||||
conflictShow: false,
|
||||
conflictLoading: 0,
|
||||
conflictList: [],
|
||||
conflictPage: 1,
|
||||
conflictPageSize: 20,
|
||||
conflictTotal: 0,
|
||||
conflictColumns: [
|
||||
{
|
||||
title: this.$L('拥有者'),
|
||||
minWidth: 120,
|
||||
maxWidth: 190,
|
||||
render: (h, {row}) => h('div', {class: 'webdav-conflict-owner'}, [
|
||||
h('div', {attrs: {title: row.owner.nickname || '-'}}, row.owner.nickname || '-'),
|
||||
h('AutoTip', {
|
||||
class: 'webdav-conflict-owner-detail',
|
||||
}, `${row.owner.email || '-'} (ID: ${row.owner.userid})`),
|
||||
]),
|
||||
},
|
||||
{
|
||||
title: this.$L('冲突路径'),
|
||||
minWidth: 200,
|
||||
render: (h, {row}) => h('div', {class: 'webdav-conflict-path-row'}, [
|
||||
h('AutoTip', {
|
||||
class: 'webdav-conflict-path',
|
||||
}, row.path),
|
||||
h('Tooltip', {props: {content: this.$L('复制'), placement: 'top', transfer: true}}, [
|
||||
h('button', {
|
||||
class: 'webdav-conflict-path-copy',
|
||||
attrs: {type: 'button'},
|
||||
on: {click: () => this.copyText(row.path)},
|
||||
}, [h('Icon', {props: {type: 'md-copy', size: 15}})]),
|
||||
]),
|
||||
]),
|
||||
},
|
||||
{
|
||||
title: this.$L('冲突记录'),
|
||||
minWidth: 180,
|
||||
render: (h, {row}) => h('div', row.files.map(item => h('div', {class: 'webdav-conflict-record'}, [
|
||||
h('AutoTip', {
|
||||
class: 'webdav-conflict-record-name',
|
||||
}, `${item.full_name} (ID: ${item.id})`),
|
||||
h('span', {class: 'webdav-conflict-record-actions'}, [
|
||||
h('Tooltip', {props: {content: this.$L('重命名'), placement: 'top', transfer: true}}, [
|
||||
h('button', {
|
||||
class: 'webdav-conflict-icon-button',
|
||||
attrs: {type: 'button'},
|
||||
on: {click: () => this.confirmRenameConflictFile(row, item)},
|
||||
}, [h('Icon', {props: {type: 'md-create', size: 15}})]),
|
||||
]),
|
||||
item.can_open_location ? h('Tooltip', {props: {content: this.$L('打开位置'), placement: 'top', transfer: true}}, [
|
||||
h('button', {
|
||||
class: 'webdav-conflict-icon-button',
|
||||
attrs: {type: 'button'},
|
||||
on: {click: () => this.openConflictFile(row, item)},
|
||||
}, [h('Icon', {props: {type: 'md-folder-open', size: 15}})]),
|
||||
]) : null,
|
||||
]),
|
||||
]))),
|
||||
},
|
||||
],
|
||||
formData: {
|
||||
|
||||
},
|
||||
@ -57,7 +179,7 @@ export default {
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapState(['formOptions']),
|
||||
...mapState(['formOptions', 'userId']),
|
||||
},
|
||||
|
||||
methods: {
|
||||
@ -73,16 +195,109 @@ export default {
|
||||
this.formData = $A.cloneJSON(this.formDatum_bak);
|
||||
},
|
||||
|
||||
openWebDavConflicts() {
|
||||
this.conflictShow = true;
|
||||
this.loadWebDavConflicts(1);
|
||||
},
|
||||
|
||||
loadWebDavConflicts(page = 1) {
|
||||
this.conflictLoading++;
|
||||
this.$store.dispatch("call", {
|
||||
url: `file/dav/conflicts?page=${page}&pagesize=${this.conflictPageSize}`,
|
||||
}).then(({data}) => {
|
||||
this.conflictPage = data.current_page || 1;
|
||||
this.conflictTotal = data.total || 0;
|
||||
this.conflictList = data.data || [];
|
||||
this.webDavStatus.path_conflicts = this.conflictTotal;
|
||||
}).finally(_ => {
|
||||
this.conflictLoading--;
|
||||
});
|
||||
},
|
||||
|
||||
confirmRenameConflictFile(row, item) {
|
||||
if (row.owner.userid === this.userId) {
|
||||
this.renameConflictFile(item);
|
||||
return;
|
||||
}
|
||||
$A.modalConfirm({
|
||||
language: false,
|
||||
title: this.$L('重命名其他成员的文件'),
|
||||
content: this.$L('你正在重命名(*)的私人文件,此操作会直接修改文件名称。', row.owner.nickname || row.owner.email || row.owner.userid),
|
||||
onOk: () => $A.modalInput({
|
||||
title: this.$L('重命名'),
|
||||
placeholder: this.$L('请输入新名称'),
|
||||
value: item.full_name,
|
||||
onOk: value => this.submitConflictRename(item, value),
|
||||
}, 300),
|
||||
});
|
||||
},
|
||||
|
||||
renameConflictFile(item) {
|
||||
$A.modalInput({
|
||||
title: this.$L('重命名'),
|
||||
placeholder: this.$L('请输入新名称'),
|
||||
value: item.full_name,
|
||||
onOk: value => this.submitConflictRename(item, value),
|
||||
});
|
||||
},
|
||||
|
||||
submitConflictRename(item, value) {
|
||||
const name = (value || '').trim();
|
||||
if (!name) {
|
||||
return this.$L('请输入新名称');
|
||||
}
|
||||
if (name === item.full_name) {
|
||||
return false;
|
||||
}
|
||||
return this.$store.dispatch("call", {
|
||||
url: 'file/dav/conflictrename',
|
||||
method: 'post',
|
||||
data: {id: item.id, name},
|
||||
}).then(({msg}) => {
|
||||
this.loadWebDavConflicts(this.conflictPage);
|
||||
return msg;
|
||||
}).catch(({msg}) => {
|
||||
return Promise.reject(msg);
|
||||
});
|
||||
},
|
||||
|
||||
openConflictFile(row, item) {
|
||||
this.conflictShow = false;
|
||||
this.$store.dispatch('filePos', {
|
||||
folderId: item.location_parent_id || null,
|
||||
fileId: null,
|
||||
shakeId: item.id,
|
||||
board: item.location_board,
|
||||
});
|
||||
},
|
||||
|
||||
systemSetting(save) {
|
||||
this.loadIng++;
|
||||
this.$store.dispatch("call", {
|
||||
const fileRequest = () => this.$store.dispatch("call", {
|
||||
url: 'system/setting/file?type=' + (save ? 'save' : 'all'),
|
||||
data: this.formData,
|
||||
}).then(({data}) => {
|
||||
});
|
||||
const webDavRequest = () => this.$store.dispatch("call", {
|
||||
url: 'file/dav/adminsetting?type=' + (save ? 'save' : 'all'),
|
||||
data: this.formData,
|
||||
method: save ? 'post' : 'get',
|
||||
});
|
||||
const webDavStatusRequest = () => this.$store.dispatch("call", {
|
||||
url: 'file/dav/adminstatus',
|
||||
});
|
||||
const request = save
|
||||
? fileRequest().then(fileSetting => webDavRequest().then(webDavSetting => Promise.all([
|
||||
Promise.resolve(fileSetting),
|
||||
Promise.resolve(webDavSetting),
|
||||
webDavStatusRequest(),
|
||||
])))
|
||||
: Promise.all([fileRequest(), webDavRequest(), webDavStatusRequest()]);
|
||||
request.then(([fileSetting, webDavSetting, webDavStatus]) => {
|
||||
if (save) {
|
||||
$A.messageSuccess('修改成功');
|
||||
}
|
||||
this.formData = data;
|
||||
this.formData = Object.assign({}, fileSetting.data, webDavSetting.data);
|
||||
this.webDavStatus = webDavStatus.data || {};
|
||||
this.formDatum_bak = $A.cloneJSON(this.formData);
|
||||
}).catch(({msg}) => {
|
||||
if (save) {
|
||||
@ -95,3 +310,124 @@ export default {
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.webdav-conflict-modal .webdav-conflict-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin: 16px 0 12px;
|
||||
}
|
||||
|
||||
.webdav-conflict-modal .webdav-conflict-table {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.webdav-conflict-modal .webdav-conflict-owner {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.webdav-conflict-modal .webdav-conflict-owner > div,
|
||||
.webdav-conflict-modal .webdav-conflict-owner-detail,
|
||||
.webdav-conflict-modal .webdav-conflict-path {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.webdav-conflict-modal .webdav-conflict-owner-detail {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.webdav-conflict-modal .webdav-conflict-path-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.webdav-conflict-modal .webdav-conflict-path {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.webdav-conflict-modal .webdav-conflict-path-row > .ivu-tooltip {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.webdav-conflict-modal .webdav-conflict-path-row:hover > .ivu-tooltip,
|
||||
.webdav-conflict-modal .webdav-conflict-path-row > .ivu-tooltip:focus-within {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.webdav-conflict-modal .webdav-conflict-path-copy {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
color: #515a6e;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.webdav-conflict-modal .webdav-conflict-path-copy:hover {
|
||||
color: #2d8cf0;
|
||||
background: #f0f5ff;
|
||||
}
|
||||
|
||||
.webdav-conflict-modal .webdav-conflict-record {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.webdav-conflict-modal .webdav-conflict-record-name {
|
||||
display: block;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.webdav-conflict-modal .webdav-conflict-record-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: none;
|
||||
gap: 4px;
|
||||
.ivu-tooltip,
|
||||
.ivu-tooltip-rel {
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.webdav-conflict-modal .webdav-conflict-icon-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
color: #515a6e;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.webdav-conflict-modal .webdav-conflict-icon-button:hover {
|
||||
color: #2d8cf0;
|
||||
background: #f0f5ff;
|
||||
}
|
||||
|
||||
.webdav-conflict-modal .webdav-conflict-table th,
|
||||
.webdav-conflict-modal .webdav-conflict-table td {
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
7
resources/assets/js/store/actions.js
vendored
7
resources/assets/js/store/actions.js
vendored
@ -524,7 +524,12 @@ export default {
|
||||
// 如果 当前不是消息页面 或 是竖屏 则关闭对话窗口
|
||||
dispatch("openDialog", 0);
|
||||
}
|
||||
$A.goForward({name: 'manage-file', params: data});
|
||||
const {board, ...params} = data;
|
||||
$A.goForward({
|
||||
name: 'manage-file',
|
||||
params,
|
||||
query: ['mine', 'shared'].includes(board) ? {board} : undefined,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
> 此文件由 `php artisan doc:api-map` 生成,勿手改。
|
||||
|
||||
接口总数:316
|
||||
接口总数:325
|
||||
|
||||
## 路由规则
|
||||
|
||||
@ -298,6 +298,20 @@ API 使用动态路由(见 `routes/web.php`),URL 段映射为控制器方
|
||||
| api/dialog/session/open | session__open() | get | AI-打开会话 |
|
||||
| api/dialog/session/rename | session__rename() | post | AI-重命名会话 |
|
||||
|
||||
## file/dav(FileDavController)
|
||||
|
||||
| URL | 方法名 | HTTP | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| api/file/dav/status | dav__status() | get | 获取 WebDAV 状态 |
|
||||
| api/file/dav/credentials | dav__credentials() | get | 获取 WebDAV 凭据 |
|
||||
| api/file/dav/create | dav__create() | post | 创建 WebDAV 应用密码 |
|
||||
| api/file/dav/revoke | dav__revoke() | post | 撤销 WebDAV 应用密码 |
|
||||
| api/file/dav/adminsetting | dav__adminsetting() | get | 获取或保存 WebDAV 设置 |
|
||||
| api/file/dav/adminstatus | dav__adminstatus() | get | 获取 WebDAV 运行状态 |
|
||||
| api/file/dav/conflicts | dav__conflicts() | get | 获取 WebDAV 路径冲突明细 |
|
||||
| api/file/dav/conflictrename | dav__conflictrename() | post | 管理员重命名 WebDAV 冲突文件 |
|
||||
| api/file/dav/userrevoke | dav__userrevoke() | post | 撤销用户全部 WebDAV 应用密码 |
|
||||
|
||||
## file(FileController)
|
||||
|
||||
| URL | 方法名 | HTTP | 说明 |
|
||||
|
||||
@ -2,8 +2,10 @@
|
||||
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use App\Http\Controllers\IndexController;
|
||||
use App\Http\Controllers\WebDavProtocolController;
|
||||
use App\Http\Controllers\Api\TestController;
|
||||
use App\Http\Controllers\Api\FileController;
|
||||
use App\Http\Controllers\Api\FileDavController;
|
||||
use App\Http\Controllers\Api\UsersController;
|
||||
use App\Http\Controllers\Api\DialogController;
|
||||
use App\Http\Controllers\Api\PublicController;
|
||||
@ -53,6 +55,7 @@ Route::prefix('api')->middleware(['webapi'])->group(function () {
|
||||
Route::any('dialog/{method}', DialogController::class);
|
||||
Route::any('dialog/{method}/{action}', DialogController::class);
|
||||
// 文件
|
||||
Route::any('file/dav/{action}', FileDavController::class)->defaults('method', 'dav');
|
||||
Route::any('file/{method}', FileController::class);
|
||||
Route::any('file/{method}/{action}', FileController::class);
|
||||
// 分片上传
|
||||
@ -81,6 +84,14 @@ Route::prefix('api')->middleware(['webapi'])->group(function () {
|
||||
Route::any('test/{method}/{action}', TestController::class);
|
||||
});
|
||||
|
||||
/**
|
||||
* WebDAV 协议入口(必须位于页面兜底路由之前)
|
||||
*/
|
||||
Route::match([
|
||||
'OPTIONS', 'PROPFIND', 'PROPPATCH', 'HEAD', 'GET', 'PUT',
|
||||
'MKCOL', 'COPY', 'MOVE', 'DELETE', 'LOCK', 'UNLOCK',
|
||||
], 'dav/{path?}', WebDavProtocolController::class)->where('path', '.*');
|
||||
|
||||
/**
|
||||
* 页面
|
||||
*/
|
||||
|
||||
253
tests/Feature/WebDavContractTest.php
Normal file
253
tests/Feature/WebDavContractTest.php
Normal file
@ -0,0 +1,253 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Http\Controllers\Api\FileDavController;
|
||||
use App\Http\Controllers\WebDavProtocolController;
|
||||
use App\Models\File;
|
||||
use App\Models\FileUser;
|
||||
use App\Models\User;
|
||||
use App\Models\WebDavCredential;
|
||||
use App\Models\WebDavOperationLog;
|
||||
use App\Services\WebDav\WebDavConfig;
|
||||
use App\Services\WebDav\WebDavConflictService;
|
||||
use App\Services\WebDav\WebDavExceptionMapper;
|
||||
use App\Services\WebDav\WebDavServerFactory;
|
||||
use App\Services\FileSystem\FileSystemService;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Foundation\Testing\DatabaseTransactions;
|
||||
use Sabre\DAV\Exception\Conflict;
|
||||
use Sabre\DAV\Exception\Forbidden;
|
||||
use Sabre\DAV\Exception\NotFound;
|
||||
use Sabre\HTTP\Response as SabreResponse;
|
||||
use Tests\TestCase;
|
||||
|
||||
class WebDavContractTest extends TestCase
|
||||
{
|
||||
use DatabaseTransactions;
|
||||
|
||||
public function test_management_route_maps_action_to_dav_controller_method(): void
|
||||
{
|
||||
$route = app('router')->getRoutes()->match(Request::create('/api/file/dav/status', 'GET'));
|
||||
|
||||
$this->assertSame(FileDavController::class, $route->getActionName());
|
||||
$this->assertSame('dav', $route->parameter('method'));
|
||||
$this->assertSame('status', $route->parameter('action'));
|
||||
|
||||
$response = app('router')->dispatch(Request::create('/api/file/dav/missing', 'GET'));
|
||||
$this->assertStringContainsString('404 not found (dav/missing)', $response->getContent());
|
||||
}
|
||||
|
||||
public function test_protocol_route_accepts_webdav_methods_and_captures_path(): void
|
||||
{
|
||||
foreach (['OPTIONS', 'PROPFIND', 'PROPPATCH', 'HEAD', 'GET', 'PUT', 'MKCOL', 'COPY', 'MOVE', 'DELETE', 'LOCK', 'UNLOCK'] as $method) {
|
||||
$route = app('router')->getRoutes()->match(Request::create('/dav/files/folder/a.txt', $method));
|
||||
$this->assertSame(WebDavProtocolController::class, $route->getActionName());
|
||||
$this->assertSame('files/folder/a.txt', $route->parameter('path'));
|
||||
}
|
||||
}
|
||||
|
||||
public function test_admin_configuration_is_bounded_and_consistent(): void
|
||||
{
|
||||
$setting = WebDavConfig::normalizeAdminInput([
|
||||
'webdav_enabled' => 'open',
|
||||
'webdav_permission_type' => 'appoint',
|
||||
'webdav_permission_userids' => ['2', 2, 0, -1],
|
||||
'webdav_max_credentials' => 100,
|
||||
'webdav_default_expire_days' => 90,
|
||||
'webdav_max_expire_days' => 30,
|
||||
'webdav_max_file_bytes' => PHP_INT_MAX,
|
||||
'webdav_copy_max_nodes' => 0,
|
||||
'webdav_audit_retention_days' => 1,
|
||||
]);
|
||||
|
||||
$this->assertSame('open', $setting['webdav_enabled']);
|
||||
$this->assertSame('appoint', $setting['webdav_permission_type']);
|
||||
$this->assertSame([2], $setting['webdav_permission_userids']);
|
||||
$this->assertSame(20, $setting['webdav_max_credentials']);
|
||||
$this->assertSame(30, $setting['webdav_default_expire_days']);
|
||||
$this->assertSame(30, $setting['webdav_max_expire_days']);
|
||||
$this->assertSame(config('dootask.webdav.max_file_bytes'), $setting['webdav_max_file_bytes']);
|
||||
$this->assertSame(1, $setting['webdav_copy_max_nodes']);
|
||||
$this->assertSame(7, $setting['webdav_audit_retention_days']);
|
||||
}
|
||||
|
||||
public function test_path_conflicts_include_owner_path_and_file_ids(): void
|
||||
{
|
||||
$user = User::query()->firstOrFail();
|
||||
$folder = File::createInstance([
|
||||
'pid' => 0,
|
||||
'pids' => '',
|
||||
'userid' => $user->userid,
|
||||
'name' => 'WebDAV conflict folder',
|
||||
'ext' => '',
|
||||
'type' => 'folder',
|
||||
]);
|
||||
$folder->save();
|
||||
$files = collect([1, 2])->map(function () use ($folder, $user) {
|
||||
$file = File::createInstance([
|
||||
'pid' => $folder->id,
|
||||
'pids' => ",{$folder->id},",
|
||||
'userid' => $user->userid,
|
||||
'name' => 'duplicate',
|
||||
'ext' => 'txt',
|
||||
'type' => 'file',
|
||||
]);
|
||||
$file->save();
|
||||
return $file;
|
||||
});
|
||||
|
||||
$result = WebDavConfig::pathConflicts(1, 100, $user);
|
||||
$conflict = collect($result['data'])->firstWhere('path', '/WebDAV conflict folder/duplicate.txt');
|
||||
|
||||
$this->assertNotNull($conflict);
|
||||
$this->assertSame(intval($user->userid), $conflict['owner']['userid']);
|
||||
$this->assertSame(intval($folder->id), $conflict['parent_id']);
|
||||
$this->assertSame($files->pluck('id')->map('intval')->all(), array_column($conflict['files'], 'id'));
|
||||
$this->assertSame('duplicate.txt', $conflict['files'][0]['full_name']);
|
||||
$this->assertTrue($conflict['files'][0]['can_open_location']);
|
||||
$this->assertSame('mine', $conflict['files'][0]['location_board']);
|
||||
$this->assertSame(intval($folder->id), $conflict['files'][0]['location_parent_id']);
|
||||
|
||||
(new FileSystemService())->rename($user, $files->first(), 'renamed.txt');
|
||||
$updated = WebDavConfig::pathConflicts(1, 100);
|
||||
$this->assertNull(collect($updated['data'])->firstWhere('path', '/WebDAV conflict folder/duplicate.txt'));
|
||||
}
|
||||
|
||||
public function test_path_conflicts_only_expose_locations_allowed_by_existing_file_permissions(): void
|
||||
{
|
||||
$owner = User::query()->firstOrFail();
|
||||
$admin = User::createInstance([
|
||||
'userid' => intval($owner->userid) + 1000001,
|
||||
'identity' => ',admin,',
|
||||
]);
|
||||
$admin->save();
|
||||
|
||||
$sharedFiles = collect([1, 2])->map(function () use ($owner, $admin) {
|
||||
$file = File::createInstance([
|
||||
'pid' => 0,
|
||||
'pids' => '',
|
||||
'userid' => $owner->userid,
|
||||
'name' => 'shared-conflict',
|
||||
'ext' => 'txt',
|
||||
'type' => 'file',
|
||||
]);
|
||||
$file->save();
|
||||
$file->share = 1;
|
||||
$file->pshare = $file->id;
|
||||
$file->save();
|
||||
FileUser::createInstance([
|
||||
'file_id' => $file->id,
|
||||
'userid' => $admin->userid,
|
||||
'permission' => 0,
|
||||
])->save();
|
||||
return $file;
|
||||
});
|
||||
collect([1, 2])->each(function () use ($owner) {
|
||||
File::createInstance([
|
||||
'pid' => 0,
|
||||
'pids' => '',
|
||||
'userid' => $owner->userid,
|
||||
'name' => 'private-conflict',
|
||||
'ext' => 'txt',
|
||||
'type' => 'file',
|
||||
])->save();
|
||||
});
|
||||
|
||||
$result = WebDavConfig::pathConflicts(1, 100, $admin);
|
||||
$shared = collect($result['data'])->firstWhere('path', '/shared-conflict.txt');
|
||||
$private = collect($result['data'])->firstWhere('path', '/private-conflict.txt');
|
||||
|
||||
$this->assertNotNull($shared);
|
||||
$this->assertSame($sharedFiles->pluck('id')->map('intval')->all(), array_column($shared['files'], 'id'));
|
||||
$this->assertTrue($shared['files'][0]['can_open_location']);
|
||||
$this->assertSame('shared', $shared['files'][0]['location_board']);
|
||||
$this->assertSame(0, $shared['files'][0]['location_parent_id']);
|
||||
$this->assertNotNull($private);
|
||||
$this->assertFalse($private['files'][0]['can_open_location']);
|
||||
$this->assertNull($private['files'][0]['location_board']);
|
||||
$this->assertNull($private['files'][0]['location_parent_id']);
|
||||
}
|
||||
|
||||
public function test_file_errors_map_to_webdav_status_categories(): void
|
||||
{
|
||||
$this->assertInstanceOf(NotFound::class, WebDavExceptionMapper::map(new \App\Exceptions\ApiException('文件不存在')));
|
||||
$this->assertInstanceOf(Conflict::class, WebDavExceptionMapper::map(new \App\Exceptions\ApiException('文件已存在')));
|
||||
$this->assertSame(413, WebDavExceptionMapper::map(new \App\Exceptions\ApiException('文件大小超过限制'))->getHTTPCode());
|
||||
$this->assertInstanceOf(Forbidden::class, WebDavExceptionMapper::map(new \App\Exceptions\ApiException('没有修改写入权限')));
|
||||
}
|
||||
|
||||
public function test_admin_can_rename_another_users_conflicting_file_with_audit_log(): void
|
||||
{
|
||||
$owner = User::query()->firstOrFail();
|
||||
$admin = User::createInstance([
|
||||
'userid' => intval($owner->userid) + 1000000,
|
||||
'identity' => ',admin,',
|
||||
]);
|
||||
$files = collect([1, 2])->map(function () use ($owner) {
|
||||
$file = File::createInstance([
|
||||
'pid' => 0,
|
||||
'pids' => '',
|
||||
'userid' => $owner->userid,
|
||||
'name' => 'admin-conflict',
|
||||
'ext' => 'txt',
|
||||
'type' => 'file',
|
||||
]);
|
||||
$file->save();
|
||||
return $file;
|
||||
});
|
||||
|
||||
$renamed = (new WebDavConflictService())->renameAsAdmin(
|
||||
$admin,
|
||||
intval($files->first()->id),
|
||||
'admin-renamed.txt',
|
||||
'test-request',
|
||||
'127.0.0.1',
|
||||
'phpunit'
|
||||
);
|
||||
|
||||
$this->assertSame('admin-renamed.txt', $renamed->getNameAndExt());
|
||||
$this->assertSame(intval($owner->userid), intval($renamed->userid));
|
||||
$this->assertSame(1, File::whereName('admin-conflict')->whereExt('txt')->count());
|
||||
$this->assertDatabaseHas('webdav_operation_logs', [
|
||||
'userid' => $admin->userid,
|
||||
'method' => 'ADMIN_RENAME',
|
||||
'file_id' => $renamed->id,
|
||||
'status' => 200,
|
||||
]);
|
||||
$log = WebDavOperationLog::whereFileId($renamed->id)->whereMethod('ADMIN_RENAME')->latest('id')->firstOrFail();
|
||||
$this->assertStringContainsString('admin-conflict.txt -> admin-renamed.txt', (string) $log->result);
|
||||
}
|
||||
|
||||
public function test_server_factory_registers_required_plugins(): void
|
||||
{
|
||||
$user = User::createInstance(['userid' => 123]);
|
||||
$credential = WebDavCredential::createInstance(['id' => 456]);
|
||||
$server = (new WebDavServerFactory())->make($user, $credential);
|
||||
|
||||
$this->assertNotNull($server->getPlugin('locks'));
|
||||
$this->assertNotNull($server->getPlugin('property-storage'));
|
||||
$this->assertNotNull($server->getPlugin('dootask-move'));
|
||||
$this->assertNotNull($server->getPlugin('dootask-copy-guard'));
|
||||
$this->assertFalse(\Sabre\DAV\Server::$exposeVersion);
|
||||
}
|
||||
|
||||
public function test_generated_api_map_contains_webdav_management_routes(): void
|
||||
{
|
||||
$map = file_get_contents(base_path('routes/api-map.md'));
|
||||
|
||||
$this->assertStringContainsString('api/file/dav/status', $map);
|
||||
$this->assertStringContainsString('dav__status()', $map);
|
||||
$this->assertStringContainsString('api/file/dav/adminsetting', $map);
|
||||
}
|
||||
|
||||
public function test_protocol_bridge_handles_sabre_null_body_as_empty_response(): void
|
||||
{
|
||||
$controller = new WebDavProtocolController();
|
||||
$method = new \ReflectionMethod($controller, 'toSymfonyResponse');
|
||||
$response = $method->invoke($controller, new SabreResponse(201));
|
||||
|
||||
$this->assertSame(201, $response->getStatusCode());
|
||||
$this->assertSame('', $response->getContent());
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user