Implement OIDC single sign-on #27
							
								
								
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										3
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							| @ -32,3 +32,6 @@ test.db.old | ||||
| .gitignore | ||||
| .nvim/session | ||||
| *templ.txt | ||||
| 
 | ||||
| # env files | ||||
| .env* | ||||
|  | ||||
| @ -1,9 +1,12 @@ | ||||
| package main | ||||
| 
 | ||||
| import ( | ||||
| 	"bytes" | ||||
| 	"fmt" | ||||
| 	"io" | ||||
| 	"net/http" | ||||
| 	"net/url" | ||||
| 	"strings" | ||||
| 	"testing" | ||||
| 
 | ||||
| 	"git.32bit.cafe/32bitcafe/guestbook/internal/assert" | ||||
| @ -150,9 +153,6 @@ func TestPostGuestbookCommentCreateRemote(t *testing.T) { | ||||
| 	ts := newTestServer(t, app.routes()) | ||||
| 	defer ts.Close() | ||||
| 
 | ||||
| 	_, _, body := ts.get(t, fmt.Sprintf("/websites/%s/guestbook", shortIdToSlug(1))) | ||||
| 	validCSRFToken := extractCSRFToken(t, body) | ||||
| 
 | ||||
| 	const ( | ||||
| 		validAuthorName  = "John Test" | ||||
| 		validAuthorEmail = "test@example.com" | ||||
| @ -166,8 +166,8 @@ func TestPostGuestbookCommentCreateRemote(t *testing.T) { | ||||
| 		authorEmail string | ||||
| 		authorSite  string | ||||
| 		content     string | ||||
| 		csrfToken   string | ||||
| 		wantCode    int | ||||
| 		wantBody    string | ||||
| 	}{ | ||||
| 		{ | ||||
| 			name:        "Valid input", | ||||
| @ -175,8 +175,8 @@ func TestPostGuestbookCommentCreateRemote(t *testing.T) { | ||||
| 			authorEmail: validAuthorEmail, | ||||
| 			authorSite:  validAuthorSite, | ||||
| 			content:     validContent, | ||||
| 			csrfToken:   validCSRFToken, | ||||
| 			wantCode:    http.StatusSeeOther, | ||||
| 			wantCode:    http.StatusOK, | ||||
| 			wantBody:    "Comment successfully posted", | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:        "Blank name", | ||||
| @ -184,8 +184,8 @@ func TestPostGuestbookCommentCreateRemote(t *testing.T) { | ||||
| 			authorEmail: validAuthorEmail, | ||||
| 			authorSite:  validAuthorSite, | ||||
| 			content:     validContent, | ||||
| 			csrfToken:   validCSRFToken, | ||||
| 			wantCode:    http.StatusUnprocessableEntity, | ||||
| 			wantCode:    http.StatusOK, | ||||
| 			wantBody:    "An error occurred", | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:        "Blank email", | ||||
| @ -193,8 +193,8 @@ func TestPostGuestbookCommentCreateRemote(t *testing.T) { | ||||
| 			authorEmail: "", | ||||
| 			authorSite:  validAuthorSite, | ||||
| 			content:     validContent, | ||||
| 			csrfToken:   validCSRFToken, | ||||
| 			wantCode:    http.StatusSeeOther, | ||||
| 			wantCode:    http.StatusOK, | ||||
| 			wantBody:    "Comment successfully posted", | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:        "Blank site", | ||||
| @ -202,8 +202,8 @@ func TestPostGuestbookCommentCreateRemote(t *testing.T) { | ||||
| 			authorEmail: validAuthorEmail, | ||||
| 			authorSite:  "", | ||||
| 			content:     validContent, | ||||
| 			csrfToken:   validCSRFToken, | ||||
| 			wantCode:    http.StatusSeeOther, | ||||
| 			wantCode:    http.StatusOK, | ||||
| 			wantBody:    "Comment successfully posted", | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:        "Blank content", | ||||
| @ -211,21 +211,39 @@ func TestPostGuestbookCommentCreateRemote(t *testing.T) { | ||||
| 			authorEmail: validAuthorEmail, | ||||
| 			authorSite:  validAuthorSite, | ||||
| 			content:     "", | ||||
| 			csrfToken:   validCSRFToken, | ||||
| 			wantCode:    http.StatusUnprocessableEntity, | ||||
| 			wantCode:    http.StatusOK, | ||||
| 			wantBody:    "An error occurred", | ||||
| 		}, | ||||
| 	} | ||||
| 	for _, tt := range tests { | ||||
| 		t.Run(tt.name, func(t *testing.T) { | ||||
| 
 | ||||
| 			form := url.Values{} | ||||
| 			form.Add("authorname", tt.authorName) | ||||
| 			form.Add("authoremail", tt.authorEmail) | ||||
| 			form.Add("authorsite", tt.authorSite) | ||||
| 			form.Add("content", tt.content) | ||||
| 			form.Add("csrf_token", tt.csrfToken) | ||||
| 			code, _, body := ts.postForm(t, fmt.Sprintf("/websites/%s/guestbook/comments/create/remote", shortIdToSlug(1)), form) | ||||
| 			assert.Equal(t, code, tt.wantCode) | ||||
| 			assert.Equal(t, body, body) | ||||
| 			r, err := http.NewRequest("POST", ts.URL, strings.NewReader(form.Encode())) | ||||
| 			if err != nil { | ||||
| 				t.Fatal(err) | ||||
| 			} | ||||
| 			r.URL.Path = fmt.Sprintf("/websites/%s/guestbook/comments/create/remote", shortIdToSlug(1)) | ||||
| 			r.Header.Set("Content-Type", "application/x-www-form-urlencoded") | ||||
| 			r.Header.Set("Origin", "http://example.com") | ||||
| 
 | ||||
| 			resp, err := ts.Client().Do(r) | ||||
| 			if err != nil { | ||||
| 				t.Fatal(err) | ||||
| 			} | ||||
| 
 | ||||
| 			defer resp.Body.Close() | ||||
| 			body, err := io.ReadAll(resp.Body) | ||||
| 			if err != nil { | ||||
| 				t.Fatal(err) | ||||
| 			} | ||||
| 			body = bytes.TrimSpace(body) | ||||
| 			assert.Equal(t, resp.StatusCode, tt.wantCode) | ||||
| 			assert.StringContains(t, string(body), tt.wantBody) | ||||
| 		}) | ||||
| 	} | ||||
| } | ||||
|  | ||||
| @ -9,15 +9,22 @@ import ( | ||||
| 	"git.32bit.cafe/32bitcafe/guestbook/internal/models" | ||||
| 	"git.32bit.cafe/32bitcafe/guestbook/internal/validator" | ||||
| 	"git.32bit.cafe/32bitcafe/guestbook/ui/views" | ||||
| 	"github.com/coreos/go-oidc/v3/oidc" | ||||
| ) | ||||
| 
 | ||||
| func (app *application) getUserRegister(w http.ResponseWriter, r *http.Request) { | ||||
| 	if !app.config.localAuthEnabled { | ||||
| 		http.Redirect(w, r, "/users/login/oidc", http.StatusFound) | ||||
| 	} | ||||
| 	form := forms.UserRegistrationForm{} | ||||
| 	data := app.newCommonData(r) | ||||
| 	views.UserRegistration("User Registration", data, form).Render(r.Context(), w) | ||||
| } | ||||
| 
 | ||||
| func (app *application) getUserLogin(w http.ResponseWriter, r *http.Request) { | ||||
| 	if !app.config.localAuthEnabled { | ||||
| 		http.Redirect(w, r, "/users/login/oidc", http.StatusFound) | ||||
| 	} | ||||
| 	views.UserLogin("Login", app.newCommonData(r), forms.UserLoginForm{}).Render(r.Context(), w) | ||||
| } | ||||
| 
 | ||||
| @ -92,6 +99,113 @@ func (app *application) postUserLogin(w http.ResponseWriter, r *http.Request) { | ||||
| 	http.Redirect(w, r, "/", http.StatusSeeOther) | ||||
| } | ||||
| 
 | ||||
| func (app *application) userLoginOIDC(w http.ResponseWriter, r *http.Request) { | ||||
| 	if !app.config.oauthEnabled { | ||||
| 		http.Redirect(w, r, "/users/login", http.StatusFound) | ||||
| 	} | ||||
| 	state, err := randString(16) | ||||
| 	if err != nil { | ||||
| 		app.serverError(w, r, err) | ||||
| 		return | ||||
| 	} | ||||
| 	nonce, err := randString(16) | ||||
| 	if err != nil { | ||||
| 		app.serverError(w, r, err) | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	setCallbackCookie(w, r, "state", state) | ||||
| 	setCallbackCookie(w, r, "nonce", nonce) | ||||
| 
 | ||||
| 	http.Redirect(w, r, app.config.oauth.config.AuthCodeURL(state, oidc.Nonce(nonce)), http.StatusFound) | ||||
| } | ||||
| 
 | ||||
| func (app *application) userLoginOIDCCallback(w http.ResponseWriter, r *http.Request) { | ||||
| 	state, err := r.Cookie("state") | ||||
| 	if err != nil { | ||||
| 		app.clientError(w, http.StatusBadRequest) | ||||
| 		return | ||||
| 	} | ||||
| 	if r.URL.Query().Get("state") != state.Value { | ||||
| 		app.clientError(w, http.StatusBadRequest) | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	oauth2Token, err := app.config.oauth.config.Exchange(r.Context(), r.URL.Query().Get("code")) | ||||
| 	if err != nil { | ||||
| 		app.logger.Error("Failed to exchange token") | ||||
| 		app.serverError(w, r, err) | ||||
| 		return | ||||
| 	} | ||||
| 	rawIDToken, ok := oauth2Token.Extra("id_token").(string) | ||||
| 	if !ok { | ||||
| 		app.serverError(w, r, errors.New("No id_token field in oauth2 token")) | ||||
| 		return | ||||
| 	} | ||||
| 	idToken, err := app.config.oauth.verifier.Verify(r.Context(), rawIDToken) | ||||
| 	if err != nil { | ||||
| 		app.logger.Error("Failed to verify ID token") | ||||
| 		app.serverError(w, r, err) | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	nonce, err := r.Cookie("nonce") | ||||
| 	if err != nil { | ||||
| 		app.logger.Error("nonce not found") | ||||
| 		app.serverError(w, r, err) | ||||
| 		return | ||||
| 	} | ||||
| 	if idToken.Nonce != nonce.Value { | ||||
| 		app.serverError(w, r, errors.New("nonce did not match")) | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	oauth2Token.AccessToken = "*REDACTED*" | ||||
| 
 | ||||
| 	var t models.UserIdToken | ||||
| 	if err := idToken.Claims(&t); err != nil { | ||||
| 		app.serverError(w, r, err) | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	err = app.sessionManager.RenewToken(r.Context()) | ||||
| 	if err != nil { | ||||
| 		app.serverError(w, r, err) | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	// search for user by subject | ||||
| 	id, err := app.users.GetBySubject(t.Subject) | ||||
| 	if err != nil && errors.Is(err, models.ErrNoRecord) { | ||||
| 		// if no user is found, check if they have signed up by email already | ||||
| 		id, err = app.users.GetByEmail(t.Email) | ||||
| 		if err == nil { | ||||
| 			// if user is found by email, update subject to match them in the first step next time | ||||
| 			err2 := app.users.UpdateSubject(id, t.Subject) | ||||
| 			if err2 != nil { | ||||
| 				app.serverError(w, r, err2) | ||||
| 				return | ||||
| 			} | ||||
| 		} | ||||
| 	} else if err != nil { | ||||
| 		app.serverError(w, r, err) | ||||
| 		return | ||||
| 	} | ||||
| 	if err != nil && errors.Is(err, models.ErrNoRecord) { | ||||
| 		// if no user is found by subject or email, create a new user | ||||
| 		id, err = app.users.InsertWithoutPassword(app.createShortId(), t.Username, t.Email, t.Subject, DefaultUserSettings()) | ||||
| 		if err != nil { | ||||
| 			app.serverError(w, r, err) | ||||
| 		} | ||||
| 	} else if err != nil { | ||||
| 		app.serverError(w, r, err) | ||||
| 		return | ||||
| 	} | ||||
| 
 | ||||
| 	app.sessionManager.Put(r.Context(), "authenticatedUserId", id) | ||||
| 	http.Redirect(w, r, "/", http.StatusSeeOther) | ||||
| } | ||||
| 
 | ||||
| func (app *application) postUserLogout(w http.ResponseWriter, r *http.Request) { | ||||
| 	err := app.sessionManager.RenewToken(r.Context()) | ||||
| 	if err != nil { | ||||
|  | ||||
| @ -1,11 +1,17 @@ | ||||
| package main | ||||
| 
 | ||||
| import ( | ||||
| 	"context" | ||||
| 	"crypto/rsa" | ||||
| 	"net/http" | ||||
| 	"net/url" | ||||
| 	"testing" | ||||
| 	"time" | ||||
| 
 | ||||
| 	"git.32bit.cafe/32bitcafe/guestbook/internal/assert" | ||||
| 	"github.com/coreos/go-oidc/v3/oidc" | ||||
| 	"github.com/coreos/go-oidc/v3/oidc/oidctest" | ||||
| 	"golang.org/x/oauth2" | ||||
| ) | ||||
| 
 | ||||
| func TestUserSignup(t *testing.T) { | ||||
| @ -119,3 +125,162 @@ func TestUserSignup(t *testing.T) { | ||||
| 	} | ||||
| 
 | ||||
| } | ||||
| 
 | ||||
| type OAuth2Mock struct { | ||||
| 	Srv     *testServer | ||||
| 	Priv    *rsa.PrivateKey | ||||
| 	Subject string | ||||
| 	Email   string | ||||
| } | ||||
| 
 | ||||
| func (o *OAuth2Mock) AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string { | ||||
| 	return "" | ||||
| } | ||||
| 
 | ||||
| func (o *OAuth2Mock) Exchange(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) { | ||||
| 	tkn := oauth2.Token{ | ||||
| 		AccessToken: "AccessToken", | ||||
| 		Expiry:      time.Now().Add(1 * time.Hour), | ||||
| 	} | ||||
| 	m := make(map[string]any) | ||||
| 	var rawClaims = `{ | ||||
| 		"iss": "` + o.Srv.URL + `", | ||||
| 		"aud": "my-client-id", | ||||
| 		"sub": "` + o.Subject + `", | ||||
| 		"email": "` + o.Email + `", | ||||
| 		"email_verified": true, | ||||
| 		"nonce": "nonce" | ||||
| 		}` | ||||
| 
 | ||||
| 	m["id_token"] = oidctest.SignIDToken(o.Priv, "test-key", oidc.RS256, rawClaims) | ||||
| 	return tkn.WithExtra(m), nil | ||||
| } | ||||
| 
 | ||||
| func (o *OAuth2Mock) Client(ctx context.Context, t *oauth2.Token) *http.Client { | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func TestUserOIDCCallback(t *testing.T) { | ||||
| 	app := newTestApplication(t) | ||||
| 	ts := newTestServer(t, app.routes()) | ||||
| 
 | ||||
| 	priv := newTestKey(t) | ||||
| 	srv := newTestOIDCServer(t, priv) | ||||
| 
 | ||||
| 	defer srv.Close() | ||||
| 	defer ts.Close() | ||||
| 	ctx := context.Background() | ||||
| 
 | ||||
| 	p, err := oidc.NewProvider(ctx, srv.URL) | ||||
| 	if err != nil { | ||||
| 		t.Fatal(err) | ||||
| 	} | ||||
| 	cfg := &oidc.Config{ | ||||
| 		ClientID:        "my-client-id", | ||||
| 		SkipExpiryCheck: true, | ||||
| 	} | ||||
| 	v := p.VerifierContext(ctx, cfg) | ||||
| 	oMock := &OAuth2Mock{ | ||||
| 		Srv:     srv, | ||||
| 		Priv:    priv, | ||||
| 		Subject: "foo", | ||||
| 	} | ||||
| 	app.config.oauth = applicationOauthConfig{ | ||||
| 		ctx:        context.Background(), | ||||
| 		oidcConfig: cfg, | ||||
| 		config:     oMock, | ||||
| 		provider:   p, | ||||
| 		verifier:   v, | ||||
| 	} | ||||
| 	app.config.oauthEnabled = true | ||||
| 
 | ||||
| 	const ( | ||||
| 		validSubject   = "goodSubject" | ||||
| 		unknownSubject = "foo" | ||||
| 		validUserId    = 1 | ||||
| 		validEmail     = "test@example.com" | ||||
| 		validState     = "goodState" | ||||
| 	) | ||||
| 
 | ||||
| 	tests := []struct { | ||||
| 		name     string | ||||
| 		subject  string | ||||
| 		email    string | ||||
| 		state    string | ||||
| 		wantCode int | ||||
| 	}{ | ||||
| 		{ | ||||
| 			name:     "By Subject", | ||||
| 			subject:  validSubject, | ||||
| 			email:    "", | ||||
| 			state:    validState, | ||||
| 			wantCode: http.StatusSeeOther, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:     "By Email", | ||||
| 			subject:  unknownSubject, | ||||
| 			email:    validEmail, | ||||
| 			state:    validState, | ||||
| 			wantCode: http.StatusSeeOther, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:     "No User", | ||||
| 			subject:  unknownSubject, | ||||
| 			email:    "", | ||||
| 			state:    validState, | ||||
| 			wantCode: http.StatusSeeOther, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:     "Invalid State", | ||||
| 			subject:  unknownSubject, | ||||
| 			email:    validEmail, | ||||
| 			state:    "", | ||||
| 			wantCode: http.StatusInternalServerError, | ||||
| 		}, | ||||
| 		{ | ||||
| 			name:     "Unknown Subject & Email", | ||||
| 			subject:  unknownSubject, | ||||
| 			email:    "", | ||||
| 			state:    validState, | ||||
| 			wantCode: http.StatusInternalServerError, | ||||
| 		}, | ||||
| 	} | ||||
| 
 | ||||
| 	for _, tt := range tests { | ||||
| 		t.Run(tt.name, func(*testing.T) { | ||||
| 			oMock.Subject = tt.subject | ||||
| 			oMock.Email = tt.email | ||||
| 			r, err := http.NewRequest("GET", ts.URL, nil) | ||||
| 			if err != nil { | ||||
| 				t.Fatal(err) | ||||
| 			} | ||||
| 			r.URL.Path = "/users/login/oidc/callback" | ||||
| 			q := r.URL.Query() | ||||
| 			q.Add("state", tt.state) | ||||
| 			r.URL.RawQuery = q.Encode() | ||||
| 
 | ||||
| 			c := &http.Cookie{ | ||||
| 				Name:     "state", | ||||
| 				Value:    validState, | ||||
| 				MaxAge:   int(time.Hour.Seconds()), | ||||
| 				Secure:   r.TLS != nil, | ||||
| 				HttpOnly: true, | ||||
| 			} | ||||
| 			d := &http.Cookie{ | ||||
| 				Name:     "nonce", | ||||
| 				Value:    "nonce", | ||||
| 				MaxAge:   int(time.Hour.Seconds()), | ||||
| 				Secure:   r.TLS != nil, | ||||
| 				HttpOnly: true, | ||||
| 			} | ||||
| 			r.AddCookie(c) | ||||
| 			r.AddCookie(d) | ||||
| 			resp, err := ts.Client().Do(r) | ||||
| 			if err != nil { | ||||
| 				t.Fatal(err) | ||||
| 			} | ||||
| 			assert.Equal(t, resp.StatusCode, tt.wantCode) | ||||
| 		}) | ||||
| 	} | ||||
| 
 | ||||
| } | ||||
|  | ||||
| @ -1,8 +1,11 @@ | ||||
| package main | ||||
| 
 | ||||
| import ( | ||||
| 	"crypto/rand" | ||||
| 	"encoding/base64" | ||||
| 	"errors" | ||||
| 	"fmt" | ||||
| 	"io" | ||||
| 	"math" | ||||
| 	"net/http" | ||||
| 	"net/url" | ||||
| @ -104,13 +107,15 @@ func (app *application) getCurrentUser(r *http.Request) *models.User { | ||||
| 
 | ||||
| func (app *application) newCommonData(r *http.Request) views.CommonData { | ||||
| 	return views.CommonData{ | ||||
| 		CurrentYear:     time.Now().Year(), | ||||
| 		Flash:           app.sessionManager.PopString(r.Context(), "flash"), | ||||
| 		IsAuthenticated: app.isAuthenticated(r), | ||||
| 		CSRFToken:       nosurf.Token(r), | ||||
| 		CurrentUser:     app.getCurrentUser(r), | ||||
| 		IsHtmx:          r.Header.Get("Hx-Request") == "true", | ||||
| 		RootUrl:         app.rootUrl, | ||||
| 		CurrentYear:      time.Now().Year(), | ||||
| 		Flash:            app.sessionManager.PopString(r.Context(), "flash"), | ||||
| 		IsAuthenticated:  app.isAuthenticated(r), | ||||
| 		CSRFToken:        nosurf.Token(r), | ||||
| 		CurrentUser:      app.getCurrentUser(r), | ||||
| 		IsHtmx:           r.Header.Get("Hx-Request") == "true", | ||||
| 		RootUrl:          app.config.rootUrl, | ||||
| 		LocalAuthEnabled: app.config.localAuthEnabled, | ||||
| 		OIDCEnabled:      app.config.oauthEnabled, | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| @ -140,3 +145,22 @@ func matchOrigin(origin string, u *url.URL) bool { | ||||
| 	} | ||||
| 	return true | ||||
| } | ||||
| 
 | ||||
| func randString(nByte int) (string, error) { | ||||
| 	b := make([]byte, nByte) | ||||
| 	if _, err := io.ReadFull(rand.Reader, b); err != nil { | ||||
| 		return "", err | ||||
| 	} | ||||
| 	return base64.RawURLEncoding.EncodeToString(b), nil | ||||
| } | ||||
| 
 | ||||
| func setCallbackCookie(w http.ResponseWriter, r *http.Request, name, value string) { | ||||
| 	c := &http.Cookie{ | ||||
| 		Name:     name, | ||||
| 		Value:    value, | ||||
| 		MaxAge:   int(time.Hour.Seconds()), | ||||
| 		Secure:   r.TLS != nil, | ||||
| 		HttpOnly: true, | ||||
| 	} | ||||
| 	http.SetCookie(w, c) | ||||
| } | ||||
|  | ||||
							
								
								
									
										109
									
								
								cmd/web/main.go
									
									
									
									
									
								
							
							
						
						
									
										109
									
								
								cmd/web/main.go
									
									
									
									
									
								
							| @ -1,23 +1,47 @@ | ||||
| package main | ||||
| 
 | ||||
| import ( | ||||
| 	"context" | ||||
| 	"crypto/tls" | ||||
| 	"database/sql" | ||||
| 	"errors" | ||||
| 	"flag" | ||||
| 	"fmt" | ||||
| 	"log/slog" | ||||
| 	"net/http" | ||||
| 	"net/url" | ||||
| 	"os" | ||||
| 	"strconv" | ||||
| 	"strings" | ||||
| 	"time" | ||||
| 	"unicode" | ||||
| 
 | ||||
| 	"git.32bit.cafe/32bitcafe/guestbook/internal/auth" | ||||
| 	"git.32bit.cafe/32bitcafe/guestbook/internal/models" | ||||
| 	"github.com/alexedwards/scs/sqlite3store" | ||||
| 	"github.com/alexedwards/scs/v2" | ||||
| 	"github.com/coreos/go-oidc/v3/oidc" | ||||
| 	"github.com/gorilla/schema" | ||||
| 	"github.com/joho/godotenv" | ||||
| 	_ "github.com/mattn/go-sqlite3" | ||||
| 	"golang.org/x/oauth2" | ||||
| ) | ||||
| 
 | ||||
| type applicationOauthConfig struct { | ||||
| 	ctx        context.Context | ||||
| 	oidcConfig *oidc.Config | ||||
| 	config     auth.OAuth2ConfigInterface | ||||
| 	provider   *oidc.Provider | ||||
| 	verifier   *oidc.IDTokenVerifier | ||||
| } | ||||
| 
 | ||||
| type applicationConfig struct { | ||||
| 	oauthEnabled     bool | ||||
| 	localAuthEnabled bool | ||||
| 	oauth            applicationOauthConfig | ||||
| 	rootUrl          string | ||||
| } | ||||
| 
 | ||||
| type application struct { | ||||
| 	sequence          uint16 | ||||
| 	logger            *slog.Logger | ||||
| @ -26,19 +50,29 @@ type application struct { | ||||
| 	guestbookComments models.GuestbookCommentModelInterface | ||||
| 	sessionManager    *scs.SessionManager | ||||
| 	formDecoder       *schema.Decoder | ||||
| 	config            applicationConfig | ||||
| 	debug             bool | ||||
| 	timezones         []string | ||||
| 	rootUrl           string | ||||
| } | ||||
| 
 | ||||
| func main() { | ||||
| 	addr := flag.String("addr", ":3000", "HTTP network address") | ||||
| 	dsn := flag.String("dsn", "guestbook.db", "data source name") | ||||
| 	debug := flag.Bool("debug", false, "enable debug mode") | ||||
| 	root := flag.String("root", "localhost:3000", "root URL of application") | ||||
| 	env := flag.String("env", ".env", ".env file path") | ||||
| 	flag.Parse() | ||||
| 
 | ||||
| 	logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug})) | ||||
| 	err := godotenv.Load(*env) | ||||
| 	if err != nil { | ||||
| 		logger.Error(err.Error()) | ||||
| 		os.Exit(1) | ||||
| 	} | ||||
| 	cfg, err := setupConfig(*addr) | ||||
| 	if err != nil { | ||||
| 		logger.Error(err.Error()) | ||||
| 		os.Exit(1) | ||||
| 	} | ||||
| 
 | ||||
| 	db, err := openDB(*dsn) | ||||
| 	if err != nil { | ||||
| @ -62,9 +96,9 @@ func main() { | ||||
| 		users:             &models.UserModel{DB: db, Settings: make(map[string]models.Setting)}, | ||||
| 		guestbookComments: &models.GuestbookCommentModel{DB: db}, | ||||
| 		formDecoder:       formDecoder, | ||||
| 		config:            cfg, | ||||
| 		debug:             *debug, | ||||
| 		timezones:         getAvailableTimezones(), | ||||
| 		rootUrl:           *root, | ||||
| 	} | ||||
| 
 | ||||
| 	err = app.users.InitializeSettingsMap() | ||||
| @ -114,6 +148,75 @@ func openDB(dsn string) (*sql.DB, error) { | ||||
| 	return db, nil | ||||
| } | ||||
| 
 | ||||
| func setupConfig(addr string) (applicationConfig, error) { | ||||
| 	var c applicationConfig | ||||
| 
 | ||||
| 	var ( | ||||
| 		rootUrl           = os.Getenv("ROOT_URL") | ||||
| 		oidcEnabled       = os.Getenv("ENABLE_OIDC") | ||||
| 		localLoginEnabled = os.Getenv("ENABLE_LOCAL_LOGIN") | ||||
| 		oauth2Provider    = os.Getenv("OAUTH2_PROVIDER") | ||||
| 		clientID          = os.Getenv("OAUTH2_CLIENT_ID") | ||||
| 		clientSecret      = os.Getenv("OAUTH2_CLIENT_SECRET") | ||||
| 	) | ||||
| 	if rootUrl != "" { | ||||
| 		c.rootUrl = rootUrl | ||||
| 	} else { | ||||
| 		u, err := url.Parse(fmt.Sprintf("https://localhost%s", addr)) | ||||
| 		if err != nil { | ||||
| 			return c, err | ||||
| 		} | ||||
| 		c.rootUrl = u.String() | ||||
| 	} | ||||
| 
 | ||||
| 	oauthEnabled, err := strconv.ParseBool(oidcEnabled) | ||||
| 	if err != nil { | ||||
| 		c.oauthEnabled = false | ||||
| 	} | ||||
| 	c.oauthEnabled = oauthEnabled | ||||
| 
 | ||||
| 	localAuthEnabled, err := strconv.ParseBool(localLoginEnabled) | ||||
| 	if err != nil { | ||||
| 		c.localAuthEnabled = true | ||||
| 	} | ||||
| 	c.localAuthEnabled = localAuthEnabled | ||||
| 
 | ||||
| 	if !c.oauthEnabled && !c.localAuthEnabled { | ||||
| 		return c, errors.New("Either ENABLE_OIDC or ENABLE_LOCAL_LOGIN must be set to true") | ||||
| 	} | ||||
| 
 | ||||
| 	// if OIDC is disabled, no more configuration needs to be read | ||||
| 	if !oauthEnabled { | ||||
| 		return c, nil | ||||
| 	} | ||||
| 
 | ||||
| 	var o applicationOauthConfig | ||||
| 	if oauth2Provider == "" || clientID == "" || clientSecret == "" { | ||||
| 		return c, errors.New("OAUTH2_PROVIDER, OAUTH2_CLIENT_ID, and OAUTH2_CLIENT_SECRET must be specified as environment variables.") | ||||
| 	} | ||||
| 
 | ||||
| 	o.ctx = context.Background() | ||||
| 	provider, err := oidc.NewProvider(o.ctx, oauth2Provider) | ||||
| 	if err != nil { | ||||
| 		return c, err | ||||
| 	} | ||||
| 	o.provider = provider | ||||
| 	o.oidcConfig = &oidc.Config{ | ||||
| 		ClientID: clientID, | ||||
| 	} | ||||
| 	o.verifier = provider.Verifier(o.oidcConfig) | ||||
| 	o.config = &oauth2.Config{ | ||||
| 		ClientID:     clientID, | ||||
| 		ClientSecret: clientSecret, | ||||
| 		Endpoint:     provider.Endpoint(), | ||||
| 		RedirectURL:  fmt.Sprintf("%s/users/login/oidc/callback", c.rootUrl), | ||||
| 		Scopes:       []string{oidc.ScopeOpenID, "profile", "email"}, | ||||
| 	} | ||||
| 
 | ||||
| 	c.oauth = o | ||||
| 	return c, nil | ||||
| } | ||||
| 
 | ||||
| func getAvailableTimezones() []string { | ||||
| 	var zones []string | ||||
| 	var zoneDirs = []string{ | ||||
|  | ||||
| @ -27,6 +27,8 @@ func (app *application) routes() http.Handler { | ||||
| 	mux.Handle("POST /users/register", dynamic.ThenFunc(app.postUserRegister)) | ||||
| 	mux.Handle("GET /users/login", dynamic.ThenFunc(app.getUserLogin)) | ||||
| 	mux.Handle("POST /users/login", dynamic.ThenFunc(app.postUserLogin)) | ||||
| 	mux.Handle("/users/login/oidc", dynamic.ThenFunc(app.userLoginOIDC)) | ||||
| 	mux.Handle("/users/login/oidc/callback", dynamic.ThenFunc(app.userLoginOIDCCallback)) | ||||
| 	mux.Handle("GET /help", dynamic.ThenFunc(app.notImplemented)) | ||||
| 
 | ||||
| 	protected := dynamic.Append(app.requireAuthentication) | ||||
|  | ||||
| @ -2,6 +2,8 @@ package main | ||||
| 
 | ||||
| import ( | ||||
| 	"bytes" | ||||
| 	"crypto/rand" | ||||
| 	"crypto/rsa" | ||||
| 	"html" | ||||
| 	"io" | ||||
| 	"log/slog" | ||||
| @ -15,6 +17,8 @@ import ( | ||||
| 
 | ||||
| 	"git.32bit.cafe/32bitcafe/guestbook/internal/models/mocks" | ||||
| 	"github.com/alexedwards/scs/v2" | ||||
| 	"github.com/coreos/go-oidc/v3/oidc" | ||||
| 	"github.com/coreos/go-oidc/v3/oidc/oidctest" | ||||
| 	"github.com/gorilla/schema" | ||||
| ) | ||||
| 
 | ||||
| @ -34,9 +38,35 @@ func newTestApplication(t *testing.T) *application { | ||||
| 		guestbookComments: &mocks.GuestbookCommentModel{}, | ||||
| 		formDecoder:       formDecoder, | ||||
| 		timezones:         getAvailableTimezones(), | ||||
| 		config: applicationConfig{ | ||||
| 			localAuthEnabled: true, | ||||
| 		}, | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| func newTestKey(t *testing.T) *rsa.PrivateKey { | ||||
| 	priv, err := rsa.GenerateKey(rand.Reader, 2048) | ||||
| 	if err != nil { | ||||
| 		t.Fatal(err) | ||||
| 	} | ||||
| 	return priv | ||||
| } | ||||
| 
 | ||||
| func newTestOIDCServer(t *testing.T, priv *rsa.PrivateKey) *testServer { | ||||
| 	s := &oidctest.Server{ | ||||
| 		PublicKeys: []oidctest.PublicKey{ | ||||
| 			{ | ||||
| 				PublicKey: priv.Public(), | ||||
| 				KeyID:     "test-key", | ||||
| 				Algorithm: oidc.ES256, | ||||
| 			}, | ||||
| 		}, | ||||
| 	} | ||||
| 	ts := httptest.NewServer(s) | ||||
| 	s.SetIssuer(ts.URL) | ||||
| 	return &testServer{ts} | ||||
| } | ||||
| 
 | ||||
| type testServer struct { | ||||
| 	*httptest.Server | ||||
| } | ||||
|  | ||||
							
								
								
									
										5
									
								
								go.mod
									
									
									
									
									
								
							
							
						
						
									
										5
									
								
								go.mod
									
									
									
									
									
								
							| @ -6,9 +6,14 @@ require ( | ||||
| 	github.com/a-h/templ v0.3.833 | ||||
| 	github.com/alexedwards/scs/sqlite3store v0.0.0-20250212122300-421ef1d8611c | ||||
| 	github.com/alexedwards/scs/v2 v2.8.0 | ||||
| 	github.com/coreos/go-oidc/v3 v3.14.1 | ||||
| 	github.com/gorilla/schema v1.4.1 | ||||
| 	github.com/joho/godotenv v1.5.1 | ||||
| 	github.com/justinas/alice v1.2.0 | ||||
| 	github.com/justinas/nosurf v1.1.1 | ||||
| 	github.com/mattn/go-sqlite3 v1.14.24 | ||||
| 	golang.org/x/crypto v0.36.0 | ||||
| 	golang.org/x/oauth2 v0.30.0 | ||||
| ) | ||||
| 
 | ||||
| require github.com/go-jose/go-jose/v4 v4.0.5 // indirect | ||||
|  | ||||
							
								
								
									
										21
									
								
								go.sum
									
									
									
									
									
								
							
							
						
						
									
										21
									
								
								go.sum
									
									
									
									
									
								
							| @ -1,24 +1,35 @@ | ||||
| github.com/a-h/templ v0.3.833 h1:L/KOk/0VvVTBegtE0fp2RJQiBm7/52Zxv5fqlEHiQUU= | ||||
| github.com/a-h/templ v0.3.833/go.mod h1:cAu4AiZhtJfBjMY0HASlyzvkrtjnHWPeEsyGK2YYmfk= | ||||
| github.com/alexedwards/scs/sqlite3store v0.0.0-20240316134038-7e11d57e8885 h1:+DCxWg/ojncqS+TGAuRUoV7OfG/S4doh0pcpAwEcow0= | ||||
| github.com/alexedwards/scs/sqlite3store v0.0.0-20240316134038-7e11d57e8885/go.mod h1:Iyk7S76cxGaiEX/mSYmTZzYehp4KfyylcLaV3OnToss= | ||||
| github.com/alexedwards/scs/sqlite3store v0.0.0-20250212122300-421ef1d8611c h1:0gBCIsmH3+aaWK55APhhY7/Z+uv5IdbMqekI97V9shU= | ||||
| github.com/alexedwards/scs/sqlite3store v0.0.0-20250212122300-421ef1d8611c/go.mod h1:Iyk7S76cxGaiEX/mSYmTZzYehp4KfyylcLaV3OnToss= | ||||
| github.com/alexedwards/scs/v2 v2.8.0 h1:h31yUYoycPuL0zt14c0gd+oqxfRwIj6SOjHdKRZxhEw= | ||||
| github.com/alexedwards/scs/v2 v2.8.0/go.mod h1:ToaROZxyKukJKT/xLcVQAChi5k6+Pn1Gvmdl7h3RRj8= | ||||
| github.com/coreos/go-oidc/v3 v3.14.1 h1:9ePWwfdwC4QKRlCXsJGou56adA/owXczOzwKdOumLqk= | ||||
| github.com/coreos/go-oidc/v3 v3.14.1/go.mod h1:HaZ3szPaZ0e4r6ebqvsLWlk2Tn+aejfmrfah6hnSYEU= | ||||
| github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= | ||||
| github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | ||||
| github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE= | ||||
| github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA= | ||||
| github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= | ||||
| github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= | ||||
| github.com/gorilla/schema v1.4.1 h1:jUg5hUjCSDZpNGLuXQOgIWGdlgrIdYvgQ0wZtdK1M3E= | ||||
| github.com/gorilla/schema v1.4.1/go.mod h1:Dg5SSm5PV60mhF2NFaTV1xuYYj8tV8NOPRo4FggUMnM= | ||||
| github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= | ||||
| github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= | ||||
| github.com/justinas/alice v1.2.0 h1:+MHSA/vccVCF4Uq37S42jwlkvI2Xzl7zTPCN5BnZNVo= | ||||
| github.com/justinas/alice v1.2.0/go.mod h1:fN5HRH/reO/zrUflLfTN43t3vXvKzvZIENsNEe7i7qA= | ||||
| github.com/justinas/nosurf v1.1.1 h1:92Aw44hjSK4MxJeMSyDa7jwuI9GR2J/JCQiaKvXXSlk= | ||||
| github.com/justinas/nosurf v1.1.1/go.mod h1:ALpWdSbuNGy2lZWtyXdjkYv4edL23oSEgfBT1gPJ5BQ= | ||||
| github.com/mattn/go-sqlite3 v1.14.6 h1:dNPt6NO46WmLVt2DLNpwczCmdV5boIZ6g/tlDrlRUbg= | ||||
| github.com/mattn/go-sqlite3 v1.14.6/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= | ||||
| github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= | ||||
| github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= | ||||
| golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ= | ||||
| golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg= | ||||
| github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= | ||||
| github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= | ||||
| github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= | ||||
| github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= | ||||
| golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= | ||||
| golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= | ||||
| golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= | ||||
| golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= | ||||
| gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= | ||||
| gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= | ||||
|  | ||||
							
								
								
									
										14
									
								
								internal/auth/auth.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								internal/auth/auth.go
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,14 @@ | ||||
| package auth | ||||
| 
 | ||||
| import ( | ||||
| 	"context" | ||||
| 	"net/http" | ||||
| 
 | ||||
| 	"golang.org/x/oauth2" | ||||
| ) | ||||
| 
 | ||||
| type OAuth2ConfigInterface interface { | ||||
| 	AuthCodeURL(state string, opts ...oauth2.AuthCodeOption) string | ||||
| 	Exchange(ctx context.Context, code string, opts ...oauth2.AuthCodeOption) (*oauth2.Token, error) | ||||
| 	Client(ctx context.Context, t *oauth2.Token) *http.Client | ||||
| } | ||||
| @ -1,6 +1,7 @@ | ||||
| package mocks | ||||
| 
 | ||||
| import ( | ||||
| 	"errors" | ||||
| 	"time" | ||||
| 
 | ||||
| 	"git.32bit.cafe/32bitcafe/guestbook/internal/models" | ||||
| @ -38,6 +39,15 @@ func (m *UserModel) Insert(shortId uint64, username string, email string, passwo | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| func (m *UserModel) InsertWithoutPassword(shortId uint64, username string, email string, password string, settings models.UserSettings) (int64, error) { | ||||
| 	switch email { | ||||
| 	case "dupe@example.com": | ||||
| 		return -1, models.ErrDuplicateEmail | ||||
| 	default: | ||||
| 		return 2, nil | ||||
| 	} | ||||
| } | ||||
| 
 | ||||
| func (m *UserModel) Get(shortId uint64) (models.User, error) { | ||||
| 	switch shortId { | ||||
| 	case 1: | ||||
| @ -87,3 +97,26 @@ func (m *UserModel) UpdateUserSettings(userId int64, settings models.UserSetting | ||||
| func (m *UserModel) UpdateSetting(userId int64, setting models.Setting, value string) error { | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func (m *UserModel) GetBySubject(subject string) (int64, error) { | ||||
| 	if subject == "goodSubject" { | ||||
| 		return 1, nil | ||||
| 	} else if subject == "foo" { | ||||
| 		return -1, models.ErrNoRecord | ||||
| 	} | ||||
| 	return -1, errors.New("Unexpected Error") | ||||
| } | ||||
| 
 | ||||
| func (m *UserModel) GetByEmail(email string) (int64, error) { | ||||
| 	if email == "test@example.com" { | ||||
| 		return 1, nil | ||||
| 	} | ||||
| 	return -1, models.ErrNoRecord | ||||
| } | ||||
| 
 | ||||
| func (m *UserModel) UpdateSubject(userId int64, subject string) error { | ||||
| 	if userId == 1 { | ||||
| 		return nil | ||||
| 	} | ||||
| 	return errors.New("invalid") | ||||
| } | ||||
| @ -35,16 +35,28 @@ type UserModel struct { | ||||
| 	Settings map[string]Setting | ||||
| } | ||||
| 
 | ||||
| type UserIdToken struct { | ||||
| 	Subject       string   `json:"sub"` | ||||
| 	Email         string   `json:"email"` | ||||
| 	EmailVerified bool     `json:"email_verified"` | ||||
| 	Username      string   `json:"preferred_username"` | ||||
| 	Groups        []string `json:"groups"` | ||||
| } | ||||
| 
 | ||||
| type UserModelInterface interface { | ||||
| 	InitializeSettingsMap() error | ||||
| 	Insert(shortId uint64, username string, email string, password string, settings UserSettings) error | ||||
| 	InsertWithoutPassword(shortId uint64, username string, email string, subject string, settings UserSettings) (int64, error) | ||||
| 	Get(shortId uint64) (User, error) | ||||
| 	GetById(id int64) (User, error) | ||||
| 	GetByEmail(email string) (int64, error) | ||||
| 	GetBySubject(subject string) (int64, error) | ||||
| 	GetAll() ([]User, error) | ||||
| 	Authenticate(email, password string) (int64, error) | ||||
| 	Exists(id int64) (bool, error) | ||||
| 	UpdateUserSettings(userId int64, settings UserSettings) error | ||||
| 	UpdateSetting(userId int64, setting Setting, value string) error | ||||
| 	UpdateSubject(userId int64, subject string) error | ||||
| } | ||||
| 
 | ||||
| func (m *UserModel) InitializeSettingsMap() error { | ||||
| @ -126,6 +138,49 @@ func (m *UserModel) Insert(shortId uint64, username string, email string, passwo | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func (m *UserModel) InsertWithoutPassword(shortId uint64, username string, email string, subject string, settings UserSettings) (int64, error) { | ||||
| 	stmt := `INSERT INTO users (ShortId, Username, Email, IsBanned, OIDCSubject, Created) | ||||
|     VALUES (?, ?, ?, FALSE, ?, ?)` | ||||
| 	tx, err := m.DB.Begin() | ||||
| 	if err != nil { | ||||
| 		return -1, err | ||||
| 	} | ||||
| 	result, err := tx.Exec(stmt, shortId, username, email, subject, time.Now().UTC()) | ||||
| 	if err != nil { | ||||
| 		if rollbackErr := tx.Rollback(); rollbackErr != nil { | ||||
| 			return -1, err | ||||
| 		} | ||||
| 		if sqliteError, ok := err.(sqlite3.Error); ok { | ||||
| 			if sqliteError.ExtendedCode == 2067 && strings.Contains(sqliteError.Error(), "Email") { | ||||
| 				return -1, ErrDuplicateEmail | ||||
| 			} | ||||
| 		} | ||||
| 		return -1, err | ||||
| 	} | ||||
| 	id, err := result.LastInsertId() | ||||
| 	if err != nil { | ||||
| 		if rollbackErr := tx.Rollback(); rollbackErr != nil { | ||||
| 			return -1, err | ||||
| 		} | ||||
| 		return -1, err | ||||
| 	} | ||||
| 	stmt = `INSERT INTO user_settings (UserId, SettingId, AllowedSettingValueId, UnconstrainedValue)  | ||||
| 		VALUES (?, ?, ?, ?)` | ||||
| 	_, err = tx.Exec(stmt, id, m.Settings[SettingUserTimezone].id, nil, settings.LocalTimezone.String()) | ||||
| 	if err != nil { | ||||
| 		if rollbackErr := tx.Rollback(); rollbackErr != nil { | ||||
| 			return -1, err | ||||
| 		} | ||||
| 		return -1, err | ||||
| 	} | ||||
| 	err = tx.Commit() | ||||
| 	if err != nil { | ||||
| 		return -1, err | ||||
| 	} | ||||
| 
 | ||||
| 	return id, nil | ||||
| } | ||||
| 
 | ||||
| func (m *UserModel) Get(shortId uint64) (User, error) { | ||||
| 	stmt := `SELECT Id, ShortId, Username, Email, Created FROM users WHERE ShortId = ? AND Deleted IS NULL` | ||||
| 	tx, err := m.DB.Begin() | ||||
| @ -239,6 +294,44 @@ func (m *UserModel) Authenticate(email, password string) (int64, error) { | ||||
| 	return id, nil | ||||
| } | ||||
| 
 | ||||
| func (m *UserModel) GetByEmail(email string) (int64, error) { | ||||
| 	var id int64 | ||||
| 	stmt := `SELECT Id FROM users WHERE Email = ?` | ||||
| 	err := m.DB.QueryRow(stmt, email).Scan(&id) | ||||
| 	if err != nil { | ||||
| 		if errors.Is(err, sql.ErrNoRows) { | ||||
| 			return -1, ErrNoRecord | ||||
| 		} else { | ||||
| 			return -1, err | ||||
| 		} | ||||
| 	} | ||||
| 	return id, nil | ||||
| } | ||||
| 
 | ||||
| func (m *UserModel) GetBySubject(subject string) (int64, error) { | ||||
| 	var id int64 | ||||
| 	var s sql.NullString | ||||
| 	stmt := `SELECT Id, OIDCSubject FROM users WHERE OIDCSubject = ?` | ||||
| 	err := m.DB.QueryRow(stmt, subject).Scan(&id, &s) | ||||
| 	if err != nil { | ||||
| 		if errors.Is(err, sql.ErrNoRows) { | ||||
| 			return -1, ErrNoRecord | ||||
| 		} else { | ||||
| 			return -1, err | ||||
| 		} | ||||
| 	} | ||||
| 	return id, nil | ||||
| } | ||||
| 
 | ||||
| func (m *UserModel) UpdateSubject(userId int64, subject string) error { | ||||
| 	stmt := `UPDATE users SET OIDCSubject = ? WHERE Id = ?` | ||||
| 	_, err := m.DB.Exec(stmt, subject, userId) | ||||
| 	if err != nil { | ||||
| 		return err | ||||
| 	} | ||||
| 	return nil | ||||
| } | ||||
| 
 | ||||
| func (m *UserModel) Exists(id int64) (bool, error) { | ||||
| 	var exists bool | ||||
| 	stmt := `SELECT EXISTS(SELECT true FROM users WHERE Id = ? AND DELETED IS NULL)` | ||||
|  | ||||
							
								
								
									
										4
									
								
								migrations/000006_change_password_field.down.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								migrations/000006_change_password_field.down.sql
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,4 @@ | ||||
| ALTER TABLE users RENAME COLUMN HashedPassword TO HashedPasswordOld; | ||||
| ALTER TABLE users ADD COLUMN HashedPassword char(60) NOT NULL DEFAULT '0000'; | ||||
| UPDATE users SET HashedPassword=HashedPasswordOld; | ||||
| ALTER TABLE users DROP COLUMN HashedPasswordOld; | ||||
							
								
								
									
										4
									
								
								migrations/000006_change_password_field.up.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								migrations/000006_change_password_field.up.sql
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,4 @@ | ||||
| ALTER TABLE users RENAME COLUMN HashedPassword TO HashedPasswordOld; | ||||
| ALTER TABLE users ADD COLUMN HashedPassword char(60) NULL; | ||||
| UPDATE users SET HashedPassword=HashedPasswordOld; | ||||
| ALTER TABLE users DROP COLUMN HashedPasswordOld; | ||||
							
								
								
									
										1
									
								
								migrations/000007_add_oidc_subject.down.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								migrations/000007_add_oidc_subject.down.sql
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1 @@ | ||||
| ALTER TABLE users DROP COLUMN OIDCSubject; | ||||
							
								
								
									
										1
									
								
								migrations/000007_add_oidc_subject.up.sql
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								migrations/000007_add_oidc_subject.up.sql
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1 @@ | ||||
| ALTER TABLE users ADD COLUMN OIDCSubject varchar(255); | ||||
| @ -6,12 +6,15 @@ import "fmt" | ||||
| import "strings" | ||||
| 
 | ||||
| type CommonData struct { | ||||
| 	CurrentYear     int | ||||
| 	Flash           string | ||||
| 	IsAuthenticated bool | ||||
| 	CSRFToken       string | ||||
| 	CurrentUser     *models.User | ||||
| 	IsHtmx          bool | ||||
| 	CurrentYear      int | ||||
| 	Flash            string | ||||
| 	IsAuthenticated  bool | ||||
| 	CSRFToken        string | ||||
| 	CurrentUser      *models.User | ||||
| 	IsHtmx           bool | ||||
| 	RootUrl          string | ||||
| 	LocalAuthEnabled bool | ||||
| 	OIDCEnabled      bool | ||||
| } | ||||
| 
 | ||||
| func shortIdToSlug(shortId uint64) string { | ||||
| @ -51,7 +54,9 @@ templ topNav(data CommonData) { | ||||
| 				<a href="/users/settings">Settings</a> |  | ||||
| 				<a href="#" hx-post="/users/logout" hx-headers={ hxHeaders }>Logout</a> | ||||
| 			} else { | ||||
| 				<a href="/users/register">Create an Account</a> |  | ||||
| 				if data.LocalAuthEnabled { | ||||
| 					<a href="/users/register">Create an Account</a> |  | ||||
| 				} | ||||
| 				<a href="/users/login">Login</a> | ||||
| 			} | ||||
| 		</div> | ||||
|  | ||||
| @ -14,13 +14,15 @@ import "fmt" | ||||
| import "strings" | ||||
| 
 | ||||
| type CommonData struct { | ||||
| 	CurrentYear     int | ||||
| 	Flash           string | ||||
| 	IsAuthenticated bool | ||||
| 	CSRFToken       string | ||||
| 	CurrentUser     *models.User | ||||
| 	IsHtmx          bool | ||||
| 	RootUrl         string | ||||
| 	CurrentYear      int | ||||
| 	Flash            string | ||||
| 	IsAuthenticated  bool | ||||
| 	CSRFToken        string | ||||
| 	CurrentUser      *models.User | ||||
| 	IsHtmx           bool | ||||
| 	RootUrl          string | ||||
| 	LocalAuthEnabled bool | ||||
| 	OIDCEnabled      bool | ||||
| } | ||||
| 
 | ||||
| func shortIdToSlug(shortId uint64) string { | ||||
| @ -102,7 +104,7 @@ func topNav(data CommonData) templ.Component { | ||||
| 			var templ_7745c5c3_Var3 string | ||||
| 			templ_7745c5c3_Var3, templ_7745c5c3_Err = templ.JoinStringErrs(data.CurrentUser.Username) | ||||
| 			if templ_7745c5c3_Err != nil { | ||||
| 				return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/common.templ`, Line: 44, Col: 40} | ||||
| 				return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/common.templ`, Line: 47, Col: 40} | ||||
| 			} | ||||
| 			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var3)) | ||||
| 			if templ_7745c5c3_Err != nil { | ||||
| @ -121,7 +123,7 @@ func topNav(data CommonData) templ.Component { | ||||
| 			var templ_7745c5c3_Var4 string | ||||
| 			templ_7745c5c3_Var4, templ_7745c5c3_Err = templ.JoinStringErrs(hxHeaders) | ||||
| 			if templ_7745c5c3_Err != nil { | ||||
| 				return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/common.templ`, Line: 52, Col: 62} | ||||
| 				return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/common.templ`, Line: 55, Col: 62} | ||||
| 			} | ||||
| 			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var4)) | ||||
| 			if templ_7745c5c3_Err != nil { | ||||
| @ -132,12 +134,18 @@ func topNav(data CommonData) templ.Component { | ||||
| 				return templ_7745c5c3_Err | ||||
| 			} | ||||
| 		} else { | ||||
| 			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<a href=\"/users/register\">Create an Account</a> |  <a href=\"/users/login\">Login</a>") | ||||
| 			if data.LocalAuthEnabled { | ||||
| 				templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 7, "<a href=\"/users/register\">Create an Account</a> | ") | ||||
| 				if templ_7745c5c3_Err != nil { | ||||
| 					return templ_7745c5c3_Err | ||||
| 				} | ||||
| 			} | ||||
| 			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, " <a href=\"/users/login\">Login</a>") | ||||
| 			if templ_7745c5c3_Err != nil { | ||||
| 				return templ_7745c5c3_Err | ||||
| 			} | ||||
| 		} | ||||
| 		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 8, "</div></nav>") | ||||
| 		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "</div></nav>") | ||||
| 		if templ_7745c5c3_Err != nil { | ||||
| 			return templ_7745c5c3_Err | ||||
| 		} | ||||
| @ -166,7 +174,7 @@ func commonFooter() templ.Component { | ||||
| 			templ_7745c5c3_Var5 = templ.NopComponent | ||||
| 		} | ||||
| 		ctx = templ.ClearChildren(ctx) | ||||
| 		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 9, "<footer><p>A <a href=\"https://32bit.cafe\">32bit.cafe</a> Project</p></footer>") | ||||
| 		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<footer><p>A <a href=\"https://32bit.cafe\">32bit.cafe</a> Project</p></footer>") | ||||
| 		if templ_7745c5c3_Err != nil { | ||||
| 			return templ_7745c5c3_Err | ||||
| 		} | ||||
| @ -195,20 +203,20 @@ func base(title string, data CommonData) templ.Component { | ||||
| 			templ_7745c5c3_Var6 = templ.NopComponent | ||||
| 		} | ||||
| 		ctx = templ.ClearChildren(ctx) | ||||
| 		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 10, "<!doctype html><html lang=\"en\"><head><title>") | ||||
| 		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, "<!doctype html><html lang=\"en\"><head><title>") | ||||
| 		if templ_7745c5c3_Err != nil { | ||||
| 			return templ_7745c5c3_Err | ||||
| 		} | ||||
| 		var templ_7745c5c3_Var7 string | ||||
| 		templ_7745c5c3_Var7, templ_7745c5c3_Err = templ.JoinStringErrs(title) | ||||
| 		if templ_7745c5c3_Err != nil { | ||||
| 			return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/common.templ`, Line: 71, Col: 17} | ||||
| 			return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/common.templ`, Line: 76, Col: 17} | ||||
| 		} | ||||
| 		_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var7)) | ||||
| 		if templ_7745c5c3_Err != nil { | ||||
| 			return templ_7745c5c3_Err | ||||
| 		} | ||||
| 		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 11, " - webweav.ing</title><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><link href=\"/static/css/classless.min.css\" rel=\"stylesheet\"><link href=\"/static/css/style.css\" rel=\"stylesheet\"><script src=\"/static/js/htmx.min.js\"></script></head><body>") | ||||
| 		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, " - webweav.ing</title><meta charset=\"UTF-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><link href=\"/static/css/classless.min.css\" rel=\"stylesheet\"><link href=\"/static/css/style.css\" rel=\"stylesheet\"><script src=\"/static/js/htmx.min.js\"></script></head><body>") | ||||
| 		if templ_7745c5c3_Err != nil { | ||||
| 			return templ_7745c5c3_Err | ||||
| 		} | ||||
| @ -220,25 +228,25 @@ func base(title string, data CommonData) templ.Component { | ||||
| 		if templ_7745c5c3_Err != nil { | ||||
| 			return templ_7745c5c3_Err | ||||
| 		} | ||||
| 		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<main>") | ||||
| 		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<main>") | ||||
| 		if templ_7745c5c3_Err != nil { | ||||
| 			return templ_7745c5c3_Err | ||||
| 		} | ||||
| 		if data.Flash != "" { | ||||
| 			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<div class=\"flash\">") | ||||
| 			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "<div class=\"flash\">") | ||||
| 			if templ_7745c5c3_Err != nil { | ||||
| 				return templ_7745c5c3_Err | ||||
| 			} | ||||
| 			var templ_7745c5c3_Var8 string | ||||
| 			templ_7745c5c3_Var8, templ_7745c5c3_Err = templ.JoinStringErrs(data.Flash) | ||||
| 			if templ_7745c5c3_Err != nil { | ||||
| 				return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/common.templ`, Line: 83, Col: 36} | ||||
| 				return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/common.templ`, Line: 88, Col: 36} | ||||
| 			} | ||||
| 			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var8)) | ||||
| 			if templ_7745c5c3_Err != nil { | ||||
| 				return templ_7745c5c3_Err | ||||
| 			} | ||||
| 			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</div>") | ||||
| 			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</div>") | ||||
| 			if templ_7745c5c3_Err != nil { | ||||
| 				return templ_7745c5c3_Err | ||||
| 			} | ||||
| @ -247,7 +255,7 @@ func base(title string, data CommonData) templ.Component { | ||||
| 		if templ_7745c5c3_Err != nil { | ||||
| 			return templ_7745c5c3_Err | ||||
| 		} | ||||
| 		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "</main>") | ||||
| 		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</main>") | ||||
| 		if templ_7745c5c3_Err != nil { | ||||
| 			return templ_7745c5c3_Err | ||||
| 		} | ||||
| @ -255,7 +263,7 @@ func base(title string, data CommonData) templ.Component { | ||||
| 		if templ_7745c5c3_Err != nil { | ||||
| 			return templ_7745c5c3_Err | ||||
| 		} | ||||
| 		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</body></html>") | ||||
| 		templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</body></html>") | ||||
| 		if templ_7745c5c3_Err != nil { | ||||
| 			return templ_7745c5c3_Err | ||||
| 		} | ||||
|  | ||||
| @ -30,7 +30,10 @@ templ UserLogin(title string, data CommonData, form forms.UserLoginForm) { | ||||
| 				<input type="password" name="password"/> | ||||
| 			</div> | ||||
| 			<div> | ||||
| 				<input type="submit" value="login"/> | ||||
| 				<input type="submit" value="Login"/> | ||||
| 				if data.OIDCEnabled { | ||||
| 					<a href="/users/login/oidc">Login with SSO</a> | ||||
| 				} | ||||
| 			</div> | ||||
| 		</form> | ||||
| 	} | ||||
|  | ||||
| @ -143,7 +143,17 @@ func UserLogin(title string, data CommonData, form forms.UserLoginForm) templ.Co | ||||
| 					return templ_7745c5c3_Err | ||||
| 				} | ||||
| 			} | ||||
| 			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<input type=\"password\" name=\"password\"></div><div><input type=\"submit\" value=\"login\"></div></form>") | ||||
| 			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 12, "<input type=\"password\" name=\"password\"></div><div><input type=\"submit\" value=\"Login\"> ") | ||||
| 			if templ_7745c5c3_Err != nil { | ||||
| 				return templ_7745c5c3_Err | ||||
| 			} | ||||
| 			if data.OIDCEnabled { | ||||
| 				templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<a href=\"/users/login/oidc\">Login with SSO</a>") | ||||
| 				if templ_7745c5c3_Err != nil { | ||||
| 					return templ_7745c5c3_Err | ||||
| 				} | ||||
| 			} | ||||
| 			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "</div></form>") | ||||
| 			if templ_7745c5c3_Err != nil { | ||||
| 				return templ_7745c5c3_Err | ||||
| 			} | ||||
| @ -190,130 +200,130 @@ func UserRegistration(title string, data CommonData, form forms.UserRegistration | ||||
| 				}() | ||||
| 			} | ||||
| 			ctx = templ.InitializeContext(ctx) | ||||
| 			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 13, "<h1>User Registration</h1><form action=\"/users/register\" method=\"post\"><input type=\"hidden\" name=\"csrf_token\" value=\"") | ||||
| 			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<h1>User Registration</h1><form action=\"/users/register\" method=\"post\"><input type=\"hidden\" name=\"csrf_token\" value=\"") | ||||
| 			if templ_7745c5c3_Err != nil { | ||||
| 				return templ_7745c5c3_Err | ||||
| 			} | ||||
| 			var templ_7745c5c3_Var10 string | ||||
| 			templ_7745c5c3_Var10, templ_7745c5c3_Err = templ.JoinStringErrs(data.CSRFToken) | ||||
| 			if templ_7745c5c3_Err != nil { | ||||
| 				return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 43, Col: 64} | ||||
| 				return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 46, Col: 64} | ||||
| 			} | ||||
| 			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var10)) | ||||
| 			if templ_7745c5c3_Err != nil { | ||||
| 				return templ_7745c5c3_Err | ||||
| 			} | ||||
| 			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, "\"><div>") | ||||
| 			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "\"><div>") | ||||
| 			if templ_7745c5c3_Err != nil { | ||||
| 				return templ_7745c5c3_Err | ||||
| 			} | ||||
| 			error, exists := form.FieldErrors["name"] | ||||
| 			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<label for=\"username\">Username: </label> ") | ||||
| 			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "<label for=\"username\">Username: </label> ") | ||||
| 			if templ_7745c5c3_Err != nil { | ||||
| 				return templ_7745c5c3_Err | ||||
| 			} | ||||
| 			if exists { | ||||
| 				templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "<label class=\"error\">") | ||||
| 				templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<label class=\"error\">") | ||||
| 				if templ_7745c5c3_Err != nil { | ||||
| 					return templ_7745c5c3_Err | ||||
| 				} | ||||
| 				var templ_7745c5c3_Var11 string | ||||
| 				templ_7745c5c3_Var11, templ_7745c5c3_Err = templ.JoinStringErrs(error) | ||||
| 				if templ_7745c5c3_Err != nil { | ||||
| 					return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 48, Col: 33} | ||||
| 					return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 51, Col: 33} | ||||
| 				} | ||||
| 				_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var11)) | ||||
| 				if templ_7745c5c3_Err != nil { | ||||
| 					return templ_7745c5c3_Err | ||||
| 				} | ||||
| 				templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</label> ") | ||||
| 				templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</label> ") | ||||
| 				if templ_7745c5c3_Err != nil { | ||||
| 					return templ_7745c5c3_Err | ||||
| 				} | ||||
| 			} | ||||
| 			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<input type=\"text\" name=\"username\" id=\"username\" value=\"") | ||||
| 			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<input type=\"text\" name=\"username\" id=\"username\" value=\"") | ||||
| 			if templ_7745c5c3_Err != nil { | ||||
| 				return templ_7745c5c3_Err | ||||
| 			} | ||||
| 			var templ_7745c5c3_Var12 string | ||||
| 			templ_7745c5c3_Var12, templ_7745c5c3_Err = templ.JoinStringErrs(form.Name) | ||||
| 			if templ_7745c5c3_Err != nil { | ||||
| 				return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 50, Col: 70} | ||||
| 				return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 53, Col: 70} | ||||
| 			} | ||||
| 			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var12)) | ||||
| 			if templ_7745c5c3_Err != nil { | ||||
| 				return templ_7745c5c3_Err | ||||
| 			} | ||||
| 			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" required></div><div>") | ||||
| 			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" required></div><div>") | ||||
| 			if templ_7745c5c3_Err != nil { | ||||
| 				return templ_7745c5c3_Err | ||||
| 			} | ||||
| 			error, exists = form.FieldErrors["email"] | ||||
| 			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<label for=\"email\">Email: </label> ") | ||||
| 			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "<label for=\"email\">Email: </label> ") | ||||
| 			if templ_7745c5c3_Err != nil { | ||||
| 				return templ_7745c5c3_Err | ||||
| 			} | ||||
| 			if exists { | ||||
| 				templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "<label class=\"error\">") | ||||
| 				templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<label class=\"error\">") | ||||
| 				if templ_7745c5c3_Err != nil { | ||||
| 					return templ_7745c5c3_Err | ||||
| 				} | ||||
| 				var templ_7745c5c3_Var13 string | ||||
| 				templ_7745c5c3_Var13, templ_7745c5c3_Err = templ.JoinStringErrs(error) | ||||
| 				if templ_7745c5c3_Err != nil { | ||||
| 					return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 56, Col: 33} | ||||
| 					return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 59, Col: 33} | ||||
| 				} | ||||
| 				_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var13)) | ||||
| 				if templ_7745c5c3_Err != nil { | ||||
| 					return templ_7745c5c3_Err | ||||
| 				} | ||||
| 				templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</label> ") | ||||
| 				templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</label> ") | ||||
| 				if templ_7745c5c3_Err != nil { | ||||
| 					return templ_7745c5c3_Err | ||||
| 				} | ||||
| 			} | ||||
| 			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<input type=\"text\" name=\"email\" id=\"email\" value=\"") | ||||
| 			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<input type=\"text\" name=\"email\" id=\"email\" value=\"") | ||||
| 			if templ_7745c5c3_Err != nil { | ||||
| 				return templ_7745c5c3_Err | ||||
| 			} | ||||
| 			var templ_7745c5c3_Var14 string | ||||
| 			templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.JoinStringErrs(form.Email) | ||||
| 			if templ_7745c5c3_Err != nil { | ||||
| 				return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 58, Col: 65} | ||||
| 				return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 61, Col: 65} | ||||
| 			} | ||||
| 			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var14)) | ||||
| 			if templ_7745c5c3_Err != nil { | ||||
| 				return templ_7745c5c3_Err | ||||
| 			} | ||||
| 			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\" required></div><div>") | ||||
| 			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\" required></div><div>") | ||||
| 			if templ_7745c5c3_Err != nil { | ||||
| 				return templ_7745c5c3_Err | ||||
| 			} | ||||
| 			error, exists = form.FieldErrors["password"] | ||||
| 			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "<label for=\"password\">Password: </label> ") | ||||
| 			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "<label for=\"password\">Password: </label> ") | ||||
| 			if templ_7745c5c3_Err != nil { | ||||
| 				return templ_7745c5c3_Err | ||||
| 			} | ||||
| 			if exists { | ||||
| 				templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "<label class=\"error\">") | ||||
| 				templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<label class=\"error\">") | ||||
| 				if templ_7745c5c3_Err != nil { | ||||
| 					return templ_7745c5c3_Err | ||||
| 				} | ||||
| 				var templ_7745c5c3_Var15 string | ||||
| 				templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(error) | ||||
| 				if templ_7745c5c3_Err != nil { | ||||
| 					return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 64, Col: 33} | ||||
| 					return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 67, Col: 33} | ||||
| 				} | ||||
| 				_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15)) | ||||
| 				if templ_7745c5c3_Err != nil { | ||||
| 					return templ_7745c5c3_Err | ||||
| 				} | ||||
| 				templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "</label> ") | ||||
| 				templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "</label> ") | ||||
| 				if templ_7745c5c3_Err != nil { | ||||
| 					return templ_7745c5c3_Err | ||||
| 				} | ||||
| 			} | ||||
| 			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "<input type=\"password\" name=\"password\" id=\"password\"></div><div><input type=\"submit\" value=\"Register\"></div></form>") | ||||
| 			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "<input type=\"password\" name=\"password\" id=\"password\"></div><div><input type=\"submit\" value=\"Register\"></div></form>") | ||||
| 			if templ_7745c5c3_Err != nil { | ||||
| 				return templ_7745c5c3_Err | ||||
| 			} | ||||
| @ -360,33 +370,33 @@ func UserProfile(title string, data CommonData, user models.User) templ.Componen | ||||
| 				}() | ||||
| 			} | ||||
| 			ctx = templ.InitializeContext(ctx) | ||||
| 			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "<h1>") | ||||
| 			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "<h1>") | ||||
| 			if templ_7745c5c3_Err != nil { | ||||
| 				return templ_7745c5c3_Err | ||||
| 			} | ||||
| 			var templ_7745c5c3_Var18 string | ||||
| 			templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(user.Username) | ||||
| 			if templ_7745c5c3_Err != nil { | ||||
| 				return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 77, Col: 21} | ||||
| 				return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 80, Col: 21} | ||||
| 			} | ||||
| 			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18)) | ||||
| 			if templ_7745c5c3_Err != nil { | ||||
| 				return templ_7745c5c3_Err | ||||
| 			} | ||||
| 			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "</h1><p>") | ||||
| 			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "</h1><p>") | ||||
| 			if templ_7745c5c3_Err != nil { | ||||
| 				return templ_7745c5c3_Err | ||||
| 			} | ||||
| 			var templ_7745c5c3_Var19 string | ||||
| 			templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.JoinStringErrs(user.Email) | ||||
| 			if templ_7745c5c3_Err != nil { | ||||
| 				return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 78, Col: 17} | ||||
| 				return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 81, Col: 17} | ||||
| 			} | ||||
| 			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var19)) | ||||
| 			if templ_7745c5c3_Err != nil { | ||||
| 				return templ_7745c5c3_Err | ||||
| 			} | ||||
| 			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "</p>") | ||||
| 			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "</p>") | ||||
| 			if templ_7745c5c3_Err != nil { | ||||
| 				return templ_7745c5c3_Err | ||||
| 			} | ||||
| @ -434,89 +444,89 @@ func UserSettingsView(data CommonData, timezones []string) templ.Component { | ||||
| 				}() | ||||
| 			} | ||||
| 			ctx = templ.InitializeContext(ctx) | ||||
| 			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "<div><h1>User Settings</h1><form hx-put=\"/users/settings\"><input type=\"hidden\" name=\"csrf_token\" value=\"") | ||||
| 			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<div><h1>User Settings</h1><form hx-put=\"/users/settings\"><input type=\"hidden\" name=\"csrf_token\" value=\"") | ||||
| 			if templ_7745c5c3_Err != nil { | ||||
| 				return templ_7745c5c3_Err | ||||
| 			} | ||||
| 			var templ_7745c5c3_Var22 string | ||||
| 			templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.JoinStringErrs(data.CSRFToken) | ||||
| 			if templ_7745c5c3_Err != nil { | ||||
| 				return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 88, Col: 65} | ||||
| 				return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 91, Col: 65} | ||||
| 			} | ||||
| 			_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var22)) | ||||
| 			if templ_7745c5c3_Err != nil { | ||||
| 				return templ_7745c5c3_Err | ||||
| 			} | ||||
| 			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\"> <label>Local Timezone</label> <select name=\"timezones\" id=\"timezone-select\">") | ||||
| 			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\"> <label>Local Timezone</label> <select name=\"timezones\" id=\"timezone-select\">") | ||||
| 			if templ_7745c5c3_Err != nil { | ||||
| 				return templ_7745c5c3_Err | ||||
| 			} | ||||
| 			for _, tz := range timezones { | ||||
| 				if tz == user.Settings.LocalTimezone.String() { | ||||
| 					templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<option value=\"") | ||||
| 					templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "<option value=\"") | ||||
| 					if templ_7745c5c3_Err != nil { | ||||
| 						return templ_7745c5c3_Err | ||||
| 					} | ||||
| 					var templ_7745c5c3_Var23 string | ||||
| 					templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.JoinStringErrs(tz) | ||||
| 					if templ_7745c5c3_Err != nil { | ||||
| 						return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 93, Col: 25} | ||||
| 						return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 96, Col: 25} | ||||
| 					} | ||||
| 					_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var23)) | ||||
| 					if templ_7745c5c3_Err != nil { | ||||
| 						return templ_7745c5c3_Err | ||||
| 					} | ||||
| 					templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\" selected=\"true\">") | ||||
| 					templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "\" selected=\"true\">") | ||||
| 					if templ_7745c5c3_Err != nil { | ||||
| 						return templ_7745c5c3_Err | ||||
| 					} | ||||
| 					var templ_7745c5c3_Var24 string | ||||
| 					templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.JoinStringErrs(tz) | ||||
| 					if templ_7745c5c3_Err != nil { | ||||
| 						return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 93, Col: 48} | ||||
| 						return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 96, Col: 48} | ||||
| 					} | ||||
| 					_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var24)) | ||||
| 					if templ_7745c5c3_Err != nil { | ||||
| 						return templ_7745c5c3_Err | ||||
| 					} | ||||
| 					templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</option>") | ||||
| 					templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "</option>") | ||||
| 					if templ_7745c5c3_Err != nil { | ||||
| 						return templ_7745c5c3_Err | ||||
| 					} | ||||
| 				} else { | ||||
| 					templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "<option value=\"") | ||||
| 					templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<option value=\"") | ||||
| 					if templ_7745c5c3_Err != nil { | ||||
| 						return templ_7745c5c3_Err | ||||
| 					} | ||||
| 					var templ_7745c5c3_Var25 string | ||||
| 					templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.JoinStringErrs(tz) | ||||
| 					if templ_7745c5c3_Err != nil { | ||||
| 						return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 95, Col: 25} | ||||
| 						return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 98, Col: 25} | ||||
| 					} | ||||
| 					_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var25)) | ||||
| 					if templ_7745c5c3_Err != nil { | ||||
| 						return templ_7745c5c3_Err | ||||
| 					} | ||||
| 					templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "\">") | ||||
| 					templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\">") | ||||
| 					if templ_7745c5c3_Err != nil { | ||||
| 						return templ_7745c5c3_Err | ||||
| 					} | ||||
| 					var templ_7745c5c3_Var26 string | ||||
| 					templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.JoinStringErrs(tz) | ||||
| 					if templ_7745c5c3_Err != nil { | ||||
| 						return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 95, Col: 32} | ||||
| 						return templ.Error{Err: templ_7745c5c3_Err, FileName: `ui/views/users.templ`, Line: 98, Col: 32} | ||||
| 					} | ||||
| 					_, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var26)) | ||||
| 					if templ_7745c5c3_Err != nil { | ||||
| 						return templ_7745c5c3_Err | ||||
| 					} | ||||
| 					templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</option>") | ||||
| 					templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</option>") | ||||
| 					if templ_7745c5c3_Err != nil { | ||||
| 						return templ_7745c5c3_Err | ||||
| 					} | ||||
| 				} | ||||
| 			} | ||||
| 			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</select> <input type=\"submit\" value=\"Submit\"></form></div>") | ||||
| 			templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</select> <input type=\"submit\" value=\"Submit\"></form></div>") | ||||
| 			if templ_7745c5c3_Err != nil { | ||||
| 				return templ_7745c5c3_Err | ||||
| 			} | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user