mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-13 20:12:57 -10:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8462d30048 | ||
|
|
21846192d1 | ||
|
|
b9f0c8601d | ||
|
|
24aac2f6d2 | ||
|
|
1eb4e36cf4 |
No files matched your search
@@ -950,7 +950,8 @@ describe('resolvePrivateIdentity', () => {
|
||||
expect(out.userUid).toBeUndefined();
|
||||
});
|
||||
|
||||
it('accepts an app-issued access token minted for the target app', async () => {
|
||||
it('ignores an app-issued access token even for the target app', async () => {
|
||||
// A scoped token (e.g. a read URL) is not the account, whoever issued it.
|
||||
const user = await makeUser();
|
||||
const targetUid = `app-target-${uuidv4()}`;
|
||||
const issuer = makeActor({
|
||||
@@ -970,11 +971,11 @@ describe('resolvePrivateIdentity', () => {
|
||||
sessionCookieName: 'puter_auth_token',
|
||||
expectedAppUid: targetUid,
|
||||
});
|
||||
expect(out.source).toBe('session-cookie');
|
||||
expect(out.userUid).toBe(user.uuid);
|
||||
expect(out.source).toBe('none');
|
||||
expect(out.userUid).toBeUndefined();
|
||||
});
|
||||
|
||||
it('still accepts a user-issued access token, which is portable', async () => {
|
||||
it('ignores a user-issued scoped access token', async () => {
|
||||
const user = await makeUser();
|
||||
const issuer = makeActor({ user: { uuid: user.uuid } });
|
||||
const out = await resolvePrivateIdentity({
|
||||
@@ -983,7 +984,46 @@ describe('resolvePrivateIdentity', () => {
|
||||
actor: makeActor({
|
||||
user: { uuid: user.uuid },
|
||||
accessToken: { uid: `tok-${uuidv4()}`, issuer },
|
||||
session: { uid: 'sess-1' },
|
||||
}),
|
||||
}),
|
||||
authService,
|
||||
sessionCookieName: 'puter_auth_token',
|
||||
expectedAppUid: `app-target-${uuidv4()}`,
|
||||
});
|
||||
expect(out.source).toBe('none');
|
||||
expect(out.userUid).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ignores a scoped access token when the host has no app', async () => {
|
||||
const user = await makeUser();
|
||||
const issuer = makeActor({ user: { uuid: user.uuid } });
|
||||
const out = await resolvePrivateIdentity({
|
||||
req: reqOf({
|
||||
cookies: {},
|
||||
actor: makeActor({
|
||||
user: { uuid: user.uuid },
|
||||
accessToken: { uid: `tok-${uuidv4()}`, issuer },
|
||||
}),
|
||||
}),
|
||||
authService,
|
||||
sessionCookieName: 'puter_auth_token',
|
||||
});
|
||||
expect(out.source).toBe('none');
|
||||
});
|
||||
|
||||
it('accepts a full-access token, which holds the account', async () => {
|
||||
const user = await makeUser();
|
||||
const issuer = makeActor({ user: { uuid: user.uuid } });
|
||||
const out = await resolvePrivateIdentity({
|
||||
req: reqOf({
|
||||
cookies: {},
|
||||
actor: makeActor({
|
||||
user: { uuid: user.uuid },
|
||||
accessToken: {
|
||||
uid: `tok-${uuidv4()}`,
|
||||
issuer,
|
||||
fullAccess: true,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
authService,
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
} from '../../../services/auth/AuthService';
|
||||
import type { AppStore } from '../../../stores/app/AppStore.js';
|
||||
import type { IConfig } from '../../../types';
|
||||
import type { Actor } from '../../actor';
|
||||
import { isAccessTokenActor, isAccountContext, type Actor } from '../../actor';
|
||||
|
||||
/**
|
||||
* Support helpers for the private-app access gate — ported from v1's
|
||||
@@ -460,11 +460,15 @@ export async function resolvePrivateIdentity(opts: {
|
||||
* "No app anywhere in the chain" is the portable case, so read `effectiveApp` —
|
||||
* an app-issued access token has no `app` of its own and would otherwise pass
|
||||
* as a plain session on any private host.
|
||||
*
|
||||
* A scoped access token (a read URL, whoever issued it) is never the account
|
||||
* here: the hosted cookie minted from it would outlive the token's revocation.
|
||||
*/
|
||||
function actorMatchesExpectedApp(
|
||||
actor: Actor,
|
||||
expectedAppUid: string | undefined,
|
||||
): boolean {
|
||||
if (isAccessTokenActor(actor) && !isAccountContext(actor)) return false;
|
||||
if (!expectedAppUid) return true;
|
||||
const app = actor.effectiveApp;
|
||||
if (!app) return true;
|
||||
|
||||
@@ -573,6 +573,24 @@ describe('SubdomainDriver.update field handling', () => {
|
||||
expect(cleared.associated_app).toBeNull();
|
||||
});
|
||||
|
||||
it('disconnects the directory when root_dir is null', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const name = uniqueName('unlink');
|
||||
await createSubdomain(actor, name);
|
||||
|
||||
// The shape `puter.hosting.update(name, null)` sends.
|
||||
const updated = (await withActor(actor, () =>
|
||||
driver.update({
|
||||
id: { subdomain: name },
|
||||
object: { root_dir: null },
|
||||
}),
|
||||
)) as Record<string, unknown>;
|
||||
|
||||
expect(updated.root_dir).toBeNull();
|
||||
const row = await server.stores.subdomain.getBySubdomain(name);
|
||||
expect(row?.root_dir_id ?? null).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects repointing root_dir at a path that does not exist', async () => {
|
||||
const { actor } = await makeUser();
|
||||
const created = await createSubdomain(actor, uniqueName('badroot'));
|
||||
|
||||
@@ -357,7 +357,10 @@ export class SubdomainDriver extends PuterDriver {
|
||||
|
||||
// Subdomain name is immutable — strip if provided
|
||||
const patch: Record<string, unknown> = {};
|
||||
if (object.root_dir !== undefined) {
|
||||
// A null root_dir disconnects the directory; the site stays reserved.
|
||||
if (object.root_dir === null) {
|
||||
patch.root_dir_id = null;
|
||||
} else if (object.root_dir !== undefined) {
|
||||
const rootDirPath = expandTildePath(
|
||||
String(object.root_dir),
|
||||
actor.user.username,
|
||||
|
||||
@@ -4749,6 +4749,25 @@ describe('FSService move', () => {
|
||||
expect(await readBack(overwritten)).toBe('winner');
|
||||
});
|
||||
|
||||
it('refuses to overwrite a directory the moved entry sits inside', async () => {
|
||||
const outer = await fs.mkdir(user.userId, {
|
||||
path: `${user.home}/Documents/nest/photos/photos`,
|
||||
createMissingParents: true,
|
||||
});
|
||||
await writeFile(user, `${user.home}/Documents/nest/photos/photos/a.txt`, 'keep');
|
||||
const nest = (await entryAt(user, '/Documents/nest'))!;
|
||||
|
||||
const refused = await caught(() =>
|
||||
fs.move(user.userId, {
|
||||
source: outer,
|
||||
destinationParent: nest,
|
||||
overwrite: true,
|
||||
}),
|
||||
);
|
||||
expect(refused.statusCode).toBe(400);
|
||||
expect(await entryAt(user, '/Documents/nest/photos/photos/a.txt')).toBeTruthy();
|
||||
});
|
||||
|
||||
/**
|
||||
* Reproduce an occupant arriving after the collision probe: the probe
|
||||
* misses it (a concurrent move, or a replica that hasn't caught up) and the
|
||||
@@ -5038,6 +5057,25 @@ describe('FSService copy', () => {
|
||||
).toBe('cannot_copy_directory_into_itself');
|
||||
});
|
||||
|
||||
it('refuses to overwrite a directory the copied entry sits inside', async () => {
|
||||
const inner = await fs.mkdir(user.userId, {
|
||||
path: `${user.home}/Documents/cp-nest/photos/photos`,
|
||||
createMissingParents: true,
|
||||
});
|
||||
await writeFile(user, `${user.home}/Documents/cp-nest/photos/photos/a.txt`, 'keep');
|
||||
const nest = (await entryAt(user, '/Documents/cp-nest'))!;
|
||||
|
||||
const refused = await caught(() =>
|
||||
fs.copy(user.userId, {
|
||||
source: inner,
|
||||
destinationParent: nest,
|
||||
overwrite: true,
|
||||
}),
|
||||
);
|
||||
expect(refused.statusCode).toBe(400);
|
||||
expect(await entryAt(user, '/Documents/cp-nest/photos/photos/a.txt')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('handles a destination collision by conflict, overwrite or dedupe', async () => {
|
||||
const destination = (await entryAt(user, '/Desktop'))!;
|
||||
const source = await writeFile(
|
||||
|
||||
@@ -3673,18 +3673,17 @@ export class FSService extends PuterService {
|
||||
updates.filter(isValid),
|
||||
);
|
||||
let next = 0;
|
||||
return updates.map(
|
||||
(update): PromiseSettledResult<FSEntry> =>
|
||||
isValid(update)
|
||||
? stored[next++]
|
||||
: {
|
||||
status: 'rejected',
|
||||
reason: new HttpError(
|
||||
400,
|
||||
'Invalid file entry identifier for thumbnail update',
|
||||
{ legacyCode: 'bad_request' },
|
||||
),
|
||||
},
|
||||
return updates.map((update): PromiseSettledResult<FSEntry> =>
|
||||
isValid(update)
|
||||
? stored[next++]
|
||||
: {
|
||||
status: 'rejected',
|
||||
reason: new HttpError(
|
||||
400,
|
||||
'Invalid file entry identifier for thumbnail update',
|
||||
{ legacyCode: 'bad_request' },
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4990,6 +4989,7 @@ export class FSService extends PuterService {
|
||||
if (collision && collision.uuid !== source.uuid) {
|
||||
name = await this.#resolveMoveCollision(
|
||||
userId,
|
||||
source,
|
||||
collision,
|
||||
destinationParent,
|
||||
name,
|
||||
@@ -5042,6 +5042,7 @@ export class FSService extends PuterService {
|
||||
if (raced && raced.uuid !== source.uuid) {
|
||||
name = await this.#resolveMoveCollision(
|
||||
userId,
|
||||
source,
|
||||
raced,
|
||||
destinationParent,
|
||||
name,
|
||||
@@ -5104,6 +5105,7 @@ export class FSService extends PuterService {
|
||||
*/
|
||||
async #resolveMoveCollision(
|
||||
userId: number,
|
||||
source: FSEntry,
|
||||
occupant: FSEntry,
|
||||
destinationParent: FSEntry,
|
||||
name: string,
|
||||
@@ -5115,6 +5117,7 @@ export class FSService extends PuterService {
|
||||
return this.#findDedupedName(destinationParent, name);
|
||||
}
|
||||
if (input.overwrite) {
|
||||
this.#assertNotReplacingAncestor(source, occupant);
|
||||
await this.remove(userId, { entry: occupant, recursive: true });
|
||||
return name;
|
||||
}
|
||||
@@ -5130,6 +5133,20 @@ export class FSService extends PuterService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse to overwrite a directory the source sits inside: removing it would
|
||||
* take the source down with it.
|
||||
*/
|
||||
#assertNotReplacingAncestor(source: FSEntry, occupant: FSEntry): void {
|
||||
if (occupant.isDir && source.path.startsWith(`${occupant.path}/`)) {
|
||||
throw new HttpError(
|
||||
400,
|
||||
`Cannot replace \`${occupant.name}\`: it contains the item being moved or copied`,
|
||||
{ legacyCode: 'bad_request' },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy an entry to a new parent. For directories, walks descendants and
|
||||
* issues S3 CopyObject + DB inserts. Thumbnail URLs on entries ride along
|
||||
@@ -5199,6 +5216,7 @@ export class FSService extends PuterService {
|
||||
|
||||
if (collision) {
|
||||
if (input.overwrite) {
|
||||
this.#assertNotReplacingAncestor(source, collision);
|
||||
await this.remove(userId, {
|
||||
entry: collision,
|
||||
recursive: true,
|
||||
|
||||
@@ -102,6 +102,8 @@ Entries the other app wrote with `disableSharing` are never delivered. Marking a
|
||||
|
||||
## Sharing key-value data with another user
|
||||
|
||||
<div class="info">KV sharing is in beta. Method shapes, limits, and behavior may change between releases.</div>
|
||||
|
||||
`kv:` always means your own data. When another user shares part of theirs with you using [`puter.kv.share()`](/KV/share/), you watch it through the share's **handle**, which goes where an app id would go. For a walkthrough, see [Send Live Updates to Other Users](/recipes/send-live-updates-to-other-users/).
|
||||
|
||||
```js
|
||||
|
||||
@@ -232,6 +232,8 @@ These Key-Value Store features are supported out of the box when using Puter.js:
|
||||
|
||||
## Sharing with other users
|
||||
|
||||
<div class="info">KV sharing is in beta. Method shapes, limits, and behavior may change between releases.</div>
|
||||
|
||||
A user can share the keys under a prefix with another Puter user. The user who shares is the share's owner, and the user it's shared with is its holder. The keys stay in the owner's store.
|
||||
|
||||
Pick the tool by what you want to do:
|
||||
|
||||
@@ -38,6 +38,8 @@ A plain object is read as a path map, never appended as an element: to append an
|
||||
|
||||
#### `optConfig` (Object) (optional)
|
||||
|
||||
<div class="info">KV sharing is in beta. Method shapes, limits, and behavior may change between releases.</div>
|
||||
|
||||
The last argument. It takes these properties:
|
||||
|
||||
- `handle` (String): Work on data another user shared with you, using the `handle` from [`puter.kv.listShared()`](/KV/listShared/). The key is then relative to the shared prefix. The share needs `write` access. See [Sharing with other users](/KV/#sharing-with-other-users).
|
||||
|
||||
@@ -36,6 +36,8 @@ Paths support dot notation, array indexes at any level (`[0]`, `items[0]`, or `s
|
||||
|
||||
#### `optConfig` (Object) (optional)
|
||||
|
||||
<div class="info">KV sharing is in beta. Method shapes, limits, and behavior may change between releases.</div>
|
||||
|
||||
The last argument. It takes these properties:
|
||||
|
||||
- `handle` (String): Work on data another user shared with you, using the `handle` from [`puter.kv.listShared()`](/KV/listShared/). The key is then relative to the shared prefix. The share needs `write` access. See [Sharing with other users](/KV/#sharing-with-other-users).
|
||||
|
||||
@@ -18,6 +18,8 @@ A string containing the name of the key you want to remove.
|
||||
|
||||
#### `optConfig` (Object) (optional)
|
||||
|
||||
<div class="info">KV sharing is in beta. Method shapes, limits, and behavior may change between releases.</div>
|
||||
|
||||
The last argument. It takes these properties:
|
||||
|
||||
- `handle` (String): Work on data another user shared with you, using the `handle` from [`puter.kv.listShared()`](/KV/listShared/). The key is then relative to the shared prefix. The share needs `write` access. See [Sharing with other users](/KV/#sharing-with-other-users).
|
||||
|
||||
@@ -25,6 +25,8 @@ The number of seconds until the key is removed from the key-value store. `0` or
|
||||
|
||||
#### `optConfig` (Object) (optional)
|
||||
|
||||
<div class="info">KV sharing is in beta. Method shapes, limits, and behavior may change between releases.</div>
|
||||
|
||||
The last argument. It takes these properties:
|
||||
|
||||
- `handle` (String): Work on data another user shared with you, using the `handle` from [`puter.kv.listShared()`](/KV/listShared/). The key is then relative to the shared prefix. The share needs `write` access. See [Sharing with other users](/KV/#sharing-with-other-users).
|
||||
|
||||
@@ -25,6 +25,8 @@ The Unix timestamp (in seconds) at which the key will be removed from the key-va
|
||||
|
||||
#### `optConfig` (Object) (optional)
|
||||
|
||||
<div class="info">KV sharing is in beta. Method shapes, limits, and behavior may change between releases.</div>
|
||||
|
||||
The last argument. It takes these properties:
|
||||
|
||||
- `handle` (String): Work on data another user shared with you, using the `handle` from [`puter.kv.listShared()`](/KV/listShared/). The key is then relative to the shared prefix. The share needs `write` access. See [Sharing with other users](/KV/#sharing-with-other-users).
|
||||
|
||||
@@ -17,6 +17,8 @@ None
|
||||
## Return value
|
||||
A `Promise` that will resolve to `true` when the key-value store has been flushed (emptied), or reject with an error on failure.
|
||||
|
||||
<div class="info">KV sharing is in beta. Method shapes, limits, and behavior may change between releases.</div>
|
||||
|
||||
Flushing works only on your own store. Passing the `handle` of data another user shared with you is rejected with `kv_handle_option_invalid`. To clear a shared prefix, delete its keys with [`puter.kv.del()`](/KV/del/).
|
||||
|
||||
## Examples
|
||||
|
||||
@@ -18,6 +18,8 @@ A string containing the name of the key you want to retrieve the value of.
|
||||
|
||||
#### `optConfig` (Object) (optional)
|
||||
|
||||
<div class="info">KV sharing is in beta. Method shapes, limits, and behavior may change between releases.</div>
|
||||
|
||||
The last argument. It takes these properties:
|
||||
|
||||
- `handle` (String): Work on data another user shared with you, using the `handle` from [`puter.kv.listShared()`](/KV/listShared/). The key is then relative to the shared prefix. The share needs `read` or `write` access. See [Sharing with other users](/KV/#sharing-with-other-users).
|
||||
|
||||
@@ -36,6 +36,8 @@ Paths support dot notation, array indexes at any level (`[0]`, `items[0]`, or `s
|
||||
|
||||
#### `optConfig` (Object) (optional)
|
||||
|
||||
<div class="info">KV sharing is in beta. Method shapes, limits, and behavior may change between releases.</div>
|
||||
|
||||
The last argument. It takes these properties:
|
||||
|
||||
- `handle` (String): Work on data another user shared with you, using the `handle` from [`puter.kv.listShared()`](/KV/listShared/). The key is then relative to the shared prefix. The share needs `write` access. See [Sharing with other users](/KV/#sharing-with-other-users).
|
||||
|
||||
@@ -35,6 +35,8 @@ If set to `true`, the returned array will contain objects with both `key` and `v
|
||||
|
||||
#### `optConfig` (Object) (optional)
|
||||
|
||||
<div class="info">KV sharing is in beta. Method shapes, limits, and behavior may change between releases.</div>
|
||||
|
||||
The last argument of the positional forms. It takes these properties:
|
||||
|
||||
- `handle` (String): List data another user shared with you, using the `handle` from [`puter.kv.listShared()`](/KV/listShared/). The share needs `read` or `write` access. The `pattern` is relative to the shared prefix and can be at most 768 bytes, and the returned keys are relative to the prefix too. The results and `total` leave out private entries. Every page of a full listing or a stream is fetched through the handle. See [Sharing with other users](/KV/#sharing-with-other-users).
|
||||
|
||||
@@ -4,6 +4,8 @@ description: List the key-value data other users have shared with you.
|
||||
platforms: [websites, apps, nodejs, workers]
|
||||
---
|
||||
|
||||
<div class="info">KV sharing is in beta. Method shapes, limits, and behavior may change between releases.</div>
|
||||
|
||||
Lists the live shares other users made with you, a page at a time. Each share comes with the `handle` you pass to the other [`puter.kv`](/KV/) methods to use it.
|
||||
|
||||
A share tells you its level, its app, and the owner's label. It never says who shared it or what the prefix is. To tell shares apart, read the label, or read a key the owner keeps for that, such as `meta`, through the handle. Reading needs `read` or `write` access, so a `watch` share only has its label.
|
||||
|
||||
@@ -4,6 +4,8 @@ description: List the key-value shares you made with other users, including ones
|
||||
platforms: [websites, apps, nodejs, workers]
|
||||
---
|
||||
|
||||
<div class="info">KV sharing is in beta. Method shapes, limits, and behavior may change between releases.</div>
|
||||
|
||||
Lists the shares you made with [`puter.kv.share()`](/KV/share/), a page at a time. Shares that stopped stay in the listing with `revokedAt` and `revokedBy` set, as a record of who had access and when it ended.
|
||||
|
||||
In an app, the listing has only the shares of that app's data. Outside an app, it has every share you made.
|
||||
|
||||
@@ -27,6 +27,8 @@ Paths support dot notation, array indexes at any level (`[0]`, `items[0]`, or `s
|
||||
|
||||
#### `optConfig` (Object) (optional)
|
||||
|
||||
<div class="info">KV sharing is in beta. Method shapes, limits, and behavior may change between releases.</div>
|
||||
|
||||
The last argument. It takes these properties:
|
||||
|
||||
- `handle` (String): Work on data another user shared with you, using the `handle` from [`puter.kv.listShared()`](/KV/listShared/). The key is then relative to the shared prefix. The share needs `write` access. See [Sharing with other users](/KV/#sharing-with-other-users).
|
||||
|
||||
@@ -56,6 +56,8 @@ The batch form rejects it with `invalid_request`.
|
||||
|
||||
#### `handle` (String) (optional)
|
||||
|
||||
<div class="info">KV sharing is in beta. Method shapes, limits, and behavior may change between releases.</div>
|
||||
|
||||
Pass inside the trailing options object, as in `set(key, value, { handle })`, to write into data another user shared with you, using the `handle` from [`puter.kv.listShared()`](/KV/listShared/). The key is then relative to the shared prefix, and the share needs `write` access. See [Sharing with other users](/KV/#sharing-with-other-users). The batch form takes it too. With `handle`, a key can be at most 768 bytes.
|
||||
|
||||
#### `items` (Array) (batch only)
|
||||
|
||||
@@ -4,6 +4,8 @@ description: Share the keys under a prefix with another Puter user, so they can
|
||||
platforms: [websites, apps, nodejs, workers]
|
||||
---
|
||||
|
||||
<div class="info">KV sharing is in beta. Method shapes, limits, and behavior may change between releases.</div>
|
||||
|
||||
Shares the keys under a prefix with another Puter user. The other user becomes the share's holder, and you stay its owner. The keys stay in your store, and the holder reaches them through the share.
|
||||
|
||||
Each share has a `handle`. The holder finds it with [`puter.kv.listShared()`](/KV/listShared/) and passes it as `handle` to the other [`puter.kv`](/KV/) methods. [Sharing with other users](/KV/#sharing-with-other-users) shows how a holder uses a share.
|
||||
|
||||
@@ -4,6 +4,8 @@ description: Stop sharing key-value data with another user, or remove your own a
|
||||
platforms: [websites, apps, nodejs, workers]
|
||||
---
|
||||
|
||||
<div class="info">KV sharing is in beta. Method shapes, limits, and behavior may change between releases.</div>
|
||||
|
||||
Stops a share made with [`puter.kv.share()`](/KV/share/). Either side can call it:
|
||||
|
||||
- **The owner** revokes the share. The holder loses access, and any narrower share you made with the same holder inside the same prefix stops too.
|
||||
|
||||
@@ -35,6 +35,8 @@ Paths support dot notation, array indexes at any level (`[0]`, `items[0]`, or `s
|
||||
|
||||
#### `optConfig` (Object) (optional)
|
||||
|
||||
<div class="info">KV sharing is in beta. Method shapes, limits, and behavior may change between releases.</div>
|
||||
|
||||
The last argument. It takes these properties:
|
||||
|
||||
- `handle` (String): Work on data another user shared with you, using the `handle` from [`puter.kv.listShared()`](/KV/listShared/). The key is then relative to the shared prefix. The share needs `write` access. See [Sharing with other users](/KV/#sharing-with-other-users).
|
||||
|
||||
@@ -40,6 +40,8 @@ What the request is about. The resource decides which details are accepted and w
|
||||
| `'backgroundEvents'` | `Resource.BACKGROUND_EVENTS` | None | `true` if granted |
|
||||
| `'kvShare'` | `Resource.KV_SHARE` | `{ prefix, access }` | `true` if granted |
|
||||
|
||||
<div class="info">KV sharing is in beta. Method shapes, limits, and behavior may change between releases.</div>
|
||||
|
||||
#### `details` (object) (optional)
|
||||
|
||||
The fields the resource takes:
|
||||
|
||||
@@ -186,6 +186,8 @@ Before the provider runs, the balance must cover every page the call can be bill
|
||||
|
||||
### Key-value store
|
||||
|
||||
<div class="info">KV sharing is in beta. Method shapes, limits, and behavior may change between releases.</div>
|
||||
|
||||
| Limit | Paid | Free | Anonymous |
|
||||
| ------------------------------- | ---- | ---- | --------- |
|
||||
| `get` / `set` / etc. per 10 s | 400 | 400 | 200 |
|
||||
@@ -422,6 +424,8 @@ See [Events](/Events/) for an overview, [`onLocal()`](/Events/subjects/#gaps) fo
|
||||
|
||||
#### What an account can hold
|
||||
|
||||
<div class="info">KV sharing is in beta. Method shapes, limits, and behavior may change between releases.</div>
|
||||
|
||||
| Limit | Scope | Paid | Free | Anonymous |
|
||||
| ------------------------------------ | ------------------ | ---- | ---- | --------- |
|
||||
| Persistent subscriptions | Per user, all apps | 500 | 100 | 0 |
|
||||
|
||||
@@ -5,6 +5,8 @@ tags: [kv, events, perms]
|
||||
order: 22
|
||||
---
|
||||
|
||||
<div class="info">KV sharing is in beta. Method shapes, limits, and behavior may change between releases.</div>
|
||||
|
||||
A group chat usually needs a server that stores the messages and sends each new
|
||||
one to everyone in the room. With [`puter.kv.share()`](/KV/share/), the room can
|
||||
live in the Puter account of the user who creates it instead. Each member gets
|
||||
|
||||
@@ -5,6 +5,8 @@ tags: [kv, perms]
|
||||
order: 18
|
||||
---
|
||||
|
||||
<div class="info">KV sharing is in beta. Method shapes, limits, and behavior may change between releases.</div>
|
||||
|
||||
A fitness app keeps each user's workouts in their own Puter account. A user who
|
||||
trains with a coach wants the coach to see the log, but not change it. With
|
||||
[`puter.kv.share()`](/KV/share/) and read access, the coach can read and list
|
||||
|
||||
@@ -5,6 +5,8 @@ tags: [kv, perms]
|
||||
order: 21
|
||||
---
|
||||
|
||||
<div class="info">KV sharing is in beta. Method shapes, limits, and behavior may change between releases.</div>
|
||||
|
||||
Once users share data in your app, they need a way to see who has it and to
|
||||
take it back. This recipe builds two screens, a "People with access" panel for
|
||||
the data a user shared and a "Shared with me" list for the data other people
|
||||
|
||||
@@ -5,6 +5,8 @@ tags: [kv, events]
|
||||
order: 19
|
||||
---
|
||||
|
||||
<div class="info">KV sharing is in beta. Method shapes, limits, and behavior may change between releases.</div>
|
||||
|
||||
A scoreboard app keeps the score of a game in the scorekeeper's Puter account.
|
||||
Fans want to see each change as it happens. With a watch share, another Puter
|
||||
user gets an [event](/Events/) each time a key under a prefix changes, with the
|
||||
|
||||
@@ -5,6 +5,8 @@ tags: [kv, perms]
|
||||
order: 17
|
||||
---
|
||||
|
||||
<div class="info">KV sharing is in beta. Method shapes, limits, and behavior may change between releases.</div>
|
||||
|
||||
[`puter.kv`](/KV/) keeps each user's data in their own Puter account, where only
|
||||
they can reach it. A shared grocery list or to-do list needs two people working
|
||||
on the same data. With [`puter.kv.share()`](/KV/share/), a user can give another
|
||||
|
||||
@@ -5,6 +5,8 @@ tags: [kv, perms, workers]
|
||||
order: 23
|
||||
---
|
||||
|
||||
<div class="info">KV sharing is in beta. Method shapes, limits, and behavior may change between releases.</div>
|
||||
|
||||
Some apps share data without the user clicking a share button. A team chat app
|
||||
can share each new room with the whole team as soon as it's created, or a
|
||||
backend can add a member to a room when your own rules say so. In both cases your app calls
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -27,6 +27,10 @@ vi.mock('../UIAlert.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('../UIWindowProgress.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('../UIContextMenu.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('../../helpers/generateFileContextMenu.js', () => ({ default: vi.fn(async () => []) }));
|
||||
vi.mock('../../helpers/itemIcon.js', async (importOriginal) => {
|
||||
const actual = await importOriginal();
|
||||
return { default: vi.fn(actual.default) };
|
||||
});
|
||||
|
||||
globalThis.$ = jQuery;
|
||||
globalThis.jQuery = jQuery;
|
||||
@@ -38,6 +42,7 @@ window.icons = {};
|
||||
|
||||
const { default: TabFiles, failedUploadsMessage, rebasePath, typeSelectTarget } = await import('./TabFiles.js');
|
||||
const { default: generateFileContextMenu } = await import('../../helpers/generateFileContextMenu.js');
|
||||
const { default: itemIcon } = await import('../../helpers/itemIcon.js');
|
||||
|
||||
const TRASH = '/alice/Trash';
|
||||
|
||||
@@ -246,6 +251,35 @@ describe('TabFiles opening a folder that fails to list', () => {
|
||||
expect(TabFiles.acceptsNewItems('/alice/Documents')).toBe(true);
|
||||
expect(TabFiles.renderingDirectory).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the rows when a refresh of it can\'t reach the server', async () => {
|
||||
mountListing(['a.txt'], '/alice/Documents');
|
||||
TabFiles.renderingDirectory = false;
|
||||
// puter.js rejects with the XMLHttpRequest itself, which has no code.
|
||||
window.puter.fs.readdir = vi.fn(async () => {
|
||||
throw { status: 0 };
|
||||
});
|
||||
|
||||
await TabFiles.renderDirectory('/alice/Documents');
|
||||
|
||||
expect($('.files .row')).toHaveLength(1);
|
||||
expect(TabFiles.acceptsNewItems('/alice/Documents')).toBe(true);
|
||||
expect(TabFiles.renderingDirectory).toBe(false);
|
||||
});
|
||||
|
||||
it('says so when a refresh finds it gone', async () => {
|
||||
mountListing(['a.txt'], '/alice/Documents');
|
||||
TabFiles.renderingDirectory = false;
|
||||
TabFiles.trashedNames ??= new Map();
|
||||
window.puter.fs.readdir = vi.fn(async () => {
|
||||
throw { code: 'subject_does_not_exist', message: 'Subject does not exist' };
|
||||
});
|
||||
|
||||
await TabFiles.renderDirectory('/alice/Documents');
|
||||
|
||||
expect($('.files .row')).toHaveLength(0);
|
||||
expect($('.files').text()).toContain("couldn't be opened");
|
||||
});
|
||||
});
|
||||
|
||||
describe('TabFiles opening a folder during a refresh', () => {
|
||||
@@ -294,6 +328,7 @@ describe('TabFiles New Folder', () => {
|
||||
});
|
||||
vi.spyOn(TabFiles, 'insertAtSortedPosition').mockImplementation(() => {});
|
||||
vi.spyOn(TabFiles, 'applyColumnWidths').mockImplementation(() => {});
|
||||
TabFiles.deferredAdds = [];
|
||||
});
|
||||
|
||||
it('is what Undo takes back, not the upload before it', async () => {
|
||||
@@ -308,6 +343,62 @@ describe('TabFiles New Folder', () => {
|
||||
expect($(last.data).attr('data-path')).toBe('/alice/Documents/New Folder');
|
||||
});
|
||||
|
||||
it('becomes the selection the footer counts', async () => {
|
||||
mountListing(['a.txt', 'b.txt'], '/alice/Documents');
|
||||
$('.files .row').addClass('selected');
|
||||
TabFiles.updateFooterStats();
|
||||
TabFiles.renderingDirectory = false;
|
||||
TabFiles._creatingItem = 0;
|
||||
|
||||
await TabFiles.createFolderInstant('/alice/Documents');
|
||||
|
||||
expect($('.files .row.selected')).toHaveLength(1);
|
||||
expect($('.files-footer-selected-items').text()).toMatch(/^1 item selected/);
|
||||
});
|
||||
|
||||
it('lets items added meanwhile in, once it is made', async () => {
|
||||
mountListing([], '/alice/Documents');
|
||||
TabFiles.renderingDirectory = false;
|
||||
TabFiles._creatingItem = 0;
|
||||
let finishMkdir;
|
||||
window.puter.fs.mkdir = vi.fn(() => new Promise((resolve) => {
|
||||
finishMkdir = resolve;
|
||||
}));
|
||||
const added = vi.fn();
|
||||
const previous = window.UIDashboardFileItem;
|
||||
window.UIDashboardFileItem = (file) => {
|
||||
if ( TabFiles._creatingItem ) TabFiles.deferredAdds.push(file);
|
||||
else added(file);
|
||||
};
|
||||
|
||||
const creating = TabFiles.createFolderInstant('/alice/Documents');
|
||||
await vi.waitFor(() => expect(finishMkdir).toBeTypeOf('function'));
|
||||
window.UIDashboardFileItem({ uid: 'u2', path: '/alice/Documents/from-elsewhere.txt' });
|
||||
expect(added).not.toHaveBeenCalled();
|
||||
finishMkdir({ uid: 'f1', name: 'New Folder', path: '/alice/Documents/New Folder', is_dir: true });
|
||||
await creating;
|
||||
|
||||
expect(added).toHaveBeenCalledWith({ uid: 'u2', path: '/alice/Documents/from-elsewhere.txt' });
|
||||
window.UIDashboardFileItem = previous;
|
||||
});
|
||||
|
||||
it('says so when the network fails, rather than vanishing', async () => {
|
||||
const { default: UIAlert } = await import('../UIAlert.js');
|
||||
UIAlert.mockClear();
|
||||
mountListing([], '/alice/Documents');
|
||||
TabFiles.renderingDirectory = false;
|
||||
TabFiles._creatingItem = 0;
|
||||
// puter.js rejects with the XMLHttpRequest itself, which has no message.
|
||||
window.puter.fs.mkdir = vi.fn(async () => {
|
||||
throw { status: 0 };
|
||||
});
|
||||
|
||||
await TabFiles.createFolderInstant('/alice/Documents');
|
||||
|
||||
expect($('.files .row')).toHaveLength(0);
|
||||
expect(UIAlert).toHaveBeenCalledWith('An unknown error occurred.');
|
||||
});
|
||||
|
||||
it('is made, not dropped, while the folder is refreshing', async () => {
|
||||
mountListing([], '/alice/Documents');
|
||||
TabFiles.trashedNames ??= new Map();
|
||||
@@ -454,6 +545,25 @@ describe('TabFiles.updateNavButtonStates', () => {
|
||||
expect($('.path-btn-forward').attr('tabindex')).toBe('-1');
|
||||
expect($('.path-btn-forward').attr('aria-disabled')).toBe('true');
|
||||
});
|
||||
|
||||
it('moves the focus off an arrow it disables', () => {
|
||||
document.body.innerHTML = `
|
||||
<div class="window">
|
||||
<img class="path-btn path-btn-back" tabindex="0">
|
||||
<img class="path-btn path-btn-forward" tabindex="0">
|
||||
<img class="path-btn path-btn-up" tabindex="0">
|
||||
</div>`;
|
||||
TabFiles.$el_window = $('.window');
|
||||
TabFiles.currentPath = '/alice';
|
||||
window.dashboard_nav_history = ['/alice', '/alice/Documents'];
|
||||
// Back was pressed from Documents.
|
||||
window.dashboard_nav_history_current_position = 0;
|
||||
$('.path-btn-back')[0].focus();
|
||||
|
||||
TabFiles.updateNavButtonStates();
|
||||
|
||||
expect(document.activeElement).toBe($('.path-btn-forward')[0]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TabFiles cancelling a drag that opened a folder', () => {
|
||||
@@ -502,6 +612,81 @@ describe('TabFiles image preview', () => {
|
||||
expect(TabFiles.previewOpen).toBe(false);
|
||||
expect($('.image-preview-popover')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('is not offered for a file named after an image type', async () => {
|
||||
mountListing(['png'], '/alice/Pictures');
|
||||
$('.files .row').attr({ 'data-uid': 'u1', 'data-name': 'png', 'data-is_dir': '0' });
|
||||
window.puter = { fs: { read: vi.fn(async () => new Blob(['x'])) } };
|
||||
TabFiles.previewOpen = false;
|
||||
|
||||
await TabFiles.showImagePreview($('.files .row'));
|
||||
|
||||
expect(window.puter.fs.read).not.toHaveBeenCalled();
|
||||
expect(TabFiles.previewOpen).toBe(false);
|
||||
});
|
||||
|
||||
it('can be cancelled while its image is still loading', async () => {
|
||||
window.user = { username: 'alice', directories: {} };
|
||||
window.home_path = '/alice';
|
||||
TabFiles.trashedNames ??= new Map();
|
||||
TabFiles.renderingDirectory = false;
|
||||
mountListing(['a.png'], '/alice/Pictures');
|
||||
$('.files .row').attr({ 'data-uid': 'u1', 'data-name': 'a.png', 'data-is_dir': '0' });
|
||||
let finishUrl;
|
||||
window.puter = { fs: {
|
||||
read: vi.fn(() => new Promise(resolve => {
|
||||
finishUrl = resolve;
|
||||
})),
|
||||
readdir: vi.fn(async () => []),
|
||||
} };
|
||||
|
||||
const preview = TabFiles.showImagePreview($('.files .row'));
|
||||
expect(TabFiles.previewOpen).toBe(true);
|
||||
// Opening another folder meanwhile, as a double-click on one would.
|
||||
await TabFiles.renderDirectory('/alice/Documents');
|
||||
finishUrl(new Blob(['x']));
|
||||
await preview;
|
||||
|
||||
expect(TabFiles.previewOpen).toBe(false);
|
||||
expect($('.image-preview-popover')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('reads the image rather than spending an access token on it, typed so an SVG shows', async () => {
|
||||
mountListing(['logo.svg'], '/alice/Pictures');
|
||||
$('.files .row').attr({ 'data-uid': 'u1', 'data-name': 'logo.svg', 'data-is_dir': '0' });
|
||||
window.puter = { fs: { read: vi.fn(async () => new Blob(['<svg/>'])), getReadURL: vi.fn() } };
|
||||
const created = [];
|
||||
URL.createObjectURL = vi.fn((blob) => {
|
||||
created.push(blob);
|
||||
return 'blob:preview';
|
||||
});
|
||||
URL.revokeObjectURL = vi.fn();
|
||||
|
||||
await TabFiles.showImagePreview($('.files .row'));
|
||||
|
||||
expect(window.puter.fs.getReadURL).not.toHaveBeenCalled();
|
||||
expect(created[0].type).toBe('image/svg+xml');
|
||||
expect($('.image-preview-popover img').attr('src')).toBe('blob:preview');
|
||||
|
||||
TabFiles.closeImagePreview();
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith('blob:preview');
|
||||
});
|
||||
|
||||
it('closes, rather than showing the last image, when the next one fails to load', async () => {
|
||||
mountListing(['b.png'], '/alice/Pictures');
|
||||
$('.files .row').attr({ 'data-uid': 'u2', 'data-name': 'b.png', 'data-is_dir': '0' });
|
||||
$('body').append('<div class="image-preview-popover" data-uid="u1"></div>');
|
||||
TabFiles.previewOpen = true;
|
||||
TabFiles.previewCurrentUid = 'u1';
|
||||
window.puter = { fs: { read: vi.fn(async () => { throw new Error('gone'); }) } };
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
await TabFiles.showImagePreview($('.files .row'));
|
||||
|
||||
expect(TabFiles.previewOpen).toBe(false);
|
||||
expect($('.image-preview-popover')).toHaveLength(0);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TabFiles.acceptsNewItems', () => {
|
||||
@@ -729,3 +914,344 @@ describe('TabFiles Remove from Shared', () => {
|
||||
expect(TabFiles.refreshKeepingSelection).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TabFiles sorting', () => {
|
||||
const entries = [
|
||||
{ name: 'file10.txt', size: 5, modified: 3 },
|
||||
{ name: 'dir10', is_dir: true, size: 0, modified: 1 },
|
||||
{ name: 'file2.txt', size: 5, modified: 3 },
|
||||
{ name: 'Beta', is_dir: true, size: 0, modified: 1 },
|
||||
{ name: 'dir2', is_dir: true, size: 0, modified: 1 },
|
||||
{ name: 'big.bin', size: 50, modified: 2 },
|
||||
];
|
||||
const sorted = (column, direction) => {
|
||||
TabFiles.sortColumn = column;
|
||||
TabFiles.sortDirection = direction;
|
||||
return TabFiles.sortFiles(entries).map(e => e.name);
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
TabFiles.currentPath = '/alice/Documents';
|
||||
window.shared_path = '/alice/Shared';
|
||||
});
|
||||
|
||||
it('orders names with numbers by value, folders first', () => {
|
||||
expect(sorted('name', 'asc')).toEqual(['Beta', 'dir2', 'dir10', 'big.bin', 'file2.txt', 'file10.txt']);
|
||||
});
|
||||
|
||||
it('breaks ties in size by name', () => {
|
||||
expect(sorted('size', 'asc')).toEqual(['Beta', 'dir2', 'dir10', 'file2.txt', 'file10.txt', 'big.bin']);
|
||||
expect(sorted('size', 'desc')).toEqual(['dir10', 'dir2', 'Beta', 'big.bin', 'file10.txt', 'file2.txt']);
|
||||
});
|
||||
|
||||
it('shows trashed items by the name they had', () => {
|
||||
TabFiles.sortColumn = 'name';
|
||||
TabFiles.sortDirection = 'asc';
|
||||
const trashed = [
|
||||
{ name: 'uid-b', metadata: JSON.stringify({ original_name: 'b.txt' }) },
|
||||
{ name: 'uid-a', metadata: JSON.stringify({ original_name: 'a.txt' }) },
|
||||
];
|
||||
expect(TabFiles.sortFiles(trashed).map(e => e.name)).toEqual(['uid-a', 'uid-b']);
|
||||
});
|
||||
|
||||
it("keeps a renamed row among its sharer's in the Shared view", () => {
|
||||
window.shared_path = 'puter://shared';
|
||||
TabFiles.currentPath = 'puter://shared';
|
||||
TabFiles.sortColumn = 'shared_by';
|
||||
TabFiles.sortDirection = 'asc';
|
||||
const row = (name, sharer) => `<div class="item row" data-name="${name}" data-is_dir="0" data-shared_by="${sharer}"></div>`;
|
||||
document.body.innerHTML = `<div class="window"><div class="files-tab"><div class="files">
|
||||
${row('beta.txt', 'alice')}${row('zeta.txt', 'alice')}${row('alpha.txt', 'bob')}
|
||||
</div></div></div>`;
|
||||
TabFiles.$el_window = $('.window');
|
||||
// beta.txt renamed to "zzz.txt": still Alice's, so still before Bob's.
|
||||
const $renamed = $('.files .row').eq(0).attr('data-name', 'zzz.txt');
|
||||
|
||||
TabFiles.insertAtSortedPosition($renamed, { name: 'zzz.txt' });
|
||||
|
||||
expect($('.files .row').map((_, r) => r.getAttribute('data-name')).get()).toEqual(['zeta.txt', 'zzz.txt', 'alpha.txt']);
|
||||
});
|
||||
|
||||
it('puts an added row where a listing would', () => {
|
||||
TabFiles.sortColumn = 'size';
|
||||
TabFiles.sortDirection = 'asc';
|
||||
const listed = TabFiles.sortFiles(entries.filter(e => e.name !== 'file2.txt'));
|
||||
document.body.innerHTML = `<div class="window"><div class="files-tab"><div class="files">${listed.map(e => (
|
||||
`<div class="item row" data-name="${e.name}" data-is_dir="${e.is_dir ? 1 : 0}" data-size="${e.size}" data-modified="${e.modified}"></div>`
|
||||
)).join('')}</div></div></div>`;
|
||||
TabFiles.$el_window = $('.window');
|
||||
const $row = $('<div class="item row" data-name="file2.txt" data-is_dir="0"></div>').appendTo('.files');
|
||||
|
||||
TabFiles.insertAtSortedPosition($row, { name: 'file2.txt', size: 5, modified: 3 });
|
||||
|
||||
expect($('.files .row').map((_, r) => r.getAttribute('data-name')).get())
|
||||
.toEqual(['Beta', 'dir2', 'dir10', 'file2.txt', 'file10.txt', 'big.bin']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TabFiles permanent delete', () => {
|
||||
beforeEach(() => {
|
||||
window.trash_path = TRASH;
|
||||
window.delete_item = vi.fn(async () => {});
|
||||
window.refresh_trash_state = vi.fn(async () => {});
|
||||
});
|
||||
|
||||
it('asks about "this item" for one row and "these items" for several', async () => {
|
||||
const { default: UIAlert } = await import('../UIAlert.js');
|
||||
UIAlert.mockClear();
|
||||
UIAlert.mockResolvedValue('delete');
|
||||
mountListing(['a', 'b'], TRASH);
|
||||
const rows = $('.files .row').toArray();
|
||||
|
||||
await TabFiles.deleteRowsPermanently([rows[0]]);
|
||||
expect(UIAlert.mock.calls[0][0].message).toMatch(/this item/);
|
||||
expect(window.delete_item).toHaveBeenCalledTimes(1);
|
||||
|
||||
await TabFiles.deleteRowsPermanently(rows);
|
||||
expect(UIAlert.mock.calls[1][0].message).toMatch(/these items/);
|
||||
expect(window.delete_item).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('deletes nothing when cancelled', async () => {
|
||||
const { default: UIAlert } = await import('../UIAlert.js');
|
||||
UIAlert.mockResolvedValue('cancel');
|
||||
mountListing(['a'], TRASH);
|
||||
|
||||
await TabFiles.deleteRowsPermanently($('.files .row').toArray());
|
||||
expect(window.delete_item).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('TabFiles column widths that no longer fit', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
window.shared_path = 'puter://shared';
|
||||
window.trash_path = TRASH;
|
||||
mountListing([], '/alice/Documents');
|
||||
vi.spyOn($.fn, 'width').mockImplementation(function () {
|
||||
return this.hasClass('files') ? 1200 : 0;
|
||||
});
|
||||
vi.spyOn(TabFiles, 'fitItemNames').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('lay out at the defaults beside an auto-width name', () => {
|
||||
TabFiles.columnWidths = { name: null, sharedBy: 160, size: 100, modified: 1800 };
|
||||
TabFiles.applyColumnWidths();
|
||||
expect(TabFiles.columnGridTemplate).toBe('24px auto 4px 100px 4px 120px 4px 20px');
|
||||
expect(TabFiles.columnWidths.modified).toBe(1800);
|
||||
});
|
||||
|
||||
it('stay as saved while they fit', () => {
|
||||
TabFiles.columnWidths = { name: null, sharedBy: 160, size: 100, modified: 900 };
|
||||
TabFiles.applyColumnWidths();
|
||||
expect(TabFiles.columnGridTemplate).toBe('24px auto 4px 100px 4px 900px 4px 20px');
|
||||
});
|
||||
});
|
||||
|
||||
describe('TabFiles names in a narrow listing', () => {
|
||||
it('are not cut to the name column a wider listing was given', () => {
|
||||
const name = 'Quarterly financial report final version.pdf';
|
||||
document.body.innerHTML = `
|
||||
<div class="window"><div class="files-tab">
|
||||
<div class="header"><div class="columns" style="display: none"></div></div>
|
||||
<div class="files files-list-view">
|
||||
<div class="row item" data-name="${name}"><pre class="item-name"></pre></div>
|
||||
</div>
|
||||
</div></div>`;
|
||||
TabFiles.$el_window = $('.window');
|
||||
TabFiles.currentView = 'list';
|
||||
TabFiles.appliedColumnWidths = { name: 120, sharedBy: 160, size: 60, modified: 80 };
|
||||
|
||||
TabFiles.fitItemNames($('.files .item-name'));
|
||||
|
||||
expect($('.files .item-name').text()).toBe(name);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TabFiles uploads', () => {
|
||||
beforeEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
window.trash_path = TRASH;
|
||||
window.active_uploads = {};
|
||||
window.actions_history = [];
|
||||
window.show_save_account_notice_if_needed = vi.fn();
|
||||
const { default: UIWindowProgress } = await import('../UIWindowProgress.js');
|
||||
UIWindowProgress.mockResolvedValue({ set_status: vi.fn(), set_progress: vi.fn(), close: vi.fn(), show_error: vi.fn() });
|
||||
window.puter = { fs: { upload: vi.fn(async (items, dest, callbacks) => {
|
||||
await callbacks.init('op1', { abort: vi.fn() });
|
||||
await callbacks.success([{ path: `${dest}/new.txt` }]);
|
||||
}) } };
|
||||
vi.spyOn(TabFiles, 'renderDirectory').mockImplementation(async () => {});
|
||||
});
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('into another folder leave the listing and its selection alone', async () => {
|
||||
mountListing(['a.txt', 'b.txt'], '/alice/Documents');
|
||||
$('.files .row').addClass('selected');
|
||||
|
||||
TabFiles.uploadFiles([new File(['x'], 'new.txt')], '/alice/Documents/sub');
|
||||
await vi.waitFor(() => expect(window.actions_history).toHaveLength(1));
|
||||
|
||||
expect(TabFiles.renderDirectory).not.toHaveBeenCalled();
|
||||
expect($('.files .row.selected')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('into the folder on screen list it again', async () => {
|
||||
mountListing(['a.txt'], '/alice/Documents');
|
||||
|
||||
TabFiles.uploadFiles([new File(['x'], 'new.txt')], '/alice/Documents');
|
||||
await vi.waitFor(() => expect(TabFiles.renderDirectory).toHaveBeenCalledWith('/alice/Documents', { consistency: 'strong' }));
|
||||
});
|
||||
});
|
||||
|
||||
describe('TabFiles refreshing a folder whose icons are slow to read', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
window.user = { username: 'alice', directories: {} };
|
||||
window.home_path = '/alice';
|
||||
window.trash_path = TRASH;
|
||||
window.byte_format = bytes => `${bytes} B`;
|
||||
vi.spyOn(TabFiles, 'renderItem').mockImplementation(async (file) => {
|
||||
$('.files').append(`<div class="row item" data-uid="${file.uid}" data-path="${file.path}" data-name="${file.name}"></div>`);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('keeps the rows up until the new ones can be drawn', async () => {
|
||||
mountListing(['old.txt'], '/alice/Documents');
|
||||
TabFiles.renderingDirectory = false;
|
||||
TabFiles.trashedNames ??= new Map();
|
||||
window.puter = { fs: { readdir: vi.fn(async () => [
|
||||
{ uid: 'w', name: 'site.weblink', path: '/alice/Documents/site.weblink', is_dir: false, size: 1, modified: 0 },
|
||||
]) } };
|
||||
let finishIcon;
|
||||
itemIcon.mockImplementationOnce(() => new Promise(resolve => {
|
||||
finishIcon = resolve;
|
||||
}));
|
||||
|
||||
const refresh = TabFiles.renderDirectory('/alice/Documents');
|
||||
await vi.waitFor(() => expect(finishIcon).toBeTypeOf('function'));
|
||||
expect($('.files .row').map((_, r) => r.getAttribute('data-path')).get()).toEqual(['/alice/Documents/old.txt']);
|
||||
|
||||
finishIcon({ image: 'icon.svg', type: 'icon' });
|
||||
await refresh;
|
||||
expect($('.files .row').map((_, r) => r.getAttribute('data-name')).get()).toEqual(['site.weblink']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TabFiles rows still waiting on their mkdir', () => {
|
||||
it('are not moved, cut or trashed', async () => {
|
||||
window.user = { username: 'alice', directories: {} };
|
||||
window.trash_path = TRASH;
|
||||
mountListing(['a.txt', 'New Folder'], '/alice/Documents');
|
||||
const [file, pending] = $('.files .row').toArray();
|
||||
pending.setAttribute('data-pending', '1');
|
||||
|
||||
expect(await TabFiles.restructurableRows([file, pending])).toEqual([file]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TabFiles folder menu', () => {
|
||||
const publishItem = items => items.find(item => item?.html === i18n('publish_as_website'));
|
||||
|
||||
it("doesn't offer to publish a folder shared with the user to view", async () => {
|
||||
const { remember_shared_root } = await import('../../helpers/sharedAccess.js');
|
||||
window.user = { username: 'alice', directories: {} };
|
||||
window.trash_path = TRASH;
|
||||
window.shared_path = 'puter://shared';
|
||||
TabFiles.trashedNames ??= new Map();
|
||||
const viewOnly = '/bob/0e98/Photos';
|
||||
remember_shared_root({ path: viewOnly, mode: 'read', name: 'Photos' });
|
||||
TabFiles.currentPath = viewOnly;
|
||||
|
||||
expect(publishItem(TabFiles.generateFolderContextMenu(viewOnly)).disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('offers to publish your own folder', () => {
|
||||
window.user = { username: 'alice', directories: {} };
|
||||
window.trash_path = TRASH;
|
||||
window.shared_path = 'puter://shared';
|
||||
TabFiles.trashedNames ??= new Map();
|
||||
TabFiles.unreadablePath = '/alice/Documents';
|
||||
|
||||
expect(publishItem(TabFiles.generateFolderContextMenu('/alice/Documents')).disabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TabFiles opening a folder being renamed', () => {
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it('opens it under its new name', async () => {
|
||||
mountListing(['New Folder'], '/alice/Documents');
|
||||
const row = $('.files .row')[0];
|
||||
row.setAttribute('data-is_dir', '1');
|
||||
let finishRename;
|
||||
row._pendingRename = new Promise((resolve) => {
|
||||
finishRename = resolve;
|
||||
});
|
||||
vi.spyOn(TabFiles, 'pushNavHistory').mockImplementation(() => {});
|
||||
vi.spyOn(TabFiles, 'renderDirectory').mockImplementation(async () => {});
|
||||
|
||||
const opening = TabFiles.openRow(row);
|
||||
row.setAttribute('data-path', '/alice/Documents/Photos');
|
||||
finishRename();
|
||||
await opening;
|
||||
|
||||
expect(TabFiles.renderDirectory).toHaveBeenCalledWith('/alice/Documents/Photos');
|
||||
});
|
||||
|
||||
it('opens it anyway when the rename never reports back', async () => {
|
||||
vi.useFakeTimers();
|
||||
mountListing(['Photos'], '/alice/Documents');
|
||||
const row = $('.files .row')[0];
|
||||
row.setAttribute('data-is_dir', '1');
|
||||
row._pendingRename = new Promise(() => {});
|
||||
vi.spyOn(TabFiles, 'pushNavHistory').mockImplementation(() => {});
|
||||
vi.spyOn(TabFiles, 'renderDirectory').mockImplementation(async () => {});
|
||||
|
||||
const opening = TabFiles.openRow(row);
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
await opening;
|
||||
vi.useRealTimers();
|
||||
|
||||
expect(TabFiles.renderDirectory).toHaveBeenCalledWith('/alice/Documents/Photos');
|
||||
});
|
||||
});
|
||||
|
||||
describe('TabFiles.folderMoved', () => {
|
||||
beforeEach(() => {
|
||||
TabFiles.$el_window = $('<div>');
|
||||
});
|
||||
|
||||
it('points Back/Forward at a moved folder', () => {
|
||||
window.dashboard_nav_history = ['/alice', '/alice/A', '/alice/A/sub'];
|
||||
window.dashboard_nav_history_current_position = 0;
|
||||
TabFiles.folderMoved('/alice/A', '/alice/Docs/A');
|
||||
expect(window.dashboard_nav_history).toEqual(['/alice', '/alice/Docs/A', '/alice/Docs/A/sub']);
|
||||
});
|
||||
|
||||
it('forgets a trashed folder, and what was inside it', () => {
|
||||
window.dashboard_nav_history = ['/alice', '/alice/A', '/alice/A/sub', '/alice/B'];
|
||||
window.dashboard_nav_history_current_position = 3;
|
||||
TabFiles.folderMoved('/alice/A', null);
|
||||
expect(window.dashboard_nav_history).toEqual(['/alice', '/alice/B']);
|
||||
expect(window.dashboard_nav_history_current_position).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps the folder on screen, and folds the same folder twice in a row', () => {
|
||||
window.dashboard_nav_history = ['/alice', '/alice/A', '/alice', '/alice/A'];
|
||||
window.dashboard_nav_history_current_position = 2;
|
||||
TabFiles.folderMoved('/alice/A', null);
|
||||
expect(window.dashboard_nav_history).toEqual(['/alice']);
|
||||
expect(window.dashboard_nav_history_current_position).toBe(0);
|
||||
|
||||
window.dashboard_nav_history = ['/alice', '/alice/A'];
|
||||
window.dashboard_nav_history_current_position = 1;
|
||||
TabFiles.folderMoved('/alice/A', null);
|
||||
expect(window.dashboard_nav_history).toEqual(['/alice', '/alice/A']);
|
||||
});
|
||||
});
|
||||
@@ -26,13 +26,12 @@ import create_access_token from '../../helpers/createAccessToken.js';
|
||||
import postUserProtected from '../../helpers/postUserProtected.js';
|
||||
import update_username_in_gui from '../../helpers/updateUsernameInGui.js';
|
||||
import UIWindowManageSessions from '../UIWindowManageSessions.js';
|
||||
import UIDashboardFormDialog from './UIDashboardFormDialog.js';
|
||||
import UIDashboardFormDialog, { copyRowHtml } from './UIDashboardFormDialog.js';
|
||||
|
||||
// Keep in sync with ProfileService on the backend.
|
||||
const DISPLAY_NAME_MAX_LENGTH = 64;
|
||||
const BIO_MAX_LENGTH = 280;
|
||||
const TOKEN_LABEL_MAX_LENGTH = 64;
|
||||
const TOKEN_COPIED_RESET_MS = 2000;
|
||||
|
||||
/**
|
||||
* @typedef {object} AccountDialogOptions
|
||||
@@ -343,43 +342,13 @@ export function openCreateTokenDialog ({ $sessionsParent, ...opts } = {}) {
|
||||
const token = await create_access_token({ label: label.trim(), expiresIn: expiresIn || null });
|
||||
|
||||
let h = `<div class="dashboard-token-warning">${i18n('token_shown_once_warning')}</div>`;
|
||||
h += '<div class="dashboard-token-row">';
|
||||
h += `<input type="text" class="dashboard-dialog-input dashboard-token-value" readonly value="${html_encode(token)}" aria-label="${i18n('api_token')}" spellcheck="false">`;
|
||||
h += `<button type="button" class="dashboard-dialog-btn dashboard-dialog-btn-quiet dashboard-token-copy">${i18n('copy')}</button>`;
|
||||
h += '</div>';
|
||||
h += copyRowHtml({ value: token, label: t('api_token'), copiedLabel: t('token_copied') });
|
||||
h += `<p class="dashboard-form-hint">${i18n('token_manage_hint')} <button type="button" class="dashboard-token-sessions">${i18n('ui_manage_sessions')}</button>.</p>`;
|
||||
// The token is shown only once; only Done, ✕ or Escape put it away.
|
||||
dialog.finish({ html: h, describe: '.dashboard-token-warning', keepOnBackdrop: true });
|
||||
},
|
||||
});
|
||||
|
||||
let copied_timer = null;
|
||||
dialog.$dialog.on('click', '.dashboard-token-copy', function () {
|
||||
const $btn = $(this);
|
||||
const $value = dialog.$dialog.find('.dashboard-token-value');
|
||||
const copied = () => {
|
||||
$btn.text(t('token_copied'));
|
||||
clearTimeout(copied_timer);
|
||||
copied_timer = setTimeout(() => $btn.text(t('copy')), TOKEN_COPIED_RESET_MS);
|
||||
};
|
||||
// No clipboard access (an insecure origin has no navigator.clipboard
|
||||
// at all): select it, try the legacy copy, and leave it selected so
|
||||
// the user can copy it by hand.
|
||||
const by_hand = () => {
|
||||
$value.get(0)?.select();
|
||||
try {
|
||||
if ( document.execCommand('copy') ) copied();
|
||||
} catch { /* stays selected */ }
|
||||
};
|
||||
if ( navigator.clipboard?.writeText ) {
|
||||
navigator.clipboard.writeText(String($value.val())).then(copied, by_hand);
|
||||
} else {
|
||||
by_hand();
|
||||
}
|
||||
});
|
||||
dialog.$dialog.on('focus', '.dashboard-token-value', function () {
|
||||
this.select();
|
||||
});
|
||||
dialog.$dialog.on('click', '.dashboard-token-sessions', () => {
|
||||
UIWindowManageSessions({
|
||||
window_options: {
|
||||
|
||||
@@ -275,12 +275,12 @@ describe('create API token', () => {
|
||||
|
||||
expect(String(fetch.mock.calls[0][0])).toBe('https://api.puter.test/auth/create-access-token');
|
||||
expect(bodyOf(0)).toMatchObject({ label: 'CLI', expiresIn: '30d' });
|
||||
expect($('.dashboard-token-value').val()).toBe('tok-abc');
|
||||
expect($('.dashboard-copy-value').val()).toBe('tok-abc');
|
||||
|
||||
$('.dashboard-token-copy').trigger('click');
|
||||
$('.dashboard-copy-btn').trigger('click');
|
||||
await settle();
|
||||
expect(writeText).toHaveBeenCalledWith('tok-abc');
|
||||
expect($('.dashboard-token-copy').text()).toBe('token_copied');
|
||||
expect($('.dashboard-copy-btn').text()).toBe('token_copied');
|
||||
});
|
||||
|
||||
it('shows why minting failed', async () => {
|
||||
|
||||
@@ -398,6 +398,13 @@ async function UIDashboard (options) {
|
||||
});
|
||||
}
|
||||
|
||||
// Files' Back/Forward follows a folder moved elsewhere, and forgets
|
||||
// one moved to the Trash.
|
||||
if ( resp.is_dir && old_path && resp.path ) {
|
||||
const trashed = resp.path.startsWith(`${window.trash_path}/`);
|
||||
window.dashboard_object?.folderMoved?.(old_path, trashed ? null : resp.path);
|
||||
}
|
||||
|
||||
// Create new item at destination if user is viewing that directory
|
||||
if ( window.UIDashboardFileItem ) {
|
||||
window.UIDashboardFileItem(resp);
|
||||
@@ -407,6 +414,7 @@ async function UIDashboard (options) {
|
||||
window.socket.on('item.removed', async (item) => {
|
||||
if ( item.original_client_socket_id === window.socket.id ) return;
|
||||
if ( item.descendants_only ) return;
|
||||
if ( item.is_dir && item.path ) window.dashboard_object?.folderMoved?.(item.path, null);
|
||||
|
||||
// Match by uid when present (O(1) attribute selector; uids are UUIDs,
|
||||
// safe to interpolate) and fall back to a path scan for minimal
|
||||
@@ -533,10 +541,12 @@ async function UIDashboard (options) {
|
||||
notifications.close({ restoreFocus: false });
|
||||
// What was open over the tab being left goes with it, as on a
|
||||
// sidebar click: the phone drawer, a menu, and Files' Share and
|
||||
// Properties, which act on its rows (their changes are already saved).
|
||||
// Properties, which act on its rows (their changes are already saved),
|
||||
// and its image preview.
|
||||
setMobileSidebar(false);
|
||||
$('.context-menu').remove();
|
||||
$el_window.find('.share-modal-close, .item-props-close').trigger('click');
|
||||
if ( tab !== 'files' && TabFiles.previewOpen ) TabFiles.closeImagePreview();
|
||||
|
||||
// Switch to correct tab
|
||||
const $targetTab = $el_window.find(`.dashboard-sidebar-item[data-section="${tab}"]`);
|
||||
|
||||
@@ -28,6 +28,8 @@ import { ICONS, SVG } from './UIDashboardDialog.js';
|
||||
|
||||
const FORM_ICONS = {
|
||||
done: SVG('<circle cx="12" cy="12" r="9"/><path d="M8 12.5l2.5 2.5L16 9.5"/>'),
|
||||
copy: SVG('<rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>'),
|
||||
copied: SVG('<path d="M20 6L9 17l-5-5"/>'),
|
||||
show: SVG('<path d="M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7S2 12 2 12z"/><circle cx="12" cy="12" r="3"/>'),
|
||||
hide: SVG('<path d="M17.9 17.9A10.4 10.4 0 0 1 12 19c-6.4 0-10-7-10-7a18.5 18.5 0 0 1 5.1-5.9"/><path d="M9.9 5.2A9.1 9.1 0 0 1 12 5c6.4 0 10 7 10 7a18.6 18.6 0 0 1-2.2 3.2"/><path d="M14.1 14.1a3 3 0 1 1-4.2-4.2"/><path d="M2 2l20 20"/>'),
|
||||
};
|
||||
@@ -36,8 +38,11 @@ const FORM_ICONS = {
|
||||
* @typedef {object} FormDialogField
|
||||
* @property {string} name Key in `values()`.
|
||||
* @property {string} label HTML the caller has already encoded.
|
||||
* @property {'text'|'email'|'password'|'textarea'|'select'} [type] Defaults to text.
|
||||
* @property {'text'|'email'|'password'|'textarea'|'select'|'switch'} [type] Defaults to text.
|
||||
* @property {string} [value]
|
||||
* @property {boolean} [checked] A switch's initial state.
|
||||
* @property {string} [suffix] Plain text shown at the end of a text field, as
|
||||
* in a domain after the name typed into it.
|
||||
* @property {string} [placeholder] Already encoded.
|
||||
* @property {string} [hint] HTML the caller has already encoded, shown under the field.
|
||||
* @property {boolean} [hidden] Rendered but not shown; see `showField`.
|
||||
@@ -51,18 +56,21 @@ const FORM_ICONS = {
|
||||
* @typedef {object} FormDialog
|
||||
* @property {jQuery} $dialog
|
||||
* @property {() => void} close
|
||||
* @property {() => Record<string, string>} values Every field's current value.
|
||||
* @property {() => Record<string, string|boolean>} values Every field's
|
||||
* current value; a switch's is a boolean.
|
||||
* @property {(name: string) => jQuery} field A field's control.
|
||||
* @property {(name: string, show: boolean) => void} showField
|
||||
* @property {(text: string, opts?: { kind?: 'error'|'info', field?: string }) => void} setStatus
|
||||
* Plain text; an empty string clears it. Naming a field marks it invalid and focuses it.
|
||||
* @property {(opts: { message?: string, html?: string, describe?: string, keepOnBackdrop?: boolean }) => void} finish
|
||||
* Swaps the form for a done state (a plain-text `message`, or the caller's
|
||||
* encoded `html`) with a single Done button. `describe` selects the part of
|
||||
* Swaps the form for a done state (a plain-text `message`, then the
|
||||
* caller's encoded `html`, either or both) with a single Done button. `describe` selects the part of
|
||||
* `html` the focused Done button reads out. `keepOnBackdrop` stops a stray
|
||||
* click outside the card from closing it, for content shown only once.
|
||||
*/
|
||||
|
||||
const COPIED_RESET_MS = 2000;
|
||||
|
||||
const attrs_html = (attrs) => Object.entries(attrs ?? {})
|
||||
.filter(([, value]) => value !== undefined && value !== null && value !== false)
|
||||
.map(([key, value]) => (value === true ? ` ${key}` : ` ${key}="${html_encode(String(value))}"`))
|
||||
@@ -71,11 +79,28 @@ const attrs_html = (attrs) => Object.entries(attrs ?? {})
|
||||
const field_html = (field, id) => {
|
||||
const control_id = `${id}-${field.name}`;
|
||||
const hint_id = `${control_id}-hint`;
|
||||
const suffix_id = `${control_id}-suffix`;
|
||||
const type = field.type ?? 'text';
|
||||
const common = ` id="${control_id}" name="${html_encode(field.name)}" class="dashboard-dialog-input dashboard-form-input"`
|
||||
+ (field.hint ? ` aria-describedby="${hint_id}"` : '')
|
||||
// A switch is drawn by its slider, not the text-box style.
|
||||
const control_class = type === 'switch' ? 'dashboard-form-input' : 'dashboard-dialog-input dashboard-form-input';
|
||||
const described_by = [field.suffix && suffix_id, field.hint && hint_id].filter(Boolean).join(' ');
|
||||
const common = ` id="${control_id}" name="${html_encode(field.name)}" class="${control_class}"`
|
||||
+ (described_by ? ` aria-describedby="${described_by}"` : '')
|
||||
+ attrs_html(field.attrs);
|
||||
|
||||
if ( type === 'switch' ) {
|
||||
return `<div class="dashboard-form-field dashboard-form-switch-field" data-field="${html_encode(field.name)}"${field.hidden ? ' hidden' : ''}>`
|
||||
+ '<div class="dashboard-form-switch-text">'
|
||||
+ `<label class="dashboard-form-label" for="${control_id}">${field.label}</label>`
|
||||
+ (field.hint ? `<p class="dashboard-form-hint" id="${hint_id}">${field.hint}</p>` : '')
|
||||
+ '</div>'
|
||||
+ '<label class="dashboard-switch">'
|
||||
+ `<input type="checkbox" role="switch"${common}${field.checked ? ' checked' : ''}>`
|
||||
+ '<span class="dashboard-switch-slider"></span>'
|
||||
+ '</label>'
|
||||
+ '</div>';
|
||||
}
|
||||
|
||||
let control;
|
||||
if ( type === 'textarea' ) {
|
||||
control = `<textarea${common} placeholder="${field.placeholder ?? ''}">${html_encode(field.value ?? '')}</textarea>`;
|
||||
@@ -85,7 +110,9 @@ const field_html = (field, id) => {
|
||||
} else {
|
||||
control = `<input type="${type}"${common} placeholder="${field.placeholder ?? ''}" value="${html_encode(field.value ?? '')}">`;
|
||||
}
|
||||
if ( type === 'password' ) {
|
||||
if ( field.suffix ) {
|
||||
control = `<div class="dashboard-form-affix">${control}<span class="dashboard-form-suffix" id="${suffix_id}">${html_encode(field.suffix)}</span></div>`;
|
||||
} else if ( type === 'password' ) {
|
||||
control = '<div class="dashboard-form-secret">'
|
||||
+ control
|
||||
+ `<button type="button" class="dashboard-form-reveal" aria-controls="${control_id}"`
|
||||
@@ -100,6 +127,29 @@ const field_html = (field, id) => {
|
||||
+ '</div>';
|
||||
};
|
||||
|
||||
/**
|
||||
* A read-only value with a Copy button, for a `finish` screen. The dialog
|
||||
* wires the button.
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {string} opts.value Plain text.
|
||||
* @param {string} opts.label Plain text naming the value for screen readers.
|
||||
* @param {string} [opts.copiedLabel] Plain text the button shows once copied.
|
||||
* @param {boolean} [opts.compact] Icon buttons, leaving the value the width;
|
||||
* `after` should hold `dashboard-icon-btn`s too.
|
||||
* @param {string} [opts.after] HTML the caller has already encoded, after the button.
|
||||
* @returns {string}
|
||||
*/
|
||||
export const copyRowHtml = ({ value, label, copiedLabel, compact = false, after = '' }) =>
|
||||
`<div class="dashboard-copy-row${compact ? ' dashboard-copy-row-compact' : ''}">`
|
||||
+ `<input type="text" class="dashboard-dialog-input dashboard-copy-value" readonly value="${html_encode(value)}" aria-label="${html_encode(label)}" spellcheck="false">`
|
||||
+ `<button type="button" class="dashboard-dialog-btn dashboard-dialog-btn-quiet dashboard-copy-btn${compact ? ' dashboard-icon-btn' : ''}"`
|
||||
+ ` data-copied="${html_encode(copiedLabel ?? i18n('copied', [], false))}"`
|
||||
+ (compact ? ` aria-label="${i18n('copy')}" title="${i18n('copy')}">${FORM_ICONS.copy}` : `>${i18n('copy')}`)
|
||||
+ '</button>'
|
||||
+ after
|
||||
+ '</div>';
|
||||
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {string} opts.title Plain text.
|
||||
@@ -164,7 +214,7 @@ export default function UIDashboardFormDialog ({ title, intro, tone, fields = []
|
||||
values () {
|
||||
const out = {};
|
||||
$controls().each(function () {
|
||||
out[this.name] = String($(this).val() ?? '');
|
||||
out[this.name] = this.type === 'checkbox' ? this.checked : String($(this).val() ?? '');
|
||||
});
|
||||
return out;
|
||||
},
|
||||
@@ -196,10 +246,12 @@ export default function UIDashboardFormDialog ({ title, intro, tone, fields = []
|
||||
},
|
||||
finish ({ message, html, describe, keepOnBackdrop = false }) {
|
||||
keep_on_backdrop = keepOnBackdrop;
|
||||
const content = html ?? `<div class="dashboard-form-done">${FORM_ICONS.done}<p id="${id}-done">${html_encode(message ?? '')}</p></div>`;
|
||||
const content = (message === undefined ? '' : `<div class="dashboard-form-done">${FORM_ICONS.done}<p id="${id}-done">${html_encode(message)}</p></div>`)
|
||||
+ (html ?? '');
|
||||
$dialog.attr('aria-describedby', null);
|
||||
$dialog.find('.dashboard-dialog-body').html(`<div class="dashboard-dialog-content" role="status">${content}</div>`);
|
||||
const described = ! html || (describe && $dialog.find('.dashboard-dialog-body').find(describe).first().attr('id', `${id}-done`).length > 0);
|
||||
const described = message !== undefined
|
||||
|| (describe && $dialog.find('.dashboard-dialog-body').find(describe).first().attr('id', `${id}-done`).length > 0);
|
||||
// A status region that arrives with its text isn't announced;
|
||||
// the focused Done button reads the message out instead.
|
||||
$dialog.find('.dashboard-dialog-footer').html(
|
||||
@@ -219,6 +271,52 @@ export default function UIDashboardFormDialog ({ title, intro, tone, fields = []
|
||||
|
||||
$dialog.on('click', '.dashboard-form-cancel, .dashboard-form-done-btn', () => close());
|
||||
|
||||
let copied_timer = null;
|
||||
$dialog.on('click', '.dashboard-copy-btn', function () {
|
||||
const $btn = $(this);
|
||||
const $value = $btn.siblings('.dashboard-copy-value');
|
||||
const show = (done) => {
|
||||
const text = done ? $btn.attr('data-copied') : i18n('copy', [], false);
|
||||
if ( $btn.hasClass('dashboard-icon-btn') ) {
|
||||
$btn.attr({ 'aria-label': text, title: text }).html(done ? FORM_ICONS.copied : FORM_ICONS.copy);
|
||||
} else {
|
||||
$btn.text(text);
|
||||
}
|
||||
};
|
||||
const copied = () => {
|
||||
show(true);
|
||||
clearTimeout(copied_timer);
|
||||
copied_timer = setTimeout(() => show(false), COPIED_RESET_MS);
|
||||
};
|
||||
// No clipboard access (an insecure origin has no navigator.clipboard
|
||||
// at all): select it, try the legacy copy, and leave it selected so
|
||||
// the user can copy it by hand.
|
||||
const by_hand = () => {
|
||||
$value.get(0)?.select();
|
||||
try {
|
||||
if ( document.execCommand('copy') ) copied();
|
||||
} catch { /* stays selected */ }
|
||||
};
|
||||
if ( navigator.clipboard?.writeText ) {
|
||||
navigator.clipboard.writeText(String($value.val())).then(copied, by_hand);
|
||||
} else {
|
||||
by_hand();
|
||||
}
|
||||
});
|
||||
$dialog.on('focus', '.dashboard-copy-value', function () {
|
||||
this.select();
|
||||
});
|
||||
|
||||
// The suffix is drawn inside the field's box, so a press on it types into
|
||||
// the field, at the end of the name.
|
||||
$dialog.on('mousedown', '.dashboard-form-suffix', function (e) {
|
||||
const input = $(this).siblings('.dashboard-form-input').get(0);
|
||||
if ( ! input || input.disabled ) return;
|
||||
e.preventDefault();
|
||||
input.focus({ preventScroll: true });
|
||||
input.setSelectionRange(input.value.length, input.value.length);
|
||||
});
|
||||
|
||||
// Keep the field focused (and a phone's keyboard up) while toggling. The
|
||||
// label names the action, so the button carries no aria-pressed as well.
|
||||
$dialog.on('pointerdown mousedown', '.dashboard-form-reveal', (e) => e.preventDefault());
|
||||
|
||||
@@ -29,7 +29,7 @@ window.html_encode = globalThis.html_encode;
|
||||
window.requestAnimationFrame = (fn) => setTimeout(fn, 0);
|
||||
window.matchMedia = () => ({ matches: false });
|
||||
|
||||
const { default: UIDashboardFormDialog } = await import('./UIDashboardFormDialog.js');
|
||||
const { default: UIDashboardFormDialog, copyRowHtml } = await import('./UIDashboardFormDialog.js');
|
||||
|
||||
const tick = () => new Promise(resolve => setTimeout(resolve, 0));
|
||||
const submit = () => $('.dashboard-form-dialog').trigger('submit');
|
||||
@@ -207,6 +207,52 @@ describe('UIDashboardFormDialog', () => {
|
||||
expect($('.dashboard-dialog-overlay').hasClass('dashboard-dialog-show')).toBe(false);
|
||||
});
|
||||
|
||||
it('shows a message and the caller\'s html together, with a working copy row', async () => {
|
||||
const writeText = vi.fn(async () => {});
|
||||
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true });
|
||||
open({ onSubmit: (dialog) => dialog.finish({
|
||||
message: 'Live',
|
||||
html: copyRowHtml({ value: 'https://a.example', label: 'Address' }),
|
||||
}) });
|
||||
submit();
|
||||
await tick();
|
||||
|
||||
expect($('.dashboard-form-done p').text()).toBe('Live');
|
||||
expect($(`#${$('.dashboard-form-done-btn').attr('aria-describedby')}`).text()).toBe('Live');
|
||||
expect($('.dashboard-copy-value').val()).toBe('https://a.example');
|
||||
$('.dashboard-copy-btn').trigger('click');
|
||||
await tick();
|
||||
expect(writeText).toHaveBeenCalledWith('https://a.example');
|
||||
expect($('.dashboard-copy-btn').text()).toBe('copied');
|
||||
});
|
||||
|
||||
it('draws a suffix inside the field and has it read with the field', () => {
|
||||
open({ fields: [{ name: 'name', label: 'Address', suffix: '.puter.site <b>' }] });
|
||||
const $input = $('.dashboard-form-input[name="name"]');
|
||||
expect($input.closest('.dashboard-form-affix')).toHaveLength(1);
|
||||
expect($(`#${$input.attr('aria-describedby')}`).text()).toBe('.puter.site <b>');
|
||||
});
|
||||
|
||||
it('types into a suffixed field when its suffix is pressed', () => {
|
||||
open({ fields: [{ name: 'name', label: 'Address', value: 'my-site', suffix: '.puter.site' }] });
|
||||
const $input = $('.dashboard-form-input[name="name"]');
|
||||
$input.get(0).blur();
|
||||
const down = $.Event('mousedown');
|
||||
$('.dashboard-form-suffix').trigger(down);
|
||||
expect(down.isDefaultPrevented()).toBe(true);
|
||||
expect(document.activeElement).toBe($input.get(0));
|
||||
expect($input.get(0).selectionStart).toBe('my-site'.length);
|
||||
});
|
||||
|
||||
it('reads a switch back as a boolean', () => {
|
||||
const dialog = open({ fields: [
|
||||
{ name: 'on', type: 'switch', label: 'On', checked: true },
|
||||
{ name: 'off', type: 'switch', label: 'Off' },
|
||||
] });
|
||||
expect($('.dashboard-form-input[name="on"]').attr('role')).toBe('switch');
|
||||
expect(dialog.values()).toEqual({ on: true, off: false });
|
||||
});
|
||||
|
||||
it('toggles a password between hidden and shown', () => {
|
||||
open();
|
||||
const $input = $('.dashboard-form-input[name="secret"]');
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
// The Files tab's publish flows, a folder as a website and a .js file as a
|
||||
// worker: dashboard form dialogs rather than desktop windows.
|
||||
|
||||
import ensure_can_publish from '../../helpers/ensureCanPublish.js';
|
||||
import { SVG } from './UIDashboardDialog.js';
|
||||
import UIDashboardFormDialog, { copyRowHtml } from './UIDashboardFormDialog.js';
|
||||
import { finalizePublishName, normalizePublishName, PUBLISH_NAME_RULES } from './publishName.js';
|
||||
|
||||
/**
|
||||
* @typedef {object} PublishTarget
|
||||
* @property {string} name Display name of the folder or file.
|
||||
* @property {string} path
|
||||
* @property {jQuery} [$container] Where the overlay mounts.
|
||||
* @property {HTMLElement} [returnFocusTo] Where focus goes on close.
|
||||
*/
|
||||
|
||||
const t = (key) => i18n(key, [], false);
|
||||
|
||||
const OPEN_ICON = SVG('<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><path d="M15 3h6v6"/><path d="M10 14L21 3"/>');
|
||||
|
||||
/** Rows showing `path` or anything under it, on the dashboard or the desktop. */
|
||||
const each_item_at = (path, fn) => $('.item').each(function () {
|
||||
const item_path = this.dataset.path ?? '';
|
||||
if ( item_path === path || item_path.startsWith(`${path}/`) ) fn($(this), item_path);
|
||||
});
|
||||
|
||||
const KINDS = {
|
||||
website: {
|
||||
title: 'window_title_publish_website',
|
||||
intro: 'publish_website_intro',
|
||||
published: 'website_published',
|
||||
rule: PUBLISH_NAME_RULES.website,
|
||||
// Rejections that mean "pick another name" (SubdomainDriver).
|
||||
nameErrors: new Set(['conflict', 'subdomain_reserved']),
|
||||
suffix: () => `.${window.hosting_domain}`,
|
||||
requiresVerification: () => !! window.require_email_verification_to_publish_website,
|
||||
async publish (name, path) {
|
||||
const site = await puter.hosting.create(name, path);
|
||||
// The server's address carries the deployment's real scheme (a
|
||||
// self-hosted instance may serve sites over http).
|
||||
return site?.root_dir?.subdomains?.find((entry) => entry.subdomain === name)?.address
|
||||
?? `https://${name}.${window.hosting_domain}`;
|
||||
},
|
||||
markPublished (path, url) {
|
||||
each_item_at(path, ($item, item_path) => {
|
||||
if ( item_path === path ) {
|
||||
$item.attr('data-has_website', '1').find('.item-has-website-badge').show();
|
||||
const fsentry = $item.data('fsentry');
|
||||
if ( fsentry ) fsentry.has_website = true;
|
||||
} else {
|
||||
$item.attr('data-website_url', url + item_path.slice(path.length))
|
||||
.find('.item-has-website-url-badge').show();
|
||||
}
|
||||
});
|
||||
window.update_sites_cache?.();
|
||||
},
|
||||
},
|
||||
worker: {
|
||||
title: 'window_title_publish_worker',
|
||||
intro: 'publish_worker_intro',
|
||||
published: 'worker_published',
|
||||
rule: PUBLISH_NAME_RULES.worker,
|
||||
nameErrors: new Set(['conflict']),
|
||||
suffix: () => '.puter.work',
|
||||
requiresVerification: () => true,
|
||||
extraFields: () => [{
|
||||
name: 'sandboxed',
|
||||
type: 'switch',
|
||||
label: i18n('worker_sandboxed'),
|
||||
hint: i18n('worker_sandboxed_hint'),
|
||||
checked: true,
|
||||
}],
|
||||
async publish (name, path, { sandboxed }) {
|
||||
const result = await puter.workers.create(name, path, { sandbox: sandboxed });
|
||||
return result?.url ?? `https://${name}.puter.work`;
|
||||
},
|
||||
markPublished (path, url) {
|
||||
each_item_at(path, ($item, item_path) => {
|
||||
if ( item_path !== path ) return;
|
||||
$item.attr({ 'data-is_worker': '1', 'data-worker_url': url })
|
||||
.find('.item-is-worker').show();
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/** puter.js rejects with an Error, or with the API's `{ error: { message } }` / `{ message }`. */
|
||||
const error_message = (err) => err?.error?.message ?? err?.message ?? t('error_unknown_cause');
|
||||
|
||||
/**
|
||||
* @param {'website'|'worker'} kind_name
|
||||
* @param {PublishTarget} target
|
||||
*/
|
||||
const open_publish_dialog = async (kind_name, { name, path, ...opts }) => {
|
||||
const kind = KINDS[kind_name];
|
||||
if ( ! await ensure_can_publish(kind.requiresVerification()) ) return;
|
||||
|
||||
const fields = [{
|
||||
name: 'name',
|
||||
label: i18n('publish_address'),
|
||||
value: normalizePublishName(window.generate_identifier(), kind.rule),
|
||||
suffix: kind.suffix(),
|
||||
attrs: {
|
||||
maxlength: kind.rule.maxLength,
|
||||
autocomplete: 'off',
|
||||
autocapitalize: 'off',
|
||||
autocorrect: 'off',
|
||||
spellcheck: 'false',
|
||||
'data-gramm_editor': 'false',
|
||||
},
|
||||
}];
|
||||
fields.push(...(kind.extraFields?.() ?? []));
|
||||
|
||||
const dialog = UIDashboardFormDialog({
|
||||
...opts,
|
||||
title: t(kind.title),
|
||||
intro: `<p>${i18n(kind.intro, `<strong>${html_encode(name)}</strong>`, false)}</p>`,
|
||||
submitLabel: t('publish'),
|
||||
fields,
|
||||
async onSubmit () {
|
||||
const values = dialog.values();
|
||||
const subdomain = finalizePublishName(values.name, kind.rule);
|
||||
dialog.field('name').val(subdomain);
|
||||
if ( ! subdomain ) {
|
||||
dialog.setStatus(t('publish_name_required'), { field: 'name' });
|
||||
return;
|
||||
}
|
||||
let url;
|
||||
try {
|
||||
url = await kind.publish(subdomain, path, values);
|
||||
} catch ( err ) {
|
||||
const code = err?.error?.code ?? err?.code;
|
||||
dialog.setStatus(error_message(err), kind.nameErrors.has(code) ? { field: 'name' } : undefined);
|
||||
return;
|
||||
}
|
||||
kind.markPublished(path, url);
|
||||
const open_link = `<a class="dashboard-dialog-btn dashboard-dialog-btn-quiet dashboard-icon-btn dashboard-publish-open" href="${html_encode(url)}"`
|
||||
+ ` target="_blank" rel="noopener noreferrer" aria-label="${i18n('open_in_new_tab')}" title="${i18n('open_in_new_tab')}">${OPEN_ICON}</a>`;
|
||||
dialog.finish({
|
||||
message: t(kind.published),
|
||||
html: copyRowHtml({ value: url, label: t('publish_address'), compact: true, after: open_link }),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Shape the name as it's typed, keeping the caret where it was.
|
||||
const el_name = dialog.field('name').get(0);
|
||||
if ( document.activeElement === el_name ) el_name.select();
|
||||
const shape = () => {
|
||||
const next = normalizePublishName(el_name.value, kind.rule);
|
||||
if ( next === el_name.value ) return;
|
||||
const caret = normalizePublishName(el_name.value.slice(0, el_name.selectionStart ?? el_name.value.length), kind.rule).length;
|
||||
el_name.value = next;
|
||||
el_name.setSelectionRange(caret, caret);
|
||||
};
|
||||
// Rewriting the text mid-composition (an IME, or a dead key's accent)
|
||||
// breaks it; the composed text is shaped once it's in.
|
||||
$(el_name).on('input', (e) => {
|
||||
if ( ! e.originalEvent?.isComposing ) shape();
|
||||
}).on('compositionend', shape);
|
||||
return dialog;
|
||||
};
|
||||
|
||||
/**
|
||||
* Publishes a folder as a website, once the account may.
|
||||
*
|
||||
* @param {PublishTarget} target
|
||||
*/
|
||||
export const openPublishWebsiteDialog = (target) => open_publish_dialog('website', target);
|
||||
|
||||
/**
|
||||
* Deploys a .js file as a worker, once the account may.
|
||||
*
|
||||
* @param {PublishTarget} target
|
||||
*/
|
||||
export const openPublishWorkerDialog = (target) => open_publish_dialog('worker', target);
|
||||
@@ -0,0 +1,204 @@
|
||||
// @vitest-environment jsdom
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import jQuery from '../../lib/jquery-3.6.1/jquery-3.6.1.min.js';
|
||||
|
||||
vi.mock('../../helpers/ensureCanPublish.js', () => ({ default: vi.fn(async () => true) }));
|
||||
|
||||
globalThis.$ = jQuery;
|
||||
globalThis.jQuery = jQuery;
|
||||
globalThis.i18n = (key, replacements) => (Array.isArray(replacements) || replacements === undefined ? key : `${key}:${replacements}`);
|
||||
globalThis.html_encode = (value) => String(value ?? '')
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
window.html_encode = globalThis.html_encode;
|
||||
window.requestAnimationFrame = (fn) => setTimeout(fn, 0);
|
||||
window.matchMedia = () => ({ matches: false });
|
||||
|
||||
const { openPublishWebsiteDialog, openPublishWorkerDialog } = await import('./UIPublishDialogs.js');
|
||||
const { default: ensure_can_publish } = await import('../../helpers/ensureCanPublish.js');
|
||||
|
||||
const settle = () => new Promise(resolve => setTimeout(resolve, 0));
|
||||
const nameField = () => $('.dashboard-form-input[name="name"]');
|
||||
const status = () => $('.dashboard-form-status').text();
|
||||
|
||||
const submitWith = async (name) => {
|
||||
if ( name !== undefined ) nameField().val(name);
|
||||
$('.dashboard-form-dialog').trigger('submit');
|
||||
for ( let i = 0; i < 5; i++ ) await settle();
|
||||
};
|
||||
|
||||
const addRow = (path) => {
|
||||
$('body').append(`<div class="item" data-path="${path}" data-has_website="0" data-is_worker="0">`
|
||||
+ '<img class="item-has-website-badge" style="display:none"><img class="item-has-website-url-badge" style="display:none">'
|
||||
+ '<img class="item-is-worker" style="display:none"></div>');
|
||||
return $('.item').last();
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
ensure_can_publish.mockClear();
|
||||
ensure_can_publish.mockResolvedValue(true);
|
||||
window.hosting_domain = 'puter.site';
|
||||
window.generate_identifier = () => 'clever-idea-42';
|
||||
window.update_sites_cache = vi.fn();
|
||||
window.require_email_verification_to_publish_website = false;
|
||||
globalThis.puter = {
|
||||
hosting: { create: vi.fn(async () => ({})) },
|
||||
workers: { create: vi.fn(async (name) => ({ success: true, url: `https://${name}.puter.work` })) },
|
||||
};
|
||||
});
|
||||
|
||||
describe('publish website', () => {
|
||||
it('names the folder and suggests an address on the hosting domain', async () => {
|
||||
await openPublishWebsiteDialog({ name: 'Site <1>', path: '/alice/Site' });
|
||||
expect($('.dashboard-dialog-message').html()).toContain('<strong>Site <1></strong>');
|
||||
expect(nameField().val()).toBe('clever-idea-42');
|
||||
expect($('.dashboard-form-suffix').text()).toBe('.puter.site');
|
||||
});
|
||||
|
||||
it('publishes the folder, shows the live address, and badges its rows', async () => {
|
||||
const $folder = addRow('/alice/Site');
|
||||
const $child = addRow('/alice/Site/index.html');
|
||||
await openPublishWebsiteDialog({ name: 'Site', path: '/alice/Site' });
|
||||
|
||||
await submitWith(' -My Site- ');
|
||||
|
||||
expect(puter.hosting.create).toHaveBeenCalledWith('my-site', '/alice/Site');
|
||||
expect($('.dashboard-form-done p').text()).toBe('website_published');
|
||||
expect($('.dashboard-copy-value').val()).toBe('https://my-site.puter.site');
|
||||
expect($('.dashboard-publish-open').attr('href')).toBe('https://my-site.puter.site');
|
||||
const writeText = vi.fn(async () => {});
|
||||
Object.defineProperty(navigator, 'clipboard', { value: { writeText }, configurable: true });
|
||||
$('.dashboard-copy-btn').trigger('click');
|
||||
await settle();
|
||||
expect(writeText).toHaveBeenCalledWith('https://my-site.puter.site');
|
||||
expect($('.dashboard-copy-btn').attr('aria-label')).toBe('copied');
|
||||
expect($folder.attr('data-has_website')).toBe('1');
|
||||
expect($folder.find('.item-has-website-badge').css('display')).not.toBe('none');
|
||||
expect($child.attr('data-website_url')).toBe('https://my-site.puter.site/index.html');
|
||||
expect(window.update_sites_cache).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("links to the server's address for the site when it sends one", async () => {
|
||||
puter.hosting.create.mockResolvedValueOnce({
|
||||
subdomain: 'docs',
|
||||
root_dir: { subdomains: [{ subdomain: 'docs', address: 'http://docs.site.example.test' }] },
|
||||
});
|
||||
await openPublishWebsiteDialog({ name: 'Site', path: '/alice/Site' });
|
||||
await submitWith('docs');
|
||||
expect($('.dashboard-copy-value').val()).toBe('http://docs.site.example.test');
|
||||
expect($('.dashboard-publish-open').attr('href')).toBe('http://docs.site.example.test');
|
||||
});
|
||||
|
||||
it('shapes the address as it is typed', async () => {
|
||||
await openPublishWebsiteDialog({ name: 'Site', path: '/alice/Site' });
|
||||
nameField().val('My_Site 2!').trigger('input');
|
||||
expect(nameField().val()).toBe('my-site-2');
|
||||
});
|
||||
|
||||
it('leaves text being composed alone and shapes it once composed', async () => {
|
||||
await openPublishWebsiteDialog({ name: 'Site', path: '/alice/Site' });
|
||||
nameField().val('caf\u00B4');
|
||||
nameField().get(0).dispatchEvent(new InputEvent('input', { bubbles: true, isComposing: true }));
|
||||
expect(nameField().val()).toBe('caf\u00B4');
|
||||
nameField().val('café').trigger('compositionend');
|
||||
expect(nameField().val()).toBe('cafe');
|
||||
});
|
||||
|
||||
it('asks for an address when nothing usable was typed', async () => {
|
||||
await openPublishWebsiteDialog({ name: 'Site', path: '/alice/Site' });
|
||||
await submitWith('---');
|
||||
expect(status()).toBe('publish_name_required');
|
||||
expect(nameField().attr('aria-invalid')).toBe('true');
|
||||
expect(puter.hosting.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows why publishing failed and lets the user try again', async () => {
|
||||
puter.hosting.create.mockRejectedValueOnce({ error: { code: 'internal_error', message: 'Something broke' } });
|
||||
await openPublishWebsiteDialog({ name: 'Site', path: '/alice/Site' });
|
||||
await submitWith('fine');
|
||||
expect(status()).toBe('Something broke');
|
||||
expect(nameField().prop('disabled')).toBe(false);
|
||||
expect(nameField().attr('aria-invalid')).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['conflict', 'A site with this subdomain already exists'],
|
||||
['subdomain_reserved', "Subdomain 'www' is reserved"],
|
||||
])('points a %s rejection at the address field', async (code, message) => {
|
||||
puter.hosting.create.mockRejectedValueOnce({ error: { code, message }, code, message });
|
||||
await openPublishWebsiteDialog({ name: 'Site', path: '/alice/Site' });
|
||||
await submitWith('www');
|
||||
expect(status()).toBe(message);
|
||||
expect(nameField().attr('aria-invalid')).toBe('true');
|
||||
expect(document.activeElement).toBe(nameField().get(0));
|
||||
});
|
||||
|
||||
it('asks for verification only when the deployment needs it', async () => {
|
||||
window.require_email_verification_to_publish_website = true;
|
||||
ensure_can_publish.mockResolvedValueOnce(false);
|
||||
await openPublishWebsiteDialog({ name: 'Site', path: '/alice/Site' });
|
||||
expect(ensure_can_publish).toHaveBeenCalledWith(true);
|
||||
expect($('.dashboard-form-dialog')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('publish worker', () => {
|
||||
it('always needs a verified account', async () => {
|
||||
await openPublishWorkerDialog({ name: 'api.js', path: '/alice/api.js' });
|
||||
expect(ensure_can_publish).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('deploys sandboxed by default and badges the file', async () => {
|
||||
const $file = addRow('/alice/api.js');
|
||||
await openPublishWorkerDialog({ name: 'api.js', path: '/alice/api.js' });
|
||||
expect($('.dashboard-form-suffix').text()).toBe('.puter.work');
|
||||
|
||||
await submitWith('my_api');
|
||||
|
||||
expect(puter.workers.create).toHaveBeenCalledWith('my_api', '/alice/api.js', { sandbox: true });
|
||||
expect($('.dashboard-copy-value').val()).toBe('https://my_api.puter.work');
|
||||
expect($file.attr('data-is_worker')).toBe('1');
|
||||
expect($file.attr('data-worker_url')).toBe('https://my_api.puter.work');
|
||||
});
|
||||
|
||||
it('deploys unsandboxed when the switch is off', async () => {
|
||||
await openPublishWorkerDialog({ name: 'api.js', path: '/alice/api.js' });
|
||||
$('.dashboard-form-input[name="sandboxed"]').prop('checked', false);
|
||||
await submitWith('api');
|
||||
expect(puter.workers.create).toHaveBeenCalledWith('api', '/alice/api.js', { sandbox: false });
|
||||
});
|
||||
|
||||
it('shows a failed build in the status line', async () => {
|
||||
puter.workers.create.mockRejectedValueOnce(new Error('SyntaxError: Unexpected token'));
|
||||
await openPublishWorkerDialog({ name: 'api.js', path: '/alice/api.js' });
|
||||
await submitWith('api');
|
||||
expect(status()).toBe('SyntaxError: Unexpected token');
|
||||
expect(nameField().attr('aria-invalid')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('points a taken worker name at the address field', async () => {
|
||||
puter.workers.create.mockRejectedValueOnce({ error: { code: 'conflict', message: 'Worker name is already in use' } });
|
||||
await openPublishWorkerDialog({ name: 'api.js', path: '/alice/api.js' });
|
||||
await submitWith('api');
|
||||
expect(status()).toBe('Worker name is already in use');
|
||||
expect(nameField().attr('aria-invalid')).toBe('true');
|
||||
});
|
||||
});
|
||||
@@ -14,9 +14,6 @@
|
||||
* GNU Affero General Public License for more details.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { dedupedName } from './dedupedName.js';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { middleTruncate } from './middleTruncate.js';
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
// The names a website or worker can be published under, as the backend
|
||||
// checks them: a website's is a DNS label (SubdomainDriver), and a worker's
|
||||
// also allows underscores (WorkerDriver).
|
||||
|
||||
/**
|
||||
* @typedef {object} PublishNameRule
|
||||
* @property {RegExp} separators Runs that become one hyphen, as between words.
|
||||
* @property {RegExp} invalid Characters the name can't hold.
|
||||
* @property {number} maxLength
|
||||
*/
|
||||
|
||||
// Either name becomes a hostname label, which DNS caps at 63; the backend
|
||||
// takes a 64-character website name, but nothing can reach it.
|
||||
const LABEL_MAX_LENGTH = 63;
|
||||
|
||||
/** @type {{ website: PublishNameRule, worker: PublishNameRule }} */
|
||||
export const PUBLISH_NAME_RULES = {
|
||||
// A website name can't hold an underscore, so it separates words too.
|
||||
website: { separators: /[\s_]+/g, invalid: /[^a-z0-9-]/g, maxLength: LABEL_MAX_LENGTH },
|
||||
worker: { separators: /\s+/g, invalid: /[^a-z0-9_-]/g, maxLength: LABEL_MAX_LENGTH },
|
||||
};
|
||||
|
||||
/**
|
||||
* What a typed name becomes as the user types: lowercase, accents taken off
|
||||
* their letters, separator runs turned into a hyphen, and anything else the
|
||||
* rule refuses dropped.
|
||||
*
|
||||
* @param {string} value
|
||||
* @param {PublishNameRule} rule
|
||||
* @returns {string}
|
||||
*/
|
||||
export const normalizePublishName = (value, rule) => String(value ?? '')
|
||||
.toLowerCase()
|
||||
.normalize('NFKD')
|
||||
.replace(/\p{M}/gu, '')
|
||||
.replace(rule.separators, '-')
|
||||
.replace(rule.invalid, '');
|
||||
|
||||
/**
|
||||
* The name to submit: normalized, without the leading or trailing hyphens a
|
||||
* hostname can't start or end with. Empty when nothing usable is left.
|
||||
*
|
||||
* @param {string} value
|
||||
* @param {PublishNameRule} rule
|
||||
* @returns {string}
|
||||
*/
|
||||
export const finalizePublishName = (value, rule) => normalizePublishName(value, rule)
|
||||
.slice(0, rule.maxLength)
|
||||
.replace(/^-+|-+$/g, '');
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { finalizePublishName, normalizePublishName, PUBLISH_NAME_RULES } from './publishName.js';
|
||||
|
||||
const { website, worker } = PUBLISH_NAME_RULES;
|
||||
|
||||
describe('normalizePublishName', () => {
|
||||
it('lowercases and turns whitespace runs into one hyphen', () => {
|
||||
expect(normalizePublishName('My Cool Site', website)).toBe('my-cool-site');
|
||||
});
|
||||
|
||||
it('drops characters a website name cannot hold', () => {
|
||||
expect(normalizePublishName('my site!.v2', website)).toBe('my-sitev2');
|
||||
});
|
||||
|
||||
it('keeps the letter under an accent', () => {
|
||||
expect(normalizePublishName('Café Ünïcode', website)).toBe('cafe-unicode');
|
||||
expect(normalizePublishName('Ñandú_API', worker)).toBe('nandu_api');
|
||||
});
|
||||
|
||||
it('turns underscores into hyphens in a website name', () => {
|
||||
expect(normalizePublishName('My_Cool _Site', website)).toBe('my-cool-site');
|
||||
});
|
||||
|
||||
it('keeps underscores in a worker name', () => {
|
||||
expect(normalizePublishName('My_API', worker)).toBe('my_api');
|
||||
});
|
||||
|
||||
it('keeps hyphens at the ends while the user is still typing', () => {
|
||||
expect(normalizePublishName('my-', website)).toBe('my-');
|
||||
});
|
||||
});
|
||||
|
||||
describe('finalizePublishName', () => {
|
||||
it('trims hyphens from both ends', () => {
|
||||
expect(finalizePublishName(' -my site- ', website)).toBe('my-site');
|
||||
});
|
||||
|
||||
it('is empty when nothing usable is left', () => {
|
||||
expect(finalizePublishName('!!! ', website)).toBe('');
|
||||
});
|
||||
|
||||
it('caps the length at a hostname label\'s 63', () => {
|
||||
expect(finalizePublishName('a'.repeat(80), website)).toHaveLength(63);
|
||||
expect(finalizePublishName('a'.repeat(80), worker)).toHaveLength(63);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} SortKey
|
||||
* @property {string} name - The name the row shows
|
||||
* @property {number} [size]
|
||||
* @property {number} [modified]
|
||||
*/
|
||||
|
||||
// One collator for every comparison: localeCompare builds one per call.
|
||||
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
|
||||
|
||||
/**
|
||||
* Orders names as a file manager does: case-insensitively, with runs of
|
||||
* digits compared by value ("file2" before "file10"). Names equal but for
|
||||
* case or accents still get a fixed order.
|
||||
*
|
||||
* @param {string} a
|
||||
* @param {string} b
|
||||
* @returns {number}
|
||||
*/
|
||||
export function compareNames (a, b) {
|
||||
return collator.compare(a, b) || (a < b ? -1 : a > b ? 1 : 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Orders two listing entries by `column`, ascending. Equal sizes or dates
|
||||
* fall back to the name, so their order is the same on every listing.
|
||||
*
|
||||
* @param {SortKey} a
|
||||
* @param {SortKey} b
|
||||
* @param {'name'|'size'|'modified'} column
|
||||
* @returns {number}
|
||||
*/
|
||||
export function compareEntries (a, b, column) {
|
||||
let comparison = 0;
|
||||
if ( column === 'size' ) comparison = (a.size || 0) - (b.size || 0);
|
||||
else if ( column === 'modified' ) comparison = (a.modified || 0) - (b.modified || 0);
|
||||
return comparison || compareNames(a.name, b.name);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { compareEntries, compareNames } from './sortEntries.js';
|
||||
|
||||
const sortNames = names => [...names].sort(compareNames);
|
||||
|
||||
describe('compareNames', () => {
|
||||
it('orders runs of digits by value', () => {
|
||||
expect(sortNames(['file10.txt', 'file2.txt', 'file1.txt'])).toEqual(['file1.txt', 'file2.txt', 'file10.txt']);
|
||||
expect(sortNames(['IMG_100', 'IMG_9', 'IMG_10'])).toEqual(['IMG_9', 'IMG_10', 'IMG_100']);
|
||||
});
|
||||
|
||||
it('ignores case', () => {
|
||||
expect(sortNames(['beta', 'Alpha', 'gamma'])).toEqual(['Alpha', 'beta', 'gamma']);
|
||||
});
|
||||
|
||||
it('still orders names that differ only in case', () => {
|
||||
expect(compareNames('a', 'A')).not.toBe(0);
|
||||
expect(Math.sign(compareNames('a', 'A'))).toBe(-Math.sign(compareNames('A', 'a')));
|
||||
});
|
||||
});
|
||||
|
||||
describe('compareEntries', () => {
|
||||
it('breaks a tie in size or date by name', () => {
|
||||
const entries = [
|
||||
{ name: 'zeta', size: 0, modified: 5 },
|
||||
{ name: 'alpha', size: 0, modified: 5 },
|
||||
{ name: 'dir10', size: 0, modified: 5 },
|
||||
{ name: 'dir2', size: 0, modified: 5 },
|
||||
];
|
||||
const expected = ['alpha', 'dir2', 'dir10', 'zeta'];
|
||||
expect([...entries].sort((a, b) => compareEntries(a, b, 'size')).map(e => e.name)).toEqual(expected);
|
||||
expect([...entries].sort((a, b) => compareEntries(a, b, 'modified')).map(e => e.name)).toEqual(expected);
|
||||
});
|
||||
|
||||
it('orders by the column before the name', () => {
|
||||
const big = { name: 'a', size: 20, modified: 1 };
|
||||
const small = { name: 'b', size: 10, modified: 2 };
|
||||
expect(compareEntries(big, small, 'size')).toBeGreaterThan(0);
|
||||
expect(compareEntries(big, small, 'modified')).toBeLessThan(0);
|
||||
expect(compareEntries(big, small, 'name')).toBeLessThan(0);
|
||||
});
|
||||
|
||||
it('reads a missing size or date as 0', () => {
|
||||
expect(compareEntries({ name: 'a' }, { name: 'b', size: 1 }, 'size')).toBeLessThan(0);
|
||||
});
|
||||
});
|
||||
@@ -4250,6 +4250,11 @@ body.myapps-reordering .myapps-tile {
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* Holds the focus while the keys move the selection, which marks the place. */
|
||||
.dashboard-section-files .files-tab .files:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.dashboard-section-files .header .path-btn-disabled {
|
||||
opacity: 0.1;
|
||||
pointer-events: none;
|
||||
@@ -9279,6 +9284,95 @@ body.dashboard-mode .notifications-close-all {
|
||||
}
|
||||
}
|
||||
|
||||
/* A text field with fixed text after it (a domain), drawn as one box. */
|
||||
.dashboard-form-affix {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 38px;
|
||||
border: 1px solid var(--dashboard-border);
|
||||
border-radius: 10px;
|
||||
background: var(--dashboard-input-background);
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease, background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.dashboard-form-affix:focus-within {
|
||||
border-color: var(--select-color);
|
||||
background: var(--dashboard-card-background);
|
||||
box-shadow: 0 0 0 3px var(--select-ring);
|
||||
}
|
||||
|
||||
.dashboard-form-affix:has([aria-invalid='true']) {
|
||||
border-color: var(--dashboard-danger-text);
|
||||
}
|
||||
|
||||
.dashboard-dialog .dashboard-form-affix .dashboard-dialog-input,
|
||||
.dashboard-dialog .dashboard-form-affix .dashboard-dialog-input:focus {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
padding: 0 2px 0 12px;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Locked while the form submits: dim the whole box, suffix included, as
|
||||
one field rather than the input alone. */
|
||||
.dashboard-form-affix:has(:disabled) {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.dashboard-dialog .dashboard-form-affix .dashboard-dialog-input:disabled {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.dashboard-form-suffix {
|
||||
flex-shrink: 0;
|
||||
max-width: 60%;
|
||||
padding-right: 12px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 14px;
|
||||
color: var(--dashboard-text-muted);
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
.dashboard-form-affix {
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.dashboard-form-suffix {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
/* A switch sits beside its label and hint, as on the settings cards. */
|
||||
.dashboard-form-switch-field {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.dashboard-form-switch-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dashboard-form-switch-field .dashboard-form-label {
|
||||
font-size: 13px;
|
||||
color: var(--dashboard-text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Password fields carry a show/hide toggle inside the box. */
|
||||
.dashboard-form-secret {
|
||||
position: relative;
|
||||
@@ -9338,6 +9432,7 @@ body.dashboard-mode .notifications-close-all {
|
||||
the content column's gap it would otherwise still claim. */
|
||||
.dashboard-form-status {
|
||||
padding: 9px 12px;
|
||||
white-space: pre-line;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
@@ -9425,12 +9520,13 @@ body.dashboard-mode .notifications-close-all {
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.dashboard-token-row {
|
||||
/* A value to copy: the API token, a published address. */
|
||||
.dashboard-copy-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dashboard-dialog .dashboard-dialog-input.dashboard-token-value {
|
||||
.dashboard-dialog .dashboard-dialog-input.dashboard-copy-value {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
@@ -9439,15 +9535,37 @@ body.dashboard-mode .notifications-close-all {
|
||||
|
||||
/* 16px, like the other dialog inputs on touch, so iOS doesn't zoom on focus. */
|
||||
@media (pointer: coarse) {
|
||||
.dashboard-dialog .dashboard-dialog-input.dashboard-token-value {
|
||||
.dashboard-dialog .dashboard-dialog-input.dashboard-copy-value {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-token-copy {
|
||||
.dashboard-copy-row .dashboard-dialog-btn {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* A square button holding only an icon, as in a compact copy row. */
|
||||
.dashboard-dialog-btn.dashboard-icon-btn {
|
||||
min-width: 36px;
|
||||
width: 36px;
|
||||
padding: 0;
|
||||
color: var(--dashboard-text-secondary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.dashboard-icon-btn svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
.dashboard-dialog-btn.dashboard-icon-btn {
|
||||
min-width: 44px;
|
||||
width: 44px;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-token-sessions {
|
||||
display: inline;
|
||||
padding: 0;
|
||||
@@ -9471,7 +9589,7 @@ body.dashboard-mode .notifications-close-all {
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.dashboard-token-row {
|
||||
.dashboard-copy-row:not(.dashboard-copy-row-compact) {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
+22
-17
@@ -18,6 +18,7 @@
|
||||
*/
|
||||
|
||||
import { createUploadThumbnailGenerator } from './services/pdfThumbnails/index.js';
|
||||
import { dedupedName } from './helpers/dedupedName.js';
|
||||
import { archiveEntryPath } from './helpers/archiveEntryPath.js';
|
||||
import get_html_element_from_options from './helpers/getHtmlElementFromOptions.js';
|
||||
import globToRegExp from './helpers/globToRegExp.js';
|
||||
@@ -1961,6 +1962,12 @@ window.move_items = async function (el_items, dest_path, is_undo = false) {
|
||||
});
|
||||
}
|
||||
|
||||
// The dashboard's Back/Forward follows a moved folder, and
|
||||
// forgets a trashed one.
|
||||
if ( $(el_item).attr('data-is_dir') === '1' ) {
|
||||
window.dashboard_object?.folderMoved?.($(el_item).attr('data-path'), is_trashing ? null : fsentry.path);
|
||||
}
|
||||
|
||||
if ( is_trashing ) {
|
||||
// if trashing dir...
|
||||
if ( $(el_item).attr('data-is_dir') === '1' ) {
|
||||
@@ -2631,7 +2638,10 @@ window.zipItems = async function (el_items, targetDirPath, download = true) {
|
||||
progwin?.set_status(i18n('zip', 'Selection(s)'));
|
||||
}, 500);
|
||||
|
||||
let toBeZipped = {};
|
||||
const toBeZipped = {};
|
||||
// Shared items from different people can share a name; each keeps its
|
||||
// own entry rather than overwriting the other.
|
||||
const topNames = [];
|
||||
|
||||
let perItemAdditionProgress = window.zippingProgressConfig.SEQUENCING / el_items.length;
|
||||
let currentProgress = 0;
|
||||
@@ -2639,6 +2649,8 @@ window.zipItems = async function (el_items, targetDirPath, download = true) {
|
||||
const el_item = el_items[idx];
|
||||
if ( window.operation_cancelled[zip_operation_id] ) return;
|
||||
let targetPath = $(el_item).attr('data-path');
|
||||
const topName = dedupedName(path.basename(targetPath), topNames);
|
||||
topNames.push(topName);
|
||||
|
||||
// if directory, zip the directory
|
||||
if ( $(el_item).attr('data-is_dir') === '1' ) {
|
||||
@@ -2650,12 +2662,10 @@ window.zipItems = async function (el_items, targetDirPath, download = true) {
|
||||
for ( let cIdx = 0; cIdx < children.length; cIdx++ ) {
|
||||
const child = children[cIdx];
|
||||
|
||||
if ( ! child.relativePath ) {
|
||||
// Add empty directiories to the zip
|
||||
toBeZipped = {
|
||||
...toBeZipped,
|
||||
[`${path.basename(child.path)}/`]: [await blobToUint8Array(new Blob()), { level: 9 }],
|
||||
};
|
||||
if ( child.emptyDirPath !== undefined ) {
|
||||
// Add empty directories to the zip, where they sit
|
||||
const dirPath = el_items.length === 1 ? child.emptyDirPath : `${topName}/${child.emptyDirPath}`;
|
||||
toBeZipped[dirPath || `${topName}/`] = [await blobToUint8Array(new Blob()), { level: 9 }];
|
||||
} else {
|
||||
// Add files from directory to the zip
|
||||
let relativePath;
|
||||
@@ -2665,17 +2675,14 @@ window.zipItems = async function (el_items, targetDirPath, download = true) {
|
||||
}
|
||||
else
|
||||
{
|
||||
relativePath = `${path.basename(targetPath) }/${ child.relativePath}`;
|
||||
relativePath = `${topName}/${child.relativePath}`;
|
||||
}
|
||||
|
||||
// read file content
|
||||
progwin?.set_status(i18n('sequencing', child.relativePath));
|
||||
let content = await puter.fs.read(child.path);
|
||||
try {
|
||||
toBeZipped = {
|
||||
...toBeZipped,
|
||||
[relativePath]: [await blobToUint8Array(content), { level: 9 }],
|
||||
};
|
||||
toBeZipped[relativePath] = [await blobToUint8Array(content), { level: 9 }];
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
@@ -2688,10 +2695,7 @@ window.zipItems = async function (el_items, targetDirPath, download = true) {
|
||||
else {
|
||||
progwin?.set_status(i18n('reading', path.basename($(el_items[0]).attr('data-path'))));
|
||||
let content = await puter.fs.read(targetPath);
|
||||
toBeZipped = {
|
||||
...toBeZipped,
|
||||
[path.basename(targetPath)]: [await blobToUint8Array(content), { level: 9 }],
|
||||
};
|
||||
toBeZipped[topName] = [await blobToUint8Array(content), { level: 9 }];
|
||||
currentProgress += perItemAdditionProgress;
|
||||
progwin?.set_progress(currentProgress.toPrecision(2));
|
||||
}
|
||||
@@ -2762,7 +2766,8 @@ async function readDirectoryRecursive (path, baseDir = '') {
|
||||
const entries = await puter.fs.readdir(path, { consistency: 'eventual' });
|
||||
|
||||
if ( entries.length === 0 ) {
|
||||
allFiles.push({ path });
|
||||
// An empty folder, by where it sits in the folder being read.
|
||||
allFiles.push({ path, emptyDirPath: baseDir });
|
||||
} else {
|
||||
// Process each entry
|
||||
for ( const entry of entries ) {
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import path from '../../lib/path.js';
|
||||
import path from '../lib/path.js';
|
||||
|
||||
/** Matches the backend's cap in FSService#findDedupedName. */
|
||||
const MAX_SUFFIX = 100_000;
|
||||
File renamed without changes.
@@ -0,0 +1,51 @@
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import UIWindowSaveAccount from '../UI/UIWindowSaveAccount.js';
|
||||
import UIWindowEmailConfirmationRequired from '../UI/UIWindowEmailConfirmationRequired.js';
|
||||
|
||||
/**
|
||||
* Walks a temporary account through saving itself, and an unconfirmed one
|
||||
* through confirming its email, before it publishes.
|
||||
*
|
||||
* @param {boolean} required - Whether this deployment needs a verified email
|
||||
* for this kind of publish; when false, resolves true straight away.
|
||||
* @returns {Promise<boolean>} False if the user backed out of either step.
|
||||
*/
|
||||
const ensure_can_publish = async function (required) {
|
||||
if ( ! required ) return true;
|
||||
if ( window.user.is_temp && ! await UIWindowSaveAccount({
|
||||
send_confirmation_code: true,
|
||||
message: i18n('save_account_to_publish'),
|
||||
window_options: {
|
||||
backdrop: true,
|
||||
close_on_backdrop_click: false,
|
||||
},
|
||||
}) ) {
|
||||
return false;
|
||||
}
|
||||
// Saving refreshes window.user; a deployment that doesn't confirm at
|
||||
// signup leaves the new account unconfirmed, and it still has to confirm.
|
||||
if ( ! window.user.email_confirmed ) {
|
||||
return !! await UIWindowEmailConfirmationRequired();
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
export default ensure_can_publish;
|
||||
@@ -0,0 +1,81 @@
|
||||
// @vitest-environment jsdom
|
||||
/*
|
||||
* Copyright (C) 2024-present Puter Technologies Inc.
|
||||
*
|
||||
* This file is part of Puter.
|
||||
*
|
||||
* Puter is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../UI/UIWindowSaveAccount.js', () => ({ default: vi.fn() }));
|
||||
vi.mock('../UI/UIWindowEmailConfirmationRequired.js', () => ({ default: vi.fn() }));
|
||||
|
||||
globalThis.i18n = (key) => key;
|
||||
|
||||
const { default: ensure_can_publish } = await import('./ensureCanPublish.js');
|
||||
const { default: UIWindowSaveAccount } = await import('../UI/UIWindowSaveAccount.js');
|
||||
const { default: UIWindowEmailConfirmationRequired } = await import('../UI/UIWindowEmailConfirmationRequired.js');
|
||||
|
||||
beforeEach(() => {
|
||||
UIWindowSaveAccount.mockReset();
|
||||
UIWindowEmailConfirmationRequired.mockReset();
|
||||
});
|
||||
|
||||
describe('ensure_can_publish', () => {
|
||||
it('lets anyone through when verification is not required', async () => {
|
||||
window.user = { is_temp: true };
|
||||
expect(await ensure_can_publish(false)).toBe(true);
|
||||
expect(UIWindowSaveAccount).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('has a temporary account saved first', async () => {
|
||||
window.user = { is_temp: true };
|
||||
UIWindowSaveAccount.mockResolvedValueOnce(false);
|
||||
expect(await ensure_can_publish(true)).toBe(false);
|
||||
expect(UIWindowEmailConfirmationRequired).not.toHaveBeenCalled();
|
||||
|
||||
// Saving replaces window.user with the saved, confirmed account.
|
||||
UIWindowSaveAccount.mockImplementationOnce(async () => {
|
||||
window.user = { is_temp: false, email_confirmed: true };
|
||||
return true;
|
||||
});
|
||||
expect(await ensure_can_publish(true)).toBe(true);
|
||||
expect(UIWindowEmailConfirmationRequired).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('still has a just-saved account confirm its email when saving left it unconfirmed', async () => {
|
||||
window.user = { is_temp: true };
|
||||
UIWindowSaveAccount.mockImplementationOnce(async () => {
|
||||
window.user = { is_temp: false, email_confirmed: false };
|
||||
return true;
|
||||
});
|
||||
UIWindowEmailConfirmationRequired.mockResolvedValueOnce(false);
|
||||
expect(await ensure_can_publish(true)).toBe(false);
|
||||
expect(UIWindowEmailConfirmationRequired).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('has an unconfirmed email confirmed first', async () => {
|
||||
window.user = { is_temp: false, email_confirmed: false };
|
||||
UIWindowEmailConfirmationRequired.mockResolvedValueOnce(false);
|
||||
expect(await ensure_can_publish(true)).toBe(false);
|
||||
expect(UIWindowSaveAccount).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lets a confirmed account through', async () => {
|
||||
window.user = { is_temp: false, email_confirmed: true };
|
||||
expect(await ensure_can_publish(true)).toBe(true);
|
||||
expect(UIWindowEmailConfirmationRequired).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -20,11 +20,10 @@
|
||||
import UIAlert from '../UI/UIAlert.js';
|
||||
|
||||
import UIWindowItemProperties from '../UI/UIWindowItemProperties.js';
|
||||
import UIWindowSaveAccount from '../UI/UIWindowSaveAccount.js';
|
||||
import UIWindowEmailConfirmationRequired from '../UI/UIWindowEmailConfirmationRequired.js';
|
||||
import UIWindowPublishWorker from '../UI/UIWindowPublishWorker.js';
|
||||
import UIWindowShare from '../UI/UIWindowShare.js';
|
||||
import publish_as_website from './publishAsWebsite.js';
|
||||
import ensure_can_publish from './ensureCanPublish.js';
|
||||
import open_item from './openItem.js';
|
||||
import openWithAI from './openWithAI.js';
|
||||
import launch_app from './launchApp.js';
|
||||
@@ -47,6 +46,8 @@ import { is_default_app } from './defaultApp.js';
|
||||
* @param {Function} options.onOpen - Optional custom open handler (used by Dashboard)
|
||||
* @param {Function} options.onShowProperties - Optional custom properties handler (used by Dashboard); receives {name, path, uid, element}
|
||||
* @param {Function} options.onShare - Optional custom share handler (used by Dashboard); receives {name, path, uid, element}
|
||||
* @param {Function} options.onPublishWebsite - Optional custom publish-as-website handler (used by Dashboard); receives {uid, name, path}
|
||||
* @param {Function} options.onPublishWorker - Optional custom publish-as-worker handler (used by Dashboard); receives {uid, name, path}
|
||||
* @param {Function} options.onPasteInto - Optional custom paste handler (used by Dashboard); receives the folder's path
|
||||
* @param {Function} options.onRemovedFromShared - Optional, called once Remove from Shared has taken the item's row away (used by Dashboard)
|
||||
* @returns {Promise<Array>} Array of context menu items
|
||||
@@ -142,11 +143,16 @@ const generate_file_context_menu = async function (options) {
|
||||
// enforces that). `write` on someone else's folder is not enough.
|
||||
disabled: !fsentry.is_dir || fsentry.has_website || !may_share,
|
||||
onClick: async function () {
|
||||
await publish_as_website({
|
||||
const target = {
|
||||
uid: fsentry.uid,
|
||||
name: $(el_item).attr('data-name'),
|
||||
path: $(el_item).attr('data-path'),
|
||||
});
|
||||
};
|
||||
if ( options.onPublishWebsite ) {
|
||||
options.onPublishWebsite(target);
|
||||
} else {
|
||||
await publish_as_website(target);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -160,22 +166,17 @@ const generate_file_context_menu = async function (options) {
|
||||
html: i18n('publish_as_serverless_worker'),
|
||||
disabled: is_worker,
|
||||
onClick: async function () {
|
||||
if ( window.user.is_temp &&
|
||||
!await UIWindowSaveAccount({
|
||||
send_confirmation_code: true,
|
||||
message: i18n('save_account_to_publish'),
|
||||
window_options: {
|
||||
backdrop: true,
|
||||
close_on_backdrop_click: false,
|
||||
},
|
||||
}) ) {
|
||||
const target = {
|
||||
uid: fsentry.uid,
|
||||
name: $(el_item).attr('data-name'),
|
||||
path: $(el_item).attr('data-path'),
|
||||
};
|
||||
if ( options.onPublishWorker ) {
|
||||
options.onPublishWorker(target);
|
||||
return;
|
||||
}
|
||||
else if ( !window.user.email_confirmed && !await UIWindowEmailConfirmationRequired() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
UIWindowPublishWorker(fsentry.uid, $(el_item).attr('data-name'), $(el_item).attr('data-path'));
|
||||
if ( ! await ensure_can_publish(true) ) return;
|
||||
UIWindowPublishWorker(target.uid, target.name, target.path);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -18,8 +18,7 @@
|
||||
*/
|
||||
|
||||
import UIWindowPublishWebsite from '../UI/UIWindowPublishWebsite.js';
|
||||
import UIWindowSaveAccount from '../UI/UIWindowSaveAccount.js';
|
||||
import UIWindowEmailConfirmationRequired from '../UI/UIWindowEmailConfirmationRequired.js';
|
||||
import ensure_can_publish from './ensureCanPublish.js';
|
||||
|
||||
/**
|
||||
* Opens the "Publish as website" flow for a directory, first walking the user
|
||||
@@ -34,25 +33,7 @@ import UIWindowEmailConfirmationRequired from '../UI/UIWindowEmailConfirmationRe
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const publish_as_website = async function ({ uid, name, path }) {
|
||||
if ( window.require_email_verification_to_publish_website ) {
|
||||
if ( window.user.is_temp &&
|
||||
!await UIWindowSaveAccount({
|
||||
send_confirmation_code: true,
|
||||
message: i18n('save_account_to_publish'),
|
||||
window_options: {
|
||||
backdrop: true,
|
||||
close_on_backdrop_click: false,
|
||||
},
|
||||
}) )
|
||||
{
|
||||
return;
|
||||
}
|
||||
else if ( !window.user.email_confirmed && !await UIWindowEmailConfirmationRequired() )
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if ( ! await ensure_can_publish(window.require_email_verification_to_publish_website) ) return;
|
||||
await UIWindowPublishWebsite(uid, name, path);
|
||||
};
|
||||
|
||||
|
||||
@@ -140,6 +140,7 @@ const en = {
|
||||
contact_us_verification_required: 'You must have a verified email address to use this.',
|
||||
contain: 'Contain',
|
||||
continue: 'Continue',
|
||||
copied: 'Copied',
|
||||
copy: 'Copy',
|
||||
copy_link: 'Copy Link',
|
||||
copying: 'Copying',
|
||||
@@ -416,6 +417,10 @@ const en = {
|
||||
publish: 'Publish',
|
||||
publish_as_website: 'Publish as website',
|
||||
publish_as_serverless_worker: 'Publish as Worker',
|
||||
publish_address: 'Address',
|
||||
publish_name_required: 'Enter an address.',
|
||||
publish_website_intro: 'Serves %strong% at a public address anyone can visit.',
|
||||
publish_worker_intro: 'Runs %strong% at a public address anyone can call.',
|
||||
puter_description: 'Puter is a privacy-first personal cloud to keep all your files, apps, and games in one secure place, accessible from anywhere at any time.',
|
||||
ram: 'RAM',
|
||||
reading: 'Reading %strong%',
|
||||
@@ -751,6 +756,10 @@ const en = {
|
||||
download_as_tar: 'Download as Tar',
|
||||
sequencing: 'Sequencing %strong%',
|
||||
worker: 'Worker',
|
||||
worker_published: 'Your worker is live',
|
||||
worker_sandboxed: 'Sandboxed',
|
||||
worker_sandboxed_hint: 'Keeps the worker\'s data separate from the rest of your account.',
|
||||
website_published: 'Your website is live',
|
||||
zipping: 'Zipping %strong%',
|
||||
tarring: 'Tarring %strong%',
|
||||
|
||||
|
||||
Reference in new issue
Block a user