2021-02-28 14:17:18 +00:00
/ *
2021-03-01 14:41:43 +00:00
GoToSocial
Copyright ( C ) 2021 GoToSocial Authors admin @ gotosocial . org
2021-02-28 14:17:18 +00:00
2021-03-01 14:41:43 +00:00
This program is free software : you can redistribute it and / or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation , either version 3 of the License , or
( at your option ) any later version .
2021-02-28 14:17:18 +00:00
2021-03-01 14:41:43 +00:00
This program is distributed in the hope that it will be useful ,
but WITHOUT ANY WARRANTY ; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE . See the
GNU Affero General Public License for more details .
2021-02-28 14:17:18 +00:00
2021-03-01 14:41:43 +00:00
You should have received a copy of the GNU Affero General Public License
along with this program . If not , see < http : //www.gnu.org/licenses/>.
2021-02-28 14:17:18 +00:00
* /
2021-03-09 16:03:40 +00:00
package media
2021-04-01 18:46:45 +00:00
import (
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/sirupsen/logrus"
"github.com/superseriousbusiness/gotosocial/internal/config"
"github.com/superseriousbusiness/gotosocial/internal/db"
2021-05-08 12:25:55 +00:00
"github.com/superseriousbusiness/gotosocial/internal/gtsmodel"
2021-04-01 18:46:45 +00:00
"github.com/superseriousbusiness/gotosocial/internal/storage"
)
2021-05-08 12:25:55 +00:00
// Size describes the *size* of a piece of media
type Size string
// Type describes the *type* of a piece of media
type Type string
2021-04-19 17:42:19 +00:00
const (
2021-05-08 12:25:55 +00:00
// Small is the key for small/thumbnail versions of media
Small Size = "small"
// Original is the key for original/fullsize versions of media and emoji
Original Size = "original"
// Static is the key for static (non-animated) versions of emoji
Static Size = "static"
// Attachment is the key for media attachments
Attachment Type = "attachment"
// Header is the key for profile header requests
Header Type = "header"
// Avatar is the key for profile avatar requests
Avatar Type = "avatar"
// Emoji is the key for emoji type requests
Emoji Type = "emoji"
2021-04-19 17:42:19 +00:00
2021-04-20 16:14:23 +00:00
// EmojiMaxBytes is the maximum permitted bytes of an emoji upload (50kb)
2021-04-19 17:42:19 +00:00
EmojiMaxBytes = 51200
)
2021-04-20 16:14:23 +00:00
// Handler provides an interface for parsing, storing, and retrieving media objects like photos, videos, and gifs.
type Handler interface {
2021-04-19 17:42:19 +00:00
// ProcessHeaderOrAvatar takes a new header image for an account, checks it out, removes exif data from it,
2021-04-01 18:46:45 +00:00
// puts it in whatever storage backend we're using, sets the relevant fields in the database for the new image,
// and then returns information to the caller about the new header.
2021-05-08 12:25:55 +00:00
ProcessHeaderOrAvatar ( img [ ] byte , accountID string , mediaType Type ) ( * gtsmodel . MediaAttachment , error )
2021-04-19 17:42:19 +00:00
// ProcessLocalAttachment takes a new attachment and the requesting account, checks it out, removes exif data from it,
// puts it in whatever storage backend we're using, sets the relevant fields in the database for the new media,
// and then returns information to the caller about the attachment.
ProcessLocalAttachment ( attachment [ ] byte , accountID string ) ( * gtsmodel . MediaAttachment , error )
// ProcessLocalEmoji takes a new emoji and a shortcode, cleans it up, puts it in storage, and creates a new
// *gts.Emoji for it, then returns it to the caller. It's the caller's responsibility to put the returned struct
// in the database.
ProcessLocalEmoji ( emojiBytes [ ] byte , shortcode string ) ( * gtsmodel . Emoji , error )
2021-04-01 18:46:45 +00:00
}
type mediaHandler struct {
config * config . Config
db db . DB
storage storage . Storage
log * logrus . Logger
}
2021-04-20 16:14:23 +00:00
// New returns a new handler with the given config, db, storage, and logger
func New ( config * config . Config , database db . DB , storage storage . Storage , log * logrus . Logger ) Handler {
2021-04-01 18:46:45 +00:00
return & mediaHandler {
config : config ,
db : database ,
storage : storage ,
log : log ,
}
}
/ *
INTERFACE FUNCTIONS
* /
2021-04-20 16:14:23 +00:00
// ProcessHeaderOrAvatar takes a new header image for an account, checks it out, removes exif data from it,
// puts it in whatever storage backend we're using, sets the relevant fields in the database for the new image,
// and then returns information to the caller about the new header.
2021-05-08 12:25:55 +00:00
func ( mh * mediaHandler ) ProcessHeaderOrAvatar ( attachment [ ] byte , accountID string , mediaType Type ) ( * gtsmodel . MediaAttachment , error ) {
2021-04-01 18:46:45 +00:00
l := mh . log . WithField ( "func" , "SetHeaderForAccountID" )
2021-05-08 12:25:55 +00:00
if mediaType != Header && mediaType != Avatar {
2021-04-01 18:46:45 +00:00
return nil , errors . New ( "header or avatar not selected" )
}
2021-04-19 17:42:19 +00:00
// make sure we have a type we can handle
contentType , err := parseContentType ( attachment )
2021-04-01 18:46:45 +00:00
if err != nil {
return nil , err
}
2021-05-08 12:25:55 +00:00
if ! SupportedImageType ( contentType ) {
2021-04-01 18:46:45 +00:00
return nil , fmt . Errorf ( "%s is not an accepted image type" , contentType )
}
2021-04-19 17:42:19 +00:00
if len ( attachment ) == 0 {
2021-04-01 18:46:45 +00:00
return nil , fmt . Errorf ( "passed reader was of size 0" )
}
2021-04-19 17:42:19 +00:00
l . Tracef ( "read %d bytes of file" , len ( attachment ) )
2021-04-01 18:46:45 +00:00
// process it
2021-05-08 12:25:55 +00:00
ma , err := mh . processHeaderOrAvi ( attachment , contentType , mediaType , accountID )
2021-04-01 18:46:45 +00:00
if err != nil {
2021-05-08 12:25:55 +00:00
return nil , fmt . Errorf ( "error processing %s: %s" , mediaType , err )
2021-04-01 18:46:45 +00:00
}
// set it in the database
if err := mh . db . SetHeaderOrAvatarForAccountID ( ma , accountID ) ; err != nil {
2021-05-08 12:25:55 +00:00
return nil , fmt . Errorf ( "error putting %s in database: %s" , mediaType , err )
2021-04-01 18:46:45 +00:00
}
return ma , nil
}
2021-04-20 16:14:23 +00:00
// ProcessLocalAttachment takes a new attachment and the requesting account, checks it out, removes exif data from it,
// puts it in whatever storage backend we're using, sets the relevant fields in the database for the new media,
// and then returns information to the caller about the attachment.
2021-04-19 17:42:19 +00:00
func ( mh * mediaHandler ) ProcessLocalAttachment ( attachment [ ] byte , accountID string ) ( * gtsmodel . MediaAttachment , error ) {
contentType , err := parseContentType ( attachment )
if err != nil {
return nil , err
}
mainType := strings . Split ( contentType , "/" ) [ 0 ]
switch mainType {
2021-05-08 12:25:55 +00:00
case MIMEVideo :
if ! SupportedVideoType ( contentType ) {
2021-04-19 17:42:19 +00:00
return nil , fmt . Errorf ( "video type %s not supported" , contentType )
}
if len ( attachment ) == 0 {
return nil , errors . New ( "video was of size 0" )
}
if len ( attachment ) > mh . config . MediaConfig . MaxVideoSize {
return nil , fmt . Errorf ( "video size %d bytes exceeded max video size of %d bytes" , len ( attachment ) , mh . config . MediaConfig . MaxVideoSize )
}
return mh . processVideoAttachment ( attachment , accountID , contentType )
2021-05-08 12:25:55 +00:00
case MIMEImage :
if ! SupportedImageType ( contentType ) {
2021-04-19 17:42:19 +00:00
return nil , fmt . Errorf ( "image type %s not supported" , contentType )
}
if len ( attachment ) == 0 {
return nil , errors . New ( "image was of size 0" )
}
if len ( attachment ) > mh . config . MediaConfig . MaxImageSize {
return nil , fmt . Errorf ( "image size %d bytes exceeded max image size of %d bytes" , len ( attachment ) , mh . config . MediaConfig . MaxImageSize )
}
return mh . processImageAttachment ( attachment , accountID , contentType )
default :
break
}
return nil , fmt . Errorf ( "content type %s not (yet) supported" , contentType )
}
2021-04-20 16:14:23 +00:00
// ProcessLocalEmoji takes a new emoji and a shortcode, cleans it up, puts it in storage, and creates a new
// *gts.Emoji for it, then returns it to the caller. It's the caller's responsibility to put the returned struct
// in the database.
2021-04-19 17:42:19 +00:00
func ( mh * mediaHandler ) ProcessLocalEmoji ( emojiBytes [ ] byte , shortcode string ) ( * gtsmodel . Emoji , error ) {
var clean [ ] byte
var err error
var original * imageAndMeta
var static * imageAndMeta
// check content type of the submitted emoji and make sure it's supported by us
contentType , err := parseContentType ( emojiBytes )
if err != nil {
return nil , err
}
if ! supportedEmojiType ( contentType ) {
return nil , fmt . Errorf ( "content type %s not supported for emojis" , contentType )
}
if len ( emojiBytes ) == 0 {
return nil , errors . New ( "emoji was of size 0" )
}
if len ( emojiBytes ) > EmojiMaxBytes {
return nil , fmt . Errorf ( "emoji size %d bytes exceeded max emoji size of %d bytes" , len ( emojiBytes ) , EmojiMaxBytes )
}
2021-05-08 12:25:55 +00:00
// clean any exif data from png but leave gifs alone
2021-04-19 17:42:19 +00:00
switch contentType {
2021-05-08 12:25:55 +00:00
case MIMEPng :
2021-04-19 17:42:19 +00:00
if clean , err = purgeExif ( emojiBytes ) ; err != nil {
return nil , fmt . Errorf ( "error cleaning exif data: %s" , err )
}
2021-05-08 12:25:55 +00:00
case MIMEGif :
2021-04-19 17:42:19 +00:00
clean = emojiBytes
default :
return nil , errors . New ( "media type unrecognized" )
}
// unlike with other attachments we don't need to derive anything here because we don't care about the width/height etc
original = & imageAndMeta {
image : clean ,
}
static , err = deriveStaticEmoji ( clean , contentType )
if err != nil {
return nil , fmt . Errorf ( "error deriving static emoji: %s" , err )
}
// since emoji aren't 'owned' by an account, but we still want to use the same pattern for serving them through the filserver,
// (ie., fileserver/ACCOUNT_ID/etc etc) we need to fetch the INSTANCE ACCOUNT from the database. That is, the account that's created
// with the same username as the instance hostname, which doesn't belong to any particular user.
instanceAccount := & gtsmodel . Account { }
2021-05-08 12:25:55 +00:00
if err := mh . db . GetLocalAccountByUsername ( mh . config . Host , instanceAccount ) ; err != nil {
2021-04-19 17:42:19 +00:00
return nil , fmt . Errorf ( "error fetching instance account: %s" , err )
}
// the file extension (either png or gif)
extension := strings . Split ( contentType , "/" ) [ 1 ]
// create the urls and storage paths
URLbase := fmt . Sprintf ( "%s://%s%s" , mh . config . StorageConfig . ServeProtocol , mh . config . StorageConfig . ServeHost , mh . config . StorageConfig . ServeBasePath )
// generate a uuid for the new emoji -- normally we could let the database do this for us,
// but we need it below so we should create it here instead.
newEmojiID := uuid . NewString ( )
// webfinger uri for the emoji -- unrelated to actually serving the image
// will be something like https://example.org/emoji/70a7f3d7-7e35-4098-8ce3-9b5e8203bb9c
2021-05-08 12:25:55 +00:00
emojiURI := fmt . Sprintf ( "%s://%s/%s/%s" , mh . config . Protocol , mh . config . Host , Emoji , newEmojiID )
2021-04-19 17:42:19 +00:00
// serve url and storage path for the original emoji -- can be png or gif
2021-05-08 12:25:55 +00:00
emojiURL := fmt . Sprintf ( "%s/%s/%s/%s/%s.%s" , URLbase , instanceAccount . ID , Emoji , Original , newEmojiID , extension )
emojiPath := fmt . Sprintf ( "%s/%s/%s/%s/%s.%s" , mh . config . StorageConfig . BasePath , instanceAccount . ID , Emoji , Original , newEmojiID , extension )
2021-04-19 17:42:19 +00:00
// serve url and storage path for the static version -- will always be png
2021-05-08 12:25:55 +00:00
emojiStaticURL := fmt . Sprintf ( "%s/%s/%s/%s/%s.png" , URLbase , instanceAccount . ID , Emoji , Static , newEmojiID )
emojiStaticPath := fmt . Sprintf ( "%s/%s/%s/%s/%s.png" , mh . config . StorageConfig . BasePath , instanceAccount . ID , Emoji , Static , newEmojiID )
2021-04-19 17:42:19 +00:00
// store the original
if err := mh . storage . StoreFileAt ( emojiPath , original . image ) ; err != nil {
return nil , fmt . Errorf ( "storage error: %s" , err )
}
// store the static
if err := mh . storage . StoreFileAt ( emojiStaticPath , static . image ) ; err != nil {
return nil , fmt . Errorf ( "storage error: %s" , err )
}
// and finally return the new emoji data to the caller -- it's up to them what to do with it
e := & gtsmodel . Emoji {
2021-05-08 12:25:55 +00:00
ID : newEmojiID ,
Shortcode : shortcode ,
Domain : "" , // empty because this is a local emoji
CreatedAt : time . Now ( ) ,
UpdatedAt : time . Now ( ) ,
ImageRemoteURL : "" , // empty because this is a local emoji
ImageStaticRemoteURL : "" , // empty because this is a local emoji
ImageURL : emojiURL ,
ImageStaticURL : emojiStaticURL ,
ImagePath : emojiPath ,
ImageStaticPath : emojiStaticPath ,
ImageContentType : contentType ,
ImageStaticContentType : MIMEPng , // static version will always be a png
ImageFileSize : len ( original . image ) ,
ImageStaticFileSize : len ( static . image ) ,
ImageUpdatedAt : time . Now ( ) ,
Disabled : false ,
URI : emojiURI ,
VisibleInPicker : true ,
CategoryID : "" , // empty because this is a new emoji -- no category yet
2021-04-19 17:42:19 +00:00
}
return e , nil
}
2021-04-01 18:46:45 +00:00
/ *
HELPER FUNCTIONS
* /
2021-04-19 17:42:19 +00:00
func ( mh * mediaHandler ) processVideoAttachment ( data [ ] byte , accountID string , contentType string ) ( * gtsmodel . MediaAttachment , error ) {
return nil , nil
}
func ( mh * mediaHandler ) processImageAttachment ( data [ ] byte , accountID string , contentType string ) ( * gtsmodel . MediaAttachment , error ) {
var clean [ ] byte
var err error
var original * imageAndMeta
var small * imageAndMeta
switch contentType {
2021-05-08 12:25:55 +00:00
case MIMEJpeg , MIMEPng :
2021-04-19 17:42:19 +00:00
if clean , err = purgeExif ( data ) ; err != nil {
return nil , fmt . Errorf ( "error cleaning exif data: %s" , err )
}
original , err = deriveImage ( clean , contentType )
if err != nil {
return nil , fmt . Errorf ( "error parsing image: %s" , err )
}
2021-05-08 12:25:55 +00:00
case MIMEGif :
2021-04-19 17:42:19 +00:00
clean = data
original , err = deriveGif ( clean , contentType )
if err != nil {
return nil , fmt . Errorf ( "error parsing gif: %s" , err )
}
default :
return nil , errors . New ( "media type unrecognized" )
}
small , err = deriveThumbnail ( clean , contentType , 256 , 256 )
if err != nil {
return nil , fmt . Errorf ( "error deriving thumbnail: %s" , err )
}
// now put it in storage, take a new uuid for the name of the file so we don't store any unnecessary info about it
extension := strings . Split ( contentType , "/" ) [ 1 ]
newMediaID := uuid . NewString ( )
URLbase := fmt . Sprintf ( "%s://%s%s" , mh . config . StorageConfig . ServeProtocol , mh . config . StorageConfig . ServeHost , mh . config . StorageConfig . ServeBasePath )
originalURL := fmt . Sprintf ( "%s/%s/attachment/original/%s.%s" , URLbase , accountID , newMediaID , extension )
smallURL := fmt . Sprintf ( "%s/%s/attachment/small/%s.jpeg" , URLbase , accountID , newMediaID ) // all thumbnails/smalls are encoded as jpeg
// we store the original...
2021-05-08 12:25:55 +00:00
originalPath := fmt . Sprintf ( "%s/%s/%s/%s/%s.%s" , mh . config . StorageConfig . BasePath , accountID , Attachment , Original , newMediaID , extension )
2021-04-19 17:42:19 +00:00
if err := mh . storage . StoreFileAt ( originalPath , original . image ) ; err != nil {
return nil , fmt . Errorf ( "storage error: %s" , err )
}
// and a thumbnail...
2021-05-08 12:25:55 +00:00
smallPath := fmt . Sprintf ( "%s/%s/%s/%s/%s.jpeg" , mh . config . StorageConfig . BasePath , accountID , Attachment , Small , newMediaID ) // all thumbnails/smalls are encoded as jpeg
2021-04-19 17:42:19 +00:00
if err := mh . storage . StoreFileAt ( smallPath , small . image ) ; err != nil {
return nil , fmt . Errorf ( "storage error: %s" , err )
}
ma := & gtsmodel . MediaAttachment {
ID : newMediaID ,
StatusID : "" ,
URL : originalURL ,
RemoteURL : "" ,
CreatedAt : time . Now ( ) ,
UpdatedAt : time . Now ( ) ,
Type : gtsmodel . FileTypeImage ,
FileMeta : gtsmodel . FileMeta {
Original : gtsmodel . Original {
Width : original . width ,
Height : original . height ,
Size : original . size ,
Aspect : original . aspect ,
} ,
Small : gtsmodel . Small {
Width : small . width ,
Height : small . height ,
Size : small . size ,
Aspect : small . aspect ,
} ,
} ,
AccountID : accountID ,
Description : "" ,
ScheduledStatusID : "" ,
Blurhash : original . blurhash ,
Processing : 2 ,
File : gtsmodel . File {
Path : originalPath ,
ContentType : contentType ,
FileSize : len ( original . image ) ,
UpdatedAt : time . Now ( ) ,
} ,
Thumbnail : gtsmodel . Thumbnail {
Path : smallPath ,
2021-05-08 12:25:55 +00:00
ContentType : MIMEJpeg , // all thumbnails/smalls are encoded as jpeg
2021-04-19 17:42:19 +00:00
FileSize : len ( small . image ) ,
UpdatedAt : time . Now ( ) ,
URL : smallURL ,
RemoteURL : "" ,
} ,
Avatar : false ,
Header : false ,
}
return ma , nil
}
2021-05-08 12:25:55 +00:00
func ( mh * mediaHandler ) processHeaderOrAvi ( imageBytes [ ] byte , contentType string , mediaType Type , accountID string ) ( * gtsmodel . MediaAttachment , error ) {
2021-04-01 18:46:45 +00:00
var isHeader bool
var isAvatar bool
2021-05-08 12:25:55 +00:00
switch mediaType {
case Header :
2021-04-01 18:46:45 +00:00
isHeader = true
2021-05-08 12:25:55 +00:00
case Avatar :
2021-04-01 18:46:45 +00:00
isAvatar = true
default :
return nil , errors . New ( "header or avatar not selected" )
}
var clean [ ] byte
var err error
switch contentType {
2021-05-08 12:25:55 +00:00
case MIMEJpeg :
2021-04-01 18:46:45 +00:00
if clean , err = purgeExif ( imageBytes ) ; err != nil {
return nil , fmt . Errorf ( "error cleaning exif data: %s" , err )
}
2021-05-08 12:25:55 +00:00
case MIMEPng :
2021-04-01 18:46:45 +00:00
if clean , err = purgeExif ( imageBytes ) ; err != nil {
return nil , fmt . Errorf ( "error cleaning exif data: %s" , err )
}
2021-05-08 12:25:55 +00:00
case MIMEGif :
2021-04-01 18:46:45 +00:00
clean = imageBytes
default :
return nil , errors . New ( "media type unrecognized" )
}
original , err := deriveImage ( clean , contentType )
if err != nil {
return nil , fmt . Errorf ( "error parsing image: %s" , err )
}
2021-04-19 17:42:19 +00:00
small , err := deriveThumbnail ( clean , contentType , 256 , 256 )
2021-04-01 18:46:45 +00:00
if err != nil {
return nil , fmt . Errorf ( "error deriving thumbnail: %s" , err )
}
// now put it in storage, take a new uuid for the name of the file so we don't store any unnecessary info about it
extension := strings . Split ( contentType , "/" ) [ 1 ]
newMediaID := uuid . NewString ( )
2021-04-19 17:42:19 +00:00
URLbase := fmt . Sprintf ( "%s://%s%s" , mh . config . StorageConfig . ServeProtocol , mh . config . StorageConfig . ServeHost , mh . config . StorageConfig . ServeBasePath )
2021-05-08 12:25:55 +00:00
originalURL := fmt . Sprintf ( "%s/%s/%s/original/%s.%s" , URLbase , accountID , mediaType , newMediaID , extension )
smallURL := fmt . Sprintf ( "%s/%s/%s/small/%s.%s" , URLbase , accountID , mediaType , newMediaID , extension )
2021-04-01 18:46:45 +00:00
// we store the original...
2021-05-08 12:25:55 +00:00
originalPath := fmt . Sprintf ( "%s/%s/%s/%s/%s.%s" , mh . config . StorageConfig . BasePath , accountID , mediaType , Original , newMediaID , extension )
2021-04-01 18:46:45 +00:00
if err := mh . storage . StoreFileAt ( originalPath , original . image ) ; err != nil {
return nil , fmt . Errorf ( "storage error: %s" , err )
}
2021-04-19 17:42:19 +00:00
2021-04-01 18:46:45 +00:00
// and a thumbnail...
2021-05-08 12:25:55 +00:00
smallPath := fmt . Sprintf ( "%s/%s/%s/%s/%s.%s" , mh . config . StorageConfig . BasePath , accountID , mediaType , Small , newMediaID , extension )
2021-04-01 18:46:45 +00:00
if err := mh . storage . StoreFileAt ( smallPath , small . image ) ; err != nil {
return nil , fmt . Errorf ( "storage error: %s" , err )
}
2021-04-19 17:42:19 +00:00
ma := & gtsmodel . MediaAttachment {
2021-04-01 18:46:45 +00:00
ID : newMediaID ,
StatusID : "" ,
2021-04-19 17:42:19 +00:00
URL : originalURL ,
2021-04-01 18:46:45 +00:00
RemoteURL : "" ,
CreatedAt : time . Now ( ) ,
UpdatedAt : time . Now ( ) ,
2021-04-19 17:42:19 +00:00
Type : gtsmodel . FileTypeImage ,
FileMeta : gtsmodel . FileMeta {
Original : gtsmodel . Original {
2021-04-01 18:46:45 +00:00
Width : original . width ,
Height : original . height ,
Size : original . size ,
Aspect : original . aspect ,
} ,
2021-04-19 17:42:19 +00:00
Small : gtsmodel . Small {
2021-04-01 18:46:45 +00:00
Width : small . width ,
Height : small . height ,
Size : small . size ,
Aspect : small . aspect ,
} ,
} ,
AccountID : accountID ,
Description : "" ,
ScheduledStatusID : "" ,
Blurhash : original . blurhash ,
Processing : 2 ,
2021-04-19 17:42:19 +00:00
File : gtsmodel . File {
2021-04-01 18:46:45 +00:00
Path : originalPath ,
ContentType : contentType ,
FileSize : len ( original . image ) ,
UpdatedAt : time . Now ( ) ,
} ,
2021-04-19 17:42:19 +00:00
Thumbnail : gtsmodel . Thumbnail {
2021-04-01 18:46:45 +00:00
Path : smallPath ,
ContentType : contentType ,
FileSize : len ( small . image ) ,
UpdatedAt : time . Now ( ) ,
2021-04-19 17:42:19 +00:00
URL : smallURL ,
2021-04-01 18:46:45 +00:00
RemoteURL : "" ,
} ,
Avatar : isAvatar ,
Header : isHeader ,
}
return ma , nil
2021-03-09 16:03:40 +00:00
}