界面是这样的





后端代码:
program BlogServer;
{$APPTYPE CONSOLE}
{$R *.res}
uses
Winapi.Windows,
System.SysUtils,
System.Classes,
System.IOUtils,
mormot.core.base,
mormot.core.data,
mormot.core.json,
mormot.core.log,
mormot.core.text,
mormot.core.os,
mormot.core.buffers,
mormot.core.rtti,
mormot.core.perf,
mormot.core.threads,
mormot.orm.core,
mormot.orm.sqlite3,
mormot.orm.storage,
mormot.rest.core,
mormot.rest.server,
mormot.rest.http.server,
mormot.rest.memserver,
mormot.rest.sqlite3, // 添加这个单元!这是关键
mormot.db.raw.sqlite3.static,
mormot.crypt.core,
mormot.net.http,
mormot.net.ws.core,
mormot.core.interfaces,
mormot.net.server,
Blog.Model,
Blog.JWT,
Blog.Service;
const
DEFAULT_PORT = '8080';
DEFAULT_ADMIN_USERNAME = 'admin';
DEFAULT_ADMIN_PASSWORD = 'admin123';
DEFAULT_ADMIN_EMAIL = 'admin@blog.com';
CONFIG_FILENAME = 'blogserver.json';
type
TServerConfig = record
Port: string;
DatabaseFile: string;
JWTSecret: string;
JWTExpirationMinutes: Integer;
EnableCORS: Boolean;
EnableWebSocket: Boolean;
LogLevel: TSynLogInfo;
CreateDefaultAdmin: Boolean;
procedure LoadFromFile(const AFilename: string);
procedure SaveToFile(const AFilename: string);
procedure SetDefault;
end;
type
TStaticFileHandler = class
private
fUploadPath: string;
public
constructor Create(const UploadPath: string);
function HandleRequest(var Call: TRestUriParams): Boolean;
end;
constructor TStaticFileHandler.Create(const UploadPath: string);
begin
inherited Create;
fUploadPath := IncludeTrailingPathDelimiter(UploadPath);
end;
// MIME类型辅助函数
function GetMimeContentType(const Ext: string): RawUtf8;
begin
if (Ext = '.jpg') or (Ext = '.jpeg') then
Result := 'image/jpeg'
else if Ext = '.png' then
Result := 'image/png'
else if Ext = '.gif' then
Result := 'image/gif'
else if Ext = '.webp' then
Result := 'image/webp'
else if Ext = '.bmp' then
Result := 'image/bmp'
else if Ext = '.ico' then
Result := 'image/x-icon'
else if Ext = '.svg' then
Result := 'image/svg+xml'
else if Ext = '.txt' then
Result := 'text/plain'
else if Ext = '.html' then
Result := 'text/html'
else if Ext = '.css' then
Result := 'text/css'
else if Ext = '.js' then
Result := 'application/javascript'
else if Ext = '.json' then
Result := 'application/json'
else if (Ext = '.pdf') then
Result := 'application/pdf'
else if (Ext = '.zip') then
Result := 'application/zip'
else
Result := 'application/octet-stream';
end;
function TStaticFileHandler.HandleRequest(var Call: TRestUriParams): Boolean;
var
FilePath, LocalFile, MimeType: RawUtf8;
ResponseBody: RawByteString;
begin
Result := False;
// 只处理 GET 请求
if Call.Method <> 'GET' then
Exit;
// 只处理 /static/ 开头的请求
if Pos('/static/', Call.Url) < 1 then
Exit;
// 提取文件路径
FilePath := Copy(Call.Url, Pos('/static/', Call.Url) + Length('/static/') , MaxInt);
FilePath := stringreplace(FilePath,'uploads/','',[rfReplaceAll]);
if FilePath = '' then
begin
Call.OutStatus := HTTP_BADREQUEST;
Call.OutBody := 'File path required';
Call.OutHead := 'Content-Type: text/plain';
Result := True;
Exit;
end;
// 安全检查
if (Pos('..', FilePath) > 0) or (Pos('\\', FilePath) > 0) or
(Pos('//', FilePath) > 0) or (Pos(':', FilePath) > 0) then
begin
Call.OutStatus := HTTP_FORBIDDEN;
Call.OutBody := 'Access forbidden';
Call.OutHead := 'Content-Type: text/plain';
Result := True;
Exit;
end;
// 构建本地路径
LocalFile := fUploadPath + StringReplace(FilePath, '/', '\', [rfReplaceAll]);
LocalFile := urldecode(LocalFile);
// 检查文件是否存在
if not FileExists(Utf8ToString(LocalFile)) then
begin
Call.OutStatus := HTTP_NOTFOUND;
Call.OutBody := 'File not found';
Call.OutHead := 'Content-Type: text/plain';
Result := True;
Exit;
end;
try
try
// ⚠️ 关键:直接赋值给 Call.OutBody(作为 RawByteString)
Call.OutBody := StringFromFile(LocalFile); // RawUtf8 和 RawByteString 在 mORMot 中是兼容的
finally
end;
// 设置 MIME 类型
var ss := LowerCase(ExtractFileExt(LocalFile));
MimeType := GetMimeContentType(ss);
Call.OutHead := 'Content-Type: ' + MimeType + #13#10 ;
Call.OutStatus := HTTP_SUCCESS;
Result := True;
except
on E: Exception do
begin
Call.OutStatus := HTTP_SERVERERROR;
Call.OutBody := 'Server error: ' + RawUtf8(E.Message);
Call.OutHead := 'Content-Type: text/plain';
Result := True;
end;
end;
end;
var
Model: TOrmModel;
RestServer: TRestServerDB; // 保持 TRestServerDB
HttpServer: TRestHttpServer;
Config: TServerConfig;
Logger: TSynLog;
ShutdownRequested: Boolean = False;
StaticFileHandler: TStaticFileHandler;
// 控制台事件处理函数
function ConsoleHandler(dwCtrlType: DWORD): BOOL; stdcall;
begin
Result := True;
case dwCtrlType of
CTRL_C_EVENT,
CTRL_BREAK_EVENT,
CTRL_CLOSE_EVENT:
begin
ShutdownRequested := True;
WriteLn('');
WriteLn('Shutdown signal received. Gracefully stopping server...');
end;
end;
end;
procedure SetupConsoleCtrlHandler;
begin
SetConsoleCtrlHandler(@ConsoleHandler, True);
end;
procedure TServerConfig.SetDefault;
begin
Port := DEFAULT_PORT;
DatabaseFile := 'blog.db3';
JWTSecret := 'ChangeThisToAStrongSecretKeyAtLeast32Chars';
JWTExpirationMinutes := 60;
EnableCORS := True;
EnableWebSocket := True;
LogLevel := sllInfo;
CreateDefaultAdmin := True;
end;
procedure TServerConfig.LoadFromFile(const AFilename: string);
var
Json: RawUtf8;
begin
SetDefault;
if FileExists(AFilename) then
begin
Json := StringFromFile(AFilename);
RecordLoadJson(Self, Json, TypeInfo(TServerConfig));
end;
end;
procedure TServerConfig.SaveToFile(const AFilename: string);
var
Json: RawUtf8;
begin
Json := RecordSaveJson(Self, TypeInfo(TServerConfig));
FileFromString(Json, AFilename);
end;
function ContainsPathTraversal(const Path: RawUtf8): Boolean;
begin
Result := (Pos('..', Path) > 0) or
(Pos('\\', Path) > 0) or
(Pos(':', Path) > 0);
end;
// 检查并创建上传目录
procedure EnsureUploadsDirectory;
var
UploadDir: string;
begin
UploadDir := ExtractFilePath(ParamStr(0)) + 'uploads';
if not DirectoryExists(UploadDir) then
begin
WriteLn('Creating uploads directory: ', UploadDir);
ForceDirectories(UploadDir);
// 创建子目录结构
ForceDirectories(UploadDir + '\images');
ForceDirectories(UploadDir + '\thumbnails');
ForceDirectories(UploadDir + '\documents');
// 添加说明文件
var ReadmeFile := UploadDir + '\README.txt';
var fs := TFileStream.Create(ReadmeFile, fmCreate);
try
var Content :=
'This directory is used by the Blog Server to store uploaded files.'#13#10 +
'Structure:'#13#10 +
'- images/ : Uploaded images'#13#10 +
'- thumbnails/ : Image thumbnails (if generated)'#13#10 +
'- documents/ : Other uploaded files'#13#10#13#10 +
'DO NOT DELETE files manually unless you know what you are doing.';
fs.WriteBuffer(Pointer(Content)^, Length(Content));
finally
fs.Free;
end;
end;
end;
procedure InitializeDatabase;
var
User: TUser;
HashedPassword: RawUtf8;
begin
// 创建默认管理员账户(如果需要)
if Config.CreateDefaultAdmin and (RestServer.Orm.TableRowCount(TUser) = 0) then
begin
Logger.Log(sllInfo, 'Creating default admin account...');
User := TUser.Create;
try
User.Username := DEFAULT_ADMIN_USERNAME;
HashedPassword := HashPassword(DEFAULT_ADMIN_PASSWORD);
User.Password := HashedPassword;
User.Email := DEFAULT_ADMIN_EMAIL;
User.CreatedAt := NowUTC;
if RestServer.Orm.Add(User, True) > 0 then
begin
Logger.Log(sllInfo, 'Default admin account created: %s/%s',
[DEFAULT_ADMIN_USERNAME, DEFAULT_ADMIN_PASSWORD]);
end
else
begin
Logger.Log(sllWarning, 'Failed to create admin account');
end;
finally
User.Free;
end;
end;
end;
procedure ShowServerInfo;
begin
WriteLn('');
WriteLn('╔══════════════════════════════════════════════════════╗');
WriteLn('║ Blog Server v1.0 ║');
WriteLn('╠══════════════════════════════════════════════════════╣');
WriteLn('║ HTTP Server: http://localhost:', Config.Port, StringOfChar(' ', 27 - Length(Config.Port)), '║');
WriteLn('║ Database: ', Config.DatabaseFile, StringOfChar(' ', 35 - Length(Config.DatabaseFile)), '║');
WriteLn('║ Log Level: ', GetEnumName(TypeInfo(TSynLogInfo), Ord(Config.LogLevel))^,
StringOfChar(' ', 35 - Length(GetEnumName(TypeInfo(TSynLogInfo), Ord(Config.LogLevel))^)), '║');
WriteLn('║ WebSocket: ', BoolToStr(Config.EnableWebSocket, True),
StringOfChar(' ', 35 - Length(BoolToStr(Config.EnableWebSocket, True))), '║');
WriteLn('║ CORS: ', BoolToStr(Config.EnableCORS, True),
StringOfChar(' ', 35 - Length(BoolToStr(Config.EnableCORS, True))), '║');
WriteLn('╚══════════════════════════════════════════════════════╝');
WriteLn('');
WriteLn('=== JWT Authentication ===');
WriteLn('Algorithm: HS256');
WriteLn('Expiration: ', Config.JWTExpirationMinutes, ' minutes');
if Config.JWTSecret = 'ChangeThisToAStrongSecretKeyAtLeast32Chars' then
begin
WriteLn('');
WriteLn('⚠️ WARNING: Using default JWT secret!');
WriteLn(' Change JWTSecret in ', CONFIG_FILENAME, ' for production!');
end;
WriteLn('');
WriteLn('=== Default Admin Account ===');
WriteLn('Username: ', DEFAULT_ADMIN_USERNAME);
WriteLn('Password: ', DEFAULT_ADMIN_PASSWORD);
WriteLn('Email: ', DEFAULT_ADMIN_EMAIL);
WriteLn('');
WriteLn('=== Available Endpoints ===');
WriteLn('POST /root/auth/login - User login (get JWT token)');
WriteLn('POST /root/auth/verify - Verify JWT token');
WriteLn('POST /root/auth/refresh - Refresh JWT token');
WriteLn('GET /root/blog/Posts - Get blog posts list');
WriteLn('GET /root/blog/Post/{id} - Get single blog post');
WriteLn('POST /root/blog/Post - Create post (requires JWT)');
WriteLn('PUT /root/blog/Post/{id} - Update post (requires JWT)');
WriteLn('DELETE /root/blog/Post/{id} - Delete post (requires JWT)');
WriteLn('');
WriteLn('=== Quick Test Commands ===');
WriteLn('1. Login:');
WriteLn(' curl -X POST http://localhost:', Config.Port, '/root/auth/login ^');
WriteLn(' -H "Content-Type: application/json" ^');
WriteLn(' -d "{\"Username\":\"admin\",\"Password\":\"admin123\"}"');
WriteLn('');
WriteLn('2. Get all posts:');
WriteLn(' curl http://localhost:', Config.Port, '/root/blog/Posts');
WriteLn('');
WriteLn('=== Server Controls ===');
WriteLn('Press Ctrl+C to stop server gracefully');
WriteLn('');
end;
procedure SetupLogging;
begin
// 创建日志实例
Logger := TSynLog.Add;
// 设置日志级别
TSynLog.Family.Level := [sllError, sllInfo, sllDebug];
// 设置文件日志
TSynLog.Family.PerThreadLog := ptIdentifiedInOneFile;
TSynLog.Family.HighResolutionTimestamp := True;
TSynLog.Family.RotateFileCount := 10;
TSynLog.Family.RotateFileSizeKB := 100 * 1024; // 100MB
// 同时输出到控制台
TSynLog.Family.EchoToConsole := LOG_VERBOSE;
end;
begin
try
// 加载配置
Config.LoadFromFile(CONFIG_FILENAME);
// 设置日志
SetupLogging;
Logger.Log(sllInfo, 'Starting Blog Server...');
// 设置控制台中断处理
SetupConsoleCtrlHandler;
// 1. 创建ORM模型
Model := TOrmModel.Create([TUser, TBlogPost, TComment,TImage,TPostImage], 'root');
Logger.Log(sllInfo, 'ORM Model created with % tables', [Model.Tables]);
// 2. 创建SQLite数据库和REST服务器
Logger.Log(sllInfo, 'Creating database: %', [Config.DatabaseFile]);
// 在 mORMot 2 中创建 TRestServerDB 的正确方式
RestServer := TRestServerDB.Create(Model, Config.DatabaseFile);
// 创建缺失的表
RestServer.Server.CreateMissingTables;
Logger.Log(sllInfo, 'Database tables created/verified');
// 3. 初始化数据库
InitializeDatabase;
// 4. 注册服务
var BlogService: TBlogService := nil;
try
BlogService := TBlogService.Create(RestServer);
RestServer.ServiceDefine(BlogService, [IBlogService]);
Logger.Log(sllInfo, 'Blog service registered');
// 5. 配置并启动HTTP服务器
HttpServer := TRestHttpServer.Create([RestServer], Config.Port);
EnsureUploadsDirectory;
// 创建静态文件处理器
StaticFileHandler := TStaticFileHandler.Create(
ExtractFilePath(ParamStr(0)) + 'uploads');
// 设置自定义请求处理器 - 使用对象方法
HttpServer.OnCustomRequest := StaticFileHandler.HandleRequest;
// 设置 CORS
HttpServer.AccessControlAllowOrigin := '*';
if Config.EnableWebSocket then
begin
HttpServer.WebSocketsEnable(RestServer, 'ws');
Logger.Log(sllInfo, 'WebSocket enabled at /ws');
end;
// 设置JWT密钥
if Config.JWTSecret <> 'D260D22C-0E20-4B2F-8DE1-87BCEA08D316' then
begin
// 这里需要根据您的 JWT 实现来设置密钥
// 通常使用 TJWTAuth 类
Logger.Log(sllInfo, 'JWT secret configured');
end
else
begin
Logger.Log(sllWarning, 'Using default JWT secret - CHANGE IN PRODUCTION!');
end;
// 显示服务器信息
ShowServerInfo;
Logger.Log(sllInfo, 'Server started successfully on port %', [Config.Port]);
Logger.Log(sllInfo, 'Press Ctrl+C to stop server');
// 6. 主循环 - 等待退出信号
while not ShutdownRequested do
begin
Sleep(100);
end;
// 7. 优雅关闭
Logger.Log(sllInfo, 'Shutting down server...');
except
on E: Exception do
begin
Logger.Log(sllError, 'Server error: %', [E.Message]);
WriteLn('');
WriteLn('❌ Error: ', E.ClassName, ': ', E.Message);
WriteLn('');
WriteLn('Press Enter to exit...');
ReadLn;
end;
end;
finally
// 清理资源
if Assigned(HttpServer) then
begin
HttpServer.Shutdown;
Sleep(100);
FreeAndNil(HttpServer);
Logger.Log(sllInfo, 'HTTP server stopped');
end;
// if Assigned(BlogService) then
// FreeAndNil(BlogService);
if Assigned(RestServer) then
begin
FreeAndNil(RestServer);
Logger.Log(sllInfo, 'REST server stopped');
end;
if Assigned(Model) then
FreeAndNil(Model);
Logger.Log(sllInfo, 'Blog Server shutdown complete');
// 保存配置(如果有修改)
Config.SaveToFile(CONFIG_FILENAME);
end;
end.
unit Blog.Service;
interface
uses
System.SysUtils,
mormot.core.base,
mormot.core.data,
mormot.core.json,
mormot.core.interfaces,
mormot.orm.core,
mormot.rest.core,
mormot.rest.server,
mormot.core.variants,
mormot.core.text,
mormot.core.os,
mormot.crypt.core,
mormot.core.buffers,
mormot.core.datetime, // 添加这个单元以使用 DateTimeToIso8601
Blog.Model,
system.Classes,
System.Generics.Collections,
Blog.JWT;
type
/// <summary>博客服务接口</summary>
IBlogService = interface(IInvokable)
['{B8F5E3D2-1A4C-4B8E-9D6F-3C2A5B7D8E9F}']
/// <summary>用户登录</summary>
function Login(const Username, Password: RawUTF8): RawJSON;
/// <summary>验证JWT令牌</summary>
function VerifyToken(const Token: RawUTF8): RawJSON;
/// <summary>获取文章列表</summary>
function GetPosts(const PublishedOnly: Boolean = True): RawJSON;
/// <summary>获取单篇文章</summary>
function GetPost(ID: TID): RawJSON;
/// <summary>创建文章</summary>
function CreatePost(const Token, Title, Content, Summary, Author: RawUTF8;
Published: Boolean): RawJSON;
/// <summary>更新文章</summary>
function UpdatePost(const Token: RawUTF8; ID: TID;
const Title, Content, Summary, Author: RawUTF8; Published: Boolean): RawJSON;
/// <summary>删除文章</summary>
function DeletePost(const Token: RawUTF8; ID: TID): RawJSON;
/// <summary>获取文章评论</summary>
function GetComments(PostID: TID): RawJSON;
/// <summary>创建评论</summary>
function CreateComment(PostID: TID; const AuthorName, AuthorEmail, Content: RawUTF8): RawJSON;
/// <summary>批准评论</summary>
function ApproveComment(const Token: RawUTF8; ID: TID): RawJSON;
/// <summary>删除评论</summary>
function DeleteComment(const Token: RawUTF8; ID: TID): RawJSON;
/// <summary>刷新令牌</summary>
function RefreshToken(const Token: RawUTF8): RawJSON;
/// <summary>上传图片</summary>
function UploadImage(const Token, FileName, MimeType: RawUTF8;
const ImageData: RawByteString): RawJSON;
/// <summary>创建带图片的文章</summary>
function CreatePostWithImages(const Token, Title, Content, Summary, Author: RawUTF8;
Published: Boolean; const ImageIDs: TIDDynArray; CoverImageID: TID = 0): RawJSON;
/// <summary>为文章添加图片</summary>
function AddImagesToPost(const Token: RawUTF8; PostID: TID;
const ImageIDs: TIDDynArray): RawJSON;
/// <summary>获取文章的图片列表</summary>
function GetPostImages(PostID: TID): RawJSON;
/// <summary>删除图片</summary>
function DeleteImage(const Token: RawUTF8; ImageID: TID): RawJSON;
function DownloadImage(ImageID: TID): RawByteString;
end;
/// <summary>博客服务实现</summary>
TBlogService = class(TInterfacedObject, IBlogService)
private
fServer: TRestServer;
fJWT: TBlogJWT;
function GetUserID(const Username: RawUTF8): TID;
function GetUser(const Username: RawUTF8): TUser;
function VerifyUserToken(const Token: RawUTF8; out UserID: TID;
out Username: RawUTF8): Boolean;
function SuccessResponse(const Data: RawJSON = 'null'): RawJSON;
function ErrorResponse(const ErrorMsg: RawUTF8): RawJSON;
public
constructor Create(Server: TRestServer);
destructor Destroy; override;
function Login(const Username, Password: RawUTF8): RawJSON;
function VerifyToken(const Token: RawUTF8): RawJSON;
function GetPosts(const PublishedOnly: Boolean): RawJSON;
function GetPost(ID: TID): RawJSON;
function CreatePost(const Token, Title, Content, Summary, Author: RawUTF8;
Published: Boolean): RawJSON;
function UpdatePost(const Token: RawUTF8; ID: TID;
const Title, Content, Summary, Author: RawUTF8; Published: Boolean): RawJSON;
function DeletePost(const Token: RawUTF8; ID: TID): RawJSON;
function GetComments(PostID: TID): RawJSON;
function CreateComment(PostID: TID; const AuthorName, AuthorEmail, Content: RawUTF8): RawJSON;
function ApproveComment(const Token: RawUTF8; ID: TID): RawJSON;
function DeleteComment(const Token: RawUTF8; ID: TID): RawJSON;
function RefreshToken(const Token: RawUTF8): RawJSON;
/// <summary>上传图片</summary>
function UploadImage(const Token, FileName, MimeType: RawUTF8;
const ImageData: RawByteString): RawJSON;
/// <summary>创建带图片的文章</summary>
function CreatePostWithImages(const Token, Title, Content, Summary, Author: RawUTF8;
Published: Boolean; const ImageIDs: TIDDynArray; CoverImageID: TID = 0): RawJSON;
/// <summary>为文章添加图片</summary>
function AddImagesToPost(const Token: RawUTF8; PostID: TID;
const ImageIDs: TIDDynArray): RawJSON;
/// <summary>获取文章的图片列表</summary>
function GetPostImages(PostID: TID): RawJSON;
/// <summary>删除图片</summary>
function DeleteImage(const Token: RawUTF8; ImageID: TID): RawJSON;
function DownloadImage(ImageID: TID): RawByteString;
end;
function HashPassword(const Password: RawUtf8): RawUtf8;
implementation
{ TBlogService }
function SanitizeFileName(const FileName: RawUTF8): RawUTF8;
var
i: Integer;
begin
Result := FileName;
// 只过滤绝对不允许的字符,保留中文(大于#127的字符)
for i := 1 to Length(Result) do
begin
// ASCII控制字符和路径分隔符
if Ord(Result[i]) < 32 then
Result[i] := '_'
else if Result[i] in ['\', '/', ':', '*', '?', '"', '<', '>', '|'] then
Result[i] := '_';
// 其他字符(包括中文)全部保留
end;
// 如果全部被过滤了,给个默认名
if Result = '' then
Result := 'image_' + IntToStr(UnixTimeUTC);
end;
function GenerateUniqueFileName(const OriginalName: RawUTF8): RawUTF8;
begin
// 格式: 时间戳_随机数_安全文件名
Result := FormatUTF8('%-%-%',
[UnixTimeUTC, Random32, SanitizeFileName(OriginalName)]);
end;
function HashPassword(const Password: RawUtf8): RawUtf8;
begin
Result := Sha256('BlogServerSalt_' + Password);
end;
constructor TBlogService.Create(Server: TRestServer);
begin
inherited Create;
fServer := Server;
fJWT := TBlogJWT.Create('D260D22C-0E20-4B2F-8DE1-87BCEA08D316');
end;
destructor TBlogService.Destroy;
begin
fJWT.Free;
inherited;
end;
function TBlogService.GetUserID(const Username: RawUTF8): TID;
begin
Result := fServer.Orm.OneFieldValueInt64(TUser, 'ID',
FormatUTF8('Username=?', [], [Username]));
end;
function TBlogService.GetUser(const Username: RawUTF8): TUser;
var
UserID: TID;
begin
Result := nil;
UserID := GetUserID(Username);
if UserID > 0 then
begin
Result := TUser.Create;
if not fServer.Orm.Retrieve(UserID, Result) then
FreeAndNil(Result);
end;
end;
function TBlogService.VerifyUserToken(const Token: RawUTF8; out UserID: TID;
out Username: RawUTF8): Boolean;
begin
Result := fJWT.VerifyToken(Token, UserID, Username);
end;
function TBlogService.SuccessResponse(const Data: RawJSON): RawJSON;
begin
Result := FormatUTF8('{"success":true,"data":%}', [Data]);
end;
function TBlogService.ErrorResponse(const ErrorMsg: RawUTF8): RawJSON;
begin
Result := FormatUTF8('{"success":false,"error":%}', [QuotedStrJson(ErrorMsg)]);
end;
function TBlogService.Login(const Username, Password: RawUTF8): RawJSON;
var
User: TUser;
begin
// 验证参数
if (Username = '') or (Password = '') then
Exit(ErrorResponse('Username and password are required'));
User := GetUser(Username);
if not Assigned(User) then
Exit(ErrorResponse('User not found'));
try
// 验证密码
if User.Password = HashPassword(Password) then
begin
// 生成JWT令牌
var Token := fJWT.GenerateToken(User);
var Data := FormatUTF8('{"token":%,"user_id":%,"username":%,"expires_in":3600}',
[QuotedStrJson(Token), User.ID, QuotedStrJson(User.Username)]);
Result := SuccessResponse(Data);
end
else
begin
Result := ErrorResponse('Invalid password');
end;
finally
User.Free;
end;
end;
function TBlogService.VerifyToken(const Token: RawUTF8): RawJSON;
var
UserID: TID;
Username: RawUTF8;
begin
if Token = '' then
Exit(ErrorResponse('Token is required'));
var Valid := fJWT.VerifyToken(Token, UserID, Username);
if Valid then
begin
var Data := FormatUTF8('{"valid":true,"user_id":%,"username":%}',
[UserID, QuotedStrJson(Username)]);
Result := SuccessResponse(Data);
end
else
begin
var Data := '{"valid":false}';
Result := SuccessResponse(Data);
end;
end;
function TBlogService.RefreshToken(const Token: RawUTF8): RawJSON;
var
UserID: TID;
Username: RawUTF8;
User: TUser;
begin
if Token = '' then
Exit(ErrorResponse('Token is required'));
if not VerifyUserToken(Token, UserID, Username) then
Exit(ErrorResponse('Invalid or expired token'));
User := GetUser(Username);
if not Assigned(User) then
Exit(ErrorResponse('User not found'));
try
// 生成新令牌
var NewToken := fJWT.GenerateToken(User);
var Data := FormatUTF8('{"token":%,"user_id":%,"username":%,"expires_in":3600}',
[QuotedStrJson(NewToken), User.ID, QuotedStrJson(User.Username)]);
Result := SuccessResponse(Data);
finally
User.Free;
end;
end;
function TBlogService.GetPosts(const PublishedOnly: Boolean): RawJSON;
begin
var JsonData: RawJSON;
if PublishedOnly then
JsonData := fServer.Orm.RetrieveListJson(TBlogPost, 'Published=1')
else
JsonData := fServer.Orm.RetrieveListJson(TBlogPost, '');
Result := SuccessResponse(JsonData);
end;
function TBlogService.GetPost(ID: TID): RawJSON;
var
Post: TBlogPost;
begin
if ID = 0 then
Exit(ErrorResponse('Invalid post ID'));
Post := TBlogPost.Create;
try
if fServer.Orm.Retrieve(ID, Post) then
begin
var Data := ObjectToJson(Post);
Result := SuccessResponse(Data);
end
else
begin
Result := ErrorResponse('Post not found');
end;
finally
Post.Free;
end;
end;
function TBlogService.CreatePost(const Token, Title, Content, Summary, Author: RawUTF8;
Published: Boolean): RawJSON;
var
UserID: TID;
Username: RawUTF8;
Post: TBlogPost;
begin
// 验证参数
if Token = '' then
Exit(ErrorResponse('Token is required'));
if Title = '' then
Exit(ErrorResponse('Title is required'));
if Content = '' then
Exit(ErrorResponse('Content is required'));
// 验证令牌
if not VerifyUserToken(Token, UserID, Username) then
Exit(ErrorResponse('Invalid or expired token'));
Post := TBlogPost.Create;
try
Post.Title := Title;
Post.Content := Content;
Post.Summary := Summary;
Post.Author := Author;
Post.Published := Published;
Post.CreatedAt := NowUTC;
Post.UpdatedAt := Post.CreatedAt;
var PostID := fServer.Orm.Add(Post, True);
if PostID > 0 then
begin
// 使用 DateTimeToIso8601 转换时间
var CreatedAtStr := DateTimeToIso8601(Post.CreatedAt, true, 'T', false);
var Data := FormatUTF8('{"id":%,"title":%,"author":%,"published":%,"created_at":%}',
[PostID,
QuotedStrJson(Title),
QuotedStrJson(Author),
BoolToStr(Published, True),
QuotedStrJson(CreatedAtStr)]);
Result := SuccessResponse(Data);
end
else
begin
Result := ErrorResponse('Failed to create post');
end;
finally
Post.Free;
end;
end;
function TBlogService.UpdatePost(const Token: RawUTF8; ID: TID;
const Title, Content, Summary, Author: RawUTF8; Published: Boolean): RawJSON;
var
UserID: TID;
Username: RawUTF8;
Post: TBlogPost;
begin
// 验证参数
if Token = '' then
Exit(ErrorResponse('Token is required'));
if ID = 0 then
Exit(ErrorResponse('Invalid post ID'));
if Title = '' then
Exit(ErrorResponse('Title is required'));
if Content = '' then
Exit(ErrorResponse('Content is required'));
// 验证令牌
if not VerifyUserToken(Token, UserID, Username) then
Exit(ErrorResponse('Invalid or expired token'));
Post := TBlogPost.Create;
try
if not fServer.Orm.Retrieve(ID, Post) then
Exit(ErrorResponse('Post not found'));
Post.Title := Title;
Post.Content := Content;
Post.Summary := Summary;
Post.Author := Author;
Post.Published := Published;
Post.UpdatedAt := NowUTC;
var Updated := fServer.Orm.Update(Post);
if Updated then
begin
var Data := FormatUTF8('{"id":%,"title":%,"updated":true}',
[ID, QuotedStrJson(Title)]);
Result := SuccessResponse(Data);
end
else
begin
Result := ErrorResponse('Failed to update post');
end;
finally
Post.Free;
end;
end;
function TBlogService.DeletePost(const Token: RawUTF8; ID: TID): RawJSON;
var
UserID: TID;
Username: RawUTF8;
begin
// 验证参数
if Token = '' then
Exit(ErrorResponse('Token is required'));
if ID = 0 then
Exit(ErrorResponse('Invalid post ID'));
// 验证令牌
if not VerifyUserToken(Token, UserID, Username) then
Exit(ErrorResponse('Invalid or expired token'));
var Deleted := fServer.Orm.Delete(TBlogPost, ID);
if Deleted then
begin
var Data := FormatUTF8('{"id":%,"deleted":true}', [ID]);
Result := SuccessResponse(Data);
end
else
begin
Result := ErrorResponse('Failed to delete post');
end;
end;
function TBlogService.GetComments(PostID: TID): RawJSON;
begin
if PostID = 0 then
Exit(ErrorResponse('Invalid post ID'));
var JsonData := fServer.Orm.RetrieveListJson(TComment,
FormatUTF8('PostID=? AND Approved=1', [PostID]));
Result := SuccessResponse(JsonData);
end;
function TBlogService.CreateComment(PostID: TID; const AuthorName, AuthorEmail, Content: RawUTF8): RawJSON;
var
Comment: TComment;
begin
// 验证参数
if PostID = 0 then
Exit(ErrorResponse('Invalid post ID'));
if AuthorName = '' then
Exit(ErrorResponse('Author name is required'));
if Content = '' then
Exit(ErrorResponse('Content is required'));
Comment := TComment.Create;
try
Comment.PostID := PostID;
Comment.AuthorName := AuthorName;
Comment.AuthorEmail := AuthorEmail;
Comment.Content := Content;
Comment.CreatedAt := NowUTC;
Comment.Approved := True;
var CommentID := fServer.Orm.Add(Comment, True);
if CommentID > 0 then
begin
var CreatedAtStr := DateTimeToIso8601(Comment.CreatedAt, true, 'T', false);
var Data := FormatUTF8('{"id":%,"author":%,"content":%,"created_at":%}',
[CommentID,
QuotedStrJson(AuthorName),
QuotedStrJson(Copy(Content, 1, 100)), // 截取前100个字符
QuotedStrJson(CreatedAtStr)]);
Result := SuccessResponse(Data);
end
else
begin
Result := ErrorResponse('Failed to create comment');
end;
finally
Comment.Free;
end;
end;
function TBlogService.ApproveComment(const Token: RawUTF8; ID: TID): RawJSON;
var
UserID: TID;
Username: RawUTF8;
Comment: TComment;
begin
// 验证参数
if Token = '' then
Exit(ErrorResponse('Token is required'));
if ID = 0 then
Exit(ErrorResponse('Invalid comment ID'));
// 验证令牌
if not VerifyUserToken(Token, UserID, Username) then
Exit(ErrorResponse('Invalid or expired token'));
Comment := TComment.Create;
try
if not fServer.Orm.Retrieve(ID, Comment) then
Exit(ErrorResponse('Comment not found'));
Comment.Approved := True;
var Updated := fServer.Orm.Update(Comment);
if Updated then
begin
var Data := FormatUTF8('{"id":%,"approved":true}', [ID]);
Result := SuccessResponse(Data);
end
else
begin
Result := ErrorResponse('Failed to approve comment');
end;
finally
Comment.Free;
end;
end;
function TBlogService.DeleteComment(const Token: RawUTF8; ID: TID): RawJSON;
var
UserID: TID;
Username: RawUTF8;
begin
// 验证参数
if Token = '' then
Exit(ErrorResponse('Token is required'));
if ID = 0 then
Exit(ErrorResponse('Invalid comment ID'));
// 验证令牌
if not VerifyUserToken(Token, UserID, Username) then
Exit(ErrorResponse('Invalid or expired token'));
var Deleted := fServer.Orm.Delete(TComment, ID);
if Deleted then
begin
var Data := FormatUTF8('{"id":%,"deleted":true}', [ID]);
Result := SuccessResponse(Data);
end
else
begin
Result := ErrorResponse('Failed to delete comment');
end;
end;
function TBlogService.UploadImage(const Token, FileName, MimeType: RawUTF8;
const ImageData: RawByteString): RawJSON;
var
UserID: TID;
Username: RawUTF8;
Image: TImage;
SavePath, SaveDir, AppPath, WebUrl, UrlFileName: RawUTF8;
fs: TFileStream;
ValidImage: Boolean;
begin
// 验证参数
if Token = '' then
Exit(ErrorResponse('Token is required'));
if FileName = '' then
Exit(ErrorResponse('File name is required'));
if Length(ImageData) = 0 then
Exit(ErrorResponse('Image data is empty'));
// 验证令牌
if not VerifyUserToken(Token, UserID, Username) then
Exit(ErrorResponse('Invalid or expired token'));
// 验证文件类型
ValidImage := (PosEx('image/', MimeType) = 1) and
((PosEx('jpeg', MimeType) > 0) or
(PosEx('png', MimeType) > 0) or
(PosEx('gif', MimeType) > 0) or
(PosEx('webp', MimeType) > 0));
if not ValidImage then
Exit(ErrorResponse('Only JPEG, PNG, GIF and WebP images are allowed'));
// 获取应用程序路径
AppPath := IncludeTrailingPathDelimiter(ExtractFilePath(ParamStr(0)));
// 按日期组织目录,避免单个目录文件过多
var DatePath := FormatDateTime('yyyy-mm-dd', NowUTC);
// 创建上传目录:uploads/images/用户名/日期/
SaveDir := FormatUTF8('%uploads\images\%\%\',
[AppPath, Username, DatePath]);
ForceDirectories(Utf8ToString(SaveDir));
// 生成唯一文件名:保留原始文件名,但添加时间戳前缀避免重名
var TimeStamp := IntToStr(UnixTimeUTC);
var RandomStr := IntToHex(Random32, 8);
// 关键:不要过滤中文!只过滤非法字符
var SafeFileName := SanitizeFileName(FileName);
// 添加时间戳和随机数,避免重名,同时保留中文名
var UniqueFileName := FormatUTF8('%-%_%', [TimeStamp, RandomStr, SafeFileName]);
SavePath := SaveDir + UniqueFileName;
// 保存原始图片
fs := TFileStream.Create(Utf8ToString(SavePath), fmCreate);
try
fs.WriteBuffer(Pointer(ImageData)^, Length(ImageData));
finally
fs.Free;
end;
// 生成Web访问URL - 关键:对文件名进行URL编码!
// 只对文件名部分编码,不对路径编码
UrlFileName := urlencode(UniqueFileName);
WebUrl := FormatUTF8('/static/images/%/%/%',
[Username, DatePath, UrlFileName]);
// 保存图片信息到数据库
Image := TImage.Create;
try
Image.FileName := SafeFileName; // 存储原始文件名(已过滤非法字符但保留中文)
Image.FileSize := Length(ImageData);
Image.MimeType := MimeType;
Image.StoragePath := SavePath; // 磁盘完整路径
Image.UploadedBy := UserID;
Image.UploadedAt := NowUTC;
// Image.ThumbnailPath := ''; // 缩略图暂不实现
var ImageID := fServer.Orm.Add(Image, True);
if ImageID > 0 then
begin
var Data := FormatUTF8(
'{"id":%,"filename":%,"url":%,"size":%,"mime_type":%,"upload_time":"%"}',
[ImageID,
QuotedStrJson(SafeFileName), // 返回原始文件名
QuotedStrJson(WebUrl), // 返回编码后的URL
Length(ImageData),
QuotedStrJson(MimeType),
DateTimeToIso8601(NowUTC, True)]);
Result := SuccessResponse(Data);
end
else
begin
// 数据库保存失败,删除已保存的文件
DeleteFile(Utf8ToString(SavePath));
Result := ErrorResponse('Failed to save image record');
end;
finally
Image.Free;
end;
end;
function TBlogService.CreatePostWithImages(const Token, Title, Content, Summary, Author: RawUTF8;
Published: Boolean; const ImageIDs: TIDDynArray; CoverImageID: TID): RawJSON;
var
UserID: TID;
Username: RawUTF8;
Post: TBlogPost;
PostImage: TPostImage;
i: Integer;
begin
// 验证参数(复用原有的参数验证)
if Token = '' then
Exit(ErrorResponse('Token is required'));
if Title = '' then
Exit(ErrorResponse('Title is required'));
if Content = '' then
Exit(ErrorResponse('Content is required'));
// 验证令牌
if not VerifyUserToken(Token, UserID, Username) then
Exit(ErrorResponse('Invalid or expired token'));
// 创建文章
Post := TBlogPost.Create;
try
Post.Title := Title;
Post.Content := Content;
Post.Summary := Summary;
Post.Author := Author;
Post.Published := Published;
Post.CreatedAt := NowUTC;
Post.UpdatedAt := Post.CreatedAt;
Post.CoverImageID := CoverImageID;
var PostID := fServer.Orm.Add(Post, True);
if PostID = 0 then
Exit(ErrorResponse('Failed to create post'));
// 关联图片
for i := 0 to High(ImageIDs) do
begin
if ImageIDs[i] > 0 then
begin
PostImage := TPostImage.Create;
try
PostImage.PostID := PostID;
PostImage.ImageID := ImageIDs[i];
PostImage.SortOrder := i + 1;
fServer.Orm.Add(PostImage, True);
finally
PostImage.Free;
end;
end;
end;
var Data := FormatUTF8('{"id":%,"title":%,"author":%,"published":%,"image_count":%}',
[PostID,
QuotedStrJson(Title),
QuotedStrJson(Author),
BoolToStr(Published, True),
Length(ImageIDs)]);
Result := SuccessResponse(Data);
finally
Post.Free;
end;
end;
function TBlogService.AddImagesToPost(const Token: RawUTF8; PostID: TID;
const ImageIDs: TIDDynArray): RawJSON;
var
UserID: TID;
Username: RawUTF8;
PostImage: TPostImage;
i, AddedCount: Integer;
CurrentCount: Int64;
begin
if Token = '' then
Exit(ErrorResponse('Token is required'));
if PostID = 0 then
Exit(ErrorResponse('Invalid post ID'));
if not VerifyUserToken(Token, UserID, Username) then
Exit(ErrorResponse('Invalid or expired token'));
// 验证文章是否存在
if not fServer.Orm.MemberExists(TBlogPost, PostID) then
Exit(ErrorResponse('Post not found'));
// 方法1:使用 OneFieldValueInt64(返回 Int64)
CurrentCount := fServer.Orm.OneFieldValueInt64(TPostImage, 'COUNT(*)',
'PostID=' + IntToStr(PostID));
AddedCount := 0;
// 添加新图片
for i := 0 to High(ImageIDs) do
begin
if (ImageIDs[i] > 0) and fServer.Orm.MemberExists(TImage, ImageIDs[i]) then
begin
PostImage := TPostImage.Create;
try
PostImage.PostID := PostID;
PostImage.ImageID := ImageIDs[i];
PostImage.SortOrder := Integer(CurrentCount) + AddedCount + 1;
if fServer.Orm.Add(PostImage, True) > 0 then
Inc(AddedCount);
finally
PostImage.Free;
end;
end;
end;
if AddedCount = 0 then
Exit(ErrorResponse('No valid images were added'));
var Data := FormatUTF8('{"post_id":%,"added_count":%}', [PostID, AddedCount]);
Result := SuccessResponse(Data);
end;
function PathToUrl(const StoragePath: RawUTF8): RawUTF8;
var
RelativePath: RawUTF8;
FileName: RawUTF8;
LastSlash: Integer;
begin
Result := '';
// 1. 找到 'uploads\' 的位置
var PosUploads := Pos('uploads\', StoragePath);
if PosUploads = 0 then
PosUploads := Pos('uploads/', StoragePath);
if PosUploads > 0 then
begin
// 2. 提取相对路径(从 uploads 开始)
RelativePath := Copy(StoragePath, PosUploads, MaxInt);
// 3. 反斜杠转正斜杠
RelativePath := StringReplace(RelativePath, '\', '/', [rfReplaceAll]);
// 4. 分离路径和文件名
LastSlash := LastDelimiter('/', RelativePath);
if LastSlash > 0 then
begin
// 路径部分保持原样,文件名部分需要 URL 编码
var PathPart := Copy(RelativePath, 1, LastSlash);
var FilePart := Copy(RelativePath, LastSlash + 1, MaxInt);
// 5. 构建 Web URL:/static/路径/编码后的文件名
Result := '/static/' + PathPart + UrlEncode(FilePart);
end
else
begin
// 没有路径分隔符,整个文件名编码
Result := '/static/' + UrlEncode(RelativePath);
end;
end;
end;
function TBlogService.GetPostImages(PostID: TID): RawJSON;
var
Table: TOrmTable;
JsonData: RawUTF8;
i: Integer;
StoragePath, WebUrl: RawUTF8;
begin
if PostID = 0 then
Exit(ErrorResponse('Invalid post ID'));
try
// 构建完整 SQL
var SQL := FormatUTF8(
'SELECT i.* FROM % i ' +
'INNER JOIN % pi ON i.ID = pi.ImageID ' +
'WHERE pi.PostID = % ' +
'ORDER BY pi.SortOrder',
[TImage.SqlTableName, TPostImage.SqlTableName, PostID]);
// 使用 ExecuteList 获取结果表
Table := fServer.Orm.ExecuteList([TImage, TPostImage], SQL);
if not Assigned(Table) then
Exit(SuccessResponse('[]'));
try
if Table.RowCount = 0 then
Exit(SuccessResponse('[]'));
// 获取原始的 JSON 数据
JsonData := Table.GetJsonValues(True);
// 将 JSON 字符串转换为 TDocVariant 进行处理
var JsonDoc := _JsonFast(JsonData);
var ArrayDoc := TDocVariantData(JsonDoc);
// 确保是数组
// if JsonDoc.Kind = dvArray then
begin
// 遍历数组中的每个对象
for i := 0 to ArrayDoc.Count - 1 do
begin
var Item := ArrayDoc[i];
var ItemData := DocVariantData(Item);
// 获取 StoragePath 字段
var StoragePathVar := ItemData.U['StoragePath'];
if StoragePathVar <> '' then
begin
StoragePath := StoragePathVar;
// 转换为 Web URL
WebUrl := PathToUrl(StoragePath);
// 添加或替换 url 字段
ItemData.AddOrUpdateValue('url', WebUrl);
ItemData.Delete('StoragePath');
end;
end;
end;
Result := SuccessResponse(ArrayDoc.ToJson);
finally
Table.Free;
end;
except
on E: Exception do
Result := ErrorResponse('Failed to get images: ' + E.Message);
end;
end;
function TBlogService.DeleteImage(const Token: RawUTF8; ImageID: TID): RawJSON;
var
UserID: TID;
Username: RawUTF8;
Image: TImage;
begin
if Token = '' then
Exit(ErrorResponse('Token is required'));
if ImageID = 0 then
Exit(ErrorResponse('Invalid image ID'));
if not VerifyUserToken(Token, UserID, Username) then
Exit(ErrorResponse('Invalid or expired token'));
Image := TImage.Create;
try
if not fServer.Orm.Retrieve(ImageID, Image) then
Exit(ErrorResponse('Image not found'));
// 验证权限:只有上传者可以删除
if Image.UploadedBy <> UserID then
Exit(ErrorResponse('Permission denied'));
// 删除物理文件
if FileExists(Image.StoragePath) then
DeleteFile(Image.StoragePath);
if FileExists(Image.ThumbnailPath) then
DeleteFile(Image.ThumbnailPath);
// 删除数据库记录
var Deleted := fServer.Orm.Delete(TImage, ImageID);
if Deleted then
begin
// 同时删除关联关系
fServer.Orm.Delete(TPostImage, 'ImageID=?', [ImageID]);
var Data := FormatUTF8('{"id":%,"deleted":true}', [ImageID]);
Result := SuccessResponse(Data);
end
else
begin
Result := ErrorResponse('Failed to delete image');
end;
finally
Image.Free;
end;
end;
function TBlogService.DownloadImage(ImageID: TID): RawByteString;
var
Image: TImage;
fs: TFileStream;
begin
if ImageID = 0 then
Exit('');
Image := TImage.Create;
try
if not fServer.Orm.Retrieve(ImageID, Image) then
Exit('');
// 检查文件是否存在
if not FileExists(Image.StoragePath) then
Exit('');
// 读取文件内容
fs := TFileStream.Create(Utf8ToString(Image.StoragePath), fmOpenRead or fmShareDenyNone);
try
SetLength(Result, fs.Size);
fs.ReadBuffer(Pointer(Result)^, fs.Size);
finally
fs.Free;
end;
finally
Image.Free;
end;
end;
initialization
TInterfaceFactory.RegisterInterfaces([TypeInfo(IBlogService)]);
end.
unit Blog.Model;
interface
uses
mormot.core.base,
mormot.orm.core;
type
/// <summary>用户模型</summary>
TUser = class(TOrm)
private
fUsername: RawUTF8;
fPassword: RawUTF8;
fEmail: RawUTF8;
fCreatedAt: TDateTime;
published
/// <summary>用户名</summary>
property Username: RawUTF8 index 50 read fUsername write fUsername;
/// <summary>密码哈希</summary>
property Password: RawUTF8 index 64 read fPassword write fPassword;
/// <summary>邮箱</summary>
property Email: RawUTF8 index 100 read fEmail write fEmail;
/// <summary>创建时间</summary>
property CreatedAt: TDateTime read fCreatedAt write fCreatedAt;
end;
/// <summary>博客文章模型</summary>
TBlogPost = class(TOrm)
private
fTitle: RawUTF8;
fContent: RawUTF8;
fSummary: RawUTF8;
fAuthor: RawUTF8;
fPublished: Boolean;
fCreatedAt: TDateTime;
fUpdatedAt: TDateTime;
fCoverImageID: TID; // 封面图片ID
published
/// <summary>文章标题</summary>
property Title: RawUTF8 index 200 read fTitle write fTitle;
/// <summary>文章内容</summary>
property Content: RawUTF8 read fContent write fContent;
/// <summary>文章摘要</summary>
property Summary: RawUTF8 index 500 read fSummary write fSummary;
/// <summary>作者</summary>
property Author: RawUTF8 index 100 read fAuthor write fAuthor;
/// <summary>是否发布</summary>
property Published: Boolean read fPublished write fPublished;
/// <summary>创建时间</summary>
property CreatedAt: TDateTime read fCreatedAt write fCreatedAt;
/// <summary>更新时间</summary>
property UpdatedAt: TDateTime read fUpdatedAt write fUpdatedAt;
/// <summary>封面图片ID</summary>
property CoverImageID: TID read fCoverImageID write fCoverImageID;
end;
/// <summary>评论模型</summary>
TComment = class(TOrm)
private
fPostID: TID;
fAuthorName: RawUTF8;
fAuthorEmail: RawUTF8;
fContent: RawUTF8;
fCreatedAt: TDateTime;
fApproved: Boolean;
published
/// <summary>关联的文章ID</summary>
property PostID: TID read fPostID write fPostID;
/// <summary>评论者姓名</summary>
property AuthorName: RawUTF8 index 50 read fAuthorName write fAuthorName;
/// <summary>评论者邮箱</summary>
property AuthorEmail: RawUTF8 index 100 read fAuthorEmail write fAuthorEmail;
/// <summary>评论内容</summary>
property Content: RawUTF8 read fContent write fContent;
/// <summary>创建时间</summary>
property CreatedAt: TDateTime read fCreatedAt write fCreatedAt;
/// <summary>是否批准</summary>
property Approved: Boolean read fApproved write fApproved;
end;
/// <summary>图片模型</summary>
TImage = class(TOrm)
private
fFileName: RawUTF8;
fFileSize: Integer;
fMimeType: RawUTF8;
fStoragePath: RawUTF8; // 实际存储路径
fUploadedBy: TID; // 上传用户ID
fUploadedAt: TDateTime;
fWidth: Integer;
fHeight: Integer;
fThumbnailPath: RawUTF8; // 缩略图路径
published
/// <summary>原始文件名</summary>
property FileName: RawUTF8 index 255 read fFileName write fFileName;
/// <summary>文件大小(字节)</summary>
property FileSize: Integer read fFileSize write fFileSize;
/// <summary>MIME类型</summary>
property MimeType: RawUTF8 index 100 read fMimeType write fMimeType;
/// <summary>存储路径</summary>
property StoragePath: RawUTF8 index 500 read fStoragePath write fStoragePath;
/// <summary>上传用户ID</summary>
property UploadedBy: TID read fUploadedBy write fUploadedBy;
/// <summary>上传时间</summary>
property UploadedAt: TDateTime read fUploadedAt write fUploadedAt;
/// <summary>图片宽度</summary>
property Width: Integer read fWidth write fWidth;
/// <summary>图片高度</summary>
property Height: Integer read fHeight write fHeight;
/// <summary>缩略图路径</summary>
property ThumbnailPath: RawUTF8 index 500 read fThumbnailPath write fThumbnailPath;
end;
/// <summary>文章-图片关联模型</summary>
TPostImage = class(TOrm)
private
fPostID: TID;
fImageID: TID;
fSortOrder: Integer;
published
/// <summary>文章ID</summary>
property PostID: TID read fPostID write fPostID;
/// <summary>图片ID</summary>
property ImageID: TID read fImageID write fImageID;
/// <summary>排序顺序</summary>
property SortOrder: Integer read fSortOrder write fSortOrder;
end;
implementation
end.
unit Blog.JWT;
interface
uses
mormot.core.base,
mormot.core.data,
mormot.core.json,
mormot.core.rtti,
mormot.crypt.core,
mormot.crypt.jwt,
mormot.core.text,
mormot.core.os,
Blog.Model;
type
/// <summary>JWT管理类</summary>
TBlogJWT = class
private
fJWT: TJwtHS256;
fSecret: RawUTF8;
public
/// <summary>使用密钥初始化JWT</summary>
constructor Create(const aSecret: RawUTF8);
/// <summary>销毁JWT实例</summary>
destructor Destroy; override;
/// <summary>为用户生成JWT令牌</summary>
function GenerateToken(User: TUser): RawUTF8;
/// <summary>验证JWT令牌</summary>
function VerifyToken(const Token: RawUTF8; out UserID: TID;
out Username: RawUTF8): Boolean;
/// <summary>验证JWT令牌并返回完整信息</summary>
function VerifyTokenEx(const Token: RawUTF8; out JwtContent: TJwtContent): Boolean;
/// <summary>获取JWT实例</summary>
property JWT: TJwtHS256 read fJWT;
/// <summary>获取JWT密钥</summary>
property Secret: RawUTF8 read fSecret;
end;
implementation
{ TBlogJWT }
constructor TBlogJWT.Create(const aSecret: RawUTF8);
begin
inherited Create;
fSecret := aSecret;
// 创建JWT实例
// 使用HS256算法,支持所有标准声明
fJWT := TJwtHS256.Create(
aSecret, // 密钥
0, // PBKDF2轮数(0表示直接使用密钥)
[jrcIssuer, jrcSubject, jrcAudience, jrcExpirationTime, jrcIssuedAt, jrcJwtID], // 声明
['blog-server'], // 受众
60, // 过期时间(分钟)
0, // ID标识符
'', // ID混淆密钥
0); // 新的KDF轮数
// 设置选项
fJWT.Options := [joAllowUnexpectedClaims, joAllowUnexpectedAudience];
// 允许自定义声明
end;
destructor TBlogJWT.Destroy;
begin
fJWT.Free;
inherited;
end;
function TBlogJWT.GenerateToken(User: TUser): RawUTF8;
var
UserIDStr: RawUTF8;
begin
// 将用户ID转换为字符串
UserIDStr := Int64ToUtf8(User.ID);
// 生成JWT令牌
Result := fJWT.Compute(
// 自定义声明
['user_id', UserIDStr,
'username', User.Username,
'email', User.Email,
'role', 'user'], // 默认角色
// 标准声明
'blog-server', // 颁发者
UserIDStr, // 主题(用户ID)
'blog-server', // 受众
0, // NotBefore
0); // ExpirationMinutes(使用默认值)
end;
function TBlogJWT.VerifyToken(const Token: RawUTF8; out UserID: TID;
out Username: RawUTF8): Boolean;
var
JwtContent: TJwtContent;
UserIDStr: RawUTF8;
begin
Result := False;
UserID := 0;
Username := '';
// 验证令牌
fJWT.Verify(Token, JwtContent);
if JwtContent.result = jwtValid then
begin
// 从自定义声明中获取用户信息
if JwtContent.data.GetAsRawUTF8('user_id', UserIDStr) and
JwtContent.data.GetAsRawUTF8('username', Username) then
begin
// 转换用户ID
UserID := Utf8ToInt64(UserIDStr);
Result := True;
end;
end;
end;
function TBlogJWT.VerifyTokenEx(const Token: RawUTF8; out JwtContent: TJwtContent): Boolean;
begin
// 验证令牌并返回完整内容
fJWT.Verify(Token, JwtContent);
Result := JwtContent.result = jwtValid;
end;
end.
前端我放百度网盘