MM-15452 - Add ability to override LHS icon for bot accounts (#11423)

* MM-15452 - Add ability to override LHS icon for bot accounts

* MM-15452 - Added translations

* MM-15452 - Updated GetIconImage test to check returned image

* MM-15452 - Added Delete handler for /icon endpoint, invalidating user cache on set/delete

* MM-15452 - Moved /icon routes under bot/, addressed other pr feedback

* MM-15452 - More conflict resolutoin

* MM-15452 Restoring api4/user.go

* MM-15452 - Using require as opposed to t for test assertions

* MM-15452 - Updated as per PR feedback
Этот коммит содержится в:
Ali Farooq
2019-07-06 02:56:21 -04:00
коммит произвёл Jesús Espino
родитель 0d05fe32af
Коммит 2ecca12bed
7 изменённых файлов: 537 добавлений и 18 удалений

Просмотреть файл

@@ -4,7 +4,11 @@
package api4
import (
"fmt"
"io"
"io/ioutil"
"net/http"
"strconv"
"github.com/mattermost/mattermost-server/model"
)
@@ -17,6 +21,10 @@ func (api *API) InitBot() {
api.BaseRoutes.Bot.Handle("/disable", api.ApiSessionRequired(disableBot)).Methods("POST")
api.BaseRoutes.Bot.Handle("/enable", api.ApiSessionRequired(enableBot)).Methods("POST")
api.BaseRoutes.Bot.Handle("/assign/{user_id:[A-Za-z0-9]+}", api.ApiSessionRequired(assignBot)).Methods("POST")
api.BaseRoutes.Bot.Handle("/icon", api.ApiSessionRequiredTrustRequester(getBotIconImage)).Methods("GET")
api.BaseRoutes.Bot.Handle("/icon", api.ApiSessionRequired(setBotIconImage)).Methods("POST")
api.BaseRoutes.Bot.Handle("/icon", api.ApiSessionRequired(deleteBotIconImage)).Methods("DELETE")
}
func createBot(c *Context, w http.ResponseWriter, r *http.Request) {
@@ -217,3 +225,127 @@ func assignBot(c *Context, w http.ResponseWriter, r *http.Request) {
w.Write(bot.ToJson())
}
func getBotIconImage(c *Context, w http.ResponseWriter, r *http.Request) {
c.RequireBotUserId()
if c.Err != nil {
return
}
botUserId := c.Params.BotUserId
canSee, err := c.App.UserCanSeeOtherUser(c.App.Session.UserId, botUserId)
if err != nil {
c.Err = err
return
}
if !canSee {
c.SetPermissionError(model.PERMISSION_VIEW_MEMBERS)
return
}
user, err := c.App.GetUser(botUserId)
if err != nil {
c.Err = err
return
}
if !user.IsBot {
c.Err = model.MakeBotNotFoundError(botUserId)
return
}
etag := strconv.FormatInt(user.LastPictureUpdate, 10)
if c.HandleEtag(etag, "Get Icon Image", w, r) {
return
}
img, readFailed, err := c.App.GetBotIconImage(user.Id)
if err != nil {
c.Err = err
return
}
if readFailed {
w.Header().Set("Cache-Control", fmt.Sprintf("max-age=%v, public", 5*60)) // 5 mins
} else {
w.Header().Set("Cache-Control", fmt.Sprintf("max-age=%v, public", 24*60*60)) // 24 hrs
w.Header().Set(model.HEADER_ETAG_SERVER, etag)
}
w.Header().Set("Content-Type", "image/svg+xml")
w.Write(img)
}
func setBotIconImage(c *Context, w http.ResponseWriter, r *http.Request) {
defer io.Copy(ioutil.Discard, r.Body)
c.RequireBotUserId()
if c.Err != nil {
return
}
botUserId := c.Params.BotUserId
if err := c.App.SessionHasPermissionToManageBot(c.App.Session, botUserId); err != nil {
c.Err = err
return
}
if _, err := c.App.GetBot(botUserId, true); err != nil {
c.Err = model.MakeBotNotFoundError(botUserId)
return
}
if r.ContentLength > *c.App.Config().FileSettings.MaxFileSize {
c.Err = model.NewAppError("setBotIconImage", "api.bot.set_bot_icon_image.too_large.app_error", nil, "", http.StatusRequestEntityTooLarge)
return
}
if err := r.ParseMultipartForm(*c.App.Config().FileSettings.MaxFileSize); err != nil {
c.Err = model.NewAppError("setBotIconImage", "api.bot.set_bot_icon_image.parse.app_error", nil, err.Error(), http.StatusInternalServerError)
return
}
m := r.MultipartForm
imageArray, ok := m.File["image"]
if !ok {
c.Err = model.NewAppError("setBotIconImage", "api.bot.set_bot_icon_image.no_file.app_error", nil, "", http.StatusBadRequest)
return
}
if len(imageArray) <= 0 {
c.Err = model.NewAppError("setBotIconImage", "api.bot.set_bot_icon_image.array.app_error", nil, "", http.StatusBadRequest)
return
}
imageData := imageArray[0]
if err := c.App.SetBotIconImage(botUserId, imageData); err != nil {
c.Err = err
return
}
c.LogAudit("")
ReturnStatusOK(w)
}
func deleteBotIconImage(c *Context, w http.ResponseWriter, r *http.Request) {
defer io.Copy(ioutil.Discard, r.Body)
c.RequireBotUserId()
if c.Err != nil {
return
}
botUserId := c.Params.BotUserId
if err := c.App.SessionHasPermissionToManageBot(c.App.Session, botUserId); err != nil {
c.Err = err
return
}
if err := c.App.DeleteBotIconImage(botUserId); err != nil {
c.Err = err
return
}
c.LogAudit("")
ReturnStatusOK(w)
}

Просмотреть файл

@@ -4,11 +4,17 @@
package api4
import (
"fmt"
"io/ioutil"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"github.com/mattermost/mattermost-server/model"
"github.com/mattermost/mattermost-server/utils/fileutils"
"github.com/mattermost/mattermost-server/utils/testutils"
"github.com/stretchr/testify/require"
)
@@ -1090,6 +1096,196 @@ func TestAssignBot(t *testing.T) {
})
}
func TestSetBotIconImage(t *testing.T) {
th := Setup().InitBasic()
defer th.TearDown()
user := th.BasicUser
defer th.RestoreDefaultRolePermissions(th.SaveDefaultRolePermissions())
th.AddPermissionToRole(model.PERMISSION_CREATE_BOT.Id, model.SYSTEM_USER_ROLE_ID)
th.AddPermissionToRole(model.PERMISSION_MANAGE_BOTS.Id, model.SYSTEM_USER_ROLE_ID)
th.AddPermissionToRole(model.PERMISSION_READ_BOTS.Id, model.SYSTEM_USER_ROLE_ID)
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.EnableBotAccountCreation = true
})
bot := &model.Bot{
Username: GenerateTestUsername(),
Description: "bot",
}
bot, resp := th.Client.CreateBot(bot)
CheckCreatedStatus(t, resp)
defer th.App.PermanentDeleteBot(bot.UserId)
badData, err := testutils.ReadTestFile("test.png")
require.Nil(t, err)
goodData, err := testutils.ReadTestFile("test.svg")
require.Nil(t, err)
// SetBotIconImage only allowed for bots
_, resp = th.SystemAdminClient.SetBotIconImage(user.Id, goodData)
CheckNotFoundStatus(t, resp)
// png/jpg is not allowed
ok, resp := th.Client.SetBotIconImage(bot.UserId, badData)
require.False(t, ok, "Should return false, set icon image only allows svg")
CheckBadRequestStatus(t, resp)
ok, resp = th.Client.SetBotIconImage(model.NewId(), badData)
require.False(t, ok, "Should return false, set icon image not allowed")
CheckNotFoundStatus(t, resp)
_, resp = th.Client.SetBotIconImage(bot.UserId, goodData)
CheckNoError(t, resp)
// status code returns either forbidden or unauthorized
// note: forbidden is set as default at Client4.SetBotIconImage when request is terminated early by server
th.Client.Logout()
_, resp = th.Client.SetBotIconImage(bot.UserId, badData)
if resp.StatusCode == http.StatusForbidden {
CheckForbiddenStatus(t, resp)
} else if resp.StatusCode == http.StatusUnauthorized {
CheckUnauthorizedStatus(t, resp)
} else {
require.Fail(t, "Should have failed either forbidden or unauthorized")
}
_, resp = th.SystemAdminClient.SetBotIconImage(bot.UserId, goodData)
CheckNoError(t, resp)
info := &model.FileInfo{Path: "/bots/" + bot.UserId + "/icon.svg"}
err = th.cleanupTestFile(info)
require.Nil(t, err)
}
func TestGetBotIconImage(t *testing.T) {
th := Setup().InitBasic()
defer th.TearDown()
defer th.RestoreDefaultRolePermissions(th.SaveDefaultRolePermissions())
th.AddPermissionToRole(model.PERMISSION_CREATE_BOT.Id, model.SYSTEM_USER_ROLE_ID)
th.AddPermissionToRole(model.PERMISSION_MANAGE_BOTS.Id, model.SYSTEM_USER_ROLE_ID)
th.AddPermissionToRole(model.PERMISSION_READ_BOTS.Id, model.SYSTEM_USER_ROLE_ID)
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.EnableBotAccountCreation = true
})
bot := &model.Bot{
Username: GenerateTestUsername(),
Description: "bot",
}
bot, resp := th.Client.CreateBot(bot)
CheckCreatedStatus(t, resp)
defer th.App.PermanentDeleteBot(bot.UserId)
// Get icon image for user with no icon
data, resp := th.Client.GetBotIconImage(bot.UserId)
CheckNotFoundStatus(t, resp)
require.Equal(t, 0, len(data))
// Set an icon image
path, _ := fileutils.FindDir("tests")
svgFile, fileErr := os.Open(filepath.Join(path, "test.svg"))
require.NoError(t, fileErr)
defer svgFile.Close()
expectedData, err := ioutil.ReadAll(svgFile)
require.NoError(t, err)
svgFile.Seek(0, 0)
fpath := fmt.Sprintf("/bots/%v/icon.svg", bot.UserId)
_, err = th.App.WriteFile(svgFile, fpath)
require.Nil(t, err)
data, resp = th.Client.GetBotIconImage(bot.UserId)
CheckNoError(t, resp)
require.Equal(t, expectedData, data)
_, resp = th.Client.GetBotIconImage("junk")
CheckBadRequestStatus(t, resp)
_, resp = th.Client.GetBotIconImage(model.NewId())
CheckNotFoundStatus(t, resp)
th.Client.Logout()
_, resp = th.Client.GetBotIconImage(bot.UserId)
CheckUnauthorizedStatus(t, resp)
_, resp = th.SystemAdminClient.GetBotIconImage(bot.UserId)
CheckNoError(t, resp)
info := &model.FileInfo{Path: "/bots/" + bot.UserId + "/icon.svg"}
err = th.cleanupTestFile(info)
require.Nil(t, err)
}
func TestDeleteBotIconImage(t *testing.T) {
th := Setup().InitBasic()
defer th.TearDown()
defer th.RestoreDefaultRolePermissions(th.SaveDefaultRolePermissions())
th.AddPermissionToRole(model.PERMISSION_CREATE_BOT.Id, model.SYSTEM_USER_ROLE_ID)
th.AddPermissionToRole(model.PERMISSION_MANAGE_BOTS.Id, model.SYSTEM_USER_ROLE_ID)
th.AddPermissionToRole(model.PERMISSION_READ_BOTS.Id, model.SYSTEM_USER_ROLE_ID)
th.App.UpdateConfig(func(cfg *model.Config) {
*cfg.ServiceSettings.EnableBotAccountCreation = true
})
bot := &model.Bot{
Username: GenerateTestUsername(),
Description: "bot",
}
bot, resp := th.Client.CreateBot(bot)
CheckCreatedStatus(t, resp)
defer th.App.PermanentDeleteBot(bot.UserId)
// Get icon image for user with no icon
data, resp := th.Client.GetBotIconImage(bot.UserId)
CheckNotFoundStatus(t, resp)
require.Equal(t, 0, len(data))
// Set an icon image
svgData, err := testutils.ReadTestFile("test.svg")
require.Nil(t, err)
_, resp = th.Client.SetBotIconImage(bot.UserId, svgData)
CheckNoError(t, resp)
fpath := fmt.Sprintf("/bots/%v/icon.svg", bot.UserId)
exists, err := th.App.FileExists(fpath)
require.Nil(t, err)
require.True(t, exists, "icon.svg needs to exist for the user")
data, resp = th.Client.GetBotIconImage(bot.UserId)
CheckNoError(t, resp)
require.Equal(t, svgData, data)
success, resp := th.Client.DeleteBotIconImage("junk")
CheckBadRequestStatus(t, resp)
require.False(t, success)
success, resp = th.Client.DeleteBotIconImage(model.NewId())
CheckNotFoundStatus(t, resp)
require.False(t, success)
success, resp = th.Client.DeleteBotIconImage(bot.UserId)
CheckNoError(t, resp)
require.True(t, success)
th.Client.Logout()
success, resp = th.Client.DeleteBotIconImage(bot.UserId)
CheckUnauthorizedStatus(t, resp)
require.False(t, success)
exists, err = th.App.FileExists(fpath)
require.Nil(t, err)
require.False(t, exists, "icon.svg should not for the user")
}
func sToP(s string) *string {
return &s
}

Просмотреть файл

@@ -4,6 +4,10 @@
package app
import (
"fmt"
"mime/multipart"
"net/http"
"github.com/mattermost/mattermost-server/mlog"
"github.com/mattermost/mattermost-server/model"
"github.com/mattermost/mattermost-server/store"
@@ -137,7 +141,7 @@ func (a *App) PermanentDeleteBot(botUserId string) *model.AppError {
return nil
}
// UpdateBotOwner changes a bot's owner to the given value
// UpdateBotOwner changes a bot's owner to the given value.
func (a *App) UpdateBotOwner(botUserId, newOwnerId string) (*model.Bot, *model.AppError) {
bot, err := a.Srv.Store.Bot().Get(botUserId, true)
if err != nil {
@@ -154,7 +158,7 @@ func (a *App) UpdateBotOwner(botUserId, newOwnerId string) (*model.Bot, *model.A
return bot, nil
}
// disableUserBots disables all bots owned by the given user
// disableUserBots disables all bots owned by the given user.
func (a *App) disableUserBots(userId string) *model.AppError {
perPage := 20
for {
@@ -188,7 +192,74 @@ func (a *App) disableUserBots(userId string) *model.AppError {
return nil
}
// ConvertUserToBot converts a user to bot
// ConvertUserToBot converts a user to bot.
func (a *App) ConvertUserToBot(user *model.User) (*model.Bot, *model.AppError) {
return a.Srv.Store.Bot().Save(model.BotFromUser(user))
}
// SetBotIconImage sets LHS icon for a bot.
func (a *App) SetBotIconImage(botUserId string, imageData *multipart.FileHeader) *model.AppError {
if len(*a.Config().FileSettings.DriverName) == 0 {
return model.NewAppError("SetBotIconImage", "api.bot.icon_image.storage.app_error", nil, "", http.StatusNotImplemented)
}
file, err := imageData.Open()
if err != nil {
return model.NewAppError("SetBotIconImage", "api.bot.set_bot_icon_image.open.app_error", nil, err.Error(), http.StatusBadRequest)
}
defer file.Close()
if _, err = parseSVG(file); err != nil {
return model.NewAppError("SetBotIconImage", "api.bot.set_bot_icon_image.parse.app_error", nil, err.Error(), http.StatusBadRequest)
}
// Set icon
file.Seek(0, 0)
if _, err := a.WriteFile(file, getBotIconPath(botUserId)); err != nil {
return model.NewAppError("SetBotIconImage", "api.bot.set_bot_icon_image.app_error", nil, err.Error(), http.StatusInternalServerError)
}
if err := a.Srv.Store.User().UpdateLastPictureUpdate(botUserId); err != nil {
mlog.Error(err.Error())
}
a.invalidateUserCacheAndPublish(botUserId)
return nil
}
// DeleteBotIconImage deletes LHS icon for a bot.
func (a *App) DeleteBotIconImage(botUserId string) *model.AppError {
if len(*a.Config().FileSettings.DriverName) == 0 {
return model.NewAppError("DeleteBotIconImage", "api.bot.icon_image.storage.app_error", nil, "", http.StatusNotImplemented)
}
// Delete icon
if err := a.RemoveFile(getBotIconPath(botUserId)); err != nil {
return model.NewAppError("DeleteBotIconImage", "api.bot.delete_bot_icon_image.app_error", nil, err.Error(), http.StatusInternalServerError)
}
if err := a.Srv.Store.User().UpdateLastPictureUpdate(botUserId); err != nil {
mlog.Error(err.Error())
}
a.invalidateUserCacheAndPublish(botUserId)
return nil
}
// GetBotIconImage retrieves LHS icon for a bot.
func (a *App) GetBotIconImage(botUserId string) ([]byte, bool, *model.AppError) {
if len(*a.Config().FileSettings.DriverName) == 0 {
return nil, false, model.NewAppError("GetBotIconImage", "api.bot.icon_image.storage.app_error", nil, "", http.StatusNotImplemented)
}
data, err := a.ReadFile(getBotIconPath(botUserId))
if err != nil {
return nil, false, model.NewAppError("GetBotIconImage", "api.bot.get_bot_icon_image.read.app_error", nil, err.Error(), http.StatusNotFound)
}
return data, false, nil
}
func getBotIconPath(botUserId string) string {
return fmt.Sprintf("bots/%v/icon.svg", botUserId)
}

Просмотреть файл

@@ -899,21 +899,7 @@ func (a *App) SetProfileImageFromFile(userId string, file io.Reader) *model.AppE
if err := a.Srv.Store.User().UpdateLastPictureUpdate(userId); err != nil {
mlog.Error(err.Error())
}
a.InvalidateCacheForUser(userId)
user, userErr := a.GetUser(userId)
if userErr != nil {
mlog.Error(fmt.Sprintf("Error in getting users profile for id=%v forcing logout", userId), mlog.String("user_id", userId))
return nil
}
options := a.Config().GetSanitizeOptions()
user.SanitizeProfile(options)
message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_USER_UPDATED, "", "", "", nil)
message.Add("user", user)
a.Publish(message)
a.invalidateUserCacheAndPublish(userId)
return nil
}
@@ -2227,3 +2213,21 @@ func (a *App) getListOfAllowedChannelsForTeam(teamId string, viewRestrictions *m
return listOfAllowedChannels, nil
}
// invalidateUserCacheAndPublish Invalidates cache for a user and publishes user updated event
func (a *App) invalidateUserCacheAndPublish(userId string) {
a.InvalidateCacheForUser(userId)
user, userErr := a.GetUser(userId)
if userErr != nil {
mlog.Error(fmt.Sprintf("Error in getting users profile for id=%v, err=%v", userId, userErr.Error()), mlog.String("user_id", userId))
return
}
options := a.Config().GetSanitizeOptions()
user.SanitizeProfile(options)
message := model.NewWebSocketEvent(model.WEBSOCKET_EVENT_USER_UPDATED, "", "", "", nil)
message.Add("user", user)
a.Publish(message)
}

Просмотреть файл

@@ -135,6 +135,42 @@
"id": "api.bot.create_disabled",
"translation": "Bot creation has been disabled."
},
{
"id": "api.bot.delete_bot_icon_image.app_error",
"translation": "Couldn't delete icon image"
},
{
"id": "api.bot.get_bot_icon_image.read.app_error",
"translation": "Unable to read icon image file"
},
{
"id": "api.bot.icon_image.storage.app_error",
"translation": "Image storage is not configured."
},
{
"id": "api.bot.set_bot_icon_image.app_error",
"translation": "Couldn't upload icon image"
},
{
"id": "api.bot.set_bot_icon_image.array.app_error",
"translation": "Empty array under 'image' in request"
},
{
"id": "api.bot.set_bot_icon_image.no_file.app_error",
"translation": "No file under 'image' in request"
},
{
"id": "api.bot.set_bot_icon_image.open.app_error",
"translation": "Could not open image file"
},
{
"id": "api.bot.set_bot_icon_image.parse.app_error",
"translation": "Could not parse multipart form"
},
{
"id": "api.bot.set_bot_icon_image.too_large.app_error",
"translation": "Unable to upload icon image. File is too large."
},
{
"id": "api.bot.teams_channels.add_message_mobile",
"translation": "Please add me to teams and channels you want me to interact in. To do this, use the browser or Mattermost Desktop App."

Просмотреть файл

@@ -1497,6 +1497,72 @@ func (c *Client4) AssignBot(botUserId, newOwnerId string) (*Bot, *Response) {
return BotFromJson(r.Body), BuildResponse(r)
}
// SetBotIconImage sets icon image of the user.
func (c *Client4) SetBotIconImage(botUserId string, data []byte) (bool, *Response) {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("image", "icon.svg")
if err != nil {
return false, &Response{Error: NewAppError("SetBotIconImage", "model.client.set_bot_icon_image.no_file.app_error", nil, err.Error(), http.StatusBadRequest)}
}
if _, err = io.Copy(part, bytes.NewBuffer(data)); err != nil {
return false, &Response{Error: NewAppError("SetBotIconImage", "model.client.set_bot_icon_image.no_file.app_error", nil, err.Error(), http.StatusBadRequest)}
}
if err = writer.Close(); err != nil {
return false, &Response{Error: NewAppError("SetBotIconImage", "model.client.set_bot_icon_image.writer.app_error", nil, err.Error(), http.StatusBadRequest)}
}
rq, err := http.NewRequest("POST", c.ApiUrl+c.GetBotRoute(botUserId)+"/icon", bytes.NewReader(body.Bytes()))
if err != nil {
return false, &Response{Error: NewAppError("SetBotIconImage", "model.client.connecting.app_error", nil, err.Error(), http.StatusBadRequest)}
}
rq.Header.Set("Content-Type", writer.FormDataContentType())
if len(c.AuthToken) > 0 {
rq.Header.Set(HEADER_AUTH, c.AuthType+" "+c.AuthToken)
}
rp, err := c.HttpClient.Do(rq)
if err != nil || rp == nil {
return false, &Response{StatusCode: http.StatusForbidden, Error: NewAppError(c.GetBotRoute(botUserId)+"/icon", "model.client.connecting.app_error", nil, err.Error(), http.StatusForbidden)}
}
defer closeBody(rp)
if rp.StatusCode >= 300 {
return false, BuildErrorResponse(rp, AppErrorFromJson(rp.Body))
}
return CheckStatusOK(rp), BuildResponse(rp)
}
// GetBotIconImage gets user's LHS icon image. Must be logged in.
func (c *Client4) GetBotIconImage(botUserId string) ([]byte, *Response) {
r, appErr := c.DoApiGet(c.GetBotRoute(botUserId)+"/icon", "")
if appErr != nil {
return nil, BuildErrorResponse(r, appErr)
}
defer closeBody(r)
data, err := ioutil.ReadAll(r.Body)
if err != nil {
return nil, BuildErrorResponse(r, NewAppError("GetBotIconImage", "model.client.read_file.app_error", nil, err.Error(), r.StatusCode))
}
return data, BuildResponse(r)
}
// DeleteBotIconImage deletes user's LHS icon image. Must be logged in.
func (c *Client4) DeleteBotIconImage(botUserId string) (bool, *Response) {
r, appErr := c.DoApiDelete(c.GetBotRoute(botUserId) + "/icon")
if appErr != nil {
return false, BuildErrorResponse(r, appErr)
}
defer closeBody(r)
return CheckStatusOK(r), BuildResponse(r)
}
// Team Section
// CreateTeam creates a team in the system based on the provided team struct.

14
tests/test.svg Обычный файл
Просмотреть файл

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg width="20px" height="20px" viewBox="0 0 20 20" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<!-- Generator: Sketch 3.8.1 (29687) - http://www.bohemiancoding.com/sketch -->
<title>github [#142]</title>
<desc>Created with Sketch.</desc>
<defs></defs>
<g id="Page-1" stroke="none" stroke-width="1" fill-rule="evenodd">
<g id="Dribbble-Light-Preview" transform="translate(-140.000000, -7559.000000)" >
<g id="icons" transform="translate(56.000000, 160.000000)">
<path d="M94,7399 C99.523,7399 104,7403.59 104,7409.253 C104,7413.782 101.138,7417.624 97.167,7418.981 C96.66,7419.082 96.48,7418.762 96.48,7418.489 C96.48,7418.151 96.492,7417.047 96.492,7415.675 C96.492,7414.719 96.172,7414.095 95.813,7413.777 C98.04,7413.523 100.38,7412.656 100.38,7408.718 C100.38,7407.598 99.992,7406.684 99.35,7405.966 C99.454,7405.707 99.797,7404.664 99.252,7403.252 C99.252,7403.252 98.414,7402.977 96.505,7404.303 C95.706,7404.076 94.85,7403.962 94,7403.958 C93.15,7403.962 92.295,7404.076 91.497,7404.303 C89.586,7402.977 88.746,7403.252 88.746,7403.252 C88.203,7404.664 88.546,7405.707 88.649,7405.966 C88.01,7406.684 87.619,7407.598 87.619,7408.718 C87.619,7412.646 89.954,7413.526 92.175,7413.785 C91.889,7414.041 91.63,7414.493 91.54,7415.156 C90.97,7415.418 89.522,7415.871 88.63,7414.304 C88.63,7414.304 88.101,7413.319 87.097,7413.247 C87.097,7413.247 86.122,7413.234 87.029,7413.87 C87.029,7413.87 87.684,7414.185 88.139,7415.37 C88.139,7415.37 88.726,7417.2 91.508,7416.58 C91.513,7417.437 91.522,7418.245 91.522,7418.489 C91.522,7418.76 91.338,7419.077 90.839,7418.982 C86.865,7417.627 84,7413.783 84,7409.253 C84,7403.59 88.478,7399 94,7399" id="github-[#142]"></path>
</g>
</g>
</g>
</svg>

После

Ширина:  |  Высота:  |  Размер: 1.8 KiB