diff --git a/.github/workflows/integration.yaml b/.github/workflows/integration.yaml index c57fdeee..e89fbcfd 100644 --- a/.github/workflows/integration.yaml +++ b/.github/workflows/integration.yaml @@ -8,6 +8,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 + - shell: bash + run: | + git submodule sync --recursive + git submodule update --init --force --recursive --depth=1 - name: Setup go uses: actions/setup-go@v1 with: @@ -29,6 +33,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 + - shell: bash + run: | + git submodule sync --recursive + git submodule update --init --force --recursive --depth=1 - name: Setup go uses: actions/setup-go@v1 with: @@ -43,5 +51,9 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 + - shell: bash + run: | + git submodule sync --recursive + git submodule update --init --force --recursive --depth=1 - name: Docker run: make docker diff --git a/.github/workflows/quality.yaml b/.github/workflows/quality.yaml index 1e293c44..66eef863 100644 --- a/.github/workflows/quality.yaml +++ b/.github/workflows/quality.yaml @@ -8,6 +8,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 + - shell: bash + run: | + git submodule sync --recursive + git submodule update --init --force --recursive --depth=1 - name: GolangCI uses: Mushus/golangci-lint-action@master with: diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..d3540969 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ +[submodule "forks/go-twitter"] + path = forks/go-twitter + url = https://github.com/drswork/go-twitter.git + branch = media diff --git a/forks/go-twitter b/forks/go-twitter new file mode 160000 index 00000000..110a3963 --- /dev/null +++ b/forks/go-twitter @@ -0,0 +1 @@ +Subproject commit 110a39637298dfb09e43179bec7c800a7c571ae3 diff --git a/go.mod b/go.mod index 5ac9228d..11795886 100644 --- a/go.mod +++ b/go.mod @@ -4,17 +4,16 @@ require ( github.com/DataDog/zstd v1.4.0 // indirect github.com/Sereal/Sereal v0.0.0-20190606082811-cf1bab6c7a3a // indirect github.com/appleboy/gin-jwt/v2 v2.6.2 - github.com/appleboy/gofight v0.0.0-20170928140920-75baa5975d3c + github.com/appleboy/gofight/v2 v2.1.1 github.com/asdine/storm v2.1.2+incompatible - github.com/buger/jsonparser v0.0.0-20181115193947-bf1c66bbce23 // indirect - github.com/cenkalti/backoff v2.1.1+incompatible // indirect github.com/dghubble/go-twitter v0.0.0-20190512073027-53f972dc4b06 github.com/dghubble/oauth1 v0.6.0 - github.com/dghubble/sling v1.2.0 // indirect + github.com/disintegration/imaging v1.6.2 github.com/gin-contrib/cors v1.3.0 + github.com/gin-contrib/size v0.0.0-20191128031627-745aacce0004 github.com/gin-gonic/gin v1.5.0 github.com/golang/snappy v0.0.1 // indirect - github.com/google/go-querystring v1.0.0 // indirect + github.com/google/uuid v1.1.1 github.com/konsorten/go-windows-terminal-sequences v1.0.2 // indirect github.com/labstack/gommon v0.2.9 // indirect github.com/paulmach/go.geojson v1.4.0 @@ -23,7 +22,7 @@ require ( github.com/prometheus/client_golang v0.9.4 github.com/sethvargo/go-password v0.1.3 github.com/sirupsen/logrus v1.4.2 - github.com/spf13/afero v1.2.2 // indirect + github.com/spf13/afero v1.2.2 github.com/spf13/jwalterweatherman v1.1.0 // indirect github.com/spf13/viper v1.6.1 github.com/stretchr/testify v1.4.0 @@ -31,8 +30,12 @@ require ( github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect go.etcd.io/bbolt v1.3.3 // indirect golang.org/x/crypto v0.0.0-20190611184440-5c40567a22f8 + golang.org/x/image v0.0.0-20191214001246-9130b4cfad52 // indirect golang.org/x/net v0.0.0-20190613194153-d28f0bde5980 // indirect google.golang.org/appengine v1.6.1 // indirect + gopkg.in/yaml.v2 v2.2.7 // indirect ) go 1.13 + +replace github.com/dghubble/go-twitter => ./forks/go-twitter diff --git a/go.sum b/go.sum index ea30600d..6e652781 100644 --- a/go.sum +++ b/go.sum @@ -11,8 +11,6 @@ github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuy github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/appleboy/gin-jwt/v2 v2.6.2 h1:aW8jd9Zt5lU5W18GvLMO3/T9O8DETfW3O7GzGxcL6So= github.com/appleboy/gin-jwt/v2 v2.6.2/go.mod h1:fPyTIp4l5gtQnThEGuMBzCcfvMVSs9dsfrZlXsaTJMY= -github.com/appleboy/gofight v0.0.0-20170928140920-75baa5975d3c h1:c4lbNPALPPyWmKyoacI9f3pCL4RWyuOx5ltAKj6XWBw= -github.com/appleboy/gofight v0.0.0-20170928140920-75baa5975d3c/go.mod h1:H/tvof1oZHnZdlBd+AeODZGkk1C+D2na0NXr0iXuZHA= github.com/appleboy/gofight/v2 v2.1.1 h1:mBiMmXAofKf1GdEHxp8QkfrRaj/nBCoDnkwjn8axsq8= github.com/appleboy/gofight/v2 v2.1.1/go.mod h1:6E7pthKhmwss84j/zEixBNim8Q6ahhHcYOtmW5ts5vA= github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= @@ -27,8 +25,6 @@ github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24 github.com/beorn7/perks v1.0.0 h1:HWo1m869IqiPhD389kmkxeTalrjNbbJTC8LXupb+sl0= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/bradfitz/gomemcache v0.0.0-20180710155616-bc664df96737/go.mod h1:PmM6Mmwb0LSuEubjR8N7PtNe1KxZLtOUHtbeikc5h60= -github.com/buger/jsonparser v0.0.0-20181115193947-bf1c66bbce23 h1:D21IyuvjDCshj1/qq+pCNd3VZOAEI9jy6Bi131YlXgI= -github.com/buger/jsonparser v0.0.0-20181115193947-bf1c66bbce23/go.mod h1:bbYlZJ7hK1yFx9hf58LP0zeX7UjIGs20ufpu3evjr+s= github.com/casbin/casbin v1.7.0/go.mod h1:c67qKN6Oum3UF5Q1+BByfFxkwKvhwW57ITjqwtzR1KE= github.com/cenkalti/backoff v2.1.1+incompatible h1:tKJnvO2kl0zmb/jA5UKAt4VoEVw1qxKWjE/Bpp46npY= github.com/cenkalti/backoff v2.1.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= @@ -47,17 +43,15 @@ github.com/cupcake/rdb v0.0.0-20161107195141-43ba34106c76/go.mod h1:vYwsqCOLxGii github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/dghubble/go-twitter v0.0.0-20190512073027-53f972dc4b06 h1:eg2cM+xR5Bgm4hgJ5xmtbOkgDAJSOXP4WGr2hEFdqe4= -github.com/dghubble/go-twitter v0.0.0-20190512073027-53f972dc4b06/go.mod h1:6beqTZaXeBPti9pDBcBEqxfJc7uCbSafqZPRDPQOKoM= -github.com/dghubble/oauth1 v0.5.0 h1:uJqX7Rzr3QRmp2slUWqI9Sm8NoP65AMiyXiijOWWLvQ= -github.com/dghubble/oauth1 v0.5.0/go.mod h1:8V8BMV9DJRREZx/lUaHtrs7GUMXpzbMqJxINCasxYug= github.com/dghubble/oauth1 v0.6.0 h1:m1yC01Ohc/eF38jwZ8JUjL1a+XHHXtGQgK+MxQbmSx0= github.com/dghubble/oauth1 v0.6.0/go.mod h1:8pFdfPkv/jr8mkChVbNVuJ0suiHe278BtWI4Tk1ujxk= -github.com/dghubble/sling v1.2.0 h1:PYGS9ofwbV9nfhB1kYjB1vtXshMxlp2oQxTMMXVJ5pE= -github.com/dghubble/sling v1.2.0/go.mod h1:ZcPRuLm0qrcULW2gOrjXrAWgf76sahqSyxXyVOvkunE= +github.com/dghubble/sling v1.3.0 h1:pZHjCJq4zJvc6qVQ5wN1jo5oNZlNE0+8T/h0XeXBUKU= +github.com/dghubble/sling v1.3.0/go.mod h1:XXShWaBWKzNLhu2OxikSNFrlsvowtz4kyRuXUG7oQKY= github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no= +github.com/disintegration/imaging v1.6.2 h1:w1LecBlG2Lnp8B3jk5zSuNqd7b4DXhcjwek1ei82L+c= +github.com/disintegration/imaging v1.6.2/go.mod h1:44/5580QXChDfwIclfc/PCwrr44amcmDAg8hxG0Ewe4= github.com/edsrzf/mmap-go v0.0.0-20170320065105-0bce6a688712/go.mod h1:YO35OhQPt3KJa3ryjFM5Bs14WD66h8eGKpfaBNrHW5M= github.com/elazarl/go-bindata-assetfs v1.0.0/go.mod h1:v+YaWX3bdea5J/mo8dSETolEo7R71Vk1u8bnjau5yw4= github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= @@ -65,6 +59,8 @@ github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMo github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/gin-contrib/cors v1.3.0 h1:PolezCc89peu+NgkIWt9OB01Kbzt6IP0J/JvkG6xxlg= github.com/gin-contrib/cors v1.3.0/go.mod h1:artPvLlhkF7oG06nK8v3U8TNz6IeX+w1uzCSEId5/Vc= +github.com/gin-contrib/size v0.0.0-20191128031627-745aacce0004 h1:jae0veiwVzGS69eoAG6mvRg7qhLPokgXH5R7XZ/40Yc= +github.com/gin-contrib/size v0.0.0-20191128031627-745aacce0004/go.mod h1:S/t/oY4Bt+d87HWO1cz8v3o81+/D1UzntCVAekB82t0= github.com/gin-contrib/sse v0.0.0-20190301062529-5545eab6dad3/go.mod h1:VJ0WA2NBN22VlZ2dKZQPAPnyWw5XTlK1KymzLKsr59s= github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= @@ -102,6 +98,9 @@ github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5a github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk= github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/uuid v1.1.1 h1:Gkbcsh/GbpXz7lPftLA3P6TYMwjCLYm83jiFQZF/3gY= +github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gorilla/context v1.1.1/go.mod h1:kBGZzfjB9CEq2AlWe17Uuf7NDRt0dE0s8S51q0aT7Yg= github.com/gorilla/mux v1.7.2/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs= @@ -117,6 +116,7 @@ github.com/json-iterator/go v1.1.6 h1:MrUvLMLTMxbqFJ9kzlvat/rYZqZnW3u4wkLzWTaFwK github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= github.com/json-iterator/go v1.1.7 h1:KfgG9LzI+pYjr4xvmz/5H4FXjokeP+rlHLhv3iH62Fo= github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4= +github.com/jtolds/gls v4.20.0+incompatible h1:xdiiI2gbIgH/gLH7ADydsJ1uDOEzR8yvV7C0MuV77Wo= github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/kisielk/errcheck v1.1.0/go.mod h1:EZBBE59ingxPouuu3KfxchcWSUPOHkagtvWXihfKN4Q= @@ -139,8 +139,6 @@ github.com/labstack/gommon v0.2.9/go.mod h1:E8ZTmW9vw5az5/ZyHWCp0Lw4OH2ecsaBP1C/ github.com/leodido/go-urn v1.1.0 h1:Sm1gr51B1kKyfD2BlRcLSiEkffoG96g6TPv6eRoEiB8= github.com/leodido/go-urn v1.1.0/go.mod h1:+cyI34gQWZcE1eQU7NVgKkkzdXDQHr1dBMtdAPozLkw= github.com/lib/pq v1.0.0/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= -github.com/magiconair/properties v1.8.0 h1:LLgXmsheXeRoUOBOjtwPQCWIYqM/LU1ayDtDePerRcY= -github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/magiconair/properties v1.8.1 h1:ZC2Vc7/ZFkGmsVC9KvOjumD+G5lXy2RtTKyzRKO2BQ4= github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx8mU= @@ -191,8 +189,6 @@ github.com/prometheus/procfs v0.0.2 h1:6LJUbpNm42llc4HRCuvApCSWB/WfhuNo9K98Q9sNG github.com/prometheus/procfs v0.0.2/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= -github.com/sethvargo/go-password v0.1.2 h1:fhBF4thiPVKEZ7R6+CX46GWJiPyCyXshbeqZ7lqEeYo= -github.com/sethvargo/go-password v0.1.2/go.mod h1:qKHfdSjT26DpHQWHWWR5+X4BI45jT31dg6j4RI2TEb0= github.com/sethvargo/go-password v0.1.3 h1:18KkbGDkw8SuzeohAbWqBLNSfRQblVwEHOLbPa0PvWM= github.com/sethvargo/go-password v0.1.3/go.mod h1:2tyaaoHK/AlXwh5WWQDYjqQbHcq4cjPj5qb/ciYvu/Q= github.com/siddontang/go v0.0.0-20180604090527-bdc77568d726/go.mod h1:3yhqj7WBBfRhbBlzyOC3gUxftwsU0u8gqevxwIHQpMw= @@ -201,7 +197,9 @@ github.com/siddontang/rdb v0.0.0-20150307021120-fc89ed2e418d/go.mod h1:AMEsy7v5z github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d h1:zE9ykElWQ6/NYmHa3jpm/yHnI4xSofP+UP6SpjHcSeM= github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc= +github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIKYqbNC9s= github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= @@ -216,8 +214,6 @@ github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmq github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg= github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= -github.com/spf13/viper v1.4.0 h1:yXHLWeravcrgGyFSyCgdYpXQ9dR9c/WED3pg1RhxqEU= -github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE= github.com/spf13/viper v1.6.1 h1:VPZzIkznI1YhVMRi6vNFLHSwhnhReBfgTxIPccpfdZk= github.com/spf13/viper v1.6.1/go.mod h1:t3iDnF5Jlj76alVNuyFBk5oUMCvsrkbvZK0WQdfDi5k= github.com/ssdb/gossdb v0.0.0-20180723034631-88f6b59b84ec/go.mod h1:QBvMkMya+gXctz3kmljlUCu/yB3GZ6oee+dUozsezQE= @@ -271,6 +267,10 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190611184440-5c40567a22f8 h1:1wopBVtVdWnn03fZelqdXTqk7U7zPQCb+T4rbU9ZEoU= golang.org/x/crypto v0.0.0-20190611184440-5c40567a22f8/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8 h1:hVwzHzIUGRjiF7EcUjqNxk3NCfkPxbDKRdnNE1Rpg0U= +golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= +golang.org/x/image v0.0.0-20191214001246-9130b4cfad52 h1:2fktqPPvDiVEEVT/vSTeoUPXfmRxRaGy6GU8jypvEn0= +golang.org/x/image v0.0.0-20191214001246-9130b4cfad52/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -299,8 +299,6 @@ golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190602015325-4c4f7f33c9ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= -golang.org/x/sys v0.0.0-20190614160838-b47fdc937951 h1:ZUgGZ7PSkne6oY+VgAvayrB16owfm9/DKAtgWubzgzU= -golang.org/x/sys v0.0.0-20190614160838-b47fdc937951/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a h1:aYOabOQFp6Vj6W1F80affTUvO9UxmJRx8K0gsfABByQ= golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= @@ -340,4 +338,6 @@ gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4 h1:/eiJrUcujPVeJ3xlSWaiNi3uSVmDGBK1pDHUHAnao1I= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.7 h1:VUgggvou5XRW9mHwD/yXxIYSMtY0zoKQf/v226p2nyo= +gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/internal/api/api.go b/internal/api/api.go index 14fa9579..a77ba45a 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -2,6 +2,7 @@ package api import ( "github.com/gin-contrib/cors" + limits "github.com/gin-contrib/size" "github.com/gin-gonic/gin" "github.com/sirupsen/logrus" "github.com/toorop/gin-logrus" @@ -27,6 +28,8 @@ func API() *gin.Engine { r.Use(NewPrometheus()) + r.Use(limits.RequestSizeLimiter(1024 * 1024 * 10)) + // the jwt middleware authMiddleware := AuthMiddleware() @@ -50,6 +53,8 @@ func API() *gin.Engine { admin.POST(`/tickers/:tickerID/messages`, PostMessageHandler) admin.DELETE(`/tickers/:tickerID/messages/:messageID`, DeleteMessageHandler) + admin.POST(`/upload`, PostUpload) + admin.GET(`/users`, GetUsersHandler) admin.GET(`/users/:userID`, GetUserHandler) admin.POST(`/users`, PostUserHandler) @@ -69,9 +74,10 @@ func API() *gin.Engine { public.GET(`/init`, GetInitHandler) public.GET(`/timeline`, GetTimelineHandler) - } + r.GET(`/media/:fileName`, GetMedia) + return r } diff --git a/internal/api/init_test.go b/internal/api/init_test.go index 7c3d54d9..f0f4e356 100644 --- a/internal/api/init_test.go +++ b/internal/api/init_test.go @@ -1,12 +1,12 @@ package api_test import ( + "encoding/json" "testing" - "github.com/appleboy/gofight" + "github.com/appleboy/gofight/v2" "github.com/stretchr/testify/assert" - "encoding/json" "github.com/systemli/ticker/internal/api" "github.com/systemli/ticker/internal/model" "github.com/systemli/ticker/internal/storage" diff --git a/internal/api/media.go b/internal/api/media.go new file mode 100644 index 00000000..0fb7412d --- /dev/null +++ b/internal/api/media.go @@ -0,0 +1,32 @@ +package api + +import ( + "fmt" + "net/http" + "strings" + "time" + + "github.com/gin-gonic/gin" + + . "github.com/systemli/ticker/internal/model" + . "github.com/systemli/ticker/internal/storage" +) + +func GetMedia(c *gin.Context) { + var upload Upload + + parts := strings.Split(c.Param("fileName"), ".") + err := DB.One("UUID", parts[0], &upload) + if err != nil { + c.String(http.StatusNotFound, "%s", err.Error()) + return + } + + expireTime := time.Now().AddDate(0, 1, 0) + cacheControl := fmt.Sprintf("public, max-age=%d", expireTime.Unix()) + expires := expireTime.Format(http.TimeFormat) + + c.Header("Cache-Control", cacheControl) + c.Header("Expires", expires) + c.File(upload.FullPath()) +} diff --git a/internal/api/media_test.go b/internal/api/media_test.go new file mode 100644 index 00000000..21e536b5 --- /dev/null +++ b/internal/api/media_test.go @@ -0,0 +1,79 @@ +package api_test + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/appleboy/gofight/v2" + "github.com/stretchr/testify/assert" + + "github.com/systemli/ticker/internal/api" + "github.com/systemli/ticker/internal/model" + "github.com/systemli/ticker/internal/storage" +) + +func TestGetMedia(t *testing.T) { + r := setup() + + ticker := model.Ticker{ + ID: 1, + Active: true, + Domain: "demoticker.org", + } + + _ = storage.DB.Save(&ticker) + + var url string + r.POST("/v1/admin/upload"). + SetHeader(map[string]string{"Authorization": "Bearer " + AdminToken}). + SetFileFromPath([]gofight.UploadFile{{Name: "files", Path: "../../testdata/gopher.jpg"}}, gofight.H{"ticker": "1"}). + Run(api.API(), func(r gofight.HTTPResponse, rq gofight.HTTPRequest) { + assert.Equal(t, 200, r.Code) + + var response struct { + Data map[string][]model.UploadResponse `json:"data"` + Status string `json:"status"` + Error interface{} `json:"error"` + } + + err := json.Unmarshal(r.Body.Bytes(), &response) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, model.ResponseSuccess, response.Status) + assert.Equal(t, "image/jpeg", response.Data["uploads"][0].ContentType) + assert.NotNil(t, response.Data["uploads"][0].URL) + assert.NotNil(t, response.Data["uploads"][0].UUID) + assert.NotNil(t, response.Data["uploads"][0].CreationDate) + assert.NotNil(t, response.Data["uploads"][0].ID) + + url = response.Data["uploads"][0].URL + }) + + r.GET(url). + Run(api.API(), func(r gofight.HTTPResponse, rq gofight.HTTPRequest) { + assert.Equal(t, 200, r.Code) + assert.Equal(t, "image/jpeg", r.HeaderMap.Get("Content-Type")) + assert.Equal(t, "62497", r.HeaderMap.Get("Content-Length")) + }) + + r.GET("/media/nonexisting"). + Run(api.API(), func(r gofight.HTTPResponse, rq gofight.HTTPRequest) { + assert.Equal(t, 404, r.Code) + }) + + r.GET("/media/ed79e414-c399-49f8-9d49-9387df6e2768.jpg"). + Run(api.API(), func(r gofight.HTTPResponse, rq gofight.HTTPRequest) { + assert.Equal(t, 404, r.Code) + }) + + upload := model.NewUpload("image.jpg", "image/jpeg", 1) + _ = storage.DB.Save(upload) + + r.GET(fmt.Sprintf("/media/%s", upload.FileName())). + Run(api.API(), func(r gofight.HTTPResponse, rq gofight.HTTPRequest) { + assert.Equal(t, 404, r.Code) + }) +} diff --git a/internal/api/messages.go b/internal/api/messages.go index f9a1ed86..e1a33f84 100644 --- a/internal/api/messages.go +++ b/internal/api/messages.go @@ -7,6 +7,7 @@ import ( "strings" "github.com/asdine/storm" + "github.com/asdine/storm/q" "github.com/gin-gonic/gin" "github.com/paulmach/go.geojson" log "github.com/sirupsen/logrus" @@ -108,6 +109,7 @@ func PostMessageHandler(c *gin.Context) { var body struct { Text string `json:"text" binding:"required"` GeoInformation geojson.FeatureCollection `json:"geo_information"` + Attachments []int `json:"attachments"` } err := c.Bind(&body) if err != nil { @@ -134,29 +136,44 @@ func PostMessageHandler(c *gin.Context) { } } - var ticker Ticker - err = DB.One("ID", tickerID, &ticker) + ticker := NewTicker() + err = DB.One("ID", tickerID, ticker) if err != nil { - c.JSON(http.StatusNotFound, NewJSONErrorResponse(ErrorCodeNotFound, err.Error())) + log.WithError(err).Error("failed to find the ticker") + c.JSON(http.StatusNotFound, NewJSONErrorResponse(ErrorCodeNotFound, "ticker not found")) return } + var uploads []*Upload + if len(body.Attachments) > 0 { + err := DB.Select(q.In("ID", body.Attachments)).Find(&uploads) + if err != nil { + c.JSON(http.StatusNotFound, NewJSONErrorResponse(ErrorCodeNotFound, err.Error())) + return + } + } + message := NewMessage() message.Text = body.Text message.Ticker = tickerID message.GeoInformation = body.GeoInformation + if len(uploads) > 0 { + var attachments []Attachment + for _, upload := range uploads { + attachments = append(attachments, Attachment{Extension: upload.Extension, UUID: upload.UUID, ContentType: upload.ContentType}) + } + + message.Attachments = attachments + } + if len(ticker.Hashtags) > 0 { message.Text = fmt.Sprintf(`%s %s`, message.Text, strings.Join(ticker.Hashtags, " ")) } - if ticker.Twitter.Active { - tweet, err := bridge.Twitter.Update(ticker, *message) - if err == nil { - message.Tweet = Tweet{ID: tweet.IDStr, UserName: tweet.User.ScreenName} - } else { - log.Error(err) - } + err = bridge.SendTweet(ticker, message) + if err != nil { + log.WithError(err).WithField("ticker", ticker.ID).WithField("message", message.ID).Error("sending message to twitter failed") } err = DB.Save(message) @@ -210,18 +227,15 @@ func DeleteMessageHandler(c *gin.Context) { return } - if message.Tweet.ID != "" { - err = bridge.Twitter.Delete(ticker, message.Tweet.ID) - if err != nil { - log.Error(err) - } - } - - err = DB.DeleteStruct(&message) + err = DeleteMessage(&ticker, &message) if err != nil { c.JSON(http.StatusNotFound, NewJSONErrorResponse(ErrorCodeDefault, err.Error())) return } + err = bridge.DeleteTweet(&ticker, &message) + if err != nil { + log.WithField("error", err).WithField("message", message).Error("failed to delete tweet") + } c.JSON(http.StatusOK, gin.H{ "data": nil, diff --git a/internal/api/messages_test.go b/internal/api/messages_test.go index cc535db0..3a7ef69e 100644 --- a/internal/api/messages_test.go +++ b/internal/api/messages_test.go @@ -4,8 +4,10 @@ import ( "encoding/json" "strings" "testing" + "time" - "github.com/appleboy/gofight" + "github.com/appleboy/gofight/v2" + "github.com/google/uuid" "github.com/paulmach/go.geojson" "github.com/stretchr/testify/assert" @@ -200,6 +202,82 @@ func TestPostMessageHandler(t *testing.T) { }) } +func TestPostMessageWithAttachmentHandler(t *testing.T) { + r := setup() + + ticker := model.Ticker{ + ID: 1, + Active: true, + Hashtags: []string{`#hashtag`}, + } + + upload := model.Upload{ + ID: 1, + UUID: uuid.New().String(), + CreationDate: time.Now(), + TickerID: 1, + Path: "1/1", + Extension: "jpg", + ContentType: "image/jpeg", + } + + storage.DB.Save(&ticker) + storage.DB.Save(&upload) + + body := `{ + "text": "message", + "geo_information": { + "type" : "FeatureCollection", + "features" : [{ + "type" : "Feature", + "properties" : { + "capacity" : "10", + "type" : "U-Rack", + "mount" : "Surface" + }, + "geometry" : { + "type" : "Point", + "coordinates" : [ -71.073283, 42.417500 ] + } + }] + }, + "attachments": [1] + }` + + r.POST("/v1/admin/tickers/1/messages"). + SetHeader(map[string]string{"Authorization": "Bearer " + AdminToken}). + SetBody(body). + Run(api.API(), func(r gofight.HTTPResponse, rq gofight.HTTPRequest) { + assert.Equal(t, 200, r.Code) + + type jsonResp struct { + Data map[string]model.MessageResponse `json:"data"` + Status string `json:"status"` + Error interface{} `json:"error"` + } + + var jres jsonResp + + err := json.Unmarshal(r.Body.Bytes(), &jres) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, model.ResponseSuccess, jres.Status) + assert.Equal(t, nil, jres.Error) + assert.Equal(t, 1, len(jres.Data)) + + message := jres.Data["message"] + + assert.Equal(t, "message #hashtag", message.Text) + assert.Equal(t, 1, message.Ticker) + + assert.Equal(t, 1, len(message.Attachments)) + assert.NotNil(t, message.Attachments[0].URL) + assert.Equal(t, "image/jpeg", message.Attachments[0].ContentType) + }) +} + func TestDeleteMessageHandler(t *testing.T) { r := setup() diff --git a/internal/api/settings_test.go b/internal/api/settings_test.go index 3e9e5ac8..c13ba7b5 100644 --- a/internal/api/settings_test.go +++ b/internal/api/settings_test.go @@ -1,13 +1,13 @@ package api_test import ( + "encoding/json" "strings" "testing" - "github.com/appleboy/gofight" + "github.com/appleboy/gofight/v2" "github.com/stretchr/testify/assert" - "encoding/json" "github.com/systemli/ticker/internal/api" "github.com/systemli/ticker/internal/model" "github.com/systemli/ticker/internal/storage" diff --git a/internal/api/tickers.go b/internal/api/tickers.go index d4f293cb..d6c840d4 100644 --- a/internal/api/tickers.go +++ b/internal/api/tickers.go @@ -8,6 +8,7 @@ import ( "github.com/asdine/storm/q" "github.com/dghubble/go-twitter/twitter" "github.com/gin-gonic/gin" + log "github.com/sirupsen/logrus" "github.com/systemli/ticker/internal/bridge" . "github.com/systemli/ticker/internal/model" @@ -277,11 +278,11 @@ func PutTickerTwitterHandler(c *gin.Context) { ticker.Twitter.Active = body.Active } - if ticker.Twitter.Connected() { - user, err := bridge.Twitter.User(ticker) - if err == nil { - ticker.Twitter.User = *user - } + tu, err := bridge.TwitterUser(&ticker) + if err != nil { + log.WithError(err).Error("cant fetch user information from twitter") + } else { + ticker.Twitter.User = *tu } err = DB.Save(&ticker) @@ -394,8 +395,14 @@ func ResetTickerHandler(c *gin.Context) { return } - //Delete all messages for ticker - _ = DB.Select(q.Eq("Ticker", tickerID)).Delete(new(Message)) + err = DeleteMessages(&ticker) + if err != nil { + log.WithError(err).WithField("ticker", ticker.ID).Error("error while deleting messages") + } + err = DeleteUploadsByTicker(&ticker) + if err != nil { + log.WithError(err).WithField("ticker", ticker.ID).Error("error while deleting remaining uploads") + } ticker.Reset() diff --git a/internal/api/tickers_test.go b/internal/api/tickers_test.go index 036ccf58..22da96bd 100644 --- a/internal/api/tickers_test.go +++ b/internal/api/tickers_test.go @@ -8,7 +8,7 @@ import ( "testing" "time" - "github.com/appleboy/gofight" + "github.com/appleboy/gofight/v2" "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" @@ -555,6 +555,7 @@ func setup() *gofight.RequestConfig { gin.SetMode(gin.TestMode) model.Config = model.NewConfig() + model.Config.UploadPath = os.TempDir() if storage.DB == nil { storage.DB = storage.OpenDB(fmt.Sprintf("%s/ticker_%d.db", os.TempDir(), time.Now().Unix())) diff --git a/internal/api/timeline_test.go b/internal/api/timeline_test.go index 63d97648..8eedef73 100644 --- a/internal/api/timeline_test.go +++ b/internal/api/timeline_test.go @@ -4,7 +4,7 @@ import ( "encoding/json" "testing" - "github.com/appleboy/gofight" + "github.com/appleboy/gofight/v2" "github.com/stretchr/testify/assert" "github.com/systemli/ticker/internal/api" diff --git a/internal/api/upload.go b/internal/api/upload.go new file mode 100644 index 00000000..d8fd60da --- /dev/null +++ b/internal/api/upload.go @@ -0,0 +1,123 @@ +package api + +import ( + "fmt" + "net/http" + "path/filepath" + "strconv" + + "github.com/gin-gonic/gin" + log "github.com/sirupsen/logrus" + + . "github.com/systemli/ticker/internal/model" + . "github.com/systemli/ticker/internal/storage" + "github.com/systemli/ticker/internal/util" +) + +var allowedContentTypes = []string{"image/jpeg", "image/gif", "image/png"} + +func PostUpload(c *gin.Context) { + me, err := Me(c) + if checkError(c, err, http.StatusBadRequest, ErrorCodeDefault, ErrorUserNotFound) { + return + } + + form, err := c.MultipartForm() + if err != nil { + c.JSON(http.StatusBadRequest, NewJSONErrorResponse(ErrorCodeDefault, err.Error())) + return + } + + if len(form.Value["ticker"]) != 1 { + c.JSON(http.StatusBadRequest, NewJSONErrorResponse(ErrorCodeDefault, ErrorTickerIdentifierMissing)) + return + } + + tickerID, err := strconv.Atoi(form.Value["ticker"][0]) + if checkError(c, err, http.StatusBadRequest, ErrorCodeDefault, "can't convert ticker id to int") { + return + } + ticker, err := GetTicker(tickerID) + if checkError(c, err, http.StatusBadRequest, ErrorCodeDefault, ErrorTickerNotFound) { + return + } + + if !me.IsSuperAdmin { + if !contains(me.Tickers, tickerID) { + c.JSON(http.StatusForbidden, NewJSONErrorResponse(ErrorCodeInsufficientPermissions, ErrorInsufficientPermissions)) + return + } + } + + files := form.File["files"] + if len(files) < 1 { + c.JSON(http.StatusBadRequest, NewJSONErrorResponse(ErrorCodeDefault, ErrorFilesIdentifierMissing)) + return + } + if len(files) > 3 { + c.JSON(http.StatusBadRequest, NewJSONErrorResponse(ErrorCodeDefault, ErrorTooMuchFiles)) + return + } + var uploads []*Upload + for _, fileHeader := range files { + file, err := fileHeader.Open() + if checkError(c, err, http.StatusBadRequest, ErrorCodeDefault, "can't open file in upload") { + return + } + + contentType := util.DetectContentType(file) + if !util.ContainsString(allowedContentTypes, contentType) { + log.Error(fmt.Sprintf("%s is not allowed to uploaded", contentType)) + c.JSON(http.StatusBadRequest, NewJSONErrorResponse(ErrorCodeDefault, "failed to upload")) + return + } + + u := NewUpload(fileHeader.Filename, contentType, ticker.ID) + err = DB.Save(u) + if checkError(c, err, http.StatusInternalServerError, ErrorCodeDefault, "can't save upload") { + return + } + + err = preparePath(u.FullPath()) + if checkError(c, err, http.StatusInternalServerError, ErrorCodeDefault, "can't prepare upload path") { + return + } + + if u.ContentType == "image/gif" { + err = c.SaveUploadedFile(fileHeader, u.FullPath()) + if checkError(c, err, http.StatusInternalServerError, ErrorCodeDefault, "can't save gif") { + return + } + } else { + nFile, _ := fileHeader.Open() + image, err := util.ResizeImage(nFile, 1280) + if checkError(c, err, http.StatusInternalServerError, ErrorCodeDefault, "can't resize file") { + return + } + + err = util.SaveImage(image, u.FullPath()) + if checkError(c, err, http.StatusInternalServerError, ErrorCodeDefault, "can't save uploaded file") { + return + } + } + + uploads = append(uploads, u) + } + + c.JSON(http.StatusOK, NewJSONSuccessResponse("uploads", NewUploadsResponse(uploads))) +} + +func checkError(c *gin.Context, err error, httpStatus, errorCode int, message string) bool { + if err != nil { + log.WithError(err).Error(message) + c.JSON(httpStatus, NewJSONErrorResponse(errorCode, "failed to upload")) + return true + } + + return false +} + +func preparePath(path string) error { + fs := Config.FileBackend + return fs.MkdirAll(filepath.Dir(path), 0750) +} diff --git a/internal/api/upload_test.go b/internal/api/upload_test.go new file mode 100644 index 00000000..9b50be38 --- /dev/null +++ b/internal/api/upload_test.go @@ -0,0 +1,201 @@ +package api_test + +import ( + "encoding/json" + "strconv" + "testing" + + "github.com/appleboy/gofight/v2" + "github.com/stretchr/testify/assert" + + "github.com/systemli/ticker/internal/api" + "github.com/systemli/ticker/internal/model" + "github.com/systemli/ticker/internal/storage" +) + +type uploadResponse struct { + Data map[string][]model.UploadResponse `json:"data"` + Status string `json:"status"` + Error map[string]interface{} `json:"error"` +} + +func TestPostUploadSuccessful(t *testing.T) { + r := setup() + + ticker := initUploadTestData() + + r.POST("/v1/admin/upload"). + SetHeader(map[string]string{"Authorization": "Bearer " + AdminToken}). + SetFileFromPath([]gofight.UploadFile{{Name: "files", Path: "../../testdata/gopher.jpg"}}, gofight.H{"ticker": strconv.Itoa(ticker.ID)}). + Run(api.API(), func(r gofight.HTTPResponse, rq gofight.HTTPRequest) { + assert.Equal(t, 200, r.Code) + + var response uploadResponse + err := json.Unmarshal(r.Body.Bytes(), &response) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, model.ResponseSuccess, response.Status) + assert.Equal(t, 1, len(response.Data)) + assert.Equal(t, 1, len(response.Data["uploads"])) + assert.NotNil(t, response.Data["uploads"][0].UUID) + assert.NotNil(t, response.Data["uploads"][0].ID) + assert.NotNil(t, response.Data["uploads"][0].URL) + assert.NotNil(t, response.Data["uploads"][0].CreationDate) + }) +} + +func TestPostUploadGIF(t *testing.T) { + r := setup() + + ticker := initUploadTestData() + files := []gofight.UploadFile{{Name: "files", Path: "../../testdata/gopher-dance.gif"}} + + r.POST("/v1/admin/upload"). + SetHeader(map[string]string{"Authorization": "Bearer " + AdminToken}). + SetFileFromPath(files, gofight.H{"ticker": strconv.Itoa(ticker.ID)}). + Run(api.API(), func(r gofight.HTTPResponse, rq gofight.HTTPRequest) { + assert.Equal(t, 200, r.Code) + }) +} + +func TestPostUploadTickerNonExisting(t *testing.T) { + r := setup() + + r.POST("/v1/admin/upload"). + SetHeader(map[string]string{"Authorization": "Bearer " + AdminToken}). + SetFileFromPath([]gofight.UploadFile{{Name: "files", Path: "../../testdata/gopher.jpg"}}, gofight.H{"ticker": "2"}). + Run(api.API(), func(r gofight.HTTPResponse, rq gofight.HTTPRequest) { + assert.Equal(t, 400, r.Code) + + var response uploadResponse + + err := json.Unmarshal(r.Body.Bytes(), &response) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, model.ResponseError, response.Status) + }) +} + +func TestPostUploadUnauthorized(t *testing.T) { + r := setup() + + ticker := initUploadTestData() + + r.POST("/v1/admin/upload"). + SetHeader(map[string]string{"Authorization": "Bearer " + UserToken}). + SetFileFromPath([]gofight.UploadFile{{Name: "files", Path: "../../testdata/gopher.jpg"}}, gofight.H{"ticker": strconv.Itoa(ticker.ID)}). + Run(api.API(), func(r gofight.HTTPResponse, rq gofight.HTTPRequest) { + assert.Equal(t, 403, r.Code) + + var response uploadResponse + + err := json.Unmarshal(r.Body.Bytes(), &response) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, model.ResponseError, response.Status) + }) +} + +func TestPostUploadWrongContentType(t *testing.T) { + r := setup() + + ticker := initUploadTestData() + r.POST("/v1/admin/upload"). + SetHeader(map[string]string{"Authorization": "Bearer " + AdminToken}). + SetFileFromPath([]gofight.UploadFile{{Name: "files", Path: "../../README.md"}}, gofight.H{"ticker": strconv.Itoa(ticker.ID)}). + Run(api.API(), func(r gofight.HTTPResponse, rq gofight.HTTPRequest) { + assert.Equal(t, 400, r.Code) + + var response uploadResponse + + err := json.Unmarshal(r.Body.Bytes(), &response) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, model.ResponseError, response.Status) + }) +} + +func TestPostUploadMissingTicker(t *testing.T) { + r := setup() + + r.POST("/v1/admin/upload"). + SetHeader(map[string]string{"Authorization": "Bearer " + AdminToken}). + SetFileFromPath([]gofight.UploadFile{{Name: "files", Path: "../../README.md"}}, gofight.H{}). + Run(api.API(), func(r gofight.HTTPResponse, rq gofight.HTTPRequest) { + assert.Equal(t, 400, r.Code) + }) +} + +func TestPostUploadWrongTickerParam(t *testing.T) { + r := setup() + + r.POST("/v1/admin/upload"). + SetHeader(map[string]string{"Authorization": "Bearer " + AdminToken}). + SetFileFromPath([]gofight.UploadFile{{Name: "files", Path: "../../README.md"}}, gofight.H{"ticker": "string"}). + Run(api.API(), func(r gofight.HTTPResponse, rq gofight.HTTPRequest) { + assert.Equal(t, 400, r.Code) + }) +} + +func TestPostUploadMissingFiles(t *testing.T) { + r := setup() + + ticker := initUploadTestData() + + r.POST("/v1/admin/upload"). + SetHeader(map[string]string{"Authorization": "Bearer " + AdminToken}). + SetFileFromPath([]gofight.UploadFile{}, gofight.H{"ticker": strconv.Itoa(ticker.ID)}). + Run(api.API(), func(r gofight.HTTPResponse, rq gofight.HTTPRequest) { + assert.Equal(t, 400, r.Code) + }) +} + +func TestPostUploadTooMuchFiles(t *testing.T) { + r := setup() + + ticker := initUploadTestData() + + files := []gofight.UploadFile{ + {Name: "files", Path: "../../testdata/gopher.jpg"}, + {Name: "files", Path: "../../testdata/gopher.jpg"}, + {Name: "files", Path: "../../testdata/gopher.jpg"}, + {Name: "files", Path: "../../testdata/gopher.jpg"}, + } + + r.POST("/v1/admin/upload"). + SetHeader(map[string]string{"Authorization": "Bearer " + AdminToken}). + SetFileFromPath(files, gofight.H{"ticker": strconv.Itoa(ticker.ID)}). + Run(api.API(), func(r gofight.HTTPResponse, rq gofight.HTTPRequest) { + assert.Equal(t, 400, r.Code) + + var response uploadResponse + + err := json.Unmarshal(r.Body.Bytes(), &response) + if err != nil { + t.Fatal(err) + } + + assert.Equal(t, model.ResponseError, response.Status) + assert.Equal(t, model.ErrorTooMuchFiles, response.Error["message"]) + }) +} + +func initUploadTestData() *model.Ticker { + ticker := &model.Ticker{ + ID: 1, + Active: true, + Domain: "demoticker.org", + } + + _ = storage.DB.Save(ticker) + + return ticker +} diff --git a/internal/api/user_test.go b/internal/api/user_test.go index 83c4ac49..d7e1009b 100644 --- a/internal/api/user_test.go +++ b/internal/api/user_test.go @@ -2,13 +2,15 @@ package api_test import ( "encoding/json" - "github.com/appleboy/gofight" + "strings" + "testing" + + "github.com/appleboy/gofight/v2" "github.com/stretchr/testify/assert" + "github.com/systemli/ticker/internal/api" "github.com/systemli/ticker/internal/model" "github.com/systemli/ticker/internal/storage" - "strings" - "testing" ) func TestGetUsersHandler(t *testing.T) { diff --git a/internal/api/utils.go b/internal/api/utils.go index 852d187b..2f1b7004 100644 --- a/internal/api/utils.go +++ b/internal/api/utils.go @@ -4,10 +4,10 @@ import ( "net/url" "strings" - "github.com/systemli/ticker/internal/model" - "github.com/gin-gonic/gin" "github.com/pkg/errors" + + . "github.com/systemli/ticker/internal/model" ) // @@ -37,14 +37,14 @@ func GetDomain(c *gin.Context) (string, error) { return domain, nil } -func Me(c *gin.Context) (model.User, error) { - var user model.User +func Me(c *gin.Context) (User, error) { + var user User u, exists := c.Get(UserKey) if !exists { - return user, errors.New(model.ErrorUserNotFound) + return user, errors.New(ErrorUserNotFound) } - return u.(model.User), nil + return u.(User), nil } func IsAdmin(c *gin.Context) bool { diff --git a/internal/api/utils_test.go b/internal/api/utils_test.go index fb6849e5..57e97f7e 100644 --- a/internal/api/utils_test.go +++ b/internal/api/utils_test.go @@ -1,12 +1,14 @@ package api_test import ( - "github.com/gin-gonic/gin" - "github.com/stretchr/testify/assert" - "github.com/systemli/ticker/internal/api" "net/http" "net/url" "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + + "github.com/systemli/ticker/internal/api" ) func TestGetDomainEmptyOrigin(t *testing.T) { diff --git a/internal/bridge/bridge.go b/internal/bridge/bridge.go deleted file mode 100644 index 695a479e..00000000 --- a/internal/bridge/bridge.go +++ /dev/null @@ -1,79 +0,0 @@ -package bridge - -import ( - "github.com/dghubble/go-twitter/twitter" - "github.com/dghubble/oauth1" - "github.com/systemli/ticker/internal/model" - "strconv" -) - -var Twitter *TwitterBridge - -// -type TwitterBridge struct { - ConsumerKey string - ConsumerSecret string -} - -// -func NewTwitterBridge(key, secret string) *TwitterBridge { - return &TwitterBridge{ - ConsumerKey: key, - ConsumerSecret: secret, - } -} - -// -func (tb *TwitterBridge) Update(ticker model.Ticker, message model.Message) (*twitter.Tweet, error) { - client := tb.client(ticker.Twitter.Token, ticker.Twitter.Secret) - - tweet, _, err := client.Statuses.Update(message.PrepareTweet(&ticker), nil) - if err != nil { - return tweet, err - } - - return tweet, nil -} - -func (tb *TwitterBridge) Delete(ticker model.Ticker, tweetID string) error { - client := tb.client(ticker.Twitter.Token, ticker.Twitter.Secret) - - id, err := strconv.ParseInt(tweetID, 10, 64) - if err != nil { - return err - } - - _, _, err = client.Statuses.Destroy(id, nil) - - return err -} - -//User returns the user information. -func (tb *TwitterBridge) User(ticker model.Ticker) (*twitter.User, error) { - token := oauth1.NewToken(ticker.Twitter.Token, ticker.Twitter.Secret) - httpClient := tb.config().Client(oauth1.NoContext, token) - - // Twitter client - client := twitter.NewClient(httpClient) - user, _, err := client.Accounts.VerifyCredentials(&twitter.AccountVerifyParams{ - IncludeEmail: twitter.Bool(false), - IncludeEntities: twitter.Bool(false), - SkipStatus: twitter.Bool(true), - }) - - if err != nil { - return user, err - } - - return user, nil -} - -func (tb *TwitterBridge) config() *oauth1.Config { - return oauth1.NewConfig(tb.ConsumerKey, tb.ConsumerSecret) -} - -func (tb *TwitterBridge) client(accessToken, accessSecret string) *twitter.Client { - token := oauth1.NewToken(accessToken, accessSecret) - httpClient := tb.config().Client(oauth1.NoContext, token) - return twitter.NewClient(httpClient) -} diff --git a/internal/bridge/bridge_test.go b/internal/bridge/bridge_test.go deleted file mode 100644 index 151af21e..00000000 --- a/internal/bridge/bridge_test.go +++ /dev/null @@ -1,16 +0,0 @@ -package bridge_test - -import ( - "testing" - - "github.com/stretchr/testify/assert" - - "github.com/systemli/ticker/internal/bridge" -) - -func TestNewTwitterBridge(t *testing.T) { - tb := bridge.NewTwitterBridge("key", "secret") - - assert.Equal(t, "key", tb.ConsumerKey) - assert.Equal(t, "secret", tb.ConsumerSecret) -} diff --git a/internal/bridge/helper.go b/internal/bridge/helper.go new file mode 100644 index 00000000..f740c862 --- /dev/null +++ b/internal/bridge/helper.go @@ -0,0 +1,31 @@ +package bridge + +import ( + "errors" + + "github.com/dghubble/go-twitter/twitter" + "github.com/dghubble/oauth1" + + "github.com/systemli/ticker/internal/model" +) + +//TwitterClient returns a client for twitter api +func TwitterClient(t, s string) *twitter.Client { + config := oauth1.NewConfig(model.Config.TwitterConsumerKey, model.Config.TwitterConsumerSecret) + token := oauth1.NewToken(t, s) + + return twitter.NewClient(config.Client(oauth1.NoContext, token)) +} + +//TwitterConnectionEnabled returns true when ticker can use twitter +func TwitterConnectionEnabled(ticker *model.Ticker) error { + if !ticker.Twitter.Connected() { + return errors.New("ticker is not connected to twitter") + } + + if !model.Config.TwitterEnabled() { + return errors.New("ticker is not configured with twitter credentials") + } + + return nil +} diff --git a/internal/bridge/helper_test.go b/internal/bridge/helper_test.go new file mode 100644 index 00000000..c5d06c97 --- /dev/null +++ b/internal/bridge/helper_test.go @@ -0,0 +1,39 @@ +package bridge_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/systemli/ticker/internal/bridge" + "github.com/systemli/ticker/internal/model" +) + +func TestTwitterClient(t *testing.T) { + setupHelperTestData() + c := bridge.TwitterClient("a", "b") + + assert.NotNil(t, c) +} + +func TestTwitterConnectionEnabled(t *testing.T) { + model.Config = model.NewConfig() + ticker := model.NewTicker() + + assert.NotNil(t, bridge.TwitterConnectionEnabled(ticker)) + + setupHelperTestData() + + assert.NotNil(t, bridge.TwitterConnectionEnabled(ticker)) + + ticker.Twitter.Token = "token" + ticker.Twitter.Secret = "secret" + + assert.Nil(t, bridge.TwitterConnectionEnabled(ticker)) +} + +func setupHelperTestData() { + model.Config = model.NewConfig() + model.Config.TwitterConsumerSecret = "consumer_secret" + model.Config.TwitterConsumerKey = "consumer_key" +} diff --git a/internal/bridge/twitter.go b/internal/bridge/twitter.go new file mode 100644 index 00000000..9c3b86f2 --- /dev/null +++ b/internal/bridge/twitter.go @@ -0,0 +1,102 @@ +package bridge + +import ( + "io/ioutil" + "strconv" + + "github.com/dghubble/go-twitter/twitter" + log "github.com/sirupsen/logrus" + + "github.com/systemli/ticker/internal/model" + "github.com/systemli/ticker/internal/storage" +) + +func SendTweet(ticker *model.Ticker, message *model.Message) error { + if !ticker.Twitter.Active { + return nil + } + + if err := TwitterConnectionEnabled(ticker); err != nil { + return err + } + + client := TwitterClient(ticker.Twitter.Token, ticker.Twitter.Secret) + params := &twitter.StatusUpdateParams{} + + if len(message.Attachments) > 0 { + var mediaIds []int64 + for _, attachment := range message.Attachments { + upload := &model.Upload{} + err := storage.DB.One("UUID", attachment.UUID, upload) + if err != nil { + log.WithError(err).Error("failed to find upload") + continue + } + bytes, err := ioutil.ReadFile(upload.FullPath()) + if err != nil { + log.WithError(err).Error("failed to open upload") + continue + } + + mpr, _, err := client.Media.Upload(bytes, upload.ContentType) + if err != nil { + log.WithError(err).Error("failed to upload the media file to twitter") + continue + } + + mediaIds = append(mediaIds, mpr.MediaID) + } + params.MediaIds = mediaIds + } + + tweet, _, err := client.Statuses.Update(message.PrepareTweet(ticker), params) + if err != nil { + return err + } + + message.Tweet = model.Tweet{ID: tweet.IDStr, UserName: tweet.User.ScreenName} + + return nil +} + +func DeleteTweet(ticker *model.Ticker, message *model.Message) error { + if err := TwitterConnectionEnabled(ticker); err != nil { + return err + } + + if message.Tweet.ID == "" { + return nil + } + + id, err := strconv.ParseInt(message.Tweet.ID, 10, 64) + if err != nil { + return err + } + + client := TwitterClient(ticker.Twitter.Token, ticker.Twitter.Secret) + _, _, err = client.Statuses.Destroy(id, nil) + + return err +} + +func TwitterUser(ticker *model.Ticker) (*twitter.User, error) { + u := &twitter.User{} + + if err := TwitterConnectionEnabled(ticker); err != nil { + return u, err + } + + client := TwitterClient(ticker.Twitter.Token, ticker.Twitter.Secret) + avp := &twitter.AccountVerifyParams{ + IncludeEmail: twitter.Bool(false), + IncludeEntities: twitter.Bool(false), + SkipStatus: twitter.Bool(true), + } + + user, _, err := client.Accounts.VerifyCredentials(avp) + if err != nil { + return user, err + } + + return user, nil +} diff --git a/internal/bridge/twitter_test.go b/internal/bridge/twitter_test.go new file mode 100644 index 00000000..e5fe5ad0 --- /dev/null +++ b/internal/bridge/twitter_test.go @@ -0,0 +1,114 @@ +package bridge_test + +import ( + "fmt" + "os" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + + "github.com/systemli/ticker/internal/bridge" + "github.com/systemli/ticker/internal/model" + "github.com/systemli/ticker/internal/storage" +) + +func TestSendTweet(t *testing.T) { + model.Config = model.NewConfig() + + ticker := model.NewTicker() + message := model.NewMessage() + + // Ticker Twitter is disabled + assert.Nil(t, bridge.SendTweet(ticker, message)) + + // Ticker Twitter is enabled but has no creds + ticker.Twitter.Active = true + assert.NotNil(t, bridge.SendTweet(ticker, message)) + + ticker.Twitter.Token = "token" + ticker.Twitter.Secret = "secret" + assert.NotNil(t, bridge.SendTweet(ticker, message)) + + setupTwitterTestData() + assert.NotNil(t, bridge.SendTweet(ticker, message)) +} + +func TestSendTweetWithAttachment(t *testing.T) { + setupTwitterTestData() + + ticker := model.NewTicker() + ticker.Twitter.Active = true + ticker.Twitter.Token = "token" + ticker.Twitter.Secret = "secret" + + attachment := &model.Attachment{UUID: uuid.New().String()} + + message := model.NewMessage() + message.Attachments = []model.Attachment{*attachment} + + assert.NotNil(t, bridge.SendTweet(ticker, message)) + + upload := model.NewUpload("filename.jpg", "image/jpeg", ticker.ID) + upload.UUID = attachment.UUID + _ = storage.DB.Save(upload) + + assert.NotNil(t, bridge.SendTweet(ticker, message)) +} + +func TestDeleteTweet(t *testing.T) { + setupTwitterTestData() + + ticker := model.NewTicker() + message := model.NewMessage() + + assert.NotNil(t, bridge.DeleteTweet(ticker, message)) + + ticker.Twitter.Active = true + ticker.Twitter.Token = "token" + ticker.Twitter.Secret = "secret" + + assert.Nil(t, bridge.DeleteTweet(ticker, message)) + + message.Tweet.ID = "foobar" + + assert.NotNil(t, bridge.DeleteTweet(ticker, message)) + + message.Tweet.ID = "1" + + assert.NotNil(t, bridge.DeleteTweet(ticker, message)) +} + +func TestTwitterUser(t *testing.T) { + setupTwitterTestData() + + ticker := model.NewTicker() + _, err := bridge.TwitterUser(ticker) + assert.NotNil(t, err) + + ticker.Twitter.Active = true + _, err = bridge.TwitterUser(ticker) + assert.NotNil(t, err) + + ticker.Twitter.Token = "token" + ticker.Twitter.Secret = "secret" + _, err = bridge.TwitterUser(ticker) + assert.NotNil(t, err) + +} + +func setupTwitterTestData() { + model.Config = model.NewConfig() + model.Config.TwitterConsumerSecret = "consumer_secret" + model.Config.TwitterConsumerKey = "consumer_key" + + if storage.DB == nil { + storage.DB = storage.OpenDB(fmt.Sprintf("%s/ticker_%d.db", os.TempDir(), time.Now().Nanosecond())) + } + _ = storage.DB.Drop("Ticker") + _ = storage.DB.Drop("Message") + _ = storage.DB.Drop("Upload") + _ = storage.DB.Drop("User") + _ = storage.DB.Drop("Setting") +} diff --git a/internal/model/config.go b/internal/model/config.go index b87a6ed7..93144412 100644 --- a/internal/model/config.go +++ b/internal/model/config.go @@ -6,6 +6,7 @@ import ( "github.com/sethvargo/go-password/password" log "github.com/sirupsen/logrus" + "github.com/spf13/afero" "github.com/spf13/viper" ) @@ -20,6 +21,9 @@ type config struct { TwitterConsumerKey string `mapstructure:"twitter_consumer_key"` TwitterConsumerSecret string `mapstructure:"twitter_consumer_secret"` MetricsListen string `mapstructure:"metrics_listen"` + UploadPath string `mapstructure:"upload_path"` + UploadURL string `mapstructure:"upload_url"` + FileBackend afero.Fs } //NewConfig returns config with default values. @@ -33,6 +37,9 @@ func NewConfig() *config { Secret: secret, Database: "ticker.db", MetricsListen: ":8181", + UploadPath: "uploads", + UploadURL: "http://localhost:8080", + FileBackend: afero.NewOsFs(), } } @@ -55,6 +62,14 @@ func LoadConfig(path string) *config { viper.SetDefault("metrics_listen", c.MetricsListen) viper.SetDefault("twitter_consumer_key", "") viper.SetDefault("twitter_consumer_secret", "") + viper.SetDefault("upload_path", c.UploadPath) + viper.SetDefault("upload_url", c.UploadURL) + + //TODO: Make configurable + fs := afero.NewOsFs() + c.FileBackend = fs + + viper.SetFs(fs) if path != "" { dir, file := filepath.Split(path) diff --git a/internal/model/message.go b/internal/model/message.go index 79e704d3..4381970c 100644 --- a/internal/model/message.go +++ b/internal/model/message.go @@ -13,6 +13,7 @@ type Message struct { CreationDate time.Time `storm:"index"` Ticker int `storm:"index"` Text string + Attachments []Attachment GeoInformation geojson.FeatureCollection Tweet Tweet //TODO: Facebook-ID @@ -24,14 +25,26 @@ type Tweet struct { UserName string } +type Attachment struct { + UUID string + Extension string + ContentType string +} + type MessageResponse struct { - ID int `json:"id"` - CreationDate time.Time `json:"creation_date"` - Text string `json:"text"` - Ticker int `json:"ticker"` - TweetID string `json:"tweet_id"` - TweetUser string `json:"tweet_user"` - GeoInformation string `json:"geo_information"` + ID int `json:"id"` + CreationDate time.Time `json:"creation_date"` + Text string `json:"text"` + Ticker int `json:"ticker"` + TweetID string `json:"tweet_id"` + TweetUser string `json:"tweet_user"` + GeoInformation string `json:"geo_information"` + Attachments []*MessageAttachmentResponse `json:"attachments"` +} + +type MessageAttachmentResponse struct { + URL string `json:"url"` + ContentType string `json:"content_type"` } //NewMessage creates new Message @@ -44,6 +57,12 @@ func NewMessage() *Message { // func NewMessageResponse(message Message) *MessageResponse { m, _ := message.GeoInformation.MarshalJSON() + var attachments []*MessageAttachmentResponse + + for _, attachment := range message.Attachments { + name := fmt.Sprintf("%s.%s", attachment.UUID, attachment.Extension) + attachments = append(attachments, &MessageAttachmentResponse{URL: MediaURL(name), ContentType: attachment.ContentType}) + } return &MessageResponse{ ID: message.ID, @@ -53,6 +72,7 @@ func NewMessageResponse(message Message) *MessageResponse { TweetID: message.Tweet.ID, TweetUser: message.Tweet.UserName, GeoInformation: string(m), + Attachments: attachments, } } diff --git a/internal/model/message_test.go b/internal/model/message_test.go index b74231c9..fb808083 100644 --- a/internal/model/message_test.go +++ b/internal/model/message_test.go @@ -4,6 +4,7 @@ import ( "testing" "time" + "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/systemli/ticker/internal/model" @@ -23,7 +24,14 @@ func TestPrepareTweet(t *testing.T) { } func TestNewMessageResponse(t *testing.T) { + model.Config = model.NewConfig() + m := model.NewMessage() + m.Attachments = []model.Attachment{{ + UUID: uuid.New().String(), + Extension: "jpg", + ContentType: "image/jpeg", + }} r := model.NewMessageResponse(*m) assert.Equal(t, 0, r.ID) @@ -31,6 +39,8 @@ func TestNewMessageResponse(t *testing.T) { assert.Equal(t, 0, r.Ticker) assert.Equal(t, "", r.TweetID) assert.Equal(t, "", r.TweetUser) + assert.Equal(t, 1, len(r.Attachments)) + assert.Equal(t, "image/jpeg", r.Attachments[0].ContentType) assert.Equal(t, `{"type":"FeatureCollection","features":[]}`, r.GeoInformation) } diff --git a/internal/model/response.go b/internal/model/response.go index 33888c12..9d9bba50 100644 --- a/internal/model/response.go +++ b/internal/model/response.go @@ -8,6 +8,9 @@ const ( ErrorInsufficientPermissions = "insufficient permissions" ErrorUserIdentifierMissing = "user identifier not found" + ErrorTickerIdentifierMissing = "ticker identifier not found" + ErrorFilesIdentifierMissing = "files identifier not found" + ErrorTooMuchFiles = "upload limit exceeded" ErrorUserNotFound = "user not found" ErrorTickerNotFound = "ticker not found" ErrorSettingNotFound = "setting not found" diff --git a/internal/model/upload.go b/internal/model/upload.go new file mode 100644 index 00000000..67394993 --- /dev/null +++ b/internal/model/upload.go @@ -0,0 +1,88 @@ +package model + +import ( + "fmt" + "path/filepath" + "time" + + uuid2 "github.com/google/uuid" +) + +//Upload represents the structure of an Upload configuration +type Upload struct { + ID int `storm:"id,increment"` + UUID string `storm:"index,unique"` + CreationDate time.Time `storm:"index"` + TickerID int `storm:"index"` + Path string + Extension string + ContentType string +} + +//UploadResponse represents the Upload for API responses. +type UploadResponse struct { + ID int `json:"id"` + UUID string `json:"uuid"` + CreationDate time.Time `json:"creation_date"` + URL string `json:"url"` + ContentType string `json:"content_type"` +} + +//NewUpload creates new Upload. +func NewUpload(filename, contentType string, tickerID int) *Upload { + now := time.Now() + uuid := uuid2.New() + ext := filepath.Ext(filename)[1:] + // First version we use a date based directory structure + path := fmt.Sprintf("%d/%d", now.Year(), now.Month()) + + return &Upload{ + CreationDate: now, + Path: path, + UUID: uuid.String(), + TickerID: tickerID, + Extension: ext, + ContentType: contentType, + } +} + +//FileName returns the name with file extension. +func (u *Upload) FileName() string { + return fmt.Sprintf("%s.%s", u.UUID, u.Extension) +} + +//FullPath returns the full path for the upload. +func (u *Upload) FullPath() string { + return fmt.Sprintf("%s/%s/%s", Config.UploadPath, u.Path, u.FileName()) +} + +//URL returns the public url for the upload. +func (u *Upload) URL() string { + return MediaURL(u.FileName()) +} + +//NewUploadResponse returns a API friendly representation for a Upload. +func NewUploadResponse(upload *Upload) *UploadResponse { + return &UploadResponse{ + ID: upload.ID, + UUID: upload.UUID, + CreationDate: upload.CreationDate, + URL: upload.URL(), + ContentType: upload.ContentType, + } +} + +//NewTickersResponse prepares a map of []TickerResponse. +func NewUploadsResponse(uploads []*Upload) []*UploadResponse { + var ur []*UploadResponse + + for _, upload := range uploads { + ur = append(ur, NewUploadResponse(upload)) + } + + return ur +} + +func MediaURL(name string) string { + return fmt.Sprintf("%s/media/%s", Config.UploadURL, name) +} diff --git a/internal/model/upload_test.go b/internal/model/upload_test.go new file mode 100644 index 00000000..77881b4b --- /dev/null +++ b/internal/model/upload_test.go @@ -0,0 +1,61 @@ +package model_test + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/systemli/ticker/internal/model" +) + +func TestNewUpload(t *testing.T) { + u := initUploadTestData() + + assert.Equal(t, 0, u.ID) + assert.Equal(t, "image/jpeg", u.ContentType) + assert.Equal(t, 1, u.TickerID) + assert.NotNil(t, u.Path) + assert.NotNil(t, u.UUID) + assert.NotNil(t, u.CreationDate) +} + +func TestUpload_FileName(t *testing.T) { + u := initUploadTestData() + + assert.Equal(t, fmt.Sprintf("%s.%s", u.UUID, u.Extension), u.FileName()) +} + +func TestUpload_FullPath(t *testing.T) { + u := initUploadTestData() + + assert.Equal(t, fmt.Sprintf("uploads/%d/%d/%s", u.CreationDate.Year(), u.CreationDate.Month(), u.FileName()), u.FullPath()) +} + +func TestUpload_URL(t *testing.T) { + u := initUploadTestData() + + assert.Equal(t, fmt.Sprintf("%s/media/%s", model.Config.UploadURL, u.FileName()), u.URL()) +} + +func TestNewUploadResponse(t *testing.T) { + u := initUploadTestData() + r := model.NewUploadResponse(u) + + assert.Equal(t, u.URL(), r.URL) + assert.Equal(t, u.ContentType, r.ContentType) +} + +func TestNewUploadsResponse(t *testing.T) { + u := initUploadTestData() + r := model.NewUploadsResponse([]*model.Upload{u}) + + assert.Equal(t, 1, len(r)) +} + +func initUploadTestData() *model.Upload { + model.Config = model.NewConfig() + u := model.NewUpload("image.jpg", "image/jpeg", 1) + + return u +} diff --git a/internal/storage/message.go b/internal/storage/message.go index f6ccc15f..d25a2bd5 100644 --- a/internal/storage/message.go +++ b/internal/storage/message.go @@ -2,6 +2,7 @@ package storage import ( "github.com/asdine/storm/q" + log "github.com/sirupsen/logrus" . "github.com/systemli/ticker/internal/model" . "github.com/systemli/ticker/internal/util" @@ -32,3 +33,33 @@ func FindByTicker(ticker *Ticker, pagination *Pagination) ([]Message, error) { } return messages, nil } + +//DeleteMessage removes a Message for a Ticker +func DeleteMessage(ticker *Ticker, message *Message) error { + uploads := FindUploadsByMessage(message) + + DeleteUploads(uploads) + + err := DB.DeleteStruct(message) + if err != nil { + log.WithField("error", err).WithField("message", message).Error("failed to delete message") + return err + } + + return nil +} + +//DeleteMessages removes all messages for a Ticker. +func DeleteMessages(ticker *Ticker) error { + var messages []*Message + if err := DB.Find("Ticker", ticker.ID, &messages); err != nil { + log.WithField("error", err).WithField("ticker", ticker.ID).Error("failed find messages for ticker") + return err + } + + for _, message := range messages { + _ = DeleteMessage(ticker, message) + } + + return nil +} diff --git a/internal/storage/message_test.go b/internal/storage/message_test.go index 38449ffd..f771e320 100644 --- a/internal/storage/message_test.go +++ b/internal/storage/message_test.go @@ -134,6 +134,96 @@ func TestFindByTickerInactive(t *testing.T) { assert.Equal(t, len(messages), 0) } +func TestDeleteMessage(t *testing.T) { + setup() + + ticker, message, _ := initialMessageTestData(t) + + err := storage.DeleteMessage(ticker, message) + if err != nil { + t.Fail() + } + + var m *model.Message + err = storage.DB.Find("ID", message.ID, &m) + if err == nil { + t.Fail() + } +} + +func TestDeleteMessageNonExisting(t *testing.T) { + setup() + + ticker, message, upload := initialMessageTestData(t) + + err := storage.DeleteMessage(ticker, model.NewMessage()) + if err == nil { + t.Fail() + } + + _ = storage.DB.DeleteStruct(upload) + + err = storage.DeleteMessage(ticker, message) + if err != nil { + t.Fail() + } +} + +func TestDeleteMessages(t *testing.T) { + setup() + + ticker, message, _ := initialMessageTestData(t) + + err := storage.DeleteMessages(ticker) + if err != nil { + t.Fail() + } + + var m *model.Message + err = storage.DB.Find("ID", message.ID, &m) + if err == nil { + t.Fail() + } +} + +func TestDeleteMessagesNonExisting(t *testing.T) { + setup() + + ticker, message, _ := initialMessageTestData(t) + + _ = storage.DB.DeleteStruct(message) + + err := storage.DeleteMessages(ticker) + if err == nil { + t.Fail() + } +} + +func initialMessageTestData(t *testing.T) (*model.Ticker, *model.Message, *model.Upload) { + ticker := model.NewTicker() + err := storage.DB.Save(ticker) + if err != nil { + t.Fail() + } + + upload := model.NewUpload("name.jpg", "image/jpeg", ticker.ID) + err = storage.DB.Save(upload) + if err != nil { + t.Fail() + } + + message := model.NewMessage() + message.Ticker = ticker.ID + attachment := model.Attachment{UUID: upload.UUID, Extension: upload.Extension, ContentType: upload.Extension} + message.Attachments = []model.Attachment{attachment} + err = storage.DB.Save(message) + if err != nil { + t.Fail() + } + + return ticker, message, upload +} + func createContext(query string) gin.Context { req := http.Request{ URL: &url.URL{ @@ -145,11 +235,17 @@ func createContext(query string) gin.Context { } func setup() { + gin.SetMode(gin.TestMode) + + model.Config = model.NewConfig() + model.Config.UploadPath = os.TempDir() + if storage.DB == nil { storage.DB = storage.OpenDB(fmt.Sprintf("%s/ticker_%d.db", os.TempDir(), time.Now().Nanosecond())) } _ = storage.DB.Drop("Ticker") _ = storage.DB.Drop("Message") + _ = storage.DB.Drop("Upload") _ = storage.DB.Drop("User") _ = storage.DB.Drop("Setting") } diff --git a/internal/storage/ticker.go b/internal/storage/ticker.go index dc379587..65032562 100644 --- a/internal/storage/ticker.go +++ b/internal/storage/ticker.go @@ -4,7 +4,7 @@ import ( . "github.com/systemli/ticker/internal/model" ) -//Find Ticker Configuration by domain +//FindTicker returns a Ticker for a given domain. func FindTicker(domain string) (*Ticker, error) { var ticker Ticker @@ -15,3 +15,15 @@ func FindTicker(domain string) (*Ticker, error) { return &ticker, nil } + +//GetTicker returns a Ticker for given id. +func GetTicker(id int) (*Ticker, error) { + var ticker Ticker + + err := DB.One("ID", id, &ticker) + if err != nil { + return &ticker, err + } + + return &ticker, nil +} diff --git a/internal/storage/ticker_test.go b/internal/storage/ticker_test.go index 08cd60a8..b16440ad 100644 --- a/internal/storage/ticker_test.go +++ b/internal/storage/ticker_test.go @@ -12,9 +12,7 @@ import ( func TestFindTicker(t *testing.T) { setup() - ticker := model.NewTicker() - ticker.Domain = "localhost" - _ = storage.DB.Save(ticker) + ticker := initTickerTestData() ticker, err := storage.FindTicker("localhost") if err != nil { @@ -30,3 +28,30 @@ func TestFindTicker(t *testing.T) { return } } + +func TestGetTicker(t *testing.T) { + setup() + + ticker := initTickerTestData() + + found, err := storage.GetTicker(ticker.ID) + if err != nil { + t.Fail() + return + } + + assert.Equal(t, ticker.ID, found.ID) + + _, err = storage.GetTicker(2) + if err == nil { + t.Fail() + } +} + +func initTickerTestData() *model.Ticker { + ticker := model.NewTicker() + ticker.Domain = "localhost" + _ = storage.DB.Save(ticker) + + return ticker +} diff --git a/internal/storage/upload.go b/internal/storage/upload.go new file mode 100644 index 00000000..79e88751 --- /dev/null +++ b/internal/storage/upload.go @@ -0,0 +1,62 @@ +package storage + +import ( + "os" + + "github.com/asdine/storm/q" + log "github.com/sirupsen/logrus" + + "github.com/systemli/ticker/internal/model" +) + +//FindUploadsByMessage returns all uploads for a Message. +func FindUploadsByMessage(message *model.Message) []*model.Upload { + var uploads []*model.Upload + + if len(message.Attachments) > 0 { + var uuids []string + for _, attachment := range message.Attachments { + uuids = append(uuids, attachment.UUID) + } + err := DB.Select(q.In("UUID", uuids)).Find(&uploads) + if err != nil { + log.WithField("error", err).Error("failed to find uploads for message") + } + } + + return uploads +} + +//DeleteUploadsByTicker removes all connected uploads with the given Ticker. +func DeleteUploadsByTicker(ticker *model.Ticker) error { + err := DB.Select(q.Eq("TickerID", ticker.ID)).Delete(&model.Upload{}) + if err != nil && err.Error() == "not found" { + return nil + } + + return err +} + +//DeleteUpload remove the given Upload. +func DeleteUpload(upload *model.Upload) error { + //TODO: Rework with afero.FS from Config + if err := os.Remove(upload.FullPath()); err != nil { + log.WithField("error", err).WithField("upload", upload).Error("failed to delete upload file") + } + + if err := DB.DeleteStruct(upload); err != nil { + log.WithField("error", err).WithField("upload", upload).Error("failed to delete upload") + return err + } + + return nil +} + +//DeleteUploads removes a map of Upload. +func DeleteUploads(uploads []*model.Upload) { + if len(uploads) > 0 { + for _, upload := range uploads { + _ = DeleteUpload(upload) + } + } +} diff --git a/internal/storage/upload_test.go b/internal/storage/upload_test.go new file mode 100644 index 00000000..9258d1e7 --- /dev/null +++ b/internal/storage/upload_test.go @@ -0,0 +1,100 @@ +package storage_test + +import ( + "os" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + + "github.com/systemli/ticker/internal/model" + "github.com/systemli/ticker/internal/storage" +) + +func TestDeleteUpload(t *testing.T) { + setup() + + upload := initialUploadTestData(t) + err := storage.DeleteUpload(upload) + if err != nil { + t.Fail() + } + + var u *model.Upload + err = storage.DB.Find("ID", upload.ID, &u) + if err == nil { + t.Fail() + } + + _, err = os.Open(upload.FullPath()) + if err == nil { + t.Fail() + } +} + +func TestDeleteUploadNonExisting(t *testing.T) { + setup() + + err := storage.DeleteUpload(&model.Upload{}) + if err == nil { + t.Fail() + } +} + +func TestDeleteUploads(t *testing.T) { + setup() + + upload := initialUploadTestData(t) + uploads := []*model.Upload{upload} + + storage.DeleteUploads(uploads) + + var u *model.Upload + err := storage.DB.Find("ID", upload.ID, &u) + if err == nil { + t.Fail() + } +} + +func TestFindUploadsByMessageNonExistingUpload(t *testing.T) { + setup() + + message := model.NewMessage() + attachment := model.Attachment{UUID: uuid.New().String(), Extension: "jpg", ContentType: "image/jpeg"} + message.Attachments = []model.Attachment{attachment} + err := storage.DB.Save(message) + if err != nil { + t.Fail() + } + + u := storage.FindUploadsByMessage(message) + assert.Equal(t, 0, len(u)) +} + +func TestDeleteUploadsByTicker(t *testing.T) { + setup() + + ticker := model.NewTicker() + _ = storage.DB.Save(ticker) + + err := storage.DeleteUploadsByTicker(ticker) + + assert.Nil(t, err) + + upload := model.NewUpload("name.jpg", "image/jpeg", ticker.ID) + _ = storage.DB.Save(upload) + + err = storage.DeleteUploadsByTicker(ticker) + + assert.Nil(t, err) +} + +func initialUploadTestData(t *testing.T) *model.Upload { + upload := model.NewUpload("name.jpg", "image/jpeg", 1) + err := storage.DB.Save(upload) + if err != nil { + t.Fail() + } + + return upload +} diff --git a/internal/util/file.go b/internal/util/file.go new file mode 100644 index 00000000..3f1b97b2 --- /dev/null +++ b/internal/util/file.go @@ -0,0 +1,19 @@ +package util + +import ( + "io" + "net/http" +) + +//DetectContentType detects the ContentType from the first 512 bytes of the given io.Reader. +func DetectContentType(r io.Reader) string { + // Only the first 512 bytes are used to sniff the content type. + buffer := make([]byte, 512) + + _, err := r.Read(buffer) + if err != nil { + return "application/octet-stream" + } + + return http.DetectContentType(buffer) +} diff --git a/internal/util/file_test.go b/internal/util/file_test.go new file mode 100644 index 00000000..afa43699 --- /dev/null +++ b/internal/util/file_test.go @@ -0,0 +1,26 @@ +package util_test + +import ( + "os" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/systemli/ticker/internal/util" +) + +func TestDetectContentTypeImage(t *testing.T) { + file, err := os.Open("../../testdata/gopher.jpg") + if err != nil { + t.Fail() + } + + assert.Equal(t, "image/jpeg", util.DetectContentType(file)) +} + +func TestDetectContentTypeOther(t *testing.T) { + r := strings.NewReader("content") + + assert.Equal(t, "application/octet-stream", util.DetectContentType(r)) +} diff --git a/internal/util/image.go b/internal/util/image.go new file mode 100644 index 00000000..0f6a3529 --- /dev/null +++ b/internal/util/image.go @@ -0,0 +1,33 @@ +package util + +import ( + "image" + "image/png" + "io" + + "github.com/disintegration/imaging" +) + +func ResizeImage(file io.Reader, maxDimension int) (image.Image, error) { + img, err := imaging.Decode(file) + if err != nil { + return img, err + } + if img.Bounds().Dx() > maxDimension { + img = imaging.Resize(img, maxDimension, 0, imaging.Linear) + } + if img.Bounds().Dy() > maxDimension { + img = imaging.Resize(img, 0, maxDimension, imaging.Linear) + } + + return img, nil +} + +func SaveImage(img image.Image, path string) error { + opts := []imaging.EncodeOption{ + imaging.JPEGQuality(60), + imaging.PNGCompressionLevel(png.BestCompression), + } + + return imaging.Save(img, path, opts...) +} diff --git a/internal/util/image_test.go b/internal/util/image_test.go new file mode 100644 index 00000000..867f6193 --- /dev/null +++ b/internal/util/image_test.go @@ -0,0 +1,50 @@ +package util_test + +import ( + "bytes" + "fmt" + "os" + "testing" + "time" + + "github.com/disintegration/imaging" + "github.com/stretchr/testify/assert" + + "github.com/systemli/ticker/internal/util" +) + +func TestResizeImage(t *testing.T) { + file, err := os.Open("../../testdata/gopher.jpg") + if err != nil { + t.Fail() + return + } + + img, err := util.ResizeImage(file, 100) + if err != nil { + t.Fail() + return + } + + assert.Equal(t, 63, img.Bounds().Dy()) + assert.Equal(t, 100, img.Bounds().Dx()) + + r := bytes.NewReader([]byte{}) + img, err = util.ResizeImage(r, 100) + if err == nil { + t.Fail() + } +} + +func TestSaveImage(t *testing.T) { + img, err := imaging.Open("../../testdata/gopher.jpg") + if err != nil { + t.Fail() + return + } + + err = util.SaveImage(img, fmt.Sprintf("%s/%d.jpg", os.TempDir(), time.Now().Nanosecond())) + if err != nil { + t.Fail() + } +} diff --git a/internal/util/slice.go b/internal/util/slice.go index c00954d2..49ce0976 100644 --- a/internal/util/slice.go +++ b/internal/util/slice.go @@ -29,3 +29,13 @@ func Remove(slice []int, s int) []int { return slice } + +//ContainsString returns true when s in slice. +func ContainsString(list []string, s string) bool { + for _, b := range list { + if b == s { + return true + } + } + return false +} diff --git a/internal/util/slice_test.go b/internal/util/slice_test.go index ff3101b2..4c393feb 100644 --- a/internal/util/slice_test.go +++ b/internal/util/slice_test.go @@ -16,16 +16,23 @@ func TestContains(t *testing.T) { } func TestAppend(t *testing.T) { - slice := []int{1,2} + slice := []int{1, 2} assert.Equal(t, slice, Append(slice, 2)) - assert.Equal(t, []int{1,2,3}, Append(slice, 3)) + assert.Equal(t, []int{1, 2, 3}, Append(slice, 3)) } func TestRemove(t *testing.T) { - slice := []int{1,2} + slice := []int{1, 2} assert.Equal(t, slice, Remove(slice, 3)) assert.Equal(t, []int{1}, Remove(slice, 2)) assert.Equal(t, []int{}, Remove([]int{}, 2)) } + +func TestContainsString(t *testing.T) { + slice := []string{"a", "b"} + + assert.True(t, ContainsString(slice, "a")) + assert.False(t, ContainsString(slice, "c")) +} diff --git a/main.go b/main.go index b5071381..14ffa9b0 100644 --- a/main.go +++ b/main.go @@ -15,7 +15,6 @@ import ( log "github.com/sirupsen/logrus" . "github.com/systemli/ticker/internal/api" - "github.com/systemli/ticker/internal/bridge" . "github.com/systemli/ticker/internal/model" . "github.com/systemli/ticker/internal/storage" ) @@ -61,10 +60,6 @@ func init() { Config = LoadConfig(*cp) DB = OpenDB(Config.Database) - if Config.TwitterEnabled() { - bridge.Twitter = bridge.NewTwitterBridge(Config.TwitterConsumerKey, Config.TwitterConsumerSecret) - } - firstRun() log.Println("Starting Ticker API") diff --git a/testdata/gopher-dance.gif b/testdata/gopher-dance.gif new file mode 100644 index 00000000..dec69d38 Binary files /dev/null and b/testdata/gopher-dance.gif differ diff --git a/testdata/gopher.jpg b/testdata/gopher.jpg new file mode 100644 index 00000000..68795a99 Binary files /dev/null and b/testdata/gopher.jpg differ