web/satellite/vuetify-poc: shorten project ID in URLs

This change shortens the project ID path segment in Vuetify URLs in
order to make the URLs more aesthetically pleasing and allow users to
see more of the URL in the address bar. The ID path segment is now 11
characters long instead of the previous 36, but in rare cases where a
user is a member of multiple projects with the same ID prefix, it
expands to preserve uniqueness.

Resolves #6308

Change-Id: I25a51d05b72d2cc701c0aa2cd3a6d070080c4b1e
This commit is contained in:
Jeremy Wharton 2023-09-27 22:43:38 -05:00 committed by Storj Robot
parent b069b9b038
commit 4dbf26e153
16 changed files with 218 additions and 50 deletions

View File

@ -20,9 +20,13 @@ import {
} from '@/types/projects';
import { ProjectsHttpApi } from '@/api/projects';
import { DEFAULT_PAGE_LIMIT } from '@/types/pagination';
import { hexToBase64 } from '@/utils/strings';
const DEFAULT_PROJECT = new Project('', '', '', '', '', true, 0);
const DEFAULT_INVITATION = new ProjectInvitation('', '', '', '', new Date());
const MAXIMUM_URL_ID_LENGTH = 22; // UUID (16 bytes) is 22 base64 characters
export const MINIMUM_URL_ID_LENGTH = 11;
export const DEFAULT_PROJECT_LIMITS = readonly(new ProjectLimits());
export class ProjectsState {
@ -53,12 +57,51 @@ export const useProjectsStore = defineStore('projects', () => {
return projects;
}
function calculateURLIds(): void {
type urlIdInfo = {
project: Project;
base64Id: string;
urlIdLength: number;
};
const occupied: Record<string, urlIdInfo[]> = {};
state.projects.forEach(p => {
const b64Id = hexToBase64(p.id.replaceAll('-', ''));
const info: urlIdInfo = {
project: p,
base64Id: b64Id,
urlIdLength: MINIMUM_URL_ID_LENGTH,
};
for (; info.urlIdLength <= MAXIMUM_URL_ID_LENGTH; info.urlIdLength++) {
const urlId = b64Id.substring(0, info.urlIdLength);
const others = occupied[urlId];
if (others) {
if (info.urlIdLength === others[0].urlIdLength && info.urlIdLength !== MAXIMUM_URL_ID_LENGTH) {
others.forEach(other => {
occupied[other.base64Id.substring(0, ++other.urlIdLength)] = [other];
});
}
others.push(info);
} else {
occupied[urlId] = [info];
break;
}
}
});
Object.keys(occupied).forEach(urlId => {
const infos = occupied[urlId];
if (infos.length !== 1) return;
infos[0].project.urlId = urlId;
});
}
function setProjects(projects: Project[]): void {
state.projects = projects;
if (!state.selectedProject.id) {
return;
}
calculateURLIds();
const projectsCount = state.projects.length;
@ -93,12 +136,13 @@ export const useProjectsStore = defineStore('projects', () => {
state.chartDataBefore = payload.before;
}
async function createProject(createProjectFields: ProjectFields): Promise<string> {
async function createProject(createProjectFields: ProjectFields): Promise<Project> {
const createdProject = await api.create(createProjectFields);
state.projects.push(createdProject);
calculateURLIds();
return createdProject.id;
return createdProject;
}
async function createDefaultProject(userID: string): Promise<void> {
@ -111,9 +155,9 @@ export const useProjectsStore = defineStore('projects', () => {
userID,
);
const createdProjectId = await createProject(project);
const createdProject = await createProject(project);
selectProject(createdProjectId);
selectProject(createdProject.id);
}
function selectProject(projectID: string): void {

View File

@ -108,6 +108,8 @@ export const MAX_DESCRIPTION_LENGTH = 100;
* Project is a type, used for creating new project in backend.
*/
export class Project {
public urlId: string;
public constructor(
public id: string = '',
public name: string = '',

View File

@ -7,3 +7,17 @@
export function getId(): string {
return '_' + Math.random().toString(36).substr(2, 9);
}
/**
* Returns random UUID in "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" format.
*/
export function randomUUID(): string {
const randHex = (numBytes: number): string => {
let str = '';
for (let i = 0; i < numBytes; i++) {
str += Math.round(Math.random()*255).toString(16).padStart(2, '0');
}
return str;
};
return [randHex(4), randHex(2), randHex(2), randHex(2), randHex(6)].join('-');
}

View File

@ -110,3 +110,54 @@ export function humanizeArray(arr: string[]): string {
default: return `${arr.slice(0, len-1).join(', ')}, and ${arr[len-1]}`;
}
}
const b64Chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_';
/**
* Returns the URL-safe base64 representation of a hexadecimal string.
* @param str - the hex string
*/
export function hexToBase64(str: string): string {
if (!str) return '';
if (str.length % 2) {
throw new Error(`Invalid length ${str.length} for hex string`);
}
const bytes = new Uint8Array(str.length/2);
for (let i = 0; i < str.length; i += 2) {
const byteStr = str.substring(i, i+2);
const n = parseInt(byteStr, 16);
if (isNaN(n)) {
throw new Error(`Invalid hex byte '${byteStr}' at position ${i}`);
}
bytes[i/2] = parseInt(str.substring(i, i+2), 16);
}
let out = '';
for (let i = 0; i < bytes.length; i += 3) {
out += b64Chars[(bytes[i] & 0b11111100) >> 2];
let nextSextet = (bytes[i] & 0b00000011) << 4;
if (i + 1 >= bytes.length) {
out += b64Chars[nextSextet];
break;
}
nextSextet |= (bytes[i+1] & 0b11110000) >> 4;
out += b64Chars[nextSextet];
nextSextet = (bytes[i+1] & 0b00001111) << 2;
if (i + 2 >= bytes.length) {
out += b64Chars[nextSextet];
break;
}
nextSextet |= (bytes[i+2] & 0b11000000) >> 6;
out += b64Chars[nextSextet];
out += b64Chars[bytes[i+2] & 0b00111111];
}
if (out.length % 4) {
return out.padEnd(out.length + (4 - (out.length % 4)), '=');
}
return out;
}

View File

@ -1,18 +1,19 @@
// Copyright (C) 2019 Storj Labs, Inc.
// See LICENSE for copying information.
import { vi } from 'vitest';
import { describe, beforeEach, it, expect, vi } from 'vitest';
import { createPinia, setActivePinia } from 'pinia';
import { ProjectsHttpApi } from '@/api/projects';
import { Project, ProjectFields, ProjectLimits } from '@/types/projects';
import { useProjectsStore } from '@/store/modules/projectsStore';
import { randomUUID } from '@/utils/idGenerator';
const limits = new ProjectLimits(15, 12, 14, 13);
const project = new Project('11', 'name', 'descr', '23', 'testOwnerId');
const project = new Project(randomUUID(), 'name', 'descr', '23', 'testOwnerId');
const projects = [
new Project(
'11',
randomUUID(),
'name',
'descr',
'23',
@ -20,7 +21,7 @@ const projects = [
false,
),
new Project(
'1',
randomUUID(),
'name2',
'descr2',
'24',
@ -39,9 +40,9 @@ describe('actions', () => {
const store = useProjectsStore();
store.state.projects = projects;
store.selectProject('11');
store.selectProject(projects[0].id);
expect(store.state.selectedProject.id).toBe('11');
expect(store.state.selectedProject.id).toBe(projects[0].id);
expect(store.state.currentLimits.bandwidthLimit).toBe(0);
});

View File

@ -1,7 +1,9 @@
// Copyright (C) 2023 Storj Labs, Inc.
// See LICENSE for copying information.
import { decimalShift, formatPrice } from '@/utils/strings';
import { describe, beforeEach, it, expect, vi } from 'vitest';
import { decimalShift, formatPrice, hexToBase64 } from '@/utils/strings';
describe('decimalShift', (): void => {
it('handles empty strings', (): void => {
@ -65,3 +67,23 @@ describe('formatPrice', (): void => {
});
});
});
describe('hexToBase64', () => {
it('rejects non-hex strings', () => {
expect(() => hexToBase64('foobar')).toThrowError();
});
it('rejects short strings', () => {
expect(() => hexToBase64('abc')).toThrowError();
});
it('handles empty strings', () => {
expect(hexToBase64('')).toBe('');
});
it('encodes properly', () => {
expect(hexToBase64('14fb9c03d97e')).toBe('FPucA9l-');
expect(hexToBase64('14fb9c03d9')).toBe('FPucA9k=');
expect(hexToBase64('14fb9c03')).toBe('FPucAw==');
});
});

View File

@ -379,7 +379,7 @@ function onFileClick(file: BrowserObject): void {
uriParts.unshift(...filePath.value.split('/'));
}
const pathAndKey = uriParts.map(part => encodeURIComponent(part)).join('/');
router.push(`/projects/${projectsStore.state.selectedProject.id}/buckets/${bucketName.value}/${pathAndKey}`);
router.push(`/projects/${projectsStore.state.selectedProject.urlId}/buckets/${bucketName.value}/${pathAndKey}`);
return;
}

View File

@ -299,7 +299,7 @@ async function openBucket(bucketName: string): Promise<void> {
}
analyticsStore.pageVisit(RouteConfig.Buckets.with(RouteConfig.UploadFile).path);
router.push(`/projects/${projectsStore.state.selectedProject.id}/buckets/${bucketsStore.state.fileComponentBucketName}`);
router.push(`/projects/${projectsStore.state.selectedProject.urlId}/buckets/${bucketsStore.state.fileComponentBucketName}`);
return;
}
passphraseDialogCallback = () => openBucket(selectedBucketName.value);

View File

@ -16,7 +16,7 @@
<v-menu activator="parent" location="end" transition="scale-transition">
<v-list class="pa-2">
<v-list-item link rounded="lg" :to="`/projects/${item.id}/settings`">
<v-list-item link rounded="lg" @click="() => onSettingsClick()">
<template #prepend>
<icon-settings />
</template>
@ -130,10 +130,19 @@ const isDeclining = ref<boolean>(false);
function openProject(): void {
if (!props.item) return;
projectsStore.selectProject(props.item.id);
router.push(`/projects/${props.item.id}/dashboard`);
router.push(`/projects/${projectsStore.state.selectedProject.urlId}/dashboard`);
analyticsStore.pageVisit('/projects/dashboard');
}
/**
* Selects the project and navigates to the project's settings.
*/
function onSettingsClick(): void {
if (!props.item) return;
projectsStore.selectProject(props.item.id);
router.push(`/projects/${projectsStore.state.selectedProject.urlId}/dashboard`);
}
/**
* Declines the project invitation.
*/

View File

@ -82,7 +82,7 @@
<v-menu activator="parent" location="bottom end" transition="scale-transition">
<v-list class="pa-0">
<template v-if="item.raw.role === ProjectRole.Owner">
<v-list-item link :to="`/projects/${item.raw.id}/settings`">
<v-list-item link @click="() => onSettingsClick(item.raw)">
<template #prepend>
<icon-settings />
</template>
@ -186,10 +186,18 @@ function getFormattedDate(date: Date): string {
*/
function openProject(item: ProjectItemModel): void {
projectsStore.selectProject(item.id);
router.push(`/projects/${item.id}/dashboard`);
router.push(`/projects/${projectsStore.state.selectedProject.urlId}/dashboard`);
analyticsStore.pageVisit('/projects/dashboard');
}
/**
* Selects the project and navigates to the project's settings.
*/
function onSettingsClick(item: ProjectItemModel): void {
projectsStore.selectProject(item.id);
router.push(`/projects/${projectsStore.state.selectedProject.urlId}/dashboard`);
}
/**
* Declines the project invitation.
*/

View File

@ -129,7 +129,7 @@ const model = computed<boolean>({
});
function redirectToBucketsPage(): void {
router.push(`/projects/${projectsStore.state.selectedProject.id}/buckets`);
router.push(`/projects/${projectsStore.state.selectedProject.urlId}/buckets`);
}
const bucket = computed((): Bucket => {

View File

@ -124,7 +124,7 @@ import {
} from 'vuetify/components';
import { RequiredRule, ValidationRule } from '@poc/types/common';
import { MAX_DESCRIPTION_LENGTH, MAX_NAME_LENGTH, ProjectFields } from '@/types/projects';
import { MAX_DESCRIPTION_LENGTH, MAX_NAME_LENGTH, Project, ProjectFields } from '@/types/projects';
import { useLoading } from '@/composables/useLoading';
import { useProjectsStore } from '@/store/modules/projectsStore';
import { useUsersStore } from '@/store/modules/usersStore';
@ -171,17 +171,17 @@ const descriptionRules: ValidationRule<string>[] = [
async function onCreateClicked(): Promise<void> {
if (!formValid.value) return;
await withLoading(async () => {
let id: string;
let project: Project;
try {
const fields = new ProjectFields(name.value, description.value, usersStore.state.user.id);
id = await projectsStore.createProject(fields);
project = await projectsStore.createProject(fields);
} catch (error) {
error.message = `Failed to create project. ${error.message}`;
notify.notifyError(error, AnalyticsErrorEventSource.CREATE_PROJECT_MODAL);
return;
}
model.value = false;
router.push(`/projects/${id}/dashboard`);
router.push(`/projects/${project.urlId}/dashboard`);
notify.success('Project created.');
});
}

View File

@ -110,7 +110,7 @@ const isDeclining = ref<boolean>(false);
function openProject(): void {
projectsStore.selectProject(props.id);
notify.success('Invite accepted!');
router.push(`/projects/${props.id}/dashboard`);
router.push(`/projects/${projectsStore.state.selectedProject.urlId}/dashboard`);
analyticsStore.pageVisit('/projects/dashboard');
}

View File

@ -30,7 +30,7 @@ import { Project } from '@/types/projects';
import { useBillingStore } from '@/store/modules/billingStore';
import { useUsersStore } from '@/store/modules/usersStore';
import { useABTestingStore } from '@/store/modules/abTestingStore';
import { useProjectsStore } from '@/store/modules/projectsStore';
import { MINIMUM_URL_ID_LENGTH, useProjectsStore } from '@/store/modules/projectsStore';
import { useAppStore } from '@poc/store/appStore';
import { useAnalyticsStore } from '@/store/modules/analyticsStore';
import { useAccessGrantsStore } from '@/store/modules/accessGrantsStore';
@ -55,29 +55,46 @@ const agStore = useAccessGrantsStore();
const isLoading = ref<boolean>(true);
/**
* Selects the project with the given ID, redirecting to the
* Selects the project with the given URL ID, redirecting to the
* all projects dashboard if no such project exists.
*/
async function selectProject(projectId: string): Promise<void> {
async function selectProject(urlId: string): Promise<void> {
const goToDashboard = () => {
const path = '/projects';
router.push(path);
analyticsStore.pageVisit(path);
};
if (urlId.length < MINIMUM_URL_ID_LENGTH) {
goToDashboard();
return;
}
let projects: Project[];
try {
projects = await projectsStore.getProjects();
} catch (_) {
const path = '/projects';
router.push(path);
analyticsStore.pageVisit(path);
goToDashboard();
return;
}
if (!projects.some(p => p.id === projectId)) {
const path = '/projects';
router.push(path);
analyticsStore.pageVisit(path);
const project = projects.find(p => {
let prefixEnd = 0;
while (
p.urlId[prefixEnd] === urlId[prefixEnd]
&& prefixEnd < p.urlId.length
&& prefixEnd < urlId.length
) prefixEnd++;
return prefixEnd === p.urlId.length || prefixEnd === urlId.length;
});
if (!project) {
goToDashboard();
return;
}
projectsStore.selectProject(projectId);
projectsStore.selectProject(project.id);
}
watch(() => route.params.projectId, async newId => {
watch(() => route.params.id, async newId => {
if (newId === undefined) return;
isLoading.value = true;
await selectProject(newId as string);
@ -111,7 +128,7 @@ onBeforeMount(async () => {
notify.notifyError(error, AnalyticsErrorEventSource.OVERALL_APP_WRAPPER_ERROR);
}
await selectProject(route.params.projectId as string);
await selectProject(route.params.id as string);
if (!agStore.state.accessGrantsWebWorker) await agStore.startWorker();

View File

@ -30,7 +30,7 @@
:key="project.id"
rounded="lg"
:active="project.isSelected"
@click="() => onProjectSelected(project.id)"
@click="() => onProjectSelected(project)"
>
<template v-if="project.isSelected" #prepend>
<img src="@poc/assets/icon-check-color.svg" alt="Selected Project">
@ -62,7 +62,7 @@
:key="project.id"
rounded="lg"
:active="project.isSelected"
@click="() => onProjectSelected(project.id)"
@click="() => onProjectSelected(project)"
>
<template v-if="project.isSelected" #prepend>
<img src="@poc/assets/icon-check-color.svg" alt="Selected Project">
@ -76,7 +76,7 @@
</template>
<!-- Project Settings -->
<v-list-item link rounded="lg" :to="`/projects/${selectedProject.id}/settings`">
<v-list-item link rounded="lg" :to="`/projects/${selectedProject.urlId}/settings`">
<template #prepend>
<IconSettings />
</template>
@ -136,7 +136,7 @@
<v-divider class="my-2" />
<v-list-item link router-link :to="`/projects/${selectedProject.id}/dashboard`" class="my-1 py-3" rounded="lg" @click="() => registerLinkClick('/dashboard')">
<v-list-item link router-link :to="`/projects/${selectedProject.urlId}/dashboard`" class="my-1 py-3" rounded="lg" @click="() => registerLinkClick('/dashboard')">
<template #prepend>
<IconDashboard />
</template>
@ -145,7 +145,7 @@
</v-list-item-title>
</v-list-item>
<v-list-item link router-link :to="`/projects/${selectedProject.id}/buckets`" class="my-1" rounded="lg" @click="() => registerLinkClick('/buckets')">
<v-list-item link router-link :to="`/projects/${selectedProject.urlId}/buckets`" class="my-1" rounded="lg" @click="() => registerLinkClick('/buckets')">
<template #prepend>
<IconBucket />
</template>
@ -154,7 +154,7 @@
</v-list-item-title>
</v-list-item>
<v-list-item link router-link :to="`/projects/${selectedProject.id}/access`" class="my-1" rounded="lg" @click="() => registerLinkClick('/access')">
<v-list-item link router-link :to="`/projects/${selectedProject.urlId}/access`" class="my-1" rounded="lg" @click="() => registerLinkClick('/access')">
<template #prepend>
<IconAccess size="18" />
</template>
@ -163,7 +163,7 @@
</v-list-item-title>
</v-list-item>
<v-list-item link router-link :to="`/projects/${selectedProject.id}/team`" class="my-1" rounded="lg" @click="() => registerLinkClick('/team')">
<v-list-item link router-link :to="`/projects/${selectedProject.urlId}/team`" class="my-1" rounded="lg" @click="() => registerLinkClick('/team')">
<template #prepend>
<IconTeam size="18" />
</template>
@ -375,13 +375,13 @@ function compareProjects(a: Project, b: Project): number {
/**
* Handles click event for items in the project dropdown.
*/
async function onProjectSelected(projectId: string): Promise<void> {
async function onProjectSelected(project: Project): Promise<void> {
analyticsStore.eventTriggered(AnalyticsEvent.NAVIGATE_PROJECTS);
await router.push({
name: route.name || undefined,
params: {
...route.params,
projectId,
id: project.urlId,
},
});
bucketsStore.clearS3Data();

View File

@ -61,7 +61,7 @@ const routes: RouteRecordRaw[] = [
],
},
{
path: '/projects/:projectId',
path: '/projects/:id',
name: RouteName.Project,
component: () => import('@poc/layouts/default/Default.vue'),
children: [