Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
4c0a169
feat: custom user avatar upload and delete
imtia33 Sep 28, 2026
edb6b0d
refactor: rename avatars collection to photos and simplify photo actions
Meldiron Sep 29, 2026
f65874b
fix: serialise photo changes per user and harden uploads
Meldiron Sep 29, 2026
8bd99bf
refactor: resolve the user's photo through a subquery
Meldiron Sep 29, 2026
ff15868
refactor: make the user photo subquery a single document
Meldiron Sep 29, 2026
aab4e5a
refactor: accept photos as a single request of at most 5MB
Meldiron Sep 29, 2026
cdee57f
fix(executions): narrow listings to the requested createdAt window be…
levivannoort Sep 29, 2026
1874807
refactor: store the user photo on the user document
Meldiron Sep 29, 2026
0163de4
refactor: keep the custom photo provider a plain adapter
Meldiron Sep 29, 2026
255e46c
refactor: build photo paths inline from a folder constant
Meldiron Sep 29, 2026
5e03bc4
test(executions): cover createdAt windows through the API
levivannoort Sep 29, 2026
a05bd3b
fix: lock photo changes per user and return the account
Meldiron Sep 29, 2026
2d63016
test(executions): wait for both executions to be stored before listin…
levivannoort Sep 29, 2026
d38173e
fix: keep a live photo when the lock fails after the update
Meldiron Sep 29, 2026
91f39ae
refactor: drop the per-user photo lock
Meldiron Sep 29, 2026
1a279f1
test(executions): describe the executions when the window count is off
levivannoort Sep 29, 2026
cebf10c
test(executions): compare createdAt values in one format
levivannoort Sep 29, 2026
d3cd0db
Merge pull request #13982 from appwrite/fix/execution-store-createdat…
levivannoort Sep 29, 2026
66242cf
feat: add current param to deleteSessions endpoint
jaysomani Sep 29, 2026
c69b5d3
fix: send a boundary with the empty photo upload test
Meldiron Sep 29, 2026
8fe5de2
fix: only change the photo the request read
Meldiron Sep 29, 2026
eb740be
fix: cap photo update retries on conflicts
Meldiron Sep 29, 2026
2067f0b
review comments
jaysomani Sep 29, 2026
60c9743
Merge pull request #13949 from imtia33/feat/custom-avatar
Meldiron Sep 29, 2026
acb4a47
Merge pull request #13990 from jaysomani/feat/delete-sessions-current…
Meldiron Sep 29, 2026
a4bbe5c
test(users): user JWTs only authenticate in the project that minted them
loks0n Sep 29, 2026
fb39f39
fix(auth): bind user JWTs to their project
loks0n Sep 29, 2026
d3acf4b
fix(console): resolve the organization header on organization routes …
Meldiron Sep 29, 2026
c3cd75f
fix(console): key the organization header on the organization group
Meldiron Sep 29, 2026
96da5a3
Merge pull request #13992 from appwrite/fix/jwt-project-binding
loks0n Sep 29, 2026
06de6e4
Merge pull request #13993 from appwrite/fix/console-organization-header
Meldiron Sep 29, 2026
393255e
fix(avatars): validate remote URLs with a public URL param validator
eldadfux Sep 29, 2026
9d13bab
fix(deployments): validate x-appwrite-id to prevent path traversal
ChiragAgg5k Sep 29, 2026
6bfa0d9
Merge pull request #13996 from ChiragAgg5k/fix/deployment-id-path-tra…
ChiragAgg5k Sep 29, 2026
6ed53a3
fix(avatars): force the browser container onto public DNS
eldadfux Sep 29, 2026
bce8ac2
Merge pull request #13995 from appwrite/fix-avatars-public-url
eldadfux Sep 29, 2026
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
22 changes: 22 additions & 0 deletions app/config/collections/common.php
Original file line number Diff line number Diff line change
Expand Up @@ -441,6 +441,28 @@
'default' => false,
'array' => false,
],
[
'$id' => ID::custom('photoId'),
'type' => Database::VAR_STRING,
'format' => '',
'size' => Database::LENGTH_KEY,
'signed' => true,
'required' => false,
'default' => null,
'array' => false,
'filters' => [],
],
[
'$id' => ID::custom('photoSize'),
'type' => Database::VAR_INTEGER,
'format' => '',
'size' => 8,
'signed' => true,
'required' => false,
'default' => 0,
'array' => false,
'filters' => [],
],
],
'indexes' => [
[
Expand Down
3 changes: 3 additions & 0 deletions app/config/events.php
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,9 @@
'impersonator' => [
'$description' => 'This event triggers when a user\'s impersonator capability is updated.',
],
'avatar' => [
'$description' => 'This event triggers when a user\'s photo is updated.',
],
]
],
'databases' => [
Expand Down
2 changes: 2 additions & 0 deletions app/config/roles.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
'projects.read',
'locale.read',
'avatars.read',
'avatars.write',
'executions.read',
'executions.write',
'targets.read',
Expand Down Expand Up @@ -101,6 +102,7 @@
'usage.read',
'locale.read',
'avatars.read',
'avatars.write',
'health.read',
'functions.read',
'functions.write',
Expand Down
4 changes: 4 additions & 0 deletions app/config/scopes/project.php
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,10 @@
'description' => 'Access to use Avatars service',
'category' => 'Other',
],
'avatars.write' => [
'description' => 'Access to update and delete the user photo',
'category' => 'Other',
],
'health.read' => [
'description' => 'Access to use Health service',
'category' => 'Other',
Expand Down
20 changes: 17 additions & 3 deletions app/controllers/api/account.php
Original file line number Diff line number Diff line change
Expand Up @@ -648,6 +648,7 @@
contentType: ContentType::NONE
))
->label('abuse-limit', 100)
->param('current', true, new Boolean(), 'Delete the current session too. Use false to sign out of every other session while staying signed in on this one.', true)
->inject('request')
->inject('response')
->inject('user')
Expand All @@ -659,17 +660,28 @@
->inject('proofForToken')
->inject('domainVerification')
->inject('cookieDomain')
->action(function (Request $request, Response $response, User $user, Database $dbForProject, Locale $locale, Event $queueForEvents, DeletePublisher $publisherForDeletes, Store $store, ProofsToken $proofForToken, bool $domainVerification, ?string $cookieDomain) {
->inject('session')
->action(function (bool $current, Request $request, Response $response, User $user, Database $dbForProject, Locale $locale, Event $queueForEvents, DeletePublisher $publisherForDeletes, Store $store, ProofsToken $proofForToken, bool $domainVerification, ?string $cookieDomain, ?Document $callingSession) {

// Nothing to keep (e.g. account API key), so refuse rather than delete every session.
if (!$current && $callingSession === null) {
throw new Exception(Exception::USER_SESSION_NOT_FOUND);
}

$protocol = $request->getProtocol();
$sessions = $user->getAttribute('sessions', []);
$currentSession = null;

foreach ($sessions as $session) {
/** @var Document $session */
if (!$current && $session->getId() === $callingSession->getId()) {
continue;
}

$dbForProject->deleteDocument('sessions', $session->getId());

if (!$domainVerification) {
// Clears the caller's fallback cookie, so only when its own session goes too.
if (!$domainVerification && $current) {
$response->addHeader('X-Fallback-Cookies', \json_encode([]));
}

Expand Down Expand Up @@ -3388,7 +3400,8 @@
->inject('user')
->inject('store')
->inject('proofForToken')
->action(function (int $duration, Request $request, Response $response, User $user, Store $store, ProofsToken $proofForToken) {
->inject('project')
->action(function (int $duration, Request $request, Response $response, User $user, Store $store, ProofsToken $proofForToken, Document $project) {
if (!empty($request->getHeaderLine('x-appwrite-jwt', ''))) {
throw new Exception(Exception::USER_JWT_CREATION_DENIED);
}
Expand All @@ -3405,6 +3418,7 @@
->setStatusCode(Response::STATUS_CODE_CREATED)
->dynamic(new Document([
'jwt' => $jwt->encode([
'projectId' => $project->getId(),
'userId' => $user->getId(),
'sessionId' => $sessionId,
])
Expand Down
3 changes: 3 additions & 0 deletions app/controllers/shared/api.php
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,9 @@
}
} // Admin User Authentication
elseif (($project->getId() === 'console' && ! $team->isEmpty() && ! $user->isEmpty()) || ($project->getId() !== 'console' && ! $user->isEmpty() && $mode === APP_MODE_ADMIN)) {
// On the console project, $team is the organization the route itself acts on (see the
// team resource), which is what lets its membership roles become the bare
// owner/developer/admin roles below.
$teamId = $team->getId();
$adminRoles = [];
$membershipSource = !$impersonatorUser->isEmpty() ? $targetUser : $user;
Expand Down
2 changes: 2 additions & 0 deletions app/init/constants.php
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@
const APP_STORAGE_IMPORTS = '/storage/imports'; // Temporary storage for csv imports
const APP_STORAGE_CERTIFICATES = '/storage/certificates';
const APP_STORAGE_CONFIG = '/storage/config';
const APP_STORAGE_PHOTOS = '_photos'; // User photos folder in each project's uploads; bucket IDs can't start with an underscore, so it never collides with a bucket's folder
const APP_STORAGE_READ_BUFFER = 20 * (1000 * 1000); //20MB other names `APP_STORAGE_MEMORY_LIMIT`, `APP_STORAGE_MEMORY_BUFFER`, `APP_STORAGE_READ_LIMIT`, `APP_STORAGE_BUFFER_LIMIT`
const APP_SOCIAL_TWITTER = 'https://twitter.com/appwrite';
const APP_SOCIAL_TWITTER_HANDLE = 'appwrite';
Expand Down Expand Up @@ -470,6 +471,7 @@
const METRIC_SITES_INBOUND = 'sites.inbound';
const METRIC_SITES_OUTBOUND = 'sites.outbound';
const METRIC_AVATARS_SCREENSHOTS_GENERATED = 'avatars.screenshotsGenerated';
const METRIC_AVATARS_STORAGE = 'avatars.storage';
const METRIC_FUNCTIONS_RUNTIME = 'functions.runtimes.{runtime}';
const METRIC_SITES_FRAMEWORK = 'sites.frameworks.{framework}';

Expand Down
11 changes: 11 additions & 0 deletions app/init/realtime/connection.php
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,17 @@
throw new Exception(Exception::USER_JWT_INVALID, 'Failed to verify JWT. ' . $error->getMessage());
}

// Every project shares the signing key, and a user ID can be chosen at
// signup, so a token is only good for the project that minted it. Tokens
// minted before the projectId claim existed are accepted only when bound
// to a session, whose ID the server generated and no other project holds.
$jwtProjectId = $payload['projectId'] ?? '';
$expectedProjectId = $mode === APP_MODE_ADMIN ? $console->getId() : $project->getId();
$bound = $jwtProjectId !== '' ? $jwtProjectId === $expectedProjectId : !empty($payload['sessionId']);
if (!$bound) {
throw new Exception(Exception::USER_JWT_INVALID, 'JWT was not issued for this project.');
}

$jwtUserId = $payload['userId'] ?? '';
if (!empty($jwtUserId)) {
if ($mode === APP_MODE_ADMIN) {
Expand Down
15 changes: 14 additions & 1 deletion app/init/resources/request.php
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,17 @@
throw new Exception(Exception::USER_JWT_INVALID, 'Failed to verify JWT. ' . $error->getMessage());
}

// Every project shares the signing key, and a user ID can be chosen at
// signup, so a token is only good for the project that minted it. Tokens
// minted before the projectId claim existed are accepted only when bound
// to a session, whose ID the server generated and no other project holds.
$jwtProjectId = $payload['projectId'] ?? '';
$expectedProjectId = $mode === APP_MODE_ADMIN ? $console->getId() : $project->getId();
$bound = $jwtProjectId !== '' ? $jwtProjectId === $expectedProjectId : ! empty($payload['sessionId']);
if (! $bound) {
throw new Exception(Exception::USER_JWT_INVALID, 'JWT was not issued for this project.');
}

$jwtUserId = $payload['userId'] ?? '';
if (! empty($jwtUserId)) {
if ($mode === APP_MODE_ADMIN) {
Expand Down Expand Up @@ -1001,7 +1012,9 @@
$team = $authorization->skip(fn () => $dbForPlatform->getDocument('teams', $teamId));

return $team;
} elseif (! empty($orgHeader)) {
} elseif (\in_array('organization', $route?->getGroups() ?? [], true) && ! empty($orgHeader)) {
// Routes in the organization group act on the organization named in the header;
// every other console route names its own team.
return $authorization->skip(fn () => $dbForPlatform->getDocument('teams', $orgHeader));
}
}
Expand Down
12 changes: 11 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ services:
start_period: 120s
networks:
- appwrite
- browser
labels:
- traefik.enable=true
- traefik.constraint-label-stack=appwrite
Expand Down Expand Up @@ -439,6 +440,7 @@ services:
image: ${_APP_IMAGE:-appwrite/appwrite}:${_APP_VERSION:-latest}
networks:
- appwrite
- browser
depends_on:
- redis
- ${_APP_DB_HOST:-postgresql}
Expand Down Expand Up @@ -1040,6 +1042,7 @@ services:
image: ${_APP_IMAGE:-appwrite/appwrite}:${_APP_VERSION:-latest}
networks:
- appwrite
- browser
volumes:
- appwrite-uploads:/storage/uploads:rw
depends_on:
Expand Down Expand Up @@ -1657,7 +1660,12 @@ services:
container_name: appwrite-browser
image: appwrite/browser:0.3.4
networks:
- appwrite
- browser
# Public resolvers only, so the browser cannot resolve internal service
# names (e.g. redis, appwrite-mariadb) and render them via a user URL.
dns:
- 1.1.1.1
- 8.8.8.8

appwrite-autogravity:
container_name: appwrite-autogravity
Expand Down Expand Up @@ -1937,6 +1945,8 @@ networks:
name: appwrite
runtimes:
name: runtimes
browser:
name: browser
volumes:
appwrite-mariadb: null
appwrite-mongodb: null
Expand Down
2 changes: 1 addition & 1 deletion docs/references/account/delete-sessions.md
Original file line number Diff line number Diff line change
@@ -1 +1 @@
Delete all sessions from the user account and remove any sessions cookies from the end client.
Delete all sessions from the user account and remove any sessions cookies from the end client. Pass `current` as false to keep the session making the request and sign out of every other session.
36 changes: 36 additions & 0 deletions src/Appwrite/AvatarPhotos/Providers/Custom.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php

namespace Appwrite\AvatarPhotos\Providers;

use Appwrite\AvatarPhotos\Photo;
use Utopia\Database\Document;
use Utopia\Storage\Device;

class Custom extends Photo
{
public function __construct(
private readonly Device $deviceForFiles,
) {
}

public function getName(): string
{
return 'custom';
}

public function supports(Document $profile): bool
{
return $profile->getAttribute('photoId', '') !== '';
}

public function get(Document $profile, int $width, int $height, string $rating): ?string
{
$path = $this->deviceForFiles->getPath(APP_STORAGE_PHOTOS . '/' . $profile->getId() . '/' . $profile->getAttribute('photoId'));

if (!$this->deviceForFiles->exists($path)) {
return null;
}

return (string) $this->deviceForFiles->read($path);
}
}
34 changes: 32 additions & 2 deletions src/Appwrite/Execution/Store.php
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,14 @@ class Store
'requestPath' => ['requestPath', 'String'],
];

private const array WINDOW_METHODS = [
Query::TYPE_LESSER,
Query::TYPE_LESSER_EQUAL,
Query::TYPE_GREATER,
Query::TYPE_GREATER_EQUAL,
Query::TYPE_BETWEEN,
];

private readonly RequestFactory $requestFactory;

private ?string $host = null;
Expand Down Expand Up @@ -508,13 +516,29 @@ private function latestSql(string $where): string
* the route's internal resource filters before aggregation avoids scanning
* and grouping every execution in a large project.
*
* A $createdAt window narrows the aggregation to the executions that have
* at least one version inside it. The window cannot filter versions
* directly: an execution queued through the API and finished by the
* functions worker gets a later createdAt on its worker-written versions,
* so dropping out-of-window versions could hide the latest one and return
* a stale status or a deleted execution. Every execution whose latest
* version matches has some version that matches, so the outer filter still
* decides on the latest snapshot.
*
* @param array<Query> $queries
* @param array<string, mixed> $params
*/
private function latestWhere(array $queries, array &$params): string
{
$conditions = ['source.projectId = {projectId:String}'];
$scope = ['projectId = {projectId:String}'];
$window = [];
foreach ($queries as $query) {
if ($query->getAttribute() === '$createdAt'
&& \in_array($query->getMethod(), self::WINDOW_METHODS, true)) {
$window[] = $this->filterSql($query, $params);
continue;
}

if ($query->getMethod() !== Query::TYPE_EQUAL
|| !\in_array($query->getAttribute(), ['resourceInternalId', 'resourceType'], true)) {
continue;
Expand All @@ -526,10 +550,16 @@ private function latestWhere(array $queries, array &$params): string
$parameters[] = $this->parameter($type, $value, $params);
}
if ($parameters !== []) {
$conditions[] = "source.{$column} IN (" . \implode(', ', $parameters) . ')';
$scope[] = "{$column} IN (" . \implode(', ', $parameters) . ')';
}
}

$conditions = \array_map(fn (string $condition) => "source.{$condition}", $scope);
if ($window !== []) {
$conditions[] = 'source.id IN (SELECT id FROM ' . $this->table()
. ' WHERE ' . \implode(' AND ', [...$scope, ...$window]) . ')';
}

return \implode(' AND ', $conditions);
}

Expand Down
9 changes: 9 additions & 0 deletions src/Appwrite/Migration/Version/V25.php
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,15 @@ private function migrateCollections(): void
Console::warning("Failed to create index \"_key_passwordPwned\" from {$id}: {$th->getMessage()}");
}

// Added in 2.3.0 for custom user photos
foreach (['photoId', 'photoSize'] as $attribute) {
try {
$this->createAttributeFromCollection($this->dbForProject, $id, $attribute);
} catch (Throwable $th) {
Console::warning("Failed to create attribute \"{$attribute}\" in collection {$id}: {$th->getMessage()}");
}
}

$this->dbForProject->purgeCachedCollection($id);
break;

Expand Down
Loading
Loading