Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .env
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ _APP_CUSTOM_DOMAIN_DENY_LIST=
_APP_DNS=172.16.238.100
_APP_OPTIONS_FORCE_HTTPS=disabled
_APP_OPTIONS_ROUTER_FORCE_HTTPS=disabled
_APP_OPTIONS_ROUTER_PROTECTION=disabled
_APP_OPTIONS_ROUTER_PROTECTION=enabled
_APP_OPTIONS_ABUSE=disabled
_APP_OPTIONS_ABUSE_INCREASED_LIMIT_PROJECTS=increased-limit-project
_APP_CONSOLE_DOMAIN=localhost
Expand Down
2 changes: 1 addition & 1 deletion app/config/collections/platform.php
Original file line number Diff line number Diff line change
Expand Up @@ -1482,7 +1482,7 @@
'filters' => [],
],
[
'$id' => ID::custom('type'), // 'api', 'redirect', 'deployment' (site or function)
'$id' => ID::custom('type'), // 'api', 'redirect', 'deployment' (site or function), 'bucket'
'type' => Database::VAR_STRING,
'format' => '',
'size' => 32,
Expand Down
100 changes: 100 additions & 0 deletions app/controllers/general.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Storage\ObjectKey;
use Appwrite\Transformation\Adapter\Preview;
use Appwrite\Transformation\Transformation;
use Appwrite\Usage\Context;
Expand Down Expand Up @@ -66,6 +67,7 @@
use Utopia\Database\Helpers\ID;
use Utopia\Database\Query;
use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\UID;
use Utopia\Domains\Domain;
use Utopia\DSN\DSN;
use Utopia\Http\Http;
Expand Down Expand Up @@ -805,6 +807,43 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S
}

return true;
} elseif ($type === 'bucket') {
// The domain serves files of one bucket only. Keys were already
// resolved onto the file view route before routing.
$bucketId = $rule->getAttribute('deploymentResourceId', '');
if (\str_starts_with($request->getURI(), '/v1/storage/buckets/' . $bucketId . '/files/')) {
// Act as API for the file routes of the bucket
return false;
}

// Re-resolve to say why the path was not served.
$path = \parse_url($request->getURI(), PHP_URL_PATH);
$key = \ltrim(\rawurldecode(\is_string($path) ? $path : ''), '/');
$dbForProject = $getProjectDB($project);
$bucket = $authorization->skip(fn () => $dbForProject->getDocument('buckets', $bucketId));

$addressesFile = false;
if ($key !== '' && !\str_starts_with($key, 'v1/') && !$bucket->isEmpty()) {
try {
ObjectKey::parse($key);
$addressesFile = true;
} catch (AppwriteException) {
// Not a key at all: a folder path, or characters a key cannot
// hold. Reported as a miss rather than as a malformed request.
}
}

if ($addressesFile) {
try {
ObjectKey::find($dbForProject, $bucket, $key);
} catch (AppwriteException $err) {
// The key parsed, so only an ambiguous match reaches this. That
// is worth naming: the fix is to rename one of the files.
throw new AppwriteException($err->getType(), $err->getMessage(), view: $errorView);
}
}

throw new AppwriteException(AppwriteException::STORAGE_FILE_NOT_FOUND, 'No file of this bucket matches this URL. Files are served by their key, for example /report.pdf, or by their ID.', view: $errorView);
} elseif ($type === 'api') {
return false;
} elseif ($type === 'redirect') {
Expand Down Expand Up @@ -879,6 +918,67 @@ function router(Http $utopia, Database $dbForPlatform, callable $getProjectDB, S
}
});

/**
* Bucket domains serve files at the root of the domain by their key, the
* folder and name that S3-style clients address them with, for example
* https://files.example.com/photos/2026/pink.png. A file whose name is not a
* usable URL is still reachable by its ID. Rewrite such paths onto the file
* view route before routing, so the regular API action with its hooks,
* permissions, usage, and audits handles the request.
*
* This runs before the route is matched, which is why it cannot live in the
* router. It stays side effect free: a path it cannot resolve is left alone
* for the router to refuse, where throwing is handled by the error hooks.
*/
Http::onRequest()
->inject('utopia')
->inject('request')
->inject('ruleForHost')
->action(function (Http $utopia, Request $request, Document $ruleForHost) {
if ($ruleForHost->getAttribute('type', '') !== 'bucket') {
return;
}

// Paths under /v1 belong to the API router, which serves this
// bucket's own file routes and refuses the rest.
$path = \parse_url($request->getURI(), PHP_URL_PATH);
$key = \ltrim(\rawurldecode(\is_string($path) ? $path : ''), '/');

if ($key === '' || \str_starts_with($key, 'v1/')) {
return;
}

$bucketId = $ruleForHost->getAttribute('deploymentResourceId', '');

try {
// Resolved from the container rather than injected: injecting the
// project database would open one for every request on every domain.
$dbForProject = $utopia->context()->get('dbForProject');
$bucket = $dbForProject->getAuthorization()->skip(fn () => $dbForProject->getDocument('buckets', $bucketId));

if ($bucket->isEmpty()) {
return;
}

$fileId = ObjectKey::find($dbForProject, $bucket, $key)?->getId();
} catch (\Throwable) {
// Nothing is reported from here. A request hook that throws still
// falls through to the matched route, so failures are left to the
// router, which refuses the request and explains why.
return;
}

if ($fileId === null) {
// Fall back to addressing by file ID.
if (\str_contains($key, '/') || !(new UID())->isValid($key)) {
return;
}
$fileId = $key;
}

$request->setURI('/v1/storage/buckets/' . $bucketId . '/files/' . $fileId . '/view');
});

Http::init()
->groups(['api', 'web'])
->inject('utopia')
Expand Down
39 changes: 37 additions & 2 deletions app/init/resources/request.php
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,35 @@
return '.' . $hostname;
}, ['request', 'project']);

/**
* Rule associated with the request host.
*/
$context->set('ruleForHost', function (Request $request, Database $dbForPlatform, array $platform, Authorization $authorization) {
$hostname = $request->getHostname();

// Same override as previewHostname, minus the API key variant, so this
// resolves before routing and without touching the project.
if (Http::isDevelopment()) {
$override = $request->getQuery('appwrite-hostname', $request->getHeaderLine('x-appwrite-hostname', ''));
if (\is_string($override) && $override !== '') {
$hostname = $override;
}
}

if (empty($hostname) || \in_array($hostname, $platform['hostnames'] ?? [])) {
return new Document();
}

// TODO: (@Meldiron) Remove after 1.7.x migration
if (System::getEnv('_APP_RULES_FORMAT') === 'md5') {
return $authorization->skip(fn () => $dbForPlatform->getDocument('rules', md5($hostname)));
}

return $authorization->skip(fn () => $dbForPlatform->findOne('rules', [
Query::equal('domain', [$hostname]),
]));
}, ['request', 'dbForPlatform', 'platform', 'authorization']);

/**
* Rule associated with a request origin.
*/
Expand Down Expand Up @@ -605,7 +634,7 @@
return $match->params['projectId'] ?? '';
}, ['request', 'utopia']);

$context->set('project', function ($dbForPlatform, $request, $console, $authorization, Http $utopia, string $projectIdFromPath) {
$context->set('project', function ($dbForPlatform, $request, $console, $authorization, Http $utopia, string $projectIdFromPath, Document $ruleForHost) {
/** @var Appwrite\Utopia\Request $request */
/** @var Utopia\Database\Database $dbForPlatform */
/** @var Utopia\Database\Document $console */
Expand Down Expand Up @@ -653,14 +682,20 @@
}
}

// Bucket rules bind their project to the domain, so a file can be
// embedded as https://files.example.com/{fileId} without the project header.
if ($projectId === '' && $ruleForHost->getAttribute('type', '') === 'bucket') {
$projectId = $ruleForHost->getAttribute('projectId', '');
}

if ($projectId === '' || $projectId === 'console') {
return $console;
}

$project = $authorization->skip(fn () => $dbForPlatform->getDocument('projects', $projectId));

return $project;
}, ['dbForPlatform', 'request', 'console', 'authorization', 'utopia', 'projectIdFromPath']);
}, ['dbForPlatform', 'request', 'console', 'authorization', 'utopia', 'projectIdFromPath', 'ruleForHost']);

$context->set('session', function (User $user, Store $store, Token $proofForToken) {
if ($user->isEmpty()) {
Expand Down
2 changes: 1 addition & 1 deletion docs/services/proxy.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
The Proxy service allows you to configure behavior for your attached domains. You can use proxy service to create rules that define what is returned on specific domains and subdomains.

Proxy Rules can be configured to serve Appwrite API, which allows you to comply with first-party cookies, making your website more secure. It can also be configured to serve Appwrite Function, which lets you accept incoming webhooks, build custom API endpoints, and serve static files.
Proxy Rules can be configured to serve Appwrite API, which allows you to comply with first-party cookies, making your website more secure. It can also be configured to serve Appwrite Function, which lets you accept incoming webhooks, build custom API endpoints, and serve static files. Rules can also serve files of a Storage bucket, so shared files live on your own domain instead of an API URL.
2 changes: 1 addition & 1 deletion src/Appwrite/Platform/Modules/Proxy/Action.php
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,7 @@ protected function verifyRule(Document $rule): void
}
$targetCNAMEs[] = new Domain($targetCNAME);
}
} elseif ($resourceType === 'function' || $ruleType === 'api') {
} elseif ($resourceType === 'function' || $ruleType === 'api' || $ruleType === 'bucket') {
// For example: fra.cloud.appwrite.io
$targetCNAMEs[] = new Domain(System::getEnv('_APP_DOMAIN_TARGET_CNAME', ''));
} elseif ($ruleType === 'redirect') {
Expand Down
167 changes: 167 additions & 0 deletions src/Appwrite/Platform/Modules/Proxy/Http/Rules/Bucket/Create.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
<?php

namespace Appwrite\Platform\Modules\Proxy\Http\Rules\Bucket;

use Appwrite\Event\Event;
use Appwrite\Event\Publisher\Certificate;
use Appwrite\Extend\Exception;
use Appwrite\Platform\Modules\Proxy\Action;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\Method;
use Appwrite\SDK\Response as SDKResponse;
use Appwrite\Utopia\Response;
use Utopia\Bus\Bus;
use Utopia\Database\Database;
use Utopia\Database\Document;
use Utopia\Database\Helpers\ID;
use Utopia\Database\Validator\Authorization;
use Utopia\Database\Validator\UID;
use Utopia\Platform\Scope\HTTP;
use Utopia\System\System;
use Utopia\Validator\Domain as ValidatorDomain;

class Create extends Action
{
use HTTP;

public static function getName()
{
return 'createBucketRule';
}

public function __construct(...$params)
{
parent::__construct(...$params);

$this
->setHttpMethod(Action::HTTP_REQUEST_METHOD_POST)
->setHttpPath('/v1/proxy/rules/bucket')
->groups(['api', 'proxy'])
->desc('Create bucket rule')
->label('scope', 'rules.write')
->label('event', 'rules.[ruleId].create')
->label('audits.event', 'rule.create')
->label('audits.resource', 'rule/{response.$id}')
->label('sdk', new Method(
namespace: 'proxy',
group: 'rules',
name: 'createBucketRule',
description: <<<EOT
Create a new proxy rule for serving files of a storage bucket on custom domain. Files are served at the root of the domain by their key, the folder and name they were uploaded with, for example https://files.example.com/photos/2026/pink.png. A file whose name is not a usable URL can be addressed by its ID instead. A key matching more than one file is refused. Bucket and file permissions apply the same way as on the file view endpoint, so only files readable by guests are public. Use file tokens to share other files.

Rule ID is automatically generated as MD5 hash of a rule domain for performance purposes.
EOT,
auth: [AuthType::ADMIN, AuthType::KEY],
responses: [
new SDKResponse(
code: Response::STATUS_CODE_CREATED,
model: Response::MODEL_PROXY_RULE,
)
]
))
->label('abuse-limit', 10)
->label('abuse-key', 'userId:{userId}, url:{url}')
->label('abuse-time', 60)
->param('domain', null, new ValidatorDomain(), 'Domain name.')
->param('bucketId', '', fn (Database $dbForProject) => new UID($dbForProject->getAdapter()->getMaxUIDLength()), 'ID of bucket to serve files from.', false, ['dbForProject'])
->inject('response')
->inject('project')
->inject('publisherForCertificates')
->inject('queueForEvents')
->inject('dbForPlatform')
->inject('dbForProject')
->inject('platform')
->inject('authorization')
->inject('bus')
->callback($this->action(...));
}

public function action(
string $domain,
string $bucketId,
Response $response,
Document $project,
Certificate $publisherForCertificates,
Event $queueForEvents,
Database $dbForPlatform,
Database $dbForProject,
array $platform,
Authorization $authorization,
Bus $bus,
) {

// DNS is case-insensitive, and the rule ID below is derived from the
// lowercased domain. Store the same canonical form so the row matches
// its own ID and downstream certificate providers.
$domain = \strtolower($domain);

$this->validateDomainRestrictions($domain, $platform);

$bucket = $dbForProject->getDocument('buckets', $bucketId);
if ($bucket->isEmpty()) {
throw new Exception(Exception::RULE_RESOURCE_NOT_FOUND);
}

// TODO: (@Meldiron) Remove after 1.7.x migration
$ruleId = System::getEnv('_APP_RULES_FORMAT') === 'md5' ? md5(\strtolower($domain)) : ID::unique();
$status = RULE_STATUS_CREATED;
$owner = '';

if ($this->isAppwriteOwned($domain)) {
$status = RULE_STATUS_VERIFIED;
$owner = 'Appwrite';
}

$rule = new Document([
'$id' => $ruleId,
'projectId' => $project->getId(),
'projectInternalId' => $project->getSequence(),
'domain' => $domain,
'status' => $status,
'type' => 'bucket',
'trigger' => 'manual',
'deploymentResourceType' => 'bucket',
'deploymentResourceId' => $bucket->getId(),
'deploymentResourceInternalId' => $bucket->getSequence(),
'certificateId' => '',
'search' => implode(' ', [$ruleId, $domain]),
'owner' => $owner,
'region' => $project->getAttribute('region')
]);

if ($rule->getAttribute('status', '') === RULE_STATUS_CREATED) {
try {
$this->verifyRule($rule);
$rule->setAttribute('status', RULE_STATUS_CERTIFICATE_GENERATING);
} catch (Exception $err) {
$rule->setAttribute('logs', $err->getMessage());
}
}

$rule = $this->createRule($rule, $dbForPlatform, $authorization, $bus);

if ($rule->getAttribute('status', '') === RULE_STATUS_CERTIFICATE_GENERATING) {
$publisherForCertificates->enqueue(new \Appwrite\Event\Message\Certificate(
project: $project,
domain: new Document([
'domain' => $rule->getAttribute('domain'),
'domainType' => $rule->getAttribute('deploymentResourceType', $rule->getAttribute('type')),
]),
action: \Appwrite\Event\Certificate::ACTION_GENERATION,
));
}

$queueForEvents->setParam('ruleId', $rule->getId());

// Rename 'created' status to 'unverified' for consistency.
// 'verifying' and 'verified' statuses stay as is.
// 'unverified' in the meaning of failed certificate generation stays as is.
if ($rule->getAttribute('status') === 'created') {
$rule->setAttribute('status', 'unverified');
}

$response
->setStatusCode(Response::STATUS_CODE_CREATED)
->dynamic($rule, Response::MODEL_PROXY_RULE);
}
}
Loading
Loading