http.go (view raw)
1package main
2
3import (
4 "bytes"
5 "database/sql"
6 "fmt"
7 gmi "git.sr.ht/~adnano/go-gemini"
8 "github.com/gorilla/handlers"
9 "github.com/gorilla/sessions"
10 _ "github.com/mattn/go-sqlite3"
11 "golang.org/x/crypto/bcrypt"
12 "html/template"
13 "io"
14 "io/ioutil"
15 "log"
16 "mime"
17 "net/http"
18 "os"
19 "path"
20 "path/filepath"
21 "strings"
22 "time"
23)
24
25var t *template.Template
26var DB *sql.DB
27var SessionStore *sessions.CookieStore
28
29func renderDefaultError(w http.ResponseWriter, statusCode int) {
30 errorMsg := http.StatusText(statusCode)
31 renderError(w, errorMsg, statusCode)
32}
33
34func renderError(w http.ResponseWriter, errorMsg string, statusCode int) {
35 data := struct {
36 PageTitle string
37 StatusCode int
38 ErrorMsg string
39 }{"Error!", statusCode, errorMsg}
40 err := t.ExecuteTemplate(w, "error.html", data)
41 if err != nil { // Shouldn't happen probably
42 http.Error(w, errorMsg, statusCode)
43 }
44}
45
46func rootHandler(w http.ResponseWriter, r *http.Request) {
47 // serve everything inside static directory
48 if r.URL.Path != "/" {
49 fileName := path.Join(c.TemplatesDirectory, "static", filepath.Clean(r.URL.Path))
50 _, err := os.Stat(fileName)
51 if err != nil {
52 renderDefaultError(w, http.StatusNotFound)
53 return
54 }
55 http.ServeFile(w, r, fileName) // TODO better error handling
56 return
57 }
58
59 user := newGetAuthUser(r)
60 indexFiles, err := getIndexFiles(user.IsAdmin)
61 if err != nil {
62 panic(err)
63 }
64 allUsers, err := getActiveUserNames()
65 if err != nil {
66 panic(err)
67 }
68 data := struct {
69 Host string
70 PageTitle string
71 Files []*File
72 Users []string
73 AuthUser AuthUser
74 }{c.Host, c.SiteTitle, indexFiles, allUsers, user}
75 err = t.ExecuteTemplate(w, "index.html", data)
76 if err != nil {
77 panic(err)
78 }
79}
80
81func feedHandler(w http.ResponseWriter, r *http.Request) {
82 user := newGetAuthUser(r)
83 feedEntries, err := getAllGemfeedEntries()
84 if err != nil {
85 panic(err)
86 }
87 data := struct {
88 Host string
89 PageTitle string
90 FeedEntries []*FeedEntry
91 AuthUser AuthUser
92 }{c.Host, c.SiteTitle, feedEntries, user}
93 err = t.ExecuteTemplate(w, "feed.html", data)
94 if err != nil {
95 panic(err)
96 }
97}
98
99func editFileHandler(w http.ResponseWriter, r *http.Request) {
100 user := newGetAuthUser(r)
101 if !user.LoggedIn {
102 renderDefaultError(w, http.StatusForbidden)
103 return
104 }
105 fileName := filepath.Clean(r.URL.Path[len("/edit/"):])
106 isText := strings.HasPrefix(mime.TypeByExtension(path.Ext(fileName)), "text")
107 filePath := path.Join(c.FilesDirectory, user.Username, fileName)
108
109 if r.Method == "GET" {
110 err := checkIfValidFile(filePath, nil)
111 if err != nil {
112 log.Println(err)
113 renderError(w, err.Error(), http.StatusBadRequest)
114 return
115 }
116 // Create directories if dne
117 f, err := os.OpenFile(filePath, os.O_RDONLY, 0644)
118 var fileBytes []byte
119 if os.IsNotExist(err) || !isText {
120 fileBytes = []byte{}
121 err = nil
122 } else {
123 defer f.Close()
124 fileBytes, err = ioutil.ReadAll(f)
125 }
126 if err != nil {
127 panic(err)
128 }
129 data := struct {
130 FileName string
131 FileText string
132 PageTitle string
133 AuthUser AuthUser
134 Host string
135 IsText bool
136 }{fileName, string(fileBytes), c.SiteTitle, user, c.Host, isText}
137 err = t.ExecuteTemplate(w, "edit_file.html", data)
138 if err != nil {
139 panic(err)
140 }
141 } else if r.Method == "POST" {
142 // get post body
143 r.ParseForm()
144 fileText := r.Form.Get("file_text")
145 // Web form by default gives us CR LF newlines.
146 // Unix files use just LF
147 fileText = strings.ReplaceAll(fileText, "\r\n", "\n")
148 fileBytes := []byte(fileText)
149 err := checkIfValidFile(filePath, fileBytes)
150 if err != nil {
151 log.Println(err)
152 renderError(w, err.Error(), http.StatusBadRequest)
153 return
154 }
155 // create directories if dne
156 os.MkdirAll(path.Dir(filePath), os.ModePerm)
157 if userHasSpace(user.Username, len(fileBytes)) {
158 if isText { // Cant edit binary files here
159 err = ioutil.WriteFile(filePath, fileBytes, 0644)
160 }
161 } else {
162 renderError(w, fmt.Sprintf("Bad Request: Out of file space. Max space: %d.", c.MaxUserBytes), http.StatusBadRequest)
163 return
164 }
165 if err != nil {
166 panic(err)
167 }
168 newName := filepath.Clean(r.Form.Get("rename"))
169 err = checkIfValidFile(newName, fileBytes)
170 if err != nil {
171 log.Println(err)
172 renderError(w, err.Error(), http.StatusBadRequest)
173 return
174 }
175 if newName != fileName {
176 newPath := path.Join(c.FilesDirectory, user.Username, newName)
177 os.MkdirAll(path.Dir(newPath), os.ModePerm)
178 os.Rename(filePath, newPath)
179 fileName = newName
180 }
181 http.Redirect(w, r, path.Join("/edit", fileName), http.StatusSeeOther)
182 }
183}
184
185func uploadFilesHandler(w http.ResponseWriter, r *http.Request) {
186 if r.Method == "POST" {
187 user := newGetAuthUser(r)
188 if !user.LoggedIn {
189 renderDefaultError(w, http.StatusForbidden)
190 return
191 }
192 r.ParseMultipartForm(10 << 6) // why does this not work
193 file, fileHeader, err := r.FormFile("file")
194 fileName := filepath.Clean(fileHeader.Filename)
195 defer file.Close()
196 if err != nil {
197 log.Println(err)
198 renderError(w, err.Error(), http.StatusBadRequest)
199 return
200 }
201 dest, _ := ioutil.ReadAll(file)
202 err = checkIfValidFile(fileName, dest)
203 if err != nil {
204 log.Println(err)
205 renderError(w, err.Error(), http.StatusBadRequest)
206 return
207 }
208 destPath := path.Join(c.FilesDirectory, user.Username, fileName)
209
210 f, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE, 0644)
211 if err != nil {
212 panic(err)
213 }
214 defer f.Close()
215 if userHasSpace(user.Username, c.MaxFileBytes) { // Not quite right
216 io.Copy(f, bytes.NewReader(dest))
217 } else {
218 renderError(w, fmt.Sprintf("Bad Request: Out of file space. Max space: %d.", c.MaxUserBytes), http.StatusBadRequest)
219 return
220 }
221 }
222 http.Redirect(w, r, "/my_site", http.StatusSeeOther)
223}
224
225type AuthUser struct {
226 LoggedIn bool
227 Username string
228 IsAdmin bool
229 ImpersonatingUser string // used if impersonating
230}
231
232func newGetAuthUser(r *http.Request) AuthUser {
233 session, _ := SessionStore.Get(r, "cookie-session")
234 user, ok := session.Values["auth_user"].(string)
235 impers, _ := session.Values["impersonating_user"].(string)
236 isAdmin, _ := session.Values["admin"].(bool)
237 return AuthUser{
238 LoggedIn: ok,
239 Username: user,
240 IsAdmin: isAdmin,
241 ImpersonatingUser: impers,
242 }
243}
244
245func mySiteHandler(w http.ResponseWriter, r *http.Request) {
246 user := newGetAuthUser(r)
247 if !user.LoggedIn {
248 renderDefaultError(w, http.StatusForbidden)
249 return
250 }
251 // check auth
252 userFolder := getUserDirectory(user.Username)
253 files, _ := getMyFilesRecursive(userFolder, user.Username)
254 data := struct {
255 Host string
256 PageTitle string
257 Files []*File
258 AuthUser AuthUser
259 }{c.Host, c.SiteTitle, files, user}
260 _ = t.ExecuteTemplate(w, "my_site.html", data)
261}
262
263func myAccountHandler(w http.ResponseWriter, r *http.Request) {
264 user := newGetAuthUser(r)
265 authUser := user.Username
266 if !user.LoggedIn {
267 renderDefaultError(w, http.StatusForbidden)
268 return
269 }
270 me, _ := getUserByName(user.Username)
271 type pageData struct {
272 PageTitle string
273 AuthUser AuthUser
274 Email string
275 Errors []string
276 }
277 data := pageData{"My Account", user, me.Email, nil}
278
279 if r.Method == "GET" {
280 err := t.ExecuteTemplate(w, "me.html", data)
281 if err != nil {
282 panic(err)
283 }
284 } else if r.Method == "POST" {
285 r.ParseForm()
286 newUsername := r.Form.Get("username")
287 errors := []string{}
288 newEmail := r.Form.Get("email")
289 newUsername = strings.ToLower(newUsername)
290 var err error
291 if newEmail != me.Email {
292 _, err = DB.Exec("update user set email = ? where username = ?", newEmail, me.Email)
293 if err != nil {
294 // TODO better error not sql
295 errors = append(errors, err.Error())
296 } else {
297 log.Printf("Changed email for %s from %s to %s", authUser, me.Email, newEmail)
298 }
299 }
300 if newUsername != authUser {
301 // Rename User
302 err = renameUser(authUser, newUsername)
303 if err != nil {
304 log.Println(err)
305 errors = append(errors, "Could not rename user")
306 } else {
307 session, _ := SessionStore.Get(r, "cookie-session")
308 session.Values["auth_user"] = newUsername
309 session.Save(r, w)
310 }
311 }
312 // reset auth
313 user = newGetAuthUser(r)
314 data.Errors = errors
315 data.AuthUser = user
316 data.Email = newEmail
317 _ = t.ExecuteTemplate(w, "me.html", data)
318 }
319}
320
321func archiveHandler(w http.ResponseWriter, r *http.Request) {
322 authUser := newGetAuthUser(r)
323 if !authUser.LoggedIn {
324 renderDefaultError(w, http.StatusForbidden)
325 return
326 }
327 if r.Method == "GET" {
328 userFolder := getUserDirectory(authUser.Username)
329 err := zipit(userFolder, w)
330 if err != nil {
331 panic(err)
332 }
333
334 }
335}
336func loginHandler(w http.ResponseWriter, r *http.Request) {
337 if r.Method == "GET" {
338 // show page
339 data := struct {
340 Error string
341 PageTitle string
342 }{"", "Login"}
343 err := t.ExecuteTemplate(w, "login.html", data)
344 if err != nil {
345 panic(err)
346 }
347 } else if r.Method == "POST" {
348 r.ParseForm()
349 name := r.Form.Get("username")
350 password := r.Form.Get("password")
351 row := DB.QueryRow("SELECT username, password_hash, active, admin FROM user where username = $1 OR email = $1", name)
352 var db_password []byte
353 var username string
354 var active bool
355 var isAdmin bool
356 err := row.Scan(&username, &db_password, &active, &isAdmin)
357 if err != nil {
358 panic(err)
359 }
360 if db_password != nil && !active {
361 data := struct {
362 Error string
363 PageTitle string
364 }{"Your account is not active yet. Pending admin approval", c.SiteTitle}
365 t.ExecuteTemplate(w, "login.html", data)
366 return
367 }
368 if bcrypt.CompareHashAndPassword(db_password, []byte(password)) == nil {
369 log.Println("logged in")
370 session, _ := SessionStore.Get(r, "cookie-session")
371 session.Values["auth_user"] = username
372 session.Values["admin"] = isAdmin
373 session.Save(r, w)
374 http.Redirect(w, r, "/my_site", http.StatusSeeOther)
375 } else {
376 data := struct {
377 Error string
378 PageTitle string
379 }{"Invalid login or password", c.SiteTitle}
380 err := t.ExecuteTemplate(w, "login.html", data)
381 if err != nil {
382 panic(err)
383 }
384 }
385 }
386}
387
388func logoutHandler(w http.ResponseWriter, r *http.Request) {
389 session, _ := SessionStore.Get(r, "cookie-session")
390 impers, ok := session.Values["impersonating_user"].(string)
391 if ok {
392 session.Values["auth_user"] = impers
393 session.Values["impersonating_user"] = nil // TODO expire this automatically
394 // session.Values["admin"] = nil // TODO fix admin
395 } else {
396 session.Options.MaxAge = -1
397 }
398 session.Save(r, w)
399 http.Redirect(w, r, "/", http.StatusSeeOther)
400}
401
402const ok = "-0123456789abcdefghijklmnopqrstuvwxyz"
403
404func isOkUsername(s string) error {
405 if len(s) < 1 {
406 return fmt.Errorf("Username is too short")
407 }
408 if len(s) > 32 {
409 return fmt.Errorf("Username is too long. 32 char max.")
410 }
411 for _, char := range s {
412 if !strings.Contains(ok, strings.ToLower(string(char))) {
413 return fmt.Errorf("Username contains invalid characters. Valid characters include lowercase letters, numbers, and hyphens.")
414 }
415 }
416 return nil
417}
418func registerHandler(w http.ResponseWriter, r *http.Request) {
419 if r.Method == "GET" {
420 data := struct {
421 Host string
422 Errors []string
423 PageTitle string
424 }{c.Host, nil, "Register"}
425 err := t.ExecuteTemplate(w, "register.html", data)
426 if err != nil {
427 panic(err)
428 }
429 } else if r.Method == "POST" {
430 r.ParseForm()
431 email := r.Form.Get("email")
432 password := r.Form.Get("password")
433 errors := []string{}
434 if r.Form.Get("password") != r.Form.Get("password2") {
435 errors = append(errors, "Passwords don't match")
436 }
437 if len(password) < 6 {
438 errors = append(errors, "Password is too short")
439 }
440 username := strings.ToLower(r.Form.Get("username"))
441 err := isOkUsername(username)
442 if err != nil {
443 errors = append(errors, err.Error())
444 }
445 hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), 8) // TODO handle error
446 if err != nil {
447 panic(err)
448 }
449 reference := r.Form.Get("reference")
450 if len(errors) == 0 {
451 _, err = DB.Exec("insert into user (username, email, password_hash, reference) values ($1, $2, $3, $4)", username, email, string(hashedPassword), reference)
452 if err != nil {
453 errors = append(errors, "Username or email is already used")
454 }
455 }
456 if len(errors) > 0 {
457 data := struct {
458 Host string
459 Errors []string
460 PageTitle string
461 }{c.Host, errors, "Register"}
462 t.ExecuteTemplate(w, "register.html", data)
463 } else {
464 data := struct {
465 Host string
466 Message string
467 PageTitle string
468 }{c.Host, "Registration complete! The server admin will approve your request before you can log in.", "Registration Complete"}
469 t.ExecuteTemplate(w, "message.html", data)
470 }
471 }
472}
473
474func deleteFileHandler(w http.ResponseWriter, r *http.Request) {
475 user := newGetAuthUser(r)
476 if !user.LoggedIn {
477 renderDefaultError(w, http.StatusForbidden)
478 return
479 }
480 filePath := safeGetFilePath(user.Username, r.URL.Path[len("/delete/"):])
481 if r.Method == "POST" {
482 os.Remove(filePath) // TODO handle error
483 }
484 http.Redirect(w, r, "/my_site", http.StatusSeeOther)
485}
486
487func adminHandler(w http.ResponseWriter, r *http.Request) {
488 user := newGetAuthUser(r)
489 if !user.IsAdmin {
490 renderDefaultError(w, http.StatusForbidden)
491 return
492 }
493 allUsers, err := getUsers()
494 if err != nil {
495 log.Println(err)
496 renderDefaultError(w, http.StatusInternalServerError)
497 return
498 }
499 data := struct {
500 Users []User
501 AuthUser AuthUser
502 PageTitle string
503 Host string
504 }{allUsers, user, "Admin", c.Host}
505 err = t.ExecuteTemplate(w, "admin.html", data)
506 if err != nil {
507 panic(err)
508 }
509}
510
511func getFavicon(user string) string {
512 faviconPath := path.Join(c.FilesDirectory, filepath.Clean(user), "favicon.txt")
513 content, err := ioutil.ReadFile(faviconPath)
514 if err != nil {
515 return ""
516 }
517 strcontent := []rune(string(content))
518 if len(strcontent) > 0 {
519 return string(strcontent[0])
520 }
521 return ""
522}
523
524// Server a user's file
525func userFile(w http.ResponseWriter, r *http.Request) {
526 userName := filepath.Clean(strings.Split(r.Host, ".")[0]) // Clean probably unnecessary
527 p := filepath.Clean(r.URL.Path)
528 var isDir bool
529 fileName := path.Join(c.FilesDirectory, userName, p)
530 stat, err := os.Stat(fileName)
531 if stat != nil {
532 isDir = stat.IsDir()
533 }
534 if p == "/" || isDir {
535 fileName = path.Join(fileName, "index.gmi")
536 }
537
538 if strings.HasPrefix(p, "/.hidden") {
539 renderDefaultError(w, http.StatusForbidden)
540 return
541 }
542 if r.URL.Path == "/style.css" {
543 http.ServeFile(w, r, path.Join(c.TemplatesDirectory, "static/style.css"))
544 return
545 }
546
547 _, err = os.Stat(fileName)
548 if os.IsNotExist(err) {
549 renderDefaultError(w, http.StatusNotFound)
550 return
551 }
552
553 // Dumb content negotiation
554 _, raw := r.URL.Query()["raw"]
555 acceptsGemini := strings.Contains(r.Header.Get("Accept"), "text/gemini")
556 if !raw && !acceptsGemini && isGemini(fileName) {
557 file, _ := os.Open(fileName)
558 htmlString := textToHTML(gmi.ParseText(file))
559 favicon := getFavicon(userName)
560 hostname := strings.Split(r.Host, ":")[0]
561 URI := hostname + r.URL.String()
562 data := struct {
563 SiteBody template.HTML
564 Favicon string
565 PageTitle string
566 URI string
567 }{template.HTML(htmlString), favicon, userName + p, URI}
568 t.ExecuteTemplate(w, "user_page.html", data)
569 } else {
570 http.ServeFile(w, r, fileName)
571 }
572}
573
574func deleteAccountHandler(w http.ResponseWriter, r *http.Request) {
575 user := newGetAuthUser(r)
576 if r.Method == "POST" {
577 r.ParseForm()
578 validate := r.Form.Get("validate-delete")
579 if validate == user.Username {
580 err := deleteUser(user.Username)
581 if err != nil {
582 log.Println(err)
583 renderDefaultError(w, http.StatusInternalServerError)
584 return
585 }
586 logoutHandler(w, r)
587 } else {
588 http.Redirect(w, r, "/me", http.StatusSeeOther)
589 }
590 }
591}
592
593func resetPasswordHandler(w http.ResponseWriter, r *http.Request) {
594 user := newGetAuthUser(r)
595 data := struct {
596 PageTitle string
597 AuthUser AuthUser
598 Error string
599 }{"Reset Password", user, ""}
600 if r.Method == "GET" {
601 err := t.ExecuteTemplate(w, "reset_pass.html", data)
602 if err != nil {
603 panic(err)
604 }
605 } else if r.Method == "POST" {
606 r.ParseForm()
607 enteredCurrPass := r.Form.Get("password")
608 password1 := r.Form.Get("new_password1")
609 password2 := r.Form.Get("new_password2")
610 if password1 != password2 {
611 data.Error = "New passwords do not match"
612 } else if len(password1) < 6 {
613 data.Error = "Password is too short"
614 } else {
615 err := checkAuth(user.Username, enteredCurrPass)
616 if err == nil {
617 hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password1), 8)
618 if err != nil {
619 panic(err)
620 }
621 _, err = DB.Exec("update user set password_hash = ? where username = ?", hashedPassword, user.Username)
622 if err != nil {
623 panic(err)
624 }
625 log.Printf("User %s reset password", user.Username)
626 http.Redirect(w, r, "/me", http.StatusSeeOther)
627 return
628 } else {
629 data.Error = "That's not your current password"
630 }
631 }
632 err := t.ExecuteTemplate(w, "reset_pass.html", data)
633 if err != nil {
634 panic(err)
635 }
636 }
637}
638
639func adminUserHandler(w http.ResponseWriter, r *http.Request) {
640 user := newGetAuthUser(r)
641 if r.Method == "POST" {
642 if !user.IsAdmin {
643 renderDefaultError(w, http.StatusForbidden)
644 return
645 }
646 components := strings.Split(r.URL.Path, "/")
647 if len(components) < 5 {
648 renderError(w, "Invalid action", http.StatusBadRequest)
649 return
650 }
651 userName := components[3]
652 action := components[4]
653 var err error
654 if action == "activate" {
655 err = activateUser(userName)
656 } else if action == "impersonate" {
657 if user.ImpersonatingUser != "" {
658 // Don't allow nested impersonation
659 renderError(w, "Cannot nest impersonation, log out from impersonated user first.", 400)
660 return
661 }
662 session, _ := SessionStore.Get(r, "cookie-session")
663 session.Values["auth_user"] = userName
664 session.Values["impersonating_user"] = user.Username
665 session.Save(r, w)
666 log.Printf("User %s impersonated %s", user.Username, userName)
667 http.Redirect(w, r, "/", http.StatusSeeOther)
668 return
669 }
670 if err != nil {
671 log.Println(err)
672 renderDefaultError(w, http.StatusInternalServerError)
673 return
674 }
675 http.Redirect(w, r, "/admin", http.StatusSeeOther)
676 }
677}
678
679func runHTTPServer() {
680 log.Printf("Running http server with hostname %s on port %d. TLS enabled: %t", c.Host, c.HttpPort, c.HttpsEnabled)
681 var err error
682 t, err = template.ParseGlob(path.Join(c.TemplatesDirectory, "*.html"))
683 if err != nil {
684 log.Fatal(err)
685 }
686 serveMux := http.NewServeMux()
687
688 s := strings.SplitN(c.Host, ":", 2)
689 hostname := s[0]
690 port := c.HttpPort
691
692 serveMux.HandleFunc(hostname+"/", rootHandler)
693 serveMux.HandleFunc(hostname+"/feed", feedHandler)
694 serveMux.HandleFunc(hostname+"/my_site", mySiteHandler)
695 serveMux.HandleFunc(hostname+"/me", myAccountHandler)
696 serveMux.HandleFunc(hostname+"/my_site/flounder-archive.zip", archiveHandler)
697 serveMux.HandleFunc(hostname+"/admin", adminHandler)
698 serveMux.HandleFunc(hostname+"/edit/", editFileHandler)
699 serveMux.HandleFunc(hostname+"/upload", uploadFilesHandler)
700 serveMux.Handle(hostname+"/login", limit(http.HandlerFunc(loginHandler)))
701 serveMux.Handle(hostname+"/register", limit(http.HandlerFunc(registerHandler)))
702 serveMux.HandleFunc(hostname+"/logout", logoutHandler)
703 serveMux.HandleFunc(hostname+"/delete/", deleteFileHandler)
704 serveMux.HandleFunc(hostname+"/delete-account", deleteAccountHandler)
705 serveMux.HandleFunc(hostname+"/reset-password", resetPasswordHandler)
706
707 // admin commands
708 serveMux.HandleFunc(hostname+"/admin/user/", adminUserHandler)
709 // TODO authentication
710 serveMux.HandleFunc(hostname+"/webdav/", webdavHandler)
711
712 wrapped := (handlers.LoggingHandler(log.Writer(), handlers.RecoveryHandler()(serveMux)))
713
714 // handle user files based on subdomain
715 serveMux.HandleFunc("/", userFile)
716 // login+register functions
717 srv := &http.Server{
718 ReadTimeout: 5 * time.Second,
719 WriteTimeout: 10 * time.Second,
720 IdleTimeout: 120 * time.Second,
721 Addr: fmt.Sprintf(":%d", port),
722 // TLSConfig: tlsConfig,
723 Handler: wrapped,
724 }
725 if c.HttpsEnabled {
726 log.Fatal(srv.ListenAndServeTLS(c.TLSCertFile, c.TLSKeyFile))
727 } else {
728 log.Fatal(srv.ListenAndServe())
729 }
730}