commit 656a37efda6cfc611ab2f011affb1311894b4400 Author: FluxKit Date: Thu Feb 19 14:02:53 2026 +0000 Initial commit: AMS Backend - Deno + Oak + MongoDB diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..fbd3ae5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM denoland/deno:2.0.0 + +WORKDIR /app + +COPY deno.json deno.lock ./ +RUN deno install + +COPY src ./src + +EXPOSE 8000 + +CMD ["deno", "task", "start"] diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..842c145 --- /dev/null +++ b/deno.json @@ -0,0 +1,11 @@ +{ + "name": "@ams/backend", + "tasks": { + "start": "deno run --allow-net --allow-env --allow-read --allow-sys --allow-run src/main.ts" + }, + "imports": { + "@std/dotenv": "jsr:@std/dotenv@^0.225.6", + "@oak/oak": "jsr:@oak/oak@^17.2.0", + "mongodb": "npm:mongodb@^6.10.0" + } +} diff --git a/deno.lock b/deno.lock new file mode 100644 index 0000000..6bdbd27 --- /dev/null +++ b/deno.lock @@ -0,0 +1,311 @@ +{ + "version": "4", + "specifiers": { + "jsr:@oak/commons@1": "1.0.1", + "jsr:@oak/oak@17.2.0": "17.2.0", + "jsr:@oak/oak@^17.2.0": "17.2.0", + "jsr:@std/assert@1": "1.0.18", + "jsr:@std/bytes@1": "1.0.6", + "jsr:@std/crypto@1": "1.0.5", + "jsr:@std/dotenv@~0.225.6": "0.225.6", + "jsr:@std/encoding@1": "1.0.10", + "jsr:@std/encoding@^1.0.10": "1.0.10", + "jsr:@std/http@1": "1.0.24", + "jsr:@std/internal@^1.0.12": "1.0.12", + "jsr:@std/media-types@1": "1.1.0", + "jsr:@std/path@1": "1.1.4", + "npm:mongodb@6.10.0": "6.10.0", + "npm:mongodb@^6.10.0": "6.21.0", + "npm:path-to-regexp@^6.3.0": "6.3.0" + }, + "jsr": { + "@oak/commons@1.0.1": { + "integrity": "889ff210f0b4292591721be07244ecb1b5c118742f5273c70cf30d7cd4184d0c", + "dependencies": [ + "jsr:@std/assert", + "jsr:@std/bytes", + "jsr:@std/crypto", + "jsr:@std/encoding@1", + "jsr:@std/http", + "jsr:@std/media-types" + ] + }, + "@oak/oak@17.2.0": { + "integrity": "938537a92fc7922a46a9984696c65fb189c9baad164416ac3e336768a9ff0cd1", + "dependencies": [ + "jsr:@oak/commons", + "jsr:@std/assert", + "jsr:@std/bytes", + "jsr:@std/http", + "jsr:@std/media-types", + "jsr:@std/path", + "npm:path-to-regexp" + ] + }, + "@std/assert@1.0.18": { + "integrity": "270245e9c2c13b446286de475131dc688ca9abcd94fc5db41d43a219b34d1c78" + }, + "@std/bytes@1.0.6": { + "integrity": "f6ac6adbd8ccd99314045f5703e23af0a68d7f7e58364b47d2c7f408aeb5820a" + }, + "@std/crypto@1.0.5": { + "integrity": "0dcfbb319fe0bba1bd3af904ceb4f948cde1b92979ec1614528380ed308a3b40" + }, + "@std/dotenv@0.225.6": { + "integrity": "1d6f9db72f565bd26790fa034c26e45ecb260b5245417be76c2279e5734c421b" + }, + "@std/encoding@1.0.10": { + "integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1" + }, + "@std/http@1.0.24": { + "integrity": "4dd59afd7cfd6e2e96e175b67a5a829b449ae55f08575721ec691e5d85d886d4", + "dependencies": [ + "jsr:@std/encoding@^1.0.10" + ] + }, + "@std/internal@1.0.12": { + "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027" + }, + "@std/media-types@1.1.0": { + "integrity": "c9d093f0c05c3512932b330e3cc1fe1d627b301db33a4c2c2185c02471d6eaa4" + }, + "@std/path@1.1.4": { + "integrity": "1d2d43f39efb1b42f0b1882a25486647cb851481862dc7313390b2bb044314b5", + "dependencies": [ + "jsr:@std/internal" + ] + } + }, + "npm": { + "@mongodb-js/saslprep@1.4.5": { + "integrity": "sha512-k64Lbyb7ycCSXHSLzxVdb2xsKGPMvYZfCICXvDsI8Z65CeWQzTEKS4YmGbnqw+U9RBvLPTsB6UCmwkgsDTGWIw==", + "dependencies": [ + "sparse-bitfield" + ] + }, + "@types/webidl-conversions@7.0.3": { + "integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==" + }, + "@types/whatwg-url@11.0.5": { + "integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==", + "dependencies": [ + "@types/webidl-conversions" + ] + }, + "bson@6.10.4": { + "integrity": "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==" + }, + "memory-pager@1.5.0": { + "integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg==" + }, + "mongodb-connection-string-url@3.0.2": { + "integrity": "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==", + "dependencies": [ + "@types/whatwg-url", + "whatwg-url" + ] + }, + "mongodb@6.10.0": { + "integrity": "sha512-gP9vduuYWb9ZkDM546M+MP2qKVk5ZG2wPF63OvSRuUbqCR+11ZCAE1mOfllhlAG0wcoJY5yDL/rV3OmYEwXIzg==", + "dependencies": [ + "@mongodb-js/saslprep", + "bson", + "mongodb-connection-string-url" + ] + }, + "mongodb@6.21.0": { + "integrity": "sha512-URyb/VXMjJ4da46OeSXg+puO39XH9DeQpWCslifrRn9JWugy0D+DvvBvkm2WxmHe61O/H19JM66p1z7RHVkZ6A==", + "dependencies": [ + "@mongodb-js/saslprep", + "bson", + "mongodb-connection-string-url" + ] + }, + "path-to-regexp@6.3.0": { + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==" + }, + "punycode@2.3.1": { + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==" + }, + "sparse-bitfield@3.0.3": { + "integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==", + "dependencies": [ + "memory-pager" + ] + }, + "tr46@5.1.1": { + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dependencies": [ + "punycode" + ] + }, + "webidl-conversions@7.0.0": { + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==" + }, + "whatwg-url@14.2.0": { + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dependencies": [ + "tr46", + "webidl-conversions" + ] + } + }, + "remote": { + "https://deno.land/std@0.200.0/_util/os.ts": "d932f56d41e4f6a6093d56044e29ce637f8dcc43c5a90af43504a889cf1775e3", + "https://deno.land/std@0.200.0/assert/assert.ts": "9a97dad6d98c238938e7540736b826440ad8c1c1e54430ca4c4e623e585607ee", + "https://deno.land/std@0.200.0/assert/assertion_error.ts": "4d0bde9b374dfbcbe8ac23f54f567b77024fb67dbb1906a852d67fe050d42f56", + "https://deno.land/std@0.200.0/async/deferred.ts": "42790112f36a75a57db4a96d33974a936deb7b04d25c6084a9fa8a49f135def8", + "https://deno.land/std@0.200.0/bytes/bytes_list.ts": "31d664f4d42fa922066405d0e421c56da89d751886ee77bbe25a88bf0310c9d0", + "https://deno.land/std@0.200.0/bytes/concat.ts": "d26d6f3d7922e6d663dacfcd357563b7bf4a380ce5b9c2bbe0c8586662f25ce2", + "https://deno.land/std@0.200.0/bytes/copy.ts": "939d89e302a9761dcf1d9c937c7711174ed74c59eef40a1e4569a05c9de88219", + "https://deno.land/std@0.200.0/bytes/ends_with.ts": "4228811ebc71615d27f065c54b5e815ec1972538772b0f413c0efe05245b472e", + "https://deno.land/std@0.200.0/bytes/equals.ts": "fc190cce412b2136979181b163ec7e05f7e7a947e39102eee4b8c0d62519ddf9", + "https://deno.land/std@0.200.0/bytes/includes_needle.ts": "76a8163126fb2f8bf86fd7f22192c3bb04bf6a20b987a095127c2ca08adf3ba6", + "https://deno.land/std@0.200.0/bytes/index_of_needle.ts": "9c06610e9611b5647ac25952e71a22e09227c9f1b8cbeeb33399bf8bf8a7f649", + "https://deno.land/std@0.200.0/bytes/last_index_of_needle.ts": "f1602f221c3b678bc4f1e1c88a70a15ab7da32c21751dbbc6c957c956951d784", + "https://deno.land/std@0.200.0/bytes/mod.ts": "e869bba1e7a2e3a9cc6c2d55471888429a544e70a840c087672e656e7ba21815", + "https://deno.land/std@0.200.0/bytes/repeat.ts": "6f5e490d8d72bcbf8d84a6bb04690b9b3eb5822c5a11687bca73a2318a842294", + "https://deno.land/std@0.200.0/bytes/starts_with.ts": "3e607a70c9c09f5140b7a7f17a695221abcc7244d20af3eb47ccbb63f5885135", + "https://deno.land/std@0.200.0/crypto/keystack.ts": "877ab0f19eb7d37ad6495190d3c3e39f58e9c52e0b6a966f82fd6df67ca55f90", + "https://deno.land/std@0.200.0/crypto/timing_safe_equal.ts": "7b0a4d2ef1c17590e0ad6c0cb1776369d2ba80cd99e945005e117851690507fe", + "https://deno.land/std@0.200.0/encoding/base64.ts": "144ae6234c1fbe5b68666c711dc15b1e9ee2aef6d42b3b4345bf9a6c91d70d0d", + "https://deno.land/std@0.200.0/encoding/base64url.ts": "2ed4ba122b20fedf226c5d337cf22ee2024fa73a8f85d915d442af7e9ce1fae1", + "https://deno.land/std@0.200.0/http/_negotiation/common.ts": "14d1a52427ab258a4b7161cd80e1d8a207b7cc64b46e911780f57ead5f4323c6", + "https://deno.land/std@0.200.0/http/_negotiation/encoding.ts": "ff747d107277c88cb7a6a62a08eeb8d56dad91564cbcccb30694d5dc126dcc53", + "https://deno.land/std@0.200.0/http/_negotiation/language.ts": "7bcddd8db3330bdb7ce4fc00a213c5547c1968139864201efd67ef2d0d51887d", + "https://deno.land/std@0.200.0/http/_negotiation/media_type.ts": "58847517cd549384ad677c0fe89e0a4815be36fe7a303ea63cee5f6a1d7e1692", + "https://deno.land/std@0.200.0/http/cookie_map.ts": "64601025a7d24c3ebd80a169ccc99145bdbfc60606935348e1d4366d0bf9010d", + "https://deno.land/std@0.200.0/http/etag.ts": "807382795850cde5c437c74bcc09392bc0fc56de348fc1271f383f4b28935b9f", + "https://deno.land/std@0.200.0/http/http_errors.ts": "bbda34819060af86537cecc9dc8e045f877130808b7e7acde4197c5328e852d0", + "https://deno.land/std@0.200.0/http/http_status.ts": "8a7bcfe3ac025199ad804075385e57f63d055b2aed539d943ccc277616d6f932", + "https://deno.land/std@0.200.0/http/method.ts": "e66c2a015cb46c21ab0bb3589aa4fca43143a506cb324ffdfd42d2edef7bc0c4", + "https://deno.land/std@0.200.0/http/negotiation.ts": "46e74a6bad4b857333a58dc5b50fe8e5a4d5267e97292293ea65f980bd918086", + "https://deno.land/std@0.200.0/http/server_sent_event.ts": "29f707c1afa5278ac0315ac115ee679d6b93596d5af3fad5ef33f04254ca76c1", + "https://deno.land/std@0.200.0/http/user_agent.ts": "35d3c70d0926b0e121b8c1bbc324b3522479158acaa4f0c43928362b7bf4e6f4", + "https://deno.land/std@0.200.0/io/buf_reader.ts": "2bccff0878537ef201c5051fc0db0ce8196388c5ea69d2be6be1900fe48c5f4b", + "https://deno.land/std@0.200.0/io/buf_writer.ts": "48c33c8f00b61dcbc7958706741cec8e59810bd307bc6a326cbd474fe8346dfd", + "https://deno.land/std@0.200.0/io/buffer.ts": "4d6883daeb2e698579c4064170515683d69f40f3de019bfe46c5cf31e74ae793", + "https://deno.land/std@0.200.0/io/copy_n.ts": "c055296297b9d4897d90d1ac056b072dc02614e60c67f438e23fbce052ea4c69", + "https://deno.land/std@0.200.0/io/limited_reader.ts": "6c9a216f8eef39c1ee2a6b37a29372c8fc63455b2eeb91f06d9646f8f759fc8b", + "https://deno.land/std@0.200.0/io/mod.ts": "2665bcccc1fd6e8627cca167c3e92aaecbd9897556b6f69e6d258070ef63fd9b", + "https://deno.land/std@0.200.0/io/multi_reader.ts": "9c2a0a31686c44b277e16da1d97b4686a986edcee48409b84be25eedbc39b271", + "https://deno.land/std@0.200.0/io/read_delim.ts": "c02b93cc546ae8caad8682ae270863e7ace6daec24c1eddd6faabc95a9d876a3", + "https://deno.land/std@0.200.0/io/read_int.ts": "7cb8bcdfaf1107586c3bacc583d11c64c060196cb070bb13ae8c2061404f911f", + "https://deno.land/std@0.200.0/io/read_lines.ts": "c526c12a20a9386dc910d500f9cdea43cba974e853397790bd146817a7eef8cc", + "https://deno.land/std@0.200.0/io/read_long.ts": "f0aaa420e3da1261c5d33c5e729f09922f3d9fa49f046258d4ff7a00d800c71e", + "https://deno.land/std@0.200.0/io/read_range.ts": "46a2263d0f8369b6d9abb0b25d99ceb65ff08d621fc57bcc53832e6979295043", + "https://deno.land/std@0.200.0/io/read_short.ts": "805cb329574b850b84bf14a92c052c59b5977a492cd780c41df8ad40826c1a20", + "https://deno.land/std@0.200.0/io/read_string_delim.ts": "5dc9f53bdf78e7d4ee1e56b9b60352238ab236a71c3e3b2a713c3d78472a53ce", + "https://deno.land/std@0.200.0/io/slice_long_to_bytes.ts": "48d9bace92684e880e46aa4a2520fc3867f9d7ce212055f76ecc11b22f9644b7", + "https://deno.land/std@0.200.0/io/string_reader.ts": "da0f68251b3d5b5112485dfd4d1b1936135c9b4d921182a7edaf47f74c25cc8f", + "https://deno.land/std@0.200.0/io/string_writer.ts": "8a03c5858c24965a54c6538bed15f32a7c72f5704a12bda56f83a40e28e5433e", + "https://deno.land/std@0.200.0/media_types/_db.ts": "7606d83e31f23ce1a7968cbaee852810c2cf477903a095696cdc62eaab7ce570", + "https://deno.land/std@0.200.0/media_types/_util.ts": "916efbd30b6148a716f110e67a4db29d6949bf4048997b754415dd7e42c52378", + "https://deno.land/std@0.200.0/media_types/content_type.ts": "ad98a5aa2d95f5965b2796072284258710a25e520952376ed432b0937ce743bc", + "https://deno.land/std@0.200.0/media_types/extension.ts": "a7cd28c9417143387cdfed27d4e8607ebcf5b1ec27eb8473d5b000144689fe65", + "https://deno.land/std@0.200.0/media_types/extensions_by_type.ts": "43806d6a52a0d6d965ada9d20e60a982feb40bc7a82268178d94edb764694fed", + "https://deno.land/std@0.200.0/media_types/format_media_type.ts": "f5e1073c05526a6f5a516ac5c5587a1abd043bf1039c71cde1166aa4328c8baf", + "https://deno.land/std@0.200.0/media_types/get_charset.ts": "18b88274796fda5d353806bf409eb1d2ddb3f004eb4bd311662c4cdd8ac173db", + "https://deno.land/std@0.200.0/media_types/mod.ts": "d3f0b99f85053bc0b98ecc24eaa3546dfa09b856dc0bbaf60d8956d2cdd710c8", + "https://deno.land/std@0.200.0/media_types/parse_media_type.ts": "8cb0144385c555c9ce81881b7cee3fbb746f23b4af988fecdb7bd01ef8cc35b1", + "https://deno.land/std@0.200.0/media_types/type_by_extension.ts": "daa801eb0f11cdf199445d0f1b656cf116d47dcf9e5b85cc1e6b4469f5ee0432", + "https://deno.land/std@0.200.0/media_types/vendor/mime-db.v1.52.0.ts": "6925bbcae81ca37241e3f55908d0505724358cda3384eaea707773b2c7e99586", + "https://deno.land/std@0.200.0/path/_basename.ts": "057d420c9049821f983f784fd87fa73ac471901fb628920b67972b0f44319343", + "https://deno.land/std@0.200.0/path/_constants.ts": "e49961f6f4f48039c0dfed3c3f93e963ca3d92791c9d478ac5b43183413136e0", + "https://deno.land/std@0.200.0/path/_dirname.ts": "355e297236b2218600aee7a5301b937204c62e12da9db4b0b044993d9e658395", + "https://deno.land/std@0.200.0/path/_extname.ts": "eaaa5aae1acf1f03254d681bd6a8ce42a9cb5b7ff2213a9d4740e8ab31283664", + "https://deno.land/std@0.200.0/path/_format.ts": "4a99270d6810f082e614309164fad75d6f1a483b68eed97c830a506cc589f8b4", + "https://deno.land/std@0.200.0/path/_from_file_url.ts": "7e4e5626089785adddb061f1b9f4932d6b21c7df778e7449531a11e32048245c", + "https://deno.land/std@0.200.0/path/_interface.ts": "6471159dfbbc357e03882c2266d21ef9afdb1e4aa771b0545e90db58a0ba314b", + "https://deno.land/std@0.200.0/path/_is_absolute.ts": "05dac10b5e93c63198b92e3687baa2be178df5321c527dc555266c0f4f51558c", + "https://deno.land/std@0.200.0/path/_join.ts": "fd78555bc34d5f188918fc7018dfe8fe2df5bbad94a3b30a433666c03934d77f", + "https://deno.land/std@0.200.0/path/_normalize.ts": "a19ec8706b2707f9dd974662a5cd89fad438e62ab1857e08b314a8eb49a34d81", + "https://deno.land/std@0.200.0/path/_parse.ts": "0f9b0ff43682dd9964eb1c4398610c4e165d8db9d3ac9d594220217adf480cfa", + "https://deno.land/std@0.200.0/path/_relative.ts": "27bdeffb5311a47d85be26d37ad1969979359f7636c5cd9fcf05dcd0d5099dc5", + "https://deno.land/std@0.200.0/path/_resolve.ts": "7a3616f1093735ed327e758313b79c3c04ea921808ca5f19ddf240cb68d0adf6", + "https://deno.land/std@0.200.0/path/_to_file_url.ts": "739bfda583598790b2e77ce227f2bb618f6ebdb939788cea47555b43970ec58c", + "https://deno.land/std@0.200.0/path/_to_namespaced_path.ts": "0d5f4caa2ed98ef7a8786286df6af804b50e38859ae897b5b5b4c8c5930a75c8", + "https://deno.land/std@0.200.0/path/_util.ts": "4e191b1bac6b3bf0c31aab42e5ca2e01a86ab5a0d2e08b75acf8585047a86221", + "https://deno.land/std@0.200.0/path/basename.ts": "6f08fbb90dbfcf320765b3abb01f995b1723f75e2534acfd5380e202c802a3aa", + "https://deno.land/std@0.200.0/path/common.ts": "ee7505ab01fd22de3963b64e46cff31f40de34f9f8de1fff6a1bd2fe79380000", + "https://deno.land/std@0.200.0/path/dirname.ts": "098996822a31b4c46e1eb52a19540d3c6f9f54b772fc8a197939eeabc29fca2f", + "https://deno.land/std@0.200.0/path/extname.ts": "9b83c62fd16505739541f7a3ab447d8972da39dbf668d47af2f93206c2480893", + "https://deno.land/std@0.200.0/path/format.ts": "cb22f95cc7853d590b87708cc9441785e760d711188facff3d225305a8213aca", + "https://deno.land/std@0.200.0/path/from_file_url.ts": "a6221cfc928928ec4d9786d767dfac98fa2ab746af0786446c9834a07b98817e", + "https://deno.land/std@0.200.0/path/glob.ts": "d479e0a695621c94d3fd7fe7abd4f9499caf32a8de13f25073451c6ef420a4e1", + "https://deno.land/std@0.200.0/path/is_absolute.ts": "6b3d36352eb7fa29edb53f9e7b09b1aeb022a3c5465764f6cc5b8c41f9736197", + "https://deno.land/std@0.200.0/path/join.ts": "4a2867ff2f3c81ffc9eb3d56dade16db6f8bd3854f269306d23dad4115089c84", + "https://deno.land/std@0.200.0/path/mod.ts": "7765507696cb321994cdacfc19ee3ba61e8e3ebf4bd98fa75a276cf5dc18ce2a", + "https://deno.land/std@0.200.0/path/normalize.ts": "7d992cd262b2deefa842d93a8ba2ed51f3949ba595b1d07f627ac2cddbc74808", + "https://deno.land/std@0.200.0/path/parse.ts": "031fe488b3497fb8312fc1dc3c3d6c2d80707edd9c661e18ee9fd20f95edf322", + "https://deno.land/std@0.200.0/path/posix.ts": "0a1c1952d132323a88736d03e92bd236f3ed5f9f079e5823fae07c8d978ee61b", + "https://deno.land/std@0.200.0/path/relative.ts": "7db80c5035016174267da16321a742d76e875215c317859a383b12f413c6f5d6", + "https://deno.land/std@0.200.0/path/resolve.ts": "103b62207726a27f28177f397008545804ecb20aaf00623af1f622b18cd80b9f", + "https://deno.land/std@0.200.0/path/separator.ts": "0fb679739d0d1d7bf45b68dacfb4ec7563597a902edbaf3c59b50d5bcadd93b1", + "https://deno.land/std@0.200.0/path/to_file_url.ts": "dd32f7a01bbf3b15b5df46796659984b372973d9b2d7d59bcf0eb990763a0cb5", + "https://deno.land/std@0.200.0/path/to_namespaced_path.ts": "4e643ab729bf49ccdc166ad48d2de262ff462938fcf2a44a4425588f4a0bd690", + "https://deno.land/std@0.200.0/path/win32.ts": "8b3f80ef7a462511d5e8020ff490edcaa0a0d118f1b1e9da50e2916bdd73f9dd", + "https://deno.land/std@0.200.0/streams/_common.ts": "f45cba84f0d813de3326466095539602364a9ba521f804cc758f7a475cda692d", + "https://deno.land/std@0.200.0/streams/buffer.ts": "6cd773d22cf21bb988a98cc551b5abfc4c3b03516f93eaa3fb6f2f6e16032deb", + "https://deno.land/std@0.200.0/streams/byte_slice_stream.ts": "c46d7c74836fc8c1a9acd9fe211cbe1bbaaee1b36087c834fb03af4991135c3a", + "https://deno.land/std@0.200.0/streams/copy.ts": "75cbc795ff89291df22ddca5252de88b2e16d40c85d02840593386a8a1454f71", + "https://deno.land/std@0.200.0/streams/delimiter_stream.ts": "f69e849b3d1f59f02424497273f411105a6f76a9f13da92aeeb9a2d554236814", + "https://deno.land/std@0.200.0/streams/early_zip_readable_streams.ts": "4005fa74162b943f79899e5d7cb96adcbc0a6b867f9144974ed12d30e0a556e1", + "https://deno.land/std@0.200.0/streams/iterate_reader.ts": "bbec1d45c2df2c0c5920bad0549351446fdc8e0886d99e95959b259dbcdb6072", + "https://deno.land/std@0.200.0/streams/limited_bytes_transform_stream.ts": "05dc592ffaab83257494d22dd53917e56243c26e5e3129b3f13ddbbbc4785048", + "https://deno.land/std@0.200.0/streams/limited_transform_stream.ts": "d69ab790232c1b86f53621ad41ef03c235f2abb4b7a1cd51960ad6e12ee55e38", + "https://deno.land/std@0.200.0/streams/merge_readable_streams.ts": "dc2db0cbf1b14d999aa2aa2a2a1ba24ce58953878f29845ed9319321d0a01fab", + "https://deno.land/std@0.200.0/streams/mod.ts": "c07ec010e700b9ea887dc36ca08729828bc7912f711e4054e24d33fd46282252", + "https://deno.land/std@0.200.0/streams/read_all.ts": "ee319772fb0fd28302f97343cc48dfcf948f154fd0d755d8efe65814b70533be", + "https://deno.land/std@0.200.0/streams/readable_stream_from_iterable.ts": "a355e97ba8671a4611ae9671c1f33c4a7e6a1b42fbdc9a399338ac3d6f875364", + "https://deno.land/std@0.200.0/streams/readable_stream_from_reader.ts": "bfc416c4576a30aac6b9af22c9dc292c20c6742141ee7c55b5e85460beb0c54e", + "https://deno.land/std@0.200.0/streams/reader_from_iterable.ts": "55f68110dce3f8f2c87b834d95f153bc904257fc65175f9f2abe78455cb8047c", + "https://deno.land/std@0.200.0/streams/reader_from_stream_reader.ts": "fa4971e5615a010e49492c5d1688ca1a4d17472a41e98b498ab89a64ebd7ac73", + "https://deno.land/std@0.200.0/streams/text_delimiter_stream.ts": "20e680ab8b751390e359288ce764f9c47d164af11a263870746eeca4bc7d976b", + "https://deno.land/std@0.200.0/streams/text_line_stream.ts": "0f2c4b33a5fdb2476f2e060974cba1347cefe99a4af33c28a57524b1a34750fa", + "https://deno.land/std@0.200.0/streams/to_transform_stream.ts": "89fd367cafb3b6d80d61e2f4f1fcf66cc75723ecee8d474b495f022264ec6c3b", + "https://deno.land/std@0.200.0/streams/writable_stream_from_writer.ts": "56fff5c82fb736fdd669b567cc0b2bbbe0351002cd13254eae26c366e2bed89a", + "https://deno.land/std@0.200.0/streams/write_all.ts": "aec90152978581ea62d56bb53a5cbf487e6a89c902f87c5969681ffbdf32b998", + "https://deno.land/std@0.200.0/streams/writer_from_stream_writer.ts": "07c7ee025151a190f37fc42cbb01ff93afc949119ebddc6e0d0df14df1bf6950", + "https://deno.land/std@0.200.0/streams/zip_readable_streams.ts": "a9d81aa451240f79230add674809dbee038d93aabe286e2d9671e66591fc86ca", + "https://deno.land/x/bcrypt@v0.4.1/mod.ts": "ff09bdae282583cf5f7d87efe37ddcecef7f14f6d12e8b8066a3058db8c6c2f7", + "https://deno.land/x/bcrypt@v0.4.1/src/bcrypt/base64.ts": "b8266450a4f1eb6960f60f2f7986afc4dde6b45bd2d7ee7ba10789e67e17b9f7", + "https://deno.land/x/bcrypt@v0.4.1/src/bcrypt/bcrypt.ts": "ec221648cc6453ea5e3803bc817c01157dada06aa6f7a0ba6b9f87aae32b21e2", + "https://deno.land/x/bcrypt@v0.4.1/src/main.ts": "08d201b289c8d9c46f8839c69cd6625b213863db29775c7a200afc3b540e64f8", + "https://deno.land/x/bcrypt@v0.4.1/src/worker.ts": "5a73bdfee9c9e622f47c9733d374b627dce52fb3ec1e74c8226698b3fc57ffac", + "https://deno.land/x/oak@v12.6.1/application.ts": "3028d3f6fa5ee743de013881550d054372c11d83c45099c2d794033786d27008", + "https://deno.land/x/oak@v12.6.1/body.ts": "1899761b97fc9d776f3710b2637fb047ba29b968609afc6c0e5219b1108e703c", + "https://deno.land/x/oak@v12.6.1/buf_reader.ts": "26640736541598dbd9f2b84a9d0595756afff03c9ca55b66eef1911f7798b56d", + "https://deno.land/x/oak@v12.6.1/content_disposition.ts": "8b8c3cb2fba7138cd5b7f82fc3b5ea39b33db924a824b28261659db7e164621e", + "https://deno.land/x/oak@v12.6.1/context.ts": "895a2d40186b89c28ba3947bf08a9335f1a11fc33133f760082536b74c53d1ca", + "https://deno.land/x/oak@v12.6.1/deps.ts": "267ef76c25592101fe1f6c6d7730664015a9179c974da4f7441297d9367a9514", + "https://deno.land/x/oak@v12.6.1/etag.ts": "32e47726b41698aefdd71faac5aaf2907c9bdd41ef18a7693863be4f8fee115d", + "https://deno.land/x/oak@v12.6.1/forwarded.ts": "e656f96a85574e2a6ee54dc35efc9f72d543c9ae0923760ef426ee7369eef01c", + "https://deno.land/x/oak@v12.6.1/headers.ts": "769fd042d34fbcd5667cbd745b5c65d335cc8430e822dbf1f87d65313cab4b47", + "https://deno.land/x/oak@v12.6.1/helpers.ts": "6b03c6a2be06ec775d54449e442a2bac234aa952948ca758356eab6dc87af618", + "https://deno.land/x/oak@v12.6.1/http_server_native.ts": "98e12c50a959553cfc144bc00999c969fa69ca781cbd96bec563f55691ab82db", + "https://deno.land/x/oak@v12.6.1/http_server_native_request.ts": "552b174b5e13e92de8897d5b6f716b1e5a53543115481d65a651a41e4ca29ec9", + "https://deno.land/x/oak@v12.6.1/isMediaType.ts": "62d638abcf837ece3a8f07a4b7ca59794135cb0d4b73194c7d5837efd4161005", + "https://deno.land/x/oak@v12.6.1/mediaTyper.ts": "042b853fc8e9c3f6c628dd389e03ef481552bf07242efc3f8a1af042102a6105", + "https://deno.land/x/oak@v12.6.1/middleware.ts": "c7f7a0424a6dd99a00e4b8d7d6e131efc0facc8dea781845d713b63df8ef1862", + "https://deno.land/x/oak@v12.6.1/middleware/proxy.ts": "6f2799cf60d926e7a8d13ff757a59d7f0f930407db7ee9b81e7c064138eb89ff", + "https://deno.land/x/oak@v12.6.1/mod.ts": "f6aa47ad1b6099470c9a884cccad9d3ac0fd242ba940896291ab76cd26cf554b", + "https://deno.land/x/oak@v12.6.1/multipart.ts": "1484e01b98f5135f2aa09f7d0ce1e7be39109bf9f045ac660e941619d04e3d29", + "https://deno.land/x/oak@v12.6.1/range.ts": "1ca15fc1ac21c650c34e6997a75af2af9d9d8eb6fe2d5d1dadeac3dfd4a9c152", + "https://deno.land/x/oak@v12.6.1/request.ts": "32409827e285ee65889b22bbaaea5d6b280258124c2e9a4f724baa8e6d6375b7", + "https://deno.land/x/oak@v12.6.1/response.ts": "094d950a5158f5b3446ca8a7b6e975dd23afb42b38c38517cc2f41dc75b16b4c", + "https://deno.land/x/oak@v12.6.1/router.ts": "0f53d6249f9e8f89f2522b2b810b9302d0f22593c184b16b24b03bf2b7d42ea1", + "https://deno.land/x/oak@v12.6.1/send.ts": "5ec49f106294593f468317a0c885da4f3274bf6d0fe9e16a7304391730b4f4fb", + "https://deno.land/x/oak@v12.6.1/structured_clone.ts": "c3888b14d1eec558345bfbf13d0993d59bd45aaa8588444e35dd558c3a921cd8", + "https://deno.land/x/oak@v12.6.1/testing.ts": "37d684d57bb8e5150fb5eb2677e66b04dcb422709cf2c5a74c1df94d52aa02e2", + "https://deno.land/x/oak@v12.6.1/util.ts": "0a3fdffb114859c2de84e1783efa3a544af4d2af8c6f08e0d25655de9d3e69bb", + "https://deno.land/x/path_to_regexp@v6.2.1/index.ts": "894060567837bae8fc9c5cbd4d0a05e9024672083d5883b525c031eea940e556" + }, + "workspace": { + "dependencies": [ + "jsr:@oak/oak@^17.2.0", + "jsr:@std/dotenv@~0.225.6", + "npm:mongodb@^6.10.0" + ] + } +} diff --git a/src/db/mongo.ts b/src/db/mongo.ts new file mode 100644 index 0000000..7e653ad --- /dev/null +++ b/src/db/mongo.ts @@ -0,0 +1,33 @@ +import { MongoClient } from "npm:mongodb@6.10.0"; + +const MONGO_URI = Deno.env.get("MONGO_URI"); +if (!MONGO_URI) { + throw new Error("MONGO_URI environment variable is required"); +} + +const DB_NAME = new URL(MONGO_URI).pathname.replace("/", "") || "ams"; + +let client: MongoClient | null = null; + +export async function getMongoClient(): Promise { + if (!client) { + console.log("Connecting to MongoDB..."); + client = new MongoClient(MONGO_URI); + await client.connect(); + console.log("✅ MongoDB connected successfully"); + } + return client; +} + +export async function getDB() { + const mongoClient = await getMongoClient(); + return mongoClient.db(DB_NAME); +} + +export async function closeMongoConnection() { + if (client) { + await client.close(); + client = null; + console.log("MongoDB connection closed"); + } +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..0efeedb --- /dev/null +++ b/src/main.ts @@ -0,0 +1,100 @@ +import { Application } from "@oak/oak"; +import "@std/dotenv/load"; +import { getMongoClient } from "./db/mongo.ts"; +import { corsMiddleware } from "./middleware/cors.ts"; +import { authRouter } from "./routes/auth.ts"; +import { tasksRouter } from "./routes/tasks.ts"; +import { agentTasksRouter } from "./routes/agenttasks.ts"; +import { secretsRouter, secretFoldersRouter } from "./routes/secrets.ts"; +import { secretAuditLogsRouter } from "./routes/secretAuditLogs.ts"; +import { workspaceRouter } from "./routes/workspace.ts"; +import { labelsRouter } from "./routes/labels.ts"; +import { cronJobsRouter } from "./routes/cronjobs.ts"; +import { quickTextsRouter } from "./routes/quicktexts.ts"; +import { transcribeRouter } from "./routes/transcribe.ts"; +import { messagingRouter } from "./routes/messaging.ts"; +import { tokensRouter } from "./routes/tokens.ts"; +import { settingsRouter } from "./routes/settings.ts"; +import { commentsRouter } from "./routes/comments.ts"; +import { attachmentsRouter } from "./routes/attachments.ts"; +import { agentsRouter } from "./routes/agents.ts"; +import { logsRouter } from "./routes/logs.ts"; +import { userSettingsRouter } from "./routes/usersettings.ts"; +import gitlabRouter from "./routes/gitlab.ts"; +import { dockerRouter } from "./routes/docker.ts"; +import { exportRouter } from "./routes/export.ts"; +import { appUpdateRouter } from "./routes/appUpdate.ts"; +import { handleWebSocket } from "./ws/handler.ts"; + +const app = new Application(); +const PORT = parseInt(Deno.env.get("PORT") || "8000"); + +// Connect to MongoDB +await getMongoClient(); + +// Middleware +app.use(corsMiddleware); + +// Routes +app.use(authRouter.routes()); +app.use(authRouter.allowedMethods()); +app.use(tasksRouter.routes()); +app.use(tasksRouter.allowedMethods()); +app.use(agentTasksRouter.routes()); +app.use(agentTasksRouter.allowedMethods()); +app.use(secretsRouter.routes()); +app.use(secretsRouter.allowedMethods()); +app.use(secretFoldersRouter.routes()); +app.use(secretFoldersRouter.allowedMethods()); +app.use(secretAuditLogsRouter.routes()); +app.use(secretAuditLogsRouter.allowedMethods()); +app.use(workspaceRouter.routes()); +app.use(workspaceRouter.allowedMethods()); +app.use(labelsRouter.routes()); +app.use(labelsRouter.allowedMethods()); +app.use(cronJobsRouter.routes()); +app.use(cronJobsRouter.allowedMethods()); +app.use(quickTextsRouter.routes()); +app.use(quickTextsRouter.allowedMethods()); +app.use(transcribeRouter.routes()); +app.use(transcribeRouter.allowedMethods()); +app.use(messagingRouter.routes()); +app.use(messagingRouter.allowedMethods()); +app.use(tokensRouter.routes()); +app.use(tokensRouter.allowedMethods()); +app.use(settingsRouter.routes()); +app.use(settingsRouter.allowedMethods()); +app.use(commentsRouter.routes()); +app.use(commentsRouter.allowedMethods()); +app.use(attachmentsRouter.routes()); +app.use(attachmentsRouter.allowedMethods()); +app.use(agentsRouter.routes()); +app.use(agentsRouter.allowedMethods()); +app.use(logsRouter.routes()); +app.use(logsRouter.allowedMethods()); +app.use(userSettingsRouter.routes()); +app.use(userSettingsRouter.allowedMethods()); +app.use(gitlabRouter.routes()); +app.use(gitlabRouter.allowedMethods()); +app.use(dockerRouter.routes()); +app.use(dockerRouter.allowedMethods()); +app.use(exportRouter.routes()); +app.use(exportRouter.allowedMethods()); +app.use(appUpdateRouter.routes()); +app.use(appUpdateRouter.allowedMethods()); + +console.log(`AMS Backend running on port ${PORT}`); + +// Use Deno.serve for WebSocket support +Deno.serve({ port: PORT }, async (req: Request): Promise => { + const url = new URL(req.url); + + // Handle WebSocket upgrades + if (url.pathname === "/ws" && req.headers.get("upgrade")?.toLowerCase() === "websocket") { + return handleWebSocket(req); + } + + // Forward to Oak for regular HTTP + const oakResponse = await app.handle(req); + return oakResponse || new Response("Not Found", { status: 404 }); +}); diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts new file mode 100644 index 0000000..8977d47 --- /dev/null +++ b/src/middleware/auth.ts @@ -0,0 +1,46 @@ +import { Context, Next } from "@oak/oak"; +import { verifyJWT } from "../utils/jwt.ts"; + +export interface AuthState { + user: { + id: string; + email: string; + username: string; + role: string; + }; +} + +export async function authMiddleware(ctx: Context, next: Next) { + const authHeader = ctx.request.headers.get("Authorization"); + + if (!authHeader || !authHeader.startsWith("Bearer ")) { + ctx.response.status = 401; + ctx.response.body = { error: "No token provided" }; + return; + } + + const token = authHeader.substring(7); + + // Service-API-Key für Agent-Zugriff (kein JWT nötig) + const serviceKey = Deno.env.get("AMS_SERVICE_API_KEY"); + if (serviceKey && token === serviceKey) { + ctx.state.user = { + id: "service:pixel", + email: "pixel@agentenbude.de", + username: "Pixel (Agent)", + role: "agent", + }; + await next(); + return; + } + + try { + const payload = await verifyJWT(token); + ctx.state.user = payload; + await next(); + } catch { + ctx.response.status = 401; + ctx.response.body = { error: "Invalid or expired token" }; + } +} + diff --git a/src/middleware/cors.ts b/src/middleware/cors.ts new file mode 100644 index 0000000..2caeb3c --- /dev/null +++ b/src/middleware/cors.ts @@ -0,0 +1,49 @@ +import { Context, Next } from "@oak/oak"; + +const ALLOWED_ORIGINS = [ + "http://localhost:5173", + "https://localhost:5173", + "http://127.0.0.1:5173", + "https://127.0.0.1:5173", + "http://localhost:3000", + "https://localhost:3000", + "https://ams.kronos-soulution.de", + "http://ams.kronos-soulution.de", + "https://api.ams.kronos-soulution.de" +]; + +const ALLOWED_DOMAIN_SUFFIX = ".kronos-soulution.de"; + +function isAllowedOrigin(origin: string): boolean { + if (ALLOWED_ORIGINS.includes(origin)) { + return true; + } + + try { + const url = new URL(origin); + return url.hostname === "kronos-soulution.de" || + url.hostname.endsWith(ALLOWED_DOMAIN_SUFFIX); + } catch { + return false; + } +} + +export async function corsMiddleware(ctx: Context, next: Next) { + const origin = ctx.request.headers.get("origin") || ""; + + if (isAllowedOrigin(origin)) { + ctx.response.headers.set("Access-Control-Allow-Origin", origin); + } + + ctx.response.headers.set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS"); + ctx.response.headers.set("Access-Control-Allow-Headers", "Content-Type, Authorization"); + ctx.response.headers.set("Access-Control-Allow-Credentials", "true"); + ctx.response.headers.set("Access-Control-Max-Age", "86400"); + + if (ctx.request.method === "OPTIONS") { + ctx.response.status = 204; + return; + } + + await next(); +} diff --git a/src/routes/agents.ts b/src/routes/agents.ts new file mode 100644 index 0000000..d7851ca --- /dev/null +++ b/src/routes/agents.ts @@ -0,0 +1,375 @@ +import { Router } from "@oak/oak"; +import { ObjectId } from "mongodb"; +import { getDB } from "../db/mongo.ts"; +import { authMiddleware } from "../middleware/auth.ts"; + +export const agentsRouter = new Router({ prefix: "/api/agents" }); + +interface Agent { + _id: ObjectId; + name: string; + emoji: string; + role: string; + status: "online" | "idle" | "offline" | "busy"; + supervisor?: string; // ID of supervising agent + discordId?: string; + containerId?: string; + containerIp?: string; + lastSeen?: Date; + createdAt: Date; + updatedAt: Date; +} + +// Get all agents +agentsRouter.get("/", authMiddleware, async (ctx) => { + const db = await getDB(); + const agents = await db.collection("agents") + .find({}) + .sort({ name: 1 }) + .toArray(); + + ctx.response.body = { agents }; +}); + +// Get single agent +agentsRouter.get("/:id", authMiddleware, async (ctx) => { + const db = await getDB(); + const id = ctx.params.id; + + let agent; + try { + agent = await db.collection("agents").findOne({ _id: new ObjectId(id) }); + } catch { + ctx.response.status = 400; + ctx.response.body = { error: "Invalid agent ID" }; + return; + } + + if (!agent) { + ctx.response.status = 404; + ctx.response.body = { error: "Agent not found" }; + return; + } + + ctx.response.body = { agent }; +}); + +// Create agent (admin only) +agentsRouter.post("/", authMiddleware, async (ctx) => { + if (ctx.state.user.role !== "admin") { + ctx.response.status = 403; + ctx.response.body = { error: "Admin access required" }; + return; + } + + const body = await ctx.request.body.json(); + const { name, emoji, role, status, supervisor, discordId, containerId, containerIp } = body; + + if (!name || !role) { + ctx.response.status = 400; + ctx.response.body = { error: "Name and role are required" }; + return; + } + + const db = await getDB(); + const now = new Date(); + + const result = await db.collection("agents").insertOne({ + name, + emoji: emoji || "🤖", + role, + status: status || "offline", + supervisor, + discordId, + containerId, + containerIp, + lastSeen: now, + createdAt: now, + updatedAt: now + } as Agent); + + ctx.response.status = 201; + ctx.response.body = { + message: "Agent created", + id: result.insertedId.toString() + }; +}); + +// Update agent +agentsRouter.put("/:id", authMiddleware, async (ctx) => { + if (ctx.state.user.role !== "admin") { + ctx.response.status = 403; + ctx.response.body = { error: "Admin access required" }; + return; + } + + const id = ctx.params.id; + const body = await ctx.request.body.json(); + const { name, emoji, role, status, supervisor, discordId, containerId, containerIp } = body; + + const db = await getDB(); + + const updateFields: Partial = { updatedAt: new Date() }; + if (name !== undefined) updateFields.name = name; + if (emoji !== undefined) updateFields.emoji = emoji; + if (role !== undefined) updateFields.role = role; + if (status !== undefined) updateFields.status = status; + if (supervisor !== undefined) updateFields.supervisor = supervisor; + if (discordId !== undefined) updateFields.discordId = discordId; + if (containerId !== undefined) updateFields.containerId = containerId; + if (containerIp !== undefined) updateFields.containerIp = containerIp; + + try { + const result = await db.collection("agents").updateOne( + { _id: new ObjectId(id) }, + { $set: updateFields } + ); + + if (result.matchedCount === 0) { + ctx.response.status = 404; + ctx.response.body = { error: "Agent not found" }; + return; + } + + ctx.response.body = { message: "Agent updated" }; + } catch { + ctx.response.status = 400; + ctx.response.body = { error: "Invalid agent ID" }; + } +}); + +// Update agent status (can be called by agents themselves) +agentsRouter.patch("/:id/status", authMiddleware, async (ctx) => { + const id = ctx.params.id; + const body = await ctx.request.body.json(); + const { status } = body; + + if (!["online", "idle", "offline", "busy"].includes(status)) { + ctx.response.status = 400; + ctx.response.body = { error: "Invalid status. Must be: online, idle, offline, or busy" }; + return; + } + + const db = await getDB(); + + try { + const result = await db.collection("agents").updateOne( + { _id: new ObjectId(id) }, + { $set: { status, lastSeen: new Date(), updatedAt: new Date() } } + ); + + if (result.matchedCount === 0) { + ctx.response.status = 404; + ctx.response.body = { error: "Agent not found" }; + return; + } + + ctx.response.body = { message: "Status updated" }; + } catch { + ctx.response.status = 400; + ctx.response.body = { error: "Invalid agent ID" }; + } +}); + +// ============ AGENT FILES ============ + +interface AgentFile { + _id: ObjectId; + agentId: string; + filename: string; + content: string; + updatedBy: string; + createdAt: Date; + updatedAt: Date; +} + +const ALLOWED_FILENAMES = [ + "SOUL.md", "MEMORY.md", "AGENTS.md", "USER.md", "TOOLS.md", + "HEARTBEAT.md", "IDENTITY.md", "WORKFLOW.md", "BOOTSTRAP.md", "README.md", + "CHECKLIST.md" +]; + +// Validate file path: must end with .md, no "..", max depth 2 +function isValidFilePath(filepath: string): boolean { + if (!filepath.endsWith(".md")) return false; + if (filepath.includes("..")) return false; + const parts = filepath.split("/"); + if (parts.length > 2) return false; + return parts.every(p => p.length > 0 && !p.startsWith(".")); +} + +// GET /api/agents/:id/files - List agent's MD files +// Optional query: ?path=memory (filter by subdirectory prefix) +agentsRouter.get("/:id/files", authMiddleware, async (ctx) => { + const agentId = ctx.params.id; + const pathFilter = ctx.request.url.searchParams.get("path"); + const db = await getDB(); + + try { + const agent = await db.collection("agents").findOne({ _id: new ObjectId(agentId) }); + if (!agent) { + ctx.response.status = 404; + ctx.response.body = { error: "Agent not found" }; + return; + } + + const query: Record = { agentId }; + if (pathFilter) { + query.filename = { $regex: `^${pathFilter}/` }; + } + + const files = await db.collection("agent_files") + .find(query) + .project({ content: 0 }) + .sort({ filename: 1 }) + .toArray(); + + ctx.response.body = { files }; + } catch { + ctx.response.status = 400; + ctx.response.body = { error: "Invalid agent ID" }; + } +}); + +// GET /api/agents/:id/files/:filename - Read file content +// Supports subdirectory paths via query ?path=memory/2026-02-08.md +// or direct :filename for root-level files +agentsRouter.get("/:id/files/:filename", authMiddleware, async (ctx) => { + const agentId = ctx.params.id; + const pathParam = ctx.request.url.searchParams.get("path"); + const filename = pathParam || ctx.params.filename; + + if (!isValidFilePath(filename)) { + ctx.response.status = 400; + ctx.response.body = { error: "Ungültiger Dateipfad. Nur .md-Dateien, max. 1 Unterordner." }; + return; + } + + const db = await getDB(); + + try { + const file = await db.collection("agent_files") + .findOne({ agentId, filename }); + + if (!file) { + ctx.response.status = 404; + ctx.response.body = { error: "File not found" }; + return; + } + + ctx.response.body = { file }; + } catch { + ctx.response.status = 400; + ctx.response.body = { error: "Invalid agent ID" }; + } +}); + +// PUT /api/agents/:id/files/:filename - Create or update file +// Supports subdirectory paths via query ?path=memory/2026-02-08.md +agentsRouter.put("/:id/files/:filename", authMiddleware, async (ctx) => { + const agentId = ctx.params.id; + const body = await ctx.request.body.json(); + const pathParam = body.path || ctx.request.url.searchParams.get("path") || ctx.params.filename; + const filename = pathParam; + const { content } = body; + + if (!isValidFilePath(filename)) { + ctx.response.status = 400; + ctx.response.body = { error: "Ungültiger Dateipfad. Nur .md-Dateien, max. 1 Unterordner." }; + return; + } + + if (content === undefined) { + ctx.response.status = 400; + ctx.response.body = { error: "Content is required" }; + return; + } + + if (content.length > 500 * 1024) { + ctx.response.status = 400; + ctx.response.body = { error: "File too large. Maximum 500KB." }; + return; + } + + const db = await getDB(); + + try { + // Verify agent exists + const agent = await db.collection("agents").findOne({ _id: new ObjectId(agentId) }); + if (!agent) { + ctx.response.status = 404; + ctx.response.body = { error: "Agent not found" }; + return; + } + + const now = new Date(); + const result = await db.collection("agent_files").updateOne( + { agentId, filename }, + { + $set: { content, updatedBy: ctx.state.user.id, updatedAt: now }, + $setOnInsert: { agentId, filename, createdAt: now } + }, + { upsert: true } + ); + + const isNew = result.upsertedCount > 0; + ctx.response.status = isNew ? 201 : 200; + ctx.response.body = { message: isNew ? "File created" : "File updated" }; + } catch { + ctx.response.status = 400; + ctx.response.body = { error: "Invalid agent ID" }; + } +}); + +// DELETE /api/agents/:id/files/:filename - Delete file +// Supports subdirectory paths via query ?path=memory/2026-02-08.md +agentsRouter.delete("/:id/files/:filename", authMiddleware, async (ctx) => { + const pathParam = ctx.request.url.searchParams.get("path") || ctx.params.filename; + const agentId = ctx.params.id; + const filename = pathParam; + const db = await getDB(); + + try { + const result = await db.collection("agent_files").deleteOne({ agentId, filename }); + if (result.deletedCount === 0) { + ctx.response.status = 404; + ctx.response.body = { error: "File not found" }; + return; + } + ctx.response.body = { message: "File deleted" }; + } catch { + ctx.response.status = 400; + ctx.response.body = { error: "Invalid agent ID" }; + } +}); + +// Delete agent (admin only) +agentsRouter.delete("/:id", authMiddleware, async (ctx) => { + if (ctx.state.user.role !== "admin") { + ctx.response.status = 403; + ctx.response.body = { error: "Admin access required" }; + return; + } + + const id = ctx.params.id; + const db = await getDB(); + + try { + const result = await db.collection("agents").deleteOne({ _id: new ObjectId(id) }); + + if (result.deletedCount === 0) { + ctx.response.status = 404; + ctx.response.body = { error: "Agent not found" }; + return; + } + + // Cascade delete agent files + await db.collection("agent_files").deleteMany({ agentId: id }); + + ctx.response.body = { message: "Agent deleted" }; + } catch { + ctx.response.status = 400; + ctx.response.body = { error: "Invalid agent ID" }; + } +}); + diff --git a/src/routes/agenttasks.ts b/src/routes/agenttasks.ts new file mode 100644 index 0000000..4de265c --- /dev/null +++ b/src/routes/agenttasks.ts @@ -0,0 +1,281 @@ +import { Router } from "@oak/oak"; +import { ObjectId } from "mongodb"; +import { getDB } from "../db/mongo.ts"; +import { authMiddleware } from "../middleware/auth.ts"; +import { getNextNumber } from "./tasks.ts"; + +export const agentTasksRouter = new Router({ prefix: "/api/agent-tasks" }); + +interface AgentTask { + _id: ObjectId; + number?: number; + message: string; + projectId?: string; + projectName?: string; + agentId?: string; + agentName?: string; + linkedTaskIds: string[]; + linkedTaskTitles: string[]; + status: "pending" | "in_progress" | "done" | "rejected"; + createdBy: string; + createdByName: string; + result?: string; + createdAt: Date; + updatedAt: Date; +} + +// Neuen Auftrag erstellen +agentTasksRouter.post("/", authMiddleware, async (ctx) => { + const db = await getDB(); + const body = await ctx.request.body.json(); + const { message, projectId, projectName, agentId, agentName, linkedTaskIds, linkedTaskTitles } = body; + + if (!message || !message.trim()) { + ctx.response.status = 400; + ctx.response.body = { error: "Nachricht ist erforderlich" }; + return; + } + + const now = new Date(); + const number = await getNextNumber("agent_task_number"); + const doc: Omit = { + number, + message: message.trim(), + projectId: projectId || undefined, + projectName: projectName || undefined, + agentId: agentId || undefined, + agentName: agentName || undefined, + linkedTaskIds: linkedTaskIds || [], + linkedTaskTitles: linkedTaskTitles || [], + status: "pending", + createdBy: ctx.state.user.id, + createdByName: ctx.state.user.username, + createdAt: now, + updatedAt: now, + }; + + const result = await db.collection("agent_tasks").insertOne(doc as AgentTask); + + ctx.response.status = 201; + ctx.response.body = { + message: "Auftrag erstellt", + id: result.insertedId.toString(), + }; +}); + +// Alle Aufträge laden (mit optionalen Filtern) +agentTasksRouter.get("/", authMiddleware, async (ctx) => { + const db = await getDB(); + const url = ctx.request.url; + + const filter: Record = {}; + + const status = url.searchParams.get("status"); + if (status) filter.status = status; + + const agentId = url.searchParams.get("agentId"); + if (agentId) filter.agentId = agentId; + + const tasks = await db.collection("agent_tasks") + .find(filter) + .sort({ createdAt: -1 }) + .limit(100) + .toArray(); + + ctx.response.body = { tasks }; +}); + +// Pending Aufträge für Agenten (Kurzform) +agentTasksRouter.get("/pending", authMiddleware, async (ctx) => { + const db = await getDB(); + + const tasks = await db.collection("agent_tasks") + .find({ status: "pending" }) + .sort({ createdAt: 1 }) + .toArray(); + + ctx.response.body = { tasks }; +}); + +// Auftrag nach Nummer laden +agentTasksRouter.get("/by-number/:number", authMiddleware, async (ctx) => { + const db = await getDB(); + const num = parseInt(ctx.params.number); + if (isNaN(num)) { + ctx.response.status = 400; + ctx.response.body = { error: "Ungültige Nummer" }; + return; + } + const task = await db.collection("agent_tasks").findOne({ number: num }); + if (!task) { + ctx.response.status = 404; + ctx.response.body = { error: "Auftrag nicht gefunden" }; + return; + } + ctx.response.body = { task }; +}); + +// Einzelnen Auftrag laden +agentTasksRouter.get("/:id", authMiddleware, async (ctx) => { + const db = await getDB(); + const id = ctx.params.id; + + try { + const task = await db.collection("agent_tasks") + .findOne({ _id: new ObjectId(id) }); + + if (!task) { + ctx.response.status = 404; + ctx.response.body = { error: "Auftrag nicht gefunden" }; + return; + } + + ctx.response.body = { task }; + } catch { + ctx.response.status = 400; + ctx.response.body = { error: "Ungültige ID" }; + } +}); + +// OpenClaw Agent-Proxy — startet isolierten Agent-Run über WireGuard VPN (hooks/agent endpoint) +agentTasksRouter.post("/wake", authMiddleware, async (ctx) => { + const openclawBaseUrl = Deno.env.get("OPENCLAW_WAKE_URL"); // e.g. http://10.10.0.3:18789/hooks/wake + const openclawToken = Deno.env.get("OPENCLAW_WAKE_TOKEN"); + + if (!openclawBaseUrl || !openclawToken) { + ctx.response.status = 503; + ctx.response.body = { error: "OpenClaw Wake nicht konfiguriert" }; + return; + } + + // Pending Tasks laden für den Prompt + const db = await getDB(); + const pendingTasks = await db.collection("agent_tasks") + .find({ status: "pending" }) + .sort({ createdAt: 1 }) + .toArray(); + + if (pendingTasks.length === 0) { + ctx.response.body = { message: "Keine pending Tasks" }; + return; + } + + // Auto-Timeout: Tasks die >30 Minuten in_progress sind, auf pending zurücksetzen + const TIMEOUT_MS = 30 * 60 * 1000; + const timeoutThreshold = new Date(Date.now() - TIMEOUT_MS); + await db.collection("agent_tasks").updateMany( + { status: "in_progress", updatedAt: { $lt: timeoutThreshold } }, + { $set: { status: "pending", updatedAt: new Date() } } + ); + + // Lock: Nur ein Task gleichzeitig pro Agent + const inProgressCount = await db.collection("agent_tasks") + .countDocuments({ status: "in_progress" }); + + if (inProgressCount > 0) { + ctx.response.body = { message: "Agent arbeitet bereits an einem Task", blocked: true }; + return; + } + + // Prompt mit Task-Details bauen + const taskList = pendingTasks.map((t, i) => + `${i + 1}. [${t._id}] "${t.message}" (Projekt: ${t.projectName || "–"}, von: ${t.createdByName})` + ).join("\n"); + + const serviceKey = Deno.env.get("AMS_SERVICE_API_KEY") || ""; + // Nur den ERSTEN pending Task bearbeiten (1 Task pro Run) + const firstTask = pendingTasks[0]; + const prompt = `WICHTIG: Lade zuerst CHECKLIST.md und WORKFLOW.md aus deinem Workspace und halte dich strikt daran! + +WICHTIG: KEINE Sub-Agenten spawnen (sessions_spawn)! Du bearbeitest Tasks SELBST, einzeln, nacheinander. Nur 1 Task pro Run! + +Dein Agent-Task: +[${firstTask._id}] "${firstTask.message}" (Projekt: ${firstTask.projectName || "–"}, von: ${firstTask.createdByName}) + +Bearbeite diesen Task nach der CHECKLIST.md: +1. Task im AMS auf "in_progress" setzen: curl -X PUT https://api.ams.agentenbude.de/api/tasks/ -H "Authorization: Bearer ${serviceKey}" -H "Content-Type: application/json" -d '{"status":"in_progress"}' +2. Feature-Branch erstellen (feature/beschreibung) +3. Backend ZUERST implementieren (bei Full-Stack) +4. Kein \`any\`, kein \`console.log\`, keine Secrets im Code +5. Dark Theme beachten! Alle UI-Elemente müssen zum bestehenden Dark-Mode-Design passen (Farben: #1a1a2e Hintergrund, rgba(255,255,255,0.05-0.15) für Karten/Borders, helle Textfarben) +6. Self-Review: npm run build (FE) / deno check (BE) +7. Conventional Commits auf DEUTSCH +8. MR erstellen und mergen +9. Tag + Deploy +10. Agent-Task auf "done" setzen: curl -X PUT https://api.ams.agentenbude.de/api/agent-tasks/ -H "Authorization: Bearer ${serviceKey}" -H "Content-Type: application/json" -d '{"status":"done","result":""}' +11. Task-Kommentar mit Zusammenfassung + MR + Version +12. Melde dem Chef das Ergebnis. + +API-Auth: Bearer ${serviceKey} +AMS-API: https://api.ams.agentenbude.de/api`; + + // /hooks/agent statt /hooks/wake aufrufen + const agentUrl = openclawBaseUrl.replace("/hooks/wake", "/hooks/agent"); + + try { + const response = await fetch(agentUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${openclawToken}`, + }, + body: JSON.stringify({ + message: prompt, + name: "AMS-Task", + sessionKey: "hook:ams-task", + wakeMode: "now", + deliver: true, + channel: "telegram", + to: "7150608398", + }), + signal: AbortSignal.timeout(5000), + }); + + if (response.ok) { + ctx.response.body = { message: "Agent gestartet", tasks: pendingTasks.length }; + } else { + ctx.response.status = 502; + ctx.response.body = { error: "Agent-Start fehlgeschlagen", status: response.status }; + } + } catch { + ctx.response.status = 502; + ctx.response.body = { error: "OpenClaw nicht erreichbar" }; + } +}); + +// Auftrag-Status aktualisieren (Agent nimmt an / erledigt / lehnt ab) +agentTasksRouter.put("/:id", authMiddleware, async (ctx) => { + const db = await getDB(); + const id = ctx.params.id; + const body = await ctx.request.body.json(); + const { status, result } = body; + + const validStatuses = ["pending", "in_progress", "done", "rejected"]; + if (status && !validStatuses.includes(status)) { + ctx.response.status = 400; + ctx.response.body = { error: `Ungültiger Status. Erlaubt: ${validStatuses.join(", ")}` }; + return; + } + + const updateFields: Record = { updatedAt: new Date() }; + if (status) updateFields.status = status; + if (result !== undefined) updateFields.result = result; + + try { + const res = await db.collection("agent_tasks").updateOne( + { _id: new ObjectId(id) }, + { $set: updateFields } + ); + + if (res.matchedCount === 0) { + ctx.response.status = 404; + ctx.response.body = { error: "Auftrag nicht gefunden" }; + return; + } + + ctx.response.body = { message: "Auftrag aktualisiert" }; + } catch { + ctx.response.status = 400; + ctx.response.body = { error: "Ungültige ID" }; + } +}); diff --git a/src/routes/appUpdate.ts b/src/routes/appUpdate.ts new file mode 100644 index 0000000..8edb1f0 --- /dev/null +++ b/src/routes/appUpdate.ts @@ -0,0 +1,153 @@ +import { Router } from "@oak/oak"; +import { getMongoClient } from "../db/mongo.ts"; +import { authMiddleware } from "../middleware/auth.ts"; +import { ObjectId } from "mongodb"; + +const router = new Router({ prefix: "/api/app" }); + +interface AppRelease { + _id?: ObjectId; + version: string; + versionCode: number; + downloadUrl: string; + releaseNotes: string; + size?: number; + createdAt: Date; + updatedAt: Date; +} + +async function getReleasesCollection() { + const client = await getMongoClient(); + const db = client.db("ams"); + return db.collection("app_releases"); +} + +// Öffentlich: Version prüfen (App ruft das ohne Auth auf) +router.get("/update/check", async (ctx) => { + const currentVersion = ctx.request.url.searchParams.get("currentVersion") || "0"; + const currentCode = parseInt(ctx.request.url.searchParams.get("currentCode") || "0"); + + const col = await getReleasesCollection(); + const latest = await col.findOne({}, { sort: { versionCode: -1 } }); + + if (!latest) { + ctx.response.body = { updateAvailable: false }; + return; + } + + const updateAvailable = latest.versionCode > currentCode; + + ctx.response.body = { + updateAvailable, + currentVersion, + latestVersion: latest.version, + latestVersionCode: latest.versionCode, + downloadUrl: latest.downloadUrl, + releaseNotes: latest.releaseNotes, + size: latest.size, + }; +}); + +// Öffentlich: Download-Redirect zur APK +router.get("/update/download", async (ctx) => { + const col = await getReleasesCollection(); + const latest = await col.findOne({}, { sort: { versionCode: -1 } }); + + if (!latest) { + ctx.response.status = 404; + ctx.response.body = { error: "Kein Release verfügbar" }; + return; + } + + ctx.response.redirect(latest.downloadUrl); +}); + +// Admin: Neues Release anlegen +router.post("/update/release", authMiddleware, async (ctx) => { + const body = await ctx.request.body.json(); + const { version, versionCode, downloadUrl, releaseNotes, size } = body; + + if (!version || !versionCode || !downloadUrl) { + ctx.response.status = 400; + ctx.response.body = { error: "version, versionCode und downloadUrl sind Pflichtfelder" }; + return; + } + + const col = await getReleasesCollection(); + const now = new Date(); + + const result = await col.insertOne({ + version, + versionCode, + downloadUrl, + releaseNotes: releaseNotes || "", + size: size || 0, + createdAt: now, + updatedAt: now, + }); + + ctx.response.status = 201; + ctx.response.body = { id: result.insertedId, version, versionCode }; +}); + +// Admin: Alle Releases auflisten +router.get("/update/releases", authMiddleware, async (ctx) => { + const col = await getReleasesCollection(); + const releases = await col.find({}).sort({ versionCode: -1 }).toArray(); + ctx.response.body = { releases }; +}); + +// Admin: Release löschen +router.delete("/update/release/:id", authMiddleware, async (ctx) => { + const id = ctx.params.id; + const col = await getReleasesCollection(); + await col.deleteOne({ _id: new ObjectId(id) }); + ctx.response.body = { deleted: true }; +}); + +// Service-API: Release per API-Key anlegen (für CI-Pipeline) +router.post("/update/release/ci", async (ctx) => { + const apiKey = ctx.request.headers.get("X-API-Key") || + ctx.request.url.searchParams.get("apiKey"); + const expectedKey = Deno.env.get("AMS_SERVICE_API_KEY"); + + if (!apiKey || apiKey !== expectedKey) { + ctx.response.status = 401; + ctx.response.body = { error: "Ungültiger API-Key" }; + return; + } + + const body = await ctx.request.body.json(); + const { version, versionCode, downloadUrl, releaseNotes, size } = body; + + if (!version || !versionCode || !downloadUrl) { + ctx.response.status = 400; + ctx.response.body = { error: "version, versionCode und downloadUrl sind Pflichtfelder" }; + return; + } + + const col = await getReleasesCollection(); + const now = new Date(); + + // Upsert by versionCode + await col.updateOne( + { versionCode }, + { + $set: { + version, + versionCode, + downloadUrl, + releaseNotes: releaseNotes || "", + size: size || 0, + updatedAt: now, + }, + $setOnInsert: { createdAt: now }, + }, + { upsert: true } + ); + + ctx.response.status = 201; + ctx.response.body = { success: true, version, versionCode }; +}); + +export const appUpdateRouter = router; diff --git a/src/routes/attachments.ts b/src/routes/attachments.ts new file mode 100644 index 0000000..b14d5fd --- /dev/null +++ b/src/routes/attachments.ts @@ -0,0 +1,241 @@ +import { Router } from "@oak/oak"; +import { ObjectId, GridFSBucket } from "mongodb"; +import { getDB } from "../db/mongo.ts"; +import { authMiddleware } from "../middleware/auth.ts"; + +export const attachmentsRouter = new Router({ prefix: "/api/attachments" }); + +// Valid parent types for attachments +const VALID_PARENT_TYPES = ["task", "comment", "project", "agent"] as const; +type ParentType = typeof VALID_PARENT_TYPES[number]; + +interface AttachmentMeta { + _id: ObjectId; + parentType: ParentType; + parentId: string; + filename: string; + originalName: string; + mimeType: string; + size: number; + gridfsFileId?: ObjectId; + data?: string; // legacy base64 — kept for backward compat during migration + uploadedBy: string; + createdAt: Date; +} + +const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB + +async function getGridFSBucket(): Promise { + const db = await getDB(); + return new GridFSBucket(db, { bucketName: "attachments" }); +} + +// Helper: read GridFS file to base64 +async function gridfsToBase64(fileId: ObjectId): Promise { + const bucket = await getGridFSBucket(); + const stream = bucket.openDownloadStream(fileId); + const chunks: Uint8Array[] = []; + + for await (const chunk of stream) { + chunks.push(chunk instanceof Uint8Array ? chunk : new Uint8Array(chunk)); + } + + const totalLength = chunks.reduce((acc, c) => acc + c.length, 0); + const combined = new Uint8Array(totalLength); + let offset = 0; + for (const chunk of chunks) { + combined.set(chunk, offset); + offset += chunk.length; + } + + return btoa(String.fromCharCode(...combined)); +} + +// Helper: write base64 to GridFS, return fileId +async function base64ToGridFS(base64: string, filename: string): Promise { + const bucket = await getGridFSBucket(); + const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)); + + const uploadStream = bucket.openUploadStream(filename); + const fileId = uploadStream.id; + + await new Promise((resolve, reject) => { + uploadStream.on("finish", resolve); + uploadStream.on("error", reject); + uploadStream.write(bytes); + uploadStream.end(); + }); + + return fileId as ObjectId; +} + +// ========================================== +// SPECIFIC ROUTES FIRST (before wildcards!) +// ========================================== + +// Get single attachment with data +attachmentsRouter.get("/file/:attachmentId", authMiddleware, async (ctx) => { + const attachmentId = ctx.params.attachmentId; + const db = await getDB(); + + try { + const attachment = await db.collection("attachments").findOne({ + _id: new ObjectId(attachmentId) + }); + + if (!attachment) { + ctx.response.status = 404; + ctx.response.body = { error: "Attachment not found" }; + return; + } + + // Read data: prefer GridFS, fall back to legacy base64 field + let data: string; + if (attachment.gridfsFileId) { + data = await gridfsToBase64(attachment.gridfsFileId); + } else if (attachment.data) { + data = attachment.data; + } else { + ctx.response.status = 500; + ctx.response.body = { error: "Attachment data not found" }; + return; + } + + ctx.response.body = { + attachment: { + _id: attachment._id, + parentType: attachment.parentType, + parentId: attachment.parentId, + filename: attachment.filename, + originalName: attachment.originalName, + mimeType: attachment.mimeType, + size: attachment.size, + data + } + }; + } catch { + ctx.response.status = 400; + ctx.response.body = { error: "Invalid attachment ID" }; + } +}); + +// Delete attachment +attachmentsRouter.delete("/:attachmentId", authMiddleware, async (ctx) => { + const attachmentId = ctx.params.attachmentId; + const db = await getDB(); + + if (!/^[a-f\d]{24}$/i.test(attachmentId)) { + ctx.response.status = 400; + ctx.response.body = { error: "Invalid attachment ID format" }; + return; + } + + try { + const attachment = await db.collection("attachments").findOne({ + _id: new ObjectId(attachmentId) + }); + + if (!attachment) { + ctx.response.status = 404; + ctx.response.body = { error: "Attachment not found" }; + return; + } + + if (attachment.uploadedBy !== ctx.state.user.id && ctx.state.user.role !== "admin") { + ctx.response.status = 403; + ctx.response.body = { error: "Not authorized to delete this attachment" }; + return; + } + + // Delete GridFS file if exists + if (attachment.gridfsFileId) { + try { + const bucket = await getGridFSBucket(); + await bucket.delete(attachment.gridfsFileId); + } catch { + // GridFS file may already be deleted, continue + } + } + + await db.collection("attachments").deleteOne({ _id: new ObjectId(attachmentId) }); + ctx.response.body = { message: "Attachment deleted" }; + } catch { + ctx.response.status = 400; + ctx.response.body = { error: "Invalid attachment ID" }; + } +}); + +// ========================================== +// WILDCARD ROUTES +// ========================================== + +// Get attachments for any parent (metadata only, no data) +attachmentsRouter.get("/:parentType/:parentId", authMiddleware, async (ctx) => { + const { parentType, parentId } = ctx.params; + + if (!VALID_PARENT_TYPES.includes(parentType as ParentType)) { + ctx.response.status = 400; + ctx.response.body = { error: `Invalid parent type. Must be one of: ${VALID_PARENT_TYPES.join(", ")}` }; + return; + } + + const db = await getDB(); + const attachments = await db.collection("attachments") + .find({ parentType, parentId }) + .project({ data: 0, gridfsFileId: 0 }) + .sort({ createdAt: -1 }) + .toArray(); + + ctx.response.body = { attachments }; +}); + +// Upload attachment — stores in GridFS +attachmentsRouter.post("/:parentType/:parentId", authMiddleware, async (ctx) => { + const { parentType, parentId } = ctx.params; + + if (!VALID_PARENT_TYPES.includes(parentType as ParentType)) { + ctx.response.status = 400; + ctx.response.body = { error: `Invalid parent type. Must be one of: ${VALID_PARENT_TYPES.join(", ")}` }; + return; + } + + const body = await ctx.request.body.json(); + const { filename, data, mimeType } = body; + + if (!filename || !data) { + ctx.response.status = 400; + ctx.response.body = { error: "Filename and data are required" }; + return; + } + + const sizeBytes = Math.round(data.length * 0.75); + if (sizeBytes > MAX_FILE_SIZE) { + ctx.response.status = 400; + ctx.response.body = { error: "File too large. Maximum 5MB allowed." }; + return; + } + + const db = await getDB(); + const storedFilename = `${Date.now()}-${filename}`; + + // Store binary data in GridFS + const gridfsFileId = await base64ToGridFS(data, storedFilename); + + const result = await db.collection("attachments").insertOne({ + parentType: parentType as ParentType, + parentId, + filename: storedFilename, + originalName: filename, + mimeType: mimeType || "application/octet-stream", + size: sizeBytes, + gridfsFileId, + uploadedBy: ctx.state.user.id, + createdAt: new Date() + } as AttachmentMeta); + + ctx.response.status = 201; + ctx.response.body = { + message: "Attachment uploaded", + id: result.insertedId.toString() + }; +}); diff --git a/src/routes/auth.ts b/src/routes/auth.ts new file mode 100644 index 0000000..cd3b183 --- /dev/null +++ b/src/routes/auth.ts @@ -0,0 +1,211 @@ +import { Router } from "@oak/oak"; +import { ObjectId } from "mongodb"; +import { getDB } from "../db/mongo.ts"; +import { signJWT } from "../utils/jwt.ts"; +import { hashPassword, verifyPassword } from "../utils/password.ts"; +import { authMiddleware } from "../middleware/auth.ts"; + +export const authRouter = new Router({ prefix: "/api/auth" }); + +interface User { + _id: ObjectId; + email: string; + username: string; + password: string; + role: "admin" | "agent" | "user"; + gitlabToken?: string; + createdAt: Date; + updatedAt: Date; +} + +// Register +authRouter.post("/register", async (ctx) => { + const db = await getDB(); + + // Check if registration is allowed + const settings = await db.collection("settings").findOne({ _id: "global" }); + if (settings && !settings.allowRegistration) { + ctx.response.status = 403; + ctx.response.body = { error: "Registrierung ist derzeit deaktiviert" }; + return; + } + + const body = await ctx.request.body.json(); + const { email, username, password } = body; + + if (!email || !username || !password) { + ctx.response.status = 400; + ctx.response.body = { error: "Email, username and password are required" }; + return; + } + + if (password.length < 8) { + ctx.response.status = 400; + ctx.response.body = { error: "Password must be at least 8 characters" }; + return; + } + + const users = db.collection("users"); + + // Check if user exists + const existing = await users.findOne({ $or: [{ email }, { username }] }); + if (existing) { + ctx.response.status = 409; + ctx.response.body = { error: "User with this email or username already exists" }; + return; + } + + const hashedPassword = await hashPassword(password); + const now = new Date(); + + const result = await users.insertOne({ + email, + username, + password: hashedPassword, + role: "user", + createdAt: now, + updatedAt: now + } as User); + + const token = await signJWT({ + id: result.insertedId.toString(), + email, + username, + role: "user" + }); + + ctx.response.status = 201; + ctx.response.body = { + message: "User registered successfully", + token, + user: { id: result.insertedId.toString(), email, username, role: "user" } + }; +}); + +// Login +authRouter.post("/login", async (ctx) => { + const body = await ctx.request.body.json(); + const { email, password } = body; + + if (!email || !password) { + ctx.response.status = 400; + ctx.response.body = { error: "Email and password are required" }; + return; + } + + const db = await getDB(); + const users = db.collection("users"); + + const user = await users.findOne({ email }); + if (!user) { + ctx.response.status = 401; + ctx.response.body = { error: "Invalid credentials" }; + return; + } + + const validPassword = await verifyPassword(password, user.password); + if (!validPassword) { + ctx.response.status = 401; + ctx.response.body = { error: "Invalid credentials" }; + return; + } + + const token = await signJWT({ + id: user._id.toString(), + email: user.email, + username: user.username, + role: user.role + }); + + ctx.response.body = { + message: "Login successful", + token, + user: { + id: user._id.toString(), + email: user.email, + username: user.username, + role: user.role + } + }; +}); + +// Get current user (with gitlabToken) +authRouter.get("/me", authMiddleware, async (ctx) => { + const db = await getDB(); + const users = db.collection("users"); + const user = await users.findOne({ _id: new ObjectId(ctx.state.user.id) }); + + ctx.response.body = { + user: ctx.state.user, + gitlabToken: user?.gitlabToken || '' + }; +}); + +// List all users (id, username, email) for sharing dialogs +authRouter.get("/users", authMiddleware, async (ctx) => { + const db = await getDB(); + const users = await db.collection("users") + .find({}, { projection: { _id: 1, username: 1, email: 1, role: 1 } }) + .sort({ username: 1 }) + .toArray(); + ctx.response.body = { users: users.map(u => ({ _id: u._id.toString(), username: u.username, email: u.email, role: u.role })) }; +}); + +// Update current user (gitlabToken) +authRouter.put("/me", authMiddleware, async (ctx) => { + const body = await ctx.request.body.json(); + const { gitlabToken } = body; + + const db = await getDB(); + const users = db.collection("users"); + + await users.updateOne( + { _id: new ObjectId(ctx.state.user.id) }, + { $set: { gitlabToken: gitlabToken || '', updatedAt: new Date() } } + ); + + ctx.response.body = { message: "Settings updated successfully" }; +}); + +// Change password +authRouter.post("/change-password", authMiddleware, async (ctx) => { + const body = await ctx.request.body.json(); + const { currentPassword, newPassword } = body; + + if (!currentPassword || !newPassword) { + ctx.response.status = 400; + ctx.response.body = { error: "Current and new password are required" }; + return; + } + + if (newPassword.length < 8) { + ctx.response.status = 400; + ctx.response.body = { error: "New password must be at least 8 characters" }; + return; + } + + const db = await getDB(); + const users = db.collection("users"); + + const user = await users.findOne({ _id: new ObjectId(ctx.state.user.id) }); + if (!user) { + ctx.response.status = 404; + ctx.response.body = { error: "User not found" }; + return; + } + + const validPassword = await verifyPassword(currentPassword, user.password); + if (!validPassword) { + ctx.response.status = 401; + ctx.response.body = { error: "Current password is incorrect" }; + return; + } + + const hashedPassword = await hashPassword(newPassword); + await users.updateOne( + { _id: user._id }, + { $set: { password: hashedPassword, updatedAt: new Date() } } + ); + + ctx.response.body = { message: "Password changed successfully" }; +}); diff --git a/src/routes/comments.ts b/src/routes/comments.ts new file mode 100644 index 0000000..14b18a2 --- /dev/null +++ b/src/routes/comments.ts @@ -0,0 +1,274 @@ +import { Router } from "@oak/oak"; +import { ObjectId } from "mongodb"; +import { getDB } from "../db/mongo.ts"; +import { authMiddleware } from "../middleware/auth.ts"; + +export const commentsRouter = new Router({ prefix: "/api/comments" }); + +// Valid parent types for comments +const VALID_PARENT_TYPES = ["task", "project", "agent"] as const; +type ParentType = typeof VALID_PARENT_TYPES[number]; + +interface Quote { + text: string; + commentId?: string; + authorName?: string; +} + +interface Comment { + _id: ObjectId; + parentType: ParentType; + parentId: string; + parentCommentId?: string; // For replies to other comments + userId: string; + username: string; + content: string; + codeBlock?: { language: string; code: string }; + quote?: Quote; // For quoting other comments + createdAt: Date; +} + +// Get comments for any parent +// GET /api/comments/:parentType/:parentId +// Returns flat list with parentCommentId for replies - frontend builds tree +commentsRouter.get("/:parentType/:parentId", authMiddleware, async (ctx) => { + const { parentType, parentId } = ctx.params; + + if (!VALID_PARENT_TYPES.includes(parentType as ParentType)) { + ctx.response.status = 400; + ctx.response.body = { error: `Invalid parent type. Must be one of: ${VALID_PARENT_TYPES.join(", ")}` }; + return; + } + + const db = await getDB(); + const comments = await db.collection("comments") + .find({ parentType, parentId }) + .sort({ createdAt: 1 }) // oldest first for conversation flow + .toArray(); + + ctx.response.body = { comments }; +}); + +// Add comment to any parent +// POST /api/comments/:parentType/:parentId +// Body: { content, codeBlock?, parentCommentId?, quote? } +commentsRouter.post("/:parentType/:parentId", authMiddleware, async (ctx) => { + const { parentType, parentId } = ctx.params; + + if (!VALID_PARENT_TYPES.includes(parentType as ParentType)) { + ctx.response.status = 400; + ctx.response.body = { error: `Invalid parent type. Must be one of: ${VALID_PARENT_TYPES.join(", ")}` }; + return; + } + + const body = await ctx.request.body.json(); + const { content, codeBlock, parentCommentId, quote } = body; + + if (!content?.trim() && !codeBlock?.code?.trim()) { + ctx.response.status = 400; + ctx.response.body = { error: "Content or code block is required" }; + return; + } + + const db = await getDB(); + + // Validate parentCommentId if provided + if (parentCommentId) { + try { + const parentComment = await db.collection("comments").findOne({ + _id: new ObjectId(parentCommentId), + parentType, + parentId + }); + if (!parentComment) { + ctx.response.status = 400; + ctx.response.body = { error: "Parent comment not found or not in same thread" }; + return; + } + } catch { + ctx.response.status = 400; + ctx.response.body = { error: "Invalid parent comment ID" }; + return; + } + } + + // Build quote object if provided + let quoteData: Quote | undefined; + if (quote?.text) { + quoteData = { + text: quote.text, + commentId: quote.commentId, + authorName: quote.authorName + }; + } + + const result = await db.collection("comments").insertOne({ + parentType: parentType as ParentType, + parentId, + parentCommentId: parentCommentId || undefined, + userId: ctx.state.user.id, + username: ctx.state.user.username, + content: content || "", + codeBlock: codeBlock?.code ? codeBlock : undefined, + quote: quoteData, + createdAt: new Date() + } as Comment); + + ctx.response.status = 201; + ctx.response.body = { + message: "Comment added", + id: result.insertedId.toString() + }; +}); + +// Delete comment (and all replies) +// DELETE /api/comments/:commentId +commentsRouter.delete("/:commentId", authMiddleware, async (ctx) => { + const commentId = ctx.params.commentId; + const db = await getDB(); + + try { + const comment = await db.collection("comments").findOne({ + _id: new ObjectId(commentId) + }); + + if (!comment) { + ctx.response.status = 404; + ctx.response.body = { error: "Comment not found" }; + return; + } + + // Only author or admin can delete + if (comment.userId !== ctx.state.user.id && ctx.state.user.role !== "admin") { + ctx.response.status = 403; + ctx.response.body = { error: "Not authorized to delete this comment" }; + return; + } + + // Find all replies (recursively) to delete + const commentsToDelete = [commentId]; + const findReplies = async (parentIds: string[]) => { + const replies = await db.collection("comments") + .find({ parentCommentId: { $in: parentIds } }) + .toArray(); + if (replies.length > 0) { + const replyIds = replies.map(r => r._id.toString()); + commentsToDelete.push(...replyIds); + await findReplies(replyIds); + } + }; + await findReplies([commentId]); + + // Delete all attachments for these comments + await db.collection("attachments").deleteMany({ + parentType: "comment", + parentId: { $in: commentsToDelete } + }); + + // Delete all label assignments for these comments + await db.collection("label_assignments").deleteMany({ + parentType: "comment", + parentId: { $in: commentsToDelete } + }); + + // Delete all comments (original + replies) + await db.collection("comments").deleteMany({ + _id: { $in: commentsToDelete.map(id => new ObjectId(id)) } + }); + + ctx.response.body = { + message: "Comment, replies, and attachments deleted", + deletedCount: commentsToDelete.length + }; + } catch { + ctx.response.status = 400; + ctx.response.body = { error: "Invalid comment ID" }; + } +}); + +// Update comment +// PATCH /api/comments/:commentId +commentsRouter.patch("/:commentId", authMiddleware, async (ctx) => { + const commentId = ctx.params.commentId; + const body = await ctx.request.body.json(); + const { content, codeBlock, quote } = body; + const db = await getDB(); + + try { + const comment = await db.collection("comments").findOne({ + _id: new ObjectId(commentId) + }); + + if (!comment) { + ctx.response.status = 404; + ctx.response.body = { error: "Comment not found" }; + return; + } + + // Only author can edit + if (comment.userId !== ctx.state.user.id) { + ctx.response.status = 403; + ctx.response.body = { error: "Not authorized to edit this comment" }; + return; + } + + const updates: Partial = {}; + if (content !== undefined) updates.content = content; + if (codeBlock !== undefined) updates.codeBlock = codeBlock?.code ? codeBlock : undefined; + if (quote !== undefined) { + updates.quote = quote?.text ? { + text: quote.text, + commentId: quote.commentId, + authorName: quote.authorName + } : undefined; + } + + await db.collection("comments").updateOne( + { _id: new ObjectId(commentId) }, + { $set: updates } + ); + + ctx.response.body = { message: "Comment updated" }; + } catch { + ctx.response.status = 400; + ctx.response.body = { error: "Invalid comment ID" }; + } +}); + +// Get single comment by ID (for API access) +// GET /api/comments/single/:commentId +commentsRouter.get("/single/:commentId", authMiddleware, async (ctx) => { + const commentId = ctx.params.commentId; + const db = await getDB(); + + try { + const comment = await db.collection("comments").findOne({ + _id: new ObjectId(commentId) + }); + + if (!comment) { + ctx.response.status = 404; + ctx.response.body = { error: "Comment not found" }; + return; + } + + ctx.response.body = { comment }; + } catch { + ctx.response.status = 400; + ctx.response.body = { error: "Invalid comment ID" }; + } +}); + +// Get replies for a comment +// GET /api/comments/replies/:commentId +commentsRouter.get("/replies/:commentId", authMiddleware, async (ctx) => { + const commentId = ctx.params.commentId; + const db = await getDB(); + + const replies = await db.collection("comments") + .find({ parentCommentId: commentId }) + .sort({ createdAt: 1 }) + .toArray(); + + ctx.response.body = { replies }; +}); diff --git a/src/routes/cronjobs.ts b/src/routes/cronjobs.ts new file mode 100644 index 0000000..f2ab5dd --- /dev/null +++ b/src/routes/cronjobs.ts @@ -0,0 +1,366 @@ +import { Router } from "@oak/oak"; +import { ObjectId } from "mongodb"; +import { getDB } from "../db/mongo.ts"; +import { authMiddleware } from "../middleware/auth.ts"; + +export const cronJobsRouter = new Router({ prefix: "/api/cronjobs" }); + +interface CronJob { + _id: ObjectId; + name: string; + description: string; + intervalMinutes: number; + enabled: boolean; + lastRun: Date | null; + lastResult: string | null; + nextRun: Date | null; + createdBy: string; + createdAt: Date; + updatedAt: Date; +} + +// In-Memory Timer Storage +const activeTimers = new Map(); + +function calculateNextRun(intervalMinutes: number): Date { + return new Date(Date.now() + intervalMinutes * 60 * 1000); +} + +async function executeWakeCheck(jobId: string) { + const db = await getDB(); + + // Prüfen ob pending Agent-Tasks existieren + const pendingCount = await db.collection("agent_tasks") + .countDocuments({ status: "pending" }); + + const now = new Date(); + + // Job-Daten für nextRun-Berechnung laden + const currentJob = await db.collection("cronjobs").findOne({ _id: new ObjectId(jobId) }); + const nextRun = currentJob ? calculateNextRun(currentJob.intervalMinutes) : null; + + if (pendingCount === 0) { + await db.collection("cronjobs").updateOne( + { _id: new ObjectId(jobId) }, + { $set: { lastRun: now, lastResult: `Keine pending Tasks (${now.toISOString()})`, nextRun } } + ); + return; + } + + // Lock: Kein Wake wenn bereits ein Task in Bearbeitung + // Auto-Timeout: Tasks die >30 Minuten in_progress sind, auf pending zurücksetzen + const TIMEOUT_MS = 30 * 60 * 1000; + const timeoutThreshold = new Date(Date.now() - TIMEOUT_MS); + + const staleResult = await db.collection("agent_tasks").updateMany( + { status: "in_progress", updatedAt: { $lt: timeoutThreshold } }, + { $set: { status: "pending", updatedAt: new Date() } } + ); + + if (staleResult.modifiedCount > 0) { + // deno-lint-ignore no-console + console.log(`[CronJobs] ${staleResult.modifiedCount} hängende Task(s) auf pending zurückgesetzt`); + } + + const inProgressCount = await db.collection("agent_tasks") + .countDocuments({ status: "in_progress" }); + + if (inProgressCount > 0) { + await db.collection("cronjobs").updateOne( + { _id: new ObjectId(jobId) }, + { $set: { lastRun: now, lastResult: `Agent arbeitet bereits (${inProgressCount} in_progress)`, nextRun } } + ); + return; + } + + // Wake-Webhook aufrufen + const openclawBaseUrl = Deno.env.get("OPENCLAW_WAKE_URL"); + const openclawToken = Deno.env.get("OPENCLAW_WAKE_TOKEN"); + const serviceKey = Deno.env.get("AMS_SERVICE_API_KEY") || ""; + + if (!openclawBaseUrl || !openclawToken) { + await db.collection("cronjobs").updateOne( + { _id: new ObjectId(jobId) }, + { $set: { lastRun: now, lastResult: "Fehler: OpenClaw nicht konfiguriert" } } + ); + return; + } + + // Pending Tasks laden für Prompt + const pendingTasks = await db.collection("agent_tasks") + .find({ status: "pending" }) + .sort({ createdAt: 1 }) + .toArray(); + + const taskList = pendingTasks.map((t, i) => + `${i + 1}. [${t._id}] "${t.message}" (Projekt: ${t.projectName || "–"}, von: ${t.createdByName})` + ).join("\n"); + + const prompt = `WICHTIG: Lade zuerst CHECKLIST.md und WORKFLOW.md aus deinem Workspace und halte dich strikt daran! + +Neue Agent-Tasks im AMS (pending): +${taskList} + +Bearbeite jeden Task nach der CHECKLIST.md: +1. Task im AMS auf "in_progress" setzen +2. Feature-Branch erstellen +3. Backend ZUERST implementieren (bei Full-Stack) +4. Dark Theme beachten! +5. Self-Review: npm run build / deno check +6. Conventional Commits auf DEUTSCH +7. MR erstellen und mergen +8. Tag + Deploy +9. Agent-Task auf "done" setzen: curl -X PUT https://api.ams.agentenbude.de/api/agent-tasks/ -H "Authorization: Bearer ${serviceKey}" -H "Content-Type: application/json" -d '{"status":"done","result":""}' +10. Task-Kommentar + Chef informieren + +API-Auth: Bearer ${serviceKey} +AMS-API: https://api.ams.agentenbude.de/api`; + + const agentUrl = openclawBaseUrl.replace("/hooks/wake", "/hooks/agent"); + + try { + const response = await fetch(agentUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${openclawToken}`, + }, + body: JSON.stringify({ + message: prompt, + name: "AMS-Task", + sessionKey: "hook:ams-task", + wakeMode: "now", + deliver: true, + channel: "telegram", + to: "7150608398", + }), + signal: AbortSignal.timeout(5000), + }); + + const resultText = response.ok + ? `Agent gestartet (${pendingCount} Tasks, ${now.toISOString()})` + : `Fehler: HTTP ${response.status}`; + + // nextRun basierend auf intervalMinutes des Jobs berechnen + const jobDoc = await db.collection("cronjobs").findOne({ _id: new ObjectId(jobId) }); + const nextRun = jobDoc ? calculateNextRun(jobDoc.intervalMinutes) : null; + + await db.collection("cronjobs").updateOne( + { _id: new ObjectId(jobId) }, + { $set: { lastRun: now, lastResult: resultText, nextRun } } + ); + } catch (err) { + const jobDoc = await db.collection("cronjobs").findOne({ _id: new ObjectId(jobId) }).catch(() => null); + const nextRun = jobDoc ? calculateNextRun(jobDoc.intervalMinutes) : null; + + await db.collection("cronjobs").updateOne( + { _id: new ObjectId(jobId) }, + { $set: { lastRun: now, lastResult: `Fehler: ${String(err)}`, nextRun } } + ); + } +} + +function startTimer(job: CronJob) { + stopTimer(job._id.toString()); + + const id = job._id.toString(); + const intervalMs = job.intervalMinutes * 60 * 1000; + + // Berechne Delay bis zum nächsten Lauf + let initialDelay = intervalMs; + if (job.nextRun) { + const msUntilNext = new Date(job.nextRun).getTime() - Date.now(); + if (msUntilNext <= 0) { + // Überfällig → sofort ausführen, dann normales Intervall + executeWakeCheck(id); + initialDelay = intervalMs; + } else { + initialDelay = msUntilNext; + } + } + + // Erster Lauf nach initialDelay, dann reguläres Intervall + const firstTimer = setTimeout(() => { + executeWakeCheck(id); + // Ab jetzt reguläres Intervall + const recurringTimer = setInterval(() => { + executeWakeCheck(id); + }, intervalMs); + activeTimers.set(id, recurringTimer as unknown as number); + }, initialDelay); + + activeTimers.set(id, firstTimer as unknown as number); +} + +function stopTimer(jobId: string) { + const existing = activeTimers.get(jobId); + if (existing) { + clearInterval(existing); + activeTimers.delete(jobId); + } +} + +// Beim Server-Start: aktive Jobs laden und Timer starten +export async function initCronJobs() { + try { + const db = await getDB(); + const jobs = await db.collection("cronjobs") + .find({ enabled: true }) + .toArray(); + + for (const job of jobs) { + startTimer(job); + } + + if (jobs.length > 0) { + // deno-lint-ignore no-console + console.log(`[CronJobs] ${jobs.length} aktive Job(s) gestartet`); + } + } catch { + // DB noch nicht bereit — wird beim ersten Request initialisiert + } +} + +// Alle CronJobs listen +cronJobsRouter.get("/", authMiddleware, async (ctx) => { + const db = await getDB(); + const jobs = await db.collection("cronjobs") + .find() + .sort({ createdAt: -1 }) + .toArray(); + + // Timer-Status hinzufügen + const jobsWithStatus = jobs.map(j => ({ + ...j, + timerActive: activeTimers.has(j._id.toString()), + })); + + ctx.response.body = { jobs: jobsWithStatus }; +}); + +// CronJob erstellen +cronJobsRouter.post("/", authMiddleware, async (ctx) => { + const db = await getDB(); + const body = await ctx.request.body.json(); + const { name, description, intervalMinutes, enabled } = body; + + if (!name || !intervalMinutes || intervalMinutes < 1) { + ctx.response.status = 400; + ctx.response.body = { error: "Name und intervalMinutes (>= 1) erforderlich" }; + return; + } + + const now = new Date(); + const doc: Omit = { + name, + description: description || "", + intervalMinutes: Number(intervalMinutes), + enabled: enabled !== false, + lastRun: null, + lastResult: null, + nextRun: enabled !== false ? calculateNextRun(Number(intervalMinutes)) : null, + createdBy: ctx.state.user.id, + createdAt: now, + updatedAt: now, + }; + + const result = await db.collection("cronjobs").insertOne(doc as CronJob); + const insertedJob = await db.collection("cronjobs").findOne({ _id: result.insertedId }); + + if (insertedJob && insertedJob.enabled) { + startTimer(insertedJob); + } + + ctx.response.status = 201; + ctx.response.body = { message: "CronJob erstellt", id: result.insertedId.toString() }; +}); + +// CronJob aktualisieren +cronJobsRouter.put("/:id", authMiddleware, async (ctx) => { + const db = await getDB(); + const id = ctx.params.id; + const body = await ctx.request.body.json(); + + const updateFields: Record = { updatedAt: new Date() }; + + if (body.name !== undefined) updateFields.name = body.name; + if (body.description !== undefined) updateFields.description = body.description; + if (body.intervalMinutes !== undefined) { + if (Number(body.intervalMinutes) < 1) { + ctx.response.status = 400; + ctx.response.body = { error: "intervalMinutes muss >= 1 sein" }; + return; + } + updateFields.intervalMinutes = Number(body.intervalMinutes); + } + if (body.enabled !== undefined) { + updateFields.enabled = body.enabled; + if (body.enabled) { + const interval = body.intervalMinutes || (await db.collection("cronjobs").findOne({ _id: new ObjectId(id) }))?.intervalMinutes || 30; + updateFields.nextRun = calculateNextRun(Number(interval)); + } else { + updateFields.nextRun = null; + } + } + + try { + const res = await db.collection("cronjobs").updateOne( + { _id: new ObjectId(id) }, + { $set: updateFields } + ); + + if (res.matchedCount === 0) { + ctx.response.status = 404; + ctx.response.body = { error: "CronJob nicht gefunden" }; + return; + } + + // Timer neu starten/stoppen + const updatedJob = await db.collection("cronjobs").findOne({ _id: new ObjectId(id) }); + if (updatedJob) { + if (updatedJob.enabled) { + startTimer(updatedJob); + } else { + stopTimer(id); + } + } + + ctx.response.body = { message: "CronJob aktualisiert" }; + } catch { + ctx.response.status = 400; + ctx.response.body = { error: "Ungültige ID" }; + } +}); + +// CronJob löschen +cronJobsRouter.delete("/:id", authMiddleware, async (ctx) => { + const id = ctx.params.id; + stopTimer(id); + + const db = await getDB(); + try { + await db.collection("cronjobs").deleteOne({ _id: new ObjectId(id) }); + ctx.response.body = { message: "CronJob gelöscht" }; + } catch { + ctx.response.status = 400; + ctx.response.body = { error: "Ungültige ID" }; + } +}); + +// CronJob manuell ausführen +cronJobsRouter.post("/:id/run", authMiddleware, async (ctx) => { + const id = ctx.params.id; + + const db = await getDB(); + const job = await db.collection("cronjobs").findOne({ _id: new ObjectId(id) }); + + if (!job) { + ctx.response.status = 404; + ctx.response.body = { error: "CronJob nicht gefunden" }; + return; + } + + await executeWakeCheck(id); + + ctx.response.body = { message: "CronJob manuell ausgeführt" }; +}); diff --git a/src/routes/docker.ts b/src/routes/docker.ts new file mode 100644 index 0000000..d969780 --- /dev/null +++ b/src/routes/docker.ts @@ -0,0 +1,240 @@ +import { Router, Context, Next } from "@oak/oak"; +import { authMiddleware, type AuthState } from "../middleware/auth.ts"; + +export const dockerRouter = new Router({ prefix: "/api/docker" }); + +/** + * Admin-only guard — must be used AFTER authMiddleware. + * Rejects non-admin users with 403. + */ +async function adminOnly(ctx: Context, next: Next) { + const user = (ctx.state as AuthState).user; + if (user.role !== "admin") { + ctx.response.status = 403; + ctx.response.body = { error: "Admin access required" }; + return; + } + await next(); +} + +/** + * Validates the container id parameter to prevent command injection. + * Allows alphanumeric, hyphens, underscores, and dots (Docker container names/IDs). + */ +function validateContainerId(id: string): boolean { + return /^[a-zA-Z0-9][a-zA-Z0-9_.\-]*$/.test(id); +} + +// List all Docker containers +dockerRouter.get("/containers", authMiddleware, adminOnly, async (ctx) => { + try { + const cmd = new Deno.Command("docker", { + args: ["ps", "-a", "--format", "json"], + stdout: "piped", + stderr: "piped", + }); + + const { stdout, stderr, success } = await cmd.output(); + + if (!success) { + const errorText = new TextDecoder().decode(stderr); + ctx.response.status = 500; + ctx.response.body = { error: "Failed to list containers", details: errorText }; + return; + } + + const output = new TextDecoder().decode(stdout); + const lines = output.trim().split("\n").filter(line => line.length > 0); + const containers = lines.map(line => { + try { + return JSON.parse(line); + } catch { + return null; + } + }).filter(c => c !== null); + + ctx.response.body = { success: true, containers }; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : "Unknown error"; + console.error("Error listing containers:", message); + ctx.response.status = 500; + ctx.response.body = { error: "Internal server error", message }; + } +}); + +// Get container details +dockerRouter.get("/containers/:id", authMiddleware, adminOnly, async (ctx) => { + try { + const { id } = ctx.params; + if (!id || !validateContainerId(id)) { + ctx.response.status = 400; + ctx.response.body = { error: "Invalid container ID" }; + return; + } + + const cmd = new Deno.Command("docker", { + args: ["inspect", id], + stdout: "piped", + stderr: "piped", + }); + + const { stdout, stderr, success } = await cmd.output(); + + if (!success) { + const errorText = new TextDecoder().decode(stderr); + ctx.response.status = 404; + ctx.response.body = { error: "Container not found", details: errorText }; + return; + } + + const output = new TextDecoder().decode(stdout); + const containerData = JSON.parse(output); + + ctx.response.body = { success: true, container: containerData[0] }; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : "Unknown error"; + console.error("Error getting container:", message); + ctx.response.status = 500; + ctx.response.body = { error: "Internal server error", message }; + } +}); + +// Start container +dockerRouter.post("/containers/:id/start", authMiddleware, adminOnly, async (ctx) => { + try { + const { id } = ctx.params; + if (!id || !validateContainerId(id)) { + ctx.response.status = 400; + ctx.response.body = { error: "Invalid container ID" }; + return; + } + + const cmd = new Deno.Command("docker", { + args: ["start", id], + stdout: "piped", + stderr: "piped", + }); + + const { stderr, success } = await cmd.output(); + + if (!success) { + const errorText = new TextDecoder().decode(stderr); + ctx.response.status = 500; + ctx.response.body = { error: "Failed to start container", details: errorText }; + return; + } + + ctx.response.body = { success: true, message: `Container ${id} started` }; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : "Unknown error"; + console.error("Error starting container:", message); + ctx.response.status = 500; + ctx.response.body = { error: "Internal server error", message }; + } +}); + +// Stop container +dockerRouter.post("/containers/:id/stop", authMiddleware, adminOnly, async (ctx) => { + try { + const { id } = ctx.params; + if (!id || !validateContainerId(id)) { + ctx.response.status = 400; + ctx.response.body = { error: "Invalid container ID" }; + return; + } + + const cmd = new Deno.Command("docker", { + args: ["stop", id], + stdout: "piped", + stderr: "piped", + }); + + const { stderr, success } = await cmd.output(); + + if (!success) { + const errorText = new TextDecoder().decode(stderr); + ctx.response.status = 500; + ctx.response.body = { error: "Failed to stop container", details: errorText }; + return; + } + + ctx.response.body = { success: true, message: `Container ${id} stopped` }; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : "Unknown error"; + console.error("Error stopping container:", message); + ctx.response.status = 500; + ctx.response.body = { error: "Internal server error", message }; + } +}); + +// Restart container +dockerRouter.post("/containers/:id/restart", authMiddleware, adminOnly, async (ctx) => { + try { + const { id } = ctx.params; + if (!id || !validateContainerId(id)) { + ctx.response.status = 400; + ctx.response.body = { error: "Invalid container ID" }; + return; + } + + const cmd = new Deno.Command("docker", { + args: ["restart", id], + stdout: "piped", + stderr: "piped", + }); + + const { stderr, success } = await cmd.output(); + + if (!success) { + const errorText = new TextDecoder().decode(stderr); + ctx.response.status = 500; + ctx.response.body = { error: "Failed to restart container", details: errorText }; + return; + } + + ctx.response.body = { success: true, message: `Container ${id} restarted` }; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : "Unknown error"; + console.error("Error restarting container:", message); + ctx.response.status = 500; + ctx.response.body = { error: "Internal server error", message }; + } +}); + +// Get container logs +dockerRouter.get("/containers/:id/logs", authMiddleware, adminOnly, async (ctx) => { + try { + const { id } = ctx.params; + if (!id || !validateContainerId(id)) { + ctx.response.status = 400; + ctx.response.body = { error: "Invalid container ID" }; + return; + } + + const tailParam = ctx.request.url.searchParams.get("tail") || "100"; + const tail = Math.min(Math.max(parseInt(tailParam, 10) || 100, 1), 5000).toString(); + + const cmd = new Deno.Command("docker", { + args: ["logs", "--tail", tail, id], + stdout: "piped", + stderr: "piped", + }); + + const { stdout, stderr, success } = await cmd.output(); + + if (!success) { + const errorText = new TextDecoder().decode(stderr); + ctx.response.status = 404; + ctx.response.body = { error: "Failed to get logs", details: errorText }; + return; + } + + const logs = new TextDecoder().decode(stdout); + ctx.response.body = { success: true, logs }; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : "Unknown error"; + console.error("Error getting logs:", message); + ctx.response.status = 500; + ctx.response.body = { error: "Internal server error", message }; + } +}); diff --git a/src/routes/export.ts b/src/routes/export.ts new file mode 100644 index 0000000..d0dd523 --- /dev/null +++ b/src/routes/export.ts @@ -0,0 +1,138 @@ +import { Router } from "@oak/oak"; +import { getDB } from "../db/mongo.ts"; +import { authMiddleware, type AuthState } from "../middleware/auth.ts"; +import type { Context, Next } from "@oak/oak"; + +export const exportRouter = new Router({ prefix: "/api/data" }); + +async function adminOnly(ctx: Context, next: Next) { + const user = (ctx.state as AuthState).user; + if (user.role !== "admin") { + ctx.response.status = 403; + ctx.response.body = { error: "Admin access required" }; + return; + } + await next(); +} + +// Export all data as JSON +exportRouter.get("/export", authMiddleware, adminOnly, async (ctx) => { + try { + const db = await getDB(); + const url = ctx.request.url; + + // Which collections to export (default: all) + const collectionsParam = url.searchParams.get("collections"); + const available = ["tasks", "projects", "agents", "labels", "logs", "users"]; + const requested = collectionsParam + ? collectionsParam.split(",").filter((c) => available.includes(c)) + : available; + + const data: Record = {}; + for (const col of requested) { + data[col] = await db.collection(col).find({}).toArray(); + } + + const exportPayload = { + version: "1.0", + exportedAt: new Date().toISOString(), + collections: requested, + data, + }; + + ctx.response.headers.set( + "Content-Disposition", + `attachment; filename="ams-export-${new Date().toISOString().slice(0, 10)}.json"` + ); + ctx.response.type = "application/json"; + ctx.response.body = exportPayload; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : "Unknown error"; + ctx.response.status = 500; + ctx.response.body = { error: "Export failed", message }; + } +}); + +// Import data from JSON +exportRouter.post("/import", authMiddleware, adminOnly, async (ctx) => { + try { + const body = await ctx.request.body.json(); + const { data, mode } = body as { + data: Record; + mode: "merge" | "replace"; + }; + + if (!data || typeof data !== "object") { + ctx.response.status = 400; + ctx.response.body = { error: "Invalid import data" }; + return; + } + + const db = await getDB(); + const available = ["tasks", "projects", "agents", "labels", "logs"]; + const results: Record = {}; + + for (const [collection, documents] of Object.entries(data)) { + // Skip users for security + if (!available.includes(collection) || !Array.isArray(documents)) continue; + + if (mode === "replace") { + await db.collection(collection).deleteMany({}); + } + + let inserted = 0; + let skipped = 0; + + for (const doc of documents) { + try { + const { _id, ...rest } = doc as Record; + if (mode === "merge" && _id) { + // Try to find existing by _id, skip if exists + const existing = await db + .collection(collection) + .findOne({ _id }); + if (existing) { + skipped++; + continue; + } + } + await db.collection(collection).insertOne(rest); + inserted++; + } catch { + skipped++; + } + } + + results[collection] = { inserted, skipped }; + } + + ctx.response.body = { + success: true, + message: "Import completed", + results, + }; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : "Unknown error"; + ctx.response.status = 500; + ctx.response.body = { error: "Import failed", message }; + } +}); + +// Get export stats (what's available to export) +exportRouter.get("/stats", authMiddleware, adminOnly, async (ctx) => { + try { + const db = await getDB(); + const collections = ["tasks", "projects", "agents", "labels", "logs", "users"]; + const stats: Record = {}; + + for (const col of collections) { + stats[col] = await db.collection(col).countDocuments({}); + } + + ctx.response.body = { stats }; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : "Unknown error"; + ctx.response.status = 500; + ctx.response.body = { error: "Failed to get stats", message }; + } +}); diff --git a/src/routes/gitlab.ts b/src/routes/gitlab.ts new file mode 100644 index 0000000..fe3c622 --- /dev/null +++ b/src/routes/gitlab.ts @@ -0,0 +1,697 @@ +import { Router } from "@oak/oak"; +import { ObjectId } from "mongodb"; +import { authMiddleware } from "../middleware/auth.ts"; +import { getDB } from "../db/mongo.ts"; +import { + fetchGitLabIssues, + importGitLabIssue, + fullSync, + syncTaskToGitLab, + createGitLabIssueFromTask, + getMappingForTask, + getMappingsForProject, + deleteMapping, + type GitLabIssue, + type IssueMappingDoc, +} from "../utils/gitlabSync.ts"; + +const router = new Router(); + +// GitLab API configuration +const GITLAB_URL = Deno.env.get("GITLAB_URL") || "https://gitlab.agentenbude.de"; +const GITLAB_TOKEN = Deno.env.get("GITLAB_TOKEN") || ""; + +// Get user's GitLab token or fall back to global +async function getUserGitLabToken(userId: string): Promise { + const db = await getDB(); + const user = await db.collection("users").findOne({ _id: new ObjectId(userId) }); + return user?.gitlabToken || GITLAB_TOKEN; +} + +// Unicode-safe base64 decode (handles UTF-8 content) +function base64Decode(base64: string): string { + const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0)); + return new TextDecoder().decode(bytes); +} + +// Unicode-safe base64 encode (handles UTF-8 content) +function base64Encode(text: string): string { + const bytes = new TextEncoder().encode(text); + return btoa(String.fromCharCode(...bytes)); +} + +interface GitLabProject { + id: number; + name: string; + name_with_namespace: string; + path_with_namespace: string; + description: string | null; + default_branch: string; + web_url: string; + last_activity_at: string; +} + +interface GitLabBranch { + name: string; + commit: { + id: string; + short_id: string; + title: string; + author_name: string; + authored_date: string; + }; + default: boolean; + protected: boolean; +} + +interface GitLabCommit { + id: string; + short_id: string; + title: string; + message: string; + author_name: string; + author_email: string; + authored_date: string; + committed_date: string; + web_url: string; +} + +interface GitLabTreeItem { + id: string; + name: string; + type: "tree" | "blob"; + path: string; + mode: string; +} + +// Helper function to call GitLab API +async function gitlabFetch(endpoint: string, options: RequestInit = {}, token?: string): Promise { + const url = `${GITLAB_URL}/api/v4${endpoint}`; + const headers = { + "PRIVATE-TOKEN": token || GITLAB_TOKEN, + "Content-Type": "application/json", + ...options.headers, + }; + + const response = await fetch(url, { ...options, headers }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`GitLab API error (${response.status}): ${error}`); + } + + return response.json(); +} + +// GET /api/gitlab/projects - List all accessible projects +router.get("/api/gitlab/projects", authMiddleware, async (ctx) => { + try { + const token = await getUserGitLabToken(ctx.state.user.id); + const projects = await gitlabFetch("/projects?membership=true&order_by=last_activity_at&per_page=100", {}, token); + + ctx.response.body = projects.map(p => ({ + id: p.id, + name: p.name, + fullName: p.name_with_namespace, + path: p.path_with_namespace, + description: p.description, + defaultBranch: p.default_branch, + webUrl: p.web_url, + lastActivity: p.last_activity_at, + })); + } catch (error: unknown) { + console.error("GitLab projects error:", error); + ctx.response.status = 500; + ctx.response.body = { error: error instanceof Error ? error.message : "Unknown error" }; + } +}); + +// GET /api/gitlab/projects/:id - Get single project details +router.get("/api/gitlab/projects/:id", authMiddleware, async (ctx) => { + const projectId = ctx.params.id; + + try { + const project = await gitlabFetch(`/projects/${projectId}`); + + ctx.response.body = { + id: project.id, + name: project.name, + fullName: project.name_with_namespace, + path: project.path_with_namespace, + description: project.description, + defaultBranch: project.default_branch, + webUrl: project.web_url, + lastActivity: project.last_activity_at, + }; + } catch (error: unknown) { + console.error("GitLab project error:", error); + ctx.response.status = 500; + ctx.response.body = { error: error instanceof Error ? error.message : "Unknown error" }; + } +}); + +// GET /api/gitlab/projects/:id/branches - List branches +router.get("/api/gitlab/projects/:id/branches", authMiddleware, async (ctx) => { + const projectId = ctx.params.id; + + try { + const branches = await gitlabFetch(`/projects/${projectId}/repository/branches?per_page=100`); + + ctx.response.body = branches.map(b => ({ + name: b.name, + isDefault: b.default, + isProtected: b.protected, + lastCommit: { + id: b.commit.id, + shortId: b.commit.short_id, + title: b.commit.title, + author: b.commit.author_name, + date: b.commit.authored_date, + }, + })); + } catch (error: unknown) { + console.error("GitLab branches error:", error); + ctx.response.status = 500; + ctx.response.body = { error: error instanceof Error ? error.message : "Unknown error" }; + } +}); + +// GET /api/gitlab/projects/:id/commits - List commits +router.get("/api/gitlab/projects/:id/commits", authMiddleware, async (ctx) => { + const projectId = ctx.params.id; + const branch = ctx.request.url.searchParams.get("branch") || "main"; + const perPage = ctx.request.url.searchParams.get("per_page") || "50"; + const page = ctx.request.url.searchParams.get("page") || "1"; + + try { + const commits = await gitlabFetch( + `/projects/${projectId}/repository/commits?ref_name=${encodeURIComponent(branch)}&per_page=${perPage}&page=${page}` + ); + + ctx.response.body = commits.map(c => ({ + id: c.id, + shortId: c.short_id, + title: c.title, + message: c.message, + author: c.author_name, + authorEmail: c.author_email, + date: c.authored_date, + webUrl: c.web_url, + })); + } catch (error: unknown) { + console.error("GitLab commits error:", error); + ctx.response.status = 500; + ctx.response.body = { error: error instanceof Error ? error.message : "Unknown error" }; + } +}); + +// GET /api/gitlab/projects/:id/tree - List files/folders in a path +router.get("/api/gitlab/projects/:id/tree", authMiddleware, async (ctx) => { + const projectId = ctx.params.id; + const branch = ctx.request.url.searchParams.get("branch") || "main"; + const path = ctx.request.url.searchParams.get("path") || ""; + + try { + let endpoint = `/projects/${projectId}/repository/tree?ref=${encodeURIComponent(branch)}&per_page=100`; + if (path) { + endpoint += `&path=${encodeURIComponent(path)}`; + } + + const items = await gitlabFetch(endpoint); + + // Sort: folders first, then files, both alphabetically + const sorted = items.sort((a, b) => { + if (a.type === "tree" && b.type === "blob") return -1; + if (a.type === "blob" && b.type === "tree") return 1; + return a.name.localeCompare(b.name); + }); + + ctx.response.body = sorted.map(item => ({ + id: item.id, + name: item.name, + path: item.path, + type: item.type === "tree" ? "folder" : "file", + })); + } catch (error: unknown) { + console.error("GitLab tree error:", error); + ctx.response.status = 500; + ctx.response.body = { error: error instanceof Error ? error.message : "Unknown error" }; + } +}); + +// GET /api/gitlab/projects/:id/file - Get file content +router.get("/api/gitlab/projects/:id/file", authMiddleware, async (ctx) => { + const projectId = ctx.params.id; + const branch = ctx.request.url.searchParams.get("branch") || "main"; + const path = ctx.request.url.searchParams.get("path") || ""; + + if (!path) { + ctx.response.status = 400; + ctx.response.body = { error: "Path parameter is required" }; + return; + } + + try { + const encodedPath = encodeURIComponent(path); + const file = await gitlabFetch<{ file_name: string; file_path: string; size: number; encoding: string; content: string; ref: string }>( + `/projects/${projectId}/repository/files/${encodedPath}?ref=${encodeURIComponent(branch)}` + ); + + // Unicode-safe base64 decode + const content = base64Decode(file.content); + + ctx.response.body = { + name: file.file_name, + path: file.file_path, + size: file.size, + content: content, + branch: file.ref, + }; + } catch (error: unknown) { + console.error("GitLab file error:", error); + ctx.response.status = 500; + ctx.response.body = { error: error instanceof Error ? error.message : "Unknown error" }; + } +}); + +// PUT /api/gitlab/projects/:id/file - Update file content +router.put("/api/gitlab/projects/:id/file", authMiddleware, async (ctx) => { + const projectId = ctx.params.id; + const body = await ctx.request.body.json(); + + const { path, branch, content, commitMessage } = body; + + if (!path || !branch || content === undefined || !commitMessage) { + ctx.response.status = 400; + ctx.response.body = { error: "path, branch, content and commitMessage are required" }; + return; + } + + try { + const token = await getUserGitLabToken(ctx.state.user.id); + const encodedPath = encodeURIComponent(path); + const encodedContent = base64Encode(content); + + await gitlabFetch( + `/projects/${projectId}/repository/files/${encodedPath}`, + { + method: "PUT", + body: JSON.stringify({ + branch: branch, + content: encodedContent, + encoding: "base64", + commit_message: commitMessage, + }), + }, + token + ); + + ctx.response.body = { success: true, message: "File updated successfully" }; + } catch (error: unknown) { + console.error("GitLab file update error:", error); + ctx.response.status = 500; + ctx.response.body = { error: error instanceof Error ? error.message : "Unknown error" }; + } +}); + +// POST /api/gitlab/projects/:id/file - Create new file +router.post("/api/gitlab/projects/:id/file", authMiddleware, async (ctx) => { + const projectId = ctx.params.id; + const body = await ctx.request.body.json(); + + const { path, branch, content, commitMessage } = body; + + if (!path || !branch || content === undefined || !commitMessage) { + ctx.response.status = 400; + ctx.response.body = { error: "path, branch, content and commitMessage are required" }; + return; + } + + try { + const token = await getUserGitLabToken(ctx.state.user.id); + const encodedPath = encodeURIComponent(path); + const encodedContent = base64Encode(content); + + await gitlabFetch( + `/projects/${projectId}/repository/files/${encodedPath}`, + { + method: "POST", + body: JSON.stringify({ + branch: branch, + content: encodedContent, + encoding: "base64", + commit_message: commitMessage, + }), + }, + token + ); + + ctx.response.body = { success: true, message: "File created successfully" }; + } catch (error: unknown) { + console.error("GitLab file create error:", error); + ctx.response.status = 500; + ctx.response.body = { error: error instanceof Error ? error.message : "Unknown error" }; + } +}); + +// DELETE /api/gitlab/projects/:id/file - Delete file +router.delete("/api/gitlab/projects/:id/file", authMiddleware, async (ctx) => { + const projectId = ctx.params.id; + const body = await ctx.request.body.json(); + + const { path, branch, commitMessage } = body; + + if (!path || !branch || !commitMessage) { + ctx.response.status = 400; + ctx.response.body = { error: "path, branch and commitMessage are required" }; + return; + } + + try { + const token = await getUserGitLabToken(ctx.state.user.id); + const encodedPath = encodeURIComponent(path); + + await gitlabFetch( + `/projects/${projectId}/repository/files/${encodedPath}`, + { + method: "DELETE", + body: JSON.stringify({ + branch: branch, + commit_message: commitMessage, + }), + }, + token + ); + + ctx.response.body = { success: true, message: "File deleted successfully" }; + } catch (error: unknown) { + console.error("GitLab file delete error:", error); + ctx.response.status = 500; + ctx.response.body = { error: error instanceof Error ? error.message : "Unknown error" }; + } +}); + +// GET /api/gitlab/projects/:id/commit/:sha - Get single commit details +router.get("/api/gitlab/projects/:id/commit/:sha", authMiddleware, async (ctx) => { + const projectId = ctx.params.id; + const sha = ctx.params.sha; + + try { + const commit = await gitlabFetch( + `/projects/${projectId}/repository/commits/${sha}` + ); + + const diff = await gitlabFetch>( + `/projects/${projectId}/repository/commits/${sha}/diff` + ); + + ctx.response.body = { + id: commit.id, + shortId: commit.short_id, + title: commit.title, + message: commit.message, + author: commit.author_name, + authorEmail: commit.author_email, + date: commit.authored_date, + webUrl: commit.web_url, + stats: commit.stats, + diff: diff.map(d => ({ + oldPath: d.old_path, + newPath: d.new_path, + diff: d.diff, + })), + }; + } catch (error: unknown) { + console.error("GitLab commit detail error:", error); + ctx.response.status = 500; + ctx.response.body = { error: error instanceof Error ? error.message : "Unknown error" }; + } +}); + +// ============ GITLAB ISSUES SYNC ============ + +// GET /api/gitlab/projects/:id/issues - List GitLab issues +router.get("/api/gitlab/projects/:id/issues", authMiddleware, async (ctx) => { + const projectId = parseInt(ctx.params.id); + const state = ctx.request.url.searchParams.get("state") || "all"; + const page = parseInt(ctx.request.url.searchParams.get("page") || "1"); + const perPage = parseInt(ctx.request.url.searchParams.get("per_page") || "50"); + + try { + const token = await getUserGitLabToken(ctx.state.user.id); + const issues = await fetchGitLabIssues(projectId, { state, page, perPage }, token); + + // Check which issues are already mapped + const db = await getDB(); + const mappings = await db.collection("gitlab_issue_mappings") + .find({ gitlabProjectId: projectId }) + .toArray(); + const mappedIids = new Set(mappings.map(m => m.gitlabIssueIid)); + + ctx.response.body = issues.map((issue: GitLabIssue) => ({ + id: issue.id, + iid: issue.iid, + title: issue.title, + description: issue.description, + state: issue.state, + labels: issue.labels, + author: issue.author?.name || issue.author?.username, + assignees: issue.assignees?.map(a => a.name || a.username) || [], + createdAt: issue.created_at, + updatedAt: issue.updated_at, + dueDate: issue.due_date, + webUrl: issue.web_url, + isMapped: mappedIids.has(issue.iid), + amsTaskId: mappings.find(m => m.gitlabIssueIid === issue.iid)?.amsTaskId || null, + })); + } catch (error: unknown) { + console.error("GitLab issues error:", error); + ctx.response.status = 500; + ctx.response.body = { error: error instanceof Error ? error.message : "Unknown error" }; + } +}); + +// POST /api/gitlab/issues/import - Import specific GitLab issues as AMS tasks +router.post("/api/gitlab/issues/import", authMiddleware, async (ctx) => { + const body = await ctx.request.body.json(); + const { gitlabProjectId, issueIids, amsProjectId } = body; + + if (!gitlabProjectId || !amsProjectId) { + ctx.response.status = 400; + ctx.response.body = { error: "gitlabProjectId and amsProjectId are required" }; + return; + } + + try { + const token = await getUserGitLabToken(ctx.state.user.id); + const results: { iid: number; taskId: string; status: string }[] = []; + + if (issueIids && Array.isArray(issueIids)) { + // Import specific issues + for (const iid of issueIids) { + const issues = await fetchGitLabIssues(gitlabProjectId, { state: "all", perPage: 1 }, token); + // Fetch single issue by IID + const issue = await (async () => { + try { + return await (await fetch(`${Deno.env.get("GITLAB_URL") || "https://gitlab.agentenbude.de"}/api/v4/projects/${gitlabProjectId}/issues/${iid}`, { + headers: { "PRIVATE-TOKEN": token }, + })).json() as GitLabIssue; + } catch { + return null; + } + })(); + + if (issue) { + const { taskId } = await importGitLabIssue(issue, amsProjectId, ctx.state.user.id, token); + results.push({ iid, taskId, status: "imported" }); + } else { + results.push({ iid, taskId: "", status: "not_found" }); + } + } + } + + ctx.response.body = { results, count: results.filter(r => r.status === "imported").length }; + } catch (error: unknown) { + console.error("GitLab import error:", error); + ctx.response.status = 500; + ctx.response.body = { error: error instanceof Error ? error.message : "Unknown error" }; + } +}); + +// POST /api/gitlab/issues/sync - Full sync: import all issues from GitLab project +router.post("/api/gitlab/issues/sync", authMiddleware, async (ctx) => { + const body = await ctx.request.body.json(); + const { gitlabProjectId, amsProjectId } = body; + + if (!gitlabProjectId || !amsProjectId) { + ctx.response.status = 400; + ctx.response.body = { error: "gitlabProjectId and amsProjectId are required" }; + return; + } + + try { + const result = await fullSync(gitlabProjectId, amsProjectId, ctx.state.user.id, ctx.state.user.id); + ctx.response.body = result; + } catch (error: unknown) { + console.error("GitLab sync error:", error); + ctx.response.status = 500; + ctx.response.body = { error: error instanceof Error ? error.message : "Unknown error" }; + } +}); + +// POST /api/gitlab/issues/push - Push AMS task changes to GitLab +router.post("/api/gitlab/issues/push", authMiddleware, async (ctx) => { + const body = await ctx.request.body.json(); + const { amsTaskId } = body; + + if (!amsTaskId) { + ctx.response.status = 400; + ctx.response.body = { error: "amsTaskId is required" }; + return; + } + + try { + const success = await syncTaskToGitLab(amsTaskId, ctx.state.user.id); + if (success) { + ctx.response.body = { message: "Task synced to GitLab" }; + } else { + ctx.response.status = 404; + ctx.response.body = { error: "No mapping found or sync not enabled" }; + } + } catch (error: unknown) { + console.error("GitLab push error:", error); + ctx.response.status = 500; + ctx.response.body = { error: error instanceof Error ? error.message : "Unknown error" }; + } +}); + +// POST /api/gitlab/issues/create - Create GitLab issue from AMS task +router.post("/api/gitlab/issues/create", authMiddleware, async (ctx) => { + const body = await ctx.request.body.json(); + const { amsTaskId, gitlabProjectId, amsProjectId } = body; + + if (!amsTaskId || !gitlabProjectId) { + ctx.response.status = 400; + ctx.response.body = { error: "amsTaskId and gitlabProjectId are required" }; + return; + } + + try { + const issue = await createGitLabIssueFromTask(amsTaskId, gitlabProjectId, amsProjectId || "", ctx.state.user.id); + ctx.response.body = { + message: "GitLab issue created", + issue: { + iid: issue.iid, + webUrl: issue.web_url, + title: issue.title, + }, + }; + } catch (error: unknown) { + console.error("GitLab issue create error:", error); + ctx.response.status = 500; + ctx.response.body = { error: error instanceof Error ? error.message : "Unknown error" }; + } +}); + +// GET /api/gitlab/mappings/:taskId - Get mapping for a task +router.get("/api/gitlab/mappings/:taskId", authMiddleware, async (ctx) => { + try { + const mapping = await getMappingForTask(ctx.params.taskId); + if (!mapping) { + ctx.response.status = 404; + ctx.response.body = { error: "No mapping found" }; + return; + } + ctx.response.body = { mapping }; + } catch (error: unknown) { + ctx.response.status = 500; + ctx.response.body = { error: error instanceof Error ? error.message : "Unknown error" }; + } +}); + +// GET /api/gitlab/mappings/project/:projectId - Get all mappings for AMS project +router.get("/api/gitlab/mappings/project/:projectId", authMiddleware, async (ctx) => { + try { + const mappings = await getMappingsForProject(ctx.params.projectId); + ctx.response.body = { mappings }; + } catch (error: unknown) { + ctx.response.status = 500; + ctx.response.body = { error: error instanceof Error ? error.message : "Unknown error" }; + } +}); + +// DELETE /api/gitlab/mappings/:taskId - Remove mapping (unlink) +router.delete("/api/gitlab/mappings/:taskId", authMiddleware, async (ctx) => { + try { + const deleted = await deleteMapping(ctx.params.taskId); + if (deleted) { + ctx.response.body = { message: "Mapping removed" }; + } else { + ctx.response.status = 404; + ctx.response.body = { error: "No mapping found" }; + } + } catch (error: unknown) { + ctx.response.status = 500; + ctx.response.body = { error: error instanceof Error ? error.message : "Unknown error" }; + } +}); + +// POST /api/gitlab/webhook - GitLab webhook for issue events (bidirectional sync) +router.post("/api/gitlab/webhook", async (ctx) => { + // Verify webhook token + const webhookToken = ctx.request.headers.get("X-Gitlab-Token"); + const expectedToken = Deno.env.get("GITLAB_WEBHOOK_SECRET") || ""; + + if (expectedToken && webhookToken !== expectedToken) { + ctx.response.status = 401; + ctx.response.body = { error: "Invalid webhook token" }; + return; + } + + try { + const body = await ctx.request.body.json(); + const eventType = body.object_kind; + + if (eventType === "issue") { + const issue = body.object_attributes as GitLabIssue & { action: string }; + const db = await getDB(); + + const mapping = await db.collection("gitlab_issue_mappings").findOne({ + gitlabProjectId: issue.project_id, + gitlabIssueIid: issue.iid, + }); + + if (mapping && mapping.syncDirection !== "ams_to_gitlab") { + // Sync GitLab changes to AMS task + const updateFields: Record = { + updatedAt: new Date(), + }; + + if (issue.title) updateFields.title = `[GL#${issue.iid}] ${issue.title}`; + if (issue.description !== undefined) updateFields.description = issue.description || ""; + if (issue.state) { + updateFields.status = issue.state === "closed" ? "done" : "todo"; + } + if (issue.due_date) updateFields.dueDate = new Date(issue.due_date); + + await db.collection("tasks").updateOne( + { _id: new ObjectId(mapping.amsTaskId) }, + { $set: updateFields } + ); + + await db.collection("gitlab_issue_mappings").updateOne( + { _id: mapping._id }, + { $set: { lastSyncedAt: new Date() } } + ); + } + } + + ctx.response.body = { received: true }; + } catch (error: unknown) { + console.error("GitLab webhook error:", error); + ctx.response.status = 500; + ctx.response.body = { error: error instanceof Error ? error.message : "Unknown error" }; + } +}); + +export default router; diff --git a/src/routes/labels.ts b/src/routes/labels.ts new file mode 100644 index 0000000..02f717f --- /dev/null +++ b/src/routes/labels.ts @@ -0,0 +1,375 @@ +import { Router } from "@oak/oak"; +import { ObjectId } from "mongodb"; +import { getDB } from "../db/mongo.ts"; +import { authMiddleware } from "../middleware/auth.ts"; + +export const labelsRouter = new Router({ prefix: "/api/labels" }); + +// Valid parent types for label assignments +const VALID_PARENT_TYPES = ["task", "project", "agent", "comment", "attachment"] as const; +type ParentType = typeof VALID_PARENT_TYPES[number]; + +interface Label { + _id: ObjectId; + name: string; + color: string; + createdAt: Date; +} + +interface LabelAssignment { + _id: ObjectId; + labelId: string; + parentType: ParentType; + parentId: string; + createdAt: Date; +} + +// ============ LABEL MANAGEMENT ============ + +// Get all labels +labelsRouter.get("/", authMiddleware, async (ctx) => { + const db = await getDB(); + const labels = await db.collection