diff --git a/.github/workflows/backend-tests.yml b/.github/workflows/backend-tests.yml index bfb62f0d5dedc..06a2de9e0784f 100644 --- a/.github/workflows/backend-tests.yml +++ b/.github/workflows/backend-tests.yml @@ -37,11 +37,11 @@ jobs: go mod tidy -go=${{ env.GO_VERSION }} git diff --exit-code + - name: Install golangci-lint + run: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.13.1 + - name: Run golangci-lint - uses: golangci/golangci-lint-action@v9 - with: - version: v2.13.1 - args: --timeout=3m + run: '"$(go env GOPATH)/bin/golangci-lint" run --timeout=3m' tests: name: Tests (${{ matrix.test-group }}) diff --git a/.gitignore b/.gitignore index 1cfc6e0e69b82..87f184baf778e 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,16 @@ dist/ # when --config.store-dir is set without an existing store; it contains a # symlink back to the workspace. .pnpm-store/ +# BEGIN RunWield owned runtime state +.wld/plan-locks +.wld/plan-transitions +.wld/plan-backups +.wld/plan-staging +.wld/worktrees +.wld/debug +.wld/controller +.wld/worktrees.json +.wld/worktrees.lock +.wld/worktree-registry-migration-issues.json +.wld/collaboration-secrets.json +# END RunWield owned runtime state diff --git a/.wld/settings.json b/.wld/settings.json new file mode 100644 index 0000000000000..111123dfccffd --- /dev/null +++ b/.wld/settings.json @@ -0,0 +1,3 @@ +{ + "verification_command": "go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.13.1 && go install github.com/bufbuild/buf/cmd/buf@v1.60.0 && \"$(go env GOPATH)/bin/golangci-lint\" run --timeout=3m && go test -p 1 ./... && cd web && pnpm lint && pnpm test && pnpm build && cd ../proto && \"$(go env GOPATH)/bin/buf\" lint && buf_format_output=$(\"$(go env GOPATH)/bin/buf\" format -d) && if [[ -n \"$buf_format_output\" ]]; then echo \"❌ Proto files are not formatted. Run 'buf format -w' to fix.\"; exit 1; fi" +} diff --git a/AGENTS.md b/AGENTS.md index efc3ff26bbd77..d7b8154cf179a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,8 +34,9 @@ go test -v -race ./server/... # Server tests with race detector go test -v -race ./internal/... # Internal package tests with race detector go test -v -run TestFoo ./pkg/... # Run matching Go tests go mod tidy -go=1.27.0 # Match CI tidy check -golangci-lint run # Go lint, config: .golangci.yaml -golangci-lint run --fix # Auto-fix lint, including goimports +go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.13.1 +"$(go env GOPATH)/bin/golangci-lint" run # Go lint, config: .golangci.yaml +"$(go env GOPATH)/bin/golangci-lint" run --fix # Auto-fix lint, including goimports # Frontend cd web && pnpm install # Install dependencies diff --git a/docs/plans/serve-memo-markdown.md b/docs/plans/serve-memo-markdown.md new file mode 100644 index 0000000000000..0ee228e0c58a5 --- /dev/null +++ b/docs/plans/serve-memo-markdown.md @@ -0,0 +1,137 @@ +--- +planId: "8dc103a7-d845-483f-b89b-800768e7d419" +classification: "PLANNED_CHANGE" +workKind: "FEATURE" +complexity: "MEDIUM" +affectedPaths: + - "server/router/api/v1/memo_markdown.go" + - "server/router/api/v1/memo_markdown_test.go" + - "server/server.go" +tickets: + - url: "https://github.com/usememos/memos/issues/6229" +executionAgent: "engineer" +collaborationRecommendation: "autonomous" +devServerCommand: "go run ./cmd/memos --port 8081" +devServerUrl: "http://localhost:8081" +devServerHmr: false +createdAt: "2026-08-26T10:19:24-04:00" +status: "validated" +origin: "internal" +userVerifiedAt: null +workRecord: + status: "generated" + recordId: "74ebcec3-e915-4e05-a656-951a72901eeb" + path: "docs/work-records/2026-08-26-served-raw-memo-markdown-from-memo-urls.md" + lastAttemptAt: "2026-08-26T16:33:40.728Z" +routingIntent: "PLANNED_CHANGE" +sessionName: "issue 6229 implementation" +targetBranch: "main" +--- + +# Serve Memo Markdown from Memo URLs + +## Context + +GitHub issue [#6229](https://github.com/usememos/memos/issues/6229) asks Memos to return a memo's Markdown source without the current open, select, and copy workflow. A caller must be able to append `.md` to a memo URL or explicitly request `text/markdown` from the normal memo URL. + +Today, `GET /memos/{memo UID}` falls through to the React single-page application. The page then calls the v1 Memo API. The server has no native Markdown representation for that browser URL. + +The agreed access scope is permission parity with normal memo reads. Anonymous callers can read eligible public memos. Authenticated callers can read the protected, private, archived, or Space memos that the existing memo access policy permits. Memo share URLs and share tokens are not part of this change. + +The repository is a fork with `origin` set to `gandazgul/memos`. After verification, the implementation is expected to be pushed on a feature branch and opened as a pull request to `usememos/memos:main` that closes issue #6229. + +## Objective + +Add a native HTTP Markdown representation for memo detail URLs: + +- `GET /memos/{memo UID}.md` returns the exact Memo Markdown. +- `GET /memos/{memo UID}` returns the exact Memo Markdown when `Accept` explicitly permits `text/markdown` with positive quality. +- Other `GET /memos/{memo UID}` requests continue to return the React application. +- The representation uses the existing authentication and memo read policy. It does not create a second access model or grant access through memo share tokens. + +## Approach + +Keep the adapter in `server/router/api/v1`, next to the service that already owns memo reads and their authorization rules. Register one native Echo route after the frontend fallback middleware is installed and before the generated gateways are registered. + +```text +GET /memos/{uid}[.md] + -> Markdown requested by suffix or explicit Accept header? + no -> return 404 to existing SPA fallback -> index.html + yes -> load memo by UID + -> resolve anonymous access first + -> if needed, authenticate bearer/PAT/refresh-cookie caller + -> access.ResolveMemoReadFacts + CheckMemoReadContext + -> exact memo.Content as text/markdown +``` + +The handler must use `access.ResolveMemoReadFacts`, `MemoReadFacts.WithViewer`, and `access.CheckMemoReadContext`, or a package-local service helper built on those owners. It must not copy the audience rules into the handler. Evaluate anonymous access before parsing credentials, as `server/router/fileserver/fileserver.go` does, so an expired browser cookie cannot break an otherwise valid public read. + +For `Accept`, parse comma-separated media ranges and their parameters. `text/markdown` is selected only when its quality is greater than zero. A wildcard such as `*/*`, a malformed range, or `text/markdown;q=0` does not select Markdown. A terminal `.md` suffix always selects Markdown, independent of `Accept`. + +Successful Markdown responses are inline and use `Content-Type: text/markdown; charset=utf-8`, `X-Content-Type-Options: nosniff`, `Vary: Accept`, and the existing API no-store headers. Do not add `Content-Disposition`; the issue asks for a directly consumable representation, not a forced download. + +The set-aside option was a new top-level Markdown router. It would make transport ownership less clear and duplicate sensitive private API access behavior. + +## Expected Change Surface + +The boundaries this change is expected to touch. This list is guidance, not an allowlist: verify the real footprint +during implementation and change whatever the Implementation Steps need, including files not named here. Stop and report +only when discovery changes approved intent — the change reaches another subsystem, public behavior or architecture +shifts, migration or compatibility risk grows, or the Verification Plan no longer proves the objective. + +- `server/router/api/v1/memo_markdown.go` — own HTTP negotiation, route registration, memo lookup, request authentication, access-decision-to-HTTP mapping, and the Markdown response. +- `server/router/api/v1/memo_markdown_test.go` — prove both request forms, exact source output, permission parity, share-token exclusion, safe headers, and React fallback preservation through real Echo routes. +- `server/server.go` — register the memo Markdown route on the constructed v1 API service before the generated gateway routes. +- `server/router/api/v1/memo_access.go` — reuse its existing memo access helpers; change it only if a small package-local extraction is necessary to avoid duplicate policy resolution. +- `server/router/frontend/frontend.go` — preserve its current SPA fallback contract; change it only if implementation proves that the negotiated route cannot coexist with the fallback without a focused adjustment. + +`docs/domain-language.md` already defines **Memo Markdown** as the memo's Markdown source text. This change makes that existing term available through another representation and does not redefine the term, so no glossary update is expected. + +## Reuse Opportunities + +- `server/access/memo_resolve.go` — reuse `ResolveMemoReadFacts` and `MemoReadFacts.WithViewer` to resolve creator, Space, and membership facts. +- `server/access/memo.go` — reuse `CheckMemoReadContext`; it remains the source of truth for memo audience and lifecycle access. +- `server/auth/authenticator.go` — reuse `Authenticator.AuthenticateToUser` for bearer access tokens, Personal Access Tokens, and refresh-token browser sessions. +- `server/router/fileserver/fileserver.go` — follow `checkAttachmentPermission` ordering: authorize an anonymous public read before credential parsing, then resolve the viewer and evaluate the same facts. +- `server/router/api/v1/v1.go` — reuse `setAPIResponseNoStoreHeaders` for revocation-safe caching behavior. +- `server/router/frontend/frontend.go` — rely on `spaFallbackMiddleware` to serve `index.html` after a non-Markdown memo request returns Echo 404. +- `store/memo.go` — use `Store.GetMemo` with `store.FindMemo{UID: ...}` and return `Memo.Content` without rendering or conversion. + +## Implementation Steps + +- [ ] `APIV1Service` registers a native `GET /memos/:uid` handler that selects Markdown for a terminal `.md` suffix or an explicit acceptable `text/markdown` media range, while wildcard, malformed, and zero-quality ranges do not select Markdown. +- [ ] A non-Markdown `GET /memos/{memo UID}` returns control through the existing not-found path so `spaFallbackMiddleware` still serves the React `index.html`; it never returns memo source from a default browser or `*/*` request. +- [ ] A selected Markdown request loads exactly one memo by its suffix-free Memo UID and returns its unrendered `Memo.Content` byte-for-byte with HTTP 200, including empty content and non-ASCII content. +- [ ] Markdown reads use `access.ResolveMemoReadFacts`, viewer resolution, and `access.CheckMemoReadContext` as the authorization owner. Public reads are evaluated before credentials; protected, private, archived-owner, and Space reads require the same viewer facts as normal memo reads. +- [ ] The HTTP adapter maps missing or policy-hidden memos to 404, missing required authentication to 401, authenticated permission denial to 403, and store or access-resolution failures to 500 without exposing internal error details. +- [ ] A valid memo share token does not authorize either Markdown request form. No route is added for `/memos/shares/:token`, and the Markdown handler does not load or evaluate memo shares. +- [ ] Every successful Markdown response has `Content-Type: text/markdown; charset=utf-8`, `X-Content-Type-Options: nosniff`, `Vary: Accept`, and `Cache-Control: no-cache, no-store, must-revalidate` with the related `Pragma` and `Expires` headers; it has no forced-download `Content-Disposition` header. +- [ ] Behavioral tests exercise the registered Echo route and fail if the handler is a placeholder, a pass-through to the SPA, a rendered-HTML response, or a local audience approximation. They cover both request forms, exact Markdown content, normal HTML fallback, explicit Accept quality handling, a public memo allowed anonymously only in public instance mode, public access with a stale cookie, protected authenticated access, private owner success and non-owner denial, Space active-member success and non-member denial, archived-owner success and non-owner hiding, an unknown UID, and share-token exclusion. +- [ ] `server/server.go` registers the completed route on the same `APIV1Service` used by the generated gateways, after frontend middleware installation and before gateway registration, without changing existing API or frontend route paths. +- [ ] The finished change is committed on a feature branch, pushed to `gandazgul/memos`, and opened as a pull request against `usememos/memos:main`; the PR title describes direct Memo Markdown access, its body summarizes behavior and verification, and `Closes usememos/memos#6229` links the implementation to the upstream issue. + +## Approval Confirmation + +No Work Record is superseded by this Plan. + +## Verification Plan + +- Automated behavior: run `go test -v -race ./server/router/api/v1/...`. The new route tests must assert the exact Markdown body from real stored memos for both `.md` and `Accept` forms. A named permission-parity table must also prove that a public memo is denied anonymously in private instance mode, an active Space member succeeds, a Space non-member fails, a private owner succeeds, and a private non-owner fails. These assertions fail if the route returns the SPA, rendered HTML, an empty stub, unconditional pass-through content, or a local visibility switch that omits instance or Space facts. +- Automated server regression: run `go test -v -race ./server/...` to protect API authentication, shared access policy, frontend fallback, and route registration behavior. +- Automated repository checks: run `golangci-lint run` and `go test ./...` before opening the pull request. +- Manual public flow: start `go run ./cmd/memos --port 8081`, create an eligible public memo with distinctive Markdown such as a heading and fenced code block, then run `curl -i http://localhost:8081/memos/{uid}.md` and `curl -i -H 'Accept: text/markdown' http://localhost:8081/memos/{uid}`. Both responses must be HTTP 200 and their bodies must exactly match the stored source, not rendered HTML. +- Manual negotiation flow: open `http://localhost:8081/memos/{uid}` in a browser or run `curl -i -H 'Accept: text/html,*/*' ...`. The React application must load as before. `curl -i -H 'Accept: text/markdown;q=0,*/*' ...` must also use the HTML path. +- Manual protected flow: use `curl -i -H 'Authorization: Bearer {PAT}' -H 'Accept: text/markdown' http://localhost:8081/memos/{uid}` for a protected memo and for a Space memo where the PAT owner is an active member. Both must return 200. Repeat without credentials and expect 401; repeat as an authenticated private-memo non-owner or Space non-member and expect 403. In private instance mode, an anonymous public-memo request must return 401. An archived memo requested by a non-owner must remain hidden with 404. +- Manual header check: confirm Markdown responses include the exact Markdown content type, no-store headers, `Vary: Accept`, and `X-Content-Type-Options: nosniff`, with no `Content-Disposition: attachment`. +- Pull request check: use `gh pr view --repo usememos/memos` after creation to confirm base `main`, head repository/branch, issue-closing reference, and the commands reported in the PR body. + +## Edge Cases & Considerations + +- Content negotiation must not treat the browser's common `*/*` range as a Markdown request. Otherwise all memo pages become raw text. +- Only a terminal, case-sensitive `.md` suffix is removed from the route parameter. The base UID is looked up normally; malformed and unknown values return 404 without revealing storage details. +- The response is exact Memo Markdown. It does not expand attachments, append metadata, render Markdown, include comments, or rewrite links. +- Anonymous authorization runs before request credential parsing. An expired refresh cookie must not turn a public memo into a server error. +- Permission parity includes the memo's own audience, lifecycle, creator validity, Space validity, and active Space membership. Relations and comments do not transfer access. +- No database, Protocol Buffer, generated output, frontend component, or public API access-control-list change is expected. +- No upstream remote is configured. The pull request must use an explicit upstream repository, for example `gh pr create --repo usememos/memos --base main --head gandazgul:`, after pushing the branch to `origin`. +- Pre-existing dirty work (`CONTEXT.md`, `.wld/`, `docs/domain-language.md`, and `docs/issues/`) must remain untouched and must not be included in the implementation commit or pull request. diff --git a/docs/work-records/2026-08-26-served-raw-memo-markdown-from-memo-urls.md b/docs/work-records/2026-08-26-served-raw-memo-markdown-from-memo-urls.md new file mode 100644 index 0000000000000..b78c7fa8ffd13 --- /dev/null +++ b/docs/work-records/2026-08-26-served-raw-memo-markdown-from-memo-urls.md @@ -0,0 +1,28 @@ +--- +kind: "work_record" +recordId: "74ebcec3-e915-4e05-a656-951a72901eeb" +status: "approved" +scope: "planned_change" +workKind: "FEATURE" +origin: "internal" +completionMode: "verified" +createdAt: "2026-08-26T16:33:40.728Z" +tickets: + - url: "https://github.com/usememos/memos/issues/6229" +provenance: + sourcePlans: + - "8dc103a7-d845-483f-b89b-800768e7d419" +--- +# Served raw Memo Markdown from memo URLs + +## Summary + +Implemented verified native Markdown responses for memo detail URLs. `GET /memos/{uid}.md` and explicit `Accept: text/markdown` on `GET /memos/{uid}` now return exact memo source with existing memo-read authorization, safe no-store Markdown headers, and preserved SPA fallback for browser/default requests. The change closes upstream issue #6229 and was delivered in PR #6235. + +## Deviations from Plan + +Verification used CI-compatible golangci-lint v2.13.1 because the preinstalled v2.12.2 could not load the Go 1.27 config. Broad `go test ./...` was killed under default package parallelism, then passed with `go test -p 1 ./...`. + +## Future Planning Notes + +Native representation routes that overlap SPA URLs must be registered before generated gateway routes and must treat wildcard Accept headers as HTML fallback, not content negotiation for raw data. Reusing `ResolveMemoReadFacts`, viewer facts, and `CheckMemoReadContext` kept authorization aligned with normal memo reads, and evaluating anonymous public access before credential parsing preserved stale-cookie public reads. \ No newline at end of file diff --git a/server/auth/authenticator.go b/server/auth/authenticator.go index c3208b1881ad1..28dbd4c904dfc 100644 --- a/server/auth/authenticator.go +++ b/server/auth/authenticator.go @@ -2,6 +2,7 @@ package auth import ( "context" + "database/sql" "log/slog" "strings" "time" @@ -28,6 +29,36 @@ type Authenticator struct { secret string } +var errInvalidCredential = errors.New("invalid credential") + +type invalidCredentialError struct { + reason string + cause error +} + +func (e *invalidCredentialError) Error() string { + if e.cause == nil { + return e.reason + } + return e.reason + ": " + e.cause.Error() +} + +func (e *invalidCredentialError) Unwrap() error { + return e.cause +} + +func (*invalidCredentialError) Is(target error) bool { + return target == errInvalidCredential +} + +func newInvalidCredentialError(reason string, cause error) error { + return &invalidCredentialError{reason: reason, cause: cause} +} + +func isInvalidCredentialError(err error) bool { + return errors.Is(err, errInvalidCredential) +} + // NewAuthenticator creates a new Authenticator instance. func NewAuthenticator(store *store.Store, secret string) *Authenticator { return &Authenticator{ @@ -41,12 +72,12 @@ func NewAuthenticator(store *store.Store, secret string) *Authenticator { func (a *Authenticator) AuthenticateByAccessTokenV2(accessToken string) (*UserClaims, error) { claims, err := ParseAccessTokenV2(accessToken, []byte(a.secret)) if err != nil { - return nil, errors.Wrap(err, "invalid access token") + return nil, newInvalidCredentialError("invalid access token", err) } userID, err := util.ConvertStringToInt32(claims.Subject) if err != nil { - return nil, errors.Wrap(err, "invalid user ID in token") + return nil, newInvalidCredentialError("invalid user ID in token", err) } return &UserClaims{ @@ -61,12 +92,12 @@ func (a *Authenticator) AuthenticateByAccessTokenV2(accessToken string) (*UserCl func (a *Authenticator) AuthenticateByRefreshToken(ctx context.Context, refreshToken string) (*store.User, string, error) { claims, err := ParseRefreshToken(refreshToken, []byte(a.secret)) if err != nil { - return nil, "", errors.Wrap(err, "invalid refresh token") + return nil, "", newInvalidCredentialError("invalid refresh token", err) } userID, err := util.ConvertStringToInt32(claims.Subject) if err != nil { - return nil, "", errors.Wrap(err, "invalid user ID in token") + return nil, "", newInvalidCredentialError("invalid user ID in token", err) } // Check token exists in database (revocation check) @@ -75,12 +106,12 @@ func (a *Authenticator) AuthenticateByRefreshToken(ctx context.Context, refreshT return nil, "", errors.Wrap(err, "failed to get refresh token") } if token == nil { - return nil, "", errors.New("refresh token revoked") + return nil, "", newInvalidCredentialError("refresh token revoked", nil) } // Check token not expired if token.ExpiresAt != nil && token.ExpiresAt.AsTime().Before(time.Now()) { - return nil, "", errors.New("refresh token expired") + return nil, "", newInvalidCredentialError("refresh token expired", nil) } // Get user @@ -89,10 +120,10 @@ func (a *Authenticator) AuthenticateByRefreshToken(ctx context.Context, refreshT return nil, "", errors.Wrap(err, "failed to get user") } if user == nil { - return nil, "", errors.New("user not found") + return nil, "", newInvalidCredentialError("user not found", nil) } if user.RowStatus == store.Archived { - return nil, "", errors.New("user is archived") + return nil, "", newInvalidCredentialError("user is archived", nil) } return user, claims.TokenID, nil @@ -101,23 +132,26 @@ func (a *Authenticator) AuthenticateByRefreshToken(ctx context.Context, refreshT // AuthenticateByPAT validates a Personal Access Token. func (a *Authenticator) AuthenticateByPAT(ctx context.Context, token string) (*store.User, *storepb.PersonalAccessTokensUserSetting_PersonalAccessToken, error) { if !strings.HasPrefix(token, PersonalAccessTokenPrefix) { - return nil, nil, errors.New("invalid PAT format") + return nil, nil, newInvalidCredentialError("invalid PAT format", nil) } tokenHash := HashPersonalAccessToken(token) result, err := a.store.GetUserByPATHash(ctx, tokenHash) if err != nil { - return nil, nil, errors.Wrap(err, "invalid PAT") + if errors.Is(err, sql.ErrNoRows) { + return nil, nil, newInvalidCredentialError("PAT not found", err) + } + return nil, nil, errors.Wrap(err, "failed to get PAT") } // Check expiry if result.PAT.ExpiresAt != nil && result.PAT.ExpiresAt.AsTime().Before(time.Now()) { - return nil, nil, errors.New("PAT expired") + return nil, nil, newInvalidCredentialError("PAT expired", nil) } // Check user status if result.User.RowStatus == store.Archived { - return nil, nil, errors.New("user is archived") + return nil, nil, newInvalidCredentialError("user is archived", nil) } return result.User, result.PAT, nil @@ -169,7 +203,14 @@ func (a *Authenticator) resolveBearer(ctx context.Context, token string) (*beare } // Personal Access Token. - if user, pat, err := a.AuthenticateByPAT(ctx, token); err == nil && user != nil { + user, pat, err := a.AuthenticateByPAT(ctx, token) + if err != nil { + if isInvalidCredentialError(err) { + return nil, nil + } + return nil, err + } + if user != nil { a.recordPATUsage(user.ID, pat.TokenId) return &bearerAuth{user: user, pat: pat}, nil } @@ -202,6 +243,9 @@ func (a *Authenticator) AuthenticateToUser(ctx context.Context, authHeader, cook if cookieHeader != "" { if refreshToken := ExtractRefreshTokenFromCookie(cookieHeader); refreshToken != "" { user, _, err := a.AuthenticateByRefreshToken(ctx, refreshToken) + if isInvalidCredentialError(err) { + return nil, nil + } return user, err } } diff --git a/server/auth/authenticator_test.go b/server/auth/authenticator_test.go index f6a866a89a369..35d773623a600 100644 --- a/server/auth/authenticator_test.go +++ b/server/auth/authenticator_test.go @@ -2,9 +2,21 @@ package auth import ( "context" + stderrors "errors" "testing" + "time" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/usememos/memos/internal/profile" + storepb "github.com/usememos/memos/proto/gen/store" + "github.com/usememos/memos/store" + "github.com/usememos/memos/store/db" + + // sqlite driver for focused authenticator tests. + _ "modernc.org/sqlite" ) // TestAuthenticateNoCredentials covers the store-free paths: absent or malformed @@ -32,3 +44,102 @@ func TestAuthenticateNoCredentials(t *testing.T) { assert.Nil(t, user) }) } + +// TestAuthenticateToUserCredentialFailuresBecomeAnonymous verifies bad stored credentials do not surface as server errors. +func TestAuthenticateToUserCredentialFailuresBecomeAnonymous(t *testing.T) { + ctx := context.Background() + st := newAuthenticatorTestingStore(ctx, t) + t.Cleanup(func() { st.Close() }) + user, err := st.CreateUser(ctx, &store.User{Username: "auth-credential-user", Role: store.RoleUser}) + require.NoError(t, err) + a := NewAuthenticator(st, "test-secret") + + t.Run("unknown PAT", func(t *testing.T) { + viewer, err := a.AuthenticateToUser(ctx, "Bearer "+PersonalAccessTokenPrefix+"missing", "") + require.NoError(t, err) + require.Nil(t, viewer) + }) + + t.Run("revoked refresh token", func(t *testing.T) { + refreshToken, _, err := GenerateRefreshToken(user.ID, "revoked-token", []byte("test-secret")) + require.NoError(t, err) + viewer, err := a.AuthenticateToUser(ctx, "", RefreshTokenCookieName+"="+refreshToken) + require.NoError(t, err) + require.Nil(t, viewer) + }) + + t.Run("expired refresh token", func(t *testing.T) { + const tokenID = "expired-token" + refreshToken, _, err := GenerateRefreshToken(user.ID, tokenID, []byte("test-secret")) + require.NoError(t, err) + require.NoError(t, st.AddUserRefreshToken(ctx, user.ID, &storepb.RefreshTokensUserSetting_RefreshToken{ + TokenId: tokenID, + ExpiresAt: timestamppb.New(time.Now().Add(-time.Hour)), + CreatedAt: timestamppb.Now(), + })) + viewer, err := a.AuthenticateToUser(ctx, "", RefreshTokenCookieName+"="+refreshToken) + require.NoError(t, err) + require.Nil(t, viewer) + }) +} + +// TestAuthenticateToUserStoreFailuresPropagate verifies outages are not hidden as invalid credentials. +func TestAuthenticateToUserStoreFailuresPropagate(t *testing.T) { + ctx := context.Background() + storeErr := stderrors.New("store unavailable") + + t.Run("access token user lookup", func(t *testing.T) { + token, _, err := GenerateAccessTokenV2(1, "auth-store-user", string(store.RoleUser), string(store.Normal), []byte("test-secret")) + require.NoError(t, err) + a := NewAuthenticator(store.New(failingAuthDriver{listUsersErr: storeErr}, &profile.Profile{}), "test-secret") + viewer, err := a.AuthenticateToUser(ctx, "Bearer "+token, "") + require.ErrorIs(t, err, storeErr) + require.Nil(t, viewer) + }) + + t.Run("PAT lookup", func(t *testing.T) { + a := NewAuthenticator(store.New(failingAuthDriver{patErr: storeErr}, &profile.Profile{}), "test-secret") + viewer, err := a.AuthenticateToUser(ctx, "Bearer "+PersonalAccessTokenPrefix+"unavailable", "") + require.ErrorIs(t, err, storeErr) + require.Nil(t, viewer) + }) + + t.Run("refresh token lookup", func(t *testing.T) { + refreshToken, _, err := GenerateRefreshToken(1, "store-failure-token", []byte("test-secret")) + require.NoError(t, err) + a := NewAuthenticator(store.New(failingAuthDriver{listUserSettingsErr: storeErr}, &profile.Profile{}), "test-secret") + viewer, err := a.AuthenticateToUser(ctx, "", RefreshTokenCookieName+"="+refreshToken) + require.ErrorIs(t, err, storeErr) + require.Nil(t, viewer) + }) +} + +type failingAuthDriver struct { + store.Driver + listUsersErr error + patErr error + listUserSettingsErr error +} + +func (d failingAuthDriver) ListUsers(context.Context, *store.FindUser) ([]*store.User, error) { + return nil, d.listUsersErr +} + +func (d failingAuthDriver) GetUserByPATHash(context.Context, string) (*store.PATQueryResult, error) { + return nil, d.patErr +} + +func (d failingAuthDriver) ListUserSettings(context.Context, *store.FindUserSetting) ([]*store.UserSetting, error) { + return nil, d.listUserSettingsErr +} + +// newAuthenticatorTestingStore returns a migrated SQLite store for authenticator behavior tests. +func newAuthenticatorTestingStore(ctx context.Context, t *testing.T) *store.Store { + t.Helper() + p := &profile.Profile{Data: t.TempDir(), Driver: "sqlite", DSN: ":memory:"} + driver, err := db.NewDBDriver(p) + require.NoError(t, err) + st := store.New(driver, p) + require.NoError(t, st.Migrate(ctx)) + return st +} diff --git a/server/router/api/v1/memo_markdown.go b/server/router/api/v1/memo_markdown.go new file mode 100644 index 0000000000000..bb7ba543051b1 --- /dev/null +++ b/server/router/api/v1/memo_markdown.go @@ -0,0 +1,138 @@ +package v1 + +import ( + "context" + "mime" + "net/http" + "strconv" + "strings" + + "github.com/labstack/echo/v5" + "github.com/pkg/errors" + + "github.com/usememos/memos/server/access" + "github.com/usememos/memos/server/auth" + "github.com/usememos/memos/store" +) + +const memoMarkdownContentType = "text/markdown; charset=utf-8" + +// RegisterMemoMarkdownRoutes registers native HTTP routes for raw memo Markdown. +func (s *APIV1Service) RegisterMemoMarkdownRoutes(echoServer *echo.Echo) { + echoServer.GET("/memos/:uid", s.serveMemoMarkdown) +} + +// serveMemoMarkdown returns a memo's raw Markdown for explicit Markdown requests. +func (s *APIV1Service) serveMemoMarkdown(c *echo.Context) error { + uid, requested := requestedMemoMarkdown(c.Param("uid"), c.Request().Header.Get(echo.HeaderAccept)) + if !requested { + c.Response().Header().Add(echo.HeaderVary, echo.HeaderAccept) + return echo.NewHTTPError(http.StatusNotFound, "memo not found") + } + + ctx := c.Request().Context() + memo, err := s.Store.GetMemo(ctx, &store.FindMemo{UID: &uid}) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "failed to get memo").Wrap(err) + } + if memo == nil { + return serveMemoMarkdownNotFound(c) + } + + if err := s.checkMemoMarkdownAccess(ctx, c, memo); err != nil { + if echo.StatusCode(err) == http.StatusNotFound { + return serveMemoMarkdownNotFound(c) + } + return err + } + + setAPIResponseNoStoreHeaders(c.Response().Header()) + c.Response().Header().Set("X-Content-Type-Options", "nosniff") + c.Response().Header().Add(echo.HeaderVary, echo.HeaderAccept) + return c.Blob(http.StatusOK, memoMarkdownContentType, []byte(memo.Content)) +} + +// requestedMemoMarkdown removes the optional Markdown suffix and reports whether the request selected Markdown. +func requestedMemoMarkdown(uidParam, acceptHeader string) (string, bool) { + uid, hasMarkdownSuffix := strings.CutSuffix(uidParam, ".md") + if hasMarkdownSuffix { + return uid, true + } + return uidParam, acceptsMemoMarkdown(acceptHeader) +} + +// serveMemoMarkdownNotFound returns a native 404 instead of falling through to the frontend. +func serveMemoMarkdownNotFound(c *echo.Context) error { + c.Response().Header().Add(echo.HeaderVary, echo.HeaderAccept) + return c.Blob(http.StatusNotFound, "text/plain; charset=utf-8", []byte("memo not found\n")) +} + +// acceptsMemoMarkdown reports whether Accept explicitly allows text/markdown with positive quality. +func acceptsMemoMarkdown(acceptHeader string) bool { + for _, mediaRange := range strings.Split(acceptHeader, ",") { + mediaRange = strings.TrimSpace(mediaRange) + if mediaRange == "" { + continue + } + mediaType, params, err := mime.ParseMediaType(mediaRange) + if err != nil || !strings.EqualFold(mediaType, "text/markdown") { + continue + } + if q, ok := params["q"]; ok { + quality, err := strconv.ParseFloat(q, 64) + if err != nil || !(quality > 0 && quality <= 1) { + continue + } + } + return true + } + return false +} + +// checkMemoMarkdownAccess applies the same read policy used by normal memo API reads. +func (s *APIV1Service) checkMemoMarkdownAccess(ctx context.Context, c *echo.Context, memo *store.Memo) error { + allowAnonymous, err := s.Store.AllowsAnonymousAccess(ctx) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "failed to resolve instance access policy").Wrap(err) + } + + facts, err := access.ResolveMemoReadFacts(ctx, s.Store, memo) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "failed to resolve memo access").Wrap(err) + } + + anonymousContext, err := facts.WithViewer(ctx, s.Store, nil, allowAnonymous, nil) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "failed to resolve memo access").Wrap(err) + } + if anonymousDecision := access.CheckMemoReadContext(anonymousContext); anonymousDecision.Allowed() { + return nil + } + + viewer, err := s.getMemoMarkdownCurrentUser(ctx, c) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "failed to get current user").Wrap(errors.Wrap(err, "get current user")) + } + readContext, err := facts.WithViewer(ctx, s.Store, viewer, allowAnonymous, nil) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "failed to resolve memo access").Wrap(err) + } + + decision := access.CheckMemoReadContext(readContext) + switch decision.Denial { + case access.MemoReadDenialNone: + return nil + case access.MemoReadDenialNotFound: + return echo.NewHTTPError(http.StatusNotFound, "memo not found") + case access.MemoReadDenialUnauthenticated: + return echo.NewHTTPError(http.StatusUnauthorized, "unauthorized access") + default: + return echo.NewHTTPError(http.StatusForbidden, "forbidden access") + } +} + +// getMemoMarkdownCurrentUser resolves the viewer from bearer credentials or the refresh cookie. +func (s *APIV1Service) getMemoMarkdownCurrentUser(ctx context.Context, c *echo.Context) (*store.User, error) { + authenticator := auth.NewAuthenticator(s.Store, s.Secret) + return authenticator.AuthenticateToUser(ctx, c.Request().Header.Get(echo.HeaderAuthorization), c.Request().Header.Get("Cookie")) +} diff --git a/server/router/api/v1/memo_markdown_test.go b/server/router/api/v1/memo_markdown_test.go new file mode 100644 index 0000000000000..251f54349e5a5 --- /dev/null +++ b/server/router/api/v1/memo_markdown_test.go @@ -0,0 +1,384 @@ +package v1 + +import ( + "context" + stderrors "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/labstack/echo/v5" + "github.com/stretchr/testify/require" + + "github.com/usememos/memos/internal/profile" + v1pb "github.com/usememos/memos/proto/gen/api/v1" + storepb "github.com/usememos/memos/proto/gen/store" + "github.com/usememos/memos/server/auth" + "github.com/usememos/memos/server/router/frontend" + "github.com/usememos/memos/store" + "github.com/usememos/memos/store/test" +) + +// TestMemoMarkdownRouteServesExactSource verifies Markdown responses return the stored source unchanged. +func TestMemoMarkdownRouteServesExactSource(t *testing.T) { + ctx := context.Background() + service := newMemoMarkdownTestService(t) + owner := createSpaceTestUser(ctx, t, service, "markdown-exact-owner", store.RoleUser) + ownerCtx := userCtx(ctx, owner.ID) + content := "# Héllo\n\n```go\nfmt.Println(\"raw\")\n```\n" + memo := createMemoForMarkdownTest(ownerCtx, t, service, "markdown-exact", content, v1pb.Visibility_PUBLIC) + + e := newMemoMarkdownEcho(service) + + for _, tc := range []struct { + name string + path string + accept string + }{ + {name: "suffix", path: "/memos/" + memoUID(memo) + ".md"}, + {name: "accept", path: "/memos/" + memoUID(memo), accept: "text/markdown"}, + } { + t.Run(tc.name, func(t *testing.T) { + response := performMemoMarkdownRequest(e, tc.path, tc.accept, "", "") + require.Equal(t, http.StatusOK, response.Code) + require.Equal(t, content, response.Body.String()) + require.Equal(t, memoMarkdownContentType, response.Header().Get(echo.HeaderContentType)) + require.Equal(t, "no-cache, no-store, must-revalidate", response.Header().Get(echo.HeaderCacheControl)) + require.Equal(t, "no-cache", response.Header().Get("Pragma")) + require.Equal(t, "0", response.Header().Get("Expires")) + require.Equal(t, "nosniff", response.Header().Get("X-Content-Type-Options")) + require.Contains(t, response.Header().Values(echo.HeaderVary), echo.HeaderAccept) + require.Empty(t, response.Header().Get(echo.HeaderContentDisposition)) + }) + } +} + +// TestMemoMarkdownRouteServesEmptyContent verifies empty memos are valid Markdown responses. +func TestMemoMarkdownRouteServesEmptyContent(t *testing.T) { + ctx := context.Background() + service := newMemoMarkdownTestService(t) + owner := createSpaceTestUser(ctx, t, service, "markdown-empty-owner", store.RoleUser) + memo := createMemoForMarkdownTest(userCtx(ctx, owner.ID), t, service, "markdown-empty", "", v1pb.Visibility_PUBLIC) + + response := performMemoMarkdownRequest(newMemoMarkdownEcho(service), "/memos/"+memoUID(memo)+".md", "", "", "") + require.Equal(t, http.StatusOK, response.Code) + require.Empty(t, response.Body.String()) +} + +// TestMemoMarkdownRoutePreservesFrontendFallback verifies browser-style requests still serve the SPA. +func TestMemoMarkdownRoutePreservesFrontendFallback(t *testing.T) { + ctx := context.Background() + service := newMemoMarkdownTestService(t) + owner := createSpaceTestUser(ctx, t, service, "markdown-spa-owner", store.RoleUser) + memo := createMemoForMarkdownTest(userCtx(ctx, owner.ID), t, service, "markdown-spa", "must not leak", v1pb.Visibility_PUBLIC) + + e := echo.New() + frontend.NewFrontendService(service.Profile, service.Store).Serve(ctx, e) + service.RegisterMemoMarkdownRoutes(e) + + for _, accept := range []string{"", "text/html,*/*", "*/*"} { + response := performMemoMarkdownRequest(e, "/memos/"+memoUID(memo), accept, "", "") + require.Equal(t, http.StatusOK, response.Code) + require.Contains(t, response.Body.String(), " 0 { + memo.Space = space[0] + } + created, err := service.CreateMemo(creatorCtx, &v1pb.CreateMemoRequest{MemoId: memoID, Memo: memo}) + require.NoError(t, err) + return created +} + +// memoUID extracts the route UID from an API memo resource name. +func memoUID(memo *v1pb.Memo) string { + return strings.TrimPrefix(memo.Name, "memos/") +} + +// setMemoMarkdownInstanceAccess changes the test instance access mode through stored settings. +func setMemoMarkdownInstanceAccess(ctx context.Context, service *APIV1Service, mode storepb.InstanceAccessMode) error { + _, err := service.Store.UpsertInstanceSetting(ctx, &storepb.InstanceSetting{ + Key: storepb.InstanceSettingKey_ACCESS, + Value: &storepb.InstanceSetting_AccessSetting{AccessSetting: &storepb.InstanceAccessSetting{ + AccessMode: mode, + }}, + }) + return err +} + +// generateMemoMarkdownAccessToken creates a valid bearer credential for route authorization tests. +func generateMemoMarkdownAccessToken(t *testing.T, service *APIV1Service, user *store.User) string { + t.Helper() + token, _, err := auth.GenerateAccessTokenV2(user.ID, user.Username, string(user.Role), string(user.RowStatus), []byte(service.Secret)) + require.NoError(t, err) + return token +} + +// bearer formats an access token as an Authorization header value. +func bearer(token string) string { + if token == "" { + return "" + } + return "Bearer " + token +} + +// mustStoreMemoID resolves a memo UID to its store ID or fails the test. +func mustStoreMemoID(ctx context.Context, t *testing.T, service *APIV1Service, uid string) int32 { + t.Helper() + memo, err := service.Store.GetMemo(ctx, &store.FindMemo{UID: &uid}) + require.NoError(t, err) + require.NotNil(t, memo) + return memo.ID +} + +// ptr returns a pointer for store update fields. +func ptr[T any](value T) *T { + return &value +} diff --git a/server/server.go b/server/server.go index 0210344a5a76d..b44cdaa5461e0 100644 --- a/server/server.go +++ b/server/server.go @@ -36,6 +36,7 @@ type Server struct { sseHub *apiv1.SSEHub } +// NewServer wires the HTTP server, native routes, and API transports for one Memos instance. func NewServer(ctx context.Context, profile *profile.Profile, store *store.Store) (*Server, error) { s := &Server{ Store: store, @@ -79,6 +80,8 @@ func NewServer(ctx context.Context, profile *profile.Profile, store *store.Store // Create and register RSS routes (needs markdown service from apiV1Service). rss.NewRSSService(s.Store, apiV1Service.MarkdownService).RegisterRoutes(rootGroup) + apiV1Service.RegisterMemoMarkdownRoutes(echoServer) + // Register gRPC gateway as api v1 (includes SSE endpoint on CORS-enabled group). if err := apiV1Service.RegisterGateway(ctx, echoServer); err != nil { return nil, errors.Wrap(err, "failed to register gRPC gateway") diff --git a/store/db/postgres/user_setting.go b/store/db/postgres/user_setting.go index 89f37fa214409..3675ecbe5ea04 100644 --- a/store/db/postgres/user_setting.go +++ b/store/db/postgres/user_setting.go @@ -2,10 +2,9 @@ package postgres import ( "context" + "database/sql" "strings" - "github.com/pkg/errors" - storepb "github.com/usememos/memos/proto/gen/store" "github.com/usememos/memos/store" ) @@ -133,5 +132,5 @@ func (d *DB) GetUserByPATHash(ctx context.Context, tokenHash string) (*store.PAT return nil, err } - return nil, errors.New("PAT not found") + return nil, sql.ErrNoRows } diff --git a/web/src/hooks/useFilteredMemoStats.ts b/web/src/hooks/useFilteredMemoStats.ts index 7e5c49eca7e58..38c9a615cfdce 100644 --- a/web/src/hooks/useFilteredMemoStats.ts +++ b/web/src/hooks/useFilteredMemoStats.ts @@ -1,5 +1,4 @@ import { timestampDate } from "@bufbuild/protobuf/wkt"; -import dayjs from "dayjs"; import { countBy } from "lodash-es"; import { useMemo } from "react"; import { type MemoTimeBasis, useView } from "@/contexts/ViewContext"; @@ -26,7 +25,12 @@ export interface UseFilteredMemoStatsOptions { filter?: string; } -const toDateString = (date: Date) => dayjs(date).format("YYYY-MM-DD"); +const toDateString = (date: Date) => { + const year = date.getUTCFullYear(); + const month = `${date.getUTCMonth() + 1}`.padStart(2, "0"); + const day = `${date.getUTCDate()}`.padStart(2, "0"); + return `${year}-${month}-${day}`; +}; const timestampsForBasis = (stats: UserStats, basis: MemoTimeBasis) => { const createdArray = stats.memoCreatedTimestamps ?? [];