mirror of
https://github.com/writefreely/writefreely
synced 2024-11-10 11:24:13 +00:00
Merge pull request #172 from writeas/import-text
add basic text file imports Resolves T609
This commit is contained in:
commit
75e2b60328
8 changed files with 303 additions and 13 deletions
195
account_import.go
Normal file
195
account_import.go
Normal file
|
@ -0,0 +1,195 @@
|
|||
package writefreely
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/go-multierror"
|
||||
"github.com/writeas/impart"
|
||||
wfimport "github.com/writeas/import"
|
||||
"github.com/writeas/web-core/log"
|
||||
)
|
||||
|
||||
func viewImport(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
|
||||
// Fetch extra user data
|
||||
p := NewUserPage(app, r, u, "Import Posts", nil)
|
||||
|
||||
c, err := app.db.GetCollections(u, app.Config().App.Host)
|
||||
if err != nil {
|
||||
return impart.HTTPError{http.StatusInternalServerError, fmt.Sprintf("unable to fetch collections: %v", err)}
|
||||
}
|
||||
|
||||
d := struct {
|
||||
*UserPage
|
||||
Collections *[]Collection
|
||||
Flashes []template.HTML
|
||||
Message string
|
||||
InfoMsg bool
|
||||
}{
|
||||
UserPage: p,
|
||||
Collections: c,
|
||||
Flashes: []template.HTML{},
|
||||
}
|
||||
|
||||
flashes, _ := getSessionFlashes(app, w, r, nil)
|
||||
for _, flash := range flashes {
|
||||
if strings.HasPrefix(flash, "SUCCESS: ") {
|
||||
d.Message = strings.TrimPrefix(flash, "SUCCESS: ")
|
||||
} else if strings.HasPrefix(flash, "INFO: ") {
|
||||
d.Message = strings.TrimPrefix(flash, "INFO: ")
|
||||
d.InfoMsg = true
|
||||
} else {
|
||||
d.Flashes = append(d.Flashes, template.HTML(flash))
|
||||
}
|
||||
}
|
||||
|
||||
showUserPage(w, "import", d)
|
||||
return nil
|
||||
}
|
||||
|
||||
func handleImport(app *App, u *User, w http.ResponseWriter, r *http.Request) error {
|
||||
// limit 10MB per submission
|
||||
r.ParseMultipartForm(10 << 20)
|
||||
|
||||
collAlias := r.PostFormValue("collection")
|
||||
coll := &Collection{
|
||||
ID: 0,
|
||||
}
|
||||
var err error
|
||||
if collAlias != "" {
|
||||
coll, err = app.db.GetCollection(collAlias)
|
||||
if err != nil {
|
||||
log.Error("Unable to get collection for import: %s", err)
|
||||
return err
|
||||
}
|
||||
// Only allow uploading to collection if current user is owner
|
||||
if coll.OwnerID != u.ID {
|
||||
err := ErrUnauthorizedGeneral
|
||||
_ = addSessionFlash(app, w, r, err.Message, nil)
|
||||
return err
|
||||
}
|
||||
coll.hostName = app.cfg.App.Host
|
||||
}
|
||||
|
||||
fileDates := make(map[string]int64)
|
||||
err = json.Unmarshal([]byte(r.FormValue("fileDates")), &fileDates)
|
||||
if err != nil {
|
||||
log.Error("invalid form data for file dates: %v", err)
|
||||
return impart.HTTPError{http.StatusBadRequest, "form data for file dates was invalid"}
|
||||
}
|
||||
files := r.MultipartForm.File["files"]
|
||||
var fileErrs []error
|
||||
filesSubmitted := len(files)
|
||||
var filesImported int
|
||||
for _, formFile := range files {
|
||||
fname := ""
|
||||
ok := func() bool {
|
||||
file, err := formFile.Open()
|
||||
if err != nil {
|
||||
fileErrs = append(fileErrs, fmt.Errorf("Unable to read file %s", formFile.Filename))
|
||||
log.Error("import file: open from form: %v", err)
|
||||
return false
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
tempFile, err := ioutil.TempFile("", "post-upload-*.txt")
|
||||
if err != nil {
|
||||
fileErrs = append(fileErrs, fmt.Errorf("Internal error for %s", formFile.Filename))
|
||||
log.Error("import file: create temp file %s: %v", formFile.Filename, err)
|
||||
return false
|
||||
}
|
||||
defer tempFile.Close()
|
||||
|
||||
_, err = io.Copy(tempFile, file)
|
||||
if err != nil {
|
||||
fileErrs = append(fileErrs, fmt.Errorf("Internal error for %s", formFile.Filename))
|
||||
log.Error("import file: copy to temp location %s: %v", formFile.Filename, err)
|
||||
return false
|
||||
}
|
||||
|
||||
info, err := tempFile.Stat()
|
||||
if err != nil {
|
||||
fileErrs = append(fileErrs, fmt.Errorf("Internal error for %s", formFile.Filename))
|
||||
log.Error("import file: stat temp file %s: %v", formFile.Filename, err)
|
||||
return false
|
||||
}
|
||||
fname = info.Name()
|
||||
return true
|
||||
}()
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
post, err := wfimport.FromFile(filepath.Join(os.TempDir(), fname))
|
||||
if err == wfimport.ErrEmptyFile {
|
||||
// not a real error so don't log
|
||||
_ = addSessionFlash(app, w, r, fmt.Sprintf("%s was empty, import skipped", formFile.Filename), nil)
|
||||
continue
|
||||
} else if err == wfimport.ErrInvalidContentType {
|
||||
// same as above
|
||||
_ = addSessionFlash(app, w, r, fmt.Sprintf("%s is not a supported post file", formFile.Filename), nil)
|
||||
continue
|
||||
} else if err != nil {
|
||||
fileErrs = append(fileErrs, fmt.Errorf("failed to read copy of %s", formFile.Filename))
|
||||
log.Error("import textfile: file to post: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if collAlias != "" {
|
||||
post.Collection = collAlias
|
||||
}
|
||||
dateTime := time.Unix(fileDates[formFile.Filename], 0)
|
||||
post.Created = &dateTime
|
||||
created := post.Created.Format("2006-01-02T15:04:05Z")
|
||||
submittedPost := SubmittedPost{
|
||||
Title: &post.Title,
|
||||
Content: &post.Content,
|
||||
Font: "norm",
|
||||
Created: &created,
|
||||
}
|
||||
rp, err := app.db.CreatePost(u.ID, coll.ID, &submittedPost)
|
||||
if err != nil {
|
||||
fileErrs = append(fileErrs, fmt.Errorf("failed to create post from %s", formFile.Filename))
|
||||
log.Error("import textfile: create db post: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// Federate post, if necessary
|
||||
if app.cfg.App.Federation && coll.ID > 0 {
|
||||
go federatePost(
|
||||
app,
|
||||
&PublicPost{
|
||||
Post: rp,
|
||||
Collection: &CollectionObj{
|
||||
Collection: *coll,
|
||||
},
|
||||
},
|
||||
coll.ID,
|
||||
false,
|
||||
)
|
||||
}
|
||||
filesImported++
|
||||
}
|
||||
if len(fileErrs) != 0 {
|
||||
_ = addSessionFlash(app, w, r, multierror.ListFormatFunc(fileErrs), nil)
|
||||
}
|
||||
|
||||
if filesImported == filesSubmitted {
|
||||
verb := "posts"
|
||||
if filesSubmitted == 1 {
|
||||
verb = "post"
|
||||
}
|
||||
_ = addSessionFlash(app, w, r, fmt.Sprintf("SUCCESS: Import complete, %d %s imported.", filesImported, verb), nil)
|
||||
} else if filesImported > 0 {
|
||||
_ = addSessionFlash(app, w, r, fmt.Sprintf("INFO: %d of %d posts imported, see details below.", filesImported, filesSubmitted), nil)
|
||||
}
|
||||
return impart.HTTPError{http.StatusFound, "/me/import"}
|
||||
}
|
3
go.mod
3
go.mod
|
@ -17,6 +17,8 @@ require (
|
|||
github.com/gorilla/schema v1.0.2
|
||||
github.com/gorilla/sessions v1.1.3
|
||||
github.com/guregu/null v3.4.0+incompatible
|
||||
github.com/hashicorp/go-multierror v1.0.0
|
||||
github.com/ikeikeikeike/go-sitemap-generator v1.0.1
|
||||
github.com/ikeikeikeike/go-sitemap-generator/v2 v2.0.2
|
||||
github.com/jtolds/gls v4.2.1+incompatible // indirect
|
||||
github.com/kr/pretty v0.1.0
|
||||
|
@ -40,6 +42,7 @@ require (
|
|||
github.com/writeas/go-webfinger v0.0.0-20190106002315-85cf805c86d2
|
||||
github.com/writeas/httpsig v1.0.0
|
||||
github.com/writeas/impart v1.1.1-0.20191230230525-d3c45ced010d
|
||||
github.com/writeas/import v0.2.0
|
||||
github.com/writeas/monday v0.0.0-20181024183321-54a7dd579219
|
||||
github.com/writeas/nerds v1.0.0
|
||||
github.com/writeas/saturday v1.7.1
|
||||
|
|
20
go.sum
20
go.sum
|
@ -1,3 +1,5 @@
|
|||
code.as/core/socks v1.0.0 h1:SPQXNp4SbEwjOAP9VzUahLHak8SDqy5n+9cm9tpjZOs=
|
||||
code.as/core/socks v1.0.0/go.mod h1:BAXBy5O9s2gmw6UxLqNJcVbWY7C/UPs+801CcSsfWOY=
|
||||
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/alecthomas/gometalinter v2.0.11+incompatible/go.mod h1:qfIpQGGz3d+NmgyPBqv+LSh50emm1pt72EtcX2vKYQk=
|
||||
|
@ -58,6 +60,12 @@ github.com/gorilla/sessions v1.1.3 h1:uXoZdcdA5XdXF3QzuSlheVRUvjl+1rKY7zBXL68L9R
|
|||
github.com/gorilla/sessions v1.1.3/go.mod h1:8KCfur6+4Mqcc6S0FEfKuN15Vl5MgXW92AE8ovaJD0w=
|
||||
github.com/guregu/null v3.4.0+incompatible h1:a4mw37gBO7ypcBlTJeZGuMpSxxFTV9qFfFKgWxQSGaM=
|
||||
github.com/guregu/null v3.4.0+incompatible/go.mod h1:ePGpQaN9cw0tj45IR5E5ehMvsFlLlQZAkkOXZurJ3NM=
|
||||
github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA=
|
||||
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||
github.com/hashicorp/go-multierror v1.0.0 h1:iVjPR7a6H0tWELX5NxNe7bYopibicUzc7uPribsnS6o=
|
||||
github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
|
||||
github.com/ikeikeikeike/go-sitemap-generator v1.0.1 h1:49Fn8gro/B12vCY8pf5/+/Jpr3kwB9TvP0MSymo69SY=
|
||||
github.com/ikeikeikeike/go-sitemap-generator v1.0.1/go.mod h1:QI+zWsz6yQyxkG9LWNcnu0f7aiAE5tPdsZOsICgmd1c=
|
||||
github.com/ikeikeikeike/go-sitemap-generator/v2 v2.0.2 h1:wIdDEle9HEy7vBPjC6oKz6ejs3Ut+jmsYvuOoAW2pSM=
|
||||
github.com/ikeikeikeike/go-sitemap-generator/v2 v2.0.2/go.mod h1:WtaVKD9TeruTED9ydiaOJU08qGoEPP/LyzTKiD3jEsw=
|
||||
github.com/jtolds/gls v4.2.1+incompatible h1:fSuqC+Gmlu6l/ZYAoZzx2pyucC8Xza35fpRVWLVmUEE=
|
||||
|
@ -119,12 +127,24 @@ github.com/writeas/go-strip-markdown v2.0.1+incompatible h1:IIqxTM5Jr7RzhigcL6Fk
|
|||
github.com/writeas/go-strip-markdown v2.0.1+incompatible/go.mod h1:Rsyu10ZhbEK9pXdk8V6MVnZmTzRG0alMNLMwa0J01fE=
|
||||
github.com/writeas/go-webfinger v0.0.0-20190106002315-85cf805c86d2 h1:DUsp4OhdfI+e6iUqcPQlwx8QYXuUDsToTz/x82D3Zuo=
|
||||
github.com/writeas/go-webfinger v0.0.0-20190106002315-85cf805c86d2/go.mod h1:w2VxyRO/J5vfNjJHYVubsjUGHd3RLDoVciz0DE3ApOc=
|
||||
github.com/writeas/go-writeas v1.1.0 h1:WHGm6wriBkxYAOGbvriXH8DlMUGOi6jhSZLUZKQ+4mQ=
|
||||
github.com/writeas/go-writeas v1.1.0/go.mod h1:oh9U1rWaiE0p3kzdKwwvOpNXgp0P0IELI7OLOwV4fkA=
|
||||
github.com/writeas/go-writeas/v2 v2.0.2 h1:akvdMg89U5oBJiCkBwOXljVLTqP354uN6qnG2oOMrbk=
|
||||
github.com/writeas/go-writeas/v2 v2.0.2/go.mod h1:9sjczQJKmru925fLzg0usrU1R1tE4vBmQtGnItUMR0M=
|
||||
github.com/writeas/httpsig v1.0.0 h1:peIAoIA3DmlP8IG8tMNZqI4YD1uEnWBmkcC9OFPjt3A=
|
||||
github.com/writeas/httpsig v1.0.0/go.mod h1:7ClMGSrSVXJbmiLa17bZ1LrG1oibGZmUMlh3402flPY=
|
||||
github.com/writeas/impart v1.1.0 h1:nPnoO211VscNkp/gnzir5UwCDEvdHThL5uELU60NFSE=
|
||||
github.com/writeas/impart v1.1.0/go.mod h1:g0MpxdnTOHHrl+Ca/2oMXUHJ0PcRAEWtkCzYCJUXC9Y=
|
||||
github.com/writeas/impart v1.1.1-0.20191230230525-d3c45ced010d h1:PK7DOj3JE6MGf647esPrKzXEHFjGWX2hl22uX79ixaE=
|
||||
github.com/writeas/impart v1.1.1-0.20191230230525-d3c45ced010d/go.mod h1:g0MpxdnTOHHrl+Ca/2oMXUHJ0PcRAEWtkCzYCJUXC9Y=
|
||||
github.com/writeas/import v0.0.0-20190815214647-baae8acd8d06 h1:S6oKKP8GhSoyZUvVuhO9UiQ9f+U1aR/x5B4MP7YQHaU=
|
||||
github.com/writeas/import v0.0.0-20190815214647-baae8acd8d06/go.mod h1:f3K8z7YnJwKnPIT4h7980n9C6cQb4DIB2QcxVCTB7lE=
|
||||
github.com/writeas/import v0.0.0-20190815235139-628d10daaa9e h1:31PkvDTWkjzC1nGzWw9uAE92ZfcVyFX/K9L9ejQjnEs=
|
||||
github.com/writeas/import v0.0.0-20190815235139-628d10daaa9e/go.mod h1:f3K8z7YnJwKnPIT4h7980n9C6cQb4DIB2QcxVCTB7lE=
|
||||
github.com/writeas/import v0.1.1 h1:SbYltT+nxrJBUe0xQWJqeKMHaupbxV0a6K3RtwcE4yY=
|
||||
github.com/writeas/import v0.1.1/go.mod h1:gFe0Pl7ZWYiXbI0TJxeMMyylPGZmhVvCfQxhMEc8CxM=
|
||||
github.com/writeas/import v0.2.0 h1:Ov23JW9Rnjxk06rki1Spar45bNX647HhwhAZj3flJiY=
|
||||
github.com/writeas/import v0.2.0/go.mod h1:gFe0Pl7ZWYiXbI0TJxeMMyylPGZmhVvCfQxhMEc8CxM=
|
||||
github.com/writeas/monday v0.0.0-20181024183321-54a7dd579219 h1:baEp0631C8sT2r/hqwypIw2snCFZa6h7U6TojoLHu/c=
|
||||
github.com/writeas/monday v0.0.0-20181024183321-54a7dd579219/go.mod h1:NyM35ayknT7lzO6O/1JpfgGyv+0W9Z9q7aE0J8bXxfQ=
|
||||
github.com/writeas/nerds v1.0.0 h1:ZzRcCN+Sr3MWID7o/x1cr1ZbLvdpej9Y1/Ho+JKlqxo=
|
||||
|
|
|
@ -1318,6 +1318,24 @@ form {
|
|||
font-size: 0.86em;
|
||||
line-height: 2;
|
||||
}
|
||||
|
||||
&.prominent {
|
||||
margin: 1em 0;
|
||||
|
||||
label {
|
||||
font-weight: bold;
|
||||
}
|
||||
input, select {
|
||||
width: 100%;
|
||||
}
|
||||
select {
|
||||
font-size: 1em;
|
||||
padding: 0.5rem;
|
||||
display: block;
|
||||
border-radius: 0.25rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
div.row {
|
||||
display: flex;
|
||||
|
|
|
@ -97,6 +97,7 @@ func InitRoutes(apper Apper, r *mux.Router) *mux.Router {
|
|||
me.HandleFunc("/posts/export.json", handler.Download(viewExportPosts, UserLevelUser)).Methods("GET")
|
||||
me.HandleFunc("/export", handler.User(viewExportOptions)).Methods("GET")
|
||||
me.HandleFunc("/export.json", handler.Download(viewExportFull, UserLevelUser)).Methods("GET")
|
||||
me.HandleFunc("/import", handler.User(viewImport)).Methods("GET")
|
||||
me.HandleFunc("/settings", handler.User(viewSettings)).Methods("GET")
|
||||
me.HandleFunc("/invites", handler.User(handleViewUserInvites)).Methods("GET")
|
||||
me.HandleFunc("/logout", handler.Web(viewLogout, UserLevelNone)).Methods("GET")
|
||||
|
@ -109,6 +110,7 @@ func InitRoutes(apper Apper, r *mux.Router) *mux.Router {
|
|||
apiMe.HandleFunc("/password", handler.All(updatePassphrase)).Methods("POST")
|
||||
apiMe.HandleFunc("/self", handler.All(updateSettings)).Methods("POST")
|
||||
apiMe.HandleFunc("/invites", handler.User(handleCreateUserInvite)).Methods("POST")
|
||||
apiMe.HandleFunc("/import", handler.User(handleImport)).Methods("POST")
|
||||
|
||||
// Sign up validation
|
||||
write.HandleFunc("/api/alias", handler.All(handleUsernameCheck)).Methods("POST")
|
||||
|
|
61
templates/user/import.tmpl
Normal file
61
templates/user/import.tmpl
Normal file
|
@ -0,0 +1,61 @@
|
|||
{{define "import"}}
|
||||
{{template "header" .}}
|
||||
<style>
|
||||
input[type=file] {
|
||||
padding: 0;
|
||||
font-size: 0.86em;
|
||||
display: block;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
label {
|
||||
display: block;
|
||||
margin: 1em 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="snug content-container">
|
||||
<h1 id="import-header">Import posts</h1>
|
||||
{{if .Message}}
|
||||
<div class="alert {{if .InfoMsg}}info{{else}}success{{end}}">
|
||||
<p>{{.Message}}</p>
|
||||
</div>
|
||||
{{end}}
|
||||
{{if .Flashes}}
|
||||
<ul class="errors">
|
||||
{{range .Flashes}}<li class="urgent">{{.}}</li>{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
<p>Publish plain text or Markdown files to your account by uploading them below.</p>
|
||||
<div class="formContainer">
|
||||
<form id="importPosts" class="prominent" enctype="multipart/form-data" action="/api/me/import" method="POST">
|
||||
<label>Select some files to import:
|
||||
<input id="fileInput" class="fileInput" name="files" type="file" multiple accept="text/markdown, text/plain"/>
|
||||
</label>
|
||||
<input id="fileDates" name="fileDates" hidden/>
|
||||
<label>
|
||||
Import these posts to:
|
||||
<select name="collection">
|
||||
{{range $i, $el := .Collections}}
|
||||
<option value="{{.Alias}}" {{if eq $i 0}}selected{{end}}>{{.DisplayTitle}}</option>
|
||||
{{end}}
|
||||
<option value="">Drafts</option>
|
||||
</select>
|
||||
</label>
|
||||
<script>
|
||||
const fileInput = document.getElementById('fileInput');
|
||||
const fileDates = document.getElementById('fileDates');
|
||||
fileInput.addEventListener('change', (e) => {
|
||||
const files = e.target.files;
|
||||
let dateMap = {};
|
||||
for (let file of files) {
|
||||
dateMap[file.name] = file.lastModified / 1000;
|
||||
}
|
||||
fileDates.value = JSON.stringify(dateMap);
|
||||
})
|
||||
</script>
|
||||
<input type="submit" value="Import" />
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{{template "footer" .}}
|
||||
{{end}}
|
|
@ -10,6 +10,7 @@
|
|||
<li class="separator"><hr /></li>
|
||||
{{if .IsAdmin}}<li><a href="/admin">Admin</a></li>{{end}}
|
||||
<li><a href="/me/settings">Settings</a></li>
|
||||
<li><a href="/me/import">Import posts</a></li>
|
||||
<li><a href="/me/export">Export</a></li>
|
||||
<li class="separator"><hr /></li>
|
||||
<li><a href="/me/logout">Log out</a></li>
|
||||
|
@ -32,6 +33,7 @@
|
|||
<ul><li><a>{{.Username}}</a> <img class="ic-18dp" src="/img/ic_down_arrow_dark@2x.png" /><ul>
|
||||
{{if .IsAdmin}}<li><a href="/admin">Admin dashboard</a></li>{{end}}
|
||||
<li><a href="/me/settings">Account settings</a></li>
|
||||
<li><a href="/me/import">Import posts</a></li>
|
||||
<li><a href="/me/export">Export</a></li>
|
||||
{{if .CanInvite}}<li><a href="/me/invites">Invite people</a></li>{{end}}
|
||||
<li class="separator"><hr /></li>
|
||||
|
|
|
@ -8,18 +8,7 @@
|
|||
margin-left: 0.5em;
|
||||
margin-right: 0;
|
||||
}
|
||||
label {
|
||||
font-weight: bold;
|
||||
}
|
||||
select {
|
||||
font-size: 1em;
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
display: block;
|
||||
border-radius: 0.25rem;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
input, table.classy {
|
||||
table.classy {
|
||||
width: 100%;
|
||||
}
|
||||
table.classy.export a {
|
||||
|
@ -34,7 +23,7 @@ table td {
|
|||
<h1>Invite people</h1>
|
||||
<p>Invite others to join <em>{{.SiteName}}</em> by generating and sharing invite links below.</p>
|
||||
|
||||
<form style="margin: 2em 0" action="/api/me/invites" method="post">
|
||||
<form style="margin: 2em 0" class="prominent" action="/api/me/invites" method="post">
|
||||
<div class="row">
|
||||
<div class="half">
|
||||
<label for="uses">Maximum number of uses:</label>
|
||||
|
|
Loading…
Reference in a new issue