XFE Git
XFE Studio Git
Git 首页 全局搜索
XFE 主站 文档 NuGet

STHApplication

【MAUI】寄当家——安卓端交易管理系统

公开
关注 0 Fork 0 Star 0
UTF-8
using System.Buffers.Binary;
using System.Security.Cryptography;
using JiDangJia.Contracts;
using Microsoft.Data.Sqlite;
using static JiDangJia.Server.ServerStore;

namespace JiDangJia.Server;

public sealed partial class CommunityMediaStore
{
    public const int MaxSize=25*1024*1024;
    private readonly ServerStore store;
    private readonly string directory;
    private readonly MediaWatermark watermark;
    public CommunityMediaStore(ServerStore store,MediaWatermark watermark)
    {
        this.store=store;this.watermark=watermark;directory=Path.Combine(store.DataDirectory,"community-media");Directory.CreateDirectory(directory);
        store.Write(c=>Exec(c,"""
        CREATE TABLE IF NOT EXISTS CommunityMedia(id TEXT PRIMARY KEY,ownerId TEXT NOT NULL,kind TEXT NOT NULL,mimeType TEXT NOT NULL,size INTEGER NOT NULL,duration REAL NOT NULL,hash TEXT NOT NULL,clientId TEXT NOT NULL,createdAt TEXT NOT NULL,UNIQUE(ownerId,clientId));
        CREATE INDEX IF NOT EXISTS idx_media_owner ON CommunityMedia(ownerId,createdAt);
        """));
        store.Write(c=>{if(!Rows(c,"PRAGMA table_info(CommunityMedia)").Any(r=>Text(r,"name")=="purpose"))Exec(c,"ALTER TABLE CommunityMedia ADD COLUMN purpose TEXT NOT NULL DEFAULT 'GENERAL'");return 0;});
        store.Write(c=>{foreach(var column in new[]{"source","markedAt"})if(!Rows(c,"PRAGMA table_info(CommunityMedia)").Any(r=>Text(r,"name")==column))Exec(c,$"ALTER TABLE CommunityMedia ADD COLUMN {column} TEXT NOT NULL DEFAULT ''");return 0;});
        store.Write(c=>{foreach(var column in new[]{"chatExpiresAt","expiredAt"})if(!Rows(c,"PRAGMA table_info(CommunityMedia)").Any(r=>Text(r,"name")==column))Exec(c,$"ALTER TABLE CommunityMedia ADD COLUMN {column} TEXT NOT NULL DEFAULT ''");Exec(c,"CREATE INDEX IF NOT EXISTS idx_media_chat_expiry ON CommunityMedia(chatExpiresAt)");return 0;});
    }
    private static CommunityMedia Item(Dictionary<string,object?> r)=>new(Text(r,"id"),Text(r,"kind"),Text(r,"mimeType"),Convert.ToInt64(r["size"]),Convert.ToDouble(r["duration"]),Text(r,"source"),Text(r,"markedAt"),Text(r,"chatExpiresAt"));
    internal CommunityMedia ForChat(SqliteConnection c,Account actor,string? id,string kind)
    {
        var item=Owned(c,actor,id,kind);var row=Rows(c,"SELECT createdAt,chatExpiresAt,expiredAt FROM CommunityMedia WHERE id=@p0",id)[0];
        var expiry=DateTimeOffset.Parse(Text(row,"createdAt")).AddDays(ChatRetention.MediaDays(kind));
        if(expiry<=DateTimeOffset.UtcNow||Text(row,"expiredAt").Length>0)throw new CommunityException(410,"此私信附件已超过服务器保留期,请重新上传");
        Exec(c,"UPDATE CommunityMedia SET chatExpiresAt=@p0 WHERE id=@p1",expiry.ToString("O"),id);return item with{ExpiresAt=expiry.ToString("O")};
    }
    internal CommunityMedia Owned(SqliteConnection c,Account actor,string? id,string kind,bool recommendation=false,bool publicPost=false)
    {
        var row=Rows(c,"SELECT * FROM CommunityMedia WHERE id=@p0 AND ownerId=@p1",id,actor.Id).FirstOrDefault()??throw new CommunityException(403,"只能使用本人上传的媒体");
        var item=Item(row);if(kind=="POST"?item.Kind is not ("IMAGE" or "VIDEO"):item.Kind!=kind)throw new ArgumentException("媒体类型与用途不匹配");if(publicPost&&Text(row,"purpose")=="CHAT")throw new CommunityException(403,"私信附件不能直接用于公开发布,请重新选择发布图片");if(!recommendation&&Text(row,"purpose")=="RECOMMEND")throw new ArgumentException("推荐专用媒体只能用于官方推荐");if(!File.Exists(Path.Combine(directory,item.Id+".media")))throw new CommunityException(404,"媒体文件不存在");return item;
    }
    public async Task<CommunityMedia> Upload(Account actor,HttpRequest request,CancellationToken ct)
    {
        var kind=request.Headers["X-Media-Kind"].ToString();var nonce=request.Headers["X-Upload-Id"].ToString();
        string purpose=request.Headers["X-Media-Purpose"].ToString();if(purpose.Length==0)purpose="GENERAL";if(purpose is not ("GENERAL" or "CHAT"))throw new ArgumentException("媒体用途无效");
        if(kind is not ("IMAGE" or "AUDIO" or "VIDEO")||!System.Text.RegularExpressions.Regex.IsMatch(nonce,"^[a-fA-F0-9]{32}$"))throw new ArgumentException("上传类型或请求标识无效");nonce=nonce.ToLowerInvariant();
        var origin=MediaOrigin.Read(request,kind);store.Read(c=>{SocialWrite(c,actor);return 0;});
        int max=kind=="IMAGE"?5*1024*1024:MaxSize;if(request.ContentLength is <=0||request.ContentLength>max)throw new ArgumentException("图片最多 5 MB,语音或视频最多 25 MB");
        using var data=new MemoryStream();var buffer=new byte[65536];int read;
        while((read=await request.Body.ReadAsync(buffer,ct))>0){if(data.Length+read>max)throw new ArgumentException("上传文件过大");await data.WriteAsync(buffer.AsMemory(0,read),ct);}
        var bytes=data.ToArray();var hash=Convert.ToHexString(SHA256.HashData(bytes));
        var previous=store.Read(c=>{var row=Rows(c,"SELECT * FROM CommunityMedia WHERE ownerId=@p0 AND clientId=@p1",actor.Id,nonce).FirstOrDefault();if(row is null)return null;if(Text(row,"hash")!=hash||Text(row,"kind")!=kind||Text(row,"purpose")!=purpose)throw new CommunityException(409,"重复上传的文件或用途不同");origin.CheckRetry(row);return Item(row);});if(previous is not null)return previous;
        (string Mime,double Duration) inspected;
        if(kind=="VIDEO")
        {
            var input=Path.Combine(directory,".input-"+Guid.NewGuid().ToString("N"));var output=input+".webm";
            try{await File.WriteAllBytesAsync(input,bytes,ct);var original=RecommendationMediaInspector.Inspect(input,kind);if(original.Duration<=0||original.Duration>30.1)throw new ArgumentException("视频最长 30 秒,请选择较短视频");inspected=await watermark.Video(input,output,origin,false,ct);bytes=await File.ReadAllBytesAsync(output,ct);}
            finally{File.Delete(input);File.Delete(output);}
        }
        else{inspected=MediaInspector.Inspect(bytes,kind);if(kind=="IMAGE"){bytes=watermark.Image(bytes,origin);inspected=("image/jpeg",0);}}
        ct.ThrowIfCancellationRequested();
        return store.Write(c=>
        {
            SocialWrite(c,actor);var old=Rows(c,"SELECT * FROM CommunityMedia WHERE ownerId=@p0 AND clientId=@p1",actor.Id,nonce).FirstOrDefault();
            if(old is not null){if(Text(old,"hash")!=hash||Text(old,"kind")!=kind||Text(old,"purpose")!=purpose)throw new CommunityException(409,"重复上传的文件或用途不同");origin.CheckRetry(old);return Item(old);}
            // Public post media keep their own lifecycle; chat attachments have a separate retention job.
            foreach(var orphan in Rows(c,"""
                SELECT id FROM CommunityMedia m WHERE ownerId=@p0 AND purpose='GENERAL' AND createdAt<@p1
                AND NOT EXISTS(SELECT 1 FROM CommunityPosts p WHERE instr(p.mediaJson,m.id)>0)
                AND NOT EXISTS(SELECT 1 FROM ChatMessages x WHERE instr(x.mediaJson,m.id)>0)
                """,actor.Id,DateTime.UtcNow.AddDays(-7).ToString("O")))
            {var id=Text(orphan,"id");File.Delete(Path.Combine(directory,id+".media"));Exec(c,"DELETE FROM CommunityMedia WHERE id=@p0",id);}
            var total=Convert.ToInt64(Rows(c,"SELECT COALESCE(SUM(size),0) total FROM CommunityMedia WHERE ownerId=@p0 AND purpose IN ('GENERAL','CHAT') AND expiredAt=''",actor.Id)[0]["total"]);
            if(total+bytes.Length>512L*1024*1024)throw new CommunityException(413,"此账户媒体空间已满,请联系管理员");
            var uploadedAt=DateTimeOffset.UtcNow;var media=new CommunityMedia(Guid.NewGuid().ToString("N"),kind,inspected.Mime,bytes.Length,inspected.Duration,origin.Source,kind=="AUDIO"?"":origin.Timestamp,purpose=="CHAT"?uploadedAt.AddDays(ChatRetention.MediaDays(kind)).ToString("O"):"");var file=Path.Combine(directory,media.Id+".media");
            try{File.WriteAllBytes(file,bytes);Exec(c,"INSERT INTO CommunityMedia(id,ownerId,kind,mimeType,size,duration,hash,clientId,createdAt,source,markedAt,purpose,chatExpiresAt) VALUES(@p0,@p1,@p2,@p3,@p4,@p5,@p6,@p7,@p8,@p9,@p10,@p11,@p12)",media.Id,actor.Id,kind,media.MimeType,media.Size,media.Duration,hash,nonce,uploadedAt.UtcDateTime.ToString("O"),media.Source,media.MarkedAt,purpose,media.ExpiresAt);return media;}
            catch{File.Delete(file);throw;}
        });
    }
    public (string Path,string Mime) Download(Account actor,string id)=>store.Read(c=>
    {
        CommunityStore.Actor(c,actor);if(!System.Text.RegularExpressions.Regex.IsMatch(id,"^[a-f0-9]{32}$"))throw new CommunityException(404,"媒体不存在");
        var row=Rows(c,"SELECT * FROM CommunityMedia WHERE id=@p0",id).FirstOrDefault()??throw new CommunityException(404,"媒体不存在");
        bool owner=Text(row,"ownerId")==actor.Id;
        bool publicReference=Rows(c,"SELECT p.id FROM CommunityPosts p JOIN Accounts a ON a.id=p.authorId WHERE p.deletedAt IS NULL AND p.shelfStatus='ACTIVE' AND p.moderationStatus='APPROVED' AND a.active=1 AND EXISTS(SELECT 1 FROM json_each(p.mediaJson) j WHERE json_extract(j.value,'$.id')=@p0)",id).Count>0;
        bool expired=Text(row,"expiredAt").Length>0||DateTimeOffset.TryParse(Text(row,"chatExpiresAt"),out var expiry)&&expiry<=DateTimeOffset.UtcNow;
        bool allowed=owner&&Rows(c,"SELECT id FROM ChatMessages WHERE senderId=@p0 AND id<=COALESCE((SELECT throughId FROM AccountChatClears WHERE accountId=@p0),0) AND json_extract(NULLIF(mediaJson,''),'$.id')=@p1",actor.Id,id).Count==0;
        if(!allowed)allowed=publicReference;
        if(!allowed)allowed=Rows(c,"SELECT id FROM ChatMessages WHERE (senderId=@p0 OR recipientId=@p0) AND moderationStatus='APPROVED' AND id>COALESCE((SELECT throughId FROM AccountChatClears WHERE accountId=@p0),0) AND json_extract(NULLIF(mediaJson,''),'$.id')=@p1",actor.Id,id).Count>0;
        if(!allowed)throw new CommunityException(403,"无权读取此私信媒体");if(expired&&!publicReference)throw new CommunityException(410,"本机尚未保存此附件,服务器保留期已过");var file=Path.Combine(directory,id+".media");if(!File.Exists(file))throw new CommunityException(404,"媒体文件不存在");return (file,Text(row,"mimeType"));
    });
    internal byte[] ScanBytes(string id,string owner)=>store.Read(c=>{if(!System.Text.RegularExpressions.Regex.IsMatch(id,"^[a-f0-9]{32}$")||Rows(c,"SELECT id FROM CommunityMedia WHERE id=@p0 AND ownerId=@p1",id,owner).Count==0)throw new CommunityException(403,"审核只能读取对应用户的社群媒体");return File.ReadAllBytes(Path.Combine(directory,id+".media"));});
}

internal static class MediaInspector
{
    public static (string Mime,double Duration) Inspect(byte[] bytes,string kind)
    {
        if(kind=="IMAGE")return (Image(bytes),0);
        var webm=new Webm(bytes);webm.Parse();
        if(kind=="VIDEO"&&!webm.Video||kind=="AUDIO"&&(!webm.Audio||webm.Video))throw new ArgumentException("录制文件的音视频轨道无效");
        double max=kind=="VIDEO"?30:60;
        if(webm.Duration<=0||webm.Duration>max+.1)throw new ArgumentException(kind=="VIDEO"?"视频最长 30 秒,请重新录制":"语音最长 60 秒,请重新录制");
        return (kind=="VIDEO"?"video/webm":"audio/webm",webm.Duration);
    }
    private static string Image(byte[] b)
    {
        if(b.Length<30)throw new ArgumentException("图片文件不完整");
        if(b.AsSpan(0,8).SequenceEqual(new byte[]{137,80,78,71,13,10,26,10}))
        {
            int width=BinaryPrimitives.ReadInt32BigEndian(b.AsSpan(16,4)),height=BinaryPrimitives.ReadInt32BigEndian(b.AsSpan(20,4));Dimensions(width,height);
            bool data=false,end=false;for(int p=8;p+12<=b.Length;){int n=BinaryPrimitives.ReadInt32BigEndian(b.AsSpan(p,4));if(n<0||n>b.Length-p-12)throw new ArgumentException("PNG 图片损坏");var type=System.Text.Encoding.ASCII.GetString(b,p+4,4);if(type=="IDAT"&&n>0)data=true;if(type=="IEND"){end=n==0;break;}p+=n+12;}
            if(!data||!end)throw new ArgumentException("PNG 图片不完整");return "image/png";
        }
        if(b[0]==255&&b[1]==216&&b[^2]==255&&b[^1]==217)
        {
            int p=2;while(p+4<b.Length){if(b[p++]!=255)continue;int marker=b[p++];if(marker is 0xff or 0x00 or 0xd8)continue;if(marker==0xda)break;int n=BinaryPrimitives.ReadUInt16BigEndian(b.AsSpan(p,2));if(n<2||n>b.Length-p)break;if(marker is >=0xc0 and <=0xc3||marker is >=0xc5 and <=0xc7||marker is >=0xc9 and <=0xcb||marker is >=0xcd and <=0xcf){if(n<7)break;Dimensions(BinaryPrimitives.ReadUInt16BigEndian(b.AsSpan(p+5,2)),BinaryPrimitives.ReadUInt16BigEndian(b.AsSpan(p+3,2)));return "image/jpeg";}p+=n;}
        }
        throw new ArgumentException("请选择完整的 JPEG 或 PNG 图片");
    }
    private static void Dimensions(int w,int h){if(w is <1 or >8192||h is <1 or >8192||(long)w*h>25_000_000)throw new ArgumentException("图片分辨率过大或无效");}
    private sealed class Webm(byte[] data)
    {
        public bool Video,Audio;public double Duration;
        private double scale=1_000_000;private double ticks;private bool header,segment,block;private int elements;
        public void Parse(){try{ParseRange(0,data.Length,0,0);if(!header||!segment||!block)throw new ArgumentException();Duration=Math.Max(Duration,ticks*scale/1_000_000_000);}catch(Exception e)when(e is ArgumentException or OverflowException or IndexOutOfRangeException){throw new ArgumentException("录制文件损坏或不支持,请在 APP 中重新录制");}}
        private ulong Vint(ref int p,bool id,out bool unknown)
        {
            if(p>=data.Length||data[p]==0)throw new ArgumentException();byte first=data[p];int length=1,mask=128;while((first&mask)==0){length++;mask>>=1;}if(length>(id?4:8)||p+length>data.Length)throw new ArgumentException();ulong n=(ulong)(id?first:first&(mask-1));p++;for(int i=1;i<length;i++)n=(n<<8)|data[p++];unknown=!id&&n==((1UL<<(7*length))-1);return n;
        }
        private ulong UInt(int start,int end){if(end-start is <1 or >8)throw new ArgumentException();ulong n=0;for(int i=start;i<end;i++)n=(n<<8)|data[i];return n;}
        private int ParseRange(int start,int end,int depth,long cluster,ulong unknownMaster=0)
        {
            if(depth>8)throw new ArgumentException();int p=start;while(p<end)
            {
                if(++elements>200000)throw new ArgumentException();int element=p;ulong id=Vint(ref p,true,out _);
                if(unknownMaster==0x1F43B675&&id is 0x1F43B675 or 0x1549A966 or 0x1654AE6B or 0x1C53BB6B or 0x114D9B74 or 0x1043A770 or 0x1941A469 or 0x1254C367||unknownMaster==0x18538067&&id==0x18538067)return element;
                ulong size=Vint(ref p,false,out var unknown);int stop=unknown?end:checked(p+(int)size);if(stop>end||stop<p)throw new ArgumentException();
                if(id==0x1A45DFA3)header=true;if(id==0x18538067)segment=true;
                if(id==0x2AD7B1)scale=UInt(p,stop);
                if(id==0x4489){double value=stop-p==4?BitConverter.Int32BitsToSingle(BinaryPrimitives.ReadInt32BigEndian(data.AsSpan(p,4))):stop-p==8?BitConverter.Int64BitsToDouble(BinaryPrimitives.ReadInt64BigEndian(data.AsSpan(p,8))):0;if(!double.IsFinite(value)||value<0)throw new ArgumentException();Duration=Math.Max(Duration,value*scale/1_000_000_000);}
                if(id==0x83){var type=UInt(p,stop);Video|=type==1;Audio|=type==2;}
                if(id==0xE7)cluster=checked((long)UInt(p,stop));
                if(id is 0xA3 or 0xA1){int b=p;Vint(ref b,false,out _);if(b+3>stop)throw new ArgumentException();int relative=BinaryPrimitives.ReadInt16BigEndian(data.AsSpan(b,2));ticks=Math.Max(ticks,cluster+relative);block=true;}
                if(id is 0x1A45DFA3 or 0x18538067 or 0x1549A966 or 0x1654AE6B or 0xAE or 0x1F43B675 or 0xA0){int actual=ParseRange(p,stop,depth+1,id==0x1F43B675?0:cluster,unknown?id:0);if(unknown)stop=actual;}
                p=stop;
            }
            return p;
        }
    }
}