Secure storage API keys (#181)

* Migrate api key to private.key

* Optimize API Key setting
This commit is contained in:
Hayden
2025-04-29 17:30:36 +08:00
committed by GitHub
parent 3cfbb5ac0e
commit e964f26798
5 changed files with 229 additions and 5 deletions

3
.gitignore vendored
View File

@@ -197,3 +197,6 @@ web/
# config
config/
# private info
private.key

View File

@@ -2,6 +2,7 @@ import os
import uuid
import time
import requests
import base64
import folder_paths
@@ -94,8 +95,68 @@ class TaskContent:
}
class ApiKey:
__store: dict[str, str] = {}
def __init__(self):
self.__cache_file = os.path.join(config.extension_uri, "private.key")
def init(self, request):
# Try to migrate api key from user setting
if not os.path.exists(self.__cache_file):
self.__store = {
"civitai": utils.get_setting_value(request, "api_key.civitai"),
"huggingface": utils.get_setting_value(request, "api_key.huggingface"),
}
self.__update__()
# Remove api key from user setting
utils.set_setting_value(request, "api_key.civitai", None)
utils.set_setting_value(request, "api_key.huggingface", None)
self.__store = utils.load_dict_pickle_file(self.__cache_file)
# Desensitization returns
result: dict[str, str] = {}
for key in self.__store:
v = self.__store[key]
if v is not None:
result[key] = v[:4] + "****" + v[-4:]
return result
def get_value(self, key: str):
return self.__store.get(key, None)
def set_value(self, key: str, value: str):
self.__store[key] = value
self.__update__()
def __update__(self):
utils.save_dict_pickle_file(self.__cache_file, self.__store)
class ModelDownload:
def __init__(self):
self.api_key = ApiKey()
def add_routes(self, routes):
@routes.post("/model-manager/download/init")
async def init_download(request):
"""
Init download setting.
"""
result = self.api_key.init(request)
return web.json_response({"success": True, "data": result})
@routes.post("/model-manager/download/setting")
async def set_download_setting(request):
"""
Set download setting.
"""
json_data = await request.json()
key = json_data.get("key", None)
value = json_data.get("value", None)
value = base64.b64decode(value).decode("utf-8") if value is not None else None
self.api_key.set_value(key, value)
return web.json_response({"success": True})
@routes.get("/model-manager/download/task")
async def scan_download_tasks(request):
@@ -331,12 +392,12 @@ class ModelDownload:
download_platform = task_status.platform
if download_platform == "civitai":
api_key = utils.get_setting_value(request, "api_key.civitai")
api_key = self.api_key.get_value("civitai")
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
elif download_platform == "huggingface":
api_key = utils.get_setting_value(request, "api_key.huggingface")
api_key = self.api_key.get_value("huggingface")
if api_key:
headers["Authorization"] = f"Bearer {api_key}"

View File

@@ -0,0 +1,69 @@
<template>
<div class="p-4">
<InputText
class="w-full"
v-model="content"
placeholder="Set New API Key"
autocomplete="off"
></InputText>
<div class="mt-4 flex items-center justify-between">
<div>
<span v-show="showError" class="text-red-400">
API Key Not Allow Empty
</span>
</div>
<Button label="Save" autofocus @click="saveKeybinding"></Button>
</div>
</div>
</template>
<script setup lang="ts">
import { useDialog } from 'hooks/dialog'
import { request } from 'hooks/request'
import { useToast } from 'hooks/toast'
import Button from 'primevue/button'
import InputText from 'primevue/inputtext'
import { ref, toValue } from 'vue'
interface Props {
keyField: string
setter: (val: string) => void
}
const props = defineProps<Props>()
const { close } = useDialog()
const { toast } = useToast()
const content = ref<string>()
const showError = ref<boolean>(false)
const saveKeybinding = async () => {
const value = toValue(content)
if (!value) {
showError.value = true
return
}
showError.value = false
const key = toValue(props.keyField)
try {
const encodeValue = value ? btoa(value) : null
await request('/download/setting', {
method: 'POST',
body: JSON.stringify({ key, value: encodeValue }),
})
const desString = value ? value.slice(0, 4) + '****' + value.slice(-4) : ''
props.setter(desString)
close()
} catch (error) {
toast.add({
severity: 'error',
summary: 'Error',
detail: error.message,
life: 3000,
})
}
}
</script>

View File

@@ -1,6 +1,9 @@
import SettingApiKey from 'components/SettingApiKey.vue'
import SettingCardSize from 'components/SettingCardSize.vue'
import { request } from 'hooks/request'
import { defineStore } from 'hooks/store'
import { app } from 'scripts/comfyAPI'
import { useToast } from 'hooks/toast'
import { $el, app } from 'scripts/comfyAPI'
import { computed, onMounted, onUnmounted, readonly, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -65,6 +68,7 @@ export const useConfig = defineStore('config', (store) => {
},
},
flat: flatLayout,
apiKeyInfo: ref<Record<string, string>>({}),
}
watch(cardSizeFlag, (val) => {
@@ -97,6 +101,84 @@ export const configSetting = {
function useAddConfigSettings(store: import('hooks/store').StoreProvider) {
const { t } = useI18n()
const { confirm } = useToast()
const iconButton = (opt: {
icon: string
onClick: () => void | Promise<void>
}) => {
return $el(
'span.h-4.cursor-pointer',
{ onclick: opt.onClick },
$el(`i.${opt.icon.replace(/\s/g, '.')}`),
)
}
const setApiKey = async (key: string, setter: (val: string) => void) => {
store.dialog.open({
key: `setting.api_key.${key}`,
title: t(`setting.api_key.${key}`),
content: SettingApiKey,
modal: true,
defaultSize: {
width: 500,
height: 200,
},
contentProps: {
keyField: key,
setter: setter,
},
})
}
const removeApiKey = async (key: string) => {
await new Promise((resolve, reject) => {
confirm.require({
message: t('deleteAsk'),
header: 'Danger',
icon: 'pi pi-info-circle',
accept: () => resolve(true),
reject: reject,
})
})
await request('/download/setting', {
method: 'POST',
body: JSON.stringify({ key, value: null }),
})
}
const renderApiKey = (key: string) => {
return () => {
const apiKey = store.config.apiKeyInfo.value[key] || 'None'
const apiKeyDisplayEl = $el('div.text-sm.text-gray-500.flex-1', {
textContent: apiKey,
})
const setter = (val: string) => {
store.config.apiKeyInfo.value[key] = val
apiKeyDisplayEl.textContent = val || 'None'
}
return $el('div.flex.gap-4', [
apiKeyDisplayEl,
iconButton({
icon: 'pi pi-pencil text-blue-400',
onClick: () => {
setApiKey(key, setter)
},
}),
iconButton({
icon: 'pi pi-trash text-red-400',
onClick: async () => {
const value = store.config.apiKeyInfo.value[key]
if (value) {
await removeApiKey(key)
setter('')
}
},
}),
])
}
}
onMounted(() => {
// API keys
@@ -104,16 +186,16 @@ function useAddConfigSettings(store: import('hooks/store').StoreProvider) {
id: 'ModelManager.APIKey.HuggingFace',
category: [t('modelManager'), t('setting.apiKey'), 'HuggingFace'],
name: 'HuggingFace API Key',
type: 'text',
defaultValue: undefined,
type: renderApiKey('huggingface'),
})
app.ui?.settings.addSetting({
id: 'ModelManager.APIKey.Civitai',
category: [t('modelManager'), t('setting.apiKey'), 'Civitai'],
name: 'Civitai API Key',
type: 'text',
defaultValue: undefined,
type: renderApiKey('civitai'),
})
const defaultCardSize = store.config.defaultCardSizeMap

View File

@@ -84,7 +84,16 @@ export const useDownload = defineStore('download', (store) => {
})
})
// Initial download settings
// Migrate API keys from user settings to private key
const init = async () => {
const res = await request('/download/init', { method: 'POST' })
store.config.apiKeyInfo.value = res
}
onBeforeMount(() => {
init()
api.addEventListener('reconnected', () => {
refresh()
})