@lampdotdev/lamp 0.0.0-stage → 0.36.2-nightly.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +397 -2
- package/Resources/Devices/roku.json +410 -0
- package/Resources/Devices/xbox-one.json +367 -0
- package/Resources/GameKit/NOTICE.md +63 -0
- package/Resources/GameKit/apple/LampPage.swift +1813 -0
- package/Resources/GameKit/apple/LampPlayer.swift +46 -0
- package/Resources/GameKit/apple/LampWebApp.swift +63 -0
- package/Resources/GameKit/apple/LampWebGame.swift +87 -0
- package/Resources/GameKit/apple/Native.swift +12 -0
- package/Resources/GameKit/build.sh +55 -0
- package/Resources/GameKit/lamp_game.h +116 -0
- package/Resources/GameKit/lamp_host.h +19 -0
- package/Resources/GameKit/lamp_kit.cpp +61 -0
- package/Resources/GameKit/lamp_sound.cpp +455 -0
- package/Resources/GameKit/lamp_xbox.cpp +374 -0
- package/Resources/GameKit/libraries/HandmadeMath.h +3940 -0
- package/Resources/GameKit/libraries/cgltf.h +7240 -0
- package/Resources/GameKit/libraries/dr_mp3.h +5430 -0
- package/Resources/GameKit/libraries/dr_wav.h +9209 -0
- package/Resources/GameKit/libraries/sokol_debugtext.h +4754 -0
- package/Resources/GameKit/libraries/sokol_gfx.h +29558 -0
- package/Resources/GameKit/libraries/sokol_gl.h +4538 -0
- package/Resources/GameKit/libraries/sokol_log.h +335 -0
- package/Resources/GameKit/libraries/sokol_shape.h +1531 -0
- package/Resources/GameKit/libraries/stb_image.h +7988 -0
- package/Resources/GameKit/libraries/stb_vorbis.c +5584 -0
- package/Resources/GameKit/page/lamp-live.js +672 -0
- package/Resources/GameKit/scaffold/Game/game.cpp +142 -0
- package/Resources/GameKit/scaffold/Game/scene.glsl +49 -0
- package/Resources/GameKit/scaffold-web/Web/game.js +31 -0
- package/Resources/GameKit/scaffold-web/Web/index.html +13 -0
- package/Resources/GameKit/scaffold-web/Web/making.js +33 -0
- package/Resources/GameKit/scaffold-web/Web/world.js +33 -0
- package/Resources/GameKit/web/lamp-game.js +3042 -0
- package/Resources/GameKit/web/lamp-phaser.js +128 -0
- package/Resources/WebHost/App.exe +0 -0
- package/Resources/WebHost/Microsoft.Web.WebView2.Core.dll +0 -0
- package/Resources/WebHost/WebView2-LICENSE.txt +27 -0
- package/Resources/WebHost/lamp_controllers.h +1506 -0
- package/Resources/WebHost/lamp_web_host.cpp +1173 -0
- package/Resources/WebLibraries/earlier.txt +318 -0
- package/Resources/WebLibraries/phaser/LICENSE.md +20 -0
- package/Resources/WebLibraries/phaser/NOTICE.md +2 -0
- package/Resources/WebLibraries/phaser/phaser.esm.min.js +1 -0
- package/Resources/WebLibraries/qrcode/LICENSE +21 -0
- package/Resources/WebLibraries/qrcode/NOTICE.md +4 -0
- package/Resources/WebLibraries/qrcode/qrcode.js +2297 -0
- package/Resources/WebLibraries/rapier/LICENSE +201 -0
- package/Resources/WebLibraries/rapier/NOTICE.md +3 -0
- package/Resources/WebLibraries/rapier/rapier.js +2 -0
- package/Resources/WebLibraries/three/LICENSE +21 -0
- package/Resources/WebLibraries/three/NOTICE.md +4 -0
- package/Resources/WebLibraries/three/addons/animation/AnimationClipCreator.js +168 -0
- package/Resources/WebLibraries/three/addons/animation/CCDIKSolver.js +607 -0
- package/Resources/WebLibraries/three/addons/controls/ArcballControls.js +3543 -0
- package/Resources/WebLibraries/three/addons/controls/DragControls.js +452 -0
- package/Resources/WebLibraries/three/addons/controls/FirstPersonControls.js +512 -0
- package/Resources/WebLibraries/three/addons/controls/FlyControls.js +384 -0
- package/Resources/WebLibraries/three/addons/controls/MapControls.js +116 -0
- package/Resources/WebLibraries/three/addons/controls/OrbitControls.js +1985 -0
- package/Resources/WebLibraries/three/addons/controls/PointerLockControls.js +264 -0
- package/Resources/WebLibraries/three/addons/controls/TrackballControls.js +1106 -0
- package/Resources/WebLibraries/three/addons/controls/TransformControls.js +2003 -0
- package/Resources/WebLibraries/three/addons/curves/CurveExtras.js +694 -0
- package/Resources/WebLibraries/three/addons/curves/NURBSCurve.js +169 -0
- package/Resources/WebLibraries/three/addons/curves/NURBSSurface.js +98 -0
- package/Resources/WebLibraries/three/addons/curves/NURBSUtils.js +532 -0
- package/Resources/WebLibraries/three/addons/curves/NURBSVolume.js +82 -0
- package/Resources/WebLibraries/three/addons/effects/AnaglyphEffect.js +274 -0
- package/Resources/WebLibraries/three/addons/effects/AsciiEffect.js +323 -0
- package/Resources/WebLibraries/three/addons/effects/OutlineEffect.js +489 -0
- package/Resources/WebLibraries/three/addons/effects/ParallaxBarrierEffect.js +155 -0
- package/Resources/WebLibraries/three/addons/effects/StereoEffect.js +91 -0
- package/Resources/WebLibraries/three/addons/environments/ColorEnvironment.js +59 -0
- package/Resources/WebLibraries/three/addons/environments/DebugEnvironment.js +102 -0
- package/Resources/WebLibraries/three/addons/environments/RoomEnvironment.js +185 -0
- package/Resources/WebLibraries/three/addons/geometries/BoxLineGeometry.js +92 -0
- package/Resources/WebLibraries/three/addons/geometries/ConvexGeometry.js +72 -0
- package/Resources/WebLibraries/three/addons/geometries/DecalGeometry.js +420 -0
- package/Resources/WebLibraries/three/addons/geometries/LoftGeometry.js +355 -0
- package/Resources/WebLibraries/three/addons/geometries/ParametricFunctions.js +100 -0
- package/Resources/WebLibraries/three/addons/geometries/ParametricGeometry.js +172 -0
- package/Resources/WebLibraries/three/addons/geometries/RoundedBoxGeometry.js +216 -0
- package/Resources/WebLibraries/three/addons/geometries/TeapotGeometry.js +689 -0
- package/Resources/WebLibraries/three/addons/geometries/TextGeometry.js +102 -0
- package/Resources/WebLibraries/three/addons/helpers/AnimationPathHelper.js +302 -0
- package/Resources/WebLibraries/three/addons/helpers/LightProbeGridHelper.js +166 -0
- package/Resources/WebLibraries/three/addons/helpers/LightProbeGridHelperWebGL.js +221 -0
- package/Resources/WebLibraries/three/addons/helpers/LightProbeHelper.js +166 -0
- package/Resources/WebLibraries/three/addons/helpers/LightProbeHelperGPU.js +102 -0
- package/Resources/WebLibraries/three/addons/helpers/OctreeHelper.js +109 -0
- package/Resources/WebLibraries/three/addons/helpers/PositionalAudioHelper.js +169 -0
- package/Resources/WebLibraries/three/addons/helpers/RapierHelper.js +59 -0
- package/Resources/WebLibraries/three/addons/helpers/RectAreaLightHelper.js +118 -0
- package/Resources/WebLibraries/three/addons/helpers/TextureHelper.js +265 -0
- package/Resources/WebLibraries/three/addons/helpers/TextureHelperGPU.js +214 -0
- package/Resources/WebLibraries/three/addons/helpers/VertexNormalsHelper.js +155 -0
- package/Resources/WebLibraries/three/addons/helpers/VertexTangentsHelper.js +133 -0
- package/Resources/WebLibraries/three/addons/helpers/ViewHelper.js +529 -0
- package/Resources/WebLibraries/three/addons/interactive/HTMLMesh.js +617 -0
- package/Resources/WebLibraries/three/addons/interactive/InteractiveGroup.js +224 -0
- package/Resources/WebLibraries/three/addons/interactive/SelectionBox.js +324 -0
- package/Resources/WebLibraries/three/addons/interactive/SelectionHelper.js +150 -0
- package/Resources/WebLibraries/three/addons/libs/fflate.module.js +2699 -0
- package/Resources/WebLibraries/three/addons/libs/lil-gui.module.min.js +8 -0
- package/Resources/WebLibraries/three/addons/libs/meshopt_decoder.module.js +196 -0
- package/Resources/WebLibraries/three/addons/libs/potpack.module.js +125 -0
- package/Resources/WebLibraries/three/addons/libs/stats.module.js +167 -0
- package/Resources/WebLibraries/three/addons/libs/tween.module.js +876 -0
- package/Resources/WebLibraries/three/addons/lighting/ClusteredLighting.js +153 -0
- package/Resources/WebLibraries/three/addons/lighting/DynamicLighting.js +82 -0
- package/Resources/WebLibraries/three/addons/lighting/LightMapper.js +295 -0
- package/Resources/WebLibraries/three/addons/lighting/LightProbeGrid.js +923 -0
- package/Resources/WebLibraries/three/addons/lighting/LightProbeGridUtils.js +107 -0
- package/Resources/WebLibraries/three/addons/lighting/LightProbeGridWebGL.js +846 -0
- package/Resources/WebLibraries/three/addons/lighting/vxgi/VXGIConeTracer.js +243 -0
- package/Resources/WebLibraries/three/addons/lighting/vxgi/VXGIIrradiance.js +43 -0
- package/Resources/WebLibraries/three/addons/lighting/vxgi/VXGINode.js +617 -0
- package/Resources/WebLibraries/three/addons/lighting/vxgi/VXGISceneCollector.js +793 -0
- package/Resources/WebLibraries/three/addons/lighting/vxgi/VXGIVolume.js +1440 -0
- package/Resources/WebLibraries/three/addons/lights/LightProbeGenerator.js +337 -0
- package/Resources/WebLibraries/three/addons/lights/RectAreaLightTexturesLib.js +127 -0
- package/Resources/WebLibraries/three/addons/lights/RectAreaLightUniformsLib.js +40 -0
- package/Resources/WebLibraries/three/addons/lights/SunLight.js +100 -0
- package/Resources/WebLibraries/three/addons/lights/SunLightNode.js +56 -0
- package/Resources/WebLibraries/three/addons/lights/SunLightShadow.js +319 -0
- package/Resources/WebLibraries/three/addons/lights/SunShadowNode.js +312 -0
- package/Resources/WebLibraries/three/addons/lines/Line2.js +56 -0
- package/Resources/WebLibraries/three/addons/lines/LineGeometry.js +157 -0
- package/Resources/WebLibraries/three/addons/lines/LineMaterial.js +742 -0
- package/Resources/WebLibraries/three/addons/lines/LineSegments2.js +434 -0
- package/Resources/WebLibraries/three/addons/lines/LineSegmentsGeometry.js +298 -0
- package/Resources/WebLibraries/three/addons/lines/Wireframe.js +108 -0
- package/Resources/WebLibraries/three/addons/lines/WireframeGeometry2.js +49 -0
- package/Resources/WebLibraries/three/addons/lines/webgpu/Line2.js +46 -0
- package/Resources/WebLibraries/three/addons/lines/webgpu/LineSegments2.js +419 -0
- package/Resources/WebLibraries/three/addons/lines/webgpu/Wireframe.js +86 -0
- package/Resources/WebLibraries/three/addons/loaders/EXRLoader.js +3411 -0
- package/Resources/WebLibraries/three/addons/loaders/FBXLoader.js +4660 -0
- package/Resources/WebLibraries/three/addons/loaders/FontLoader.js +262 -0
- package/Resources/WebLibraries/three/addons/loaders/GLTFLoader.js +5006 -0
- package/Resources/WebLibraries/three/addons/loaders/HDRLoader.js +463 -0
- package/Resources/WebLibraries/three/addons/loaders/MTLLoader.js +601 -0
- package/Resources/WebLibraries/three/addons/loaders/OBJLoader.js +961 -0
- package/Resources/WebLibraries/three/addons/loaders/PLYLoader.js +992 -0
- package/Resources/WebLibraries/three/addons/loaders/RGBELoader.js +18 -0
- package/Resources/WebLibraries/three/addons/loaders/STLLoader.js +423 -0
- package/Resources/WebLibraries/three/addons/loaders/SVGLoader.js +3297 -0
- package/Resources/WebLibraries/three/addons/loaders/TGALoader.js +539 -0
- package/Resources/WebLibraries/three/addons/loaders/UltraHDRLoader.js +757 -0
- package/Resources/WebLibraries/three/addons/math/Capsule.js +159 -0
- package/Resources/WebLibraries/three/addons/math/ColorConverter.js +62 -0
- package/Resources/WebLibraries/three/addons/math/ColorSpaces.js +147 -0
- package/Resources/WebLibraries/three/addons/math/ConvexHull.js +1695 -0
- package/Resources/WebLibraries/three/addons/math/ImprovedNoise.js +88 -0
- package/Resources/WebLibraries/three/addons/math/Lut.js +317 -0
- package/Resources/WebLibraries/three/addons/math/MeshSurfaceSampler.js +315 -0
- package/Resources/WebLibraries/three/addons/math/OBB.js +535 -0
- package/Resources/WebLibraries/three/addons/math/Octree.js +825 -0
- package/Resources/WebLibraries/three/addons/math/SimplexNoise.js +470 -0
- package/Resources/WebLibraries/three/addons/misc/ConvexObjectBreaker.js +539 -0
- package/Resources/WebLibraries/three/addons/misc/GPUComputationRenderer.js +508 -0
- package/Resources/WebLibraries/three/addons/misc/Gyroscope.js +78 -0
- package/Resources/WebLibraries/three/addons/misc/MorphAnimMesh.js +119 -0
- package/Resources/WebLibraries/three/addons/misc/MorphBlendMesh.js +425 -0
- package/Resources/WebLibraries/three/addons/misc/ProgressiveLightMap.js +370 -0
- package/Resources/WebLibraries/three/addons/misc/ProgressiveLightMapGPU.js +322 -0
- package/Resources/WebLibraries/three/addons/misc/RollerCoaster.js +661 -0
- package/Resources/WebLibraries/three/addons/misc/Sculptor.js +1679 -0
- package/Resources/WebLibraries/three/addons/misc/SculptorMesh.js +1504 -0
- package/Resources/WebLibraries/three/addons/misc/SculptorTools.js +1180 -0
- package/Resources/WebLibraries/three/addons/misc/SculptorUtils.js +345 -0
- package/Resources/WebLibraries/three/addons/misc/TileCreasedNormalsPlugin.js +270 -0
- package/Resources/WebLibraries/three/addons/misc/TubePainter.js +611 -0
- package/Resources/WebLibraries/three/addons/misc/Volume.js +520 -0
- package/Resources/WebLibraries/three/addons/misc/VolumeSlice.js +275 -0
- package/Resources/WebLibraries/three/addons/modifiers/CurveModifier.js +374 -0
- package/Resources/WebLibraries/three/addons/modifiers/CurveModifierGPU.js +256 -0
- package/Resources/WebLibraries/three/addons/modifiers/EdgeSplitModifier.js +299 -0
- package/Resources/WebLibraries/three/addons/modifiers/TessellateModifier.js +340 -0
- package/Resources/WebLibraries/three/addons/objects/GroundedSkybox.js +69 -0
- package/Resources/WebLibraries/three/addons/objects/Lensflare.js +489 -0
- package/Resources/WebLibraries/three/addons/objects/LensflareMesh.js +376 -0
- package/Resources/WebLibraries/three/addons/objects/MarchingCubes.js +1261 -0
- package/Resources/WebLibraries/three/addons/objects/Reflector.js +390 -0
- package/Resources/WebLibraries/three/addons/objects/ReflectorForSSRPass.js +393 -0
- package/Resources/WebLibraries/three/addons/objects/Refractor.js +389 -0
- package/Resources/WebLibraries/three/addons/objects/ShadowMesh.js +130 -0
- package/Resources/WebLibraries/three/addons/objects/Sky.js +342 -0
- package/Resources/WebLibraries/three/addons/objects/SkyMesh.js +403 -0
- package/Resources/WebLibraries/three/addons/objects/Water.js +374 -0
- package/Resources/WebLibraries/three/addons/objects/Water2.js +403 -0
- package/Resources/WebLibraries/three/addons/objects/Water2Mesh.js +199 -0
- package/Resources/WebLibraries/three/addons/objects/WaterMesh.js +194 -0
- package/Resources/WebLibraries/three/addons/postprocessing/AfterimagePass.js +187 -0
- package/Resources/WebLibraries/three/addons/postprocessing/BloomPass.js +274 -0
- package/Resources/WebLibraries/three/addons/postprocessing/BokehPass.js +218 -0
- package/Resources/WebLibraries/three/addons/postprocessing/ClearPass.js +97 -0
- package/Resources/WebLibraries/three/addons/postprocessing/CubeTexturePass.js +146 -0
- package/Resources/WebLibraries/three/addons/postprocessing/DotScreenPass.js +114 -0
- package/Resources/WebLibraries/three/addons/postprocessing/EffectComposer.js +365 -0
- package/Resources/WebLibraries/three/addons/postprocessing/FXAAPass.js +40 -0
- package/Resources/WebLibraries/three/addons/postprocessing/FilmPass.js +113 -0
- package/Resources/WebLibraries/three/addons/postprocessing/GTAOPass.js +735 -0
- package/Resources/WebLibraries/three/addons/postprocessing/GlitchPass.js +177 -0
- package/Resources/WebLibraries/three/addons/postprocessing/HalftonePass.js +134 -0
- package/Resources/WebLibraries/three/addons/postprocessing/LUTPass.js +138 -0
- package/Resources/WebLibraries/three/addons/postprocessing/MaskPass.js +195 -0
- package/Resources/WebLibraries/three/addons/postprocessing/OutlinePass.js +776 -0
- package/Resources/WebLibraries/three/addons/postprocessing/OutputPass.js +148 -0
- package/Resources/WebLibraries/three/addons/postprocessing/Pass.js +191 -0
- package/Resources/WebLibraries/three/addons/postprocessing/RenderPass.js +193 -0
- package/Resources/WebLibraries/three/addons/postprocessing/RenderPixelatedPass.js +314 -0
- package/Resources/WebLibraries/three/addons/postprocessing/RenderTransitionPass.js +267 -0
- package/Resources/WebLibraries/three/addons/postprocessing/SAOPass.js +407 -0
- package/Resources/WebLibraries/three/addons/postprocessing/SMAAPass.js +230 -0
- package/Resources/WebLibraries/three/addons/postprocessing/SSAARenderPass.js +313 -0
- package/Resources/WebLibraries/three/addons/postprocessing/SSAOPass.js +527 -0
- package/Resources/WebLibraries/three/addons/postprocessing/SSRPass.js +858 -0
- package/Resources/WebLibraries/three/addons/postprocessing/SavePass.js +132 -0
- package/Resources/WebLibraries/three/addons/postprocessing/ShaderPass.js +135 -0
- package/Resources/WebLibraries/three/addons/postprocessing/TAARenderPass.js +243 -0
- package/Resources/WebLibraries/three/addons/postprocessing/TexturePass.js +131 -0
- package/Resources/WebLibraries/three/addons/postprocessing/UnrealBloomPass.js +541 -0
- package/Resources/WebLibraries/three/addons/shaders/ACESFilmicToneMappingShader.js +96 -0
- package/Resources/WebLibraries/three/addons/shaders/AfterimageShader.js +63 -0
- package/Resources/WebLibraries/three/addons/shaders/BasicShader.js +36 -0
- package/Resources/WebLibraries/three/addons/shaders/BleachBypassShader.js +68 -0
- package/Resources/WebLibraries/three/addons/shaders/BlendShader.js +56 -0
- package/Resources/WebLibraries/three/addons/shaders/BokehShader.js +151 -0
- package/Resources/WebLibraries/three/addons/shaders/BokehShader2.js +404 -0
- package/Resources/WebLibraries/three/addons/shaders/BrightnessContrastShader.js +62 -0
- package/Resources/WebLibraries/three/addons/shaders/ColorCorrectionShader.js +59 -0
- package/Resources/WebLibraries/three/addons/shaders/ColorifyShader.js +57 -0
- package/Resources/WebLibraries/three/addons/shaders/ConvolutionShader.js +74 -0
- package/Resources/WebLibraries/three/addons/shaders/CopyShader.js +52 -0
- package/Resources/WebLibraries/three/addons/shaders/DOFMipMapShader.js +63 -0
- package/Resources/WebLibraries/three/addons/shaders/DepthLimitedBlurShader.js +183 -0
- package/Resources/WebLibraries/three/addons/shaders/DigitalGlitch.js +104 -0
- package/Resources/WebLibraries/three/addons/shaders/DotScreenShader.js +75 -0
- package/Resources/WebLibraries/three/addons/shaders/ExposureShader.js +51 -0
- package/Resources/WebLibraries/three/addons/shaders/FXAAShader.js +298 -0
- package/Resources/WebLibraries/three/addons/shaders/FilmShader.js +73 -0
- package/Resources/WebLibraries/three/addons/shaders/FocusShader.js +94 -0
- package/Resources/WebLibraries/three/addons/shaders/FreiChenShader.js +103 -0
- package/Resources/WebLibraries/three/addons/shaders/GTAOShader.js +437 -0
- package/Resources/WebLibraries/three/addons/shaders/GammaCorrectionShader.js +52 -0
- package/Resources/WebLibraries/three/addons/shaders/HalftoneShader.js +332 -0
- package/Resources/WebLibraries/three/addons/shaders/HorizontalBlurShader.js +68 -0
- package/Resources/WebLibraries/three/addons/shaders/HorizontalTiltShiftShader.js +70 -0
- package/Resources/WebLibraries/three/addons/shaders/HueSaturationShader.js +74 -0
- package/Resources/WebLibraries/three/addons/shaders/KaleidoShader.js +65 -0
- package/Resources/WebLibraries/three/addons/shaders/LuminosityHighPassShader.js +68 -0
- package/Resources/WebLibraries/three/addons/shaders/LuminosityShader.js +54 -0
- package/Resources/WebLibraries/three/addons/shaders/MirrorShader.js +62 -0
- package/Resources/WebLibraries/three/addons/shaders/NormalMapShader.js +60 -0
- package/Resources/WebLibraries/three/addons/shaders/OutputShader.js +103 -0
- package/Resources/WebLibraries/three/addons/shaders/PoissonDenoiseShader.js +230 -0
- package/Resources/WebLibraries/three/addons/shaders/RGBShiftShader.js +61 -0
- package/Resources/WebLibraries/three/addons/shaders/SAOShader.js +201 -0
- package/Resources/WebLibraries/three/addons/shaders/SMAAShader.js +489 -0
- package/Resources/WebLibraries/three/addons/shaders/SSAOShader.js +331 -0
- package/Resources/WebLibraries/three/addons/shaders/SSRShader.js +396 -0
- package/Resources/WebLibraries/three/addons/shaders/SepiaShader.js +57 -0
- package/Resources/WebLibraries/three/addons/shaders/SobelOperatorShader.js +98 -0
- package/Resources/WebLibraries/three/addons/shaders/SubsurfaceScatteringShader.js +95 -0
- package/Resources/WebLibraries/three/addons/shaders/TechnicolorShader.js +51 -0
- package/Resources/WebLibraries/three/addons/shaders/ToonShader.js +349 -0
- package/Resources/WebLibraries/three/addons/shaders/TriangleBlurShader.js +79 -0
- package/Resources/WebLibraries/three/addons/shaders/UnpackDepthRGBAShader.js +60 -0
- package/Resources/WebLibraries/three/addons/shaders/VelocityShader.js +137 -0
- package/Resources/WebLibraries/three/addons/shaders/VerticalBlurShader.js +66 -0
- package/Resources/WebLibraries/three/addons/shaders/VerticalTiltShiftShader.js +70 -0
- package/Resources/WebLibraries/three/addons/shaders/VignetteShader.js +56 -0
- package/Resources/WebLibraries/three/addons/shaders/VolumeShader.js +285 -0
- package/Resources/WebLibraries/three/addons/shaders/WaterRefractionShader.js +106 -0
- package/Resources/WebLibraries/three/addons/textures/FlakesTexture.js +54 -0
- package/Resources/WebLibraries/three/addons/tsl/WebGLNodesHandler.js +607 -0
- package/Resources/WebLibraries/three/addons/tsl/display/AfterImageNode.js +256 -0
- package/Resources/WebLibraries/three/addons/tsl/display/AnaglyphPassNode.js +550 -0
- package/Resources/WebLibraries/three/addons/tsl/display/BilateralBlurNode.js +377 -0
- package/Resources/WebLibraries/three/addons/tsl/display/BleachBypass.js +33 -0
- package/Resources/WebLibraries/three/addons/tsl/display/BloomNode.js +599 -0
- package/Resources/WebLibraries/three/addons/tsl/display/CRT.js +150 -0
- package/Resources/WebLibraries/three/addons/tsl/display/ChromaticAberrationNode.js +163 -0
- package/Resources/WebLibraries/three/addons/tsl/display/DenoiseNode.js +354 -0
- package/Resources/WebLibraries/three/addons/tsl/display/DepthOfFieldNode.js +572 -0
- package/Resources/WebLibraries/three/addons/tsl/display/DotScreenNode.js +104 -0
- package/Resources/WebLibraries/three/addons/tsl/display/FSR1Node.js +481 -0
- package/Resources/WebLibraries/three/addons/tsl/display/FXAANode.js +357 -0
- package/Resources/WebLibraries/three/addons/tsl/display/FilmNode.js +101 -0
- package/Resources/WebLibraries/three/addons/tsl/display/GTAONode.js +727 -0
- package/Resources/WebLibraries/three/addons/tsl/display/GaussianBlurNode.js +412 -0
- package/Resources/WebLibraries/three/addons/tsl/display/GodraysNode.js +604 -0
- package/Resources/WebLibraries/three/addons/tsl/display/ImportanceSampledEnvironment.js +564 -0
- package/Resources/WebLibraries/three/addons/tsl/display/LensflareNode.js +282 -0
- package/Resources/WebLibraries/three/addons/tsl/display/Lut3DNode.js +109 -0
- package/Resources/WebLibraries/three/addons/tsl/display/MotionBlur.js +33 -0
- package/Resources/WebLibraries/three/addons/tsl/display/OITPassNode.js +341 -0
- package/Resources/WebLibraries/three/addons/tsl/display/OutlineNode.js +916 -0
- package/Resources/WebLibraries/three/addons/tsl/display/ParallaxBarrierPassNode.js +90 -0
- package/Resources/WebLibraries/three/addons/tsl/display/PixelationPassNode.js +227 -0
- package/Resources/WebLibraries/three/addons/tsl/display/RGBShiftNode.js +96 -0
- package/Resources/WebLibraries/three/addons/tsl/display/RecurrentDenoiseNode.js +812 -0
- package/Resources/WebLibraries/three/addons/tsl/display/RetroPassNode.js +263 -0
- package/Resources/WebLibraries/three/addons/tsl/display/SMAANode.js +729 -0
- package/Resources/WebLibraries/three/addons/tsl/display/SSAAPassNode.js +344 -0
- package/Resources/WebLibraries/three/addons/tsl/display/SSAONode.js +422 -0
- package/Resources/WebLibraries/three/addons/tsl/display/SSGINode.js +698 -0
- package/Resources/WebLibraries/three/addons/tsl/display/SSRNode.js +1430 -0
- package/Resources/WebLibraries/three/addons/tsl/display/SSSNode.js +479 -0
- package/Resources/WebLibraries/three/addons/tsl/display/Sepia.js +24 -0
- package/Resources/WebLibraries/three/addons/tsl/display/Shape.js +29 -0
- package/Resources/WebLibraries/three/addons/tsl/display/SharpenNode.js +284 -0
- package/Resources/WebLibraries/three/addons/tsl/display/SobelOperatorNode.js +168 -0
- package/Resources/WebLibraries/three/addons/tsl/display/StereoCompositePassNode.js +190 -0
- package/Resources/WebLibraries/three/addons/tsl/display/StereoPassNode.js +117 -0
- package/Resources/WebLibraries/three/addons/tsl/display/TAAUNode.js +700 -0
- package/Resources/WebLibraries/three/addons/tsl/display/TRAANode.js +609 -0
- package/Resources/WebLibraries/three/addons/tsl/display/TemporalReprojectNode.js +1024 -0
- package/Resources/WebLibraries/three/addons/tsl/display/TransitionNode.js +141 -0
- package/Resources/WebLibraries/three/addons/tsl/display/boxBlur.js +65 -0
- package/Resources/WebLibraries/three/addons/tsl/display/depthAwareBlend.js +80 -0
- package/Resources/WebLibraries/three/addons/tsl/display/depthAwareBlur.js +63 -0
- package/Resources/WebLibraries/three/addons/tsl/display/hashBlur.js +55 -0
- package/Resources/WebLibraries/three/addons/tsl/display/lensDirt.js +30 -0
- package/Resources/WebLibraries/three/addons/tsl/display/radialBlur.js +68 -0
- package/Resources/WebLibraries/three/addons/tsl/lighting/ClusteredLightsNode.js +994 -0
- package/Resources/WebLibraries/three/addons/tsl/lighting/DynamicLightsNode.js +201 -0
- package/Resources/WebLibraries/three/addons/tsl/lighting/LightProbeGridNode.js +183 -0
- package/Resources/WebLibraries/three/addons/tsl/lighting/data/AmbientLightDataNode.js +67 -0
- package/Resources/WebLibraries/three/addons/tsl/lighting/data/DirectionalLightDataNode.js +117 -0
- package/Resources/WebLibraries/three/addons/tsl/lighting/data/HemisphereLightDataNode.js +105 -0
- package/Resources/WebLibraries/three/addons/tsl/lighting/data/PointLightDataNode.js +140 -0
- package/Resources/WebLibraries/three/addons/tsl/lighting/data/SpotLightDataNode.js +167 -0
- package/Resources/WebLibraries/three/addons/tsl/math/Bayer.js +84 -0
- package/Resources/WebLibraries/three/addons/tsl/math/curlNoise.js +107 -0
- package/Resources/WebLibraries/three/addons/tsl/math/voronoiNoise.js +112 -0
- package/Resources/WebLibraries/three/addons/tsl/shadows/TileShadowNode.js +542 -0
- package/Resources/WebLibraries/three/addons/tsl/shadows/TileShadowNodeHelper.js +212 -0
- package/Resources/WebLibraries/three/addons/tsl/utils/GroundedSkybox.js +62 -0
- package/Resources/WebLibraries/three/addons/tsl/utils/RNoise.js +51 -0
- package/Resources/WebLibraries/three/addons/tsl/utils/Raymarching.js +70 -0
- package/Resources/WebLibraries/three/addons/tsl/utils/SoftParticles.js +53 -0
- package/Resources/WebLibraries/three/addons/tsl/utils/SpecularHelpers.js +302 -0
- package/Resources/WebLibraries/three/addons/tsl/utils/TAAUtils.js +184 -0
- package/Resources/WebLibraries/three/addons/utils/BufferGeometryUtils.js +1523 -0
- package/Resources/WebLibraries/three/addons/utils/CameraUtils.js +82 -0
- package/Resources/WebLibraries/three/addons/utils/ColorUtils.js +76 -0
- package/Resources/WebLibraries/three/addons/utils/GaussianSplatUtils.js +229 -0
- package/Resources/WebLibraries/three/addons/utils/GeometryCompressionUtils.js +547 -0
- package/Resources/WebLibraries/three/addons/utils/GeometryUtils.js +226 -0
- package/Resources/WebLibraries/three/addons/utils/LDrawUtils.js +211 -0
- package/Resources/WebLibraries/three/addons/utils/SceneOptimizer.js +459 -0
- package/Resources/WebLibraries/three/addons/utils/SceneUtils.js +363 -0
- package/Resources/WebLibraries/three/addons/utils/ShadowMapViewer.js +244 -0
- package/Resources/WebLibraries/three/addons/utils/ShadowMapViewerGPU.js +233 -0
- package/Resources/WebLibraries/three/addons/utils/SkeletonUtils.js +496 -0
- package/Resources/WebLibraries/three/addons/utils/SortUtils.js +175 -0
- package/Resources/WebLibraries/three/addons/utils/UVsDebug.js +173 -0
- package/Resources/WebLibraries/three/addons/utils/WebGLTextureUtils.js +115 -0
- package/Resources/WebLibraries/three/addons/utils/WebGPUTextureUtils.js +81 -0
- package/Resources/WebLibraries/three/addons/utils/WorkerPool.js +167 -0
- package/Resources/WebLibraries/three/addons/utils/unwrapUVs.js +865 -0
- package/Resources/WebLibraries/three/three.core.js +61162 -0
- package/Resources/WebLibraries/three/three.module.js +19537 -0
- package/Resources/WebLibraries/three/three.tsl.js +41903 -0
- package/Resources/WebLibraries/three/three.webgpu.js +53714 -0
- package/Resources/share-rules.json +64 -0
- package/Resources/skills/2d-game/SKILL.md +305 -0
- package/Resources/skills/3d-game/SKILL.md +274 -0
- package/Resources/skills/accounts/SKILL.md +41 -0
- package/Resources/skills/android-design/SKILL.md +159 -0
- package/Resources/skills/app-ai/SKILL.md +154 -0
- package/Resources/skills/app-intents/SKILL.md +31 -0
- package/Resources/skills/app-kinds/SKILL.md +122 -0
- package/Resources/skills/apple-watch/SKILL.md +43 -0
- package/Resources/skills/assets/SKILL.md +183 -0
- package/Resources/skills/building-blocks/SKILL.md +97 -0
- package/Resources/skills/device-control/SKILL.md +56 -0
- package/Resources/skills/device-integrations/SKILL.md +278 -0
- package/Resources/skills/finder-files/SKILL.md +19 -0
- package/Resources/skills/game/SKILL.md +416 -0
- package/Resources/skills/game-3d/SKILL.md +12 -0
- package/Resources/skills/game-art/SKILL.md +11 -0
- package/Resources/skills/game-feel/SKILL.md +13 -0
- package/Resources/skills/game-levels/SKILL.md +12 -0
- package/Resources/skills/game-live/SKILL.md +12 -0
- package/Resources/skills/game-physics/SKILL.md +13 -0
- package/Resources/skills/game-playtest/SKILL.md +26 -0
- package/Resources/skills/game-ui/SKILL.md +278 -0
- package/Resources/skills/github-projects/SKILL.md +62 -0
- package/Resources/skills/icloud-sync/SKILL.md +30 -0
- package/Resources/skills/iphone-hardware/SKILL.md +57 -0
- package/Resources/skills/iphone-widgets/SKILL.md +53 -0
- package/Resources/skills/lg-developer-mode/SKILL.md +152 -0
- package/Resources/skills/mac-widgets/SKILL.md +60 -0
- package/Resources/skills/maps/SKILL.md +8 -0
- package/Resources/skills/menu-bar/SKILL.md +36 -0
- package/Resources/skills/more-apps/SKILL.md +53 -0
- package/Resources/skills/phone-controllers/SKILL.md +79 -0
- package/Resources/skills/phone-pages/SKILL.md +173 -0
- package/Resources/skills/roku-developer-mode/SKILL.md +106 -0
- package/Resources/skills/scene-3d/SKILL.md +27 -0
- package/Resources/skills/scripting/SKILL.md +17 -0
- package/Resources/skills/share-extension/SKILL.md +22 -0
- package/Resources/skills/tv-design/SKILL.md +72 -0
- package/Resources/skills/web-app/SKILL.md +170 -0
- package/Resources/skills/web-game/SKILL.md +95 -0
- package/Resources/skills/web-or-native/SKILL.md +108 -0
- package/Resources/skills/wish-links/SKILL.md +156 -0
- package/THIRD_PARTY_NOTICES.md +170 -0
- package/cli/ai.mjs +237 -0
- package/cli/android.mjs +941 -0
- package/cli/app-ai.mjs +473 -0
- package/cli/browser.mjs +284 -0
- package/cli/calls.mjs +594 -0
- package/cli/dev.mjs +820 -0
- package/cli/devices.mjs +984 -0
- package/cli/lamp.mjs +1326 -0
- package/cli/lampapp.mjs +332 -0
- package/cli/lamps/android-host/LampPageGateway.kt +345 -0
- package/cli/lamps/android-host/app/build.gradle.kts +40 -0
- package/cli/lamps/android-host/build.gradle.kts +4 -0
- package/cli/lamps/android-host/game/AndroidManifest.xml +33 -0
- package/cli/lamps/android-host/game/MainActivity.kt +173 -0
- package/cli/lamps/android-host/gradle/libs.versions.toml +39 -0
- package/cli/lamps/android-host/gradle.properties +8 -0
- package/cli/lamps/android-host/page/AndroidManifest.xml +35 -0
- package/cli/lamps/android-host/page/MainActivity.kt +193 -0
- package/cli/lamps/android-host/settings.gradle.kts +30 -0
- package/cli/lamps/android-host/tools.json +43 -0
- package/cli/lamps/lamp-controllers.js +2 -0
- package/cli/lamps/lamp-for-games.js +6922 -0
- package/cli/lamps/lamp.js +7082 -0
- package/cli/lamps/mac-starter/.lamp/meta.json +1 -0
- package/cli/lamps/mac-starter/APP.md +12 -0
- package/cli/lamps/mac-starter/Package.swift +8 -0
- package/cli/lamps/mac-starter/Sources/App.swift +11 -0
- package/cli/lamps/mac-starter/Sources/ContentView.swift +17 -0
- package/cli/lamps/mac-starter/Sources/LampSupport.swift +18215 -0
- package/cli/lamps/mac-starter/build.sh +336 -0
- package/cli/lamps/mac-starter/gitignore +3 -0
- package/cli/lamps/mac-starter/lamp-starter.json +6 -0
- package/cli/lamps/web-starter/APP.md +8 -0
- package/cli/lamps/web-starter/app.js +1 -0
- package/cli/lamps/web-starter/index.html +17 -0
- package/cli/lamps/web-starter/lamp-starter.json +5 -0
- package/cli/lamps/web-starter/lamp.js +7082 -0
- package/cli/lamps/web-starter/style.css +25 -0
- package/cli/link.mjs +202 -0
- package/cli/local-ai.mjs +193 -0
- package/cli/mac.mjs +621 -0
- package/cli/mdns.mjs +205 -0
- package/cli/msix.mjs +413 -0
- package/cli/network.mjs +209 -0
- package/cli/openrouter.mjs +133 -0
- package/cli/outside.mjs +203 -0
- package/cli/page.js +317 -0
- package/cli/place.mjs +298 -0
- package/cli/png.mjs +184 -0
- package/cli/share.mjs +270 -0
- package/cli/skill/SKILL.md +280 -0
- package/cli/skills.mjs +71 -0
- package/cli/state.mjs +176 -0
- package/cli/update.mjs +162 -0
- package/package.json +20 -4
- package/site/lib/quota.js +59 -0
- package/site/lib/shared-wishes.js +1727 -0
|
@@ -0,0 +1,1727 @@
|
|
|
1
|
+
import { createHash, createHmac, randomBytes, randomInt, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { isIP } from "node:net";
|
|
3
|
+
import { inflateRawSync } from "node:zlib";
|
|
4
|
+
import { redis, reserve } from "./quota.js";
|
|
5
|
+
|
|
6
|
+
// A wish shared as a link (lamp.dev/w/<id>): the idea, as the sharer confirmed it in Lamp. Its name, its line,
|
|
7
|
+
// its words and, if they kept them, a picture of it and its icon; whoever opens the link makes their own version from
|
|
8
|
+
// the words. If the sharer chose to, the wish itself goes too, as the shared file Lamp makes (`<name>.lampapp`,
|
|
9
|
+
// `createFileHandler`): then the page offers that exact wish to anyone with Lamp, which reviews it before it opens, as
|
|
10
|
+
// any shared file. Lamp's side is Sources/Lamp/WishLink.swift; the two must agree on the limits.
|
|
11
|
+
|
|
12
|
+
/** The most each piece may hold. Lamp holds the sharer to the same numbers, so a longer one is refused, not cut. */
|
|
13
|
+
export const LIMITS = {
|
|
14
|
+
name: 60,
|
|
15
|
+
/** Who shared it, as they want to be named on its page: their name, or nothing. */
|
|
16
|
+
by: 60,
|
|
17
|
+
line: 140,
|
|
18
|
+
words: 2000,
|
|
19
|
+
/** The changes Lamp suggests for the wish (`RemixSuggestions`), each a line; the page offers them to remix with. */
|
|
20
|
+
ideas: 3,
|
|
21
|
+
idea: 100,
|
|
22
|
+
/** The questions the box under the app asks in turn, where people send its maker ideas ("Found a bug?"). */
|
|
23
|
+
asks: 3,
|
|
24
|
+
ask: 80,
|
|
25
|
+
/** A playing page's shape, its width over its height (`shape`), within these; or "fill", a page that takes any. */
|
|
26
|
+
shape: [0.2, 5],
|
|
27
|
+
/** What's new in each version the sharer released (`releases`), newest first: the most kept, the lines each holds
|
|
28
|
+
* and the characters in a line. Lamp writes the lines; the sharer can change them before they go. */
|
|
29
|
+
releases: 30,
|
|
30
|
+
releaseLines: 5,
|
|
31
|
+
releaseLine: 120,
|
|
32
|
+
picture: 1_500_000,
|
|
33
|
+
icon: 300_000,
|
|
34
|
+
/** Pixels on a side, for both pictures. */
|
|
35
|
+
side: 4096,
|
|
36
|
+
};
|
|
37
|
+
/** Both pictures as base64 and the words, with room for the JSON around them. */
|
|
38
|
+
export const MAX_BODY_BYTES = 2_600_000;
|
|
39
|
+
|
|
40
|
+
/** The wish itself, a shared file: sent in parts, since a request to a function holds 4.5 MB at most. */
|
|
41
|
+
export const FILE_LIMITS = { part: 3_000_000, parts: 16, total: 48_000_000 };
|
|
42
|
+
|
|
43
|
+
// Enough to share and reshare a few wishes in an hour; overall, a ceiling on what a flood could store in a day.
|
|
44
|
+
export const QUOTAS = [
|
|
45
|
+
{ name: "share-ip", perIP: true, limit: 10, seconds: 3600 },
|
|
46
|
+
{ name: "share-hour", limit: 200, seconds: 3600 },
|
|
47
|
+
{ name: "share-day", limit: 1000, seconds: 86400 },
|
|
48
|
+
];
|
|
49
|
+
export const DELETE_QUOTAS = [{ name: "unshare-ip", perIP: true, limit: 60, seconds: 3600 }];
|
|
50
|
+
/**
|
|
51
|
+
* Changing a link that's out (its secret shown to the site) and putting its file and page together: kept apart from
|
|
52
|
+
* new links, so someone working on a wish can update it again and again without running out of shares. An update
|
|
53
|
+
* counts up to three here (the link, its file, its page). Overall, a ceiling on what a flood could rewrite in a day.
|
|
54
|
+
*/
|
|
55
|
+
export const UPDATE_QUOTAS = [
|
|
56
|
+
{ name: "update-ip", perIP: true, limit: 90, seconds: 3600 },
|
|
57
|
+
{ name: "update-day", limit: 5000, seconds: 86400 },
|
|
58
|
+
];
|
|
59
|
+
/** A file's parts: a few files' worth an hour from one address. Putting the parts together counts as an update. */
|
|
60
|
+
export const PART_QUOTAS = [
|
|
61
|
+
// A share sends the file and its page, each in up to `FILE_LIMITS.parts` parts.
|
|
62
|
+
{ name: "part-ip", perIP: true, limit: 200, seconds: 3600 },
|
|
63
|
+
{ name: "part-day", limit: 3000, seconds: 86400 },
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
/** Ids are short enough to read in a link and long enough (about 58 bits) that nobody finds one by guessing. */
|
|
67
|
+
const ALPHABET = "23456789abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ";
|
|
68
|
+
export const ID_LENGTH = 10;
|
|
69
|
+
export const newID = () => Array.from({ length: ID_LENGTH }, () => ALPHABET[randomInt(ALPHABET.length)]).join("");
|
|
70
|
+
export const isID = (id) => typeof id === "string" && id.length === ID_LENGTH && [...id].every((c) => ALPHABET.includes(c));
|
|
71
|
+
|
|
72
|
+
/** The secret that lets the sharer's Lamp change or remove the link. Only its hash is kept here. */
|
|
73
|
+
export const newSecret = () => randomBytes(32).toString("base64url");
|
|
74
|
+
export const hashSecret = (secret) => createHash("sha256").update(secret).digest("hex");
|
|
75
|
+
function secretMatches(secret, hash) {
|
|
76
|
+
if (typeof secret !== "string" || !/^[A-Za-z0-9_-]{43}$/.test(secret) || typeof hash !== "string") return false;
|
|
77
|
+
const a = Buffer.from(hashSecret(secret), "hex"), b = Buffer.from(hash, "hex");
|
|
78
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Words as they're kept: no control characters (a line break only where lines are allowed), trimmed. Null when
|
|
82
|
+
* there's nothing, or more than `max` characters. */
|
|
83
|
+
export function tidy(value, max, { lines = false } = {}) {
|
|
84
|
+
if (typeof value !== "string") return null;
|
|
85
|
+
const spaced = value.replace(/\r\n?/g, "\n").replace(lines ? /\t/g : /[\t\n]/g, " ");
|
|
86
|
+
const kept = [...spaced].filter((c) => (lines && c === "\n") || !/[\p{Cc}\p{Cf}\u2028\u2029]/u.test(c) || c === "\u200d").join("");
|
|
87
|
+
const text = (lines ? kept.replace(/\n{3,}/g, "\n\n") : kept.replace(/\s+/g, " ")).trim();
|
|
88
|
+
return text && characters(text) <= max ? text : null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Characters as people see them (and as Swift counts them on Lamp's card): an accented letter or a family emoji
|
|
92
|
+
* made of several code points is one. */
|
|
93
|
+
const graphemes = new Intl.Segmenter("en", { granularity: "grapheme" });
|
|
94
|
+
export const characters = (text) => { let n = 0; for (const _ of graphemes.segment(text)) n++; return n; };
|
|
95
|
+
|
|
96
|
+
/** What a picture is, read from its first bytes: only a PNG or a JPEG of a sensible size. */
|
|
97
|
+
export function imageInfo(bytes) {
|
|
98
|
+
if (bytes.length > 24 && bytes.readUInt32BE(0) === 0x89504e47 && bytes.readUInt32BE(4) === 0x0d0a1a0a && bytes.toString("latin1", 12, 16) === "IHDR") {
|
|
99
|
+
return { type: "image/png", extension: "png", width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) };
|
|
100
|
+
}
|
|
101
|
+
if (bytes.length > 4 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
|
|
102
|
+
// The frame's size is in its start-of-frame segment, found by walking the segments before it.
|
|
103
|
+
let at = 2;
|
|
104
|
+
while (at + 9 < bytes.length) {
|
|
105
|
+
if (bytes[at] !== 0xff) return null;
|
|
106
|
+
const marker = bytes[at + 1];
|
|
107
|
+
if (marker === 0xff) { at += 1; continue; }
|
|
108
|
+
const length = bytes.readUInt16BE(at + 2);
|
|
109
|
+
if (length < 2) return null;
|
|
110
|
+
if (marker >= 0xc0 && marker <= 0xcf && ![0xc4, 0xc8, 0xcc].includes(marker)) {
|
|
111
|
+
return { type: "image/jpeg", extension: "jpg", width: bytes.readUInt16BE(at + 7), height: bytes.readUInt16BE(at + 5) };
|
|
112
|
+
}
|
|
113
|
+
at += 2 + length;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function picture(value, max, { square = false } = {}) {
|
|
120
|
+
if (value == null) return { bytes: null };
|
|
121
|
+
if (typeof value !== "string" || !/^[A-Za-z0-9+/]+={0,2}$/.test(value) || value.length > Math.ceil(max / 3) * 4) return null;
|
|
122
|
+
const bytes = Buffer.from(value, "base64");
|
|
123
|
+
const info = imageInfo(bytes);
|
|
124
|
+
if (!info || bytes.length > max || !info.width || !info.height || info.width > LIMITS.side || info.height > LIMITS.side) return null;
|
|
125
|
+
if (square && (info.type !== "image/png" || info.width !== info.height)) return null;
|
|
126
|
+
return { bytes, ...info };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** A release as it's kept: when it went out (an ISO time, never more than a day ahead) and its lines. Null when off. */
|
|
130
|
+
function release(value) {
|
|
131
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).some((key) => !["date", "lines"].includes(key))) return null;
|
|
132
|
+
const time = typeof value.date === "string" && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/.test(value.date) ? Date.parse(value.date) : NaN;
|
|
133
|
+
if (!Number.isFinite(time) || time > Date.now() + 86_400_000) return null;
|
|
134
|
+
if (!Array.isArray(value.lines) || !value.lines.length || value.lines.length > LIMITS.releaseLines) return null;
|
|
135
|
+
const lines = value.lines.map((line) => tidy(line, LIMITS.releaseLine));
|
|
136
|
+
return lines.includes(null) ? null : { date: new Date(time).toISOString(), lines };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** What a share sends, checked. Null when anything is off. `releases` is undefined when the share didn't say (a Lamp
|
|
140
|
+
* from before them), so a change keeps the ones the link has. */
|
|
141
|
+
const GITHUB_REPO = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/(?!\.\.?$)[A-Za-z0-9._-]{1,100}$/;
|
|
142
|
+
export function readShare(body) {
|
|
143
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return null;
|
|
144
|
+
const known = ["name", "by", "line", "words", "ideas", "asks", "shape", "releases", "picture", "icon", "github"];
|
|
145
|
+
if (Object.keys(body).some((key) => !known.includes(key))) return null;
|
|
146
|
+
const name = tidy(body.name, LIMITS.name);
|
|
147
|
+
const words = tidy(body.words, LIMITS.words, { lines: true });
|
|
148
|
+
const line = body.line == null || body.line === "" ? "" : tidy(body.line, LIMITS.line);
|
|
149
|
+
const by = body.by == null || body.by === "" ? "" : tidy(body.by, LIMITS.by);
|
|
150
|
+
const ideas = body.ideas == null ? [] : Array.isArray(body.ideas) && body.ideas.length <= LIMITS.ideas
|
|
151
|
+
? body.ideas.map((idea) => tidy(idea, LIMITS.idea)) : [null];
|
|
152
|
+
const asks = body.asks == null ? [] : Array.isArray(body.asks) && body.asks.length <= LIMITS.asks
|
|
153
|
+
? body.asks.map((ask) => tidy(ask, LIMITS.ask)) : [null];
|
|
154
|
+
const shape = body.shape == null ? null : body.shape === "fill" ? "fill"
|
|
155
|
+
: typeof body.shape === "number" && body.shape >= LIMITS.shape[0] && body.shape <= LIMITS.shape[1] ? Math.round(body.shape * 10000) / 10000 : undefined;
|
|
156
|
+
const releases = body.releases === undefined ? undefined : Array.isArray(body.releases) && body.releases.length <= LIMITS.releases
|
|
157
|
+
? body.releases.map(release) : [null];
|
|
158
|
+
const shot = picture(body.picture, LIMITS.picture);
|
|
159
|
+
const icon = picture(body.icon, LIMITS.icon, { square: true });
|
|
160
|
+
// The app's public repository on GitHub, as "owner/name", which the page links to: nothing else is taken.
|
|
161
|
+
const github = body.github == null || body.github === "" ? null : GITHUB_REPO.test(body.github) ? body.github : undefined;
|
|
162
|
+
if (!name || !words || line == null || by == null || ideas.includes(null) || asks.includes(null) || shape === undefined || releases?.includes(null) || !shot || !icon || github === undefined) return null;
|
|
163
|
+
return { name, by, line, words, ideas, asks, shape, releases: releases?.sort((a, b) => b.date.localeCompare(a.date)),
|
|
164
|
+
picture: shot.bytes ? shot : null, icon: icon.bytes ? icon : null, github };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** What anyone may see of a shared wish: never its secret. */
|
|
168
|
+
export const shown = (record) => record && ({
|
|
169
|
+
id: record.id, name: record.name, by: record.by ?? "", line: record.line, words: record.words, ideas: record.ideas ?? [], asks: record.asks ?? [], shape: record.shape ?? null,
|
|
170
|
+
releases: record.releases ?? [], picture: record.picture, icon: record.icon, file: record.file ?? null, updated: record.updated, github: record.github ?? null,
|
|
171
|
+
page: record.page ? { version: record.page.version } : null,
|
|
172
|
+
...(record.owner && record.app ? { owner: record.owner, app: record.app } : {}),
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
/** Every file kept for a record: its pictures, the wish's file and any parts of one still being sent. */
|
|
176
|
+
const blobsOf = (record) => [record?.picture?.url, record?.icon?.url, record?.file?.url, ...partBlobs(record?.parts),
|
|
177
|
+
...pageBlobs(record), ...partBlobs(record?.pageParts)].filter(Boolean);
|
|
178
|
+
/** A file's parts so far, and those set aside (`spare`): replaced by a part sent again, or left by an earlier count. */
|
|
179
|
+
const partBlobs = (parts) => [...Object.values(parts?.urls ?? {}), ...(parts?.spare ?? [])];
|
|
180
|
+
/** The page's files, and the version before it, kept for whoever was still playing that when this one went up. */
|
|
181
|
+
const pageBlobs = (record) => [...Object.values(record?.page?.files ?? {}), ...Object.values(record?.previousPage?.files ?? {})];
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* What anyone's Lamp asks of a link it follows (`GET /api/wishes?id=`): the format of the answer, and the version of
|
|
185
|
+
* the wish's file, which goes up each time the sharer sends it again (Release Update), with the file's address. Version 0
|
|
186
|
+
* and no file for a link that carries none, and who it's by as the page names them (empty for nobody), which Lamp keeps
|
|
187
|
+
* with what it pulls in from the copy so the change can credit them. Never the secret or anything a follower doesn't need.
|
|
188
|
+
*/
|
|
189
|
+
export const FOLLOW_FORMAT = 1;
|
|
190
|
+
export const latest = (record) => ({
|
|
191
|
+
format: FOLLOW_FORMAT, id: record.id, name: record.name, by: record.by ?? "",
|
|
192
|
+
version: record.file ? (record.file.version ?? 1) : 0, file: record.file?.url ?? null,
|
|
193
|
+
// The version of its page that plays now, which lamp waits on after sending one (it's stored after the answer).
|
|
194
|
+
page: record.page?.version ?? null,
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* The wish's name as its link spells it ("Falling Blocks" → "falling-blocks"), so a link says what it's for. Only for
|
|
199
|
+
* reading: the id finds the wish (`wishIDFrom`), so a link with an old name or none still works. Empty for a name with
|
|
200
|
+
* no letters or digits in plain Latin.
|
|
201
|
+
*/
|
|
202
|
+
export const slugFor = (name) => (name ?? "").normalize("NFKD").replace(/[\u0300-\u036f]/g, "")
|
|
203
|
+
.replace(/[^A-Za-z0-9]+/g, "-").toLowerCase().replace(/^-+/, "").slice(0, 48).replace(/-+$/, "");
|
|
204
|
+
|
|
205
|
+
/** The link people pass around: the wish's id, then its name after a dash (`/w/JP2R862d45-falling-blocks`). */
|
|
206
|
+
export const linkFor = (origin, id, name) => {
|
|
207
|
+
const slug = slugFor(name);
|
|
208
|
+
return `${origin}/w/${id}${slug ? `-${slug}` : ""}`;
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* The link a wish is known by: its maker's username and the app's name (`/matt-rubens/bricks`) once its maker has an
|
|
213
|
+
* account, else the old kind (`linkFor`), which still opens it and sends anyone to the new one.
|
|
214
|
+
*/
|
|
215
|
+
export const linkOf = (origin, record) => (record?.owner && record?.app ? `${origin}/${record.owner}/${record.app}` : linkFor(origin, record.id, record.name));
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* A browser signed in to an account: the email's link, opened in it, leaves a key of its own in this cookie, which only
|
|
219
|
+
* lamp.dev reads (HttpOnly, and Lax, so another site's form never sends it). Ideas sent from it are by that username.
|
|
220
|
+
*/
|
|
221
|
+
export const ACCOUNT_COOKIE = "lamp_account";
|
|
222
|
+
export const cookieKey = (request) => new RegExp(`(?:^|;\\s*)${ACCOUNT_COOKIE}=([A-Za-z0-9_-]{43})(?:;|$)`).exec(request.headers.get("cookie") || "")?.[1] ?? null;
|
|
223
|
+
export const signInCookie = (key, url) => `${ACCOUNT_COOKIE}=${key}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000${new URL(url).protocol === "https:" ? "; Secure" : ""}`;
|
|
224
|
+
export const signOutCookie = (url) => `${ACCOUNT_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${new URL(url).protocol === "https:" ? "; Secure" : ""}`;
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Asks someone who sent an idea with their address to confirm it, by email: its link signs their browser in to the
|
|
228
|
+
* address's account, or makes one with a username they choose. Emails about their ideas go only to a confirmed
|
|
229
|
+
* address (`writeToSender`), so nobody hears about an idea that someone else typed their address into.
|
|
230
|
+
*/
|
|
231
|
+
export async function askToConfirm(kept, { email, name, app, origin, env, fetcher }) {
|
|
232
|
+
const token = randomBytes(16).toString("hex");
|
|
233
|
+
await kept.waitForAccount(token, { email, browser: true, ...(name ? { suggested: name } : {}), at: new Date().toISOString() });
|
|
234
|
+
const link = `${origin}/api/accounts?confirm=${token}`;
|
|
235
|
+
return sendMail(env, fetcher, { to: [email], subject: `Confirm your email to hear back about ${app}`,
|
|
236
|
+
text: `You sent an idea for ${app} on Lamp. To hear back about it, confirm your email here:\n\n${link}\n\nIf you didn't send it, you can ignore this email.` });
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** The account a share's key (`X-Lamp-Account`) belongs to, by its username, or null. */
|
|
240
|
+
export async function accountOf(kept, key) {
|
|
241
|
+
if (typeof key !== "string" || !/^[A-Za-z0-9_-]{43}$/.test(key) || !kept.accountForKey) return null;
|
|
242
|
+
return kept.accountForKey(hashSecret(key));
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** A name for the app under its maker's username, free or already this link's: its own, then -2, -3… */
|
|
246
|
+
async function claimAppName(kept, owner, name, id) {
|
|
247
|
+
const base = slugFor(name) || "app";
|
|
248
|
+
for (let n = 1; n < 100; n++) {
|
|
249
|
+
const app = n === 1 ? base : `${base}-${n}`;
|
|
250
|
+
if (await kept.claimApp(owner, app, id)) return app;
|
|
251
|
+
}
|
|
252
|
+
throw new Error("No name free");
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** The wish's id in its link's last part, whatever name follows it after a dash. Null when it doesn't start with one. */
|
|
256
|
+
export const wishIDFrom = (part) => {
|
|
257
|
+
const id = typeof part === "string" ? part.slice(0, ID_LENGTH) : null;
|
|
258
|
+
return isID(id) && (part.length === ID_LENGTH || part[ID_LENGTH] === "-") ? id : null;
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
// MARK: Where shared wishes are kept
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* The record of each shared wish in Upstash (the store the site's quotas already use), its pictures in Vercel
|
|
265
|
+
* Blob, public at addresses with a random part. Preview deployments keep theirs apart from production's.
|
|
266
|
+
*/
|
|
267
|
+
/**
|
|
268
|
+
* A write to the blob store that answers in time: one that hasn't after `seconds` is cancelled and tried again, a few
|
|
269
|
+
* times, since a write that never answers held a whole page up until its function's time ran out.
|
|
270
|
+
*/
|
|
271
|
+
async function inTime(write, { seconds = 30, tries = 3, what = "a file" } = {}) {
|
|
272
|
+
for (let attempt = 1; ; attempt++) {
|
|
273
|
+
// Given up on at its time whatever the blob library is doing: its own retries of a failed request wait between
|
|
274
|
+
// tries and don't hear a cancel then, so a write that keeps failing could otherwise hold on for minutes.
|
|
275
|
+
const signal = AbortSignal.timeout(seconds * 1000);
|
|
276
|
+
let timer;
|
|
277
|
+
const late = new Promise((_, failed) => { timer = setTimeout(() => failed(new Error(`no answer in ${seconds}s`)), seconds * 1000 + 1000); });
|
|
278
|
+
try { return await Promise.race([write(signal), late]); } catch (error) {
|
|
279
|
+
if (attempt >= tries) throw error;
|
|
280
|
+
console.warn(`Storing ${what} again (try ${attempt + 1} of ${tries}): ${error?.message ?? error}`);
|
|
281
|
+
} finally { clearTimeout(timer); }
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
export function cloudStore(env, fetcher = fetch) {
|
|
286
|
+
// The blob library tries a failed request again 10 times by default, waiting longer each time; `inTime` tries again
|
|
287
|
+
// itself, and says why, so the library's own are few.
|
|
288
|
+
process.env.VERCEL_BLOB_RETRIES ??= "2";
|
|
289
|
+
const db = redis(env);
|
|
290
|
+
const scope = env.VERCEL_ENV === "production" ? "production" : "preview";
|
|
291
|
+
const key = (id) => `lamp:shared-wish:{${scope}}:${id}`;
|
|
292
|
+
const ideasKey = (id) => `lamp:shared-wish:{${scope}}:ideas:${id}`;
|
|
293
|
+
const mailKey = (box) => `lamp:shared-wish:{${scope}}:idea-mail:${box}`;
|
|
294
|
+
const makerKey = (id) => `lamp:shared-wish:{${scope}}:maker:${id}`;
|
|
295
|
+
// Makers' accounts: a username, its confirmed email, the keys their Lamps hold, and the apps under it by name.
|
|
296
|
+
const accountKey = (name) => `lamp:shared-wish:{${scope}}:account:${name}`;
|
|
297
|
+
const accountEmailKey = (email) => `lamp:shared-wish:{${scope}}:account-email:${email.toLowerCase()}`;
|
|
298
|
+
const accountByKeyKey = (hash) => `lamp:shared-wish:{${scope}}:account-key:${hash}`;
|
|
299
|
+
const accountWaitingKey = (token) => `lamp:shared-wish:{${scope}}:account-waiting:${token}`;
|
|
300
|
+
const appKey = (name, app) => `lamp:shared-wish:{${scope}}:app:${name}:${app}`;
|
|
301
|
+
const command = async (args) => {
|
|
302
|
+
const response = await fetcher(db.url, {
|
|
303
|
+
method: "POST", redirect: "error", signal: AbortSignal.timeout(3000),
|
|
304
|
+
headers: { Authorization: `Bearer ${db.token}`, "Content-Type": "application/json" },
|
|
305
|
+
body: JSON.stringify(args),
|
|
306
|
+
});
|
|
307
|
+
// Upstash's own words for it (a limit reached, say), which name no key: it gives them with a status that isn't
|
|
308
|
+
// OK too, so the answer is read either way, and the status alone says it when there are none.
|
|
309
|
+
let body = null;
|
|
310
|
+
try { body = await response.json(); } catch {}
|
|
311
|
+
if (body?.error) throw new Error(`Store unavailable (${response.status}): ${String(body.error).slice(0, 200)}`);
|
|
312
|
+
if (!response.ok || !body) throw new Error(`Store unavailable (${response.status})`);
|
|
313
|
+
return body.result;
|
|
314
|
+
};
|
|
315
|
+
// A store connected now gives the project its id (BLOB_STORE_ID), and @vercel/blob signs in with the deployment's
|
|
316
|
+
// own OIDC token; an older connection gave a read-write token instead.
|
|
317
|
+
const auth = env.BLOB_READ_WRITE_TOKEN ? { token: env.BLOB_READ_WRITE_TOKEN } : {};
|
|
318
|
+
return {
|
|
319
|
+
limiter: { ...db, prefix: `lamp:shared-wish:{${scope}}:quota` },
|
|
320
|
+
async get(id) {
|
|
321
|
+
const text = await command(["GET", key(id)]);
|
|
322
|
+
return typeof text === "string" ? JSON.parse(text) : null;
|
|
323
|
+
},
|
|
324
|
+
async create(record) { return (await command(["SET", key(record.id), JSON.stringify(record), "NX"])) === "OK"; },
|
|
325
|
+
/** False when the record is gone (taken down meanwhile): nothing was written. */
|
|
326
|
+
async replace(record) { return (await command(["SET", key(record.id), JSON.stringify(record), "XX"])) === "OK"; },
|
|
327
|
+
/** Writes `after` only while the record is still `before` as read: false when it changed meanwhile, or is gone. */
|
|
328
|
+
async swap(before, after) {
|
|
329
|
+
return (await command(["EVAL", "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('SET', KEYS[1], ARGV[2]) end return false",
|
|
330
|
+
"1", key(after.id), JSON.stringify(before), JSON.stringify(after)])) === "OK";
|
|
331
|
+
},
|
|
332
|
+
async remove(id) { await command(["DEL", key(id), ideasKey(id), makerKey(id)]); },
|
|
333
|
+
/** A maker's account by its username: `{ name, email, created }`, or null. */
|
|
334
|
+
async account(name) { const text = await command(["GET", accountKey(name)]); return typeof text === "string" ? JSON.parse(text) : null; },
|
|
335
|
+
async accountForEmail(email) { const name = await command(["GET", accountEmailKey(email)]); return typeof name === "string" ? name : null; },
|
|
336
|
+
async accountForKey(hash) { const name = await command(["GET", accountByKeyKey(hash)]); return typeof name === "string" ? name : null; },
|
|
337
|
+
/** Makes the account, and its address its own, together: false when either is someone's already. */
|
|
338
|
+
async createAccount(account) {
|
|
339
|
+
return (await command(["EVAL", `if redis.call('EXISTS', KEYS[1]) == 1 or redis.call('EXISTS', KEYS[2]) == 1 then return 0 end
|
|
340
|
+
redis.call('SET', KEYS[1], ARGV[1]) redis.call('SET', KEYS[2], ARGV[2]) return 1`,
|
|
341
|
+
"2", accountKey(account.name), accountEmailKey(account.email), JSON.stringify(account), account.name])) === 1;
|
|
342
|
+
},
|
|
343
|
+
async addAccountKey(hash, name) { await command(["SET", accountByKeyKey(hash), name]); },
|
|
344
|
+
/** What waits on an email's confirmation, a day at most; let go once it's done, so a try that fails can be tried again. */
|
|
345
|
+
async waitForAccount(token, waiting) { await command(["SET", accountWaitingKey(token), JSON.stringify(waiting), "EX", "86400"]); },
|
|
346
|
+
async accountWaiting(token) { const text = await command(["GET", accountWaitingKey(token)]); return typeof text === "string" ? JSON.parse(text) : null; },
|
|
347
|
+
async doneWaiting(token) { await command(["DEL", accountWaitingKey(token)]); },
|
|
348
|
+
/** Claims an app's name under a username for a link: false when another link has it. */
|
|
349
|
+
async claimApp(name, app, id) {
|
|
350
|
+
return (await command(["EVAL", `local had = redis.call('GET', KEYS[1]) if had and had ~= ARGV[1] then return 0 end
|
|
351
|
+
redis.call('SET', KEYS[1], ARGV[1]) return 1`, "1", appKey(name, app), id])) === 1;
|
|
352
|
+
},
|
|
353
|
+
async appID(name, app) { const id = await command(["GET", appKey(name, app)]); return typeof id === "string" ? id : null; },
|
|
354
|
+
async releaseApp(name, app) { await command(["DEL", appKey(name, app)]); },
|
|
355
|
+
/** The maker's own address for a link's ideas, as `{ email, confirmed, key }`, or null. */
|
|
356
|
+
async maker(id) { const text = await command(["GET", makerKey(id)]); return typeof text === "string" ? JSON.parse(text) : null; },
|
|
357
|
+
async setMaker(id, value) { await command(value ? ["SET", makerKey(id), JSON.stringify(value)] : ["DEL", makerKey(id)]); },
|
|
358
|
+
/** Confirms ("confirm") or lets go ("stop") the maker's address in one step, only while `key` is still its key. */
|
|
359
|
+
async makerStep(id, key, step) {
|
|
360
|
+
await command(["EVAL", `local text = redis.call('GET', KEYS[1])
|
|
361
|
+
if not text then return 0 end
|
|
362
|
+
local maker = cjson.decode(text)
|
|
363
|
+
if maker.key ~= ARGV[1] then return 0 end
|
|
364
|
+
if ARGV[2] == "confirm" then
|
|
365
|
+
maker.confirmed = true
|
|
366
|
+
redis.call('SET', KEYS[1], cjson.encode(maker))
|
|
367
|
+
else redis.call('DEL', KEYS[1]) end
|
|
368
|
+
return 1`, "1", makerKey(id), key, step]);
|
|
369
|
+
},
|
|
370
|
+
/** Keeps an idea for the link, numbered in the order they came: null when the link already holds `IDEA_LIMITS.kept`. */
|
|
371
|
+
async addIdea(id, idea) {
|
|
372
|
+
// Its number is its field, given as it's kept, so two at once never share one. The cap counts those still open
|
|
373
|
+
// (`open`, which an outcome counts down), so a maker who deals with them makes room; and, far above it, all kept.
|
|
374
|
+
const n = await command(["EVAL", `if tonumber(redis.call('HGET', KEYS[1], 'open') or '0') >= tonumber(ARGV[1]) then return false end
|
|
375
|
+
if redis.call('HLEN', KEYS[1]) > 10 * tonumber(ARGV[1]) then return false end
|
|
376
|
+
redis.call('HINCRBY', KEYS[1], 'open', 1)
|
|
377
|
+
local n = redis.call('HINCRBY', KEYS[1], 'next', 1)
|
|
378
|
+
redis.call('HSET', KEYS[1], tostring(n), ARGV[2])
|
|
379
|
+
return n`, "1", ideasKey(id), String(IDEA_LIMITS.kept), JSON.stringify(idea)]);
|
|
380
|
+
return Number.isInteger(n) ? n : null;
|
|
381
|
+
},
|
|
382
|
+
async ideas(id) {
|
|
383
|
+
const flat = (await command(["HGETALL", ideasKey(id)])) ?? [];
|
|
384
|
+
const ideas = [];
|
|
385
|
+
for (let i = 0; i < flat.length; i += 2) if (flat[i] !== "next" && flat[i] !== "open") ideas.push({ ...JSON.parse(flat[i + 1]), n: Number(flat[i]) });
|
|
386
|
+
return ideas.sort((a, b) => a.n - b.n);
|
|
387
|
+
},
|
|
388
|
+
/** True when this call took it away: two at once never both do. */
|
|
389
|
+
async removeIdea(id, n) { return (await command(["HDEL", ideasKey(id), String(n)])) === 1; },
|
|
390
|
+
/**
|
|
391
|
+
* Gives idea `n` its outcome ("in" or "dismissed"), with the maker's note to its sender as the first of its thread
|
|
392
|
+
* if they wrote one, in one step, and answers it as it was, its address included, to whichever call gave it: null
|
|
393
|
+
* when it's gone or already has one, so it's told once. The picture goes with this step, and the address too when
|
|
394
|
+
* it's dismissed with nothing said, since nothing more will be written to it.
|
|
395
|
+
*/
|
|
396
|
+
async finishIdea(id, n, status, at, message) {
|
|
397
|
+
const text = await command(["EVAL", `local text = redis.call('HGET', KEYS[1], ARGV[1])
|
|
398
|
+
if not text then return false end
|
|
399
|
+
local idea = cjson.decode(text)
|
|
400
|
+
if idea.status then return false end
|
|
401
|
+
idea.status = ARGV[2]
|
|
402
|
+
idea.done = ARGV[3]
|
|
403
|
+
idea.last = ARGV[3]
|
|
404
|
+
idea.picture = nil
|
|
405
|
+
idea.waiting = nil
|
|
406
|
+
if ARGV[4] ~= "" then
|
|
407
|
+
local thread = idea.thread or {}
|
|
408
|
+
table.insert(thread, cjson.decode(ARGV[4]))
|
|
409
|
+
idea.thread = thread
|
|
410
|
+
end
|
|
411
|
+
if ARGV[2] == "dismissed" and not idea.thread then idea.email = nil end
|
|
412
|
+
redis.call('HSET', KEYS[1], ARGV[1], cjson.encode(idea))
|
|
413
|
+
redis.call('HINCRBY', KEYS[1], 'open', -1)
|
|
414
|
+
return text`, "1", ideasKey(id), String(n), status, at, message ? JSON.stringify(message) : ""]);
|
|
415
|
+
return typeof text === "string" ? { ...JSON.parse(text), n } : null;
|
|
416
|
+
},
|
|
417
|
+
/**
|
|
418
|
+
* One step in an idea's conversation, answering the idea as it was before it, or null when it's gone or the step
|
|
419
|
+
* doesn't apply: "say" adds a message to its thread (one of a message id already there, or past
|
|
420
|
+
* `IDEA_LIMITS.thread`, doesn't), and an answer from its sender leaves it waiting on the maker; "settle" says the
|
|
421
|
+
* maker has seen to it; "box" gives it its mailbox and the subject of the first email, unless another call gave it
|
|
422
|
+
* one first (whose is in what's answered), and "makerbox" the same for its maker's mailbox; "stop", with its mailbox,
|
|
423
|
+
* lets its address go.
|
|
424
|
+
*/
|
|
425
|
+
async talk(id, n, op, value = "", extra = "") {
|
|
426
|
+
const text = await command(["EVAL", `local text = redis.call('HGET', KEYS[1], ARGV[1])
|
|
427
|
+
if not text then return false end
|
|
428
|
+
local idea = cjson.decode(text)
|
|
429
|
+
local op = ARGV[2]
|
|
430
|
+
if op == "say" then
|
|
431
|
+
local said = cjson.decode(ARGV[3])
|
|
432
|
+
-- A note given before ideas had threads starts its thread.
|
|
433
|
+
if idea.note and not idea.thread then
|
|
434
|
+
idea.thread = {{by = "maker", text = idea.note, at = idea.done or idea.at}}
|
|
435
|
+
idea.note = nil
|
|
436
|
+
end
|
|
437
|
+
local thread = idea.thread or {}
|
|
438
|
+
if #thread >= tonumber(ARGV[4]) then return false end
|
|
439
|
+
for _, one in ipairs(thread) do
|
|
440
|
+
if (said.mid and one.mid == said.mid) or (said.via and one.via == said.via) then return false end
|
|
441
|
+
end
|
|
442
|
+
table.insert(thread, said)
|
|
443
|
+
idea.thread = thread
|
|
444
|
+
idea.last = said.at
|
|
445
|
+
if said.by == "sender" then idea.waiting = true else idea.waiting = nil end
|
|
446
|
+
elseif op == "settle" then idea.waiting = nil
|
|
447
|
+
elseif op == "box" then
|
|
448
|
+
if idea.mailbox then return text end
|
|
449
|
+
idea.mailbox = ARGV[3]; idea.subject = ARGV[4]
|
|
450
|
+
elseif op == "makerbox" then
|
|
451
|
+
if idea.makerBox then return text end
|
|
452
|
+
idea.makerBox = ARGV[3]
|
|
453
|
+
elseif op == "stop" then
|
|
454
|
+
if idea.mailbox ~= ARGV[3] then return false end
|
|
455
|
+
idea.email = nil
|
|
456
|
+
else return false end
|
|
457
|
+
redis.call('HSET', KEYS[1], ARGV[1], cjson.encode(idea))
|
|
458
|
+
return text`, "1", ideasKey(id), String(n), op, typeof value === "string" ? value : JSON.stringify(value), String(extra || IDEA_LIMITS.thread)]);
|
|
459
|
+
return typeof text === "string" ? { ...JSON.parse(text), n } : null;
|
|
460
|
+
},
|
|
461
|
+
/**
|
|
462
|
+
* An idea's mailbox: the address replies to its emails come to, kept a while past the last of them. Each idea has
|
|
463
|
+
* one for its sender's replies and, once its maker has emails of their own, one for the maker's (`side` "maker").
|
|
464
|
+
*/
|
|
465
|
+
async openMailbox(id, n, side = "sender") {
|
|
466
|
+
const box = randomBytes(10).toString("hex");
|
|
467
|
+
await command(["SET", mailKey(box), `${id}:${n}${side === "maker" ? ":maker" : ""}`, "EX", String(MAILBOX_SECONDS)]);
|
|
468
|
+
return box;
|
|
469
|
+
},
|
|
470
|
+
async mailbox(box) {
|
|
471
|
+
const found = await command(["GETEX", mailKey(box), "EX", String(MAILBOX_SECONDS)]);
|
|
472
|
+
const [id, n, side] = typeof found === "string" ? found.split(":") : [];
|
|
473
|
+
return isID(id) && /^\d+$/.test(n ?? "") ? { id, n: Number(n), side: side === "maker" ? "maker" : "sender" } : null;
|
|
474
|
+
},
|
|
475
|
+
async closeMailbox(box) { await command(["DEL", mailKey(box)]); },
|
|
476
|
+
async putImage(id, name, image) {
|
|
477
|
+
const { put } = await import("@vercel/blob");
|
|
478
|
+
const blob = await put(`${scope}/w/${id}/${name}.${image.extension}`, image.bytes, {
|
|
479
|
+
access: "public", contentType: image.type, addRandomSuffix: true, cacheControlMaxAge: 300, ...auth,
|
|
480
|
+
});
|
|
481
|
+
return blob.url;
|
|
482
|
+
},
|
|
483
|
+
async removeImages(urls) {
|
|
484
|
+
if (!urls.length) return;
|
|
485
|
+
const { del } = await import("@vercel/blob");
|
|
486
|
+
await del(urls, auth);
|
|
487
|
+
},
|
|
488
|
+
// A part of the wish's file, or the file once its parts are together: public at an address with a random part, as
|
|
489
|
+
// the pictures are, and only ever read back from the store's own host.
|
|
490
|
+
async putFile(id, name, bytes) {
|
|
491
|
+
const { put } = await import("@vercel/blob");
|
|
492
|
+
const blob = await inTime((abortSignal) => put(`${scope}/w/${id}/${name}`, bytes, {
|
|
493
|
+
access: "public", contentType: "application/octet-stream", addRandomSuffix: true, cacheControlMaxAge: 300, abortSignal, ...auth,
|
|
494
|
+
}), { seconds: 60, what: name });
|
|
495
|
+
return blob.url;
|
|
496
|
+
},
|
|
497
|
+
// One file of a playable page, under the version's own random folder: read back by the play handler, never served
|
|
498
|
+
// from Blob itself (which hands HTML out as a download).
|
|
499
|
+
async putPageFile(id, token, path, bytes) {
|
|
500
|
+
const { put } = await import("@vercel/blob");
|
|
501
|
+
const blob = await inTime((abortSignal) => put(`${scope}/w/${id}/page/${token}/${path}`, bytes, {
|
|
502
|
+
// Kept as what it is, so a browser sent straight to a big one (`DIRECT_FILE`) runs or reads it. Written again
|
|
503
|
+
// in place when a try was cancelled after it got there.
|
|
504
|
+
access: "public", contentType: pageType(path), addRandomSuffix: false, allowOverwrite: true, cacheControlMaxAge: 300, abortSignal, ...auth,
|
|
505
|
+
}), { seconds: 30, what: path });
|
|
506
|
+
return blob.url;
|
|
507
|
+
},
|
|
508
|
+
async readFile(url) {
|
|
509
|
+
const { body } = await this.openFile(url);
|
|
510
|
+
return Buffer.from(await new Response(body).arrayBuffer());
|
|
511
|
+
},
|
|
512
|
+
// A page's file as it comes, passed on without being held whole: a big one (a game's map data, tens of MB) is more
|
|
513
|
+
// than a function's answer may hold at once, and more than it can read in the time a whole read is given.
|
|
514
|
+
// A small one is kept in the instance after its first read (`remembered`): a page's files never change, and a game
|
|
515
|
+
// asks for some many times over (a worker's script, under an address of its own each time), which the CDN can't
|
|
516
|
+
// keep. The store turns this function's reads away for a while when they come in bursts (403), and asking again
|
|
517
|
+
// soon only keeps that going: a refused read is asked once more, a second later, and said with what the store
|
|
518
|
+
// gave as its reason.
|
|
519
|
+
async openFile(url) {
|
|
520
|
+
const address = new URL(url);
|
|
521
|
+
if (address.protocol !== "https:" || !address.hostname.endsWith(".public.blob.vercel-storage.com")) throw new Error("Not the store's");
|
|
522
|
+
const kept = remembered.get(url);
|
|
523
|
+
if (kept) { remembered.delete(url); remembered.set(url, kept); return { body: kept, length: String(kept.length) }; }
|
|
524
|
+
let said = null;
|
|
525
|
+
for (let attempt = 1; attempt <= 2; attempt++) {
|
|
526
|
+
if (attempt > 1) await new Promise((done) => setTimeout(done, 1000 + Math.random() * 500));
|
|
527
|
+
// The time limit is for the store to start answering: a big file's body may take longer to pass on.
|
|
528
|
+
const waiting = new AbortController();
|
|
529
|
+
const timer = setTimeout(() => waiting.abort(), 10000);
|
|
530
|
+
let response;
|
|
531
|
+
try { response = await fetcher(url, { redirect: "error", signal: waiting.signal }); }
|
|
532
|
+
catch (error) { said = error?.message ?? String(error); continue; }
|
|
533
|
+
finally { clearTimeout(timer); }
|
|
534
|
+
const length = response.headers.get("content-length");
|
|
535
|
+
if (response.ok && response.body) {
|
|
536
|
+
if (length === null || Number(length) > REMEMBERED_FILE) return { body: response.body, length };
|
|
537
|
+
const bytes = Buffer.from(await response.arrayBuffer());
|
|
538
|
+
remember(url, bytes);
|
|
539
|
+
return { body: bytes, length: String(bytes.length) };
|
|
540
|
+
}
|
|
541
|
+
// The start of the store's words, read for a moment at most: a body that stalls never holds up the next try.
|
|
542
|
+
let words = "";
|
|
543
|
+
const reader = response.body?.getReader();
|
|
544
|
+
if (reader) {
|
|
545
|
+
const first = await Promise.race([reader.read().catch(() => null), new Promise((done) => setTimeout(done, 1000, null))]);
|
|
546
|
+
if (first?.value) words = Buffer.from(first.value).subarray(0, 100).toString("utf8").replace(/\s+/g, " ");
|
|
547
|
+
reader.cancel().catch(() => {});
|
|
548
|
+
}
|
|
549
|
+
said = [response.status, response.headers.get("x-vercel-error"), response.headers.get("x-vercel-mitigated") && `mitigated ${response.headers.get("x-vercel-mitigated")}`, words && `"${words}"`].filter(Boolean).join(" ");
|
|
550
|
+
if (!(response.status === 403 || response.status === 429 || response.status >= 500)) break;
|
|
551
|
+
}
|
|
552
|
+
throw new Error(`Part unavailable (${said})`);
|
|
553
|
+
},
|
|
554
|
+
// The page and its card are cached by Vercel's CDN, tagged with the wish (see `cacheTags`): a change or a removal
|
|
555
|
+
// takes them out at once rather than when they'd have run out.
|
|
556
|
+
async forget(id) {
|
|
557
|
+
try {
|
|
558
|
+
const { dangerouslyDeleteByTag } = await import("@vercel/functions");
|
|
559
|
+
await dangerouslyDeleteByTag(`wish-${id}`);
|
|
560
|
+
} catch {}
|
|
561
|
+
},
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
// A page's small files, as read from the store by this instance, the most recently served last: at most REMEMBERED in
|
|
566
|
+
// all, the least recently served going first.
|
|
567
|
+
const REMEMBERED = 96 * 1024 * 1024, REMEMBERED_FILE = 2 * 1024 * 1024;
|
|
568
|
+
const remembered = new Map();
|
|
569
|
+
let rememberedBytes = 0;
|
|
570
|
+
function remember(url, bytes) {
|
|
571
|
+
// Reads of one file at once each arrive here: the one already kept is counted out before its copy is counted in.
|
|
572
|
+
rememberedBytes -= remembered.get(url)?.length ?? 0;
|
|
573
|
+
remembered.delete(url);
|
|
574
|
+
remembered.set(url, bytes);
|
|
575
|
+
rememberedBytes += bytes.length;
|
|
576
|
+
for (const [oldest, kept] of remembered) {
|
|
577
|
+
if (rememberedBytes <= REMEMBERED) break;
|
|
578
|
+
remembered.delete(oldest);
|
|
579
|
+
rememberedBytes -= kept.length;
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/** Kept in memory, for `npm run dev` and the tests: pictures as data: URLs, so nothing else needs serving. */
|
|
584
|
+
export function memoryStore() {
|
|
585
|
+
const records = new Map(), pages = new Map(), ideas = new Map(), mailboxes = new Map(), makers = new Map();
|
|
586
|
+
const accounts = new Map(), emails = new Map(), keys = new Map(), waiting = new Map(), apps = new Map();
|
|
587
|
+
return {
|
|
588
|
+
limiter: null,
|
|
589
|
+
records,
|
|
590
|
+
accounts,
|
|
591
|
+
async account(name) { return accounts.has(name) ? structuredClone(accounts.get(name)) : null; },
|
|
592
|
+
async accountForEmail(email) { return emails.get(email.toLowerCase()) ?? null; },
|
|
593
|
+
async accountForKey(hash) { return keys.get(hash) ?? null; },
|
|
594
|
+
async createAccount(account) {
|
|
595
|
+
if (accounts.has(account.name) || emails.has(account.email.toLowerCase())) return false;
|
|
596
|
+
accounts.set(account.name, structuredClone(account)); emails.set(account.email.toLowerCase(), account.name); return true;
|
|
597
|
+
},
|
|
598
|
+
async addAccountKey(hash, name) { keys.set(hash, name); },
|
|
599
|
+
async waitForAccount(token, value) { waiting.set(token, structuredClone(value)); },
|
|
600
|
+
async accountWaiting(token) { return waiting.has(token) ? structuredClone(waiting.get(token)) : null; },
|
|
601
|
+
async doneWaiting(token) { waiting.delete(token); },
|
|
602
|
+
async claimApp(name, app, id) { const had = apps.get(`${name}/${app}`); if (had && had !== id) return false; apps.set(`${name}/${app}`, id); return true; },
|
|
603
|
+
async appID(name, app) { return apps.get(`${name}/${app}`) ?? null; },
|
|
604
|
+
async releaseApp(name, app) { apps.delete(`${name}/${app}`); },
|
|
605
|
+
async get(id) { return records.has(id) ? structuredClone(records.get(id)) : null; },
|
|
606
|
+
async create(record) { if (records.has(record.id)) return false; records.set(record.id, structuredClone(record)); return true; },
|
|
607
|
+
async replace(record) { if (!records.has(record.id)) return false; records.set(record.id, structuredClone(record)); return true; },
|
|
608
|
+
async swap(before, after) {
|
|
609
|
+
if (!records.has(after.id) || JSON.stringify(records.get(after.id)) !== JSON.stringify(before)) return false;
|
|
610
|
+
records.set(after.id, structuredClone(after));
|
|
611
|
+
return true;
|
|
612
|
+
},
|
|
613
|
+
async remove(id) { records.delete(id); ideas.delete(id); makers.delete(id); },
|
|
614
|
+
async maker(id) { return makers.has(id) ? structuredClone(makers.get(id)) : null; },
|
|
615
|
+
async setMaker(id, value) { if (value) makers.set(id, structuredClone(value)); else makers.delete(id); },
|
|
616
|
+
async makerStep(id, key, step) {
|
|
617
|
+
const maker = makers.get(id);
|
|
618
|
+
if (maker?.key !== key) return;
|
|
619
|
+
if (step === "confirm") maker.confirmed = true; else makers.delete(id);
|
|
620
|
+
},
|
|
621
|
+
async addIdea(id, idea) {
|
|
622
|
+
const kept = ideas.get(id) ?? { next: 0, all: new Map() };
|
|
623
|
+
if ([...kept.all.values()].filter((one) => !one.status).length >= IDEA_LIMITS.kept) return null;
|
|
624
|
+
const n = ++kept.next;
|
|
625
|
+
kept.all.set(n, { ...idea, n });
|
|
626
|
+
ideas.set(id, kept);
|
|
627
|
+
return n;
|
|
628
|
+
},
|
|
629
|
+
async ideas(id) { return [...(ideas.get(id)?.all.values() ?? [])].map((idea) => structuredClone(idea)).sort((a, b) => a.n - b.n); },
|
|
630
|
+
async removeIdea(id, n) { return ideas.get(id)?.all.delete(n) ?? false; },
|
|
631
|
+
async finishIdea(id, n, status, at, message) {
|
|
632
|
+
const idea = ideas.get(id)?.all.get(n);
|
|
633
|
+
if (!idea || idea.status) return null;
|
|
634
|
+
const before = structuredClone(idea);
|
|
635
|
+
Object.assign(idea, { status, done: at, last: at });
|
|
636
|
+
delete idea.waiting;
|
|
637
|
+
if (message) idea.thread = [...(idea.thread ?? []), message];
|
|
638
|
+
if (status === "dismissed" && !idea.thread) delete idea.email;
|
|
639
|
+
delete idea.picture;
|
|
640
|
+
return before;
|
|
641
|
+
},
|
|
642
|
+
async talk(id, n, op, value = "", extra = "") {
|
|
643
|
+
const idea = ideas.get(id)?.all.get(n);
|
|
644
|
+
if (!idea) return null;
|
|
645
|
+
const before = structuredClone(idea);
|
|
646
|
+
if (op === "say") {
|
|
647
|
+
if (idea.note && !idea.thread) { idea.thread = [{ by: "maker", text: idea.note, at: idea.done ?? idea.at }]; delete idea.note; }
|
|
648
|
+
const thread = idea.thread ?? [];
|
|
649
|
+
if (thread.length >= IDEA_LIMITS.thread || thread.some((one) => (value.mid && one.mid === value.mid) || (value.via && one.via === value.via))) return null;
|
|
650
|
+
idea.thread = [...thread, value];
|
|
651
|
+
idea.last = value.at;
|
|
652
|
+
if (value.by === "sender") idea.waiting = true; else delete idea.waiting;
|
|
653
|
+
} else if (op === "settle") delete idea.waiting;
|
|
654
|
+
else if (op === "box") { if (!idea.mailbox) Object.assign(idea, { mailbox: value, subject: extra }); }
|
|
655
|
+
else if (op === "makerbox") { if (!idea.makerBox) idea.makerBox = value; }
|
|
656
|
+
else if (op === "stop" && idea.mailbox === value) delete idea.email;
|
|
657
|
+
else return null;
|
|
658
|
+
return before;
|
|
659
|
+
},
|
|
660
|
+
async openMailbox(id, n, side = "sender") { const box = randomBytes(10).toString("hex"); mailboxes.set(box, { id, n, side }); return box; },
|
|
661
|
+
async mailbox(box) { return mailboxes.get(box) ?? null; },
|
|
662
|
+
async closeMailbox(box) { mailboxes.delete(box); },
|
|
663
|
+
async putImage(_id, _name, image) { return `data:${image.type};base64,${image.bytes.toString("base64")}`; },
|
|
664
|
+
async removeImages() {},
|
|
665
|
+
async putFile(_id, _name, bytes) { return `data:application/octet-stream;base64,${bytes.toString("base64")}`; },
|
|
666
|
+
async putPageFile(id, token, path, bytes) { const at = `memory:${id}/${token}/${path}`; pages.set(at, Buffer.from(bytes)); return at; },
|
|
667
|
+
async readFile(url) { return url.startsWith("memory:") ? pages.get(url) : Buffer.from(url.slice(url.indexOf(",") + 1), "base64"); },
|
|
668
|
+
async openFile(url) { const bytes = await this.readFile(url); return { body: bytes, length: String(bytes.length) }; },
|
|
669
|
+
async forget() {},
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
export const cacheTags = (id) => ({ "Vercel-Cache-Tag": `wish-${id}` });
|
|
674
|
+
|
|
675
|
+
// MARK: The API Lamp talks to
|
|
676
|
+
|
|
677
|
+
const reply = (status, body, headers = {}) => (body == null
|
|
678
|
+
? new Response(null, { status, headers: { "Cache-Control": "no-store", ...headers } })
|
|
679
|
+
: Response.json(body, { status, headers: { "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff", ...headers } }));
|
|
680
|
+
|
|
681
|
+
async function readBody(request) {
|
|
682
|
+
const length = request.headers.get("content-length");
|
|
683
|
+
if (length && (!/^\d+$/.test(length) || Number(length) > MAX_BODY_BYTES)) throw new RangeError();
|
|
684
|
+
if (!request.body) return "";
|
|
685
|
+
const reader = request.body.getReader();
|
|
686
|
+
const chunks = [];
|
|
687
|
+
let size = 0;
|
|
688
|
+
try {
|
|
689
|
+
while (true) {
|
|
690
|
+
const { value, done } = await reader.read();
|
|
691
|
+
if (done) break;
|
|
692
|
+
size += value.byteLength;
|
|
693
|
+
if (size > MAX_BODY_BYTES) { await reader.cancel(); throw new RangeError(); }
|
|
694
|
+
chunks.push(value);
|
|
695
|
+
}
|
|
696
|
+
} finally { reader.releaseLock(); }
|
|
697
|
+
return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(Buffer.concat(chunks));
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
const bearer = (request) => /^Bearer (\S+)$/.exec(request.headers.get("authorization") || "")?.[1];
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* POST shares a wish and answers its id, link and secret. PUT ?id= with the secret replaces what the link shows,
|
|
704
|
+
* keeping the link; DELETE ?id= with the secret takes it down. Lamp is the only caller: a browser on another site
|
|
705
|
+
* is refused, and nothing a request holds is logged.
|
|
706
|
+
*/
|
|
707
|
+
export function createShareHandler({ env = process.env, fetcher = fetch, store, limited = true, now = () => new Date() } = {}) {
|
|
708
|
+
return async (request) => {
|
|
709
|
+
const method = request.method;
|
|
710
|
+
if (!["GET", "POST", "PUT", "DELETE"].includes(method)) return reply(405, { error: "method_not_allowed" }, { Allow: "GET, POST, PUT, DELETE" });
|
|
711
|
+
const url = new URL(request.url);
|
|
712
|
+
const origin = request.headers.get("origin");
|
|
713
|
+
if (origin && origin !== url.origin) return reply(403, { error: "origin_not_allowed" });
|
|
714
|
+
const id = url.searchParams.get("id");
|
|
715
|
+
const user = url.searchParams.get("user"), app = url.searchParams.get("app");
|
|
716
|
+
const byName = method === "GET" && !id && typeof user === "string" && typeof app === "string" && /^[a-z0-9-]{1,60}$/.test(user) && /^[a-z0-9-]{1,60}$/.test(app);
|
|
717
|
+
if (method !== "POST" && !isID(id) && !byName) return reply(404, { error: "not_found" });
|
|
718
|
+
// Asked by the Lamps that follow this link: public, as the page is, and cheap (one read, no quota). By its id, or
|
|
719
|
+
// by its maker's username and its name there.
|
|
720
|
+
if (method === "GET") {
|
|
721
|
+
let kept;
|
|
722
|
+
try { kept = store ?? cloudStore(env, fetcher); } catch { return reply(503, { error: "service_unavailable" }); }
|
|
723
|
+
try {
|
|
724
|
+
const found = byName ? await kept.appID(user, app) : id;
|
|
725
|
+
const record = found ? await kept.get(found) : null;
|
|
726
|
+
return record ? reply(200, latest(record)) : reply(404, { error: "not_found" });
|
|
727
|
+
} catch { return reply(503, { error: "service_unavailable" }); }
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
let share = null;
|
|
731
|
+
if (method !== "DELETE") {
|
|
732
|
+
if (!/^application\/json(?:\s*;\s*charset=utf-8)?$/i.test(request.headers.get("content-type") || "")) return reply(415, { error: "json_required" });
|
|
733
|
+
try { share = readShare(JSON.parse(await readBody(request))); }
|
|
734
|
+
catch (error) { return reply(error instanceof RangeError ? 413 : 400, { error: "invalid_wish" }); }
|
|
735
|
+
if (!share) return reply(400, { error: "invalid_wish" });
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
let kept;
|
|
739
|
+
try { kept = store ?? cloudStore(env, fetcher); } catch { return reply(503, { error: "service_unavailable" }); }
|
|
740
|
+
if (limited) {
|
|
741
|
+
// Vercel overwrites these headers with the client's address. Only a keyed hash of it is kept, in expiring counters.
|
|
742
|
+
const ip = (request.headers.get("x-vercel-forwarded-for") || request.headers.get("x-forwarded-for") || "").trim();
|
|
743
|
+
if (!isIP(ip) || !kept.limiter) return reply(503, { error: "service_unavailable" });
|
|
744
|
+
let retry;
|
|
745
|
+
try { retry = await reserve(kept.limiter, ip, method === "DELETE" ? DELETE_QUOTAS : method === "PUT" ? UPDATE_QUOTAS : QUOTAS, fetcher); }
|
|
746
|
+
catch { return reply(503, { error: "service_unavailable" }); }
|
|
747
|
+
if (retry > 0) return reply(429, { error: "rate_limited" }, { "Retry-After": String(retry) });
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
let uploaded = [], claimed = null;
|
|
751
|
+
try {
|
|
752
|
+
const earlier = method === "POST" ? null : await kept.get(id);
|
|
753
|
+
if (method !== "POST" && (!earlier || !secretMatches(bearer(request), earlier.secret))) return reply(404, { error: "not_found" });
|
|
754
|
+
// Whose it is: a share sent with its maker's account key (their Lamp sends it) goes under their username, and an
|
|
755
|
+
// old link sent again with one goes there from then on. One without a key is shared as links always were.
|
|
756
|
+
const owner = method === "DELETE" ? null : await accountOf(kept, request.headers.get("x-lamp-account"));
|
|
757
|
+
const oldImages = [earlier?.picture?.url, earlier?.icon?.url].filter(Boolean);
|
|
758
|
+
if (method === "DELETE") {
|
|
759
|
+
const ideaShots = ideaPictures(await kept.ideas(id).catch(() => []));
|
|
760
|
+
await kept.remove(id);
|
|
761
|
+
if (earlier.owner && earlier.app) await kept.releaseApp(earlier.owner, earlier.app);
|
|
762
|
+
await kept.removeImages([...blobsOf(earlier), ...ideaShots]);
|
|
763
|
+
await kept.forget(id);
|
|
764
|
+
return reply(204);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
const wishID = method === "POST" ? newID() : id;
|
|
768
|
+
// Pictures go up before the record that names them; until it's kept they're taken back on any failure, or
|
|
769
|
+
// they'd stay public with nothing that leads to them.
|
|
770
|
+
uploaded = [];
|
|
771
|
+
const put = async (name, image) => { const at = await kept.putImage(wishID, name, image); uploaded.push(at); return at; };
|
|
772
|
+
const picture = share.picture && { url: await put("picture", share.picture), width: share.picture.width, height: share.picture.height };
|
|
773
|
+
const icon = share.icon && { url: await put("icon", share.icon) };
|
|
774
|
+
const time = now().toISOString();
|
|
775
|
+
const words = { name: share.name, by: share.by, line: share.line, words: share.words, ideas: share.ideas, asks: share.asks, shape: share.shape, github: share.github,
|
|
776
|
+
...(share.releases ? { releases: share.releases } : {}), picture: picture || null, icon: icon || null };
|
|
777
|
+
// Its name under the account stays what it was first given, whatever the app is called later.
|
|
778
|
+
const named = earlier?.owner ? { owner: earlier.owner, app: earlier.app }
|
|
779
|
+
: owner ? { owner, app: await claimAppName(kept, owner, words.name, wishID) } : {};
|
|
780
|
+
// A name claimed for this share now, let go again if the record can't be kept.
|
|
781
|
+
if (!earlier?.owner && named.owner) claimed = named;
|
|
782
|
+
if (method === "PUT") {
|
|
783
|
+
const record = { ...earlier, ...words, ...named, updated: time };
|
|
784
|
+
await kept.replace(record);
|
|
785
|
+
// Kept: the name is the record's now, whatever the cleanup after it does.
|
|
786
|
+
uploaded = [];
|
|
787
|
+
claimed = null;
|
|
788
|
+
await kept.removeImages(oldImages);
|
|
789
|
+
await kept.forget(id);
|
|
790
|
+
return reply(200, { id, url: linkOf(url.origin, record) });
|
|
791
|
+
}
|
|
792
|
+
const secret = newSecret();
|
|
793
|
+
const record = { id: wishID, ...words, ...named, secret: hashSecret(secret), created: time, updated: time };
|
|
794
|
+
if (!(await kept.create(record))) {
|
|
795
|
+
throw new Error("Id taken");
|
|
796
|
+
}
|
|
797
|
+
uploaded = [];
|
|
798
|
+
claimed = null;
|
|
799
|
+
return reply(201, { id: wishID, url: linkOf(url.origin, record), secret });
|
|
800
|
+
} catch {
|
|
801
|
+
try { await kept.removeImages(uploaded); } catch {}
|
|
802
|
+
try { if (claimed) await kept.releaseApp(claimed.owner, claimed.app); } catch {}
|
|
803
|
+
return reply(503, { error: "service_unavailable" });
|
|
804
|
+
}
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// MARK: Ideas for the maker
|
|
809
|
+
|
|
810
|
+
/** What someone playing a shared wish may send its maker: their idea, and an address to hear back at if they want. */
|
|
811
|
+
export const IDEA_LIMITS = { text: 1000, note: 2000, thread: 60, earlier: 100, name: 60, email: 200, picture: 400_000, kept: 500, asked: 20, keptDone: 90 * 86400_000 };
|
|
812
|
+
// A visitor sends a handful an hour; a link takes a few hundred a day; overall, a ceiling on a flood.
|
|
813
|
+
export const IDEA_QUOTAS = [
|
|
814
|
+
{ name: "idea-ip", perIP: true, limit: 12, seconds: 3600 },
|
|
815
|
+
{ name: "idea-ip-day", perIP: true, limit: 40, seconds: 86400 },
|
|
816
|
+
{ name: "idea-day", limit: 5000, seconds: 86400 },
|
|
817
|
+
];
|
|
818
|
+
// Confirmations to a maker's address: one a minute per link, and a few an hour from one place, a ceiling overall.
|
|
819
|
+
export const MAKER_QUOTAS = [
|
|
820
|
+
{ name: "maker-ip", perIP: true, limit: 6, seconds: 3600 },
|
|
821
|
+
{ name: "maker-day", limit: 1000, seconds: 86400 },
|
|
822
|
+
];
|
|
823
|
+
const MAKER_RESEND_MS = 60_000;
|
|
824
|
+
const EMAIL = /^[^\s@<>()",;:]+@[^\s@<>()",;:]+\.[^\s@<>()",;:]+$/;
|
|
825
|
+
// An idea's mailbox outlives its last message by a little more than the idea itself does (`IDEA_LIMITS.keptDone`).
|
|
826
|
+
const MAILBOX_SECONDS = 100 * 86400;
|
|
827
|
+
|
|
828
|
+
/** The domain of the address the site's emails come from (`LAMP_IDEAS_FROM`), for the message ids it gives them. */
|
|
829
|
+
const mailDomain = (env) => /@([^\s>]+)>?\s*$/.exec(env.LAMP_IDEAS_FROM ?? "")?.[1] ?? "lamp.dev";
|
|
830
|
+
/** A new message id for something the maker says, which the email carrying it goes out with. */
|
|
831
|
+
const newMessageID = (env) => `<idea.${randomBytes(12).toString("hex")}@${mailDomain(env)}>`;
|
|
832
|
+
|
|
833
|
+
/**
|
|
834
|
+
* Writes to an idea's sender through Resend (`RESEND_API_KEY`, from `LAMP_IDEAS_FROM`, an address on a domain Resend
|
|
835
|
+
* has verified): `kind` "in" when it's in (with the maker's note, if any), "reply" for something the maker says after.
|
|
836
|
+
* The first email gives the idea a mailbox: with `LAMP_IDEAS_REPLY_DOMAIN` set, a reply to any of them goes to
|
|
837
|
+
* `idea-<mailbox>@` it, and comes back to the idea's thread (`createIdeaMailHandler`); each also has a link to stop
|
|
838
|
+
* them. Every one after the first answers the one before, so they read as one conversation. False, and nothing
|
|
839
|
+
* sent, without an address or Resend, or when Resend refuses.
|
|
840
|
+
*/
|
|
841
|
+
export async function writeToSender(kept, wishID, idea, record, link, { env = process.env, fetcher = fetch, kind = "in", said, origin, once } = {}) {
|
|
842
|
+
const key = env.RESEND_API_KEY, from = env.LAMP_IDEAS_FROM;
|
|
843
|
+
if (!key || !from || !idea.email) return false;
|
|
844
|
+
// Only an address its owner has confirmed (one with an account) hears back.
|
|
845
|
+
if (!kept.accountForEmail || !(await kept.accountForEmail(idea.email.toLowerCase()))) return false;
|
|
846
|
+
let subject = idea.subject || (kind === "in" ? `Your idea is in ${record.name}` : `About your idea for ${record.name}`);
|
|
847
|
+
let box = idea.mailbox;
|
|
848
|
+
if (!box) {
|
|
849
|
+
// Two first emails at once each open one, and both go with whichever the idea kept.
|
|
850
|
+
box = await kept.openMailbox(wishID, idea.n);
|
|
851
|
+
const before = await kept.talk(wishID, idea.n, "box", box, subject);
|
|
852
|
+
if (before?.mailbox) {
|
|
853
|
+
try { await kept.closeMailbox(box); } catch {}
|
|
854
|
+
box = before.mailbox;
|
|
855
|
+
subject = before.subject || subject;
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
const by = record.by?.trim();
|
|
859
|
+
const maker = by || "Whoever made it";
|
|
860
|
+
const stop = `${origin}/api/wish-ideas?id=${wishID}&n=${idea.n}&stop=${box}`;
|
|
861
|
+
const replies = env.LAMP_IDEAS_REPLY_DOMAIN ? `idea-${box}@${env.LAMP_IDEAS_REPLY_DOMAIN}` : null;
|
|
862
|
+
const note = said?.text;
|
|
863
|
+
const text = (kind === "in"
|
|
864
|
+
? `${maker} added your idea to ${record.name}:\n\n${idea.text}\n\n` + (note ? `${by ? `${by} wrote` : "Their note"}:\n\n${note}\n\n` : "")
|
|
865
|
+
: `${by ? `${by} wrote` : "A note"} about your idea for ${record.name}:\n\n${note}\n\nYour idea: ${idea.text}\n\n`) +
|
|
866
|
+
`Play it: ${link}\n\n` + (replies ? `Reply to this email to answer ${by || "them"}.\n` : "") +
|
|
867
|
+
`No more emails about this idea: ${stop}`;
|
|
868
|
+
// The thread so far, the emails each way, for mail apps to keep them together.
|
|
869
|
+
const ids = (idea.thread ?? []).map((one) => one.mid).filter((mid) => typeof mid === "string" && /^<[^<>\s]+>$/.test(mid));
|
|
870
|
+
const headers = {
|
|
871
|
+
...(said?.mid ? { "Message-ID": said.mid } : {}),
|
|
872
|
+
...(ids.length ? { "In-Reply-To": ids.at(-1), References: ids.slice(-20).join(" ") } : {}),
|
|
873
|
+
"List-Unsubscribe": `<${stop}>`, "List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
|
|
874
|
+
};
|
|
875
|
+
try {
|
|
876
|
+
const response = await fetcher("https://api.resend.com/emails", {
|
|
877
|
+
method: "POST", redirect: "error", signal: AbortSignal.timeout(5000),
|
|
878
|
+
// Resend sends one email for a key however often it's asked within a day.
|
|
879
|
+
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json", ...(once ? { "Idempotency-Key": once } : {}) },
|
|
880
|
+
body: JSON.stringify({
|
|
881
|
+
from, to: [idea.email], subject: idea.mailbox ? `Re: ${subject}` : subject, text, headers,
|
|
882
|
+
...(replies ? { reply_to: [replies] } : {}),
|
|
883
|
+
}),
|
|
884
|
+
});
|
|
885
|
+
return response.ok;
|
|
886
|
+
} catch { return false; }
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/** Sends one email through Resend; false, and nothing sent, without Resend or when it refuses. */
|
|
890
|
+
export async function sendMail(env, fetcher, message, once) {
|
|
891
|
+
if (!env.RESEND_API_KEY || !env.LAMP_IDEAS_FROM) return false;
|
|
892
|
+
try {
|
|
893
|
+
const response = await fetcher("https://api.resend.com/emails", {
|
|
894
|
+
method: "POST", redirect: "error", signal: AbortSignal.timeout(5000),
|
|
895
|
+
headers: { Authorization: `Bearer ${env.RESEND_API_KEY}`, "Content-Type": "application/json", ...(once ? { "Idempotency-Key": once } : {}) },
|
|
896
|
+
body: JSON.stringify({ from: env.LAMP_IDEAS_FROM, ...message }),
|
|
897
|
+
});
|
|
898
|
+
return response.ok;
|
|
899
|
+
} catch { return false; }
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
/**
|
|
903
|
+
* Writes to a link's maker at their own address (`maker`, once they've confirmed it): `kind` "idea" for a new idea,
|
|
904
|
+
* "reply" for its sender's answer. The first gives the idea a mailbox of the maker's, and a reply to any of them comes
|
|
905
|
+
* back as the maker's own word on its thread, sent on to the sender (`createIdeaMailHandler`). Each idea's emails to
|
|
906
|
+
* the maker are one conversation of their own, and each has a link to stop them for the whole link.
|
|
907
|
+
*/
|
|
908
|
+
export async function writeToMaker(kept, wishID, idea, record, link, { env = process.env, fetcher = fetch, kind = "idea", said, origin, maker, once } = {}) {
|
|
909
|
+
if (!env.RESEND_API_KEY || !env.LAMP_IDEAS_FROM || !maker?.confirmed || !maker.email) return false;
|
|
910
|
+
let box = idea.makerBox;
|
|
911
|
+
if (!box) {
|
|
912
|
+
box = await kept.openMailbox(wishID, idea.n, "maker");
|
|
913
|
+
const before = await kept.talk(wishID, idea.n, "makerbox", box);
|
|
914
|
+
if (before?.makerBox) {
|
|
915
|
+
try { await kept.closeMailbox(box); } catch {}
|
|
916
|
+
box = before.makerBox;
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
const replies = env.LAMP_IDEAS_REPLY_DOMAIN ? `idea-${box}@${env.LAMP_IDEAS_REPLY_DOMAIN}` : null;
|
|
920
|
+
const stop = `${origin}/api/wish-ideas?id=${wishID}&stopmaker=${maker.key}`;
|
|
921
|
+
const flat = idea.text.replace(/\s+/g, " ");
|
|
922
|
+
const subject = `Idea for ${record.name}: ${[...flat].length > 60 ? `${[...flat].slice(0, 60).join("").trimEnd()}…` : flat}`;
|
|
923
|
+
const root = `<idea-${box}@${mailDomain(env)}>`;
|
|
924
|
+
const text = (kind === "idea"
|
|
925
|
+
? `${idea.name || "Someone"} playing ${record.name} sent an idea:\n\n${idea.text}\n\n` +
|
|
926
|
+
(typeof idea.picture === "string" && idea.picture.startsWith("https://") ? `The app as they saw it: ${idea.picture}\n\n` : "") +
|
|
927
|
+
(idea.email ? "They'd like to hear back.\n\n" : "They left no address, so what you say shows on the link's page.\n\n")
|
|
928
|
+
: `${idea.name || "They"} answered about their idea for ${record.name}:\n\n${said.text}\n\nTheir idea: ${idea.text}\n\n`) +
|
|
929
|
+
(replies ? "Reply to this email to answer them. It's in Lamp too.\n" : "Answer them in Lamp.\n") +
|
|
930
|
+
`Play it: ${link}\n\nNo more emails about ideas for ${record.name}: ${stop}`;
|
|
931
|
+
return sendMail(env, fetcher, {
|
|
932
|
+
to: [maker.email], subject: kind === "idea" ? subject : `Re: ${subject}`, text,
|
|
933
|
+
headers: {
|
|
934
|
+
...(kind === "idea" ? { "Message-ID": root } : { "In-Reply-To": root, References: root }),
|
|
935
|
+
"List-Unsubscribe": `<${stop}>`, "List-Unsubscribe-Post": "List-Unsubscribe=One-Click",
|
|
936
|
+
},
|
|
937
|
+
...(replies ? { reply_to: [replies] } : {}),
|
|
938
|
+
}, once);
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/**
|
|
942
|
+
* An idea as sent from a link's page, or null: its words, an address to hear back at only if it looks like one, and
|
|
943
|
+
* the picture of the app the page took when the sender opened the box, if they kept it in (a PNG or JPEG, base64).
|
|
944
|
+
*/
|
|
945
|
+
export function readIdea(body) {
|
|
946
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return null;
|
|
947
|
+
const text = tidy(body.text, IDEA_LIMITS.text, { lines: true });
|
|
948
|
+
if (!text) return null;
|
|
949
|
+
const shot = picture(body.picture, IDEA_LIMITS.picture);
|
|
950
|
+
if (!shot) return null;
|
|
951
|
+
// Their name, if they gave one, for the maker: shown in Lamp and in the maker's emails, where the address never is.
|
|
952
|
+
const name = body.name == null || body.name === "" ? "" : tidy(body.name, IDEA_LIMITS.name);
|
|
953
|
+
if (name == null) return null;
|
|
954
|
+
const idea = { text, ...(name ? { name } : {}), ...(shot.bytes ? { picture: shot } : {}) };
|
|
955
|
+
if (body.email == null || body.email === "") return idea;
|
|
956
|
+
const email = tidy(body.email, IDEA_LIMITS.email);
|
|
957
|
+
return email && EMAIL.test(email) ? { ...idea, email } : null;
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
/** The pictures a link's ideas carry, to go with them. */
|
|
961
|
+
const ideaPictures = (ideas) => ideas.map((one) => one.picture).filter((url) => typeof url === "string" && url.startsWith("https://"));
|
|
962
|
+
|
|
963
|
+
/**
|
|
964
|
+
* Ideas for a link's maker (`/api/wish-ideas?id=`). POST, from the link's page once the sender confirms it, keeps one:
|
|
965
|
+
* what someone using it would change, an address to hear back at, if they gave one, and the picture of the app the
|
|
966
|
+
* page took as they opened the box, if they kept it in (in the store, until the idea has its outcome). GET, with the
|
|
967
|
+
* link's secret, answers for Lamp to show the maker those still open and those whose sender has answered since
|
|
968
|
+
* (`waiting`), or with `earlier=1` those seen to, each with its picture's address and its thread: never the keys or the addresses, only whether someone
|
|
969
|
+
* wants to hear back. DELETE ?n= with the secret lets one go: with `built=1` it's in, and the site writes to whoever
|
|
970
|
+
* asked (`writeToSender`); without, it's dismissed. Its JSON body may carry the maker's `note`, the first of the idea's
|
|
971
|
+
* thread. PATCH ?n= with the secret and a `note` says something more, written to the sender if they gave an address;
|
|
972
|
+
* without one it only says the maker has seen to their answer. Every idea is kept a while past its last word, for the
|
|
973
|
+
* sender's own browser, which kept the number and a token the POST answered, to ask after it on a later visit (GET
|
|
974
|
+
* `?id=&mine=<n>.<token>,…`, answering only those, with the maker's last word), and for the sender's replies by email.
|
|
975
|
+
* A link in each email (`?stop=<mailbox>`, GET or a mail app's one-click POST) lets the address go. Never logged,
|
|
976
|
+
* never on the page for anyone else, gone with the link.
|
|
977
|
+
*/
|
|
978
|
+
export function createIdeasHandler({ env = process.env, fetcher = fetch, store, limited = true, now = () => new Date() } = {}) {
|
|
979
|
+
return async (request) => {
|
|
980
|
+
const method = request.method;
|
|
981
|
+
if (!["GET", "POST", "PUT", "PATCH", "DELETE"].includes(method)) return reply(405, { error: "method_not_allowed" }, { Allow: "GET, POST, PUT, PATCH, DELETE" });
|
|
982
|
+
const url = new URL(request.url);
|
|
983
|
+
const id = url.searchParams.get("id");
|
|
984
|
+
if (!isID(id)) return reply(404, { error: "not_found" });
|
|
985
|
+
const stop = url.searchParams.get("stop");
|
|
986
|
+
if (stop != null && (method === "GET" || method === "POST")) return stopEmails(id, Number(url.searchParams.get("n")), stop);
|
|
987
|
+
// The links in the maker's own emails: confirming their address, and stopping them.
|
|
988
|
+
for (const step of ["confirm", "stopmaker"]) {
|
|
989
|
+
const given = url.searchParams.get(step);
|
|
990
|
+
if (given != null && (method === "GET" || method === "POST")) return makerLink(id, step, given);
|
|
991
|
+
}
|
|
992
|
+
const origin = request.headers.get("origin");
|
|
993
|
+
if (origin && origin !== url.origin) return reply(403, { error: "origin_not_allowed" });
|
|
994
|
+
|
|
995
|
+
let idea = null;
|
|
996
|
+
if (method === "POST") {
|
|
997
|
+
if (!/^application\/json(?:\s*;\s*charset=utf-8)?$/i.test(request.headers.get("content-type") || "")) return reply(415, { error: "json_required" });
|
|
998
|
+
try { idea = readIdea(JSON.parse(await readBody(request))); }
|
|
999
|
+
catch (error) { return reply(error instanceof RangeError ? 413 : 400, { error: "invalid_idea" }); }
|
|
1000
|
+
if (!idea) return reply(400, { error: "invalid_idea" });
|
|
1001
|
+
}
|
|
1002
|
+
// The maker's own address for the link's ideas (PUT ?maker=1 with the secret): checked here, kept once the secret is.
|
|
1003
|
+
let makerEmail;
|
|
1004
|
+
if (method === "PUT") {
|
|
1005
|
+
if (url.searchParams.get("maker") !== "1") return reply(405, { error: "method_not_allowed" });
|
|
1006
|
+
let given;
|
|
1007
|
+
try { given = JSON.parse((await readBody(request)) || "{}")?.email; }
|
|
1008
|
+
catch (error) { return reply(error instanceof RangeError ? 413 : 400, { error: "invalid_email" }); }
|
|
1009
|
+
makerEmail = given == null || given === "" ? "" : tidy(given, IDEA_LIMITS.email);
|
|
1010
|
+
if (makerEmail === null || (makerEmail && !EMAIL.test(makerEmail))) return reply(400, { error: "invalid_email" });
|
|
1011
|
+
}
|
|
1012
|
+
const n = method === "DELETE" || method === "PATCH" ? Number(url.searchParams.get("n")) : null;
|
|
1013
|
+
if (n !== null && (!Number.isInteger(n) || n < 1)) return reply(400, { error: "invalid_idea" });
|
|
1014
|
+
// The maker's note to the sender, if they wrote one: a body with nothing in it, or none, is no note.
|
|
1015
|
+
let note = null;
|
|
1016
|
+
if (n !== null && /^application\/json/i.test(request.headers.get("content-type") || "")) {
|
|
1017
|
+
let given;
|
|
1018
|
+
try { given = JSON.parse((await readBody(request)) || "{}")?.note; }
|
|
1019
|
+
catch (error) { return reply(error instanceof RangeError ? 413 : 400, { error: "invalid_idea" }); }
|
|
1020
|
+
if (given != null && given !== "") {
|
|
1021
|
+
note = tidy(given, IDEA_LIMITS.note, { lines: true });
|
|
1022
|
+
if (!note) return reply(400, { error: "invalid_idea" });
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
let kept;
|
|
1027
|
+
try { kept = store ?? cloudStore(env, fetcher); } catch { return reply(503, { error: "service_unavailable" }); }
|
|
1028
|
+
// A new idea, or a confirmation to a maker's address: each an email or more, so each is counted.
|
|
1029
|
+
if (limited && (method === "POST" || (method === "PUT" && makerEmail))) {
|
|
1030
|
+
const ip = (request.headers.get("x-vercel-forwarded-for") || request.headers.get("x-forwarded-for") || "").trim();
|
|
1031
|
+
if (!isIP(ip) || !kept.limiter) return reply(503, { error: "service_unavailable" });
|
|
1032
|
+
let retry;
|
|
1033
|
+
try { retry = await reserve(kept.limiter, ip, method === "POST" ? IDEA_QUOTAS : MAKER_QUOTAS, fetcher); }
|
|
1034
|
+
catch { return reply(503, { error: "service_unavailable" }); }
|
|
1035
|
+
if (retry > 0) return reply(429, { error: "rate_limited" }, { "Retry-After": String(retry) });
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
try {
|
|
1039
|
+
const record = await kept.get(id);
|
|
1040
|
+
if (!record) return reply(404, { error: "not_found" });
|
|
1041
|
+
if (method === "POST") {
|
|
1042
|
+
const token = randomBytes(12).toString("base64url");
|
|
1043
|
+
// The picture goes to the store first, at an address with a random part, and goes again if the idea can't.
|
|
1044
|
+
const { picture: shot, ...given } = idea;
|
|
1045
|
+
// From a signed-in browser, it's by that username, and its news goes to the account's address. Otherwise an
|
|
1046
|
+
// address not yet confirmed is asked to be, once the idea is kept.
|
|
1047
|
+
let words = given, confirming = false;
|
|
1048
|
+
const user = await accountOf(kept, cookieKey(request));
|
|
1049
|
+
const account = user ? await kept.account(user) : null;
|
|
1050
|
+
if (account) words = { ...given, name: user, user, email: account.email };
|
|
1051
|
+
else if (given.email) confirming = !(await kept.accountForEmail(given.email.toLowerCase()));
|
|
1052
|
+
const at = shot ? await kept.putImage(id, "idea", shot) : null;
|
|
1053
|
+
let number = null;
|
|
1054
|
+
const added = { ...words, ...(at ? { picture: at } : {}), token, at: now().toISOString() };
|
|
1055
|
+
try { number = await kept.addIdea(id, added); }
|
|
1056
|
+
finally { if (!number && at) { try { await kept.removeImages([at]); } catch {} } }
|
|
1057
|
+
if (!number) return reply(429, { error: "full" });
|
|
1058
|
+
// To the maker's own address too, if they gave one, and the sender asked to confirm theirs: both at once, so the
|
|
1059
|
+
// answer that carries the sender's token comes inside the page's wait. The idea is kept whether or not they go.
|
|
1060
|
+
const [, confirm] = await Promise.all([
|
|
1061
|
+
(async () => {
|
|
1062
|
+
const maker = await kept.maker(id);
|
|
1063
|
+
if (maker?.confirmed) await writeToMaker(kept, id, { ...added, n: number }, record, linkOf(url.origin, record), { env, fetcher, origin: url.origin, maker, once: `maker-${id}-${number}` });
|
|
1064
|
+
})().catch(() => {}),
|
|
1065
|
+
confirming ? askToConfirm(kept, { email: words.email, name: words.name, app: record.name, origin: url.origin, env, fetcher }).catch(() => false) : false,
|
|
1066
|
+
]);
|
|
1067
|
+
return reply(201, { n: number, token, ...(user ? { user } : {}), ...(confirm ? { confirm: true } : {}) });
|
|
1068
|
+
}
|
|
1069
|
+
// The sender's own ideas, asked by number and token from their browser: what became of each, and the maker's
|
|
1070
|
+
// last word to them, nothing else.
|
|
1071
|
+
const mine = method === "GET" ? url.searchParams.get("mine") : null;
|
|
1072
|
+
if (mine != null) {
|
|
1073
|
+
const asked = new Map(mine.split(",").slice(0, IDEA_LIMITS.asked).map((one) => one.split("."))
|
|
1074
|
+
.filter(([n, token]) => /^\d{1,6}$/.test(n ?? "") && /^[A-Za-z0-9_-]{16}$/.test(token ?? "")).map(([n, token]) => [Number(n), token]));
|
|
1075
|
+
const all = await kept.ideas(id);
|
|
1076
|
+
return reply(200, { ideas: all.filter((one) => one.token && asked.get(one.n) === one.token).map((one) => {
|
|
1077
|
+
// A note given before ideas had threads was kept on its own.
|
|
1078
|
+
const last = (one.thread ?? []).filter((said) => said.by === "maker").at(-1)?.text ?? one.note;
|
|
1079
|
+
return { n: one.n, status: one.status ?? "open", ...(one.done ? { done: one.done } : {}), ...(last ? { note: last } : {}) };
|
|
1080
|
+
}) });
|
|
1081
|
+
}
|
|
1082
|
+
if (!secretMatches(bearer(request), record.secret)) return reply(404, { error: "not_found" });
|
|
1083
|
+
if (url.searchParams.get("maker") === "1" && (method === "GET" || method === "PUT")) return setMaker(kept, record, id, url.origin, method === "PUT" ? makerEmail : undefined);
|
|
1084
|
+
// The maker sees the ideas still open, and those answered since, and whether someone wants to hear back, never
|
|
1085
|
+
// their address: the site writes to them. Those settled are kept a while past their last word, then go.
|
|
1086
|
+
if (method === "GET") {
|
|
1087
|
+
const all = await kept.ideas(id);
|
|
1088
|
+
const old = all.filter((one) => one.done && now() - new Date(one.last ?? one.done) > IDEA_LIMITS.keptDone);
|
|
1089
|
+
for (const one of old) {
|
|
1090
|
+
await kept.removeIdea(id, one.n);
|
|
1091
|
+
for (const box of [one.mailbox, one.makerBox]) if (box) { try { await kept.closeMailbox(box); } catch {} }
|
|
1092
|
+
}
|
|
1093
|
+
try { await kept.removeImages(ideaPictures(old)); } catch {}
|
|
1094
|
+
// `earlier=1`: those seen to, the latest word first, for the maker to look back on or answer again.
|
|
1095
|
+
if (url.searchParams.get("earlier") === "1") {
|
|
1096
|
+
const settled = all.filter((one) => one.status && !one.waiting && !old.includes(one))
|
|
1097
|
+
.sort((a, b) => String(b.last ?? b.done).localeCompare(String(a.last ?? a.done)));
|
|
1098
|
+
return reply(200, { ideas: settled.slice(0, IDEA_LIMITS.earlier).map(shownToMaker) });
|
|
1099
|
+
}
|
|
1100
|
+
return reply(200, { ideas: all.filter((one) => !one.status || one.waiting).map(shownToMaker) });
|
|
1101
|
+
}
|
|
1102
|
+
const link = linkOf(url.origin, record);
|
|
1103
|
+
const write = (to, kind, said, once) => writeToSender(kept, id, to, record, link, { env, fetcher, kind, said, origin: url.origin, once });
|
|
1104
|
+
const said = note ? { by: "maker", text: note, at: now().toISOString(), mid: newMessageID(env) } : null;
|
|
1105
|
+
if (method === "PATCH") {
|
|
1106
|
+
if (!said) return (await kept.talk(id, n, "settle")) ? reply(200, { notified: false }) : reply(404, { error: "not_found" });
|
|
1107
|
+
const was = await kept.talk(id, n, "say", said);
|
|
1108
|
+
if (!was) return (await kept.ideas(id)).some((one) => one.n === n) ? reply(429, { error: "full" }) : reply(404, { error: "not_found" });
|
|
1109
|
+
return reply(200, { notified: await write(was, "reply", said, `idea-${id}-${n}-${said.mid}`) });
|
|
1110
|
+
}
|
|
1111
|
+
// An outcome, given once: only the call that gave it writes to the sender, so two at once, or one tried again,
|
|
1112
|
+
// send one email.
|
|
1113
|
+
const built = url.searchParams.get("built") === "1";
|
|
1114
|
+
// Its picture was for the maker, who has seen to it: it goes first, so a store that turns that away leaves the
|
|
1115
|
+
// idea open, with its picture, to be let go again, rather than the picture left with nothing pointing to it.
|
|
1116
|
+
const open = (await kept.ideas(id)).find((one) => one.n === n && !one.status);
|
|
1117
|
+
if (open) await kept.removeImages(ideaPictures([open]));
|
|
1118
|
+
const was = await kept.finishIdea(id, n, built ? "in" : "dismissed", now().toISOString(), said);
|
|
1119
|
+
const notified = built && was?.email ? await write(was, "in", said, `idea-${id}-${n}`) : false;
|
|
1120
|
+
return reply(200, { notified });
|
|
1121
|
+
} catch { return reply(503, { error: "service_unavailable" }); }
|
|
1122
|
+
};
|
|
1123
|
+
|
|
1124
|
+
/**
|
|
1125
|
+
* The maker's own address: as it is (`email` undefined), or given (asked to confirm it first, by an email with a
|
|
1126
|
+
* link in it, unless it's the one they confirmed already) or taken away (""). Never anyone else's to see.
|
|
1127
|
+
*/
|
|
1128
|
+
async function setMaker(kept, record, id, origin, email) {
|
|
1129
|
+
let maker = await kept.maker(id);
|
|
1130
|
+
let sent = false;
|
|
1131
|
+
if (email === "") { await kept.setMaker(id, null); maker = null; }
|
|
1132
|
+
else if (email !== undefined && !(maker?.confirmed && maker.email.toLowerCase() === email.toLowerCase())) {
|
|
1133
|
+
// Send Again, or another address, at most once a minute for a link.
|
|
1134
|
+
const since = now() - new Date(maker?.asked ?? 0);
|
|
1135
|
+
if (since < MAKER_RESEND_MS) return reply(429, { error: "rate_limited" }, { "Retry-After": String(Math.ceil((MAKER_RESEND_MS - since) / 1000)) });
|
|
1136
|
+
maker = { email, confirmed: false, key: randomBytes(10).toString("hex"), asked: now().toISOString() };
|
|
1137
|
+
await kept.setMaker(id, maker);
|
|
1138
|
+
sent = await sendMail(env, fetcher, {
|
|
1139
|
+
to: [email], subject: `Get ideas for ${record.name} by email`,
|
|
1140
|
+
text: `To get the ideas people send for ${record.name}, and their replies, at this address, confirm it here:\n\n` +
|
|
1141
|
+
`${origin}/api/wish-ideas?id=${id}&confirm=${maker.key}\n\nIf you didn't ask for this, you can ignore this email.`,
|
|
1142
|
+
});
|
|
1143
|
+
}
|
|
1144
|
+
return reply(200, { email: maker?.email ?? null, confirmed: maker?.confirmed === true, ...(email && !maker?.confirmed ? { sent } : {}) });
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
/** A link from the maker's emails: their address confirmed, or let go, if the key is theirs. */
|
|
1148
|
+
async function makerLink(id, step, key) {
|
|
1149
|
+
if (!/^[0-9a-f]{20}$/.test(key)) return reply(404, { error: "not_found" });
|
|
1150
|
+
try {
|
|
1151
|
+
const kept = store ?? cloudStore(env, fetcher);
|
|
1152
|
+
await kept.makerStep(id, key, step === "confirm" ? "confirm" : "stop");
|
|
1153
|
+
} catch { return reply(503, { error: "service_unavailable" }); }
|
|
1154
|
+
const words = step === "confirm" ? "Ideas for this link, and replies to them, will come to your email." : "You won't get any more emails about this link's ideas.";
|
|
1155
|
+
return new Response(`${words}\n`, { status: 200, headers: { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" } });
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
/** A link from one of the emails: the idea's address goes, if the mailbox is the idea's own. */
|
|
1159
|
+
async function stopEmails(id, n, box) {
|
|
1160
|
+
if (!Number.isInteger(n) || n < 1 || !/^[0-9a-f]{20}$/.test(box)) return reply(404, { error: "not_found" });
|
|
1161
|
+
try {
|
|
1162
|
+
const kept = store ?? cloudStore(env, fetcher);
|
|
1163
|
+
await kept.talk(id, n, "stop", box);
|
|
1164
|
+
} catch { return reply(503, { error: "service_unavailable" }); }
|
|
1165
|
+
// The same words whether or not it was theirs, so the link says nothing about an idea to anyone else.
|
|
1166
|
+
return new Response("You won't get any more emails about this idea.\n", { status: 200, headers: { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" } });
|
|
1167
|
+
}
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
/**
|
|
1171
|
+
* An idea as its maker sees it: never its sender's address, its token or its mailbox, only whether they want to hear.
|
|
1172
|
+
* A note given before ideas had threads is shown as the start of one.
|
|
1173
|
+
*/
|
|
1174
|
+
const shownToMaker = ({ email, token: _token, mailbox: _mailbox, makerBox: _makerBox, subject: _subject, note, ...given }) => {
|
|
1175
|
+
const shown = note && !given.thread ? { ...given, thread: [{ by: "maker", text: note, at: given.done ?? given.at }] } : given;
|
|
1176
|
+
return {
|
|
1177
|
+
...shown,
|
|
1178
|
+
...(shown.thread ? { thread: shown.thread.map(({ by, text, at }) => ({ by, text, at })) } : {}),
|
|
1179
|
+
...(email ? { wantsNews: true } : {}),
|
|
1180
|
+
};
|
|
1181
|
+
};
|
|
1182
|
+
|
|
1183
|
+
/** The address in a From line, in lower case: `Someone <a@b.c>` or `a@b.c`. */
|
|
1184
|
+
const addressIn = (from) => (/<([^<>\s]+@[^<>\s]+)>/.exec(from ?? "")?.[1] ?? String(from ?? "").trim()).toLowerCase();
|
|
1185
|
+
|
|
1186
|
+
/**
|
|
1187
|
+
* A reply's own words: what's above the earlier email it quotes (a line "On … wrote:", a "-----Original Message-----"
|
|
1188
|
+
* or a "From:" line after a blank one), without lines quoted with ">", tidied and cut to `IDEA_LIMITS.note`.
|
|
1189
|
+
*/
|
|
1190
|
+
export function replyWords(text) {
|
|
1191
|
+
const lines = String(text ?? "").replace(/\r\n?/g, "\n").split("\n");
|
|
1192
|
+
const kept = [];
|
|
1193
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1194
|
+
const line = lines[i];
|
|
1195
|
+
const two = `${line} ${lines[i + 1] ?? ""}`;
|
|
1196
|
+
if (/^\s*-{2,}\s*Original Message\s*-{2,}/i.test(line) || /^\s*_{10,}\s*$/.test(line)) break;
|
|
1197
|
+
if (/^\s*On\b.{0,300}\bwrote:\s*$/i.test(line) || (/^\s*On\b/i.test(line) && /^\s*On\b.{0,300}\bwrote:\s*$/i.test(two))) break;
|
|
1198
|
+
if (/^\s*From:\s/i.test(line) && kept.length && kept.at(-1).trim() === "") break;
|
|
1199
|
+
if (/^\s*>/.test(line)) continue;
|
|
1200
|
+
kept.push(line);
|
|
1201
|
+
}
|
|
1202
|
+
const words = kept.join("\n").trim();
|
|
1203
|
+
return tidy([...words].slice(0, IDEA_LIMITS.note).join(""), IDEA_LIMITS.note, { lines: true });
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
/**
|
|
1207
|
+
* Whether a webhook from Resend is its own (`RESEND_WEBHOOK_SECRET`, `whsec_` and base64): Svix's signature, an
|
|
1208
|
+
* HMAC-SHA256 of `<id>.<timestamp>.<body>`, sent no more than five minutes ago.
|
|
1209
|
+
*/
|
|
1210
|
+
export function signedByResend(headers, body, secret, now = Date.now()) {
|
|
1211
|
+
const id = headers.get("svix-id"), at = headers.get("svix-timestamp"), signatures = headers.get("svix-signature");
|
|
1212
|
+
if (!secret || !id || !at || !signatures || !/^\d+$/.test(at) || Math.abs(now / 1000 - Number(at)) > 300) return false;
|
|
1213
|
+
const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
|
|
1214
|
+
const expected = createHmac("sha256", key).update(`${id}.${at}.${body}`).digest();
|
|
1215
|
+
return signatures.split(" ").some((one) => {
|
|
1216
|
+
const [version, signature] = one.split(",");
|
|
1217
|
+
if (version !== "v1" || !signature) return false;
|
|
1218
|
+
const given = Buffer.from(signature, "base64");
|
|
1219
|
+
return given.length === expected.length && timingSafeEqual(given, expected);
|
|
1220
|
+
});
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
/**
|
|
1224
|
+
* Replies to an idea's emails (`/api/idea-mail`, Resend's `email.received` webhook, for mail to
|
|
1225
|
+
* `LAMP_IDEAS_REPLY_DOMAIN`). Each to `idea-<mailbox>@` it goes on that idea's thread, its own words only: from the
|
|
1226
|
+
* sender's mailbox, only from the address the idea was sent with, leaving it waiting on the maker and sent on to the
|
|
1227
|
+
* maker's own address if they gave one; from the maker's, only from their confirmed address, as their word, sent on to
|
|
1228
|
+
* the sender. Anything else is let go. Answers 200 to
|
|
1229
|
+
* whatever Resend signed, so it isn't sent again, and 401 to anything else.
|
|
1230
|
+
*/
|
|
1231
|
+
export function createIdeaMailHandler({ env = process.env, fetcher = fetch, store, now = () => new Date() } = {}) {
|
|
1232
|
+
return async (request) => {
|
|
1233
|
+
if (request.method !== "POST") return reply(405, { error: "method_not_allowed" }, { Allow: "POST" });
|
|
1234
|
+
let body;
|
|
1235
|
+
try { body = await readBody(request); } catch { return reply(413, { error: "too_large" }); }
|
|
1236
|
+
if (!signedByResend(request.headers, body, env.RESEND_WEBHOOK_SECRET, now().getTime())) return reply(401, { error: "unsigned" });
|
|
1237
|
+
let event;
|
|
1238
|
+
try { event = JSON.parse(body); } catch { return reply(200, {}); }
|
|
1239
|
+
const mail = event?.data;
|
|
1240
|
+
const domain = (env.LAMP_IDEAS_REPLY_DOMAIN ?? "").toLowerCase();
|
|
1241
|
+
if (event?.type !== "email.received" || !domain || typeof mail?.email_id !== "string") return reply(200, {});
|
|
1242
|
+
const boxes = [...new Set([...(mail.to ?? []), ...(mail.received_for ?? [])].map(addressIn)
|
|
1243
|
+
.map((to) => new RegExp(`^idea-([0-9a-f]{20})@${domain.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`).exec(to)?.[1]).filter(Boolean))];
|
|
1244
|
+
if (!boxes.length) return reply(200, {});
|
|
1245
|
+
const origin = new URL(request.url).origin;
|
|
1246
|
+
try {
|
|
1247
|
+
const kept = store ?? cloudStore(env, fetcher);
|
|
1248
|
+
for (const box of boxes.slice(0, 3)) {
|
|
1249
|
+
const found = await kept.mailbox(box);
|
|
1250
|
+
if (!found) continue;
|
|
1251
|
+
const idea = (await kept.ideas(found.id)).find((one) => one.n === found.n);
|
|
1252
|
+
if (!idea) continue;
|
|
1253
|
+
const maker = await kept.maker(found.id);
|
|
1254
|
+
// Only from the address the idea's emails went to on that side, and only while it's kept.
|
|
1255
|
+
const from = addressIn(mail.from);
|
|
1256
|
+
const fromMaker = found.side === "maker";
|
|
1257
|
+
if (fromMaker ? !(maker?.confirmed && idea.makerBox === box && from === maker.email.toLowerCase())
|
|
1258
|
+
: !(idea.email && idea.mailbox === box && from === idea.email.toLowerCase())) continue;
|
|
1259
|
+
const response = await fetcher(`https://api.resend.com/emails/receiving/${encodeURIComponent(mail.email_id)}`, {
|
|
1260
|
+
redirect: "error", signal: AbortSignal.timeout(5000), headers: { Authorization: `Bearer ${env.RESEND_API_KEY}` },
|
|
1261
|
+
});
|
|
1262
|
+
if (!response.ok) return reply(503, { error: "service_unavailable" });
|
|
1263
|
+
const got = await response.json();
|
|
1264
|
+
const words = replyWords(got.text ?? String(got.html ?? "").replace(/<br\s*\/?>|<\/p>|<\/div>/gi, "\n").replace(/<[^>]*>/g, "")
|
|
1265
|
+
.replace(/ /g, " ").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, '"').replace(/'/g, "'").replace(/&/g, "&"));
|
|
1266
|
+
if (!words) continue;
|
|
1267
|
+
const mid = typeof mail.message_id === "string" && /^<[^<>\s]+>$/.test(mail.message_id) ? mail.message_id : undefined;
|
|
1268
|
+
const record = await kept.get(found.id);
|
|
1269
|
+
if (!record) continue;
|
|
1270
|
+
const link = linkOf(origin, record);
|
|
1271
|
+
// The email as Resend received it, which a webhook sent again names the same, keeps it from going on twice.
|
|
1272
|
+
const via = `resend:${mail.email_id}`;
|
|
1273
|
+
if (fromMaker) {
|
|
1274
|
+
// The maker's own word, as if said in Lamp: on the thread, and on to the sender, with a message id of its own.
|
|
1275
|
+
const said = { by: "maker", text: words, at: now().toISOString(), mid: newMessageID(env), via };
|
|
1276
|
+
const was = await kept.talk(found.id, found.n, "say", said);
|
|
1277
|
+
if (was?.email) await writeToSender(kept, found.id, was, record, link, { env, fetcher, kind: "reply", said, origin, once: `idea-${found.id}-${found.n}-${said.mid}` });
|
|
1278
|
+
} else {
|
|
1279
|
+
const said = { by: "sender", text: words, at: now().toISOString(), via, ...(mid ? { mid } : {}) };
|
|
1280
|
+
const was = await kept.talk(found.id, found.n, "say", said);
|
|
1281
|
+
if (was && maker?.confirmed) await writeToMaker(kept, found.id, was, record, link, { env, fetcher, kind: "reply", said, origin, maker, once: `maker-${found.id}-${found.n}-${mail.email_id}` });
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
} catch { return reply(503, { error: "service_unavailable" }); }
|
|
1285
|
+
return reply(200, {});
|
|
1286
|
+
};
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
/** A shared wish for the page and its card, or null when there's none by that id (or it can't be read now). */
|
|
1290
|
+
export async function findWish(id, { env = process.env, fetcher = fetch, store } = {}) {
|
|
1291
|
+
if (!isID(id)) return null;
|
|
1292
|
+
try { return shown(await (store ?? cloudStore(env, fetcher)).get(id)); }
|
|
1293
|
+
catch { return undefined; }
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
/** Takes a shared wish down without its secret: for the Lamp team, when a link shows what it mustn't. */
|
|
1297
|
+
export async function takeDown(id, { env = process.env, fetcher = fetch } = {}) {
|
|
1298
|
+
if (!isID(id)) return false;
|
|
1299
|
+
const kept = cloudStore(env, fetcher);
|
|
1300
|
+
const record = await kept.get(id);
|
|
1301
|
+
if (!record) return false;
|
|
1302
|
+
const ideaShots = ideaPictures(await kept.ideas(id).catch(() => []));
|
|
1303
|
+
await kept.remove(id);
|
|
1304
|
+
await kept.removeImages([...blobsOf(record), ...ideaShots]);
|
|
1305
|
+
await kept.forget(id);
|
|
1306
|
+
return true;
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
// MARK: The wish's page
|
|
1310
|
+
|
|
1311
|
+
/** The most a playable page may hold: as many bytes as the wish's file, in at most this many files. */
|
|
1312
|
+
// A page is sent packed within `FILE_LIMITS.total`; what it unpacks to may be more, since a big 3D game's data packs small.
|
|
1313
|
+
export const PAGE_LIMITS = { files: 3000, total: 128_000_000 };
|
|
1314
|
+
|
|
1315
|
+
/** How many of a page's files go to the blob store at once as it's put together. */
|
|
1316
|
+
const PAGE_UPLOADS = 12;
|
|
1317
|
+
|
|
1318
|
+
/**
|
|
1319
|
+
* A zip's files, read from its central directory: stored or deflated, never a link, a folder, an absolute path or one
|
|
1320
|
+
* that steps up. Null for anything else, or past the limits, counting what they unpack to.
|
|
1321
|
+
*/
|
|
1322
|
+
export function readZip(bytes, limits = PAGE_LIMITS) {
|
|
1323
|
+
if (bytes.length < 22 || bytes.readUInt32LE(0) !== 0x04034b50) return null;
|
|
1324
|
+
const end = bytes.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06]));
|
|
1325
|
+
if (end < Math.max(0, bytes.length - 65_557)) return null;
|
|
1326
|
+
const count = bytes.readUInt16LE(end + 10), size = bytes.readUInt32LE(end + 12), offset = bytes.readUInt32LE(end + 16);
|
|
1327
|
+
if (offset + size > end || count > limits.files) return null;
|
|
1328
|
+
const files = [];
|
|
1329
|
+
let total = 0;
|
|
1330
|
+
for (let at = offset, n = 0; n < count; n++) {
|
|
1331
|
+
if (at + 46 > end || bytes.readUInt32LE(at) !== 0x02014b50) return null;
|
|
1332
|
+
const method = bytes.readUInt16LE(at + 10), packed = bytes.readUInt32LE(at + 20), unpacked = bytes.readUInt32LE(at + 24);
|
|
1333
|
+
const nameLength = bytes.readUInt16LE(at + 28), extra = bytes.readUInt16LE(at + 30), comment = bytes.readUInt16LE(at + 32);
|
|
1334
|
+
const external = bytes.readUInt32LE(at + 38), local = bytes.readUInt32LE(at + 42);
|
|
1335
|
+
const path = bytes.toString("utf8", at + 46, at + 46 + nameLength);
|
|
1336
|
+
at += 46 + nameLength + extra + comment;
|
|
1337
|
+
if (path.endsWith("/")) continue;
|
|
1338
|
+
// A link (its Unix mode in the high bits) never goes up.
|
|
1339
|
+
if (((external >>> 16) & 0o170000) === 0o120000) return null;
|
|
1340
|
+
if (!path || path.startsWith("/") || path.includes("\\") || path.split("/").some((part) => part === ".." || part === "" || part === ".")) return null;
|
|
1341
|
+
if (path.split("/").some((part) => part.startsWith(".")) || path.startsWith("__MACOSX/")) continue;
|
|
1342
|
+
total += unpacked;
|
|
1343
|
+
if (total > limits.total) return null;
|
|
1344
|
+
if (local + 30 > bytes.length || bytes.readUInt32LE(local) !== 0x04034b50) return null;
|
|
1345
|
+
const start = local + 30 + bytes.readUInt16LE(local + 26) + bytes.readUInt16LE(local + 28);
|
|
1346
|
+
const data = bytes.subarray(start, start + packed);
|
|
1347
|
+
let content;
|
|
1348
|
+
try { content = method === 0 ? Buffer.from(data) : method === 8 ? inflateRawSync(data, { maxOutputLength: Math.max(1, unpacked) }) : null; }
|
|
1349
|
+
catch { return null; }
|
|
1350
|
+
if (!content || content.length !== unpacked) return null;
|
|
1351
|
+
files.push({ path, bytes: content });
|
|
1352
|
+
}
|
|
1353
|
+
return files;
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
/** A playable page: a zip with index.html at its root (`WishLink.playablePage` in Lamp). Null for anything else. */
|
|
1357
|
+
export function readPage(bytes) {
|
|
1358
|
+
const files = readZip(bytes);
|
|
1359
|
+
return files && files.some((file) => file.path === "index.html") ? files : null;
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
/** What a page's file is served as, by its extension: anything unknown as bytes, never guessed from what's in it. */
|
|
1363
|
+
export function pageType(path) {
|
|
1364
|
+
const types = {
|
|
1365
|
+
html: "text/html; charset=utf-8", js: "text/javascript; charset=utf-8", mjs: "text/javascript; charset=utf-8",
|
|
1366
|
+
css: "text/css; charset=utf-8", json: "application/json", map: "application/json", txt: "text/plain; charset=utf-8",
|
|
1367
|
+
svg: "image/svg+xml", png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg", gif: "image/gif", webp: "image/webp",
|
|
1368
|
+
avif: "image/avif", ico: "image/x-icon", wasm: "application/wasm", mp3: "audio/mpeg", ogg: "audio/ogg", wav: "audio/wav",
|
|
1369
|
+
m4a: "audio/mp4", mp4: "video/mp4", webm: "video/webm", woff: "font/woff", woff2: "font/woff2", ttf: "font/ttf", otf: "font/otf",
|
|
1370
|
+
glb: "model/gltf-binary", gltf: "model/gltf+json", bin: "application/octet-stream", xml: "application/xml",
|
|
1371
|
+
};
|
|
1372
|
+
return types[path.split(".").pop()?.toLowerCase() ?? ""] ?? "application/octet-stream";
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
/**
|
|
1376
|
+
* Where a link's page plays: a host of its own under the play host (`LAMP_PLAY_ORIGIN`, say https://play.lamp.dev),
|
|
1377
|
+
* one for each link, so no shared page can read what another saved in the browser, nor anything of lamp.dev's. The id
|
|
1378
|
+
* is written in hex, since a host name doesn't keep the id's upper and lower case apart. Null without a play host.
|
|
1379
|
+
*/
|
|
1380
|
+
export function playOrigin(base, id) {
|
|
1381
|
+
if (!base || !isID(id)) return null;
|
|
1382
|
+
const url = new URL(base);
|
|
1383
|
+
return `${url.protocol}//${Buffer.from(id).toString("hex")}.${url.host}`;
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
/**
|
|
1387
|
+
* The page a link plays, one file at a time (`/w/<id>/play/<version>/<path>`): only on that link's own play host
|
|
1388
|
+
* (`playOrigin`), only into a frame on the site's own pages, and only the version the link has now. Each version's
|
|
1389
|
+
* files never change, so the CDN keeps them.
|
|
1390
|
+
*/
|
|
1391
|
+
/**
|
|
1392
|
+
* A page's file this big or bigger is played straight from the store instead of through the play host: the CDN keeps
|
|
1393
|
+
* nothing over 10 MB from a function, so every visit would pass it through again. Only for a page kept with each
|
|
1394
|
+
* file's own type (`page.sizes`), since a browser won't run a script the store calls anything else. Never a page's
|
|
1395
|
+
* HTML, which the store hands out as a download.
|
|
1396
|
+
*/
|
|
1397
|
+
export const DIRECT_FILE = 9_000_000;
|
|
1398
|
+
/**
|
|
1399
|
+
* A file of data a game reads with fetch (map tiles, models, sound, JSON) is played straight from the store, whatever
|
|
1400
|
+
* its size: the store's CDN serves it to any page (CORS for all), keeps it whatever the site deploys, and the play host
|
|
1401
|
+
* reads nothing for it. A game may have hundreds of small ones (a map's tiles), all asked for again after each deploy.
|
|
1402
|
+
* From 1 byte, so only for a page kept with each file's size and type (`page.sizes`). The play host reading a game's hundreds of files from the store in bursts is what the store
|
|
1403
|
+
* turned away (403) until the game didn't load. Not a script or a style sheet, whose own imports would then resolve
|
|
1404
|
+
* at the store, nor a picture, which a game may draw from without asking for it cross-origin. Only when the page
|
|
1405
|
+
* fetched it (`Sec-Fetch-Mode: cors`): a video or sound an element of the page loads (no-cors), drawn into WebGL or
|
|
1406
|
+
* played through Web Audio, works only from the page's own origin.
|
|
1407
|
+
*/
|
|
1408
|
+
export const DIRECT_DATA = 1;
|
|
1409
|
+
const DATA_FILE = /\.(bin|gz|br|zst|json|geojson|csv|txt|glb|gltf|ktx2|basis|drc|wasm|mp3|ogg|m4a|wav|aac|flac|opus|mp4|webm|mov)$/i;
|
|
1410
|
+
|
|
1411
|
+
/** How long a link's record is remembered for serving its page's files, and how many links at once. */
|
|
1412
|
+
export const PLAY_MEMORY = { ms: 60_000, links: 200 };
|
|
1413
|
+
|
|
1414
|
+
/**
|
|
1415
|
+
* What a page played on its link is given at the top of its HTML: when the link's page asks for a picture of the app
|
|
1416
|
+
* (as someone opens the box to send its maker an idea), the app's biggest canvas, drawn after the app has drawn its
|
|
1417
|
+
* next frame, scaled to at most 1280 wide, as a JPEG small enough to go with an idea. Only the site's own page may ask,
|
|
1418
|
+
* and only it is answered. Nothing for an app without a canvas, one that can't be read, or one with nothing drawn in it
|
|
1419
|
+
* (see-through all over). A JPEG has no see-through, so what shows through the canvas on the page is put behind it: the
|
|
1420
|
+
* first color behind it there, else white as the browser's own. A scene that's dark (night, space) is a picture.
|
|
1421
|
+
*
|
|
1422
|
+
* A 3D canvas's picture is gone once its frame is on screen, and an app that draws only when something changes (a
|
|
1423
|
+
* builder, a viewer) draws no next frame to catch: so before the app's own code runs, every WebGL canvas made here
|
|
1424
|
+
* keeps its last picture (`preserveDrawingBuffer`), which costs a game little and lets any of them be read.
|
|
1425
|
+
*/
|
|
1426
|
+
export function pictureScript(site) {
|
|
1427
|
+
// The most a picture may be as a data: URL to go with an idea (`IDEA_LIMITS.picture` bytes, as base64).
|
|
1428
|
+
const limit = 23 + Math.floor(IDEA_LIMITS.picture / 3) * 4;
|
|
1429
|
+
const keep = `try{const get=HTMLCanvasElement.prototype.getContext;HTMLCanvasElement.prototype.getContext=function(type,options){if(/^(webgl2?|experimental-webgl)$/.test(type))options=Object.assign({},options,{preserveDrawingBuffer:true});return get.call(this,type,options)}}catch(_){}`;
|
|
1430
|
+
// Nothing drawn (a canvas read when it held nothing) is no picture: every point of the copy see-through. Read before
|
|
1431
|
+
// anything is put behind it, and by alpha alone, so a dark scene is kept.
|
|
1432
|
+
const blank = `const blank=x=>{const d=x.getImageData(0,0,x.canvas.width,x.canvas.height).data;for(let i=3;i<d.length;i+=4)if(d[i])return false;return true};`;
|
|
1433
|
+
// The color that shows through the canvas on the page: its own background or the first of the elements around it that
|
|
1434
|
+
// has one. None (the page left as the browser draws it) is white. Only an alpha of 0 is none: the fourth of rgba()'s
|
|
1435
|
+
// values or what follows a slash, never a color's last channel, so black, red and green count.
|
|
1436
|
+
const behind = `const behind=c=>{for(let e=c;e;e=e.parentElement){const b=getComputedStyle(e).backgroundColor;if(b&&b!=="transparent"&&!/^rgba\\(([^,]*,){3}\\s*0(\\.0*)?%?\\s*\\)$|\\/\\s*0(\\.0*)?%?\\s*\\)$/.test(b))return b}return"#fff"};`;
|
|
1437
|
+
return `<script>(()=>{${keep}const site=${JSON.stringify(site)};${blank}${behind}addEventListener("message",e=>{if(e.origin!==site||e.source!==parent||!e.data||e.data.lamp!=="picture")return;const n=e.data.n;requestAnimationFrame(()=>{let picture=null;try{const all=[...document.querySelectorAll("canvas")].filter(c=>c.width&&c.height&&c.getClientRects().length);const c=all.sort((a,b)=>b.clientWidth*b.clientHeight-a.clientWidth*a.clientHeight)[0];if(c){const k=Math.min(1,1280/c.width),o=document.createElement("canvas");o.width=Math.round(c.width*k);o.height=Math.round(c.height*k);const x=o.getContext("2d");x.drawImage(c,0,0,o.width,o.height);if(!blank(x)){x.globalCompositeOperation="destination-over";x.fillStyle=behind(c);x.fillRect(0,0,o.width,o.height);for(const q of [0.8,0.6,0.4]){picture=o.toDataURL("image/jpeg",q);if(picture.length<=${limit})break;picture=null}}}}catch(_){}parent.postMessage({lamp:"picture",n,picture},site)})})})()</script>`;
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
export function createPlayHandler({ env = process.env, fetcher = fetch, store, anyHost = false, direct = DIRECT_FILE, directData = DIRECT_DATA, now = () => Date.now() } = {}) {
|
|
1441
|
+
// A link's record, remembered a minute per instance: a game asks for hundreds of files as it's played, and reading
|
|
1442
|
+
// the store for each used up its allowance of requests in a day, which took every link down. A version the memory
|
|
1443
|
+
// doesn't have (a release since) is read afresh; a link taken down plays on here for that minute at most.
|
|
1444
|
+
// Files asked for together while it's being read share that one read; one that fails is let go, so the next asks again.
|
|
1445
|
+
const remembered = new Map(), reading = new Map();
|
|
1446
|
+
const recordOf = async (id, version) => {
|
|
1447
|
+
const kept = remembered.get(id);
|
|
1448
|
+
if (kept && now() - kept.at < PLAY_MEMORY.ms && (kept.record?.page?.version === version || kept.record?.previousPage?.version === version)) return kept.record;
|
|
1449
|
+
if (reading.has(id)) return reading.get(id);
|
|
1450
|
+
const read = (async () => {
|
|
1451
|
+
try {
|
|
1452
|
+
const record = await (store ?? cloudStore(env, fetcher)).get(id);
|
|
1453
|
+
remembered.delete(id);
|
|
1454
|
+
if (remembered.size >= PLAY_MEMORY.links) remembered.delete(remembered.keys().next().value);
|
|
1455
|
+
remembered.set(id, { record, at: now() });
|
|
1456
|
+
return record;
|
|
1457
|
+
} finally { reading.delete(id); }
|
|
1458
|
+
})();
|
|
1459
|
+
reading.set(id, read);
|
|
1460
|
+
return read;
|
|
1461
|
+
};
|
|
1462
|
+
// Every answer the play host gives, errors too (a browser settles an origin's cluster on its first answer): the
|
|
1463
|
+
// play host is the same site as lamp.dev, so without this Chrome runs the framed page in the site's own process,
|
|
1464
|
+
// sharing its memory with the page around it and other lamp.dev tabs. A big game ran out there and crashed the tab,
|
|
1465
|
+
// while the same game opened on its own didn't. An agent cluster of its own lets Chrome give it a process of its own.
|
|
1466
|
+
const isolated = (response) => { response.headers.set("Origin-Agent-Cluster", "?1"); return response; };
|
|
1467
|
+
return async (request) => isolated(await play(request));
|
|
1468
|
+
async function play(request) {
|
|
1469
|
+
if (request.method !== "GET" && request.method !== "HEAD") return new Response(null, { status: 405, headers: { Allow: "GET, HEAD" } });
|
|
1470
|
+
const url = new URL(request.url);
|
|
1471
|
+
const id = url.searchParams.get("id"), version = Number(url.searchParams.get("v"));
|
|
1472
|
+
const own = playOrigin(env.LAMP_PLAY_ORIGIN, id);
|
|
1473
|
+
if (!anyHost && (!own || url.host !== new URL(own).host)) return new Response(null, { status: 404 });
|
|
1474
|
+
const path = url.searchParams.get("path") || "index.html";
|
|
1475
|
+
const missing = () => new Response(null, { status: 404, headers: { "Cache-Control": "no-store" } });
|
|
1476
|
+
if (!isID(id) || !Number.isInteger(version)) return missing();
|
|
1477
|
+
let record;
|
|
1478
|
+
// Why it failed goes to the log, never the link's id or anything it holds: a 503 here once took every link down
|
|
1479
|
+
// with nothing to say which store refused.
|
|
1480
|
+
try { record = await recordOf(id, version); } catch (error) {
|
|
1481
|
+
console.error("wish play: the record couldn't be read:", error?.name, error?.message);
|
|
1482
|
+
return new Response(null, { status: 503 });
|
|
1483
|
+
}
|
|
1484
|
+
// A page's other .html files are asked for by their clean names (the site's `cleanUrls` redirects to them), and a
|
|
1485
|
+
// folder's index.html by the folder, with its slash or without (a rewrite may not keep it): without, the page is
|
|
1486
|
+
// given its folder as its base, so its own files resolve there and not one folder up.
|
|
1487
|
+
// The page as it is, or the version before it, for a game that was open on that when this one went up.
|
|
1488
|
+
const served = record?.page?.version === version ? record.page : record?.previousPage?.version === version ? record.previousPage : null;
|
|
1489
|
+
const files = served?.files ?? {};
|
|
1490
|
+
const folder = path.replace(/\/$/, "");
|
|
1491
|
+
const name = path.endsWith("/") ? (files[`${path}index.html`] ? `${path}index.html` : null)
|
|
1492
|
+
: files[path] ? path : files[`${path}.html`] ? `${path}.html` : files[`${folder}/index.html`] ? `${folder}/index.html` : null;
|
|
1493
|
+
const rebased = name === `${folder}/index.html` && !path.endsWith("/");
|
|
1494
|
+
const at = name ? files[name] : null;
|
|
1495
|
+
if (!at) return missing();
|
|
1496
|
+
// A big file: the browser goes to it in the store, which serves it to any page. Each version's files never change.
|
|
1497
|
+
// Not for a worker's own script, which a browser only takes from the page's own origin, nor from a store a browser
|
|
1498
|
+
// can't reach (the dev server's memory). Kept apart by what asked (`Vary`), so a worker never gets the redirect.
|
|
1499
|
+
const worker = /worker$/i.test(request.headers.get("sec-fetch-dest") ?? "");
|
|
1500
|
+
const size = served.sizes?.[name] ?? 0;
|
|
1501
|
+
if (!rebased && !worker && at.startsWith("https://") && !/\.html?$/i.test(name) && (size >= direct || (DATA_FILE.test(name) && size >= directData && request.headers.get("sec-fetch-mode") === "cors"))) {
|
|
1502
|
+
return new Response(null, { status: 302, headers: { Location: at, Vary: "Sec-Fetch-Dest, Sec-Fetch-Mode", "Cache-Control": "public, max-age=300, s-maxage=31536000", ...cacheTags(id) } });
|
|
1503
|
+
}
|
|
1504
|
+
// Passed on as it comes from the store; only a page's HTML is read whole, to be given what the link's page asks of
|
|
1505
|
+
// it (`pictureScript`), and a folder's index.html its base.
|
|
1506
|
+
const kept = store ?? cloudStore(env, fetcher);
|
|
1507
|
+
const site = env.LAMP_SITE_ORIGIN || (anyHost ? url.origin : "https://lamp.dev");
|
|
1508
|
+
let bytes, length;
|
|
1509
|
+
try {
|
|
1510
|
+
if (/\.html?$/i.test(name) && request.method !== "HEAD") {
|
|
1511
|
+
const base = rebased ? `<base href="/w/${id}/play/${version}/${folder.split("/").map(encodeURIComponent).join("/")}/">` : "";
|
|
1512
|
+
const added = base + pictureScript(site);
|
|
1513
|
+
const html = (await kept.readFile(at)).toString("utf8");
|
|
1514
|
+
// In its head, or after its doctype where it has no head of its own: before a doctype, a page is drawn in quirks mode.
|
|
1515
|
+
const head = /<head[^>]*>/i.exec(html) ?? /^(?:\s|<!--[\s\S]*?-->)*<!doctype[^>]*>/i.exec(html);
|
|
1516
|
+
bytes = Buffer.from(head ? html.slice(0, head.index + head[0].length) + added + html.slice(head.index + head[0].length) : added + html);
|
|
1517
|
+
} else if (request.method !== "HEAD") {
|
|
1518
|
+
({ body: bytes, length } = await kept.openFile(at));
|
|
1519
|
+
}
|
|
1520
|
+
} catch (error) {
|
|
1521
|
+
console.error("wish play: a file couldn't be read:", error?.name, error?.message);
|
|
1522
|
+
return new Response(null, { status: 503 });
|
|
1523
|
+
}
|
|
1524
|
+
return new Response(request.method === "HEAD" ? null : bytes, {
|
|
1525
|
+
headers: {
|
|
1526
|
+
"Content-Type": pageType(name), "X-Content-Type-Options": "nosniff", "Referrer-Policy": "no-referrer",
|
|
1527
|
+
...(length ? { "Content-Length": length } : {}),
|
|
1528
|
+
// Framed only by the link's page; frame-ancestors takes the place of the site's X-Frame-Options.
|
|
1529
|
+
"Content-Security-Policy": `frame-ancestors ${site}`,
|
|
1530
|
+
"Cache-Control": "public, max-age=300, s-maxage=31536000", ...cacheTags(id),
|
|
1531
|
+
},
|
|
1532
|
+
});
|
|
1533
|
+
};
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
// MARK: The wish itself
|
|
1537
|
+
|
|
1538
|
+
/** A shared file Lamp made: a zip whose listing names lamp-app.json, the file every shared wish has at its root. */
|
|
1539
|
+
export function isWishFile(bytes) {
|
|
1540
|
+
if (bytes.length < 22 || bytes.readUInt32LE(0) !== 0x04034b50) return false;
|
|
1541
|
+
// The central directory's entries, from the end record backwards: only a name, not what's in it, is read here; Lamp
|
|
1542
|
+
// reads and reviews the rest when the file is opened.
|
|
1543
|
+
const from = Math.max(0, bytes.length - 65_557);
|
|
1544
|
+
const end = bytes.lastIndexOf(Buffer.from([0x50, 0x4b, 0x05, 0x06]));
|
|
1545
|
+
if (end < from) return false;
|
|
1546
|
+
const size = bytes.readUInt32LE(end + 12), offset = bytes.readUInt32LE(end + 16);
|
|
1547
|
+
if (offset + size > end) return false;
|
|
1548
|
+
for (let at = offset; at + 46 <= offset + size;) {
|
|
1549
|
+
if (bytes.readUInt32LE(at) !== 0x02014b50) return false;
|
|
1550
|
+
const nameLength = bytes.readUInt16LE(at + 28), extra = bytes.readUInt16LE(at + 30), comment = bytes.readUInt16LE(at + 32);
|
|
1551
|
+
if (bytes.toString("utf8", at + 46, at + 46 + nameLength) === "lamp-app.json") return true;
|
|
1552
|
+
at += 46 + nameLength + extra + comment;
|
|
1553
|
+
}
|
|
1554
|
+
return false;
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
/** The file's name where it's downloaded: the wish's name, made safe for an address. */
|
|
1558
|
+
export const fileName = (name) => `${(name.normalize("NFKD").replace(/[^\w\s-]/g, "").trim().replace(/\s+/g, "-").toLowerCase() || "app").slice(0, 60)}.lampapp`;
|
|
1559
|
+
|
|
1560
|
+
/**
|
|
1561
|
+
* The wish's own file, for a link that has one, only with the link's secret. PUT ?id=&part=<n>&of=<count> sends one part
|
|
1562
|
+
* (raw bytes, at most `FILE_LIMITS.part`); POST ?id= puts the parts together, checks that it's a file Lamp made and
|
|
1563
|
+
* within `FILE_LIMITS.total`, and gives it to the link in place of any it had; DELETE ?id= takes it away. The parts
|
|
1564
|
+
* are taken away once they're together, or with the link.
|
|
1565
|
+
*/
|
|
1566
|
+
/**
|
|
1567
|
+
* Work a request goes on with once it has answered (`waitUntil`, on Vercel), which the function is kept alive for up
|
|
1568
|
+
* to its whole time; elsewhere (a test, `npm run dev`) it's done before the answer.
|
|
1569
|
+
*/
|
|
1570
|
+
async function afterAnswering(work, env) {
|
|
1571
|
+
if (!env.VERCEL) return await work();
|
|
1572
|
+
const { waitUntil } = await import("@vercel/functions");
|
|
1573
|
+
waitUntil(work());
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
export function createFileHandler({ env = process.env, fetcher = fetch, store, limited = true, now = () => new Date(), later = (work) => afterAnswering(work, env) } = {}) {
|
|
1577
|
+
return async (request) => {
|
|
1578
|
+
const method = request.method;
|
|
1579
|
+
if (!["PUT", "POST", "DELETE"].includes(method)) return reply(405, { error: "method_not_allowed" }, { Allow: "PUT, POST, DELETE" });
|
|
1580
|
+
const url = new URL(request.url);
|
|
1581
|
+
const origin = request.headers.get("origin");
|
|
1582
|
+
if (origin && origin !== url.origin) return reply(403, { error: "origin_not_allowed" });
|
|
1583
|
+
const id = url.searchParams.get("id");
|
|
1584
|
+
if (!isID(id)) return reply(404, { error: "not_found" });
|
|
1585
|
+
// The wish's file, or (`what=page`) its playable page, sent and put together the same way.
|
|
1586
|
+
const page = url.searchParams.get("what") === "page";
|
|
1587
|
+
const partsKey = page ? "pageParts" : "parts";
|
|
1588
|
+
|
|
1589
|
+
let part = null;
|
|
1590
|
+
if (method === "PUT") {
|
|
1591
|
+
const n = Number(url.searchParams.get("part")), of = Number(url.searchParams.get("of"));
|
|
1592
|
+
if (!Number.isInteger(of) || of < 1 || of > FILE_LIMITS.parts || !Number.isInteger(n) || n < 0 || n >= of) return reply(400, { error: "invalid_part" });
|
|
1593
|
+
if (!/^application\/octet-stream$/i.test(request.headers.get("content-type") || "")) return reply(415, { error: "bytes_required" });
|
|
1594
|
+
const length = request.headers.get("content-length");
|
|
1595
|
+
if (length && (!/^\d+$/.test(length) || Number(length) > FILE_LIMITS.part)) return reply(413, { error: "part_too_large" });
|
|
1596
|
+
const bytes = Buffer.from(await request.arrayBuffer());
|
|
1597
|
+
if (!bytes.length || bytes.length > FILE_LIMITS.part) return reply(bytes.length ? 413 : 400, { error: "invalid_part" });
|
|
1598
|
+
part = { n, of, bytes };
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
let kept;
|
|
1602
|
+
try { kept = store ?? cloudStore(env, fetcher); } catch { return reply(503, { error: "service_unavailable" }); }
|
|
1603
|
+
if (limited) {
|
|
1604
|
+
const ip = (request.headers.get("x-vercel-forwarded-for") || request.headers.get("x-forwarded-for") || "").trim();
|
|
1605
|
+
if (!isIP(ip) || !kept.limiter) return reply(503, { error: "service_unavailable" });
|
|
1606
|
+
let retry;
|
|
1607
|
+
try { retry = await reserve(kept.limiter, ip, method === "PUT" ? PART_QUOTAS : method === "POST" ? UPDATE_QUOTAS : DELETE_QUOTAS, fetcher); }
|
|
1608
|
+
catch { return reply(503, { error: "service_unavailable" }); }
|
|
1609
|
+
if (retry > 0) return reply(429, { error: "rate_limited" }, { "Retry-After": String(retry) });
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
let uploaded = [];
|
|
1613
|
+
try {
|
|
1614
|
+
const record = await kept.get(id);
|
|
1615
|
+
if (!record || !secretMatches(bearer(request), record.secret)) return reply(404, { error: "not_found" });
|
|
1616
|
+
if (method === "PUT") {
|
|
1617
|
+
const at = await kept.putFile(id, `${page ? "page-part" : "part"}-${part.n}`, part.bytes);
|
|
1618
|
+
uploaded.push(at);
|
|
1619
|
+
// The part goes into the record as it is when it's written, never as it was read before the part went up: Lamp
|
|
1620
|
+
// sends a part again when its connection drops, and the first try can still be storing then, so another part
|
|
1621
|
+
// may have gone in meanwhile, which a write from the old read would lose. What it replaces is only set aside,
|
|
1622
|
+
// never removed here: putting the parts together may be reading it right now. It goes when they've been put
|
|
1623
|
+
// together, or with the link.
|
|
1624
|
+
for (let attempt = 0; ; attempt++) {
|
|
1625
|
+
const current = attempt ? await kept.get(id) : record;
|
|
1626
|
+
// Taken down while the part went up: nothing leads to it, so it goes now (the catch below takes it away).
|
|
1627
|
+
if (!current || !secretMatches(bearer(request), current.secret)) throw new Error("Gone");
|
|
1628
|
+
// A new count starts a new file: parts of an earlier one that never came together are set aside.
|
|
1629
|
+
const earlier = current[partsKey];
|
|
1630
|
+
const same = earlier?.of === part.of;
|
|
1631
|
+
const urls = same ? earlier.urls : {};
|
|
1632
|
+
const spare = [...(earlier?.spare ?? []), ...(same ? [] : Object.values(earlier?.urls ?? {})),
|
|
1633
|
+
...(urls[part.n] && urls[part.n] !== at ? [urls[part.n]] : [])];
|
|
1634
|
+
if (await kept.swap(current, { ...current, [partsKey]: { of: part.of, urls: { ...urls, [part.n]: at }, spare } })) break;
|
|
1635
|
+
if (attempt === 4) throw new Error("Busy");
|
|
1636
|
+
}
|
|
1637
|
+
uploaded = [];
|
|
1638
|
+
return reply(204);
|
|
1639
|
+
}
|
|
1640
|
+
if (method === "DELETE") {
|
|
1641
|
+
// The page goes with the wish itself: a link that no longer carries the wish has nothing to play.
|
|
1642
|
+
const gone = [...pageBlobs(record), ...partBlobs(record.pageParts),
|
|
1643
|
+
...(page ? [] : [record.file?.url, ...partBlobs(record.parts)])].filter(Boolean);
|
|
1644
|
+
const { page: _page, previousPage: _previousPage, pageParts: _pageParts, ...rest } = record;
|
|
1645
|
+
// The count stays with the link, so a file sent again later is newer to its followers than any before.
|
|
1646
|
+
const next = { ...(page ? rest : { ...rest, file: null, parts: undefined, fileVersion: record.file?.version ?? record.fileVersion }),
|
|
1647
|
+
pageVersion: record.page?.version ?? record.pageVersion };
|
|
1648
|
+
await kept.replace({ ...next, updated: now().toISOString() });
|
|
1649
|
+
await kept.removeImages(gone);
|
|
1650
|
+
await kept.forget(id);
|
|
1651
|
+
return reply(204);
|
|
1652
|
+
}
|
|
1653
|
+
const parts = record[partsKey];
|
|
1654
|
+
if (!parts || Object.keys(parts.urls).length !== parts.of) return reply(409, { error: "parts_missing" });
|
|
1655
|
+
const pieces = [];
|
|
1656
|
+
for (let n = 0; n < parts.of; n++) pieces.push(await kept.readFile(parts.urls[n]));
|
|
1657
|
+
const bytes = Buffer.concat(pieces);
|
|
1658
|
+
if (bytes.length > FILE_LIMITS.total) return reply(413, { error: "file_too_large" });
|
|
1659
|
+
if (page) {
|
|
1660
|
+
const files = readPage(bytes);
|
|
1661
|
+
if (!files) return reply(400, { error: "invalid_page" });
|
|
1662
|
+
// The count is the link's (`pageVersion`), so a page taken away and sent again never reuses a play address.
|
|
1663
|
+
const version = (record.page?.version ?? record.pageVersion ?? 0) + 1;
|
|
1664
|
+
// Checked, it's answered at once, and its files are stored after: a big game's page is hundreds of files, which
|
|
1665
|
+
// take longer than a Lamp waits for an answer, and a Lamp that heard nothing took the link's page down. The
|
|
1666
|
+
// link plays the page it had until this one is in; if this one never gets in, it goes on playing that one.
|
|
1667
|
+
await later(async () => {
|
|
1668
|
+
const stored = [];
|
|
1669
|
+
try {
|
|
1670
|
+
// Each version in a folder of its own, under a random part, so an old one never mixes with a new one.
|
|
1671
|
+
const token = randomBytes(12).toString("base64url");
|
|
1672
|
+
const urls = {}, sizes = {};
|
|
1673
|
+
// Many at a time, within what the blob store takes. Said in the log as it goes, so a page that doesn't get in
|
|
1674
|
+
// shows how far it got.
|
|
1675
|
+
const began = Date.now();
|
|
1676
|
+
console.log(`Page ${version} of ${id}: storing ${files.length} files`);
|
|
1677
|
+
for (let n = 0; n < files.length; n += PAGE_UPLOADS) {
|
|
1678
|
+
await Promise.all(files.slice(n, n + PAGE_UPLOADS).map(async (file) => {
|
|
1679
|
+
// Each file named as it starts, so a page that stops shows on which.
|
|
1680
|
+
console.log(`Page ${version} of ${id}: storing ${file.path} (${file.bytes.length} bytes)`);
|
|
1681
|
+
urls[file.path] = await kept.putPageFile(id, token, file.path, file.bytes);
|
|
1682
|
+
sizes[file.path] = file.bytes.length;
|
|
1683
|
+
stored.push(urls[file.path]);
|
|
1684
|
+
}));
|
|
1685
|
+
if ((n / PAGE_UPLOADS) % 10 === 9) console.log(`Page ${version} of ${id}: ${stored.length} of ${files.length} stored, ${Math.round((Date.now() - began) / 1000)}s`);
|
|
1686
|
+
}
|
|
1687
|
+
console.log(`Page ${version} of ${id}: all ${files.length} stored in ${Math.round((Date.now() - began) / 1000)}s`);
|
|
1688
|
+
// As the record is now, not as it was when the parts were read: the link may have changed meanwhile.
|
|
1689
|
+
const current = await kept.get(id);
|
|
1690
|
+
if (!current || !secretMatches(bearer(request), current.secret)) throw new Error("Gone");
|
|
1691
|
+
// The page it replaces stays playable as the version before, so a game open on it goes on finding its
|
|
1692
|
+
// files (a game loads its map as the player moves); the one before that goes now.
|
|
1693
|
+
const { pageParts: _pageParts, ...rest } = current;
|
|
1694
|
+
const previousPage = current.page ? { version: current.page.version, files: current.page.files, sizes: current.page.sizes } : undefined;
|
|
1695
|
+
if (!(await kept.replace({ ...rest, page: { version, files: urls, sizes }, ...(previousPage ? { previousPage } : {}),
|
|
1696
|
+
pageVersion: Math.max(version, current.pageVersion ?? 0), updated: now().toISOString() }))) throw new Error("Gone");
|
|
1697
|
+
await kept.removeImages([...Object.values(current.previousPage?.files ?? {}), ...partBlobs(parts)]);
|
|
1698
|
+
await kept.forget(id);
|
|
1699
|
+
} catch (error) {
|
|
1700
|
+
console.error(`Page ${version} of ${id} didn't get in, ${stored.length} of ${files.length} stored: ${error?.message ?? error}${error?.cause ? ` (${error.cause.message ?? error.cause})` : ""}`);
|
|
1701
|
+
try { await kept.removeImages(stored); } catch {}
|
|
1702
|
+
}
|
|
1703
|
+
});
|
|
1704
|
+
return reply(200, { version, files: files.length });
|
|
1705
|
+
}
|
|
1706
|
+
if (!isWishFile(bytes)) return reply(400, { error: "invalid_file" });
|
|
1707
|
+
const at = await kept.putFile(id, fileName(record.name), bytes);
|
|
1708
|
+
uploaded.push(at);
|
|
1709
|
+
const { parts: _parts, ...rest } = record;
|
|
1710
|
+
// Each file sent counts up, so the Lamps that follow the link can tell a newer one (`latest`).
|
|
1711
|
+
const version = (record.file?.version ?? (record.file ? 1 : record.fileVersion ?? 0)) + 1;
|
|
1712
|
+
if (!(await kept.replace({ ...rest, file: { url: at, size: bytes.length, version }, fileVersion: version, updated: now().toISOString() }))) {
|
|
1713
|
+
// Taken down meanwhile: the file and its parts go with it.
|
|
1714
|
+
await kept.removeImages([...uploaded, ...partBlobs(parts)]);
|
|
1715
|
+
uploaded = [];
|
|
1716
|
+
return reply(404, { error: "not_found" });
|
|
1717
|
+
}
|
|
1718
|
+
uploaded = [];
|
|
1719
|
+
await kept.removeImages([record.file?.url, ...partBlobs(parts)].filter(Boolean));
|
|
1720
|
+
await kept.forget(id);
|
|
1721
|
+
return reply(200, { url: at, size: bytes.length });
|
|
1722
|
+
} catch (error) {
|
|
1723
|
+
try { await kept.removeImages(uploaded); } catch {}
|
|
1724
|
+
return error?.message === "Gone" ? reply(404, { error: "not_found" }) : reply(503, { error: "service_unavailable" });
|
|
1725
|
+
}
|
|
1726
|
+
};
|
|
1727
|
+
}
|