Browse Source

ADD EVERYTHING TO TEMPLATES FILE OH YEAH

tags/v1.4.0
Morgan Bazalgette 2 years ago
parent
commit
4dbeca116a
Signed by: Morgan Bazalgette <the@howl.moe> GPG Key ID: 40D328300D245DA5
18 changed files with 125 additions and 174 deletions
  1. 7
    7
      2fa.go
  2. 6
    6
      avatar.go
  3. 76
    6
      data/locales/templates.pot
  4. 1
    1
      errors.go
  5. 8
    8
      helpers.go
  6. 2
    5
      irc.go
  7. 0
    10
      login.go
  8. 0
    3
      main.go
  9. 7
    7
      profbackground.go
  10. 3
    3
      profile.go
  11. 2
    2
      register.go
  12. 5
    6
      sessions.go
  13. 2
    2
      simple.go
  14. 2
    4
      templates.go
  15. 1
    1
      templates/about.html
  16. 1
    1
      templates/changelog.html
  17. 2
    1
      templates/friends.html
  18. 0
    101
      verify.go

+ 7
- 7
2fa.go View File

@@ -126,7 +126,7 @@ func tfaGateway(c *gin.Context) {
}

resp(c, 200, "2fa_gateway.html", &baseTemplateData{
TitleBar: T(c, "Two Factor Authentication"),
TitleBar: "Two Factor Authentication",
KyutGrill: "2fa.jpg",
RequestInfo: map[string]interface{}{
"redir": redir,
@@ -312,7 +312,7 @@ func disable2fa(c *gin.Context) {

db.Exec("DELETE FROM 2fa_telegram WHERE userid = ?", ctx.User.ID)
db.Exec("DELETE FROM 2fa_totp WHERE userid = ?", ctx.User.ID)
m = successMessage{"2FA disabled successfully."}
m = successMessage{T(c, "2FA disabled successfully.")}
}

func totpSetup(c *gin.Context) {
@@ -332,10 +332,10 @@ func totpSetup(c *gin.Context) {

switch is2faEnabled(ctx.User.ID) {
case 1:
addMessage(c, errorMessage{"You currently have Telegram 2FA enabled. You first need to disable that if you want to use TOTP-based 2FA."})
addMessage(c, errorMessage{T(c, "You currently have Telegram 2FA enabled. You first need to disable that if you want to use TOTP-based 2FA.")})
return
case 2:
addMessage(c, errorMessage{"TOTP-based 2FA is already enabled!"})
addMessage(c, errorMessage{T(c, "TOTP-based 2FA is already enabled!")})
return
}

@@ -344,20 +344,20 @@ func totpSetup(c *gin.Context) {
var secret string
db.Get(&secret, "SELECT secret FROM 2fa_totp WHERE userid = ?", ctx.User.ID)
if secret == "" || pc == "" {
addMessage(c, errorMessage{"No passcode/secret was given. Please try again"})
addMessage(c, errorMessage{T(c, "No passcode/secret was given. Please try again")})
return
}

fmt.Println(pc, secret)
if !totp.Validate(pc, secret) {
addMessage(c, errorMessage{"Passcode is invalid. Perhaps it expired?"})
addMessage(c, errorMessage{T(c, "Passcode is invalid. Perhaps it expired?")})
return
}

codes, _ := json.Marshal(generateRecoveryCodes())
db.Exec("UPDATE 2fa_totp SET recovery = ?, enabled = 1 WHERE userid = ?", string(codes), ctx.User.ID)

addMessage(c, successMessage{"TOTP-based 2FA has been enabled on your account."})
addMessage(c, successMessage{T(c, "TOTP-based 2FA has been enabled on your account.")})
}

func generateRecoveryCodes() []string {

+ 6
- 6
avatar.go View File

@@ -23,32 +23,32 @@ func avatarSubmit(c *gin.Context) {
simpleReply(c, m)
}()
if config.AvatarsFolder == "" {
m = errorMessage{"Changing avatar is currently not possible."}
m = errorMessage{T(c, "Changing avatar is currently not possible.")}
return
}
file, _, err := c.Request.FormFile("avatar")
if err != nil {
m = errorMessage{"An error occurred."}
m = errorMessage{T(c, "An error occurred.")}
return
}
img, _, err := image.Decode(file)
if err != nil {
m = errorMessage{"An error occurred."}
m = errorMessage{T(c, "An error occurred.")}
return
}
img = resize.Thumbnail(256, 256, img, resize.Bilinear)
f, err := os.Create(fmt.Sprintf("%s/%d.png", config.AvatarsFolder, ctx.User.ID))
defer f.Close()
if err != nil {
m = errorMessage{"An error occurred."}
m = errorMessage{T(c, "An error occurred.")}
c.Error(err)
return
}
err = png.Encode(f, img)
if err != nil {
m = errorMessage{"We were not able to save your avatar."}
m = errorMessage{T(c, "We were not able to save your avatar.")}
c.Error(err)
return
}
m = successMessage{"Your avatar was successfully changed. It may take some time to properly update. To force a cache refresh, you can use CTRL+F5."}
m = successMessage{T(c, "Your avatar was successfully changed. It may take some time to properly update. To force a cache refresh, you can use CTRL+F5.")}
}

+ 76
- 6
data/locales/templates.pot View File

@@ -624,6 +624,9 @@ msgid "Playcount"
msgstr ""

# profile.html
msgid "%s's profile"
msgstr ""

# for <reason>, expires <placeholder for time>.
msgid "User is <b>silenced</b> for %s, expires %s."
msgstr ""
@@ -644,6 +647,9 @@ msgstr ""
msgid "locked"
msgstr ""

msgid "User not found"
msgstr ""

msgid "(aka <b>%s</b>)"
msgstr ""

@@ -835,18 +841,12 @@ msgid "Please log in to get supporter"
msgstr ""

# Settings
msgid "Profile"
msgstr ""

msgid "Userpage"
msgstr ""

msgid "Avatar"
msgstr ""

msgid "Two Factor Authentication"
msgstr ""

msgid "Discord donor"
msgstr ""

@@ -972,3 +972,73 @@ msgstr ""

msgid "Playstyle"
msgstr ""

# Go source
msgid "TOTP-based 2FA has been enabled on your account."
msgstr ""

msgid "Passcode is invalid. Perhaps it expired?"
msgstr ""

msgid "No passcode/secret was given. Please try again"
msgstr ""

msgid "TOTP-based 2FA is already enabled!"
msgstr ""

msgid "You currently have Telegram 2FA enabled. You first need to disable that if you want to use TOTP-based 2FA."
msgstr ""

msgid "2FA disabled successfully."
msgstr ""

msgid "Changing avatar is currently not possible."
msgstr ""

msgid "An error occurred."
msgstr ""

msgid "We were not able to save your avatar."
msgstr ""

msgid "Your avatar was successfully changed. It may take some time to properly update. To force a cache refresh, you can use CTRL+F5."
msgstr ""

msgid "Internal Server Error"
msgstr ""

msgid "You're not a donor!"
msgstr ""

msgid "You've not joined the discord server! Links to it are below on the page. Please join the server before attempting to connect your account to Discord."
msgstr ""

msgid "Your account has been linked successfully!"
msgstr ""

msgid "Your new IRC token is <code>%s</code>. The old IRC token is not valid anymore.<br>Keep it safe, don't show it around, and store it now! We won't show it to you again."
msgstr ""

msgid "Your profile background has been saved."
msgstr ""

msgid "We were not able to save your profile background."
msgstr ""

msgid "Colour is invalid"
msgstr ""

msgid "You have been automatically logged out of your account because your account has either been banned or locked. Should you believe this is a mistake, you can contact our support team at support@ripple.moe."
msgstr ""

msgid "You have been automatically logged out for security reasons. Please <a href='/login?redir=%s'>log back in</a>."
msgstr ""

msgid "You need to login first."
msgstr ""

msgid "Forbidden"
msgstr ""

msgid "You should not be 'round here."
msgstr ""

+ 1
- 1
errors.go View File

@@ -4,7 +4,7 @@ import "github.com/gin-gonic/gin"

func notFound(c *gin.Context) {
resp(c, 404, "not_found.html", &baseTemplateData{
TitleBar: T(c, "Not Found"),
TitleBar: "Not Found",
KyutGrill: "not_found.jpg",
})
}

+ 8
- 8
helpers.go View File

@@ -89,19 +89,19 @@ func discordFinish(c *gin.Context) {

ctx := getContext(c)
if ok, _ := CSRF.Validate(ctx.User.ID, c.Query("state")); !ok {
addMessage(c, errorMessage{"CSRF token is invalid. Please retry linking your account."})
addMessage(c, errorMessage{T(c, "Your session has expired. Please try redoing what you were trying to do.")})
return
}

if ctx.User.Privileges&common.UserPrivilegeDonor == 0 {
addMessage(c, errorMessage{"You're not a donor!"})
addMessage(c, errorMessage{T(c, "You're not a donor!")})
return
}

tok, err := getDiscord().Exchange(nil, c.Query("code"))
if err != nil {
c.Error(err)
addMessage(c, errorMessage{"An error occurred."})
addMessage(c, errorMessage{T(c, "An error occurred.")})
return
}

@@ -117,7 +117,7 @@ func discordFinish(c *gin.Context) {
err = json.Unmarshal(rawData, &x)
if err != nil {
c.Error(err)
addMessage(c, errorMessage{"An error occurred."})
addMessage(c, errorMessage{T(c, "An error occurred.")})
return
}

@@ -130,7 +130,7 @@ func discordFinish(c *gin.Context) {
resp, err = http.Post(config.DonorBotURL+"/api/v1/give_donor", "application/x-www-form-urlencoded", bytes.NewReader([]byte(vals.Encode())))
if err != nil {
c.Error(err)
addMessage(c, errorMessage{"An error occurred."})
addMessage(c, errorMessage{T(c, "An error occurred.")})
return
}

@@ -143,16 +143,16 @@ func discordFinish(c *gin.Context) {
case 200:
// move on
case 404:
addMessage(c, errorMessage{"You've not joined the discord server! Links to it are below on the page. Please join the server before attempting to connect your account to Discord."})
addMessage(c, errorMessage{T(c, "You've not joined the discord server! Links to it are below on the page. Please join the server before attempting to connect your account to Discord.")})
default:
c.Error(fmt.Errorf("donorbot: %d", resp.StatusCode))
addMessage(c, errorMessage{"An error occurred."})
addMessage(c, errorMessage{T(c, "An error occurred.")})
return
}

db.Exec("INSERT INTO discord_roles (id, userid, discordid, roleid) VALUES (NULL, ?, ?, 0)", ctx.User.ID, x.ID)

addMessage(c, successMessage{"Your account has been linked successfully!"})
addMessage(c, successMessage{T(c, "Your account has been linked successfully!")})
}

func mustCSRFGenerate(u int) string {

+ 2
- 5
irc.go View File

@@ -2,10 +2,9 @@ package main

import (
"database/sql"
"fmt"

"zxq.co/ripple/rippleapi/common"
"github.com/gin-gonic/gin"
"zxq.co/ripple/rippleapi/common"
)

func ircGenToken(c *gin.Context) {
@@ -29,8 +28,6 @@ func ircGenToken(c *gin.Context) {

db.Exec("INSERT INTO irc_tokens(userid, token) VALUES (?, ?)", ctx.User.ID, m)
simple(c, getSimple("/irc"), []message{successMessage{
fmt.Sprintf("Your new IRC token is <code>%s</code>. The old IRC token is not valid anymore."+
"<br>Keep it safe, don't show it around, and store it now! "+
"We won't show it to you again.", s),
T(c, "Your new IRC token is <code>%s</code>. The old IRC token is not valid anymore.<br>Keep it safe, don't show it around, and store it now! We won't show it to you again.", s),
}}, nil)
}

+ 0
- 10
login.go View File

@@ -155,16 +155,6 @@ func afterLogin(c *gin.Context, id int, country string, flags uint) {
setCountry(c, id)
}
logIP(c, id)
if flags&common.FlagEmailVerified == 0 {
addMessage(c, warningMessage{
"Hey! Hate to bother you, but we noticed you have not verified " +
"your email address. Now, if you don't mind, could you please " +
"<a href='/email_verify/start?csrf=" + mustCSRFGenerate(id) +
"' target='_blank'>verify it?</a> If you verify your account, you will have a " +
"chance to get your account back even if evil hackers manage to " +
"get your password.",
})
}
}

func safeUsername(u string) string {

+ 0
- 3
main.go View File

@@ -291,9 +291,6 @@ func generateEngine() *gin.Engine {
r.GET("/settings/discord/finish", discordFinish)
r.POST("/settings/profbackground/:type", profBackground)

r.GET("/email_verify/start", startEmailVerification)
r.GET("/email_verify/finish", finishEmailVerification)

r.GET("/donate/rates", btcconversions.GetRates)

r.Any("/blog/*url", blogRedirect)

+ 7
- 7
profbackground.go View File

@@ -21,14 +21,14 @@ func profBackground(c *gin.Context) {
resp403(c)
return
}
var m message = successMessage{"Your profile background has been saved."}
var m message = successMessage{T(c, "Your profile background has been saved.")}
defer func() {
addMessage(c, m)
getSession(c).Save()
c.Redirect(302, "/settings/profbackground")
}()
if ok, _ := CSRF.Validate(ctx.User.ID, c.PostForm("csrf")); !ok {
m = errorMessage{"CSRF token has expired. Please try again."}
m = errorMessage{T(c, "Your session has expired. Please try redoing what you were trying to do.")}
return
}
t := c.Param("type")
@@ -39,25 +39,25 @@ func profBackground(c *gin.Context) {
// image
file, _, err := c.Request.FormFile("value")
if err != nil {
m = errorMessage{"An error occurred."}
m = errorMessage{T(c, "An error occurred.")}
return
}
img, _, err := image.Decode(file)
if err != nil {
m = errorMessage{"An error occurred."}
m = errorMessage{T(c, "An error occurred.")}
return
}
img = resize.Thumbnail(2496, 1404, img, resize.Bilinear)
f, err := os.Create(fmt.Sprintf("static/profbackgrounds/%d.jpg", ctx.User.ID))
defer f.Close()
if err != nil {
m = errorMessage{"An error occurred."}
m = errorMessage{T(c, "An error occurred.")}
c.Error(err)
return
}
err = jpeg.Encode(f, img, nil)
if err != nil {
m = errorMessage{"We were not able to save your profile background."}
m = errorMessage{T(c, "We were not able to save your profile background.")}
c.Error(err)
return
}
@@ -67,7 +67,7 @@ func profBackground(c *gin.Context) {
col := strings.ToLower(c.PostForm("value"))
// verify it's valid
if !hexColourRegex.MatchString(col) {
m = errorMessage{"Colour is invalid"}
m = errorMessage{T(c, "Colour is invalid")}
return
}
saveProfileBackground(ctx, 2, col)

+ 3
- 3
profile.go View File

@@ -4,8 +4,8 @@ import (
"database/sql"
"strconv"

"zxq.co/ripple/rippleapi/common"
"github.com/gin-gonic/gin"
"zxq.co/ripple/rippleapi/common"
)

// TODO: replace with simple ResponseInfo containing userid
@@ -50,7 +50,7 @@ func userProfile(c *gin.Context) {

if data.UserID == 0 {
data.TitleBar = "User not found"
data.Messages = append(data.Messages, warningMessage{"That user could not be found!"})
data.Messages = append(data.Messages, warningMessage{T(c, "That user could not be found.")})
return
}

@@ -69,7 +69,7 @@ func userProfile(c *gin.Context) {
}
}

data.TitleBar = username + "'s profile"
data.TitleBar = T(c, "%s's profile", username)
data.DisableHH = true
data.Scripts = append(data.Scripts, "/static/profile.js")
}

+ 2
- 2
register.go View File

@@ -142,7 +142,7 @@ func registerSubmit(c *gin.Context) {

func registerResp(c *gin.Context, messages ...message) {
resp(c, 200, "register/register.html", &baseTemplateData{
TitleBar: T(c, "Register"),
TitleBar: "Register",
KyutGrill: "register.jpg",
Scripts: []string{"https://www.google.com/recaptcha/api.js"},
Messages: messages,
@@ -178,7 +178,7 @@ func verifyAccount(c *gin.Context) {
}

resp(c, 200, "register/verify.html", &baseTemplateData{
TitleBar: T(c, "Verify account"),
TitleBar: "Verify account",
HeadingOnRight: true,
KyutGrill: "welcome.jpg",
})

+ 5
- 6
sessions.go View File

@@ -2,14 +2,13 @@ package main

import (
"net/http"
"time"

"net/url"
"time"

"zxq.co/ripple/rippleapi/common"
"zxq.co/x/rs"
"github.com/gin-gonic/contrib/sessions"
"github.com/gin-gonic/gin"
"zxq.co/ripple/rippleapi/common"
"zxq.co/x/rs"
)

func sessionInitializer() func(c *gin.Context) {
@@ -75,10 +74,10 @@ func sessionInitializer() func(c *gin.Context) {
c.Set("session", sess)

if addBannedMessage {
addMessage(c, warningMessage{"You have been automatically logged out of your account because your account has either been banned or locked. Should you believe this is a mistake, you can contact our support team at support@ripple.moe."})
addMessage(c, warningMessage{T(c, "You have been automatically logged out of your account because your account has either been banned or locked. Should you believe this is a mistake, you can contact our support team at support@ripple.moe.")})
}
if passwordChanged {
addMessage(c, warningMessage{"You have been automatically logged out for security reasons. Please <a href='/login?redir=" + url.QueryEscape(c.Request.URL.Path) + "'>log back in</a>."})
addMessage(c, warningMessage{T(c, "You have been automatically logged out for security reasons. Please <a href='/login?redir=%s'>log back in</a>.", url.QueryEscape(c.Request.URL.Path))})
}

c.Next()

+ 2
- 2
simple.go View File

@@ -31,12 +31,12 @@ func simplePageFunc(p templateConfig) gin.HandlerFunc {
func resp403(c *gin.Context) {
if getContext(c).User.ID == 0 {
ru := c.Request.URL
addMessage(c, warningMessage{"You need to login first."})
addMessage(c, warningMessage{T(c, "You need to login first.")})
getSession(c).Save()
c.Redirect(302, "/login?redir="+url.QueryEscape(ru.Path+"?"+ru.RawQuery))
return
}
respEmpty(c, "Forbidden", warningMessage{"You should not be 'round here."})
respEmpty(c, "Forbidden", warningMessage{T(c, "You should not be 'round here.")})
}

func simpleReply(c *gin.Context, errs ...message) error {

+ 2
- 4
templates.go View File

@@ -13,12 +13,12 @@ import (
"strings"
"time"

"zxq.co/ripple/rippleapi/common"
"github.com/gin-gonic/contrib/sessions"
"github.com/gin-gonic/gin"
"github.com/pariz/gountries"
"github.com/rjeczalik/notify"
"github.com/thehowl/conf"
"zxq.co/ripple/rippleapi/common"
)

var templates = make(map[string]*template.Template)
@@ -119,9 +119,7 @@ func resp(c *gin.Context, statusCode int, tpl string, data interface{}) {
if err != nil {
c.String(
200,
"oooops! A brit monkey stumbled upon a banana while trying to process your request. "+
"This doesn't make much sense, but in a few words: we fucked up something while processing your "+
"request. We are sorry for this, but don't worry: we have been notified and are on it!",
"An error occurred while trying to render the page, and we have now been notified about it.",
)
c.Error(err)
return

+ 1
- 1
templates/about.html View File

@@ -78,7 +78,7 @@ TitleBar=About
{{ .T "It has now evolved into the largest available osu! private server, with more than 10,000 registered users. <a href=\"/register\">Signing up</a> on Ripple won't result in getting your account on osu! restricted, so what are you waiting for?! Come join us! %s" "😉" | html }}
</p>
<p class="about subtitle">
{{ .T "In case you want to make sure we're not doing shady stuff with your data, you can also check the <a href=\"https://zxq.co/ripple/ripple\">source code</a> Ripple is running on." }}
{{ .T "In case you want to make sure we're not doing shady stuff with your data, you can also check the <a href=\"https://zxq.co/ripple/ripple\">source code</a> Ripple is running on." | html }}
</p>
</div>
</div>

+ 1
- 1
templates/changelog.html View File

@@ -11,7 +11,7 @@ KyutGrill=changelog.jpg
{{ .T "This is the changelog. Changes are published here as soon as they hit the production status (as in, live on the website)." }}
</p>
<p>
{{ .T "For various reasons, some software of Ripple does not contribute to the changelog, to which this website is a part of. In case you want to see the changelog of Hanayo, you can do so by <a href=\"https://zxq.co/ripple/hanayo/commits/master\">clicking here</a>." }}
{{ .T "For various reasons, some software of Ripple does not contribute to the changelog, to which this website is a part of. In case you want to see the changelog of Hanayo, you can do so by <a href=\"https://zxq.co/ripple/hanayo/commits/master\">clicking here</a>." | html }}
</p>
</div>
<div class="ui segment">

+ 2
- 1
templates/friends.html View File

@@ -11,6 +11,7 @@ KyutGrill=friends.jpg
{{ .T "On this page you can see all of your friends, and unfriend them as you see fit." }}
</div>
<div class="ui segment">
{{ $ := . }}
{{ $page := or (atoint (.Gin.Query "p")) 1 }}
{{ $friends := .Get "friends?p=%d&l=40&sort=username,asc" $page }}
{{ with $friends }}
@@ -25,7 +26,7 @@ KyutGrill=friends.jpg
<div class="ui compact circular smalltext icon labeled {{ if .is_mutual }}red{{ else }}green{{ end }} button"
data-userid="{{ .id }}">
<i class="{{ if .is_mutual }}heart{{ else }}minus{{ end }} icon"></i>
<span>{{ if .is_mutual }}{{ .T "Mutual" }}{{ else }}{{ .T "Remove" }}{{ end }}</span>
<span>{{ if .is_mutual }}{{ $.T "Mutual" }}{{ else }}{{ $.T "Remove" }}{{ end }}</span>
</div>
</div>
</div>

+ 0
- 101
verify.go View File

@@ -1,101 +0,0 @@
package main

import (
"fmt"
"time"

"github.com/gin-gonic/gin"
"zxq.co/ripple/rippleapi/common"
"zxq.co/x/rs"
)

func startEmailVerification(c *gin.Context) {
ctx := getContext(c)
if ctx.User.ID == 0 {
resp403(c)
return
}

var m message
defer func() {
if m != nil {
respEmpty(c, "Verify email", m)
}
}()

if ok, _ := CSRF.Validate(ctx.User.ID, c.Query("csrf")); !ok {
m = errorMessage{"CSRF token expired. Please try again."}
return
}

if ctx.User.Flags&common.FlagEmailVerified > 0 {
m = errorMessage{"Your email has already been verified!"}
return
}

var key string
for i := 0; i < 10; i++ {
key = rs.String(50)
_, err := db.Exec(
"INSERT INTO verification_emails(`key`, user, `time`) VALUES (?, ?, ?)",
key, ctx.User.ID, time.Now().Unix(),
)
if err == nil {
break
}
fmt.Println("verification email:", err)
}

var email string
db.Get(&email, "SELECT email FROM users WHERE id = ?", ctx.User.ID)

content := fmt.Sprintf(`Howdy, %s! Someone, which we hope was you, requested to have their email verified.
In case it was you indeed, <a href="%s">click on this link.</a>`,
ctx.User.Username, config.BaseURL+"/email_verify/finish?k="+key)
msg := mg.NewMessage(config.MailgunFrom, "Ripple email verification", content, email)
msg.SetHtml(content)

_, _, err := mg.Send(msg)
if err != nil {
m = errorMessage{"An error occurred."}
c.Error(err)
return
}

addMessage(c, successMessage{"Success! You should have received an email with instructions on how to verify your email address."})
getSession(c).Save()
c.Redirect(302, "/")
}

func finishEmailVerification(c *gin.Context) {
ctx := getContext(c)
if ctx.User.ID == 0 {
resp403(c)
return
}

if ctx.User.Flags&common.FlagEmailVerified > 0 {
respEmpty(c, "Verify email", errorMessage{"Your account has already been verified once!"})
return
}

k := c.Query("k")
var u int
db.Get(
&u,
"SELECT user FROM verification_emails WHERE `key` = ? AND user = ? AND `time` > ?",
k, ctx.User.ID, time.Now().Add(-time.Hour*24).Unix(),
)

if u != ctx.User.ID {
respEmpty(c, "Verify email", errorMessage{"The email verification you were looking for could not be found. Perhaps it's another user's, or it expired?"})
return
}

db.Exec("DELETE FROM verification_emails WHERE user = ?", ctx.User.ID)
db.Exec("UPDATE users SET flags = flags | 3 WHERE id = ?", ctx.User.ID)

addMessage(c, successMessage{"Your email has been verified. Thanks!"})
getSession(c).Save()
c.Redirect(302, "/")
}

Loading…
Cancel
Save