This commit is contained in:
233
api/http.go
Normal file
233
api/http.go
Normal file
@@ -0,0 +1,233 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"git.t-juice.club/torjus/gpaste"
|
||||
"git.t-juice.club/torjus/gpaste/files"
|
||||
"git.t-juice.club/torjus/gpaste/users"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type HTTPServer struct {
|
||||
Files files.FileStore
|
||||
Users users.UserStore
|
||||
Auth *gpaste.AuthService
|
||||
config *gpaste.ServerConfig
|
||||
Logger *zap.SugaredLogger
|
||||
AccessLogger *zap.SugaredLogger
|
||||
http.Server
|
||||
}
|
||||
|
||||
func NewHTTPServer(cfg *gpaste.ServerConfig) *HTTPServer {
|
||||
srv := &HTTPServer{
|
||||
config: cfg,
|
||||
Logger: zap.NewNop().Sugar(),
|
||||
AccessLogger: zap.NewNop().Sugar(),
|
||||
}
|
||||
srv.Files = files.NewMemoryFileStore()
|
||||
srv.Users = users.NewMemoryUserStore()
|
||||
srv.Auth = gpaste.NewAuthService(srv.Users, []byte(srv.config.SigningSecret))
|
||||
|
||||
// Create initial user
|
||||
// TODO: Do properly
|
||||
user := &users.User{Username: "admin"}
|
||||
user.SetPassword("admin")
|
||||
srv.Users.Store(user)
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Use(middleware.RealIP)
|
||||
r.Use(middleware.RequestID)
|
||||
r.Use(srv.MiddlewareAccessLogger)
|
||||
r.Use(srv.MiddlewareAuthentication)
|
||||
r.Get("/", srv.HandlerIndex)
|
||||
r.Post("/api/file", srv.HandlerAPIFilePost)
|
||||
r.Get("/api/file/{id}", srv.HandlerAPIFileGet)
|
||||
r.Post("/api/login", srv.HandlerAPILogin)
|
||||
r.Post("/api/user", srv.HandlerAPIUserCreate)
|
||||
srv.Handler = r
|
||||
|
||||
return srv
|
||||
}
|
||||
|
||||
func (s *HTTPServer) HandlerIndex(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte("index"))
|
||||
}
|
||||
|
||||
func (s *HTTPServer) HandlerAPIFilePost(w http.ResponseWriter, r *http.Request) {
|
||||
f := &files.File{
|
||||
ID: uuid.Must(uuid.NewRandom()).String(),
|
||||
Body: r.Body,
|
||||
}
|
||||
reqID := middleware.GetReqID(r.Context())
|
||||
|
||||
// Check if multipart form
|
||||
ct := r.Header.Get("Content-Type")
|
||||
if strings.Contains(ct, "multipart/form-data") {
|
||||
s.processMultiPartFormUpload(w, r)
|
||||
return
|
||||
}
|
||||
err := s.Files.Store(f)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
s.Logger.Warnw("Error storing file.", "req_id", reqID, "error", err, "id", f.ID, "remote_addr", r.RemoteAddr)
|
||||
return
|
||||
}
|
||||
s.Logger.Infow("Stored file.", "req_id", reqID, "id", f.ID, "remote_addr", r.RemoteAddr)
|
||||
var resp = struct {
|
||||
Message string `json:"message"`
|
||||
ID string `json:"id"`
|
||||
URL string `json:"url"`
|
||||
}{
|
||||
Message: "OK",
|
||||
ID: f.ID,
|
||||
URL: "TODO",
|
||||
}
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
encoder := json.NewEncoder(w)
|
||||
if err := encoder.Encode(&resp); err != nil {
|
||||
s.Logger.Warnw("Error encoding response to client.", "req_id", reqID, "error", err, "remote_addr", r.RemoteAddr)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *HTTPServer) HandlerAPIFileGet(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if id == "" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
f, err := s.Files.Get(id)
|
||||
if err != nil {
|
||||
// TODO: LOG
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if _, err := io.Copy(w, f.Body); err != nil {
|
||||
reqID := middleware.GetReqID(r.Context())
|
||||
s.Logger.Warnw("Error writing file to client.", "req_id", reqID, "error", err, "remote_addr", r.RemoteAddr)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *HTTPServer) processMultiPartFormUpload(w http.ResponseWriter, r *http.Request) {
|
||||
reqID := middleware.GetReqID(r.Context())
|
||||
type resp struct {
|
||||
Message string `json:"message"`
|
||||
ID string `json:"id"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
var responses []resp
|
||||
|
||||
if err := r.ParseMultipartForm(1024 * 1024 * 10); err != nil {
|
||||
s.Logger.Warnw("Error parsing multipart form.", "req_id", reqID, "err", err)
|
||||
}
|
||||
for k := range r.MultipartForm.File {
|
||||
ff, fh, err := r.FormFile(k)
|
||||
if err != nil {
|
||||
s.Logger.Warnw("Error reading file from multipart form.", "req_id", reqID, "error", err)
|
||||
return
|
||||
}
|
||||
f := &files.File{
|
||||
ID: uuid.Must(uuid.NewRandom()).String(),
|
||||
OriginalFilename: fh.Filename,
|
||||
Body: ff,
|
||||
}
|
||||
|
||||
if err := s.Files.Store(f); err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
s.Logger.Warnw("Error storing file.", "req_id", reqID, "error", err, "id", f.ID, "remote_addr", r.RemoteAddr)
|
||||
return
|
||||
}
|
||||
s.Logger.Infow("Stored file.", "req_id", reqID, "id", f.ID, "filename", f.OriginalFilename, "remote_addr", r.RemoteAddr)
|
||||
|
||||
responses = append(responses, resp{Message: "OK", ID: f.ID, URL: "TODO"})
|
||||
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
encoder := json.NewEncoder(w)
|
||||
if err := encoder.Encode(&responses); err != nil {
|
||||
s.Logger.Warnw("Error encoding response to client.", "req_id", reqID, "error", err, "remote_addr", r.RemoteAddr)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *HTTPServer) HandlerAPILogin(w http.ResponseWriter, r *http.Request) {
|
||||
reqID := middleware.GetReqID(r.Context())
|
||||
expectedRequest := struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}{}
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
defer r.Body.Close()
|
||||
if err := decoder.Decode(&expectedRequest); err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
token, err := s.Auth.Login(expectedRequest.Username, expectedRequest.Password)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
response := struct {
|
||||
Token string `json:"token"`
|
||||
}{
|
||||
Token: token,
|
||||
}
|
||||
|
||||
s.Logger.Infow("User logged in.", "req_id", reqID, "username", expectedRequest.Username)
|
||||
|
||||
encoder := json.NewEncoder(w)
|
||||
if err := encoder.Encode(&response); err != nil {
|
||||
s.Logger.Infow("Error encoding json response to client.", "req_id", reqID, "error", err, "remote_addr", r.RemoteAddr)
|
||||
}
|
||||
}
|
||||
|
||||
type RequestAPIUserCreate struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
func (s *HTTPServer) HandlerAPIUserCreate(w http.ResponseWriter, r *http.Request) {
|
||||
reqID := middleware.GetReqID(r.Context())
|
||||
defer r.Body.Close()
|
||||
|
||||
level, err := AuthLevelFromRequest(r)
|
||||
if err != nil || level < gpaste.AuthLevelAdmin {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var req RequestAPIUserCreate
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
if err := decoder.Decode(&req); err != nil {
|
||||
s.Logger.Debugw("Error parsing request.", "req_id", reqID, "error", err, "remote_addr", r.RemoteAddr)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: Ensure user does not already exist
|
||||
user := &users.User{Username: req.Username}
|
||||
if err := user.SetPassword(req.Password); err != nil {
|
||||
s.Logger.Warnw("Error setting user password.", "req_id", reqID, "error", err, "remote_addr", r.RemoteAddr)
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := s.Users.Store(user); err != nil {
|
||||
s.Logger.Warnw("Error setting user password.", "req_id", reqID, "error", err, "remote_addr", r.RemoteAddr)
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
s.Logger.Infow("Created user.", "req_id", reqID, "remote_addr", r.RemoteAddr, "username", req.Username)
|
||||
}
|
146
api/http_test.go
Normal file
146
api/http_test.go
Normal file
@@ -0,0 +1,146 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"git.t-juice.club/torjus/gpaste"
|
||||
"git.t-juice.club/torjus/gpaste/api"
|
||||
"git.t-juice.club/torjus/gpaste/users"
|
||||
)
|
||||
|
||||
func TestHandlers(t *testing.T) {
|
||||
cfg := &gpaste.ServerConfig{
|
||||
SigningSecret: "abc123",
|
||||
Store: &gpaste.ServerStoreConfig{
|
||||
Type: "memory",
|
||||
},
|
||||
URL: "http://localhost:8080",
|
||||
}
|
||||
hs := api.NewHTTPServer(cfg)
|
||||
|
||||
t.Run("HandlerIndex", func(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
|
||||
hs.Handler.ServeHTTP(rr, req)
|
||||
|
||||
if status := rr.Code; status != http.StatusOK {
|
||||
t.Errorf("Returned unexpected status")
|
||||
}
|
||||
|
||||
expectedBody := "index"
|
||||
if body := rr.Body.String(); body != expectedBody {
|
||||
t.Errorf("Body does not match expected. Got %s want %s", body, expectedBody)
|
||||
}
|
||||
})
|
||||
t.Run("HandlerAPIFilePost", func(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
buf := &bytes.Buffer{}
|
||||
mw := multipart.NewWriter(buf)
|
||||
fw, err := mw.CreateFormFile("test", "test.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("Unable to create form file: %s", err)
|
||||
}
|
||||
expectedData := "Test OMEGALUL PLS."
|
||||
if _, err := io.WriteString(fw, expectedData); err != nil {
|
||||
t.Fatalf("Unable to write body to buffer: %s", err)
|
||||
}
|
||||
mw.Close()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/file", buf)
|
||||
req.Header.Add("Content-Type", mw.FormDataContentType())
|
||||
|
||||
hs.Handler.ServeHTTP(rr, req)
|
||||
|
||||
if status := rr.Code; status != http.StatusAccepted {
|
||||
t.Errorf("Returned unexpected status. Got %d want %d", status, http.StatusAccepted)
|
||||
}
|
||||
|
||||
var expectedResp []struct {
|
||||
Message string `json:"message"`
|
||||
ID string `json:"id"`
|
||||
URL string `json:"url"`
|
||||
}
|
||||
|
||||
decoder := json.NewDecoder(rr.Result().Body)
|
||||
if err := decoder.Decode(&expectedResp); err != nil {
|
||||
t.Fatalf("error decoding response: %s", err)
|
||||
}
|
||||
|
||||
if l := len(expectedResp); l != 1 {
|
||||
t.Errorf("Response has wrong length. Got %d want %d", l, 1)
|
||||
}
|
||||
|
||||
uploadID := expectedResp[0].ID
|
||||
if uploadID == "" {
|
||||
t.Errorf("Response has empty id")
|
||||
}
|
||||
|
||||
t.Run("HandlerAPIFileGet", func(t *testing.T) {
|
||||
rr := httptest.NewRecorder()
|
||||
url := fmt.Sprintf("/api/file/%s", uploadID)
|
||||
req := httptest.NewRequest(http.MethodGet, url, nil)
|
||||
|
||||
hs.Handler.ServeHTTP(rr, req)
|
||||
|
||||
if status := rr.Code; status != http.StatusOK {
|
||||
t.Errorf("Returned unexpected status. Got %d want %d", status, http.StatusAccepted)
|
||||
t.Logf(url)
|
||||
}
|
||||
if body := rr.Body.String(); body != expectedData {
|
||||
t.Errorf("Returned body does not match expected.")
|
||||
}
|
||||
})
|
||||
})
|
||||
t.Run("HandlerAPILogin", func(t *testing.T) {
|
||||
// TODO: Add test
|
||||
username := "admin"
|
||||
password := "admin"
|
||||
user := &users.User{Username: username}
|
||||
if err := user.SetPassword(password); err != nil {
|
||||
t.Fatalf("Error setting user password: %s", err)
|
||||
}
|
||||
if err := hs.Users.Store(user); err != nil {
|
||||
t.Fatalf("Error storing user: %s", err)
|
||||
}
|
||||
|
||||
requestData := struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}{
|
||||
Username: username,
|
||||
Password: password,
|
||||
}
|
||||
|
||||
body := new(bytes.Buffer)
|
||||
encoder := json.NewEncoder(body)
|
||||
if err := encoder.Encode(&requestData); err != nil {
|
||||
t.Fatalf("Error encoding request body: %s", err)
|
||||
}
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/login", body)
|
||||
|
||||
hs.Handler.ServeHTTP(rr, req)
|
||||
|
||||
responseData := struct {
|
||||
Token string `json:"token"`
|
||||
}{}
|
||||
|
||||
decoder := json.NewDecoder(rr.Body)
|
||||
if err := decoder.Decode(&responseData); err != nil {
|
||||
t.Fatalf("Error decoding response: %s", err)
|
||||
}
|
||||
|
||||
if _, err := hs.Auth.ValidateToken(responseData.Token); err != nil {
|
||||
t.Fatalf("Unable to validate received token: %s", err)
|
||||
}
|
||||
})
|
||||
}
|
102
api/middleware.go
Normal file
102
api/middleware.go
Normal file
@@ -0,0 +1,102 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.t-juice.club/torjus/gpaste"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
)
|
||||
|
||||
type authCtxKey int
|
||||
|
||||
const (
|
||||
authCtxUsername authCtxKey = iota
|
||||
authCtxAuthLevel
|
||||
)
|
||||
|
||||
func (s *HTTPServer) MiddlewareAccessLogger(next http.Handler) http.Handler {
|
||||
fn := func(w http.ResponseWriter, r *http.Request) {
|
||||
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
|
||||
t1 := time.Now()
|
||||
|
||||
reqID := middleware.GetReqID(r.Context())
|
||||
|
||||
defer func() {
|
||||
s.AccessLogger.Infow(r.Method,
|
||||
"path", r.URL.Path,
|
||||
"status", ww.Status(),
|
||||
"written", ww.BytesWritten(),
|
||||
"remote_addr", r.RemoteAddr,
|
||||
"processing_time_ms", time.Since(t1).Milliseconds(),
|
||||
"req_id", reqID)
|
||||
}()
|
||||
|
||||
next.ServeHTTP(ww, r)
|
||||
}
|
||||
return http.HandlerFunc(fn)
|
||||
}
|
||||
|
||||
func (s *HTTPServer) MiddlewareAuthentication(next http.Handler) http.Handler {
|
||||
fn := func(w http.ResponseWriter, r *http.Request) {
|
||||
reqID := middleware.GetReqID(r.Context())
|
||||
header := r.Header.Get("Authorization")
|
||||
if header == "" {
|
||||
s.Logger.Debugw("Request has no auth header.", "req_id", reqID)
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
splitHeader := strings.Split(header, "Bearer ")
|
||||
if len(splitHeader) != 2 {
|
||||
s.Logger.Debugw("Request has invalid token.", "req_id", reqID)
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
token := splitHeader[1]
|
||||
|
||||
claims, err := s.Auth.ValidateToken(token)
|
||||
if err != nil {
|
||||
s.Logger.Debugw("Request has invalid token.", "req_id", reqID)
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), authCtxUsername, claims.Subject)
|
||||
ctx = context.WithValue(ctx, authCtxAuthLevel, gpaste.AuthLevelUser)
|
||||
withCtx := r.WithContext(ctx)
|
||||
s.Logger.Debugw("Request is authenticated.", "req_id", reqID, "username", claims.Subject)
|
||||
|
||||
next.ServeHTTP(w, withCtx)
|
||||
}
|
||||
|
||||
return http.HandlerFunc(fn)
|
||||
}
|
||||
|
||||
func UsernameFromRequest(r *http.Request) (string, error) {
|
||||
rawUsername := r.Context().Value(authCtxUsername)
|
||||
if rawUsername == nil {
|
||||
|
||||
return "", fmt.Errorf("no username")
|
||||
}
|
||||
username, ok := rawUsername.(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("no username")
|
||||
}
|
||||
return username, nil
|
||||
}
|
||||
|
||||
func AuthLevelFromRequest(r *http.Request) (gpaste.AuthLevel, error) {
|
||||
rawLevel := r.Context().Value(authCtxAuthLevel)
|
||||
if rawLevel == nil {
|
||||
return gpaste.AuthLevelUnset, fmt.Errorf("no username")
|
||||
}
|
||||
level, ok := rawLevel.(gpaste.AuthLevel)
|
||||
if !ok {
|
||||
return gpaste.AuthLevelUnset, fmt.Errorf("no username")
|
||||
}
|
||||
return level, nil
|
||||
}
|
Reference in New Issue
Block a user