Add variant support to the logo fetching feature

This commit is contained in:
Bubka 2025-06-13 16:46:28 +02:00
parent 7ddb4276a6
commit 6909be3318
14 changed files with 218 additions and 76 deletions

View File

@ -57,7 +57,11 @@ class IconController extends Controller
? $validated['iconCollection']
: $request->user()->preferences['iconCollection'];
$icon = LogoLib::driver($iconCollection)->getIcon($validated['service']);
$variant = Arr::has($validated, 'variant') && $validated['variant']
? $validated['variant']
: 'regular';
$icon = LogoLib::driver($iconCollection)->getIcon($validated['service'], $variant);
return $icon
? response()->json(['filename' => $icon], 201)

View File

@ -24,10 +24,29 @@ class IconFetchRequest extends FormRequest
*/
public function rules()
{
return [
$rules = [
'service' => 'string',
'iconCollection' => 'nullable|string|in:tfa,selfh,dashboardicons',
'iconCollection' => 'sometimes|required|string|in:tfa,selfh,dashboardicons',
'variant' => [
'sometimes',
'required',
'string',
]
];
if ($this->input('iconCollection', null) === 'selfh') {
$rules['variant'][] = 'in:regular,light,dark';
}
if ($this->input('iconCollection', null) === 'dashboardicons') {
$rules['variant'][] = 'in:regular,light,dark';
}
if ($this->input('iconCollection', null) === 'tfa') {
$rules['variant'][] = 'in:regular';
}
return $rules;
}
/**
@ -40,7 +59,7 @@ class IconFetchRequest extends FormRequest
protected function prepareForValidation()
{
$this->merge([
'service' => strip_tags(strval($this->service)),
'service' => strip_tags(strval($this->input('service'))),
]);
}
}

View File

@ -4,6 +4,7 @@ namespace App\Services\LogoLib;
use App\Facades\IconStore;
use App\Services\LogoLib\LogoLibInterface;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Storage;
@ -25,14 +26,21 @@ abstract class AbstractLogoLib implements LogoLibInterface
*/
protected string $format = 'svg';
/**
* Suffix to append to the queried resource to get a specific variant
*/
protected string $variant = '';
/**
* Fetch a logo for the given service and save it as an icon
*
* @param string|null $serviceName Name of the service to fetch a logo for
* @param string|null $variant The theme variant to fetch (light, dark, etc...)
* @return string|null The icon filename or null if no logo has been found
*/
public function getIcon(?string $serviceName) : string|null
public function getIcon(?string $serviceName, string $variant = null) : string|null
{
$this->setVariant($variant);
$logoFilename = $this->getLogo(strval($serviceName));
if (!$logoFilename) {
@ -50,6 +58,20 @@ abstract class AbstractLogoLib implements LogoLibInterface
}
}
/**
* Sets the variant using passed parameter or default
*/
protected function setVariant(?string $variant) : void
{
if (! $variant || ! in_array($variant, ['regular', 'dark', 'light'])) {
$this->variant = Auth::user()
? Auth::user()->preferences['iconVariant']
: 'regular';
} else {
$this->variant = $variant;
}
}
/**
* Return the logo's filename for a given service
*
@ -59,7 +81,7 @@ abstract class AbstractLogoLib implements LogoLibInterface
protected function getLogo(string $serviceName)
{
$referenceName = $this->sanitizeServiceName(strval($serviceName));
$logoFilename = $referenceName . '.' . $this->format;
$logoFilename = $referenceName . $this->suffix() . '.' . $this->format;
$cachedFilename = $this->cachePrefix . $logoFilename;
if ($referenceName && ! Storage::disk('logos')->exists($cachedFilename)) {
@ -69,6 +91,28 @@ abstract class AbstractLogoLib implements LogoLibInterface
return Storage::disk('logos')->exists($cachedFilename) ? $cachedFilename : null;
}
/**
* Suffix to append to the reference name to get a specific variant
*/
protected function suffix() : string
{
switch ($this->variant) {
case 'light':
$suffix = '-light';
break;
case 'dark':
$suffix = '-dark';
break;
default:
$suffix = '';
break;
}
return $suffix;
}
/**
* Url to use in http request to get a specific logo from the logo lib
*/
@ -92,10 +136,13 @@ abstract class AbstractLogoLib implements LogoLibInterface
*/
protected function fetchLogo(string $logoFilename) : void
{
$url = $this->logoUrl($logoFilename);
try {
$response = Http::withOptions([
'proxy' => config('2fauth.config.outgoingProxy'),
])->retry(3, 100)->get($this->logoUrl($logoFilename));
// $response = Http::withOptions([
// 'proxy' => config('2fauth.config.outgoingProxy'),
// ])->retry(3, 100)->get($url);
$response = Http::get($url);
if ($response->successful()) {
$filename = $this->cachePrefix . $logoFilename;

View File

@ -4,5 +4,5 @@ namespace App\Services\LogoLib;
interface LogoLibInterface
{
public function getIcon(?string $serviceName): string|null;
public function getIcon(?string $serviceName, string $variant = null): string|null;
}

View File

@ -24,7 +24,7 @@ class TfaLogoLib extends AbstractLogoLib implements LogoLibInterface
/**
* @var string
*/
const TFA_URL = 'https://2fa.directory/api/v3/tfa.json';
const TFA_JSON_URL = 'https://2fa.directory/api/v3/tfa.json';
/**
* @var string
@ -45,7 +45,7 @@ class TfaLogoLib extends AbstractLogoLib implements LogoLibInterface
* @param string|null $serviceName Name of the service to fetch a logo for
* @return string|null The icon filename or null if no logo has been found
*/
public function getIcon(?string $serviceName) : string|null
public function getIcon(?string $serviceName, string $variant = null) : string|null
{
$logoFilename = $this->getLogo(strval($serviceName));
@ -58,6 +58,14 @@ class TfaLogoLib extends AbstractLogoLib implements LogoLibInterface
}
}
/**
* Suffix to append to the reference name to get a specific variant
*/
protected function suffix() : string
{
return '';
}
/**
* Return the logo's filename for a given service
*
@ -104,7 +112,7 @@ class TfaLogoLib extends AbstractLogoLib implements LogoLibInterface
try {
$response = Http::withOptions([
'proxy' => config('2fauth.config.outgoingProxy'),
])->retry(3, 100)->get(self::TFA_URL);
])->retry(3, 100)->get(self::TFA_JSON_URL);
$coll = collect(json_decode(htmlspecialchars_decode($response->body()), true)) /* @phpstan-ignore-line */
->mapWithKeys(function ($item, $key) {

View File

@ -14,6 +14,7 @@ $preferences = [
'displayMode' => envUnlessEmpty('USERPREF_DEFAULT__DISPLAY_MODE', 'list'),
'showAccountsIcons' => envUnlessEmpty('USERPREF_DEFAULT__SHOW_ACCOUNTS_ICONS', true),
'iconCollection' => envUnlessEmpty('USERPREF_DEFAULT__ICON_COLLECTION', 'selfh'),
'iconVariant' => envUnlessEmpty('USERPREF_DEFAULT__ICON_VARIANT', 'regular'),
'kickUserAfter' => envUnlessEmpty('USERPREF_DEFAULT__KICK_USER_AFTER', 15),
'activeGroup' => 0,
'rememberActiveGroup' => envUnlessEmpty('USERPREF_DEFAULT__REMEMBER_ACTIVE_GROUP', true),

View File

@ -1,8 +1,8 @@
<script setup>
import { useIdGenerator, useValidationErrorIdGenerator } from '@/composables/helpers'
const model = defineModel()
const props = defineProps({
modelValue: [String, Number, Boolean],
label: {
type: String,
default: ''
@ -30,7 +30,6 @@
},
})
const selected = ref(props.modelValue)
const { inputId } = useIdGenerator('select', props.fieldName + props.idSuffix)
const { valErrorId } = useValidationErrorIdGenerator(props.fieldName)
const legendId = useIdGenerator('legend', props.fieldName + props.idSuffix).inputId
@ -49,8 +48,7 @@
<div class="select">
<select
:id="inputId"
v-model="selected"
v-on:change="$emit('update:modelValue', $event.target.value)"
v-model="model"
:disabled="isDisabled || isLocked"
:aria-describedby="help ? legendId : undefined"
:aria-invalid="fieldError != undefined"

View File

@ -23,8 +23,8 @@ export default {
return apiClient.post('/twofaccounts', { uri: uri }, { ...config })
},
getLogo(service, iconCollection, config = {}) {
return apiClient.post('/icons/default', { service: service, iconCollection: iconCollection }, { ...config })
getLogo(service, iconCollection, variant, config = {}) {
return apiClient.post('/icons/default', { service: service, iconCollection: iconCollection, variant: variant }, { ...config })
},
deleteIcon(icon, config = {}) {

View File

@ -24,10 +24,22 @@
{ text: 'settings.forms.automatic', value: 'system', icon: 'desktop' },
]
const iconCollections = [
{ text: 'selfh.st', value: 'selfh', url: 'https://selfh.st/icons/' },
{ text: 'dashboardicons.com', value: 'dashboardicons', url: 'https://dashboardicons.com/' },
{ text: '2fa.directory', value: 'tfa', url: 'https://2fa.directory/' },
{ text: 'selfh.st', value: 'selfh', url: 'https://selfh.st/icons/', defaultVariant: 'regular' },
{ text: 'dashboardicons.com', value: 'dashboardicons', url: 'https://dashboardicons.com/', defaultVariant: 'regular' },
{ text: '2fa.directory', value: 'tfa', url: 'https://2fa.directory/', defaultVariant: 'regular' },
]
const iconCollectionVariants = {
selfh: [
{ text: 'commons.regular', value: 'regular' },
{ text: 'settings.forms.light', value: 'light' },
{ text: 'settings.forms.dark', value: 'dark' },
],
dashboardicons: [
{ text: 'commons.regular', value: 'regular' },
{ text: 'settings.forms.light', value: 'light' },
{ text: 'settings.forms.dark', value: 'dark' },
]
}
const passwordFormats = [
{ text: '12 34 56', value: 2, legend: 'settings.forms.pair', title: 'settings.forms.pair_legend' },
{ text: '123 456', value: 3, legend: 'settings.forms.trio', title: 'settings.forms.trio_legend' },
@ -118,6 +130,28 @@
})
}
/**
* Saves the iconCollection preference on the backend
* @param {string} preference
* @param {any} value
*/
function saveIconCollection(value) {
savePreference('iconCollection', value)
if (! Object.prototype.hasOwnProperty.call(iconCollectionVariants, value)) {
if (user.preferences.iconVariant != 'regular') {
user.preferences.iconVariant = 'regular'
userService.updatePreference('iconVariant', user.preferences.iconVariant)
}
}
else {
if (iconCollectionVariants[value].find((variant) => variant.value == user.preferences.iconVariant) == undefined) {
user.preferences.iconVariant = iconCollections.find((collection) => collection.value == value).defaultVariant
userService.updatePreference('iconVariant', user.preferences.iconVariant)
}
}
}
onBeforeRouteLeave((to) => {
if (! to.name.startsWith('settings.')) {
notify.clear()
@ -159,11 +193,13 @@
<!-- Official icons -->
<FormCheckbox v-model="user.preferences.getOfficialIcons" @update:model-value="val => savePreference('getOfficialIcons', val)" fieldName="getOfficialIcons" :isLocked="appSettings.lockedPreferences.includes('getOfficialIcons')" label="settings.forms.get_official_icons.label" help="settings.forms.get_official_icons.help" />
<!-- icon collections -->
<FormSelect v-model="user.preferences.iconCollection" @update:model-value="val => savePreference('iconCollection', val)" :options="iconCollections" fieldName="iconCollection" :isLocked="appSettings.lockedPreferences.includes('iconCollection')" :isDisabled="!user.preferences.getOfficialIcons" label="settings.forms.icon_collection.label" help="settings.forms.icon_collection.help" :isIndented="true">
<FormSelect v-model="user.preferences.iconCollection" @update:model-value="val => saveIconCollection(val)" :options="iconCollections" fieldName="iconCollection" :isLocked="appSettings.lockedPreferences.includes('iconCollection')" :isDisabled="!user.preferences.getOfficialIcons" label="settings.forms.icon_collection.label" help="settings.forms.icon_collection.help" :isIndented="true">
<a class="button is-ghost" :href="iconCollectionUrl" target="_blank" :title="$t('commons.visit_x', { website: iconCollectionDomain})">
<FontAwesomeIcon :icon="['fas', 'external-link-alt']" />
</a>
</FormSelect>
<!-- icon variant -->
<FormSelect v-if="iconCollectionVariants[user.preferences.iconCollection]" v-model="user.preferences.iconVariant" @update:model-value="val => savePreference('iconVariant', val)" :options="iconCollectionVariants[user.preferences.iconCollection]" fieldName="iconVariant" :isLocked="appSettings.lockedPreferences.includes('iconVariant')" :isDisabled="!user.preferences.getOfficialIcons" label="settings.forms.icon_variant.label" help="settings.forms.icon_variant.help" :isIndented="true" />
<!-- password format -->
<FormCheckbox v-model="user.preferences.formatPassword" @update:model-value="val => savePreference('formatPassword', val)" fieldName="formatPassword" :isLocked="appSettings.lockedPreferences.includes('formatPassword')" label="settings.forms.password_format.label" help="settings.forms.password_format.help" />
<FormToggle v-model="user.preferences.formatPasswordBy" @update:model-value="val => savePreference('formatPasswordBy', val)" :choices="passwordFormats" fieldName="formatPasswordBy" :isLocked="appSettings.lockedPreferences.includes('formatPasswordBy')" :isDisabled="!user.preferences.formatPassword" />

View File

@ -38,10 +38,22 @@
icon: null
}))
const iconCollections = [
{ text: 'selfh.st', value: 'selfh' },
{ text: 'dashboardicons.com', value: 'dashboardicons' },
{ text: '2fa.directory', value: 'tfa' },
{ text: 'selfh.st', value: 'selfh', asVariant: true },
{ text: 'dashboardicons.com', value: 'dashboardicons', asVariant: true },
{ text: '2fa.directory', value: 'tfa', asVariant: false },
]
const iconCollectionVariants = {
selfh: [
{ text: 'commons.regular', value: 'regular' },
{ text: 'settings.forms.light', value: 'light' },
{ text: 'settings.forms.dark', value: 'dark' },
],
dashboardicons: [
{ text: 'commons.regular', value: 'regular' },
{ text: 'settings.forms.light', value: 'light' },
{ text: 'settings.forms.dark', value: 'dark' },
]
}
const otpDisplayProps = ref({
otp_type: '',
account : '',
@ -75,6 +87,8 @@
const ShowTwofaccountInModal = ref(false)
const fetchingLogo = ref(false)
const iconCollection = ref(user.preferences.iconCollection)
const iconCollectionVariant = ref(user.preferences.iconVariant)
// $refs
const iconInput = ref(null)
@ -152,6 +166,12 @@
}
})
watch(iconCollection, (val) => {
iconCollectionVariant.value = Object.prototype.hasOwnProperty.call(iconCollectionVariants, val)
? iconCollectionVariants[val][0].value
: ''
})
watch(tempIcon, (val) => {
if( showQuickForm.value ) {
nextTick().then(() => {
@ -385,17 +405,17 @@
if (user.preferences.getOfficialIcons) {
fetchingLogo.value = true
twofaccountService.getLogo(form.service, iconCollection.value, { returnError: true })
twofaccountService.getLogo(form.service, iconCollection.value, iconCollectionVariant.value, { returnError: true })
.then(response => {
if (response.status === 201) {
// clean possible already uploaded temp icon
deleteTempIcon()
tempIcon.value = response.data.filename;
}
else notify.warn( {text: trans('errors.no_logo_found_for_x', {service: strip_tags(form.service)}) })
else notify.warn( {text: trans('errors.no_icon_for_this_variant') })
})
.catch(() => {
notify.warn({ text: trans('errors.no_logo_found_for_x', {service: strip_tags(form.service)}) })
notify.warn({ text: trans('errors.no_icon_for_this_variant') })
})
.finally(() => {
fetchingLogo.value = false
@ -492,52 +512,56 @@
<FormField v-model="form.account" fieldName="account" :fieldError="form.errors.get('account')" label="twofaccounts.account" :placeholder="$t('twofaccounts.forms.account.placeholder')" />
<!-- icon upload -->
<label for="filUploadIcon" class="label">{{ $t('twofaccounts.icon') }}</label>
<div class="columns is-mobile mb-0">
<div class="column pt-0">
<!-- try my luck -->
<fieldset v-if="user.preferences.getOfficialIcons" :disabled="!form.service">
<div class="field is-grouped">
<div class="control">
<VueButton @click="fetchLogo" :color="mode == 'dark' ? 'is-dark' : ''" :nativeType="'button'" :is-loading="fetchingLogo" aria-describedby="lgdTryMyLuck">
<span class="icon is-small">
<FontAwesomeIcon :icon="['fas', 'globe']" />
</span>
<span>{{ $t('twofaccounts.forms.i_m_lucky') }}</span>
</VueButton>
</div>
<div class="control">
<div class="select">
<select name="icon-collection" v-model="iconCollection">
<option v-for="collection in iconCollections" :key="collection.text" :value="collection.value">
{{ collection.text }}
</option>
</select>
</div>
</div>
<!-- try my luck -->
<!-- <fieldset v-if="user.preferences.getOfficialIcons" :disabled="!form.service"> -->
<div class="field has-addons">
<div class="control">
<div class="select">
<select :disabled="!form.service" name="icon-collection" v-model="iconCollection">
<option v-for="collection in iconCollections" :key="collection.text" :value="collection.value">
{{ collection.text }}
</option>
</select>
</div>
</fieldset>
<div class="field is-grouped">
<!-- upload icon button -->
<div class="control is-flex">
<div role="button" tabindex="0" class="file mr-3" :class="mode == 'dark' ? 'is-dark' : 'is-white'" @keyup.enter="iconInputLabel.click()">
<label for="filUploadIcon" class="file-label" ref="iconInputLabel">
<input id="filUploadIcon" tabindex="-1" class="file-input" type="file" accept="image/*" v-on:change="uploadIcon" ref="iconInput">
<span class="file-cta">
<span class="file-icon">
<FontAwesomeIcon :icon="['fas', 'upload']" />
</span>
<span class="file-label">{{ $t('twofaccounts.forms.choose_image') }}</span>
</span>
</label>
</div>
<span class="tag is-large" :class="mode =='dark' ? 'is-dark' : 'is-white'" v-if="tempIcon">
<img class="icon-preview" :src="$2fauth.config.subdirectory + '/storage/icons/' + tempIcon" :alt="$t('twofaccounts.icon_to_illustrate_the_account')">
<button type="button" class="clear-selection delete is-small" @click.prevent="deleteTempIcon" :aria-label="$t('twofaccounts.remove_icon')"></button>
</span>
</div>
<div v-if="iconCollectionVariants[iconCollection]" class="control">
<div class="select">
<select :disabled="!form.service" name="icon-collection-variant" v-model="iconCollectionVariant">
<option v-for="variant in iconCollectionVariants[iconCollection]" :key="variant.value" :value="variant.value">
{{ $t(variant.text) }}
</option>
</select>
</div>
</div>
</div>
<div class="column is-narrow" style="width: 200px;">
<!-- </fieldset> -->
<div class="field is-grouped">
<!-- try my luck button -->
<div class="control">
<VueButton @click="fetchLogo" :color="mode == 'dark' ? 'is-dark' : ''" :nativeType="'button'" :is-loading="fetchingLogo" :disabled="!form.service" aria-describedby="lgdTryMyLuck">
<span class="icon is-small">
<FontAwesomeIcon :icon="['fas', 'globe']" />
</span>
<span>{{ $t('twofaccounts.forms.i_m_lucky') }}</span>
</VueButton>
</div>
<!-- upload icon button -->
<div class="control is-flex">
<div role="button" tabindex="0" class="file mr-3" :class="mode == 'dark' ? 'is-dark' : 'is-white'" @keyup.enter="iconInputLabel.click()">
<label for="filUploadIcon" class="file-label" ref="iconInputLabel">
<input id="filUploadIcon" tabindex="-1" class="file-input" type="file" accept="image/*" v-on:change="uploadIcon" ref="iconInput">
<span class="file-cta">
<span class="file-icon">
<FontAwesomeIcon :icon="['fas', 'upload']" />
</span>
<span class="file-label">{{ $t('twofaccounts.forms.choose_image') }}</span>
</span>
</label>
</div>
<span class="tag is-large" :class="mode =='dark' ? 'is-dark' : 'is-white'" v-if="tempIcon">
<img class="icon-preview" :src="$2fauth.config.subdirectory + '/storage/icons/' + tempIcon" :alt="$t('twofaccounts.icon_to_illustrate_the_account')">
<button type="button" class="clear-selection delete is-small" @click.prevent="deleteTempIcon" :aria-label="$t('twofaccounts.remove_icon')"></button>
</span>
</div>
</div>
<div class="field">

View File

@ -92,5 +92,6 @@ return [
'x_month' => ':x mos.',
'one_year' => '1 yr.',
'copy_next_password' => 'Copy next password to clipboard',
'visit_x' => 'Visit :website'
'visit_x' => 'Visit :website',
'regular' => 'Regular',
];

View File

@ -53,7 +53,7 @@ return [
'unsupported_migration' => 'Data do not match any supported format',
'unsupported_otp_type' => 'Unsupported OTP type',
'encrypted_migration' => 'Unreadable, the data seem encrypted',
'no_logo_found_for_x' => 'No logo available for :service',
'no_icon_for_this_variant' => 'No icon available in this variant',
'file_upload_failed' => 'File upload failed',
'unauthorized' => 'Unauthorized',
'unauthorized_legend' => 'You do not have permissions to view this resource or to perform this action',

View File

@ -126,8 +126,12 @@ return [
'help' => '(Try to) Get the official icon of the 2FA issuer when adding an account'
],
'icon_collection' => [
'label' => 'Preferred icons source',
'help' => 'The icons collection to be queried when an official icon is required. Changing this setting does not refresh icons that have already been fetched.'
'label' => 'Favorite icon source',
'help' => 'The icons collection to be queried at first when an official icon is required. Changing this setting does not refresh icons that have already been fetched.'
],
'icon_variant' => [
'label' => 'Icon variant',
'help' => 'Some icons may be available in several flavors to best suit dark or light UIs. Set the one you want to look for first.'
],
'auto_lock' => [
'label' => 'Auto lock',

View File

@ -66,7 +66,7 @@ return [
],
'choose_image' => 'Upload',
'i_m_lucky' => 'Try my luck',
'i_m_lucky_legend' => 'The "Try my luck" button tries to get a standard icon from the selected icon collection. The simpler the service value, the more likely you are to get the correct icon: Do not append any extension (like ".com"), use the exact name of the service, avoid special chars.',
'i_m_lucky_legend' => 'The "Try my luck" button tries to get a standard icon from the selected icon collection. The simpler the Service field value, the more likely you are to get the expected icon: Do not append any extension (like ".com"), use the exact name of the service, avoid special chars.',
'test' => 'Test',
'group' => [
'label' => 'Group',