Enhanced file uploading for production (thanks citrinitas3421!)
This commit is contained in:
@ -20,10 +20,15 @@ NAME=Ahmed Al-Taiar
|
||||
GMAIL=example@gmail.com
|
||||
GMAIL_SMTP_PASSWORD=chan geme xyza bcde
|
||||
|
||||
DOMAIN=example.com
|
||||
API_DOMAIN=api.example.com
|
||||
|
||||
# Must not end with "/"
|
||||
DOMAIN_PROD=https://example.com
|
||||
DOMAIN_DEV=http://localhost:8910
|
||||
ADDRESS_PROD=https://example.com
|
||||
ADDRESS_DEV=http://localhost:8910
|
||||
API_ADDRESS_PROD=https://api.example.com
|
||||
API_ADDRESS_DEV=http://localhost:8911
|
||||
|
||||
MAX_HTTP_CONNECTIONS_PER_MINUTE=60
|
||||
|
||||
DATABASE_URL=postgresql://user:password@localhost:5432/rw_portfolio
|
||||
|
||||
SESSION_SECRET=regenerate_me
|
||||
|
13
.env.example
13
.env.example
@ -8,10 +8,15 @@ NAME=Firstname Lastname
|
||||
GMAIL=example@gmail.com
|
||||
GMAIL_SMTP_PASSWORD=chan geme xyza bcde
|
||||
|
||||
DOMAIN=example.com
|
||||
API_DOMAIN=api.example.com
|
||||
|
||||
# Must not end with "/"
|
||||
DOMAIN_PROD=https://example.com
|
||||
DOMAIN_DEV=http://localhost:8910
|
||||
ADDRESS_PROD=https://example.com
|
||||
ADDRESS_DEV=http://localhost:8910
|
||||
API_ADDRESS_PROD=https://api.example.com
|
||||
API_ADDRESS_DEV=http://localhost:8911
|
||||
|
||||
MAX_HTTP_CONNECTIONS_PER_MINUTE=60
|
||||
|
||||
DATABASE_URL=postgresql://user:password@localhost:5432/rw_portfolio
|
||||
|
||||
SESSION_SECRET=regenerate_me
|
||||
|
2
.gitignore
vendored
2
.gitignore
vendored
@ -22,4 +22,4 @@ api/src/lib/generateGraphiQLHeader.*
|
||||
!.yarn/releases
|
||||
!.yarn/sdks
|
||||
!.yarn/versions
|
||||
files
|
||||
files_*
|
||||
|
@ -3,6 +3,8 @@
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^9.0.1",
|
||||
"@fastify/rate-limit": "^9.1.0",
|
||||
"@redwoodjs/api": "7.7.4",
|
||||
"@redwoodjs/api-server": "7.7.4",
|
||||
"@redwoodjs/auth-dbauth-api": "7.7.4",
|
||||
|
7
api/quick-lint-js.config
Normal file
7
api/quick-lint-js.config
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"globals": {
|
||||
"context": {
|
||||
"writable": false
|
||||
}
|
||||
}
|
||||
}
|
@ -1,5 +1,6 @@
|
||||
import type { APIGatewayProxyEvent, Context } from 'aws-lambda'
|
||||
|
||||
import { isProduction } from '@redwoodjs/api/dist/logger'
|
||||
import {
|
||||
DbAuthHandler,
|
||||
PasswordValidationError,
|
||||
@ -16,11 +17,9 @@ export const handler = async (
|
||||
) => {
|
||||
const forgotPasswordOptions: DbAuthHandlerOptions['forgotPassword'] = {
|
||||
handler: async (user, resetToken) => {
|
||||
const domain =
|
||||
(process.env.NODE_ENV || 'development') == 'production'
|
||||
? process.env.DOMAIN_PROD
|
||||
: process.env.DOMAIN_DEV
|
||||
|
||||
const domain = isProduction
|
||||
? process.env.ADDRESS_PROD
|
||||
: process.env.ADDRESS_DEV
|
||||
const text = `If this wasn't you, please disregard this email.
|
||||
|
||||
Hello,
|
||||
@ -76,9 +75,7 @@ ${domain}/reset-password?resetToken=${resetToken}
|
||||
// didn't validate their email yet), throw an error and it will be returned
|
||||
// by the `logIn()` function from `useAuth()` in the form of:
|
||||
// `{ message: 'Error message' }`
|
||||
handler: (user) => {
|
||||
return user
|
||||
},
|
||||
handler: (user) => user,
|
||||
|
||||
errors: {
|
||||
usernameOrPasswordMissing: 'Both username and password are required',
|
||||
@ -199,6 +196,14 @@ ${domain}/reset-password?resetToken=${resetToken}
|
||||
resetTokenExpiresAt: 'resetTokenExpiresAt',
|
||||
},
|
||||
|
||||
cors: {
|
||||
origin: isProduction
|
||||
? [process.env.ADDRESS_PROD, process.env.API_ADDRESS_PROD]
|
||||
: [process.env.ADDRESS_DEV, process.env.API_ADDRESS_DEV],
|
||||
credentials: true,
|
||||
methods: ['GET', 'OPTIONS', 'POST', 'PUT', 'DELETE', 'HEAD', 'PATCH'],
|
||||
},
|
||||
|
||||
// A list of fields on your user object that are safe to return to the
|
||||
// client when invoking a handler that returns a user (like forgotPassword
|
||||
// and signup). This list should be as small as possible to be sure not to
|
||||
@ -211,12 +216,10 @@ ${domain}/reset-password?resetToken=${resetToken}
|
||||
attributes: {
|
||||
HttpOnly: true,
|
||||
Path: '/',
|
||||
SameSite: 'Strict',
|
||||
Secure: process.env.NODE_ENV !== 'development',
|
||||
|
||||
// If you need to allow other domains (besides the api side) access to
|
||||
// the dbAuth session cookie:
|
||||
// Domain: 'example.com',
|
||||
SameSite: isProduction ? 'None' : 'Strict',
|
||||
Secure: isProduction,
|
||||
Domain: isProduction ? 'localhost' : 'localhost',
|
||||
// Domain: isProduction ? process.env.DOMAIN : 'localhost',
|
||||
},
|
||||
name: cookieName,
|
||||
},
|
||||
|
@ -1,5 +1,10 @@
|
||||
import type { Decoded } from '@redwoodjs/api'
|
||||
import { AuthenticationError, ForbiddenError } from '@redwoodjs/graphql-server'
|
||||
import { decryptSession, getSession } from '@redwoodjs/auth-dbauth-api'
|
||||
import {
|
||||
AuthenticationError,
|
||||
ForbiddenError,
|
||||
ValidationError,
|
||||
} from '@redwoodjs/graphql-server'
|
||||
|
||||
import { db } from './db'
|
||||
|
||||
@ -12,6 +17,10 @@ import { db } from './db'
|
||||
*/
|
||||
export const cookieName = 'session_%port%'
|
||||
|
||||
const cookieRegex = /([a-zA-Z0-9+/|=]{110})\w+/
|
||||
const tokenRegex =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
|
||||
/**
|
||||
* The session object sent in as the first argument to getCurrentUser() will
|
||||
* have a single key `id` containing the unique ID of the logged in user
|
||||
@ -30,9 +39,8 @@ export const cookieName = 'session_%port%'
|
||||
* seen if someone were to open the Web Inspector in their browser.
|
||||
*/
|
||||
export const getCurrentUser = async (session: Decoded) => {
|
||||
if (!session || typeof session.id !== 'number') {
|
||||
if (!session || typeof session.id !== 'number')
|
||||
throw new Error('Invalid session')
|
||||
}
|
||||
|
||||
return await db.user.findUnique({
|
||||
where: { id: session.id },
|
||||
@ -107,11 +115,29 @@ export const hasRole = (roles: AllowedRoles): boolean => {
|
||||
* @see https://github.com/redwoodjs/redwood/tree/main/packages/auth for examples
|
||||
*/
|
||||
export const requireAuth = ({ roles }: { roles?: AllowedRoles } = {}) => {
|
||||
if (!isAuthenticated()) {
|
||||
if (!isAuthenticated())
|
||||
throw new AuthenticationError("You don't have permission to do that.")
|
||||
}
|
||||
|
||||
if (roles && !hasRole(roles)) {
|
||||
if (roles && !hasRole(roles))
|
||||
throw new ForbiddenError("You don't have access to do that.")
|
||||
}
|
||||
|
||||
export const validateSessionCookie = (sessionCookie: string) => {
|
||||
const sessionCookieContent = sessionCookie.substring(
|
||||
sessionCookie.indexOf('=') + 1
|
||||
)
|
||||
|
||||
if (!cookieRegex.test(sessionCookieContent))
|
||||
throw new ValidationError('Invalid token format')
|
||||
}
|
||||
|
||||
export const decryptAndValidateSession = (sessionCookie: string) => {
|
||||
const cookie = cookieName.replace('%port%', '8911')
|
||||
const [session, csrfToken] = decryptSession(getSession(sessionCookie, cookie))
|
||||
if (!session.id) throw new ValidationError('Invalid session')
|
||||
|
||||
if (!tokenRegex.test(csrfToken))
|
||||
throw new ValidationError('Invalid session format')
|
||||
|
||||
return session.id
|
||||
}
|
||||
|
19
api/src/lib/cors.ts
Normal file
19
api/src/lib/cors.ts
Normal file
@ -0,0 +1,19 @@
|
||||
import type { FastifyReply } from 'fastify'
|
||||
|
||||
import { isProduction } from '@redwoodjs/api/dist/logger'
|
||||
|
||||
export const setCorsHeaders = (res: FastifyReply) => {
|
||||
res.raw.setHeader(
|
||||
'Access-Control-Allow-Origin',
|
||||
isProduction ? process.env.ADDRESS_PROD : process.env.ADDRESS_DEV
|
||||
)
|
||||
res.raw.setHeader(
|
||||
'Access-Control-Allow-Methods',
|
||||
'GET, POST, OPTIONS, PATCH, HEAD'
|
||||
)
|
||||
res.raw.setHeader(
|
||||
'Access-Control-Allow-Headers',
|
||||
'Origin, X-Requested-With, Content-Type, Accept, Authorization, Tus-Resumable, Upload-Length, Upload-Metadata, Upload-Offset'
|
||||
)
|
||||
res.raw.setHeader('Access-Control-Allow-Credentials', 'true')
|
||||
}
|
@ -15,7 +15,7 @@ const transporter = nodemailer.createTransport({
|
||||
},
|
||||
})
|
||||
|
||||
export async function sendEmail({ to, subject, text, html }: Options) {
|
||||
export const sendEmail = async ({ to, subject, text, html }: Options) => {
|
||||
return await transporter.sendMail({
|
||||
from: `"${process.env.NAME} (noreply)" <${process.env.GMAIL}>`,
|
||||
to: Array.isArray(to) ? to : [to],
|
||||
@ -25,7 +25,7 @@ export async function sendEmail({ to, subject, text, html }: Options) {
|
||||
})
|
||||
}
|
||||
|
||||
export function censorEmail(email: string): string {
|
||||
export const censorEmail = (email: string): string => {
|
||||
const [localPart, domain] = email.split('@')
|
||||
|
||||
if (localPart.length <= 2) return `${localPart}@${domain}`
|
||||
|
106
api/src/lib/tus.ts
Normal file
106
api/src/lib/tus.ts
Normal file
@ -0,0 +1,106 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import type { Server } from '@tus/server'
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify'
|
||||
|
||||
import { isProduction } from '@redwoodjs/api/dist/logger'
|
||||
import { ValidationError } from '@redwoodjs/graphql-server'
|
||||
|
||||
import { decryptAndValidateSession, validateSessionCookie } from 'src/lib/auth'
|
||||
import { setCorsHeaders } from 'src/lib/cors'
|
||||
import { db } from 'src/lib/db'
|
||||
|
||||
interface User {
|
||||
id: number
|
||||
username: string
|
||||
email: string
|
||||
hashedPassword: string
|
||||
salt: string
|
||||
resetToken: string | null
|
||||
resetTokenExpiresAt: Date | null
|
||||
}
|
||||
|
||||
export const handleTusUpload = (
|
||||
req: FastifyRequest,
|
||||
res: FastifyReply,
|
||||
tusHandler: Server,
|
||||
isPublicEndpoint: boolean
|
||||
) => {
|
||||
if (isProduction) {
|
||||
if (req.method === 'OPTIONS') handleOptionsRequest(res)
|
||||
else if (isPublicEndpoint && req.method === 'GET')
|
||||
tusHandler.handle(req.raw, res.raw)
|
||||
else if (['GET', 'POST', 'HEAD', 'PATCH'].includes(req.method)) {
|
||||
if (req.headers.cookie) handleAuthenticatedRequest(req, res, tusHandler)
|
||||
else {
|
||||
res.raw.statusCode = 401
|
||||
res.raw.end('Unauthenticated')
|
||||
}
|
||||
} else {
|
||||
res.raw.statusCode = 405
|
||||
res.raw.end('Method not allowed')
|
||||
}
|
||||
} else tusHandler.handle(req.raw, res.raw)
|
||||
}
|
||||
|
||||
const handleAuthenticatedRequest = async (
|
||||
req: FastifyRequest,
|
||||
res: FastifyReply,
|
||||
tusHandler: Server
|
||||
) => {
|
||||
try {
|
||||
const sessionCookie = extractSessionCookie(req.headers.cookie)
|
||||
validateSessionCookie(sessionCookie)
|
||||
const userId = decryptAndValidateSession(sessionCookie)
|
||||
|
||||
if (userId) {
|
||||
res.raw.setHeader('Access-Control-Allow-Credentials', 'true')
|
||||
|
||||
try {
|
||||
const user = await db.user.findUnique({
|
||||
where: {
|
||||
id: userId,
|
||||
},
|
||||
})
|
||||
|
||||
addUserMetadataToRequest(req, user)
|
||||
|
||||
if ((req.raw as any).userId) tusHandler.handle(req.raw, res.raw)
|
||||
else {
|
||||
res.raw.statusCode = 500
|
||||
res.raw.end('Server error')
|
||||
}
|
||||
} catch (error) {
|
||||
res.raw.statusCode = 500
|
||||
res.raw.end('Server error')
|
||||
}
|
||||
} else {
|
||||
res.raw.statusCode = 403
|
||||
res.raw.end('Forbidden')
|
||||
}
|
||||
} catch (error) {
|
||||
res.raw.statusCode = 401
|
||||
res.raw.end('Unauthenticated')
|
||||
}
|
||||
}
|
||||
|
||||
const addUserMetadataToRequest = (req: FastifyRequest, user: User) => {
|
||||
;(req.raw as any).userId = user.id
|
||||
;(req.raw as any).userEmail = user.email
|
||||
}
|
||||
|
||||
const handleOptionsRequest = (res: FastifyReply) => {
|
||||
setCorsHeaders(res)
|
||||
res.raw.statusCode = 204
|
||||
res.raw.end()
|
||||
}
|
||||
|
||||
const extractSessionCookie = (cookie: string) => {
|
||||
const sessionCookie = cookie
|
||||
.split(';')
|
||||
.find((item) => item.trim().startsWith('session_8911'))
|
||||
?.trim()
|
||||
|
||||
if (!sessionCookie) throw new ValidationError('Invalid token')
|
||||
|
||||
return sessionCookie
|
||||
}
|
@ -1,17 +1,40 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import Cors from '@fastify/cors'
|
||||
import RateLimit from '@fastify/rate-limit'
|
||||
import { FileStore } from '@tus/file-store'
|
||||
import { Server } from '@tus/server'
|
||||
|
||||
import { isProduction } from '@redwoodjs/api/dist/logger'
|
||||
import { createServer } from '@redwoodjs/api-server'
|
||||
|
||||
import { logger } from 'src/lib/logger'
|
||||
import { handleTusUpload } from 'src/lib/tus'
|
||||
;(async () => {
|
||||
const server = await createServer({
|
||||
logger,
|
||||
configureApiServer: async (server) => {
|
||||
await server.register(Cors, {
|
||||
origin: isProduction
|
||||
? [process.env.ADDRESS_PROD, process.env.API_ADDRESS_PROD]
|
||||
: [process.env.ADDRESS_DEV, process.env.API_ADDRESS_DEV],
|
||||
methods: ['GET', 'POST', 'OPTIONS', 'PATCH', 'HEAD'],
|
||||
credentials: isProduction ? true : false,
|
||||
})
|
||||
|
||||
await server.register(RateLimit, {
|
||||
max: Number(process.env.MAX_HTTP_CONNECTIONS_PER_MINUTE),
|
||||
timeWindow: `1 minute`,
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
const tusServer = new Server({
|
||||
path: '/files',
|
||||
datastore: new FileStore({ directory: './files' }),
|
||||
respectForwardedHeaders: true,
|
||||
datastore: new FileStore({
|
||||
directory: `./files_${isProduction ? 'prod' : 'dev'}`,
|
||||
}),
|
||||
onResponseError: (_req, res, _err) => logger.error(res),
|
||||
})
|
||||
|
||||
server.addContentTypeParser(
|
||||
@ -19,15 +42,12 @@ import { logger } from 'src/lib/logger'
|
||||
(_request, _payload, done) => done(null)
|
||||
)
|
||||
|
||||
server.all('/files', (req, res) => {
|
||||
tusServer.handle(req.raw, res.raw)
|
||||
return res
|
||||
})
|
||||
|
||||
server.all('/files/*', (req, res) => {
|
||||
tusServer.handle(req.raw, res.raw)
|
||||
return res
|
||||
})
|
||||
server.all('/files', (req, res) =>
|
||||
handleTusUpload(req, res, tusServer, false)
|
||||
)
|
||||
server.all('/files/*', (req, res) =>
|
||||
handleTusUpload(req, res, tusServer, true)
|
||||
)
|
||||
|
||||
await server.start()
|
||||
})()
|
||||
|
@ -9,7 +9,7 @@
|
||||
title = "${NAME}"
|
||||
port = 8910
|
||||
apiUrl = "/api"
|
||||
includeEnvironmentVariables = ["NAME"]
|
||||
includeEnvironmentVariables = ["NAME", "API_ADDRESS_PROD", "API_ADDRESS_DEV"]
|
||||
[generate]
|
||||
tests = false
|
||||
stories = false
|
||||
|
@ -2,26 +2,62 @@ import { useState } from 'react'
|
||||
|
||||
import Compressor from '@uppy/compressor'
|
||||
import Uppy from '@uppy/core'
|
||||
import type { UploadResult, Meta } from '@uppy/core'
|
||||
import ImageEditor from '@uppy/image-editor'
|
||||
import { Dashboard } from '@uppy/react'
|
||||
import Tus from '@uppy/tus'
|
||||
|
||||
import { isProduction } from '@redwoodjs/api/dist/logger'
|
||||
|
||||
import '@uppy/image-editor/dist/style.min.css'
|
||||
import '@uppy/core/dist/style.min.css'
|
||||
import '@uppy/dashboard/dist/style.min.css'
|
||||
|
||||
const Uploader = () => {
|
||||
const [uppy] = useState(() =>
|
||||
new Uppy({
|
||||
interface Props {
|
||||
onComplete?(result: UploadResult<Meta, Record<string, never>>): void
|
||||
}
|
||||
|
||||
const apiDomain = isProduction
|
||||
? process.env.API_ADDRESS_PROD
|
||||
: process.env.API_ADDRESS_DEV
|
||||
|
||||
const Uploader = ({ onComplete }: Props) => {
|
||||
const [uppy] = useState(() => {
|
||||
const instance = new Uppy({
|
||||
restrictions: {
|
||||
allowedFileTypes: ['image/*'],
|
||||
allowedFileTypes: [
|
||||
'image/webp',
|
||||
'image/png',
|
||||
'image/jpg',
|
||||
'image/jpeg',
|
||||
],
|
||||
maxNumberOfFiles: 10,
|
||||
maxFileSize: 25 * 1024 * 1024,
|
||||
},
|
||||
onBeforeUpload: (files) => {
|
||||
for (const [key, file] of Object.entries(files)) {
|
||||
instance.setFileMeta(key, {
|
||||
name: new Date().getTime().toString(),
|
||||
type: file.type,
|
||||
contentType: file.type,
|
||||
})
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
})
|
||||
.use(Tus, { endpoint: 'http://localhost:8911/files' }) // TODO: check if env is production and change endpoint accordingly
|
||||
.use(Tus, {
|
||||
endpoint: `${apiDomain}/files`,
|
||||
withCredentials: true,
|
||||
removeFingerprintOnSuccess: true,
|
||||
})
|
||||
.use(ImageEditor)
|
||||
.use(Compressor)
|
||||
)
|
||||
.use(Compressor, {
|
||||
mimeType: 'image/webp',
|
||||
})
|
||||
|
||||
return instance.on('complete', onComplete)
|
||||
})
|
||||
|
||||
return <Dashboard uppy={uppy} proudlyDisplayPoweredByUppy={false} />
|
||||
}
|
||||
|
@ -5,6 +5,7 @@ import Uploader from 'src/components/Uploader/Uploader'
|
||||
|
||||
const HomePage = () => {
|
||||
const { isAuthenticated } = useAuth()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Metadata title="Home" description="Home page" />
|
||||
|
41
yarn.lock
41
yarn.lock
@ -2419,6 +2419,16 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@fastify/cors@npm:^9.0.1":
|
||||
version: 9.0.1
|
||||
resolution: "@fastify/cors@npm:9.0.1"
|
||||
dependencies:
|
||||
fastify-plugin: "npm:^4.0.0"
|
||||
mnemonist: "npm:0.39.6"
|
||||
checksum: 10c0/4db9d3d02edbca741c8ed053819bf3b235ecd70e07c640ed91ba0fc1ee2dc8abedbbffeb79ae1a38ccbf59832e414cad90a554ee44227d0811d5a2d062940611
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@fastify/error@npm:^3.0.0, @fastify/error@npm:^3.3.0, @fastify/error@npm:^3.4.0":
|
||||
version: 3.4.1
|
||||
resolution: "@fastify/error@npm:3.4.1"
|
||||
@ -2456,6 +2466,17 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@fastify/rate-limit@npm:^9.1.0":
|
||||
version: 9.1.0
|
||||
resolution: "@fastify/rate-limit@npm:9.1.0"
|
||||
dependencies:
|
||||
"@lukeed/ms": "npm:^2.0.1"
|
||||
fastify-plugin: "npm:^4.0.0"
|
||||
toad-cache: "npm:^3.3.1"
|
||||
checksum: 10c0/55914e4af5d93fff29e94ad4e3f369a371a3e7b97e501e796167dd49115c5d8dfa94aec8ec78ffcdb058268465b072a0892880523774b7b90a27a8ff304fea82
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@fastify/reply-from@npm:^9.0.0":
|
||||
version: 9.8.0
|
||||
resolution: "@fastify/reply-from@npm:9.8.0"
|
||||
@ -7254,6 +7275,8 @@ __metadata:
|
||||
version: 0.0.0-use.local
|
||||
resolution: "api@workspace:api"
|
||||
dependencies:
|
||||
"@fastify/cors": "npm:^9.0.1"
|
||||
"@fastify/rate-limit": "npm:^9.1.0"
|
||||
"@redwoodjs/api": "npm:7.7.4"
|
||||
"@redwoodjs/api-server": "npm:7.7.4"
|
||||
"@redwoodjs/auth-dbauth-api": "npm:7.7.4"
|
||||
@ -16052,6 +16075,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"mnemonist@npm:0.39.6":
|
||||
version: 0.39.6
|
||||
resolution: "mnemonist@npm:0.39.6"
|
||||
dependencies:
|
||||
obliterator: "npm:^2.0.1"
|
||||
checksum: 10c0/a538945ea547976136ee6e16f224c0a50983143619941f6c4d2c82159e36eb6f8ee93d69d3a1267038fc5b16f88e2d43390023de10dfb145fa15c5e2befa1cdf
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"module-not-found-error@npm:^1.0.1":
|
||||
version: 1.0.1
|
||||
resolution: "module-not-found-error@npm:1.0.1"
|
||||
@ -16620,6 +16652,13 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"obliterator@npm:^2.0.1":
|
||||
version: 2.0.4
|
||||
resolution: "obliterator@npm:2.0.4"
|
||||
checksum: 10c0/ff2c10d4de7d62cd1d588b4d18dfc42f246c9e3a259f60d5716f7f88e5b3a3f79856b3207db96ec9a836a01d0958a21c15afa62a3f4e73a1e0b75f2c2f6bab40
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"obuf@npm:^1.0.0, obuf@npm:^1.1.2":
|
||||
version: 1.1.2
|
||||
resolution: "obuf@npm:1.1.2"
|
||||
@ -20632,7 +20671,7 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"toad-cache@npm:^3.3.0, toad-cache@npm:^3.7.0":
|
||||
"toad-cache@npm:^3.3.0, toad-cache@npm:^3.3.1, toad-cache@npm:^3.7.0":
|
||||
version: 3.7.0
|
||||
resolution: "toad-cache@npm:3.7.0"
|
||||
checksum: 10c0/7dae2782ee20b22c9798bb8b71dec7ec6a0091021d2ea9dd6e8afccab6b65b358fdba49a02209fac574499702e2c000660721516c87c2538d1b2c0ba03e8c0c3
|
||||
|
Reference in New Issue
Block a user