routes.py 181 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272327332743275327632773278327932803281328232833284328532863287328832893290329132923293329432953296329732983299330033013302330333043305330633073308330933103311331233133314331533163317331833193320332133223323332433253326332733283329333033313332333333343335333633373338333933403341334233433344334533463347334833493350335133523353335433553356335733583359336033613362336333643365336633673368336933703371337233733374337533763377337833793380338133823383338433853386338733883389339033913392339333943395339633973398339934003401340234033404340534063407340834093410341134123413341434153416341734183419342034213422342334243425342634273428342934303431343234333434343534363437343834393440344134423443344434453446344734483449345034513452345334543455345634573458345934603461346234633464346534663467346834693470347134723473347434753476347734783479348034813482348334843485348634873488348934903491349234933494349534963497349834993500350135023503350435053506350735083509351035113512351335143515351635173518351935203521352235233524352535263527352835293530353135323533353435353536353735383539354035413542354335443545354635473548354935503551355235533554355535563557355835593560356135623563356435653566356735683569357035713572357335743575357635773578357935803581358235833584358535863587358835893590359135923593359435953596359735983599360036013602360336043605360636073608360936103611361236133614361536163617361836193620362136223623362436253626362736283629363036313632363336343635363636373638363936403641364236433644364536463647364836493650365136523653365436553656365736583659366036613662366336643665366636673668366936703671367236733674367536763677367836793680368136823683368436853686368736883689369036913692369336943695369636973698369937003701370237033704370537063707370837093710371137123713371437153716371737183719372037213722372337243725372637273728372937303731373237333734373537363737373837393740374137423743374437453746374737483749375037513752375337543755375637573758375937603761376237633764376537663767376837693770377137723773377437753776377737783779378037813782378337843785378637873788378937903791379237933794379537963797379837993800380138023803380438053806380738083809381038113812381338143815381638173818381938203821382238233824382538263827382838293830383138323833383438353836383738383839384038413842384338443845384638473848384938503851385238533854385538563857385838593860386138623863386438653866386738683869387038713872387338743875387638773878387938803881388238833884388538863887388838893890389138923893389438953896389738983899390039013902390339043905390639073908390939103911391239133914391539163917391839193920392139223923392439253926392739283929393039313932393339343935393639373938393939403941394239433944394539463947394839493950395139523953395439553956395739583959396039613962396339643965396639673968396939703971397239733974397539763977397839793980398139823983398439853986398739883989399039913992399339943995399639973998399940004001400240034004400540064007400840094010401140124013401440154016401740184019402040214022402340244025402640274028402940304031403240334034403540364037403840394040404140424043404440454046404740484049405040514052405340544055405640574058405940604061406240634064406540664067406840694070407140724073407440754076407740784079408040814082408340844085408640874088408940904091409240934094409540964097409840994100410141024103410441054106410741084109411041114112411341144115411641174118411941204121412241234124412541264127412841294130413141324133413441354136413741384139414041414142414341444145414641474148414941504151415241534154415541564157415841594160416141624163416441654166416741684169417041714172417341744175417641774178417941804181418241834184418541864187418841894190419141924193419441954196419741984199420042014202420342044205
  1. import base64
  2. import hashlib
  3. import json
  4. import os
  5. import re
  6. import shutil
  7. import threading
  8. import time
  9. import uuid
  10. from datetime import timedelta, timezone
  11. from decimal import Decimal
  12. from pathlib import Path
  13. from typing import Any, Iterable
  14. from urllib.parse import quote, quote_plus, urlencode, urlsplit, urlunsplit
  15. import requests
  16. from flask import Flask, Response, abort, has_request_context, jsonify, redirect, render_template, request, send_file, session, url_for
  17. from werkzeug.security import check_password_hash, generate_password_hash
  18. from werkzeug.utils import secure_filename
  19. from .audit import audit
  20. from .auth import current_admin, current_user, extend_vip, is_vip_active, require_admin, require_user
  21. from .context import get_config
  22. from .core import isoformat, parse_datetime, parse_int, utcnow
  23. from .db import IntegrityError, db_status, execute, fetch_all, fetch_one, get_active_backend, switch_database
  24. from .gogs import (
  25. GogsGitError,
  26. gogs_archive_get,
  27. gogs_branches,
  28. gogs_commits,
  29. gogs_contents,
  30. gogs_create_repo,
  31. gogs_delete_repo,
  32. gogs_git_archive_zip,
  33. gogs_git_archive_zip_commit,
  34. gogs_git_list_refs,
  35. gogs_git_delete_path,
  36. gogs_git_write_file,
  37. gogs_my_repos,
  38. gogs_repo_info,
  39. gogs_resolve_ref_commit,
  40. gogs_tags,
  41. gogs_user_repos,
  42. )
  43. from .settings import delete_setting_value, get_setting_value, set_setting_value
  44. def register_routes(app: Flask) -> None:
  45. def create_user_message(
  46. user_id: int,
  47. title: str,
  48. content: str,
  49. *,
  50. sender_type: str = "SYSTEM",
  51. sender_id: int | None = None,
  52. ) -> int:
  53. cur = execute(
  54. """
  55. INSERT INTO user_messages (user_id, title, content, created_at, sender_type, sender_id)
  56. VALUES (?, ?, ?, ?, ?, ?)
  57. """,
  58. (
  59. user_id,
  60. (title or "").strip()[:120],
  61. (content or "").strip()[:4000],
  62. isoformat(utcnow()),
  63. (sender_type or "SYSTEM").strip().upper()[:16],
  64. sender_id,
  65. ),
  66. )
  67. return int(cur.lastrowid)
  68. def _gogs_base_url_and_token() -> tuple[str, str]:
  69. config = get_config()
  70. base_url = (get_setting_value("GOGS_BASE_URL") or config.gogs_base_url or "").strip().rstrip("/")
  71. token = get_setting_value("GOGS_TOKEN")
  72. if token is not None:
  73. token = token.strip() or None
  74. if token is None:
  75. token = (config.gogs_token or "").strip() or None
  76. return base_url, (token or "").strip()
  77. def _gogs_error_message(resp: requests.Response) -> str | None:
  78. try:
  79. data = resp.json() if resp is not None else None
  80. except Exception:
  81. data = None
  82. if isinstance(data, dict):
  83. msg = data.get("message") or data.get("error") or data.get("error_description")
  84. if msg:
  85. return str(msg)[:300]
  86. try:
  87. text = (resp.text or "").strip()
  88. except Exception:
  89. text = ""
  90. return text[:300] or None
  91. def _safe_upstream_url(resp: requests.Response) -> str | None:
  92. try:
  93. url = (getattr(resp, "url", None) or "").strip()
  94. except Exception:
  95. url = ""
  96. if not url:
  97. return None
  98. url = re.sub(r"([?&])token=[^&]+", r"\1token=***", url)
  99. url = re.sub(r"//[^/@]*@", "//***@", url)
  100. return url[:500]
  101. def _looks_like_html(text: str | None) -> bool:
  102. s = (text or "").lstrip().lower()
  103. return s.startswith("<!doctype html") or s.startswith("<html") or s.startswith("<head")
  104. def _alipay_wrap_key(key: str | None, kind: str) -> str:
  105. s = (key or "").strip()
  106. if not s:
  107. return ""
  108. if "BEGIN " in s and "END " in s:
  109. return s
  110. body = re.sub(r"\s+", "", s)
  111. if not body:
  112. return ""
  113. header = "-----BEGIN PRIVATE KEY-----" if kind == "private" else "-----BEGIN PUBLIC KEY-----"
  114. footer = "-----END PRIVATE KEY-----" if kind == "private" else "-----END PUBLIC KEY-----"
  115. lines = [body[i : i + 64] for i in range(0, len(body), 64)]
  116. return "\n".join([header, *lines, footer, ""])
  117. def _alipay_sign_content(params: dict[str, Any]) -> str:
  118. items: list[tuple[str, str]] = []
  119. for k, v in (params or {}).items():
  120. if v is None:
  121. continue
  122. sv = str(v)
  123. if sv == "":
  124. continue
  125. items.append((str(k), sv))
  126. items.sort(key=lambda x: x[0])
  127. return "&".join([f"{k}={v}" for k, v in items])
  128. def _alipay_rsa2_sign(sign_content: str, private_key: str) -> str:
  129. try:
  130. from Crypto.Hash import SHA256
  131. from Crypto.PublicKey import RSA
  132. from Crypto.Signature import pkcs1_15
  133. except Exception as e:
  134. raise RuntimeError("pycryptodome_required") from e
  135. key = RSA.import_key(_alipay_wrap_key(private_key, "private"))
  136. h = SHA256.new((sign_content or "").encode("utf-8"))
  137. sig = pkcs1_15.new(key).sign(h)
  138. return base64.b64encode(sig).decode("utf-8")
  139. def _alipay_rsa2_verify(sign_content: str, signature_b64: str, public_key: str) -> bool:
  140. try:
  141. from Crypto.Hash import SHA256
  142. from Crypto.PublicKey import RSA
  143. from Crypto.Signature import pkcs1_15
  144. except Exception as e:
  145. raise RuntimeError("pycryptodome_required") from e
  146. key = RSA.import_key(_alipay_wrap_key(public_key, "public"))
  147. h = SHA256.new((sign_content or "").encode("utf-8"))
  148. try:
  149. pkcs1_15.new(key).verify(h, base64.b64decode(signature_b64 or ""))
  150. return True
  151. except Exception:
  152. return False
  153. def _parse_keywords(value: Any) -> list[str]:
  154. if isinstance(value, list):
  155. parts = [str(x).strip() for x in value]
  156. else:
  157. raw = str(value or "")
  158. parts = re.split(r"[,\n\r\t ]+", raw)
  159. items: list[str] = []
  160. seen: set[str] = set()
  161. for p in parts:
  162. p = (p or "").strip()
  163. if not p:
  164. continue
  165. if len(p) > 32:
  166. p = p[:32]
  167. k = p.lower()
  168. if k in seen:
  169. continue
  170. seen.add(k)
  171. items.append(p)
  172. if len(items) >= 20:
  173. break
  174. return items
  175. def _slugify_repo_name(title: str) -> str:
  176. s = (title or "").strip().lower()
  177. s = re.sub(r"[^a-z0-9]+", "-", s)
  178. s = s.strip("-")
  179. if not s:
  180. s = f"resource-{uuid.uuid4().hex[:8]}"
  181. if len(s) > 50:
  182. s = s[:50].rstrip("-")
  183. return s
  184. def _uploads_dir() -> Path:
  185. project_root = Path(__file__).resolve().parent.parent
  186. d = project_root / "static" / "uploads"
  187. d.mkdir(parents=True, exist_ok=True)
  188. return d
  189. def _extract_upload_names(value: Any) -> set[str]:
  190. s = str(value or "").strip()
  191. if not s:
  192. return set()
  193. names: set[str] = set()
  194. for m in re.finditer(r"(?i)(?:/static/uploads/|/uploads/|/)([0-9a-f]{32}(?:\.[a-z0-9]+)?)", s):
  195. names.add(m.group(1))
  196. if s.startswith("/static/uploads/") or s.startswith("static/uploads/") or s.startswith("uploads/"):
  197. name = os.path.basename(s)
  198. if re.fullmatch(r"(?i)[0-9a-f]{32}(?:\.[a-z0-9]+)?", name or ""):
  199. names.add(name)
  200. return names
  201. def _delete_upload_files(names: set[str]) -> None:
  202. if not names:
  203. return
  204. base = _uploads_dir().resolve()
  205. for name in names:
  206. n = os.path.basename(str(name or ""))
  207. if not re.fullmatch(r"(?i)[0-9a-f]{32}(?:\.[a-z0-9]+)?", n or ""):
  208. continue
  209. p = (base / n).resolve()
  210. if p.parent != base:
  211. continue
  212. try:
  213. p.unlink(missing_ok=True)
  214. except Exception:
  215. pass
  216. def _normalize_upload_prefix(prefix: Any) -> str:
  217. p = str(prefix or "").strip().replace("\\", "/")
  218. p = p.lstrip("/")
  219. if not p:
  220. p = "uploads/"
  221. if not p.endswith("/"):
  222. p = f"{p}/"
  223. return p
  224. def _get_upload_storage_mode() -> str:
  225. v = (get_setting_value("STORAGE_PROVIDER") or "").strip().upper()
  226. if v in {"LOCAL", "OSS", "AUTO"}:
  227. return v
  228. return "AUTO"
  229. def _get_oss_upload_config() -> dict[str, Any]:
  230. endpoint = str(get_setting_value("OSS_ENDPOINT") or "").strip().rstrip("/")
  231. bucket = str(get_setting_value("OSS_BUCKET") or "").strip()
  232. access_key_id = str(get_setting_value("OSS_ACCESS_KEY_ID") or "").strip()
  233. access_key_secret = str(get_setting_value("OSS_ACCESS_KEY_SECRET") or "").strip()
  234. upload_prefix = _normalize_upload_prefix(get_setting_value("OSS_UPLOAD_PREFIX") or "uploads/")
  235. public_base_url = str(get_setting_value("OSS_PUBLIC_BASE_URL") or "").strip().rstrip("/")
  236. ok = bool(endpoint and bucket and access_key_id and access_key_secret)
  237. return {
  238. "ok": ok,
  239. "endpoint": endpoint,
  240. "bucket": bucket,
  241. "accessKeyId": access_key_id,
  242. "accessKeySecret": access_key_secret,
  243. "uploadPrefix": upload_prefix,
  244. "publicBaseUrl": public_base_url,
  245. }
  246. def _build_oss_public_url(*, public_base_url: str, endpoint: str, bucket: str, key: str) -> str:
  247. key = str(key or "").lstrip("/")
  248. if public_base_url:
  249. return f"{public_base_url.rstrip('/')}/{key}"
  250. try:
  251. parts = urlsplit(endpoint)
  252. scheme = parts.scheme or "https"
  253. host = parts.netloc
  254. if not host:
  255. return f"/{key}"
  256. host = host.split("@", 1)[-1]
  257. if host.startswith(f"{bucket}."):
  258. full_host = host
  259. else:
  260. full_host = f"{bucket}.{host}"
  261. return urlunsplit((scheme, full_host, f"/{key}", "", ""))
  262. except Exception:
  263. return f"/{key}"
  264. def _guess_upload_kind(ext: str) -> str:
  265. e = (ext or "").lower()
  266. if e in {".png", ".jpg", ".jpeg", ".gif", ".webp"}:
  267. return "image"
  268. if e in {".mp4", ".webm", ".mov", ".m4v"}:
  269. return "video"
  270. return "file"
  271. class _LocalUploadStorage:
  272. def save_upload(self, file_storage: Any, name: str) -> dict[str, Any]:
  273. out = _uploads_dir() / name
  274. file_storage.save(out)
  275. return {"name": name, "url": f"/static/uploads/{name}"}
  276. def delete_uploads(self, names: set[str]) -> None:
  277. _delete_upload_files(names)
  278. def list_items(self) -> list[dict[str, Any]]:
  279. base = _uploads_dir().resolve()
  280. all_items: list[dict[str, Any]] = []
  281. for p in base.iterdir():
  282. if not p.is_file():
  283. continue
  284. name = p.name
  285. if not re.fullmatch(r"(?i)[0-9a-f]{32}(?:\.[a-z0-9]+)?", name or ""):
  286. continue
  287. try:
  288. st = p.stat()
  289. except Exception:
  290. continue
  291. ext = p.suffix.lower()
  292. all_items.append(
  293. {
  294. "name": name,
  295. "url": f"/static/uploads/{name}",
  296. "bytes": int(getattr(st, "st_size", 0) or 0),
  297. "mtime": int(getattr(st, "st_mtime", 0) or 0),
  298. "ext": ext,
  299. "kind": _guess_upload_kind(ext),
  300. }
  301. )
  302. return all_items
  303. class _OssUploadStorage:
  304. def __init__(self, cfg: dict[str, Any]):
  305. try:
  306. import oss2 # type: ignore
  307. except Exception:
  308. raise RuntimeError("oss_client_missing")
  309. endpoint = str(cfg.get("endpoint") or "").strip().rstrip("/")
  310. bucket = str(cfg.get("bucket") or "").strip()
  311. access_key_id = str(cfg.get("accessKeyId") or "").strip()
  312. access_key_secret = str(cfg.get("accessKeySecret") or "").strip()
  313. self._upload_prefix = str(cfg.get("uploadPrefix") or "uploads/")
  314. self._public_base_url = str(cfg.get("publicBaseUrl") or "").strip().rstrip("/")
  315. if not endpoint or not bucket or not access_key_id or not access_key_secret:
  316. raise RuntimeError("oss_not_configured")
  317. auth = oss2.Auth(access_key_id, access_key_secret)
  318. self._bucket_name = bucket
  319. self._endpoint = endpoint
  320. self._bucket = oss2.Bucket(auth, endpoint, bucket)
  321. self._oss2 = oss2
  322. def _key_for_name(self, name: str) -> str:
  323. n = os.path.basename(str(name or ""))
  324. return f"{self._upload_prefix}{n}"
  325. def save_upload(self, file_storage: Any, name: str) -> dict[str, Any]:
  326. key = self._key_for_name(name)
  327. try:
  328. file_storage.stream.seek(0)
  329. except Exception:
  330. pass
  331. self._bucket.put_object(key, file_storage.stream)
  332. url = _build_oss_public_url(public_base_url=self._public_base_url, endpoint=self._endpoint, bucket=self._bucket_name, key=key)
  333. return {"name": os.path.basename(name), "url": url}
  334. def delete_uploads(self, names: set[str]) -> None:
  335. for name in names:
  336. n = os.path.basename(str(name or ""))
  337. if not re.fullmatch(r"(?i)[0-9a-f]{32}(?:\.[a-z0-9]+)?", n or ""):
  338. continue
  339. key = self._key_for_name(n)
  340. try:
  341. self._bucket.delete_object(key)
  342. except Exception:
  343. pass
  344. def list_items(self) -> list[dict[str, Any]]:
  345. items: list[dict[str, Any]] = []
  346. for obj in self._oss2.ObjectIterator(self._bucket, prefix=self._upload_prefix):
  347. key = str(getattr(obj, "key", "") or "")
  348. if not key.startswith(self._upload_prefix):
  349. continue
  350. name = key[len(self._upload_prefix) :]
  351. if not name or "/" in name:
  352. continue
  353. if not re.fullmatch(r"(?i)[0-9a-f]{32}(?:\.[a-z0-9]+)?", name or ""):
  354. continue
  355. ext = os.path.splitext(name)[1].lower()
  356. url = _build_oss_public_url(public_base_url=self._public_base_url, endpoint=self._endpoint, bucket=self._bucket_name, key=key)
  357. items.append(
  358. {
  359. "name": name,
  360. "url": url,
  361. "bytes": int(getattr(obj, "size", 0) or 0),
  362. "mtime": int(getattr(obj, "last_modified", 0) or 0),
  363. "ext": ext,
  364. "kind": _guess_upload_kind(ext),
  365. }
  366. )
  367. return items
  368. def _get_upload_storage() -> Any:
  369. mode = _get_upload_storage_mode()
  370. oss_cfg = _get_oss_upload_config()
  371. if mode == "LOCAL":
  372. return _LocalUploadStorage()
  373. if mode == "OSS":
  374. return _OssUploadStorage(oss_cfg)
  375. if oss_cfg.get("ok"):
  376. return _OssUploadStorage(oss_cfg)
  377. return _LocalUploadStorage()
  378. def _guest_can_preview_repo_path(path: str) -> bool:
  379. p = (path or "").strip().replace("\\", "/").lstrip("/")
  380. if not p:
  381. return False
  382. base = os.path.basename(p).lower()
  383. if base in {
  384. ".env",
  385. ".env.local",
  386. ".env.development",
  387. ".env.production",
  388. ".env.test",
  389. "id_rsa",
  390. "id_dsa",
  391. "id_ed25519",
  392. "id_ecdsa",
  393. }:
  394. return False
  395. ext = os.path.splitext(base)[1].lower()
  396. if ext in {".key", ".pem", ".p12", ".pfx"}:
  397. return False
  398. if base.startswith(("readme", "license", "changelog")):
  399. return True
  400. return ext in {".md", ".txt", ".json", ".yml", ".yaml", ".toml", ".ini", ".conf"}
  401. @app.get("/")
  402. def page_index() -> str:
  403. return render_template("index.html")
  404. @app.get("/ui/resources")
  405. def page_resources() -> str:
  406. return render_template("resources.html")
  407. @app.get("/ui/resources/<int:resource_id>")
  408. def page_resource_detail(resource_id: int) -> str:
  409. return render_template("resource_detail.html", resource_id=resource_id)
  410. @app.get("/ui/login")
  411. def page_login() -> str:
  412. return render_template("login.html")
  413. @app.get("/ui/register")
  414. def page_register() -> str:
  415. return render_template("register.html")
  416. @app.get("/ui/me")
  417. def page_me() -> str:
  418. return render_template("me.html")
  419. @app.get("/ui/messages")
  420. def page_messages() -> str:
  421. return render_template("messages.html")
  422. @app.get("/ui/vip")
  423. def page_vip() -> str:
  424. return render_template("vip.html")
  425. @app.get("/ui/admin")
  426. def page_admin() -> Response:
  427. if current_admin() is None:
  428. return redirect(url_for("page_admin_login"))
  429. return render_template("admin.html")
  430. @app.get("/ui/admin/login")
  431. def page_admin_login() -> str:
  432. return render_template("admin_login.html")
  433. @app.get("/admin")
  434. def page_admin_shortcut() -> Response:
  435. return redirect(url_for("page_admin"))
  436. @app.get("/admin/login")
  437. def page_admin_login_shortcut() -> Response:
  438. return redirect(url_for("page_admin_login"))
  439. @app.post("/auth/register")
  440. def api_register() -> Response:
  441. payload = request.get_json(silent=True) or {}
  442. phone = (payload.get("phone") or "").strip()
  443. password = payload.get("password") or ""
  444. if not phone or not password:
  445. return jsonify({"error": "phone_and_password_required"}), 400
  446. if len(password) < 6:
  447. return jsonify({"error": "password_too_short"}), 400
  448. created_at = isoformat(utcnow())
  449. try:
  450. cur = execute(
  451. "INSERT INTO users (phone, password_hash, status, created_at) VALUES (?, ?, 'ACTIVE', ?)",
  452. (phone, generate_password_hash(password), created_at),
  453. )
  454. except IntegrityError:
  455. return jsonify({"error": "phone_exists"}), 409
  456. session["user_id"] = cur.lastrowid
  457. return jsonify({"id": cur.lastrowid, "phone": phone, "vipExpireAt": None})
  458. @app.post("/auth/login")
  459. def api_login() -> Response:
  460. payload = request.get_json(silent=True) or {}
  461. phone = (payload.get("phone") or "").strip()
  462. password = payload.get("password") or ""
  463. user = fetch_one("SELECT * FROM users WHERE phone = ?", (phone,))
  464. if user is None or not check_password_hash(user["password_hash"], password):
  465. return jsonify({"error": "invalid_credentials"}), 401
  466. if user["status"] != "ACTIVE":
  467. return jsonify({"error": "user_disabled"}), 403
  468. session["user_id"] = user["id"]
  469. return jsonify({"id": user["id"], "phone": user["phone"], "vipExpireAt": user["vip_expire_at"]})
  470. @app.post("/auth/logout")
  471. def api_logout() -> Response:
  472. session.pop("user_id", None)
  473. return jsonify({"ok": True})
  474. @app.get("/me")
  475. def api_me() -> Response:
  476. user = current_user()
  477. if user is None:
  478. return jsonify({"user": None})
  479. return jsonify(
  480. {
  481. "user": {
  482. "id": user["id"],
  483. "phone": user["phone"],
  484. "vipExpireAt": user["vip_expire_at"],
  485. "vipActive": is_vip_active(user),
  486. }
  487. }
  488. )
  489. @app.get("/plans")
  490. def api_plans() -> Response:
  491. rows = fetch_all("SELECT * FROM plans WHERE enabled = 1 ORDER BY sort DESC, id DESC")
  492. return jsonify(
  493. [
  494. {
  495. "id": row["id"],
  496. "name": row["name"],
  497. "durationDays": row["duration_days"],
  498. "priceCents": row["price_cents"],
  499. }
  500. for row in rows
  501. ]
  502. )
  503. @app.get("/resources")
  504. def api_resources() -> Response:
  505. q = (request.args.get("q") or "").strip()
  506. resource_type = (request.args.get("type") or "").strip().upper()
  507. sort = (request.args.get("sort") or "latest").strip()
  508. page = max(parse_int(request.args.get("page"), 1), 1)
  509. page_size = min(max(parse_int(request.args.get("pageSize"), 12), 1), 50)
  510. where = ["status = 'ONLINE'"]
  511. params: list[Any] = []
  512. if q:
  513. where.append("(title LIKE ? OR summary LIKE ?)")
  514. params.extend([f"%{q}%", f"%{q}%"])
  515. if resource_type in {"FREE", "VIP"}:
  516. where.append("type = ?")
  517. params.append(resource_type)
  518. if sort == "hot":
  519. order_by = "view_count DESC, id DESC"
  520. else:
  521. order_by = "updated_at DESC, id DESC"
  522. where_sql = " AND ".join(where)
  523. total_row = fetch_one(f"SELECT COUNT(1) AS cnt FROM resources WHERE {where_sql}", params)
  524. total = int(total_row["cnt"] if total_row is not None else 0)
  525. offset = (page - 1) * page_size
  526. rows = fetch_all(
  527. f"""
  528. SELECT * FROM resources
  529. WHERE {where_sql}
  530. ORDER BY {order_by}
  531. LIMIT ? OFFSET ?
  532. """,
  533. params + [page_size, offset],
  534. )
  535. items = []
  536. for row in rows:
  537. try:
  538. tags = json.loads(row["tags_json"] or "[]")
  539. except Exception:
  540. tags = []
  541. items.append(
  542. {
  543. "id": row["id"],
  544. "title": row["title"],
  545. "summary": row["summary"],
  546. "type": row["type"],
  547. "coverUrl": (str(row["cover_url"]).strip() if row["cover_url"] is not None else "") or "/static/images/resources/default.png",
  548. "tags": tags,
  549. "updatedAt": row["updated_at"],
  550. "viewCount": row["view_count"],
  551. "downloadCount": row["download_count"],
  552. "repo": {
  553. "owner": row["repo_owner"],
  554. "name": row["repo_name"],
  555. "defaultRef": row["default_ref"],
  556. },
  557. }
  558. )
  559. return jsonify({"items": items, "page": page, "pageSize": page_size, "total": total})
  560. @app.get("/resources/<int:resource_id>")
  561. def api_resource_detail(resource_id: int) -> Response:
  562. row = fetch_one("SELECT * FROM resources WHERE id = ? AND status = 'ONLINE'", (resource_id,))
  563. if row is None:
  564. abort(404)
  565. execute("UPDATE resources SET view_count = view_count + 1 WHERE id = ?", (resource_id,))
  566. try:
  567. tags = json.loads(row["tags_json"] or "[]")
  568. except Exception:
  569. tags = []
  570. return jsonify(
  571. {
  572. "id": row["id"],
  573. "title": row["title"],
  574. "summary": row["summary"],
  575. "type": row["type"],
  576. "coverUrl": (str(row["cover_url"]).strip() if row["cover_url"] is not None else "") or "/static/images/resources/default.png",
  577. "tags": tags,
  578. "updatedAt": row["updated_at"],
  579. "viewCount": row["view_count"] + 1,
  580. "downloadCount": row["download_count"],
  581. "repo": {
  582. "owner": row["repo_owner"],
  583. "name": row["repo_name"],
  584. "htmlUrl": row["repo_html_url"],
  585. "defaultRef": row["default_ref"],
  586. "private": bool(row["repo_private"]),
  587. },
  588. }
  589. )
  590. @app.get("/resources/<int:resource_id>/repo/refs")
  591. def api_repo_refs(resource_id: int) -> Response:
  592. row = fetch_one("SELECT repo_owner, repo_name FROM resources WHERE id = ? AND status = 'ONLINE'", (resource_id,))
  593. if row is None:
  594. abort(404)
  595. owner, repo = row["repo_owner"], row["repo_name"]
  596. branches_resp = gogs_branches(owner, repo)
  597. tags_resp = gogs_tags(owner, repo)
  598. if branches_resp.status_code < 400 and tags_resp.status_code < 400:
  599. try:
  600. branches = branches_resp.json()
  601. tags = tags_resp.json()
  602. except Exception:
  603. msg = _gogs_error_message(branches_resp) or _gogs_error_message(tags_resp)
  604. upstream_url = _safe_upstream_url(branches_resp) or _safe_upstream_url(tags_resp)
  605. return jsonify({"error": "gogs_invalid_response", "status": 200, "message": msg, "url": upstream_url}), 502
  606. return jsonify({"branches": [{"name": b.get("name")} for b in (branches or [])], "tags": [{"name": t.get("name")} for t in (tags or [])]})
  607. if branches_resp.status_code in {401, 403} or tags_resp.status_code in {401, 403}:
  608. msg = _gogs_error_message(branches_resp) or _gogs_error_message(tags_resp)
  609. upstream_url = _safe_upstream_url(branches_resp) or _safe_upstream_url(tags_resp)
  610. status = branches_resp.status_code if branches_resp.status_code >= 400 else tags_resp.status_code
  611. return jsonify({"error": "gogs_unauthorized", "status": status, "message": msg, "url": upstream_url}), 400
  612. if branches_resp.status_code == 599 or tags_resp.status_code == 599:
  613. msg = _gogs_error_message(branches_resp) or _gogs_error_message(tags_resp)
  614. upstream_url = _safe_upstream_url(branches_resp) or _safe_upstream_url(tags_resp)
  615. return jsonify({"error": "gogs_unreachable", "status": 599, "message": msg, "url": upstream_url}), 502
  616. try:
  617. return jsonify(gogs_git_list_refs(owner, repo))
  618. except GogsGitError as e:
  619. resp, status = _git_error_to_response(e)
  620. return resp, status
  621. msg = _gogs_error_message(branches_resp) or _gogs_error_message(tags_resp)
  622. upstream_url = _safe_upstream_url(branches_resp) or _safe_upstream_url(tags_resp)
  623. status = branches_resp.status_code if branches_resp.status_code >= 400 else tags_resp.status_code
  624. return jsonify({"error": "gogs_failed", "status": status, "message": msg, "url": upstream_url}), 502
  625. @app.get("/resources/<int:resource_id>/repo/tree")
  626. def api_repo_tree(resource_id: int) -> Response:
  627. ref = (request.args.get("ref") or "").strip()
  628. path = (request.args.get("path") or "").strip()
  629. user = current_user()
  630. row = fetch_one(
  631. "SELECT repo_owner, repo_name, default_ref FROM resources WHERE id = ? AND status = 'ONLINE'",
  632. (resource_id,),
  633. )
  634. if row is None:
  635. abort(404)
  636. owner, repo, default_ref = row["repo_owner"], row["repo_name"], row["default_ref"]
  637. if not ref:
  638. ref = default_ref
  639. resp = gogs_contents(owner, repo, path, ref)
  640. if resp.status_code == 404:
  641. return jsonify({"error": "path_not_found"}), 404
  642. if resp.status_code >= 400:
  643. return jsonify({"error": "gogs_failed", "status": resp.status_code}), 502
  644. try:
  645. data = resp.json()
  646. except Exception:
  647. msg = _gogs_error_message(resp)
  648. upstream_url = _safe_upstream_url(resp)
  649. return jsonify({"error": "gogs_invalid_response", "status": resp.status_code, "message": msg, "url": upstream_url}), 502
  650. if not isinstance(data, list):
  651. return jsonify({"error": "not_a_directory"}), 400
  652. items = []
  653. for item in data:
  654. item_path = item.get("path") or ""
  655. item_type = item.get("type")
  656. items.append(
  657. {
  658. "name": item.get("name"),
  659. "path": item_path,
  660. "type": item_type,
  661. "size": item.get("size"),
  662. "guestAllowed": True if user is not None else (True if item_type == "dir" else _guest_can_preview_repo_path(item_path)),
  663. }
  664. )
  665. items.sort(key=lambda x: (0 if x["type"] == "dir" else 1, x["name"] or ""))
  666. return jsonify({"ref": ref, "path": path, "items": items})
  667. @app.get("/resources/<int:resource_id>/repo/file")
  668. def api_repo_file(resource_id: int) -> Response:
  669. config = get_config()
  670. ref = (request.args.get("ref") or "").strip()
  671. raw_path = (request.args.get("path") or "").strip()
  672. if not raw_path:
  673. return jsonify({"error": "path_required"}), 400
  674. path = _normalize_repo_path(raw_path) or ""
  675. if not path:
  676. return jsonify({"error": "path_invalid"}), 400
  677. user = current_user()
  678. if user is None and not _guest_can_preview_repo_path(path):
  679. return jsonify({"error": "login_required", "path": path}), 401
  680. row = fetch_one(
  681. "SELECT repo_owner, repo_name, default_ref FROM resources WHERE id = ? AND status = 'ONLINE'",
  682. (resource_id,),
  683. )
  684. if row is None:
  685. abort(404)
  686. owner, repo, default_ref = row["repo_owner"], row["repo_name"], row["default_ref"]
  687. if not ref:
  688. ref = default_ref
  689. resp = gogs_contents(owner, repo, path, ref)
  690. if resp.status_code == 404:
  691. return jsonify({"error": "file_not_found"}), 404
  692. if resp.status_code >= 400:
  693. return jsonify({"error": "gogs_failed", "status": resp.status_code}), 502
  694. try:
  695. data = resp.json()
  696. except Exception:
  697. msg = _gogs_error_message(resp)
  698. upstream_url = _safe_upstream_url(resp)
  699. return jsonify({"error": "gogs_invalid_response", "status": resp.status_code, "message": msg, "url": upstream_url}), 502
  700. if isinstance(data, list) or data.get("type") != "file":
  701. return jsonify({"error": "not_a_file"}), 400
  702. size = parse_int(data.get("size"), 0)
  703. if size > config.max_preview_bytes:
  704. return jsonify({"error": "file_too_large", "maxBytes": config.max_preview_bytes, "size": size}), 413
  705. encoding = data.get("encoding")
  706. content = data.get("content") or ""
  707. if encoding != "base64":
  708. return jsonify({"error": "unsupported_encoding", "encoding": encoding}), 400
  709. try:
  710. raw = base64.b64decode(content, validate=False)
  711. except Exception:
  712. return jsonify({"error": "decode_failed"}), 400
  713. try:
  714. text = raw.decode("utf-8")
  715. is_text = True
  716. except UnicodeDecodeError:
  717. text = ""
  718. is_text = False
  719. if not is_text:
  720. return jsonify({"error": "binary_file_not_previewable"}), 415
  721. return jsonify({"ref": ref, "path": path, "content": text})
  722. def _normalize_repo_path(raw: str) -> str | None:
  723. s = (raw or "").strip().replace("\\", "/").lstrip("/")
  724. if not s:
  725. return None
  726. parts = [p for p in s.split("/") if p]
  727. if any(p == ".." for p in parts):
  728. return None
  729. if ":" in parts[0]:
  730. return None
  731. return "/".join(parts)
  732. def _git_error_to_response(e: GogsGitError) -> tuple[Response, int]:
  733. if e.code in {"path_required", "ref_required", "gogs_token_required", "invalid_gogs_base_url"}:
  734. return jsonify({"error": e.code, "message": e.message}), 400
  735. if e.code == "file_exists":
  736. return jsonify({"error": e.code, "message": e.message}), 409
  737. if e.code == "empty_repo":
  738. return jsonify({"error": e.code, "message": e.message}), 409
  739. if e.code in {"file_not_found", "path_not_found", "branch_not_found"}:
  740. return jsonify({"error": e.code, "message": e.message}), 404
  741. if e.code == "git_not_found":
  742. return jsonify({"error": e.code, "message": e.message}), 501
  743. return jsonify({"error": e.code, "message": e.message}), 502
  744. @app.post("/resources/<int:resource_id>/repo/file")
  745. def api_repo_file_create(resource_id: int) -> Response:
  746. _ = require_admin()
  747. payload = request.get_json(silent=True) or {}
  748. ref = (payload.get("ref") or "").strip()
  749. path = _normalize_repo_path(payload.get("path") or "")
  750. content = payload.get("content") or ""
  751. message = payload.get("message") or ""
  752. if not path:
  753. return jsonify({"error": "path_required"}), 400
  754. row = fetch_one("SELECT repo_owner, repo_name, default_ref FROM resources WHERE id = ?", (resource_id,))
  755. if row is None:
  756. abort(404)
  757. if not ref:
  758. ref = (row["default_ref"] or "").strip()
  759. try:
  760. result = gogs_git_write_file(row["repo_owner"], row["repo_name"], ref, path, str(content), str(message), must_create=True)
  761. except GogsGitError as e:
  762. resp, status = _git_error_to_response(e)
  763. return resp, status
  764. return jsonify({"ok": True, **result})
  765. @app.put("/resources/<int:resource_id>/repo/file")
  766. def api_repo_file_update(resource_id: int) -> Response:
  767. _ = require_admin()
  768. payload = request.get_json(silent=True) or {}
  769. ref = (payload.get("ref") or "").strip()
  770. path = _normalize_repo_path(payload.get("path") or "")
  771. content = payload.get("content") or ""
  772. message = payload.get("message") or ""
  773. if not path:
  774. return jsonify({"error": "path_required"}), 400
  775. row = fetch_one("SELECT repo_owner, repo_name, default_ref FROM resources WHERE id = ?", (resource_id,))
  776. if row is None:
  777. abort(404)
  778. if not ref:
  779. ref = (row["default_ref"] or "").strip()
  780. try:
  781. result = gogs_git_write_file(row["repo_owner"], row["repo_name"], ref, path, str(content), str(message), must_create=False)
  782. except GogsGitError as e:
  783. resp, status = _git_error_to_response(e)
  784. return resp, status
  785. return jsonify({"ok": True, **result})
  786. @app.delete("/resources/<int:resource_id>/repo/file")
  787. def api_repo_file_delete(resource_id: int) -> Response:
  788. _ = require_admin()
  789. payload = request.get_json(silent=True) or {}
  790. ref = (payload.get("ref") or "").strip()
  791. path = _normalize_repo_path(payload.get("path") or "")
  792. message = payload.get("message") or ""
  793. if not path:
  794. return jsonify({"error": "path_required"}), 400
  795. row = fetch_one("SELECT repo_owner, repo_name, default_ref FROM resources WHERE id = ?", (resource_id,))
  796. if row is None:
  797. abort(404)
  798. if not ref:
  799. ref = (row["default_ref"] or "").strip()
  800. try:
  801. result = gogs_git_delete_path(row["repo_owner"], row["repo_name"], ref, path, str(message))
  802. except GogsGitError as e:
  803. resp, status = _git_error_to_response(e)
  804. return resp, status
  805. return jsonify({"ok": True, **result})
  806. @app.get("/resources/<int:resource_id>/repo/commits")
  807. def api_repo_commits(resource_id: int) -> Response:
  808. ref = (request.args.get("ref") or "").strip()
  809. raw_path = (request.args.get("path") or "").strip()
  810. limit = parse_int(request.args.get("limit"), 20)
  811. if limit < 1:
  812. limit = 1
  813. if limit > 50:
  814. limit = 50
  815. path = ""
  816. if raw_path:
  817. path = _normalize_repo_path(raw_path) or ""
  818. if not path:
  819. return jsonify({"error": "path_invalid"}), 400
  820. row = fetch_one(
  821. "SELECT repo_owner, repo_name, default_ref FROM resources WHERE id = ? AND status = 'ONLINE'",
  822. (resource_id,),
  823. )
  824. if row is None:
  825. abort(404)
  826. if not ref:
  827. ref = (row["default_ref"] or "").strip()
  828. resp = gogs_commits(row["repo_owner"], row["repo_name"], ref=ref, path=path, limit=limit)
  829. if resp.status_code >= 400:
  830. msg = _gogs_error_message(resp)
  831. upstream_url = _safe_upstream_url(resp)
  832. return jsonify({"error": "gogs_failed", "status": resp.status_code, "message": msg, "url": upstream_url}), 502
  833. try:
  834. data = resp.json()
  835. except Exception:
  836. msg = _gogs_error_message(resp)
  837. upstream_url = _safe_upstream_url(resp)
  838. return jsonify({"error": "gogs_invalid_response", "status": resp.status_code, "message": msg, "url": upstream_url}), 502
  839. if not isinstance(data, list):
  840. return jsonify({"error": "gogs_invalid_response", "status": resp.status_code}), 502
  841. items = []
  842. for c in data:
  843. sha = (c.get("sha") or "").strip()
  844. commit = c.get("commit") or {}
  845. author = (commit.get("author") or {}) if isinstance(commit, dict) else {}
  846. items.append(
  847. {
  848. "sha": sha,
  849. "authorName": (author.get("name") or "") if isinstance(author, dict) else "",
  850. "authorDate": (author.get("date") or "") if isinstance(author, dict) else "",
  851. "subject": (commit.get("message") or "").splitlines()[0][:300] if isinstance(commit, dict) else "",
  852. }
  853. )
  854. return jsonify({"ref": ref, "path": path or "", "items": items})
  855. @app.get("/resources/<int:resource_id>/repo/readme")
  856. def api_repo_readme(resource_id: int) -> Response:
  857. config = get_config()
  858. ref = (request.args.get("ref") or "").strip()
  859. row = fetch_one(
  860. "SELECT repo_owner, repo_name, default_ref FROM resources WHERE id = ? AND status = 'ONLINE'",
  861. (resource_id,),
  862. )
  863. if row is None:
  864. abort(404)
  865. owner, repo, default_ref = row["repo_owner"], row["repo_name"], row["default_ref"]
  866. if not ref:
  867. ref = default_ref
  868. candidates = ["README.md", "readme.md", "README.MD", "Readme.md"]
  869. for name in candidates:
  870. resp = gogs_contents(owner, repo, name, ref)
  871. if resp.status_code == 404:
  872. continue
  873. if resp.status_code >= 400:
  874. return jsonify({"error": "gogs_failed", "status": resp.status_code}), 502
  875. try:
  876. data = resp.json()
  877. except Exception:
  878. msg = _gogs_error_message(resp)
  879. upstream_url = _safe_upstream_url(resp)
  880. return jsonify({"error": "gogs_invalid_response", "status": resp.status_code, "message": msg, "url": upstream_url}), 502
  881. if data.get("type") != "file":
  882. continue
  883. size = parse_int(data.get("size"), 0)
  884. if size > config.max_preview_bytes:
  885. return jsonify({"error": "readme_too_large"}), 413
  886. raw = base64.b64decode(data.get("content") or "", validate=False)
  887. try:
  888. text = raw.decode("utf-8")
  889. except UnicodeDecodeError:
  890. text = raw.decode("utf-8", errors="replace")
  891. return jsonify({"ref": ref, "path": name, "content": text})
  892. return jsonify({"ref": ref, "path": None, "content": None})
  893. _download_lock = threading.Lock()
  894. _download_jobs: dict[str, dict[str, Any]] = {}
  895. _download_build_sema = threading.Semaphore(int(os.environ.get("DOWNLOAD_BUILD_CONCURRENCY", "2") or "2"))
  896. _download_git_sema = threading.Semaphore(
  897. int(os.environ.get("DOWNLOAD_GIT_FALLBACK_CONCURRENCY", "1") or "1")
  898. )
  899. _download_cache_ttl_seconds = int(os.environ.get("DOWNLOAD_CACHE_TTL_SECONDS", "900") or "900")
  900. def _download_cache_dir() -> Path:
  901. cfg = get_config()
  902. data_dir = cfg.database_path.parent
  903. d = data_dir / "download_cache"
  904. d.mkdir(parents=True, exist_ok=True)
  905. return d
  906. def _download_cache_path(*, resource_id: int, owner: str, repo: str, cache_key: str) -> Path:
  907. safe_owner = re.sub(r"[^a-zA-Z0-9._-]+", "_", (owner or "").strip())[:50] or "owner"
  908. safe_repo = re.sub(r"[^a-zA-Z0-9._-]+", "_", (repo or "").strip())[:50] or "repo"
  909. key = (cache_key or "").strip()
  910. if re.fullmatch(r"[0-9a-fA-F]{7,80}", key or ""):
  911. key_tag = key.lower()[:24]
  912. else:
  913. key_tag = hashlib.sha256(key.encode("utf-8")).hexdigest()[:24]
  914. rid = int(resource_id or 0)
  915. return _download_cache_dir() / f"res{rid}__{safe_owner}__{safe_repo}__{key_tag}.zip"
  916. def _download_cache_meta_path(zip_path: Path) -> Path:
  917. return zip_path.with_suffix(zip_path.suffix + ".meta.json")
  918. def _download_cache_ready(path: Path) -> bool:
  919. try:
  920. st = path.stat()
  921. except Exception:
  922. return False
  923. if st.st_size <= 0:
  924. return False
  925. if app.testing or (has_request_context() and bool(getattr(request, "environ", {}).get("werkzeug.test"))):
  926. return True
  927. if _download_cache_ttl_seconds <= 0:
  928. return True
  929. return (time.time() - float(st.st_mtime)) <= float(_download_cache_ttl_seconds)
  930. def _read_download_cache_meta(zip_path: Path) -> dict[str, Any] | None:
  931. meta_path = _download_cache_meta_path(zip_path)
  932. try:
  933. raw = meta_path.read_text(encoding="utf-8")
  934. except Exception:
  935. return None
  936. try:
  937. data = json.loads(raw)
  938. except Exception:
  939. return None
  940. return data if isinstance(data, dict) else None
  941. def _write_download_cache_meta(zip_path: Path, meta: dict[str, Any]) -> None:
  942. meta_path = _download_cache_meta_path(zip_path)
  943. tmp_meta = meta_path.with_suffix(meta_path.suffix + ".partial")
  944. try:
  945. tmp_meta.write_text(json.dumps(meta, ensure_ascii=False), encoding="utf-8")
  946. os.replace(tmp_meta, meta_path)
  947. finally:
  948. try:
  949. if tmp_meta.exists():
  950. tmp_meta.unlink()
  951. except Exception:
  952. pass
  953. def _looks_like_commit(s: str) -> bool:
  954. t = (s or "").strip()
  955. if len(t) < 7 or len(t) > 40:
  956. return False
  957. return bool(re.fullmatch(r"[0-9a-fA-F]{7,40}", t))
  958. def _resolve_download_commit(*, owner: str, repo: str, ref: str) -> dict[str, Any]:
  959. ref = (ref or "").strip() or "HEAD"
  960. if _looks_like_commit(ref):
  961. return {"ok": True, "ref": ref, "commit": ref.lower(), "kind": "commit"}
  962. try:
  963. info = gogs_resolve_ref_commit(owner, repo, ref)
  964. except Exception:
  965. info = {"ok": False, "ref": ref, "commit": None, "kind": "unknown"}
  966. if not isinstance(info, dict):
  967. return {"ok": False, "ref": ref, "commit": None, "kind": "unknown"}
  968. commit = (info.get("commit") or "").strip()
  969. if commit and _looks_like_commit(commit):
  970. return {"ok": True, "ref": ref, "commit": commit.lower(), "kind": info.get("kind") or "unknown"}
  971. return {"ok": False, "ref": ref, "commit": None, "kind": info.get("kind") or "unknown"}
  972. def _build_zip_to_cache(
  973. *, owner: str, repo: str, ref: str, commit: str | None, resolved_kind: str, out_path: Path
  974. ) -> None:
  975. out_path.parent.mkdir(parents=True, exist_ok=True)
  976. tmp_path = out_path.with_suffix(out_path.suffix + ".partial")
  977. meta: dict[str, Any] = {
  978. "owner": owner,
  979. "repo": repo,
  980. "ref": ref,
  981. "commit": (commit or "").strip() or None,
  982. "refKind": resolved_kind or "unknown",
  983. "builtAt": isoformat(utcnow()),
  984. }
  985. try:
  986. upstream_ref = (commit or "").strip() or ref
  987. upstream = gogs_archive_get(owner, repo, upstream_ref)
  988. if upstream.status_code == 404:
  989. base_url = (get_config().gogs_base_url or "").strip().rstrip("/")
  990. parts = urlsplit(base_url)
  991. if parts.scheme in {"http", "https"} and parts.netloc and not parts.username and not parts.password:
  992. fallback = f"{base_url}/{quote(owner)}/{quote(repo)}/archive/{quote(upstream_ref)}.zip"
  993. upstream = requests.get(fallback, stream=True, timeout=60, allow_redirects=False)
  994. if upstream.status_code < 400:
  995. with open(tmp_path, "wb") as f:
  996. for chunk in upstream.iter_content(chunk_size=1024 * 256):
  997. if chunk:
  998. f.write(chunk)
  999. os.replace(tmp_path, out_path)
  1000. meta["method"] = "gogs_archive"
  1001. meta["upstreamStatus"] = int(upstream.status_code)
  1002. try:
  1003. meta["bytes"] = int(out_path.stat().st_size)
  1004. meta["mtime"] = int(out_path.stat().st_mtime)
  1005. except Exception:
  1006. pass
  1007. _write_download_cache_meta(out_path, meta)
  1008. return
  1009. got_git = _download_git_sema.acquire(blocking=False)
  1010. if not got_git:
  1011. raise RuntimeError("git_fallback_busy")
  1012. zip_path = None
  1013. try:
  1014. if commit and _looks_like_commit(commit):
  1015. zip_path = gogs_git_archive_zip_commit(owner, repo, commit)
  1016. else:
  1017. zip_path = gogs_git_archive_zip(owner, repo, ref)
  1018. with open(zip_path, "rb") as src, open(tmp_path, "wb") as dst:
  1019. shutil.copyfileobj(src, dst, length=1024 * 256)
  1020. os.replace(tmp_path, out_path)
  1021. meta["method"] = "git_archive"
  1022. meta["upstreamStatus"] = int(upstream.status_code) if upstream is not None else None
  1023. try:
  1024. meta["bytes"] = int(out_path.stat().st_size)
  1025. meta["mtime"] = int(out_path.stat().st_mtime)
  1026. except Exception:
  1027. pass
  1028. _write_download_cache_meta(out_path, meta)
  1029. finally:
  1030. try:
  1031. if zip_path:
  1032. os.unlink(zip_path)
  1033. except Exception:
  1034. pass
  1035. _download_git_sema.release()
  1036. finally:
  1037. try:
  1038. if tmp_path.exists():
  1039. os.unlink(tmp_path)
  1040. except Exception:
  1041. pass
  1042. def _ensure_download_ready(
  1043. *,
  1044. resource_id: int,
  1045. owner: str,
  1046. repo: str,
  1047. ref: str,
  1048. commit: str | None,
  1049. resolved_kind: str,
  1050. force: bool = False,
  1051. ) -> dict[str, Any]:
  1052. ref = (ref or "").strip() or "HEAD"
  1053. commit = (commit or "").strip() or None
  1054. cache_key = commit or ref
  1055. cache_path = _download_cache_path(resource_id=resource_id, owner=owner, repo=repo, cache_key=cache_key)
  1056. if force:
  1057. try:
  1058. if cache_path.exists():
  1059. cache_path.unlink()
  1060. except Exception:
  1061. pass
  1062. try:
  1063. mp = _download_cache_meta_path(cache_path)
  1064. if mp.exists():
  1065. mp.unlink()
  1066. except Exception:
  1067. pass
  1068. if _download_cache_ready(cache_path):
  1069. return {"ready": True, "path": cache_path, "ref": ref, "commit": commit, "cacheKey": cache_key}
  1070. key = f"res{int(resource_id or 0)}:{owner}/{repo}@{cache_key}"
  1071. with _download_lock:
  1072. job = _download_jobs.get(key)
  1073. if job and job.get("state") == "building":
  1074. return {"ready": False, "state": "building", "ref": ref, "commit": commit, "cacheKey": cache_key}
  1075. _download_jobs[key] = {"state": "building", "updatedAt": time.time(), "error": None, "ref": ref, "commit": commit, "cacheKey": cache_key}
  1076. def runner() -> None:
  1077. with app.app_context():
  1078. if not _download_build_sema.acquire(blocking=False):
  1079. with _download_lock:
  1080. _download_jobs[key] = {"state": "error", "updatedAt": time.time(), "error": "build_busy"}
  1081. return
  1082. try:
  1083. _build_zip_to_cache(owner=owner, repo=repo, ref=ref, commit=commit, resolved_kind=resolved_kind, out_path=cache_path)
  1084. with _download_lock:
  1085. _download_jobs[key] = {"state": "ready", "updatedAt": time.time(), "error": None}
  1086. except Exception as e:
  1087. code = str(e) or "build_failed"
  1088. with _download_lock:
  1089. _download_jobs[key] = {"state": "error", "updatedAt": time.time(), "error": code[:120]}
  1090. finally:
  1091. _download_build_sema.release()
  1092. force_sync = bool(app.testing) or (has_request_context() and bool(request.environ.get("werkzeug.test")))
  1093. if force_sync:
  1094. runner()
  1095. else:
  1096. threading.Thread(target=runner, daemon=True).start()
  1097. if _download_cache_ready(cache_path):
  1098. return {"ready": True, "path": cache_path, "ref": ref, "commit": commit, "cacheKey": cache_key}
  1099. with _download_lock:
  1100. job = _download_jobs.get(key) or {}
  1101. return {
  1102. "ready": False,
  1103. "state": job.get("state") or "building",
  1104. "error": job.get("error"),
  1105. "ref": ref,
  1106. "commit": commit,
  1107. "cacheKey": cache_key,
  1108. }
  1109. @app.post("/resources/<int:resource_id>/download")
  1110. def api_download_prepare(resource_id: int) -> Response:
  1111. user = require_user()
  1112. row = fetch_one("SELECT * FROM resources WHERE id = ? AND status = 'ONLINE'", (resource_id,))
  1113. if row is None:
  1114. abort(404)
  1115. if row["type"] == "VIP" and not is_vip_active(user):
  1116. return jsonify({"error": "vip_required"}), 403
  1117. payload = request.get_json(silent=True) or {}
  1118. ref = (payload.get("ref") or "").strip() or row["default_ref"]
  1119. owner, repo = row["repo_owner"], row["repo_name"]
  1120. ip = (request.headers.get("X-Forwarded-For") or "").split(",")[0].strip() or (request.remote_addr or "")
  1121. ua = (request.headers.get("User-Agent") or "").strip()
  1122. if len(ip) > 64:
  1123. ip = ip[:64]
  1124. if len(ua) > 256:
  1125. ua = ua[:256]
  1126. execute(
  1127. """
  1128. INSERT INTO download_logs
  1129. (user_id, resource_id, resource_title_snapshot, resource_type_snapshot, ref_snapshot, downloaded_at, ip, user_agent)
  1130. VALUES
  1131. (?, ?, ?, ?, ?, ?, ?, ?)
  1132. """,
  1133. (user["id"], row["id"], row["title"], row["type"], ref, isoformat(utcnow()), ip, ua),
  1134. )
  1135. execute("UPDATE resources SET download_count = download_count + 1 WHERE id = ?", (resource_id,))
  1136. resolved = _resolve_download_commit(owner=owner, repo=repo, ref=ref)
  1137. commit = resolved.get("commit") if resolved.get("ok") else None
  1138. resolved_kind = resolved.get("kind") or "unknown"
  1139. st = _ensure_download_ready(
  1140. resource_id=resource_id,
  1141. owner=owner,
  1142. repo=repo,
  1143. ref=ref,
  1144. commit=commit,
  1145. resolved_kind=resolved_kind,
  1146. force=bool(payload.get("force")),
  1147. )
  1148. cache_key = st.get("cacheKey") or (commit or ref)
  1149. qs = {"ref": ref, "commit": commit} if commit else {"ref": ref}
  1150. return jsonify(
  1151. {
  1152. "ok": True,
  1153. "ready": bool(st.get("ready")),
  1154. "state": st.get("state") or ("ready" if st.get("ready") else "building"),
  1155. "error": st.get("error"),
  1156. "ref": ref,
  1157. "commit": commit,
  1158. "cacheKey": cache_key,
  1159. "downloadUrl": f"/resources/{resource_id}/download?{urlencode(qs)}",
  1160. "statusUrl": f"/resources/{resource_id}/download/status?{urlencode(qs)}",
  1161. }
  1162. )
  1163. @app.get("/resources/<int:resource_id>/download/status")
  1164. def api_download_status(resource_id: int) -> Response:
  1165. user = require_user()
  1166. row = fetch_one("SELECT * FROM resources WHERE id = ? AND status = 'ONLINE'", (resource_id,))
  1167. if row is None:
  1168. abort(404)
  1169. if row["type"] == "VIP" and not is_vip_active(user):
  1170. return jsonify({"error": "vip_required"}), 403
  1171. ref = (request.args.get("ref") or "").strip() or row["default_ref"]
  1172. commit_q = (request.args.get("commit") or "").strip() or None
  1173. owner, repo = row["repo_owner"], row["repo_name"]
  1174. resolved_kind = "unknown"
  1175. commit = None
  1176. if commit_q and _looks_like_commit(commit_q):
  1177. commit = commit_q.lower()
  1178. else:
  1179. resolved = _resolve_download_commit(owner=owner, repo=repo, ref=ref)
  1180. if resolved.get("ok"):
  1181. commit = resolved.get("commit")
  1182. resolved_kind = resolved.get("kind") or "unknown"
  1183. cache_key = commit or ref
  1184. cache_path = _download_cache_path(resource_id=resource_id, owner=owner, repo=repo, cache_key=cache_key)
  1185. if _download_cache_ready(cache_path):
  1186. meta = _read_download_cache_meta(cache_path)
  1187. size = None
  1188. mtime = None
  1189. ttl_remaining = None
  1190. try:
  1191. st = cache_path.stat()
  1192. size = int(st.st_size)
  1193. mtime = int(st.st_mtime)
  1194. if _download_cache_ttl_seconds > 0:
  1195. ttl_remaining = max(0, int(_download_cache_ttl_seconds - (time.time() - float(st.st_mtime))))
  1196. except Exception:
  1197. pass
  1198. return jsonify(
  1199. {
  1200. "ok": True,
  1201. "ready": True,
  1202. "state": "ready",
  1203. "error": None,
  1204. "ref": ref,
  1205. "commit": commit,
  1206. "cacheKey": cache_key,
  1207. "bytes": size,
  1208. "mtime": mtime,
  1209. "ttlRemainingSeconds": ttl_remaining,
  1210. "meta": meta,
  1211. }
  1212. )
  1213. key = f"res{int(resource_id or 0)}:{owner}/{repo}@{cache_key}"
  1214. with _download_lock:
  1215. job = _download_jobs.get(key) or {}
  1216. state = job.get("state") or "building"
  1217. return jsonify(
  1218. {
  1219. "ok": True,
  1220. "ready": False,
  1221. "state": state,
  1222. "error": job.get("error"),
  1223. "ref": ref,
  1224. "commit": commit,
  1225. "cacheKey": cache_key,
  1226. }
  1227. )
  1228. @app.get("/resources/<int:resource_id>/download")
  1229. def api_download_file(resource_id: int) -> Response:
  1230. user = require_user()
  1231. row = fetch_one("SELECT * FROM resources WHERE id = ? AND status = 'ONLINE'", (resource_id,))
  1232. if row is None:
  1233. abort(404)
  1234. if row["type"] == "VIP" and not is_vip_active(user):
  1235. return jsonify({"error": "vip_required"}), 403
  1236. ref = (request.args.get("ref") or "").strip() or row["default_ref"]
  1237. commit_q = (request.args.get("commit") or "").strip() or None
  1238. owner, repo = row["repo_owner"], row["repo_name"]
  1239. resolved_kind = "unknown"
  1240. commit = None
  1241. if commit_q and _looks_like_commit(commit_q):
  1242. commit = commit_q.lower()
  1243. else:
  1244. resolved = _resolve_download_commit(owner=owner, repo=repo, ref=ref)
  1245. if resolved.get("ok"):
  1246. commit = resolved.get("commit")
  1247. resolved_kind = resolved.get("kind") or "unknown"
  1248. st = _ensure_download_ready(
  1249. resource_id=resource_id,
  1250. owner=owner,
  1251. repo=repo,
  1252. ref=ref,
  1253. commit=commit,
  1254. resolved_kind=resolved_kind,
  1255. )
  1256. if not st.get("ready"):
  1257. return jsonify({"error": st.get("error") or "building", "state": st.get("state") or "building"}), 202
  1258. cache_path = st.get("path")
  1259. if not isinstance(cache_path, Path) or not cache_path.exists():
  1260. return jsonify({"error": "download_not_ready"}), 409
  1261. filename = f"{owner}-{repo}-{ref}.zip".replace("/", "-")
  1262. if app.testing or (has_request_context() and bool(request.environ.get("werkzeug.test"))):
  1263. with open(cache_path, "rb") as f:
  1264. payload = f.read()
  1265. return Response(
  1266. payload,
  1267. headers={
  1268. "Content-Type": "application/zip",
  1269. "Content-Disposition": f'attachment; filename="{filename}"',
  1270. },
  1271. )
  1272. f = open(cache_path, "rb")
  1273. resp = send_file(
  1274. f,
  1275. mimetype="application/zip",
  1276. as_attachment=True,
  1277. download_name=filename,
  1278. conditional=True,
  1279. max_age=0,
  1280. )
  1281. resp.call_on_close(f.close)
  1282. resp.direct_passthrough = False
  1283. return resp
  1284. @app.post("/orders")
  1285. def api_create_order() -> Response:
  1286. user = require_user()
  1287. payload = request.get_json(silent=True) or {}
  1288. plan_id = parse_int(payload.get("planId"), 0)
  1289. plan = fetch_one("SELECT * FROM plans WHERE id = ? AND enabled = 1", (plan_id,))
  1290. if plan is None:
  1291. return jsonify({"error": "plan_not_found"}), 404
  1292. order_id = uuid.uuid4().hex
  1293. snapshot = {
  1294. "name": plan["name"],
  1295. "durationDays": plan["duration_days"],
  1296. "priceCents": plan["price_cents"],
  1297. }
  1298. execute(
  1299. """
  1300. INSERT INTO orders (id, user_id, plan_id, amount_cents, status, created_at, plan_snapshot_json)
  1301. VALUES (?, ?, ?, ?, 'PENDING', ?, ?)
  1302. """,
  1303. (order_id, user["id"], plan["id"], plan["price_cents"], isoformat(utcnow()), json.dumps(snapshot)),
  1304. )
  1305. return jsonify({"id": order_id, "status": "PENDING", "amountCents": plan["price_cents"], "plan": snapshot})
  1306. @app.get("/orders")
  1307. def api_my_orders() -> Response:
  1308. user = require_user()
  1309. rows = fetch_all(
  1310. "SELECT * FROM orders WHERE user_id = ? ORDER BY created_at DESC",
  1311. (user["id"],),
  1312. )
  1313. items = []
  1314. for row in rows:
  1315. items.append(
  1316. {
  1317. "id": row["id"],
  1318. "status": row["status"],
  1319. "amountCents": row["amount_cents"],
  1320. "createdAt": row["created_at"],
  1321. "paidAt": row["paid_at"],
  1322. "planSnapshot": json.loads(row["plan_snapshot_json"]),
  1323. }
  1324. )
  1325. return jsonify({"items": items})
  1326. @app.get("/me/downloads")
  1327. def api_my_downloads() -> Response:
  1328. user = require_user()
  1329. page = max(parse_int(request.args.get("page"), 1), 1)
  1330. page_size = min(max(parse_int(request.args.get("pageSize"), 20), 1), 50)
  1331. offset = (page - 1) * page_size
  1332. total_row = fetch_one("SELECT COUNT(1) AS cnt FROM download_logs WHERE user_id = ?", (user["id"],))
  1333. total = int(total_row["cnt"] if total_row is not None else 0)
  1334. rows = fetch_all(
  1335. """
  1336. SELECT
  1337. dl.id,
  1338. dl.user_id,
  1339. dl.resource_id,
  1340. dl.resource_title_snapshot,
  1341. dl.resource_type_snapshot,
  1342. dl.ref_snapshot,
  1343. dl.downloaded_at,
  1344. r.id AS r_id,
  1345. r.status AS r_status,
  1346. r.type AS r_type
  1347. FROM download_logs dl
  1348. LEFT JOIN resources r ON r.id = dl.resource_id
  1349. WHERE dl.user_id = ?
  1350. ORDER BY dl.downloaded_at DESC, dl.id DESC
  1351. LIMIT ? OFFSET ?
  1352. """,
  1353. (user["id"], page_size, offset),
  1354. )
  1355. items = []
  1356. for row in rows:
  1357. if row["r_id"] is None:
  1358. resource_state = "DELETED"
  1359. elif row["r_status"] != "ONLINE":
  1360. resource_state = "OFFLINE"
  1361. else:
  1362. resource_state = "ONLINE"
  1363. items.append(
  1364. {
  1365. "id": row["id"],
  1366. "resourceId": row["resource_id"],
  1367. "resourceTitle": row["resource_title_snapshot"],
  1368. "resourceType": row["resource_type_snapshot"],
  1369. "currentResourceType": row["r_type"] if row["r_id"] is not None else None,
  1370. "ref": row["ref_snapshot"],
  1371. "downloadedAt": row["downloaded_at"],
  1372. "resourceState": resource_state,
  1373. }
  1374. )
  1375. return jsonify({"items": items, "total": total, "page": page, "pageSize": page_size})
  1376. @app.get("/me/messages")
  1377. def api_my_messages() -> Response:
  1378. user = require_user()
  1379. unread_only_raw = (request.args.get("unread") or "").strip().lower()
  1380. unread_only = unread_only_raw in {"1", "true", "yes", "on"}
  1381. page = max(parse_int(request.args.get("page"), 1), 1)
  1382. page_size = min(max(parse_int(request.args.get("pageSize"), 20), 1), 50)
  1383. offset = (page - 1) * page_size
  1384. where = ["user_id = ?"]
  1385. params: list[Any] = [user["id"]]
  1386. if unread_only:
  1387. where.append("read_at IS NULL")
  1388. where_sql = f"WHERE {' AND '.join(where)}"
  1389. total_row = fetch_one(f"SELECT COUNT(1) AS cnt FROM user_messages {where_sql}", tuple(params))
  1390. total = int(total_row["cnt"] if total_row is not None else 0)
  1391. unread_row = fetch_one(
  1392. "SELECT COUNT(1) AS cnt FROM user_messages WHERE user_id = ? AND read_at IS NULL",
  1393. (user["id"],),
  1394. )
  1395. unread_count = int(unread_row["cnt"] if unread_row is not None else 0)
  1396. rows = fetch_all(
  1397. f"""
  1398. SELECT id, title, content, created_at, read_at
  1399. FROM user_messages
  1400. {where_sql}
  1401. ORDER BY created_at DESC, id DESC
  1402. LIMIT ? OFFSET ?
  1403. """,
  1404. tuple(params + [page_size, offset]),
  1405. )
  1406. items = []
  1407. for row in rows:
  1408. items.append(
  1409. {
  1410. "id": row["id"],
  1411. "title": row["title"],
  1412. "content": row["content"],
  1413. "createdAt": row["created_at"],
  1414. "readAt": row["read_at"],
  1415. "read": bool(row["read_at"]),
  1416. }
  1417. )
  1418. return jsonify({"items": items, "total": total, "unreadCount": unread_count, "page": page, "pageSize": page_size})
  1419. @app.put("/me/messages/<int:message_id>/read")
  1420. def api_my_message_read(message_id: int) -> Response:
  1421. user = require_user()
  1422. execute(
  1423. """
  1424. UPDATE user_messages
  1425. SET read_at = ?
  1426. WHERE id = ? AND user_id = ? AND read_at IS NULL
  1427. """,
  1428. (isoformat(utcnow()), message_id, user["id"]),
  1429. )
  1430. return jsonify({"ok": True})
  1431. @app.post("/orders/<order_id>/pay")
  1432. def api_pay_order(order_id: str) -> Response:
  1433. user = require_user()
  1434. config = get_config()
  1435. row = fetch_one("SELECT * FROM orders WHERE id = ? AND user_id = ?", (order_id, user["id"]))
  1436. if row is None:
  1437. abort(404)
  1438. if row["status"] != "PENDING":
  1439. return jsonify({"error": "order_not_pending"}), 409
  1440. # 判断是否启用模拟支付
  1441. enable_mock_pay_raw = get_setting_value("ENABLE_MOCK_PAY")
  1442. if enable_mock_pay_raw is None:
  1443. enable_mock_pay = bool(config.enable_mock_pay)
  1444. else:
  1445. enable_mock_pay = enable_mock_pay_raw.strip().lower() in {"1", "true", "yes", "on"}
  1446. snapshot = json.loads(row["plan_snapshot_json"])
  1447. # 模拟支付:直接标记为已支付并发放会员权益
  1448. if enable_mock_pay:
  1449. execute(
  1450. "UPDATE orders SET status = 'PAID', paid_at = ?, pay_channel = ?, pay_trade_no = ? WHERE id = ?",
  1451. (isoformat(utcnow()), "MOCK", None, order_id),
  1452. )
  1453. extend_vip(user["id"], int(snapshot["durationDays"]))
  1454. return jsonify({"ok": True, "provider": "MOCK", "status": "PAID"})
  1455. # 真实支付:调用中间层 REST API 创建支付订单
  1456. host_base = request.host_url.rstrip("/")
  1457. # return_url:优先读 .env,否则回落到本站 /pay/return
  1458. return_url = config.pay_return_url or f"{host_base}/pay/return"
  1459. # callback_url:优先读 .env,否则回落到本站 /pay/notify
  1460. callback_url = config.pay_callback_url or f"{host_base}/pay/notify"
  1461. amount_cents = int(row["amount_cents"] or 0)
  1462. total_amount = float((Decimal(amount_cents) / Decimal(100)).quantize(Decimal("0.01")))
  1463. subject = f"VIP {snapshot.get('name') or ''}".strip()[:120] or "VIP"
  1464. pay_api_base = config.pay_api_base_url
  1465. pay_payload = {
  1466. "bill_no": order_id,
  1467. "amount": total_amount,
  1468. "subject": subject,
  1469. "return_url": return_url,
  1470. "callback_url": callback_url,
  1471. }
  1472. import sys
  1473. print(f"[PAY] 调用中间层: POST {pay_api_base}/api/alipay/pay", file=sys.stderr)
  1474. print(f"[PAY] 请求体: {pay_payload}", file=sys.stderr)
  1475. try:
  1476. resp = requests.post(
  1477. f"{pay_api_base}/api/alipay/pay",
  1478. json=pay_payload,
  1479. timeout=15,
  1480. )
  1481. except Exception as e:
  1482. print(f"[PAY] 连接失败: {e}", file=sys.stderr)
  1483. return jsonify({"error": "pay_api_unreachable", "detail": str(e)}), 502
  1484. print(f"[PAY] 响应状态: {resp.status_code}", file=sys.stderr)
  1485. print(f"[PAY] 响应体: {resp.text[:500]}", file=sys.stderr)
  1486. if resp.status_code != 200:
  1487. try:
  1488. detail = resp.json()
  1489. except Exception:
  1490. detail = resp.text[:200]
  1491. return jsonify({"error": "pay_api_error", "detail": detail}), 502
  1492. data = resp.json()
  1493. pay_url = data.get("payment_url") or ""
  1494. if not pay_url:
  1495. return jsonify({"error": "pay_api_no_payment_url", "detail": data}), 502
  1496. execute("UPDATE orders SET pay_channel = ? WHERE id = ? AND status = 'PENDING'", ("ALIPAY", order_id))
  1497. return jsonify({"ok": True, "provider": "ALIPAY", "status": "PENDING", "payUrl": pay_url})
  1498. @app.post("/orders/<order_id>/query-and-activate")
  1499. def api_order_query_and_activate(order_id: str) -> Response:
  1500. """前端轮询调用:向中间层查询订单状态,若已支付则激活订单并发放会员权益。
  1501. 与 callback_url 回调竞争,后端已做幂等保护,重复调用安全。
  1502. """
  1503. user = require_user()
  1504. row = fetch_one("SELECT * FROM orders WHERE id = ? AND user_id = ?", (order_id, user["id"]))
  1505. if row is None:
  1506. abort(404)
  1507. # 已支付,直接返回,无需再查
  1508. if row["status"] == "PAID":
  1509. return jsonify({"status": "PAID"})
  1510. # 非 PENDING 状态(CLOSED/FAILED)也直接返回
  1511. if row["status"] != "PENDING":
  1512. return jsonify({"status": row["status"]})
  1513. config = get_config()
  1514. pay_api_base = config.pay_api_base_url
  1515. # 调用中间层查询接口
  1516. try:
  1517. resp = requests.get(
  1518. f"{pay_api_base}/api/alipay/query",
  1519. params={"bill_no": order_id},
  1520. timeout=10,
  1521. )
  1522. except Exception as e:
  1523. return jsonify({"status": "PENDING", "error": str(e)}), 200
  1524. if resp.status_code != 200:
  1525. return jsonify({"status": "PENDING"}), 200
  1526. try:
  1527. data = resp.json()
  1528. except Exception:
  1529. return jsonify({"status": "PENDING"}), 200
  1530. trade_status = (data.get("trade_status") or "").strip().upper()
  1531. if trade_status not in {"TRADE_SUCCESS", "TRADE_FINISHED"}:
  1532. # 返回中间层的状态供前端判断是否继续轮询
  1533. return jsonify({"status": "PENDING", "tradeStatus": trade_status}), 200
  1534. # 查询到支付成功,激活订单(幂等:只有 PENDING 状态才会更新)
  1535. trade_no = (data.get("trade_no") or "").strip()
  1536. amount_raw = str(data.get("amount") or "").strip()
  1537. try:
  1538. amount = Decimal(amount_raw).quantize(Decimal("0.01"))
  1539. except Exception:
  1540. return jsonify({"status": "PENDING", "error": "invalid_amount"}), 200
  1541. expect_amount = (Decimal(int(row["amount_cents"] or 0)) / Decimal(100)).quantize(Decimal("0.01"))
  1542. if amount != expect_amount:
  1543. return jsonify({"status": "PENDING", "error": "amount_mismatch"}), 200
  1544. snapshot = json.loads(row["plan_snapshot_json"])
  1545. cur = execute(
  1546. """
  1547. UPDATE orders
  1548. SET status = 'PAID', paid_at = ?, pay_channel = ?, pay_trade_no = ?
  1549. WHERE id = ? AND status = 'PENDING'
  1550. """,
  1551. (isoformat(utcnow()), "ALIPAY", trade_no or None, order_id),
  1552. )
  1553. if getattr(cur, "rowcount", 0) == 1:
  1554. extend_vip(int(row["user_id"]), int(snapshot["durationDays"]))
  1555. return jsonify({"status": "PAID"})
  1556. @app.post("/pay/callback")
  1557. def api_pay_callback() -> Response:
  1558. """兼容旧版支付宝直连回调(form 表单格式),保留以防万一。"""
  1559. params: dict[str, Any] = {}
  1560. try:
  1561. for k in request.form.keys():
  1562. params[k] = request.form.get(k)
  1563. except Exception:
  1564. params = {}
  1565. if not params:
  1566. try:
  1567. params = dict(request.args)
  1568. except Exception:
  1569. params = {}
  1570. sign = (params.get("sign") or "").strip()
  1571. sign_type = (params.get("sign_type") or "RSA2").strip().upper()
  1572. if not sign or sign_type != "RSA2":
  1573. return Response("fail", mimetype="text/plain")
  1574. verify_params = dict(params)
  1575. verify_params.pop("sign", None)
  1576. verify_params.pop("sign_type", None)
  1577. sign_content = _alipay_sign_content(verify_params)
  1578. alipay_public_key = (get_setting_value("ALIPAY_PUBLIC_KEY") or "").strip()
  1579. alipay_app_id = (get_setting_value("ALIPAY_APP_ID") or "").strip()
  1580. if not alipay_public_key:
  1581. return Response("fail", mimetype="text/plain")
  1582. try:
  1583. ok = _alipay_rsa2_verify(sign_content, sign, alipay_public_key)
  1584. except RuntimeError:
  1585. return Response("fail", mimetype="text/plain")
  1586. if not ok:
  1587. return Response("fail", mimetype="text/plain")
  1588. out_trade_no = (params.get("out_trade_no") or "").strip()
  1589. trade_no = (params.get("trade_no") or "").strip()
  1590. trade_status = (params.get("trade_status") or "").strip().upper()
  1591. total_amount_raw = (params.get("total_amount") or "").strip()
  1592. app_id = (params.get("app_id") or "").strip()
  1593. if alipay_app_id and app_id and alipay_app_id != app_id:
  1594. return Response("fail", mimetype="text/plain")
  1595. if not out_trade_no:
  1596. return Response("fail", mimetype="text/plain")
  1597. if trade_status not in {"TRADE_SUCCESS", "TRADE_FINISHED"}:
  1598. return Response("success", mimetype="text/plain")
  1599. row = fetch_one("SELECT * FROM orders WHERE id = ?", (out_trade_no,))
  1600. if row is None:
  1601. return Response("success", mimetype="text/plain")
  1602. if row["status"] == "PAID":
  1603. return Response("success", mimetype="text/plain")
  1604. try:
  1605. amount = Decimal(total_amount_raw).quantize(Decimal("0.01"))
  1606. except Exception:
  1607. return Response("fail", mimetype="text/plain")
  1608. expect_amount = (Decimal(int(row["amount_cents"] or 0)) / Decimal(100)).quantize(Decimal("0.01"))
  1609. if amount != expect_amount:
  1610. return Response("fail", mimetype="text/plain")
  1611. snapshot = json.loads(row["plan_snapshot_json"])
  1612. cur = execute(
  1613. """
  1614. UPDATE orders
  1615. SET status = 'PAID', paid_at = ?, pay_channel = ?, pay_trade_no = ?
  1616. WHERE id = ? AND status = 'PENDING'
  1617. """,
  1618. (isoformat(utcnow()), "ALIPAY", trade_no or None, out_trade_no),
  1619. )
  1620. if getattr(cur, "rowcount", 0) == 1:
  1621. extend_vip(int(row["user_id"]), int(snapshot["durationDays"]))
  1622. return Response("success", mimetype="text/plain")
  1623. @app.post("/pay/notify")
  1624. def api_pay_notify() -> Response:
  1625. """中间层 REST API 支付成功后的异步回调接口(JSON 格式)。
  1626. 接收字段:bill_no, trade_no, trade_status, amount, paid_at
  1627. """
  1628. data = request.get_json(silent=True) or {}
  1629. bill_no = (data.get("bill_no") or "").strip()
  1630. trade_no = (data.get("trade_no") or "").strip()
  1631. trade_status = (data.get("trade_status") or "").strip().upper()
  1632. amount_raw = str(data.get("amount") or "").strip()
  1633. if not bill_no:
  1634. return jsonify({"error": "bill_no_missing"}), 400
  1635. # 只处理支付成功的状态
  1636. if trade_status not in {"TRADE_SUCCESS", "TRADE_FINISHED"}:
  1637. return jsonify({"ok": True, "msg": "ignored"}), 200
  1638. row = fetch_one("SELECT * FROM orders WHERE id = ?", (bill_no,))
  1639. if row is None:
  1640. # 订单不存在,返回 200 避免中间层重试
  1641. return jsonify({"ok": True, "msg": "order_not_found"}), 200
  1642. if row["status"] == "PAID":
  1643. # 幂等:已处理过,直接返回成功
  1644. return jsonify({"ok": True, "msg": "already_paid"}), 200
  1645. # 校验金额
  1646. try:
  1647. amount = Decimal(amount_raw).quantize(Decimal("0.01"))
  1648. except Exception:
  1649. return jsonify({"error": "invalid_amount"}), 400
  1650. expect_amount = (Decimal(int(row["amount_cents"] or 0)) / Decimal(100)).quantize(Decimal("0.01"))
  1651. if amount != expect_amount:
  1652. return jsonify({"error": "amount_mismatch"}), 400
  1653. snapshot = json.loads(row["plan_snapshot_json"])
  1654. cur = execute(
  1655. """
  1656. UPDATE orders
  1657. SET status = 'PAID', paid_at = ?, pay_channel = ?, pay_trade_no = ?
  1658. WHERE id = ? AND status = 'PENDING'
  1659. """,
  1660. (isoformat(utcnow()), "ALIPAY", trade_no or None, bill_no),
  1661. )
  1662. if getattr(cur, "rowcount", 0) == 1:
  1663. extend_vip(int(row["user_id"]), int(snapshot["durationDays"]))
  1664. return jsonify({"ok": True}), 200
  1665. @app.get("/pay/return")
  1666. def api_pay_return() -> Response:
  1667. """支付宝支付完成后的同步跳转落地页。
  1668. 中间层会将用户浏览器重定向到此地址,展示支付结果并跳转到个人中心。
  1669. """
  1670. bill_no = (request.args.get("bill_no") or request.args.get("out_trade_no") or "").strip()
  1671. status = "unknown"
  1672. if bill_no:
  1673. row = fetch_one("SELECT status FROM orders WHERE id = ?", (bill_no,))
  1674. if row:
  1675. status = row["status"]
  1676. # 渲染一个简单的跳转页面,3 秒后自动跳转到个人中心
  1677. html = f"""<!DOCTYPE html>
  1678. <html lang="zh-CN">
  1679. <head>
  1680. <meta charset="UTF-8">
  1681. <meta http-equiv="refresh" content="3;url=/ui/me">
  1682. <title>支付结果</title>
  1683. <style>
  1684. body {{ font-family: sans-serif; display: flex; justify-content: center;
  1685. align-items: center; height: 100vh; margin: 0; background: #f5f5f5; }}
  1686. .box {{ text-align: center; background: #fff; padding: 48px 64px;
  1687. border-radius: 16px; box-shadow: 0 4px 24px rgba(0,0,0,.08); }}
  1688. .icon {{ font-size: 3rem; margin-bottom: 16px; }}
  1689. h2 {{ margin: 0 0 8px; color: #333; }}
  1690. p {{ color: #888; margin: 0 0 24px; }}
  1691. a {{ color: #0ea5e9; text-decoration: none; }}
  1692. </style>
  1693. </head>
  1694. <body>
  1695. <div class="box">
  1696. <div class="icon">{"✅" if status == "PAID" else "⏳"}</div>
  1697. <h2>{"支付成功" if status == "PAID" else "支付处理中"}</h2>
  1698. <p>{"会员权益已生效,正在跳转到个人中心…" if status == "PAID" else "订单处理中,请稍候,正在跳转…"}</p>
  1699. <a href="/ui/me">立即前往个人中心</a>
  1700. </div>
  1701. </body>
  1702. </html>"""
  1703. return Response(html, mimetype="text/html")
  1704. @app.post("/admin/auth/login")
  1705. def api_admin_login() -> Response:
  1706. payload = request.get_json(silent=True) or {}
  1707. username = (payload.get("username") or "").strip()
  1708. password = payload.get("password") or ""
  1709. admin = fetch_one("SELECT * FROM admin_users WHERE username = ?", (username,))
  1710. if admin is None or not check_password_hash(admin["password_hash"], password):
  1711. return jsonify({"error": "invalid_credentials"}), 401
  1712. if admin["status"] != "ACTIVE":
  1713. return jsonify({"error": "admin_disabled"}), 403
  1714. session["admin_user_id"] = admin["id"]
  1715. execute("UPDATE admin_users SET last_login_at = ? WHERE id = ?", (isoformat(utcnow()), admin["id"]))
  1716. return jsonify({"ok": True})
  1717. @app.post("/admin/auth/logout")
  1718. def api_admin_logout() -> Response:
  1719. session.pop("admin_user_id", None)
  1720. return jsonify({"ok": True})
  1721. @app.get("/admin/stats")
  1722. def api_admin_stats() -> Response:
  1723. _ = require_admin()
  1724. from datetime import timedelta
  1725. now = utcnow()
  1726. now_s = isoformat(now)
  1727. since_24h = isoformat(now - timedelta(hours=24))
  1728. def _count(sql: str, params: tuple = ()) -> int:
  1729. row = fetch_one(sql, params)
  1730. if row is None:
  1731. return 0
  1732. v = row.get("c") if isinstance(row, dict) else row["c"]
  1733. try:
  1734. return int(v or 0)
  1735. except Exception:
  1736. return 0
  1737. def _sum(sql: str, params: tuple = ()) -> int:
  1738. row = fetch_one(sql, params)
  1739. if row is None:
  1740. return 0
  1741. v = row.get("s") if isinstance(row, dict) else row["s"]
  1742. try:
  1743. return int(v or 0)
  1744. except Exception:
  1745. return 0
  1746. users_total = _count("SELECT COUNT(1) AS c FROM users")
  1747. users_active = _count("SELECT COUNT(1) AS c FROM users WHERE status = ?", ("ACTIVE",))
  1748. users_vip_active = _count(
  1749. "SELECT COUNT(1) AS c FROM users WHERE vip_expire_at IS NOT NULL AND vip_expire_at > ?",
  1750. (now_s,),
  1751. )
  1752. resources_total = _count("SELECT COUNT(1) AS c FROM resources")
  1753. resources_online = _count("SELECT COUNT(1) AS c FROM resources WHERE status = ?", ("ONLINE",))
  1754. orders_total = _count("SELECT COUNT(1) AS c FROM orders")
  1755. orders_paid = _count("SELECT COUNT(1) AS c FROM orders WHERE status = ?", ("PAID",))
  1756. orders_pending = _count("SELECT COUNT(1) AS c FROM orders WHERE status = ?", ("PENDING",))
  1757. revenue_total_cents = _sum("SELECT COALESCE(SUM(amount_cents), 0) AS s FROM orders WHERE status = ?", ("PAID",))
  1758. revenue_24h_cents = _sum(
  1759. "SELECT COALESCE(SUM(amount_cents), 0) AS s FROM orders WHERE status = ? AND paid_at IS NOT NULL AND paid_at >= ?",
  1760. ("PAID", since_24h),
  1761. )
  1762. downloads_total = _count("SELECT COUNT(1) AS c FROM download_logs")
  1763. downloads_24h = _count("SELECT COUNT(1) AS c FROM download_logs WHERE downloaded_at >= ?", (since_24h,))
  1764. msg_total = _count("SELECT COUNT(1) AS c FROM user_messages")
  1765. msg_24h = _count("SELECT COUNT(1) AS c FROM user_messages WHERE created_at >= ?", (since_24h,))
  1766. return jsonify(
  1767. {
  1768. "now": now_s,
  1769. "users": {"total": users_total, "active": users_active, "vipActive": users_vip_active},
  1770. "resources": {"total": resources_total, "online": resources_online},
  1771. "orders": {"total": orders_total, "paid": orders_paid, "pending": orders_pending},
  1772. "revenue": {"totalCents": revenue_total_cents, "last24hCents": revenue_24h_cents},
  1773. "downloads": {"total": downloads_total, "last24h": downloads_24h},
  1774. "messages": {"total": msg_total, "last24h": msg_24h},
  1775. "backend": get_active_backend(),
  1776. }
  1777. )
  1778. @app.post("/admin/uploads")
  1779. def api_admin_upload() -> Response:
  1780. _ = require_admin()
  1781. f = request.files.get("file")
  1782. if f is None:
  1783. return jsonify({"error": "file_required"}), 400
  1784. max_bytes = 50 * 1024 * 1024
  1785. if request.content_length is not None and int(request.content_length) > max_bytes:
  1786. return jsonify({"error": "file_too_large"}), 413
  1787. original = secure_filename(f.filename or "")
  1788. ext = os.path.splitext(original)[1].lower()
  1789. allowed = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".mp4", ".webm", ".mov", ".m4v"}
  1790. if ext and ext not in allowed:
  1791. return jsonify({"error": "unsupported_file_type"}), 400
  1792. name = f"{uuid.uuid4().hex}{ext}"
  1793. try:
  1794. storage = _get_upload_storage()
  1795. resp = storage.save_upload(f, name)
  1796. return jsonify({"url": resp.get("url"), "name": resp.get("name")})
  1797. except RuntimeError as e:
  1798. return jsonify({"error": str(e) or "upload_failed"}), 500
  1799. except Exception:
  1800. return jsonify({"error": "upload_failed"}), 500
  1801. @app.delete("/admin/uploads/<name>")
  1802. def api_admin_delete_upload(name: str) -> Response:
  1803. _ = require_admin()
  1804. try:
  1805. storage = _get_upload_storage()
  1806. storage.delete_uploads({name})
  1807. except RuntimeError as e:
  1808. return jsonify({"error": str(e) or "delete_failed"}), 500
  1809. except Exception:
  1810. return jsonify({"error": "delete_failed"}), 500
  1811. return jsonify({"ok": True})
  1812. @app.get("/admin/uploads")
  1813. def api_admin_uploads_list() -> Response:
  1814. _ = require_admin()
  1815. q = (request.args.get("q") or "").strip().lower()
  1816. used_filter = (request.args.get("used") or "").strip().lower()
  1817. used_names: set[str] = set()
  1818. for row in fetch_all("SELECT cover_url, summary FROM resources", ()):
  1819. used_names |= _extract_upload_names(row["cover_url"])
  1820. used_names |= _extract_upload_names(row["summary"])
  1821. used_lower = {n.lower() for n in used_names}
  1822. try:
  1823. storage = _get_upload_storage()
  1824. all_items = storage.list_items() or []
  1825. except RuntimeError as e:
  1826. return jsonify({"error": str(e) or "list_failed"}), 500
  1827. except Exception:
  1828. return jsonify({"error": "list_failed"}), 500
  1829. for it in all_items:
  1830. name = str(it.get("name") or "")
  1831. it["used"] = bool(name.lower() in used_lower)
  1832. all_items.sort(key=lambda x: x["mtime"], reverse=True)
  1833. stats = {
  1834. "totalCount": len(all_items),
  1835. "totalBytes": sum(int(i.get("bytes") or 0) for i in all_items),
  1836. "usedCount": sum(1 for i in all_items if i.get("used")),
  1837. "usedBytes": sum(int(i.get("bytes") or 0) for i in all_items if i.get("used")),
  1838. }
  1839. stats["unusedCount"] = int(stats["totalCount"] - stats["usedCount"])
  1840. stats["unusedBytes"] = int(stats["totalBytes"] - stats["usedBytes"])
  1841. items = all_items
  1842. if q:
  1843. items = [i for i in items if q in str(i.get("name") or "").lower()]
  1844. if used_filter == "used":
  1845. items = [i for i in items if i.get("used")]
  1846. elif used_filter == "unused":
  1847. items = [i for i in items if not i.get("used")]
  1848. return jsonify({"items": items, "stats": stats})
  1849. @app.post("/admin/uploads/cleanup-unused")
  1850. def api_admin_uploads_cleanup_unused() -> Response:
  1851. _ = require_admin()
  1852. used_names: set[str] = set()
  1853. for row in fetch_all("SELECT cover_url, summary FROM resources", ()):
  1854. used_names |= _extract_upload_names(row["cover_url"])
  1855. used_names |= _extract_upload_names(row["summary"])
  1856. used_lower = {n.lower() for n in used_names}
  1857. try:
  1858. storage = _get_upload_storage()
  1859. all_items = storage.list_items() or []
  1860. all_names = {str(i.get("name") or "") for i in all_items if str(i.get("name") or "")}
  1861. unused = {n for n in all_names if n.lower() not in used_lower}
  1862. storage.delete_uploads(unused)
  1863. return jsonify({"ok": True, "deletedCount": len(unused)})
  1864. except RuntimeError as e:
  1865. return jsonify({"error": str(e) or "cleanup_failed"}), 500
  1866. except Exception:
  1867. return jsonify({"error": "cleanup_failed"}), 500
  1868. @app.get("/admin/settings")
  1869. def api_admin_settings_get() -> Response:
  1870. _ = require_admin()
  1871. config = get_config()
  1872. gogs_base_url = (get_setting_value("GOGS_BASE_URL") or config.gogs_base_url).rstrip("/")
  1873. gogs_token = get_setting_value("GOGS_TOKEN")
  1874. if gogs_token is not None:
  1875. gogs_token = gogs_token.strip() or None
  1876. if gogs_token is None:
  1877. gogs_token = config.gogs_token
  1878. pay_provider = (get_setting_value("PAY_PROVIDER") or "MOCK").strip().upper()
  1879. pay_api_key = get_setting_value("PAY_API_KEY")
  1880. if pay_api_key is not None:
  1881. pay_api_key = pay_api_key.strip() or None
  1882. alipay_app_id = (get_setting_value("ALIPAY_APP_ID") or "").strip()
  1883. alipay_gateway = (get_setting_value("ALIPAY_GATEWAY") or "https://openapi.alipay.com/gateway.do").strip()
  1884. alipay_notify_url = (get_setting_value("ALIPAY_NOTIFY_URL") or "").strip()
  1885. alipay_return_url = (get_setting_value("ALIPAY_RETURN_URL") or "").strip()
  1886. alipay_private_key = get_setting_value("ALIPAY_PRIVATE_KEY")
  1887. if alipay_private_key is not None:
  1888. alipay_private_key = alipay_private_key.strip() or None
  1889. alipay_public_key = get_setting_value("ALIPAY_PUBLIC_KEY")
  1890. if alipay_public_key is not None:
  1891. alipay_public_key = alipay_public_key.strip() or None
  1892. llm_provider = (get_setting_value("LLM_PROVIDER") or "").strip()
  1893. llm_base_url = (get_setting_value("LLM_BASE_URL") or "").strip()
  1894. llm_model = (get_setting_value("LLM_MODEL") or "").strip()
  1895. llm_api_key = get_setting_value("LLM_API_KEY")
  1896. if llm_api_key is not None:
  1897. llm_api_key = llm_api_key.strip() or None
  1898. redis_url = get_setting_value("REDIS_URL")
  1899. if redis_url is not None:
  1900. redis_url = redis_url.strip() or None
  1901. redis_url_safe = ""
  1902. if redis_url:
  1903. try:
  1904. parts = urlsplit(redis_url)
  1905. if parts.scheme in {"redis", "rediss"} and parts.netloc:
  1906. netloc = parts.netloc
  1907. if "@" in netloc:
  1908. netloc = netloc.split("@", 1)[1]
  1909. redis_url_safe = urlunsplit((parts.scheme, netloc, parts.path, parts.query, parts.fragment))
  1910. else:
  1911. redis_url_safe = redis_url
  1912. except Exception:
  1913. redis_url_safe = ""
  1914. enable_mock_pay_raw = get_setting_value("ENABLE_MOCK_PAY")
  1915. if enable_mock_pay_raw is None:
  1916. enable_mock_pay = bool(config.enable_mock_pay)
  1917. else:
  1918. enable_mock_pay = enable_mock_pay_raw.strip().lower() in {"1", "true", "yes", "on"}
  1919. storage_provider = (get_setting_value("STORAGE_PROVIDER") or "AUTO").strip().upper()
  1920. if storage_provider not in {"AUTO", "LOCAL", "OSS"}:
  1921. storage_provider = "AUTO"
  1922. oss_endpoint = (get_setting_value("OSS_ENDPOINT") or "").strip().rstrip("/")
  1923. oss_bucket = (get_setting_value("OSS_BUCKET") or "").strip()
  1924. oss_access_key_id = (get_setting_value("OSS_ACCESS_KEY_ID") or "").strip()
  1925. oss_access_key_secret = get_setting_value("OSS_ACCESS_KEY_SECRET")
  1926. if oss_access_key_secret is not None:
  1927. oss_access_key_secret = oss_access_key_secret.strip() or None
  1928. oss_upload_prefix = _normalize_upload_prefix(get_setting_value("OSS_UPLOAD_PREFIX") or "uploads/")
  1929. oss_public_base_url = (get_setting_value("OSS_PUBLIC_BASE_URL") or "").strip().rstrip("/")
  1930. return jsonify(
  1931. {
  1932. "gogsBaseUrl": gogs_base_url,
  1933. "hasGogsToken": bool(gogs_token),
  1934. "payment": {
  1935. "provider": pay_provider,
  1936. "hasApiKey": bool(pay_api_key),
  1937. "enableMockPay": enable_mock_pay,
  1938. "alipay": {
  1939. "appId": alipay_app_id,
  1940. "gateway": alipay_gateway,
  1941. "notifyUrl": alipay_notify_url,
  1942. "returnUrl": alipay_return_url,
  1943. "hasPrivateKey": bool(alipay_private_key),
  1944. "hasPublicKey": bool(alipay_public_key),
  1945. },
  1946. },
  1947. "llm": {"provider": llm_provider, "baseUrl": llm_base_url, "model": llm_model, "hasApiKey": bool(llm_api_key)},
  1948. "cache": {"hasRedisUrl": bool(redis_url), "redisUrl": redis_url_safe},
  1949. "storage": {
  1950. "provider": storage_provider,
  1951. "oss": {
  1952. "endpoint": oss_endpoint,
  1953. "bucket": oss_bucket,
  1954. "accessKeyId": oss_access_key_id,
  1955. "hasAccessKeySecret": bool(oss_access_key_secret),
  1956. "uploadPrefix": oss_upload_prefix,
  1957. "publicBaseUrl": oss_public_base_url,
  1958. },
  1959. },
  1960. "db": db_status(),
  1961. }
  1962. )
  1963. @app.put("/admin/settings")
  1964. def api_admin_settings_put() -> Response:
  1965. admin = require_admin()
  1966. payload = request.get_json(silent=True) or {}
  1967. config = get_config()
  1968. gogs_base_url = payload.get("gogsBaseUrl")
  1969. gogs_token = payload.get("gogsToken")
  1970. clear_token = bool(payload.get("clearGogsToken"))
  1971. pay = payload.get("payment") or {}
  1972. llm = payload.get("llm") or {}
  1973. cache = payload.get("cache") or {}
  1974. storage = payload.get("storage") or {}
  1975. mysql = payload.get("mysql") or {}
  1976. before = {
  1977. "gogsBaseUrl": (get_setting_value("GOGS_BASE_URL") or config.gogs_base_url).rstrip("/"),
  1978. "hasGogsToken": bool((get_setting_value("GOGS_TOKEN") or "").strip() or config.gogs_token),
  1979. "payment": {
  1980. "provider": (get_setting_value("PAY_PROVIDER") or "MOCK").strip().upper(),
  1981. "hasApiKey": bool((get_setting_value("PAY_API_KEY") or "").strip()),
  1982. "enableMockPay": (get_setting_value("ENABLE_MOCK_PAY") or "").strip().lower() in {"1", "true", "yes", "on"},
  1983. "alipay": {
  1984. "appId": (get_setting_value("ALIPAY_APP_ID") or "").strip(),
  1985. "gateway": (get_setting_value("ALIPAY_GATEWAY") or "").strip(),
  1986. "notifyUrl": (get_setting_value("ALIPAY_NOTIFY_URL") or "").strip(),
  1987. "returnUrl": (get_setting_value("ALIPAY_RETURN_URL") or "").strip(),
  1988. "hasPrivateKey": bool((get_setting_value("ALIPAY_PRIVATE_KEY") or "").strip()),
  1989. "hasPublicKey": bool((get_setting_value("ALIPAY_PUBLIC_KEY") or "").strip()),
  1990. },
  1991. },
  1992. "llm": {
  1993. "provider": (get_setting_value("LLM_PROVIDER") or "").strip(),
  1994. "baseUrl": (get_setting_value("LLM_BASE_URL") or "").strip(),
  1995. "model": (get_setting_value("LLM_MODEL") or "").strip(),
  1996. "hasApiKey": bool((get_setting_value("LLM_API_KEY") or "").strip()),
  1997. },
  1998. "cache": {"hasRedisUrl": bool((get_setting_value("REDIS_URL") or "").strip())},
  1999. "storage": {
  2000. "provider": (get_setting_value("STORAGE_PROVIDER") or "AUTO").strip().upper(),
  2001. "oss": {
  2002. "endpoint": (get_setting_value("OSS_ENDPOINT") or "").strip().rstrip("/"),
  2003. "bucket": (get_setting_value("OSS_BUCKET") or "").strip(),
  2004. "accessKeyId": (get_setting_value("OSS_ACCESS_KEY_ID") or "").strip(),
  2005. "hasAccessKeySecret": bool((get_setting_value("OSS_ACCESS_KEY_SECRET") or "").strip()),
  2006. "uploadPrefix": _normalize_upload_prefix(get_setting_value("OSS_UPLOAD_PREFIX") or "uploads/"),
  2007. "publicBaseUrl": (get_setting_value("OSS_PUBLIC_BASE_URL") or "").strip().rstrip("/"),
  2008. },
  2009. },
  2010. "db": db_status(),
  2011. }
  2012. if gogs_base_url is not None:
  2013. gogs_base_url = str(gogs_base_url).strip().rstrip("/")
  2014. if gogs_base_url and not (gogs_base_url.startswith("http://") or gogs_base_url.startswith("https://")):
  2015. return jsonify({"error": "invalid_gogs_base_url"}), 400
  2016. if gogs_base_url:
  2017. set_setting_value("GOGS_BASE_URL", gogs_base_url, category="GOGS")
  2018. else:
  2019. delete_setting_value("GOGS_BASE_URL")
  2020. if clear_token:
  2021. delete_setting_value("GOGS_TOKEN")
  2022. elif gogs_token is not None:
  2023. gogs_token = str(gogs_token).strip()
  2024. if gogs_token:
  2025. set_setting_value("GOGS_TOKEN", gogs_token, category="GOGS")
  2026. pay_provider = pay.get("provider")
  2027. pay_api_key = pay.get("apiKey")
  2028. clear_pay_api_key = bool(pay.get("clearApiKey"))
  2029. enable_mock_pay = pay.get("enableMockPay")
  2030. alipay = pay.get("alipay") or {}
  2031. if pay_provider is not None:
  2032. pay_provider = str(pay_provider).strip().upper()
  2033. if pay_provider:
  2034. set_setting_value("PAY_PROVIDER", pay_provider, category="PAYMENT")
  2035. if enable_mock_pay is not None:
  2036. set_setting_value("ENABLE_MOCK_PAY", "1" if bool(enable_mock_pay) else "0", category="PAYMENT")
  2037. if clear_pay_api_key:
  2038. delete_setting_value("PAY_API_KEY")
  2039. elif pay_api_key is not None:
  2040. pay_api_key = str(pay_api_key).strip()
  2041. if pay_api_key:
  2042. set_setting_value("PAY_API_KEY", pay_api_key, category="PAYMENT")
  2043. alipay_app_id = alipay.get("appId")
  2044. alipay_gateway = alipay.get("gateway")
  2045. alipay_notify_url = alipay.get("notifyUrl")
  2046. alipay_return_url = alipay.get("returnUrl")
  2047. alipay_private_key = alipay.get("privateKey")
  2048. clear_alipay_private_key = bool(alipay.get("clearPrivateKey"))
  2049. alipay_public_key = alipay.get("publicKey")
  2050. clear_alipay_public_key = bool(alipay.get("clearPublicKey"))
  2051. if alipay_app_id is not None:
  2052. alipay_app_id = str(alipay_app_id).strip()
  2053. if alipay_app_id:
  2054. set_setting_value("ALIPAY_APP_ID", alipay_app_id, category="PAYMENT")
  2055. else:
  2056. delete_setting_value("ALIPAY_APP_ID")
  2057. if alipay_gateway is not None:
  2058. alipay_gateway = str(alipay_gateway).strip().rstrip("/")
  2059. if alipay_gateway:
  2060. if not (alipay_gateway.startswith("http://") or alipay_gateway.startswith("https://")):
  2061. return jsonify({"error": "invalid_alipay_gateway"}), 400
  2062. set_setting_value("ALIPAY_GATEWAY", alipay_gateway, category="PAYMENT")
  2063. else:
  2064. delete_setting_value("ALIPAY_GATEWAY")
  2065. if alipay_notify_url is not None:
  2066. alipay_notify_url = str(alipay_notify_url).strip()
  2067. if alipay_notify_url:
  2068. if not (alipay_notify_url.startswith("http://") or alipay_notify_url.startswith("https://")):
  2069. return jsonify({"error": "invalid_alipay_notify_url"}), 400
  2070. set_setting_value("ALIPAY_NOTIFY_URL", alipay_notify_url, category="PAYMENT")
  2071. else:
  2072. delete_setting_value("ALIPAY_NOTIFY_URL")
  2073. if alipay_return_url is not None:
  2074. alipay_return_url = str(alipay_return_url).strip()
  2075. if alipay_return_url:
  2076. if not (alipay_return_url.startswith("http://") or alipay_return_url.startswith("https://")):
  2077. return jsonify({"error": "invalid_alipay_return_url"}), 400
  2078. set_setting_value("ALIPAY_RETURN_URL", alipay_return_url, category="PAYMENT")
  2079. else:
  2080. delete_setting_value("ALIPAY_RETURN_URL")
  2081. if clear_alipay_private_key:
  2082. delete_setting_value("ALIPAY_PRIVATE_KEY")
  2083. elif alipay_private_key is not None:
  2084. alipay_private_key = str(alipay_private_key).strip()
  2085. if alipay_private_key:
  2086. set_setting_value("ALIPAY_PRIVATE_KEY", alipay_private_key, category="PAYMENT")
  2087. if clear_alipay_public_key:
  2088. delete_setting_value("ALIPAY_PUBLIC_KEY")
  2089. elif alipay_public_key is not None:
  2090. alipay_public_key = str(alipay_public_key).strip()
  2091. if alipay_public_key:
  2092. set_setting_value("ALIPAY_PUBLIC_KEY", alipay_public_key, category="PAYMENT")
  2093. llm_provider = llm.get("provider")
  2094. llm_base_url = llm.get("baseUrl")
  2095. llm_model = llm.get("model")
  2096. llm_api_key = llm.get("apiKey")
  2097. clear_llm_api_key = bool(llm.get("clearApiKey"))
  2098. if llm_provider is not None:
  2099. llm_provider = str(llm_provider).strip()
  2100. if llm_provider:
  2101. set_setting_value("LLM_PROVIDER", llm_provider, category="LLM")
  2102. if llm_base_url is not None:
  2103. llm_base_url = str(llm_base_url).strip().rstrip("/")
  2104. if llm_base_url:
  2105. set_setting_value("LLM_BASE_URL", llm_base_url, category="LLM")
  2106. if llm_model is not None:
  2107. llm_model = str(llm_model).strip()
  2108. if llm_model:
  2109. set_setting_value("LLM_MODEL", llm_model, category="LLM")
  2110. if clear_llm_api_key:
  2111. delete_setting_value("LLM_API_KEY")
  2112. elif llm_api_key is not None:
  2113. llm_api_key = str(llm_api_key).strip()
  2114. if llm_api_key:
  2115. set_setting_value("LLM_API_KEY", llm_api_key, category="LLM")
  2116. redis_url = cache.get("redisUrl")
  2117. clear_redis_url = bool(cache.get("clearRedisUrl"))
  2118. if clear_redis_url:
  2119. delete_setting_value("REDIS_URL")
  2120. elif redis_url is not None:
  2121. redis_url = str(redis_url).strip()
  2122. if redis_url:
  2123. if not (redis_url.startswith("redis://") or redis_url.startswith("rediss://")):
  2124. return jsonify({"error": "invalid_redis_url"}), 400
  2125. set_setting_value("REDIS_URL", redis_url, category="CACHE")
  2126. storage_provider = storage.get("provider")
  2127. oss = storage.get("oss") or {}
  2128. if storage_provider is not None:
  2129. storage_provider = str(storage_provider).strip().upper()
  2130. if storage_provider and storage_provider not in {"AUTO", "LOCAL", "OSS"}:
  2131. return jsonify({"error": "invalid_storage_provider"}), 400
  2132. if storage_provider:
  2133. set_setting_value("STORAGE_PROVIDER", storage_provider, category="STORAGE")
  2134. else:
  2135. delete_setting_value("STORAGE_PROVIDER")
  2136. oss_endpoint = oss.get("endpoint")
  2137. oss_bucket = oss.get("bucket")
  2138. oss_access_key_id = oss.get("accessKeyId")
  2139. oss_access_key_secret = oss.get("accessKeySecret")
  2140. oss_upload_prefix = oss.get("uploadPrefix")
  2141. oss_public_base_url = oss.get("publicBaseUrl")
  2142. clear_oss_access_key_secret = bool(oss.get("clearAccessKeySecret"))
  2143. if oss_endpoint is not None:
  2144. oss_endpoint = str(oss_endpoint).strip().rstrip("/")
  2145. if oss_endpoint:
  2146. if not (oss_endpoint.startswith("http://") or oss_endpoint.startswith("https://")):
  2147. return jsonify({"error": "invalid_oss_endpoint"}), 400
  2148. set_setting_value("OSS_ENDPOINT", oss_endpoint, category="STORAGE")
  2149. else:
  2150. delete_setting_value("OSS_ENDPOINT")
  2151. if oss_bucket is not None:
  2152. oss_bucket = str(oss_bucket).strip()
  2153. if oss_bucket:
  2154. set_setting_value("OSS_BUCKET", oss_bucket, category="STORAGE")
  2155. else:
  2156. delete_setting_value("OSS_BUCKET")
  2157. if oss_access_key_id is not None:
  2158. oss_access_key_id = str(oss_access_key_id).strip()
  2159. if oss_access_key_id:
  2160. set_setting_value("OSS_ACCESS_KEY_ID", oss_access_key_id, category="STORAGE")
  2161. else:
  2162. delete_setting_value("OSS_ACCESS_KEY_ID")
  2163. if clear_oss_access_key_secret:
  2164. delete_setting_value("OSS_ACCESS_KEY_SECRET")
  2165. elif oss_access_key_secret is not None:
  2166. oss_access_key_secret = str(oss_access_key_secret).strip()
  2167. if oss_access_key_secret:
  2168. set_setting_value("OSS_ACCESS_KEY_SECRET", oss_access_key_secret, category="STORAGE")
  2169. if oss_upload_prefix is not None:
  2170. oss_upload_prefix = _normalize_upload_prefix(oss_upload_prefix)
  2171. if oss_upload_prefix:
  2172. set_setting_value("OSS_UPLOAD_PREFIX", oss_upload_prefix, category="STORAGE")
  2173. else:
  2174. delete_setting_value("OSS_UPLOAD_PREFIX")
  2175. if oss_public_base_url is not None:
  2176. oss_public_base_url = str(oss_public_base_url).strip().rstrip("/")
  2177. if oss_public_base_url:
  2178. if not (oss_public_base_url.startswith("http://") or oss_public_base_url.startswith("https://")):
  2179. return jsonify({"error": "invalid_oss_public_base_url"}), 400
  2180. set_setting_value("OSS_PUBLIC_BASE_URL", oss_public_base_url, category="STORAGE")
  2181. else:
  2182. delete_setting_value("OSS_PUBLIC_BASE_URL")
  2183. mysql_host = mysql.get("host")
  2184. mysql_port = mysql.get("port")
  2185. mysql_user = mysql.get("user")
  2186. mysql_database = mysql.get("database")
  2187. mysql_password = mysql.get("password")
  2188. clear_mysql_password = bool(mysql.get("clearPassword"))
  2189. if mysql_host is not None:
  2190. mysql_host = str(mysql_host).strip()
  2191. if mysql_host:
  2192. set_setting_value("MYSQL_HOST", mysql_host, category="DB")
  2193. else:
  2194. delete_setting_value("MYSQL_HOST")
  2195. if mysql_port is not None:
  2196. mysql_port = str(mysql_port).strip()
  2197. if mysql_port:
  2198. p = parse_int(mysql_port, 0)
  2199. if p <= 0 or p > 65535:
  2200. return jsonify({"error": "invalid_mysql_port"}), 400
  2201. set_setting_value("MYSQL_PORT", str(p), category="DB")
  2202. else:
  2203. delete_setting_value("MYSQL_PORT")
  2204. if mysql_user is not None:
  2205. mysql_user = str(mysql_user).strip()
  2206. if mysql_user:
  2207. set_setting_value("MYSQL_USER", mysql_user, category="DB")
  2208. else:
  2209. delete_setting_value("MYSQL_USER")
  2210. if mysql_database is not None:
  2211. mysql_database = str(mysql_database).strip()
  2212. if mysql_database:
  2213. set_setting_value("MYSQL_DATABASE", mysql_database, category="DB")
  2214. else:
  2215. delete_setting_value("MYSQL_DATABASE")
  2216. if clear_mysql_password:
  2217. delete_setting_value("MYSQL_PASSWORD")
  2218. elif mysql_password is not None:
  2219. mysql_password = str(mysql_password).strip()
  2220. if mysql_password:
  2221. set_setting_value("MYSQL_PASSWORD", mysql_password, category="DB")
  2222. after = {
  2223. "gogsBaseUrl": (get_setting_value("GOGS_BASE_URL") or config.gogs_base_url).rstrip("/"),
  2224. "hasGogsToken": bool((get_setting_value("GOGS_TOKEN") or "").strip() or config.gogs_token),
  2225. "payment": {
  2226. "provider": (get_setting_value("PAY_PROVIDER") or "MOCK").strip().upper(),
  2227. "hasApiKey": bool((get_setting_value("PAY_API_KEY") or "").strip()),
  2228. "enableMockPay": (get_setting_value("ENABLE_MOCK_PAY") or "").strip().lower() in {"1", "true", "yes", "on"},
  2229. "alipay": {
  2230. "appId": (get_setting_value("ALIPAY_APP_ID") or "").strip(),
  2231. "gateway": (get_setting_value("ALIPAY_GATEWAY") or "").strip(),
  2232. "notifyUrl": (get_setting_value("ALIPAY_NOTIFY_URL") or "").strip(),
  2233. "returnUrl": (get_setting_value("ALIPAY_RETURN_URL") or "").strip(),
  2234. "hasPrivateKey": bool((get_setting_value("ALIPAY_PRIVATE_KEY") or "").strip()),
  2235. "hasPublicKey": bool((get_setting_value("ALIPAY_PUBLIC_KEY") or "").strip()),
  2236. },
  2237. },
  2238. "llm": {
  2239. "provider": (get_setting_value("LLM_PROVIDER") or "").strip(),
  2240. "baseUrl": (get_setting_value("LLM_BASE_URL") or "").strip(),
  2241. "model": (get_setting_value("LLM_MODEL") or "").strip(),
  2242. "hasApiKey": bool((get_setting_value("LLM_API_KEY") or "").strip()),
  2243. },
  2244. "cache": {"hasRedisUrl": bool((get_setting_value("REDIS_URL") or "").strip())},
  2245. "storage": {
  2246. "provider": (get_setting_value("STORAGE_PROVIDER") or "AUTO").strip().upper(),
  2247. "oss": {
  2248. "endpoint": (get_setting_value("OSS_ENDPOINT") or "").strip().rstrip("/"),
  2249. "bucket": (get_setting_value("OSS_BUCKET") or "").strip(),
  2250. "accessKeyId": (get_setting_value("OSS_ACCESS_KEY_ID") or "").strip(),
  2251. "hasAccessKeySecret": bool((get_setting_value("OSS_ACCESS_KEY_SECRET") or "").strip()),
  2252. "uploadPrefix": _normalize_upload_prefix(get_setting_value("OSS_UPLOAD_PREFIX") or "uploads/"),
  2253. "publicBaseUrl": (get_setting_value("OSS_PUBLIC_BASE_URL") or "").strip().rstrip("/"),
  2254. },
  2255. },
  2256. "db": db_status(),
  2257. }
  2258. audit("ADMIN", admin["id"], "SETTINGS_UPDATE", "AppSettings", "-", before, after)
  2259. return jsonify({"ok": True})
  2260. @app.post("/admin/redis/test")
  2261. def api_admin_redis_test() -> Response:
  2262. _ = require_admin()
  2263. payload = request.get_json(silent=True) or {}
  2264. url = (payload.get("url") or get_setting_value("REDIS_URL") or os.environ.get("REDIS_URL") or "").strip()
  2265. if not url:
  2266. return jsonify({"error": "redis_not_configured"}), 400
  2267. try:
  2268. import redis # type: ignore
  2269. except Exception:
  2270. return jsonify({"error": "redis_client_missing"}), 500
  2271. try:
  2272. r = redis.Redis.from_url(url, socket_connect_timeout=1, socket_timeout=2, decode_responses=False)
  2273. ok = bool(r.ping())
  2274. return jsonify({"ok": ok})
  2275. except Exception:
  2276. return jsonify({"error": "connect_failed"}), 502
  2277. @app.get("/admin/db/status")
  2278. def api_admin_db_status() -> Response:
  2279. _ = require_admin()
  2280. probe = {"connectOk": True, "effective": None, "error": None}
  2281. try:
  2282. _ = fetch_one("SELECT 1 AS one", ())
  2283. probe["connectOk"] = True
  2284. except Exception as e:
  2285. probe["connectOk"] = False
  2286. probe["error"] = str(e) or "connect_failed"
  2287. try:
  2288. probe["effective"] = get_active_backend()
  2289. except Exception:
  2290. probe["effective"] = None
  2291. return jsonify({"ok": True, "db": db_status(), "probe": probe})
  2292. @app.post("/admin/db/switch")
  2293. def api_admin_db_switch() -> Response:
  2294. admin = require_admin()
  2295. payload = request.get_json(silent=True) or {}
  2296. target = (payload.get("target") or "").strip().lower()
  2297. force = bool(payload.get("force"))
  2298. mysql = payload.get("mysql") or {}
  2299. if isinstance(mysql, dict) and mysql:
  2300. mysql_host = mysql.get("host")
  2301. mysql_port = mysql.get("port")
  2302. mysql_user = mysql.get("user")
  2303. mysql_database = mysql.get("database")
  2304. mysql_password = mysql.get("password")
  2305. clear_mysql_password = bool(mysql.get("clearPassword"))
  2306. if mysql_host is not None:
  2307. mysql_host = str(mysql_host).strip()
  2308. if mysql_host:
  2309. set_setting_value("MYSQL_HOST", mysql_host, category="DB")
  2310. else:
  2311. delete_setting_value("MYSQL_HOST")
  2312. if mysql_port is not None:
  2313. mysql_port = str(mysql_port).strip()
  2314. if mysql_port:
  2315. p = parse_int(mysql_port, 0)
  2316. if p <= 0 or p > 65535:
  2317. return jsonify({"error": "invalid_mysql_port"}), 400
  2318. set_setting_value("MYSQL_PORT", str(p), category="DB")
  2319. else:
  2320. delete_setting_value("MYSQL_PORT")
  2321. if mysql_user is not None:
  2322. mysql_user = str(mysql_user).strip()
  2323. if mysql_user:
  2324. set_setting_value("MYSQL_USER", mysql_user, category="DB")
  2325. else:
  2326. delete_setting_value("MYSQL_USER")
  2327. if mysql_database is not None:
  2328. mysql_database = str(mysql_database).strip()
  2329. if mysql_database:
  2330. set_setting_value("MYSQL_DATABASE", mysql_database, category="DB")
  2331. else:
  2332. delete_setting_value("MYSQL_DATABASE")
  2333. if clear_mysql_password:
  2334. delete_setting_value("MYSQL_PASSWORD")
  2335. elif mysql_password is not None:
  2336. mysql_password = str(mysql_password).strip()
  2337. if mysql_password:
  2338. set_setting_value("MYSQL_PASSWORD", mysql_password, category="DB")
  2339. try:
  2340. result = switch_database(target=target, force=force)
  2341. except RuntimeError as e:
  2342. code = str(e) or "switch_failed"
  2343. status = 500
  2344. if code in {"invalid_target"}:
  2345. status = 400
  2346. elif code in {"mysql_not_configured"}:
  2347. status = 400
  2348. elif code in {"connect_failed", "db_create_failed"}:
  2349. status = 502
  2350. elif code in {"target_not_empty"}:
  2351. status = 409
  2352. elif code in {"migration_running"}:
  2353. status = 409
  2354. elif code in {"pymysql_required"}:
  2355. status = 500
  2356. elif code in {"verify_failed"}:
  2357. status = 502
  2358. return jsonify({"error": code}), status
  2359. except Exception:
  2360. return jsonify({"error": "switch_failed"}), 500
  2361. audit("ADMIN", admin["id"], "DB_SWITCH", "Database", "-", {"target": target, "force": force}, result)
  2362. return jsonify(result)
  2363. @app.post("/admin/mysql/test")
  2364. def api_admin_mysql_test() -> Response:
  2365. _ = require_admin()
  2366. payload = request.get_json(silent=True) or {}
  2367. config = get_config()
  2368. host = (payload.get("host") or get_setting_value("MYSQL_HOST") or config.mysql_host or "").strip()
  2369. port = parse_int(payload.get("port") or get_setting_value("MYSQL_PORT") or "", config.mysql_port or 3306)
  2370. user = (payload.get("user") or get_setting_value("MYSQL_USER") or config.mysql_user or "").strip()
  2371. database = (payload.get("database") or get_setting_value("MYSQL_DATABASE") or config.mysql_database or "").strip()
  2372. password = payload.get("password")
  2373. if password is None:
  2374. password = get_setting_value("MYSQL_PASSWORD")
  2375. if password is None:
  2376. password = config.mysql_password
  2377. password = (str(password) if password is not None else "").strip()
  2378. if not host or not user or not database:
  2379. return jsonify({"error": "mysql_params_required"}), 400
  2380. if port <= 0 or port > 65535:
  2381. return jsonify({"error": "invalid_mysql_port"}), 400
  2382. try:
  2383. import pymysql
  2384. except Exception:
  2385. return jsonify({"error": "pymysql_required"}), 500
  2386. try:
  2387. created_db = False
  2388. try:
  2389. conn = pymysql.connect(
  2390. host=host,
  2391. port=port,
  2392. user=user,
  2393. password=password,
  2394. database=database,
  2395. charset="utf8mb4",
  2396. connect_timeout=3,
  2397. read_timeout=3,
  2398. write_timeout=3,
  2399. autocommit=True,
  2400. )
  2401. except Exception as e:
  2402. errno = int(getattr(e, "args", [0])[0] or 0) if getattr(e, "args", None) else 0
  2403. if errno != 1049:
  2404. raise
  2405. server_conn = pymysql.connect(
  2406. host=host,
  2407. port=port,
  2408. user=user,
  2409. password=password,
  2410. charset="utf8mb4",
  2411. connect_timeout=3,
  2412. read_timeout=3,
  2413. write_timeout=3,
  2414. autocommit=True,
  2415. )
  2416. try:
  2417. esc = database.replace("`", "``")
  2418. cur = server_conn.cursor()
  2419. cur.execute(
  2420. f"CREATE DATABASE IF NOT EXISTS `{esc}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"
  2421. )
  2422. created_db = True
  2423. finally:
  2424. server_conn.close()
  2425. conn = pymysql.connect(
  2426. host=host,
  2427. port=port,
  2428. user=user,
  2429. password=password,
  2430. database=database,
  2431. charset="utf8mb4",
  2432. connect_timeout=3,
  2433. read_timeout=3,
  2434. write_timeout=3,
  2435. autocommit=True,
  2436. )
  2437. try:
  2438. cur = conn.cursor()
  2439. cur.execute("SELECT 1")
  2440. _ = cur.fetchone()
  2441. finally:
  2442. conn.close()
  2443. except Exception as e:
  2444. errno = int(getattr(e, "args", [0])[0] or 0) if getattr(e, "args", None) else 0
  2445. msg = str(getattr(e, "args", [""])[1] if getattr(e, "args", None) and len(e.args) > 1 else str(e) or "")
  2446. msg = (msg or "").strip()[:200]
  2447. return jsonify({"ok": False, "error": "connect_failed", "errno": errno, "message": msg}), 502
  2448. return jsonify({"ok": True, "createdDatabase": bool(created_db)})
  2449. @app.get("/admin/gogs/repo")
  2450. def api_admin_gogs_repo() -> Response:
  2451. _ = require_admin()
  2452. owner = (request.args.get("owner") or "").strip()
  2453. repo = (request.args.get("repo") or "").strip()
  2454. if not owner or not repo:
  2455. return jsonify({"error": "owner_repo_required"}), 400
  2456. resp = gogs_repo_info(owner, repo)
  2457. if resp.status_code == 404:
  2458. return jsonify({"error": "repo_not_found"}), 404
  2459. if resp.status_code >= 400:
  2460. if resp.status_code == 599:
  2461. return jsonify({"error": "gogs_failed", "status": resp.status_code, "url": resp.url}), 502
  2462. return jsonify({"error": "gogs_failed", "status": resp.status_code}), 502
  2463. data = resp.json()
  2464. return jsonify(
  2465. {
  2466. "owner": owner,
  2467. "repo": repo,
  2468. "fullName": data.get("full_name"),
  2469. "description": data.get("description"),
  2470. "private": bool(data.get("private")),
  2471. "defaultBranch": data.get("default_branch"),
  2472. "htmlUrl": data.get("html_url"),
  2473. "cloneUrl": data.get("clone_url"),
  2474. "sshUrl": data.get("ssh_url"),
  2475. "updatedAt": data.get("updated_at") or data.get("updated_at_unix"),
  2476. }
  2477. )
  2478. @app.get("/admin/gogs/user-repos")
  2479. def api_admin_gogs_user_repos() -> Response:
  2480. _ = require_admin()
  2481. owner = (request.args.get("owner") or "").strip()
  2482. q = (request.args.get("q") or "").strip().lower()
  2483. if not owner:
  2484. return jsonify({"error": "owner_required"}), 400
  2485. resp = gogs_user_repos(owner)
  2486. if resp.status_code == 404:
  2487. return jsonify({"error": "user_not_found"}), 404
  2488. if resp.status_code >= 400:
  2489. if resp.status_code == 599:
  2490. return jsonify({"error": "gogs_failed", "status": resp.status_code, "url": resp.url}), 502
  2491. return jsonify({"error": "gogs_failed", "status": resp.status_code}), 502
  2492. repos = []
  2493. for item in resp.json() or []:
  2494. name = (item.get("name") or "").strip()
  2495. full_name = (item.get("full_name") or "").strip()
  2496. if q and (q not in name.lower() and q not in full_name.lower()):
  2497. continue
  2498. repos.append(
  2499. {
  2500. "id": item.get("id"),
  2501. "name": name,
  2502. "fullName": full_name,
  2503. "private": bool(item.get("private")),
  2504. "defaultBranch": item.get("default_branch"),
  2505. "description": item.get("description"),
  2506. "htmlUrl": item.get("html_url"),
  2507. "updatedAt": item.get("updated_at") or item.get("updated_at_unix"),
  2508. }
  2509. )
  2510. return jsonify({"items": repos})
  2511. @app.get("/admin/gogs/branches")
  2512. def api_admin_gogs_branches() -> Response:
  2513. _ = require_admin()
  2514. owner = (request.args.get("owner") or "").strip()
  2515. repo = (request.args.get("repo") or "").strip()
  2516. if not owner or not repo:
  2517. return jsonify({"error": "owner_repo_required"}), 400
  2518. resp = gogs_branches(owner, repo)
  2519. if resp.status_code == 404:
  2520. return jsonify({"error": "repo_not_found"}), 404
  2521. if resp.status_code >= 400:
  2522. if resp.status_code == 599:
  2523. return jsonify({"error": "gogs_failed", "status": resp.status_code, "url": resp.url}), 502
  2524. return jsonify({"error": "gogs_failed", "status": resp.status_code}), 502
  2525. items = []
  2526. for b in resp.json() or []:
  2527. name = (b.get("name") or "").strip()
  2528. if not name:
  2529. continue
  2530. items.append({"name": name, "commit": (b.get("commit") or {}).get("id")})
  2531. items.sort(key=lambda x: x["name"])
  2532. return jsonify({"items": items})
  2533. @app.get("/admin/gogs/tags")
  2534. def api_admin_gogs_tags() -> Response:
  2535. _ = require_admin()
  2536. owner = (request.args.get("owner") or "").strip()
  2537. repo = (request.args.get("repo") or "").strip()
  2538. if not owner or not repo:
  2539. return jsonify({"error": "owner_repo_required"}), 400
  2540. resp = gogs_tags(owner, repo)
  2541. if resp.status_code == 404:
  2542. return jsonify({"error": "repo_not_found"}), 404
  2543. if resp.status_code >= 400:
  2544. if resp.status_code == 599:
  2545. return jsonify({"error": "gogs_failed", "status": resp.status_code, "url": resp.url}), 502
  2546. return jsonify({"error": "gogs_failed", "status": resp.status_code}), 502
  2547. items = []
  2548. for t in resp.json() or []:
  2549. name = (t.get("name") or "").strip()
  2550. if not name:
  2551. continue
  2552. items.append({"name": name})
  2553. items.sort(key=lambda x: x["name"])
  2554. return jsonify({"items": items})
  2555. @app.get("/admin/gogs/file-text")
  2556. def api_admin_gogs_file_text() -> Response:
  2557. _ = require_admin()
  2558. owner = (request.args.get("owner") or "").strip()
  2559. repo = (request.args.get("repo") or "").strip()
  2560. ref = (request.args.get("ref") or "").strip() or "AUTO"
  2561. path = (request.args.get("path") or "").strip() or "README.md"
  2562. if not owner or not repo:
  2563. return jsonify({"error": "owner_repo_required"}), 400
  2564. path = path.lstrip("/")
  2565. if not path:
  2566. return jsonify({"error": "path_required"}), 400
  2567. if ref.upper() == "AUTO":
  2568. repo_resp = gogs_repo_info(owner, repo)
  2569. if repo_resp.status_code == 404:
  2570. return jsonify({"error": "repo_not_found"}), 404
  2571. if repo_resp.status_code >= 400:
  2572. if repo_resp.status_code == 599:
  2573. return jsonify({"error": "gogs_failed", "status": repo_resp.status_code, "url": repo_resp.url}), 502
  2574. return jsonify({"error": "gogs_failed", "status": repo_resp.status_code}), 502
  2575. repo_data = repo_resp.json() or {}
  2576. ref = (repo_data.get("default_branch") or "master").strip() or "master"
  2577. resp = gogs_contents(owner, repo, path, ref)
  2578. if resp.status_code == 404:
  2579. return jsonify({"error": "file_not_found"}), 404
  2580. if resp.status_code >= 400:
  2581. if resp.status_code == 599:
  2582. return jsonify({"error": "gogs_failed", "status": resp.status_code, "url": resp.url}), 502
  2583. return jsonify({"error": "gogs_failed", "status": resp.status_code}), 502
  2584. data = resp.json() or {}
  2585. if (data.get("type") or "").strip().lower() != "file":
  2586. return jsonify({"error": "not_a_file"}), 400
  2587. encoding = (data.get("encoding") or "").strip().lower()
  2588. if encoding != "base64":
  2589. return jsonify({"error": "unsupported_encoding"}), 502
  2590. content_b64 = (data.get("content") or "").strip()
  2591. if not content_b64:
  2592. return jsonify({"error": "empty_content"}), 404
  2593. try:
  2594. raw = base64.b64decode(content_b64, validate=False)
  2595. except Exception:
  2596. return jsonify({"error": "decode_failed"}), 502
  2597. if len(raw) > 200_000:
  2598. return jsonify({"error": "file_too_large"}), 413
  2599. try:
  2600. text = raw.decode("utf-8")
  2601. except Exception:
  2602. text = raw.decode("utf-8", errors="replace")
  2603. return jsonify({"owner": owner, "repo": repo, "ref": ref, "path": path, "text": text, "sha": data.get("sha")})
  2604. @app.get("/admin/gogs/repos")
  2605. def api_admin_gogs_repos() -> Response:
  2606. _ = require_admin()
  2607. owner = (request.args.get("owner") or "").strip()
  2608. q = (request.args.get("q") or "").strip().lower()
  2609. config = get_config()
  2610. gogs_token = get_setting_value("GOGS_TOKEN")
  2611. if gogs_token is not None:
  2612. gogs_token = gogs_token.strip() or None
  2613. if gogs_token is None:
  2614. gogs_token = config.gogs_token
  2615. if not owner and not gogs_token:
  2616. return jsonify({"error": "gogs_token_required"}), 400
  2617. resp = gogs_user_repos(owner) if owner else gogs_my_repos()
  2618. if resp.status_code == 404 and owner:
  2619. return jsonify({"error": "user_not_found"}), 404
  2620. if resp.status_code >= 400:
  2621. if resp.status_code == 599:
  2622. return jsonify({"error": "gogs_failed", "status": resp.status_code, "url": resp.url}), 502
  2623. return jsonify({"error": "gogs_failed", "status": resp.status_code}), 502
  2624. repos = []
  2625. for item in resp.json() or []:
  2626. name = (item.get("name") or "").strip()
  2627. full_name = (item.get("full_name") or "").strip()
  2628. if q and (q not in name.lower() and q not in full_name.lower()):
  2629. continue
  2630. repo_owner = ((item.get("owner") or {}).get("username") or "").strip()
  2631. if not repo_owner and "/" in full_name:
  2632. repo_owner = full_name.split("/", 1)[0].strip()
  2633. repos.append(
  2634. {
  2635. "id": item.get("id"),
  2636. "owner": repo_owner,
  2637. "name": name,
  2638. "fullName": full_name,
  2639. "private": bool(item.get("private")),
  2640. "defaultBranch": item.get("default_branch"),
  2641. "description": item.get("description"),
  2642. "htmlUrl": item.get("html_url"),
  2643. "updatedAt": item.get("updated_at") or item.get("updated_at_unix"),
  2644. }
  2645. )
  2646. return jsonify({"items": repos})
  2647. @app.get("/admin/plans")
  2648. def api_admin_plans() -> Response:
  2649. _ = require_admin()
  2650. rows = fetch_all("SELECT * FROM plans ORDER BY sort DESC, id DESC")
  2651. return jsonify(
  2652. [
  2653. {
  2654. "id": row["id"],
  2655. "name": row["name"],
  2656. "durationDays": row["duration_days"],
  2657. "priceCents": row["price_cents"],
  2658. "enabled": bool(row["enabled"]),
  2659. "sort": row["sort"],
  2660. }
  2661. for row in rows
  2662. ]
  2663. )
  2664. @app.post("/admin/plans")
  2665. def api_admin_create_plan() -> Response:
  2666. admin = require_admin()
  2667. payload = request.get_json(silent=True) or {}
  2668. name = (payload.get("name") or "").strip()
  2669. duration_days = parse_int(payload.get("durationDays"), 0)
  2670. price_cents = parse_int(payload.get("priceCents"), 0)
  2671. enabled = 1 if payload.get("enabled", True) else 0
  2672. sort = parse_int(payload.get("sort"), 0)
  2673. if not name or duration_days <= 0 or price_cents <= 0:
  2674. return jsonify({"error": "invalid_payload"}), 400
  2675. cur = execute(
  2676. "INSERT INTO plans (name, duration_days, price_cents, enabled, sort) VALUES (?, ?, ?, ?, ?)",
  2677. (name, duration_days, price_cents, enabled, sort),
  2678. )
  2679. audit("ADMIN", admin["id"], "PLAN_CREATE", "Plan", str(cur.lastrowid), None, payload)
  2680. return jsonify({"id": cur.lastrowid})
  2681. @app.put("/admin/plans/<int:plan_id>")
  2682. def api_admin_update_plan(plan_id: int) -> Response:
  2683. admin = require_admin()
  2684. before_row = fetch_one("SELECT * FROM plans WHERE id = ?", (plan_id,))
  2685. if before_row is None:
  2686. abort(404)
  2687. payload = request.get_json(silent=True) or {}
  2688. name = (payload.get("name") or before_row["name"]).strip()
  2689. duration_days = parse_int(payload.get("durationDays", before_row["duration_days"]), before_row["duration_days"])
  2690. price_cents = parse_int(payload.get("priceCents", before_row["price_cents"]), before_row["price_cents"])
  2691. enabled = 1 if payload.get("enabled", bool(before_row["enabled"])) else 0
  2692. sort = parse_int(payload.get("sort", before_row["sort"]), before_row["sort"])
  2693. if not name or duration_days <= 0 or price_cents <= 0:
  2694. return jsonify({"error": "invalid_payload"}), 400
  2695. execute(
  2696. "UPDATE plans SET name = ?, duration_days = ?, price_cents = ?, enabled = ?, sort = ? WHERE id = ?",
  2697. (name, duration_days, price_cents, enabled, sort, plan_id),
  2698. )
  2699. after_row = fetch_one("SELECT * FROM plans WHERE id = ?", (plan_id,))
  2700. audit(
  2701. "ADMIN",
  2702. admin["id"],
  2703. "PLAN_UPDATE",
  2704. "Plan",
  2705. str(plan_id),
  2706. dict(before_row),
  2707. dict(after_row) if after_row is not None else None,
  2708. )
  2709. return jsonify({"ok": True})
  2710. @app.delete("/admin/plans/<int:plan_id>")
  2711. def api_admin_delete_plan(plan_id: int) -> Response:
  2712. admin = require_admin()
  2713. before_row = fetch_one("SELECT * FROM plans WHERE id = ?", (plan_id,))
  2714. if before_row is None:
  2715. abort(404)
  2716. execute("DELETE FROM plans WHERE id = ?", (plan_id,))
  2717. audit("ADMIN", admin["id"], "PLAN_DELETE", "Plan", str(plan_id), dict(before_row), None)
  2718. return jsonify({"ok": True})
  2719. @app.get("/admin/resources")
  2720. def api_admin_resources() -> Response:
  2721. _ = require_admin()
  2722. q = (request.args.get("q") or "").strip()
  2723. resource_type = (request.args.get("type") or "").strip().upper()
  2724. status = (request.args.get("status") or "").strip().upper()
  2725. page = max(1, parse_int(request.args.get("page"), 1))
  2726. page_size = min(100, max(1, parse_int(request.args.get("pageSize"), 20)))
  2727. where = []
  2728. params: list[Any] = []
  2729. if q:
  2730. where.append("(title LIKE ? OR summary LIKE ? OR repo_owner LIKE ? OR repo_name LIKE ?)")
  2731. like = f"%{q}%"
  2732. params.extend([like, like, like, like])
  2733. if resource_type in {"FREE", "VIP"}:
  2734. where.append("type = ?")
  2735. params.append(resource_type)
  2736. if status in {"DRAFT", "ONLINE", "OFFLINE"}:
  2737. where.append("status = ?")
  2738. params.append(status)
  2739. where_sql = f"WHERE {' AND '.join(where)}" if where else ""
  2740. total_row = fetch_one(f"SELECT COUNT(1) AS cnt FROM resources {where_sql}", tuple(params))
  2741. total = int(total_row["cnt"]) if total_row is not None else 0
  2742. offset = (page - 1) * page_size
  2743. rows = fetch_all(
  2744. f"""
  2745. SELECT *
  2746. FROM resources
  2747. {where_sql}
  2748. ORDER BY updated_at DESC, id DESC
  2749. LIMIT ? OFFSET ?
  2750. """,
  2751. tuple(params + [page_size, offset]),
  2752. )
  2753. items = []
  2754. for row in rows:
  2755. try:
  2756. tags = json.loads(row["tags_json"] or "[]")
  2757. except Exception:
  2758. tags = []
  2759. items.append(
  2760. {
  2761. "id": row["id"],
  2762. "title": row["title"],
  2763. "summary": row["summary"],
  2764. "type": row["type"],
  2765. "status": row["status"],
  2766. "coverUrl": row["cover_url"],
  2767. "tags": tags,
  2768. "repoOwner": row["repo_owner"],
  2769. "repoName": row["repo_name"],
  2770. "repoPrivate": bool(row["repo_private"]),
  2771. "repoHtmlUrl": row["repo_html_url"],
  2772. "defaultRef": row["default_ref"],
  2773. "updatedAt": row["updated_at"],
  2774. }
  2775. )
  2776. return jsonify({"items": items, "total": total, "page": page, "pageSize": page_size})
  2777. @app.post("/admin/resources")
  2778. def api_admin_create_resource() -> Response:
  2779. admin = require_admin()
  2780. payload = request.get_json(silent=True) or {}
  2781. title = (payload.get("title") or "").strip()
  2782. summary = (payload.get("summary") or "").strip()
  2783. resource_type = (payload.get("type") or "").strip().upper()
  2784. status = (payload.get("status") or "").strip().upper()
  2785. cover_url = (payload.get("coverUrl") or "").strip() or None
  2786. tags = _parse_keywords(payload.get("keywords") if "keywords" in payload else payload.get("tags"))
  2787. sync_readme = payload.get("syncReadme")
  2788. if sync_readme is None:
  2789. sync_readme = True
  2790. create_repo = payload.get("createRepo")
  2791. if create_repo is None:
  2792. create_repo = True
  2793. repo_owner = (payload.get("repoOwner") or "").strip()
  2794. repo_name = (payload.get("repoName") or "").strip()
  2795. repo_private = 1 if payload.get("repoPrivate") else 0
  2796. repo_html_url: str | None = None
  2797. default_ref = (payload.get("defaultRef") or "").strip()
  2798. requested_ref = default_ref
  2799. if not title or resource_type not in {"FREE", "VIP"}:
  2800. return jsonify({"error": "invalid_payload"}), 400
  2801. if status not in {"DRAFT", "ONLINE", "OFFLINE"}:
  2802. status = "DRAFT"
  2803. base_url, token = _gogs_base_url_and_token()
  2804. if not base_url:
  2805. return jsonify({"error": "gogs_base_url_required"}), 400
  2806. if not token:
  2807. return jsonify({"error": "gogs_token_required"}), 400
  2808. if create_repo:
  2809. desired_name = repo_name or _slugify_repo_name(title)
  2810. desired_owner = repo_owner
  2811. description = title
  2812. repo_data: dict[str, Any] | None = None
  2813. created_resp: requests.Response | None = None
  2814. for i in range(5):
  2815. try_name = desired_name if i == 0 else f"{desired_name}-{uuid.uuid4().hex[:6]}"
  2816. resp = gogs_create_repo(desired_owner, try_name, description, bool(repo_private))
  2817. if desired_owner and resp.status_code in {403, 404}:
  2818. resp = gogs_create_repo("", try_name, description, bool(repo_private))
  2819. if resp.status_code == 409:
  2820. if repo_name:
  2821. return jsonify({"error": "repo_exists"}), 409
  2822. continue
  2823. if resp.status_code >= 400:
  2824. msg = _gogs_error_message(resp)
  2825. upstream_url = _safe_upstream_url(resp)
  2826. if resp.status_code in {401, 403}:
  2827. return jsonify({"error": "gogs_unauthorized", "status": resp.status_code, "message": msg, "url": upstream_url}), 400
  2828. if resp.status_code == 599:
  2829. return jsonify({"error": "gogs_unreachable", "status": resp.status_code, "message": msg, "url": upstream_url}), 502
  2830. return jsonify({"error": "gogs_failed", "status": resp.status_code, "message": msg, "url": upstream_url}), 502
  2831. created_resp = resp
  2832. repo_data = resp.json() or {}
  2833. break
  2834. if created_resp is None or repo_data is None:
  2835. return jsonify({"error": "repo_create_failed"}), 502
  2836. full_name = (repo_data.get("full_name") or "").strip()
  2837. if "/" in full_name:
  2838. owner_part, repo_part = full_name.split("/", 1)
  2839. repo_owner = owner_part.strip()
  2840. repo_name = repo_part.strip()
  2841. else:
  2842. repo_owner = (repo_data.get("owner", {}) or {}).get("username") or repo_owner
  2843. repo_name = (repo_data.get("name") or repo_name).strip()
  2844. repo_html_url = (repo_data.get("html_url") or "").strip() or None
  2845. if repo_data.get("private") is not None:
  2846. repo_private = 1 if repo_data.get("private") else 0
  2847. default_ref = (repo_data.get("default_branch") or "master").strip()
  2848. if sync_readme:
  2849. readme = f"# {title}\n\n{summary}\n" if summary else f"# {title}\n"
  2850. try:
  2851. gogs_git_write_file(repo_owner, repo_name, default_ref, "README.md", readme, "init README", must_create=True)
  2852. except GogsGitError as e:
  2853. if e.code != "file_exists":
  2854. if e.code in {"ref_required", "gogs_token_required", "invalid_gogs_base_url"}:
  2855. return jsonify({"error": e.code, "message": e.message}), 400
  2856. if e.code == "git_not_found":
  2857. return jsonify({"error": e.code, "message": e.message}), 501
  2858. return jsonify({"error": "readme_sync_failed", "message": e.message}), 502
  2859. else:
  2860. if not repo_owner or not repo_name:
  2861. return jsonify({"error": "repo_required"}), 400
  2862. repo_resp = gogs_repo_info(repo_owner, repo_name)
  2863. if repo_resp.status_code == 404:
  2864. return jsonify({"error": "repo_not_found"}), 400
  2865. if repo_resp.status_code >= 400:
  2866. msg = _gogs_error_message(repo_resp)
  2867. upstream_url = _safe_upstream_url(repo_resp)
  2868. if repo_resp.status_code in {401, 403}:
  2869. return jsonify({"error": "gogs_unauthorized", "status": repo_resp.status_code, "message": msg, "url": upstream_url}), 400
  2870. if repo_resp.status_code == 599:
  2871. return jsonify({"error": "gogs_unreachable", "status": repo_resp.status_code, "message": msg, "url": upstream_url}), 502
  2872. return jsonify({"error": "gogs_failed", "status": repo_resp.status_code, "message": msg, "url": upstream_url}), 502
  2873. repo_data = repo_resp.json()
  2874. repo_html_url = (repo_data.get("html_url") or "").strip() or None
  2875. if repo_data.get("private") is not None:
  2876. repo_private = 1 if repo_data.get("private") else 0
  2877. if not default_ref or default_ref.upper() == "AUTO":
  2878. default_ref = (repo_data.get("default_branch") or "master").strip()
  2879. if requested_ref and requested_ref.upper() != "AUTO":
  2880. branches_resp = gogs_branches(repo_owner, repo_name)
  2881. tags_resp = gogs_tags(repo_owner, repo_name)
  2882. if branches_resp.status_code >= 400:
  2883. msg = _gogs_error_message(branches_resp)
  2884. upstream_url = _safe_upstream_url(branches_resp)
  2885. if branches_resp.status_code in {401, 403}:
  2886. return jsonify({"error": "gogs_unauthorized", "status": branches_resp.status_code, "message": msg, "url": upstream_url}), 400
  2887. if branches_resp.status_code == 599:
  2888. return jsonify({"error": "gogs_unreachable", "status": branches_resp.status_code, "message": msg, "url": upstream_url}), 502
  2889. return jsonify({"error": "gogs_failed", "status": branches_resp.status_code, "message": msg, "url": upstream_url}), 502
  2890. if tags_resp.status_code >= 400:
  2891. msg = _gogs_error_message(tags_resp)
  2892. upstream_url = _safe_upstream_url(tags_resp)
  2893. if tags_resp.status_code in {401, 403}:
  2894. return jsonify({"error": "gogs_unauthorized", "status": tags_resp.status_code, "message": msg, "url": upstream_url}), 400
  2895. if tags_resp.status_code == 599:
  2896. return jsonify({"error": "gogs_unreachable", "status": tags_resp.status_code, "message": msg, "url": upstream_url}), 502
  2897. return jsonify({"error": "gogs_failed", "status": tags_resp.status_code, "message": msg, "url": upstream_url}), 502
  2898. exists = False
  2899. for b in branches_resp.json() or []:
  2900. if (b.get("name") or "").strip() == requested_ref:
  2901. exists = True
  2902. break
  2903. if not exists:
  2904. for t in tags_resp.json() or []:
  2905. if (t.get("name") or "").strip() == requested_ref:
  2906. exists = True
  2907. break
  2908. if not exists:
  2909. return jsonify({"error": "invalid_ref"}), 400
  2910. if sync_readme:
  2911. readme = f"# {title}\n\n{summary}\n" if summary else f"# {title}\n"
  2912. try:
  2913. gogs_git_write_file(repo_owner, repo_name, default_ref, "README.md", readme, "sync README", must_create=False)
  2914. except GogsGitError as e:
  2915. if e.code == "file_not_found":
  2916. try:
  2917. gogs_git_write_file(repo_owner, repo_name, default_ref, "README.md", readme, "sync README", must_create=True)
  2918. except GogsGitError as e2:
  2919. if e2.code in {"ref_required", "gogs_token_required", "invalid_gogs_base_url"}:
  2920. return jsonify({"error": e2.code, "message": e2.message}), 400
  2921. if e2.code == "git_not_found":
  2922. return jsonify({"error": e2.code, "message": e2.message}), 501
  2923. return jsonify({"error": "readme_sync_failed", "message": e2.message}), 502
  2924. else:
  2925. if e.code in {"ref_required", "gogs_token_required", "invalid_gogs_base_url"}:
  2926. return jsonify({"error": e.code, "message": e.message}), 400
  2927. if e.code == "git_not_found":
  2928. return jsonify({"error": e.code, "message": e.message}), 501
  2929. return jsonify({"error": "readme_sync_failed", "message": e.message}), 502
  2930. now = isoformat(utcnow())
  2931. cur = execute(
  2932. """
  2933. INSERT INTO resources
  2934. (title, summary, type, status, cover_url, tags_json, repo_owner, repo_name, repo_private, repo_html_url, default_ref, created_at, updated_at)
  2935. VALUES
  2936. (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
  2937. """,
  2938. (title, summary, resource_type, status, cover_url, json.dumps(tags, ensure_ascii=False), repo_owner, repo_name, repo_private, repo_html_url, default_ref, now, now),
  2939. )
  2940. audit("ADMIN", admin["id"], "RESOURCE_CREATE", "Resource", str(cur.lastrowid), None, payload)
  2941. return jsonify({"id": cur.lastrowid})
  2942. @app.put("/admin/resources/<int:resource_id>")
  2943. def api_admin_update_resource(resource_id: int) -> Response:
  2944. admin = require_admin()
  2945. before_row = fetch_one("SELECT * FROM resources WHERE id = ?", (resource_id,))
  2946. if before_row is None:
  2947. abort(404)
  2948. payload = request.get_json(silent=True) or {}
  2949. title = (payload.get("title") or before_row["title"]).strip()
  2950. summary = (payload.get("summary") or before_row["summary"]).strip()
  2951. resource_type = (payload.get("type") or before_row["type"]).strip().upper()
  2952. status = (payload.get("status") or before_row["status"]).strip().upper()
  2953. cover_url = (payload.get("coverUrl") if "coverUrl" in payload else before_row["cover_url"]) or None
  2954. if cover_url is not None:
  2955. cover_url = str(cover_url).strip() or None
  2956. if "keywords" in payload or "tags" in payload:
  2957. tags = _parse_keywords(payload.get("keywords") if "keywords" in payload else payload.get("tags"))
  2958. tags_json = json.dumps(tags, ensure_ascii=False)
  2959. else:
  2960. tags_json = before_row["tags_json"]
  2961. repo_owner = (payload.get("repoOwner") or before_row["repo_owner"]).strip()
  2962. repo_name = (payload.get("repoName") or before_row["repo_name"]).strip()
  2963. default_ref = (payload.get("defaultRef") or before_row["default_ref"]).strip()
  2964. requested_ref = (payload.get("defaultRef") or "").strip()
  2965. if not title or resource_type not in {"FREE", "VIP"}:
  2966. return jsonify({"error": "invalid_payload"}), 400
  2967. if status not in {"DRAFT", "ONLINE", "OFFLINE"}:
  2968. return jsonify({"error": "invalid_status"}), 400
  2969. if not repo_owner or not repo_name:
  2970. return jsonify({"error": "repo_required"}), 400
  2971. base_url, token = _gogs_base_url_and_token()
  2972. if not base_url:
  2973. return jsonify({"error": "gogs_base_url_required"}), 400
  2974. if not token:
  2975. return jsonify({"error": "gogs_token_required"}), 400
  2976. repo_resp = gogs_repo_info(repo_owner, repo_name)
  2977. if repo_resp.status_code == 404:
  2978. return jsonify({"error": "repo_not_found"}), 400
  2979. if repo_resp.status_code >= 400:
  2980. msg = _gogs_error_message(repo_resp)
  2981. if repo_resp.status_code in {401, 403}:
  2982. return jsonify({"error": "gogs_unauthorized", "status": repo_resp.status_code, "message": msg}), 400
  2983. if repo_resp.status_code == 599:
  2984. return jsonify({"error": "gogs_unreachable", "status": repo_resp.status_code, "message": msg}), 502
  2985. return jsonify({"error": "gogs_failed", "status": repo_resp.status_code, "message": msg}), 502
  2986. repo_data = repo_resp.json()
  2987. repo_html_url = (repo_data.get("html_url") or "").strip() or None
  2988. repo_private = 1 if repo_data.get("private") else 0
  2989. if not default_ref or default_ref.upper() == "AUTO":
  2990. default_ref = (repo_data.get("default_branch") or "master").strip()
  2991. if requested_ref and requested_ref.upper() != "AUTO":
  2992. branches_resp = gogs_branches(repo_owner, repo_name)
  2993. tags_resp = gogs_tags(repo_owner, repo_name)
  2994. if branches_resp.status_code >= 400:
  2995. msg = _gogs_error_message(branches_resp)
  2996. if branches_resp.status_code in {401, 403}:
  2997. return jsonify({"error": "gogs_unauthorized", "status": branches_resp.status_code, "message": msg}), 400
  2998. if branches_resp.status_code == 599:
  2999. return jsonify({"error": "gogs_unreachable", "status": branches_resp.status_code, "message": msg}), 502
  3000. return jsonify({"error": "gogs_failed", "status": branches_resp.status_code, "message": msg}), 502
  3001. if tags_resp.status_code >= 400:
  3002. msg = _gogs_error_message(tags_resp)
  3003. if tags_resp.status_code in {401, 403}:
  3004. return jsonify({"error": "gogs_unauthorized", "status": tags_resp.status_code, "message": msg}), 400
  3005. if tags_resp.status_code == 599:
  3006. return jsonify({"error": "gogs_unreachable", "status": tags_resp.status_code, "message": msg}), 502
  3007. return jsonify({"error": "gogs_failed", "status": tags_resp.status_code, "message": msg}), 502
  3008. exists = False
  3009. for b in branches_resp.json() or []:
  3010. if (b.get("name") or "").strip() == requested_ref:
  3011. exists = True
  3012. break
  3013. if not exists:
  3014. for t in tags_resp.json() or []:
  3015. if (t.get("name") or "").strip() == requested_ref:
  3016. exists = True
  3017. break
  3018. if not exists:
  3019. return jsonify({"error": "invalid_ref"}), 400
  3020. sync_readme = payload.get("syncReadme")
  3021. if sync_readme is None:
  3022. sync_readme = ("title" in payload) or ("summary" in payload)
  3023. if sync_readme:
  3024. readme = f"# {title}\n\n{summary}\n" if summary else f"# {title}\n"
  3025. try:
  3026. gogs_git_write_file(repo_owner, repo_name, default_ref, "README.md", readme, "sync README", must_create=False)
  3027. except GogsGitError as e:
  3028. if e.code == "file_not_found":
  3029. try:
  3030. gogs_git_write_file(repo_owner, repo_name, default_ref, "README.md", readme, "sync README", must_create=True)
  3031. except GogsGitError as e2:
  3032. if e2.code in {"ref_required", "gogs_token_required", "invalid_gogs_base_url"}:
  3033. return jsonify({"error": e2.code, "message": e2.message}), 400
  3034. if e2.code == "git_not_found":
  3035. return jsonify({"error": e2.code, "message": e2.message}), 501
  3036. return jsonify({"error": "readme_sync_failed", "message": e2.message}), 502
  3037. else:
  3038. if e.code in {"ref_required", "gogs_token_required", "invalid_gogs_base_url"}:
  3039. return jsonify({"error": e.code, "message": e.message}), 400
  3040. if e.code == "git_not_found":
  3041. return jsonify({"error": e.code, "message": e.message}), 501
  3042. return jsonify({"error": "readme_sync_failed", "message": e.message}), 502
  3043. execute(
  3044. """
  3045. UPDATE resources
  3046. SET title = ?, summary = ?, type = ?, status = ?, cover_url = ?, tags_json = ?, repo_owner = ?, repo_name = ?, repo_private = ?, repo_html_url = ?, default_ref = ?, updated_at = ?
  3047. WHERE id = ?
  3048. """,
  3049. (
  3050. title,
  3051. summary,
  3052. resource_type,
  3053. status,
  3054. cover_url,
  3055. tags_json,
  3056. repo_owner,
  3057. repo_name,
  3058. repo_private,
  3059. repo_html_url,
  3060. default_ref,
  3061. isoformat(utcnow()),
  3062. resource_id,
  3063. ),
  3064. )
  3065. after_row = fetch_one("SELECT * FROM resources WHERE id = ?", (resource_id,))
  3066. audit(
  3067. "ADMIN",
  3068. admin["id"],
  3069. "RESOURCE_UPDATE",
  3070. "Resource",
  3071. str(resource_id),
  3072. dict(before_row),
  3073. dict(after_row) if after_row is not None else None,
  3074. )
  3075. return jsonify({"ok": True})
  3076. @app.delete("/admin/resources/<int:resource_id>")
  3077. def api_admin_delete_resource(resource_id: int) -> Response:
  3078. admin = require_admin()
  3079. before_row = fetch_one("SELECT * FROM resources WHERE id = ?", (resource_id,))
  3080. if before_row is None:
  3081. abort(404)
  3082. repo_owner = (before_row["repo_owner"] or "").strip()
  3083. repo_name = (before_row["repo_name"] or "").strip()
  3084. if repo_owner and repo_name:
  3085. resp = gogs_delete_repo(repo_owner, repo_name)
  3086. if resp.status_code not in {204, 404} and resp.status_code >= 400:
  3087. msg = _gogs_error_message(resp)
  3088. upstream_url = _safe_upstream_url(resp)
  3089. if resp.status_code in {401, 403}:
  3090. return jsonify({"error": "gogs_unauthorized", "status": resp.status_code, "message": msg, "url": upstream_url}), 400
  3091. if resp.status_code == 599:
  3092. return jsonify({"error": "gogs_unreachable", "status": resp.status_code, "message": msg, "url": upstream_url}), 502
  3093. return jsonify({"error": "gogs_failed", "status": resp.status_code, "message": msg, "url": upstream_url}), 502
  3094. upload_names: set[str] = set()
  3095. upload_names |= _extract_upload_names(before_row["cover_url"])
  3096. upload_names |= _extract_upload_names(before_row["summary"])
  3097. execute("DELETE FROM resources WHERE id = ?", (resource_id,))
  3098. audit("ADMIN", admin["id"], "RESOURCE_DELETE", "Resource", str(resource_id), dict(before_row), None)
  3099. _delete_upload_files(upload_names)
  3100. return jsonify({"ok": True})
  3101. def _download_cache_glob_prefix(*, resource_id: int, owner: str, repo: str) -> str:
  3102. safe_owner = re.sub(r"[^a-zA-Z0-9._-]+", "_", (owner or "").strip())[:50] or "owner"
  3103. safe_repo = re.sub(r"[^a-zA-Z0-9._-]+", "_", (repo or "").strip())[:50] or "repo"
  3104. rid = int(resource_id or 0)
  3105. return f"res{rid}__{safe_owner}__{safe_repo}__"
  3106. def _admin_cache_entry_from_path(p: Path) -> dict[str, Any]:
  3107. try:
  3108. st = p.stat()
  3109. except Exception:
  3110. st = None
  3111. meta = _read_download_cache_meta(p)
  3112. ttl_remaining = None
  3113. if st is not None and _download_cache_ttl_seconds > 0:
  3114. ttl_remaining = max(0, int(_download_cache_ttl_seconds - (time.time() - float(st.st_mtime))))
  3115. commit = None
  3116. ref = None
  3117. if isinstance(meta, dict):
  3118. commit = (meta.get("commit") or "").strip() or None
  3119. ref = (meta.get("ref") or "").strip() or None
  3120. return {
  3121. "fileName": p.name,
  3122. "path": str(p),
  3123. "bytes": int(st.st_size) if st is not None else None,
  3124. "mtime": int(st.st_mtime) if st is not None else None,
  3125. "ttlRemainingSeconds": ttl_remaining,
  3126. "ref": ref,
  3127. "commit": commit,
  3128. "meta": meta,
  3129. "ready": bool(st is not None and st.st_size > 0),
  3130. }
  3131. def _admin_cache_building_for(*, resource_id: int, owner: str, repo: str) -> list[dict[str, Any]]:
  3132. rid = int(resource_id or 0)
  3133. prefix = f"res{rid}:{owner}/{repo}@"
  3134. out: list[dict[str, Any]] = []
  3135. with _download_lock:
  3136. for k, v in _download_jobs.items():
  3137. if not isinstance(k, str) or not k.startswith(prefix):
  3138. continue
  3139. if not isinstance(v, dict):
  3140. continue
  3141. out.append(
  3142. {
  3143. "key": k,
  3144. "state": v.get("state"),
  3145. "updatedAt": v.get("updatedAt"),
  3146. "error": v.get("error"),
  3147. "ref": v.get("ref"),
  3148. "commit": v.get("commit"),
  3149. "cacheKey": v.get("cacheKey"),
  3150. }
  3151. )
  3152. out.sort(key=lambda x: float(x.get("updatedAt") or 0), reverse=True)
  3153. return out[:20]
  3154. @app.get("/admin/resources/<int:resource_id>/download-cache/summary")
  3155. def api_admin_resource_download_cache_summary(resource_id: int) -> Response:
  3156. _ = require_admin()
  3157. row = fetch_one("SELECT repo_owner, repo_name, default_ref FROM resources WHERE id = ?", (resource_id,))
  3158. if row is None:
  3159. abort(404)
  3160. owner, repo, default_ref = row["repo_owner"], row["repo_name"], row["default_ref"]
  3161. cache_dir = _download_cache_dir()
  3162. prefix = _download_cache_glob_prefix(resource_id=resource_id, owner=owner, repo=repo)
  3163. files = list(cache_dir.glob(prefix + "*.zip"))
  3164. latest = None
  3165. latest_mtime = -1
  3166. for p in files:
  3167. try:
  3168. st = p.stat()
  3169. except Exception:
  3170. continue
  3171. if st.st_size <= 0:
  3172. continue
  3173. if float(st.st_mtime) > float(latest_mtime):
  3174. latest_mtime = float(st.st_mtime)
  3175. latest = p
  3176. latest_entry = _admin_cache_entry_from_path(latest) if latest else None
  3177. building = _admin_cache_building_for(resource_id=resource_id, owner=owner, repo=repo)
  3178. return jsonify(
  3179. {
  3180. "ok": True,
  3181. "resourceId": int(resource_id),
  3182. "owner": owner,
  3183. "repo": repo,
  3184. "defaultRef": default_ref,
  3185. "count": len(files),
  3186. "latest": latest_entry,
  3187. "jobs": building,
  3188. }
  3189. )
  3190. @app.get("/admin/resources/<int:resource_id>/download-cache/list")
  3191. def api_admin_resource_download_cache_list(resource_id: int) -> Response:
  3192. _ = require_admin()
  3193. row = fetch_one("SELECT repo_owner, repo_name FROM resources WHERE id = ?", (resource_id,))
  3194. if row is None:
  3195. abort(404)
  3196. owner, repo = row["repo_owner"], row["repo_name"]
  3197. cache_dir = _download_cache_dir()
  3198. prefix = _download_cache_glob_prefix(resource_id=resource_id, owner=owner, repo=repo)
  3199. files = list(cache_dir.glob(prefix + "*.zip"))
  3200. items: list[dict[str, Any]] = []
  3201. for p in files:
  3202. items.append(_admin_cache_entry_from_path(p))
  3203. items.sort(key=lambda x: float(x.get("mtime") or 0), reverse=True)
  3204. return jsonify({"ok": True, "items": items[:50], "total": len(items)})
  3205. @app.get("/admin/resources/<int:resource_id>/download-cache/status")
  3206. def api_admin_resource_download_cache_status(resource_id: int) -> Response:
  3207. _ = require_admin()
  3208. row = fetch_one("SELECT repo_owner, repo_name, default_ref FROM resources WHERE id = ?", (resource_id,))
  3209. if row is None:
  3210. abort(404)
  3211. owner, repo, default_ref = row["repo_owner"], row["repo_name"], row["default_ref"]
  3212. ref = (request.args.get("ref") or "").strip() or default_ref
  3213. resolved = _resolve_download_commit(owner=owner, repo=repo, ref=ref)
  3214. commit = resolved.get("commit") if resolved.get("ok") else None
  3215. resolved_kind = resolved.get("kind") or "unknown"
  3216. cache_key = commit or ref
  3217. cache_path = _download_cache_path(resource_id=resource_id, owner=owner, repo=repo, cache_key=cache_key)
  3218. if _download_cache_ready(cache_path):
  3219. return jsonify({"ok": True, "ready": True, "state": "ready", "ref": ref, "commit": commit, "cacheKey": cache_key})
  3220. key = f"res{int(resource_id or 0)}:{owner}/{repo}@{cache_key}"
  3221. with _download_lock:
  3222. job = _download_jobs.get(key) or {}
  3223. return jsonify(
  3224. {
  3225. "ok": True,
  3226. "ready": False,
  3227. "state": job.get("state") or "building",
  3228. "error": job.get("error"),
  3229. "ref": ref,
  3230. "commit": commit,
  3231. "cacheKey": cache_key,
  3232. "refKind": resolved_kind,
  3233. }
  3234. )
  3235. @app.post("/admin/resources/<int:resource_id>/download-cache/refresh")
  3236. def api_admin_resource_download_cache_refresh(resource_id: int) -> Response:
  3237. admin = require_admin()
  3238. row = fetch_one("SELECT repo_owner, repo_name, default_ref FROM resources WHERE id = ?", (resource_id,))
  3239. if row is None:
  3240. abort(404)
  3241. payload = request.get_json(silent=True) or {}
  3242. owner, repo, default_ref = row["repo_owner"], row["repo_name"], row["default_ref"]
  3243. ref = (payload.get("ref") or "").strip() or default_ref
  3244. resolved = _resolve_download_commit(owner=owner, repo=repo, ref=ref)
  3245. commit = resolved.get("commit") if resolved.get("ok") else None
  3246. resolved_kind = resolved.get("kind") or "unknown"
  3247. st = _ensure_download_ready(
  3248. resource_id=resource_id,
  3249. owner=owner,
  3250. repo=repo,
  3251. ref=ref,
  3252. commit=commit,
  3253. resolved_kind=resolved_kind,
  3254. force=True,
  3255. )
  3256. audit("ADMIN", admin["id"], "DOWNLOAD_CACHE_REFRESH", "Resource", str(resource_id), None, {"ref": ref, "commit": commit})
  3257. return jsonify(
  3258. {
  3259. "ok": True,
  3260. "ready": bool(st.get("ready")),
  3261. "state": st.get("state") or ("ready" if st.get("ready") else "building"),
  3262. "error": st.get("error"),
  3263. "ref": ref,
  3264. "commit": commit,
  3265. "cacheKey": st.get("cacheKey") or (commit or ref),
  3266. }
  3267. )
  3268. @app.delete("/admin/resources/<int:resource_id>/download-cache")
  3269. def api_admin_resource_download_cache_clear(resource_id: int) -> Response:
  3270. admin = require_admin()
  3271. row = fetch_one("SELECT repo_owner, repo_name FROM resources WHERE id = ?", (resource_id,))
  3272. if row is None:
  3273. abort(404)
  3274. owner, repo = row["repo_owner"], row["repo_name"]
  3275. commit = (request.args.get("commit") or "").strip() or None
  3276. clear_all = (request.args.get("all") or "").strip() in {"1", "true", "True"}
  3277. cache_dir = _download_cache_dir()
  3278. prefix = _download_cache_glob_prefix(resource_id=resource_id, owner=owner, repo=repo)
  3279. files = list(cache_dir.glob(prefix + "*.zip"))
  3280. removed = 0
  3281. for p in files:
  3282. if not clear_all and commit:
  3283. meta = _read_download_cache_meta(p) or {}
  3284. if (meta.get("commit") or "").strip().lower() != commit.lower():
  3285. continue
  3286. try:
  3287. p.unlink()
  3288. removed += 1
  3289. except Exception:
  3290. pass
  3291. try:
  3292. mp = _download_cache_meta_path(p)
  3293. if mp.exists():
  3294. mp.unlink()
  3295. except Exception:
  3296. pass
  3297. audit("ADMIN", admin["id"], "DOWNLOAD_CACHE_CLEAR", "Resource", str(resource_id), None, {"commit": commit, "all": clear_all, "removed": removed})
  3298. return jsonify({"ok": True, "removed": removed})
  3299. @app.get("/admin/resources/<int:resource_id>/download-cache/file")
  3300. def api_admin_resource_download_cache_file(resource_id: int) -> Response:
  3301. _ = require_admin()
  3302. row = fetch_one("SELECT repo_owner, repo_name FROM resources WHERE id = ?", (resource_id,))
  3303. if row is None:
  3304. abort(404)
  3305. owner, repo = row["repo_owner"], row["repo_name"]
  3306. commit = (request.args.get("commit") or "").strip()
  3307. if not commit or not _looks_like_commit(commit):
  3308. return jsonify({"error": "commit_required"}), 400
  3309. cache_path = _download_cache_path(resource_id=resource_id, owner=owner, repo=repo, cache_key=commit.lower())
  3310. if not cache_path.exists():
  3311. abort(404)
  3312. filename = f"{owner}-{repo}-{commit[:12]}.zip".replace("/", "-")
  3313. f = open(cache_path, "rb")
  3314. resp = send_file(
  3315. f,
  3316. mimetype="application/zip",
  3317. as_attachment=True,
  3318. download_name=filename,
  3319. conditional=True,
  3320. max_age=0,
  3321. )
  3322. resp.call_on_close(f.close)
  3323. resp.direct_passthrough = False
  3324. return resp
  3325. @app.get("/admin/orders")
  3326. def api_admin_orders() -> Response:
  3327. _ = require_admin()
  3328. q = (request.args.get("q") or "").strip()
  3329. status = (request.args.get("status") or "").strip().upper()
  3330. page = max(1, parse_int(request.args.get("page"), 1))
  3331. page_size = min(100, max(1, parse_int(request.args.get("pageSize"), 20)))
  3332. where = []
  3333. params: list[Any] = []
  3334. if q:
  3335. where.append("(o.id LIKE ? OR u.phone LIKE ?)")
  3336. like = f"%{q}%"
  3337. params.extend([like, like])
  3338. if status in {"PENDING", "PAID", "CLOSED", "FAILED"}:
  3339. where.append("o.status = ?")
  3340. params.append(status)
  3341. where_sql = f"WHERE {' AND '.join(where)}" if where else ""
  3342. total_row = fetch_one(
  3343. f"""
  3344. SELECT COUNT(1) AS cnt
  3345. FROM orders o
  3346. JOIN users u ON u.id = o.user_id
  3347. {where_sql}
  3348. """,
  3349. tuple(params),
  3350. )
  3351. total = int(total_row["cnt"]) if total_row is not None else 0
  3352. offset = (page - 1) * page_size
  3353. rows = fetch_all(
  3354. f"""
  3355. SELECT o.*, u.phone as user_phone
  3356. FROM orders o
  3357. JOIN users u ON u.id = o.user_id
  3358. {where_sql}
  3359. ORDER BY o.created_at DESC
  3360. LIMIT ? OFFSET ?
  3361. """,
  3362. tuple(params + [page_size, offset]),
  3363. )
  3364. items = []
  3365. for row in rows:
  3366. items.append(
  3367. {
  3368. "id": row["id"],
  3369. "status": row["status"],
  3370. "amountCents": row["amount_cents"],
  3371. "userId": row["user_id"],
  3372. "userPhone": row["user_phone"],
  3373. "createdAt": row["created_at"],
  3374. "paidAt": row["paid_at"],
  3375. "planSnapshot": json.loads(row["plan_snapshot_json"]),
  3376. }
  3377. )
  3378. return jsonify({"items": items, "total": total, "page": page, "pageSize": page_size})
  3379. @app.get("/admin/orders/<order_id>")
  3380. def api_admin_get_order(order_id: str) -> Response:
  3381. _ = require_admin()
  3382. row = fetch_one(
  3383. """
  3384. SELECT o.*, u.phone as user_phone
  3385. FROM orders o
  3386. JOIN users u ON u.id = o.user_id
  3387. WHERE o.id = ?
  3388. """,
  3389. (order_id,),
  3390. )
  3391. if row is None:
  3392. abort(404)
  3393. return jsonify(
  3394. {
  3395. "id": row["id"],
  3396. "status": row["status"],
  3397. "amountCents": row["amount_cents"],
  3398. "userId": row["user_id"],
  3399. "userPhone": row["user_phone"],
  3400. "planId": row["plan_id"],
  3401. "payChannel": row["pay_channel"],
  3402. "payTradeNo": row["pay_trade_no"],
  3403. "createdAt": row["created_at"],
  3404. "paidAt": row["paid_at"],
  3405. "planSnapshot": json.loads(row["plan_snapshot_json"]),
  3406. }
  3407. )
  3408. @app.post("/admin/orders")
  3409. def api_admin_create_order() -> Response:
  3410. admin = require_admin()
  3411. payload = request.get_json(silent=True) or {}
  3412. user_id = parse_int(payload.get("userId"), 0)
  3413. user_phone = (payload.get("userPhone") or "").strip()
  3414. plan_id = parse_int(payload.get("planId"), 0)
  3415. status = (payload.get("status") or "PENDING").strip().upper()
  3416. if status not in {"PENDING", "PAID", "CLOSED", "FAILED"}:
  3417. return jsonify({"error": "invalid_status"}), 400
  3418. if user_id <= 0 and not user_phone:
  3419. return jsonify({"error": "user_required"}), 400
  3420. if plan_id <= 0:
  3421. return jsonify({"error": "plan_required"}), 400
  3422. user_row = None
  3423. if user_id > 0:
  3424. user_row = fetch_one("SELECT * FROM users WHERE id = ?", (user_id,))
  3425. else:
  3426. user_row = fetch_one("SELECT * FROM users WHERE phone = ?", (user_phone,))
  3427. if user_row is None:
  3428. return jsonify({"error": "user_not_found"}), 404
  3429. plan = fetch_one("SELECT * FROM plans WHERE id = ? AND enabled = 1", (plan_id,))
  3430. if plan is None:
  3431. return jsonify({"error": "plan_not_found"}), 404
  3432. order_id = uuid.uuid4().hex
  3433. snapshot = {
  3434. "name": plan["name"],
  3435. "durationDays": plan["duration_days"],
  3436. "priceCents": plan["price_cents"],
  3437. }
  3438. created_at = isoformat(utcnow())
  3439. paid_at = isoformat(utcnow()) if status == "PAID" else None
  3440. execute(
  3441. """
  3442. INSERT INTO orders (id, user_id, plan_id, amount_cents, status, created_at, paid_at, plan_snapshot_json)
  3443. VALUES (?, ?, ?, ?, ?, ?, ?, ?)
  3444. """,
  3445. (
  3446. order_id,
  3447. user_row["id"],
  3448. plan["id"],
  3449. plan["price_cents"],
  3450. status,
  3451. created_at,
  3452. paid_at,
  3453. json.dumps(snapshot, ensure_ascii=False),
  3454. ),
  3455. )
  3456. if status == "PAID":
  3457. extend_vip(int(user_row["id"]), int(snapshot["durationDays"]))
  3458. after_row = fetch_one("SELECT * FROM orders WHERE id = ?", (order_id,))
  3459. audit(
  3460. "ADMIN",
  3461. admin["id"],
  3462. "ORDER_CREATE",
  3463. "Order",
  3464. order_id,
  3465. None,
  3466. dict(after_row) if after_row is not None else None,
  3467. )
  3468. return jsonify({"ok": True, "id": order_id})
  3469. @app.put("/admin/orders/<order_id>")
  3470. def api_admin_update_order(order_id: str) -> Response:
  3471. admin = require_admin()
  3472. before_row = fetch_one("SELECT * FROM orders WHERE id = ?", (order_id,))
  3473. if before_row is None:
  3474. abort(404)
  3475. payload = request.get_json(silent=True) or {}
  3476. status = (payload.get("status") or "").strip().upper()
  3477. if status not in {"PENDING", "PAID", "CLOSED", "FAILED"}:
  3478. return jsonify({"error": "invalid_status"}), 400
  3479. before_status = before_row["status"]
  3480. if before_status == "PAID" and status != "PAID":
  3481. return jsonify({"error": "cannot_change_paid_order"}), 409
  3482. if before_status == "CLOSED" and status != "CLOSED":
  3483. return jsonify({"error": "cannot_change_closed_order"}), 409
  3484. allowed = False
  3485. if before_status == status:
  3486. allowed = True
  3487. elif before_status == "PENDING" and status in {"PAID", "CLOSED", "FAILED"}:
  3488. allowed = True
  3489. elif before_status == "FAILED" and status in {"PAID", "CLOSED"}:
  3490. allowed = True
  3491. if not allowed:
  3492. return jsonify({"error": "invalid_transition"}), 409
  3493. paid_at = before_row["paid_at"]
  3494. if status == "PAID":
  3495. if before_status != "PAID":
  3496. paid_at = isoformat(utcnow())
  3497. snapshot = json.loads(before_row["plan_snapshot_json"])
  3498. extend_vip(int(before_row["user_id"]), int(snapshot.get("durationDays") or 0))
  3499. else:
  3500. paid_at = None
  3501. execute("UPDATE orders SET status = ?, paid_at = ? WHERE id = ?", (status, paid_at, order_id))
  3502. after_row = fetch_one("SELECT * FROM orders WHERE id = ?", (order_id,))
  3503. audit(
  3504. "ADMIN",
  3505. admin["id"],
  3506. "ORDER_UPDATE",
  3507. "Order",
  3508. order_id,
  3509. dict(before_row),
  3510. dict(after_row) if after_row is not None else None,
  3511. )
  3512. return jsonify({"ok": True})
  3513. @app.delete("/admin/orders/<order_id>")
  3514. def api_admin_delete_order(order_id: str) -> Response:
  3515. admin = require_admin()
  3516. before_row = fetch_one("SELECT * FROM orders WHERE id = ?", (order_id,))
  3517. if before_row is None:
  3518. abort(404)
  3519. if before_row["status"] == "PAID":
  3520. return jsonify({"error": "cannot_delete_paid_order"}), 409
  3521. execute("DELETE FROM orders WHERE id = ?", (order_id,))
  3522. audit("ADMIN", admin["id"], "ORDER_DELETE", "Order", order_id, dict(before_row), None)
  3523. return jsonify({"ok": True})
  3524. @app.get("/admin/users")
  3525. def api_admin_users() -> Response:
  3526. _ = require_admin()
  3527. q = (request.args.get("q") or "").strip()
  3528. status = (request.args.get("status") or "").strip().upper()
  3529. vip = (request.args.get("vip") or "").strip().upper()
  3530. page = max(1, parse_int(request.args.get("page"), 1))
  3531. page_size = min(100, max(1, parse_int(request.args.get("pageSize"), 20)))
  3532. now_dt = utcnow()
  3533. now_iso = isoformat(now_dt)
  3534. where = []
  3535. params: list[Any] = []
  3536. if q:
  3537. where.append("(phone LIKE ?)")
  3538. params.append(f"%{q}%")
  3539. if status in {"ACTIVE", "DISABLED"}:
  3540. where.append("status = ?")
  3541. params.append(status)
  3542. if vip in {"VIP", "ACTIVE"}:
  3543. where.append("(vip_expire_at IS NOT NULL AND vip_expire_at > ?)")
  3544. params.append(now_iso)
  3545. elif vip in {"NONVIP", "NOVIP", "INACTIVE"}:
  3546. where.append("(vip_expire_at IS NULL OR vip_expire_at <= ?)")
  3547. params.append(now_iso)
  3548. where_sql = f"WHERE {' AND '.join(where)}" if where else ""
  3549. total_row = fetch_one(f"SELECT COUNT(1) AS cnt FROM users {where_sql}", tuple(params))
  3550. total = int(total_row["cnt"]) if total_row is not None else 0
  3551. offset = (page - 1) * page_size
  3552. rows = fetch_all(
  3553. f"""
  3554. SELECT *
  3555. FROM users
  3556. {where_sql}
  3557. ORDER BY created_at DESC, id DESC
  3558. LIMIT ? OFFSET ?
  3559. """,
  3560. tuple(params + [page_size, offset]),
  3561. )
  3562. items = []
  3563. for row in rows:
  3564. expire_dt = parse_datetime(row["vip_expire_at"]) if row["vip_expire_at"] else None
  3565. vip_active = bool(expire_dt is not None and expire_dt > now_dt)
  3566. vip_remaining_days = None
  3567. if vip_active and expire_dt is not None:
  3568. seconds = (expire_dt - now_dt).total_seconds()
  3569. vip_remaining_days = max(0, int((seconds + 86399) // 86400))
  3570. items.append(
  3571. {
  3572. "id": row["id"],
  3573. "phone": row["phone"],
  3574. "status": row["status"],
  3575. "vipActive": vip_active,
  3576. "vipRemainingDays": vip_remaining_days,
  3577. "vipExpireAt": row["vip_expire_at"],
  3578. "createdAt": row["created_at"],
  3579. }
  3580. )
  3581. return jsonify({"items": items, "total": total, "page": page, "pageSize": page_size})
  3582. @app.get("/admin/download-logs")
  3583. def api_admin_download_logs() -> Response:
  3584. _ = require_admin()
  3585. q = (request.args.get("q") or "").strip()
  3586. typ = (request.args.get("type") or "").strip().upper()
  3587. state = (request.args.get("state") or "").strip().upper()
  3588. page = max(1, parse_int(request.args.get("page"), 1))
  3589. page_size = min(100, max(1, parse_int(request.args.get("pageSize"), 20)))
  3590. where = []
  3591. params: list[Any] = []
  3592. if q:
  3593. like = f"%{q}%"
  3594. where.append("(u.phone LIKE ? OR dl.resource_title_snapshot LIKE ? OR dl.ref_snapshot LIKE ? OR dl.ip LIKE ?)")
  3595. params.extend([like, like, like, like])
  3596. if typ in {"FREE", "VIP"}:
  3597. where.append("dl.resource_type_snapshot = ?")
  3598. params.append(typ)
  3599. if state == "DELETED":
  3600. where.append("r.id IS NULL")
  3601. elif state == "OFFLINE":
  3602. where.append("(r.id IS NOT NULL AND r.status != 'ONLINE')")
  3603. elif state == "ONLINE":
  3604. where.append("r.status = 'ONLINE'")
  3605. where_sql = f"WHERE {' AND '.join(where)}" if where else ""
  3606. total_row = fetch_one(
  3607. f"""
  3608. SELECT COUNT(1) AS cnt
  3609. FROM download_logs dl
  3610. JOIN users u ON u.id = dl.user_id
  3611. LEFT JOIN resources r ON r.id = dl.resource_id
  3612. {where_sql}
  3613. """,
  3614. tuple(params),
  3615. )
  3616. total = int(total_row["cnt"]) if total_row is not None else 0
  3617. offset = (page - 1) * page_size
  3618. rows = fetch_all(
  3619. f"""
  3620. SELECT
  3621. dl.id,
  3622. dl.user_id,
  3623. u.phone AS user_phone,
  3624. dl.resource_id,
  3625. dl.resource_title_snapshot,
  3626. dl.resource_type_snapshot,
  3627. dl.ref_snapshot,
  3628. dl.downloaded_at,
  3629. dl.ip,
  3630. dl.user_agent,
  3631. r.id AS r_id,
  3632. r.status AS r_status,
  3633. r.type AS r_type
  3634. FROM download_logs dl
  3635. JOIN users u ON u.id = dl.user_id
  3636. LEFT JOIN resources r ON r.id = dl.resource_id
  3637. {where_sql}
  3638. ORDER BY dl.downloaded_at DESC, dl.id DESC
  3639. LIMIT ? OFFSET ?
  3640. """,
  3641. tuple(params + [page_size, offset]),
  3642. )
  3643. items = []
  3644. for row in rows:
  3645. if row["r_id"] is None:
  3646. resource_state = "DELETED"
  3647. elif row["r_status"] != "ONLINE":
  3648. resource_state = "OFFLINE"
  3649. else:
  3650. resource_state = "ONLINE"
  3651. items.append(
  3652. {
  3653. "id": row["id"],
  3654. "userId": row["user_id"],
  3655. "userPhone": row["user_phone"],
  3656. "resourceId": row["resource_id"],
  3657. "resourceTitle": row["resource_title_snapshot"],
  3658. "resourceType": row["resource_type_snapshot"],
  3659. "currentResourceType": row["r_type"] if row["r_id"] is not None else None,
  3660. "ref": row["ref_snapshot"],
  3661. "downloadedAt": row["downloaded_at"],
  3662. "resourceState": resource_state,
  3663. "ip": row["ip"],
  3664. "userAgent": row["user_agent"],
  3665. }
  3666. )
  3667. return jsonify({"items": items, "total": total, "page": page, "pageSize": page_size})
  3668. @app.put("/admin/users/<int:user_id>")
  3669. def api_admin_update_user(user_id: int) -> Response:
  3670. admin = require_admin()
  3671. before_row = fetch_one("SELECT * FROM users WHERE id = ?", (user_id,))
  3672. if before_row is None:
  3673. abort(404)
  3674. payload = request.get_json(silent=True) or {}
  3675. status = (payload.get("status") or before_row["status"]).strip().upper()
  3676. if status not in {"ACTIVE", "DISABLED"}:
  3677. return jsonify({"error": "invalid_status"}), 400
  3678. execute("UPDATE users SET status = ? WHERE id = ?", (status, user_id))
  3679. after_row = fetch_one("SELECT * FROM users WHERE id = ?", (user_id,))
  3680. audit(
  3681. "ADMIN",
  3682. admin["id"],
  3683. "USER_UPDATE",
  3684. "User",
  3685. str(user_id),
  3686. dict(before_row),
  3687. dict(after_row) if after_row is not None else None,
  3688. )
  3689. return jsonify({"ok": True})
  3690. @app.post("/admin/users/<int:user_id>/password-reset")
  3691. def api_admin_reset_user_password(user_id: int) -> Response:
  3692. admin = require_admin()
  3693. before_row = fetch_one("SELECT * FROM users WHERE id = ?", (user_id,))
  3694. if before_row is None:
  3695. abort(404)
  3696. payload = request.get_json(silent=True) or {}
  3697. password = payload.get("password") or ""
  3698. if not password:
  3699. return jsonify({"error": "password_required"}), 400
  3700. if len(password) < 6:
  3701. return jsonify({"error": "password_too_short"}), 400
  3702. execute("UPDATE users SET password_hash = ? WHERE id = ?", (generate_password_hash(password), user_id))
  3703. after_row = fetch_one("SELECT * FROM users WHERE id = ?", (user_id,))
  3704. audit(
  3705. "ADMIN",
  3706. admin["id"],
  3707. "USER_PASSWORD_RESET",
  3708. "User",
  3709. str(user_id),
  3710. dict(before_row),
  3711. dict(after_row) if after_row is not None else None,
  3712. )
  3713. return jsonify({"ok": True})
  3714. @app.post("/admin/users/<int:user_id>/vip-adjust")
  3715. def api_admin_vip_adjust(user_id: int) -> Response:
  3716. admin = require_admin()
  3717. payload = request.get_json(silent=True) or {}
  3718. duration_days = parse_int(payload.get("addDays"), 0)
  3719. if duration_days == 0:
  3720. return jsonify({"error": "addDays_required"}), 400
  3721. before_row = fetch_one("SELECT * FROM users WHERE id = ?", (user_id,))
  3722. if before_row is None:
  3723. abort(404)
  3724. extend_vip(user_id, duration_days)
  3725. after_row = fetch_one("SELECT * FROM users WHERE id = ?", (user_id,))
  3726. before_expire = before_row["vip_expire_at"] or ""
  3727. after_expire = after_row["vip_expire_at"] if after_row is not None else ""
  3728. delta = f"{duration_days:+d} 天"
  3729. create_user_message(
  3730. user_id,
  3731. "会员期限变更",
  3732. "\n".join(
  3733. [
  3734. f"管理员已调整你的会员天数:{delta}",
  3735. f"调整前到期:{before_expire or '无'}",
  3736. f"调整后到期:{after_expire or '无'}",
  3737. ]
  3738. ),
  3739. sender_type="ADMIN",
  3740. sender_id=int(admin["id"]),
  3741. )
  3742. audit(
  3743. "ADMIN",
  3744. admin["id"],
  3745. "VIP_ADJUST",
  3746. "User",
  3747. str(user_id),
  3748. dict(before_row),
  3749. dict(after_row) if after_row is not None else None,
  3750. )
  3751. return jsonify({"ok": True})
  3752. @app.get("/admin/messages")
  3753. def api_admin_messages() -> Response:
  3754. admin = require_admin()
  3755. _ = admin
  3756. q = (request.args.get("q") or "").strip()
  3757. sender_type = (request.args.get("senderType") or "").strip().upper()
  3758. read_raw = (request.args.get("read") or "").strip().lower()
  3759. user_id = parse_int(request.args.get("user_id"), 0)
  3760. page = max(parse_int(request.args.get("page"), 1), 1)
  3761. page_size = min(max(parse_int(request.args.get("pageSize"), 20), 1), 50)
  3762. offset = (page - 1) * page_size
  3763. where: list[str] = []
  3764. params: list[Any] = []
  3765. if user_id > 0:
  3766. where.append("m.user_id = ?")
  3767. params.append(user_id)
  3768. if q:
  3769. where.append("(u.phone LIKE ? OR m.title LIKE ? OR m.content LIKE ?)")
  3770. like = f"%{q}%"
  3771. params.extend([like, like, like])
  3772. if sender_type in {"SYSTEM", "ADMIN"}:
  3773. where.append("m.sender_type = ?")
  3774. params.append(sender_type)
  3775. if read_raw in {"1", "true", "yes", "on", "read"}:
  3776. where.append("m.read_at IS NOT NULL")
  3777. elif read_raw in {"0", "false", "no", "off", "unread"}:
  3778. where.append("m.read_at IS NULL")
  3779. where_sql = f"WHERE {' AND '.join(where)}" if where else ""
  3780. total_row = fetch_one(
  3781. f"""
  3782. SELECT COUNT(1) AS cnt
  3783. FROM user_messages m
  3784. JOIN users u ON u.id = m.user_id
  3785. {where_sql}
  3786. """,
  3787. tuple(params),
  3788. )
  3789. total = int(total_row["cnt"] if total_row is not None else 0)
  3790. rows = fetch_all(
  3791. f"""
  3792. SELECT
  3793. m.id, m.user_id, m.title, m.content, m.created_at, m.read_at, m.sender_type, m.sender_id,
  3794. u.phone AS user_phone
  3795. FROM user_messages m
  3796. JOIN users u ON u.id = m.user_id
  3797. {where_sql}
  3798. ORDER BY m.created_at DESC, m.id DESC
  3799. LIMIT ? OFFSET ?
  3800. """,
  3801. tuple(params + [page_size, offset]),
  3802. )
  3803. items = []
  3804. for row in rows:
  3805. items.append(
  3806. {
  3807. "id": row["id"],
  3808. "userId": row["user_id"],
  3809. "userPhone": row["user_phone"],
  3810. "title": row["title"],
  3811. "content": row["content"],
  3812. "createdAt": row["created_at"],
  3813. "readAt": row["read_at"],
  3814. "read": bool(row["read_at"]),
  3815. "senderType": row["sender_type"] or "SYSTEM",
  3816. "senderId": row["sender_id"],
  3817. }
  3818. )
  3819. return jsonify({"items": items, "total": total, "page": page, "pageSize": page_size})
  3820. @app.post("/admin/messages/send")
  3821. def api_admin_message_send() -> Response:
  3822. admin = require_admin()
  3823. payload = request.get_json(silent=True) or {}
  3824. user_id = parse_int(payload.get("userId"), 0)
  3825. phone = (payload.get("phone") or "").strip()
  3826. title = (payload.get("title") or "").strip()
  3827. content = (payload.get("content") or "").strip()
  3828. if not title or not content:
  3829. return jsonify({"error": "title_and_content_required"}), 400
  3830. if user_id <= 0 and not phone:
  3831. return jsonify({"error": "user_required"}), 400
  3832. if user_id <= 0 and phone:
  3833. row = fetch_one("SELECT id FROM users WHERE phone = ?", (phone,))
  3834. if row is None:
  3835. return jsonify({"error": "user_not_found"}), 404
  3836. user_id = int(row["id"])
  3837. msg_id = create_user_message(user_id, title, content, sender_type="ADMIN", sender_id=int(admin["id"]))
  3838. audit(
  3839. "ADMIN",
  3840. admin["id"],
  3841. "MESSAGE_SEND",
  3842. "User",
  3843. str(user_id),
  3844. None,
  3845. {"title": title[:120], "contentLen": len(content)},
  3846. )
  3847. return jsonify({"ok": True, "id": msg_id})
  3848. @app.post("/admin/messages/broadcast")
  3849. def api_admin_message_broadcast() -> Response:
  3850. admin = require_admin()
  3851. payload = request.get_json(silent=True) or {}
  3852. audience = (payload.get("audience") or "ALL").strip().upper()
  3853. title = (payload.get("title") or "").strip()
  3854. content = (payload.get("content") or "").strip()
  3855. if not title or not content:
  3856. return jsonify({"error": "title_and_content_required"}), 400
  3857. now_iso = isoformat(utcnow()) or ""
  3858. where = ["status = 'ACTIVE'"]
  3859. params: list[Any] = []
  3860. if audience == "VIP":
  3861. where.append("vip_expire_at IS NOT NULL AND vip_expire_at > ?")
  3862. params.append(now_iso)
  3863. elif audience == "NONVIP":
  3864. where.append("(vip_expire_at IS NULL OR vip_expire_at <= ?)")
  3865. params.append(now_iso)
  3866. where_sql = f"WHERE {' AND '.join(where)}"
  3867. rows = fetch_all(f"SELECT id FROM users {where_sql}", tuple(params))
  3868. user_ids = [int(r["id"]) for r in rows]
  3869. for uid in user_ids:
  3870. create_user_message(uid, title, content, sender_type="ADMIN", sender_id=int(admin["id"]))
  3871. audit(
  3872. "ADMIN",
  3873. admin["id"],
  3874. "MESSAGE_BROADCAST",
  3875. "Users",
  3876. audience,
  3877. None,
  3878. {"title": title[:120], "contentLen": len(content), "count": len(user_ids)},
  3879. )
  3880. return jsonify({"ok": True, "count": len(user_ids)})
  3881. @app.delete("/admin/messages/<int:message_id>")
  3882. def api_admin_message_delete(message_id: int) -> Response:
  3883. admin = require_admin()
  3884. before = fetch_one("SELECT * FROM user_messages WHERE id = ?", (message_id,))
  3885. if before is None:
  3886. abort(404)
  3887. execute("DELETE FROM user_messages WHERE id = ?", (message_id,))
  3888. audit(
  3889. "ADMIN",
  3890. admin["id"],
  3891. "MESSAGE_DELETE",
  3892. "UserMessage",
  3893. str(message_id),
  3894. dict(before),
  3895. None,
  3896. )
  3897. return jsonify({"ok": True})