From b87e8604444d45bb852b66182347676f04e69936 Mon Sep 17 00:00:00 2001 From: Michael Mayer Date: Sun, 4 Oct 2020 22:22:53 +0200 Subject: [PATCH] Add feedback form Signed-off-by: Michael Mayer --- frontend/src/component/footer.vue | 4 +- frontend/src/component/navigation.vue | 10 +- frontend/src/css/help.css | 9 +- frontend/src/dialog/photo/info.vue | 2 +- frontend/src/options/options.js | 9 ++ frontend/src/pages/about/about.vue | 142 ++++++++++++++------------ frontend/src/pages/about/feedback.vue | 121 ++++++++++++++++++++++ frontend/src/routes.js | 7 ++ internal/acl/resources.go | 1 + internal/api/feedback.go | 47 +++++++++ internal/config/client.go | 11 +- internal/config/config.go | 21 ++-- internal/form/feedback.go | 15 +++ internal/pro/config.go | 5 - internal/pro/feedback.go | 89 ++++++++++++++++ internal/server/routes.go | 1 + 16 files changed, 400 insertions(+), 94 deletions(-) create mode 100644 frontend/src/pages/about/feedback.vue create mode 100644 internal/api/feedback.go create mode 100644 internal/form/feedback.go create mode 100644 internal/pro/feedback.go diff --git a/frontend/src/component/footer.vue b/frontend/src/component/footer.vue index 32e327adc..bc9f8f495 100644 --- a/frontend/src/component/footer.vue +++ b/frontend/src/component/footer.vue @@ -11,11 +11,11 @@ - Thank you to everyone who made this possible!
+ class="primary--text text--darken-4" target="_blank"> 3rd-party software packages
diff --git a/frontend/src/component/navigation.vue b/frontend/src/component/navigation.vue index 14cb463ee..1f1090a6d 100644 --- a/frontend/src/component/navigation.vue +++ b/frontend/src/component/navigation.vue @@ -363,7 +363,15 @@ - Help + About + + + + + + + + Feedback diff --git a/frontend/src/css/help.css b/frontend/src/css/help.css index a2dbbb401..a88cdd3fb 100644 --- a/frontend/src/css/help.css +++ b/frontend/src/css/help.css @@ -10,10 +10,13 @@ #photoprism p a.highlight, #photoprism #photoprism-help p a{ - color: #222; text-decoration: none; - text-shadow: -3px 0px 3px rgba(84, 226, 255, 0.15), 3px 0px 3px rgba(84, 226, 255, 0.15), 6px 0px 6px rgba(84, 226, 255, 0.15), -6px 0px 6px rgba(84, 226, 255, 0.15); - /* text-shadow: -3px 0px 3px rgba(255, 255, 0, 0.5), 3px 0px 3px rgba(255, 255, 0, 0.5), 6px 0px 6px rgba(255, 255, 0, 0.5), -6px 0px 6px rgba(255, 255, 0, 0.5); */ + color: #4a148c; +} + +#photoprism p a.highlight:hover, +#photoprism #photoprism-help p a:hover{ + text-decoration: underline !important; } #photoprism .v-alert a { diff --git a/frontend/src/dialog/photo/info.vue b/frontend/src/dialog/photo/info.vue index 918f2c3a8..2993ba0ad 100644 --- a/frontend/src/dialog/photo/info.vue +++ b/frontend/src/dialog/photo/info.vue @@ -257,7 +257,7 @@ const months = Info.months("long"); for (let i = 0; i < months.length; i++) { - result.push({"Month": i + 1, "Name": months[i]}); + result.push({"Month": i + 1, "UserName": months[i]}); } return result; diff --git a/frontend/src/options/options.js b/frontend/src/options/options.js index 81ddcac0d..edc6161ac 100644 --- a/frontend/src/options/options.js +++ b/frontend/src/options/options.js @@ -236,3 +236,12 @@ export const Colors = () => [ {"Example": "#9E9E9E", "Name": $gettext("Grey"), "Slug": "grey"}, {"Example": "#212121", "Name": $gettext("Black"), "Slug": "black"}, ]; + +export const FeedbackCategories = () => [ + {"value": "help", "text": $gettext("Customer Support")}, + {"value": "feedback", "text": $gettext("Product Feedback")}, + {"value": "feature", "text": $gettext("Feature Request")}, + {"value": "bug", "text": $gettext("Bug Report")}, + {"value": "donations", "text": $gettext("Donations")}, + {"value": "other", "text": $gettext("Other")}, +]; \ No newline at end of file diff --git a/frontend/src/pages/about/about.vue b/frontend/src/pages/about/about.vue index fdf44dfad..c2c901590 100644 --- a/frontend/src/pages/about/about.vue +++ b/frontend/src/pages/about/about.vue @@ -2,7 +2,7 @@
- Whatever it is, we'd love to hear from you! + Contributors @@ -12,12 +12,71 @@ -

Feel free to use the - chat - or send an e-mail to hello@photoprism.org - if you have questions, want to support our work, or just want to say hello.

+

Andrea Ceroni

+

Andrea is a data scientist specialized in temporal information retrieval and machine + learning. + He holds a PhD in Computer Science from the Leibniz University of Hannover (L3S Research Center) and wrote + numerous papers on topics such as + Personal + Photo Management and Preservation + and Photo Selection Models for + Personal Photo Collections.

+

You can find him on Google Scholar and LinkedIn

-

+

Theresa Gresch

+

Theresa works as a freelance product manager and developer in Berlin. She has a Master's in + Neurobiology + and aims to dive deeper into machine learning while working on this project.

+

You can find her on GitHub and LinkedIn

+ +

Michael Mayer

+

Michael learned coding on an Atari 1040 ST and started his first open-source projects in + the 90s. He has more than 25 years of experience in building Web applications. + His motivation is to explore the latest technologies and build an amazing product outside the constraints of a + corporate environment.

+

You can find him on GitHub, + LinkedIn + and Twitter +

+ +

Guy Sheffer

+

Known as GuySoft on the web. Active + developer in + the Free Software and Maker community. + Creator of OctoPi + and + FullPageOS, + which have hundreds of thousands of downloads. Raspberry Pi distro expert. + Currently VP R&D and Co-Founder at ShapeDo.

+ +

+ ...and many more +

+ +

+ Trademarks +

+

+ PhotoPrism® is a registered trademark of Michael Mayer. + You may use it as required to describe our software, run your own server, for educational purposes, but not for offering commercial goods, products, or services without prior written permission. In other words, please ask. +

+ +

License AGPL v3 @@ -25,62 +84,9 @@ alt="Documentation" style="max-width:100%;"> Community Chat + src="/static/img/badge-chat.svg" alt="Community Chat" style="max-width:100%;"> Twitter -

- -

Who we are

- -

Andrea Ceroni

-

Andrea is a data scientist specialized in temporal information retrieval and machine - learning. - He holds a PhD in Computer Science from the Leibniz University of Hannover (L3S Research Center) and wrote - numerous papers on topics such as - Personal - Photo Management and Preservation - and Photo Selection Models for - Personal Photo Collections.

-

You can find him on Google Scholar and LinkedIn

- -

Theresa Gresch

-

Theresa works as a freelance product manager and developer in Berlin. She has a Master's in - Neurobiology - and aims to dive deeper into machine learning while working on this project.

-

You can find her on GitHub and LinkedIn

- -

Michael Mayer

-

Michael learned coding on an Atari 1040 ST and started his first open-source projects in - the 90s. He has more than 20 years of experience in building Web applications. - His motivation is to explore the latest technologies and build an amazing product outside the constraints of a - corporate environment.

-

You can find him on GitHub, - LinkedIn - and Twitter -

- -

Guy Sheffer

-

Known as GuySoft on the web. Active - developer in - the Free Software and Maker community. - Creator of OctoPi and - FullPageOS, - which have hundreds of thousands of downloads. Raspberry Pi distro expert. - Currently VP R&D and Co-Founder at ShapeDo.

- -

...and many more

-

Thank you to - everyone who - contributed to this project!

- - -

Trademarks

-

- PhotoPrism® is a registered trademark of Michael Mayer. - You may use it as required to describe our software, run your own server, for educational purposes, but not for offering commercial goods, products, or services without prior written permission. In other words, please ask. + src="/static/img/badge-twitter.svg" alt="Twitter" style="max-width:100%;">

@@ -89,11 +95,11 @@ diff --git a/frontend/src/pages/about/feedback.vue b/frontend/src/pages/about/feedback.vue new file mode 100644 index 000000000..3e6069260 --- /dev/null +++ b/frontend/src/pages/about/feedback.vue @@ -0,0 +1,121 @@ + + + diff --git a/frontend/src/routes.js b/frontend/src/routes.js index bffaa1955..f24207fbb 100644 --- a/frontend/src/routes.js +++ b/frontend/src/routes.js @@ -41,6 +41,7 @@ import Settings from "pages/settings.vue"; import Login from "pages/login.vue"; import Discover from "pages/discover.vue"; import About from "pages/about/about.vue"; +import Feedback from "pages/about/feedback.vue"; import License from "pages/about/license.vue"; import Help from "pages/help.vue"; import {$gettext} from "common/vm"; @@ -59,6 +60,12 @@ export default [ component: About, meta: {title: c.name, auth: false}, }, + { + name: "feedback", + path: "/feedback", + component: Feedback, + meta: {title: c.name, auth: false}, + }, { name: "license", path: "/about/license", diff --git a/internal/acl/resources.go b/internal/acl/resources.go index ca4538b95..29b93e424 100644 --- a/internal/acl/resources.go +++ b/internal/acl/resources.go @@ -22,4 +22,5 @@ const ( ResourcePeople Resource = "people" ResourcePhotos Resource = "photos" ResourcePlaces Resource = "places" + ResourceFeedback Resource = "feedback" ) diff --git a/internal/api/feedback.go b/internal/api/feedback.go new file mode 100644 index 000000000..13459fb63 --- /dev/null +++ b/internal/api/feedback.go @@ -0,0 +1,47 @@ +package api + +import ( + "github.com/photoprism/photoprism/internal/service" + "net/http" + + "github.com/photoprism/photoprism/internal/acl" + "github.com/photoprism/photoprism/internal/form" + "github.com/photoprism/photoprism/internal/i18n" + + "github.com/gin-gonic/gin" +) + +// POST /api/v1/feedback +func SendFeedback(router *gin.RouterGroup) { + router.POST("/feedback", func(c *gin.Context) { + s := Auth(SessionID(c), acl.ResourceFeedback, acl.ActionCreate) + + if s.Invalid() { + AbortUnauthorized(c) + return + } + + conf := service.Config() + conf.UpdatePro() + + var f form.Feedback + + if err := c.BindJSON(&f); err != nil { + AbortBadRequest(c) + return + } + + if f.Empty() { + Abort(c, http.StatusBadRequest, i18n.ErrNoItemsSelected) + return + } + + if err := conf.Pro().SendFeedback(f); err != nil { + log.Error(err) + AbortSaveFailed(c) + return + } + + c.JSON(http.StatusOK, gin.H{"code": http.StatusOK}) + }) +} diff --git a/internal/config/client.go b/internal/config/client.go index b7dc7771f..a717ff292 100644 --- a/internal/config/client.go +++ b/internal/config/client.go @@ -26,9 +26,6 @@ type ClientConfig struct { ReadOnly bool `json:"readonly"` UploadNSFW bool `json:"uploadNSFW"` Public bool `json:"public"` - Pro bool `json:"pro"` - Sponsor bool `json:"sponsor"` - Contributor bool `json:"contributor"` Experimental bool `json:"experimental"` DisableSettings bool `json:"disableSettings"` AlbumCategories []string `json:"albumCategories"` @@ -37,7 +34,7 @@ type ClientConfig struct { Lenses []entity.Lens `json:"lenses"` Countries []entity.Country `json:"countries"` Thumbs []Thumb `json:"thumbs"` - ApiKey string `json:"apiKey"` + Status string `json:"status"` MapKey string `json:"mapKey"` DownloadToken string `json:"downloadToken"` PreviewToken string `json:"previewToken"` @@ -144,7 +141,7 @@ func (c *Config) PublicConfig() ClientConfig { DisableSettings: c.SettingsHidden(), Public: c.Public(), Experimental: c.Experimental(), - ApiKey: "", + Status: "", MapKey: "", Thumbs: Thumbs, Colors: colors.All.List(), @@ -187,7 +184,7 @@ func (c *Config) GuestConfig() ClientConfig { Experimental: false, Colors: colors.All.List(), Thumbs: Thumbs, - ApiKey: c.Pro().ApiKey(), + Status: c.Pro().Status, MapKey: c.Pro().MapKey(), DownloadToken: c.DownloadToken(), PreviewToken: c.PreviewToken(), @@ -221,7 +218,7 @@ func (c *Config) UserConfig() ClientConfig { Experimental: c.Experimental(), Colors: colors.All.List(), Thumbs: Thumbs, - ApiKey: c.Pro().ApiKey(), + Status: c.Pro().Status, MapKey: c.Pro().MapKey(), DownloadToken: c.DownloadToken(), PreviewToken: c.PreviewToken(), diff --git a/internal/config/config.go b/internal/config/config.go index 0b0d04d0f..2e6651e90 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -269,6 +269,19 @@ func (c *Config) OriginalsLimit() int64 { return c.params.OriginalsLimit * 1024 * 1024 } +// UpdatePro updates photoprism.pro api credentials. +func (c *Config) UpdatePro() { + p := c.ProConfigFile() + + if err := c.pro.Refresh(); err != nil { + log.Errorf("pro: %s", err) + } else if err := c.pro.Save(p); err != nil { + log.Errorf("pro: %s", err) + } else { + c.pro.Propagate() + } +} + // initPro initializes photoprism.pro api credentials. func (c *Config) initPro() { c.pro = pro.NewConfig(c.Version()) @@ -290,13 +303,7 @@ func (c *Config) initPro() { for { select { case <-ticker.C: - if err := c.pro.Refresh(); err != nil { - log.Errorf("pro: %s", err) - } else if err := c.pro.Save(p); err != nil { - log.Errorf("pro: %s", err) - } else { - c.pro.Propagate() - } + c.UpdatePro() } } }() diff --git a/internal/form/feedback.go b/internal/form/feedback.go new file mode 100644 index 000000000..c2fc49358 --- /dev/null +++ b/internal/form/feedback.go @@ -0,0 +1,15 @@ +package form + +// Feedback represents support requests / customer feedback. +type Feedback struct { + Category string `json:"Category"` + Message string `json:"Message"` + UserName string `json:"UserName"` + UserEmail string `json:"UserEmail"` + UserAgent string `json:"UserAgent"` + UserLocales string `json:"UserLocales"` +} + +func (f Feedback) Empty() bool { + return len(f.Category) < 1 || len(f.Message) < 3 || len(f.UserEmail) < 5 +} diff --git a/internal/pro/config.go b/internal/pro/config.go index 7ea360e04..8aaf8d54b 100644 --- a/internal/pro/config.go +++ b/internal/pro/config.go @@ -41,11 +41,6 @@ func NewConfig(version string) *Config { } } -// ApiKey returns the photoprism.pro api key. -func (c *Config) ApiKey() string { - return c.Key -} - // MapKey returns the maps api key. func (c *Config) MapKey() string { if sess, err := c.DecodeSession(); err != nil { diff --git a/internal/pro/feedback.go b/internal/pro/feedback.go new file mode 100644 index 000000000..629b329e1 --- /dev/null +++ b/internal/pro/feedback.go @@ -0,0 +1,89 @@ +package pro + +import ( + "bytes" + "encoding/json" + "fmt" + "github.com/photoprism/photoprism/internal/form" + "net/http" + "runtime" + "time" +) + +var FeedbackURL = ApiURL + "/%s/feedback" + +type Feedback struct { + Key string `json:"ApiKey"` + ClientVersion string `json:"ClientVersion"` + ClientOS string `json:"ClientOS"` + ClientArch string `json:"ClientArch"` + ClientCPU int `json:"ClientCPU"` + Category string `json:"Category"` + Message string `json:"Message"` + UserName string `json:"UserName"` + UserEmail string `json:"UserEmail"` + UserAgent string `json:"UserAgent"` +} + +// NewFeedback creates a new photoprism.pro key request instance. +func NewFeedback(version string) *Feedback { + return &Feedback{ + ClientVersion: version, + ClientOS: runtime.GOOS, + ClientArch: runtime.GOARCH, + ClientCPU: runtime.NumCPU(), + } +} + +func (c *Config) SendFeedback(f form.Feedback) (err error) { + feedback := NewFeedback(c.Version) + feedback.Category = f.Category + feedback.Message = f.Message + feedback.UserName = f.UserName + feedback.UserEmail = f.UserEmail + feedback.UserAgent = f.UserAgent + feedback.Key = c.Key + + client := &http.Client{Timeout: 60 * time.Second} + url := fmt.Sprintf(FeedbackURL, c.Key) + method := http.MethodPost + var req *http.Request + + log.Debugf("pro: sending feedback") + + if j, err := json.Marshal(feedback); err != nil { + return err + } else if req, err = http.NewRequest(method, url, bytes.NewReader(j)); err != nil { + return err + } + + req.Header.Add("Accept-Language", f.UserLocales) + req.Header.Add("Content-Type", "application/json") + + var r *http.Response + + for i := 0; i < 3; i++ { + r, err = client.Do(req) + + if err == nil { + break + } + } + + if err != nil { + log.Errorf("pro: %s", err.Error()) + return err + } else if r.StatusCode >= 400 { + err = fmt.Errorf("sending feedback failed with code %d", r.StatusCode) + return err + } + + err = json.NewDecoder(r.Body).Decode(c) + + if err != nil { + log.Errorf("pro: %s", err.Error()) + return err + } + + return nil +} diff --git a/internal/server/routes.go b/internal/server/routes.go index bb733acb9..23145e0df 100644 --- a/internal/server/routes.go +++ b/internal/server/routes.go @@ -111,6 +111,7 @@ func registerRoutes(router *gin.Engine, conf *config.Config) { api.SaveSettings(v1) api.ChangePassword(v1) api.GetErrors(v1) + api.SendFeedback(v1) api.GetSvg(v1)