memos/server/router/api/v1/v1.go
boojack d1cef7a9ab feat(auth): add private instance mode derived from instance_url
Run the instance in private mode when instance_url is not configured: the API rejects anonymous requests except the auth-bootstrap set (sign-in, token refresh, instance profile/settings, SSO providers, share-link access) plus first-run user creation, and the web UI redirects anonymous visitors to /auth instead of /explore. Setting instance_url keeps the current public behavior. Access tokens and personal access tokens are never gated.

Enforcement lives in a shared Authorizer used by both the Connect interceptor and the gRPC-gateway middleware; the file server applies the same rule to public-memo attachments and avatars. Also merges the duplicated Authenticate/AuthenticateToUser token dispatch behind resolveBearer, dedups the AuthContext unauthenticated state, extracts the redirect decision into a pure shouldGatePrivateInstance helper, and prints the access mode at startup.
2026-07-05 22:48:00 +08:00

147 lines
5.1 KiB
Go

package v1
import (
"context"
"net/http"
"connectrpc.com/connect"
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
"github.com/labstack/echo/v5"
"golang.org/x/sync/semaphore"
"github.com/usememos/memos/internal/markdown"
"github.com/usememos/memos/internal/profile"
v1pb "github.com/usememos/memos/proto/gen/api/v1"
"github.com/usememos/memos/server/auth"
"github.com/usememos/memos/server/notification"
"github.com/usememos/memos/store"
)
const maxAPIRequestBytes = 256 << 20
type APIV1Service struct {
v1pb.UnimplementedInstanceServiceServer
v1pb.UnimplementedAuthServiceServer
v1pb.UnimplementedUserServiceServer
v1pb.UnimplementedMemoServiceServer
v1pb.UnimplementedAttachmentServiceServer
v1pb.UnimplementedAIServiceServer
v1pb.UnimplementedShortcutServiceServer
v1pb.UnimplementedIdentityProviderServiceServer
Secret string
Profile *profile.Profile
Store *store.Store
MarkdownService markdown.Service
SSEHub *SSEHub
NotificationEmailSender notification.EmailSender
// thumbnailSemaphore limits concurrent thumbnail generation to prevent memory exhaustion
thumbnailSemaphore *semaphore.Weighted
imageProcessingSemaphore *semaphore.Weighted
// instanceStatsCache memoizes GetInstanceStats results for instanceStatsCacheTTL.
instanceStatsCache instanceStatsCache
}
func NewAPIV1Service(secret string, profile *profile.Profile, store *store.Store) *APIV1Service {
markdownService := markdown.NewService(
markdown.WithTagExtension(),
markdown.WithMentionExtension(),
)
return &APIV1Service{
Secret: secret,
Profile: profile,
Store: store,
MarkdownService: markdownService,
SSEHub: NewSSEHub(),
NotificationEmailSender: nil,
thumbnailSemaphore: semaphore.NewWeighted(3), // Limit to 3 concurrent thumbnail generations
imageProcessingSemaphore: semaphore.NewWeighted(2),
}
}
// RegisterGateway registers the gRPC-Gateway and Connect handlers with the given Echo instance.
func (s *APIV1Service) RegisterGateway(ctx context.Context, echoServer *echo.Echo) error {
// Shared authorizer: one source of truth for authentication and anonymous-access
// policy, used by both the gRPC-Gateway middleware and the Connect interceptor.
authorizer := NewAuthorizer(s.Store, s.Secret, s.Profile)
gatewayAuthMiddleware := func(next runtime.HandlerFunc) runtime.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request, pathParams map[string]string) {
ctx := r.Context()
// The RPC method name is set by grpc-gateway after routing. When it can't be
// determined, skip the policy check and let the service layer handle visibility.
rpcMethod, ok := runtime.RPCMethod(ctx)
authHeader := r.Header.Get("Authorization")
result := authorizer.Authenticate(ctx, authHeader)
if ok {
if err := authorizer.CheckAccess(ctx, rpcMethod, result); err != nil {
http.Error(w, `{"code": 16, "message": "authentication required"}`, http.StatusUnauthorized)
return
}
}
// Apply the identity to the context (no-op for permitted anonymous requests).
if result != nil {
r = r.WithContext(auth.ApplyToContext(ctx, result))
}
next(w, r, pathParams)
}
}
// Create gRPC-Gateway mux with auth middleware.
gwMux := runtime.NewServeMux(
runtime.WithMiddlewares(gatewayAuthMiddleware),
)
if err := v1pb.RegisterInstanceServiceHandlerServer(ctx, gwMux, s); err != nil {
return err
}
if err := v1pb.RegisterAuthServiceHandlerServer(ctx, gwMux, s); err != nil {
return err
}
if err := v1pb.RegisterUserServiceHandlerServer(ctx, gwMux, s); err != nil {
return err
}
if err := v1pb.RegisterMemoServiceHandlerServer(ctx, gwMux, s); err != nil {
return err
}
if err := v1pb.RegisterAttachmentServiceHandlerServer(ctx, gwMux, s); err != nil {
return err
}
if err := v1pb.RegisterAIServiceHandlerServer(ctx, gwMux, s); err != nil {
return err
}
if err := v1pb.RegisterShortcutServiceHandlerServer(ctx, gwMux, s); err != nil {
return err
}
if err := v1pb.RegisterIdentityProviderServiceHandlerServer(ctx, gwMux, s); err != nil {
return err
}
gwGroup := echoServer.Group("")
// Register SSE endpoint with same CORS as rest of /api/v1.
RegisterSSERoutes(gwGroup, s.SSEHub, s.Store, s.Secret)
handler := echo.WrapHandler(http.MaxBytesHandler(gwMux, maxAPIRequestBytes))
gwGroup.Any("/api/v1/*", handler)
gwGroup.Any("/file/*", handler)
// Connect handlers for browser clients (replaces grpc-web).
logStacktraces := s.Profile.Demo
connectInterceptors := connect.WithInterceptors(
NewMetadataInterceptor(), // Convert HTTP headers to gRPC metadata first
NewLoggingInterceptor(logStacktraces),
NewRecoveryInterceptor(logStacktraces),
NewAuthInterceptor(authorizer),
)
connectMux := http.NewServeMux()
connectHandler := NewConnectServiceHandler(s)
connectHandler.RegisterConnectHandlers(connectMux, connectInterceptors, connect.WithReadMaxBytes(maxAPIRequestBytes))
connectGroup := echoServer.Group("")
connectGroup.Any("/memos.api.v1.*", echo.WrapHandler(http.MaxBytesHandler(connectMux, maxAPIRequestBytes)))
return nil
}