From 1342faf447ac5cc86073ca62ef360f856cef833f Mon Sep 17 00:00:00 2001 From: StJudeWasHere <707925+StJudeWasHere@users.noreply.github.com> Date: Wed, 6 Nov 2024 17:36:59 +0100 Subject: [PATCH] Add WACZ archive as export option --- .gitignore | 1 + archive/.gitignore | 0 go.mod | 1 + go.sum | 2 + internal/models/project.go | 1 + internal/repository/project.go | 18 +- internal/routes/app.go | 1 + internal/routes/export.go | 78 +++++++- internal/routes/project.go | 11 ++ internal/services/archiver.go | 282 +++++++++++++++++++++++++++ internal/services/crawler.go | 20 +- internal/services/crawler_handler.go | 10 + internal/services/project.go | 34 ++++ migrations/0067_archive.down.sql | 1 + migrations/0067_archive.up.sql | 1 + web/templates/export.html | 14 ++ web/templates/project_add.html | 31 ++- web/templates/project_edit.html | 19 ++ 18 files changed, 509 insertions(+), 16 deletions(-) create mode 100644 archive/.gitignore create mode 100644 internal/services/archiver.go create mode 100644 migrations/0067_archive.down.sql create mode 100644 migrations/0067_archive.up.sql diff --git a/.gitignore b/.gitignore index 0318de9f..4d01d58f 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ bin/* # Ignore any file in the web/static folder except favicon.ico and robots.txt # The frontend build will copy fonts and styles into this folder. +archive/* web/static/* !web/static/favicon.ico !web/static/robots.txt \ No newline at end of file diff --git a/archive/.gitignore b/archive/.gitignore new file mode 100644 index 00000000..e69de29b diff --git a/go.mod b/go.mod index 0f539178..44052fe1 100644 --- a/go.mod +++ b/go.mod @@ -12,6 +12,7 @@ require ( github.com/gorilla/websocket v1.5.3 github.com/microcosm-cc/bluemonday v1.0.27 github.com/oxffaa/gopher-parse-sitemap v0.0.0-20191021113419-005d2eb1def4 + github.com/slyrz/warc v0.0.0-20150806225202-a50edd19b690 github.com/spf13/viper v1.19.0 github.com/temoto/robotstxt v1.1.2 github.com/turk/go-sitemap v0.0.0-20210912154218-82ad01095e30 diff --git a/go.sum b/go.sum index 8fd2b185..7e266523 100644 --- a/go.sum +++ b/go.sum @@ -100,6 +100,8 @@ github.com/sagikazarmark/locafero v0.6.0 h1:ON7AQg37yzcRPU69mt7gwhFEBwxI6P9T4Qu3 github.com/sagikazarmark/locafero v0.6.0/go.mod h1:77OmuIc6VTraTXKXIs/uvUxKGUXjE1GbemJYHqdNjX0= github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= +github.com/slyrz/warc v0.0.0-20150806225202-a50edd19b690 h1:2RLSydlHktw3Fo4nwOQwjexn1d49KJb/i+EmlT4D878= +github.com/slyrz/warc v0.0.0-20150806225202-a50edd19b690/go.mod h1:LuhAhBK7l5/QEJmiz3tVGLi8n0IwqAwLX/ndr+6XSDE= github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= diff --git a/internal/models/project.go b/internal/models/project.go index 878ab3ea..75e3fd6a 100644 --- a/internal/models/project.go +++ b/internal/models/project.go @@ -17,4 +17,5 @@ type Project struct { Deleting bool BasicAuth bool CheckExternalLinks bool + Archive bool } diff --git a/internal/repository/project.go b/internal/repository/project.go index 488ab6ea..59efc5d0 100644 --- a/internal/repository/project.go +++ b/internal/repository/project.go @@ -23,9 +23,10 @@ func (ds *ProjectRepository) SaveProject(project *models.Project, uid int) { allow_subdomains, basic_auth, user_id, - check_external_links + check_external_links, + archive ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ` stmt, _ := ds.DB.Prepare(query) @@ -40,6 +41,7 @@ func (ds *ProjectRepository) SaveProject(project *models.Project, uid int) { project.BasicAuth, uid, project.CheckExternalLinks, + project.Archive, ) if err != nil { log.Printf("saveProject: %v\n", err) @@ -61,7 +63,8 @@ func (ds *ProjectRepository) FindProjectsByUser(uid int) []models.Project { basic_auth, deleting, created, - check_external_links + check_external_links, + archive FROM projects WHERE user_id = ? ORDER BY url ASC` @@ -86,6 +89,7 @@ func (ds *ProjectRepository) FindProjectsByUser(uid int) []models.Project { &p.Deleting, &p.Created, &p.CheckExternalLinks, + &p.Archive, ) if err != nil { log.Println(err) @@ -112,7 +116,8 @@ func (ds *ProjectRepository) FindProjectById(id int, uid int) (models.Project, e basic_auth, deleting, created, - check_external_links + check_external_links, + archive FROM projects WHERE id = ? AND user_id = ?` @@ -131,6 +136,7 @@ func (ds *ProjectRepository) FindProjectById(id int, uid int) (models.Project, e &p.Deleting, &p.Created, &p.CheckExternalLinks, + &p.Archive, ) if err != nil { log.Println(err) @@ -169,7 +175,8 @@ func (ds *ProjectRepository) UpdateProject(p *models.Project) error { crawl_sitemap = ?, allow_subdomains = ?, basic_auth = ?, - check_external_links = ? + check_external_links = ?, + archive = ? WHERE id = ? ` _, err := ds.DB.Exec( @@ -181,6 +188,7 @@ func (ds *ProjectRepository) UpdateProject(p *models.Project) error { p.AllowSubdomains, p.BasicAuth, p.CheckExternalLinks, + p.Archive, p.Id, ) diff --git a/internal/routes/app.go b/internal/routes/app.go index 6da03b6d..e0f06173 100644 --- a/internal/routes/app.go +++ b/internal/routes/app.go @@ -48,6 +48,7 @@ func NewServer(container *services.Container) { http.HandleFunc("GET /export/csv", container.CookieSession.Auth(exportHandler.csvHandler)) http.HandleFunc("GET /export/sitemap", container.CookieSession.Auth(exportHandler.sitemapHandler)) http.HandleFunc("GET /export/resources", container.CookieSession.Auth(exportHandler.resourcesHandler)) + http.HandleFunc("GET /export/wazc", container.CookieSession.Auth(exportHandler.waczHandler)) // Issues routes issueHandler := issueHandler{container} diff --git a/internal/routes/export.go b/internal/routes/export.go index adcc500d..9149640b 100644 --- a/internal/routes/export.go +++ b/internal/routes/export.go @@ -3,7 +3,9 @@ package routes import ( "fmt" "io" + "log" "net/http" + "os" "strconv" "time" @@ -43,10 +45,17 @@ func (h *exportHandler) indexHandler(w http.ResponseWriter, r *http.Request) { return } + archiveExists := h.Container.ProjectService.ArchiveExists(&pv.Project) h.Renderer.RenderTemplate(w, "export", &PageView{ - Data: struct{ Project models.Project }{Project: pv.Project}, User: *user, PageTitle: "EXPORT_VIEW", + Data: struct { + Project models.Project + ArchiveExists bool + }{ + Project: pv.Project, + ArchiveExists: archiveExists, + }, }) } @@ -169,3 +178,70 @@ func (h *exportHandler) resourcesHandler(w http.ResponseWriter, r *http.Request) w.Header().Add("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s.csv\"", fileName)) e(w, &pv.Crawl) } + +// waczHandler exports the WACZ archive of a specific project. +// It expects a "pid" query parameter with the project's id. It checks if +// the file exists before passing it to the response. +func (h *exportHandler) waczHandler(w http.ResponseWriter, r *http.Request) { + user, ok := h.CookieSession.GetUser(r.Context()) + if !ok { + http.Redirect(w, r, "/signout", http.StatusSeeOther) + return + } + + pid, err := strconv.Atoi(r.URL.Query().Get("pid")) + if err != nil { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + + p, err := h.ProjectService.FindProject(pid, user.Id) + if err != nil { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + + archiveFilePath, err := h.Container.ProjectService.GetArchiveFilePath(&p) + if err != nil { + http.Redirect(w, r, "/", http.StatusSeeOther) + return + } + + file, err := os.Open(archiveFilePath) + if err != nil { + http.Error(w, "File not found", http.StatusNotFound) + return + } + defer file.Close() + + info, err := file.Stat() + if err != nil { + http.Error(w, "File not found", http.StatusNotFound) + return + } + + size := info.Size() + + w.Header().Set("Content-Type", "application/wacz") + w.Header().Add("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s.wacz\"", p.Host)) + w.Header().Set("Content-Length", fmt.Sprintf("%d", size)) + + buf := make([]byte, 4096) + for { + n, err := file.Read(buf) + if n > 0 { + if _, writeErr := w.Write(buf[:n]); writeErr != nil { + log.Printf("Failed to write data: %v", writeErr) + break + } + + w.(http.Flusher).Flush() + } + if err != nil { + if err != io.EOF { + log.Printf("Error reading file: %v", err) + } + break + } + } +} diff --git a/internal/routes/project.go b/internal/routes/project.go index 0499af9b..89e1179e 100644 --- a/internal/routes/project.go +++ b/internal/routes/project.go @@ -109,6 +109,11 @@ func (h *projectHandler) addPostHandler(w http.ResponseWriter, r *http.Request) basicAuth = false } + archive, err := strconv.ParseBool(r.FormValue("archive")) + if err != nil { + archive = false + } + project := &models.Project{ URL: r.FormValue("url"), IgnoreRobotsTxt: ignoreRobotsTxt, @@ -118,6 +123,7 @@ func (h *projectHandler) addPostHandler(w http.ResponseWriter, r *http.Request) AllowSubdomains: allowSubdomains, BasicAuth: basicAuth, CheckExternalLinks: checkExternalLinks, + Archive: archive, } err = h.ProjectService.SaveProject(project, user.Id) @@ -263,6 +269,11 @@ func (h *projectHandler) editPostHandler(w http.ResponseWriter, r *http.Request) p.BasicAuth = false } + p.Archive, err = strconv.ParseBool(r.FormValue("archive")) + if err != nil { + p.Archive = false + } + err = h.ProjectService.UpdateProject(&p) if err != nil { pageView := &PageView{ diff --git a/internal/services/archiver.go b/internal/services/archiver.go new file mode 100644 index 00000000..c689a102 --- /dev/null +++ b/internal/services/archiver.go @@ -0,0 +1,282 @@ +package services + +import ( + "archive/zip" + "bytes" + "compress/gzip" + "crypto/md5" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "log" + "net/http" + "net/url" + "os" + "strings" + "time" + + "github.com/google/uuid" + "github.com/slyrz/warc" +) + +const ArchiveDir = "archive/" + +type Archiver struct { + zipWriter *zip.Writer + file *os.File + cdxjEntries []CDXJEntry + pagesEntries []PageEntry +} + +type CDXJEntry struct { + TargetURI string + Timestamp string + RecordID string + Offset string + Status string + Length string + Mime string + Filename string + Digest string +} + +type PageEntry struct { + URL string + TS string +} + +// Returns a new Archiver. +// It creates a new wacz file for the given url string. +func NewArchiver(urlStr string) (*Archiver, error) { + file, err := os.Create(ArchiveDir + urlStr + ".wacz") + if err != nil { + return nil, err + } + + return &Archiver{ + zipWriter: zip.NewWriter(file), + file: file, + }, nil +} + +// AddRecord adds a new response record to the warc file and keeps track +// of the added records to create the index once the archiver is closed. +func (s *Archiver) AddRecord(response *http.Response) { + uuidStr := uuid.New().String() + record := warc.NewRecord() + record.Header.Set("warc-type", "response") + record.Header.Set("warc-date", time.Now().Format(time.RFC3339)) + record.Header.Set("warc-target-uri", response.Request.URL.String()) + record.Header.Set("content-type", response.Header.Get("Content-Type")) + record.Header.Set("warc-record-id", fmt.Sprintf("", uuidStr)) + + var contentBuffer bytes.Buffer + contentBuffer.WriteString(fmt.Sprintf("HTTP/%d.%d %d %s\r\n", + response.ProtoMajor, response.ProtoMinor, response.StatusCode, response.Status)) + + for key, values := range response.Header { + for _, value := range values { + contentBuffer.WriteString(fmt.Sprintf("%s: %s\r\n", key, value)) + } + } + contentBuffer.WriteString("\r\n") + + var bodyCopy bytes.Buffer + _, err := io.Copy(&bodyCopy, response.Body) + if err != nil { + log.Printf("Failed to copy response body: %v", err) + return + } + response.Body.Close() // Close the original body + response.Body = io.NopCloser(bytes.NewReader(bodyCopy.Bytes())) + + if _, err := io.Copy(&contentBuffer, &bodyCopy); err != nil { + fmt.Println("Error reading response body:", err) + return + } + + record.Content = bytes.NewReader(contentBuffer.Bytes()) + + filePath := ArchiveDir + fmt.Sprintf("data-%s.warc.gz", uuidStr) + archiveFile, err := s.zipWriter.Create(filePath) + if err != nil { + log.Printf("Failed to create WARC file entry in ZIP: %v", err) + return + } + archiveZipWritter := gzip.NewWriter(archiveFile) + archiveWriter := warc.NewWriter(archiveZipWritter) + + if _, err := archiveWriter.WriteRecord(record); err != nil { + log.Printf("Failed to write WARC record to archive: %v", err) + return + } + + archiveZipWritter.Close() + + cdxjEntry := CDXJEntry{ + TargetURI: response.Request.URL.String(), + Timestamp: time.Now().Format("20060102150405"), + RecordID: fmt.Sprintf("", uuidStr), + Status: fmt.Sprintf("%d", response.StatusCode), + Length: fmt.Sprintf("%d", len(contentBuffer.Bytes())), + Mime: response.Header.Get("Content-Type"), + Filename: filePath, + Digest: fmt.Sprintf("%x", md5.Sum(contentBuffer.Bytes())), + Offset: fmt.Sprintf("%d", 0), + } + + s.cdxjEntries = append(s.cdxjEntries, cdxjEntry) + + pageEntry := PageEntry{ + URL: response.Request.URL.String(), + TS: time.Now().Format(time.RFC3339), + } + + s.pagesEntries = append(s.pagesEntries, pageEntry) +} + +// Close closes the archive and creates the remaining files. +func (s *Archiver) Close() { + pagesFile, err := s.zipWriter.Create("pages/pages.jsonl") + if err != nil { + log.Printf("Failed to create pages file entry in ZIP: %v", err) + return + } + pagesWriter := gzip.NewWriter(pagesFile) + + header := `{"format": "json-pages-1.0", "id": "pages", "title": "All Pages"}` + header += "\n" + pagesWriter.Write([]byte(header)) + + for _, page := range s.pagesEntries { + pageLine := fmt.Sprintf(`{"url":"%s","ts":"%s"}`, page.URL, page.TS) + pageLine += "\n" + pagesWriter.Write([]byte(pageLine)) + } + pagesWriter.Close() + + indexFile, err := s.zipWriter.Create("indexes/index.cdx.gz") + if err != nil { + log.Printf("Failed to create WARC file entry in ZIP: %v", err) + return + } + indexWriter := gzip.NewWriter(indexFile) + + for _, entry := range s.cdxjEntries { + parsedURL, err := url.Parse(entry.TargetURI) + if err != nil { + log.Printf("Failed to parse URL: %v", err) + continue + } + domainParts := strings.Split(parsedURL.Hostname(), ".") + for i := len(domainParts) - 1; i >= 0; i-- { + indexWriter.Write([]byte(domainParts[i])) + if i > 0 { + indexWriter.Write([]byte(",")) + } + } + indexWriter.Write([]byte(")/")) + + cdxjLine := fmt.Sprintf( + "%s %s\n", + entry.Timestamp, + fmt.Sprintf(`{"offset":"%s","status":"%s","length":"%s","mime":"%s","filename":"%s","url":"%s","digest":"%s"}`, + entry.Offset, entry.Status, entry.Length, entry.Mime, entry.Filename, entry.TargetURI, entry.Digest, + ), + ) + indexWriter.Write([]byte(cdxjLine)) + } + indexWriter.Close() + + s.zipWriter.Close() + s.file.Close() + + err = s.createDatapackageJSON() + if err != nil { + log.Printf("Failed to create datapackage.json: %v", err) + return + } +} + +// Create the datapackage.json file +func (s *Archiver) createDatapackageJSON() error { + archive, err := zip.OpenReader(s.file.Name()) + if err != nil { + log.Printf("Failed to open ZIP archive for reading: %v", err) + return err + } + defer archive.Close() + + calculateSHA256AndSize := func(file *zip.File) (string, int64, error) { + rc, err := file.Open() + if err != nil { + log.Printf("Failed to open file in ZIP: %v", err) + return "", 0, err + } + defer rc.Close() + + hash := sha256.New() + _, err = io.Copy(hash, rc) + if err != nil { + log.Printf("Failed to calculate SHA256: %v", err) + return "", 0, err + } + + fileSize := file.FileInfo().Size() + + return "sha256:" + hex.EncodeToString(hash.Sum(nil)), fileSize, nil + } + + var resources []map[string]interface{} + + for _, file := range archive.File { + hash, size, err := calculateSHA256AndSize(file) + if err != nil { + return err + } + + resources = append(resources, map[string]interface{}{ + "name": file.Name, + "path": file.Name, + "hash": hash, + "bytes": size, + }) + } + + datapackage := map[string]interface{}{ + "profile": "data-package", + "wacz_version": "1.1.1", + "resources": resources, + } + + f, err := os.OpenFile(s.file.Name(), os.O_RDWR|os.O_CREATE, 0644) + if err != nil { + log.Println(err) + return err + } + defer f.Close() + + zipWriter := zip.NewWriter(f) + defer zipWriter.Close() + + for _, file := range archive.File { + zipWriter.Copy(file) + } + + datapackageFile, err := zipWriter.Create("datapackage.json") + if err != nil { + log.Printf("Failed to create datapackage.json in ZIP: %v", err) + return err + } + + encoder := json.NewEncoder(datapackageFile) + err = encoder.Encode(datapackage) + if err != nil { + log.Printf("Failed to write datapackage.json: %v", err) + return err + } + + return nil +} diff --git a/internal/services/crawler.go b/internal/services/crawler.go index fc70b155..ccc3706a 100644 --- a/internal/services/crawler.go +++ b/internal/services/crawler.go @@ -85,9 +85,24 @@ func (s *CrawlerService) StartCrawler(p models.Project, b models.BasicAuth) erro return err } - c.OnResponse(s.crawlerHandler.responseCallback(crawl, &p, c)) - go func() { + defer s.repository.DeleteCrawlData(&previousCrawl) + + var archiver *Archiver + if p.Archive { + archiver, err = NewArchiver(p.Host) + if err != nil { + log.Printf("Failed to create archive: %v", err) + } + } + + if archiver != nil { + defer archiver.Close() + c.OnResponse(s.crawlerHandler.archiveCallback(crawl, &p, c, archiver)) + } else { + c.OnResponse(s.crawlerHandler.responseCallback(crawl, &p, c)) + } + log.Printf("Crawling %s...", p.URL) c.AddRequest(&crawler.RequestMessage{URL: u, Data: crawlerData{}}) @@ -114,7 +129,6 @@ func (s *CrawlerService) StartCrawler(p models.Project, b models.BasicAuth) erro log.Printf("Crawled %d urls in %s", crawl.TotalURLs, p.URL) s.removeCrawler(&p) - s.repository.DeleteCrawlData(&previousCrawl) }() return nil diff --git a/internal/services/crawler_handler.go b/internal/services/crawler_handler.go index 04b8dd64..8ed8750d 100644 --- a/internal/services/crawler_handler.go +++ b/internal/services/crawler_handler.go @@ -38,6 +38,16 @@ func NewCrawlerHandler(r CrawlerHandlerRepository, b *Broker, m *ReportManager) } } +func (s *CrawlerHandler) archiveCallback(crawl *models.Crawl, p *models.Project, c *crawler.Crawler, a *Archiver) crawler.ResponseCallback { + responseCallback := s.responseCallback(crawl, p, c) + return func(r *crawler.ResponseMessage) { + if r.Error == nil && a != nil { + a.AddRecord(r.Response) + } + responseCallback(r) + } +} + func (s *CrawlerHandler) responseCallback(crawl *models.Crawl, p *models.Project, c *crawler.Crawler) crawler.ResponseCallback { return func(r *crawler.ResponseMessage) { pageReport, htmlNode, err := s.buildPageReport(r) diff --git a/internal/services/project.go b/internal/services/project.go index 8fe1ec20..d8bad4b5 100644 --- a/internal/services/project.go +++ b/internal/services/project.go @@ -3,6 +3,7 @@ package services import ( "errors" "net/url" + "os" "strings" "github.com/stjudewashere/seonaut/internal/models" @@ -72,11 +73,16 @@ func (s *ProjectService) DeleteProject(p *models.Project) { go func() { s.repository.DeleteProjectCrawls(p) s.repository.DeleteProject(p) + s.DeleteArchive(p) }() } // Update project details. func (s *ProjectService) UpdateProject(p *models.Project) error { + if !p.Archive { + s.DeleteArchive(p) + } + return s.repository.UpdateProject(p) } @@ -86,5 +92,33 @@ func (s *ProjectService) DeleteAllUserProjects(user *models.User) { for _, p := range projects { s.repository.DeleteProjectCrawls(&p) s.repository.DeleteProject(&p) + s.DeleteArchive(&p) + } +} + +// ArchiveExists checks if a wacz file exists for the current project. +// It returns true if it exists, otherwise it returns false. +func (s *ProjectService) ArchiveExists(p *models.Project) bool { + _, err := os.Stat(ArchiveDir + p.Host + ".wacz") + return err == nil +} + +// DeleteArchive removes the wacz archive file for a given project. +// It checks if the file exists before removing it. +func (s *ProjectService) DeleteArchive(p *models.Project) { + if !s.ArchiveExists(p) { + return + } + + os.Remove(ArchiveDir + p.Host + ".wacz") +} + +// GetArchiveFilePath returns the project's wacz file path if it exists, +// otherwise it returns an error. +func (s *ProjectService) GetArchiveFilePath(p *models.Project) (string, error) { + if !s.ArchiveExists(p) { + return "", errors.New("WACZ archive file does not exist") } + + return ArchiveDir + p.Host + ".wacz", nil } diff --git a/migrations/0067_archive.down.sql b/migrations/0067_archive.down.sql new file mode 100644 index 00000000..e087d5e8 --- /dev/null +++ b/migrations/0067_archive.down.sql @@ -0,0 +1 @@ +ALTER TABLE `projects` DROP COLUMN `archive`; \ No newline at end of file diff --git a/migrations/0067_archive.up.sql b/migrations/0067_archive.up.sql new file mode 100644 index 00000000..44f3c28e --- /dev/null +++ b/migrations/0067_archive.up.sql @@ -0,0 +1 @@ +ALTER TABLE `projects` ADD COLUMN `archive` tinyint NOT NULL DEFAULT '0'; \ No newline at end of file diff --git a/web/templates/export.html b/web/templates/export.html index a98a78d6..5813fda9 100644 --- a/web/templates/export.html +++ b/web/templates/export.html @@ -163,6 +163,20 @@

Export Hreflangs

+ {{ if .ArchiveExists }} +
+
+
+

Export WACZ Archive

+

Export your project's crawled data as a WACZ file for archival or replaying.

+
+
+ +
+ Download +
+
+ {{ end }} {{ end}} diff --git a/web/templates/project_add.html b/web/templates/project_add.html index 66d9b9ac..8ff2620d 100644 --- a/web/templates/project_add.html +++ b/web/templates/project_add.html @@ -140,6 +140,23 @@

Add Project

+
+
+
+
+ + Create WACZ archive +
+ + If checked a WACZ archive will be created and available as an export option. + +
+
+
+
@@ -153,21 +170,21 @@

Add Project

Check this option if your site is password protected with HTTP Basic Auth. -
+ -
-
-
+
+
+
- or cancel. + or cancel. -
+
- +
{{ template "footer" . }} \ No newline at end of file diff --git a/web/templates/project_edit.html b/web/templates/project_edit.html index 70a5766d..1c998085 100644 --- a/web/templates/project_edit.html +++ b/web/templates/project_edit.html @@ -147,6 +147,25 @@

Edit Project

+
+
+
+ +
+ + Create WACZ archive +
+ + If checked a WACZ archive will be created and available as an export option. + + +
+
+
+