diff --git a/app/controllers/general.php b/app/controllers/general.php
index 6764cbae40..18dc81a102 100644
--- a/app/controllers/general.php
+++ b/app/controllers/general.php
@@ -11,6 +11,7 @@ use Appwrite\Event\Usage;
use Appwrite\Extend\Exception as AppwriteException;
use Appwrite\Network\Validator\Origin;
use Appwrite\Platform\Appwrite;
+use Appwrite\Platform\Modules\Sites\Transformers\Preview;
use Appwrite\SDK\AuthType;
use Appwrite\SDK\ContentType;
use Appwrite\SDK\Method;
@@ -130,7 +131,7 @@ function router(App $utopia, Database $dbForPlatform, callable $getProjectDB, Sw
};
}
- if ($type === 'function' || $type === 'site') {
+ if ($type === 'function' || $type === 'site' || $type === 'deployment') {
$method = $utopia->getRoute()?->getLabel('sdk', null);
if (empty($method)) {
@@ -446,6 +447,21 @@ function router(App $utopia, Database $dbForPlatform, callable $getProjectDB, Sw
requestTimeout: 30
);
+
+ // Branded banner for previews
+ if ($type === 'deployment' && Preview::isValid($executionResponse['headers'])) {
+ $transformer = new Preview($executionResponse['body']);
+ $transformer->transform();
+ $executionResponse['body'] = $transformer->getBody();
+
+ $execution->setAttribute('responseBody', $body);
+ foreach ($executionResponse['headers'] as $key => $value) {
+ if (\strtolower($key) === 'content-length') {
+ $executionResponse['headers'][$key] = \strlen($executionResponse['body']);
+ }
+ }
+ }
+
$headersFiltered = [];
foreach ($executionResponse['headers'] as $key => $value) {
if (\in_array(\strtolower($key), FUNCTION_ALLOWLIST_HEADERS_RESPONSE)) {
diff --git a/app/http.php b/app/http.php
index 608ac2ec12..611e373ffa 100644
--- a/app/http.php
+++ b/app/http.php
@@ -35,6 +35,8 @@ $domains = new Table(1_000_000); // 1 million rows
$domains->column('value', Table::TYPE_INT, 1);
$domains->create();
+Files::load(__DIR__ . '/../public');
+
$http = new Server(
host: "0.0.0.0",
port: System::getEnv('PORT', 80),
diff --git a/public/scripts/preview.js b/public/scripts/preview.js
new file mode 100644
index 0000000000..66eac6153d
--- /dev/null
+++ b/public/scripts/preview.js
@@ -0,0 +1,129 @@
+var banner = document.createElement('button');
+banner.id = 'appwrite-preview';
+banner.innerHTML = `
+
Preview by
+
+
+
+
+
+
+`;
+banner.addEventListener("click", function() {
+ banner.style.opacity = 0;
+ setTimeout(() => {
+ banner.style.display = 'none';
+ }, 350);
+});
+
+var css = document.createElement('style');
+css.innerHTML += `
+#appwrite-preview {
+ padding: 0;
+ margin: 0;
+ position: absolute;
+ right: 16px;
+ bottom: 16px;
+ z-index: 1;
+ border-radius: var(--border-radius-S, 8px);
+ border: var(--border-width-S, 1px) solid var(--color-border-neutral, #EDEDF0);
+ background: var(--color-bgColor-neutral-primary, #FFF);
+ box-shadow: 0px 1px 3px 0px rgba(0, 0, 0, 0.03), 0px 4px 4px 0px rgba(0, 0, 0, 0.04);
+ padding: var(--space-3, 6px) var(--space-4, 8px);
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ gap: var(--gap-XXS, 4px);
+ cursor: pointer;
+
+ transition: opacity 0.3s;
+}
+
+#appwrite-preview-close {
+ position: absolute;
+ right: 0px;
+ bottom: 0px;
+ border-radius: var(--border-radius-S, 8px);
+ background: linear-gradient(270deg, #FFF 69.64%, rgba(255, 255, 255, 0.00) 114.29%);
+ height: 100%;
+ aspect-ratio: 1 / 1;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ opacity: 0;
+ transition: opacity 0.3s;
+}
+
+#appwrite-preview-logo-dark {
+ display: none;
+}
+
+#appwrite-preview:hover #appwrite-preview-close {
+ opacity: 1;
+}
+
+#appwrite-preview-text {
+ padding: 0;
+ margin: 0;
+ color: var(--color-fgColor-neutral-secondary, #56565C);
+ font-family: var(--font-family-sansSerif, Inter);
+ font-size: var(--font-size-XS, 12px);
+ font-style: normal;
+ font-weight: 500;
+ line-height: 130%;
+ letter-spacing: -0.12px;
+}
+
+@media (prefers-color-scheme: dark) {
+ #appwrite-preview {
+ border: var(--border-width-S, 1px) solid var(--color-border-neutral, #2D2D31);
+ background: var(--color-bgColor-neutral-primary, #1D1D21);
+ box-shadow: 0px 1px 3px 0px rgba(0, 0, 0, 0.03), 0px 4px 4px 0px rgba(0, 0, 0, 0.04);
+ }
+
+ #appwrite-preview-text {
+ color: var(--color-fgColor-neutral-secondary, #C3C3C6);
+ font-family: var(--font-family-sansSerif, Inter);
+ font-size: var(--font-size-XS, 12px);
+ }
+
+ #appwrite-preview-logo-dark {
+ display: block;
+ }
+
+ #appwrite-preview-logo-light {
+ display: none;
+ }
+}
+`;
+
+document.head.appendChild(css);
+document.body.appendChild(banner);
\ No newline at end of file
diff --git a/src/Appwrite/Platform/Modules/Sites/Transformers/Preview.php b/src/Appwrite/Platform/Modules/Sites/Transformers/Preview.php
new file mode 100644
index 0000000000..365ce70d18
--- /dev/null
+++ b/src/Appwrite/Platform/Modules/Sites/Transformers/Preview.php
@@ -0,0 +1,58 @@
+ $headers
+ */
+ public static function isValid(array $headers): bool
+ {
+ $contentType = '';
+
+ foreach ($headers as $key => $value) {
+ if (\strtolower($key) === 'content-type') {
+ $contentType = $value;
+ break;
+ }
+ }
+
+ if (\str_contains($contentType, 'text/html')) {
+ return true;
+ }
+
+ return false;
+ }
+
+ public function transform(): bool
+ {
+ $protocol = System::getEnv('_APP_OPTIONS_FORCE_HTTPS') == 'disabled' ? 'http' : 'https';
+ $hostname = System::getEnv('_APP_DOMAIN');
+
+ // TODO: Temporary fix for development
+ if (App::isDevelopment()) {
+ $hostname = 'localhost';
+ }
+
+ $source = "{$protocol}://{$hostname}/scripts/preview.js";
+
+ $this->body .= '';
+
+ return true;
+ }
+
+ public function getBody(): string
+ {
+ return $this->body;
+ }
+}
diff --git a/tests/e2e/Services/Sites/SitesBase.php b/tests/e2e/Services/Sites/SitesBase.php
index 277064ead5..88805317fd 100644
--- a/tests/e2e/Services/Sites/SitesBase.php
+++ b/tests/e2e/Services/Sites/SitesBase.php
@@ -238,4 +238,27 @@ trait SitesBase
return $domain;
}
+
+
+ protected function getDeploymentDomain(string $deploymentId): string
+ {
+ $rules = $this->client->call(Client::METHOD_GET, '/proxy/rules', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ], $this->getHeaders()), [
+ 'queries' => [
+ Query::equal('resourceId', [$deploymentId])->toString(),
+ Query::equal('resourceType', ['deployment'])->toString(),
+ ],
+ ]);
+
+ $this->assertEquals(200, $rules['headers']['status-code']);
+ $this->assertGreaterThanOrEqual(1, $rules['body']['total']);
+ $this->assertGreaterThanOrEqual(1, \count($rules['body']['rules']));
+ $this->assertNotEmpty($rules['body']['rules'][0]['domain']);
+
+ $domain = $rules['body']['rules'][0]['domain'];
+
+ return $domain;
+ }
}
diff --git a/tests/e2e/Services/Sites/SitesCustomServerTest.php b/tests/e2e/Services/Sites/SitesCustomServerTest.php
index ff3aa7b348..e471651ef6 100644
--- a/tests/e2e/Services/Sites/SitesCustomServerTest.php
+++ b/tests/e2e/Services/Sites/SitesCustomServerTest.php
@@ -1088,5 +1088,64 @@ class SitesCustomServerTest extends Scope
$this->cleanupSite($site['body']['$id']);
}
+ public function testSitePreviewBranding(): void
+ {
+ $siteId = $this->setupSite([
+ 'siteId' => ID::unique(),
+ 'name' => 'A site',
+ 'framework' => 'other',
+ 'adapter' => 'static',
+ 'buildRuntime' => 'static-1',
+ 'outputDirectory' => './',
+ 'buildCommand' => '',
+ 'installCommand' => '',
+ 'fallbackFile' => '',
+ ]);
+
+ $this->assertNotEmpty($siteId);
+
+ $deploymentId = $this->setupDeployment($siteId, [
+ 'code' => $this->packageSite('static'),
+ 'activate' => 'true'
+ ]);
+
+ $this->assertNotEmpty($deploymentId);
+
+ $domain = $this->getSiteDomain($siteId);
+ $previewDomain = $this->getDeploymentDomain($deploymentId);
+
+ $this->assertNotEmpty($domain);
+ $this->assertNotEmpty($previewDomain);
+
+ $proxyClient = new Client();
+ $proxyClient->setEndpoint('http://' . $domain);
+
+ $response = $proxyClient->call(Client::METHOD_GET, '/', array_merge([
+ 'content-type' => 'application/json',
+ 'x-appwrite-project' => $this->getProject()['$id'],
+ ]));
+
+ $this->assertEquals(200, $response['headers']['status-code']);
+ $this->assertStringContainsString("Hello Appwrite", $response['body']);
+ $this->assertStringNotContainsString("