import { Inject } from '@nestjs/common'; import { createHash } from 'crypto'; import axios, { AxiosError } from 'axios'; import { strict as assert } from 'assert'; import { PostRepository } from '@app/post'; import { UserRepository } from '@app/user'; import { AuthUser } from '@app/auth'; import { Post, PostTypes } from '@domain/post'; import { UserTypes } from '@domain/user'; const { VERSTKA_API_URL = 'https://verstka.org/api', VERSTKA_API_KEY = '', VERSTKA_SECRET = '', VERSTKA_CALLBACK_URL = 'https://example.com/api/verstka', VERSTKA_HOST_NAME = 'storage.yandexcloud.net/imagesexample.com', VERSTKA_FONTS = 'https://storage.yandexcloud.net/imagesexample.com/vms_fonts.css', IMAGE_SERVICE_ENDPOINT = 'https://images.example.com', } = process.env; type EditParams = { id: string; userId: string; ip: string; isMobile?: boolean; }; type SaveParams = { id: string; html: string; userId: string; downloadUrl: string; customFields: { client_folder: string; mobile?: boolean; }; }; type EditResponse = | string | { data: { session_id: string; edit_url: string; }; }; type VerstkaPostExtraFields = { imagesMap?: Record<string, string>; }; export class VerstkaService { @Inject() private readonly postRepository: PostRepository; @Inject() private readonly userRepository: UserRepository; public async getList(): Promise<Post[]> { return this.postRepository.find({ type: PostTypes.Type.VERSTKA }); } public async edit({ id, ip, userId, isMobile }: EditParams): Promise<never | string> { const user = await this.getUser(userId); user.assert(UserTypes.Permission.WriteArticle); const post = await this.postRepository.getOrFail(id); const html = post.content.body ? this.htmlUnboxing(post.content.body, isMobile) : ''; const materialId = isMobile ? `M${id}` : id; const callbackUrl = `${VERSTKA_CALLBACK_URL}/${id}`; const callbackSign = createHash('md5') .update(`${VERSTKA_SECRET}${VERSTKA_API_KEY}${materialId}${userId}${callbackUrl}`) .digest('hex'); const customFields = { mobile: isMobile, 'fonts.css': VERSTKA_FONTS, }; try { const data = new URLSearchParams(); data.append('material_id', materialId); data.append('user_id', userId); data.append('html_body', html); data.append('api-key', VERSTKA_API_KEY); data.append('callback_url', callbackUrl); data.append('host_name', VERSTKA_HOST_NAME); data.append('user_ip', ip); data.append('callback_sign', callbackSign); data.append('custom_fields', JSON.stringify(customFields)); const response = await axios(`${VERSTKA_API_URL}/open`, { data, method: 'post', }); const verstkaRespose = response.data as EditResponse; assert.ok(typeof verstkaRespose !== 'string', verstkaRespose as string); return verstkaRespose.data.edit_url; } catch (error: unknown) { this.logAxiosError(error as AxiosError); throw error; } } public async save(params: SaveParams): Promise<void> { const isMobile = params.customFields.mobile; const id = isMobile ? params.id.substring(1) : params.id; const post = await this.postRepository.getOrFail(id); const html = await this.downloadFiles(params.html, params.downloadUrl, post); const user = await this.getUser(params.userId); await this.updateContent(post, html, user, isMobile); } private async downloadFiles(html: string, downloadUrl: string, post: Post): Promise<string> { let result = html; const fields = (post.extraFields ?? {}) as VerstkaPostExtraFields; const imagesMap = fields.imagesMap ?? {}; const { data } = await axios.get(downloadUrl); const files = (data?.data ?? []) as string[]; let url: Optional<string>; let originalUrl: string; for (const file of files) { originalUrl = `${downloadUrl}/${file}`; url = imagesMap[originalUrl]; if (!url) { url = await this.downloadFile(originalUrl); imagesMap[originalUrl] = url; } result = result.replaceAll(`/vms_images/${file}`, url); } post.extraFields = { imagesMap }; return result; } private async getUser(userId: UserTypes.Id): Promise<AuthUser> { const user = await this.userRepository.getOrFail(userId); const { id, role } = user.toDTO(); return new AuthUser(id, role); } private async downloadFile(url: string): Promise<string> { let result: string; try { const source = encodeURI(url); // eslint-disable-next-line no-console console.time(`Upload ${url}`); const response = await axios.post( `${IMAGE_SERVICE_ENDPOINT}/image?defaultPreviews=1©FromUrl=${source}&isPreserveTransparency=true` ); const { image } = response.data; // eslint-disable-next-line no-console console.timeEnd(`Upload ${url}`); result = image.urlTemplate.replace('{width}', 'original'); } catch (error: unknown) { this.logAxiosError(error as AxiosError); throw error; } return result; } // eslint-disable-next-line max-params private async updateContent(post: Post, body: string, user: AuthUser, isMobile?: boolean): Promise<void> { const pageHtml = post.content.body ?? ''; const desktopContent = isMobile ? this.htmlUnboxing(pageHtml) : body; const mobileContent = isMobile ? body : this.htmlUnboxing(pageHtml, true); const content = { body: this.htmlBoxing(desktopContent, mobileContent) }; post.update({ content }, user); await this.postRepository.update(post); } private logAxiosError(error: AxiosError): void { if (error.response) { // eslint-disable-next-line no-console console.error(error.response.data); // eslint-disable-next-line no-console console.error(error.response.status); // eslint-disable-next-line no-console console.error(error.response.headers); } else { // eslint-disable-next-line no-console console.error(error.message); } } // eslint-disable-next-line max-lines-per-function private htmlBoxing(desktopContent = '', mobileContent = ''): string { return ` <link rel="stylesheet" media="all" href="${VERSTKA_FONTS}" /> <article class="verstka-article"> <div class="content content_desktop">${desktopContent}</div> <div class="content content_mobile">${mobileContent}</div> </article> <script type = "text/javascript"> (() => { const [mobileContent] = document.getElementsByClassName('content_mobile'); const [desktopContent] = document.getElementsByClassName('content_desktop') const htmls = { desktop: desktopContent.outerHTML, mobile: mobileContent.innerHTML ? mobileContent.outerHTML : desktopContent.outerHTML, }; mobileContent.parentElement.removeChild(mobileContent); desktopContent.parentElement.removeChild(desktopContent); function switchHtml(html) { const [article] = document.getElementsByClassName('verstka-article'); if (window.VMS_API) { window.VMS_API.Article.disable() } article.innerHTML = html; if (window.VMS_API) { window.VMS_API.Article.enable({ display_mode: 'default' }); } } let prev = null; function onResize() { const isMobile = window.innerWidth < 768; if (prev !== isMobile) { prev = isMobile; switchHtml(htmls[isMobile ? 'mobile' : 'desktop']); } } window.onresize = onResize; if (window.VMS_API) { onResize(); } else { window.onVMSAPIReady = onResize; } })(); </script> <script src="https://go.verstka.org/api.js" async type="text/javascript"></script> `; } private htmlUnboxing(html: string, isMobile?: boolean): string { const desktopOpenTag = '<div class="content content_desktop">'; const mobileOpenTag = '<div class="content content_mobile">'; const closeTag = '</div>'; const desktopStart = html.indexOf(desktopOpenTag); const mobileStart = html.indexOf(mobileOpenTag); const desktopEnd = html.lastIndexOf(closeTag, mobileStart); const mobileEnd = html.lastIndexOf(closeTag); return isMobile ? html.substring(mobileStart + mobileOpenTag.length, mobileEnd) : html.substring(desktopStart + desktopOpenTag.length, desktopEnd); } }
def add(a, b): return a + b