diff --git a/.github/workflows/publish_feature.yml b/.github/workflows/publish_feature.yml index 629d2cd1..3b02542b 100644 --- a/.github/workflows/publish_feature.yml +++ b/.github/workflows/publish_feature.yml @@ -8,41 +8,17 @@ on: - release/* jobs: - setup: - name: Setup jobs - runs-on: ubuntu-latest - outputs: - targets: ${{ steps.filter.outputs.changes }} - steps: - - uses: actions/checkout@v4 - - uses: dorny/paths-filter@v3 - id: filter - with: - filters: | - backend: - - "tdrive/backend/node/**" - - "tdrive/docker/tdrive-node/Dockerfile" - frontend: - - "tdrive/frontend/**" - - "tdrive/docker/tdrive-frontend/**" - onlyoffice-connector: - - "tdrive/connectors/onlyoffice-connector/**" - - "tdrive/docker/onlyoffice-connector/Dockerfile" - ldap-sync: - - "tdrive/backend/utils/ldap-sync/**" - - "tdrive/docker/tdrive-ldap-sync/Dockerfile" - nextcloud-migration: - - "tdrive/backend/utils/nextcloud-migration/**" - - "tdrive/docker/tdrive-nextcloud-migration/Dockerfile" - publish-feature: runs-on: ubuntu-latest strategy: matrix: - targets: ${{ fromJSON(needs.setup.outputs.targets) }} + targets: + - backend + - frontend + - onlyoffice-connector + - ldap-sync + - nextcloud-migration fail-fast: false - needs: - - setup steps: - name: Checkout uses: actions/checkout@v4 diff --git a/tdrive/backend/node/src/core/platform/services/storage/exceptions.ts b/tdrive/backend/node/src/core/platform/services/storage/exceptions.ts index f4fb0f7b..72d59c8d 100644 --- a/tdrive/backend/node/src/core/platform/services/storage/exceptions.ts +++ b/tdrive/backend/node/src/core/platform/services/storage/exceptions.ts @@ -1,2 +1,10 @@ -export class FileNotFountException extends Error {} -export class WriteFileException extends Error {} +export class FileNotFountException extends Error { + constructor(readonly path: string, details: string) { + super(details); + } +} +export class WriteFileException extends Error { + constructor(readonly path: string, details: string) { + super(details); + } +} diff --git a/tdrive/backend/node/src/core/platform/services/storage/oneof-storage-strategy.ts b/tdrive/backend/node/src/core/platform/services/storage/oneof-storage-strategy.ts index 0223b8a6..f6a6f3dc 100644 --- a/tdrive/backend/node/src/core/platform/services/storage/oneof-storage-strategy.ts +++ b/tdrive/backend/node/src/core/platform/services/storage/oneof-storage-strategy.ts @@ -82,19 +82,23 @@ export class OneOfStorageStrategy implements StorageConnectorAPI { ); // Log all errors and throw if all write operations fail - const errors = writeResults.filter(result => result.status === "rejected"); - errors.forEach((error, index) => { - const storageId = this.storages[index].getId(); - logger.error( - new OneOfStorageWriteOneFailedException( - storageId, - `Error writing to storage ${storageId}`, - (error as PromiseRejectedResult).reason, - ), - ); + let errorsCount = 0; + writeResults.forEach((result, index) => { + if (result.status === "rejected") { + const storageId = this.storages[index].getId(); + logger.error( + new OneOfStorageWriteOneFailedException( + storageId, + path, + `Error writing to storage ${storageId}`, + (result as PromiseRejectedResult).reason, + ), + ); + errorsCount++; + } }); - if (errors.length === this.storages.length) { - throw new WriteFileException(`Write ${path} failed for all storages`); + if (errorsCount === this.storages.length) { + throw new WriteFileException(path, `Write ${path} failed for all storages`); } const successResult = writeResults.filter( @@ -120,13 +124,14 @@ export class OneOfStorageStrategy implements StorageConnectorAPI { logger.error( new OneOfStorageReadOneFailedException( storage.getId(), + path, `Reading ${path} from storage ${storage} failed.`, err, ), ); } } - throw new FileNotFountException(`Error reading ${path}`); + throw new FileNotFountException(path, `Error reading ${path}`); }; /** @@ -137,8 +142,17 @@ export class OneOfStorageStrategy implements StorageConnectorAPI { */ exists = async (path: string, options?: ReadOptions): Promise => { for (const storage of this.storages) { - if (await storage.exists(path, options)) { - return true; + try { + if (await storage.exists(path, options)) { + return true; + } + } catch (e) { + throw new OneOfStorageReadOneFailedException( + storage.getId(), + path, + `Reading ${path} from storage ${storage} failed.`, + e, + ); } } return false; @@ -160,7 +174,7 @@ export class OneOfStorageStrategy implements StorageConnectorAPI { * Throw when read from one of the storages is filed. */ class StorageException extends Error { - constructor(readonly storageId: string, details: string, error: Error) { + constructor(readonly storageId: string, readonly path: string, details: string, error: Error) { super(details, error); } } diff --git a/tdrive/backend/node/src/core/platform/services/storage/storage-service.ts b/tdrive/backend/node/src/core/platform/services/storage/storage-service.ts index b2763250..710832f6 100644 --- a/tdrive/backend/node/src/core/platform/services/storage/storage-service.ts +++ b/tdrive/backend/node/src/core/platform/services/storage/storage-service.ts @@ -108,7 +108,7 @@ export default class StorageService extends TdriveService implements async read(path: string, options?: ReadOptions): Promise { if (!(await this.exists(path, options))) { - throw new FileNotFountException(); + throw new FileNotFountException(path, "File doesn't exist"); } try { // eslint-disable-next-line @typescript-eslint/no-this-alias diff --git a/tdrive/backend/node/src/services/documents/services/index.ts b/tdrive/backend/node/src/services/documents/services/index.ts index bf64ec71..40aa08b2 100644 --- a/tdrive/backend/node/src/services/documents/services/index.ts +++ b/tdrive/backend/node/src/services/documents/services/index.ts @@ -400,7 +400,7 @@ export class DocumentsService { company_id: driveItem.company_id, }, context, - { waitForThumbnail: true }, + { waitForThumbnail: false }, ); } @@ -1505,10 +1505,17 @@ export class DocumentsService { if (token) throw new CrudException("Invalid token", 401); } }; - + /** + * + * @param id DriveItemID to download. If it's a folder, a zip file will be returned + * @param versionId Optional specific version to download + * @param {Function} beginArchiveTransmit If a folder, + * called when the zip file can begin streaming, otherwise not called at all. + */ download = async ( id: string, versionId: string | null, + beginArchiveTransmit: (readable: archiver.Archiver) => void, context: DriveExecutionContext, ): Promise<{ archive?: archiver.Archiver; @@ -1522,7 +1529,7 @@ export class DocumentsService { const item = await this.get(id, null, context); if (item.item.is_directory) { - return { archive: await this.createZip([id], context) }; + return { archive: await this.createZip([id], beginArchiveTransmit, context) }; } let version = item.item.last_version_cache; @@ -1541,11 +1548,13 @@ export class DocumentsService { * Creates a zip archive containing the drive items. * * @param {string[]} ids - the drive item list + * @param {Function} beginArchiveTransmit - Called when the zip file can begin streaming * @param {DriveExecutionContext} context - the execution context * @returns {Promise} the created archive. */ createZip = async ( ids: string[] = [], + beginArchiveTransmit: (archive: archiver.Archiver) => void, context: DriveExecutionContext, ): Promise => { if (!context) { @@ -1561,19 +1570,31 @@ export class DocumentsService { this.logger.error("error while creating ZIP file: ", error); }); + let didBeginTransmission = false; for (const id of ids) { if (!(await checkAccess(id, null, "read", this.repository, context))) { - this.logger.warn(`not enough permissions to download ${id}, skipping`); - return; + this.logger.warn({ id }, `not enough permissions to download ${id}, skipping`); + continue; } try { - await addDriveItemToArchive(id, null, archive, this.repository, context); - } catch (error) { - console.error(error); - this.logger.warn("failed to add item to archive", error); + await addDriveItemToArchive( + id, + null, + archive, + archive => { + if (didBeginTransmission) return; + didBeginTransmission = true; + beginArchiveTransmit(archive); + }, + this.repository, + context, + ); + } catch (err) { + this.logger.warn({ err, id }, "failed to add item to archive"); } } + if (!didBeginTransmission) beginArchiveTransmit(archive); //TODO[ASH] why do we need this call?? archive.finalize(); diff --git a/tdrive/backend/node/src/services/documents/utils.ts b/tdrive/backend/node/src/services/documents/utils.ts index 6bae8632..4f0dd3d9 100644 --- a/tdrive/backend/node/src/services/documents/utils.ts +++ b/tdrive/backend/node/src/services/documents/utils.ts @@ -345,6 +345,9 @@ export const addDriveItemToArchive = async ( id: string, entity: DriveFile | null, archive: archiver.Archiver, + beginArchiveTransmit: + | "ONLY_SET_THIS_VALUE_IF_ALREADY_CALLED" + | ((archive: archiver.Archiver) => void), repository: Repository, context: CompanyExecutionContext, prefix?: string, @@ -368,6 +371,10 @@ export const addDriveItemToArchive = async ( } archive.append(file.file, { name: item.name, prefix: prefix ?? "" }); + if (beginArchiveTransmit !== "ONLY_SET_THIS_VALUE_IF_ALREADY_CALLED") { + beginArchiveTransmit(archive); + beginArchiveTransmit = "ONLY_SET_THIS_VALUE_IF_ALREADY_CALLED"; + } return; } else { let nextPage = ""; @@ -384,6 +391,7 @@ export const addDriveItemToArchive = async ( child.id, child, archive, + beginArchiveTransmit, repository, context, `${prefix || ""}${item.name}/`, @@ -490,7 +498,7 @@ export const getFileMetadata = async ( company_id: context.company.id, }, context, - { ...(context.user?.server_request ? {} : { waitForThumbnail: true }) }, + { ...(context.user?.server_request ? {} : { waitForThumbnail: false }) }, ); if (!file) { diff --git a/tdrive/backend/node/src/services/documents/web/controllers/documents.ts b/tdrive/backend/node/src/services/documents/web/controllers/documents.ts index 2811de95..7f2e01ae 100644 --- a/tdrive/backend/node/src/services/documents/web/controllers/documents.ts +++ b/tdrive/backend/node/src/services/documents/web/controllers/documents.ts @@ -63,8 +63,8 @@ export class DocumentsController { chunkNumber: parseInt(q.resumableChunkNumber || q.chunk_number) || 1, filename: q.resumableFilename || q.filename || file?.filename || undefined, type: q.resumableType || q.type || file?.mimetype || undefined, - waitForThumbnail: !!q.thumbnail_sync, - ignoreThumbnails: false, + waitForThumbnail: false, + ignoreThumbnails: true, }; createdFile = await globalResolver.services.files.save(null, file, options, context); @@ -551,21 +551,21 @@ export class DocumentsController { const archiveOrFile = await globalResolver.services.documents.documents.download( id, versionId, + archive => { + archive.on("finish", () => { + response.status(200); + }); + + archive.on("error", () => { + response.internalServerError(); + }); + + archive.pipe(response.raw); + }, context, ); if (archiveOrFile.archive) { - const archive = archiveOrFile.archive; - - archive.on("finish", () => { - response.status(200); - }); - - archive.on("error", () => { - response.internalServerError(); - }); - - archive.pipe(response.raw); return response; } else if (archiveOrFile.file) { const data = archiveOrFile.file; @@ -623,22 +623,27 @@ export class DocumentsController { } try { - const archive = await globalResolver.services.documents.documents.createZip(ids, context); - reply.raw.setHeader( - "content-disposition", - formatAttachmentContentDispositionHeader("twake_drive.zip"), + await globalResolver.services.documents.documents.createZip( + ids, + archive => { + reply.raw.setHeader( + "content-disposition", + formatAttachmentContentDispositionHeader("twake_drive.zip"), + ); + + archive.on("finish", () => { + reply.status(200); + }); + + archive.on("error", () => { + reply.internalServerError(); + }); + + archive.pipe(reply.raw); + }, + context, ); - archive.on("finish", () => { - reply.status(200); - }); - - archive.on("error", () => { - reply.internalServerError(); - }); - - archive.pipe(reply.raw); - return reply; } catch (error) { logger.error({ error: `${error}` }, "failed to send zip file"); diff --git a/tdrive/backend/node/src/services/files/web/controllers/files.ts b/tdrive/backend/node/src/services/files/web/controllers/files.ts index 0bb0801f..e3570bcf 100644 --- a/tdrive/backend/node/src/services/files/web/controllers/files.ts +++ b/tdrive/backend/node/src/services/files/web/controllers/files.ts @@ -29,8 +29,8 @@ export class FileController { chunkNumber: parseInt(q.resumableChunkNumber || q.chunk_number) || 1, filename: q.resumableFilename || q.filename || file?.filename || undefined, type: q.resumableType || q.type || file?.mimetype || undefined, - waitForThumbnail: q.thumbnail_sync, - ignoreThumbnails: q.ignore_thumbnails, + waitForThumbnail: false, + ignoreThumbnails: true, }; const id = request.params.id; diff --git a/tdrive/backend/node/src/services/user/services/users/service.ts b/tdrive/backend/node/src/services/user/services/users/service.ts index 818979b7..ac361266 100644 --- a/tdrive/backend/node/src/services/user/services/users/service.ts +++ b/tdrive/backend/node/src/services/user/services/users/service.ts @@ -31,6 +31,8 @@ import { isNumber, isString } from "lodash"; import NodeCache from "node-cache"; import gr from "../../../global-resolver"; import { TYPE as DriveFileType, DriveFile } from "../../../documents/entities/drive-file"; +import { UpdateUser } from "./types"; +import { formatUsername } from "../../../../utils/users"; export class UserServiceImpl { version: "1"; @@ -77,14 +79,29 @@ export class UserServiceImpl { } } + private async updateExtRepositoryInCaseChangeEmail(user: User, context?: ExecutionContext) { + if (user.identity_provider_id === "null") { + return; + } + + const extUser = await this.extUserRepository.findOne({ user_id: user.id }, {}, context); + if (extUser) { + await this.extUserRepository.remove(extUser, context); + } + + const newExtUser = getExternalUserInstance({ + service_id: user.identity_provider || "null", + external_id: user.identity_provider_id || "null", + user_id: user.id, + }); + await this.extUserRepository.save(newExtUser, context); + } + private assignDefaults(user: User) { user.creation_date = !isNumber(user.creation_date) ? Date.now() : user.creation_date; if (user.identity_provider_id && !user.identity_provider) user.identity_provider = "console"; if (user.email_canonical) user.email_canonical = user.email_canonical.toLocaleLowerCase(); - if (user.username_canonical) - user.username_canonical = (user.username_canonical || "") - .toLocaleLowerCase() - .replace(/[^a-z0-9_-]/, ""); + if (user.username_canonical) user.username_canonical = formatUsername(user.username_canonical); } async create(user: User, context?: ExecutionContext): Promise> { @@ -92,8 +109,35 @@ export class UserServiceImpl { return new CreateResult("user", user); } - update(pk: Partial, item: User, context?: ExecutionContext): Promise> { - throw new Error("Method not implemented."); + async update( + instance: User, + user: UpdateUser, + context?: ExecutionContext, + ): Promise> { + let isChangeEmail = false; + if (instance.email_canonical !== user.email) { + isChangeEmail = true; + } + + instance.email_canonical = user.email || instance.email_canonical; + instance.first_name = user.first_name || instance.first_name; + instance.last_name = user.last_name || instance.last_name; + instance.picture = user.picture || instance.picture; + instance.creation_date = !isNumber(instance.creation_date) + ? Date.now() + : instance.creation_date; + if (isChangeEmail) { + instance.username_canonical = formatUsername(user.email); + instance.identity_provider_id = + instance.identity_provider_id !== "null" ? user.email : "null"; + } + + await this.repository.save(instance, context); + if (isChangeEmail) { + await this.updateExtRepositoryInCaseChangeEmail(instance, context); + } + + return new UpdateResult("user", instance); } async save(user: User, context?: ExecutionContext): Promise> { diff --git a/tdrive/backend/node/src/services/user/services/users/types.ts b/tdrive/backend/node/src/services/user/services/users/types.ts index 18a8d752..5c63854a 100644 --- a/tdrive/backend/node/src/services/user/services/users/types.ts +++ b/tdrive/backend/node/src/services/user/services/users/types.ts @@ -8,3 +8,10 @@ export type SearchUserOptions = { workspaceId?: string; channelId?: string; }; + +export type UpdateUser = { + email: string; + picture: string; + first_name: string; + last_name: string; +}; diff --git a/tdrive/backend/node/src/services/user/web/controller.ts b/tdrive/backend/node/src/services/user/web/controller.ts index d13283aa..f6f55ea9 100644 --- a/tdrive/backend/node/src/services/user/web/controller.ts +++ b/tdrive/backend/node/src/services/user/web/controller.ts @@ -37,6 +37,8 @@ import { formatUser } from "../../../utils/users"; import gr from "../../global-resolver"; import config from "config"; import { getLogger } from "../../../core/platform/framework"; +import { UpdateUser } from "../services/users/types"; +import { hasCompanyAdminLevel } from "../../../utils/company"; export class UsersCrudController implements @@ -366,6 +368,53 @@ export class UsersCrudController used: quota, } as UserQuota; } + + async update( + request: FastifyRequest<{ Body: UpdateUser; Params: UserParameters }>, + reply: FastifyReply, + ): Promise> { + const id = request.params.id; + const context = getExecutionContext(request); + + const [currentUserCompanies, requestedUserCompanies] = await Promise.all( + [context.user.id, request.params.id].map(userId => + gr.services.users.getUserCompanies({ id: userId }), + ), + ); + const currentUserCompaniesIds = new Set(currentUserCompanies.map(a => a.group_id)); + const sameCompanies = requestedUserCompanies.filter(a => + currentUserCompaniesIds.has(a.group_id), + ); + const roles = await Promise.all( + sameCompanies.map(a => gr.services.companies.getUserRole(a.group_id, context.user?.id)), + ); + + if (!roles.some(role => hasCompanyAdminLevel(role) === true)) { + reply.unauthorized(`User ${context.user?.id} is not allowed to update user ${id}`); + return; + } + + const user = await gr.services.users.get({ id }); + if (!user) { + reply.notFound(`User ${id} not found`); + return; + } + + const body = { ...request.body }; + if (user.email_canonical !== body.email) { + const userByEmail = await gr.services.users.getByEmail(body.email, context); + if (userByEmail) { + reply.conflict(`Email ${body.email} is existed`); + return; + } + } + + await gr.services.users.update(user, body, context); + + return { + resource: await formatUser(user), + }; + } } function getExecutionContext(request: FastifyRequest): ExecutionContext { diff --git a/tdrive/backend/node/src/services/user/web/routes.ts b/tdrive/backend/node/src/services/user/web/routes.ts index 6937e816..83d030b6 100644 --- a/tdrive/backend/node/src/services/user/web/routes.ts +++ b/tdrive/backend/node/src/services/user/web/routes.ts @@ -143,6 +143,13 @@ const routes: FastifyPluginCallback = (fastify: FastifyInstance, options, next) handler: usersController.recent.bind(usersController), }); + fastify.route({ + method: "PUT", + url: `${usersUrl}/:id`, + preValidation: [fastify.authenticate], + handler: usersController.update.bind(usersController), + }); + next(); }; diff --git a/tdrive/backend/node/src/utils/users.ts b/tdrive/backend/node/src/utils/users.ts index 0a631384..6ef561ec 100644 --- a/tdrive/backend/node/src/utils/users.ts +++ b/tdrive/backend/node/src/utils/users.ts @@ -87,3 +87,7 @@ export async function formatUser( return resUser; } + +export function formatUsername(value: string) { + return (value?.replace(/[^a-z0-9_-]/, "") || "").toLocaleLowerCase(); +} diff --git a/tdrive/backend/node/test/e2e/users/users.spec.ts b/tdrive/backend/node/test/e2e/users/users.spec.ts index 2f116699..d91fecde 100644 --- a/tdrive/backend/node/test/e2e/users/users.spec.ts +++ b/tdrive/backend/node/test/e2e/users/users.spec.ts @@ -51,12 +51,11 @@ describe("The /users API", () => { username: "adminuser", firstName: "admin", }); - await testDbService.createUser([workspacePk]); - + await testDbService.createUser([workspacePk], { password: "123456" }); }); - afterAll(async () => { - }); + // eslint-disable-next-line @typescript-eslint/no-empty-function + afterAll(async () => {}); describe("The GET /users/:id route", () => { it("should 401 when not authenticated", async () => { @@ -136,7 +135,6 @@ describe("The /users API", () => { }), ]), ); - }); it("should 200 and short response for another user", async () => { @@ -222,7 +220,7 @@ describe("The /users API", () => { }, query: { search: "anon", - company_ids: oneUser.workspace.company_id + company_ids: oneUser.workspace.company_id, }, }); @@ -232,9 +230,7 @@ describe("The /users API", () => { const resources = json.resources; console.log(resources); expect(resources.length).toBe(0); - }); - }); describe("The GET /users/:user_id/companies route", () => { @@ -354,7 +350,6 @@ describe("The /users API", () => { total_guests: expect.any(Number), total_messages: expect.any(Number), }); - }); }); @@ -473,7 +468,6 @@ describe("The /users API", () => { user = await testDbService.getUserFromDb({ id: firstId }); expect(user.devices).toMatchObject([]); - }); }); describe("List registered devices (GET)", () => { @@ -545,8 +539,94 @@ describe("The /users API", () => { expect(user.devices).toMatchObject([]); const device = await testDbService.getDeviceFromDb(deviceToken); expect(device).toBeFalsy(); - }); }); }); + + describe("The PUT /users/:id route", () => { + it("should 200 and response for updated user", async () => { + const myId = testDbService.users[0].id; + const updatedUserId = testDbService.users[1].id; + + const jwtToken = await platform.auth.getJWTToken({ sub: myId }); + const response = await platform.app.inject({ + method: "PUT", + url: `${url}/users/${updatedUserId}`, + headers: { + authorization: `Bearer ${jwtToken}`, + }, + body: { + email: "newvalue@gmail.com", + first_name: "New", + last_name: "Value", + picture: "null", + }, + }); + + expect(response.statusCode).toBe(200); + + const resource = response.json()["resource"]; + + console.log("resource: ", resource); + + expect(resource).toMatchObject({ + id: updatedUserId, + provider: expect.any(String), + provider_id: expect.any(String), + email: expect.any(String), + username: expect.any(String), + is_verified: expect.any(Boolean), + picture: expect.any(String), + first_name: expect.any(String), + last_name: expect.any(String), + full_name: expect.any(String), + created_at: expect.any(Number), + deleted: expect.any(Boolean), + status: null, + cache: { companies: expect.any(Array) }, + }); + + // Separate check for last_activity + expect([null, expect.any(Number)]).toContainEqual(resource.last_activity); + + expect(resource).not.toMatchObject({ + locale: expect.anything(), + timezone: expect.anything(), + companies: expect.anything(), + }); + }); + + it("should login with new email", async () => { + const myId = testDbService.users[0].id; + const updatedUserId = testDbService.users[1].id; + + const jwtToken = await platform.auth.getJWTToken({ sub: myId }); + await platform.app.inject({ + method: "PUT", + url: `${url}/users/${updatedUserId}`, + headers: { + authorization: `Bearer ${jwtToken}`, + }, + body: { + email: "newvalue@gmail.com", + first_name: "New", + last_name: "Value", + picture: "null", + }, + }); + + const response = await platform.app.inject({ + method: "POST", + url: "/internal/services/console/v1/login", + body: { + email: "newvalue@gmail.com", + password: "123456", + remember_me: true, + device: {}, + }, + }); + + expect(response.statusCode).toBe(200); + }); + }); }); diff --git a/tdrive/frontend/src/app/components/menus/menu.scss b/tdrive/frontend/src/app/components/menus/menu.scss index 4c8a4d81..c49d6a01 100755 --- a/tdrive/frontend/src/app/components/menus/menu.scss +++ b/tdrive/frontend/src/app/components/menus/menu.scss @@ -50,7 +50,7 @@ .email { height: 46px !important; - line-height: 46px !important; + align-items: center; .icon { font-size: 24px; }