diff options
| author | Rose Hogenson <rosehogenson@posteo.net> | 2025-09-28 09:48:40 -0700 |
|---|---|---|
| committer | Rose Hogenson <rosehogenson@posteo.net> | 2025-09-28 09:48:40 -0700 |
| commit | 04821a6e7752d6852bc1d283a88f05f7abecae87 (patch) | |
| tree | 667266ce89224443831cb21d4d9e94cd6ce48c3d /roseh.moe.go | |
| parent | Turn up all the parameters (diff) | |
| download | roseh.moe-04821a6e7752d6852bc1d283a88f05f7abecae87.tar.zst | |
Add a gob API
Diffstat (limited to 'roseh.moe.go')
| -rw-r--r-- | roseh.moe.go | 312 |
1 files changed, 278 insertions, 34 deletions
diff --git a/roseh.moe.go b/roseh.moe.go index a7279cd..e6db419 100644 --- a/roseh.moe.go +++ b/roseh.moe.go @@ -6,6 +6,8 @@ import ( "crypto/subtle" "embed" "encoding/base64" + "encoding/binary" + "encoding/gob" "encoding/hex" "errors" "flag" @@ -15,18 +17,18 @@ import ( "log" "net/http" "os" - "path/filepath" "strings" "sync" "time" + "gitlab.com/rhogenson/roseh.moe/internal/api" "gitlab.com/rhogenson/roseh.moe/internal/cryptoutil" ) var ( port = flag.Int("port", 42069, "port to listen on") secretsFile = flag.String("secrets", "secrets", "path to the secrets file") - notepadFile = flag.String("notepad", "notepad", "path to save user notes") + notepadDir = flag.String("notepad", "notepad", "directory to save user notes") serverStartTime = time.Now() ) @@ -131,16 +133,40 @@ func favicon(w http.ResponseWriter, r *http.Request) { serveStaticFile(w, r, "static/favicon.ico") } +func makeToken() (string, error) { + nowBytes, err := time.Now().MarshalBinary() + if err != nil { + return "", err + } + return base64.RawStdEncoding.EncodeToString(secretKey.Sign(nowBytes)), nil +} + +func checkToken(token string) bool { + authCookie, err := base64.RawStdEncoding.DecodeString(token) + if err != nil { + return false + } + msg, ok := secretKey.Verify(authCookie) + if !ok { + return false + } + var t time.Time + if err := t.UnmarshalBinary(msg); err != nil { + return false + } + return time.Since(t) < cookieExpiration +} + const cookieExpiration = 180 * 24 * time.Hour func attachCookie(w http.ResponseWriter) error { - nowBytes, err := time.Now().MarshalBinary() + token, err := makeToken() if err != nil { return err } http.SetCookie(w, &http.Cookie{ Name: "auth", - Value: base64.RawStdEncoding.EncodeToString(secretKey.Sign(nowBytes)), + Value: token, Path: "/notepad", Expires: time.Now().Add(cookieExpiration), Secure: true, @@ -158,19 +184,7 @@ func cookieAuth(w http.ResponseWriter, r *http.Request) (string, bool) { if err != nil { return "", false } - authCookie, err := base64.RawStdEncoding.DecodeString(cookie.Value) - if err != nil { - return "", false - } - msg, ok := secretKey.Verify(authCookie) - if !ok { - return "", false - } - var t time.Time - if err := t.UnmarshalBinary(msg); err != nil { - return "", false - } - if time.Since(t) > cookieExpiration { + if !checkToken(cookie.Value) { return "", false } attachCookie(w) @@ -194,6 +208,25 @@ func cookieAuth(w http.ResponseWriter, r *http.Request) (string, bool) { return csrfToken, true } +func setEncryptionKey(rawKey cryptoutil.RawKey) error { + encryptionKeyMu.Lock() + currentKey := encryptionKey + encryptionKeyMu.Unlock() + if currentKey != nil { + return nil + } + key, err := rawKey.EncryptionKey() + if err != nil { + return err + } + encryptionKeyMu.Lock() + if encryptionKey == nil { + encryptionKey = key + } + encryptionKeyMu.Unlock() + return nil +} + var ( //go:embed templates/login.html.template loginString string @@ -212,27 +245,16 @@ func login(w http.ResponseWriter, r *http.Request) { } return } - encryptionKeyMu.Lock() - currentKey := encryptionKey - encryptionKeyMu.Unlock() - if currentKey == nil { - key, err := rawKey.EncryptionKey() - if err != nil { - http.Error(w, fmt.Sprintf("failed to derive encryption key: %s", err), http.StatusInternalServerError) - return - } - encryptionKeyMu.Lock() - if encryptionKey == nil { - encryptionKey = key - } - encryptionKeyMu.Unlock() + if err := setEncryptionKey(rawKey); err != nil { + http.Error(w, fmt.Sprintf("Error: %s", err), http.StatusInternalServerError) + return } attachCookie(w) http.Redirect(w, r, "/notepad", http.StatusSeeOther) } func readNotepad(key cryptoutil.EncryptionKey) (string, error) { - encrypted, err := os.ReadFile(*notepadFile) + encrypted, err := os.ReadFile(*notepadDir + "/notepad") if err != nil { return "", err } @@ -297,7 +319,7 @@ func saveNote(w http.ResponseWriter, r *http.Request) error { if err != nil { return err } - f, err := os.CreateTemp(filepath.Dir(*notepadFile), "notepad") + f, err := os.CreateTemp(*notepadDir, "notepad") if err != nil { return err } @@ -310,7 +332,7 @@ func saveNote(w http.ResponseWriter, r *http.Request) error { os.Remove(f.Name()) return err } - return os.Rename(f.Name(), *notepadFile) + return os.Rename(f.Name(), *notepadDir+"/notepad") } func autosave(w http.ResponseWriter, r *http.Request) { @@ -323,6 +345,224 @@ func autosave(w http.ResponseWriter, r *http.Request) { } } +func writeGob(w io.Writer, resp any) { + if err := gob.NewEncoder(w).Encode(&api.Response{Ok: resp}); err != nil { + log.Printf("Warning: write gob response: %s", err) + } +} + +func gobError(w io.Writer, err error) { + var code api.ErrorCode + if !errors.As(err, &code) { + code = api.Internal + } + if err := gob.NewEncoder(w).Encode(&api.Response{Status: code, Err: err.Error()}); err != nil { + log.Printf("Warning: write gob response: %s", err) + } +} + +func apiLogin(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/gob") + rawKey, err := notepadPassword.CheckPassword(r.Header.Get("Roseh-Password")) + if err != nil { + gobError(w, fmt.Errorf("%w: incorrect password", api.PermissionDenied)) + return + } + if err := setEncryptionKey(rawKey); err != nil { + gobError(w, err) + return + } + token, err := makeToken() + if err != nil { + gobError(w, err) + return + } + writeGob(w, &api.LoginResponse{Token: token}) +} + +type encoder struct { + enc *gob.Encoder +} + +func (e *encoder) send(resp any) error { + return e.enc.Encode(&api.Response{Ok: resp}) +} + +func listNotes(req *api.ListNotesRequest) (*api.ListNotesResponse, error) { + notes, err := os.ReadDir(*notepadDir + "/notes") + if err != nil { + return nil, err + } + names := make([]string, len(notes)) + for i, note := range notes { + names[i] = note.Name() + } + return &api.ListNotesResponse{Notes: names}, nil +} + +var ( + //go:embed wordlist.txt + wordListString string + wordList = strings.Split(strings.TrimSuffix(wordListString, "\n"), "\n") +) + +func createNote(stream *gob.Decoder) (*api.CreateNoteResponse, error) { + encryptionKeyMu.Lock() + key := encryptionKey + encryptionKeyMu.Unlock() + if key == nil { + return nil, fmt.Errorf("%w: need login", api.PermissionDenied) + } + var name string + var f *os.File + for n := 1; ; n++ { + buf := make([]byte, 2*n) + rand.Read(buf) + words := make([]string, n) + for i := range words { + words[i] = wordList[binary.NativeEndian.Uint16(buf[2*i:])&0x1fff] + } + name = strings.Join(words, "-") + var err error + f, err = os.OpenFile(*notepadDir+"/notes/"+name, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) + if err != nil { + if errors.Is(err, os.ErrExist) { + continue + } + return nil, fmt.Errorf("create note: create note file: %s", err) + } + break + } + defer f.Close() + encryptingWriter, err := key.Writer(f) + if err != nil { + return nil, err + } + for { + req := new(api.CreateNoteRequestStream) + if err := stream.Decode(req); err != nil { + if errors.Is(err, io.EOF) { + break + } + return nil, fmt.Errorf("create note: read input stream: %s", err) + } + if _, err := encryptingWriter.Write(req.Chunk); err != nil { + return nil, fmt.Errorf("create note: write note to file: %s", err) + } + } + if err := encryptingWriter.Flush(); err != nil { + return nil, err + } + if err := f.Close(); err != nil { + return nil, fmt.Errorf("create note: write file: %s", err) + } + return &api.CreateNoteResponse{Name: name}, nil +} + +func readNote(stream *encoder, req *api.ReadNoteRequest) error { + f, err := os.OpenInRoot(*notepadDir+"/notes", req.Note) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return fmt.Errorf("%w: note %q", api.NotFound, req.Note) + } + return err + } + defer f.Close() + encryptionKeyMu.Lock() + key := encryptionKey + encryptionKeyMu.Unlock() + if key == nil { + return fmt.Errorf("%w: need login", api.PermissionDenied) + } + decryptingReader, err := key.Reader(f) + if err != nil { + return err + } + buf := make([]byte, 4*1024*1024) + for { + n, err := decryptingReader.Read(buf) + if n > 0 { + if err := stream.send(&api.ReadNoteResponseStream{Chunk: buf[:n]}); err != nil { + return err + } + } + if err != nil { + if errors.Is(err, io.EOF) { + break + } + return err + } + } + return nil +} + +func tokenAuth(w http.ResponseWriter, r *http.Request) bool { + token := r.Header.Get("Roseh-Token") + if token == "" { + gobError(w, api.Unauthenticated) + return false + } + if !checkToken(token) { + gobError(w, fmt.Errorf("%w: bad token", api.PermissionDenied)) + return false + } + return true +} + +func gobReqRespMiddleware[Request, Response any](next func(*Request) (*Response, error)) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/gob") + if !tokenAuth(w, r) { + return + } + req := new(Request) + if err := gob.NewDecoder(r.Body).Decode(req); err != nil { + gobError(w, fmt.Errorf("%w: read body: %s", api.BadRequest, err)) + return + } + resp, err := next(req) + if err != nil { + gobError(w, err) + return + } + writeGob(w, resp) + } +} + +func gobReqStreamMiddleware[Response any](next func(*gob.Decoder) (*Response, error)) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/gob") + if !tokenAuth(w, r) { + return + } + resp, err := next(gob.NewDecoder(r.Body)) + if err != nil { + gobError(w, err) + return + } + writeGob(w, resp) + } +} + +func gobRespStreamMiddleware[Request any](next func(*encoder, *Request) error) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/gob") + if !tokenAuth(w, r) { + return + } + req := new(Request) + if err := gob.NewDecoder(r.Body).Decode(req); err != nil { + gobError(w, fmt.Errorf("%w: read body: %s", api.BadRequest, err)) + return + } + gobEncoder := gob.NewEncoder(w) + if err := next(&encoder{gobEncoder}, req); err != nil { + gobError(w, err) + return + } + } +} + func main() { flag.Parse() @@ -335,6 +575,10 @@ func main() { http.HandleFunc("POST /login", login) http.HandleFunc("GET /notepad", notepad) http.HandleFunc("POST /notepad/autosave", autosave) + http.HandleFunc("POST /api/login", apiLogin) + http.HandleFunc("GET /api/list-notes", gobReqRespMiddleware(listNotes)) + http.HandleFunc("POST /api/create-note", gobReqStreamMiddleware(createNote)) + http.HandleFunc("GET /api/read-note", gobRespStreamMiddleware(readNote)) http.HandleFunc("GET /static/", static) http.HandleFunc("GET /favicon.ico", favicon) http.HandleFunc("GET /{$}", index) |
