Compare commits
5 commits
6dbf475e32
...
b7068ed630
Author | SHA1 | Date | |
---|---|---|---|
May | b7068ed630 | ||
May | 7d9da874cd | ||
May | 5564b7b356 | ||
May | a5fe1e33e7 | ||
May | 7792898653 |
|
@ -8,4 +8,5 @@ README.md
|
||||||
.prettierignore
|
.prettierignore
|
||||||
.gitignore
|
.gitignore
|
||||||
.env.example
|
.env.example
|
||||||
|
.env
|
||||||
.svelte-kit
|
.svelte-kit
|
10
.env.example
|
@ -17,8 +17,14 @@ USERINFO__ROUTE=
|
||||||
# Identifier you'd like to use to link avatars with
|
# Identifier you'd like to use to link avatars with
|
||||||
USERINFO__IDENTIFIER=preferred_username
|
USERINFO__IDENTIFIER=preferred_username
|
||||||
|
|
||||||
# Comma-separated list of allowed image types
|
# Comma-separated list of any extra formats you'd like to be available
|
||||||
ALLOWED_TYPES=image/jpg,image/jpeg,image/png,image/png,image/webp,image/gif
|
IMAGES__EXTRA_OUTPUT_FORMATS=jpeg
|
||||||
|
# Comma-separated list of output resolutions you'd like to be available - Default 1024,512,256,128,64,32
|
||||||
|
IMAGES__OUTPUT_RESOLUTIONS=1024,512,256,128,64,48,32
|
||||||
|
# Default selected resolution - Default 512
|
||||||
|
IMAGES__DEFAULT_RESOLUTION=512
|
||||||
|
# Comma-separated list of permitted image mimetypes
|
||||||
|
IMAGES__ALLOWED_INPUT_FORMATS=image/jpg,image/jpeg,image/png,image/webp,image/gif
|
||||||
|
|
||||||
# Prisma database URL
|
# Prisma database URL
|
||||||
DATABASE_URL=file:../.data/data.db
|
DATABASE_URL=file:../.data/data.db
|
||||||
|
|
|
@ -27,7 +27,7 @@ FROM base AS release
|
||||||
COPY --from=prisma /usr/src/app/prisma prisma
|
COPY --from=prisma /usr/src/app/prisma prisma
|
||||||
COPY --from=prisma /usr/src/app/node_modules node_modules
|
COPY --from=prisma /usr/src/app/node_modules node_modules
|
||||||
COPY --from=build /usr/src/app/build build
|
COPY --from=build /usr/src/app/build build
|
||||||
COPY --from=build /usr/src/app/static static
|
COPY --from=build /usr/src/app/assets assets
|
||||||
COPY --from=build /usr/src/app/package.json .
|
COPY --from=build /usr/src/app/package.json .
|
||||||
|
|
||||||
EXPOSE 3000/tcp
|
EXPOSE 3000/tcp
|
||||||
|
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 31 KiB |
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "ava-node",
|
"name": "ava-node",
|
||||||
"version": "1.1.1",
|
"version": "1.2.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
|
|
3
src/app.d.ts
vendored
|
@ -8,6 +8,9 @@ declare global {
|
||||||
// interface PageState {}
|
// interface PageState {}
|
||||||
// interface Platform {}
|
// interface Platform {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
declare const __APP_NAME__: string;
|
||||||
|
declare const __APP_VERSION__: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
|
|
|
@ -1,58 +1,168 @@
|
||||||
import { mkdir, readdir, rm, writeFile } from "node:fs/promises"
|
import { mkdir, readdir, readFile, rm, writeFile } from "node:fs/promises"
|
||||||
import { existsSync } from "node:fs"
|
import { existsSync } from "node:fs"
|
||||||
import { join } from "node:path"
|
import { join } from "node:path"
|
||||||
import { prisma } from "./clientsingleton"
|
import { prisma } from "./clientsingleton"
|
||||||
import configuration from "./configuration"
|
import configuration from "./configuration"
|
||||||
import Sharp from "sharp"
|
import Sharp, { type FormatEnum } from "sharp"
|
||||||
import mime from "mime"
|
|
||||||
|
|
||||||
// todo: make customizable
|
// todo: make customizable
|
||||||
export const avatarDirectory = "./.data/avatars"
|
export const avatarDirectory = "./.data/avatars"
|
||||||
export const defaultAvatarDirectory = "./static/default/"
|
export const defaultAvatarDirectory = "./.data/defaultAvatar/"
|
||||||
export const renderSizes = [ 1024, 512, 256, 128, 64, 32 ]
|
|
||||||
|
|
||||||
export async function getPathToAvatarForUid(uid?: string, size: string = "512") {
|
await mkdir(defaultAvatarDirectory, { recursive: true })
|
||||||
|
|
||||||
|
export const missingAvatarQueue = new Map<
|
||||||
|
string,
|
||||||
|
Promise<string>
|
||||||
|
>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description Generate an avatar at the selected size and format
|
||||||
|
* @param path Path to the avatar directory
|
||||||
|
* @param size Avatar size
|
||||||
|
* @param fmt Avatar format
|
||||||
|
* @returns Promise that resolves to the path of the newly-generated avatar
|
||||||
|
*/
|
||||||
|
export function generateMissingAvatar(path: string, size: number, fmt?: keyof Sharp.FormatEnum) {
|
||||||
|
let qid = JSON.stringify([path, size, fmt])
|
||||||
|
if (missingAvatarQueue.has(qid))
|
||||||
|
return missingAvatarQueue.get(qid)!
|
||||||
|
|
||||||
|
let prom = new Promise<string>(async (res, rej) => {
|
||||||
|
// locate best quality currently available
|
||||||
|
const av = await readdir(path)
|
||||||
|
// this can probably be done better but I DON'T GIVE A FUCK !!!!
|
||||||
|
const pathToBestQualityImg =
|
||||||
|
path == defaultAvatarDirectory
|
||||||
|
? "./assets/default.png"
|
||||||
|
: join(
|
||||||
|
path,
|
||||||
|
av
|
||||||
|
.map(e => [parseInt(e.match(/(.*)\..*/)?.[1] || "", 10), e] as [number, string])
|
||||||
|
.sort(([a],[b]) => b - a)
|
||||||
|
[0][1]
|
||||||
|
)
|
||||||
|
|
||||||
|
const buf = await readFile(pathToBestQualityImg)
|
||||||
|
res(writeAvatar(path, await renderAvatar(buf, size, fmt)))
|
||||||
|
missingAvatarQueue.delete(qid)
|
||||||
|
})
|
||||||
|
|
||||||
|
missingAvatarQueue.set(qid, prom)
|
||||||
|
return prom
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description Get the path of an avatar for a user
|
||||||
|
* @param uid UID of the user
|
||||||
|
* @param size Avatar size
|
||||||
|
* @param fmt Avatar format
|
||||||
|
* @returns Path to the avatar of a user
|
||||||
|
*/
|
||||||
|
export async function getPathToAvatarForUid(uid?: string, size: number = configuration.images.default_resolution, fmt?: string) {
|
||||||
if (uid?.includes("/"))
|
if (uid?.includes("/"))
|
||||||
throw Error("UID cannot include /")
|
throw Error("UID cannot include /")
|
||||||
|
|
||||||
|
// check if format is valid
|
||||||
|
if (![undefined, ...configuration.images.extra_output_types].includes(fmt as keyof FormatEnum))
|
||||||
|
return
|
||||||
|
|
||||||
|
// if no uid / no avatar folder then default to the default avatar directory
|
||||||
let userAvatarDirectory = uid ? join(avatarDirectory, uid) : defaultAvatarDirectory
|
let userAvatarDirectory = uid ? join(avatarDirectory, uid) : defaultAvatarDirectory
|
||||||
if (!existsSync(userAvatarDirectory))
|
if (!existsSync(userAvatarDirectory))
|
||||||
userAvatarDirectory = defaultAvatarDirectory
|
userAvatarDirectory = defaultAvatarDirectory
|
||||||
|
|
||||||
let sizes = await readdir(userAvatarDirectory)
|
// bind a makeMissing function
|
||||||
const targetAvatar = sizes.find(s => s.match(/(.*)\..*/)?.[1] == size)
|
const makeMissing = generateMissingAvatar.bind(null, userAvatarDirectory, size, fmt as keyof FormatEnum)
|
||||||
|
|
||||||
|
// get directory to extract imgs from
|
||||||
|
let targetAvatarDirectory = join(userAvatarDirectory, fmt||"")
|
||||||
|
|
||||||
|
// if there's no images for the specified fmt, generate new ones
|
||||||
|
if (!existsSync(targetAvatarDirectory))
|
||||||
|
return makeMissing()
|
||||||
|
|
||||||
|
let sizes = await readdir(targetAvatarDirectory, {withFileTypes: true})
|
||||||
|
|
||||||
|
const targetAvatar = sizes.filter(e => e.isFile()).find(
|
||||||
|
s => parseInt(s.name.match(/(.*)\..*/)?.[1] || "", 10) == size
|
||||||
|
)
|
||||||
|
|
||||||
if (targetAvatar)
|
if (targetAvatar)
|
||||||
return join(userAvatarDirectory, targetAvatar)
|
return join(targetAvatarDirectory, targetAvatar.name)
|
||||||
|
else if (configuration.images.output_resolutions.includes(size))
|
||||||
|
return makeMissing() // generate image at this size for the specified format
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getPathToAvatarForIdentifier(identifier: string, size: string = "512") {
|
export async function getPathToAvatarForIdentifier(identifier: string, size: number = configuration.images.default_resolution, fmt?: string) {
|
||||||
let user = await prisma.user.findFirst({
|
let user = await prisma.user.findFirst({
|
||||||
where: {
|
where: {
|
||||||
identifier
|
identifier
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return getPathToAvatarForUid(user?.userId, size)
|
return getPathToAvatarForUid(user?.userId, size, fmt)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function rerenderAvatar(bin: ArrayBuffer, squareSize: number) {
|
/**
|
||||||
|
* @description Render an avatar at the specified size and format
|
||||||
|
* @param bin Image to rerender
|
||||||
|
* @param squareSize Target size of the new image
|
||||||
|
* @param format Image target format
|
||||||
|
* @returns Avatar buffer and other information which may be useful
|
||||||
|
*/
|
||||||
|
export async function renderAvatar(bin: ArrayBuffer|Buffer, squareSize: number, format?: keyof Sharp.FormatEnum) {
|
||||||
|
const opBegin = Date.now()
|
||||||
let img = Sharp(bin);
|
let img = Sharp(bin);
|
||||||
let metadata = await img.metadata();
|
let metadata = await img.metadata();
|
||||||
squareSize = Math.min(...[metadata.width, metadata.height].filter(e => e) as number[], squareSize)
|
let realSquareSize = Math.min(...[metadata.width, metadata.height].filter(e => e) as number[], squareSize)
|
||||||
|
|
||||||
img.resize({
|
img.resize({
|
||||||
width: squareSize,
|
width: realSquareSize,
|
||||||
height: squareSize,
|
height: realSquareSize,
|
||||||
fit: "cover"
|
fit: "cover"
|
||||||
})
|
})
|
||||||
|
|
||||||
return img.toBuffer()
|
if (format) img.toFormat(format)
|
||||||
|
|
||||||
|
return {
|
||||||
|
buf: await img.toBuffer(),
|
||||||
|
extension: format || metadata.format,
|
||||||
|
requestedFormat: format,
|
||||||
|
squareSize,
|
||||||
|
time: Date.now()-opBegin
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function writeAvatar(avatarDir: string, renderedAvatar: Awaited<ReturnType<typeof renderAvatar>>) {
|
||||||
|
const targetDir = join(
|
||||||
|
avatarDir,
|
||||||
|
...(
|
||||||
|
renderedAvatar.requestedFormat
|
||||||
|
? [ renderedAvatar.requestedFormat ]
|
||||||
|
: []
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
await mkdir(targetDir, {recursive: true})
|
||||||
|
|
||||||
|
const targetPath = join(
|
||||||
|
targetDir,
|
||||||
|
`${renderedAvatar.squareSize}.${renderedAvatar.extension}`
|
||||||
|
)
|
||||||
|
|
||||||
|
await writeFile(
|
||||||
|
targetPath,
|
||||||
|
renderedAvatar.buf
|
||||||
|
)
|
||||||
|
|
||||||
|
return targetPath
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setNewAvatar(uid: string, avatar?: File) {
|
export async function setNewAvatar(uid: string, avatar?: File) {
|
||||||
if (uid?.includes("/"))
|
if (uid?.includes("/"))
|
||||||
throw Error("UID cannot include /")
|
throw Error("UID cannot include /")
|
||||||
|
|
||||||
// Delete current avatar directory
|
// Delete current avatar directory
|
||||||
const userAvatarDirectory = join(avatarDirectory, uid)
|
const userAvatarDirectory = join(avatarDirectory, uid)
|
||||||
await rm(userAvatarDirectory, { recursive: true, force: true })
|
await rm(userAvatarDirectory, { recursive: true, force: true })
|
||||||
|
@ -62,24 +172,25 @@ export async function setNewAvatar(uid: string, avatar?: File) {
|
||||||
// make a new directory
|
// make a new directory
|
||||||
mkdir(userAvatarDirectory, { recursive: true })
|
mkdir(userAvatarDirectory, { recursive: true })
|
||||||
|
|
||||||
let time: Record<number, number> = {}
|
let time: Record<number, Record<"input" | keyof Sharp.FormatEnum, number>> = {}
|
||||||
|
|
||||||
// render all images and write to disk
|
// render all images and write to disk
|
||||||
let avatarData = await avatar.arrayBuffer()
|
let avatarData = await avatar.arrayBuffer()
|
||||||
let fileExtension = mime.getExtension(avatar.type)
|
for (let x of configuration.images.output_resolutions) {
|
||||||
for (let x of renderSizes) {
|
time[x] = Object.fromEntries([
|
||||||
console.log(x)
|
["input", -1],
|
||||||
try {
|
...configuration.images.extra_output_types
|
||||||
let start = Date.now()
|
.map( e => [ e, -1 ] )
|
||||||
let rerenderedAvatarData = await rerenderAvatar(avatarData, x)
|
])
|
||||||
await writeFile(
|
for (let t of [undefined, ...configuration.images.extra_output_types]) {
|
||||||
join(userAvatarDirectory, `${x}.${fileExtension}`),
|
try {
|
||||||
rerenderedAvatarData
|
const rendered = await renderAvatar(avatarData, x, t)
|
||||||
)
|
await writeAvatar(userAvatarDirectory, rendered)
|
||||||
time[x] = Date.now()-start
|
time[x][t || "input"] = rendered.time
|
||||||
} catch (e) { // clear pfp and throw if error encountered
|
} catch (e) { // clear pfp and throw if error encountered
|
||||||
await rm(userAvatarDirectory, { recursive: true, force: true })
|
await rm(userAvatarDirectory, { recursive: true, force: true })
|
||||||
throw e
|
throw e
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import Sharp, { type FormatEnum } from "sharp"
|
||||||
const configuration = {
|
const configuration = {
|
||||||
oauth2: {
|
oauth2: {
|
||||||
endpoints: {
|
endpoints: {
|
||||||
|
@ -15,6 +16,23 @@ const configuration = {
|
||||||
route: process.env.USERINFO__ROUTE!,
|
route: process.env.USERINFO__ROUTE!,
|
||||||
identifier: process.env.USERINFO__IDENTIFIER!
|
identifier: process.env.USERINFO__IDENTIFIER!
|
||||||
},
|
},
|
||||||
allowed_types: process.env.ALLOWED_TYPES?.split(",") || []
|
images: {
|
||||||
|
permitted_input:
|
||||||
|
(
|
||||||
|
process.env.ALLOWED_TYPES
|
||||||
|
|| process.env.IMAGES__ALLOWED_INPUT_FORMATS
|
||||||
|
)?.split(",") || [],
|
||||||
|
default_resolution: parseInt((process.env.IMAGES__DEFAULT_RESOLUTION || "").toString(), 10) || 512,
|
||||||
|
extra_output_types:
|
||||||
|
process.env.IMAGES__EXTRA_OUTPUT_FORMATS
|
||||||
|
?.split(",")
|
||||||
|
.filter(e => e in Sharp.format) as (keyof FormatEnum)[]
|
||||||
|
|| [],
|
||||||
|
output_resolutions:
|
||||||
|
process.env.IMAGES__OUTPUT_RESOLUTIONS
|
||||||
|
?.split(",")
|
||||||
|
.map(e => parseInt(e,10))
|
||||||
|
|| [ 1024, 512, 256, 128, 64, 32 ]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
export default configuration
|
export default configuration
|
|
@ -4,6 +4,8 @@
|
||||||
import ava from "../assets/ava_icon.svg?raw"
|
import ava from "../assets/ava_icon.svg?raw"
|
||||||
import type { User } from "$lib/types";
|
import type { User } from "$lib/types";
|
||||||
export let data: { user?: User };
|
export let data: { user?: User };
|
||||||
|
|
||||||
|
const buildName = `${__APP_NAME__} ${__APP_VERSION__}`
|
||||||
</script>
|
</script>
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>ava</title>
|
<title>ava</title>
|
||||||
|
@ -50,12 +52,16 @@
|
||||||
nav > * {
|
nav > * {
|
||||||
display: flex; /* Flexbox fixes everything! */
|
display: flex; /* Flexbox fixes everything! */
|
||||||
}
|
}
|
||||||
a, small {
|
a, small, footer {
|
||||||
color: var(--link)
|
color: var(--link)
|
||||||
}
|
}
|
||||||
code, pre {
|
code, pre {
|
||||||
font-family: "Space Mono", monospace, monospace;
|
font-family: "Space Mono", monospace, monospace;
|
||||||
}
|
}
|
||||||
|
footer {
|
||||||
|
margin: 1em 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</svelte:head>
|
</svelte:head>
|
||||||
<body>
|
<body>
|
||||||
|
@ -69,4 +75,9 @@
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<slot />
|
<slot />
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
{import.meta.env.DEV ? "[DEV]" : ""}
|
||||||
|
{buildName}
|
||||||
|
</footer>
|
||||||
</body>
|
</body>
|
|
@ -3,11 +3,15 @@ import { error } from '@sveltejs/kit';
|
||||||
import { readFile } from 'fs/promises';
|
import { readFile } from 'fs/promises';
|
||||||
import mime from "mime";
|
import mime from "mime";
|
||||||
|
|
||||||
export async function GET({ params : { identifier, size } }) {
|
export async function GET({ params : { identifier, size }, url }) {
|
||||||
let avPath = await getPathToAvatarForIdentifier(identifier, size)
|
let sz = size ? parseInt(size, 10) : undefined
|
||||||
|
if (sz && Number.isNaN(sz))
|
||||||
|
throw error(400, "Invalid number")
|
||||||
|
|
||||||
|
let avPath = await getPathToAvatarForIdentifier(identifier, sz, url.searchParams.get("format") || undefined)
|
||||||
|
|
||||||
if (!avPath)
|
if (!avPath)
|
||||||
throw error(404, "Avatar at this size not found")
|
throw error(404, "Avatar at this size not found, or this is an invalid format")
|
||||||
|
|
||||||
return new Response(await readFile(avPath), {
|
return new Response(await readFile(avPath), {
|
||||||
headers: {
|
headers: {
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import {getRequestUser, launchLogin} from "$lib/oidc"
|
import {getRequestUser, launchLogin} from "$lib/oidc"
|
||||||
import configuration from "$lib/configuration.js";
|
import configuration from "$lib/configuration.js";
|
||||||
import { fail } from "@sveltejs/kit";
|
import { fail } from "@sveltejs/kit";
|
||||||
import { avatarDirectory, renderSizes, setNewAvatar } from "$lib/avatars.js";
|
import { avatarDirectory, setNewAvatar } from "$lib/avatars.js";
|
||||||
import { join } from "path";
|
import { join } from "path";
|
||||||
export async function load({ request, parent, url }) {
|
export async function load({ request, parent, url }) {
|
||||||
const { user } = await parent();
|
const { user } = await parent();
|
||||||
|
@ -10,8 +10,8 @@ export async function load({ request, parent, url }) {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
url: url.toString(),
|
url: url.toString(),
|
||||||
allowedImageTypes: configuration.allowed_types,
|
allowedImageTypes: configuration.images.permitted_input,
|
||||||
renderSizes
|
renderSizes: configuration.images.output_resolutions
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -27,15 +27,17 @@ export const actions = {
|
||||||
newAvatar = submission.get("newAvatar")
|
newAvatar = submission.get("newAvatar")
|
||||||
if (newAvatar !== undefined && !(newAvatar instanceof File))
|
if (newAvatar !== undefined && !(newAvatar instanceof File))
|
||||||
return fail(400, {success: false, error: "incorrect entry type"})
|
return fail(400, {success: false, error: "incorrect entry type"})
|
||||||
if (!configuration.allowed_types.includes(newAvatar.type))
|
if (!configuration.images.permitted_input.includes(newAvatar.type))
|
||||||
return fail(400, {success: false, error: `allowed types does not include ${newAvatar.type}`})
|
return fail(400, {success: false, error: `allowed types does not include ${newAvatar.type}`})
|
||||||
}
|
}
|
||||||
let time = await setNewAvatar(user.sub, newAvatar)
|
let timing = await setNewAvatar(user.sub, newAvatar)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
message: Object.entries(time)
|
message: Object.entries(timing)
|
||||||
.map(([res, time]) => `${res}x${res} took ${time}ms to render`)
|
.map(([res, time]) => `render ${res}x${res}: ${
|
||||||
|
Object.entries(time).map(([type, t]) => `${type}: ${t}ms`).join(", ")
|
||||||
|
}`)
|
||||||
.join("\n")
|
.join("\n")
|
||||||
|| "No timing information available"
|
|| "No timing information available"
|
||||||
}
|
}
|
||||||
|
|
Before Width: | Height: | Size: 4.1 KiB |
Before Width: | Height: | Size: 8 KiB |
Before Width: | Height: | Size: 1.1 KiB |
Before Width: | Height: | Size: 14 KiB |
Before Width: | Height: | Size: 2 KiB |
Before Width: | Height: | Size: 14 KiB |
|
@ -1,6 +1,12 @@
|
||||||
import { sveltekit } from '@sveltejs/kit/vite';
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
|
import pkg from "./package.json"
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [sveltekit()]
|
plugins: [sveltekit()],
|
||||||
|
|
||||||
|
define: {
|
||||||
|
'__APP_NAME__': JSON.stringify(pkg.name),
|
||||||
|
'__APP_VERSION__': JSON.stringify(pkg.version)
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|