├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── aws ├── session.go └── tts.go ├── bolt ├── bolt.go ├── bolt.pb.go ├── bolt.proto ├── db.go ├── db_test.go ├── job.go ├── playlist.go ├── track.go ├── track_test.go └── user.go ├── cmd └── peapod │ └── main.go ├── context.go ├── error.go ├── file.go ├── glide.lock ├── glide.yaml ├── http ├── asset.go ├── assets.gen.go ├── assets │ └── logo-1024x1024.png ├── context.go ├── error.go ├── file.go ├── internal_test.go ├── playlist.go ├── server.go ├── track.go └── twilio.go ├── job.go ├── local ├── file.go └── file_test.go ├── mock ├── file.go ├── job.go ├── playlist.go ├── sms.go ├── track.go └── user.go ├── peapod.go ├── playlist.go ├── sms.go ├── track.go ├── tts.go ├── twilio ├── sms.go └── sms_test.go ├── user.go └── youtube_dl ├── youtube_dl.go └── youtube_dl_test.go /.gitignore: -------------------------------------------------------------------------------- 1 | /TODO 2 | /vendor 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Middlemost Systems, LLC 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | default: 2 | GOOS=linux GOARCH=amd64 go build -o /tmp/peapod ./cmd/peapod 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | peapod 2 | ====== 3 | 4 | Peapod is a service for hosting personal podcast feeds. It allows users to send 5 | URLs via SMS to the service and, in return, receive a personal feed URL that 6 | they can subscribe to. 7 | 8 | -------------------------------------------------------------------------------- /aws/session.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/aws/aws-sdk-go/aws" 7 | "github.com/aws/aws-sdk-go/aws/credentials" 8 | "github.com/aws/aws-sdk-go/aws/session" 9 | ) 10 | 11 | // Session represents a session to AWS. 12 | type Session struct { 13 | session *session.Session 14 | } 15 | 16 | // NewSession returns a session with the given credentials. 17 | func NewSession(accessKeyID, secretAccessKey, region string) (*Session, error) { 18 | if region == "" { 19 | return nil, errors.New("aws region required") 20 | } 21 | 22 | s, err := session.NewSession(&aws.Config{ 23 | Region: aws.String(region), 24 | Credentials: credentials.NewStaticCredentials(accessKeyID, secretAccessKey, ""), 25 | }) 26 | if err != nil { 27 | return nil, err 28 | } 29 | return &Session{session: s}, nil 30 | } 31 | -------------------------------------------------------------------------------- /aws/tts.go: -------------------------------------------------------------------------------- 1 | package aws 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "io/ioutil" 8 | "os" 9 | "os/exec" 10 | "regexp" 11 | "strings" 12 | 13 | "github.com/aws/aws-sdk-go/aws" 14 | "github.com/aws/aws-sdk-go/service/polly" 15 | "github.com/middlemost/peapod" 16 | "golang.org/x/sync/errgroup" 17 | ) 18 | 19 | // MaxCharactersPerRequest is the maximum number of characters allowed by Polly. 20 | const MaxCharactersPerRequest = 1500 21 | 22 | // DefaultVoiceID is the default voice to use when synthesizing speech. 23 | const DefaultVoiceID = "Emma" 24 | 25 | // Ensure service implements interface. 26 | var _ peapod.TTSService = &TTSService{} 27 | 28 | // TTSService represents a service for performing text-to-speech. 29 | type TTSService struct { 30 | Session *Session 31 | VoiceID string 32 | LogOutput io.Writer 33 | } 34 | 35 | // NewTTSService returns a new instance of TTSService. 36 | func NewTTSService() *TTSService { 37 | return &TTSService{ 38 | VoiceID: DefaultVoiceID, 39 | LogOutput: ioutil.Discard, 40 | } 41 | } 42 | 43 | // SynthesizeSpeech encodes text to speech. 44 | func (s *TTSService) SynthesizeSpeech(ctx context.Context, text string) (io.ReadCloser, error) { 45 | // Split into chunks. 46 | chunks := splitTextOnParagraphs(text, MaxCharactersPerRequest) 47 | 48 | // Synthesize chunks in parallel. 49 | paths := make([]string, len(chunks)) 50 | var wg errgroup.Group 51 | for i, chunk := range chunks { 52 | i, chunk := i, chunk 53 | fmt.Fprintf(s.LogOutput, "tts: synthesizing chunk: index=%d, len=%d\n", i, len(chunk)) 54 | 55 | wg.Go(func() error { 56 | path, err := s.synthesizeChunk(ctx, i, chunk) 57 | paths[i] = path 58 | return err 59 | }) 60 | } 61 | 62 | // Wait for the chunks to complete. 63 | if err := wg.Wait(); err != nil { 64 | return nil, err 65 | } 66 | 67 | // Combine chunks. 68 | combinedPath, err := s.concatenateFiles(ctx, paths) 69 | if err != nil { 70 | return nil, err 71 | } 72 | 73 | // Open file handle to return for reading. 74 | file, err := os.Open(combinedPath) 75 | if err != nil { 76 | return nil, err 77 | } 78 | return &oneTimeReader{File: file}, nil 79 | } 80 | 81 | // synthesizeChunk synthesizes a single chunk of text to a temp file. 82 | // Returns a path to the temporary file. 83 | func (s *TTSService) synthesizeChunk(ctx context.Context, index int, text string) (string, error) { 84 | svc := polly.New(s.Session.session) 85 | 86 | resp, err := svc.SynthesizeSpeech(&polly.SynthesizeSpeechInput{ 87 | OutputFormat: aws.String("mp3"), 88 | VoiceId: aws.String(s.VoiceID), 89 | Text: aws.String(text), 90 | }) 91 | if resp != nil { 92 | fmt.Fprintf(s.LogOutput, "tts: response: chars=%d\n", resp.RequestCharacters) 93 | } 94 | if err != nil { 95 | return "", err 96 | } 97 | defer resp.AudioStream.Close() 98 | 99 | // Write audio to a temporary file. 100 | f, err := ioutil.TempFile("", "peapod-polly-chunk-") 101 | if err != nil { 102 | return "", err 103 | } else if _, err := io.Copy(f, resp.AudioStream); err != nil { 104 | return "", err 105 | } else if err := f.Close(); err != nil { 106 | return "", err 107 | } 108 | 109 | // Rename with extension. 110 | path := f.Name() + ".mp3" 111 | if err := os.Rename(f.Name(), path); err != nil { 112 | return "", err 113 | } 114 | return path, nil 115 | } 116 | 117 | func (s *TTSService) concatenateFiles(ctx context.Context, paths []string) (string, error) { 118 | // Create a temporary path. 119 | f, err := ioutil.TempFile("", "peapod-polly-") 120 | if err != nil { 121 | return "", err 122 | } else if err := f.Close(); err != nil { 123 | return "", err 124 | } else if err := os.Remove(f.Name()); err != nil { 125 | return "", err 126 | } 127 | path := f.Name() + ".mp3" 128 | 129 | // Execute command. 130 | args := []string{"-i", "concat:" + strings.Join(paths, "|"), "-c", "copy", path} 131 | cmd := exec.Command("ffmpeg", args...) 132 | cmd.Stdout = s.LogOutput 133 | cmd.Stderr = s.LogOutput 134 | if err := cmd.Run(); err != nil { 135 | return "", err 136 | } 137 | 138 | return path, nil 139 | } 140 | 141 | // oneTimeReader allows the reader to read once and then it deletes on close. 142 | type oneTimeReader struct { 143 | *os.File 144 | } 145 | 146 | // Close closes the file handle and deletes the file. 147 | func (r *oneTimeReader) Close() error { 148 | if err := r.File.Close(); err != nil { 149 | return err 150 | } 151 | return os.Remove(r.File.Name()) 152 | } 153 | 154 | // splitTextOnParagraphs splits into chunks of maxChars-length chunks. 155 | func splitTextOnParagraphs(text string, maxChars int) []string { 156 | lines := regexp.MustCompile(`\n+`).Split(text, -1) 157 | 158 | var chunks []string 159 | for _, line := range lines { 160 | line += "\n" 161 | 162 | // If line is too large for one chunk then split on words. 163 | if len(line) > maxChars { 164 | chunks = append(chunks, splitTextOnWords(line, maxChars)...) 165 | continue 166 | } 167 | 168 | // Add if this is the first line. 169 | if len(chunks) == 0 { 170 | chunks = append(chunks, line) 171 | continue 172 | } 173 | 174 | // Add new chunk if adding line will exceed max. 175 | if len(chunks[len(chunks)-1])+len(line) > maxChars { 176 | chunks = append(chunks, line) 177 | continue 178 | } 179 | 180 | // Append to last chunk. 181 | chunks[len(chunks)-1] = chunks[len(chunks)-1] + "\n" + line 182 | } 183 | 184 | return chunks 185 | } 186 | 187 | // splitTextOnWords splits into max length chunks at word boundries. 188 | func splitTextOnWords(text string, maxChars int) []string { 189 | words := regexp.MustCompile(` +`).Split(text, -1) 190 | 191 | chunks := make([]string, 1) 192 | chunks[0] = words[0] 193 | for _, word := range words[1:] { 194 | if len(chunks[len(chunks)-1])+len(word) > maxChars { 195 | chunks = append(chunks, word) 196 | continue 197 | } 198 | 199 | chunks[len(chunks)-1] = chunks[len(chunks)-1] + " " + word 200 | } 201 | 202 | return chunks 203 | } 204 | -------------------------------------------------------------------------------- /bolt/bolt.go: -------------------------------------------------------------------------------- 1 | package bolt 2 | 3 | import ( 4 | "context" 5 | "encoding/binary" 6 | "fmt" 7 | "time" 8 | ) 9 | 10 | //go:generate protoc --gogo_out=. bolt.proto 11 | 12 | // itob returns an 8-byte big-endian encoded byte slice of v. 13 | func itob(v int) []byte { 14 | b := make([]byte, 8) 15 | binary.BigEndian.PutUint64(b, uint64(v)) 16 | return b 17 | } 18 | 19 | // btoi returns an integer decoded from an 8-byte big-endian encoded byte slice. 20 | func btoi(b []byte) int { 21 | return int(binary.BigEndian.Uint64(b)) 22 | } 23 | 24 | func encodeTime(t time.Time) int64 { 25 | if t.IsZero() { 26 | return 0 27 | } 28 | return t.UnixNano() 29 | } 30 | 31 | func decodeTime(v int64) time.Time { 32 | if v == 0 { 33 | return time.Time{} 34 | } 35 | return time.Unix(0, v).UTC() 36 | } 37 | 38 | // updateIndex removes an index at and adds . 39 | func updateIndex(ctx context.Context, tx *Tx, name []byte, oldParentID, oldChildID, newParentID, newChildID int) error { 40 | // Ignore if index is unchanged. 41 | if oldParentID == newParentID && oldChildID == newChildID { 42 | return nil 43 | } 44 | 45 | // Find index bucket. 46 | bkt, err := tx.CreateBucketIfNotExists(name) 47 | if err != nil { 48 | return err 49 | } 50 | 51 | // Remove old index entry, if specified. 52 | if oldParentID != 0 || oldChildID != 0 { 53 | if err := bkt.Delete(makeIndexKey(oldParentID, oldChildID)); err != nil { 54 | return err 55 | } 56 | } 57 | 58 | // Add new index entry, if specified. 59 | if newParentID != 0 || newChildID != 0 { 60 | if err := bkt.Put(makeIndexKey(newParentID, newChildID), nil); err != nil { 61 | return err 62 | } 63 | } 64 | 65 | return nil 66 | } 67 | 68 | func makeIndexKey(v0, v1 int) []byte { 69 | key := make([]byte, 16) 70 | binary.BigEndian.PutUint64(key[0:8], uint64(v0)) 71 | binary.BigEndian.PutUint64(key[8:16], uint64(v1)) 72 | return key 73 | 74 | } 75 | 76 | // assert panics with a formatted message if condition is false. 77 | func assert(condition bool, format string, a ...interface{}) { 78 | if !condition { 79 | panic(fmt.Sprintf(format, a...)) 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /bolt/bolt.pb.go: -------------------------------------------------------------------------------- 1 | // Code generated by protoc-gen-gogo. 2 | // source: bolt.proto 3 | // DO NOT EDIT! 4 | 5 | /* 6 | Package bolt is a generated protocol buffer package. 7 | 8 | It is generated from these files: 9 | bolt.proto 10 | 11 | It has these top-level messages: 12 | Job 13 | Playlist 14 | Track 15 | User 16 | */ 17 | package bolt 18 | 19 | import proto "github.com/gogo/protobuf/proto" 20 | import fmt "fmt" 21 | import math "math" 22 | 23 | // Reference imports to suppress errors if they are not otherwise used. 24 | var _ = proto.Marshal 25 | var _ = fmt.Errorf 26 | var _ = math.Inf 27 | 28 | // This is a compile-time assertion to ensure that this generated file 29 | // is compatible with the proto package it is being compiled against. 30 | // A compilation error at this line likely means your copy of the 31 | // proto package needs to be updated. 32 | const _ = proto.GoGoProtoPackageIsVersion2 // please upgrade the proto package 33 | 34 | type Job struct { 35 | ID int64 `protobuf:"varint,1,opt,name=ID,proto3" json:"ID,omitempty"` 36 | OwnerID int64 `protobuf:"varint,2,opt,name=OwnerID,proto3" json:"OwnerID,omitempty"` 37 | Type string `protobuf:"bytes,3,opt,name=Type,proto3" json:"Type,omitempty"` 38 | Status string `protobuf:"bytes,4,opt,name=Status,proto3" json:"Status,omitempty"` 39 | PlaylistID int64 `protobuf:"varint,5,opt,name=PlaylistID,proto3" json:"PlaylistID,omitempty"` 40 | Title string `protobuf:"bytes,10,opt,name=Title,proto3" json:"Title,omitempty"` 41 | URL string `protobuf:"bytes,6,opt,name=URL,proto3" json:"URL,omitempty"` 42 | Text string `protobuf:"bytes,11,opt,name=Text,proto3" json:"Text,omitempty"` 43 | Error string `protobuf:"bytes,7,opt,name=Error,proto3" json:"Error,omitempty"` 44 | CreatedAt int64 `protobuf:"varint,8,opt,name=CreatedAt,proto3" json:"CreatedAt,omitempty"` 45 | UpdatedAt int64 `protobuf:"varint,9,opt,name=UpdatedAt,proto3" json:"UpdatedAt,omitempty"` 46 | } 47 | 48 | func (m *Job) Reset() { *m = Job{} } 49 | func (m *Job) String() string { return proto.CompactTextString(m) } 50 | func (*Job) ProtoMessage() {} 51 | func (*Job) Descriptor() ([]byte, []int) { return fileDescriptorBolt, []int{0} } 52 | 53 | type Playlist struct { 54 | ID int64 `protobuf:"varint,1,opt,name=ID,proto3" json:"ID,omitempty"` 55 | OwnerID int64 `protobuf:"varint,2,opt,name=OwnerID,proto3" json:"OwnerID,omitempty"` 56 | Token string `protobuf:"bytes,3,opt,name=Token,proto3" json:"Token,omitempty"` 57 | Name string `protobuf:"bytes,4,opt,name=Name,proto3" json:"Name,omitempty"` 58 | CreatedAt int64 `protobuf:"varint,5,opt,name=CreatedAt,proto3" json:"CreatedAt,omitempty"` 59 | UpdatedAt int64 `protobuf:"varint,6,opt,name=UpdatedAt,proto3" json:"UpdatedAt,omitempty"` 60 | } 61 | 62 | func (m *Playlist) Reset() { *m = Playlist{} } 63 | func (m *Playlist) String() string { return proto.CompactTextString(m) } 64 | func (*Playlist) ProtoMessage() {} 65 | func (*Playlist) Descriptor() ([]byte, []int) { return fileDescriptorBolt, []int{1} } 66 | 67 | type Track struct { 68 | ID int64 `protobuf:"varint,1,opt,name=ID,proto3" json:"ID,omitempty"` 69 | PlaylistID int64 `protobuf:"varint,2,opt,name=PlaylistID,proto3" json:"PlaylistID,omitempty"` 70 | Filename string `protobuf:"bytes,3,opt,name=Filename,proto3" json:"Filename,omitempty"` 71 | ContentType string `protobuf:"bytes,4,opt,name=ContentType,proto3" json:"ContentType,omitempty"` 72 | Title string `protobuf:"bytes,5,opt,name=Title,proto3" json:"Title,omitempty"` 73 | Description string `protobuf:"bytes,10,opt,name=Description,proto3" json:"Description,omitempty"` 74 | Duration int64 `protobuf:"varint,6,opt,name=Duration,proto3" json:"Duration,omitempty"` 75 | FileSize int64 `protobuf:"varint,7,opt,name=FileSize,proto3" json:"FileSize,omitempty"` 76 | CreatedAt int64 `protobuf:"varint,8,opt,name=CreatedAt,proto3" json:"CreatedAt,omitempty"` 77 | UpdatedAt int64 `protobuf:"varint,9,opt,name=UpdatedAt,proto3" json:"UpdatedAt,omitempty"` 78 | } 79 | 80 | func (m *Track) Reset() { *m = Track{} } 81 | func (m *Track) String() string { return proto.CompactTextString(m) } 82 | func (*Track) ProtoMessage() {} 83 | func (*Track) Descriptor() ([]byte, []int) { return fileDescriptorBolt, []int{2} } 84 | 85 | type User struct { 86 | ID int64 `protobuf:"varint,1,opt,name=ID,proto3" json:"ID,omitempty"` 87 | MobileNumber string `protobuf:"bytes,2,opt,name=MobileNumber,proto3" json:"MobileNumber,omitempty"` 88 | CreatedAt int64 `protobuf:"varint,3,opt,name=CreatedAt,proto3" json:"CreatedAt,omitempty"` 89 | UpdatedAt int64 `protobuf:"varint,4,opt,name=UpdatedAt,proto3" json:"UpdatedAt,omitempty"` 90 | } 91 | 92 | func (m *User) Reset() { *m = User{} } 93 | func (m *User) String() string { return proto.CompactTextString(m) } 94 | func (*User) ProtoMessage() {} 95 | func (*User) Descriptor() ([]byte, []int) { return fileDescriptorBolt, []int{3} } 96 | 97 | func init() { 98 | proto.RegisterType((*Job)(nil), "bolt.Job") 99 | proto.RegisterType((*Playlist)(nil), "bolt.Playlist") 100 | proto.RegisterType((*Track)(nil), "bolt.Track") 101 | proto.RegisterType((*User)(nil), "bolt.User") 102 | } 103 | 104 | func init() { proto.RegisterFile("bolt.proto", fileDescriptorBolt) } 105 | 106 | var fileDescriptorBolt = []byte{ 107 | // 376 bytes of a gzipped FileDescriptorProto 108 | 0x1f, 0x8b, 0x08, 0x00, 0x00, 0x09, 0x6e, 0x88, 0x02, 0xff, 0xa4, 0x53, 0xcb, 0x6a, 0xe3, 0x40, 109 | 0x10, 0x44, 0x4f, 0x5b, 0xed, 0x65, 0x59, 0x86, 0x65, 0x19, 0x96, 0x10, 0x84, 0x4f, 0x3e, 0xe5, 110 | 0x92, 0x2f, 0x08, 0x56, 0x02, 0x0a, 0x89, 0x13, 0x64, 0xfb, 0x03, 0x24, 0xbb, 0x0f, 0xc2, 0xb2, 111 | 0x46, 0x8c, 0xc6, 0x89, 0x9d, 0x3f, 0xc8, 0x2f, 0xe4, 0x96, 0x3f, 0x0d, 0xd3, 0x92, 0x65, 0xc9, 112 | 0x06, 0x43, 0xc8, 0xad, 0xab, 0x9a, 0x56, 0xd5, 0x54, 0x21, 0x80, 0x44, 0x64, 0xea, 0xaa, 0x90, 113 | 0x42, 0x09, 0x66, 0xeb, 0x79, 0xf8, 0x6e, 0x82, 0x75, 0x2f, 0x12, 0xf6, 0x1b, 0xcc, 0x30, 0xe0, 114 | 0x86, 0x6f, 0x8c, 0xac, 0xc8, 0x0c, 0x03, 0xc6, 0xa1, 0xf7, 0xf4, 0x9a, 0xa3, 0x0c, 0x03, 0x6e, 115 | 0x12, 0xb9, 0x87, 0x8c, 0x81, 0x3d, 0xdb, 0x15, 0xc8, 0x2d, 0xdf, 0x18, 0x79, 0x11, 0xcd, 0xec, 116 | 0x1f, 0xb8, 0x53, 0x15, 0xab, 0x4d, 0xc9, 0x6d, 0x62, 0x6b, 0xc4, 0x2e, 0x01, 0x9e, 0xb3, 0x78, 117 | 0x97, 0xa5, 0xa5, 0x0a, 0x03, 0xee, 0xd0, 0x87, 0x5a, 0x0c, 0xfb, 0x0b, 0xce, 0x2c, 0x55, 0x19, 118 | 0x72, 0xa0, 0xb3, 0x0a, 0xb0, 0x3f, 0x60, 0xcd, 0xa3, 0x07, 0xee, 0x12, 0xa7, 0x47, 0xd2, 0xc4, 119 | 0xad, 0xe2, 0x83, 0x5a, 0x13, 0xb7, 0x4a, 0xdf, 0xde, 0x4a, 0x29, 0x24, 0xef, 0x55, 0xb7, 0x04, 120 | 0xd8, 0x05, 0x78, 0x63, 0x89, 0xb1, 0xc2, 0xe5, 0x8d, 0xe2, 0x7d, 0x12, 0x3c, 0x10, 0x7a, 0x3b, 121 | 0x2f, 0x96, 0xf5, 0xd6, 0xab, 0xb6, 0x0d, 0x31, 0xfc, 0x30, 0xa0, 0xbf, 0x37, 0xf7, 0x8d, 0x40, 122 | 0xf4, 0x23, 0xc4, 0x0a, 0xf3, 0x3a, 0x91, 0x0a, 0x68, 0xcb, 0x93, 0x78, 0x8d, 0x75, 0x20, 0x34, 123 | 0x77, 0xcd, 0x39, 0x67, 0xcd, 0xb9, 0xc7, 0xe6, 0x3e, 0x4d, 0x70, 0x66, 0x32, 0x5e, 0xac, 0x4e, 124 | 0x9c, 0x75, 0x43, 0x36, 0x4f, 0x42, 0xfe, 0x0f, 0xfd, 0xbb, 0x34, 0xc3, 0x5c, 0xbb, 0xa9, 0x2c, 125 | 0x36, 0x98, 0xf9, 0x30, 0x18, 0x8b, 0x5c, 0x61, 0xae, 0xa8, 0xd3, 0xca, 0x6c, 0x9b, 0x3a, 0x54, 126 | 0xe4, 0xb4, 0x2b, 0xf2, 0x61, 0x10, 0x60, 0xb9, 0x90, 0x69, 0xa1, 0x52, 0x91, 0xd7, 0xf5, 0xb5, 127 | 0x29, 0xad, 0x1a, 0x6c, 0x64, 0x4c, 0xeb, 0xea, 0x31, 0x0d, 0xde, 0x3b, 0x9a, 0xa6, 0x6f, 0x48, 128 | 0xed, 0x59, 0x51, 0x83, 0x7f, 0x54, 0xe0, 0x0b, 0xd8, 0xf3, 0x12, 0xe5, 0x49, 0x42, 0x43, 0xf8, 129 | 0xf5, 0x28, 0x92, 0x34, 0xc3, 0xc9, 0x66, 0x9d, 0xa0, 0xa4, 0x8c, 0xbc, 0xa8, 0xc3, 0x75, 0x75, 130 | 0xad, 0xb3, 0xba, 0xf6, 0x91, 0x6e, 0xe2, 0xd2, 0x1f, 0x75, 0xfd, 0x15, 0x00, 0x00, 0xff, 0xff, 131 | 0x14, 0x1e, 0x30, 0x83, 0x5f, 0x03, 0x00, 0x00, 132 | } 133 | -------------------------------------------------------------------------------- /bolt/bolt.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package bolt; 4 | 5 | message Job { 6 | int64 ID = 1; 7 | int64 OwnerID = 2; 8 | string Type = 3; 9 | string Status = 4; 10 | int64 PlaylistID = 5; 11 | string Title = 10; 12 | string URL = 6; 13 | string Text = 11; 14 | string Error = 7; 15 | int64 CreatedAt = 8; 16 | int64 UpdatedAt = 9; 17 | } 18 | 19 | message Playlist { 20 | int64 ID = 1; 21 | int64 OwnerID = 2; 22 | string Token = 3; 23 | string Name = 4; 24 | int64 CreatedAt = 5; 25 | int64 UpdatedAt = 6; 26 | } 27 | 28 | message Track { 29 | int64 ID = 1; 30 | int64 PlaylistID = 2; 31 | string Filename = 3; 32 | string ContentType = 4; 33 | string Title = 5; 34 | string Description = 10; 35 | int64 Duration = 6; 36 | int64 FileSize = 7; 37 | int64 CreatedAt = 8; 38 | int64 UpdatedAt = 9; 39 | } 40 | 41 | message User { 42 | int64 ID = 1; 43 | string MobileNumber = 2; 44 | int64 CreatedAt = 3; 45 | int64 UpdatedAt = 4; 46 | } -------------------------------------------------------------------------------- /bolt/db.go: -------------------------------------------------------------------------------- 1 | package bolt 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "path/filepath" 7 | "time" 8 | 9 | "github.com/boltdb/bolt" 10 | "github.com/middlemost/peapod" 11 | ) 12 | 13 | // DB represents a handle to a Bolt database. 14 | type DB struct { 15 | db *bolt.DB 16 | 17 | Path string 18 | Now func() time.Time 19 | GenerateToken func() string 20 | } 21 | 22 | // NewDB returns a new instance of DB. 23 | func NewDB() *DB { 24 | return &DB{ 25 | Now: time.Now, 26 | GenerateToken: peapod.GenerateToken, 27 | } 28 | } 29 | 30 | // Open opens and initializes the database. 31 | func (db *DB) Open() error { 32 | // Create parent directory, if necessary. 33 | if err := os.MkdirAll(filepath.Dir(db.Path), 0700); err != nil { 34 | return err 35 | } 36 | 37 | // Open bolt database. 38 | d, err := bolt.Open(db.Path, 0600, &bolt.Options{Timeout: 1 * time.Second}) 39 | if err != nil { 40 | return err 41 | } 42 | db.db = d 43 | 44 | return nil 45 | } 46 | 47 | // Close closes the database. 48 | func (db *DB) Close() error { 49 | if db.db != nil { 50 | db.db.Close() 51 | } 52 | return nil 53 | } 54 | 55 | // Begin starts a new transaction. 56 | func (db *DB) Begin(ctx context.Context, writable bool) (*Tx, error) { 57 | tx, err := db.db.Begin(writable) 58 | if err != nil { 59 | return nil, err 60 | } 61 | return &Tx{Tx: tx, Now: db.Now(), GenerateToken: db.GenerateToken}, nil 62 | } 63 | 64 | // BeginAuth starts a new transaction and verifies that a user is authenticated. 65 | func (db *DB) BeginAuth(ctx context.Context, writable bool) (*Tx, error) { 66 | u := peapod.FromContext(ctx) 67 | if u == nil { 68 | return nil, peapod.ErrUnauthorized 69 | } 70 | return db.Begin(ctx, writable) 71 | } 72 | 73 | // Tx is a wrapper for bolt.Tx. 74 | type Tx struct { 75 | *bolt.Tx 76 | Now time.Time 77 | GenerateToken func() string 78 | } 79 | 80 | func errorString(err error) string { 81 | if err != nil { 82 | return err.Error() 83 | } 84 | return "" 85 | } 86 | -------------------------------------------------------------------------------- /bolt/db_test.go: -------------------------------------------------------------------------------- 1 | package bolt_test 2 | 3 | import ( 4 | "io/ioutil" 5 | "os" 6 | "time" 7 | 8 | "github.com/middlemost/peapod/bolt" 9 | ) 10 | 11 | var Now = time.Date(2000, time.January, 1, 0, 0, 0, 0, time.UTC) 12 | 13 | // DB is a test wrapper for bolt.DB. 14 | type DB struct { 15 | *bolt.DB 16 | } 17 | 18 | // NewDB returns a new instance of DB. 19 | func NewDB() *DB { 20 | db := &DB{DB: bolt.NewDB()} 21 | db.Now = func() time.Time { return Now } 22 | return db 23 | } 24 | 25 | // MustOpenDB opens a DB at a temporary file path. 26 | func MustOpenDB() *DB { 27 | f, err := ioutil.TempFile("", "") 28 | if err != nil { 29 | panic(err) 30 | } else if err := f.Close(); err != nil { 31 | panic(err) 32 | } 33 | 34 | db := NewDB() 35 | db.Path = f.Name() 36 | if err := db.Open(); err != nil { 37 | panic(err) 38 | } 39 | return db 40 | } 41 | 42 | // Close closes the database and removes the underlying data file. 43 | func (db *DB) Close() error { 44 | defer os.Remove(db.Path) 45 | return db.DB.Close() 46 | } 47 | 48 | // MustClose closes the database. Panic on error. 49 | func (db *DB) MustClose() { 50 | if err := db.Close(); err != nil { 51 | panic(err) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /bolt/job.go: -------------------------------------------------------------------------------- 1 | package bolt 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/gogo/protobuf/proto" 7 | "github.com/middlemost/peapod" 8 | ) 9 | 10 | // Ensure service implement interface. 11 | var _ peapod.JobService = &JobService{} 12 | 13 | // JobService represents a service for creating and processing jobs. 14 | type JobService struct { 15 | db *DB 16 | 17 | c chan struct{} 18 | } 19 | 20 | // NewJobService returns a new instance of JobService. 21 | func NewJobService(db *DB) *JobService { 22 | return &JobService{ 23 | db: db, 24 | c: make(chan struct{}, 1), 25 | } 26 | } 27 | 28 | // C returns a channel that sends notifications of new jobs. 29 | func (s *JobService) C() <-chan struct{} { return s.c } 30 | 31 | // CreateJob creates adds a job to the job queue. 32 | func (s *JobService) CreateJob(ctx context.Context, job *peapod.Job) error { 33 | tx, err := s.db.Begin(ctx, true) 34 | if err != nil { 35 | return err 36 | } 37 | defer tx.Rollback() 38 | 39 | // Create job & commit. 40 | if err := func() error { 41 | if err := createJob(ctx, tx, job); err != nil { 42 | return err 43 | } else if err := tx.Commit(); err != nil { 44 | return err 45 | } 46 | return nil 47 | }(); err != nil { 48 | job.ID = 0 49 | return err 50 | } 51 | 52 | // Signal change notification. 53 | select { 54 | case s.c <- struct{}{}: 55 | default: 56 | } 57 | 58 | return nil 59 | } 60 | 61 | // NextJob returns the next job in the job queue and marks it as started. 62 | func (s *JobService) NextJob(ctx context.Context) (*peapod.Job, error) { 63 | tx, err := s.db.Begin(ctx, true) 64 | if err != nil { 65 | return nil, err 66 | } 67 | defer tx.Rollback() 68 | 69 | // Retrieve next job id. 70 | job, err := nextJob(ctx, tx) 71 | if err != nil { 72 | return nil, err 73 | } else if job == nil { 74 | return nil, nil 75 | } 76 | 77 | // Mark job as started. 78 | if err := setJobStatus(ctx, tx, job.ID, peapod.JobStatusProcessing, nil); err != nil { 79 | return nil, err 80 | } 81 | 82 | // Re-fetch job. 83 | job, err = findJobByID(ctx, tx, job.ID) 84 | if err != nil { 85 | return nil, err 86 | } 87 | 88 | // Attach owner. 89 | owner, err := findUserByID(ctx, tx, job.OwnerID) 90 | if err != nil { 91 | return nil, err 92 | } else if owner == nil { 93 | return nil, peapod.ErrJobOwnerNotFound 94 | } 95 | job.Owner = owner 96 | 97 | // Commit changes. 98 | if err := tx.Commit(); err != nil { 99 | return nil, err 100 | } 101 | return job, nil 102 | } 103 | 104 | // CompleteJob marks a job as completed or failed. 105 | func (s *JobService) CompleteJob(ctx context.Context, id int, e error) error { 106 | tx, err := s.db.Begin(ctx, true) 107 | if err != nil { 108 | return err 109 | } 110 | defer tx.Rollback() 111 | 112 | // Determine status based on error. 113 | status := peapod.JobStatusCompleted 114 | if e != nil { 115 | status = peapod.JobStatusFailed 116 | } 117 | 118 | // Update status & commit. 119 | if err := setJobStatus(ctx, tx, id, status, e); err != nil { 120 | return err 121 | } else if err := tx.Commit(); err != nil { 122 | return err 123 | } 124 | 125 | return nil 126 | } 127 | 128 | // ResetJobQueue resets all queued jobs to a pending status. 129 | // This should be called when the process starts so that all jobs are restarted. 130 | func (s *JobService) ResetJobQueue(ctx context.Context) error { 131 | tx, err := s.db.Begin(ctx, true) 132 | if err != nil { 133 | return err 134 | } 135 | defer tx.Rollback() 136 | 137 | // Fetch queue. 138 | bkt := tx.Bucket([]byte("JobQueue")) 139 | if bkt == nil { 140 | return nil 141 | } 142 | cur := bkt.Cursor() 143 | 144 | // Iterate over queue. 145 | for k, v := cur.First(); k != nil; k, v = cur.Next() { 146 | if err := setJobStatus(ctx, tx, btoi(v), peapod.JobStatusPending, nil); err != nil { 147 | return err 148 | } 149 | } 150 | 151 | return tx.Commit() 152 | } 153 | 154 | func findJobByID(ctx context.Context, tx *Tx, id int) (*peapod.Job, error) { 155 | bkt := tx.Bucket([]byte("Jobs")) 156 | if bkt == nil { 157 | return nil, nil 158 | } 159 | 160 | var job peapod.Job 161 | if buf := bkt.Get(itob(id)); buf == nil { 162 | return nil, nil 163 | } else if err := unmarshalJob(buf, &job); err != nil { 164 | return nil, err 165 | } 166 | return &job, nil 167 | } 168 | 169 | func jobExists(ctx context.Context, tx *Tx, id int) bool { 170 | bkt := tx.Bucket([]byte("Jobs")) 171 | if bkt == nil { 172 | return false 173 | } 174 | return bkt.Get(itob(id)) != nil 175 | } 176 | 177 | func createJob(ctx context.Context, tx *Tx, job *peapod.Job) error { 178 | bkt, err := tx.CreateBucketIfNotExists([]byte("Jobs")) 179 | if err != nil { 180 | return err 181 | } 182 | 183 | // Retrieve next sequence. 184 | id, _ := bkt.NextSequence() 185 | job.ID = int(id) 186 | 187 | // Mark as pending. 188 | job.Status = peapod.JobStatusPending 189 | 190 | // Update timestamps. 191 | job.CreatedAt = tx.Now 192 | 193 | // Save data & add to end of job queue. 194 | if err := saveJob(ctx, tx, job); err != nil { 195 | return err 196 | } else if addJobToQueue(ctx, tx, job.ID); err != nil { 197 | return err 198 | } 199 | 200 | return nil 201 | } 202 | 203 | func saveJob(ctx context.Context, tx *Tx, job *peapod.Job) error { 204 | // Validate record. 205 | if !peapod.IsValidJobType(job.Type) { 206 | return peapod.ErrInvalidJobType 207 | } else if !peapod.IsValidJobStatus(job.Status) { 208 | return peapod.ErrInvalidJobStatus 209 | } else if job.OwnerID == 0 { 210 | return peapod.ErrJobOwnerRequired 211 | } else if !userExists(ctx, tx, job.OwnerID) { 212 | return peapod.ErrUserNotFound 213 | } 214 | 215 | // Marshal and update record. 216 | if buf, err := marshalJob(job); err != nil { 217 | return err 218 | } else if bkt, err := tx.CreateBucketIfNotExists([]byte("Jobs")); err != nil { 219 | return err 220 | } else if err := bkt.Put(itob(job.ID), buf); err != nil { 221 | return err 222 | } 223 | return nil 224 | } 225 | 226 | func setJobStatus(ctx context.Context, tx *Tx, id int, status string, e error) error { 227 | // Fetch job. 228 | job, err := findJobByID(ctx, tx, id) 229 | if err != nil { 230 | return err 231 | } else if job == nil { 232 | return peapod.ErrJobNotFound 233 | } 234 | 235 | // Ignore if status unchanged. 236 | if job.Status == status { 237 | return nil 238 | } 239 | 240 | // If status is a completion status then remove from the job queue. 241 | switch status { 242 | case peapod.JobStatusCompleted, peapod.JobStatusFailed: 243 | if err := removeJobFromQueue(ctx, tx, job.ID); err != nil { 244 | return err 245 | } 246 | } 247 | 248 | // Update status and save job. 249 | job.Status = status 250 | job.Error = errorString(e) 251 | if err := saveJob(ctx, tx, job); err != nil { 252 | return err 253 | } 254 | 255 | return nil 256 | } 257 | 258 | // nextJob returns the next pending job in the job queue. 259 | func nextJob(ctx context.Context, tx *Tx) (*peapod.Job, error) { 260 | bkt := tx.Bucket([]byte("JobQueue")) 261 | if bkt == nil { 262 | return nil, nil 263 | } 264 | 265 | cur := bkt.Cursor() 266 | for k, v := cur.First(); k != nil; k, v = cur.Next() { 267 | job, err := findJobByID(ctx, tx, btoi(v)) 268 | if err != nil { 269 | return nil, err 270 | } else if job.Status == peapod.JobStatusPending { 271 | return job, nil 272 | } 273 | } 274 | 275 | return nil, nil 276 | } 277 | 278 | // addJobToQueue appends a job to the end of the queue. 279 | func addJobToQueue(ctx context.Context, tx *Tx, id int) error { 280 | bkt, err := tx.CreateBucketIfNotExists([]byte("JobQueue")) 281 | if err != nil { 282 | return err 283 | } 284 | seq, _ := bkt.NextSequence() 285 | return bkt.Put(itob(int(seq)), itob(id)) 286 | } 287 | 288 | // removeJobFromQueue finds a job in the queue and removes it. 289 | func removeJobFromQueue(ctx context.Context, tx *Tx, id int) error { 290 | bkt := tx.Bucket([]byte("JobQueue")) 291 | if bkt == nil { 292 | return nil 293 | } 294 | 295 | cur := bkt.Cursor() 296 | for k, v := cur.First(); k != nil; k, v = cur.Next() { 297 | if id == btoi(v) { 298 | if err := cur.Delete(); err != nil { 299 | return err 300 | } 301 | } 302 | } 303 | return nil 304 | } 305 | 306 | func marshalJob(v *peapod.Job) ([]byte, error) { 307 | return proto.Marshal(&Job{ 308 | ID: int64(v.ID), 309 | OwnerID: int64(v.OwnerID), 310 | Type: v.Type, 311 | Status: v.Status, 312 | PlaylistID: int64(v.PlaylistID), 313 | Title: v.Title, 314 | URL: v.URL, 315 | Text: v.Text, 316 | Error: v.Error, 317 | CreatedAt: encodeTime(v.CreatedAt), 318 | UpdatedAt: encodeTime(v.UpdatedAt), 319 | }) 320 | } 321 | 322 | func unmarshalJob(data []byte, v *peapod.Job) error { 323 | var pb Job 324 | if err := proto.Unmarshal(data, &pb); err != nil { 325 | return err 326 | } 327 | *v = peapod.Job{ 328 | ID: int(pb.ID), 329 | OwnerID: int(pb.OwnerID), 330 | Type: pb.Type, 331 | Status: pb.Status, 332 | PlaylistID: int(pb.PlaylistID), 333 | Title: pb.Title, 334 | URL: pb.URL, 335 | Text: pb.Text, 336 | Error: pb.Error, 337 | CreatedAt: decodeTime(pb.CreatedAt), 338 | UpdatedAt: decodeTime(pb.UpdatedAt), 339 | } 340 | return nil 341 | } 342 | -------------------------------------------------------------------------------- /bolt/playlist.go: -------------------------------------------------------------------------------- 1 | package bolt 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | 7 | "github.com/gogo/protobuf/proto" 8 | "github.com/middlemost/peapod" 9 | ) 10 | 11 | // Ensure service implements interface. 12 | var _ peapod.PlaylistService = &PlaylistService{} 13 | 14 | // PlaylistService represents a service to manage playlists. 15 | type PlaylistService struct { 16 | db *DB 17 | } 18 | 19 | // NewPlaylistService returns a new instance of PlaylistService. 20 | func NewPlaylistService(db *DB) *PlaylistService { 21 | return &PlaylistService{db: db} 22 | } 23 | 24 | // FindPlaylistByID returns a playlist and its tracks by id. 25 | func (s *PlaylistService) FindPlaylistByID(ctx context.Context, id int) (*peapod.Playlist, error) { 26 | tx, err := s.db.Begin(ctx, false) 27 | if err != nil { 28 | return nil, err 29 | } 30 | defer tx.Rollback() 31 | 32 | // Retrieve playlist. 33 | playlist, err := findPlaylistByID(ctx, tx, id) 34 | if err != nil { 35 | return nil, err 36 | } 37 | 38 | // Attach tracks. 39 | tracks, err := playlistTracks(ctx, tx, playlist.ID) 40 | if err != nil { 41 | return nil, err 42 | } 43 | playlist.Tracks = tracks 44 | 45 | return playlist, nil 46 | } 47 | 48 | // FindPlaylistByToken returns a playlist and its tracks by token. 49 | func (s *PlaylistService) FindPlaylistByToken(ctx context.Context, token string) (*peapod.Playlist, error) { 50 | tx, err := s.db.Begin(ctx, false) 51 | if err != nil { 52 | return nil, err 53 | } 54 | defer tx.Rollback() 55 | 56 | // Retrieve id from the token. 57 | id := findPlaylistIDByToken(ctx, tx, token) 58 | if id == 0 { 59 | return nil, peapod.ErrPlaylistNotFound 60 | } 61 | 62 | // Retrieve playlist. 63 | playlist, err := findPlaylistByID(ctx, tx, id) 64 | if err != nil { 65 | return nil, err 66 | } 67 | 68 | // Attach tracks. 69 | tracks, err := playlistTracks(ctx, tx, playlist.ID) 70 | if err != nil { 71 | return nil, err 72 | } 73 | playlist.Tracks = tracks 74 | 75 | return playlist, nil 76 | } 77 | 78 | // FindPlaylistsByUserID returns a list of all playlists for a user. 79 | func (s *PlaylistService) FindPlaylistsByUserID(ctx context.Context, id int) ([]*peapod.Playlist, error) { 80 | tx, err := s.db.Begin(ctx, false) 81 | if err != nil { 82 | return nil, err 83 | } 84 | defer tx.Rollback() 85 | return findPlaylistsByUserID(ctx, tx, id) 86 | } 87 | 88 | func findPlaylistByID(ctx context.Context, tx *Tx, id int) (*peapod.Playlist, error) { 89 | bkt := tx.Bucket([]byte("Playlists")) 90 | if bkt == nil { 91 | return nil, nil 92 | } 93 | 94 | var playlist peapod.Playlist 95 | if buf := bkt.Get(itob(id)); buf == nil { 96 | return nil, nil 97 | } else if err := unmarshalPlaylist(buf, &playlist); err != nil { 98 | return nil, err 99 | } 100 | return &playlist, nil 101 | } 102 | 103 | func playlistExists(ctx context.Context, tx *Tx, id int) bool { 104 | bkt := tx.Bucket([]byte("Playlists")) 105 | if bkt == nil { 106 | return false 107 | } 108 | return bkt.Get(itob(id)) != nil 109 | } 110 | 111 | func findPlaylistIDByToken(ctx context.Context, tx *Tx, token string) int { 112 | bkt := tx.Bucket([]byte("Playlists.Token")) 113 | if bkt == nil { 114 | return 0 115 | } 116 | v := bkt.Get([]byte(token)) 117 | if v == nil { 118 | return 0 119 | } 120 | return btoi(v) 121 | } 122 | 123 | func findPlaylistsByUserID(ctx context.Context, tx *Tx, id int) ([]*peapod.Playlist, error) { 124 | bkt := tx.Bucket([]byte("Users.Playlists")) 125 | if bkt == nil { 126 | return nil, nil 127 | } 128 | 129 | cur := bkt.Cursor() 130 | prefix := itob(id) 131 | a := make([]*peapod.Playlist, 0, 1) 132 | for k, _ := cur.Seek(prefix); bytes.HasPrefix(k, prefix); k, _ = cur.Next() { 133 | playlistID := btoi(k[8:]) 134 | playlist, err := findPlaylistByID(ctx, tx, playlistID) 135 | if err != nil { 136 | return nil, err 137 | } 138 | assert(playlist != nil, "indexed playlist not found: id=%d", playlistID) 139 | a = append(a, playlist) 140 | } 141 | return a, nil 142 | } 143 | 144 | func createPlaylist(ctx context.Context, tx *Tx, playlist *peapod.Playlist) error { 145 | if playlist == nil { 146 | return peapod.ErrPlaylistRequired 147 | } 148 | 149 | bkt, err := tx.CreateBucketIfNotExists([]byte("Playlists")) 150 | if err != nil { 151 | return err 152 | } 153 | 154 | // Retrieve next sequence. 155 | id, _ := bkt.NextSequence() 156 | playlist.ID = int(id) 157 | 158 | // Generate external token. 159 | playlist.Token = tx.GenerateToken() 160 | 161 | // Update timestamps. 162 | playlist.CreatedAt = tx.Now 163 | 164 | // Save data. 165 | if err := savePlaylist(ctx, tx, playlist); err != nil { 166 | return err 167 | } 168 | 169 | // Index by owner. 170 | if err := updateIndex(ctx, tx, []byte("Users.Playlists"), 0, 0, playlist.OwnerID, playlist.ID); err != nil { 171 | return err 172 | } 173 | 174 | // Index by token. 175 | if bkt, err := tx.CreateBucketIfNotExists([]byte("Playlists.Token")); err != nil { 176 | return err 177 | } else if err := bkt.Put([]byte(playlist.Token), itob(playlist.ID)); err != nil { 178 | return err 179 | } 180 | 181 | return nil 182 | } 183 | 184 | func savePlaylist(ctx context.Context, tx *Tx, playlist *peapod.Playlist) error { 185 | // Validate record. 186 | if playlist.OwnerID == 0 { 187 | return peapod.ErrPlaylistOwnerRequired 188 | } else if !userExists(ctx, tx, playlist.OwnerID) { 189 | return peapod.ErrUserNotFound 190 | } else if playlist.Token == "" { 191 | return peapod.ErrPlaylistTokenRequired 192 | } else if playlist.Name == "" { 193 | return peapod.ErrPlaylistNameRequired 194 | } 195 | 196 | // Update timestamp. 197 | playlist.UpdatedAt = tx.Now 198 | 199 | // Marshal and update record. 200 | if buf, err := marshalPlaylist(playlist); err != nil { 201 | return err 202 | } else if bkt, err := tx.CreateBucketIfNotExists([]byte("Playlists")); err != nil { 203 | return err 204 | } else if err := bkt.Put(itob(playlist.ID), buf); err != nil { 205 | return err 206 | } 207 | return nil 208 | } 209 | 210 | func marshalPlaylist(v *peapod.Playlist) ([]byte, error) { 211 | return proto.Marshal(&Playlist{ 212 | ID: int64(v.ID), 213 | OwnerID: int64(v.OwnerID), 214 | Token: v.Token, 215 | Name: v.Name, 216 | CreatedAt: encodeTime(v.CreatedAt), 217 | UpdatedAt: encodeTime(v.UpdatedAt), 218 | }) 219 | } 220 | 221 | func unmarshalPlaylist(data []byte, v *peapod.Playlist) error { 222 | var pb Playlist 223 | if err := proto.Unmarshal(data, &pb); err != nil { 224 | return err 225 | } 226 | *v = peapod.Playlist{ 227 | ID: int(pb.ID), 228 | OwnerID: int(pb.OwnerID), 229 | Token: pb.Token, 230 | Name: pb.Name, 231 | CreatedAt: decodeTime(pb.CreatedAt), 232 | UpdatedAt: decodeTime(pb.UpdatedAt), 233 | } 234 | return nil 235 | } 236 | -------------------------------------------------------------------------------- /bolt/track.go: -------------------------------------------------------------------------------- 1 | package bolt 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "time" 7 | 8 | "github.com/gogo/protobuf/proto" 9 | "github.com/middlemost/peapod" 10 | ) 11 | 12 | // Ensure service implements interface. 13 | var _ peapod.TrackService = &TrackService{} 14 | 15 | // TrackService represents a service to manage tracks. 16 | type TrackService struct { 17 | db *DB 18 | } 19 | 20 | // NewTrackService returns a new instance of TrackService. 21 | func NewTrackService(db *DB) *TrackService { 22 | return &TrackService{db: db} 23 | } 24 | 25 | // FindTrackByID returns a track by id. 26 | func (s *TrackService) FindTrackByID(ctx context.Context, id int) (*peapod.Track, error) { 27 | tx, err := s.db.Begin(ctx, false) 28 | if err != nil { 29 | return nil, err 30 | } 31 | defer tx.Rollback() 32 | 33 | return findTrackByID(ctx, tx, id) 34 | } 35 | 36 | // CreateTrack creates a new track on a playlist. 37 | func (s *TrackService) CreateTrack(ctx context.Context, track *peapod.Track) error { 38 | tx, err := s.db.BeginAuth(ctx, true) 39 | if err != nil { 40 | return err 41 | } 42 | defer tx.Rollback() 43 | 44 | // Create track & commit. 45 | if err := func() error { 46 | if err := createTrack(ctx, tx, track); err != nil { 47 | return err 48 | } else if err := tx.Commit(); err != nil { 49 | return err 50 | } 51 | return nil 52 | }(); err != nil { 53 | track.ID = 0 54 | return nil 55 | } 56 | 57 | return nil 58 | } 59 | 60 | func findTrackByID(ctx context.Context, tx *Tx, id int) (*peapod.Track, error) { 61 | bkt := tx.Bucket([]byte("Tracks")) 62 | if bkt == nil { 63 | return nil, nil 64 | } 65 | 66 | var track peapod.Track 67 | if buf := bkt.Get(itob(id)); buf == nil { 68 | return nil, nil 69 | } else if err := unmarshalTrack(buf, &track); err != nil { 70 | return nil, err 71 | } 72 | return &track, nil 73 | } 74 | 75 | func trackExists(ctx context.Context, tx *Tx, id int) bool { 76 | bkt := tx.Bucket([]byte("Tracks")) 77 | if bkt == nil { 78 | return false 79 | } 80 | return bkt.Get(itob(id)) != nil 81 | } 82 | 83 | func createTrack(ctx context.Context, tx *Tx, track *peapod.Track) error { 84 | bkt, err := tx.CreateBucketIfNotExists([]byte("Tracks")) 85 | if err != nil { 86 | return err 87 | } 88 | 89 | // Retrieve next sequence. 90 | id, _ := bkt.NextSequence() 91 | track.ID = int(id) 92 | 93 | // Update timestamps. 94 | track.CreatedAt = tx.Now 95 | 96 | // Save data & add to index. 97 | if err := saveTrack(ctx, tx, track); err != nil { 98 | return err 99 | } else if err := updateIndex(ctx, tx, []byte("Playlists.Tracks"), 0, 0, track.PlaylistID, track.ID); err != nil { 100 | return err 101 | } 102 | return nil 103 | } 104 | 105 | func saveTrack(ctx context.Context, tx *Tx, track *peapod.Track) error { 106 | // Validate record. 107 | if track.PlaylistID == 0 { 108 | return peapod.ErrTrackPlaylistRequired 109 | } else if !playlistExists(ctx, tx, track.PlaylistID) { 110 | return peapod.ErrPlaylistNotFound 111 | } else if track.Filename == "" { 112 | return peapod.ErrTrackFilenameRequired 113 | } 114 | 115 | // Update timestamps. 116 | track.UpdatedAt = tx.Now 117 | 118 | // Marshal and update record. 119 | if buf, err := marshalTrack(track); err != nil { 120 | return err 121 | } else if bkt, err := tx.CreateBucketIfNotExists([]byte("Tracks")); err != nil { 122 | return err 123 | } else if err := bkt.Put(itob(track.ID), buf); err != nil { 124 | return err 125 | } 126 | return nil 127 | } 128 | 129 | func playlistTracks(ctx context.Context, tx *Tx, playlistID int) ([]*peapod.Track, error) { 130 | bkt := tx.Bucket([]byte("Playlists.Tracks")) 131 | if bkt == nil { 132 | return nil, nil 133 | } 134 | 135 | // Iterate over index. 136 | a := make([]*peapod.Track, 0, 10) 137 | cur := bkt.Cursor() 138 | prefix := itob(playlistID) 139 | for k, _ := cur.Seek(prefix); bytes.HasPrefix(k, prefix); k, _ = cur.Next() { 140 | track, err := findTrackByID(ctx, tx, btoi(k[8:])) 141 | if err != nil { 142 | return nil, err 143 | } 144 | a = append(a, track) 145 | } 146 | return a, nil 147 | } 148 | 149 | func marshalTrack(v *peapod.Track) ([]byte, error) { 150 | return proto.Marshal(&Track{ 151 | ID: int64(v.ID), 152 | PlaylistID: int64(v.PlaylistID), 153 | Filename: v.Filename, 154 | ContentType: v.ContentType, 155 | Title: v.Title, 156 | Description: v.Description, 157 | Duration: int64(v.Duration), 158 | FileSize: int64(v.Size), 159 | CreatedAt: encodeTime(v.CreatedAt), 160 | UpdatedAt: encodeTime(v.UpdatedAt), 161 | }) 162 | } 163 | 164 | func unmarshalTrack(data []byte, v *peapod.Track) error { 165 | var pb Track 166 | if err := proto.Unmarshal(data, &pb); err != nil { 167 | return err 168 | } 169 | *v = peapod.Track{ 170 | ID: int(pb.ID), 171 | PlaylistID: int(pb.PlaylistID), 172 | Filename: pb.Filename, 173 | ContentType: pb.ContentType, 174 | Title: pb.Title, 175 | Description: pb.Description, 176 | Duration: time.Duration(pb.Duration), 177 | Size: int(pb.FileSize), 178 | CreatedAt: decodeTime(pb.CreatedAt), 179 | UpdatedAt: decodeTime(pb.UpdatedAt), 180 | } 181 | return nil 182 | } 183 | -------------------------------------------------------------------------------- /bolt/track_test.go: -------------------------------------------------------------------------------- 1 | package bolt_test 2 | -------------------------------------------------------------------------------- /bolt/user.go: -------------------------------------------------------------------------------- 1 | package bolt 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/gogo/protobuf/proto" 7 | "github.com/middlemost/peapod" 8 | ) 9 | 10 | // Ensure service implements interface. 11 | var _ peapod.UserService = &UserService{} 12 | 13 | // UserService represents a service to manage users. 14 | type UserService struct { 15 | db *DB 16 | } 17 | 18 | // NewUserService returns a new instance of UserService. 19 | func NewUserService(db *DB) *UserService { 20 | return &UserService{ 21 | db: db, 22 | } 23 | } 24 | 25 | // FindUserByID returns a user with a given id. 26 | func (s *UserService) FindUserByID(ctx context.Context, id int) (*peapod.User, error) { 27 | tx, err := s.db.Begin(ctx, false) 28 | if err != nil { 29 | return nil, err 30 | } 31 | defer tx.Rollback() 32 | 33 | user, err := findUserByID(ctx, tx, id) 34 | if err != nil { 35 | return nil, err 36 | } 37 | return user, nil 38 | } 39 | 40 | // FindUserByMobileNumber returns a user by mobile number. 41 | func (s *UserService) FindUserByMobileNumber(ctx context.Context, mobileNumber string) (*peapod.User, error) { 42 | tx, err := s.db.Begin(ctx, false) 43 | if err != nil { 44 | return nil, err 45 | } 46 | defer tx.Rollback() 47 | 48 | id := findUserIDByMobileNumber(ctx, tx, mobileNumber) 49 | if id == 0 { 50 | return nil, nil 51 | } 52 | 53 | user, err := findUserByID(ctx, tx, id) 54 | if err != nil { 55 | return nil, err 56 | } 57 | return user, nil 58 | } 59 | 60 | // CreateUser creates a new user. 61 | func (s *UserService) CreateUser(ctx context.Context, user *peapod.User) error { 62 | tx, err := s.db.Begin(ctx, true) 63 | if err != nil { 64 | return err 65 | } 66 | defer tx.Rollback() 67 | 68 | // Create user & commit. 69 | if err := func() error { 70 | if err := createUser(ctx, tx, user); err != nil { 71 | return err 72 | } 73 | return tx.Commit() 74 | }(); err != nil { 75 | user.ID = 0 76 | return err 77 | } 78 | return nil 79 | } 80 | 81 | func findUserByID(ctx context.Context, tx *Tx, id int) (*peapod.User, error) { 82 | bkt := tx.Bucket([]byte("Users")) 83 | if bkt == nil { 84 | return nil, nil 85 | } 86 | 87 | var u peapod.User 88 | if buf := bkt.Get(itob(id)); buf == nil { 89 | return nil, nil 90 | } else if err := unmarshalUser(buf, &u); err != nil { 91 | return nil, err 92 | } 93 | return &u, nil 94 | } 95 | 96 | func userExists(ctx context.Context, tx *Tx, id int) bool { 97 | bkt := tx.Bucket([]byte("Users")) 98 | if bkt == nil { 99 | return false 100 | } 101 | return bkt.Get(itob(id)) != nil 102 | } 103 | 104 | func findUserIDByMobileNumber(ctx context.Context, tx *Tx, mobileNumber string) int { 105 | bkt := tx.Bucket([]byte("Users.MobileNumber")) 106 | if bkt == nil { 107 | return 0 108 | } 109 | v := bkt.Get([]byte(mobileNumber)) 110 | if v == nil { 111 | return 0 112 | } 113 | return btoi(v) 114 | } 115 | 116 | func createUser(ctx context.Context, tx *Tx, user *peapod.User) error { 117 | if user == nil { 118 | return peapod.ErrUserRequired 119 | } else if id := findUserIDByMobileNumber(ctx, tx, user.MobileNumber); id != 0 { 120 | return peapod.ErrUserMobileNumberInUse 121 | } 122 | 123 | bkt, err := tx.CreateBucketIfNotExists([]byte("Users")) 124 | if err != nil { 125 | return err 126 | } 127 | 128 | // Retrieve next sequence. 129 | id, _ := bkt.NextSequence() 130 | user.ID = int(id) 131 | 132 | // Update timestamps. 133 | user.CreatedAt = tx.Now 134 | 135 | // Save data. 136 | if err := saveUser(ctx, tx, user); err != nil { 137 | return err 138 | } 139 | 140 | // Index by mobile number. 141 | if bkt, err := tx.CreateBucketIfNotExists([]byte("Users.MobileNumber")); err != nil { 142 | return err 143 | } else if err := bkt.Put([]byte(user.MobileNumber), itob(user.ID)); err != nil { 144 | return err 145 | } 146 | 147 | // Create a default playlist. 148 | if err := createPlaylist(ctx, tx, &peapod.Playlist{ 149 | OwnerID: user.ID, 150 | Name: peapod.DefaultPlaylistName, 151 | }); err != nil { 152 | return err 153 | } 154 | 155 | return nil 156 | } 157 | 158 | func saveUser(ctx context.Context, tx *Tx, user *peapod.User) error { 159 | // Validate record. 160 | if user.MobileNumber == "" { 161 | return peapod.ErrUserMobileNumberRequired 162 | } 163 | 164 | // Update timestamp. 165 | user.UpdatedAt = tx.Now 166 | 167 | // Marshal and update record. 168 | if buf, err := marshalUser(user); err != nil { 169 | return err 170 | } else if bkt, err := tx.CreateBucketIfNotExists([]byte("Users")); err != nil { 171 | return err 172 | } else if err := bkt.Put(itob(user.ID), buf); err != nil { 173 | return err 174 | } 175 | return nil 176 | } 177 | 178 | func marshalUser(v *peapod.User) ([]byte, error) { 179 | return proto.Marshal(&User{ 180 | ID: int64(v.ID), 181 | MobileNumber: v.MobileNumber, 182 | CreatedAt: encodeTime(v.CreatedAt), 183 | UpdatedAt: encodeTime(v.UpdatedAt), 184 | }) 185 | } 186 | 187 | func unmarshalUser(data []byte, v *peapod.User) error { 188 | var pb User 189 | if err := proto.Unmarshal(data, &pb); err != nil { 190 | return err 191 | } 192 | *v = peapod.User{ 193 | ID: int(pb.ID), 194 | MobileNumber: pb.MobileNumber, 195 | CreatedAt: decodeTime(pb.CreatedAt), 196 | UpdatedAt: decodeTime(pb.UpdatedAt), 197 | } 198 | return nil 199 | } 200 | -------------------------------------------------------------------------------- /cmd/peapod/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "flag" 7 | "fmt" 8 | "io" 9 | "io/ioutil" 10 | "os" 11 | "os/signal" 12 | "os/user" 13 | "path/filepath" 14 | "strings" 15 | 16 | "github.com/BurntSushi/toml" 17 | "github.com/middlemost/peapod" 18 | "github.com/middlemost/peapod/aws" 19 | "github.com/middlemost/peapod/bolt" 20 | "github.com/middlemost/peapod/http" 21 | "github.com/middlemost/peapod/local" 22 | "github.com/middlemost/peapod/twilio" 23 | "github.com/middlemost/peapod/youtube_dl" 24 | ) 25 | 26 | func main() { 27 | m := NewMain() 28 | 29 | // Parse command line flags. 30 | if err := m.ParseFlags(os.Args[1:]); err == flag.ErrHelp { 31 | os.Exit(1) 32 | } else if err != nil { 33 | fmt.Fprintln(m.Stderr, err) 34 | os.Exit(1) 35 | } 36 | 37 | // Load configuration. 38 | if err := m.LoadConfig(); err != nil { 39 | fmt.Fprintln(m.Stderr, err) 40 | os.Exit(1) 41 | } 42 | 43 | // Execute program. 44 | if err := m.Run(); err != nil { 45 | fmt.Fprintln(m.Stderr, err) 46 | os.Exit(1) 47 | } 48 | 49 | // Shutdown on SIGINT (CTRL-C). 50 | c := make(chan os.Signal, 1) 51 | signal.Notify(c, os.Interrupt) 52 | <-c 53 | fmt.Fprintln(m.Stdout, "received interrupt, shutting down...") 54 | } 55 | 56 | // Main represents the main program execution. 57 | type Main struct { 58 | ConfigPath string 59 | Config Config 60 | 61 | // Input/output streams 62 | Stdin io.Reader 63 | Stdout io.Writer 64 | Stderr io.Writer 65 | 66 | closeFn func() error 67 | } 68 | 69 | // NewMain returns a new instance of Main. 70 | func NewMain() *Main { 71 | return &Main{ 72 | ConfigPath: DefaultConfigPath, 73 | Config: DefaultConfig(), 74 | 75 | Stdin: os.Stdin, 76 | Stdout: os.Stdout, 77 | Stderr: os.Stderr, 78 | 79 | closeFn: func() error { return nil }, 80 | } 81 | } 82 | 83 | // Close cleans up the program. 84 | func (m *Main) Close() error { return m.closeFn() } 85 | 86 | // Usage returns the usage message. 87 | func (m *Main) Usage() string { 88 | return strings.TrimSpace(` 89 | usage: peapod [flags] 90 | 91 | The daemon process for managing peapod API requests and processing. 92 | 93 | The following flags are available: 94 | 95 | -config PATH 96 | Specifies the configuration file to read. 97 | Defaults to ~/.peapod/config 98 | 99 | `) 100 | } 101 | 102 | // ParseFlags parses the command line flags. 103 | func (m *Main) ParseFlags(args []string) error { 104 | fs := flag.NewFlagSet("peapod", flag.ContinueOnError) 105 | fs.SetOutput(ioutil.Discard) 106 | fs.StringVar(&m.ConfigPath, "config", "", "config file") 107 | return fs.Parse(args) 108 | } 109 | 110 | // LoadConfig parses the configuration file. 111 | func (m *Main) LoadConfig() error { 112 | // Default configuration path if not specified. 113 | path := m.ConfigPath 114 | if path == "" { 115 | path = DefaultConfigPath 116 | } 117 | 118 | // Interpolate path. 119 | if err := InterpolatePaths(&path); err != nil { 120 | return err 121 | } 122 | 123 | // Read configuration file. 124 | if _, err := toml.DecodeFile(path, &m.Config); os.IsNotExist(err) { 125 | if m.ConfigPath != "" { 126 | return err 127 | } 128 | } else if err != nil { 129 | return err 130 | } 131 | return nil 132 | } 133 | 134 | // Run executes the program. 135 | func (m *Main) Run() error { 136 | // Interpolate config paths. 137 | dbPath := m.Config.Database.Path 138 | filePath := m.Config.File.Path 139 | if err := InterpolatePaths(&dbPath, &filePath); err != nil { 140 | return err 141 | } 142 | 143 | // Initialize file service. 144 | fileService := local.NewFileService() 145 | fileService.Path = filePath 146 | fmt.Fprintf(m.Stdout, "file storage: path=%s\n", m.Config.File.Path) 147 | 148 | // Initialize AWS session. 149 | var awsSession *aws.Session 150 | if m.Config.AWS.AccessKeyID != "" && m.Config.AWS.SecretAccessKey != "" { 151 | session, err := aws.NewSession(m.Config.AWS.AccessKeyID, m.Config.AWS.SecretAccessKey, m.Config.AWS.Region) 152 | if err != nil { 153 | return err 154 | } 155 | awsSession = session 156 | } 157 | 158 | // Initialize TTS service. 159 | ttsService := aws.NewTTSService() 160 | ttsService.Session = awsSession 161 | ttsService.LogOutput = m.Stdout 162 | 163 | // Initialize Twilio service. 164 | smsService := twilio.NewSMSService() 165 | smsService.AccountSID = m.Config.Twilio.AccountSID 166 | smsService.AuthToken = m.Config.Twilio.AuthToken 167 | smsService.From = m.Config.Twilio.From 168 | smsService.LogOutput = m.Stdout 169 | 170 | // Initialize youtube-dl. 171 | urlTrackGenerator := youtube_dl.NewURLTrackGenerator() 172 | urlTrackGenerator.Proxy = m.Config.YoutubeDL.Proxy 173 | urlTrackGenerator.LogOutput = m.Stdout 174 | 175 | // Open database. 176 | db := bolt.NewDB() 177 | db.Path = dbPath 178 | if err := db.Open(); err != nil { 179 | return err 180 | } 181 | fmt.Fprintf(m.Stdout, "database initialized: path=%s\n", m.Config.Database.Path) 182 | 183 | // Instantiate bolt services. 184 | jobService := bolt.NewJobService(db) 185 | playlistService := bolt.NewPlaylistService(db) 186 | trackService := bolt.NewTrackService(db) 187 | userService := bolt.NewUserService(db) 188 | 189 | // Reset job queue. 190 | if err := jobService.ResetJobQueue(context.Background()); err != nil { 191 | return fmt.Errorf("error: reset job queue: %s", err) 192 | } 193 | 194 | // Start job scheduler. 195 | jobScheduler := peapod.NewJobScheduler() 196 | jobScheduler.FileService = fileService 197 | jobScheduler.JobService = jobService 198 | jobScheduler.SMSService = smsService 199 | jobScheduler.TrackService = trackService 200 | jobScheduler.TTSService = ttsService 201 | jobScheduler.UserService = userService 202 | jobScheduler.URLTrackGenerator = urlTrackGenerator 203 | jobScheduler.LogOutput = m.Stdout 204 | 205 | if err := jobScheduler.Open(); err != nil { 206 | return fmt.Errorf("error: open job scheduler: %s", err) 207 | } 208 | 209 | // Initialize HTTP server. 210 | httpServer := http.NewServer() 211 | httpServer.Addr = m.Config.HTTP.Addr 212 | httpServer.Host = m.Config.HTTP.Host 213 | httpServer.Autocert = m.Config.HTTP.Autocert 214 | httpServer.Twilio.AccountSID = m.Config.Twilio.AccountSID 215 | httpServer.LogOutput = m.Stdout 216 | 217 | httpServer.FileService = fileService 218 | httpServer.JobService = jobService 219 | httpServer.PlaylistService = playlistService 220 | httpServer.SMSService = smsService 221 | httpServer.TrackService = trackService 222 | httpServer.UserService = userService 223 | 224 | // Open HTTP server. 225 | if err := httpServer.Open(); err != nil { 226 | return err 227 | } 228 | u := httpServer.URL() 229 | fmt.Fprintf(m.Stdout, "http listening: %s\n", u.String()) 230 | 231 | // Assign close function. 232 | m.closeFn = func() error { 233 | httpServer.Close() 234 | jobScheduler.Close() 235 | db.Close() 236 | return nil 237 | } 238 | 239 | return nil 240 | } 241 | 242 | // DefaultConfigPath is the default configuration path. 243 | const DefaultConfigPath = "~/.peapod/config" 244 | 245 | // Config represents a configuration file. 246 | type Config struct { 247 | Database struct { 248 | Path string `toml:"path"` 249 | } `toml:"database"` 250 | 251 | File struct { 252 | Path string `toml:"path"` 253 | } `toml:"file"` 254 | 255 | HTTP struct { 256 | Addr string `toml:"addr"` 257 | Host string `toml:"host"` 258 | Autocert bool `toml:"autocert"` 259 | } `toml:"http"` 260 | 261 | AWS struct { 262 | AccessKeyID string `toml:"access-key-id"` 263 | SecretAccessKey string `toml:"secret-access-key"` 264 | Region string `toml:"region"` 265 | } `toml:"aws"` 266 | 267 | Twilio struct { 268 | AccountSID string `toml:"account-sid"` 269 | AuthToken string `toml:"auth-token"` 270 | From string `toml:"from"` 271 | } `toml:"twilio"` 272 | 273 | YoutubeDL struct { 274 | Proxy string `toml:"proxy"` 275 | } `toml:"youtube-dl"` 276 | } 277 | 278 | // NewConfig returns a configuration with default settings. 279 | func DefaultConfig() Config { 280 | var c Config 281 | c.Database.Path = "~/.peapod/db" 282 | c.File.Path = "~/.peapod/file" 283 | c.HTTP.Addr = ":3000" 284 | return c 285 | } 286 | 287 | // InterpolatePaths replaces the tilde prefix with the user's home directory. 288 | func InterpolatePaths(a ...*string) error { 289 | for _, s := range a { 290 | if !strings.HasPrefix(*s, "~/") { 291 | continue 292 | } 293 | 294 | u, err := user.Current() 295 | if err != nil { 296 | return err 297 | } else if u.HomeDir == "" { 298 | return errors.New("home directory not found") 299 | } 300 | *s = filepath.Join(u.HomeDir, strings.TrimPrefix(*s, "~/")) 301 | } 302 | return nil 303 | } 304 | -------------------------------------------------------------------------------- /context.go: -------------------------------------------------------------------------------- 1 | package peapod 2 | 3 | import "context" 4 | 5 | // NewContext returns a new Context that carries the authenticated user. 6 | func NewContext(ctx context.Context, user *User) context.Context { 7 | return context.WithValue(ctx, valueKey, contextValue{ 8 | user: user, 9 | }) 10 | } 11 | 12 | // FromContext returns the user stored in ctx, if any. 13 | func FromContext(ctx context.Context) *User { 14 | v, _ := ctx.Value(valueKey).(contextValue) 15 | return v.user 16 | } 17 | 18 | // contextValue is the set of data passed with Context. 19 | type contextValue struct { 20 | user *User 21 | } 22 | 23 | // contextKey is an unexported type for preventing context key collisions. 24 | type contextKey int 25 | 26 | // valueKey is the key used to store the context value. 27 | const valueKey contextKey = 0 28 | -------------------------------------------------------------------------------- /error.go: -------------------------------------------------------------------------------- 1 | package peapod 2 | 3 | // General errors. 4 | const ( 5 | ErrInternal = Error("internal error") 6 | ErrUnauthorized = Error("unauthorized") 7 | ) 8 | 9 | // Error represents a peapod error. 10 | type Error string 11 | 12 | // Error returns the error as a string. 13 | func (e Error) Error() string { return string(e) } 14 | -------------------------------------------------------------------------------- /file.go: -------------------------------------------------------------------------------- 1 | package peapod 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "regexp" 7 | ) 8 | 9 | // File errors 10 | const ( 11 | ErrFilenameRequired = Error("filename required") 12 | ErrInvalidFilename = Error("invalid filename") 13 | ) 14 | 15 | // File represents an on-disk file. 16 | type File struct { 17 | Name string `json:"name"` 18 | Size int64 `json:"size"` 19 | } 20 | 21 | // FileService represents a service for managing file objects. 22 | type FileService interface { 23 | GenerateName(ext string) string 24 | FindFileByName(ctx context.Context, name string) (*File, io.ReadCloser, error) 25 | CreateFile(ctx context.Context, f *File, r io.Reader) error 26 | } 27 | 28 | // IsValidFilename returns true if the name is in a valid format. 29 | func IsValidFilename(name string) bool { 30 | return fileIDRegex.MatchString(name) 31 | } 32 | 33 | var fileIDRegex = regexp.MustCompile(`^[a-z0-9]+(\.[a-z0-9]+)?$`) 34 | -------------------------------------------------------------------------------- /glide.lock: -------------------------------------------------------------------------------- 1 | hash: db89749bce704d892516cf94a338ae8af08f6674a8bbaf28c4421d0c3ae740d8 2 | updated: 2017-05-02T07:43:53.848838261-06:00 3 | imports: 4 | - name: github.com/boltdb/bolt 5 | version: 4b1ebc1869ad66568b313d0dc410e2be72670dda 6 | - name: github.com/BurntSushi/toml 7 | version: 99064174e013895bbd9b025c31100bd1d9b590ca 8 | - name: github.com/gogo/protobuf 9 | version: a9cd0c35b97daf74d0ebf3514c5254814b2703b4 10 | subpackages: 11 | - proto 12 | - name: github.com/pressly/chi 13 | version: 7765b0916fdc6791938554e9d53d39cae059ac8c 14 | subpackages: 15 | - middleware 16 | - name: github.com/subosito/twilio 17 | version: ef2f13504366093ed052627008181166612c646d 18 | - name: golang.org/x/crypto 19 | version: cbc3d0884eac986df6e78a039b8792e869bff863 20 | subpackages: 21 | - acme 22 | - acme/autocert 23 | - name: golang.org/x/sys 24 | version: c200b10b5d5e122be351b67af224adc6128af5bf 25 | subpackages: 26 | - unix 27 | testImports: [] 28 | -------------------------------------------------------------------------------- /glide.yaml: -------------------------------------------------------------------------------- 1 | package: github.com/middlemost/peapod 2 | import: 3 | - package: github.com/BurntSushi/toml 4 | - package: github.com/boltdb/bolt 5 | - package: github.com/gogo/protobuf 6 | subpackages: 7 | - proto 8 | - package: github.com/pressly/chi 9 | subpackages: 10 | - middleware 11 | - package: github.com/subosito/twilio 12 | - package: golang.org/x/crypto 13 | subpackages: 14 | - acme/autocert 15 | -------------------------------------------------------------------------------- /http/asset.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "mime" 5 | "net/http" 6 | "path" 7 | "strconv" 8 | 9 | "github.com/pressly/chi" 10 | ) 11 | 12 | //go:generate go-bindata -o assets.gen.go -pkg http -prefix assets -ignore "\\.go$" assets 13 | 14 | // assetHandler represents an HTTP handler for embedded assets. 15 | type assetHandler struct { 16 | router chi.Router 17 | } 18 | 19 | // newAssetHandler returns a new instance of assetHandler. 20 | func newAssetHandler() *assetHandler { 21 | h := &assetHandler{router: chi.NewRouter()} 22 | h.router.Get("/:name", h.handleGet) 23 | return h 24 | } 25 | 26 | // ServeHTTP implements http.Handler. 27 | func (h *assetHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 28 | h.router.ServeHTTP(w, r) 29 | } 30 | 31 | func (h *assetHandler) handleGet(w http.ResponseWriter, r *http.Request) { 32 | name := chi.URLParam(r, "name") 33 | 34 | buf, _ := Asset(name) 35 | if len(buf) == 0 { 36 | Error(w, r, ErrAssetNotFound) 37 | return 38 | } 39 | 40 | // Set headers. 41 | w.Header().Set("Content-Type", mime.TypeByExtension(path.Ext(name))) 42 | w.Header().Set("Content-Length", strconv.Itoa(len(buf))) 43 | 44 | // Write contents. 45 | w.Write(buf) 46 | } 47 | -------------------------------------------------------------------------------- /http/assets.gen.go: -------------------------------------------------------------------------------- 1 | // Code generated by go-bindata. 2 | // sources: 3 | // assets/logo-1024x1024.png 4 | // DO NOT EDIT! 5 | 6 | package http 7 | 8 | import ( 9 | "bytes" 10 | "compress/gzip" 11 | "fmt" 12 | "io" 13 | "io/ioutil" 14 | "os" 15 | "path/filepath" 16 | "strings" 17 | "time" 18 | ) 19 | 20 | func bindataRead(data []byte, name string) ([]byte, error) { 21 | gz, err := gzip.NewReader(bytes.NewBuffer(data)) 22 | if err != nil { 23 | return nil, fmt.Errorf("Read %q: %v", name, err) 24 | } 25 | 26 | var buf bytes.Buffer 27 | _, err = io.Copy(&buf, gz) 28 | clErr := gz.Close() 29 | 30 | if err != nil { 31 | return nil, fmt.Errorf("Read %q: %v", name, err) 32 | } 33 | if clErr != nil { 34 | return nil, err 35 | } 36 | 37 | return buf.Bytes(), nil 38 | } 39 | 40 | type asset struct { 41 | bytes []byte 42 | info os.FileInfo 43 | } 44 | 45 | type bindataFileInfo struct { 46 | name string 47 | size int64 48 | mode os.FileMode 49 | modTime time.Time 50 | } 51 | 52 | func (fi bindataFileInfo) Name() string { 53 | return fi.name 54 | } 55 | func (fi bindataFileInfo) Size() int64 { 56 | return fi.size 57 | } 58 | func (fi bindataFileInfo) Mode() os.FileMode { 59 | return fi.mode 60 | } 61 | func (fi bindataFileInfo) ModTime() time.Time { 62 | return fi.modTime 63 | } 64 | func (fi bindataFileInfo) IsDir() bool { 65 | return false 66 | } 67 | func (fi bindataFileInfo) Sys() interface{} { 68 | return nil 69 | } 70 | 71 | var _logo1024x1024Png = []byte("\x1f\x8b\x08\x00\x00\x09\x6e\x88\x00\xff\xec\x7c\xfb\x3b\x54\xed\xf7\xff\xde\xdb\x1e\xcd\x20\xc6\x59\x07\x19\xe4\x10\x62\x0a\x11\xc9\x2e\x1d\x88\xd0\xc1\xa1\x72\x98\xea\x11\xa5\x42\x39\x17\xb3\xa7\x72\xe8\x48\x45\x0a\x31\xa5\x22\xca\xa9\xa2\x9c\x66\x42\xa9\x1c\x2b\x3a\xc9\x29\x9a\x90\xf3\xe4\x3c\x66\x7f\xaf\x3d\xe3\x79\xbf\xdf\xcf\xe7\x4f\xf8\x5e\xcf\x0f\xcf\x75\x3d\xed\xd9\xee\x7b\xdd\xeb\x5e\xeb\xb5\x5e\x6b\xad\xfb\xde\xe7\x1d\xed\xb7\x2e\x14\x5b\x2c\x06\x00\xc0\x42\x1b\xeb\x4d\x3b\x01\x00\x06\xf0\xff\x88\xa2\x00\x00\xd0\x95\x75\xcf\x02\x00\x00\x9e\xdc\xb9\x75\x23\x90\xdb\xb0\xb4\x0f\x00\x10\xc0\x66\xd3\x86\xdd\xa1\xe0\x40\xdb\x82\xe7\x5e\x4e\x61\x54\x7e\xd0\x6c\xf9\xc7\x5f\xf6\xc3\x51\x8b\x44\x95\x94\xa2\x6f\xdd\x39\xab\xbd\x79\x87\x28\xf9\x5e\x16\x71\x0b\x77\x05\x74\x41\xec\xd3\x4d\xd6\x16\xee\xc3\x1d\x1b\x0a\x5e\x5d\xbb\xa9\x74\x59\xc3\x47\x86\x50\xe7\xdd\x5c\x2d\xb3\x50\x73\xe7\x4a\x34\x8b\xc9\x8c\xa5\x6c\xef\xa3\xf0\x26\xd7\x57\x8d\x32\x0e\x17\xa7\x9e\x9c\x68\xf7\xcd\xeb\xa8\x3f\x99\x7e\x6b\xad\x57\xde\xe4\x5c\xf9\xc5\x75\x15\xc3\xed\xde\xe9\xb9\x7d\x9e\x11\xa3\x19\x2f\xa5\x40\x08\x26\x92\x29\x08\x0a\x08\xff\xc7\xd1\x97\x31\xb4\xd7\x5b\x6b\xed\x3f\x9f\x22\x4c\x55\x1a\x40\x53\xff\xdf\x27\x28\xc0\x80\x61\x09\x40\x46\xbb\x50\x8e\xf4\x8f\xe7\x28\x88\x5a\xfc\xef\x6b\x20\x04\xcb\x90\xa9\x80\x35\xf3\xcd\xce\xe5\xff\x7c\x2e\x0a\x4c\x00\xff\x9c\xde\x1a\x61\x03\x14\x86\x9a\xed\xd5\x7f\x3e\x57\x02\xbe\x88\xfc\x53\x1e\x0a\x08\x8a\xa0\xf0\x05\xbd\xcd\xff\x94\x93\x06\xd0\xa4\xfe\x15\xf2\x5f\x21\xff\x15\xf2\x5f\x21\xff\x15\xf2\x5f\x21\xff\x15\xf2\x5f\x21\xff\x15\xf2\x5f\x21\xff\x15\xf2\x5f\x21\xff\x15\xf2\x5f\x21\xff\x15\xf2\x5f\x21\xff\x15\xf2\x5f\x21\xff\x15\xf2\x5f\x21\xff\x3f\x16\x92\x69\xc2\x58\x7a\xdf\x1a\xff\x67\x0a\xc9\xae\x4f\x59\xcc\x70\x46\x33\xaa\x6b\x20\x64\x0b\x76\x2f\x1e\x2b\xcd\xcb\xd1\xfa\x13\x32\x79\x7c\x66\x30\x7c\xa0\x71\x28\x8d\xda\xd4\xf6\x39\xf4\x38\xe5\x54\xae\x01\x56\xef\xf1\x2b\x06\x60\x84\x12\x22\xc3\x87\xfd\x2a\xc3\x2c\xb1\x13\x65\xa4\xd8\x54\x7c\xc8\xd7\x2b\x69\x00\xf5\xb8\x99\xdf\x72\x04\x45\x9e\xc1\xd1\x25\xb3\x55\x3f\xee\x2c\xda\x3f\x4a\x9b\x30\x37\x32\xd4\x53\x4a\xad\x78\xf8\xd2\xcf\x68\x46\xb6\xca\xff\x53\xaf\xd4\xa4\x17\xfd\x10\x36\xd4\x77\x02\x74\x7d\x96\xad\xb5\x18\x7a\x45\x74\x3b\xd5\x58\xdf\x35\x68\x59\xca\xbc\xf9\xea\xd1\x46\x5c\xa2\x44\x59\x2a\x90\xe5\x66\x03\x6f\x42\x01\xc6\x63\xf1\xc0\xa8\xdc\x7c\x23\xdf\x81\x24\xc9\xfa\x23\x46\x33\xb6\x85\x2c\x83\x77\xc9\x73\xbc\xc6\xb2\xbe\x74\x0b\xaa\xd7\x6f\xbf\x3f\xda\x20\x04\xaf\x66\xfc\x1e\x2e\x37\x5e\xf7\xbb\x90\x5b\x99\xb7\xec\xf9\xd9\x05\xf8\x1a\x97\xeb\xd0\x80\x78\x9f\xae\xfb\xdb\x51\xe4\x19\xe1\x81\xb7\x77\x7f\xc8\xad\xa7\x94\x47\x69\x4f\xa9\xb3\x6d\x7f\x7e\xd9\x5f\xe7\xc4\x90\x2c\xe6\x5a\x24\xdd\x02\x61\xe2\xee\x9d\x9d\x8a\x93\x73\x6b\x96\x06\xf5\xbd\x48\xc6\xb6\x39\x6f\x75\x84\x89\x64\x6d\x7b\x36\xc0\x34\xf5\xd5\x95\xa6\x20\xb4\x1c\xc8\xdc\xfe\xe6\x92\xe0\xf4\xc2\x9e\x82\x68\x4b\xdf\x09\x2d\x03\x09\xae\xe8\x83\x63\xe3\xcf\x96\x2d\x47\x50\xb6\x2b\x21\xb7\x74\xf2\xc6\x48\x5d\x6d\x7b\xc6\xb4\x8a\x6e\xea\x67\x59\x0a\xc2\x8c\x86\x44\x01\x63\xbd\xec\x04\x22\x39\x2b\x17\x89\x54\x2f\xb7\x78\xa0\xb7\xf0\x94\x43\x9b\xea\x80\xc5\x4a\xcc\x5d\x7a\xec\xc9\x5c\x24\x91\x4c\x69\x22\x31\x96\x14\x1a\x62\x61\x5c\xac\x76\xd6\x6f\xb8\xba\x4b\xf9\x3a\xae\x47\x35\x59\x2a\x10\xfb\x88\x56\xa9\x81\x50\xf4\x19\x3c\xfb\xd9\xb0\xeb\x1d\x3e\x13\x4f\x1e\xd0\x8f\x05\x3f\xb0\x64\x7d\xac\x50\x81\x60\x62\xe0\x5e\x8a\x1f\xdf\xe4\x50\xd2\x9c\x2f\xdf\x7c\xe5\xe0\x14\x4c\x24\xc7\x6e\x65\x63\x62\x97\x33\x9d\xd5\x34\x10\x0a\xc4\xe0\x49\x60\xe0\x47\x97\x28\x2b\xef\x81\xc5\x92\x69\x05\xb6\xae\x6b\x03\x8e\xc3\x3b\xc4\xaf\x02\x8c\x12\x52\x4f\x3d\x66\xa8\x1f\xe6\xf2\xbc\x8f\x61\xfe\xfb\x46\xf2\x69\xdc\x20\x98\x04\x50\x04\x55\x18\x29\x4b\x04\x40\xe8\x05\x7b\xec\xce\xd7\xae\xa6\x5b\xa5\xd1\x56\x87\x1e\x35\x24\x63\xca\x55\xf1\x4b\xc6\xbf\x2a\xc1\xc4\x04\xed\xa6\x75\xdc\xbc\x69\x83\x02\xe2\x0d\x4d\x04\x45\x36\x13\x24\x00\x9d\xbd\x4a\xfb\xa5\x29\x8e\xee\x8c\x3b\x43\x7c\xff\xce\x91\xec\x2d\x55\xe9\xe9\x7d\x9e\x6b\xf6\x3b\x07\x9d\x95\x5f\x04\x42\x36\x24\xa3\x14\x7a\xd2\x9c\x96\x53\xf0\x5b\x19\x0a\x42\x6b\x86\x44\x81\x8c\x07\x63\x1b\x21\x78\xa7\xc2\xcd\x78\xe3\x39\x87\x94\x67\x9a\x4b\x0d\xbd\xad\xba\xc7\xf8\x2f\xd3\x2a\x12\xd6\xf6\xd8\xa3\x08\x81\x60\x84\xc9\xeb\x5d\x88\x27\x92\x29\x8e\xab\x40\x11\xf4\x82\xfc\x26\x53\x98\xb8\x7b\x2b\x2d\xbf\xd4\x60\xe9\x0d\xf7\x6f\xf4\xba\x54\x8b\xaa\x5d\xde\x43\x0a\x9a\xcf\x9f\xb2\xcc\x5e\x49\x93\x40\xc8\x81\xfc\x7c\xb4\xa2\xf5\xfb\xc8\x30\x49\x8d\xbf\x01\xb7\x9c\x7d\xb2\x54\xe0\x4b\xf2\x85\x1a\x22\xd9\xda\x99\xc9\xca\x31\x59\xba\xfa\x64\x50\xb7\x1d\xd6\x65\xf8\xf9\x9c\x4d\xf9\xd9\xb7\x7b\xad\x53\x01\x86\x29\x3c\xc6\xc1\x62\x6d\x1b\x03\x8a\x31\xd3\x77\x1a\xdb\x20\x98\x98\xa0\x43\x03\x1c\x75\x58\xaf\x48\xe4\xac\x0b\x6c\xfe\xd7\xfa\x59\xfb\xe4\x62\xcf\xd0\xd7\x03\xcf\xf6\x6f\x1b\x76\x74\xaa\x94\xc1\xed\xa9\x48\x77\xe4\xfa\xa9\xe3\x92\x8d\x25\xbc\xf2\x64\x6c\x50\x27\xec\x1a\xbe\x5b\xc9\x90\x28\xb0\x9d\x44\x97\xa1\xc4\xa7\xb2\xeb\x2a\xde\x76\xbd\xe0\x4e\x2e\xd4\x32\x30\xa5\x0c\x50\xf7\xdb\x9f\x98\xb6\xbd\x09\x30\x9e\x13\xc6\x0c\xb1\x64\xec\xb9\xaf\x76\x38\x04\x13\xe5\x56\xd2\x00\xc7\xa7\xcf\xc7\x89\x64\x6d\x27\xa6\x6e\x60\x54\xa6\xc4\xf0\x52\xfb\x3d\xfe\xb4\xd0\x99\x47\x6d\x56\x9e\x21\x0e\x0f\x47\x5d\x71\x6f\xea\x96\xf6\xff\x8a\x85\x36\x97\xc5\xe0\x73\x48\x00\x03\x20\x04\x5f\x23\xdf\x34\xa1\xae\x34\x6f\x65\x79\x9a\x86\xf1\xf9\xfb\x47\x9b\xea\x96\x9f\xb8\xb1\x1c\x84\xc6\xe5\xfc\xab\x58\xe1\x9f\x9c\x9c\x71\x9b\xdd\xc8\x06\x98\x44\x2f\x5f\x32\xc5\x31\x99\x5d\xf1\xd1\x39\xda\x50\x41\xe5\xdb\x94\x18\x2b\x2d\x77\xe8\xc3\x8f\x7c\x2a\x08\xc1\xdf\x54\x47\x6a\x67\xfd\xa4\x1c\x58\x3d\xb7\xbb\x1e\x2c\x18\x3a\x5a\xd3\xb5\xf4\xac\x00\x37\x64\xa9\x80\xf5\xa3\xce\x0f\x1a\xf1\x67\x90\x90\xe0\x5b\xd8\x8e\x27\x9f\xa3\x92\xf4\xc3\x8e\xb8\x9a\x04\x1d\x16\x5b\x51\x7b\x15\x87\x20\x81\xd2\x1a\xde\xf3\xb4\x1c\x7d\x57\xc8\x50\x10\xe6\x2e\x48\x14\xb8\x7d\x2d\xc8\x0d\x82\xc5\x14\x6f\x7e\x31\x24\xa7\x25\xb9\xff\xa0\x7f\xfb\x61\xe5\xc6\xf1\x7c\x5e\x41\x20\x91\x7b\xef\x21\x4f\xa6\x59\xa3\xfc\x56\xb3\x17\x97\x0e\xca\x52\x10\x14\xa9\x27\x48\x00\x47\x3c\x68\x7d\xd2\xd6\x1b\x68\xf6\x58\xb3\x7f\xff\x8f\xb3\x55\xe2\x0a\xeb\x56\xd8\x0d\xa6\xc4\x6f\x7c\x77\x62\x39\x42\x51\x05\xeb\xe9\x1b\x5f\xdd\xd0\x01\x21\xd8\x4c\x8f\x06\x34\xed\xa7\xc5\x68\x21\xcc\x93\x90\x6e\x61\xde\xd0\xa9\x57\x1f\xaa\xaa\x02\x4e\x4e\x8a\xcd\xb5\xf1\x3b\xf6\xfc\xba\xa7\x7c\x0b\x60\x14\xc2\xb9\xac\xc9\x4f\xfe\x75\x5e\xc3\xa4\x06\x5e\x92\x13\xf8\x1b\x17\xcc\x03\xe2\x2b\x9e\x2c\xef\x5f\xb1\x04\xb2\x21\xea\xbd\x1d\xfa\x81\x25\x7b\x2d\xa9\x22\x71\x27\x24\x8f\x0f\x24\x6d\x7a\xab\x61\x43\xb1\x61\xf0\xea\xe8\xed\xbc\x8e\xce\x42\x6c\xf9\x6f\x83\xc3\xb8\x4b\xe1\x9a\x93\xcd\xbe\x62\x83\x22\x5b\x61\x05\x49\xc9\x98\x27\x49\x16\xea\xa9\x46\x7e\xac\xa6\xd6\xb6\x8f\x57\x8d\x97\x26\x00\x20\xe4\x44\x7e\x93\x82\x5d\xfe\xba\x10\x33\xc2\x9c\xb2\xa2\x95\x70\x2b\x55\x01\x45\xd0\x99\xe6\x9d\x9a\xf0\x11\xdd\x78\xfa\x6b\xec\x91\x97\x83\x54\xa3\xa7\x3f\xd3\x76\xce\x77\x9f\xd7\x79\x69\x4a\x93\x5c\x13\xcf\x44\x83\x67\x83\x1b\xe6\xa0\x02\x15\xf0\x29\xce\xea\x96\xa6\x34\xad\x61\x2c\x8a\xb8\xbe\x38\x7d\x94\xde\x55\x79\xb6\xd2\xd1\x81\xde\x7a\xe5\x37\x02\x13\xc9\x59\xd9\x28\x87\x95\x9d\x28\xad\x08\x42\x70\xbf\x2a\x0d\x70\x8c\x33\x7b\x77\x06\x79\x0c\x6b\x06\x4e\xc6\x24\x79\x9b\x4c\x9a\x47\xbc\xbd\xa3\x8a\x1d\xb1\xb4\xc4\xe1\x7b\x03\xc5\x8f\x2f\x85\xb5\x9b\x57\x6e\x97\xfa\x7e\x6b\x63\xc9\x09\x1c\xf0\x19\x32\xb0\x04\xb0\x0d\x29\x5e\x0e\x42\xad\x0a\xa6\x91\x43\xed\x5d\xdb\x75\xaf\x90\xb8\x0b\x32\xfd\x66\x37\xa7\xa9\xe2\xfa\xc7\xf7\xf0\x8d\x6d\x63\x0b\xef\xf3\xa3\xdc\x0f\xf8\x93\x16\x58\x02\xc8\x76\x96\x1e\x25\x5b\xef\xa6\xcd\x25\x78\x75\x0d\xe6\xd8\x4b\x2c\x79\xa0\x6f\xbb\x1b\xf9\x06\x13\xef\xe9\x34\x1d\xe5\xd3\x03\x3a\xfc\x76\x69\xc4\x9b\xe0\x88\x2a\xb7\x9c\x06\x38\x92\x9f\x7a\x11\xc9\x5f\x4a\xd8\xdf\x07\x44\xe9\x7e\x7b\xc3\x8e\x9a\xaa\x9a\x73\x3b\x5a\x5a\x9e\x8d\x2b\x86\xe1\x5a\x32\x96\x9f\x6a\x99\x0b\x31\x6a\xe0\xf9\xf2\x7d\xf7\xd2\x0e\x4b\x53\x10\xe6\x01\x48\x14\x90\xd7\x8d\xf5\x27\xde\x53\xa3\x36\xb5\x2d\x69\x70\x3d\xed\x1a\xea\x15\x1c\x42\x4f\xe7\x3a\xdc\xef\x1f\x5b\x8e\x74\x8e\x42\x61\x5c\xd6\xe8\x87\x93\x67\x38\xd8\xa8\x0e\xdf\x1e\x57\xd7\x6d\x39\x2a\xd0\x7b\x2f\xf7\xa0\x2c\x25\xdf\x89\xe1\x5d\xb7\x77\x49\x7b\x51\xf7\x28\x6e\xf5\x8f\xd7\x0f\x6f\x80\x60\x62\xb3\x7b\xe7\xaf\xc9\xf6\xc5\x57\x5e\xf0\xd6\xd5\xd3\xdb\xc4\x57\x5a\x42\x30\x71\xb7\x1d\x1b\x60\x1e\x20\x48\x4b\x32\xcc\xc4\xeb\xde\xea\x4f\xd2\x6b\xb2\xbd\x63\x32\x38\x8f\x72\x4e\x26\x9f\x50\x47\x50\x76\x30\xee\x8a\x35\x1e\x7c\x8f\x7a\x2c\x38\x71\x9d\x60\x57\x6e\x93\xa9\x40\xef\xb9\xec\x83\x32\x94\xfc\x28\xf6\x5b\xae\x14\xd1\x6f\xcf\xf4\x23\xea\x33\xbf\x4a\x85\xc6\x30\xfc\xf7\x87\x44\x23\x4b\x2e\xc6\x9d\x3d\xbe\x5d\xc3\x2b\x4c\x06\xb7\xe1\x28\x58\x02\xd0\xd9\x2d\x71\x87\x9c\x95\xc1\x2e\xbc\xb3\xa7\xab\xdc\x3c\x7d\xa9\xa4\x92\x87\x53\xcd\x9f\x1e\x3c\x5a\xed\x10\xf7\xe1\x6f\xdd\xfe\x33\x0a\x8f\xb5\x4d\xaa\xa0\x08\x2a\xf9\xd2\x4c\x8f\xd1\x4c\x08\x2f\x29\xa9\xb2\x7d\x92\xc2\x2b\x3f\x34\xfd\x9a\xda\xfc\x31\xdd\x2c\x86\x47\x22\x5b\xbb\x75\x3e\xa8\x19\x9a\x18\xae\xa3\xff\x58\xb2\x61\x06\x77\xe0\x3d\x6c\x80\xd9\x03\xb9\xa6\x21\xca\x84\x4f\x76\x1e\x4b\xdb\x93\x6e\x8f\x7b\x1a\x7a\x05\x28\x7f\xbc\x2e\x8b\xc7\xd8\xe5\xf1\x26\xd8\x12\x03\x0b\x13\x1f\x7e\x4d\x35\xe7\x95\x60\x0e\x9c\x43\x48\x32\xcd\x96\x33\x76\x13\xc6\x9e\xf0\xf3\x3c\x82\x37\x99\x9e\xfa\x6a\xb8\x91\x1f\x22\x30\x13\x77\x8a\x1f\x3f\x3d\xe0\x6d\x69\xe5\xc2\xc9\x8e\x1b\x66\x51\x3c\x9c\x40\x30\x21\x7c\x3f\xb4\xcf\x90\x89\xcd\x1b\x68\x36\x13\x89\x91\x2a\x6d\xe3\x0e\x55\x69\xbb\x92\x3e\xab\x2a\xaf\xc5\x7f\xf7\x62\x70\xbc\xb0\x64\xec\xc6\x0a\x65\x01\x59\xf8\xb6\x9c\x06\x34\x69\x5a\xc7\xa8\xc6\x17\xb2\xcf\x73\x27\x48\xc3\x6e\xe3\xa9\xd4\x17\x21\x54\x2f\xd1\x17\xb2\x14\xa4\x33\x0e\xaa\xe7\x60\xe1\x21\xb3\x58\xc6\x79\x16\xce\x2f\x90\x69\x48\x14\xf8\xf9\x19\x81\xe1\xbb\xf2\x37\x3f\x98\xcb\xb5\x97\x7f\xb4\xe9\xfa\xf2\xe2\xd4\x96\x94\xb3\x6c\x12\x99\xd2\x74\x1c\xac\xa7\x97\x6e\x56\x8e\x13\x0c\xad\x41\x03\x9a\xf4\x6d\x6a\x54\xe3\x6f\x0a\xf0\x2a\x6e\x60\xa6\x32\xdf\xdf\xdf\xb4\xa0\xee\xbe\x2d\x0a\x30\x8e\x89\xf3\x66\x18\xe6\x55\x57\xbf\x2e\xc4\x52\xa7\x3b\x8a\xae\x7a\x0b\xe2\xd5\x3a\x50\x04\x1d\x1b\x78\x6c\x47\xd9\xc6\xe0\x71\x22\xf3\x3c\x23\xff\x0a\x7d\xf5\xa1\xca\xaa\x78\xc9\xdc\xe6\x3d\x56\x1b\x64\x29\x4d\x8b\x18\x65\xd3\x1d\xa1\x6f\xd7\x26\x63\x0d\x23\x66\x38\x94\x10\x9f\x6e\x60\x03\xcc\x85\xb1\x92\x1b\x28\xf2\x8c\x30\x91\x8f\x95\x35\x9f\x53\xfb\xb7\xd0\x1f\xca\xb9\xa5\xc1\xc4\x4b\x78\x34\xe5\x96\xce\x61\x3f\x2a\x3a\xae\xcc\x58\x78\x08\x28\x11\x7c\x41\x81\x0a\x68\xef\xfd\x1e\x0e\xab\xc9\x7f\x31\x28\xed\xc1\x86\x3f\x97\x99\xd6\xf5\xa7\xcb\x1c\x16\xad\xc4\x03\xa0\x37\xbc\x38\x69\xce\xf3\x44\x2e\x87\x65\x58\x76\x65\x1b\xfe\x17\xc7\xe4\xa8\x80\x8f\xfa\x03\x2b\xe9\xac\x1c\xf6\xe1\x8b\x52\x24\x3f\x8f\xd1\x47\x4d\xad\x6d\xbb\x3e\xcb\xfd\xdc\x8c\x02\x8c\x00\x92\x51\x0a\xf6\x7b\xa8\xd4\xcb\x6d\x78\x52\x53\xc0\xc4\x1c\xef\xa1\x20\x72\xf5\xdb\x27\x35\xf2\x2d\xf6\xb9\x21\xfe\x48\x85\xbb\x7d\x40\x12\xf6\xa6\x5f\xd3\x0d\x82\x2f\x48\x37\xf1\x14\xbb\x8f\xca\xe3\x2f\xfa\xa4\xa3\x20\x32\xab\xa7\x7c\x1e\x79\x0c\xdf\x90\xaa\x97\x4c\xff\x32\xa7\xb1\x3f\xc9\x46\xfc\x48\x62\xd2\x5f\xb8\x01\x48\x82\xf5\x74\x73\x39\xe5\xb3\xff\x94\x9a\x1c\xfa\xad\xce\xf8\x72\xde\x54\xca\x07\x73\xb9\x0d\x4f\x37\xe2\xac\xac\x00\x32\xb1\xf4\x77\x31\x5d\x3d\x5b\xc7\xaa\xe5\x5f\x0c\x2a\x5e\x2c\xd0\x8c\x3e\x0d\x68\x2a\xb9\xa3\x4e\xce\x4a\x64\xfb\x26\xf9\x49\xe4\xed\x29\x5a\x15\x18\x14\xd4\xa2\x63\xf0\x16\xd7\xb5\x32\x64\xd2\x1e\xb2\xcf\x74\x62\x8c\x6b\x84\x0d\xb2\x03\x25\x70\x81\xb4\xdd\xd8\x00\xd3\xff\x84\xb8\x4c\xd6\x03\xc4\xd3\xc5\x61\xaa\xb3\xdf\x92\x54\x53\xdf\x96\x93\x0c\x5e\xb8\x00\x80\x90\x86\x78\x8f\x37\x9f\xcb\xc1\xbc\x27\x4b\xba\x3c\xdf\xde\xcf\x5f\x87\x4f\x73\x69\x17\x1b\xa0\xa5\xfa\xb7\x6f\x61\xda\x43\xbb\x4e\x69\x9d\x73\x18\x8c\xdc\xfb\xc5\x6f\xe6\x4a\xd0\x36\x5b\x7d\x7c\x8d\x29\x48\x77\x21\x66\xdb\xf8\x9e\x57\xbc\xbd\x99\x21\xd8\x65\x09\x50\x04\x5d\xdd\xfc\xd8\x86\xb9\x11\xb2\x8d\x3e\x14\xcd\xfd\x5c\xa1\xf7\x28\xd5\xb4\x78\xe3\x0e\x52\x14\x00\x42\x2d\xd2\x4d\x3c\x97\x4c\x05\x35\x55\x1c\xf6\x3e\xc1\x12\xc0\x5e\x2e\xf7\x38\xb4\x43\xfc\x51\x7a\xa1\xde\xe9\xf0\xc1\xbe\xfd\xce\x58\x42\x16\x97\x8c\xcf\x6d\xa6\xc1\x9c\x1e\x4e\x78\xd4\x2d\xf0\xde\x45\x90\x28\xe0\x95\xb5\xef\x3a\x42\x80\xd7\x34\x7c\xcb\x08\xc8\xc4\xee\xa4\xf1\xc5\x36\xa9\xc3\x38\x7c\x38\x74\x9a\x7c\xf5\xe6\xc7\x95\xfc\x71\x4b\x9a\x73\xc9\x33\x52\x53\xc3\x07\x0f\x10\x53\x02\x2e\xc9\x36\xb4\x12\xab\xf5\xfc\xbf\xfd\xb0\xaa\xf4\xcc\x5d\xa7\x1f\x18\x12\x54\x93\x1a\xfd\x05\xb7\xe6\x55\x8c\xb2\x69\xcb\xd5\xbe\x7c\x7a\xf8\xb0\x9f\x59\x51\xb4\x88\xac\x80\xaf\xdf\x25\x48\x00\x17\x14\x8a\x97\x31\xae\x11\xda\x4b\xba\x47\xbb\x9e\xbe\x8a\xb9\xb1\x34\xea\x27\x89\x8c\xf3\xa5\xb2\x69\xfa\x8c\xc6\xb0\x98\x25\x6b\xf8\x53\xbf\xe8\x8a\xd5\x02\x63\xa0\x4a\x82\x22\x68\x00\x33\x4c\x9c\xa1\x44\x28\xd9\xe8\x12\xda\xd8\xd0\x28\x9e\xa9\x67\x4a\x79\x3d\xf2\xca\x0e\x05\x18\x1b\xe1\x64\x6f\xbe\x31\x16\xe5\xbf\xef\x1c\xbe\xcd\x62\xf8\x36\x2b\x7d\x3f\x01\xbf\x23\xf7\xbe\xd5\x1f\xc7\x9a\xfa\x15\xcf\x75\x73\x86\x9c\x5e\x8f\x7d\xc2\x37\x5a\x0e\xaa\xe7\x74\x78\x61\x3b\x12\x6c\xe3\xff\x39\xf6\x4d\x78\xa6\x24\xbc\xb2\x63\x70\xe2\x87\x7f\xf5\xc0\xb3\x55\x51\x2b\x9d\x71\x50\xd7\x89\x37\xc1\xb6\xba\xcf\x1c\x4f\x9f\x36\xd8\xdd\x76\x40\x60\x9a\x4f\x51\x10\x79\xff\xe3\x93\xba\x23\x93\x4d\x0b\xf1\x9c\xea\x19\xca\x8c\xb2\x3a\xf4\x48\xdf\xdf\xc9\x56\x9a\x82\xf3\xd2\x43\x5c\xec\xfa\x29\xbf\x2b\xba\xfe\xef\x65\x04\xeb\x5d\x85\x2b\x49\xba\xae\x96\x54\xad\x46\x7d\xe8\x4d\x39\x1d\x3c\x68\x5b\x15\xf9\x31\xe4\x92\xc6\x5f\x01\x78\x40\xf6\x67\xec\x5b\x36\xe5\xde\x5d\xc7\xe2\xb0\xfc\xc3\xc5\x05\x1b\x41\x82\x44\x01\x0d\xe6\xbe\x6b\x48\x26\xe1\xc1\x84\x3d\x31\xbd\xa3\xbb\x2e\x3f\xb2\xbf\xd0\xde\xe0\xa7\x80\xcf\x3a\xe3\x3e\x99\x37\xf3\x91\xb7\x75\xdf\x46\x06\xae\x53\xb6\x12\x41\x02\x78\x4d\xe4\xfc\x84\xb3\xf5\xa9\x2b\x7d\xf9\x06\xae\x74\xe7\x2f\x41\x63\x77\xb3\x63\x74\xd6\xe3\x04\x4e\x33\xde\x04\x8b\xf1\x1c\xb3\xf7\x1c\x77\xd9\x4b\xd0\xb3\xc3\x3d\xd2\x8b\x18\x98\x54\x90\x65\x4c\x22\xf7\x5e\x62\xf7\xa4\xbd\x1c\x69\xfc\xed\x91\x3b\x3d\x2c\x91\x97\x21\x43\x71\x94\x65\x6c\x4f\x9a\x4b\xcf\x99\xce\xb3\xb1\xb8\x2f\xe0\x8d\x19\x8a\x54\x40\xa9\x3c\xd1\x45\x26\xeb\x29\xdb\x31\x84\xde\x6d\x88\x9d\xb2\xdb\xb3\xb7\xcf\x94\xb0\x15\x05\x18\xab\x09\xc9\xf5\xd8\xb3\xe3\x53\x2e\x87\x16\x3d\xda\x8e\xbf\xad\x41\x52\x02\x12\x60\xbf\x3d\xd0\xa0\x5c\xef\x70\xff\xb1\xef\x83\xf4\x8e\x3e\xdd\xd6\x30\xd7\x1a\x35\x04\x45\x1e\xc3\x46\xd8\x33\x9d\x4c\xdc\x39\x11\xda\x52\x48\x14\xe8\x46\xb8\x0a\x90\x93\x4c\x6f\x5f\xbf\xa4\x4a\xeb\xe0\xe6\xae\xce\x45\xa5\xbb\x57\xe8\x15\xe0\x56\xbe\x13\x4a\xc6\xb6\x25\x5a\x51\x05\x5a\xbf\x86\x82\x88\xcd\xc8\x0d\x8d\x26\x49\x46\x6e\x93\xfc\xfa\xa7\x33\x84\xae\xd8\xaf\xbe\xad\x01\xa6\x35\xb8\xc6\x56\x12\x8e\x47\x50\xb1\x6c\xd6\xc9\xb3\xb5\x7c\xdf\x3d\x1e\x36\xd2\x02\xad\xc0\x12\xc0\x6b\xf4\xe7\x7d\x38\x5b\x73\x64\xe8\xb6\x83\x18\xee\x87\x47\xf6\x9f\xc7\x79\x63\xa2\xf4\x97\x66\x1e\x6b\x1f\xd7\xf3\xd6\x05\xa2\xc0\x6a\xde\xc9\x51\x01\xed\x5d\x1d\xdf\xe0\xbd\xe4\xd0\xdf\x57\x6a\x62\x7e\x19\x98\xe6\x47\xa9\x87\xea\xe2\x74\x63\x65\x7e\x5f\x21\x16\x12\xa3\xd2\xc1\xfd\x1d\xae\xa5\x63\x2e\xb4\xc9\x6d\xa0\x08\xba\x63\x63\xd8\x22\x86\x0e\x29\xf5\x45\xcf\x43\xdc\x8a\x9d\x57\x2c\x9f\x97\x7b\xfa\x16\x96\x61\xf0\x91\xe7\x92\xad\x7d\x46\xe0\x84\x1f\x08\x12\xc0\x36\x03\x06\x40\xbc\xb4\xbd\xb3\xf7\xeb\xef\xaa\x14\xe7\xa5\xe6\x45\x26\x61\xc6\x38\xb9\x94\xf1\xf1\xc0\x83\xdc\xc6\xcd\x49\x02\x34\xbe\xb4\x8d\x0d\xd0\x76\xdb\x79\x6c\x62\x2e\x86\x4e\xd9\xbb\xb7\x3f\xef\xf3\x74\x71\x71\x31\x86\x64\x08\x20\x04\xeb\x68\x32\xa7\xf3\x5c\x37\x9c\xb3\x12\xe4\x81\x32\x54\x20\x6b\xf7\x5a\xd8\x8a\xd6\x0a\xd9\xc6\x1c\x8a\x72\x18\x8e\xdc\xa2\x34\xd0\xd7\x3a\x71\x71\x9d\x15\x0a\x30\xce\x11\x70\x14\xee\x2f\xc4\xce\x15\xa3\xcd\x78\x68\xa3\xc1\xa0\x08\x2a\x8d\xae\xdb\xce\x14\x83\x3e\x86\x6b\x9d\xc3\x51\xa3\xae\x37\x7d\x77\x94\x7f\x35\x2e\xe3\x3b\x12\x6f\xe2\x65\x47\x4c\xc6\x13\x0e\xb6\xd8\x84\x97\xe3\x76\x0c\x4f\x30\x71\x30\xa1\x02\x59\xf6\x2b\x37\x6d\xa4\xe5\x40\x73\xee\xe5\x5d\xbb\x4a\xd3\x97\x39\xdb\xa5\x3e\x69\xf9\x65\x61\x8d\x02\x8c\x66\xc2\x18\x07\xcb\xc2\x59\x53\x66\x85\x74\x8f\x03\xfe\x17\xea\xb8\xbd\x5b\xf7\x10\xe0\x77\xf2\x53\xec\xfe\x5f\x31\x11\x99\x79\x7a\x26\xc4\x1c\x3c\xf5\xbb\x0b\x25\x63\x17\x14\xb6\xb8\x0a\x76\xd4\x06\x14\x41\xb7\xdd\xd8\xec\x08\xaf\x21\xf1\xbe\x8e\x65\x54\x06\x9c\x9c\xac\x14\xa6\x29\xd2\x53\xf5\x22\x21\x6b\xb9\x2c\x79\x45\x29\x69\x81\x0b\x1d\x23\x29\x01\x47\x6d\x5f\x77\xc3\xdb\x14\x4c\x4f\x4c\x2c\xeb\x72\xaa\x8d\xd3\x5e\x2b\x56\x8b\x0f\x99\x8c\x2b\xfb\xa0\x81\x79\xa3\x17\xd6\xa2\x17\x97\x25\x80\xc7\xbb\x78\x0c\x5f\x79\x29\xff\x83\x82\xeb\xda\xfd\xc7\xa7\xd8\xfa\xf4\x6b\x6b\xce\x0c\x90\xc8\x94\xfc\x85\x0c\xce\x42\x2c\xb7\xe2\xd7\x83\x63\x65\x95\x55\xc2\x94\x9f\x8d\x23\x64\xd1\x83\x1f\xab\x20\x0d\x31\xbb\xfe\x45\xc4\xf4\xef\x73\x4f\xa9\x2f\x42\xf2\xab\xb5\xf5\xb6\xa3\x00\xe3\x88\x98\x0f\x3f\x67\x8f\xe2\x0e\x5c\x10\x04\x37\xd8\x80\x66\x9b\x04\x12\xa9\xc7\x9b\x1f\x92\x6a\x6a\x72\xe7\xd2\xfd\x6d\x28\xc0\x78\x4f\xfa\x3e\xd1\xd9\x76\x39\x6f\xe6\x24\x87\x1e\xe8\xe1\xb1\x59\x10\x25\xd7\x28\x52\x81\x2f\x6e\xd5\x2c\x52\x82\xf6\x48\xa1\x86\xc3\xc2\xad\xf5\x7b\x5c\x0e\xc0\x82\x14\xd1\x98\x30\xc6\xb1\xc4\xb2\x2d\xeb\xe9\xc6\xbf\xd5\x75\x70\x83\x8a\xb5\x62\x03\x94\x2b\x66\xe7\x57\x83\x0c\x91\xcc\x3c\x31\x3f\xb7\x00\x3b\xe8\xd6\xb6\xab\x00\x08\x1d\x94\xf5\x49\x9d\x66\x0d\xb4\x25\x63\x05\x05\x02\x8b\x21\x92\xbf\x6c\x64\x03\x34\x71\x19\x29\xb2\xb6\x07\xed\x25\xfb\xcf\x48\x41\x9b\x87\x5c\x78\x09\x4f\x6b\x4f\x59\x85\x3c\x89\x9c\x55\x88\xa7\x16\xfd\x43\x9e\x4e\x6b\xed\x0a\x84\xfa\xd3\x86\x03\x97\xec\x48\xb0\x89\x0f\x52\xe8\xfd\xdd\x4f\x52\xf1\x3c\x79\xe4\xe6\x57\xcb\x17\x0d\x87\xdf\x5d\xc6\x27\x50\xf0\xf9\x3e\x62\x60\xce\xcd\x9b\x56\x31\xbc\x6d\x22\xac\xa9\x3c\x16\x53\x02\x8e\x26\xbe\x3a\x0b\x5f\x90\x7d\x63\x30\xeb\x69\x19\xb4\x27\x40\x7c\xef\x79\x00\x84\xa4\x89\x3d\xde\xfc\xf4\xb0\xaf\xa7\x8c\x30\xe9\xe6\x6d\xb1\xb8\x0a\xc9\x71\x28\x88\xac\x7b\x26\x73\x78\x21\x41\x72\x71\xfb\x8a\xd4\x8a\xa9\xa6\xc5\x87\x34\x35\x70\x5b\xf6\x64\x7b\x61\xdd\xc5\x5c\x7b\x81\xcb\xe4\x5b\x80\x22\xe8\x60\x5f\xe8\x15\x64\x01\xe1\xc1\xe4\x76\x09\x7a\xf9\x0c\xe1\x4e\x4e\x4e\xce\xd9\x1c\x53\x23\x10\x82\x7f\xc8\x3c\xb7\xc0\xb2\x2d\x93\xe6\x6a\x9c\x8c\x04\xc8\x89\x18\x41\xa2\xc0\xfb\x84\x6d\xcc\x20\xd9\xd0\xe1\x06\xad\x98\x30\x91\x8f\xfd\x37\x6e\xe0\x69\x39\xf5\x38\x1e\x58\xb6\x71\x2d\x8f\x5b\xfa\x09\x8b\x39\xea\x24\x25\xe0\xd2\xf2\x9e\x30\x48\x99\x6c\x1a\xc6\x07\xf7\x77\x0f\x58\x98\xf9\xff\x15\x83\x03\x7e\x2a\x94\x8c\x99\xfe\xd6\x3c\x22\xd0\xb2\x2d\x1b\xa0\xe4\x86\x9f\x2f\x12\x85\x6a\x63\x0e\xc5\x14\x3f\x36\xbb\x23\x7a\xcb\x9a\x81\x67\x20\xd2\x4d\xbc\xe2\x9a\xf7\x3b\x84\x26\x61\x0d\x89\x02\xea\xa4\xb0\x34\x64\x09\x21\xfc\x49\x1e\x6f\xb6\x37\x97\xbe\x58\x73\x42\xe2\xe9\x68\x96\x24\xce\x57\x42\x09\x46\xd8\xe8\x27\xfb\x1e\xc1\xea\x71\x06\x72\xda\x58\x36\xa0\x1a\xca\xdb\x57\xb1\xea\x51\x4a\x89\xd1\x0f\x89\x73\xf6\x28\xc0\x98\x20\xf5\xd4\x63\xef\x2b\xfa\xf3\x22\x98\x57\x04\xae\x04\x6f\x21\xf7\x2a\x77\x85\x39\xc8\x52\x9a\x34\x19\xe5\x0f\xf4\xa5\xf2\x5c\xc3\x43\x53\xae\x70\x70\x80\x8b\xdd\x47\xf1\xe3\x9b\x78\x84\xdb\x3f\xe7\xa4\x79\xd6\xdc\x8e\xed\x15\x54\xb3\x90\x4d\x50\x2e\x10\x9b\xe5\xba\xf1\xb2\x0b\xd3\xa0\xf4\x0f\x56\x36\x90\x8b\xb5\xb9\xf3\xaf\x5f\x2c\xbe\xa0\xa6\x85\x30\xdd\xa1\x90\x75\x81\x0e\x82\xd0\xe1\x7e\xf7\x8a\x95\x70\x0e\x2d\xe6\x92\x45\xb7\xaf\xbb\x48\xfb\xd8\x74\xe2\x29\x97\xf9\xf6\xe4\x8e\x0f\xab\xca\x6e\x01\x20\x14\x84\xe7\x68\x19\xfa\x11\x21\x53\xef\xdb\x58\x8f\xac\xcc\x4e\x08\x0b\x5e\x3b\x48\x4a\xc0\x26\x27\xfd\x97\xaf\xb5\xf3\x8f\xfc\x28\x1b\x79\x77\xdf\x21\x6e\x55\xe0\x2a\xdc\xb7\x1e\x43\x97\xf1\xf4\xbc\x28\xd9\x4c\x49\x60\x77\x4a\x17\x51\x90\xed\xfa\xb4\xec\x06\x5b\x10\x88\xbb\x3f\x60\xfc\xe3\xc5\xad\x4a\xea\xb7\x93\x64\x29\x8e\x86\x78\xe0\xb3\xf2\xe6\xfb\x3a\xef\xe9\x13\xc8\x6e\x07\x89\x02\xab\x9c\x6c\x1f\xbd\x27\x95\xf9\x77\x6d\xd8\x3f\x7c\xd8\x7c\x93\x62\x31\x8a\xf3\x02\x7b\x9c\x95\x19\x2b\x5f\x14\x54\xdb\x2e\x79\xb2\x01\x1a\x2f\xb8\xdd\x86\x56\x0a\x71\x17\x3c\x88\x32\xc9\x35\x3d\x54\x9a\x37\xba\x1c\x47\x5e\x7d\xe6\x74\xde\xa9\xa8\x9f\x5b\x71\x31\x57\x91\x94\x80\xea\xdd\x2b\x0b\xb6\x68\xf8\x0f\xd9\x6b\x45\xbd\x2d\x3f\xbb\xe3\x95\x9e\x2b\xce\xd2\x76\x21\xdc\x0e\xfb\xed\x78\x78\x01\x40\xe8\xb1\xb8\x12\xf0\xf4\x78\x2f\x11\x2e\x22\x9b\x1e\x6a\x88\x1e\x79\xba\xc7\xb7\x28\xf4\xbb\x08\xce\xf7\x37\xe3\x7c\x3f\xec\xeb\xa9\x33\x8d\xbc\xcc\x9d\x01\xc2\x8c\x45\x02\x1a\xb2\x53\xb0\x4a\xac\x56\x4a\x64\x57\xa7\x31\x46\x3e\x29\x3b\xc4\xea\x1c\x7a\x89\xc7\xe5\xdb\x50\xc8\xa1\x7a\xec\xfd\xd7\x36\xbe\x31\x36\xb8\x3b\x48\x4f\x60\xc3\x4d\x25\xa8\x2e\xb0\xd7\x24\x10\x86\x1c\xc8\xa6\xba\xb3\xef\x58\x2f\x44\x76\xa5\x8f\xdc\xa4\x93\x29\x08\x45\x9e\xb1\x85\x3b\x5c\x47\xd7\xf9\x79\x43\x0b\x5f\xd7\x26\x4d\x1a\x40\xde\xf3\xf2\xdc\xbe\x63\x8c\x57\x67\x0d\x2b\xfb\x6c\x25\x1f\x07\xc7\xdf\x55\x47\x50\x24\x06\x4e\xae\xc7\xa4\x22\xe9\x07\x63\x9c\xfd\x5d\x85\x85\x47\xa6\x3c\x28\x82\x3a\xff\x29\x4b\x62\x1f\x82\xfc\xf6\x84\xb9\x29\xf5\x0e\xb5\x0e\xec\xc0\x70\xfb\x9d\x23\x18\x61\xe3\x79\x05\x12\xb8\x41\x50\x22\x41\x11\x74\xca\x57\xfb\xec\x05\xe2\x80\xc9\x31\x2f\xd3\x23\x0d\x25\x17\x36\xa9\xe3\xc4\x53\x4e\x2b\xde\x04\x6b\x2c\x1b\x28\xeb\xac\xa3\x07\x70\x9c\x85\xba\xe8\x96\xa6\x02\x4a\x71\xc9\x93\xe4\x2f\x76\x4c\xdd\xc0\xa8\xe2\x3c\xb3\x80\x27\x5b\xe7\xae\x03\x20\x34\xae\xe8\xb3\x06\xeb\xc0\xe6\x16\x8e\x3b\x38\x47\x0b\xeb\xad\x27\x40\x11\xf4\x8d\xb8\x36\xef\x08\x31\x75\x1d\x1d\xcb\x6f\xed\xd9\xdc\x42\xdc\x22\x87\x53\x5a\x6b\x84\xeb\x29\x48\x6c\x71\x85\xa5\xc0\x12\xc0\x37\xb8\x8f\xda\x27\x66\x37\xb4\x48\xc2\xcf\x35\x20\xb0\x04\x58\xad\x85\x50\x6c\xc0\x7a\xfa\x35\xaf\x24\x23\xc1\xd2\xf5\x69\x00\x39\x0c\x8c\xf2\xac\x60\xe7\xdc\x50\x91\x3e\x34\xee\xb0\xc2\x92\x2c\x8d\x2f\x93\xc2\x28\x5d\x56\x62\xb2\xf4\x7a\x04\xce\xc1\x08\x8c\x7d\xd1\x02\x13\x78\x6a\xc3\x2c\xde\x5e\x13\xa0\x04\xd9\x88\xe9\x2d\x5d\x2a\x99\xa9\x6f\x66\xfa\xf6\xbb\xc6\x76\x14\x49\x86\xc2\xb8\xf4\x3f\x5e\xc3\xef\x79\x99\x7b\x22\xf7\x08\xca\x24\x3d\xb8\x47\x94\xa1\x95\x06\x2e\x9d\x03\x5e\x8d\xc4\xb9\x36\x8e\xcf\x4f\xe7\x97\xfd\x30\x51\x4e\x03\xd7\x40\x33\x4f\xf1\xd5\x31\x65\xe1\x5e\x05\x83\x22\x68\xd1\x75\x33\x9a\x8e\x80\x24\x5f\xe7\x0c\x26\x6d\x2b\x19\x5b\x8e\x50\x02\x18\x87\xb8\xf4\xfb\xa5\x73\x5a\x15\xb2\xf7\x04\x11\x6b\x95\xb8\x12\x50\xed\x66\x51\x90\xa4\x33\x92\xec\xb5\xc4\xca\x2f\xc4\xe1\xe1\x2a\x41\xa9\x69\x33\xd1\x87\x7f\xd1\xb1\x48\x54\x60\xfd\xbd\x65\x28\x88\xfc\xba\xfc\xd8\xd1\x8a\xa1\xac\x59\x27\xa9\x55\xd7\x5a\x1a\x7d\xcb\x06\x07\x45\x07\xb9\x37\x29\x18\x2f\x9a\xde\xc0\xb3\x7c\xff\x8a\x25\xd4\xd3\x25\xdc\xfa\x9d\xb7\xd7\xdd\x17\x2f\x0b\x9e\xbe\x7a\x27\xb3\x96\xbe\xfe\xbe\xe2\x46\x00\x26\x06\xee\x66\x7e\xe5\x79\x8e\xce\x7a\x06\x06\x2a\x2f\xd0\x12\x96\x9c\x2f\x48\x37\xbd\x94\xeb\x3e\x9a\xb8\x98\x34\x60\x14\xe0\x61\x1a\xdc\x10\xbd\xd8\x6b\x39\x9e\xe0\x1c\x5d\xc1\x9c\xee\xf8\x50\xad\x2e\xdc\x22\x7c\x41\x5f\x74\xb5\xf9\x95\x84\xb1\x27\x58\x5b\x03\x5f\x71\xe8\xf8\x06\x08\xbe\x20\x3b\xd5\x56\x35\x69\xac\x3f\x1b\xc2\x61\x99\x9e\xd4\x15\x22\xbe\x1a\x2c\x01\x5c\x70\x2c\xda\x72\x0c\x0e\xbe\xfd\x94\xba\xd4\xf8\x94\xfb\xb1\xb1\xab\x44\x41\x54\xc2\x99\x8f\x96\x0f\xdf\x24\x69\xd4\x4f\xa0\x27\x32\x0b\x35\xa0\xc6\xb1\xa2\x57\x6b\x3b\x76\x1a\xd7\x9a\x44\xe7\xe6\xe6\x42\x2e\x4f\xe5\x70\xb1\x64\xdf\xa4\x60\xea\x06\x16\x26\xbc\xce\x49\x13\x85\xe2\xac\xc4\xf9\x8a\x7b\x25\xec\x06\x3a\x2a\x3d\x8b\x3a\xa0\xe8\xba\xde\xc8\x7c\xdd\xc1\x4f\x43\x05\x99\xaa\x91\xb8\xb8\x4e\xcc\x30\x8e\x27\x97\xe5\xb4\x4c\x4a\xc8\x7d\xf7\x12\x95\x80\xe6\xbb\x97\xae\x69\x6f\xec\xfc\xa3\x11\x33\x33\x40\x97\x6e\xfa\x24\xe0\xbc\x87\xb8\xf4\xe9\xe1\xef\x83\x2c\xff\xd3\xe2\xc2\x37\x21\x82\x04\x20\x56\x5d\xb4\xbb\x0c\xaa\x8a\xb8\x5e\x79\x92\x33\xb8\xc4\x0f\x52\xd3\x42\x50\xf6\x1a\xd8\x08\x6b\x45\x47\xa9\x82\x3d\xc8\xba\x83\x82\xc8\xc2\x8b\x3a\xf7\x27\xc4\x05\xa0\x92\x95\xc9\x9a\x5b\x10\xbc\x09\x27\xb9\xda\xf1\x26\x58\x8d\x47\xb0\x87\x3f\x87\x55\xad\xe6\x9a\x2c\x30\x2c\x39\x6d\x1a\xe0\x48\x29\xba\xff\x43\x4e\xa9\x77\x68\xa1\xc1\xde\x3f\x3d\xa6\x67\x15\xf1\x18\x5a\xa4\xda\x74\x94\x6f\x30\xdb\xd6\x58\x3c\x2a\x26\x25\x2b\xc4\xc1\x20\x59\x2a\x90\xb5\x6f\xf3\xcf\x8f\xc1\x0c\x59\xce\x90\xec\xa1\x71\x87\xc3\xb3\x01\xca\xf1\x38\x59\x26\xf9\xf0\x6b\xaf\x15\xeb\x0b\x44\xd0\x76\x61\x03\x4c\x37\x7a\xce\x56\x4a\x7c\x07\xaf\x51\x0a\x87\x29\x35\xe6\x74\x9e\xad\xc6\x4f\x01\x79\x81\x5f\xeb\xd3\x00\x6a\xb0\xe1\xb0\xb5\x75\xe7\x43\xc3\x10\xf1\x1c\xcd\x06\x5b\xed\x83\x0f\x65\x28\x8e\xde\x78\x72\xf3\xc9\x6b\x78\xa1\xfe\x6c\xde\xc9\xc4\x9f\x5b\x04\x6f\x17\xad\xa4\x01\xf9\xc7\x96\x34\x92\x89\x06\xee\xa7\x95\x08\x10\x4c\x6c\xde\x88\xdb\xc7\x4c\xe9\x6f\xdf\xab\x71\xcb\xe7\x15\x5e\x20\xae\x04\x5c\x6a\xab\x33\x97\x91\x7e\xd3\xc8\x53\xa1\x96\xe9\x5c\x3d\x7a\xdf\x06\x45\x6e\x13\x8c\xb0\xa0\xdc\xdc\xef\xc2\xa0\xd5\x4b\x90\x00\xb6\xf8\xfd\x4c\x3c\x4f\x4a\x2d\xec\x44\xab\x57\xdf\xd0\x07\xa1\x20\xb9\x26\xde\xe7\x82\xec\x13\xf3\x78\x20\x4b\x05\x94\xd2\x53\x42\x5a\xdc\x98\xac\x1c\x93\xd4\x9b\x6d\x73\x1d\xb8\xfc\x07\x14\x9a\x78\x35\x6e\x16\x05\xc2\xf4\x6c\x02\x07\xd0\x8e\x66\x69\x5f\x4a\x7e\xc0\xc4\x37\xea\x77\xcb\x87\x82\x10\xc9\xd8\x8d\x33\xbd\xe7\x03\xd1\xf4\xf5\xdc\xf4\x7e\x49\x61\xcd\x8b\xfc\x65\x27\x1b\xa0\xd9\xca\xc6\x91\x89\x99\x7a\xa6\xf1\x10\x08\xc1\x77\xe5\x7d\xd6\x60\x49\xcf\xbb\x3c\x9f\x1a\xcb\x99\x0a\xfd\xe6\x1c\x0a\x22\x5a\xbb\x95\x0d\x8f\x30\xee\x5b\x3d\xa9\x64\x7c\x6c\xe3\x19\xf4\xab\x89\xa5\x21\x95\x10\x8b\x3e\xc5\x7f\x7d\x8b\x5e\x4f\xaf\x4c\x41\xfa\x05\x36\x4e\x5b\x02\x89\x02\xad\xe8\xa8\x7f\x09\xa4\xd5\xd8\x61\x19\x18\x14\x74\x5f\x9c\x72\x12\x1f\xf6\x4d\x0a\xe6\xed\x1e\xec\xd1\xf4\x9f\xac\x07\x60\x5c\x11\x53\x02\x02\x13\x3e\xbf\x3c\x28\x8b\xa7\x0f\x8d\xb7\x59\x9e\x47\xf7\x5f\x4c\x02\x40\xe8\x4e\x00\x07\x1b\x9b\x3c\xc2\x1f\x24\x08\xe1\x95\x96\x82\x8f\x7a\x6f\x74\xd3\x05\xc8\x21\xe5\x85\x66\xdd\xa2\x9b\xc9\x07\xfa\x4f\x68\x22\x94\x03\x78\xd0\x2a\x2f\xcd\xbd\x68\x2a\x7e\x67\xde\x0c\x6e\x2b\xf4\x0e\xfe\x81\xae\x38\x50\x34\x19\xbb\x14\x96\x92\x77\x3d\x3f\xa0\x81\xa0\xc8\xf3\xdf\x77\x4e\x55\x91\x5c\xca\x2c\x7d\xf8\xc7\x44\xe7\x07\x5d\x0e\xba\xe0\xa4\x63\xfa\x2f\x18\xa7\x33\x0b\xa4\x86\xaf\xd9\x55\xc8\x68\x20\x94\x48\x06\xaf\x8e\xc5\xb1\xe4\x9b\xbd\x13\xf6\x93\xe4\x34\x68\x80\x63\xc2\xd3\x20\x1d\x83\xfc\x80\x4f\xcb\xee\x14\x97\x66\x8e\x05\x88\xe1\x76\xf5\x02\x69\xe7\xa9\xa4\x47\x70\x3a\x6e\xd1\x67\x19\xc2\xd6\x93\xd2\x63\x14\x64\xfb\xdc\x2f\xcb\x9d\x80\x6a\xea\xbe\x3a\xce\x2e\xd5\xb0\x46\x11\x0b\x3c\x60\x60\x33\x75\x74\x0b\x31\x1d\x71\x61\x7d\x24\x1e\x05\x91\x0d\x4d\x37\x1e\x16\xb1\x2f\x55\xd8\x74\xb1\xbb\x9c\x5a\x24\x77\x06\xc2\x44\xf2\x1b\xd8\x08\x2b\x02\x85\xed\x2c\xaa\x07\x28\x82\x8e\xbe\x0b\xf3\x7c\x0c\x5f\x49\x2e\xd7\xef\xf1\x04\x7b\xf1\xdc\xc7\x49\xe1\xf9\x6c\x41\x2d\xff\xf6\x74\xde\x89\xc4\x6a\xa1\x15\x66\xeb\x32\xbb\x1f\x1e\x3f\xb6\xc9\x99\xc9\x36\x74\xe7\x7b\x32\x8b\xee\x13\xc9\x4a\x71\xec\x07\x1c\x2c\x62\x03\xd7\x72\x99\xc5\xb0\xaa\x10\xa7\x4e\x80\x22\xc0\x2c\x25\x8c\xb1\x67\xee\x7a\x15\xff\xa3\x8b\xe6\x30\x80\x02\x8c\x7e\x31\xa3\x14\x0c\x7b\xf1\xdb\xbb\x65\xe1\xce\xbf\x84\x9c\x1a\xf9\x49\x90\x00\x74\x3c\xd5\xc0\xe0\xbd\x9d\x59\x5f\x7f\x5b\x15\xd7\x62\xe6\xf5\x03\x53\x30\xf1\xd2\x2e\x84\x6b\x69\x6f\xea\xa7\x2e\x1c\xaf\x88\x4c\x05\xbe\x9c\xa9\x16\x79\x48\x5a\x6a\xe8\x1d\x71\x64\x45\x6d\x12\xc0\xb0\x86\x8d\xb0\xab\x72\xdb\x6e\x0a\x21\x4f\x06\x14\x41\xad\xc5\x34\xea\x1a\x88\x75\x7d\xe9\x8b\x27\x6e\x15\xdd\x35\x5c\x10\xb2\x1b\x8f\x19\x94\x91\xd2\x98\x61\xf1\x1a\x4f\x7e\x45\x67\x21\xa6\x40\x2e\x4f\x14\x2a\x30\xf4\x0c\x0a\xb2\x65\x4d\x79\xea\xc6\x8c\xaa\x43\x43\xfd\x63\x58\xf3\x05\x7d\xcf\x79\xda\x1a\xf1\x6a\xc1\x7f\x69\x2b\x42\xd1\x04\x45\xd0\x9b\xd9\x1a\x9f\x8a\xc4\x97\x9a\x1d\xfc\x5c\xa4\xf2\x11\xc4\xc9\xc9\x05\xa4\x9d\x97\xee\xff\x6a\xb6\x2a\x64\x98\x77\x58\x37\x66\xd4\x63\xbe\x35\xc8\x56\x80\x44\x81\x7d\xf1\xa1\x14\x79\x46\xda\xc0\x6b\xba\x43\x5a\xc1\x49\x9d\xbe\x77\xd7\x01\x06\x11\x36\xc2\xa4\xe5\x6c\xe6\x07\x3d\x00\x8a\xa0\xb1\x17\xb5\xfe\x7c\x82\x27\x52\x4b\xd6\xd9\xf5\x29\x1f\x19\xa4\xb4\xe3\x89\xc4\x1a\x92\x0f\xbf\x15\xee\xa3\xce\x8f\xb6\x86\x20\x01\x14\xd9\x74\x9f\x2f\xc1\x27\x0f\x6a\xdb\x95\xa0\x02\x42\x57\x89\xdf\xb9\x31\x0b\xdc\x26\x54\x6c\x1e\x9f\x13\x54\x60\xe1\x03\x0a\x54\x20\xf6\x45\xf8\x01\x3f\x1b\xf6\x2f\x6c\xdf\x0f\xee\x21\x08\xfe\xb6\x3c\xde\x04\x9b\x6e\xe3\x47\x70\x58\xdb\x99\x92\x42\xd2\x47\x3c\xba\x8b\x0d\xd0\x52\x8c\xd2\xb2\x9e\xb0\x07\xf8\x2b\xf6\x17\xbf\xbd\xd8\x9f\x7a\xf7\x3b\x91\x1c\x8b\x20\x5c\x95\x0d\x57\xcf\x09\xeb\x76\x47\x96\xd3\x00\xc7\x8c\x03\xdb\xbc\xfe\x40\x0b\xbc\x93\xb0\x90\xa0\xa0\xcb\xfc\x85\x1e\x36\xb8\x70\xe2\x3e\xfc\x56\x89\x7e\xff\x79\xe1\x62\x71\xfe\x2b\x13\xb6\x41\x99\x70\x9a\xcf\xba\xe2\x94\x28\xcb\x08\x85\x17\xa7\x4f\xb3\xbe\xbb\xcc\xbd\xda\xb5\xea\xaa\x10\xb1\xc3\x89\x7f\x3e\xa9\x2f\xdc\x43\x56\x2a\x47\x42\x7a\xc5\x1c\x6e\x15\xd4\xf6\x76\x74\x26\xc2\x44\xb2\x4f\x06\x12\x59\x8c\xc9\xe6\x4d\x21\xcf\xa7\x1a\xd2\xe7\x49\xb3\xd0\xba\xf7\xb0\xe5\x01\xf5\x25\x61\x9b\xeb\x61\x67\x07\x0f\xa5\x07\x93\xdb\x1f\xed\x71\xfe\xa0\x8a\x50\x44\x85\x2e\x13\xbd\xed\xbc\xb0\x31\xdc\xbc\x8b\x0d\x30\xd3\x82\x12\x5b\x82\xe1\x89\xb4\x92\xaa\xe0\xb4\x71\x3c\x1e\x99\xa9\x37\x95\x0d\x74\xdc\xc2\x1a\xa6\x84\x95\x4b\x32\x85\x5a\x88\x82\xec\x37\x5b\x3c\x76\x18\x32\xca\x33\x1f\x67\xed\xd9\xf2\x41\x15\x61\xf6\xe3\xb4\xf5\xab\x37\xdf\x18\x73\xca\x4b\x98\x12\x1a\x64\x39\x41\x02\xe8\x77\xf7\xfc\x7a\x6c\x33\x6d\xf8\x70\x2e\xf0\x45\xec\x62\x0c\x91\xdc\x1b\xcf\x2e\xa7\x87\x52\x53\x6f\x1a\x63\xd8\xa0\x21\xa6\x2c\x13\x3c\x4f\xc7\x68\x4f\x21\x51\x20\x23\x71\xac\x64\x2f\x51\x4f\xe9\xe6\x32\x05\x49\xc9\xcf\x6a\x8a\x36\x64\x8a\x63\x04\x9e\x78\x74\x8d\x4d\x1e\xe6\xe7\x78\xea\x48\x0b\xf5\xb0\x1c\x96\x00\xae\x69\x3f\x59\x9e\x02\x35\xf0\x05\x0d\x0d\x24\x93\x60\x84\xdd\x56\x08\x3e\x3d\x3f\xd8\x23\x48\x14\x38\x28\xcf\x9d\xc9\x22\x9c\xe6\x5b\xf6\x8f\x91\xd2\x10\x23\xe8\x72\x2d\x3f\xcf\xff\x2b\xdd\x88\x20\xab\x2d\x6c\x39\x6f\x62\x03\x94\xab\x1b\x6d\x4f\x5e\x47\xfd\x80\x7c\x59\x22\xb9\xf7\x29\xf2\x64\x1a\x6b\x50\xfe\x1c\x1c\xbe\xa2\x66\x9e\x26\x10\x2f\xd9\xb0\x01\x9a\x9d\x9b\xc7\xd2\x27\x38\x5b\xcd\x0f\x1f\xab\xbc\xb9\x8b\x6c\x0e\x42\xf2\x8a\x4d\x3c\x97\x32\xa4\x52\xe8\x56\xe7\xc5\x95\x80\xe5\x15\xe0\xce\x6d\xf4\xaa\x66\xaf\x3b\x99\xb5\x37\x1c\x1e\x72\x4e\x68\x38\xde\x43\x39\x2c\xeb\x63\xbe\xd2\x94\x79\x6e\xd8\xab\xdc\x98\x52\x16\x83\x5c\x82\x96\x3c\xd0\x8f\x5b\x75\x74\x95\x2c\x85\xcc\x10\x00\x52\xdb\x20\x76\xc0\x73\x5c\x71\xc3\xb3\x79\xb3\xf9\x20\xef\x6f\x66\xd4\xf6\x4d\x2b\x69\xba\xdf\x46\x1f\x73\xc8\xed\x95\xd1\x74\x44\x91\x76\x9e\xe5\xec\x05\x29\x1f\xbe\x78\x7e\x11\x2c\xdc\x8c\xa6\x64\x14\x44\x7c\x23\xdb\x95\xaf\xb2\xfb\x6d\xc4\xd5\x2e\x38\x67\xc1\xc4\x6a\x4a\xd3\x51\x3e\x2b\xa2\x26\xee\x20\xd1\xe5\xd2\x3c\x38\xfc\x80\x44\x81\xf3\x7e\xe8\xee\x0f\x2a\xbd\x43\x0b\x6b\xb5\xea\xec\x35\xdc\xc3\x64\x28\x8e\x28\x3b\x22\xee\xf6\xf4\x67\x9e\x22\x7a\x8c\x34\xcf\x8a\x57\x42\xa2\xc0\xb1\xfd\x81\xf1\xd9\xe2\x03\x46\x01\x15\x4b\x00\xe7\x72\xeb\x1f\xea\xe4\xab\x28\xc7\xf2\xee\x01\xd2\xbc\xc3\x4f\x65\xa0\x20\x5b\x65\x05\xef\x7e\x03\x54\xfa\x9c\xc7\x6a\xf8\x99\xb8\x84\xb1\x91\x90\x5c\x8f\xd1\x5f\x0c\xb0\x1c\x43\x2e\xce\xaf\xf7\xef\xbc\x40\x9a\xf8\x1c\xa4\xc1\x9b\x65\xd4\xfe\xe9\xb7\xff\x75\x48\x33\xd2\xa3\xb4\x01\x51\x34\x28\x3b\xd3\x8d\x98\xa0\xca\x9c\x36\xb0\x6a\x4b\x9e\x17\xdc\x9c\x20\x01\x7c\x7b\xd8\xe7\x9a\x28\x3f\x22\x8d\xbe\xbb\xb1\x85\x06\xdf\xc5\x53\xaa\x5f\x6e\xa3\x2f\x3b\x0b\x31\xed\x77\x1a\xd6\xd0\x3f\x83\x58\x04\x7c\x68\xdc\x41\xf2\xc9\x4c\x66\xb3\x96\xea\x2d\x32\xc5\x31\x0b\xdf\xd3\x4f\xca\x9f\xe5\x4a\x72\x5f\xfd\x7d\xde\xe0\xef\xde\x97\x93\x3d\xfb\x17\xa6\xeb\x22\x03\x4a\xc7\x6e\xe9\x6c\xaf\x69\xe0\xf9\xf2\x33\x56\xdc\x42\x84\xa7\x1a\x18\x83\x44\x25\xe0\xe8\x82\x9a\x5d\xe3\xe4\x2f\xfb\x2b\x53\xc3\x97\xab\x89\x43\xb0\x98\xfc\x9b\x46\x9e\xa5\x49\xe9\x80\x27\xd7\x53\xbb\x74\x7e\x40\x86\x24\x1e\x14\xc9\x9f\xef\xe4\xa8\xe6\x87\xfa\x6c\x31\x86\xa4\x09\x20\xf4\x9e\x68\x94\x8c\xe9\x1a\x63\xef\xe5\xec\x98\xf3\xcb\xcd\x82\x25\x80\x2d\xaf\x7a\x36\xaf\x12\xd7\x5b\x94\xba\xe8\xe4\xd4\x01\x2d\x84\x49\xc3\xa3\x61\x7d\x6b\x04\x87\x45\x0b\xaf\x9d\xd7\x9b\xb2\x02\x15\xe8\xa5\xe6\x57\x25\x19\x74\x8a\xa1\x63\xbe\x14\x08\x2a\x20\x7e\xc7\x62\x62\x2a\x4f\x3e\xc1\x73\x43\x57\x1d\x4d\xfb\xf9\x45\xeb\xe1\x66\xfe\x7e\xec\x9b\x36\xe1\x86\xc4\x0b\x88\xf2\xae\x92\x94\x8c\xbc\x84\xc2\xb8\x58\xaa\xae\x25\x73\xda\xe0\x4b\xdb\x89\x79\x35\x06\x40\xa2\xc0\xab\x3d\xfb\x1e\xc2\x0c\x1b\x0b\x91\xfd\xe1\x1d\xd5\x32\xce\x45\xcb\xf1\x04\xcf\x94\xab\xb2\xeb\x59\xf5\xb6\x79\x5b\x77\x61\x03\xb4\xb2\x13\xed\x8a\x51\xec\x03\xde\x58\x46\xd6\x75\x0a\x29\x61\x05\x6e\x4c\x01\xb3\x2d\x5e\xd1\xfb\x8f\xcd\xc3\x32\x52\x43\x90\x00\xcc\xec\x8f\x32\x56\x13\x14\xa4\x66\x44\xd1\xd5\x73\x8f\xb7\x33\xfb\xa1\x64\x2c\xdb\xc5\xfa\xf4\xff\xdd\x37\x59\x7f\x02\xf2\x73\x9b\x1a\x05\x4a\x94\x6e\xe2\xb9\x3c\x90\x83\xe6\xe3\x9a\x98\x0c\x15\xd0\x26\x7e\x5b\xfd\x93\xa8\xb7\xe8\x04\x01\x59\x3b\xe6\xac\xe1\x78\x0c\x77\xf5\xbe\x77\x2c\x49\xfd\xd9\xbc\xe0\xdb\x9b\xb6\x0a\xa5\x7a\xea\xc2\x2c\x2e\x8f\x39\x69\x04\xb5\xc8\xb8\xae\xeb\x5d\x02\x64\x3b\x49\xbf\x27\x6b\x7b\x52\xfc\xf8\x71\xac\x1f\x11\x5d\xe6\x21\x6e\xfb\x22\xa9\x6a\x7f\x9f\x24\x91\xd3\xee\x14\x43\xa1\x82\x95\x4e\x65\xec\x47\xfc\xb7\xaf\x0c\x8e\x6c\x86\x60\x1d\x5d\xc1\x69\x81\xbc\xa3\xe7\x61\x61\xf0\x25\x06\x3a\xb2\x01\xe6\xd9\xe7\x92\xee\x15\x6c\xff\x8e\x7b\x63\x67\xc9\x20\x08\x25\x92\x7d\xd6\x60\x9e\xac\xb9\xec\x6d\xd5\xea\xc4\xbf\x97\xfa\x1e\x12\x05\x36\x5f\xdc\x97\xe7\xc8\x58\x34\x27\x0a\x44\x9d\xd9\x77\x09\xd9\x8c\xb3\xb2\x30\x01\xd8\xc9\xaf\x8c\xcf\x9f\xb7\x81\x8d\xb0\x04\x20\xb6\xbf\xc8\x7a\x11\xe4\x7e\x47\xd7\x6e\x50\x59\xe7\xe0\x61\x5f\x19\x8a\xa3\x2a\xbe\xbf\xe9\x0d\x3c\x97\x2c\x9d\x8d\xf3\x2b\x7f\x47\xa6\x02\xda\x56\x6d\xc9\x51\x44\xbb\xdf\x29\xe4\x92\xd2\xa3\xa3\x1b\xf3\x57\x6e\x64\xde\x85\xa4\xd6\x15\xf6\xd5\xb1\x38\xac\xbb\x04\x19\x05\xa1\x7b\x3b\x66\xa1\x43\x59\x8e\xc7\x2f\xca\xaf\xa0\x56\xfd\xc2\x2e\xf6\x14\x29\x30\xde\x91\x7a\xea\x31\x1b\x17\x5e\x50\x13\xcf\x25\xd7\x08\x12\xb2\x4c\x78\x1b\xae\x4d\x52\x7b\xe1\x6d\x45\xa5\xa1\x94\x2f\xd3\x9b\x1f\x21\x28\xa2\x41\x58\x73\x7b\x1a\x8b\xe7\xb0\xae\xb5\x48\xcb\xcd\x8f\x77\x03\x05\x91\x3d\x63\x9f\x06\x9f\xa3\x7e\x40\xaf\x69\x01\x81\xb8\x7b\x27\xf3\x2b\x0f\xab\x39\x66\x99\xfd\xd7\xe9\x33\xf3\x83\x11\x13\x70\xf6\x74\xa3\x2c\xc8\x5c\x9f\x06\x34\x05\x1c\x10\x27\x67\xa5\xa2\x1c\xd6\x09\x0d\x69\xf2\xfc\x30\xaa\xa0\x08\x2a\x26\x6f\xa5\x3d\x88\x7b\xe5\x44\xb8\xd9\x62\x46\xb6\xb8\x0f\x3f\xc7\x43\x75\x74\xde\x84\xbd\xc4\xff\xdc\x50\x46\xc7\x49\x81\xdb\x68\xcf\xe9\x3b\x33\x25\x07\x9f\xc3\xc4\xdd\x76\x14\x3f\x01\x21\xa8\x34\xf1\x63\x3d\x38\x70\x95\x14\x33\x7f\x06\xc8\x51\x19\x74\x01\x76\x67\x2a\xa8\x1d\x7e\xc2\xa6\x85\x78\xa6\xc4\xf5\xfa\xc3\xc4\xdd\x3b\x28\x7e\x7c\x87\x8a\x31\xab\x64\x4c\x47\x61\xab\xdd\xfc\x76\xdc\x87\x25\x00\x1d\x27\xc9\x97\x4f\x77\x31\x59\xad\x04\xfd\xc1\x3f\x81\x7a\x90\x93\x1c\xbe\x6f\x27\xab\x58\x3a\x37\xfe\xf3\xde\x2b\xfc\x3d\x67\x72\xd7\x53\x07\x26\xab\x16\x60\x46\x24\x5f\xd9\xc4\x0c\x87\x92\xb1\xfe\x96\x4d\x9b\xe6\x5f\x79\x46\x90\x00\xb2\xdd\x55\xde\x3f\xa3\x34\x75\xb4\x7d\xac\x2c\x2d\x4f\x04\x18\x3a\xe2\x3d\xf5\xd8\x0a\x97\x48\x53\x53\xae\xe7\x4f\x9d\xf3\x97\x85\x02\x66\xe1\x20\xdb\xa2\xa3\x96\x56\x44\xfa\x53\xba\x00\x69\xd5\x53\x8e\x45\x12\x71\x7e\xe8\xe2\x37\xbb\x3f\xb1\x52\x46\x7c\xfe\x28\x13\x73\x0c\x12\x05\x12\x0d\xae\x02\x35\xba\x4d\x1d\x3f\xc5\xd0\xd1\x6f\x61\x2a\x8c\x6c\xb1\x75\x53\x6f\xaa\x04\x45\x9d\x6c\x67\x6b\xb7\xf9\xc9\x13\xf1\xc9\xdd\x94\x0b\x9e\x2d\xef\xd4\x44\x57\x8e\x86\x19\x0a\xf6\x95\x1e\x59\x4c\xaf\xa7\xff\xdc\xa6\x4e\x81\xfe\x49\xc5\xfe\x4e\x46\x88\xbb\x9d\x98\x01\x1c\x6c\x56\xe5\x13\xcf\xe5\x19\xf2\x63\x7e\xbf\xee\x92\xa9\x40\xec\x5d\xbb\x33\x4b\xe3\x50\x90\x1d\xda\x5c\x16\x83\x8c\x0b\xa4\x8b\x9c\x3d\xdb\x4f\xd0\x52\xfb\xdb\x94\x37\x41\xa2\x80\xb4\xbc\x6d\xd6\x41\xd9\x11\x63\xf4\x20\x77\xec\x08\xe4\x24\xd7\xc4\x73\x29\xd0\x39\x30\x3f\xce\x01\xf9\x91\xcd\x7d\x05\xce\xf6\xdf\xd5\xc5\x24\x02\x5b\xdb\xd5\xd5\xc2\x40\x86\x18\xae\x91\x93\xb9\x53\xb4\xa9\x99\x41\x17\x27\xd3\x6d\x77\xe6\x37\x18\xfe\x21\xed\x4f\x40\xd6\x3d\x93\xe9\x53\x22\x94\x94\x1e\x1d\x5e\x75\x2d\x6c\x2d\xe3\x1d\xd1\xa8\x91\xe7\xcb\xd7\xdd\x25\xc3\x98\x47\x5a\x63\x72\x93\x6a\x0b\x33\x76\x27\x3e\xff\x6d\x38\xb9\x9e\xd5\xc8\xfb\xfc\x30\xfb\xc4\xbc\x27\x64\x53\x68\x80\x63\x94\x4e\xac\x80\x88\x2f\x86\x92\xe9\x4b\xe6\x29\x39\xa5\x69\x2d\x28\x82\x36\x7c\x35\x5b\x2d\x80\xa5\x0d\xb8\xe1\x60\x73\x71\xb0\x37\xbf\x06\xe5\xdc\xff\xfb\x14\xda\x2a\xa2\x12\x70\x09\xac\xb5\x15\x34\x91\x0d\xa1\x7a\x0e\xd6\x41\x9f\x9d\x6b\xd0\xeb\xcc\xfa\xcf\xc9\xb2\x97\x00\x5b\x53\xf0\xee\x4e\x82\x11\x76\x7f\x6c\x5f\xea\xfc\x69\x33\x6b\x36\x40\xb9\x73\xc0\x16\x8f\x67\x4e\xb2\x4d\x3c\x97\xc7\x6a\x3f\xe6\x65\xea\xd7\xa3\x01\x8e\xbf\xfa\x05\x65\x5a\x79\x85\xd0\xde\xab\x96\xe7\x2c\xdf\xae\xe7\x0e\x17\x62\x62\x8a\x36\x3e\xf3\xaa\x7c\x0c\x4b\x00\x47\x9c\xec\xdf\xe2\x7b\x24\x89\xe7\x98\xfc\x49\xcb\xa5\x15\x03\x86\x58\x80\x9c\x5d\xc2\xfc\x3b\x2b\xf1\x77\x76\x6c\xb1\xc1\xdf\x59\x06\xd6\xd3\xd7\x3d\x93\x59\x31\x6f\xef\x06\xa0\x08\x1a\xde\xb2\x6b\x0d\x6e\x5b\x4f\x51\x0e\xeb\xc3\x4f\xe9\xa5\xf3\x3f\x9d\x04\x45\xd0\x64\x45\xd7\x70\xfc\xa7\x0b\x48\xb7\x21\x66\x39\x15\x5b\xcb\xcf\xd9\x49\x0e\x98\x5f\xd1\x5b\x92\x12\xb0\xdc\xe5\x04\x49\x96\x82\x30\x43\x71\x70\xc1\x8e\x97\x17\x4e\xbd\x7a\x6c\xfd\xf7\x01\xbb\x26\x32\x15\xc8\x97\x16\xd8\x63\x06\x94\x8c\x5d\x19\x33\x33\x9d\xb7\xcf\xff\x39\xf4\x44\x24\x2b\xe5\x20\xed\x3c\x56\xe7\x02\x95\x85\x0d\xbc\x38\xa7\x86\xdf\x7f\x1f\xe5\x1b\x54\xa4\x02\x3e\xac\x87\xea\x82\x52\x9d\x66\xd3\x51\x3e\x36\xf8\x49\x37\xf4\x3f\x07\xf5\x76\x23\x6c\x80\x09\x6d\x26\xe3\x79\x67\xbf\x26\x73\x3a\x6f\xdf\xe6\x9f\xf3\x84\xb8\x79\x03\x1b\x60\x4a\x3a\x25\x88\x80\x10\xac\xb3\x9c\x39\x9d\xe7\xb8\xea\xdc\xc6\x79\xa9\x42\x53\x51\x90\xdd\x4d\xe5\x09\x08\xc4\x5e\xd9\x37\x29\x98\xe2\xa1\xf4\xe9\x90\xaf\x74\xdf\x2b\xed\xdb\xe7\xdf\xf9\x3b\x69\xbd\x0e\x80\x10\x09\x4f\x70\x0c\xa6\xeb\xe8\x67\x5a\x92\x56\x0b\xc5\x27\x9f\x45\x41\x44\xc2\x49\x46\x1d\x1f\x5e\x9f\x39\x9d\x17\xca\x38\x37\x1f\x21\xc8\x6f\x60\x09\xe0\xa8\x88\xa0\x3b\x6d\xd3\xd9\x35\x50\x88\x59\xce\xbd\xec\x80\xbd\xf9\xfb\x0e\xfc\x39\xf4\x7f\x82\x96\x40\x52\x17\x8a\x1f\xdf\xf3\xa6\x31\xa6\xd8\x16\x1e\x16\x71\x3d\x61\xd1\xdf\x0e\xac\x09\x89\x02\xc6\x2b\xee\x19\x09\x7b\x8e\x1c\x96\x4f\xc0\xef\xbf\xed\x7d\xaf\x2c\x15\xf0\xa9\x6f\x2c\x25\x91\x29\xf1\xf7\x50\x8e\xe5\xdb\x42\x5b\x61\xc1\x83\x1c\xeb\xc6\x06\x28\x63\x90\x58\x3a\x00\x42\x9b\xc5\x8d\xd6\x73\x31\xab\xa4\x39\xc5\xc1\xe3\x16\x7f\xcf\x9c\x03\x89\x02\x8f\xc5\xc2\x2d\x85\xad\xa7\x35\x98\x62\xfb\x0f\x43\x8c\x94\xe8\x9e\x3d\xff\xfb\x76\xdc\x52\x76\x5a\x1f\xc6\x25\xf8\x08\x25\x63\xf6\xe2\xe6\xeb\xff\x96\xe8\x09\x24\x0a\xdc\x1e\x0f\x0a\x14\x54\x29\x10\xae\xe7\xc5\xd5\xdb\xce\xcc\xdb\x30\x3e\xed\xe8\x42\x4a\x04\xee\x21\x78\x82\x82\xfd\x8a\xa1\x2f\x71\x9b\xb0\xbc\x96\xf0\xeb\xef\xcd\x6c\x51\xa0\x02\x59\xc1\x11\x78\x24\x65\xec\xc4\x09\x1a\xa1\xd8\xcd\xf9\xc8\x52\x85\xbf\xc9\x2d\xd9\xc7\x9d\x59\x3c\x6e\x65\x0a\x09\xb4\xb2\x03\xe1\x7a\xd6\x64\xaf\x5f\x95\xd9\x9e\x9b\x46\x7d\x1c\xb2\xf1\x66\xbb\xc9\x2a\x09\x6f\xd6\xc7\xc2\x0e\xde\xf0\xc7\x59\xcd\x73\x8c\xa1\x8a\xa6\x8e\x96\x25\x33\xa5\xa7\xf0\x27\x16\x77\xf2\x66\x0b\xe8\xde\x16\x2a\xca\x36\x7f\x7f\x0b\x20\xce\x84\x9f\x66\x31\xe1\x13\xfc\x66\x81\x4a\x84\xe9\xba\x3f\x6f\xaa\xba\x5e\x0e\x1c\xb9\xca\x1b\x96\x5a\x23\x78\x3e\x31\x3c\x30\x3c\x9d\x3e\xc9\xef\x7a\xe9\x5f\x63\x35\x79\xce\xfc\x49\x47\xeb\x93\x0e\xde\x70\xb1\x65\x60\xc8\xf3\xdf\x5d\xb3\x85\x07\x7b\xb6\x8e\xce\xdb\x52\xe0\x5e\x36\xc0\xfc\x58\x79\xc5\x01\x05\x18\x44\x3c\xde\x6e\xb6\xe4\xb9\xfa\x73\x2c\x2b\x7f\x78\xa9\x4e\x7a\x5d\x9e\x3e\xf3\xb2\x2b\x3f\x6b\xd4\x5c\x5d\xaf\x0e\x5b\xba\x58\x92\xbb\xe0\x81\xf7\x65\xfe\xbd\x2d\x67\xd3\x4d\x66\x77\x85\xc7\x6c\x73\x18\xb9\xba\x55\x85\xc4\xaa\x8d\x06\xea\xef\xb0\x8f\x7d\x75\xec\x74\x99\xec\x90\x3e\x5e\x86\x15\x8d\xd4\xce\xfa\xf9\xf1\xda\x43\x3b\x8e\x75\x7d\x96\xfa\xbe\x68\xd9\x2a\x6d\xed\x72\x0f\x48\x00\x10\xcf\x09\x12\xc0\x8f\xdc\xf4\x5e\x3c\x1f\x0d\xc6\xfd\xea\xd5\x3e\xfd\x63\xb3\x05\xca\xf9\x11\xd0\xf5\x53\xa2\x55\xf6\xd7\x39\x31\xb7\x35\x1b\xa4\xcc\xed\x6e\xce\xb9\x5e\xb7\x54\xf7\x9d\xf1\x3e\xb7\x76\x6e\xd7\x46\x17\x17\x97\x59\x85\xdf\x9d\x57\x4b\x4a\x4b\x96\xb8\x2c\xeb\x11\x39\x3f\x38\xd7\x33\xbb\x5a\x05\xea\x39\xcb\x58\xf2\xe4\x25\xb2\x5e\xee\xf9\x44\x7b\x4a\x95\x71\x48\x87\x5c\xe4\xb5\xb9\x69\x24\xf8\x3e\x43\x65\xde\x19\x67\x71\xc2\x3a\x39\xaa\x07\x42\xf0\x35\x99\x26\x9e\x4b\xde\x77\xd9\x53\xa2\x95\xd9\xe7\xb9\x8b\x2f\xf3\x1f\x44\xd3\x3d\xbe\x84\x7d\x38\x33\x20\x81\xfd\xe9\x27\xb4\xbe\x8c\x0d\x7f\x12\xa7\xa6\xb7\xf8\x9d\xe8\xd2\xaf\xc3\x1c\x4b\xfa\x9b\x5c\xb5\xe7\xfc\xf6\x7d\x55\xc6\x93\xdf\x9b\x95\x88\x3d\x74\x8f\xdb\x0b\x45\x23\xc3\x87\xfd\x16\xab\x58\x7e\xe1\x8d\xf3\x85\x0e\xb5\x53\x9e\x0a\x58\x3f\x1b\x1b\xd3\x44\x50\xf6\x21\x28\x19\xab\x47\xb6\x4d\xfd\x3a\x37\xc4\xcf\x0f\x7e\x77\x2d\xff\x57\xf3\xcf\xfd\x56\x13\xa5\xad\x3d\x51\x97\x0d\x67\x0a\x52\xeb\x0d\x18\x8f\x0a\xfe\x72\x08\x96\x7f\x79\xe1\xfc\xc9\xc5\xac\x25\xde\xc3\xa3\x35\xd4\x31\x8b\xb9\x27\xb3\xc7\x49\x1d\x58\xdb\xc2\xae\x53\x21\xee\x8a\xfa\x2f\x7e\xcf\x8c\x54\x95\xd2\x17\x2d\x6b\x18\xb2\xe9\x14\x06\xef\x7b\xda\x34\x20\xfe\x40\xcb\x4f\x3b\x14\x60\xd4\xc3\x63\x86\x59\x06\x0d\x02\xac\xac\xa7\x99\xf1\x1a\xe5\x65\x39\x46\x8b\xe7\x16\xee\x4a\x32\x4e\xe6\xbb\x17\x1a\xce\x86\x19\xfe\xce\xb4\x80\xa6\x9d\x44\xa6\x5b\x64\xa7\x87\x47\xb9\x13\xc7\x97\xfe\xa9\x18\x38\x74\xce\x85\x5f\xb1\x96\xfc\xc5\x48\xee\x8d\xe5\xde\xbf\x87\xa5\xd0\x80\xf8\x8d\x89\x3f\xad\x05\x34\xbc\xa7\x1e\xbb\x18\x39\x52\xdc\x25\xbe\x9e\xcb\x52\xbe\xff\x29\xbd\x5f\xd2\x7d\x7f\xdc\x0a\xdb\xaa\xeb\xde\x03\x4b\x27\xd2\x4a\xac\x3e\x4d\x19\xf8\x9e\x9e\x28\x1f\x57\x37\xaf\x74\x85\xa3\xc3\x67\xaa\xd6\x7f\x74\x36\x8a\x4c\x19\xae\xf5\x33\x0b\xb5\x09\x8e\x62\xbf\x0a\xe1\x1b\x9d\x9e\x2b\xca\x59\x3c\x29\xe7\xb3\xfe\x97\x83\x10\x68\xb5\x40\x11\xd4\xcc\x69\x97\x3e\x0e\x85\x19\x28\x87\x7e\x89\x76\xf0\xf4\xf4\xb5\x3b\xb7\xba\x56\x54\x99\xc7\x68\x49\x0d\xcb\x48\x2e\x6e\x5f\x61\x57\x5f\xe1\xd1\xe8\x30\x0e\x8f\xcd\xae\xd0\x53\x72\x53\xb9\x92\xf6\xa4\x42\xda\x6d\x3d\x79\xea\xf5\xa0\x4b\x94\xdf\xf7\xe0\xa0\x89\x5f\x2d\xaa\xd8\x21\xc8\x52\xe8\xab\x92\x90\x28\x10\x94\x75\x77\xa9\xe0\x8c\x03\x83\xe3\x85\x35\xf0\xe2\x44\xfc\x96\xf8\xdd\xfa\x66\xae\xba\xee\x70\xa8\x53\x46\x57\xe3\x12\xec\xbe\xec\xa1\x63\xdd\x89\xcb\x72\x0b\x91\xf5\x64\xd3\xc3\x7c\xfe\xe5\x8e\xad\x2a\x11\x4f\x03\x3a\x50\xde\x3b\x34\xcf\xdb\x8a\x31\x7c\xc8\x85\x34\xf5\x9a\xa1\xe3\xe2\x70\xf4\x1f\x1d\x3c\xc1\x51\x36\x9f\x35\x18\x36\x93\x62\x7b\x24\x79\x7f\xc0\xe1\x95\x0d\x15\xc1\xc3\x6f\xbe\xfe\xde\x18\xc9\x7d\x61\xf1\xa4\x76\x86\x9b\xf7\x8c\xbb\x4b\x85\xc1\x13\xbd\x54\xba\xdd\xa3\x79\xca\xe2\x64\xdf\xc7\xca\x39\x7a\xa8\xf6\xe9\xdf\x57\x7c\x2b\xa3\x8a\xdd\x14\xf8\x83\x9f\x74\xab\x94\xa5\xda\x76\xa5\xff\xe5\x25\xc0\xe5\x67\x8a\x54\x20\x2b\xe2\xbc\x06\xae\x65\x2d\x31\x1f\x7e\x7a\xef\xaa\x34\xfa\xa0\xe9\xfe\xe3\x19\x5d\x96\x3a\xbe\xbc\xcf\x76\x7b\xd6\xa5\x59\x85\x3a\xdc\x83\xce\x5a\x44\x74\x3d\x34\x0c\xf1\xe5\xa5\x25\xb5\x59\x76\x9b\x87\xb8\x2d\xd1\x7f\x13\x0d\x7a\x96\xfc\xbc\x30\x92\xb2\x60\xf8\x82\x91\xee\x7e\x41\x9f\x40\x07\x12\x05\x1e\xee\xde\x9b\x36\x5f\x13\xeb\xee\x10\x9d\xde\xd3\xa9\xee\x3d\x28\x7d\x25\xf5\x49\x6e\x80\xcb\xf4\xa8\xca\x5b\xf3\xdf\x6a\xf1\xf4\x83\x55\xd7\x0b\x4f\x99\xf7\xb0\x86\xf7\x8e\xa7\x8e\xb0\xbe\x5e\x86\xb9\xba\x22\xfa\x65\x3f\xc7\xfc\xbb\xe7\x8a\x5b\x34\xb6\x87\x09\x40\xf1\x32\x0e\x8a\xbb\xed\x1e\xe2\xae\x72\x0a\x0a\xe3\xd2\x8f\x4c\x38\xd1\xf5\x67\x3b\xd2\x3e\xc8\xa6\xd7\xb2\x1e\x0c\xbf\xe0\x4e\x8a\xd7\xd4\x7d\x25\x8f\x2d\x99\x99\x56\xcd\x5f\xc9\xe0\x65\x40\xa7\x1c\x5a\xef\x8c\xce\xd2\x43\xd6\xad\x77\x9b\x2e\x5d\x52\xf9\xcb\x8f\xdf\xe1\xcf\xaf\xef\x58\x58\xbc\x7e\x6a\xf6\x58\x79\x7d\xb7\x70\x97\x1c\x41\x11\x74\xe3\x8b\x95\x5b\x50\x80\x71\x02\xa7\x49\xac\xa9\x3a\x2c\x96\x70\xf3\xbb\xe1\x17\x9f\x29\xee\x6b\x7a\xde\xa3\x3b\x5c\x2b\xda\x3a\xa8\xd4\x5b\x65\xe1\x5b\x7a\xc7\xd3\x1f\x37\x47\x9e\xb5\x46\x2a\xad\x0f\xfa\x48\x8f\xa1\x63\x83\x52\x95\x55\x71\xfc\xfd\xeb\x62\xb8\xcb\x84\xc1\x56\x01\x14\x41\x8b\xa8\x66\x44\x61\x45\x6f\x5a\xc5\xfd\x58\x9a\x51\x44\xe6\x40\xee\xf0\xac\xf1\xfa\xd6\x74\xba\x4b\x99\x0a\x34\x48\xee\x1d\x1c\x22\xc5\x34\x7e\x2b\x1f\x3d\xd5\x7a\x36\x24\xa6\xb1\x8d\x67\x79\x79\x3f\x3b\x6e\x88\xef\x65\x2e\x39\xac\x24\x44\x03\x3b\x3c\x93\xfb\x31\x76\x00\x82\x89\xd5\x1a\xf1\x26\x98\xc1\x74\x57\x07\xc1\x9b\x5f\xec\x51\xcc\x2b\xe4\xff\x6a\x68\x35\x58\x77\xaa\x7d\xfd\xfe\xf7\x4d\x97\x1e\xb3\xde\x64\xc0\xdf\x96\x53\x33\xe8\x3b\x43\xdf\xfe\xb0\x5a\xcc\xea\x68\x5c\xaf\x36\xd2\xe7\xe9\xf9\x5d\x89\x38\xa5\xc9\x58\x03\x9a\x4b\x08\x38\x93\x36\x0d\xa0\xaa\x5d\x55\xc4\xd9\x87\x3d\x0e\x67\x8d\xac\x69\x8f\x54\x52\x20\xb5\xbf\xea\xd0\xc0\x62\xfb\xfa\xc5\x05\x99\x12\xc3\x4b\xc7\x1a\x8f\xcf\xaa\x3a\x3e\x45\xf8\x83\xd2\xd3\xc3\xfb\x9c\xd7\xe7\x04\x78\x5e\x91\xea\x18\x51\xe9\x7a\xd7\xd1\xe1\x18\xa8\xf4\x49\x1d\x47\xc6\x6a\x58\x02\x90\xd1\x7d\x1e\x48\x22\x5b\x5b\x21\x5c\x96\xd7\x6d\xad\x09\x89\xbf\xbc\x67\x26\x17\x66\xea\x9b\x99\x3e\xba\x69\xea\x5d\xab\x18\x7a\x0e\x21\x41\x73\x6d\xe5\x5d\xd5\x03\x62\x09\xbc\xc9\xd2\x10\xbe\x51\xcf\x59\xf4\x62\xa1\x9f\x74\xe8\xf2\x75\x7b\x37\x1a\xdd\x10\x1c\x07\x2d\x11\x57\x02\x9a\xcb\x2b\xc7\xd4\x11\xca\x5a\xb0\x1e\x93\xb5\xad\xf0\xf1\xdf\x12\xfa\xfe\x6e\xe6\xb9\x5d\x0a\x4b\xc9\x0f\xfe\x48\x5a\x8c\xda\x60\x6a\x8e\xaa\x8c\x88\x07\x8f\x89\xd3\xc3\x06\xc3\x2c\x0b\x5e\xfd\x65\x4f\xa9\xf4\xa9\x96\xe2\x2a\x77\xa9\x8e\x7d\x77\x88\x51\x02\xc3\xc5\xe9\x70\xb5\xc7\x86\x51\x59\x0a\xd2\x19\x80\xdb\xc6\xb7\x38\xfe\xfa\xe7\x03\x96\xe7\x22\x32\x1f\x4b\x64\x7a\x25\x4d\x5b\x5a\x94\x82\xfb\xf8\x11\x83\x91\xc4\x6a\xb5\x7c\x7f\x7f\xd3\xb2\xb5\xb3\x3e\x6c\xdf\x24\x95\x45\xeb\x53\xda\xb4\x3a\x33\x4c\x78\xde\x38\x2c\x1f\x24\x79\xa6\x0a\xb0\x60\x3f\x9e\xce\x28\x6e\xf5\x11\xf4\x5b\x91\x27\xd3\x8d\xac\x53\xeb\x7e\xa6\x5f\x7e\x32\x41\xcc\xdb\x3b\x71\xe9\xf5\xaf\x9c\x5d\xc1\xf1\xa7\x78\x90\x6c\xa1\x5f\x46\x68\x57\xe5\xd9\x4a\x71\x85\x75\x2b\x66\x75\x5e\xaa\x44\x9d\xcc\xc5\x76\x4e\xb5\xbb\x7f\x7e\x95\x7e\x53\x42\x60\x5c\x46\xa0\x08\xfa\xa1\x77\xa5\x9d\xa0\x17\xed\xc3\xb7\x32\x0d\x9f\x98\x19\xe9\xff\xb9\xf5\x9c\xbb\xe6\x4a\x1f\x95\x47\xea\xd2\x59\x28\x12\x99\xf1\xae\xea\xd0\xd0\xb7\xa9\xb9\x99\x63\x2e\x51\x21\x1e\x21\x3b\xa6\xd2\x4b\x23\x9d\x19\x6f\xce\x09\x82\x72\x01\x49\x09\xa8\x76\x5e\x35\x2a\x43\x41\x68\x86\x8c\x84\x14\x2c\xb7\x49\x3e\x6d\xad\x77\x44\xf0\x6b\x2c\x36\x9f\x7b\x6f\x3a\xe5\xed\x94\x16\x63\x86\xb4\xce\xeb\xb5\xbe\x2a\x36\xc7\x19\x74\xf1\x3e\x8b\x1a\x44\x5d\x1c\xe6\xd9\xcc\x5d\x4b\x90\x16\xc4\x4e\x3b\x36\xc0\x3c\xf8\x54\x12\x17\xa2\x41\x7c\xdd\xd8\x17\x6f\xfe\xae\xd2\x01\x4b\x3f\x5e\xba\x71\x48\xa3\x7f\x70\xf0\x43\x2c\xfb\x3c\xd7\x4c\xea\x18\x01\x92\x97\x35\xd5\x9e\x15\xab\xe5\x4d\xb6\x16\x63\xa6\x65\xdd\x33\x17\x2b\x43\x86\x9e\x17\x1d\x90\x16\x13\xa0\x70\xb6\x36\x0d\x70\x7c\x95\x1d\x7b\x1e\x00\xa1\x0c\xf2\x9b\x14\x2c\xe9\x2f\x13\x9e\xed\xc3\xe9\xe1\x86\xc5\x58\x37\x67\xa8\xc5\x87\xe7\x62\x9f\x6a\x2e\xa6\x61\xa0\xea\xe8\xc7\x10\x89\x76\x89\xe2\x4d\xfe\x30\x8f\x94\x69\xbf\x1a\x61\xd4\xb9\x2f\xc4\x6d\xd7\xf0\x39\x2f\x01\x3f\x5b\x49\x52\x02\x9e\x56\xff\xaa\x87\x89\x64\xed\xad\x94\x57\x1c\x2c\x7e\x51\x4e\xd5\x3e\xe7\x65\xbf\x76\x62\x27\x8a\x52\x9d\x74\xf5\xf3\x87\xff\x24\x76\x89\xff\xf6\x5c\x54\x11\xd0\xda\xd2\xd5\x48\x1f\x7b\xe1\xdf\xd2\xc6\xca\x0e\xad\x16\x14\xd7\x18\xab\x61\x09\xe0\x9d\x63\xb1\x26\x4e\xc2\xc8\x5f\x02\xbf\x62\x35\xd7\xd7\x72\xd3\x3b\x9a\x0d\xfc\x78\x52\x0d\xeb\x27\x07\x62\x00\xc6\xa0\xf8\xba\xb0\xd7\x0d\x5d\xfb\xaa\xe8\xcb\xd6\x47\x7c\x7c\xd5\xf5\xd8\x72\xf6\xc0\x54\xfd\xd9\x90\x63\xd8\xc2\x34\x0d\x7c\x8c\x7b\xb0\x04\x70\x4d\xa7\xf8\x2f\x12\x39\xd6\x9d\xf9\x95\x47\x1f\xe2\x60\xe7\x2d\x12\xa5\xbb\xd3\x3f\x5a\x1a\x97\x45\xc6\x64\x54\x72\x39\xc3\x8a\x25\xa5\x25\x5b\x4f\x97\x2b\x92\x29\xf9\x36\x14\x6c\xf8\x67\x98\xf8\x84\x54\x4d\x5d\x1b\xb9\x1d\xe3\xe6\x9d\x9b\xc4\x7e\xe8\x76\x55\x6c\x5d\x56\x2d\x24\x28\x67\x40\x74\x3b\x3e\xae\x35\xbc\xf3\x30\x3f\xa6\x64\xb6\xca\x36\x69\xa9\x42\x83\xdb\xcc\x53\x6a\x81\x37\xe5\x5a\x3f\xf1\x3a\x32\x0e\x8f\x61\x91\x1e\xc5\x55\x57\xbb\xa6\xc5\xa5\x24\xfc\xe6\xfa\x14\xab\xbc\x23\x23\x02\x42\x8d\x66\x05\x07\xa6\x29\xf1\xb9\x28\x88\x94\x3c\x4b\x5a\x0b\x42\xd2\x78\x4a\xdf\x67\x39\x13\x9a\xc5\xbd\x85\xf1\x1a\xf8\x8d\x69\xc5\x0e\xa1\x55\x92\x5b\x63\xa6\xcf\x7e\x54\xcf\xbe\xe2\x48\xda\xbd\x8f\xd6\xce\x7e\x3a\xc2\x68\x69\x94\x32\xd8\x73\xda\x7f\x8a\xeb\x6b\x39\xbc\x85\xd1\xa2\x9f\xd4\x8b\x43\xd0\x0e\x50\x04\x9d\x67\xf9\x7b\x15\x7d\xd6\x60\xbf\x3e\x4f\x15\x62\xc6\xd7\x15\x63\x26\x07\x4e\xef\xfc\x72\xf0\xdd\x87\x4f\x2f\x97\x32\x00\xc6\x63\xb1\x01\xb3\xb0\x1d\xdd\x5b\x18\x8a\x6d\x27\xc2\x42\xdb\xe2\x2c\x97\x1c\x0f\xef\x6c\x78\x5c\xd1\x70\x56\xe0\x03\x9e\xa0\x08\x3a\xf3\xc9\xf9\x10\x4c\x6c\x46\x98\x43\xf5\xf4\xc8\xf3\x63\xbf\x93\x54\xe4\xed\xeb\x1b\xec\x26\xa6\x52\xe9\x0b\x83\x1f\xc9\x1d\x20\x7f\x31\x3d\x15\xe7\xdf\x34\xd2\x70\x39\x05\xe6\xd7\x5f\xf6\x9c\x2c\x40\x95\x2e\x68\x6e\x11\xb4\x93\xd9\xee\x89\xdf\x84\xa5\x76\x9f\x62\xa4\x9d\x97\x1e\xd0\x62\x79\xae\x24\x6d\xda\xf2\x34\x27\xd9\x7f\xb4\x82\x2e\x6d\xef\x79\x22\x8b\xc3\x6a\x4b\xa9\x19\x25\x5e\xa2\x50\x5f\x84\x50\x79\x2d\xd9\xb2\x55\x9f\x27\xc2\xdd\xbf\xac\x2c\x5d\xcd\x16\x7c\xd2\xe9\x9e\x2a\x0d\x88\xdf\x72\xdb\xcb\x06\x45\xfe\xc2\xd3\xb9\xf4\xc8\xb9\xb6\x37\x6a\x2a\x6b\xbf\xf3\xb8\x6f\xb0\x7c\x4e\xf3\x48\xdf\x33\xef\x73\x51\x67\xab\xd4\x59\x2f\x75\x89\xe4\xde\x9b\x08\xbf\x20\x05\xfb\xfe\xbb\xb1\xd2\xf3\x90\xe7\xb3\xb5\xaa\xa5\x60\xa6\x80\x5c\x65\x15\xa3\x20\x52\xfe\x5b\xd7\x1c\x82\xd5\xe4\xb2\x7c\xb9\xe9\x1d\x3d\xc3\x9b\x2f\xa6\xc9\x54\x94\x0e\xa7\x54\x5d\x7d\xf2\x79\xb3\xeb\x22\x55\xd9\xac\x4c\x84\x3f\x9a\x62\x58\xe8\xd7\x3e\x35\x14\xe9\x27\xe9\x50\x9b\x5b\x27\x24\x9f\xe3\xf2\xfe\x52\xf3\x9a\xd8\xa4\xed\xf8\x99\x43\x1f\xbf\x17\x25\x38\x55\xfd\x69\xbb\xfb\xe2\xfd\x4e\xe7\xec\x28\x0b\x19\xbc\x0b\xa9\x8d\x7f\xb9\x70\xdc\x42\x6b\xbd\xba\x9e\x7d\x3e\xbd\xe6\x3b\xaf\xd3\xfd\xf0\x72\x7d\x5b\x08\x26\x2e\x5f\xde\x29\x86\x9e\xbb\xb0\xd2\x09\x82\xb3\x57\xe2\x40\x5e\xde\x2f\x7b\xc7\x3e\x69\xee\x97\xf7\x9c\x67\xc1\x9f\x91\xd7\x1a\xaf\x93\xb1\x23\xc3\x96\xef\xa5\x7d\xc4\x7a\x5c\x7a\x4f\x7b\x77\x98\x44\x65\x78\xe7\xf9\xad\xef\x11\xf0\x92\xac\xf3\x28\x88\x6c\x17\x5b\xb1\x10\x82\x7f\x28\xf8\xac\xc1\xd2\xe7\xc6\x5f\x05\x68\x8d\x3d\xef\x55\x94\x6a\x20\x7a\x7e\x1b\xc9\x1e\x79\x2d\x5f\x73\xae\x62\x28\xad\xc6\xa2\x3b\x0f\x26\xee\x76\xa7\x85\x77\x54\x8f\x4c\xfa\x7a\x2c\x5d\xff\x74\x86\xb0\x7f\xdb\xb0\x63\x4b\x95\xbc\x8a\x20\x00\xec\x64\x03\xb4\x04\x47\x0f\x3c\xce\xd9\x93\x9a\x0b\xa6\xe9\x0d\x65\x11\x55\xf4\x41\xee\xeb\xfc\xc6\xa7\xf2\xb7\x0f\x9c\x74\x83\x60\x31\xb2\x69\x40\xfb\xfa\xae\xd6\xaf\xf4\xda\xd9\xed\xfb\xf6\xb0\x8f\xdc\x10\x50\x61\x7b\x71\x25\x20\x70\xef\x97\x51\x12\x99\x92\xaf\x8c\x67\xb7\x7b\x82\xbf\xd2\xdd\x1b\x78\x52\x93\xdb\x8f\x7c\xef\xc2\xce\xe6\x60\xcb\x7b\x3c\x0d\x39\x31\x00\x63\x8a\x30\x76\x4a\xc4\x2d\x9f\xff\x68\x44\x51\xf7\xca\xc2\x08\xf7\x5c\xce\x79\xd6\xdd\xd5\x14\x84\xe2\x05\x8a\xa0\x09\x39\x06\x21\x10\xbc\x0d\x27\x2d\xe9\x0f\xdf\x7a\x61\x31\x0d\xae\xca\x15\xd7\x6f\xaf\x08\x8c\xc1\x76\x16\x27\xbe\x22\x05\x6e\x62\x9a\xcf\x3e\x40\x0d\x93\xd6\x3f\x0e\xb0\x8c\x73\xd7\x5c\xd9\xfb\x66\xb1\xa4\xa0\x12\x79\x5b\x91\x0a\xf4\x96\x15\xc8\xe3\xfc\x27\x0a\xb9\x9d\x8c\xdd\x19\x28\xad\x6a\xfc\xda\xf7\xa2\x8b\x67\xd1\x20\x71\x52\xf4\xc1\xf1\xab\x39\x0b\x88\xe4\xac\x38\x76\xff\x5b\xf1\x2a\xfa\xc0\xf8\x8c\xff\xeb\x1f\x55\xfd\xd7\x13\xec\x70\xc7\x8a\x47\x41\x44\x78\x67\xa3\x85\x9c\xb5\x8b\xcb\xaa\x3a\xf7\xb5\x2b\xe7\x54\xb9\x7e\x99\x7f\xd7\x86\x16\xb6\x3c\x01\x84\x94\x15\x95\x7a\x87\xc4\xe9\x2f\xba\x03\xba\x9e\xf8\x7a\x2e\x7e\x30\x61\xdf\x3b\x15\xae\x20\x6c\xc5\xe2\x60\xd5\xf8\xe1\x2a\xce\xe1\xee\xe2\x76\x1c\xd6\xa2\x65\xa5\xac\x3f\x6b\x79\xe0\xd3\xb7\x01\xbb\xce\x74\x42\x92\x48\x9c\x6b\xb0\x01\xc5\xd1\x96\x91\xd9\xf0\x7d\xe5\xe9\x93\xc3\x26\xe7\x8c\xaf\x2b\x7e\x5e\x6d\x69\x23\x4d\x41\x98\x57\x70\x0a\xf8\x28\x3b\x95\x48\xce\xba\xc7\xce\x88\x5b\xff\xcd\x87\x6f\x39\xf7\x1b\xcb\xfd\xeb\x74\xc6\xc2\x6c\xf3\x40\xe3\xe3\xa2\x13\x6f\xdb\x1c\xb8\xe3\xb4\x8c\xcd\x5c\x33\xc8\x58\xde\x75\x9d\x91\xe1\xe9\xb0\x2a\x5f\x1c\x7f\x3c\x3f\x4a\xdc\xdf\x8c\x02\x8c\x70\x92\x12\xd0\x9c\x93\x56\xa8\x8a\x30\xef\x42\x61\xdc\xf4\x8e\x41\xcb\x09\x0e\xdd\x19\xeb\x19\x79\x35\x70\xbc\x73\xba\x9e\xcd\xc3\x83\xba\xa2\xd2\x40\x9f\xf8\x70\xeb\xcf\x84\x91\x8a\xdb\x0e\x24\x91\xb6\x72\x47\x41\x00\xaa\x84\x44\x01\x79\xfd\x2b\x4d\x44\x72\x56\x0c\xd2\x5d\x87\xb5\x8c\x9a\x60\xa7\x1c\xda\x54\x4f\x1f\x1c\xb6\x94\x9a\x75\xe8\x28\x58\x16\x75\x95\x00\x42\xc7\xe4\x43\xdb\xcf\xcc\x55\xb1\xfc\x42\x8f\xb9\xae\x33\xcd\x78\x5c\x13\x44\xfd\xc7\xd9\x6e\x00\x84\xf6\x11\x8d\x52\x30\x87\x36\xb9\x49\x2f\xcc\x60\x4f\xa0\xf5\x73\xff\xb9\xaf\xfb\xdb\xd9\x4f\x57\x9e\xf8\x23\xbc\x68\xe4\xfb\x40\xaf\x41\x34\xca\x8f\x38\x3d\x07\xf2\x04\x8c\x6d\x1c\x96\x00\xb2\x5d\x64\xa5\xa5\x29\x8e\xb2\x0c\x33\x23\x6c\xda\x85\x17\x71\xb4\x58\x31\xaa\xf8\xb1\x99\xea\x9f\xe0\xc9\xe6\x7b\xab\x3f\x50\x10\xe6\x75\xe8\xe3\x8c\x66\x74\xe3\x40\xe4\x96\xd0\xb6\x31\x75\xf3\xf9\xab\x38\x6c\x53\x58\x02\x78\xcd\xe0\xac\xc4\x43\xb0\x0d\x9e\x9d\x47\x8e\x6b\x59\xd5\x79\xf3\xb9\x8d\xb3\xbf\xbc\xe7\xe2\x6e\x3d\xd9\x7f\x2f\xd0\x02\xcf\x06\xb5\x88\x81\x41\x41\x51\x9f\xf5\x3a\x94\xf8\xc1\x9f\xb1\xee\x0a\xc0\x21\x7e\x54\x0a\x82\x89\x09\xba\x34\xc0\x31\x97\xd5\x42\x22\x67\xdd\x41\x9e\x4c\x63\x35\x29\x15\xb2\xc7\x82\xcd\xb7\xa7\x2a\x56\x14\xfd\xfa\x5d\x55\xf2\xd0\x36\xa3\x7e\x43\x27\x4c\x4c\x30\xf0\x1f\xf8\x6e\x51\xa5\xf8\xe9\xb6\x7c\xf4\xac\x66\xa2\xca\x25\xa2\xa0\xd2\xa6\x04\x7c\x81\x85\x7d\x34\xe5\x64\xec\x6c\x74\x88\x98\x67\x3b\xf7\xd9\xfe\xf7\xf1\x4b\xc2\x9e\x05\xf8\x9a\xca\x31\xfe\x22\x4c\xa4\x15\x59\x4d\xf5\xa7\xe5\x89\xeb\x6a\xd6\xd9\x7c\x42\x7e\x08\x2a\x34\x28\x88\x38\xe9\xa8\x4a\x42\xf0\x5d\xc5\x37\x29\x98\x8b\xdb\x5c\x9c\x2a\xc7\x10\xd3\x6a\xb4\xa4\x9c\x3e\x3e\x78\x0b\xf7\xc7\x3f\xe3\xf1\x82\x23\x9d\xae\x6b\xf6\x3b\x4f\x6d\x5b\xe3\xfa\xdd\xef\xdb\xbb\x7e\x58\x43\x06\x87\x0a\x3c\x79\xa2\xbd\xbf\x6f\x87\x22\xb3\x78\x72\xae\xe5\x37\x13\x3c\x2b\xe5\xd1\x24\xdd\xf1\xfc\xc7\xcd\x91\x76\x77\xfb\xe0\x27\xac\x33\x65\x2b\xad\x28\xc6\x0c\x1e\x07\xc5\x0a\x87\xe4\xbb\xaa\xc3\x66\xef\xdd\x28\x39\x4d\x22\x53\x1c\x17\x81\x22\xe8\xbb\x84\x8d\x14\x98\x58\xad\x43\x3e\xc5\xb5\x6c\x6a\x5b\xc2\x3f\x5e\xac\x18\xe3\xa2\x67\xb1\xee\x93\x4c\x6c\x0a\x1e\x9c\x66\x9e\x60\x74\xee\x9d\x16\x83\x91\x85\xe0\xd5\x66\xdb\x9b\x30\xb1\xd9\x81\x0d\x30\x23\x8d\x13\xa5\x41\x88\x20\x56\xdd\xce\xfb\x65\xc2\x29\xa1\x94\x7e\xc4\x0c\x7f\x3d\x32\x70\xac\xdf\x09\x13\xef\xe9\xc7\xd3\xad\xd8\x5b\x9d\x59\xd4\x3f\xc1\xd3\xe1\xef\x9b\x27\xcf\xf0\x48\x64\x6d\x67\x36\xc0\x74\xf5\xcb\x91\xa6\x38\xaa\xe3\x78\xe0\x35\x71\x0b\x73\x35\x98\x4d\x6f\xb5\x34\x76\x4b\x30\xc6\x4e\xd9\xbb\x7b\x64\x64\x5b\x47\x01\x0c\x57\xf8\x4a\x7a\x71\xc8\x54\xf3\x50\x66\xcc\xcb\xa1\x0a\xd7\x8d\x0a\x5a\x20\xae\x1a\x2a\x10\x5b\x71\x4e\x86\x82\x30\x25\x70\x26\x7a\xde\xda\x92\xcb\xf2\xf1\xdf\x32\xf5\x21\xda\x25\xe6\xb6\x66\xbd\xc1\xd9\xc6\x64\x5c\xf5\xc4\xa5\x6b\x4e\x7e\xf6\xe7\x7f\x4b\x8a\x3a\x6f\x21\xb2\x49\x60\xcd\x00\x28\x82\x42\xf1\x2b\x37\xa0\x48\x39\x7c\xc0\x8f\x1f\x72\x93\x6a\xce\xdb\xf7\x89\xf7\x2b\xe4\xe7\x84\x15\x0a\x30\x0a\x09\x92\x4a\x1e\xf2\xfc\xa0\x36\xdd\xaa\xe6\x76\x92\x57\xcc\x81\x08\x08\x26\x36\x7b\xb0\x01\xe6\x84\xc6\x0d\x0a\x08\x0d\xca\xbd\x49\xc1\x9e\xe4\x4e\xef\x8e\x1d\xaf\xc3\xf4\xaf\x2b\x9c\x33\x78\x4c\x0f\x72\xa5\x9a\x96\xdf\xd6\xbb\x82\x7b\xda\x0d\x78\x8d\x9d\xbb\x82\xbe\x1b\xdd\xf9\xe6\xd5\xb6\xa4\x6a\x41\x0c\x0a\xb4\x67\x03\xcc\x93\x45\x57\xec\x50\xa4\x1e\x4f\x0f\x2c\x79\xfd\xac\xbb\x75\x47\xe3\x56\xd8\x16\x62\x8c\x88\xe0\x9b\x16\x54\x8d\x77\x53\x84\xed\x28\x52\x4e\x08\xbe\x5d\x96\x35\xfa\xfe\x5e\x98\xc8\xc7\xfe\xdb\x59\x03\x78\x10\xbd\x8b\x82\xc8\xf9\xa9\x1b\x9a\x08\x65\x19\xe3\x4b\x0a\x86\xd3\x51\x4f\xd7\x10\xe7\x9b\x69\x11\x52\xc5\x78\x68\xda\xed\x44\xab\x39\x7f\x72\x89\xca\x8e\xbd\xa9\x6d\x99\xc2\xf9\x9e\x7a\xb2\x01\xe6\xdc\x83\x2b\xd6\xf3\xd7\xa1\x06\x06\x0a\x31\xd7\x46\x9e\xca\xba\x32\xdb\x29\xde\x32\xef\xe8\xb7\xe5\x67\x8d\x73\x2e\x05\x12\xc9\x59\x19\xec\x6b\x77\x6e\x5d\x56\x65\xbf\x23\x9d\xfc\x7d\x33\x2d\x15\x9f\x2f\x1b\x05\x91\x87\x3c\x05\x2d\x84\xd9\x8f\xc7\xcb\xee\xab\x1e\x13\xf4\xea\x47\xcf\xba\x3e\xfa\x36\x12\xad\x2c\xe6\x9c\x32\x15\xf0\x58\xc0\xd7\xa6\x5a\x96\x3a\xbc\xdd\xbc\xa4\x41\x32\x47\xb3\x61\xb6\xfb\x20\x7f\xe3\x3f\x8e\xed\x11\x9b\x77\x53\x26\x38\xd8\x61\xc3\xcf\xe7\xe2\x06\x27\xee\x36\x75\xb4\xed\xfb\x28\x66\x73\x11\x60\x98\x89\xd7\xf5\xa7\xcb\x54\xdc\x8c\x08\x7c\x4b\x66\x36\x38\xdd\x58\x02\x42\x9b\xc5\x95\x00\xb9\x95\xc7\x2d\x20\x61\x1f\xce\xc5\x9d\xdb\x47\xfe\x7d\x0b\xc3\xb1\x90\x57\x95\xbc\xcb\x18\xdb\x31\xb0\x66\x83\x0c\x05\x4f\xc8\x1e\xdc\x9f\x26\xd3\xb6\xaa\x2c\xf8\xa2\x70\xb5\x86\x44\xa6\x50\x35\x41\x11\x74\x55\x40\xd8\x2a\x10\x5a\x81\x43\x0b\x0b\x6b\x29\xbe\x6b\x67\xe2\xc7\xb7\x2c\xe0\x68\x24\x63\xda\x39\x5a\x3e\x10\xbc\x46\xf1\x4d\x0c\xa0\xf8\x57\x07\xed\x00\x31\xff\xc5\x25\xba\x2c\x05\xa1\x0d\xe0\x99\x91\x09\x57\x12\x82\x8f\xe8\x31\xa7\xf3\x02\x02\xee\x6f\x41\x91\xa7\x50\x34\xf0\xfe\x86\x7d\xe7\xff\x5c\xb2\x02\xa1\x9f\x24\x1f\x7e\xed\x93\xe2\xd5\x20\x04\x6f\x91\xa1\xc2\x4a\x36\x6f\xb8\xc2\x06\x38\x40\xfb\x4b\x3d\x8f\x82\x50\x40\xdc\x78\xc7\xe3\x2c\x65\xdd\x26\xd2\x91\x82\xfb\xd6\xff\x18\x67\x13\x1b\x60\xaa\x45\x91\x45\xf1\x71\x70\xf1\x66\x7f\x9a\xd4\x8a\xdf\x8d\x4a\x8c\xe0\xb0\x08\xeb\xfc\x34\x10\x8a\x19\xe3\x34\x71\xe4\xd5\xb8\x77\x54\xe2\x71\x09\x25\xf7\x41\xd8\xee\x7f\xae\x16\x11\x13\xf4\xe2\xd3\xa7\x3f\xf3\x92\xdc\xe0\x5a\x5c\x68\x09\xe8\xa4\xe8\x83\x43\x26\xa2\x46\xb0\x92\xc8\xad\x24\x99\xff\x7e\x8b\x32\xfe\x29\xc2\x77\xa2\x17\xac\xc1\x62\xea\xbf\xb4\xb4\xf1\xaf\xb7\x97\x5f\xd3\x8a\x2f\x42\x55\x91\x6f\xe3\x49\x16\xff\x3d\x2e\x24\x4d\x71\x14\x61\xf4\xa6\x60\x6d\x11\x2d\x9e\x0b\x8b\xee\xc8\x11\xc9\x3e\x1b\xd8\x64\xa6\x50\x10\x1b\x50\x04\x5d\x5e\xab\x7f\x18\x82\xfb\x9b\x78\x52\x81\x93\x2b\x96\x83\x10\x7c\x4d\x91\x0a\x5b\x47\x8a\x03\x82\xc9\x45\x81\x83\xec\xb1\x9d\x10\xdc\xaf\x3a\xd2\xef\xfb\x63\xae\x8e\xde\x5d\x88\x0d\xd4\xb1\x38\xac\x65\xa7\x76\x28\xc0\xc4\xa7\x5b\x99\x6b\x61\xd7\x55\xfb\xed\x4f\x49\xf6\x6a\xe3\x38\xf6\x10\x67\x1d\xe2\xba\x2b\x21\xb8\x48\x3f\xde\x04\x53\x99\xfd\x69\x52\xc4\xff\xeb\x95\x8a\x45\xe0\xeb\xfe\x11\x52\x3d\xfd\x55\xce\xf2\x75\x10\xfc\x4e\x06\xdf\x9c\x5c\x4b\x73\x25\x28\x41\x70\xb5\xf1\x7f\x4a\xb2\xc7\xa4\x63\xcb\xa7\xb1\x9e\xa1\xcc\xb5\xdc\x8e\x1b\xb7\x3b\x29\x30\xb1\xd9\x89\xf6\x31\x11\x88\x06\x82\x1e\xe7\x95\x11\xc9\x3e\xee\x6c\x80\x19\x64\xb5\x95\x82\x50\x20\xc6\xe7\x4d\x91\x1c\x4f\xae\xca\x4b\x4d\x59\x35\x10\x4a\x24\xdf\x6c\x9d\xba\x80\x58\xa1\xdb\xe4\xac\xc8\x30\x71\x37\xee\x7f\xc2\x56\xd3\xa0\xc2\x9b\x14\x8c\x75\x79\xaa\x90\x5e\x4f\x2f\x0d\x39\xd7\x2d\x4d\x71\x0c\x01\xad\xd0\x12\x17\xd7\xc8\xff\xf6\x2a\xb7\xa2\x88\x1c\xb4\xca\x04\x8b\xf9\x0b\xd7\xff\xd6\x82\x7a\xd8\x06\x45\x08\x84\x64\x2b\x54\x59\xb3\x2e\x97\x16\xb8\x51\x0d\x41\x91\xb5\xf8\x3e\x3d\xdf\x97\x08\x30\x20\xd8\x63\xb6\xbb\x71\xcb\x29\x8e\xa5\x1f\x1f\x7b\xef\xcd\x17\xff\xa2\x71\x83\x82\x30\x1b\xa0\x69\x72\x67\xe5\x78\x9e\x58\x34\x10\x94\x5b\xf8\xfd\x7f\xce\xdd\x02\x0c\x1f\xdc\x2d\xfd\x8e\xf3\xc2\x8d\x46\xd7\xaf\x77\xac\xc7\xba\x8d\x79\xb7\x71\xf7\x21\x29\x41\x4f\x39\xbf\xda\x60\x62\xb3\x2d\x1b\x60\x7a\x8c\x27\x2c\x04\xa1\x5b\x28\xa7\x63\x8d\x80\x48\xd4\x88\x4f\x99\xb2\x05\xaf\x32\x12\x08\x12\xc0\xce\xdc\x8a\xef\xa4\x66\x47\x0a\x87\x83\x75\x76\xb9\xbb\x4d\x78\x56\x59\xac\xd0\x80\xe0\x6c\x55\xaa\x1f\x6d\xef\x73\xda\xdc\xd7\xf5\x92\x8e\x97\x4f\xc4\x9e\x01\x18\xd7\x60\x09\x60\xdb\x99\x62\x3d\x10\x1a\x54\x0c\x6d\x8b\xc3\x56\x7c\xab\xe5\x9b\x18\x63\x99\x31\x8c\xa1\x8a\x89\x2a\x53\x35\x1c\x34\x8c\x60\x09\xf0\x28\x0c\xc1\x3b\x15\xa9\x80\x75\xc4\x82\x37\xe9\xc8\x76\x5c\xc8\x0c\x13\xbe\x77\xc2\x57\xa3\x14\xec\xe5\xb2\x7a\x7a\x02\x41\xce\x08\x84\x76\x90\x8c\x62\x80\xda\x98\xd3\xe6\xc8\xee\x28\x59\x3d\x7c\x9f\xa8\x80\x8f\x78\xc6\x59\x32\xc5\xf1\x01\xca\x61\xd9\x45\xf8\xca\x52\x10\xca\x7a\xc6\x69\xa2\x7f\x57\xf4\x93\xb3\xaa\xc8\xe9\x67\xb2\xab\x04\x8b\x03\x84\x8b\x4b\xd0\x1b\x99\x89\x6d\xe4\x59\xde\x1f\xbd\x85\x7d\x3c\x6b\x88\x69\xf8\xa9\xd5\xca\x52\xe2\xef\xa1\xaa\xc8\x35\xaf\x24\x23\x46\x0f\x41\x02\xb8\xd6\x01\x01\xc4\x6a\x5d\xdc\x72\xce\xff\x29\xc4\xca\xa6\xb1\xe6\xa1\x64\x2c\x34\x24\xfd\x0d\x89\x9c\x75\x16\x55\x45\x94\xb3\xb5\x17\x40\xf0\x6b\x35\x1a\x40\x55\x58\x99\xa7\x81\x30\x57\x43\xc9\x98\x30\x78\x91\x63\xf7\xd2\x3e\x5a\x80\x0d\x63\x01\x68\x56\x8e\x76\x28\x04\x6f\xd1\xa5\x01\xd4\xbd\x2b\xf2\xd4\xf1\xb7\xe8\x13\xdd\xdc\xdb\xd3\x79\x76\xc6\xf0\x46\x14\xa7\x9b\x1c\x29\xfb\xc6\x0a\xb5\x91\x47\xf9\x47\x7d\x9c\x79\x6f\xdf\xe0\x30\xd6\x2a\x4d\x05\xbe\x48\x5e\xc9\x24\x92\xb3\xd2\x90\x76\x1e\x7d\xea\x35\x96\xe5\x41\xcb\x88\xed\x7d\x5b\x65\x5e\x3a\x40\x97\xb3\xf7\xd8\x28\x48\x22\xc7\x54\xd9\x51\xbf\x1b\xd7\xc0\xda\x22\x1d\xc9\x30\x51\x4e\x93\x06\x38\x2a\x3d\x8b\x22\x92\x7d\x9c\x70\xf0\xfb\x58\x9a\xde\x15\x4d\x7f\x7c\x1c\x1a\xd6\x42\x50\x64\x01\x41\x02\x34\x8b\xfd\xdd\x04\x13\x77\x6f\x64\x03\x4c\x19\x2f\x32\x0c\x42\xea\xa4\xb2\xae\x6f\xcf\x1b\x79\xbe\xfc\xad\xd9\xf9\xbf\xf0\x10\xbc\x10\xb4\x42\x8f\x34\x6f\x90\x83\x89\x81\x5b\xd8\x00\xd3\x30\xf7\xca\x36\x14\x59\x42\x18\xe3\x60\x56\x26\xd5\xa7\xbe\xb2\x38\xac\xcb\x29\x2f\xef\xe0\x40\x8d\xa2\xaa\x08\xf8\x06\xb7\x34\x39\x50\x04\xdd\xf9\x65\xe5\x36\x94\xed\x43\x38\x70\x9c\x2f\xb5\xb8\xfd\xad\x37\x7f\xeb\x3e\x9f\x3c\x75\x41\xdb\x72\x4c\x95\x4d\xf8\x6a\x01\xde\x28\x59\x45\x7b\xa5\xa3\xbe\x14\x82\xf7\xe2\x1b\xb6\xb9\xf1\x14\x89\xdc\x9b\xc5\x4e\xf7\x9c\x7d\xe1\xd3\xc8\xfb\x55\x4f\x8f\xe4\xd0\xb3\x32\x59\x19\x5b\x24\x85\xb7\x53\x65\x5d\x47\xbe\x5c\x06\x17\x82\x66\xde\x03\x6f\x60\xa2\x1c\xce\x85\x1e\x15\xb7\x10\xc9\x4a\xf7\x91\x76\x1e\x36\xbe\xd0\x73\xc9\xcf\x9c\x9c\xf1\xd5\xf5\xf4\x67\xfd\xaa\x8b\xf1\xa8\xa3\x46\x23\x36\x5d\x48\x9b\x94\xa1\x38\x1a\x83\x22\x68\xb6\xd3\xa6\x75\x30\x11\xae\x67\x6d\x17\xd4\x20\x9c\x64\xa8\x70\xd6\x4e\xe3\xea\x8d\x28\xa2\x0b\x89\x02\x8f\x7b\xc3\x0c\x40\xe8\x98\xe2\x14\x96\x3b\x77\xfa\xa9\x37\x3f\xdd\xa8\xcd\x0b\x3b\xd6\x41\x96\xb4\x46\x01\xc6\x05\xa2\x12\xb4\xfb\x1e\xb9\x92\x82\x30\x35\x40\x11\xf4\xc6\x54\xd9\x2d\x80\x11\x2c\xd6\x53\x8f\x2d\xf0\x9b\x3a\x62\x2e\xb5\xe7\x4b\xc1\x18\x07\x33\xf4\x8d\xe0\xb0\xa8\xfe\xe2\x38\x47\x86\x41\x2b\x54\xa6\x69\xa5\x0d\x8a\xec\x23\x48\x00\xdf\xde\xf5\xd1\x70\x6f\x46\xb8\x96\x1e\x6b\x87\x05\x09\xbf\x1a\x01\xd7\xc2\xef\x46\x31\xdb\xe8\x43\x0a\x2f\x1e\x3d\xfb\x67\x9d\xed\x31\xb1\xec\xfd\x2d\xcc\x79\xaa\x2c\x49\x78\xf5\xcc\xd5\xd4\x74\xa9\x7e\x5d\x85\x5a\x13\x99\x49\x0b\xad\x95\xa6\x34\x15\xa2\x20\x52\x7b\x4e\x12\xc1\x85\x0f\xe3\x62\x0c\x6b\x83\x59\x4b\xff\x19\x9d\xa4\xb9\xe2\x9d\xa7\x87\x35\x10\x14\xa9\x86\xa2\x01\x69\x45\x87\x5e\x58\x46\x81\x0a\x58\x3f\x9f\xda\xa9\xe5\x58\x84\x0c\xd4\x63\xde\x9f\x2b\x8c\x24\xc6\xff\x24\xd5\xfb\x44\x08\x4e\x9d\xda\xb0\xc9\xcc\xe5\xa1\xb5\x32\x14\xea\x71\x50\x04\x0d\xf8\x18\xa6\x2e\x98\xbd\x4f\x0b\x5b\x3a\x7e\x8b\x5e\x4f\xd7\x6d\x99\xf4\xc0\x09\xc7\x56\x36\x99\xb9\xfe\x59\x02\x89\x21\x07\x4b\x00\x3b\xb3\x0a\x97\x92\x04\xad\xee\x0e\xec\x62\xde\x6e\xbd\xec\xf7\x51\x4f\xfa\x0a\xe9\xde\xfc\xad\x99\x3f\x9d\x60\x22\xd9\xda\x8a\x4d\xa6\x5c\x36\xab\xbd\x86\x28\xc3\x12\x40\xbf\xe3\xe6\xb5\xe4\x58\x6b\x1c\x16\xd0\xd7\xde\x7c\xcf\xc5\x9a\xcf\x07\x2c\xef\x2a\x6b\xed\x86\x60\xa2\x9c\x7a\xd3\x52\x48\x6f\x59\xea\x8e\xba\x33\xce\xaa\xff\xa0\xc8\x97\x1c\x3a\x0d\xd4\xd6\x73\xd3\x4d\xb0\x69\x2f\x2c\x19\x1b\x6d\x69\x2f\xc5\x13\x9f\x33\xc8\x7a\xf8\xb9\x4f\x55\xce\x19\x55\x44\xac\xe5\x86\x3a\xc3\x94\x20\x01\xfc\x78\x94\x92\x45\xda\xa4\x15\x6f\x82\x0d\x5f\xfc\x83\x03\x69\x60\xeb\x76\x36\xbe\xb8\xed\x6c\x32\xd3\x28\x5c\x5c\x96\x42\x15\x03\x45\xd0\x1d\x94\x30\x12\xee\xf5\x3e\xfc\x7d\x5c\xee\xf1\xf9\x7b\x1b\x64\x4a\x6e\xf8\xf9\x1b\xc8\x36\x48\x14\x58\xe1\xb4\x2f\x17\xde\x46\x0e\xe5\x3a\xf0\x35\xbe\x19\x62\xb3\x0e\x1d\xef\xbd\x30\x8d\x81\x45\xb6\x32\x82\xba\x77\xb4\xb0\xee\xfd\x1f\x76\xc9\x78\x47\xec\xa9\xc7\x4e\xd1\x47\x87\x13\xcf\x7b\xeb\x31\x7a\xea\x31\xcb\xc7\x11\xb7\x1a\x79\x71\x84\x7a\x5f\x12\x99\x82\x74\x92\xa0\x7a\x55\xb6\x9a\x89\xe8\xe9\x33\x17\xe9\x64\x8a\x63\x1a\x0a\x22\x61\x13\xce\x9a\x8e\xbe\x60\x3d\xfd\x71\xad\xae\xe0\x4b\xce\x4a\xf7\x91\x83\xc4\x7c\xfe\x00\x8d\x48\xdd\x46\x35\x50\x47\x98\x19\x90\x28\xa0\xfc\x33\xc8\x1f\x3a\x2c\x56\xf6\x33\x13\xeb\xaf\xa3\x27\xdf\x4f\x12\x5c\xcf\x39\xba\x99\xf6\xbe\xe9\x92\xff\x55\x55\xb6\xf3\x5f\xeb\x36\xfd\xe7\xd4\x46\x1c\x52\x8f\x3b\x63\x59\x3a\x7d\xed\x3b\xbd\xc9\x94\xa2\x0f\x5e\xd8\x0e\x67\xfb\x26\x3c\xe2\x55\xa0\xaa\xc8\xb8\xef\xf2\x8d\xd0\x0a\xa2\x12\x70\xcf\xd1\x6b\xad\x8c\xb6\x35\x65\x86\x43\x97\x4a\x9f\x36\xf0\xc9\x71\x8d\x06\x40\xc8\x46\x4c\x09\x5a\xbe\x27\xd8\x46\x36\x2b\x01\x05\x91\xfb\xef\x54\x95\xa0\x51\xb1\xe6\x3b\xd3\xe9\x5f\x0c\xc9\x7e\xe0\x26\x81\xe3\xed\x20\x2a\x41\x9b\x76\x68\x6e\x90\xc9\x7a\x8c\x82\xc8\x31\x5b\xdd\x55\xd0\x2b\x92\x0f\xff\xe2\x93\x22\x19\x5c\xae\x6a\x83\xa6\xa5\x50\x8f\x09\xf1\x8f\x95\xc0\xac\x25\x80\x0b\xcd\x45\x0a\x8c\xc5\x62\xd5\xdf\x79\xc3\x29\x74\x8e\x65\xe9\x40\xc7\x2d\x6c\xf1\x71\xbb\x15\xb8\x5f\xfc\x80\xa2\x01\x79\x83\xf8\x11\x62\xf3\x6e\x36\xc0\x7c\x10\x95\xa8\xc0\x50\x83\xf9\x63\x99\x9e\xb1\x13\xd1\xf4\x30\x2e\xbd\x82\xc3\x8a\x3d\x76\x60\xa1\xe0\x0c\x70\x34\xfb\xc7\x9d\x5b\x97\xfd\x16\xf8\x6b\xb0\xd7\xa0\xeb\xec\x50\xa4\x15\x77\x25\xfb\x7e\x14\xfe\x80\xd3\xb2\x49\x1e\x0f\xdb\x7c\xeb\x11\xc7\xf2\x5d\x21\x29\x19\x00\xa1\x67\x72\x54\x38\xcb\x6b\x8f\xc6\x66\x66\x21\x24\x0a\x24\xae\xbc\xcc\x26\xee\x76\x41\xb8\x9e\x6d\xdf\x62\x2f\x09\x9a\xf9\xb2\x54\x38\x36\xef\x90\x9a\x26\x39\x07\x05\x91\x47\x89\xb2\x26\x8c\x6b\x30\xdf\xaf\xda\x9b\x3f\x79\xab\xa8\xd2\x0b\xd3\x18\x5c\x52\x2b\x83\xa7\x69\x13\x04\x09\xb0\x68\xb8\x7b\x0f\x04\x7f\x53\xa7\x01\x4d\xcb\xb6\x6e\x55\x8d\x3f\x87\xb4\xf3\x58\xe6\x1f\x6a\xf9\x5c\x0e\x76\xf2\x54\x6a\x5f\x34\xdd\x66\x60\xb7\x8d\x0c\x05\xa1\xd8\x83\x56\xa8\xe0\x1e\xd0\xdf\x17\x49\x64\xb4\x77\x20\x5c\x4f\xb5\x8a\xfb\x82\x9b\x15\x8b\x04\x51\x67\xe6\xc9\xd9\x25\x22\x9a\xbb\x4e\x9c\x97\xd1\xc6\xd7\xed\x78\xfc\xa2\x74\xec\xae\xce\xc7\x96\xb3\x07\x9a\xbd\xf9\x8d\x9e\xbf\x73\xf8\x3f\xbe\x87\xdd\xd5\x44\x50\x24\x19\xb2\x95\x00\x3f\xe8\x95\x3d\x22\xc9\x51\x68\x80\x23\x50\xa8\x41\xac\xd6\x8b\x37\xc1\x7e\x79\x60\x7e\x3b\x1e\x34\x4d\xd5\xd1\x3f\xf1\x1c\xfa\x95\x2f\xad\x8e\xdc\x20\xe8\x47\x94\x40\xd1\x40\xdf\x27\x0f\x0e\x6c\xb6\x82\x06\x34\xd9\xb8\xd7\x68\x92\x5f\xa0\x1c\xcb\xf9\x56\x2d\x7c\x57\x81\x0a\xc7\x96\x9c\x3e\xa3\xf5\xdf\x6b\x71\xef\x14\x43\x5b\x1b\xf5\xfd\x39\x2c\xde\xea\x58\xe1\xf7\x6b\x70\x56\x75\x5a\xc4\x3a\x1d\x49\xc1\x15\x2c\xd1\xef\x0f\xf7\x53\xf0\x29\xdd\xb0\xfb\xda\xbb\x02\xbe\xd2\x13\xbc\x64\x05\xf7\x2f\x8e\x6e\xa5\xa8\x40\x6e\xf9\x43\x8f\xf2\x69\xb4\x99\x60\x8f\x9f\x2b\x20\x98\x78\x49\x8f\x06\x50\x0b\xaf\xba\x90\xad\xb7\x21\xdc\xf4\xdd\x2f\x34\x6c\xf1\x31\xaf\x12\xf5\x6e\x53\x73\x46\x07\x81\x68\xe0\xaa\x9c\xdd\x4d\x58\x47\x87\x06\x38\xbe\xaf\x24\x5e\x44\x16\x41\x7e\x71\x53\x75\x58\xfb\xae\x10\xe2\x74\x1b\xbf\x6d\x24\xef\xd2\xed\x83\xdb\x21\xe1\x77\x34\xc8\x82\xef\x68\xfc\xb7\xb2\x95\x81\x73\xa3\x90\xcd\xe1\x5f\xe9\xed\x3c\xfa\xf1\x38\x43\x63\x2c\x23\xb7\x48\x51\xd0\xb4\x7f\x04\x45\x03\x05\xce\xf6\xdf\xe1\x6c\x55\x1a\xe0\x78\xd9\x2c\xf6\x0c\xf2\x10\x4a\xc6\x76\x4e\xaf\xb4\x17\x9e\x96\x5f\x4e\x23\x52\x35\xf5\x0d\x34\x91\xce\x00\x48\x14\x88\xab\x1a\x71\x81\x56\x88\xf1\x5a\x1a\xf5\x2f\xcd\xd4\x61\xd3\x0d\xbc\xcc\xe7\xc7\x04\x0d\xbd\x1e\x82\x04\x28\x08\xed\x97\x6c\xd9\x00\xcd\xed\x68\xfb\x26\xe6\x32\x1c\x51\x1b\x7e\x1b\x5c\x58\x7d\xab\xfc\x81\xbe\x54\x18\xd7\xf2\x45\x08\x55\x90\x0f\x09\x7a\xe4\xf2\x38\x41\x7c\x64\x69\x5e\xa7\x72\xf3\x53\xff\x9d\xb2\x74\x80\xb1\x09\x96\x00\x64\x56\x3c\x35\x22\x1d\xd5\x27\x9f\xe2\x62\x2f\x2c\x3c\x72\xa7\x3b\xca\xb2\x84\x3d\xca\xb7\x24\x3d\xdf\xd3\xe6\xec\x1c\x09\xf0\x5a\x4e\xd9\x39\xd2\x25\x07\x36\x40\x3b\x7d\xbc\x7d\x33\xe5\x08\x23\xc4\x4d\x71\x12\xc7\xd8\xe6\x56\x87\x11\x41\xa3\xe5\x10\x68\x85\xae\x39\x6b\xb6\x94\xb1\x58\x5c\x09\x68\x7e\xfe\x7c\xa7\x7a\xbe\x31\x4e\xe7\x6b\x82\x46\x3d\x26\xf2\xa6\x3b\xf6\x3a\x59\x08\xce\x76\x3d\x14\x57\x82\xee\x55\x88\xf4\x38\xd0\x02\x21\x51\x20\xe3\xe4\x98\x0e\xf4\x8c\x1c\xfb\x62\xba\xc3\x70\x16\x27\xc0\x0a\xad\x8e\x32\xf3\xdf\x42\xb0\x12\x7e\x0b\x61\x31\x49\x09\x68\xce\xba\xb9\x53\x35\xdf\x82\x11\x72\xc8\x7b\xee\xa0\x05\x0e\xac\xe9\x93\xb7\xb0\xc1\xc4\xcf\x27\x85\x3d\x23\xd5\x78\x29\xf0\xbb\x52\xd3\x21\x0a\x42\xdb\x07\x89\x02\xad\xd7\x46\x4d\x19\x8b\x49\x3d\xf5\x98\xa7\x57\x12\xbf\x66\x2a\x63\x3a\xae\xe0\xc8\x01\x4d\x41\xb1\x9a\x70\xc3\xe1\x66\x9b\x12\x54\xc7\x1d\x04\xbc\x6e\xbc\xfc\x06\xeb\xac\xa0\x01\x8e\x6f\x0b\x89\x17\x90\x59\x78\xac\x74\xce\x97\xdf\xfa\xab\xcf\x5a\x78\xba\x47\x14\xb7\xff\xfd\x13\xf2\x8c\x68\x60\xd5\x6e\xdb\x4b\xf0\x05\x59\x2a\xa0\xdd\xf0\x6d\x1b\x2c\xa6\x18\xfa\x39\xc8\x53\x6c\xb8\x6c\x7d\xc3\x12\xfe\xb1\x99\x3a\xec\x56\x45\x7f\xde\x88\xfb\xc1\x0d\x82\x12\xe1\x45\x54\x15\x71\xd7\x59\x21\x05\x0d\xca\x52\x01\x9f\xf4\xfb\xea\xd2\x59\x09\xb8\x0b\xce\x95\x88\xeb\xc8\x7f\xf4\x39\xfb\x95\xea\xb5\x27\xb8\x8a\x65\x58\x95\xd0\x7e\xe7\x7d\xc5\xab\xdf\xf3\xa7\x65\x1e\x13\x95\xa0\xa3\x84\x57\x56\xff\x89\x7a\x79\x79\xec\xb5\x57\x4d\xb0\x74\xcf\xc9\x13\xf5\xf4\x88\xe2\xcc\x3a\xc1\x52\x69\xa0\x15\x7a\xe6\xe5\xca\xad\xff\xad\x24\x5d\xda\x8a\x07\x33\x85\xd1\x5b\xf4\x7a\x2c\x34\x7b\xc8\xe2\xef\xf6\xf5\x34\xb9\xf3\xcc\xb8\xf7\x0b\x2d\xf6\xc4\xad\x75\xb6\xcc\x09\x48\x14\x78\x66\x50\x00\xef\x92\x69\xe2\x65\xee\x3e\xf6\x42\x78\x50\x17\x63\x95\x9b\xf7\x25\x76\xaf\xcb\x03\xbe\x9f\xd8\xb4\x58\x73\xf3\x87\x31\xe6\xcb\xf3\x31\xec\x93\xeb\xc6\xda\x8f\x8e\x19\x14\xda\xf1\xd1\x62\xe9\x15\xde\x69\x99\x5e\x3f\xea\xce\x1a\x0e\xae\xf6\xa5\x10\xfe\xda\x79\x77\x39\x9d\x72\x11\x00\x0f\xbc\x8d\x5e\xb9\xa2\x15\x60\x28\x7a\x10\xfc\x4b\xf4\xbf\x82\x60\x4a\x4f\x5f\x45\x5b\x4b\x50\x76\x5a\x22\x3c\x36\x98\x5b\x9c\xf6\x31\x7f\xfc\xf6\x50\x6b\x88\x39\x43\xca\x23\x7c\xec\x11\x1c\xda\xff\xe3\xd9\xe0\x32\x9f\x64\x80\xf1\x95\xa8\x0d\x1c\x92\xbd\xbf\x64\x4f\x09\x3b\xef\xf4\xb8\xdf\x91\x8e\x69\x4b\x77\xac\x8e\x15\xc2\x4f\xe2\x8b\x05\x63\x82\x33\xcc\xd1\x6c\x47\xa8\xec\x4f\x79\x63\x6e\x02\x70\xd0\xa7\x54\x94\xd1\x47\xd2\x06\x1e\x24\x9d\xbf\x4c\x3c\xba\x92\xba\x94\x4f\x9f\xd6\x2d\xfe\x7f\xec\x9c\xfb\x57\x52\xf9\xde\xc7\xf7\xde\x6d\x1a\xc0\x86\x29\x24\xb5\x39\x96\x5d\xbc\xa0\x96\xd2\x5c\x94\x49\x05\x9a\x2e\x4e\x6a\xc5\x49\x4b\xbb\x28\xac\x31\xb5\xc9\xc6\x70\x46\xf3\x2e\x30\x4f\x65\x56\x46\x27\x4b\x2d\x53\x68\xcc\xd3\xcd\xd4\x6a\xba\x69\x0a\x27\x9a\x71\x6a\x4c\xce\xd4\xa8\x58\x21\xd0\x4d\xcd\x1b\x49\x22\x2a\xf0\x7d\x16\xb1\x7d\xd6\xea\xfc\x05\xcf\x59\x8b\x5f\x59\x7b\x6d\x5e\x9f\xfd\x7d\x7f\x3f\x7b\xaf\xbd\xdf\xef\xcf\x8d\xfc\x67\x94\x79\x34\x57\x8b\x58\xff\x39\x88\x3c\x70\x97\xf4\xfe\x63\xec\x4c\xd8\x43\xf6\xf5\x4f\x7b\xca\xd1\xe6\x22\x01\xa2\x79\x2a\x31\x2e\x42\xd6\x22\x24\xb0\xf6\xbb\x2e\x9b\x33\x36\x62\x05\x8b\x40\x2b\x72\xdd\xb5\x9c\xbb\x0b\xc6\xc9\x94\xcf\x6b\x43\xb9\x22\x44\xfa\xa6\x02\x84\xec\xcc\xf6\xb1\x25\x32\x17\x0b\x83\xd4\x04\x4d\x0b\x5c\x72\xa2\xa1\x31\xb6\x41\x80\x70\xfb\xea\xa0\x31\x8e\xaf\xf5\x5f\x2f\xbc\x8b\x07\x5e\xd1\x4c\x4d\x3c\xb8\x5e\x17\x7c\xe8\xfd\x6b\xb1\xd3\x28\x23\x2d\xcb\xf8\x2d\xcb\x43\x76\xfa\xb3\x74\x7f\x74\x9e\xbb\x04\xca\x3c\x7a\x44\xba\x88\xf6\x77\xa1\x41\x0e\xf4\x5e\x8a\x9a\x5a\xc6\xc4\x8d\xc2\x37\x5b\x6c\xc1\xbe\xc5\xe1\x2c\x42\x7d\xcd\x1d\xd5\xe0\x55\x1c\x05\xfa\xa3\x4d\xad\x72\x26\xe6\x6a\x2f\xf1\xb7\x9a\xdb\xa4\x72\xbe\xf6\x50\xdb\x83\xf7\xc5\xe7\x08\xd7\x12\x74\x67\x39\xb3\xe8\x93\x35\x2a\xcd\xd4\x91\xa5\x84\x19\x05\x80\x63\x7a\x70\x28\x61\xab\x7a\x73\x86\x51\x29\x95\xf3\x13\xd2\x4a\xde\x6b\x7a\x1f\x7e\x86\x87\x2c\xbc\xdf\xed\x23\x36\xb2\xd8\x9d\x77\xf3\xf6\x37\x30\x4e\xb6\x30\x3e\xe3\x71\x11\x42\x02\x45\x17\xba\x4e\xda\x1c\x1b\x04\x2a\xdc\x30\x72\x75\x89\xf0\x7b\x1c\x05\x7a\x7a\xf0\xa4\x17\x0f\x15\x72\x26\x9f\xa8\xdb\x9b\xf5\x7c\xf3\x9b\xc9\x26\x3d\x7f\x6f\xe7\xe1\x70\x9b\x2a\x62\x71\x43\x26\xba\x6c\xad\x18\x55\xce\xfe\xf7\x0b\x7c\x44\x0c\x0b\xa6\x69\x79\x35\xab\xb9\x33\x5d\x48\x40\xaa\x7b\xe5\xec\xb3\x74\xbf\x1c\x38\x78\x05\xe6\x6a\x39\x09\xd9\x36\x06\x0f\x87\x5c\xe3\x48\x64\xbf\x9a\x24\x41\x63\x46\x92\x65\x4d\x8a\x48\x16\x4c\x53\x6c\x53\xb5\x47\xa0\xae\x96\x24\xf2\xd9\xa7\x36\xc3\xcb\x3e\x24\x4c\x70\xf7\xdf\x35\x11\x9a\x05\x08\x51\x50\x00\x8d\x27\x23\x2d\xe4\x1d\x4c\x13\x93\xaf\xaa\x9b\x08\xea\x48\x6e\x49\x7f\xef\x8c\x91\x35\xa3\x01\x1e\x32\x47\xe7\xba\xa1\x7e\xef\x90\x4f\x60\x24\x94\xc2\x86\x5c\x67\xa9\xab\x09\xcf\xd6\x4b\xc6\x75\xa0\xcb\xb7\x5f\x3c\xce\x71\xb3\xd4\xc5\xde\x4a\x93\x3e\xd5\xf7\x60\x36\x9d\x7f\xc8\xd8\xc8\x0c\xe9\x5c\x16\x81\x4d\x39\xb5\x91\xf0\x2c\x94\x05\xf3\xdc\x5c\xf8\x11\x9a\x18\x64\xb0\x00\x38\xaf\x73\xb5\x88\x46\x9f\xa4\xc6\xbe\x5f\x65\x1e\x55\x18\x24\x46\xd9\x88\xeb\x83\x4e\x3d\xd9\x25\x9c\x05\xd3\x4a\x7e\xe9\x9f\x89\x47\xf3\xcd\xc9\x7c\xce\xe4\x3e\xbe\x3c\x08\x90\xc0\xf2\xdf\x57\xdb\x4e\x7c\x41\x16\xda\xd6\xec\xc2\x26\xb0\x3f\x15\x5f\x27\x9c\x26\x50\xa1\x3b\x33\xf1\x11\x2b\xb9\x74\x13\x30\x95\x8a\xc5\xbf\x07\x3c\xbd\xac\xd5\x49\x2b\x4a\xbf\xb3\x7d\x71\xf6\x91\xa0\x3b\xd6\xb6\x75\xa3\x43\x78\x2a\x74\x87\x8c\x8f\x60\xc9\xb4\x7c\x64\x41\x0c\xe6\x9c\x3b\x80\x1e\x87\x66\x15\x2f\x40\x8a\x29\x6c\x28\xe2\x33\xa7\xf0\x25\x6b\x34\x81\x07\xd5\xe3\xa0\xf5\xc4\xd8\xb8\x12\x1c\x8b\xab\x19\xb2\x29\xf5\xd9\x6c\x36\x92\xe9\x3d\xb3\x97\xc2\x86\xd2\x5e\x27\xf6\x9f\xc4\xcf\x28\x00\x62\x5f\xb3\x12\xf0\x8c\xfc\xb7\x2f\xf7\xef\x9e\x14\xdf\x4f\xfe\xf3\x7b\x5b\xe7\xf8\x1e\xb7\x9f\xac\xe9\xe8\x7b\xd8\xde\xe8\x1f\xd7\x65\x38\xf3\x9a\x30\x93\x7e\x4c\x80\x68\xbe\xd1\xf9\x85\x4b\xcc\xba\x61\x07\x70\x67\x3f\x66\x99\x16\xc5\xe5\x67\xa7\xe7\x98\x7d\x91\x13\x50\x9c\x03\xba\x0c\xa5\x40\x73\xbf\xea\xfa\x21\x11\x91\x8e\x4f\x30\xa3\xa4\x7a\xa6\xab\x45\x9d\xaf\x1d\x69\xdd\x90\x1a\x84\x59\xa2\xfd\x90\x30\x41\xec\x5f\xc1\xfb\x65\xa9\x28\x05\x6a\x49\xcd\x68\xc7\x39\x58\x41\xf2\x06\x6f\x87\x09\x1f\x2a\x4c\x41\xa6\xe8\xdf\x92\x6c\x75\xf3\x68\xb0\x87\xec\x5f\x3b\x42\x96\x0a\x9b\x88\x54\xc8\xf0\xa0\xef\x3e\x3e\x38\xc6\xba\x5a\x7d\x8d\x1c\x63\x8f\x0e\x68\x0e\x9b\x97\xbc\xbf\x02\x31\x17\x05\x64\xee\x85\xfb\x8d\xc3\x9e\x30\x8e\x15\xb9\x65\xcf\x65\x27\xe3\xe5\x4e\x4e\x6d\x5e\x0f\x9f\x6e\x92\xf6\xc6\x83\xf6\x3a\xe6\x1f\xb6\x2c\xd3\x52\xd4\x5a\x57\xa1\xff\x74\x57\xf9\x93\x2f\xf1\x03\x0b\x25\x90\x72\xd5\xc3\x78\xfc\x2e\x6f\xeb\x66\x7c\x7d\x5d\xda\x3d\xf2\xf7\xc7\x2e\x19\x46\xf1\x6a\xd2\x0d\x9b\xba\x96\x20\x6e\x39\x03\x21\x61\x82\x92\xfa\x81\xdb\x74\x18\x27\x7b\xbe\x30\x43\x1e\xef\x40\x37\x89\x96\x0c\x65\x63\x05\x5d\xc1\x05\x78\xc8\x1a\xfd\x8c\xb3\x85\x2d\x6d\x77\x47\x09\xcf\x7c\x25\xd0\xad\x68\xb9\xd4\x51\x79\x8d\xa5\xd7\x82\xc8\xe8\xee\xfe\x78\x90\x93\x1f\x92\xb9\xcc\xd6\x0d\xdc\x85\x67\xd0\x5b\x79\xd9\x3f\x56\xa3\x31\x1b\xde\x0c\xa5\xbf\x41\x88\x82\x0b\x5e\xdb\xa5\x5b\x84\x6a\x3f\x8b\x12\x54\xc8\xc1\xc7\xd2\x37\x75\x86\xf6\xa4\x54\x5b\xf4\xe2\x63\xd8\x43\xf6\xc5\x0f\xf5\x38\x61\x91\x00\xe1\x2e\xd2\x8a\x51\x03\xd5\xca\xda\xf0\x2e\xc9\x12\x58\x00\x5a\xfc\xb7\x75\x57\x1d\x38\x87\xb9\x39\x4b\x7c\x78\x86\x17\x10\x83\x50\xff\x97\x51\x35\x76\x0f\xa5\x40\x8e\x0e\xf7\x96\x99\x11\x12\x30\xcf\x69\x98\x86\x79\x90\xa4\x88\x9b\x9b\x55\x79\xf5\x9c\x28\xc5\x6a\xee\x47\x30\x4e\x46\xda\x31\xef\x6b\x4d\x3e\x22\xd5\x4f\x30\xb3\x7b\x40\x3e\xdd\x82\x39\x26\x63\x7e\x11\x90\xb9\x7f\xbe\x6d\x1c\x2f\xc4\x53\x21\x4f\xf2\xde\x2f\x27\x70\xfb\xe5\x60\x7d\x93\x21\xfa\x44\xe0\xc8\xfd\x96\xe6\x64\xf3\x3e\x7e\xe0\x26\x86\x81\x33\x47\x5c\x6d\xcb\x51\x17\x09\xc8\x12\x4f\x9f\x43\x6d\x51\x2c\x98\xf6\x98\xab\xea\xb8\x65\xdd\x26\xda\x45\x59\x24\xf0\xa2\x39\x71\x9b\xcd\x5a\xbb\x78\x23\x37\x12\xb9\xa3\xbf\x1b\x1f\xee\xc1\x53\xa3\x47\x3c\x25\x10\xfd\xd3\x26\x07\xc5\x2a\x4d\x86\xba\x37\xbf\x47\xad\xe5\xfb\xd5\xad\x1c\xb3\x65\xfc\xb2\xd1\xe3\x50\xc8\xe6\xae\xb4\x38\x84\x28\xc8\x7a\xd5\x97\xb2\xda\xc9\x85\x04\x98\xe3\x17\x45\x66\x51\xeb\x10\x66\x73\x97\xcd\x40\x9b\x7b\xae\x0c\x07\x4f\x7e\xcb\x12\xa3\x65\xcb\xcb\x52\xce\xac\x66\xc1\x6c\xc7\x13\xab\xce\x79\xd2\x0a\xe4\x20\xc8\x6f\xa2\x57\xf5\x57\x5c\xb4\xc1\x66\xf5\x0c\x27\xb3\x91\xc5\xde\xdf\x8f\x2d\xc3\xc1\x38\x19\x65\x43\xfd\x8a\x0a\xd9\x70\xd3\xdb\xf5\xd7\x87\x4d\xfc\xe7\x43\xce\x96\xaf\x4c\x9d\x4d\x03\xb6\xc9\x1d\x68\xa2\xb7\x04\x2d\x4b\xa8\x49\xd9\xf9\x33\x42\x14\x14\x4e\x8f\xbf\xdc\x47\xb4\xca\x34\x73\xd4\xff\x5c\xa3\x66\x5a\xc0\xdf\x48\xe0\x66\x87\xda\x30\xac\x03\x90\x3b\x1f\x0b\xde\x1c\xf6\x93\xa0\x2e\x15\xcf\x70\xce\x3e\x12\x28\x66\x87\x2e\x25\x27\x10\x2e\x00\xaa\x67\xc1\x07\x31\x9b\xa6\xce\xaa\x67\xf9\x5e\x53\x88\x50\xef\x40\x85\xda\x76\xf7\xce\x71\x5a\xa8\x9b\xdc\x7d\xe7\x79\x05\xd8\xb7\x7d\x30\x00\xdb\x50\x2f\xf0\xbb\xc2\x04\x19\x56\x35\x11\x64\x1b\x93\xfc\x25\x50\x59\xde\xbd\x14\x9e\x1b\x22\xd7\x82\x16\x8e\xf9\x50\x5e\x70\x7b\xaf\x22\xd5\xc0\x3f\xcb\x30\x30\x1b\x1a\x6d\xb1\x4e\x18\x69\x24\xcc\x00\x83\x3f\x4c\xbe\x7b\x1d\x46\xa8\x2f\x17\x87\xce\xca\x3c\x2a\x40\x34\xcb\x97\x59\x8e\xc8\xb6\x48\xcc\x3a\xfe\xe4\x76\x33\x5f\xd2\x3c\xa8\x4e\xb6\x35\xee\x99\x54\x32\x1b\xa1\xec\x4b\x68\xf7\x81\x71\xac\x8d\xdb\x4a\xe6\x6e\xd5\x1c\x1a\x1d\x36\x05\x99\x44\xe6\x53\xa9\x98\x87\x91\xcb\x44\xc2\x04\x66\x7a\xb6\x13\x32\x46\xa4\x42\x43\xed\xba\x61\x74\x68\x96\xf5\xfa\x6f\x32\x5f\x01\xe5\x97\x32\xb2\x7b\x40\xe5\x4a\xb3\x2f\x66\x40\xf5\xd4\x01\xe6\x83\xde\x20\xf8\xc9\xcf\xd7\x86\x57\xe2\x28\x90\xe3\xc7\xbf\x6d\x69\x99\xcd\x33\x32\x6b\xa5\x97\xb0\xc4\x1f\x1a\x80\x88\x6f\xfd\x30\xd6\xe9\xe2\x0b\x07\xb8\x5b\xd6\x70\x93\x60\x9c\xac\x2d\x6d\x1e\x3d\x5a\xc8\xcc\xd4\x8b\x9b\xf4\xcc\x4e\x9e\xca\x19\xb3\xc4\x7a\x4b\xd0\xf3\x87\x1f\x3e\x72\xb4\x76\x34\x1a\xc5\x77\xee\x7a\x6b\x67\xec\x2d\xb5\x98\x32\x1d\x4e\xbf\x78\x90\xc1\x58\xd8\x13\x0f\x7a\xe6\xbf\x63\x63\xa8\x77\x91\x72\xb2\xe6\xad\x43\xa9\xd7\x9d\xea\x76\x7c\xc4\x56\x16\x4c\x1b\xcc\xdd\x15\xf8\xb3\x40\x0e\x0e\x28\xfd\xa6\x52\x51\xca\xe5\x5c\x2a\xbc\x5f\xcd\x3b\x8b\x2a\x61\xc5\x0a\x7c\x0d\x8e\x02\xc5\x4d\x47\x3b\x10\x7e\x6f\x05\xbf\x6e\xda\x94\x71\x73\x9f\x60\x3d\x12\x75\x6f\xfb\xfd\x42\x22\x15\xf2\xfc\xf4\xf0\xf5\xdf\x1c\xad\x35\x6f\x30\x5d\x01\xce\xeb\x53\x8c\x37\xc0\xdb\x38\xbf\x5a\xdb\x98\x01\x7c\x04\x4b\x53\x1c\xdd\xdd\xd8\x70\xdd\xb2\x7b\xbe\x18\x2d\x63\xb9\x6c\x3a\x4f\x66\x43\x51\xbf\x26\x74\xe5\x39\x7c\xe1\x66\x61\xe6\x8f\x87\x5c\xd2\xea\x40\xed\xa1\x68\xc3\xc3\xe7\xe3\x53\xf6\xe4\x62\x67\x36\x12\xc1\x58\xe8\xb0\x8e\xcd\x82\x69\xff\x62\xd7\x7c\x71\x84\x35\xa9\x05\x1d\x7d\x6b\x0b\x40\x6a\x9c\x4f\x2d\xe6\x9e\x8d\x88\x62\x11\x68\x7f\x72\x6b\xbe\xdc\x0c\xe3\x64\xdb\xab\xae\x28\x17\xe0\x2c\xbd\x0f\xf9\x17\xcc\x4a\xa0\x6b\x93\xca\xc1\x02\xce\xdc\xca\x72\xcc\xcf\x49\x76\x09\x13\x68\x23\xff\x91\xef\x2a\xf1\xfe\xb2\x71\x5e\x38\x0b\xe6\x2d\xce\xea\x0c\x39\x25\xe3\x19\x81\xd6\xdf\xdc\xf5\x67\x2d\xbf\xd2\x21\xc8\x24\x0e\x55\xc4\x63\x0a\xbb\x4d\xcc\x0d\x23\xd0\x3a\x77\xa8\x3a\x2b\x71\x14\x28\x67\xfa\x68\xda\x63\x27\x9e\x51\xbc\x87\x71\x03\x0b\x0c\xe2\x07\x7c\xea\xbd\xe1\x6e\x4b\x6f\x75\xac\xf8\xee\x0b\x7c\x6b\x1c\x0b\xa6\x4d\x1e\x50\x69\x56\x6b\x44\xa0\x9f\x93\xac\x1e\x07\xca\x57\xe7\xf2\x06\xf8\x8e\x67\xf9\x98\xfe\xd0\xa1\x45\xb4\xe3\x90\x5c\x14\xe6\x03\x0f\x4d\x7b\x29\x72\xf6\x97\x40\x31\xa6\x70\xee\x1c\x48\xae\x05\x7a\x30\x7e\xee\x44\x60\xbf\x8e\x33\xa7\xbc\x1a\x23\x58\x44\xa0\xc2\x47\xd0\xe1\xab\xb3\x11\xa2\xe0\x60\xd9\xe2\xac\x79\xc2\xaf\x5c\x2d\xea\xbc\xb7\x6f\xb2\xf0\xc1\x25\xd8\x6e\xa9\xa7\xc2\x4c\x52\xfd\xf9\x5b\xaa\x01\x27\x84\x28\x58\x52\x7f\xd8\x40\xa0\x9b\xc6\x12\x12\xe3\xa7\x92\x82\x0c\x94\x91\x37\x9a\x97\x24\x9d\x2b\x13\x4d\x1b\x58\xf1\xf8\xc7\xd6\x55\x2c\x98\x56\xea\xae\x6a\xb9\x89\x37\xdd\x9f\xc1\x30\x0c\x57\x80\xdd\x85\xed\x6e\x53\x3e\xe6\x6f\x61\x0f\x56\x44\x02\x6f\x01\x22\x0a\xb6\x8c\xf4\x34\xdf\x3e\x9a\x93\x5e\x8e\xfa\xa0\x8c\x06\xcb\x5d\xe9\xd3\x7e\xff\xba\x54\x63\xdd\x81\x3f\x8e\x61\x32\x10\xca\xa4\x7c\x73\x51\xfb\x18\x78\xea\xbc\x09\xe8\xa5\x6a\xaa\xb2\xe3\x20\x4a\xf2\xd2\x38\x42\x05\xd7\x46\x5b\xd1\x1b\x4e\x3c\xa3\xfa\x54\xeb\xb4\xa9\x45\xfd\x64\xb6\x32\x97\xac\x39\xc5\x99\x65\x64\xfb\x4e\xd3\x3b\xb1\xa1\xd6\x20\x4e\x46\xb4\xbb\x46\xc7\xa1\x94\x6c\xc4\x6a\x5e\x06\x7b\xb0\x62\xb7\x3b\x31\x04\x02\x44\x03\x9d\xaf\x3c\x26\xbb\x61\xdd\xd2\x1d\x85\x22\xf3\xee\xfe\xc4\xca\xa9\xa2\x8b\x64\x49\x43\xf9\x4b\x25\xeb\x91\x5d\x55\x89\x7b\xd6\x21\x44\xc1\x57\xd5\x7d\x71\x3e\xb8\x80\x02\xe0\xd5\xfd\x26\xda\xbc\x71\xfa\x8e\xef\x30\x9b\x70\x18\x8b\xc0\x9e\x5d\x9a\x30\xb6\x58\x02\x29\x3f\x3f\x7f\x6e\x51\x7d\x91\x2c\xd4\x2f\x24\xdd\x60\x95\x47\xc0\xce\xc4\x29\x3f\x70\x8f\x23\x1b\x39\xad\x50\x54\xe5\xe0\x28\x50\x53\x57\xd7\x9d\x6d\x5c\xba\x49\xcd\xc8\xdb\xf3\x4a\xc7\x99\x77\x76\x14\x63\x0b\x13\x9e\x41\x33\xb5\x2f\x4f\x9e\x70\xfb\xe8\xfc\x61\xc5\xa3\x20\x22\x15\xda\xb3\x57\x4f\x43\x7a\x66\xf1\x8c\x4c\x51\x65\xcb\xd4\x2a\x1b\x16\xe9\xba\x2e\x0d\x47\x23\x03\x05\x8f\xae\x1d\xc7\x53\xa1\xb0\x9f\xae\xbe\xce\x45\x5d\x2d\x3d\xc5\xd5\x41\x98\x99\x7e\x02\x77\x1c\x32\xcf\x69\xf8\x9f\x6d\x2c\xf8\xe8\xd6\x93\xee\xbc\x14\xb8\x00\xfc\xe8\x98\x8d\x4c\xad\x5a\xb7\xf5\x6e\xd9\x1e\xaa\xbe\x31\xd6\x63\xd8\x8b\xfe\xd3\x5d\x02\x95\x6d\xf8\x74\xcf\xc9\xeb\xb2\x82\x02\x7f\x53\x50\x53\x5f\x92\x25\xa9\xe2\x6a\x39\x26\xc4\x2d\xd6\xc7\x90\x5f\x8b\xff\xe6\xeb\xc1\x4a\xfe\xc9\xb0\x0e\xa5\x40\x85\xc7\xe6\x20\x3d\x14\x17\x12\xb8\xd9\x59\x00\xbc\xd4\xd9\x83\xa7\x15\x2f\xb5\xff\xe7\xfd\x2f\x20\xeb\xd6\x08\x56\xdc\xcf\xee\xbe\x80\xa7\x42\xff\x3c\xb6\x37\x24\xd1\x57\xa3\x63\x3e\xdd\xa1\x72\xc1\x82\x8c\x2b\x84\xfd\xd1\x4c\x9e\xe1\x57\x01\x83\x40\x93\xf3\x54\x8f\xc2\x60\xcd\x2a\x96\x46\x80\x90\xc0\x47\xf7\x46\xff\x9a\x4a\x19\x4c\x23\x50\x61\x12\xda\xff\x9d\xc8\x4f\x02\xd1\xb3\x51\xa7\xb2\x4b\x02\x39\xd8\x56\x9c\xce\xc1\x6a\x20\xa3\xc7\xa1\xf0\x6f\xb2\xb2\xc6\xce\x43\x31\x71\xaf\x87\x32\x51\xe1\x27\xb1\xd9\x8f\x14\x26\x91\x66\x80\x3c\x75\x8e\x15\x44\x2a\x4c\xba\xd0\x7f\x5b\xb4\x40\x02\xd1\xf7\x37\x24\xb7\x6d\xe1\xd2\x4d\xd2\xb4\x89\x1a\x7f\x53\x79\xfa\x83\x94\x23\xd8\x3d\xf6\xa0\x8c\x8d\x04\xf3\xcd\x73\xa4\x73\xb9\xde\x42\x85\x89\x31\x1b\xfa\xfc\x13\xe5\x60\x91\x2f\xaf\xa7\x82\x5f\x00\x52\xdb\x63\xb1\x40\xca\x7c\x5e\x8c\xb0\x38\x39\x4c\x20\x27\xe6\x18\xae\xa0\x14\x28\x7e\x4d\x63\xdb\x80\x55\x16\xe7\x4c\x17\xa5\x5d\x49\x83\xd3\xb1\x95\x7c\x45\xa0\xc2\x25\xa5\xe3\xae\xc3\x31\x30\xad\x25\x4a\xf5\xb8\x17\x21\x81\xaa\xe2\xc6\xf9\x53\x79\x80\x34\xd8\x43\xd6\xfb\xd2\xef\x96\xb5\xfb\x56\x2e\x0f\x51\x8d\xa0\xae\x96\xd2\xd2\xdb\x41\x9e\xd8\x13\x84\x2f\xed\x38\xa4\x88\x05\x0b\xd4\x84\xfa\x67\xa5\xac\xd1\x0a\x5f\xe8\xe2\x8b\x6b\xa6\x2b\x38\x46\x66\x5f\xe1\xe8\x4d\x33\xb3\x71\xe4\x0a\x88\xf7\xd2\x4e\x4c\x95\xf9\x16\x5d\x57\x97\x77\x93\xcc\x0d\x4e\x89\x7e\x52\x25\x40\xb8\xa2\xb2\xca\x9d\x9b\xad\xc2\xae\x7e\xac\x08\x32\xf9\xfb\xee\xc9\x08\xc7\xfa\x2f\xdd\x91\x8d\x64\x2e\xf8\x82\x18\xfc\xe4\xd6\x2f\x15\x04\xd7\xad\x9a\x25\x97\x42\x37\x31\x8d\xef\xc6\x2e\xc7\x8d\x0f\x03\xba\xc1\xa7\x76\xbd\x00\x12\x56\x11\x82\xaf\x5a\xd4\x77\x1d\x76\x8f\x1f\xa9\x8a\x55\xef\x23\xcc\x9c\x4f\xc3\x09\xdd\x62\x40\xa0\xd6\x59\x9d\x9b\x40\xbc\xa4\x14\xca\x56\xe0\xe6\x65\x99\x5e\x29\xf2\xf4\x67\x22\xa7\xe3\xff\x63\x4a\xf1\x4e\x98\x89\xb0\xe7\x8a\x5f\x7d\x38\xa5\x98\xe5\x29\x81\x7e\x3f\x1d\x31\xeb\x3f\x8f\xc5\xb1\xd2\x73\x4b\xbc\x3e\x9c\x53\x7c\x52\x80\xcc\xff\xf1\xb3\x55\x1f\xce\x34\xbe\x88\xa3\x40\xed\x6f\x37\x57\x7e\x38\xc1\x78\x3b\x42\x14\xb4\xde\xa7\x7c\x38\xd5\xf8\xbc\x13\x1b\x8a\x6a\x49\xca\xfa\x70\xa0\xf2\x51\x02\x15\xfa\xcc\x2b\xf2\x83\xdf\x58\x92\x2d\x2c\xd8\x0e\x6b\x87\xb5\xc3\xda\x61\xed\xb0\x76\x58\x3b\xac\x1d\xd6\x0e\x6b\x87\xb5\xc3\xda\x61\xed\xb0\x76\x58\x3b\xac\x1d\xf6\xff\x3b\xec\xd2\x88\x80\x0e\x08\xc2\x25\xae\x59\xb9\x3c\xea\xbf\x0a\xdc\x0e\x6b\x87\xb5\xc3\xda\x61\xff\x8b\x60\x8d\xcc\x88\x2c\x2f\x06\xbb\x2a\x3a\x11\x82\x20\x68\xcd\xaa\x75\x2b\x6b\xbf\xe6\xfe\xf4\xbf\x01\x00\x00\xff\xff\x65\x28\x89\xc9\xc4\x85\x00\x00") 72 | 73 | func logo1024x1024PngBytes() ([]byte, error) { 74 | return bindataRead( 75 | _logo1024x1024Png, 76 | "logo-1024x1024.png", 77 | ) 78 | } 79 | 80 | func logo1024x1024Png() (*asset, error) { 81 | bytes, err := logo1024x1024PngBytes() 82 | if err != nil { 83 | return nil, err 84 | } 85 | 86 | info := bindataFileInfo{name: "logo-1024x1024.png", size: 34244, mode: os.FileMode(420), modTime: time.Unix(1493757766, 0)} 87 | a := &asset{bytes: bytes, info: info} 88 | return a, nil 89 | } 90 | 91 | // Asset loads and returns the asset for the given name. 92 | // It returns an error if the asset could not be found or 93 | // could not be loaded. 94 | func Asset(name string) ([]byte, error) { 95 | cannonicalName := strings.Replace(name, "\\", "/", -1) 96 | if f, ok := _bindata[cannonicalName]; ok { 97 | a, err := f() 98 | if err != nil { 99 | return nil, fmt.Errorf("Asset %s can't read by error: %v", name, err) 100 | } 101 | return a.bytes, nil 102 | } 103 | return nil, fmt.Errorf("Asset %s not found", name) 104 | } 105 | 106 | // MustAsset is like Asset but panics when Asset would return an error. 107 | // It simplifies safe initialization of global variables. 108 | func MustAsset(name string) []byte { 109 | a, err := Asset(name) 110 | if err != nil { 111 | panic("asset: Asset(" + name + "): " + err.Error()) 112 | } 113 | 114 | return a 115 | } 116 | 117 | // AssetInfo loads and returns the asset info for the given name. 118 | // It returns an error if the asset could not be found or 119 | // could not be loaded. 120 | func AssetInfo(name string) (os.FileInfo, error) { 121 | cannonicalName := strings.Replace(name, "\\", "/", -1) 122 | if f, ok := _bindata[cannonicalName]; ok { 123 | a, err := f() 124 | if err != nil { 125 | return nil, fmt.Errorf("AssetInfo %s can't read by error: %v", name, err) 126 | } 127 | return a.info, nil 128 | } 129 | return nil, fmt.Errorf("AssetInfo %s not found", name) 130 | } 131 | 132 | // AssetNames returns the names of the assets. 133 | func AssetNames() []string { 134 | names := make([]string, 0, len(_bindata)) 135 | for name := range _bindata { 136 | names = append(names, name) 137 | } 138 | return names 139 | } 140 | 141 | // _bindata is a table, holding each asset generator, mapped to its name. 142 | var _bindata = map[string]func() (*asset, error){ 143 | "logo-1024x1024.png": logo1024x1024Png, 144 | } 145 | 146 | // AssetDir returns the file names below a certain 147 | // directory embedded in the file by go-bindata. 148 | // For example if you run go-bindata on data/... and data contains the 149 | // following hierarchy: 150 | // data/ 151 | // foo.txt 152 | // img/ 153 | // a.png 154 | // b.png 155 | // then AssetDir("data") would return []string{"foo.txt", "img"} 156 | // AssetDir("data/img") would return []string{"a.png", "b.png"} 157 | // AssetDir("foo.txt") and AssetDir("notexist") would return an error 158 | // AssetDir("") will return []string{"data"}. 159 | func AssetDir(name string) ([]string, error) { 160 | node := _bintree 161 | if len(name) != 0 { 162 | cannonicalName := strings.Replace(name, "\\", "/", -1) 163 | pathList := strings.Split(cannonicalName, "/") 164 | for _, p := range pathList { 165 | node = node.Children[p] 166 | if node == nil { 167 | return nil, fmt.Errorf("Asset %s not found", name) 168 | } 169 | } 170 | } 171 | if node.Func != nil { 172 | return nil, fmt.Errorf("Asset %s not found", name) 173 | } 174 | rv := make([]string, 0, len(node.Children)) 175 | for childName := range node.Children { 176 | rv = append(rv, childName) 177 | } 178 | return rv, nil 179 | } 180 | 181 | type bintree struct { 182 | Func func() (*asset, error) 183 | Children map[string]*bintree 184 | } 185 | var _bintree = &bintree{nil, map[string]*bintree{ 186 | "logo-1024x1024.png": &bintree{logo1024x1024Png, map[string]*bintree{}}, 187 | }} 188 | 189 | // RestoreAsset restores an asset under the given directory 190 | func RestoreAsset(dir, name string) error { 191 | data, err := Asset(name) 192 | if err != nil { 193 | return err 194 | } 195 | info, err := AssetInfo(name) 196 | if err != nil { 197 | return err 198 | } 199 | err = os.MkdirAll(_filePath(dir, filepath.Dir(name)), os.FileMode(0755)) 200 | if err != nil { 201 | return err 202 | } 203 | err = ioutil.WriteFile(_filePath(dir, name), data, info.Mode()) 204 | if err != nil { 205 | return err 206 | } 207 | err = os.Chtimes(_filePath(dir, name), info.ModTime(), info.ModTime()) 208 | if err != nil { 209 | return err 210 | } 211 | return nil 212 | } 213 | 214 | // RestoreAssets restores an asset under the given directory recursively 215 | func RestoreAssets(dir, name string) error { 216 | children, err := AssetDir(name) 217 | // File 218 | if err != nil { 219 | return RestoreAsset(dir, name) 220 | } 221 | // Dir 222 | for _, child := range children { 223 | err = RestoreAssets(dir, filepath.Join(name, child)) 224 | if err != nil { 225 | return err 226 | } 227 | } 228 | return nil 229 | } 230 | 231 | func _filePath(dir, name string) string { 232 | cannonicalName := strings.Replace(name, "\\", "/", -1) 233 | return filepath.Join(append([]string{dir}, strings.Split(cannonicalName, "/")...)...) 234 | } 235 | 236 | -------------------------------------------------------------------------------- /http/assets/logo-1024x1024.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/benbjohnson/peapod/6e9cc21a9d227df8b76a6e1a23d6451556575c5b/http/assets/logo-1024x1024.png -------------------------------------------------------------------------------- /http/context.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "context" 5 | "io" 6 | ) 7 | 8 | // NewContext returns a new Context that carries the log output. 9 | func NewContext(ctx context.Context, logOutput io.Writer) context.Context { 10 | return context.WithValue(ctx, valueKey, contextValue{ 11 | logOutput: logOutput, 12 | }) 13 | } 14 | 15 | // FromContext returns the log output stored in ctx, if any. 16 | func FromContext(ctx context.Context) io.Writer { 17 | v, _ := ctx.Value(valueKey).(contextValue) 18 | return v.logOutput 19 | } 20 | 21 | // contextValue is the set of data passed with Context. 22 | type contextValue struct { 23 | logOutput io.Writer 24 | } 25 | 26 | // contextKey is an unexported type for preventing context key collisions. 27 | type contextKey int 28 | 29 | // valueKey is the key used to store the context value. 30 | const valueKey contextKey = 0 31 | -------------------------------------------------------------------------------- /http/error.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "strings" 8 | 9 | "github.com/middlemost/peapod" 10 | ) 11 | 12 | const ( 13 | ErrNotAcceptable = peapod.Error("not acceptable") 14 | ErrAssetNotFound = peapod.Error("asset not found") 15 | ) 16 | 17 | // errorMap is a whitelist that maps errors to status codes. 18 | var errorMap = map[error]int{ 19 | ErrNotAcceptable: http.StatusNotAcceptable, 20 | ErrTwilioAccountMismatch: http.StatusBadRequest, 21 | ErrInvalidSMSRequestBody: http.StatusBadRequest, 22 | } 23 | 24 | // ErrorStatusCode returns the HTTP status code for an error object. 25 | func ErrorStatusCode(err error) int { 26 | if code, ok := errorMap[err]; ok { 27 | return code 28 | } 29 | return http.StatusInternalServerError 30 | } 31 | 32 | // Error writes an error reponse to the writer. 33 | func Error(w http.ResponseWriter, r *http.Request, err error) { 34 | // Determine status code. 35 | code := ErrorStatusCode(err) 36 | 37 | // Log error. 38 | if logOutput := FromContext(r.Context()); logOutput != nil { 39 | fmt.Fprintf(logOutput, "http error: %d %s\n", code, err.Error()) 40 | } 41 | 42 | // Mask unrecognized errors from end users. 43 | if _, ok := errorMap[err]; !ok { 44 | err = peapod.ErrInternal 45 | } 46 | 47 | // Write response. 48 | switch { 49 | case strings.Contains(r.Header.Get("Accept"), "application/json"): 50 | w.Header().Set("Context-Type", "application/json") 51 | w.WriteHeader(code) 52 | json.NewEncoder(w).Encode(&errorResponse{Err: err.Error()}) 53 | 54 | default: 55 | w.Header().Set("Context-Type", "text/plain") 56 | w.WriteHeader(code) 57 | w.Write([]byte(err.Error())) 58 | } 59 | } 60 | 61 | type errorResponse struct { 62 | Err string `json:"error,omitempty"` 63 | } 64 | -------------------------------------------------------------------------------- /http/file.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "io" 5 | "mime" 6 | "net/http" 7 | "net/url" 8 | "path" 9 | "strconv" 10 | 11 | "github.com/middlemost/peapod" 12 | "github.com/pressly/chi" 13 | ) 14 | 15 | // fileHandler represents an HTTP handler for files. 16 | type fileHandler struct { 17 | router chi.Router 18 | 19 | baseURL url.URL 20 | fileService peapod.FileService 21 | } 22 | 23 | // newFileHandler returns a new instance of fileHandler. 24 | func newFileHandler() *fileHandler { 25 | h := &fileHandler{router: chi.NewRouter()} 26 | h.router.Get("/:name", h.handleGet) 27 | return h 28 | } 29 | 30 | // ServeHTTP implements http.Handler. 31 | func (h *fileHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 32 | h.router.ServeHTTP(w, r) 33 | } 34 | 35 | func (h *fileHandler) handleGet(w http.ResponseWriter, r *http.Request) { 36 | ctx := r.Context() 37 | name := chi.URLParam(r, "name") 38 | 39 | // Fetch file. 40 | f, rc, err := h.fileService.FindFileByName(ctx, name) 41 | if err != nil { 42 | Error(w, r, err) 43 | return 44 | } 45 | defer rc.Close() 46 | 47 | // Set headers. 48 | w.Header().Set("Content-Type", mime.TypeByExtension(path.Ext(f.Name))) 49 | w.Header().Set("Content-Length", strconv.FormatInt(f.Size, 10)) 50 | 51 | // Write file contents to response. 52 | if _, err := io.Copy(w, rc); err != nil { 53 | Error(w, r, err) 54 | return 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /http/internal_test.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "encoding/xml" 5 | "os" 6 | "testing" 7 | "time" 8 | ) 9 | 10 | // Ensure playlist can encode to RSS. 11 | func TestPlaylistRSS(t *testing.T) { 12 | rss := &playlistRSS{ 13 | Channel: channelRSS{ 14 | Title: "TITLE", 15 | Description: cdata{"DESC"}, 16 | LastBuildDate: "LASTBUILDDATE", 17 | Image: imageRSS{Href: "IMAGE"}, 18 | Items: []itemRSS{ 19 | { 20 | Title: "TITLE", 21 | Link: "LINK", 22 | PubDate: "PUBDATE", 23 | Duration: formatDuration(63742 * time.Second), 24 | Enclosure: enclosureRSS{ 25 | URL: "URL", 26 | Type: "TYPE", 27 | Length: 100, 28 | }, 29 | }, 30 | }, 31 | }, 32 | } 33 | 34 | if err := xml.NewEncoder(os.Stdout).EncodeElement( 35 | rss, 36 | xml.StartElement{ 37 | Name: xml.Name{Local: "rss"}, 38 | Attr: []xml.Attr{ 39 | {Name: xml.Name{Local: "xmlns:itunes"}, Value: "http://www.itunes.com/dtds/podcast-1.0.dtd"}, 40 | {Name: xml.Name{Local: "xmlns:atom"}, Value: "http://www.w3.org/2005/Atom"}, 41 | {Name: xml.Name{Local: "version"}, Value: "2.0"}, 42 | }, 43 | }, 44 | ); err != nil { 45 | t.Fatal(err) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /http/playlist.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "encoding/xml" 5 | "fmt" 6 | "mime" 7 | "net/http" 8 | "net/url" 9 | "path" 10 | "sort" 11 | "strings" 12 | "time" 13 | 14 | "github.com/middlemost/peapod" 15 | "github.com/pressly/chi" 16 | ) 17 | 18 | // playlistHandler represents an HTTP handler for playlists. 19 | type playlistHandler struct { 20 | router chi.Router 21 | 22 | baseURL url.URL 23 | playlistService peapod.PlaylistService 24 | } 25 | 26 | // newPlaylistHandler returns a new instance of playlistHandler. 27 | func newPlaylistHandler() *playlistHandler { 28 | h := &playlistHandler{router: chi.NewRouter()} 29 | h.router.Get("/:token", h.handleGet) 30 | return h 31 | } 32 | 33 | // ServeHTTP implements http.Handler. 34 | func (h *playlistHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 35 | h.router.ServeHTTP(w, r) 36 | } 37 | 38 | func (h *playlistHandler) handleGet(w http.ResponseWriter, r *http.Request) { 39 | ctx := r.Context() 40 | token := strings.TrimSuffix(chi.URLParam(r, "token"), ".rss") 41 | 42 | // Fetch playlist by token. 43 | playlist, err := h.playlistService.FindPlaylistByToken(ctx, token) 44 | 45 | // Reverse track order. 46 | if playlist != nil { 47 | sort.Slice(playlist.Tracks, func(i, j int) bool { return i >= j }) 48 | } 49 | 50 | // Encode response. 51 | switch { 52 | case strings.Contains(r.Header.Get("Accept"), "text/xml"): 53 | if err != nil { 54 | Error(w, r, err) 55 | return 56 | } 57 | 58 | // Determine logo image. 59 | imageURL := h.baseURL 60 | imageURL.Path = "/assets/logo-1024x1024.png" 61 | 62 | // Convert playlist to RSS feed. 63 | rss := playlistRSS{ 64 | Channel: channelRSS{ 65 | Title: playlist.Name, 66 | Description: cdata{"Your personal podcast."}, 67 | Summary: cdata{"Your personal podcast."}, 68 | Image: imageRSS{Href: imageURL.String()}, 69 | Items: make([]itemRSS, len(playlist.Tracks)), 70 | }, 71 | } 72 | if t := playlist.LastTrackUpdatedAt(); !t.IsZero() { 73 | rss.Channel.LastBuildDate = t.Format(time.RFC1123Z) 74 | } 75 | 76 | // Conver tracks to RSS. 77 | for i, track := range playlist.Tracks { 78 | enclosureURL := h.baseURL 79 | enclosureURL.Path = fmt.Sprintf("/files/%s", track.Filename) 80 | 81 | rss.Channel.Items[i] = itemRSS{ 82 | Title: track.Title, 83 | Description: cdata{track.Description}, 84 | Summary: cdata{track.Description}, 85 | PubDate: track.CreatedAt.Format(time.RFC1123Z), 86 | Duration: formatDuration(track.Duration), 87 | Enclosure: enclosureRSS{ 88 | URL: enclosureURL.String(), 89 | Type: mime.TypeByExtension(path.Ext(track.Filename)), 90 | Length: track.Size, 91 | }, 92 | } 93 | } 94 | 95 | w.Header().Set("Content-Type", "text/xml") 96 | if err := xml.NewEncoder(w).EncodeElement( 97 | rss, 98 | xml.StartElement{ 99 | Name: xml.Name{Local: "rss"}, 100 | Attr: []xml.Attr{ 101 | {Name: xml.Name{Local: "xmlns:itunes"}, Value: "http://www.itunes.com/dtds/podcast-1.0.dtd"}, 102 | {Name: xml.Name{Local: "xmlns:atom"}, Value: "http://www.w3.org/2005/Atom"}, 103 | {Name: xml.Name{Local: "version"}, Value: "2.0"}, 104 | }, 105 | }, 106 | ); err != nil { 107 | Error(w, r, err) 108 | return 109 | } 110 | 111 | default: 112 | Error(w, r, ErrNotAcceptable) 113 | } 114 | } 115 | 116 | // playlistRSS represents an RSS feed for a playlist. 117 | type playlistRSS struct { 118 | Channel channelRSS `xml:"channel"` 119 | } 120 | 121 | type channelRSS struct { 122 | Title string `xml:"title"` 123 | Description cdata `xml:"description"` 124 | Summary cdata `xml:"itunes:summary"` 125 | Image imageRSS `xml:"itunes:image"` 126 | LastBuildDate string `xml:"lastBuildDate"` 127 | Items []itemRSS `xml:"item"` 128 | } 129 | 130 | type imageRSS struct { 131 | Href string `xml:"href,attr"` 132 | } 133 | 134 | type itemRSS struct { 135 | Title string `xml:"title"` 136 | Description cdata `xml:"description"` 137 | Summary cdata `xml:"itunes:summary"` 138 | Link string `xml:"link"` 139 | PubDate string `xml:"pubDate"` 140 | Duration string `xml:"itunes:duration,omitempty"` 141 | Enclosure enclosureRSS `xml:"enclosure"` 142 | } 143 | 144 | type enclosureRSS struct { 145 | URL string `xml:"url,attr"` 146 | Type string `xml:"type,attr"` 147 | Length int `xml:"length,attr"` 148 | } 149 | 150 | type cdata struct { 151 | Value string `xml:",cdata"` 152 | } 153 | 154 | // formatDuration formats d in HH:MM:SS format. 155 | func formatDuration(d time.Duration) string { 156 | if d == 0 { 157 | return "" 158 | } 159 | 160 | s := (d / time.Second) % 60 161 | m := (d / time.Minute) % 60 162 | h := d / time.Hour 163 | return fmt.Sprintf("%02d:%02d:%02d", h, m, s) 164 | } 165 | -------------------------------------------------------------------------------- /http/server.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "io" 5 | "io/ioutil" 6 | "net" 7 | "net/http" 8 | "net/url" 9 | "path" 10 | 11 | "github.com/middlemost/peapod" 12 | "github.com/pressly/chi" 13 | "github.com/pressly/chi/middleware" 14 | "golang.org/x/crypto/acme/autocert" 15 | ) 16 | 17 | // Server represents an HTTP server. 18 | type Server struct { 19 | ln net.Listener 20 | 21 | // Services 22 | FileService peapod.FileService 23 | JobService peapod.JobService 24 | PlaylistService peapod.PlaylistService 25 | SMSService peapod.SMSService 26 | TrackService peapod.TrackService 27 | UserService peapod.UserService 28 | 29 | // Server options. 30 | Addr string // bind address 31 | Host string // external hostname 32 | Autocert bool // ACME autocert 33 | Recoverable bool // panic recovery 34 | 35 | // Twilio specific options. 36 | Twilio struct { 37 | AccountSID string // twilio account number 38 | } 39 | 40 | LogOutput io.Writer 41 | } 42 | 43 | // NewServer returns a new instance of Server. 44 | func NewServer() *Server { 45 | return &Server{ 46 | Recoverable: true, 47 | LogOutput: ioutil.Discard, 48 | } 49 | } 50 | 51 | // Open opens the server. 52 | func (s *Server) Open() error { 53 | // Open listener on specified bind address. 54 | // Use HTTPS port if autocert is enabled. 55 | if s.Autocert { 56 | s.ln = autocert.NewListener(s.Host) 57 | } else { 58 | ln, err := net.Listen("tcp", s.Addr) 59 | if err != nil { 60 | return err 61 | } 62 | s.ln = ln 63 | } 64 | 65 | // Start HTTP server. 66 | go http.Serve(s.ln, s.router()) 67 | 68 | return nil 69 | } 70 | 71 | // Close closes the socket. 72 | func (s *Server) Close() error { 73 | if s.ln != nil { 74 | s.ln.Close() 75 | } 76 | return nil 77 | } 78 | 79 | // URL returns a base URL string with the scheme and host. 80 | // This is available after the server has been opened. 81 | func (s *Server) URL() url.URL { 82 | if s.ln == nil { 83 | return url.URL{} 84 | } 85 | 86 | if s.Autocert { 87 | return url.URL{Scheme: "https", Host: s.Host} 88 | } 89 | return url.URL{Scheme: "http", Host: s.ln.Addr().String()} 90 | } 91 | 92 | func (s *Server) router() http.Handler { 93 | r := chi.NewRouter() 94 | 95 | // Attach router middleware. 96 | r.Use(middleware.RealIP) 97 | r.Use(middleware.Logger) 98 | if s.Recoverable { 99 | r.Use(middleware.Recoverer) 100 | } 101 | // r.Mount("/debug", middleware.Profiler()) 102 | r.Use(s.attachLogOutputToContext) 103 | r.Use(s.detectAccept) 104 | 105 | // Create API routes. 106 | r.Route("/", func(r chi.Router) { 107 | r.Use(middleware.DefaultCompress) 108 | r.Get("/ping", s.handlePing) 109 | r.Mount("/assets", newAssetHandler()) 110 | r.Mount("/p", s.playlistHandler()) // alias 111 | r.Mount("/playlists", s.playlistHandler()) 112 | r.Mount("/files", s.fileHandler()) 113 | r.Mount("/tracks", s.trackHandler()) 114 | r.Mount("/twilio", s.twilioHandler()) 115 | }) 116 | 117 | return r 118 | } 119 | 120 | // handlePing verifies the database connection and returns a success. 121 | func (s *Server) handlePing(w http.ResponseWriter, r *http.Request) { 122 | w.Write([]byte(`{"status:":"ok"}` + "\n")) 123 | } 124 | 125 | func (s *Server) playlistHandler() *playlistHandler { 126 | h := newPlaylistHandler() 127 | h.baseURL = s.URL() 128 | h.playlistService = s.PlaylistService 129 | return h 130 | } 131 | 132 | func (s *Server) fileHandler() *fileHandler { 133 | h := newFileHandler() 134 | h.fileService = s.FileService 135 | return h 136 | } 137 | 138 | func (s *Server) trackHandler() *trackHandler { 139 | h := newTrackHandler() 140 | h.jobService = s.JobService 141 | h.playlistService = s.PlaylistService 142 | h.trackService = s.TrackService 143 | h.userService = s.UserService 144 | return h 145 | } 146 | 147 | func (s *Server) twilioHandler() *twilioHandler { 148 | h := newTwilioHandler() 149 | h.baseURL = s.URL() 150 | h.accountSID = s.Twilio.AccountSID 151 | h.jobService = s.JobService 152 | h.playlistService = s.PlaylistService 153 | h.smsService = s.SMSService 154 | h.trackService = s.TrackService 155 | h.userService = s.UserService 156 | return h 157 | } 158 | 159 | func (s *Server) attachLogOutputToContext(next http.Handler) http.Handler { 160 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 161 | next.ServeHTTP(w, r.WithContext(NewContext(r.Context(), s.LogOutput))) 162 | }) 163 | } 164 | 165 | func (s *Server) detectAccept(next http.Handler) http.Handler { 166 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 167 | switch path.Ext(r.URL.Path) { 168 | case ".json": 169 | r.Header.Set("Accept", "application/json") 170 | case ".rss": 171 | r.Header.Set("Accept", "text/xml") 172 | } 173 | 174 | next.ServeHTTP(w, r) 175 | }) 176 | } 177 | -------------------------------------------------------------------------------- /http/track.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "io/ioutil" 5 | "net/http" 6 | "strings" 7 | 8 | "github.com/middlemost/peapod" 9 | "github.com/pressly/chi" 10 | ) 11 | 12 | const ( 13 | ErrTTSTextRequired = peapod.Error("tts text required") 14 | ) 15 | 16 | // trackHandler represents an HTTP handler for managing tracks. 17 | type trackHandler struct { 18 | router chi.Router 19 | 20 | // Services 21 | jobService peapod.JobService 22 | playlistService peapod.PlaylistService 23 | trackService peapod.TrackService 24 | userService peapod.UserService 25 | } 26 | 27 | // newTrackHandler returns a new instance of trackHandler. 28 | func newTrackHandler() *trackHandler { 29 | h := &trackHandler{router: chi.NewRouter()} 30 | h.router.Post("/tts", h.handlePostTTS) 31 | return h 32 | } 33 | 34 | // ServeHTTP implements http.Handler. 35 | func (h *trackHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 36 | h.router.ServeHTTP(w, r) 37 | } 38 | 39 | func (h *trackHandler) handlePostTTS(w http.ResponseWriter, r *http.Request) { 40 | ctx := r.Context() 41 | 42 | // Read body text. 43 | body, err := ioutil.ReadAll(r.Body) 44 | if err != nil { 45 | Error(w, r, err) 46 | return 47 | } 48 | text := strings.TrimSpace(string(body)) 49 | if len(text) == 0 { 50 | Error(w, r, ErrTTSTextRequired) 51 | return 52 | } 53 | 54 | // Read title. 55 | title := r.URL.Query().Get("title") 56 | if title == "" { 57 | Error(w, r, peapod.ErrTrackTitleRequired) 58 | return 59 | } 60 | 61 | // Retrieve phone number from header. 62 | // TODO: Use token auth system instead. 63 | mobileNumber := r.Header.Get("X-MOBILE-NUMBER") 64 | 65 | // Lookup user. 66 | u, err := h.userService.FindUserByMobileNumber(ctx, mobileNumber) 67 | if err != nil { 68 | Error(w, r, err) 69 | return 70 | } else if u == nil { 71 | Error(w, r, peapod.ErrUserNotFound) 72 | return 73 | } 74 | 75 | // Lookup default playlist. 76 | playlists, err := h.playlistService.FindPlaylistsByUserID(ctx, u.ID) 77 | if err != nil { 78 | Error(w, r, err) 79 | return 80 | } 81 | 82 | // Add text to job processing queue. 83 | job := peapod.Job{ 84 | OwnerID: u.ID, 85 | Type: peapod.JobTypeCreateTrackFromTTS, 86 | PlaylistID: playlists[0].ID, 87 | Title: title, 88 | Text: text, 89 | } 90 | if err := h.jobService.CreateJob(ctx, &job); err != nil { 91 | Error(w, r, err) 92 | return 93 | } 94 | 95 | w.WriteHeader(http.StatusOK) 96 | } 97 | -------------------------------------------------------------------------------- /http/twilio.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "net/url" 7 | "strings" 8 | 9 | "github.com/middlemost/peapod" 10 | "github.com/pressly/chi" 11 | ) 12 | 13 | const ( 14 | ErrTwilioAccountMismatch = peapod.Error("twilio account mismatch") 15 | ErrInvalidSMSRequestBody = peapod.Error("invalid sms request body") 16 | ) 17 | 18 | // twilioHandler represents an HTTP handler for Twilio webhooks. 19 | type twilioHandler struct { 20 | router chi.Router 21 | 22 | // The server's base URL. 23 | baseURL url.URL 24 | 25 | // Account identifier. Used to verify incoming messages. 26 | accountSID string 27 | 28 | // Services 29 | jobService peapod.JobService 30 | playlistService peapod.PlaylistService 31 | smsService peapod.SMSService 32 | trackService peapod.TrackService 33 | userService peapod.UserService 34 | } 35 | 36 | // newTwilioHandler returns a new instance of Twilio handler. 37 | func newTwilioHandler() *twilioHandler { 38 | h := &twilioHandler{router: chi.NewRouter()} 39 | h.router.Post("/voice", h.handlePostVoice) 40 | h.router.Post("/sms", h.handlePostSMS) 41 | return h 42 | } 43 | 44 | // ServeHTTP implements http.Handler. 45 | func (h *twilioHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 46 | h.router.ServeHTTP(w, r) 47 | } 48 | 49 | func (h *twilioHandler) handlePostVoice(w http.ResponseWriter, r *http.Request) { 50 | w.Header().Set("Context-Type", "text/plain") 51 | w.WriteHeader(http.StatusNotImplemented) 52 | w.Write([]byte(`Peapod does not support voice calls. Please text me instead.`)) 53 | } 54 | 55 | func (h *twilioHandler) handlePostSMS(w http.ResponseWriter, r *http.Request) { 56 | ctx := r.Context() 57 | 58 | // Verify incoming message matches account. 59 | accountSID := r.PostFormValue("AccountSid") 60 | if accountSID != h.accountSID { 61 | Error(w, r, ErrTwilioAccountMismatch) 62 | return 63 | } 64 | 65 | // Read incoming parameters. 66 | from := r.PostFormValue("From") 67 | body := strings.TrimSpace(r.PostFormValue("Body")) 68 | 69 | // Parse message as URL & ensure it doesn't point locally. 70 | u, err := url.Parse(body) 71 | if err != nil { 72 | Error(w, r, ErrInvalidSMSRequestBody) 73 | return 74 | } else if peapod.IsLocal(u.Hostname()) { 75 | Error(w, r, peapod.ErrInvalidURL) 76 | return 77 | } 78 | 79 | // Lookup user by mobile number. 80 | user, err := h.userService.FindUserByMobileNumber(ctx, from) 81 | if err != nil { 82 | Error(w, r, err) 83 | return 84 | } 85 | 86 | // Create the user if they don't exist. 87 | var isNewUser bool 88 | if user == nil { 89 | isNewUser = true 90 | user = &peapod.User{MobileNumber: from} 91 | if err := h.userService.CreateUser(ctx, user); err != nil { 92 | Error(w, r, err) 93 | return 94 | } 95 | } 96 | 97 | // Update context. 98 | ctx = peapod.NewContext(r.Context(), user) 99 | 100 | // Fetch user playlists. 101 | playlists, err := h.playlistService.FindPlaylistsByUserID(ctx, user.ID) 102 | if err != nil { 103 | Error(w, r, err) 104 | return 105 | } else if len(playlists) == 0 { 106 | Error(w, r, peapod.ErrPlaylistNotFound) 107 | return 108 | } 109 | 110 | // TODO: Ask user which playlist if there are multiple. Currently only one can exist. 111 | playlist := playlists[0] 112 | 113 | // If the user is new then send them their playlist feed URL. 114 | if isNewUser { 115 | feedURL := h.baseURL 116 | feedURL.Path = fmt.Sprintf("/p/%s.rss", playlist.Token) 117 | 118 | sms := &peapod.SMS{ 119 | To: user.MobileNumber, 120 | Body: fmt.Sprintf("Welcome to Peapod! Your personal podcast feed is:\n\n%s", feedURL.String()), 121 | } 122 | if err := h.smsService.SendSMS(ctx, sms); err != nil { 123 | Error(w, r, err) 124 | return 125 | } 126 | } 127 | 128 | // Add URL to job processing queue. 129 | job := peapod.Job{ 130 | OwnerID: user.ID, 131 | Type: peapod.JobTypeCreateTrackFromURL, 132 | PlaylistID: playlist.ID, 133 | URL: u.String(), 134 | } 135 | if err := h.jobService.CreateJob(ctx, &job); err != nil { 136 | Error(w, r, err) 137 | return 138 | } 139 | 140 | w.WriteHeader(http.StatusOK) 141 | } 142 | -------------------------------------------------------------------------------- /job.go: -------------------------------------------------------------------------------- 1 | package peapod 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "io/ioutil" 8 | "net/url" 9 | "sync" 10 | "time" 11 | ) 12 | 13 | // Job errors. 14 | const ( 15 | ErrJobRequired = Error("job required") 16 | ErrJobNotFound = Error("job not found") 17 | ErrJobOwnerRequired = Error("job owner required") 18 | ErrJobOwnerNotFound = Error("job owner not found") 19 | ErrInvalidJobType = Error("invalid job type") 20 | ErrInvalidJobStatus = Error("invalid job status") 21 | ) 22 | 23 | // Job types. 24 | const ( 25 | JobTypeCreateTrackFromURL = "create_track_from_url" 26 | JobTypeCreateTrackFromTTS = "create_track_from_tts" 27 | ) 28 | 29 | // IsValidJobType returns true if v is a valid type. 30 | func IsValidJobType(v string) bool { 31 | switch v { 32 | case JobTypeCreateTrackFromURL, JobTypeCreateTrackFromTTS: 33 | return true 34 | default: 35 | return false 36 | } 37 | } 38 | 39 | // Job statuses. 40 | const ( 41 | JobStatusPending = "pending" 42 | JobStatusProcessing = "processing" 43 | JobStatusCompleted = "completed" 44 | JobStatusFailed = "failed" 45 | ) 46 | 47 | // IsValidJobType returns true if v is a valid type. 48 | func IsValidJobStatus(v string) bool { 49 | switch v { 50 | case JobStatusPending, JobStatusProcessing, JobStatusCompleted, JobStatusFailed: 51 | return true 52 | default: 53 | return false 54 | } 55 | } 56 | 57 | // Job represents an task to be performed by a worker. 58 | type Job struct { 59 | ID int `json:"id"` 60 | OwnerID int `json:"owner_id"` 61 | Owner *User `json:"owner,omitempty"` 62 | Type string `json:"type"` 63 | Status string `json:"status"` 64 | PlaylistID int `json:"playlist_id,omitempty"` 65 | Title string `json:"title"` 66 | URL string `json:"url,omitempty"` 67 | Text string `json:"text,omitempty"` 68 | Error string `json:"error,omitempty"` 69 | CreatedAt time.Time `json:"created_at"` 70 | UpdatedAt time.Time `json:"updated_at"` 71 | } 72 | 73 | // JobService manages jobs in a job queue. 74 | type JobService interface { 75 | // Notification channel when a new job is ready. 76 | C() <-chan struct{} 77 | 78 | CreateJob(ctx context.Context, job *Job) error 79 | NextJob(ctx context.Context) (*Job, error) 80 | CompleteJob(ctx context.Context, id int, err error) error 81 | } 82 | 83 | // JobScheduler receives new jobs and schedules them for execution. 84 | type JobScheduler struct { 85 | once sync.Once 86 | closing chan struct{} 87 | wg sync.WaitGroup 88 | 89 | FileService FileService 90 | JobService JobService 91 | SMSService SMSService 92 | TrackService TrackService 93 | TTSService TTSService 94 | UserService UserService 95 | URLTrackGenerator URLTrackGenerator 96 | 97 | LogOutput io.Writer 98 | } 99 | 100 | // NewJobScheduler returns a new instance of JobScheduler. 101 | func NewJobScheduler() *JobScheduler { 102 | return &JobScheduler{ 103 | closing: make(chan struct{}), 104 | LogOutput: ioutil.Discard, 105 | } 106 | } 107 | 108 | // Open initializes the job processing queue. 109 | func (s *JobScheduler) Open() error { 110 | s.wg.Add(1) 111 | go func() { defer s.wg.Done(); s.monitor() }() 112 | return nil 113 | } 114 | 115 | // Close stops the job processing queue and waits for outstanding workers. 116 | func (s *JobScheduler) Close() error { 117 | s.once.Do(func() { close(s.closing) }) 118 | s.wg.Wait() 119 | return nil 120 | } 121 | 122 | // monitor waits for notifications from the job service and starts jobs. 123 | func (s *JobScheduler) monitor() { 124 | ctx, cancel := context.WithCancel(context.Background()) 125 | defer cancel() 126 | 127 | // Always check once initially. 128 | c := make(chan struct{}, 1) 129 | c <- struct{}{} 130 | 131 | for { 132 | // Wait for next job or for the scheduler to close. 133 | select { 134 | case <-s.closing: 135 | return 136 | 137 | case <-c: 138 | case <-s.JobService.C(): 139 | } 140 | 141 | // Read next job. 142 | job, err := s.JobService.NextJob(ctx) 143 | if err != nil { 144 | fmt.Fprintf(s.LogOutput, "scheduler: next job error: err=%s\n", err) 145 | continue 146 | } else if job == nil { 147 | fmt.Fprintf(s.LogOutput, "scheduler: no jobs found, skipping\n") 148 | continue 149 | } 150 | 151 | // Launch job processing in a separate goroutine. 152 | s.wg.Add(1) 153 | go func(ctx context.Context, job *Job) { 154 | defer s.wg.Done() 155 | s.executeJob(ctx, job) 156 | }(ctx, job) 157 | } 158 | } 159 | 160 | // executeJob processes a job in a separate goroutine. 161 | func (s *JobScheduler) executeJob(ctx context.Context, job *Job) { 162 | // Lookup user. 163 | user, err := s.UserService.FindUserByID(ctx, job.OwnerID) 164 | if err != nil { 165 | fmt.Fprintf(s.LogOutput, "scheduler: find job owner error: id=%d err=%q\n", job.OwnerID, err) 166 | return 167 | } else if user == nil { 168 | fmt.Fprintf(s.LogOutput, "scheduler: job owner not found: id=%d\n", job.OwnerID) 169 | return 170 | } 171 | 172 | // Build context with user. 173 | ctx = NewContext(ctx, user) 174 | 175 | // Log job start. 176 | fmt.Fprintf(s.LogOutput, "scheduler: job started: id=%d user=%d\n", job.ID, job.OwnerID) 177 | 178 | // Execute job. 179 | ex := JobExecutor{ 180 | FileService: s.FileService, 181 | SMSService: s.SMSService, 182 | TrackService: s.TrackService, 183 | TTSService: s.TTSService, 184 | 185 | URLTrackGenerator: s.URLTrackGenerator, 186 | } 187 | err = ex.ExecuteJob(ctx, job) 188 | 189 | // Mark job as completed. 190 | if e := s.JobService.CompleteJob(ctx, job.ID, err); e != nil { 191 | fmt.Fprintf(s.LogOutput, "scheduler: complete job error: id=%d err=%s\n", job.ID, e) 192 | return 193 | } 194 | 195 | // Log job completion. 196 | fmt.Fprintf(s.LogOutput, "scheduler: job completed: id=%d user=%d err=%q\n", job.ID, job.OwnerID, errorString(err)) 197 | } 198 | 199 | // JobExecutor represents a worker that executes a job. 200 | type JobExecutor struct { 201 | FileService FileService 202 | SMSService SMSService 203 | TrackService TrackService 204 | TTSService TTSService 205 | 206 | URLTrackGenerator URLTrackGenerator 207 | } 208 | 209 | // ExecuteJob processes a single job. 210 | func (e *JobExecutor) ExecuteJob(ctx context.Context, job *Job) error { 211 | switch job.Type { 212 | case JobTypeCreateTrackFromURL: 213 | return e.createTrackFromURL(ctx, job) 214 | case JobTypeCreateTrackFromTTS: 215 | return e.createTrackFromTTS(ctx, job) 216 | default: 217 | return ErrInvalidJobType 218 | } 219 | } 220 | 221 | // createTrackFromURL generates a new track based on a URL. 222 | func (e *JobExecutor) createTrackFromURL(ctx context.Context, job *Job) error { 223 | user := FromContext(ctx) 224 | 225 | var title string 226 | jobErr := func() error { 227 | // Parse URL. 228 | u, err := url.Parse(job.URL) 229 | if err != nil { 230 | return ErrInvalidURL 231 | } 232 | 233 | // Generate track & file contents from a URL. 234 | track, rc, err := e.URLTrackGenerator.GenerateTrackFromURL(ctx, *u) 235 | if err != nil { 236 | return err 237 | } 238 | defer rc.Close() 239 | title = track.Title 240 | 241 | // Create a file from the reader. 242 | file := &File{Name: e.FileService.GenerateName(".mp3")} 243 | if err := e.FileService.CreateFile(ctx, file, rc); err != nil { 244 | return err 245 | } 246 | 247 | // Attach playlist & file to track. 248 | track.PlaylistID = job.PlaylistID 249 | track.Filename = file.Name 250 | 251 | // Create new track. 252 | if err := e.TrackService.CreateTrack(ctx, track); err != nil { 253 | return err 254 | } 255 | return nil 256 | }() 257 | 258 | // Notify user of success/failure. 259 | msg := &SMS{To: user.MobileNumber} 260 | if jobErr == nil { 261 | msg.Body = fmt.Sprintf(`%q has been added to your playlist.`, title) 262 | } else { 263 | if title != "" { 264 | msg.Body = fmt.Sprintf(`Unfortunately there was a problem processing %q.`, title) 265 | } else { 266 | msg.Body = fmt.Sprintf(`Unfortunately there was a problem processing your request.`) 267 | } 268 | } 269 | 270 | if err := e.SMSService.SendSMS(ctx, msg); err != nil { 271 | return err 272 | } 273 | 274 | return jobErr 275 | } 276 | 277 | // createTrackFromTTS generates a new track using text-to-speech. 278 | func (e *JobExecutor) createTrackFromTTS(ctx context.Context, job *Job) error { 279 | user := FromContext(ctx) 280 | 281 | jobErr := func() error { 282 | // Generate audio file. 283 | rc, err := e.TTSService.SynthesizeSpeech(ctx, job.Text) 284 | if err != nil { 285 | return err 286 | } 287 | defer rc.Close() 288 | 289 | // Create a file from the reader. 290 | file := &File{Name: e.FileService.GenerateName(".mp3")} 291 | if err := e.FileService.CreateFile(ctx, file, rc); err != nil { 292 | return err 293 | } 294 | 295 | // Create new track. 296 | if err := e.TrackService.CreateTrack(ctx, &Track{ 297 | PlaylistID: job.PlaylistID, 298 | Filename: file.Name, 299 | Title: job.Title, 300 | ContentType: "audio/mp3", 301 | Size: int(file.Size), 302 | }); err != nil { 303 | return err 304 | } 305 | return nil 306 | }() 307 | 308 | // Notify user of success/failure. 309 | msg := &SMS{To: user.MobileNumber} 310 | if jobErr == nil { 311 | msg.Body = fmt.Sprintf(`%q has been added to your playlist.`, job.Title) 312 | } else { 313 | msg.Body = fmt.Sprintf(`Unfortunately there was a problem processing %q.`, job.Title) 314 | } 315 | 316 | if err := e.SMSService.SendSMS(ctx, msg); err != nil { 317 | return err 318 | } 319 | 320 | return jobErr 321 | } 322 | 323 | func errorString(err error) string { 324 | if err != nil { 325 | return err.Error() 326 | } 327 | return "" 328 | } 329 | -------------------------------------------------------------------------------- /local/file.go: -------------------------------------------------------------------------------- 1 | package local 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "os" 7 | "path/filepath" 8 | 9 | "github.com/middlemost/peapod" 10 | ) 11 | 12 | // FileService represents a service for serving files from the local filesystem. 13 | type FileService struct { 14 | Path string 15 | GenerateToken func() string 16 | } 17 | 18 | // NewFileService returns a new instance of FileService. 19 | func NewFileService() *FileService { 20 | return &FileService{ 21 | GenerateToken: peapod.GenerateToken, 22 | } 23 | } 24 | 25 | // GenerateName returns a randomly generated name with the given extension. 26 | func (s *FileService) GenerateName(ext string) string { 27 | return s.GenerateToken() + ext 28 | } 29 | 30 | // FindFileByName returns a file and a reader to its contents. 31 | // The read must be closed by the caller. 32 | func (s *FileService) FindFileByName(ctx context.Context, name string) (*peapod.File, io.ReadCloser, error) { 33 | if name == "" { 34 | return nil, nil, peapod.ErrFilenameRequired 35 | } else if !peapod.IsValidFilename(name) { 36 | return nil, nil, peapod.ErrInvalidFilename 37 | } 38 | 39 | // Stat file. 40 | path := filepath.Join(s.Path, name) 41 | fi, err := os.Stat(path) 42 | if os.IsNotExist(err) { 43 | return nil, nil, nil 44 | } else if err != nil { 45 | return nil, nil, err 46 | } 47 | 48 | // Open local file. 49 | file, err := os.Open(path) 50 | if os.IsNotExist(err) { 51 | return nil, nil, nil 52 | } else if err != nil { 53 | return nil, nil, err 54 | } 55 | 56 | // Generate file object. 57 | f := &peapod.File{Name: name, Size: fi.Size()} 58 | 59 | return f, file, nil 60 | } 61 | 62 | // CreateFile creates a new file with the contents of r. 63 | func (s *FileService) CreateFile(ctx context.Context, f *peapod.File, r io.Reader) error { 64 | // Ensure parent path exists. 65 | if err := os.MkdirAll(s.Path, 0777); err != nil { 66 | return err 67 | } 68 | 69 | // Create file inside directory. 70 | path := filepath.Join(s.Path, f.Name) 71 | file, err := os.Create(path) 72 | if err != nil { 73 | return err 74 | } 75 | defer file.Close() 76 | 77 | // Copy contents. 78 | if _, err := io.Copy(file, r); err != nil { 79 | os.Remove(file.Name()) 80 | return err 81 | } 82 | 83 | // Close file handle. 84 | if err := file.Close(); err != nil { 85 | return err 86 | } 87 | 88 | // Read size. 89 | fi, err := os.Stat(path) 90 | if err != nil { 91 | return err 92 | } 93 | f.Size = fi.Size() 94 | 95 | return nil 96 | } 97 | -------------------------------------------------------------------------------- /local/file_test.go: -------------------------------------------------------------------------------- 1 | package local_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io/ioutil" 7 | "os" 8 | "reflect" 9 | "strings" 10 | "testing" 11 | 12 | "github.com/middlemost/peapod" 13 | "github.com/middlemost/peapod/local" 14 | ) 15 | 16 | // Ensure file service can create and fetch a file. 17 | func TestFileService(t *testing.T) { 18 | s := NewFileService() 19 | defer s.MustClose() 20 | 21 | // Create file. 22 | var f peapod.File 23 | if err := s.CreateFile(context.Background(), &peapod.File{Name: "0001"}, strings.NewReader("ABC")); err != nil { 24 | t.Fatal(err) 25 | } 26 | 27 | // Fetch file & verify. 28 | if other, rc, err := s.FindFileByName(context.Background(), "0001"); err != nil { 29 | t.Fatal(err) 30 | } else if !reflect.DeepEqual(other, &peapod.File{Name: "0001", Size: 3}) { 31 | t.Fatalf("unexpected file: %#v", f) 32 | } else if buf, err := ioutil.ReadAll(rc); err != nil { 33 | t.Fatal(err) 34 | } else if string(buf) != "ABC" { 35 | t.Fatalf("unexpected file data: %q", buf) 36 | } else if err := rc.Close(); err != nil { 37 | t.Fatal(err) 38 | } 39 | } 40 | 41 | // FileService is a test wrapper for local.FileService. 42 | type FileService struct { 43 | *local.FileService 44 | } 45 | 46 | // NewFileService returns a file service in a temporary directory. 47 | func NewFileService() *FileService { 48 | path, err := ioutil.TempDir("", "peapod-") 49 | if err != nil { 50 | panic(err) 51 | } 52 | 53 | s := &FileService{FileService: local.NewFileService()} 54 | s.Path = path 55 | return s 56 | } 57 | 58 | // MustClose cleans up the temporary directory used by the service. 59 | func (s *FileService) MustClose() { 60 | if err := os.RemoveAll(s.Path); err != nil { 61 | panic(err) 62 | } 63 | } 64 | 65 | // SequentialTokenGenerator returns an autoincrementing token. 66 | func SequentialTokenGenerator() func() string { 67 | var i int 68 | return func() string { 69 | i++ 70 | return fmt.Sprintf("%04x", i) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /mock/file.go: -------------------------------------------------------------------------------- 1 | package mock 2 | 3 | import ( 4 | "context" 5 | "io" 6 | 7 | "github.com/middlemost/peapod" 8 | ) 9 | 10 | var _ peapod.FileService = &FileService{} 11 | 12 | type FileService struct { 13 | GenerateNameFn func(ext string) string 14 | FindFileByNameFn func(ctx context.Context, name string) (*peapod.File, io.ReadCloser, error) 15 | CreateFileFn func(ctx context.Context, f *peapod.File, r io.Reader) error 16 | } 17 | 18 | func (s *FileService) GenerateName(ext string) string { 19 | return s.GenerateNameFn(ext) 20 | } 21 | 22 | func (s *FileService) FindFileByName(ctx context.Context, name string) (*peapod.File, io.ReadCloser, error) { 23 | return s.FindFileByNameFn(ctx, name) 24 | } 25 | 26 | func (s *FileService) CreateFile(ctx context.Context, f *peapod.File, r io.Reader) error { 27 | return s.CreateFileFn(ctx, f, r) 28 | } 29 | -------------------------------------------------------------------------------- /mock/job.go: -------------------------------------------------------------------------------- 1 | package mock 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/middlemost/peapod" 7 | ) 8 | 9 | var _ peapod.JobService = &JobService{} 10 | 11 | // JobService manages jobs in a job queue. 12 | type JobService struct { 13 | CFn func() <-chan struct{} 14 | CreateJobFn func(ctx context.Context, job *peapod.Job) error 15 | NextJobFn func(ctx context.Context) (*peapod.Job, error) 16 | CompleteJobFn func(ctx context.Context, id int, err error) error 17 | } 18 | 19 | func (s *JobService) C() <-chan struct{} { 20 | return s.CFn() 21 | } 22 | 23 | func (s *JobService) CreateJob(ctx context.Context, job *peapod.Job) error { 24 | return s.CreateJobFn(ctx, job) 25 | } 26 | 27 | func (s *JobService) NextJob(ctx context.Context) (*peapod.Job, error) { 28 | return s.NextJobFn(ctx) 29 | } 30 | 31 | func (s *JobService) CompleteJob(ctx context.Context, id int, err error) error { 32 | return s.CompleteJobFn(ctx, id, err) 33 | } 34 | -------------------------------------------------------------------------------- /mock/playlist.go: -------------------------------------------------------------------------------- 1 | package mock 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/middlemost/peapod" 7 | ) 8 | 9 | var _ peapod.PlaylistService = &PlaylistService{} 10 | 11 | type PlaylistService struct { 12 | FindPlaylistByIDFn func(ctx context.Context, id int) (*peapod.Playlist, error) 13 | FindPlaylistByTokenFn func(ctx context.Context, token string) (*peapod.Playlist, error) 14 | FindPlaylistsByUserIDFn func(ctx context.Context, id int) ([]*peapod.Playlist, error) 15 | } 16 | 17 | func (s *PlaylistService) FindPlaylistByID(ctx context.Context, id int) (*peapod.Playlist, error) { 18 | return s.FindPlaylistByIDFn(ctx, id) 19 | } 20 | 21 | func (s *PlaylistService) FindPlaylistByToken(ctx context.Context, token string) (*peapod.Playlist, error) { 22 | return s.FindPlaylistByTokenFn(ctx, token) 23 | } 24 | 25 | func (s *PlaylistService) FindPlaylistsByUserID(ctx context.Context, id int) ([]*peapod.Playlist, error) { 26 | return s.FindPlaylistsByUserIDFn(ctx, id) 27 | } 28 | -------------------------------------------------------------------------------- /mock/sms.go: -------------------------------------------------------------------------------- 1 | package mock 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/middlemost/peapod" 7 | ) 8 | 9 | var _ peapod.SMSService = &SMSService{} 10 | 11 | type SMSService struct { 12 | SendSMSFn func(ctx context.Context, msg *peapod.SMS) error 13 | } 14 | 15 | func (s *SMSService) SendSMS(ctx context.Context, msg *peapod.SMS) error { 16 | return s.SendSMSFn(ctx, msg) 17 | } 18 | -------------------------------------------------------------------------------- /mock/track.go: -------------------------------------------------------------------------------- 1 | package mock 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "net/url" 7 | 8 | "github.com/middlemost/peapod" 9 | ) 10 | 11 | var _ peapod.TrackService = &TrackService{} 12 | 13 | type TrackService struct { 14 | FindTrackByIDFn func(ctx context.Context, id int) (*peapod.Track, error) 15 | CreateTrackFn func(ctx context.Context, track *peapod.Track) error 16 | } 17 | 18 | func (s *TrackService) FindTrackByID(ctx context.Context, id int) (*peapod.Track, error) { 19 | return s.FindTrackByIDFn(ctx, id) 20 | } 21 | 22 | func (s *TrackService) CreateTrack(ctx context.Context, track *peapod.Track) error { 23 | return s.CreateTrackFn(ctx, track) 24 | } 25 | 26 | var _ peapod.URLTrackGenerator = &URLTrackGenerator{} 27 | 28 | type URLTrackGenerator struct { 29 | GenerateTrackFromURLFn func(ctx context.Context, url url.URL) (*peapod.Track, io.ReadCloser, error) 30 | } 31 | 32 | func (g *URLTrackGenerator) GenerateTrackFromURL(ctx context.Context, url url.URL) (*peapod.Track, io.ReadCloser, error) { 33 | return g.GenerateTrackFromURLFn(ctx, url) 34 | } 35 | -------------------------------------------------------------------------------- /mock/user.go: -------------------------------------------------------------------------------- 1 | package mock 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/middlemost/peapod" 7 | ) 8 | 9 | var _ peapod.UserService = &UserService{} 10 | 11 | type UserService struct { 12 | FindUserByIDFn func(ctx context.Context, id int) (*peapod.User, error) 13 | FindUserByMobileNumberFn func(ctx context.Context, mobileNumber string) (*peapod.User, error) 14 | CreateUserFn func(ctx context.Context, user *peapod.User) error 15 | } 16 | 17 | func (s *UserService) FindUserByID(ctx context.Context, id int) (*peapod.User, error) { 18 | return s.FindUserByIDFn(ctx, id) 19 | } 20 | 21 | func (s *UserService) FindUserByMobileNumber(ctx context.Context, mobileNumber string) (*peapod.User, error) { 22 | return s.FindUserByMobileNumberFn(ctx, mobileNumber) 23 | } 24 | 25 | func (s *UserService) CreateUser(ctx context.Context, user *peapod.User) error { 26 | return s.CreateUserFn(ctx, user) 27 | } 28 | -------------------------------------------------------------------------------- /peapod.go: -------------------------------------------------------------------------------- 1 | package peapod 2 | 3 | import ( 4 | "crypto/rand" 5 | "fmt" 6 | "net" 7 | ) 8 | 9 | // General errors. 10 | const ( 11 | ErrInvalidURL = Error("invalid url") 12 | ) 13 | 14 | // IsLocal returns true if the host represents the local machine. 15 | // This function assumes the hostname has no port. 16 | func IsLocal(hostname string) bool { 17 | // Check for localhost. 18 | if hostname == "localhost" { 19 | return true 20 | } 21 | 22 | // Check if an IP. 23 | if ip := net.ParseIP(hostname); ip != nil && ip.IsLoopback() { 24 | return true 25 | } 26 | 27 | return false 28 | } 29 | 30 | // GenerateToken returns a random string. 31 | func GenerateToken() string { 32 | buf := make([]byte, 20) 33 | if _, err := rand.Read(buf); err != nil { 34 | panic(err) 35 | } 36 | return fmt.Sprintf("%x", buf) 37 | } 38 | -------------------------------------------------------------------------------- /playlist.go: -------------------------------------------------------------------------------- 1 | package peapod 2 | 3 | import ( 4 | "context" 5 | "time" 6 | ) 7 | 8 | // Playlist errors. 9 | const ( 10 | ErrPlaylistRequired = Error("playlist required") 11 | ErrPlaylistNotFound = Error("playlist not found") 12 | ErrPlaylistOwnerRequired = Error("playlist owner required") 13 | ErrPlaylistTokenRequired = Error("playlist token required") 14 | ErrPlaylistNameRequired = Error("playlist name required") 15 | ) 16 | 17 | const DefaultPlaylistName = "My Peapod" 18 | 19 | // Playlist represents a time-ordered list of tracks. 20 | type Playlist struct { 21 | ID int `json:"id"` 22 | OwnerID int `json:"owner_id"` 23 | Token string `json:"token"` 24 | Name string `json:"name"` 25 | CreatedAt time.Time `json:"created_at"` 26 | UpdatedAt time.Time `json:"updated_at"` 27 | 28 | Tracks []*Track `json:"tracks,omitempty"` 29 | } 30 | 31 | // LastTrackUpdatedAt returns maximum track time. 32 | func (p *Playlist) LastTrackUpdatedAt() time.Time { 33 | var max time.Time 34 | for _, track := range p.Tracks { 35 | if track.UpdatedAt.After(max) { 36 | max = track.UpdatedAt 37 | } 38 | } 39 | return max 40 | } 41 | 42 | // PlaylistService represents a service for managing playlists. 43 | type PlaylistService interface { 44 | FindPlaylistByID(ctx context.Context, id int) (*Playlist, error) 45 | FindPlaylistByToken(ctx context.Context, token string) (*Playlist, error) 46 | FindPlaylistsByUserID(ctx context.Context, id int) ([]*Playlist, error) 47 | } 48 | -------------------------------------------------------------------------------- /sms.go: -------------------------------------------------------------------------------- 1 | package peapod 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | // SMS represents a text message. 8 | type SMS struct { 9 | ID string 10 | To string 11 | Body string 12 | } 13 | 14 | // SMSService sends a text message to a recipient. 15 | type SMSService interface { 16 | SendSMS(ctx context.Context, msg *SMS) error 17 | } 18 | -------------------------------------------------------------------------------- /track.go: -------------------------------------------------------------------------------- 1 | package peapod 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "net/url" 7 | "time" 8 | ) 9 | 10 | // Track errors. 11 | const ( 12 | ErrTrackRequired = Error("track required") 13 | ErrTrackNotFound = Error("track not found") 14 | ErrTrackPlaylistRequired = Error("track playlist required") 15 | ErrTrackFilenameRequired = Error("track filename required") 16 | ErrTrackTitleRequired = Error("track title required") 17 | ) 18 | 19 | // Track represents an audio track. 20 | type Track struct { 21 | ID int `json:"id"` 22 | PlaylistID int `json:"playlist_id"` 23 | Filename string `json:"filename"` 24 | Title string `json:"title"` 25 | Description string `json:"description"` 26 | Duration time.Duration `json:"duration"` 27 | ContentType string `json:"content_type"` 28 | Size int `json:"size"` 29 | CreatedAt time.Time `json:"created_at"` 30 | UpdatedAt time.Time `json:"updated_at"` 31 | } 32 | 33 | // TrackService represents a service for managing audio tracks. 34 | type TrackService interface { 35 | FindTrackByID(ctx context.Context, id int) (*Track, error) 36 | CreateTrack(ctx context.Context, track *Track) error 37 | } 38 | 39 | // URLTrackGenerator returns a track and file contents from a URL. 40 | type URLTrackGenerator interface { 41 | GenerateTrackFromURL(ctx context.Context, url url.URL) (*Track, io.ReadCloser, error) 42 | } 43 | -------------------------------------------------------------------------------- /tts.go: -------------------------------------------------------------------------------- 1 | package peapod 2 | 3 | import ( 4 | "context" 5 | "io" 6 | ) 7 | 8 | type TTSService interface { 9 | SynthesizeSpeech(ctx context.Context, text string) (io.ReadCloser, error) 10 | } 11 | -------------------------------------------------------------------------------- /twilio/sms.go: -------------------------------------------------------------------------------- 1 | package twilio 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "io/ioutil" 9 | 10 | "github.com/middlemost/peapod" 11 | "github.com/subosito/twilio" 12 | ) 13 | 14 | // Ensure service implements interface. 15 | var _ peapod.SMSService = &SMSService{} 16 | 17 | // SMSService represents a service for sending SMS text messages over Twilio. 18 | type SMSService struct { 19 | // API settings. 20 | AccountSID string 21 | AuthToken string 22 | 23 | // Sender phone number. 24 | From string 25 | 26 | LogOutput io.Writer 27 | } 28 | 29 | // NewSMSService returns a new instance of SMSService. 30 | func NewSMSService() *SMSService { 31 | return &SMSService{LogOutput: ioutil.Discard} 32 | } 33 | 34 | // SendSMS sends an SMS message. 35 | func (s *SMSService) SendSMS(ctx context.Context, msg *peapod.SMS) error { 36 | client := twilio.NewClient(s.AccountSID, s.AuthToken, nil) 37 | 38 | // Send message. 39 | ret, _, err := client.Messages.SendSMS(s.From, msg.To, msg.Body) 40 | if err != nil { 41 | return err 42 | } 43 | msg.ID = ret.Sid 44 | 45 | // Log returned message. 46 | buf, _ := json.Marshal(ret) 47 | fmt.Fprintf(s.LogOutput, "twilio: send: %s\n", buf) 48 | 49 | return nil 50 | } 51 | -------------------------------------------------------------------------------- /twilio/sms_test.go: -------------------------------------------------------------------------------- 1 | // +build integration 2 | 3 | package twilio_test 4 | 5 | import ( 6 | "bytes" 7 | "context" 8 | "flag" 9 | "testing" 10 | 11 | "github.com/middlemost/peapod" 12 | "github.com/middlemost/peapod/twilio" 13 | ) 14 | 15 | var ( 16 | accountSID = flag.String("account-sid", "", "Account SID") 17 | authToken = flag.String("auth-token", "", "Auth Token") 18 | from = flag.String("from", "", "From") 19 | to = flag.String("to", "", "To") 20 | ) 21 | 22 | // Ensure service can send an SMS over Twilio. 23 | func TestSMSService_SendSMS(t *testing.T) { 24 | if *accountSID == "" { 25 | t.Fatal("account sid required") 26 | } else if *authToken == "" { 27 | t.Fatal("auth token required") 28 | } else if *from == "" { 29 | t.Fatal("from required") 30 | } else if *to == "" { 31 | t.Fatal("to required") 32 | } 33 | 34 | // Initialize service. 35 | var buf bytes.Buffer 36 | s := twilio.NewSMSService() 37 | s.AccountSID = *accountSID 38 | s.AuthToken = *authToken 39 | s.From = *from 40 | s.LogOutput = &buf 41 | 42 | // Send text. 43 | sms := &peapod.SMS{To: *to, Body: "TEST"} 44 | if err := s.SendSMS(context.Background(), sms); err != nil { 45 | t.Fatal(err) 46 | } 47 | 48 | // Verify message id is set. 49 | if sms.ID == "" { 50 | t.Fatal("expected message sid") 51 | } 52 | 53 | // Show log. 54 | t.Log(buf.String()) 55 | } 56 | -------------------------------------------------------------------------------- /user.go: -------------------------------------------------------------------------------- 1 | package peapod 2 | 3 | import ( 4 | "context" 5 | "time" 6 | ) 7 | 8 | // User errors. 9 | const ( 10 | ErrUserRequired = Error("user required") 11 | ErrUserNotFound = Error("user not found") 12 | ErrUserMobileNumberInUse = Error("mobile number already in use") 13 | ErrUserMobileNumberRequired = Error("mobile number required") 14 | ) 15 | 16 | // User represents a user in the system. 17 | type User struct { 18 | ID int `json:"id"` 19 | MobileNumber string `json:"mobile_number,omitempty"` 20 | CreatedAt time.Time `json:"created_at"` 21 | UpdatedAt time.Time `json:"updated_at"` 22 | } 23 | 24 | // UserService represents a service for managing users. 25 | type UserService interface { 26 | FindUserByID(ctx context.Context, id int) (*User, error) 27 | FindUserByMobileNumber(ctx context.Context, mobileNumber string) (*User, error) 28 | CreateUser(ctx context.Context, user *User) error 29 | } 30 | -------------------------------------------------------------------------------- /youtube_dl/youtube_dl.go: -------------------------------------------------------------------------------- 1 | package youtube_dl 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "io" 7 | "io/ioutil" 8 | "net/url" 9 | "os" 10 | "os/exec" 11 | "time" 12 | 13 | "github.com/middlemost/peapod" 14 | ) 15 | 16 | // URLTrackGenerator generates audio tracks from a URL. 17 | type URLTrackGenerator struct { 18 | Proxy string 19 | 20 | LogOutput io.Writer 21 | } 22 | 23 | // NewURLTrackGenerator returns a new instance of URLTrackGenerator. 24 | func NewURLTrackGenerator() *URLTrackGenerator { 25 | return &URLTrackGenerator{} 26 | } 27 | 28 | // GenerateTrackFromURL fetches an audio stream from a given URL. 29 | func (g *URLTrackGenerator) GenerateTrackFromURL(ctx context.Context, u url.URL) (*peapod.Track, io.ReadCloser, error) { 30 | // Ensure URL does not point to the local machine. 31 | if peapod.IsLocal(u.Hostname()) { 32 | return nil, nil, peapod.ErrInvalidURL 33 | } 34 | 35 | // Generate empty temporary file. 36 | f, err := ioutil.TempFile("", "peapod-youtube-dl-") 37 | if err != nil { 38 | return nil, nil, err 39 | } else if err := f.Close(); err != nil { 40 | return nil, nil, err 41 | } else if err := os.Remove(f.Name()); err != nil { 42 | return nil, nil, err 43 | } 44 | path := f.Name() 45 | 46 | // Build argument list. 47 | args := []string{ 48 | "-v", 49 | "-f", "bestaudio", 50 | "--no-playlist", 51 | "--extract-audio", 52 | "--audio-format", "mp3", 53 | "--audio-quality", "128K", 54 | "-o", path + ".%(ext)s", 55 | "--write-info-json", 56 | } 57 | if g.Proxy != "" { 58 | args = append(args, "--proxy", g.Proxy) 59 | } 60 | args = append(args, u.String()) 61 | 62 | // Execute command. 63 | cmd := exec.Command("youtube-dl", args...) 64 | cmd.Stdout = g.LogOutput 65 | cmd.Stderr = g.LogOutput 66 | if err := cmd.Run(); err != nil { 67 | return nil, nil, err 68 | } 69 | 70 | // Read info file. 71 | var info infoFile 72 | if buf, err := ioutil.ReadFile(path + ".info.json"); err != nil { 73 | return nil, nil, err 74 | } else if err := json.Unmarshal(buf, &info); err != nil { 75 | return nil, nil, err 76 | } 77 | 78 | // Build track. 79 | track := &peapod.Track{ 80 | Title: info.Title, 81 | Description: info.Description, 82 | Duration: time.Duration(info.Duration) * time.Second, 83 | ContentType: "audio/mp3", 84 | Size: info.Size, 85 | } 86 | 87 | // Open file handle to return for reading. 88 | file, err := os.Open(path + ".mp3") 89 | if err != nil { 90 | return nil, nil, err 91 | } 92 | 93 | return track, &oneTimeReader{File: file}, nil 94 | } 95 | 96 | // infoFile represents a partial structure of the youtube-dl info JSON file. 97 | type infoFile struct { 98 | Title string `json:"title"` 99 | Description string `json:"description"` 100 | Duration int `json:"duration"` 101 | Size int `json:"filesize"` 102 | } 103 | 104 | // oneTimeReader allows the reader to read once and then it deletes on close. 105 | type oneTimeReader struct { 106 | *os.File 107 | } 108 | 109 | // Close closes the file handle and deletes the file. 110 | func (r *oneTimeReader) Close() error { 111 | if err := r.File.Close(); err != nil { 112 | return err 113 | } 114 | return os.Remove(r.File.Name()) 115 | } 116 | -------------------------------------------------------------------------------- /youtube_dl/youtube_dl_test.go: -------------------------------------------------------------------------------- 1 | // +build integration 2 | 3 | package youtube_dl_test 4 | 5 | import ( 6 | "bytes" 7 | "context" 8 | "flag" 9 | "io" 10 | "io/ioutil" 11 | "net/url" 12 | "testing" 13 | 14 | "github.com/middlemost/peapod/youtube_dl" 15 | ) 16 | 17 | var ( 18 | proxy = flag.String("proxy", "", "Proxy") 19 | videoURL = flag.String("url", "", "Video URL") 20 | ) 21 | 22 | // Ensure service can generate a track from a URL. 23 | func TestURLTrackGenerator_GenerateTrackFromURL(t *testing.T) { 24 | if *videoURL == "" { 25 | t.Fatal("url required") 26 | } 27 | 28 | // Initialize service. 29 | var buf bytes.Buffer 30 | g := youtube_dl.NewURLTrackGenerator() 31 | g.Proxy = *proxy 32 | g.LogOutput = &buf 33 | 34 | // Parse URL. 35 | u, err := url.Parse(*videoURL) 36 | if err != nil { 37 | t.Fatal(err) 38 | } 39 | 40 | // Fetch URL. 41 | track, rc, err := g.GenerateTrackFromURL(context.Background(), *u) 42 | if err != nil { 43 | t.Log(buf.String()) 44 | t.Fatal(err) 45 | } 46 | 47 | // Copy to a temporary file. 48 | f, err := ioutil.TempFile("", "peapod-test-") 49 | if err != nil { 50 | t.Fatal(err) 51 | } else if _, err := io.Copy(f, rc); err != nil { 52 | t.Fatal(err) 53 | } else if err := f.Close(); err != nil { 54 | t.Fatal(err) 55 | } 56 | 57 | // Print output. 58 | t.Logf("Title: %s", track.Title) 59 | t.Logf("Duration: %s", track.Duration) 60 | t.Logf("ContentType: %s", track.ContentType) 61 | t.Logf("Size: %d", track.Size) 62 | t.Logf("File: %s", f.Name()) 63 | t.Log("===") 64 | 65 | // Show log. 66 | t.Log(buf.String()) 67 | } 68 | --------------------------------------------------------------------------------