feat(auth): require auth to fetch avatars (#930)

This commit is contained in:
kolaente 2025-06-14 15:12:41 +02:00 committed by GitHub
parent fe621802b1
commit 59a0b9c40d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 204 additions and 37 deletions

View File

@ -8,7 +8,7 @@
}, },
"eslint.format.enable": true, "eslint.format.enable": true,
"[javascript]": { "[javascript]": {
"editor.defaultFormatter": "dbaeumer.vscode-eslint" "editor.defaultFormatter": "vscode.typescript-language-features"
}, },
"[typescript]": { "[typescript]": {
"editor.defaultFormatter": "vscode.typescript-language-features" "editor.defaultFormatter": "vscode.typescript-language-features"

View File

@ -1,8 +1,10 @@
import {LinkShareFactory} from '../../factories/link_sharing' import {LinkShareFactory} from '../../factories/link_sharing'
import {TaskFactory} from '../../factories/task' import {TaskFactory} from '../../factories/task'
import {UserFactory} from '../../factories/user'
import {createProjects} from '../project/prepareProjects' import {createProjects} from '../project/prepareProjects'
function prepareLinkShare() { function prepareLinkShare() {
UserFactory.create()
const projects = createProjects() const projects = createProjects()
const tasks = TaskFactory.create(10, { const tasks = TaskFactory.create(10, {
project_id: projects[0].id, project_id: projects[0].id,

View File

@ -6,7 +6,7 @@
<img <img
v-tooltip="displayName" v-tooltip="displayName"
:height="avatarSize" :height="avatarSize"
:src="getAvatarUrl(user, avatarSize)" :src="avatarSrc"
:width="avatarSize" :width="avatarSize"
:alt="'Avatar of ' + displayName" :alt="'Avatar of ' + displayName"
class="avatar" class="avatar"
@ -19,9 +19,9 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import {computed} from 'vue' import {computed, ref, watch} from 'vue'
import {getAvatarUrl, getDisplayName} from '@/models/user' import {fetchAvatarBlobUrl, getDisplayName} from '@/models/user'
import type {IUser} from '@/modelTypes/IUser' import type {IUser} from '@/modelTypes/IUser'
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
@ -36,6 +36,13 @@ const props = withDefaults(defineProps<{
}) })
const displayName = computed(() => getDisplayName(props.user)) const displayName = computed(() => getDisplayName(props.user))
const avatarSrc = ref('')
async function loadAvatar() {
avatarSrc.value = await fetchAvatarBlobUrl(props.user, props.avatarSize)
}
watch(() => [props.user, props.avatarSize], loadAvatar, { immediate: true })
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>

View File

@ -29,7 +29,7 @@
> >
<figure class="media-left is-hidden-mobile"> <figure class="media-left is-hidden-mobile">
<img <img
:src="getAvatarUrl(c.author, 48)" :src="avatarFor(c.author, 48)"
alt="" alt=""
class="image is-avatar" class="image is-avatar"
height="48" height="48"
@ -42,7 +42,7 @@
<div class="media-content"> <div class="media-content">
<div class="comment-info"> <div class="comment-info">
<img <img
:src="getAvatarUrl(c.author, 20)" :src="avatarFor(c.author, 20)"
alt="" alt=""
class="image is-avatar d-print-none" class="image is-avatar d-print-none"
height="20" height="20"
@ -222,11 +222,12 @@ import type {ITask} from '@/modelTypes/ITask'
import {uploadFile} from '@/helpers/attachments' import {uploadFile} from '@/helpers/attachments'
import {success} from '@/message' import {success} from '@/message'
import {formatDateLong, formatDateSince} from '@/helpers/time/formatDate' import {formatDateLong, formatDateSince} from '@/helpers/time/formatDate'
import {getAvatarUrl, getDisplayName} from '@/models/user' import {fetchAvatarBlobUrl, getDisplayName} from '@/models/user'
import type {IUser} from '@/modelTypes/IUser'
import {useConfigStore} from '@/stores/config' import {useConfigStore} from '@/stores/config'
import {useAuthStore} from '@/stores/auth' import {useAuthStore} from '@/stores/auth'
import Reactions from '@/components/input/Reactions.vue' import Reactions from '@/components/input/Reactions.vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard' import {useCopyToClipboard} from '@/composables/useCopyToClipboard'
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
taskId: number, taskId: number,
@ -255,7 +256,26 @@ const newCommentText = ref('')
const saved = ref<ITask['id'] | null>(null) const saved = ref<ITask['id'] | null>(null)
const saving = ref<ITask['id'] | null>(null) const saving = ref<ITask['id'] | null>(null)
const userAvatar = computed(() => getAvatarUrl(authStore.info, 48)) const userAvatar = ref('')
const avatarCache = reactive(new Map<string, string>())
function avatarFor(u: IUser, size: number) {
const key = `${u.id}-${size}`
const cached = avatarCache.get(key)
if (!cached) {
fetchAvatarBlobUrl(u, size).then(url => avatarCache.set(key, url))
}
return avatarCache.get(key) || ''
}
watch(() => authStore.info, async (nu) => {
if (!nu) {
return
}
userAvatar.value = await fetchAvatarBlobUrl(nu, 48)
}, {immediate: true})
const currentUserId = computed(() => authStore.info.id) const currentUserId = computed(() => authStore.info.id)
const enabled = computed(() => configStore.taskCommentsEnabled) const enabled = computed(() => configStore.taskCommentsEnabled)
const actions = computed(() => { const actions = computed(() => {

View File

@ -3,9 +3,42 @@ import UserSettingsModel from '@/models/userSettings'
import { AUTH_TYPES, type IUser, type AuthType } from '@/modelTypes/IUser' import { AUTH_TYPES, type IUser, type AuthType } from '@/modelTypes/IUser'
import type { IUserSettings } from '@/modelTypes/IUserSettings' import type { IUserSettings } from '@/modelTypes/IUserSettings'
import AvatarService from '@/services/avatar'
export function getAvatarUrl(user: IUser, size = 50) { const avatarService = new AvatarService()
return `${window.API_URL}/avatar/${user.username}?size=${size}` const avatarCache = new Map<string, string>()
const pendingRequests = new Map<string, Promise<string>>()
export async function fetchAvatarBlobUrl(user: IUser, size = 50) {
if (!user || !user.username) {
return ''
}
const key = `${user.username}-${size}`
// Return cached URL if available
if (avatarCache.has(key)) {
return avatarCache.get(key) as string
}
// If there's already a pending request for this avatar, wait for it
if (pendingRequests.has(key)) {
return await pendingRequests.get(key) as string
}
// Create a new request
const requestPromise = avatarService.getBlobUrl(`/avatar/${user.username}?size=${size}`)
.then(url => {
avatarCache.set(key, url)
pendingRequests.delete(key)
return url
})
.catch(error => {
pendingRequests.delete(key)
throw error
})
pendingRequests.set(key, requestPromise)
return await requestPromise
} }
export function getDisplayName(user: IUser) { export function getDisplayName(user: IUser) {

View File

@ -324,6 +324,17 @@ export default abstract class AbstractService<Model extends IAbstract = IAbstrac
responseType: 'blob', responseType: 'blob',
data, data,
}) })
// Handle SVG blobs specially - convert to data URL for better browser compatibility
if (response.data.type === 'image/svg+xml') {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.onerror = reject
reader.readAsDataURL(response.data)
})
}
return window.URL.createObjectURL(new Blob([response.data])) return window.URL.createObjectURL(new Blob([response.data]))
} }

View File

@ -4,7 +4,7 @@ import {acceptHMRUpdate, defineStore} from 'pinia'
import {AuthenticatedHTTPFactory, HTTPFactory} from '@/helpers/fetcher' import {AuthenticatedHTTPFactory, HTTPFactory} from '@/helpers/fetcher'
import {getBrowserLanguage, i18n, setLanguage} from '@/i18n' import {getBrowserLanguage, i18n, setLanguage} from '@/i18n'
import {objectToSnakeCase} from '@/helpers/case' import {objectToSnakeCase} from '@/helpers/case'
import UserModel, {getAvatarUrl, getDisplayName} from '@/models/user' import UserModel, {getDisplayName, fetchAvatarBlobUrl} from '@/models/user'
import UserSettingsService from '@/services/userSettings' import UserSettingsService from '@/services/userSettings'
import {getToken, refreshToken, removeToken, saveToken} from '@/helpers/auth' import {getToken, refreshToken, removeToken, saveToken} from '@/helpers/auth'
import {setModuleLoading} from '@/stores/helper' import {setModuleLoading} from '@/stores/helper'
@ -66,7 +66,6 @@ export const useAuthStore = defineStore('auth', () => {
const configStore = useConfigStore() const configStore = useConfigStore()
const authenticated = ref(false) const authenticated = ref(false)
const isLinkShareAuth = ref(false)
const needsTotpPasscode = ref(false) const needsTotpPasscode = ref(false)
const info = ref<IUser | null>(null) const info = ref<IUser | null>(null)
@ -92,7 +91,8 @@ export const useAuthStore = defineStore('auth', () => {
}) })
const userDisplayName = computed(() => info.value ? getDisplayName(info.value) : undefined) const userDisplayName = computed(() => info.value ? getDisplayName(info.value) : undefined)
const isLinkShareAuth = computed(() => info.value?.type === AUTH_TYPES.LINK_SHARE)
function setIsLoading(newIsLoading: boolean) { function setIsLoading(newIsLoading: boolean) {
isLoading.value = newIsLoading isLoading.value = newIsLoading
@ -104,14 +104,12 @@ export const useAuthStore = defineStore('auth', () => {
function setUser(newUser: IUser | null, saveSettings = true) { function setUser(newUser: IUser | null, saveSettings = true) {
info.value = newUser info.value = newUser
if (newUser !== null) { if (newUser !== null && !isLinkShareAuth.value) {
reloadAvatar() reloadAvatar()
if (saveSettings && newUser.settings) { if (saveSettings && newUser.settings) {
loadSettings(newUser.settings) loadSettings(newUser.settings)
} }
isLinkShareAuth.value = newUser.id < 0
} }
} }
@ -141,17 +139,16 @@ export const useAuthStore = defineStore('auth', () => {
authenticated.value = newAuthenticated authenticated.value = newAuthenticated
} }
function setIsLinkShareAuth(newIsLinkShareAuth: boolean) {
isLinkShareAuth.value = newIsLinkShareAuth
}
function setNeedsTotpPasscode(newNeedsTotpPasscode: boolean) { function setNeedsTotpPasscode(newNeedsTotpPasscode: boolean) {
needsTotpPasscode.value = newNeedsTotpPasscode needsTotpPasscode.value = newNeedsTotpPasscode
} }
function reloadAvatar() { async function reloadAvatar() {
if (!info.value) return if (!info.value || !info.value.username) {
avatarUrl.value = `${getAvatarUrl(info.value)}&=${new Date().valueOf()}` return
}
avatarUrl.value = await fetchAvatarBlobUrl(info.value, 40)
} }
function updateLastUserRefresh() { function updateLastUserRefresh() {
@ -442,7 +439,6 @@ export const useAuthStore = defineStore('auth', () => {
return { return {
// state // state
authenticated: readonly(authenticated), authenticated: readonly(authenticated),
isLinkShareAuth: readonly(isLinkShareAuth),
needsTotpPasscode: readonly(needsTotpPasscode), needsTotpPasscode: readonly(needsTotpPasscode),
info: readonly(info), info: readonly(info),
@ -454,6 +450,7 @@ export const useAuthStore = defineStore('auth', () => {
authUser, authUser,
authLinkShare, authLinkShare,
userDisplayName, userDisplayName,
isLinkShareAuth,
isLoading: readonly(isLoading), isLoading: readonly(isLoading),
setIsLoading, setIsLoading,
@ -464,7 +461,6 @@ export const useAuthStore = defineStore('auth', () => {
setUser, setUser,
setUserSettings, setUserSettings,
setAuthenticated, setAuthenticated,
setIsLinkShareAuth,
setNeedsTotpPasscode, setNeedsTotpPasscode,
reloadAvatar, reloadAvatar,

View File

@ -18,14 +18,14 @@ package models
import ( import (
"errors" "errors"
"strconv"
"time" "time"
"code.vikunja.io/api/pkg/db" "code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/user" "code.vikunja.io/api/pkg/user"
"code.vikunja.io/api/pkg/utils" "code.vikunja.io/api/pkg/utils"
"code.vikunja.io/api/pkg/web" "code.vikunja.io/api/pkg/web"
"github.com/golang-jwt/jwt/v5" "github.com/golang-jwt/jwt/v5"
"golang.org/x/crypto/bcrypt" "golang.org/x/crypto/bcrypt"
"xorm.io/builder" "xorm.io/builder"
@ -110,10 +110,12 @@ func (share *LinkSharing) toUser() *user.User {
suffix = " (" + suffix + ")" suffix = " (" + suffix + ")"
} }
username := "link-share-" + strconv.FormatInt(share.ID, 10)
return &user.User{ return &user.User{
ID: share.getUserID(), ID: share.getUserID(),
Name: share.Name + suffix, Name: share.Name + suffix,
Username: share.Name, Username: username,
Created: share.Created, Created: share.Created,
Updated: share.Updated, Updated: share.Updated,
} }

View File

@ -18,6 +18,7 @@ package models
import ( import (
"testing" "testing"
"time"
"code.vikunja.io/api/pkg/db" "code.vikunja.io/api/pkg/db"
"code.vikunja.io/api/pkg/user" "code.vikunja.io/api/pkg/user"
@ -155,3 +156,35 @@ func TestLinkSharing_ReadOne(t *testing.T) {
assert.Empty(t, share.Password) assert.Empty(t, share.Password)
}) })
} }
func TestLinkSharing_toUser(t *testing.T) {
t.Run("empty name", func(t *testing.T) {
share := &LinkSharing{
ID: 1,
Name: "",
Created: time.Now(),
Updated: time.Now(),
}
user := share.toUser()
assert.Equal(t, "link-share-1", user.Username)
assert.Equal(t, "Link Share", user.Name)
assert.Equal(t, int64(-1), user.ID)
})
t.Run("name provided", func(t *testing.T) {
share := &LinkSharing{
ID: 2,
Name: "My Test Share",
Created: time.Now(),
Updated: time.Now(),
}
user := share.toUser()
assert.Equal(t, "link-share-2", user.Username)
assert.Equal(t, "My Test Share (Link Share)", user.Name)
assert.Equal(t, int64(-2), user.ID)
})
}

View File

@ -69,10 +69,11 @@ func TestTaskCollection_ReadAll(t *testing.T) {
Updated: testUpdatedTime, Updated: testUpdatedTime,
} }
linkShareUser2 := &user.User{ linkShareUser2 := &user.User{
ID: -2, ID: -2,
Name: "Link Share", Name: "Link Share",
Created: testCreatedTime, Username: "link-share-2",
Updated: testUpdatedTime, Created: testCreatedTime,
Updated: testUpdatedTime,
} }
loc := config.GetTimeZone() loc := config.GetTimeZone()

View File

@ -269,9 +269,6 @@ func registerAPIRoutes(a *echo.Group) {
// Info endpoint // Info endpoint
n.GET("/info", apiv1.Info) n.GET("/info", apiv1.Info)
// Avatar endpoint
n.GET("/avatar/:username", apiv1.GetAvatar)
// Link share auth // Link share auth
if config.ServiceEnableLinkSharing.GetBool() { if config.ServiceEnableLinkSharing.GetBool() {
ur.POST("/shares/:share/auth", apiv1.AuthenticateLinkShare) ur.POST("/shares/:share/auth", apiv1.AuthenticateLinkShare)
@ -290,6 +287,9 @@ func registerAPIRoutes(a *echo.Group) {
a.POST("/token/test", apiv1.CheckToken) a.POST("/token/test", apiv1.CheckToken)
a.GET("/routes", models.GetAvailableAPIRoutesForToken) a.GET("/routes", models.GetAvailableAPIRoutesForToken)
// Avatar endpoint
a.GET("/avatar/:username", apiv1.GetAvatar)
// User stuff // User stuff
u := a.Group("/user") u := a.Group("/user")

View File

@ -609,3 +609,26 @@ const ErrorCodeInvalidTimezone = 1025
func (err ErrInvalidTimezone) HTTPError() web.HTTPError { func (err ErrInvalidTimezone) HTTPError() web.HTTPError {
return web.HTTPError{HTTPCode: http.StatusBadRequest, Code: ErrorCodeInvalidTimezone, Message: fmt.Sprintf("The timezone '%s' is invalid. Please select a valid timezone from the list.", err.Name)} return web.HTTPError{HTTPCode: http.StatusBadRequest, Code: ErrorCodeInvalidTimezone, Message: fmt.Sprintf("The timezone '%s' is invalid. Please select a valid timezone from the list.", err.Name)}
} }
// ErrUsernameReserved represents a "UsernameReserved" kind of error.
type ErrUsernameReserved struct {
Username string
}
// IsErrUsernameReserved checks if an error is a ErrUsernameReserved.
func IsErrUsernameReserved(err error) bool {
_, ok := err.(ErrUsernameReserved)
return ok
}
func (err ErrUsernameReserved) Error() string {
return fmt.Sprintf("Username is reserved [Username: %s]", err.Username)
}
// ErrorCodeUsernameReserved holds the unique world-error code of this error
const ErrorCodeUsernameReserved = 1026
// HTTPError holds the http error description
func (err ErrUsernameReserved) HTTPError() web.HTTPError {
return web.HTTPError{HTTPCode: http.StatusBadRequest, Code: ErrorCodeUsernameReserved, Message: "This username is reserved and cannot be used."}
}

View File

@ -17,6 +17,7 @@
package user package user
import ( import (
"regexp"
"strings" "strings"
"code.vikunja.io/api/pkg/config" "code.vikunja.io/api/pkg/config"
@ -143,6 +144,14 @@ func checkIfUserIsValid(user *User) error {
} }
} }
// Check if username matches the reserved link-share pattern
linkSharePattern := regexp.MustCompile(`^link-share-\d+$`)
if linkSharePattern.MatchString(user.Username) {
return ErrUsernameReserved{
Username: user.Username,
}
}
return nil return nil
} }

View File

@ -147,6 +147,32 @@ func TestCreateUser(t *testing.T) {
require.Error(t, err) require.Error(t, err)
assert.True(t, IsErrUsernameMustNotContainSpaces(err)) assert.True(t, IsErrUsernameMustNotContainSpaces(err))
}) })
t.Run("reserved link-share username", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
_, err := CreateUser(s, &User{
Username: "link-share-123",
Password: "12345678",
Email: "user2@example.com",
})
require.Error(t, err)
assert.True(t, IsErrUsernameReserved(err))
})
t.Run("reserved link-share username with single digit", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
s := db.NewSession()
defer s.Close()
_, err := CreateUser(s, &User{
Username: "link-share-1",
Password: "12345678",
Email: "user3@example.com",
})
require.Error(t, err)
assert.True(t, IsErrUsernameReserved(err))
})
} }
func TestGetUser(t *testing.T) { func TestGetUser(t *testing.T) {

View File

@ -17,6 +17,7 @@
package user package user
import ( import (
"regexp"
"strings" "strings"
"code.vikunja.io/api/pkg/i18n" "code.vikunja.io/api/pkg/i18n"
@ -26,10 +27,11 @@ import (
func init() { func init() {
govalidator.TagMap["username"] = func(i string) bool { govalidator.TagMap["username"] = func(i string) bool {
// To avoid making this overly complicated, we only check three things: // To avoid making this overly complicated, we only check a few things:
// 1. No Spaces // 1. No Spaces
// 2. Should not look like an url // 2. Should not look like an url
// 3. Should not contain , (because then it will be impossible to search for) // 3. Should not contain , (because then it will be impossible to search for)
// 4. Should not start with link-share-[NUMBER] (reserved for link sharing system)
if govalidator.HasWhitespace(i) { if govalidator.HasWhitespace(i) {
return false return false
} }
@ -42,7 +44,9 @@ func init() {
return false return false
} }
return true // Check if username matches the reserved link-share pattern
linkSharePattern := regexp.MustCompile(`^link-share-\d+$`)
return !linkSharePattern.MatchString(i)
} }
govalidator.TagMap["bcrypt_password"] = func(str string) bool { govalidator.TagMap["bcrypt_password"] = func(str string) bool {