feat: 加固本地缓存安全
This commit is contained in:
1 parent
694714a11d
commit
06b418ec51
10 files changed
+146
-256
No files matched your search
@@ -10,8 +10,9 @@
|
||||
|
||||
|
||||
[**Demo演示**](https://2fa.nezha.pp.ua) https://2fa.nezha.pp.ua
|
||||
- 设置任何用户都能登录,不受登录白名单限制,正常部署,只有设置的白名单用户才能登录
|
||||
- 演示数据可以任意修改、删除;
|
||||
|
||||
- 演示站默认允许任何用户登录,区别于正常部署只有白名单用户才允许登录
|
||||
- 演示站数据可以任意修改、删除
|
||||
|
||||
---
|
||||
|
||||
@@ -162,7 +163,7 @@ cd backend
|
||||
npx wrangler d1 execute 2fauth-db-dev --local --env dev --file=schema.sql
|
||||
|
||||
# 4. 终端启动应用
|
||||
# 终端 1:
|
||||
```bash
|
||||
cd backend && npm run dev
|
||||
```
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@ auth.post('/callback/:provider', async (c) => {
|
||||
const service = getService(c);
|
||||
|
||||
// 调用 Service 层处理登录
|
||||
const { token, userInfo } = await service.handleOAuthCallback(providerName, body);
|
||||
const { token, userInfo, deviceKey } = await service.handleOAuthCallback(providerName, body);
|
||||
|
||||
// 1. 设置 httpOnly 的鉴权 Cookie
|
||||
setCookie(c, 'auth_token', token, {
|
||||
@@ -71,7 +71,8 @@ auth.post('/callback/:provider', async (c) => {
|
||||
|
||||
return c.json({
|
||||
success: true,
|
||||
userInfo
|
||||
userInfo,
|
||||
deviceKey
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -26,9 +26,9 @@ export class AuthService {
|
||||
}
|
||||
|
||||
/**
|
||||
* OAuth Callback 处理,进行鉴权并签发系统内部 JWT token
|
||||
* OAuth Callback 处理,生成会话并返回附加参数
|
||||
*/
|
||||
async handleOAuthCallback(providerName: string, body: any): Promise<{ token: string, userInfo: OAuthUserInfo }> {
|
||||
async handleOAuthCallback(providerName: string, body: any): Promise<{ token: string, userInfo: OAuthUserInfo, deviceKey: string }> {
|
||||
const provider = getOAuthProvider(providerName, this.env);
|
||||
|
||||
let params: string | URLSearchParams;
|
||||
@@ -53,7 +53,27 @@ export class AuthService {
|
||||
// 签发 Token
|
||||
const token = await this.generateSystemToken(userInfo);
|
||||
|
||||
return { token, userInfo };
|
||||
// 附加客户端签名因子
|
||||
const deviceKey = await this.generateDeviceKey(userInfo.id);
|
||||
|
||||
return { token, userInfo, deviceKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成客户端绑定标识
|
||||
*/
|
||||
private async generateDeviceKey(userId: string): Promise<string> {
|
||||
const secret = this.env.JWT_SECRET;
|
||||
const encoder = new TextEncoder();
|
||||
const keyMaterial = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
encoder.encode(secret),
|
||||
{ name: 'HMAC', hash: 'SHA-256' },
|
||||
false,
|
||||
['sign']
|
||||
);
|
||||
const signature = await crypto.subtle.sign('HMAC', keyMaterial, encoder.encode(userId + "device_salt_offline_key"));
|
||||
return Array.from(new Uint8Array(signature)).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,6 +2,7 @@ import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { request } from '@/shared/utils/request'
|
||||
import { useVaultStore } from '@/features/vault/store/vaultStore'
|
||||
import { removeIdbItem } from '@/shared/utils/idb'
|
||||
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
const getStoredUser = () => {
|
||||
@@ -19,13 +20,19 @@ export const useUserStore = defineStore('user', () => {
|
||||
localStorage.setItem('userInfo', JSON.stringify(info))
|
||||
}
|
||||
|
||||
const clearUserInfo = () => {
|
||||
const clearUserInfo = async () => {
|
||||
userInfo.value = {}
|
||||
localStorage.removeItem('userInfo')
|
||||
localStorage.removeItem('secure_vault')
|
||||
localStorage.removeItem('backup_providers_cache')
|
||||
|
||||
// 锁定保险箱
|
||||
// 清理本地持久化运行配置
|
||||
try {
|
||||
await removeIdbItem('device_salt')
|
||||
} catch (e) {
|
||||
console.error('Failed to remove device_salt from IDB', e)
|
||||
}
|
||||
|
||||
const vaultStore = useVaultStore()
|
||||
vaultStore.lock()
|
||||
}
|
||||
@@ -36,7 +43,7 @@ export const useUserStore = defineStore('user', () => {
|
||||
} catch (e) {
|
||||
console.error('Logout request failed', e)
|
||||
} finally {
|
||||
clearUserInfo()
|
||||
await clearUserInfo()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -48,7 +55,7 @@ export const useUserStore = defineStore('user', () => {
|
||||
return true
|
||||
}
|
||||
} catch (e) {
|
||||
clearUserInfo()
|
||||
await clearUserInfo()
|
||||
return false
|
||||
}
|
||||
return false
|
||||
|
||||
@@ -31,6 +31,7 @@ import { ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { Loading } from '@element-plus/icons-vue'
|
||||
import { useUserStore } from '@/features/auth/store/userStore'
|
||||
import { setIdbItem } from '@/shared/utils/idb'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -125,6 +126,11 @@ onMounted(async () => {
|
||||
|
||||
if (data.success) {
|
||||
// 登录成功!Token 已写入 httpOnly Cookie
|
||||
// 持久化客户端参数
|
||||
if (data.deviceKey) {
|
||||
await setIdbItem('device_salt', data.deviceKey)
|
||||
}
|
||||
|
||||
// 立即调用接口确认会话有效性,并更新用户信息
|
||||
const userStore = useUserStore()
|
||||
await userStore.fetchUserInfo()
|
||||
|
||||
@@ -228,15 +228,11 @@ const isRestoring = ref(false)
|
||||
|
||||
// --- API 交互 ---
|
||||
const fetchProviders = async () => {
|
||||
// 1. 离线优先:尝试加载加密缓存 (仅在保险箱已解锁时有效)
|
||||
if (vaultStore.isUnlocked) {
|
||||
const cachedEncrypted = await vaultStore.getEncryptedBackupProviders()
|
||||
if (cachedEncrypted && Array.isArray(cachedEncrypted)) {
|
||||
providers.value = cachedEncrypted
|
||||
// 有缓存时静默更新,不转圈圈
|
||||
} else {
|
||||
isLoading.value = true
|
||||
}
|
||||
// 1. 离线优先:尝试加载加密缓存
|
||||
const cachedEncrypted = await vaultStore.getEncryptedBackupProviders()
|
||||
if (cachedEncrypted && Array.isArray(cachedEncrypted)) {
|
||||
providers.value = cachedEncrypted
|
||||
// 有缓存时静默更新,不转圈圈
|
||||
} else {
|
||||
isLoading.value = true
|
||||
}
|
||||
@@ -245,10 +241,8 @@ const fetchProviders = async () => {
|
||||
const res = await request('/api/backups/providers')
|
||||
if (res.success) {
|
||||
providers.value = res.providers
|
||||
// 2. 更新加密缓存 (如果保险箱已解锁)
|
||||
if (vaultStore.isUnlocked) {
|
||||
await vaultStore.saveEncryptedBackupProviders(res.providers)
|
||||
}
|
||||
// 2. 更新加密缓存
|
||||
await vaultStore.saveEncryptedBackupProviders(res.providers)
|
||||
}
|
||||
} finally { isLoading.value = false }
|
||||
}
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
<template>
|
||||
<div class="vault-list-wrapper" style="min-height: 400px;">
|
||||
<!-- 0. 保险箱锁定状态 -->
|
||||
<div v-if="!vaultStore.isUnlocked" class="vault-lock-wrapper">
|
||||
<VaultUnlock @unlocked="handleUnlocked" />
|
||||
</div>
|
||||
|
||||
<!-- 1. 加载状态 (已解锁且本地缓存尚未挂载,或者正在首次/后台请求时但无数据) -->
|
||||
<div v-else-if="(isLoading || isFetching) && vault.length === 0" class="loading-state" style="display: flex; flex-direction: column; justify-content: center; align-items: center; min-height: 400px; color: var(--el-text-color-secondary);">
|
||||
<!-- 1. 加载状态 (本地缓存尚未挂载,或者正在首次/后台请求时但无数据) -->
|
||||
<div v-if="(isLoading || isFetching) && vault.length === 0" class="loading-state" style="display: flex; flex-direction: column; justify-content: center; align-items: center; min-height: 400px; color: var(--el-text-color-secondary);">
|
||||
<el-icon class="is-loading" :size="48" style="margin-bottom: 20px; color: var(--el-color-primary);"><Loading /></el-icon>
|
||||
<p style="font-size: 16px; letter-spacing: 1px;">数据获取中, 请稍候...</p>
|
||||
</div>
|
||||
@@ -179,9 +174,6 @@ import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/vue-que
|
||||
import { generateTOTP } from '@/shared/utils/totp'
|
||||
import { copyToClipboard } from '@/shared/utils/common'
|
||||
|
||||
// 异步引入解锁组件,避免循环依赖
|
||||
const VaultUnlock = defineAsyncComponent(() => import('@/features/vault/components/vaultUnlock.vue'))
|
||||
|
||||
const emit = defineEmits(['switch-tab'])
|
||||
const queryClient = useQueryClient()
|
||||
const vaultStore = useVaultStore()
|
||||
@@ -528,10 +520,8 @@ const copySecret = async () => {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
// 如果已经解锁 (例如从其他 Tab 切回来),尝试加载缓存
|
||||
if (vaultStore.isUnlocked) {
|
||||
handleUnlocked()
|
||||
}
|
||||
// 组件加载即尝试读取本地离线数据
|
||||
handleUnlocked()
|
||||
globalTimer = setInterval(updateVaultStatus, 1000)
|
||||
})
|
||||
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
<template>
|
||||
<div class="vault-unlock-container">
|
||||
<el-card class="unlock-card" shadow="hover">
|
||||
<div class="icon-wrapper">
|
||||
<el-icon :size="48" color="#409EFF"><Lock /></el-icon>
|
||||
</div>
|
||||
|
||||
<h2 class="title">{{ vaultStore.hasVault ? '解锁本地缓存' : '设置缓存密码' }}</h2>
|
||||
<p class="subtitle" style="margin-bottom: 10px;">
|
||||
{{ vaultStore.hasVault
|
||||
? '请输入您的缓存密码以加载本地离线数据'
|
||||
: '设置一个密码用于将 2FA 数据安全地缓存在本机'
|
||||
}}
|
||||
</p>
|
||||
|
||||
<div class="form-container">
|
||||
<el-input
|
||||
v-model="password"
|
||||
type="password"
|
||||
show-password
|
||||
placeholder="请输入密码"
|
||||
@keyup.enter="handleSubmit"
|
||||
:prefix-icon="Key"
|
||||
size="large"
|
||||
/>
|
||||
|
||||
<div v-if="!vaultStore.hasVault" style="margin-top: 15px;">
|
||||
<el-input
|
||||
v-model="confirmPassword"
|
||||
type="password"
|
||||
show-password
|
||||
placeholder="再次确认密码"
|
||||
@keyup.enter="handleSubmit"
|
||||
:prefix-icon="Key"
|
||||
size="large"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="!vaultStore.hasVault" style="margin-top: 15px; text-align: left;">
|
||||
<el-alert
|
||||
title="缓存密码用途说明"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
>
|
||||
<p style="margin: 5px 0 0 0; line-height: 1.5; font-size: 13px;">
|
||||
此密码仅用于将 2FA 数据强加密保存到您的浏览器本地缓存中,防止<span style="color: var(--el-color-primary); font-weight: bold;">离线使用</span>时的明文泄露。<br/>
|
||||
即使您意外遗忘了它,也可以随时<span style="color: var(--el-color-success); font-weight: bold;">清除并重置</span>,重新登录即可从云端恢复您的全部 2FA 数据。
|
||||
</p>
|
||||
</el-alert>
|
||||
</div>
|
||||
|
||||
<div v-if="errorMsg" class="error-msg">
|
||||
{{ errorMsg }}
|
||||
</div>
|
||||
|
||||
<el-button
|
||||
type="primary"
|
||||
size="large"
|
||||
class="submit-btn"
|
||||
:loading="loading"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
{{ vaultStore.hasVault ? '验证并进入' : '开启缓存保护' }}
|
||||
</el-button>
|
||||
|
||||
<div v-if="vaultStore.hasVault" class="reset-link">
|
||||
<el-button link type="info" size="small" @click="handleResetPrompt">忘记密码?清除本地离线缓存</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, h } from 'vue'
|
||||
import { Lock, Key } from '@element-plus/icons-vue'
|
||||
import { useVaultStore } from '@/features/vault/store/vaultStore'
|
||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||
|
||||
const vaultStore = useVaultStore()
|
||||
|
||||
const emit = defineEmits(['unlocked'])
|
||||
|
||||
const password = ref('')
|
||||
const confirmPassword = ref('')
|
||||
const loading = ref(false)
|
||||
const errorMsg = ref('')
|
||||
|
||||
onMounted(() => {
|
||||
vaultStore.init()
|
||||
})
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!password.value) {
|
||||
errorMsg.value = '密码不能为空'
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
errorMsg.value = ''
|
||||
|
||||
try {
|
||||
if (vaultStore.hasVault) {
|
||||
// 解锁模式
|
||||
await vaultStore.unlock(password.value)
|
||||
ElMessage.success('解锁成功')
|
||||
emit('unlocked')
|
||||
} else {
|
||||
// 创建模式
|
||||
if (password.value.length < 6) {
|
||||
throw new Error('密码长度至少需要 6 位')
|
||||
}
|
||||
if (password.value !== confirmPassword.value) {
|
||||
throw new Error('两次输入的密码不一致')
|
||||
}
|
||||
await vaultStore.setup(password.value)
|
||||
ElMessage.success('保险箱创建成功')
|
||||
emit('unlocked')
|
||||
}
|
||||
} catch (e) {
|
||||
errorMsg.value = e.message
|
||||
// 震动反馈 (如果支持)
|
||||
if (navigator.vibrate) navigator.vibrate(200)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleResetPrompt = async () => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
h('div', null, [
|
||||
h('p', { style: 'margin-bottom: 10px; font-weight: 500;' }, '密码找不到了?没关系!'),
|
||||
h('p', { style: 'font-size: 13px; color: var(--el-text-color-secondary); line-height: 1.6;' },
|
||||
'点击下方按钮,系统将为您强制清理当前浏览器的本地加密缓存。清理完成后,您只需像初次访问一样,重新设置一个新的缓存密码即可。您的云端原始数据不会受到任何影响。'
|
||||
)
|
||||
]),
|
||||
'清除缓存锁',
|
||||
{
|
||||
confirmButtonText: '确定清除',
|
||||
cancelButtonText: '点错了,返回',
|
||||
type: 'warning',
|
||||
center: true
|
||||
}
|
||||
)
|
||||
|
||||
// User confirmed
|
||||
localStorage.removeItem('secure_vault')
|
||||
vaultStore.init()
|
||||
password.value = ''
|
||||
confirmPassword.value = ''
|
||||
errorMsg.value = ''
|
||||
ElMessage.success('本地缓存已完全清除,请重新设置密码')
|
||||
} catch (err) {
|
||||
if (err !== 'cancel') console.error(err)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,91 +1,61 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref } from 'vue'
|
||||
import { encryptDataWithPassword, decryptDataWithPassword } from '@/shared/utils/crypto'
|
||||
import { getIdbItem } from '@/shared/utils/idb'
|
||||
|
||||
export const useVaultStore = defineStore('vault', () => {
|
||||
const isUnlocked = ref(false)
|
||||
const isUnlocked = ref(true) // 默认挂载状态
|
||||
const hasVault = ref(false)
|
||||
const isDirty = ref(false) // 标记缓存是否已因变更操作而过期
|
||||
const _password = ref(null) // 仅保存在内存中,刷新页面即丢失
|
||||
|
||||
// 初始化检查:判断本地是否有加密数据,并尝试恢复会话
|
||||
// 初始化检查
|
||||
const init = async () => {
|
||||
const encrypted = localStorage.getItem('secure_vault')
|
||||
hasVault.value = !!encrypted
|
||||
if (!hasVault.value) return
|
||||
|
||||
// 模式 A: 尝试从 sessionStorage 恢复会话级解锁状态
|
||||
const sessionPassword = sessionStorage.getItem('vault_session_key')
|
||||
if (sessionPassword) {
|
||||
try {
|
||||
// 验证密码是否仍然有效
|
||||
await decryptDataWithPassword(encrypted, sessionPassword)
|
||||
// 验证通过,自动解锁
|
||||
_password.value = sessionPassword
|
||||
isUnlocked.value = true
|
||||
} catch (e) {
|
||||
// 密码无效,清除会话并锁定
|
||||
lock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 解锁保险箱
|
||||
const unlock = async (password) => {
|
||||
const encrypted = localStorage.getItem('secure_vault')
|
||||
if (!encrypted) throw new Error('尚未创建保险箱')
|
||||
|
||||
// 获取本地运行时参数
|
||||
const getDeviceKey = async () => {
|
||||
try {
|
||||
await decryptDataWithPassword(encrypted, password)
|
||||
|
||||
_password.value = password
|
||||
isUnlocked.value = true
|
||||
sessionStorage.setItem('vault_session_key', password)
|
||||
return true
|
||||
return await getIdbItem('device_salt')
|
||||
} catch (e) {
|
||||
throw new Error('密码错误')
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// 创建/重置保险箱
|
||||
const setup = async (password, initialData = { vault: [] }) => {
|
||||
const encrypted = await encryptDataWithPassword(initialData, password)
|
||||
localStorage.setItem('secure_vault', encrypted)
|
||||
_password.value = password
|
||||
hasVault.value = true
|
||||
isUnlocked.value = true
|
||||
sessionStorage.setItem('vault_session_key', password)
|
||||
}
|
||||
|
||||
// 锁定保险箱
|
||||
const lock = () => {
|
||||
_password.value = null
|
||||
isUnlocked.value = false
|
||||
sessionStorage.removeItem('vault_session_key')
|
||||
}
|
||||
|
||||
// 获取数据 (使用内存密码解密)
|
||||
// 加载数据
|
||||
const getData = async () => {
|
||||
if (!isUnlocked.value || !_password.value) return null
|
||||
const key = await getDeviceKey()
|
||||
if (!key) throw new Error('设备授权信息已失效,请重新登录')
|
||||
|
||||
const encrypted = localStorage.getItem('secure_vault')
|
||||
if (!encrypted) return { vault: [] }
|
||||
return await decryptDataWithPassword(encrypted, _password.value)
|
||||
|
||||
try {
|
||||
return await decryptDataWithPassword(encrypted, key)
|
||||
} catch (e) {
|
||||
// 若解密失败(极少情况如果用户清库或密钥被篡改),强行回退给空库
|
||||
return { vault: [] }
|
||||
}
|
||||
}
|
||||
|
||||
// 保存数据 (使用内存密码加密)
|
||||
// 持久化数据
|
||||
const saveData = async (data) => {
|
||||
if (!isUnlocked.value || !_password.value) throw new Error('保险箱已锁定')
|
||||
const encrypted = await encryptDataWithPassword(data, _password.value)
|
||||
const key = await getDeviceKey()
|
||||
if (!key) throw new Error('设备授权信息已失效,请重新登录')
|
||||
const encrypted = await encryptDataWithPassword(data, key)
|
||||
localStorage.setItem('secure_vault', encrypted)
|
||||
hasVault.value = true
|
||||
}
|
||||
|
||||
// --- 备份云配置的专属加密缓存 ---
|
||||
const getEncryptedBackupProviders = async () => {
|
||||
if (!isUnlocked.value || !_password.value) return null
|
||||
const key = await getDeviceKey()
|
||||
if (!key) return null
|
||||
const encrypted = localStorage.getItem('backup_providers_cache')
|
||||
if (!encrypted) return null
|
||||
try {
|
||||
const decrypted = await decryptDataWithPassword(encrypted, _password.value)
|
||||
const decrypted = await decryptDataWithPassword(encrypted, key)
|
||||
return decrypted.providers || null
|
||||
} catch (e) {
|
||||
return null
|
||||
@@ -93,11 +63,17 @@ export const useVaultStore = defineStore('vault', () => {
|
||||
}
|
||||
|
||||
const saveEncryptedBackupProviders = async (providers) => {
|
||||
if (!isUnlocked.value || !_password.value) return
|
||||
const encrypted = await encryptDataWithPassword({ providers }, _password.value)
|
||||
const key = await getDeviceKey()
|
||||
if (!key) return
|
||||
const encrypted = await encryptDataWithPassword({ providers }, key)
|
||||
localStorage.setItem('backup_providers_cache', encrypted)
|
||||
}
|
||||
|
||||
// 残留供部分旧UI避免报错的空函数 (即将被清理)
|
||||
const unlock = async () => { return true }
|
||||
const setup = async () => { }
|
||||
const lock = () => { }
|
||||
|
||||
return {
|
||||
isUnlocked,
|
||||
hasVault,
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// frontend/src/shared/utils/idb.js
|
||||
|
||||
const DB_NAME = 'VaultAppDB'
|
||||
const STORE_NAME = 'app_key_store'
|
||||
const DB_VERSION = 1
|
||||
|
||||
function openDB() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION)
|
||||
|
||||
request.onerror = () => reject(request.error)
|
||||
request.onsuccess = () => resolve(request.result)
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = event.target.result
|
||||
if (!db.objectStoreNames.contains(STORE_NAME)) {
|
||||
db.createObjectStore(STORE_NAME)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export async function setIdbItem(key, value) {
|
||||
const db = await openDB()
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction(STORE_NAME, 'readwrite')
|
||||
const store = transaction.objectStore(STORE_NAME)
|
||||
const request = store.put(value, key)
|
||||
request.onsuccess = () => resolve()
|
||||
request.onerror = () => reject(request.error)
|
||||
})
|
||||
}
|
||||
|
||||
export async function getIdbItem(key) {
|
||||
const db = await openDB()
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction(STORE_NAME, 'readonly')
|
||||
const store = transaction.objectStore(STORE_NAME)
|
||||
const request = store.get(key)
|
||||
request.onsuccess = () => resolve(request.result)
|
||||
request.onerror = () => reject(request.error)
|
||||
})
|
||||
}
|
||||
|
||||
export async function removeIdbItem(key) {
|
||||
const db = await openDB()
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction(STORE_NAME, 'readwrite')
|
||||
const store = transaction.objectStore(STORE_NAME)
|
||||
const request = store.delete(key)
|
||||
request.onsuccess = () => resolve()
|
||||
request.onerror = () => reject(request.error)
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user