8 Commits
v0.6 ... v0.7

Author SHA1 Message Date
Alex Schroeder
92a52d2c97 Make some fields in the templates required 2023-09-14 16:21:14 +02:00
Alex Schroeder
7f0b371570 Remove UMask from service file 2023-09-14 16:20:54 +02:00
Alex Schroeder
a44d903775 Document limitations regarding uploads 2023-09-14 16:20:41 +02:00
Alex Schroeder
1ca8e6f3aa Removed useless div 2023-09-14 16:02:32 +02:00
Alex Schroeder
93197f94bf Add upload form and documentation 2023-09-14 16:02:27 +02:00
Alex Schroeder
a7861edbad Renamed uploadHandler to saveUploadHandler
It's annoying that we keep needing two URLs for everything. One to
server the UI and one to actually do it: edit/save (with page name in
the URL), add/append (that's a sketchy pair), upload/save (save again,
but this time without a trailing slash, ugh).
2023-09-14 15:43:12 +02:00
Alex Schroeder
d4090ab146 Add html command for the command-line 2023-09-14 15:36:46 +02:00
Alex Schroeder
4da0ba8d94 Allow file uploads 2023-09-14 15:36:24 +02:00
8 changed files with 180 additions and 29 deletions

View File

@@ -87,9 +87,10 @@ Text
## Templates
The template files are the HTML files in the working directory:
`add.html`, `edit.html`, `search.html` and `view.html`. Feel free to
change the templates and restart the server. The first change you
should make is to replace the email address in `view.html`. 😄
`add.html`, `edit.html`, `search.html`, `upload.html` and `view.html`.
Feel free to change the templates and restart the server. The first
change you should make is to replace the email address in `view.html`.
😄
See [Structuring the web
with HTML](https://developer.mozilla.org/en-US/docs/Learn/HTML) to
@@ -128,6 +129,8 @@ summary, as HTML.
`{{.Score}}` is a numerical score for search results.
The `upload.html` template cannot refer to anything.
When calling the `save` action, the page name is take from the URL and
the page content is taken from the `body` form parameter. To
illustrate, here's how to edit a page using `curl`:
@@ -193,7 +196,7 @@ adduser --system --home /home/oddmu oddmu
```
Copy all the files into `/home/oddmu` to your server: `oddmu`,
`oddmu.service`, `view.html` and `edit.html`.
`oddmu.service`, and all the template files ending in `.html`.
Edit the `oddmu.service` file. These are the three lines you most
likely have to take care of:
@@ -250,7 +253,7 @@ MDCertificateAgreement accepted
ServerAdmin alex@alexschroeder.ch
ServerName transjovian.org
SSLEngine on
ProxyPassMatch ^/(search|(view|edit|save|add|append)/(.*))?$ http://localhost:8080/$1
ProxyPassMatch ^/(search|upload|save|(view|edit|save|add|append)/(.*))?$ http://localhost:8080/$1
</VirtualHost>
```
@@ -308,11 +311,11 @@ htpasswd -D .htpasswd berta
```
Modify your site configuration and protect the `/edit/`, `/save/`,
`/add/` and `/append/` URLs with a password by adding the following to
your `<VirtualHost *:443>` section:
`/add/`, `/append/`, `/upload` and `/save` URLs with a password by
adding the following to your `<VirtualHost *:443>` section:
```apache
<LocationMatch "^/(edit|save|add|append)/">
<LocationMatch "^/(upload|save|(edit|save|add|append)/(.*))$">
AuthType Basic
AuthName "Password Required"
AuthUserFile /home/oddmu/.htpasswd
@@ -337,9 +340,9 @@ webserver can read (world readable file, world readable and executable
directory). Populate it with files.
Make sure that none of the static files look like the wiki paths
`/view/`, `/edit/`, `/save/`, `/add/`, `/append/` or `/search/`. For
example, create a file called `robots.txt` containing the following,
tellin all robots that they're not welcome.
`/view/`, `/edit/`, `/save/`, `/add/`, `/append/`, `/upload`, `/save`
or `/search`. For example, create a file called `robots.txt`
containing the following, tellin all robots that they're not welcome.
```text
User-agent: *
@@ -363,7 +366,7 @@ above.
This requires a valid login by the user "alex" or "berta":
```apache
<LocationMatch "^/(edit|save)/intetebi/">
<LocationMatch "^/(edit|save|add|append)/intetebi/">
Require user alex berta
</LocationMatch>
```
@@ -439,6 +442,12 @@ memory.
Files may not end with a tilde (`~`) these are backup files.
You cannot edit uploaded files. If you upload a file called
`hello.txt` and attempt to edit it by using `/edit/hello.txt` you will
create a page with the name `hello.txt.md` instead.
You cannot delete uploaded files via the web.
## Bugs
If you spot any, [contact](https://alexschroeder.ch/wiki/Contact) me.

View File

@@ -1,4 +1,5 @@
Upload files.
Upload files should use path info so that we can use Apache to
restrict access to directories.
Automatically scale or process files.

View File

@@ -13,7 +13,7 @@ form, textarea { width: 100%; }
<body>
<h1>Adding to {{.Title}}</h1>
<form action="/append/{{.Name}}" method="POST">
<div><textarea name="body" rows="20" cols="80" placeholder="Text" autofocus></textarea></div>
<textarea name="body" rows="20" cols="80" placeholder="Text" autofocus required></textarea>
<p><input type="submit" value="Add">
<a href="/view/{{.Name}}"><button>Cancel</button></a></p>
</form>

25
commands.go Normal file
View File

@@ -0,0 +1,25 @@
package main
import (
"fmt"
"os"
)
func commands() {
if len(os.Args) == 3 && os.Args[1] == "html" {
p, err := loadPage(os.Args[2]);
if err != nil {
fmt.Println(err);
} else {
p.renderHtml();
fmt.Println(p.Html);
}
} else {
fmt.Printf("Unknown command: %v\n", os.Args[1:])
fmt.Print("Without any arguments, serves a wiki.\n")
fmt.Print(" Environment variable ODDMUSE_PORT controls the port.\n")
fmt.Print(" Environment variable ODDMUSE_LANGAUGES controls the languages detected.\n")
fmt.Print("html PAGENAME\n")
fmt.Print(" Print the HTML of the page.\n")
}
}

View File

@@ -17,7 +17,6 @@ Environment="ODDMU_PORT=8080"
ReadWritePaths=/home/oddmu
ProtectHostname=yes
RestrictSUIDSGID=yes
UMask=0077
RemoveIPC=yes
MemoryDenyWriteExecute=yes

22
upload.html Normal file
View File

@@ -0,0 +1,22 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="format-detection" content="telephone=no">
<meta name="viewport" content="width=device-width">
<title>Upload File</title>
<style>
html { max-width: 70ch; padding: 2ch; margin: auto; color: #111; background: #ffe; }
form, textarea { width: 100%; }
</style>
</head>
<body>
<h1>Upload File</h1>
<form action="/save" method="POST" enctype="multipart/form-data">
<input type="text" name="name" placeholder="image.jpg" autofocus required>
<p><input type="file" name="file" required>
<p><input type="submit" value="Save">
<a href="/view/index"><button type="button">Cancel</button></a></p>
</form>
</body>
</html>

62
wiki.go
View File

@@ -3,14 +3,17 @@ package main
import (
"fmt"
"html/template"
"io"
"net/http"
"os"
"path/filepath"
"regexp"
)
// Templates are parsed at startup.
var templates = template.Must(
template.ParseFiles("edit.html", "add.html", "view.html", "search.html"))
template.ParseFiles("edit.html", "add.html", "view.html",
"search.html", "upload.html"))
// validPath is a regular expression where the second group matches a
// page, so when the editHandler is called, a URL path of "/edit/foo"
@@ -119,6 +122,51 @@ func appendHandler(w http.ResponseWriter, r *http.Request, name string) {
http.Redirect(w, r, "/view/"+name, http.StatusFound)
}
// uploadHandler uses the "upload.html" template to enable uploads.
// The file is saved using the saveUploadHandler.
func uploadHandler(w http.ResponseWriter, r *http.Request) {
renderTemplate(w, "upload", nil)
}
// saveUploadHandler takes the "name" form field and the "file" form
// file and saves the file under the given name. The browser is
// redirected to the view of that file.
func saveUploadHandler(w http.ResponseWriter, r *http.Request) {
filename := r.FormValue("name")
file, _, err := r.FormFile("file")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer file.Close()
// backup an existing file with the same name
_, err = os.Stat(filename)
if err != nil {
os.Rename(filename, filename + "~")
}
// create the directory, if necessary
d := filepath.Dir(filename)
if d != "." {
err := os.MkdirAll(d, 0755)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
}
// create the new file
dst, err := os.Create(filename)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer dst.Close()
if _, err := io.Copy(dst, file); err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
http.Redirect(w, r, "/view/"+filename, http.StatusFound)
}
// makeHandler returns a handler that uses the URL path without the
// first path element as its argument, e.g. if the URL path is
// /edit/foo/bar, the editHandler is called with "foo/bar" as its
@@ -177,13 +225,15 @@ func scheduleLoadLanguages() {
fmt.Printf("Loaded %d languages\n", n)
}
func main() {
func serve() {
http.HandleFunc("/", rootHandler)
http.HandleFunc("/view/", makeHandler(viewHandler))
http.HandleFunc("/edit/", makeHandler(editHandler))
http.HandleFunc("/save/", makeHandler(saveHandler))
http.HandleFunc("/add/", makeHandler(addHandler))
http.HandleFunc("/append/", makeHandler(appendHandler))
http.HandleFunc("/upload", uploadHandler)
http.HandleFunc("/save", saveUploadHandler)
http.HandleFunc("/search", searchHandler)
go scheduleLoadIndex()
go scheduleLoadLanguages()
@@ -191,3 +241,11 @@ func main() {
fmt.Printf("Serving a wiki on port %s\n", port)
http.ListenAndServe(":"+port, nil)
}
func main() {
if len(os.Args) == 1 {
serve()
} else {
commands()
}
}

View File

@@ -1,8 +1,9 @@
package main
import (
"fmt"
"bytes"
"github.com/stretchr/testify/assert"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
@@ -27,8 +28,7 @@ func HTTPHeaders(handler http.HandlerFunc, method, url string, values url.Values
// HTTPRedirectTo checks that the request results in a redirect and it
// checks the destination of the redirect. It returns whether the
// request did in fact result in a redirect. Note: This method assumes
// that POST requests ignore the query part of the URL which is often
// true but not mandated by the standards.
// that POST requests ignore the query part of the URL.
func HTTPRedirectTo(t *testing.T, handler http.HandlerFunc, method, url string, values url.Values, destination string) bool {
w := httptest.NewRecorder()
var req *http.Request
@@ -40,19 +40,32 @@ func HTTPRedirectTo(t *testing.T, handler http.HandlerFunc, method, url string,
} else {
req, err = http.NewRequest(method, url+"?"+values.Encode(), nil)
}
if err != nil {
assert.Fail(t, fmt.Sprintf("Failed to build test request, got error: %s", err))
}
assert.NoError(t, err)
handler(w, req)
code := w.Code
isRedirectCode := code >= http.StatusMultipleChoices && code <= http.StatusTemporaryRedirect
if !isRedirectCode {
assert.Fail(t, fmt.Sprintf("Expected HTTP redirect status code for %q but received %d", url+"?"+values.Encode(), code))
}
assert.True(t, isRedirectCode, "Expected HTTP redirect status code for %q but received %d", url+"?"+values.Encode(), code)
headers := w.Result().Header["Location"]
if len(headers) != 1 || headers[0] != destination {
assert.Fail(t, fmt.Sprintf("Expected HTTP redirect location %s for %q but received %v", destination, url+"?"+values.Encode(), headers))
}
assert.True(t, len(headers) == 1 && headers[0] == destination,
"Expected HTTP redirect location %s for %q but received %v", destination, url+"?"+values.Encode(), headers)
return isRedirectCode
}
// HTTPUploadAndRedirectTo checks that the request results in a redirect and it
// checks the destination of the redirect. It returns whether the
// request did in fact result in a redirect.
func HTTPUploadAndRedirectTo(t *testing.T, handler http.HandlerFunc, url, contentType string, body *bytes.Buffer, destination string) bool {
w := httptest.NewRecorder()
req, err := http.NewRequest("POST", url, body)
req.Header.Set("Content-Type", contentType)
assert.NoError(t, err)
handler(w, req)
code := w.Code
isRedirectCode := code >= http.StatusMultipleChoices && code <= http.StatusTemporaryRedirect
assert.True(t, isRedirectCode, "Expected HTTP redirect status code for %q but received %d", url, code)
headers := w.Result().Header["Location"]
assert.True(t, len(headers) == 1 && headers[0] == destination,
"Expected HTTP redirect location %s for %q but received %v", destination, url, headers)
return isRedirectCode
}
@@ -110,6 +123,30 @@ It's not `)}
})
}
// wipes testdata
func TestUpload(t *testing.T) {
_ = os.RemoveAll("testdata")
assert.HTTPStatusCode(t, uploadHandler, "GET", "/upload", nil, 200)
form := new(bytes.Buffer)
writer := multipart.NewWriter(form)
field, err := writer.CreateFormField("name")
assert.NoError(t, err)
_, err = field.Write([]byte("testdata/ok.txt"))
assert.NoError(t, err)
file, err := writer.CreateFormFile("file", "example.txt");
assert.NoError(t, err)
file.Write([]byte("Hello!"))
err = writer.Close()
assert.NoError(t, err)
t.Log(writer.FormDataContentType())
HTTPUploadAndRedirectTo(t, saveUploadHandler, "/upload", writer.FormDataContentType(), form, "/view/testdata/ok.txt")
assert.Regexp(t, regexp.MustCompile("Hello!"),
assert.HTTPBody(makeHandler(viewHandler), "GET", "/view/testdata/ok.txt", nil))
t.Cleanup(func() {
_ = os.RemoveAll("testdata")
})
}
// wipes testdata
func TestPageTitleWithAmp(t *testing.T) {
_ = os.RemoveAll("testdata")