mirror of
https://git.joinsharkey.org/Sharkey/Sharkey.git
synced 2024-11-25 08:23:09 +02:00
Compare commits
51 commits
4ade52ce71
...
eece498288
Author | SHA1 | Date | |
---|---|---|---|
|
eece498288 | ||
|
e0afeff248 | ||
|
cfc8081cec | ||
|
011ccd3a9a | ||
|
28065fc1d1 | ||
|
960f4fcff7 | ||
|
92eec2178f | ||
|
56dca6dbf5 | ||
|
2a634e0309 | ||
|
e6970a0e7c | ||
|
571272a564 | ||
|
30bb0f60a2 | ||
|
328546c4cd | ||
|
f4e89f2e6b | ||
|
2cad97c1ab | ||
|
6ecfe7c7c3 | ||
|
23f476dbf3 | ||
|
7a1251423f | ||
|
7f5492a395 | ||
|
11d9fd9199 | ||
|
6132bc3b3e | ||
|
fef7a7b99a | ||
|
1948ca9aa8 | ||
|
848e1f9a56 | ||
|
9c4353ee79 | ||
|
a6e257f502 | ||
|
310e1a1262 | ||
|
15f3c046d1 | ||
|
01d695428a | ||
|
acf3e3460f | ||
|
4c8116859c | ||
|
0e13397db7 | ||
|
ad8818508f | ||
|
d444ee662f | ||
|
4c354fff2d | ||
|
634259d600 | ||
|
a3c302e756 | ||
|
bc24e6a294 | ||
|
1cd59c1ee3 | ||
|
b81448edf6 | ||
|
134d2895f0 | ||
|
7ba8fde9b9 | ||
|
1022280465 | ||
|
021d3924e6 | ||
|
b6d50d781f | ||
|
1d411bb885 | ||
|
f7afd1ae4a | ||
|
1ef1f2a03c | ||
|
829ce4f86a | ||
|
6d5d863150 | ||
|
fc7d4bc420 |
14 changed files with 144 additions and 12 deletions
|
@ -255,6 +255,11 @@ proxyRemoteFiles: true
|
||||||
# https://example.com/thumbnail.webp?thumbnail=1&url=https%3A%2F%2Fstorage.example.com%2Fpath%2Fto%2Fvideo.mp4
|
# https://example.com/thumbnail.webp?thumbnail=1&url=https%3A%2F%2Fstorage.example.com%2Fpath%2Fto%2Fvideo.mp4
|
||||||
#videoThumbnailGenerator: https://example.com
|
#videoThumbnailGenerator: https://example.com
|
||||||
|
|
||||||
|
# Enables the built-in thumbnail generator for remote videos. (default: false)
|
||||||
|
# Only useful if "Cache remote files" is disabled, and "videoThumbnailGenerator" is unset.
|
||||||
|
# Without it, remote video files that are not cached will not have any thumbnails.
|
||||||
|
#enableBuiltinVideoThumbnailGenerator: false
|
||||||
|
|
||||||
# Sign to ActivityPub GET request (default: true)
|
# Sign to ActivityPub GET request (default: true)
|
||||||
signToActivityPubGet: true
|
signToActivityPubGet: true
|
||||||
# check that inbound ActivityPub GET requests are signed ("authorized fetch")
|
# check that inbound ActivityPub GET requests are signed ("authorized fetch")
|
||||||
|
|
|
@ -270,6 +270,11 @@ proxyRemoteFiles: true
|
||||||
# https://example.com/thumbnail.webp?thumbnail=1&url=https%3A%2F%2Fstorage.example.com%2Fpath%2Fto%2Fvideo.mp4
|
# https://example.com/thumbnail.webp?thumbnail=1&url=https%3A%2F%2Fstorage.example.com%2Fpath%2Fto%2Fvideo.mp4
|
||||||
#videoThumbnailGenerator: https://example.com
|
#videoThumbnailGenerator: https://example.com
|
||||||
|
|
||||||
|
# Enables the built-in thumbnail generator for remote videos. (default: false)
|
||||||
|
# Only useful if "Cache remote files" is disabled, and "videoThumbnailGenerator" is unset.
|
||||||
|
# Without it, remote video files that are not cached will not have any thumbnails.
|
||||||
|
#enableBuiltinVideoThumbnailGenerator: false
|
||||||
|
|
||||||
# Sign to ActivityPub GET request (default: true)
|
# Sign to ActivityPub GET request (default: true)
|
||||||
signToActivityPubGet: true
|
signToActivityPubGet: true
|
||||||
# check that inbound ActivityPub GET requests are signed ("authorized fetch")
|
# check that inbound ActivityPub GET requests are signed ("authorized fetch")
|
||||||
|
|
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -55,6 +55,7 @@ api-docs.json
|
||||||
*.code-workspace
|
*.code-workspace
|
||||||
.DS_Store
|
.DS_Store
|
||||||
/files
|
/files
|
||||||
|
/cache
|
||||||
ormconfig.json
|
ormconfig.json
|
||||||
temp
|
temp
|
||||||
/packages/frontend/src/**/*.stories.ts
|
/packages/frontend/src/**/*.stories.ts
|
||||||
|
|
|
@ -55,6 +55,8 @@ getImageTag:
|
||||||
only:
|
only:
|
||||||
- stable
|
- stable
|
||||||
- develop
|
- develop
|
||||||
|
- tags
|
||||||
|
|
||||||
buildDocker:
|
buildDocker:
|
||||||
stage: deploy
|
stage: deploy
|
||||||
needs:
|
needs:
|
||||||
|
@ -78,6 +80,8 @@ buildDocker:
|
||||||
only:
|
only:
|
||||||
- stable
|
- stable
|
||||||
- develop
|
- develop
|
||||||
|
- tags
|
||||||
|
|
||||||
mergeManifests:
|
mergeManifests:
|
||||||
stage: deploy
|
stage: deploy
|
||||||
needs:
|
needs:
|
||||||
|
@ -103,3 +107,4 @@ mergeManifests:
|
||||||
only:
|
only:
|
||||||
- stable
|
- stable
|
||||||
- develop
|
- develop
|
||||||
|
- tags
|
||||||
|
|
|
@ -21,6 +21,7 @@ services:
|
||||||
- shonk
|
- shonk
|
||||||
volumes:
|
volumes:
|
||||||
- ./files:/sharkey/files
|
- ./files:/sharkey/files
|
||||||
|
- ./cache:/sharkey/cache
|
||||||
- ./.config:/sharkey/.config:ro
|
- ./.config:/sharkey/.config:ro
|
||||||
|
|
||||||
redis:
|
redis:
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "sharkey",
|
"name": "sharkey",
|
||||||
"version": "2024.3.1",
|
"version": "2024.3.2-devel",
|
||||||
"codename": "shonk",
|
"codename": "shonk",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|
|
@ -172,7 +172,7 @@
|
||||||
"stringz": "2.1.0",
|
"stringz": "2.1.0",
|
||||||
"systeminformation": "5.22.0",
|
"systeminformation": "5.22.0",
|
||||||
"tinycolor2": "1.6.0",
|
"tinycolor2": "1.6.0",
|
||||||
"tmp": "0.2.2",
|
"tmp": "0.2.3",
|
||||||
"tsc-alias": "1.8.8",
|
"tsc-alias": "1.8.8",
|
||||||
"tsconfig-paths": "4.2.0",
|
"tsconfig-paths": "4.2.0",
|
||||||
"typeorm": "0.3.20",
|
"typeorm": "0.3.20",
|
||||||
|
|
|
@ -88,6 +88,7 @@ type Source = {
|
||||||
mediaProxy?: string;
|
mediaProxy?: string;
|
||||||
proxyRemoteFiles?: boolean;
|
proxyRemoteFiles?: boolean;
|
||||||
videoThumbnailGenerator?: string;
|
videoThumbnailGenerator?: string;
|
||||||
|
enableBuiltinVideoThumbnailGenerator?: boolean;
|
||||||
|
|
||||||
customMOTD?: string[];
|
customMOTD?: string[];
|
||||||
|
|
||||||
|
@ -170,6 +171,7 @@ export type Config = {
|
||||||
mediaProxy: string;
|
mediaProxy: string;
|
||||||
externalMediaProxyEnabled: boolean;
|
externalMediaProxyEnabled: boolean;
|
||||||
videoThumbnailGenerator: string | null;
|
videoThumbnailGenerator: string | null;
|
||||||
|
enableBuiltinVideoThumbnailGenerator: boolean;
|
||||||
redis: RedisOptions & RedisOptionsSource;
|
redis: RedisOptions & RedisOptionsSource;
|
||||||
redisForPubsub: RedisOptions & RedisOptionsSource;
|
redisForPubsub: RedisOptions & RedisOptionsSource;
|
||||||
redisForJobQueue: RedisOptions & RedisOptionsSource;
|
redisForJobQueue: RedisOptions & RedisOptionsSource;
|
||||||
|
@ -276,6 +278,7 @@ export function loadConfig(): Config {
|
||||||
videoThumbnailGenerator: config.videoThumbnailGenerator ?
|
videoThumbnailGenerator: config.videoThumbnailGenerator ?
|
||||||
config.videoThumbnailGenerator.endsWith('/') ? config.videoThumbnailGenerator.substring(0, config.videoThumbnailGenerator.length - 1) : config.videoThumbnailGenerator
|
config.videoThumbnailGenerator.endsWith('/') ? config.videoThumbnailGenerator.substring(0, config.videoThumbnailGenerator.length - 1) : config.videoThumbnailGenerator
|
||||||
: null,
|
: null,
|
||||||
|
enableBuiltinVideoThumbnailGenerator: config.enableBuiltinVideoThumbnailGenerator ?? false,
|
||||||
userAgent: `Misskey/${version} (${config.url})`,
|
userAgent: `Misskey/${version} (${config.url})`,
|
||||||
clientEntry: clientManifest['src/_boot_.ts'],
|
clientEntry: clientManifest['src/_boot_.ts'],
|
||||||
clientManifestExists: clientManifestExists,
|
clientManifestExists: clientManifestExists,
|
||||||
|
|
|
@ -16,6 +16,7 @@ const _filename = fileURLToPath(import.meta.url);
|
||||||
const _dirname = dirname(_filename);
|
const _dirname = dirname(_filename);
|
||||||
|
|
||||||
const path = Path.resolve(_dirname, '../../../../files');
|
const path = Path.resolve(_dirname, '../../../../files');
|
||||||
|
const cachePath = Path.resolve(_dirname, '../../../../cache');
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class InternalStorageService {
|
export class InternalStorageService {
|
||||||
|
@ -30,11 +31,26 @@ export class InternalStorageService {
|
||||||
return Path.resolve(path, key);
|
return Path.resolve(path, key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public resolveCachePath(key: string) {
|
||||||
|
return Path.resolve(cachePath, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public existsCache(key: string) {
|
||||||
|
return fs.existsSync(this.resolveCachePath(key));
|
||||||
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public read(key: string) {
|
public read(key: string) {
|
||||||
return fs.createReadStream(this.resolvePath(key));
|
return fs.createReadStream(this.resolvePath(key));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public readCache(key: string) {
|
||||||
|
return fs.createReadStream(this.resolveCachePath(key));
|
||||||
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public saveFromPath(key: string, srcPath: string) {
|
public saveFromPath(key: string, srcPath: string) {
|
||||||
fs.mkdirSync(path, { recursive: true });
|
fs.mkdirSync(path, { recursive: true });
|
||||||
|
@ -49,8 +65,19 @@ export class InternalStorageService {
|
||||||
return `${this.config.url}/files/${key}`;
|
return `${this.config.url}/files/${key}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public saveCacheFromBuffer(key: string, data: Buffer) {
|
||||||
|
fs.mkdirSync(cachePath, { recursive: true });
|
||||||
|
fs.writeFileSync(this.resolveCachePath(key), data);
|
||||||
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public del(key: string) {
|
public del(key: string) {
|
||||||
fs.unlink(this.resolvePath(key), () => {});
|
fs.unlink(this.resolvePath(key), () => {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
public delCache(key: string) {
|
||||||
|
fs.unlink(this.resolveCachePath(key), () => {});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -50,7 +50,16 @@ export class VideoProcessingService {
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
public getExternalVideoThumbnailUrl(url: string): string | null {
|
public getExternalVideoThumbnailUrl(url: string): string | null {
|
||||||
if (this.config.videoThumbnailGenerator == null) return null;
|
if (this.config.videoThumbnailGenerator == null) {
|
||||||
|
if (this.config.enableBuiltinVideoThumbnailGenerator) {
|
||||||
|
return appendQuery(
|
||||||
|
`${this.config.url}/proxy/thumbnail.webp`,
|
||||||
|
query({ url }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return appendQuery(
|
return appendQuery(
|
||||||
`${this.config.videoThumbnailGenerator}/thumbnail.webp`,
|
`${this.config.videoThumbnailGenerator}/thumbnail.webp`,
|
||||||
|
|
|
@ -4,6 +4,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import * as fs from 'node:fs';
|
import * as fs from 'node:fs';
|
||||||
|
import * as crypto from 'node:crypto';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { dirname } from 'node:path';
|
import { dirname } from 'node:path';
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
@ -34,6 +35,7 @@ const _filename = fileURLToPath(import.meta.url);
|
||||||
const _dirname = dirname(_filename);
|
const _dirname = dirname(_filename);
|
||||||
|
|
||||||
const assets = `${_dirname}/../../server/file/assets/`;
|
const assets = `${_dirname}/../../server/file/assets/`;
|
||||||
|
const cacheDir = `${_dirname}/../../../../cache/`;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class FileServerService {
|
export class FileServerService {
|
||||||
|
@ -85,6 +87,15 @@ export class FileServerService {
|
||||||
done();
|
done();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (this.config.enableBuiltinVideoThumbnailGenerator) {
|
||||||
|
fastify.get<{
|
||||||
|
Querystring: { url: string; };
|
||||||
|
}>('/proxy/thumbnail.webp', async (request, reply) => {
|
||||||
|
return await this.videoThumbnailHandler(request, reply)
|
||||||
|
.catch(err => this.errorHandler(request, reply, err));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
fastify.get<{
|
fastify.get<{
|
||||||
Params: { url: string; };
|
Params: { url: string; };
|
||||||
Querystring: { url?: string; };
|
Querystring: { url?: string; };
|
||||||
|
@ -192,6 +203,7 @@ export class FileServerService {
|
||||||
reply.header('Content-Range', `bytes ${start}-${end}/${file.file.size}`);
|
reply.header('Content-Range', `bytes ${start}-${end}/${file.file.size}`);
|
||||||
reply.header('Accept-Ranges', 'bytes');
|
reply.header('Accept-Ranges', 'bytes');
|
||||||
reply.header('Content-Length', chunksize);
|
reply.header('Content-Length', chunksize);
|
||||||
|
reply.code(206);
|
||||||
} else {
|
} else {
|
||||||
image = {
|
image = {
|
||||||
data: fs.createReadStream(file.path),
|
data: fs.createReadStream(file.path),
|
||||||
|
@ -261,7 +273,6 @@ export class FileServerService {
|
||||||
const parts = range.replace(/bytes=/, '').split('-');
|
const parts = range.replace(/bytes=/, '').split('-');
|
||||||
const start = parseInt(parts[0], 10);
|
const start = parseInt(parts[0], 10);
|
||||||
let end = parts[1] ? parseInt(parts[1], 10) : file.file.size - 1;
|
let end = parts[1] ? parseInt(parts[1], 10) : file.file.size - 1;
|
||||||
console.log(end);
|
|
||||||
if (end > file.file.size) {
|
if (end > file.file.size) {
|
||||||
end = file.file.size - 1;
|
end = file.file.size - 1;
|
||||||
}
|
}
|
||||||
|
@ -431,6 +442,7 @@ export class FileServerService {
|
||||||
reply.header('Content-Range', `bytes ${start}-${end}/${file.file.size}`);
|
reply.header('Content-Range', `bytes ${start}-${end}/${file.file.size}`);
|
||||||
reply.header('Accept-Ranges', 'bytes');
|
reply.header('Accept-Ranges', 'bytes');
|
||||||
reply.header('Content-Length', chunksize);
|
reply.header('Content-Length', chunksize);
|
||||||
|
reply.code(206);
|
||||||
} else {
|
} else {
|
||||||
image = {
|
image = {
|
||||||
data: fs.createReadStream(file.path),
|
data: fs.createReadStream(file.path),
|
||||||
|
@ -466,6 +478,61 @@ export class FileServerService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@bindThis
|
||||||
|
private async videoThumbnailHandler(request: FastifyRequest<{ Querystring: { url: string; }; }>, reply: FastifyReply) {
|
||||||
|
const cacheKey = crypto.createHash('md5').update(request.query.url).digest('base64url');
|
||||||
|
const cacheFile = `videoThumbnail-${cacheKey}.webp`;
|
||||||
|
if (this.internalStorageService.existsCache(cacheFile)) {
|
||||||
|
reply.header('Content-Type', 'image/webp');
|
||||||
|
reply.header('Cache-Control', 'max-age=31536000, immutable');
|
||||||
|
return reply.sendFile(cacheFile, cacheDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
const file = await this.getStreamAndTypeFromUrl(request.query.url);
|
||||||
|
|
||||||
|
if (file === '404') {
|
||||||
|
reply.code(404);
|
||||||
|
reply.header('Cache-Control', 'max-age=86400');
|
||||||
|
return reply.sendFile('/dummy.png', assets);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file === '204') {
|
||||||
|
reply.code(204);
|
||||||
|
reply.header('Cache-Control', 'max-age=86400');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.file?.thumbnailUrl) {
|
||||||
|
return await reply.redirect(301, file.file.thumbnailUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!file.mime.startsWith('video/')) {
|
||||||
|
if ('cleanup' in file) {
|
||||||
|
file.cleanup();
|
||||||
|
}
|
||||||
|
|
||||||
|
reply.code(400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const image = await this.videoProcessingService.generateVideoThumbnail(file.path);
|
||||||
|
|
||||||
|
if ('cleanup' in file) {
|
||||||
|
file.cleanup();
|
||||||
|
}
|
||||||
|
|
||||||
|
this.internalStorageService.saveCacheFromBuffer(cacheFile, image.data);
|
||||||
|
|
||||||
|
reply.header('Content-Type', image.type);
|
||||||
|
reply.header('Cache-Control', 'max-age=31536000, immutable');
|
||||||
|
return image.data;
|
||||||
|
} catch (e) {
|
||||||
|
if ('cleanup' in file) file.cleanup();
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@bindThis
|
@bindThis
|
||||||
private async getStreamAndTypeFromUrl(url: string): Promise<
|
private async getStreamAndTypeFromUrl(url: string): Promise<
|
||||||
{ state: 'remote'; fileRole?: 'thumbnail' | 'webpublic' | 'original'; file?: MiDriveFile; mime: string; ext: string | null; path: string; cleanup: () => void; filename: string; }
|
{ state: 'remote'; fileRole?: 'thumbnail' | 'webpublic' | 'original'; file?: MiDriveFile; mime: string; ext: string | null; path: string; cleanup: () => void; filename: string; }
|
||||||
|
@ -527,6 +594,9 @@ export class FileServerService {
|
||||||
if (!file.storedInternal) {
|
if (!file.storedInternal) {
|
||||||
if (!(file.isLink && file.uri)) return '204';
|
if (!(file.isLink && file.uri)) return '204';
|
||||||
const result = await this.downloadAndDetectTypeFromUrl(file.uri);
|
const result = await this.downloadAndDetectTypeFromUrl(file.uri);
|
||||||
|
if (!file.size) {
|
||||||
|
file.size = (await fs.promises.stat(result.path)).size;
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
url: file.uri,
|
url: file.uri,
|
||||||
|
|
|
@ -5,8 +5,8 @@ block vars
|
||||||
- const title = user.name ? `${user.name} (@${user.username})` : `@${user.username}`;
|
- const title = user.name ? `${user.name} (@${user.username})` : `@${user.username}`;
|
||||||
- const url = `${config.url}/notes/${note.id}`;
|
- const url = `${config.url}/notes/${note.id}`;
|
||||||
- const isRenote = note.renote && note.text == null && note.fileIds.length == 0 && note.poll == null;
|
- const isRenote = note.renote && note.text == null && note.fileIds.length == 0 && note.poll == null;
|
||||||
- const images = (note.files || []).filter(file => file.type.startsWith('image/') && !file.isSensitive)
|
- const images = note.cw ? [] : (note.files || []).filter(file => file.type.startsWith('image/') && !file.isSensitive)
|
||||||
- const videos = (note.files || []).filter(file => file.type.startsWith('video/') && !file.isSensitive)
|
- const videos = note.cw ? [] : (note.files || []).filter(file => file.type.startsWith('video/') && !file.isSensitive)
|
||||||
|
|
||||||
block title
|
block title
|
||||||
= `${title} | ${instanceName}`
|
= `${title} | ${instanceName}`
|
||||||
|
|
|
@ -392,8 +392,8 @@ importers:
|
||||||
specifier: 1.6.0
|
specifier: 1.6.0
|
||||||
version: 1.6.0
|
version: 1.6.0
|
||||||
tmp:
|
tmp:
|
||||||
specifier: 0.2.2
|
specifier: 0.2.3
|
||||||
version: 0.2.2
|
version: 0.2.3
|
||||||
tsc-alias:
|
tsc-alias:
|
||||||
specifier: 1.8.8
|
specifier: 1.8.8
|
||||||
version: 1.8.8
|
version: 1.8.8
|
||||||
|
@ -18813,6 +18813,12 @@ packages:
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
dependencies:
|
dependencies:
|
||||||
rimraf: 5.0.5
|
rimraf: 5.0.5
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/tmp@0.2.3:
|
||||||
|
resolution: {integrity: sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==}
|
||||||
|
engines: {node: '>=14.14'}
|
||||||
|
dev: false
|
||||||
|
|
||||||
/tmpl@1.0.5:
|
/tmpl@1.0.5:
|
||||||
resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==}
|
resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==}
|
||||||
|
|
Loading…
Reference in a new issue