internal/httpd/snippets.go
249 lines · 7890 bytes
1package httpd
2
3import (
4 "bytes"
5 "html/template"
6 "net/http"
7 "strings"
8
9 "gitbay.org/gitbay/internal/control"
10 "gitbay.org/gitbay/internal/policy"
11 "gitbay.org/gitbay/internal/protocol"
12 "gitbay.org/gitbay/internal/store"
13)
14
15// snippetScope resolves the owner and id in the URL for the viewer. A
16// missing owner, an id under another owner, and a private snippet the
17// viewer may not read are all the same 404.
18func (s *Server) snippetScope(w http.ResponseWriter, r *http.Request) (store.Snippet, store.User, bool) {
19 viewer := s.viewer(r)
20 sn, err := s.st.SnippetByPublicID(r.PathValue("id"))
21 if err != nil || sn.OwnerName != r.PathValue("owner") || !policy.CanReadSnippet(viewer, sn) {
22 s.notFound(w, r)
23 return sn, viewer, false
24 }
25 return sn, viewer, true
26}
27
28type snippetRow struct {
29 store.Snippet
30 Names string
31}
32
33// ownerSnippets lists an owner's snippets for the profile's Snippets
34// tab. The list is a section of the profile like the repositories are,
35// not a page of its own (a snippet itself still is). A private snippet
36// is in the list only for its owner and the admins.
37func (s *Server) ownerSnippets(w http.ResponseWriter, r *http.Request, viewer store.User, name string) ([]snippetRow, bool) {
38 owner, err := s.st.UserByUsername(name)
39 if err != nil {
40 s.notFound(w, r)
41 return nil, false
42 }
43 all := viewer.IsAdmin || (viewer.ID != 0 && viewer.ID == owner.ID)
44 list, err := s.st.ListSnippets(owner.ID, all, 0, 0)
45 if err != nil {
46 http.Error(w, "internal error", http.StatusInternalServerError)
47 return nil, false
48 }
49 rows := make([]snippetRow, 0, len(list))
50 for _, sn := range list {
51 var names bytes.Buffer
52 for i, f := range sn.Files {
53 if i > 0 {
54 names.WriteString(", ")
55 }
56 names.WriteString(f.Name)
57 }
58 rows = append(rows, snippetRow{sn, names.String()})
59 }
60 return rows, true
61}
62
63type snippetFileView struct {
64 Name string
65 Size int64
66 Lines int
67 Content string
68 HTML template.HTML
69 TooLarge bool
70}
71
72// snippetPage highlights files up to a shared budget across the page: a
73// snippet with many or large files does not make one request highlight
74// megabytes of markup. Content is filled only for the owner, whose edit
75// textarea needs the raw text regardless of the budget.
76func (s *Server) snippetPage(w http.ResponseWriter, r *http.Request) {
77 sn, viewer, ok := s.snippetScope(w, r)
78 if !ok {
79 return
80 }
81 files, err := s.st.SnippetFiles(sn.ID)
82 if err != nil {
83 http.Error(w, "internal error", http.StatusInternalServerError)
84 return
85 }
86 canWrite := policy.CanWriteSnippet(viewer, sn)
87 budget := int64(maxRenderBytes)
88 views := make([]snippetFileView, 0, len(files))
89 for _, f := range files {
90 lines := bytes.Count(f.Content, []byte("\n"))
91 if len(f.Content) > 0 && f.Content[len(f.Content)-1] != '\n' {
92 lines++
93 }
94 view := snippetFileView{Name: f.Name, Size: f.Size, Lines: lines}
95 if canWrite {
96 view.Content = string(f.Content)
97 }
98 if f.Size <= budget {
99 view.HTML = highlightPlain(f.Name, f.Content)
100 budget -= f.Size
101 } else {
102 view.TooLarge = true
103 }
104 views = append(views, view)
105 }
106 s.render(w, "snippet.html", struct {
107 basePage
108 Owner string
109 Snippet store.Snippet
110 Files []snippetFileView
111 CanWrite bool
112 Notice string
113 }{s.baseFor(viewer), sn.OwnerName, sn, views, canWrite, s.takeFlash(w, r)})
114}
115
116// snippetRaw serves one file as text, inert on the forge's origin.
117func (s *Server) snippetRaw(w http.ResponseWriter, r *http.Request) {
118 sn, _, ok := s.snippetScope(w, r)
119 if !ok {
120 return
121 }
122 f, err := s.st.SnippetFile(sn.ID, r.PathValue("name"))
123 if err != nil {
124 s.notFound(w, r)
125 return
126 }
127 w.Header().Set("Content-Type", "text/plain; charset=utf-8")
128 w.Header().Set("X-Content-Type-Options", "nosniff")
129 w.Write(f.Content)
130}
131
132type snippetNewPage struct {
133 basePage
134 Owner string
135 Name string
136 Description string
137 Visibility string
138 Content string
139 Error string
140}
141
142// snippetNewForm is the owner's own page only: the URL names the owner
143// and a snippet cannot be created for someone else.
144func (s *Server) snippetNewForm(w http.ResponseWriter, r *http.Request, u store.User) {
145 if r.PathValue("owner") != u.Username {
146 s.notFound(w, r)
147 return
148 }
149 s.render(w, "snippetnew.html", snippetNewPage{basePage: s.baseFor(u), Owner: u.Username})
150}
151
152// snippetNewSubmit re-renders the form with the submitted values on a
153// refusal, so a typo in the name does not throw away a pasted body.
154func (s *Server) snippetNewSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
155 if r.PathValue("owner") != u.Username {
156 s.notFound(w, r)
157 return
158 }
159 name := strings.TrimSpace(r.FormValue("name"))
160 description := strings.TrimSpace(r.FormValue("description"))
161 visibility := r.FormValue("visibility")
162 content := r.FormValue("content")
163 argv := []string{"snippet", "create", name, "--description", description, "--visibility", visibility}
164 var out control.SnippetOut
165 code, msg := s.dispatchIntoStdin(u, argv, content, &out)
166 if code != protocol.ExitOK {
167 s.render(w, "snippetnew.html", snippetNewPage{
168 basePage: s.baseFor(u), Owner: u.Username,
169 Name: name, Description: description, Visibility: visibility, Content: content, Error: msg,
170 })
171 return
172 }
173 http.Redirect(w, r, "/"+u.Username+"/-/snippets/"+out.ID, http.StatusSeeOther)
174}
175
176// snippetAction runs a write on an already-resolved snippet and returns to
177// its page with the message, or to dest (the list, for a delete) on
178// success. Callers resolve the snippet with snippetScope first, so a
179// snippet the viewer may not read is the 404 page before any confirmation
180// or write is considered.
181func (s *Server) snippetAction(w http.ResponseWriter, r *http.Request, u store.User, sn store.Snippet, argv []string, stdin string, dest string) {
182 page := "/" + sn.OwnerName + "/-/snippets/" + sn.PublicID
183 if dest == "" {
184 dest = page
185 }
186 back := func(w http.ResponseWriter, r *http.Request, msg string) {
187 s.setFlash(w, msg)
188 to := dest
189 if msg != "" {
190 to = page
191 }
192 http.Redirect(w, r, to, http.StatusSeeOther)
193 }
194 msg, code := s.runControlStdinCode(u, argv, stdin)
195 if code == protocol.ExitDenied {
196 http.Error(w, msg, http.StatusForbidden)
197 return
198 }
199 s.done(w, r, code, msg, back)
200}
201
202func (s *Server) snippetEditSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
203 sn, _, ok := s.snippetScope(w, r)
204 if !ok {
205 return
206 }
207 s.snippetAction(w, r, u, sn, []string{"snippet", "edit", r.PathValue("id"),
208 "--description", strings.TrimSpace(r.FormValue("description")),
209 "--visibility", r.FormValue("visibility")}, "", "")
210}
211
212func (s *Server) snippetDeleteSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
213 sn, _, ok := s.snippetScope(w, r)
214 if !ok {
215 return
216 }
217 if ok, msg := confirmed(r, sn.PublicID); !ok {
218 s.setFlash(w, msg)
219 http.Redirect(w, r, "/"+sn.OwnerName+"/-/snippets/"+sn.PublicID, http.StatusSeeOther)
220 return
221 }
222 s.snippetAction(w, r, u, sn, []string{"snippet", "delete", sn.PublicID}, "",
223 "/"+sn.OwnerName+"/-/snippets")
224}
225
226// An empty textarea reaches the command as empty stdin, which it refuses;
227// the message lands on the page like any other.
228func (s *Server) snippetFileSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
229 sn, _, ok := s.snippetScope(w, r)
230 if !ok {
231 return
232 }
233 s.snippetAction(w, r, u, sn, []string{"snippet", "file", "set", r.PathValue("id"), strings.TrimSpace(r.FormValue("name"))},
234 r.FormValue("content"), "")
235}
236
237func (s *Server) snippetFileRemoveSubmit(w http.ResponseWriter, r *http.Request, u store.User) {
238 sn, _, ok := s.snippetScope(w, r)
239 if !ok {
240 return
241 }
242 name := strings.TrimSpace(r.FormValue("name"))
243 if ok, msg := confirmed(r, name); !ok {
244 s.setFlash(w, msg)
245 http.Redirect(w, r, "/"+sn.OwnerName+"/-/snippets/"+sn.PublicID, http.StatusSeeOther)
246 return
247 }
248 s.snippetAction(w, r, u, sn, []string{"snippet", "file", "remove", r.PathValue("id"), name}, "", "")
249}